From d271a63241d6380acee203c489df11e7560daa7e Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sat, 12 Sep 2026 12:09:58 +0200 Subject: [PATCH] Add Home Assistant screen manager and runtime tile configuration --- .gitignore | 4 + AGENTS.md | 13 + README.md | 17 +- components/smart_display/__init__.py | 3 + components/smart_display/calibration_math.h | 57 + components/smart_display/chunk_transport.h | 38 + components/smart_display/cyd_calibration.h | 80 + components/smart_display/cyd_ui.h | 46 + .../smart_display/guition_diagnostics.h | 59 + components/smart_display/light_controls.h | 221 + components/smart_display/runtime_model.h | 66 + components/smart_display/runtime_tiles.h | 190 + components/smart_display/smart_display.h | 24 + components/smart_display/text.py | 15 + cyd_ui.h | 50 +- diagnostics/test_runtime_transport.py | 51 + docs/EASY_SETUP.md | 164 + docs/RELEASING.md | 58 + guition-4848s040.yaml | 177 +- guition_diagnostics.h | 57 +- home-like-2432s028.yaml | 191 +- installers/cyd.yaml | 9 + installers/guition.yaml | 9 + light_controls.h | 218 +- packages/cyd.yaml | 5973 +++++++++++++++++ packages/guition.yaml | 5776 ++++++++++++++++ repository.yaml | 3 + screen_manager/CHANGELOG.md | 7 + screen_manager/DOCS.md | 24 + screen_manager/Dockerfile | 8 + screen_manager/README.md | 8 + screen_manager/app/core.py | 137 + screen_manager/app/server.py | 261 + screen_manager/app/static/app.js | 64 + screen_manager/app/static/index.html | 21 + screen_manager/app/static/style.css | 5 + screen_manager/config.yaml | 17 + tests/test_calibration_math.cpp | 14 + tests/test_chunk_transport.cpp | 20 + tests/test_portal.py | 121 + tests/test_runtime_model.cpp | 28 + tools/export_bundle.py | 7 +- tools/generate_packages.py | 68 + 43 files changed, 13991 insertions(+), 388 deletions(-) create mode 100644 components/smart_display/__init__.py create mode 100644 components/smart_display/calibration_math.h create mode 100644 components/smart_display/chunk_transport.h create mode 100644 components/smart_display/cyd_calibration.h create mode 100644 components/smart_display/cyd_ui.h create mode 100644 components/smart_display/guition_diagnostics.h create mode 100644 components/smart_display/light_controls.h create mode 100644 components/smart_display/runtime_model.h create mode 100644 components/smart_display/runtime_tiles.h create mode 100644 components/smart_display/smart_display.h create mode 100644 components/smart_display/text.py create mode 100644 diagnostics/test_runtime_transport.py create mode 100644 docs/EASY_SETUP.md create mode 100644 docs/RELEASING.md create mode 100644 installers/cyd.yaml create mode 100644 installers/guition.yaml create mode 100644 packages/cyd.yaml create mode 100644 packages/guition.yaml create mode 100644 repository.yaml create mode 100644 screen_manager/CHANGELOG.md create mode 100644 screen_manager/DOCS.md create mode 100644 screen_manager/Dockerfile create mode 100644 screen_manager/README.md create mode 100644 screen_manager/app/core.py create mode 100644 screen_manager/app/server.py create mode 100644 screen_manager/app/static/app.js create mode 100644 screen_manager/app/static/index.html create mode 100644 screen_manager/app/static/style.css create mode 100644 screen_manager/config.yaml create mode 100644 tests/test_calibration_math.cpp create mode 100644 tests/test_chunk_transport.cpp create mode 100644 tests/test_portal.py create mode 100644 tests/test_runtime_model.cpp create mode 100644 tools/generate_packages.py diff --git a/.gitignore b/.gitignore index c747387..81582f0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ __pycache__/ /guition-device.yaml /diagnostics/*.png +/.venv-portal/ +/easy-guition-device.yaml +/easy-cyd-test.yaml +/portal-test-data/ diff --git a/AGENTS.md b/AGENTS.md index 3465e11..13e4407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,16 @@ +## Easy Setup en releases + +De voorkeursroute voor nieuwe gebruikers is docs/EASY_SETUP.md: ESP Screen Manager +plus remote ESPHome-pakketten. Geen token of blueprint nodig. Tegels staan in de +permanente add-ondata; wifi/API/OTA blijven in de eigen ESPHome-YAML. Lees +docs/RELEASING.md voordat je updates publiceert. Main distribueert beide borden. +Genereer packages met tools/generate_packages.py; bewerk ze niet handmatig. +Runtime-modus ondersteunt alle kaarten op alle tien posities. De positiebeperkingen +hieronder gelden uitsluitend voor de oude handmatige profielen. +Behoud gegevensschema, protocolcompatibiliteit, unieke sleutels en CYD-preferences. +Test updates met bestaande gegevens. Publiceer geen onbekende opslagversie zonder +migratie. Productie-Ingress heeft geen long-lived token of publieke poort nodig. + ## Guition-branch De Guition 4848S040 heeft een apart profiel `guition-4848s040.yaml` met diff --git a/README.md b/README.md index d96cf70..a6e09e0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,16 @@ -> **Guition ESP32-S3 4-inch / 480×480:** gebruik [de Guition-handleiding](docs/GUITION.md) -> en `guition-device.yaml`. De handleiding hieronder betreft het 2,8-inch CYD. +# Home Assistant ESP Screens + +**Nieuw scherm installeren? Begin met [Easy Setup](docs/EASY_SETUP.md).** +Installeer ESP Screen Manager in Home Assistant, download de YAML voor CYD of +Guition, flash eenmaal via USB op je Raspberry en kies je tegels in de beheerpagina. +Zoeken op entiteit/apparaat/ruimte, slepen, opslaan — zonder opnieuw flashen. + +Updates van de app behouden je indelingen. Nieuwe firmware installeer je via +ESPHome OTA met behoud van je eigen naam, wifi en sleutels. +Zie [updates publiceren](docs/RELEASING.md). + +De handleiding hieronder blijft beschikbaar voor handmatige installatie via een +computer. Voor de handmatige Guition-route: [Guition](docs/GUITION.md). # CYD Home Assistant-bedieningsscherm @@ -8,7 +19,7 @@ Home Assistant-scherm, via USB. Met lichtbediening, scènes/scripts, climate, vacuum, maximaal tien tegels en vaste pagina's. Bij maximaal zes tegels verdwijnt de paginering. Standby begint standaard na tien minuten. -**Nieuw hier? Begin hieronder.** Je hoeft geen Home Assistant-token te maken +**Handmatige installatie via de computer:** je hoeft geen Home Assistant-token te maken of een ESPHome-add-on te installeren om via de computer te flashen. ## Met een LLM of developer werken diff --git a/components/smart_display/__init__.py b/components/smart_display/__init__.py new file mode 100644 index 0000000..4d4ced6 --- /dev/null +++ b/components/smart_display/__init__.py @@ -0,0 +1,3 @@ +"""Runtime tile configuration transported through the normal ESPHome API.""" +CODEOWNERS = [] +AUTO_LOAD = ["json"] diff --git a/components/smart_display/calibration_math.h b/components/smart_display/calibration_math.h new file mode 100644 index 0000000..af8df07 --- /dev/null +++ b/components/smart_display/calibration_math.h @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +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 &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; +} +} diff --git a/components/smart_display/chunk_transport.h b/components/smart_display/chunk_transport.h new file mode 100644 index 0000000..9d4bfd7 --- /dev/null +++ b/components/smart_display/chunk_transport.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include +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; +}; +} diff --git a/components/smart_display/cyd_calibration.h b/components/smart_display/cyd_calibration.h new file mode 100644 index 0000000..1510bc0 --- /dev/null +++ b/components/smart_display/cyd_calibration.h @@ -0,0 +1,80 @@ +#pragma once +#include "calibration_math.h" +#include "esphome/core/preferences.h" +#include "esphome/core/hal.h" +#include "lvgl.h" +#include +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,5> samples; +inline esphome::ESPPreferenceObject preference; +inline std::function apply; +inline std::function 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(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 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); +} +} diff --git a/components/smart_display/cyd_ui.h b/components/smart_display/cyd_ui.h new file mode 100644 index 0000000..22cb30c --- /dev/null +++ b/components/smart_display/cyd_ui.h @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include + +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(std::lround( + static_cast(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 diff --git a/components/smart_display/guition_diagnostics.h b/components/smart_display/guition_diagnostics.h new file mode 100644 index 0000000..13b94f1 --- /dev/null +++ b/components/smart_display/guition_diagnostics.h @@ -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( + 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 diff --git a/components/smart_display/light_controls.h b/components/smart_display/light_controls.h new file mode 100644 index 0000000..728437f --- /dev/null +++ b/components/smart_display/light_controls.h @@ -0,0 +1,221 @@ +#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 "lvgl.h" +#include +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 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(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_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 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 diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h new file mode 100644 index 0000000..6df9fdf --- /dev/null +++ b/components/smart_display/runtime_model.h @@ -0,0 +1,66 @@ +#pragma once +#include +#include +#include +#include +#include + +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 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 tiles; + size_t count = 0; + std::string title = "Kies tegels in HA"; + bool configured = false; + bool set_layout(const std::vector &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 diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h new file mode 100644 index 0000000..d39ac0c --- /dev/null +++ b/components/smart_display/runtime_tiles.h @@ -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 +#include + +namespace runtime_tiles { +inline bool enabled = false; +inline Model model; +inline int active_index = -1; +inline uint32_t last_received = 0; +inline std::function layout_changed, refresh, dismiss; +inline std::function detail, detail_update; +struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; }; +inline std::array widgets; +inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; } +inline float number(JsonVariant value, float fallback = NAN) { + if (!value.is() && !value.is()) return fallback; + float n = value.as(); + return std::isfinite(n) ? n : fallback; +} +inline std::string string(JsonVariant value, size_t maximum = 160) { + if (!value.is()) return {}; + std::string s = value.as(); + if (s.size() > maximum) { + while (maximum > 0 && (static_cast(s[maximum]) & 0xC0) == 0x80) --maximum; + s.resize(maximum); + } + return s; +} +inline std::string list(JsonVariant value) { + if (!value.is()) 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() != 1) { result = "Fout: protocolversie"; return false; } + auto op = string(root["op"]); + if (op == "layout") { + if (!root["entities"].is() || !root["title"].is()) return false; + std::vector entities; + for (JsonVariant entity : root["entities"].as()) { + if (!entity.is() || entities.size() == MAX_TILES) return false; + entities.push_back(entity.as()); + } + 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() || !root["a"].is()) return false; + unsigned index = root["i"].as(); + 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(); + 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()) for (JsonVariant speed : a["fan_speed_list"].as()) { + 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(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(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(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(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(active_index) >= model.count) return {}; + auto &tile = model.tiles[active_index]; + return index < tile.fan_speed_count ? tile.fan_speeds[index] : ""; +} +} diff --git a/components/smart_display/smart_display.h b/components/smart_display/smart_display.h new file mode 100644 index 0000000..dcd1f4d --- /dev/null +++ b/components/smart_display/smart_display.h @@ -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(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)); + } +}; +} diff --git a/components/smart_display/text.py b/components/smart_display/text.py new file mode 100644 index 0000000..93985a1 --- /dev/null +++ b/components/smart_display/text.py @@ -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) diff --git a/cyd_ui.h b/cyd_ui.h index 22cb30c..c60e86b 100644 --- a/cyd_ui.h +++ b/cyd_ui.h @@ -1,46 +1,6 @@ #pragma once -#include -#include -#include - -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(std::lround( - static_cast(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 +#if __has_include("esphome/components/smart_display/cyd_ui.h") +#include "esphome/components/smart_display/cyd_ui.h" +#else +#include "components/smart_display/cyd_ui.h" +#endif diff --git a/diagnostics/test_runtime_transport.py b/diagnostics/test_runtime_transport.py new file mode 100644 index 0000000..8656c71 --- /dev/null +++ b/diagnostics/test_runtime_transport.py @@ -0,0 +1,51 @@ +"""Verify invalid runtime commands are rejected by an actual screen; no HA actions.""" +import argparse +import asyncio +import base64 +import json +from pathlib import Path +import secrets + +import yaml +from aioesphomeapi import APIClient, TextInfo, TextState + +async def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--host', required=True) + parser.add_argument('--name', required=True) + parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml') + args = parser.parse_args() + key = yaml.safe_load(args.secrets.read_text())['api_encryption_key'] + client = APIClient(args.host, 6053, noise_psk=key, expected_name=args.name, client_info='Runtime protocol test') + await client.connect(login=True) + try: + entities, _ = await client.list_entities_services() + inbox = next(e for e in entities if isinstance(e, TextInfo) and e.name == 'Tegelinstellingen') + assert inbox.max_length == 255 + queue = asyncio.Queue() + client.subscribe_states(lambda s: queue.put_nowait(s.state) if isinstance(s, TextState) and s.key == inbox.key else None) + messages = [ + {'v': 99, 'op': 'layout', 'title': 'Rejected', 'entities': []}, + {'v': 1, 'op': 'layout', 'title': 'Rejected', 'entities': ['lock.not_allowed']}, + {'v': 1, 'op': 'state', 'i': 99, 'entity': 'light.not_a_slot', 'a': {}}, + ] + for message in messages: + encoded = base64.b64encode(json.dumps(message).encode()).decode() + assert len(encoded) <= 200 + client.text_command(inbox.key, secrets.token_hex(6) + '|0|1|' + encoded) + while True: + ack = await asyncio.wait_for(queue.get(), 5) + if ack.startswith('Fout:'): + print('PASS: rejected invalid command — ' + ack) + break + client.text_command(inbox.key, secrets.token_hex(6) + '|2|1|e30=') + while True: + ack = await asyncio.wait_for(queue.get(), 5) + if ack == 'Fout: onvolledig bericht': + print('PASS: missing chunks rejected') + break + finally: + await client.disconnect() + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md new file mode 100644 index 0000000..a63ac91 --- /dev/null +++ b/docs/EASY_SETUP.md @@ -0,0 +1,164 @@ +# Een nieuw scherm, van USB naar dagelijks gebruik + +Met **ESP Screen Manager** kies je je tegels in Home Assistant. Je zoekt op +naam, apparaat, ruimte of entity-ID, zet ze in de gewenste volgorde en klikt +op **Opslaan & naar scherm**. Daarna blijft de status automatisch actueel. +Je hoeft voor andere tegels **niet opnieuw te flashen**. + +Dit werkt met Home Assistant OS op een 64-bits Raspberry Pi of een amd64-machine, +ESPHome Device Builder en een van deze exacte schermvarianten: + +| Keuze | Hardware | +| --- | --- | +| CYD | ESP32-2432S028, 320×240, ILI9341 en XPT2046 | +| Guition | ESP32-S3-4848S040, 480×480, ST7701S en GT911 | + +Andere schermen met ongeveer dezelfde naam kunnen andere pinnen hebben. Gebruik +het bordprofiel dat bij de hardware hoort. Gebruik een USB-kabel die data ondersteunt. +De wallbox-relais worden niet gebruikt. + +## 1. Installeer de twee apps + +1. Open **Instellingen → Apps → Installeer een app** (in oudere HA-versies: + **Instellingen → Add-ons → Add-onwinkel**). +2. Installeer **ESPHome Device Builder**, start deze en zet hem in de zijbalk. + Gebruik ESPHome **2026.6.2 of nieuwer**. De release is met 2026.6.2 getest. +3. Open in de appwinkel het menu rechtsboven → **Repositories**. Voeg toe: + `https://github.com/MaxGramser/homeassistant_espscreen`. +4. Zoek **ESP Screen Manager**, installeer en start deze. Zet **Starten bij + opstarten** en **Tonen in zijbalk** aan. Open de webinterface. + +De beheerpagina draait binnen Home Assistant en gebruikt je HA-aanmelding. +Je hoeft geen extra gebruiker, MQTT, blueprint of long-lived token in te stellen. +Installeer voor normaal gebruik de GitHub-versie: een handmatig gekopieerde lokale +test-add-on ontvangt geen updates uit GitHub. + +## 2. Download je eigen installatie-YAML + +Klik in ESP Screens op **Nieuw scherm**. Kies CYD of Guition, een herkenbare naam +en een unieke apparaatnaam zoals `scherm-keuken`. Klik **Maak installatie-YAML**. Gebruik **Kopieer YAML** om de tekst meteen in +ESPHome te plakken, of **Download bestand**. Op een HTTP-verbinding kan Chrome +downloads blokkeren; de kopieeroptie blijft bruikbaar. + +Dit bestand bevat unieke API- en OTA-sleutels. Bewaar het. Maak voor ieder nieuw +scherm een nieuw bestand. Download voor een bestaand scherm niet telkens een nieuw +bestand: daarmee zou je nieuwe sleutels genereren. + +Open **ESPHome Device Builder → Secrets** en vul eenmaal je 2,4GHz-wifi in: + +```yaml +wifi_ssid: "Jouw wifi" +wifi_password: "Jouw wifiwachtwoord" +``` + +Andere bestaande secrets mogen blijven staan. Gebruik geen gedeelde API-sleutel +voor alle nieuwe schermen; de gedownloade YAML bevat al een eigen sleutel. + +## 3. Eerste flash via USB op de Raspberry + +1. Klik in ESPHome op **New device**, gebruik dezelfde unieke naam en sla de + directe installatie over. Kies zo nodig ESP32 als tijdelijke wizardkeuze. +2. Klik **Edit** bij het nieuwe apparaat. Vervang de **hele inhoud** door je + gedownloade YAML en sla op. Het pakket levert zelf de juiste hardwareconfiguratie. +3. Sluit het scherm met USB aan op de Raspberry waarop Home Assistant draait. +4. Klik **Install → Plug into the computer running ESPHome Device Builder**. + Kies de USB-seriële poort van dit scherm. Bij meerdere schermen: sluit ze voor + de eerste installatie één voor één aan. +5. Wacht op een geslaagde build én upload. De eerste build kan op een Raspberry + lang duren. Laat deze afronden. Het scherm maakt daarna verbinding met wifi. + +Een browseroptie **Plug into this computer** bedoelt de computer waarop je browser +draait, niet de Raspberry. Kies die alleen wanneer het scherm aan die computer zit. + +**CYD:** bij de eerste start verschijnt de kalibratie. Tik het zichtbare kruisje +drie keer rustig aan, houd elke tik kort vast en volg steeds het volgende kruisje. +Er zijn vijf posities. Het midden controleert de nauwkeurigheid. Bij een mislukte +meting vraagt het scherm opnieuw te beginnen. De correctie wordt lokaal opgeslagen +en blijft bij OTA-updates behouden. Via de HA-apparaatknop **Touch kalibreren** kun +je later opnieuw meten. Voor diagnose en de oudere handmatige installatie: +[CALIBREREN.md](CALIBREREN.md). + +**Guition:** de GT911-touchmapping zit in het bordprofiel; er is geen ADC-kalibratie. + +## 4. Koppel het scherm aan Home Assistant + +1. Open **Instellingen → Apparaten & diensten**. Voeg het ontdekte ESPHome-apparaat + toe. Niet ontdekt? Voeg de integratie **ESPHome** handmatig toe met het IP-adres + uit de ESPHome-logs, poort 6053. +2. Vraagt HA om een encryptiesleutel? Kopieer de waarde van **api → encryption → key** + uit je eigen YAML. Gebruik niet het OTA-wachtwoord. +3. Open bij de ESPHome-integratie **Configureren** en zet **Allow the device to + perform Home Assistant actions** aan. Zonder deze toestemming verschijnen + waarden wel, maar kan het scherm lampen en apparaten niet bedienen. +4. Open ESP Screens. Het scherm verschijnt binnen ongeveer 30 seconden. + +## 5. Kies en wijzig je tegels + +Selecteer je scherm, vul de titel in en zoek entiteiten. Je kunt maximaal tien +tegels toevoegen. Sleep om te ordenen of gebruik de pijltjes. Je kunt iedere tegel +een korte eigen naam geven. Klik **Opslaan & naar scherm**. + +- Lamp, switch, input_boolean en fan: tik om aan/uit te zetten. +- Lamp lang indrukken: helderheid, regenboogkleur en wittemperatuur, voor zover + de lamp die functies ondersteunt. +- Climate, vacuum en cover: tik om de bedieningskaart te openen. +- Fan lang indrukken: snelheid als het apparaat percentages ondersteunt. +- Scene/script: tik om uit te voeren; button/input_button: tik om in te drukken. +- Sensor/binary_sensor/input_select: toont de waarde, voert geen actie uit. + +Er staan zes tegels op een pagina. Bij maximaal zes verdwijnen Vorige/Volgende. +De standby-tijd is standaard tien minuten. Bij offline apparaten blokkeert het +scherm acties. Als de app/HA langer dan circa 95 seconden geen updates geeft, +wordt de bediening geblokkeerd totdat de gegevens opnieuw ontvangen zijn. + +Je mag indelingen opslaan terwijl een scherm offline is. De app verstuurt ze +zodra het scherm terugkomt. De app moet blijven draaien voor actuele tegeldata. + +## 6. Updates zonder je instellingen kwijt te raken + +| Wat verandert? | Wat doe je? | Wat blijft behouden? | +| --- | --- | --- | +| Andere entiteiten, namen of volgorde | Opslaan in ESP Screens | Wifi, sleutels, kalibratie | +| Nieuwe beheerpagina/appversie | Appwinkel → ESP Screen Manager → Update | Alle indelingen in `/data/screens.json` | +| Nieuwe schermfunctie/kaart | ESPHome → bestaand apparaat → Install → Wirelessly | Eigen YAML, sleutels en CYD-kalibratie; app stuurt tegels opnieuw | + +De eigen YAML verwijst naar de firmwarepakketten in `main`. Bij een nieuwe build +haalt ESPHome de nieuwste gepubliceerde pakket- en componentcode op. Je vervangt +je eigen YAML dus niet door een nieuw downloadbestand. Wifi, naam en sleutels +staan buiten het gedeelde pakket en blijven gelijk. + +Maak vóór updates een Home Assistant-back-up inclusief ESP Screen Manager en +ESPHome Device Builder. **Verwijderen/herinstalleren** van een app is niet hetzelfde +als updaten; daarmee kun je de gegevensmap wissen. Houd de apparaatnaam en de +entity-ID van **Tegelinstellingen** gelijk, zodat de bestaande indeling gekoppeld blijft. + +Voor een nieuwe ondersteunde kaart update je eerst de app, daarna de firmware. +De beheerder moet protocol- en gegevensmigraties achterwaarts compatibel maken; +zie [RELEASING.md](RELEASING.md). Voor terugrollen kun je `ref: main` in je eigen +YAML tijdelijk vervangen door een eerdere releasetag, zonder de sleutels te wijzigen. + +## Als iets niet werkt + +- **Geen scherm in de lijst:** controleer of de nieuwe Easy Setup-firmware draait, + de ESPHome-integratie verbonden is en de tekstentiteit **Tegelinstellingen** + niet uitgeschakeld is. De oude handmatige firmware publiceert die standaard niet. +- **Wel tegels, geen acties:** geef het apparaat toestemming voor HA-acties. +- **Niet beschikbaar:** controleer of de geselecteerde entiteit in HA bestaat en + beschikbaar is. Een hernoemde entity-ID moet je opnieuw kiezen. +- **Geen OTA:** controleer wifi/IP en het oorspronkelijke OTA-wachtwoord. Gebruik + zo nodig dezelfde eigen YAML via USB. Genereer geen nieuwe identiteit. +- **Build mislukt:** lees de eerste fout, controleer ESPHome-versie en internet voor + GitHub/fontdownloads. Gebruik bij te weinig Raspberry-geheugen tijdelijk een + krachtigere computer voor compileren; de YAML blijft hetzelfde. +- **Bestaand handmatig scherm migreren:** bewaar de oude YAML en neem de bestaande + apparaatnaam, API-sleutel en OTA-wachtwoord over in het nieuwe installatieprofiel. + Kies daarna de tegels in de app. De oude vaste tegel-substituties worden niet + automatisch in de nieuwe beheerpagina geïmporteerd. + +HA Container zonder Supervisor heeft geen appwinkel. Deze installatiehandleiding +richt zich op Home Assistant OS; de ontwikkelserver is geen productieroute voor +een los openbaar portal. + +Gebruikte HA-mechanismen: [Ingress](https://developers.home-assistant.io/docs/apps/presentation/), +[interne HA-API](https://developers.home-assistant.io/docs/apps/communication/) en +[ESPHome-pakketten](https://esphome.io/components/packages/). diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..7790db6 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,58 @@ +# Updates publiceren zonder gebruikersconfiguratie te vervangen + +## Indeling van de code en gegevens + +- `main` is de distributiebranch voor de app **en beide** firmwarepakketten. + De Guition-branch blijft beschikbaar voor bordwerk; publiceer gedeelde releases + altijd ook naar main. +- `screen_manager/config.yaml` bevat de appversie. Verhoog die bij iedere apprelease. + Git push alleen is niet genoeg om een bestaande app een update aan te bieden. +- De appimage bevat uitsluitend code. Indelingen leven in `/data/screens.json` + (`version: 1`, `screens: {...}`). Een update/herbuild behoudt deze volumegegevens. +- De eigen ESPHome-YAML bevat naam, wifi-verwijzingen en unieke API/OTA-sleutels. + Gedeelde pakketten bevatten geen secrets, vaste eigenaarsentiteiten of wifi. +- CYD-kalibratie staat in ESP32-preferences. Behoud de preference-key, structuur + en partitie-indeling, of schrijf een expliciete migratie. +- Het tegelprotocol gebruikt `v: 1`. Houd oudere velden en domeinen bruikbaar. + Een gewijzigde opslagversie moet een geteste migratie met back-up krijgen. + De app weigert onbekende versies in plaats van de data leeg te overschrijven. + +## Voor elke release + +1. Pas de bronprofielen en gedeelde componenten aan. Draai + `python3 tools/generate_packages.py`; bewerk `packages/*.yaml` nooit rechtstreeks. +2. Draai alle Python-tests met aiohttp geïnstalleerd, alle `tests/*.cpp`, de + generator met `--check`, en compileer beide Easy Setup-profielen plus de + bestaande handmatige profielen. Controleer dat er geen secrets in Git staan. +3. Test appstart, opslaan, opnieuw starten/updaten met bestaande indelingen, + opnieuw verbinden met HA en een ESP-herstart. Test een nieuwe kaart op echte + hardware. Een goede build vervangt fysieke touchacceptatie niet. +4. Verhoog appversie en firmwareprojectversie; schrijf CHANGELOG en concrete + testresultaten. Publiceer alleen compatibele wijzigingen rechtstreeks op main. +5. Commit en push main. Maak een onveranderlijke tag `screens-vX.Y.Z` van dezelfde + commit. Werk de Guition-branch bij. Test de remote YAML in een lege map: + alle componenten/fonts moeten via GitHub kunnen worden opgehaald. +6. De gebruiker controleert de appwinkel op updates en update ESP Screen Manager. + Voor nieuwe schermfuncties: bestaand ESPHome-apparaat → Install → Wirelessly. + De bestaande YAML blijft staan; `refresh: 0s` haalt bij elke build actuele code. + +## Lokale ontwikkeling + +Kopieer alleen `screen_manager/` naar de gedeelde `addons/esp_screen_manager/`. +Herlaad de appwinkel, installeer de lokale versie en bouw na codewijzigingen opnieuw. +Een lokale testversie heeft een andere add-onidentiteit dan de GitHub-versie; de +gegevens verhuizen niet automatisch. Test voor de uiteindelijke installatie de +GitHub-versie en zet de lokale versie uit om twee schrijvers te voorkomen. + +Voor backendtests op een ontwikkelcomputer: + +```sh +python3 -m venv .venv-portal +.venv-portal/bin/pip install aiohttp PyYAML +.venv-portal/bin/python -m unittest discover -s tests +``` + +Een tijdelijke ontwikkelserver ondersteunt `SCREEN_DEV=1`, `HA_API` (eindigt op +`/api`), `HA_TOKEN_FILE` en `SCREEN_DATA`. Hij bindt alleen op localhost. Zet een +token nooit in broncode, URL's of Git. Productie gebruikt Supervisor en accepteert +alleen het Ingress-proxyadres; er is geen extra publieke poort. diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index 2cd6775..a476fac 100644 --- a/guition-4848s040.yaml +++ b/guition-4848s040.yaml @@ -1,6 +1,8 @@ # Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. # Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile. substitutions: + DYNAMIC_TILES: "false" + DASHBOARD_INBOX_INTERNAL: "true" DEVICE_NAME: "guition-wallbox" DEVICE_FRIENDLY_NAME: "Guition Wallbox" CALIBRATION_ON_BOOT: "false" @@ -570,7 +572,17 @@ substitutions: # Source: https://github.com/Templarian/MaterialDesign-Webfont # ============================================================ +external_components: + - source: + type: local + path: components + components: [smart_display] + esphome: + min_version: 2026.6.2 + project: + name: maxgramser.esp_screen + version: "0.1.0" platformio_options: build_flags: - -DLV_USE_SNAPSHOT=1 @@ -590,9 +602,76 @@ esphome: lv_obj_set_style_pad_column(id(vacuum_actions), 4, 0); lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0); lv_obj_set_style_pad_column(id(vacuum_speeds), 4, 0); + - lambda: |- + runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; + if (!runtime_tiles::enabled) return; + runtime_tiles::dismiss = []() { + id(active_entity).clear(); + lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(vacuum_overlay), LV_OBJ_FLAG_HIDDEN); + }; + runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; + runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; + runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); + runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); + runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); + runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); + runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); + runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); + runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); + runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); + runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); + runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); + runtime_tiles::detail = [](runtime_tiles::Tile &t) { + if (t.domain() == "vacuum") { + lv_obj_t *buttons[] = {id(vacspd_quiet), id(vacspd_balanced), id(vacspd_turbo), id(vacspd_max)}; + for (unsigned i = 0; i < 4; ++i) { + if (i < t.fan_speed_count) { + lv_obj_clear_flag(buttons[i], LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(lv_obj_get_child(buttons[i], 0), t.fan_speeds[i].c_str()); + } else lv_obj_add_flag(buttons[i], LV_OBJ_FLAG_HIDDEN); + } + id(open_vacuum_overlay).execute(t.name, t.entity); + return; + } + light_controls::fallback = light_controls::State{}; + light_controls::fallback.hue = t.hue; + light_controls::fallback.kelvin = t.kelvin; + light_controls::fallback.minimum = t.min_kelvin; + light_controls::fallback.maximum = t.max_kelvin; + id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, + t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), + 0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes, + t.current, t.target, t.humidity, t.state, t.hvac_modes, + std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); + }; + runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { + if (id(active_entity) != t.entity || id(touch_down)) return; + if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { + if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; + id(climate_edit_pending) = false; + id(active_climate_mode) = t.state; + id(active_climate_supported_modes) = t.hvac_modes; + lv_label_set_text(id(climate_mode_label), t.state.c_str()); + char buf[72]; + if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); + else snprintf(buf, sizeof(buf), "Nu: --"); + lv_label_set_text(id(climate_current_label), buf); + if (std::isfinite(t.target)) { + int target = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); + id(active_climate_target_x10) = target; + snprintf(buf, sizeof(buf), "%.1f°", target / 10.0f); + lv_label_set_text(id(climate_target_label), buf); + lv_arc_set_value(id(climate_arc), target); + } + } + }; - lambda: |- static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; - light_controls::subscribe(entities, 8); + if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8); light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; @@ -935,6 +1014,10 @@ font: id: materialdesign_icons size: 42 glyphs: + - "\U000F0335" + - "\U000F0210" + - "\U000F111C" + - "\U000F029A" - "${MDI_GLYPH_1}" - "${MDI_GLYPH_2}" - "${MDI_GLYPH_3}" @@ -1226,6 +1309,11 @@ binary_sensor: # - Used by the UI to display fan presets when applicable # ---------------------------------------------------------- text_sensor: + - platform: template + name: "Schermfirmware" + entity_category: diagnostic + lambda: 'return {"0.1.0"};' + update_interval: 60s - platform: template id: smartdisplay_action name: "SmartDisplay Action" @@ -1741,7 +1829,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 1);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);' then: - script.execute: { id: publish_action, action: "tile1_press" } - script.execute: @@ -1759,7 +1847,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 1);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);' then: - script.execute: { id: publish_action, action: "tile1_long_press" } - if: @@ -1876,7 +1964,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 2);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);' then: - script.execute: { id: publish_action, action: "tile2_press" } - script.execute: @@ -1894,7 +1982,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 2);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);' then: - script.execute: { id: publish_action, action: "tile2_long_press" } - if: @@ -2011,7 +2099,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 3);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);' then: - script.execute: { id: publish_action, action: "tile3_press" } - script.execute: @@ -2029,7 +2117,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 3);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);' then: - script.execute: { id: publish_action, action: "tile3_long_press" } - if: @@ -2146,7 +2234,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 4);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);' then: - script.execute: { id: publish_action, action: "tile4_press" } - script.execute: @@ -2164,7 +2252,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 4);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);' then: - script.execute: { id: publish_action, action: "tile4_long_press" } - if: @@ -2281,7 +2369,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 5);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);' then: - script.execute: { id: publish_action, action: "tile5_press" } - script.execute: @@ -2299,7 +2387,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 5);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);' then: - script.execute: { id: publish_action, action: "tile5_long_press" } - if: @@ -2416,7 +2504,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 6);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);' then: - script.execute: { id: publish_action, action: "tile6_press" } - script.execute: @@ -2434,7 +2522,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 6);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);' then: - script.execute: { id: publish_action, action: "tile6_long_press" } - if: @@ -2554,7 +2642,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 7);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);' then: - script.execute: { id: publish_action, action: "tile7_press" } - script.execute: @@ -2569,7 +2657,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 7);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);' then: - script.execute: { id: publish_action, action: "tile7_long_press" } - if: @@ -2677,7 +2765,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 8);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);' then: - script.execute: { id: publish_action, action: "tile8_press" } - script.execute: @@ -2692,7 +2780,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 8);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);' then: - script.execute: { id: publish_action, action: "tile8_long_press" } # Slider-modus kan hier niet zonder eerst de bijbehorende @@ -2766,7 +2854,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 9);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);' then: - script.execute: { id: publish_action, action: "tile9_press" } - script.execute: @@ -2781,7 +2869,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 9);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);' then: - script.execute: { id: publish_action, action: "tile9_long_press" } - if: @@ -2884,7 +2972,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 10);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);' then: - script.execute: { id: publish_action, action: "tile10_press" } - script.execute: @@ -2899,7 +2987,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 10);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);' then: - script.execute: { id: publish_action, action: "tile10_long_press" } - if: @@ -4028,7 +4116,9 @@ lvgl: scrollable: false pad_all: 0 on_click: - - script.execute: { id: vacuum_fan_speed, speed: "quiet" } + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(0) : "quiet";' widgets: - label: align: CENTER @@ -4048,7 +4138,9 @@ lvgl: scrollable: false pad_all: 0 on_click: - - script.execute: { id: vacuum_fan_speed, speed: "balanced" } + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(1) : "balanced";' widgets: - label: align: CENTER @@ -4068,7 +4160,9 @@ lvgl: scrollable: false pad_all: 0 on_click: - - script.execute: { id: vacuum_fan_speed, speed: "turbo" } + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(2) : "turbo";' widgets: - label: align: CENTER @@ -4088,7 +4182,9 @@ lvgl: scrollable: false pad_all: 0 on_click: - - script.execute: { id: vacuum_fan_speed, speed: "max" } + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(3) : "max";' widgets: - label: align: CENTER @@ -4279,7 +4375,7 @@ script: - delay: 150ms - lambda: |- lv_obj_update_layout(id(tile_scroll)); - const int count = std::clamp(atoi("${TILE_COUNT}"), 1, 10); + const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); const bool paginated = count > 6; lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; @@ -4327,7 +4423,7 @@ script: lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; - const int count = std::clamp(atoi("${TILE_COUNT}"), 1, 10); + const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); const bool paginated = count > 6; if (!paginated) id(tile_page) = 0; for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { @@ -4465,7 +4561,7 @@ script: value: !lambda "return value;" - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' then: - homeassistant.action: action: climate.set_temperature @@ -4510,7 +4606,7 @@ script: then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' then: - homeassistant.action: action: !lambda "return service;" @@ -4525,7 +4621,7 @@ script: then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' then: - homeassistant.action: action: vacuum.set_fan_speed @@ -4587,7 +4683,7 @@ script: - lvgl.widget.hide: climate_mode_overlay - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' then: - homeassistant.action: action: climate.set_hvac_mode @@ -4633,7 +4729,7 @@ script: - lambda: 'id(active_brightness_pct) = value;' - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_entity).rfind("light.", 0) == 0;' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_entity).rfind("light.", 0) == 0;' then: - homeassistant.action: action: light.turn_on @@ -4647,7 +4743,7 @@ script: then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' then: - homeassistant.action: action: light.turn_on @@ -4664,7 +4760,7 @@ script: then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' then: - homeassistant.action: action: light.turn_on @@ -5295,6 +5391,12 @@ script: then: - delay: 100ms - lambda: |- + if (runtime_tiles::enabled) { + runtime_tiles::render(id(lbl_room)); + auto now = id(homeassistant_time).now(); + if (now.is_valid()) runtime_tiles::label(id(lbl_time), now.strftime("%H:%M")); + return; + } auto set_lbl = [](lv_obj_t* lbl, const std::string& s) { if (lbl && s != lv_label_get_text(lbl)) lv_label_set_text(lbl, s.c_str()); }; @@ -5732,3 +5834,10 @@ script: ${TILE10_BG_ACTIVE_COLOR}, ${TILE10_BG_DISABLED_COLOR}, ${TILE10_TITLE_ACTIVE_COLOR}, ${TILE10_TITLE_DISABLED_COLOR}, ${TILE10_VALUE_ACTIVE_COLOR}, ${TILE10_VALUE_DISABLED_COLOR}); + +# HA blueprint transport; never publishes the JSON command as an HA state. +text: + - platform: smart_display + id: dashboard_inbox + name: "Tegelinstellingen" + internal: ${DASHBOARD_INBOX_INTERNAL} diff --git a/guition_diagnostics.h b/guition_diagnostics.h index 63e0d81..098d6f2 100644 --- a/guition_diagnostics.h +++ b/guition_diagnostics.h @@ -1,57 +1,2 @@ #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; -} -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( - 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"); - } -} -} // namespace guition +#include "esphome/components/smart_display/guition_diagnostics.h" diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 0d3a78d..42bfbe1 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -58,6 +58,8 @@ ############################################################ substitutions: + DYNAMIC_TILES: "false" + DASHBOARD_INBOX_INTERNAL: "true" DEVICE_NAME: "cyd-new" DEVICE_FRIENDLY_NAME: "Mijn CYD" CALIBRATION_ON_BOOT: "false" @@ -769,9 +771,13 @@ external_components: - source: type: local path: components - components: [xpt2046] + components: [xpt2046, smart_display] esphome: + min_version: 2026.6.2 + project: + name: maxgramser.esp_screen + version: "0.1.0" includes: - cyd_ui.h - light_controls.h @@ -789,13 +795,88 @@ esphome: lv_obj_set_style_pad_column(id(vacuum_actions), 4, 0); lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0); lv_obj_set_style_pad_column(id(vacuum_speeds), 4, 0); + - lambda: |- + runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; + if (!runtime_tiles::enabled) return; + runtime_tiles::dismiss = []() { + id(active_entity).clear(); + lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(vacuum_overlay), LV_OBJ_FLAG_HIDDEN); + }; + runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; + runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; + runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); + runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); + runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); + runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); + runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); + runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); + runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); + runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); + runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); + runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); + runtime_tiles::detail = [](runtime_tiles::Tile &t) { + if (t.domain() == "vacuum") { + lv_obj_t *buttons[] = {id(vacspd_quiet), id(vacspd_balanced), id(vacspd_turbo), id(vacspd_max)}; + for (unsigned i = 0; i < 4; ++i) { + if (i < t.fan_speed_count) { + lv_obj_clear_flag(buttons[i], LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(lv_obj_get_child(buttons[i], 0), t.fan_speeds[i].c_str()); + } else lv_obj_add_flag(buttons[i], LV_OBJ_FLAG_HIDDEN); + } + id(open_vacuum_overlay).execute(t.name, t.entity); + return; + } + light_controls::fallback = light_controls::State{}; + light_controls::fallback.hue = t.hue; + light_controls::fallback.kelvin = t.kelvin; + light_controls::fallback.minimum = t.min_kelvin; + light_controls::fallback.maximum = t.max_kelvin; + id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, + t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), + 0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes, + t.current, t.target, t.humidity, t.state, t.hvac_modes, + std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); + }; + runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { + if (id(active_entity) != t.entity || id(touch_down)) return; + if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { + if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; + id(climate_edit_pending) = false; + id(active_climate_mode) = t.state; + id(active_climate_supported_modes) = t.hvac_modes; + lv_label_set_text(id(climate_mode_label), t.state.c_str()); + char buf[72]; + if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); + else snprintf(buf, sizeof(buf), "Nu: --"); + lv_label_set_text(id(climate_current_label), buf); + if (std::isfinite(t.target)) { + int target = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); + id(active_climate_target_x10) = target; + snprintf(buf, sizeof(buf), "%.1f°", target / 10.0f); + lv_label_set_text(id(climate_target_label), buf); + lv_arc_set_value(id(climate_arc), target); + } + } + }; - lambda: |- static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; - light_controls::subscribe(entities, 8); + if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8); light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; + - lambda: |- + if (!runtime_tiles::enabled) return; + screen_calibration::apply = [](const screen_calibration::Calibration &cal) { + id(ts_touch).set_raw_correction(cal.c[0],cal.c[1],cal.c[2],cal.c[3],cal.c[4],cal.c[5]); + }; + screen_calibration::isolation = [](bool active) { id(calibration_active) = active; }; + screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(), + ${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX}); - light.turn_on: id: back_light brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;" @@ -1120,6 +1201,10 @@ font: id: materialdesign_icons size: 28 glyphs: + - "\U000F0335" + - "\U000F0210" + - "\U000F111C" + - "\U000F029A" - "${MDI_GLYPH_1}" - "${MDI_GLYPH_2}" - "${MDI_GLYPH_3}" @@ -1411,6 +1496,11 @@ binary_sensor: # - Used by the UI to display fan presets when applicable # ---------------------------------------------------------- text_sensor: + - platform: template + name: "Schermfirmware" + entity_category: diagnostic + lambda: 'return {"0.1.0"};' + update_interval: 60s - platform: template id: smartdisplay_action name: "SmartDisplay Action" @@ -1733,6 +1823,7 @@ touchscreen: id(last_touch_ms) = millis(); id(touch_down) = true; cyd::touch_guard.begin(millis(), touch.x, touch.y); + screen_calibration::press(); ESP_LOGI("touch", "press native=%u,%u raw=%d,%d pressure=%d calibration=%d", touch.x, touch.y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y(), touch.z_raw, id(calibration_active)); on_update: - lambda: |- @@ -1744,6 +1835,7 @@ touchscreen: - lambda: |- id(last_touch_ms) = millis(); id(touch_down) = false; + screen_calibration::release(id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y()); # Display configuration (ILI9341) display: @@ -1975,7 +2067,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 1);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);' then: - script.execute: { id: publish_action, action: "tile1_press" } - script.execute: @@ -1993,7 +2085,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 1);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);' then: - script.execute: { id: publish_action, action: "tile1_long_press" } - if: @@ -2106,7 +2198,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 2);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);' then: - script.execute: { id: publish_action, action: "tile2_press" } - script.execute: @@ -2124,7 +2216,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 2);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);' then: - script.execute: { id: publish_action, action: "tile2_long_press" } - if: @@ -2237,7 +2329,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 3);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);' then: - script.execute: { id: publish_action, action: "tile3_press" } - script.execute: @@ -2255,7 +2347,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 3);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);' then: - script.execute: { id: publish_action, action: "tile3_long_press" } - if: @@ -2368,7 +2460,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 4);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);' then: - script.execute: { id: publish_action, action: "tile4_press" } - script.execute: @@ -2386,7 +2478,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 4);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);' then: - script.execute: { id: publish_action, action: "tile4_long_press" } - if: @@ -2499,7 +2591,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 5);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);' then: - script.execute: { id: publish_action, action: "tile5_press" } - script.execute: @@ -2517,7 +2609,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 5);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);' then: - script.execute: { id: publish_action, action: "tile5_long_press" } - if: @@ -2630,7 +2722,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 6);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);' then: - script.execute: { id: publish_action, action: "tile6_press" } - script.execute: @@ -2648,7 +2740,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 6);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);' then: - script.execute: { id: publish_action, action: "tile6_long_press" } - if: @@ -2764,7 +2856,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 7);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);' then: - script.execute: { id: publish_action, action: "tile7_press" } - script.execute: @@ -2779,7 +2871,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 7);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);' then: - script.execute: { id: publish_action, action: "tile7_long_press" } - if: @@ -2883,7 +2975,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 8);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);' then: - script.execute: { id: publish_action, action: "tile8_press" } - script.execute: @@ -2898,7 +2990,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 8);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);' then: - script.execute: { id: publish_action, action: "tile8_long_press" } # Slider-modus kan hier niet zonder eerst de bijbehorende @@ -2968,7 +3060,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 9);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);' then: - script.execute: { id: publish_action, action: "tile9_press" } - script.execute: @@ -2983,7 +3075,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 9);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);' then: - script.execute: { id: publish_action, action: "tile9_long_press" } - if: @@ -3082,7 +3174,7 @@ lvgl: on_short_click: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 10);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);' then: - script.execute: { id: publish_action, action: "tile10_press" } - script.execute: @@ -3097,7 +3189,7 @@ lvgl: on_long_press: - if: condition: - lambda: 'return cyd::touch_guard.accept(millis(), 10);' + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);' then: - script.execute: { id: publish_action, action: "tile10_long_press" } - if: @@ -4226,7 +4318,9 @@ lvgl: scrollable: false pad_all: 0 on_click: - - script.execute: { id: vacuum_fan_speed, speed: "quiet" } + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(0) : "quiet";' widgets: - label: align: CENTER @@ -4246,7 +4340,9 @@ lvgl: scrollable: false pad_all: 0 on_click: - - script.execute: { id: vacuum_fan_speed, speed: "balanced" } + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(1) : "balanced";' widgets: - label: align: CENTER @@ -4266,7 +4362,9 @@ lvgl: scrollable: false pad_all: 0 on_click: - - script.execute: { id: vacuum_fan_speed, speed: "turbo" } + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(2) : "turbo";' widgets: - label: align: CENTER @@ -4286,7 +4384,9 @@ lvgl: scrollable: false pad_all: 0 on_click: - - script.execute: { id: vacuum_fan_speed, speed: "max" } + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(3) : "max";' widgets: - label: align: CENTER @@ -4468,7 +4568,7 @@ script: - delay: 150ms - lambda: |- lv_obj_update_layout(id(tile_scroll)); - const int count = std::clamp(atoi("${TILE_COUNT}"), 1, 10); + const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); const bool paginated = count > 6; lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; @@ -4511,7 +4611,7 @@ script: lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; - const int count = std::clamp(atoi("${TILE_COUNT}"), 1, 10); + const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); const bool paginated = count > 6; if (!paginated) id(tile_page) = 0; for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { @@ -4649,7 +4749,7 @@ script: value: !lambda "return value;" - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' then: - homeassistant.action: action: climate.set_temperature @@ -4694,7 +4794,7 @@ script: then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' then: - homeassistant.action: action: !lambda "return service;" @@ -4709,7 +4809,7 @@ script: then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' then: - homeassistant.action: action: vacuum.set_fan_speed @@ -4771,7 +4871,7 @@ script: - lvgl.widget.hide: climate_mode_overlay - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' then: - homeassistant.action: action: climate.set_hvac_mode @@ -4817,7 +4917,7 @@ script: - lambda: 'id(active_brightness_pct) = value;' - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_entity).rfind("light.", 0) == 0;' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_entity).rfind("light.", 0) == 0;' then: - homeassistant.action: action: light.turn_on @@ -4831,7 +4931,7 @@ script: then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' then: - homeassistant.action: action: light.turn_on @@ -4848,7 +4948,7 @@ script: then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' then: - homeassistant.action: action: light.turn_on @@ -5479,6 +5579,12 @@ script: then: - delay: 100ms - lambda: |- + if (runtime_tiles::enabled) { + runtime_tiles::render(id(lbl_room)); + auto now = id(homeassistant_time).now(); + if (now.is_valid()) runtime_tiles::label(id(lbl_time), now.strftime("%H:%M")); + return; + } auto set_lbl = [](lv_obj_t* lbl, const std::string& s) { if (lbl && s != lv_label_get_text(lbl)) lv_label_set_text(lbl, s.c_str()); }; @@ -5917,3 +6023,18 @@ script: ${TILE10_TITLE_ACTIVE_COLOR}, ${TILE10_TITLE_DISABLED_COLOR}, ${TILE10_VALUE_ACTIVE_COLOR}, ${TILE10_VALUE_DISABLED_COLOR}); + +# HA blueprint transport; never publishes the JSON command as an HA state. +text: + - platform: smart_display + id: dashboard_inbox + name: "Tegelinstellingen" + internal: ${DASHBOARD_INBOX_INTERNAL} + +button: + - platform: template + name: "Touch kalibreren" + internal: ${DASHBOARD_INBOX_INTERNAL} + entity_category: config + on_press: + - lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();' diff --git a/installers/cyd.yaml b/installers/cyd.yaml new file mode 100644 index 0000000..1f43cf0 --- /dev/null +++ b/installers/cyd.yaml @@ -0,0 +1,9 @@ +# Handmatig alternatief: voeg dit blok toe aan de ESPHome-wizardconfiguratie. +# Verwijder het oude esp32:-blok; behoud je eigen esphome:, api:, ota: en wifi:. +# De makkelijke route genereert een compleet eigen bestand: ESP Screen Manager. +packages: + scherm: + url: https://github.com/MaxGramser/homeassistant_espscreen + ref: main + files: [packages/cyd.yaml] + refresh: 0s diff --git a/installers/guition.yaml b/installers/guition.yaml new file mode 100644 index 0000000..6306201 --- /dev/null +++ b/installers/guition.yaml @@ -0,0 +1,9 @@ +# Handmatig alternatief: voeg dit blok toe aan de ESPHome-wizardconfiguratie. +# Verwijder het oude esp32:-blok; behoud je eigen esphome:, api:, ota: en wifi:. +# De makkelijke route genereert een compleet eigen bestand: ESP Screen Manager. +packages: + scherm: + url: https://github.com/MaxGramser/homeassistant_espscreen + ref: main + files: [packages/guition.yaml] + refresh: 0s diff --git a/light_controls.h b/light_controls.h index c64a346..a16135a 100644 --- a/light_controls.h +++ b/light_controls.h @@ -1,216 +1,6 @@ #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 "lvgl.h" -#include -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 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) { - 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); }); - } -} -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(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_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 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; -} - -} +#if __has_include("esphome/components/smart_display/light_controls.h") +#include "esphome/components/smart_display/light_controls.h" +#else +#include "components/smart_display/light_controls.h" #endif diff --git a/packages/cyd.yaml b/packages/cyd.yaml new file mode 100644 index 0000000..2773ca5 --- /dev/null +++ b/packages/cyd.yaml @@ -0,0 +1,5973 @@ +# GENERATED by tools/generate_packages.py; edit the source board profile. +############################################################ +# SmartDisplay – iOS Home app–inspired Tiles UI +# Target device: ESP32-2432S028 / Cheap Yellow Display (CYD) +# Resolution: configurable via ORIENTATION substitutions (landscape 320×240 default) +# +# Target hardware +# - Cheap Yellow Display (ESP32-2432S028 or compatible) +# - Built-in ILI9341 320x240 display +# - Built-in XPT2046 touchscreen +# +# What this config does +# - Renders a gradient background + two fixed pages of “tiles” (6 + 4) (LVGL). +# - Each tile shows: icon (MDI font), title, and a value line. +# - Tile taps publish a short-lived text_sensor event (smartdisplay_action) +# so you can build Home Assistant automations on top. +# - Optional: the device can also call Home Assistant services directly +# (toggle lights/switches/fans/covers, activate scenes/scripts, set fan preset, +# set light brightness, set fan percentage, set cover position). +# +# The layout is inspired by common modern smart home UIs. +# +# How to customize +# 1) Create device.yaml with tools/new_device.py; edit its TILE1..TILE10 overrides. +# 2) If you change icons, update the FONT GLYPHS block in the substitutions below. +# Each glyph must appear exactly once — same icon on multiple tiles is fine, just list it once. +# 3) The background is a gradient (BG_TOP_COLOR / BG_BOTTOM_COLOR), not an image. +# Required fonts are bundled in fonts/ (Roboto and Material Design Icons). +# +# Tile configuration model +# - TILE*_TYPE defines the entity kind: light | fan | switch | scene | script | cover +# - TILE*_TAP_ACTION defines what happens on short tap: +# auto | toggle | activate | fan_toggle_preset | custom +# - TILE*_LONGPRESS defines the long press mode: none | slider | action +# slider: opens a value overlay — configure with TILE*_LONGPRESS_SLIDER + TILE*_LONGPRESS_OFF_VALUE +# action: fires a different entity — configure with TILE*_LONGPRESS_ACTION* +# - TILE*_VALUE_MODE defines how the value line is rendered: +# auto | brightness | percentage | preset | text +# +# Notes +# - DIRECT_ACTIONS="true" enables direct HA service calls from the device. +# If "false", only the smartdisplay_action event is emitted. +# - In auto mode, values and long-press behavior are derived from TILE*_TYPE: +# light -> brightness, fan -> percentage/preset, scene/script -> text +# +# Device configuration: home-like-2432s028.yaml (ESP32-2432S028). +# home-like.yaml targets the separate ST7789V/GT911 board. +# Credentials stay in secrets.yaml. Fonts are bundled for offline validation. +# Start at README.md; docs/ covers calibration, HA setup and acceptance tests. +# +# What you must adapt before flashing +# 1) Secrets: api_encryption_key, ota_password, WiFi credentials (see secrets.yaml) +# 2) Pins, touch calibration and transform if your CYD variant differs +# 3) TILE*_ENTITY / TILE*_STATE_ENTITY: Home Assistant entity IDs you want to control/mirror +# 4) Orientation + touch calibration: +# - rotation is set via LVGL_ROTATION in the ORIENTATION presets (LVGL +# rotates display + touch together, ESPHome 2026.4+) +# - calibration is device-specific +############################################################ + +substitutions: + DYNAMIC_TILES: "true" + DASHBOARD_INBOX_INTERNAL: "false" + DEVICE_NAME: "cyd-new" + DEVICE_FRIENDLY_NAME: "Mijn CYD" + CALIBRATION_ON_BOOT: "false" + TOUCH_AFFINE_XX: "1.000000000" + TOUCH_AFFINE_XY: "0.000000000" + TOUCH_AFFINE_XC: "0.000000000" + TOUCH_AFFINE_YX: "0.000000000" + TOUCH_AFFINE_YY: "1.000000000" + TOUCH_AFFINE_YC: "0.000000000" + + TILE_COUNT: "1" # 1-10; <=6 hides page navigation + + DIRECT_ACTIONS: "true" # true = call Home Assistant services directly, false = only emit action events + ROOM_NAME: "Kies tegels in HA" # room name shown in the top-left header + TIME_24H: "true" # true = 24h format, false = 12h format + + ## DIM settings + AUTO_DIM: "true" # true = enable auto dimming + AUTO_DIM_TIMEOUT: "600" # seconds after last touch + MAX_BRIGHTNESS: "100" # backlight brightness in percent + DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent + NIGHT_MODE: "true" # true = use separate dim level at night + NIGHT_START_HOUR: "22" # 0-23 + NIGHT_END_HOUR: "7" # 0-23 + NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night + + # ============================================================ + # ACCENTKLEUREN — automatisch bepaald uit het Home Assistant-domein + # + # Het domein is het stuk voor de punt in het entity_id: + # script.goede_morgen -> "script" -> ACCENT_ACTION + # light.woonkamer_2 -> "light" -> ACCENT_LIGHT + # vacuum.s8 -> "vacuum" -> ACCENT_DEVICE + # + # Dat is Home Assistant's eigen indeling, dus een tegel krijgt vanzelf de + # juiste kleur zodra je er een entity in zet — je hoeft per tegel geen + # kleuren meer te kiezen. Het onderscheid dat telt: een script/scene vuur + # je af, een apparaat zet je aan of uit. Die twee horen niet hetzelfde + # te zijn. + # + # Zet TILE*_CIRCLE_ACTIVE_COLOR / TILE*_ICON_DISABLED_COLOR op + # ${ACCENT_AUTO} om dit te gebruiken, of op een echte hexwaarde om die + # ene tegel handmatig te kleuren (zie tegel 8 — rood zodra er post ligt). + # ============================================================ + ACCENT_AUTO: "0x1000000" # sentinel: bewust buiten het 24-bits kleurbereik + ACCENT_ACTION: "0x8B5CF6" # script / scene / button / input_button / automation + ACCENT_LIGHT: "0xFEC600" # light + ACCENT_DEVICE: "0x00C5EC" # switch, fan, cover, climate, vacuum, media_player, rest + + # ============================================================ + # FONT GLYPHS + # List every unique MDI icon used across your tiles — each value must appear exactly once. + # Reusing the same icon on multiple tiles is fine: just list that glyph once here. + # If you use fewer than 6 unique icons, leave the extra slots with their default values + # (unused glyphs add a few bytes to the font but cause no errors). + # ============================================================ + MDI_GLYPH_1: "\U000F001B" + MDI_GLYPH_2: "\U000F0387" # mdi:music + MDI_GLYPH_3: "\U000F02E3" + MDI_GLYPH_4: "\U000F059C" + MDI_GLYPH_5: "\U000F0425" + MDI_GLYPH_6: "\U000F070D" + MDI_GLYPH_7: "\U000F06B5" # mdi:lamp + MDI_GLYPH_8: "\U000F06EE" # mdi:mailbox + MDI_GLYPH_9: "\U000F04B9" # mdi:sofa + MDI_GLYPH_10: "\U000F002A" # mdi:alarm-light + + # ============================================================ + # TILE 1 + # ============================================================ + + # --- basic settings --- + TILE1_ENTITY: "light.voorbeeld_lamp" # main Home Assistant entity for this tile + TILE1_STATE_ENTITY: "light.voorbeeld_lamp" # entity used for active/inactive state styling + TILE1_TITLE: "Lamp" # tile title shown in the UI + TILE1_ICON: "\U000F001B" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE1_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate + TILE1_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE1_LONGPRESS: "slider" # long press mode: none | slider | action + TILE1_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE1_LABEL_OFF: "Uit" # text shown when entity is off + TILE1_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE1_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE1_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE1_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE1_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE1_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE1_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE1_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE1_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE1_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE1_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE1_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE1_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE1_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE1_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE1_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE1_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE1_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE1_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE1_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE1_CLIMATE_MIN_TEMP: "16.0" # minimum target temperature shown in the thermostat overlay + TILE1_CLIMATE_MAX_TEMP: "32.0" # maximum target temperature shown in the thermostat overlay + TILE1_CLIMATE_STEP: "1.0" # target temperature step size + TILE1_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE1_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE1_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 2 + # ============================================================ + + # --- basic settings --- + TILE2_ENTITY: "switch.voorbeeld_stekker" # main Home Assistant entity for this tile + TILE2_STATE_ENTITY: "switch.voorbeeld_stekker" # entity used for active/inactive state styling + TILE2_TITLE: "Stekker" # tile title shown in the UI + TILE2_ICON: "\U000F0387" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE2_TYPE: "switch" # tile type: light | fan | switch | scene | script | cover | climate + TILE2_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE2_LONGPRESS: "none" # long press mode: none | slider | action + TILE2_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE2_LABEL_OFF: "Uit" # text shown when entity is off + TILE2_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE2_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE2_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE2_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE2_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE2_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE2_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE2_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE2_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE2_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE2_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE2_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE2_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE2_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE2_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE2_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE2_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE2_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE2_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE2_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE2_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE2_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE2_CLIMATE_STEP: "0.5" # target temperature step size + TILE2_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE2_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE2_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 3 + # ============================================================ + + # --- basic settings --- + TILE3_ENTITY: "scene.voorbeeld_avond" # main Home Assistant entity for this tile + TILE3_STATE_ENTITY: "scene.voorbeeld_avond" # entity used for active/inactive state styling + TILE3_TITLE: "Avond" # tile title shown in the UI + TILE3_ICON: "\U000F02E3" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE3_TYPE: "scene" # tile type: light | fan | switch | scene | script | cover | climate + TILE3_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE3_LONGPRESS: "none" # long press mode: none | slider | action + TILE3_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE3_LABEL_OFF: "Uit" # text shown when entity is off + TILE3_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE3_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE3_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE3_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE3_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE3_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE3_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE3_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE3_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE3_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE3_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE3_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE3_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE3_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE3_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE3_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE3_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE3_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE3_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE3_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE3_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE3_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE3_CLIMATE_STEP: "0.5" # target temperature step size + TILE3_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE3_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE3_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 4 + # ============================================================ + + # --- basic settings --- + TILE4_ENTITY: "script.voorbeeld_uit" # main Home Assistant entity for this tile + TILE4_STATE_ENTITY: "script.voorbeeld_uit" # entity used for active/inactive state styling + TILE4_TITLE: "Alles uit" # tile title shown in the UI + TILE4_ICON: "\U000F059C" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE4_TYPE: "script" # tile type: light | fan | switch | scene | script | cover | climate + TILE4_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE4_LONGPRESS: "none" # long press mode: none | slider | action + TILE4_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE4_LABEL_OFF: "Uit" # text shown when entity is off + TILE4_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE4_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE4_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE4_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE4_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE4_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE4_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE4_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE4_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE4_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE4_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE4_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE4_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE4_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE4_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE4_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE4_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE4_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE4_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE4_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE4_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE4_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE4_CLIMATE_STEP: "0.5" # target temperature step size + TILE4_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE4_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE4_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 5 + # ============================================================ + + # --- basic settings --- + TILE5_ENTITY: "climate.voorbeeld_airco" # main Home Assistant entity for this tile + TILE5_STATE_ENTITY: "climate.voorbeeld_airco" # entity used for active/inactive state styling + TILE5_TITLE: "Airco" # tile title shown in the UI + TILE5_ICON: "\U000F0425" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE5_TYPE: "climate" # tile type: light | fan | switch | scene | script | cover | climate + TILE5_TAP_ACTION: "toggle" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE5_LONGPRESS: "slider" # long press mode: none | slider | action + TILE5_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE5_LABEL_OFF: "Uit" # text shown when entity is off (closed) + TILE5_LABEL_ON: "Aan" # text shown when entity is on (open) + TILE5_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE5_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE5_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE5_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE5_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE5_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE5_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE5_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE5_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE5_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE5_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE5_TAP_SERVICE: "climate.toggle" # optional service override, e.g. light.toggle + TILE5_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE5_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE5_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE5_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE5_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE5_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE5_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE5_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE5_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE5_CLIMATE_STEP: "0.5" # target temperature step size + TILE5_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE5_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE5_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 6 + # ============================================================ + + # --- basic settings --- + TILE6_ENTITY: "vacuum.voorbeeld_robot" # main Home Assistant entity for this tile + TILE6_STATE_ENTITY: "vacuum.voorbeeld_robot" # entity used for active/inactive state styling + TILE6_TITLE: "Stofzuiger" # tile title shown in the UI + TILE6_ICON: "\U000F070D" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE6_TYPE: "vacuum" # tile type: light | fan | switch | scene | script | cover | climate + TILE6_TAP_ACTION: "custom" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE6_LONGPRESS: "vacuum" # long press mode: none | slider | action + TILE6_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE6_LABEL_OFF: "Uit" # text shown when entity is off + TILE6_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE6_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE6_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE6_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE6_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE6_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE6_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE6_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE6_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE6_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE6_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE6_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE6_TAP_SERVICE: "vacuum.start" # optional service override, e.g. fan.toggle + TILE6_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE6_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE6_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE6_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE6_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE6_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE6_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE6_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE6_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE6_CLIMATE_STEP: "0.5" # target temperature step size + TILE6_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE6_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE6_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + # ============================================================ + # TILE 7 — Tafellamp + # Alleen-helderheid lamp (HA meldt supported_color_modes: ['brightness']), + # dus de kleurenknop in de slider-overlay blijft vanzelf verborgen. + # ============================================================ + + # --- basic settings --- + TILE7_ENTITY: "light.ongebruikt_7" # main Home Assistant entity for this tile + TILE7_STATE_ENTITY: "light.ongebruikt_7" # entity used for active/inactive state styling + TILE7_TITLE: "Tegel 7" # tile title shown in the UI + TILE7_ICON: "\U000F06B5" # mdi:lamp — also listed in MDI_GLYPH_7 above + TILE7_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate + TILE7_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset + TILE7_LONGPRESS: "none" # long press mode: none | slider | action | vacuum + TILE7_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE7_LABEL_OFF: "Uit" # text shown when entity is off + TILE7_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE7_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE7_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE7_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE7_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE7_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE7_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE7_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE7_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE7_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE7_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE7_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE7_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE7_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE7_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE7_LONGPRESS_SLIDER: "auto" # auto = brightness voor een light + TILE7_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE7_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE7_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE) + TILE7_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on) + + # ============================================================ + # TILE 8 — Brievenbus reset + # input_select.brievenbus_status heeft opties ['Vol', 'Leeg']. + # De tegel licht op zodra de status "Vol" is (ACTIVE_STATE), en een korte + # tik zet hem terug op "Leeg" via input_select.select_option. + # ============================================================ + + # --- basic settings --- + TILE8_ENTITY: "light.ongebruikt_8" # main Home Assistant entity for this tile + TILE8_STATE_ENTITY: "light.ongebruikt_8" # entity used for active/inactive state styling + TILE8_TITLE: "Tegel 8" # tile title shown in the UI + TILE8_ICON: "\U000F06EE" # mdi:mailbox — also listed in MDI_GLYPH_8 above + TILE8_TYPE: "light" # generiek type; het echte gedrag komt uit TAP_SERVICE hieronder + TILE8_TAP_ACTION: "auto" # activate + TAP_SERVICE = losse service call + TILE8_LONGPRESS: "none" # long press stuurt alleen het event naar HA + TILE8_VALUE_MODE: "auto" # toont LABEL_ON / LABEL_OFF + TILE8_LABEL_OFF: "Uit" # text shown when entity is off / inactive + TILE8_LABEL_ON: "Aan" # text shown when entity is active + TILE8_ACTIVE_STATE: "" # tegel is "aan" zodra de status exact "Vol" is + + # --- colors --- + TILE8_CIRCLE_ACTIVE_COLOR: "0xE5484D" # rood zodra er post ligt + TILE8_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE8_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE8_ICON_DISABLED_COLOR: "0xE5484D" # icon color when inactive + TILE8_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE8_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE8_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE8_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE8_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE8_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE8_TAP_SERVICE: "" # zet de input_select op een vaste optie + TILE8_TAP_PARAM_KEY: "" # parameternaam voor input_select.select_option + TILE8_TAP_PARAM_VAL: "" # de "reset"-waarde + # if LONGPRESS = slider: + TILE8_LONGPRESS_SLIDER: "auto" # niet in gebruik (LONGPRESS = none) + TILE8_LONGPRESS_OFF_VALUE: "0" # niet in gebruik + # if LONGPRESS = action: + TILE8_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE8_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE) + TILE8_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on) + + # ============================================================ + # TILE 9 — Woonkamer + # Verhuisd van tegel 2 naar rij 5 om plaats te maken voor Ambient. + # Kleurenlamp (color_temp / rgb / xy), dus de kleurknop in de + # slider-overlay verschijnt vanzelf. + # ============================================================ + + # --- basic settings --- + TILE9_ENTITY: "light.ongebruikt_9" # main Home Assistant entity for this tile + TILE9_STATE_ENTITY: "light.ongebruikt_9" # entity used for active/inactive state styling + TILE9_TITLE: "Tegel 9" # tile title shown in the UI + TILE9_ICON: "\U000F04B9" # mdi:sofa — also listed in MDI_GLYPH_9 above + TILE9_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate + TILE9_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset + TILE9_LONGPRESS: "none" # long press mode: none | slider | action | vacuum + TILE9_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE9_LABEL_OFF: "Uit" # text shown when entity is off + TILE9_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE9_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE9_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # light -> ACCENT_LIGHT (amber) + TILE9_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE9_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE9_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE9_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE9_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE9_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE9_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE9_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE9_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE9_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE9_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE9_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE9_LONGPRESS_SLIDER: "auto" # auto = brightness voor een light + TILE9_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE9_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE9_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE) + TILE9_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on) + + # ============================================================ + # TILE 10 — Alarm aan/uit + # input_boolean.alarm_engaged. Een input_boolean reageert niet op + # switch.toggle, vandaar de expliciete TAP_SERVICE. + # ============================================================ + + # --- basic settings --- + TILE10_ENTITY: "light.ongebruikt_10" # main Home Assistant entity for this tile + TILE10_STATE_ENTITY: "light.ongebruikt_10" # entity used for active/inactive state styling + TILE10_TITLE: "Tegel 10" # tile title shown in the UI + TILE10_ICON: "\U000F002A" # mdi:alarm-light — also listed in MDI_GLYPH_10 above + TILE10_TYPE: "light" # generiek aan/uit-type; de service hieronder doet het echte werk + TILE10_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset + TILE10_LONGPRESS: "none" # long press stuurt alleen het event naar HA + TILE10_VALUE_MODE: "auto" # toont LABEL_ON / LABEL_OFF + TILE10_LABEL_OFF: "Uit" # text shown when entity is off + TILE10_LABEL_ON: "Aan" # text shown when entity is on + TILE10_ACTIVE_STATE: "" # input_boolean gebruikt gewoon on/off + + # --- colors --- + TILE10_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # input_boolean -> ACCENT_DEVICE (cyaan) + TILE10_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive + TILE10_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE10_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE10_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active + TILE10_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive + TILE10_TITLE_ACTIVE_COLOR: "0x000000" # title color when active + TILE10_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE10_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active + TILE10_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive + + # --- advanced settings --- + TILE10_TAP_SERVICE: "" # switch.toggle werkt niet op een input_boolean + TILE10_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE10_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE10_LONGPRESS_SLIDER: "auto" # niet in gebruik (LONGPRESS = none) + TILE10_LONGPRESS_OFF_VALUE: "0" # niet in gebruik + # if LONGPRESS = action: + TILE10_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE10_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE) + TILE10_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on) + + + # ============================================================ + # ORIENTATION / LAYOUT + # Uncomment ONE preset block below, comment out the others. + # Touch calibration values (TOUCH_CAL_*) are device-specific — + # portrait/flipped values are approximate and may need tuning. + # + # LVGL_ROTATION (0/90/180/270) drives the rotation — LVGL rotates both the + # display output and the touch coordinates (ESPHome 2026.4+). DISPLAY_W/H are + # the *logical* (post-rotation) canvas size used by the tile layout below; + # the physical panel is always driven at its native 240×320 (see display:). + # + # Tile content layout: + # Landscape — icon left, title + value to its right + # Portrait — icon centered on top, title + value stacked below + # ============================================================ + + # ----- 0° — Landscape 320×240 (default, USB on right) ----- + ORIENTATION: "0" + DISPLAY_W: "320" + DISPLAY_H: "240" + LVGL_ROTATION: "90" + TOUCH_SWAP_XY: "false" + # true op dit bord (resistieve XPT2046) — was false op het 032c-bord + # (capacitieve GT911). Dit is de originele upstream-waarde voor dit paneel. + TOUCH_MIRROR_X: "true" + TOUCH_MIRROR_Y: "false" + # Measured on this board: median top-right raw=(523,3390), + # bottom-left raw=(3487,482), at screen (299,20) and (20,219). + # Extrapolated to panel edges; verify with the isolated corner test. + TOUCH_CAL_X_MIN: "280" + TOUCH_CAL_X_MAX: "3860" + TOUCH_CAL_Y_MIN: "340" + TOUCH_CAL_Y_MAX: "3860" + GRID_COL1_X: "9" + GRID_COL2_X: "164" + GRID_ROW1_Y: "0" + GRID_ROW2_Y: "56" + GRID_ROW3_Y: "112" + GRID_ROW4_Y: "168" + GRID_ROW5_Y: "224" + SCROLL_Y: "40" + SCROLL_H: "164" + TILE_W: "147" + TILE_H: "52" + HEADER_Y: "14" + OVERLAY_SLIDER_W: "96" + OVERLAY_SLIDER_H: "160" + OVERLAY_ICON_Y: "74" + BG_TOP_COLOR: "0xAEC4D9" # achtergrond boven (zacht blauw) + BG_BOTTOM_COLOR: "0xD6BB9E" # achtergrond onder (warm zand) + TILE_ICON_X: "0" + TILE_ICON_Y: "0" + TILE_TITLE_X: "48" + TILE_TITLE_Y: "4" + TILE_VALUE_X: "48" + TILE_VALUE_Y: "18" + + # ----- 90° — Portrait 240×320 (USB on bottom) ----- + # ORIENTATION: "90" + # DISPLAY_W: "240" + # DISPLAY_H: "320" + # LVGL_ROTATION: "180" + # TOUCH_SWAP_XY: "false" + # TOUCH_MIRROR_X: "true" + # TOUCH_MIRROR_Y: "false" + # TOUCH_CAL_X_MIN: "280" + # TOUCH_CAL_X_MAX: "3860" + # TOUCH_CAL_Y_MIN: "340" + # TOUCH_CAL_Y_MAX: "3860" + # GRID_COL1_X: "6" + # GRID_COL2_X: "123" + # GRID_ROW1_Y: "8" + # GRID_ROW2_Y: "96" + # GRID_ROW3_Y: "184" + # GRID_ROW4_Y: "272" + # GRID_ROW5_Y: "360" + # SCROLL_Y: "44" + # SCROLL_H: "276" + # TILE_W: "111" + # TILE_H: "80" + # HEADER_Y: "14" + # OVERLAY_SLIDER_W: "96" + # OVERLAY_SLIDER_H: "200" + # OVERLAY_ICON_Y: "94" + # BG_TOP_COLOR: "0xAEC4D9" # achtergrond boven (zacht blauw) + # BG_BOTTOM_COLOR: "0xD6BB9E" # achtergrond onder (warm zand) + # TILE_ICON_X: "0" + # TILE_ICON_Y: "0" + # TILE_TITLE_X: "4" + # TILE_TITLE_Y: "38" + # TILE_VALUE_X: "4" + # TILE_VALUE_Y: "52" + + # ----- 180° — Landscape flipped 320×240 (USB on left) ----- + # ORIENTATION: "180" + # DISPLAY_W: "320" + # DISPLAY_H: "240" + # LVGL_ROTATION: "270" + # TOUCH_SWAP_XY: "false" + # TOUCH_MIRROR_X: "true" + # TOUCH_MIRROR_Y: "false" + # TOUCH_CAL_X_MIN: "280" + # TOUCH_CAL_X_MAX: "3860" + # TOUCH_CAL_Y_MIN: "340" + # TOUCH_CAL_Y_MAX: "3860" + # GRID_COL1_X: "9" + # GRID_COL2_X: "164" + # GRID_ROW1_Y: "8" + # GRID_ROW2_Y: "68" + # GRID_ROW3_Y: "128" + # GRID_ROW4_Y: "188" + # GRID_ROW5_Y: "248" + # SCROLL_Y: "44" + # SCROLL_H: "196" + # TILE_W: "147" + # TILE_H: "52" + # HEADER_Y: "14" + # OVERLAY_SLIDER_W: "96" + # OVERLAY_SLIDER_H: "160" + # OVERLAY_ICON_Y: "74" + # BG_TOP_COLOR: "0xAEC4D9" # achtergrond boven (zacht blauw) + # BG_BOTTOM_COLOR: "0xD6BB9E" # achtergrond onder (warm zand) + # TILE_ICON_X: "0" + # TILE_ICON_Y: "0" + # TILE_TITLE_X: "48" + # TILE_TITLE_Y: "4" + # TILE_VALUE_X: "48" + # TILE_VALUE_Y: "18" + + # ----- 270° — Portrait flipped 240×320 (USB on top) ----- + # ORIENTATION: "270" + # DISPLAY_W: "240" + # DISPLAY_H: "320" + # LVGL_ROTATION: "0" + # TOUCH_SWAP_XY: "false" + # TOUCH_MIRROR_X: "true" + # TOUCH_MIRROR_Y: "false" + # TOUCH_CAL_X_MIN: "280" + # TOUCH_CAL_X_MAX: "3860" + # TOUCH_CAL_Y_MIN: "340" + # TOUCH_CAL_Y_MAX: "3860" + # GRID_COL1_X: "6" + # GRID_COL2_X: "123" + # GRID_ROW1_Y: "8" + # GRID_ROW2_Y: "96" + # GRID_ROW3_Y: "184" + # GRID_ROW4_Y: "272" + # GRID_ROW5_Y: "360" + # SCROLL_Y: "44" + # SCROLL_H: "276" + # TILE_W: "111" + # TILE_H: "80" + # HEADER_Y: "14" + # OVERLAY_SLIDER_W: "96" + # OVERLAY_SLIDER_H: "200" + # OVERLAY_ICON_Y: "94" + # BG_TOP_COLOR: "0xAEC4D9" # achtergrond boven (zacht blauw) + # BG_BOTTOM_COLOR: "0xD6BB9E" # achtergrond onder (warm zand) + # TILE_ICON_X: "0" + # TILE_ICON_Y: "0" + # TILE_TITLE_X: "4" + # TILE_TITLE_Y: "38" + # TILE_VALUE_X: "4" + # TILE_VALUE_Y: "52" + +# ============================================================ +# No need to edit below this line. +# All configuration is done via the substitutions above. +# Credentials are managed in secrets.yaml (see secrets.yaml.example). +# The Material Design Icons font is included at fonts/materialdesignicons-webfont.ttf (Apache 2.0). +# Source: https://github.com/Templarian/MaterialDesign-Webfont +# ============================================================ + +external_components: + - source: + type: git + url: https://github.com/MaxGramser/homeassistant_espscreen.git + ref: main + path: components + refresh: 0s + components: [xpt2046, smart_display] + +esphome: + min_version: 2026.6.2 + project: + name: maxgramser.esp_screen + version: "0.1.0" + includes: + - + - + name: ${DEVICE_NAME} + friendly_name: ${DEVICE_FRIENDLY_NAME} + on_boot: + priority: -100 + then: + - lambda: |- + // Per-panel affine correction; shared defaults are neutral. + id(ts_touch).set_raw_correction(${TOUCH_AFFINE_XX}f, ${TOUCH_AFFINE_XY}f, ${TOUCH_AFFINE_XC}f, ${TOUCH_AFFINE_YX}f, ${TOUCH_AFFINE_YY}f, ${TOUCH_AFFINE_YC}f); + id(last_touch_ms) = millis(); + id(display_dimmed) = false; + lv_obj_set_style_pad_row(id(vacuum_actions), 4, 0); + lv_obj_set_style_pad_column(id(vacuum_actions), 4, 0); + lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0); + lv_obj_set_style_pad_column(id(vacuum_speeds), 4, 0); + - lambda: |- + runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; + if (!runtime_tiles::enabled) return; + runtime_tiles::dismiss = []() { + id(active_entity).clear(); + lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(vacuum_overlay), LV_OBJ_FLAG_HIDDEN); + }; + runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; + runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; + runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); + runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); + runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); + runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); + runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); + runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); + runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); + runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); + runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); + runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); + runtime_tiles::detail = [](runtime_tiles::Tile &t) { + if (t.domain() == "vacuum") { + lv_obj_t *buttons[] = {id(vacspd_quiet), id(vacspd_balanced), id(vacspd_turbo), id(vacspd_max)}; + for (unsigned i = 0; i < 4; ++i) { + if (i < t.fan_speed_count) { + lv_obj_clear_flag(buttons[i], LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(lv_obj_get_child(buttons[i], 0), t.fan_speeds[i].c_str()); + } else lv_obj_add_flag(buttons[i], LV_OBJ_FLAG_HIDDEN); + } + id(open_vacuum_overlay).execute(t.name, t.entity); + return; + } + light_controls::fallback = light_controls::State{}; + light_controls::fallback.hue = t.hue; + light_controls::fallback.kelvin = t.kelvin; + light_controls::fallback.minimum = t.min_kelvin; + light_controls::fallback.maximum = t.max_kelvin; + id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, + t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), + 0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes, + t.current, t.target, t.humidity, t.state, t.hvac_modes, + std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); + }; + runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { + if (id(active_entity) != t.entity || id(touch_down)) return; + if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { + if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; + id(climate_edit_pending) = false; + id(active_climate_mode) = t.state; + id(active_climate_supported_modes) = t.hvac_modes; + lv_label_set_text(id(climate_mode_label), t.state.c_str()); + char buf[72]; + if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); + else snprintf(buf, sizeof(buf), "Nu: --"); + lv_label_set_text(id(climate_current_label), buf); + if (std::isfinite(t.target)) { + int target = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); + id(active_climate_target_x10) = target; + snprintf(buf, sizeof(buf), "%.1f°", target / 10.0f); + lv_label_set_text(id(climate_target_label), buf); + lv_arc_set_value(id(climate_arc), target); + } + } + }; + - lambda: |- + static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; + if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8); + light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); + light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; + light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; + light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; + - lambda: |- + if (!runtime_tiles::enabled) return; + screen_calibration::apply = [](const screen_calibration::Calibration &cal) { + id(ts_touch).set_raw_correction(cal.c[0],cal.c[1],cal.c[2],cal.c[3],cal.c[4],cal.c[5]); + }; + screen_calibration::isolation = [](bool active) { id(calibration_active) = active; }; + screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(), + ${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX}); + - light.turn_on: + id: back_light + brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;" + - script.execute: show_tile_page + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + - if: + condition: + lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";' + then: + - lambda: 'id(calibration_active) = true;' + - lvgl.page.show: touch_cal_page + - lambda: |- + lv_obj_invalidate(id(touch_cal_page)->obj); + lv_refr_now(nullptr); + ESP_LOGI("ui_diag", "USB calibration ready; no tile actions"); + + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + # ESPHome adviseert dit zelf in de boot-log. Levert ~40KB extra IRAM + # en een kleinere binary op, en op dit bord telt elke KB. + sram1_as_iram: true + # minimum_chip_revision bewust weggelaten: dat stond hier voor het + # 032c-bord (rev 3.1 bevestigd), maar dit is een ander fysiek bordje + # met een onbekende chip-revisie. Met een onterecht hoge minimum + # weigert de firmware simpelweg te booten. + version: recommended + variant: esp32 + +logger: + # Voor diagnose tijdelijk op DEBUG of VERBOSE zetten. Let op: ESPHome logt + # zijn configuratieregels op niveau CONFIG, die zijn op INFO onzichtbaar. + level: DEBUG + logs: + sensor: WARN + text_sensor: WARN + xpt2046: WARN + homeassistant.sensor: WARN + homeassistant.text_sensor: WARN + + +# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values. +api: + actions: + - action: touch_diagnostics + then: + - lambda: 'id(calibration_active) = true;' + - script.execute: wake_display + - lvgl.page.show: touch_cal_page + - lambda: |- + lv_obj_invalidate(id(touch_cal_page)->obj); + lv_refr_now(nullptr); + ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj); + - action: end_touch_diagnostics + then: + - lambda: 'id(calibration_active) = false;' + - lvgl.page.show: home_page + - action: ui_state + then: + - lambda: |- + lv_area_t nav, grid; + lv_obj_update_layout(id(home_page)->obj); + lv_obj_get_coords(id(page_next), &nav); + lv_obj_get_coords(id(tile_scroll), &grid); + ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", + lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, + grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); + - action: light_controls_preview + then: + - script.execute: light_controls_preview + - action: ui_self_test + then: + - script.execute: ui_self_test + +captive_portal: + +interval: + - interval: 1s + then: + - if: + condition: + lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;' + then: + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + - if: + condition: + lambda: |- + if (std::string("${AUTO_DIM}") != "true") return false; + if (id(display_dimmed) || id(touch_down) || id(calibration_active)) return false; + return (millis() - id(last_touch_ms)) > (atoi("${AUTO_DIM_TIMEOUT}") * 1000UL); + then: + - script.execute: dim_display + +time: + - platform: homeassistant + id: homeassistant_time + # Refresh immediately on time sync; do not wait for an HA entity update. + update_interval: 60s + on_time_sync: + then: + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + on_time: + - seconds: 0 + minutes: /1 + then: + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + + - seconds: 0 + minutes: 0 + hours: ${NIGHT_START_HOUR} + then: + - if: + condition: + lambda: 'return id(display_dimmed);' + then: + - script.execute: dim_display + + - seconds: 0 + minutes: 0 + hours: ${NIGHT_END_HOUR} + then: + - if: + condition: + lambda: 'return id(display_dimmed);' + then: + - script.execute: dim_display + +globals: + - id: rendered_frames + type: uint32_t + initial_value: '0' + - id: calibration_active + type: bool + initial_value: 'false' + - id: tile_page + type: int + initial_value: '0' + - id: touch_down + type: bool + initial_value: 'false' + - id: climate_edit_ms + type: uint32_t + initial_value: '0' + - id: climate_edit_pending + type: bool + initial_value: 'false' + - id: active_entity + type: std::string + restore_value: no + + - id: active_control_kind + type: std::string + restore_value: no + + - id: active_value_suffix + type: std::string + restore_value: no + + - id: last_touch_ms + type: uint32_t + restore_value: no + initial_value: '0' + + # Debounce voor do_tile_action: dit resistieve XPT2046-paneel geeft soms + # meerdere losse press/release-cycli voor één fysieke aanraking (ruis in + # het weerstandslaagje), waardoor dezelfde HA-actie meerdere keren binnen + # een paar honderd ms vuurt (gezien: script.ambient_morning 6x in 8s). + - id: last_action_entity + type: std::string + restore_value: no + + - id: last_action_ms + type: uint32_t + restore_value: no + initial_value: '0' + + - id: display_dimmed + type: bool + restore_value: no + initial_value: 'false' + + - id: active_label_off + type: std::string + restore_value: no + + - id: active_label_on + type: std::string + restore_value: no + + - id: active_title + type: std::string + restore_value: no + + - id: active_icon_text + type: std::string + restore_value: no + + - id: active_brightness_pct + type: int + restore_value: no + initial_value: '100' + + - id: active_supports_color + type: bool + restore_value: no + initial_value: 'false' + + - id: active_supports_temperature + type: bool + restore_value: no + initial_value: 'false' + + - id: active_color_detail_tab + type: int + restore_value: no + initial_value: '0' + + - id: active_climate_current_x10 + type: int + restore_value: no + initial_value: '0' + + - id: active_climate_target_x10 + type: int + restore_value: no + initial_value: '200' + + - id: active_climate_humidity + type: int + restore_value: no + initial_value: '-1' + + - id: active_climate_min_x10 + type: int + restore_value: no + initial_value: '50' + + - id: active_climate_max_x10 + type: int + restore_value: no + initial_value: '300' + + - id: active_climate_step_x10 + type: int + restore_value: no + initial_value: '5' + + - id: active_climate_mode + type: std::string + restore_value: no + + - id: active_climate_supported_modes + type: std::string + restore_value: no + +font: + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" + id: time_label + size: 18 + bpp: 4 + glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P'] + + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" + id: headline + size: 18 + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-700.ttf" + id: label + size: 11 + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" + id: sublabel + size: 11 + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" + id: sublabel_big + size: 14 + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + + # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) + # Source: https://github.com/Templarian/MaterialDesign-Webfont + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" + id: materialdesign_icons + size: 28 + glyphs: + - "\U000F0335" + - "\U000F0210" + - "\U000F111C" + - "\U000F029A" + - "${MDI_GLYPH_1}" + - "${MDI_GLYPH_2}" + - "${MDI_GLYPH_3}" + - "${MDI_GLYPH_4}" + - "${MDI_GLYPH_5}" + - "${MDI_GLYPH_6}" + - "${MDI_GLYPH_7}" + - "${MDI_GLYPH_8}" + - "${MDI_GLYPH_9}" + - "${MDI_GLYPH_10}" + - "\U000F012C" # check + +# Geheugen zichtbaar maken: dit bord heeft geen PSRAM, dus de vrije heap +# bepaalt hoeveel UI erbij kan. Verschijnt ook als entity in Home Assistant. +debug: + update_interval: 30s + +sensor: + - platform: debug + free: + name: "Heap Free" + id: heap_free + on_value: + - lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);' + fragmentation: + name: "Heap Fragmentation" + block: + name: "Heap Largest Block" + on_value: + - lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);' + - platform: wifi_signal + name: "Wifi Signal" + update_interval: 600s + + - platform: uptime + name: "Uptime" + id: uptime_s + update_interval: 15s + + - platform: template + update_interval: never + id: tile1_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + # Tegel 7 (Tafellamp) is een alleen-helderheid light: alleen brightness. + # Bewust geen percentage / cover_position / climate-sensors — elke + # HA-subscription kost heap op een bord zonder PSRAM. + - platform: template + update_interval: never + id: tile7_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + # Tegel 9 (Woonkamer) is een kleurenlamp: brightness + supported_color_modes. + # Geen percentage / cover / climate — die leest deze tegel nooit. + - platform: template + update_interval: never + id: tile9_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + +binary_sensor: + - platform: status + name: "Node Status" + id: system_status + +# ---------------------------------------------------------- +# Text sensors +# +# smartdisplay_action: +# - Main touch event channel exported to Home Assistant +# - On short tap, publishes one of: +# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press +# - The value is reset shortly after publishing +# +# tile*_preset: +# - Mirrors the Home Assistant preset_mode attribute for each tile entity +# - Used by the UI to display fan presets when applicable +# ---------------------------------------------------------- +text_sensor: + - platform: template + name: "Schermfirmware" + entity_category: diagnostic + lambda: 'return {"0.1.0"};' + update_interval: 60s + - platform: template + id: smartdisplay_action + name: "SmartDisplay Action" + icon: mdi:gesture-tap-button + + - platform: template + update_interval: never + id: ha_state_tile1 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile2 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile3 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile4 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile5 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile6 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile7 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile8 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile9 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile10 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + # Woonkamer meldt color_temp/rgb/xy, dus hier verschijnt de kleurknop wel. + - platform: template + update_interval: never + id: tile9_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + # Gebruikt door de slider-overlay om te bepalen of de kleurknop nodig is. + # Deze lamp meldt ['brightness'], dus de knop blijft vanzelf verborgen. + - platform: template + update_interval: never + id: tile7_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_supported_modes + + - platform: template + update_interval: never + id: tile2_climate_supported_modes + + - platform: template + update_interval: never + id: tile3_climate_supported_modes + + - platform: template + update_interval: never + id: tile4_climate_supported_modes + + - platform: template + update_interval: never + id: tile5_climate_supported_modes + + - platform: template + update_interval: never + id: tile6_climate_supported_modes + + - platform: template + update_interval: never + id: tile1_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + +switch: + - platform: restart + name: "Restart" + +# Two SPI buses used by the CYD: +# - lcd: built-in display +# - my_touchscreen: built-in touch controller +# Twee SPI bussen op de 2432S028: een voor het display, een losse voor de +# resistieve XPT2046 touch (geen I2C op dit bord, in tegenstelling tot 032c). +spi: + - id: lcd + clk_pin: GPIO14 + mosi_pin: GPIO13 + + - id: my_touchscreen + clk_pin: GPIO25 + mosi_pin: GPIO32 + miso_pin: GPIO39 + +output: + - platform: ledc + pin: GPIO21 + id: gpio_backlight_pwm + # ESPHome's ledc default is 1kHz. Bij stilstand zie je dat niet (ruim + # boven wat het oog als flikkering waarneemt), maar bij beweging van het + # scherm onthult motion-blur de aan/uit-pulsen als strepen (zelfde effect + # als een PWM-LED die "schokt" als je hem beweegt). 20kHz zit ruim boven + # de snelheid waarbij beweging dat nog zichtbaar maakt, en laat nog altijd + # ruim voldoende duty-cycle resolutie over voor vloeiend dimmen. + frequency: 20000Hz + + - platform: ledc + id: output_red + pin: GPIO4 + inverted: true + - platform: ledc + id: output_green + pin: GPIO16 + inverted: true + - platform: ledc + id: output_blue + pin: GPIO17 + inverted: true + +light: + - platform: monochromatic + output: gpio_backlight_pwm + name: "Power Display Backlight" + id: back_light + restore_mode: ALWAYS_ON + + - platform: rgb + name: LED + red: output_red + id: led + green: output_green + blue: output_blue + restore_mode: ALWAYS_OFF + +# Touchscreen configuration (XPT2046, resistief, via SPI) +# XPT2046 calibration is board-specific. Poll at 50Hz; LVGL rotates touch +# with the display. GPIO36 IRQ is deliberately unused (driver requests a +# pull-up that this input-only pin cannot provide). +touchscreen: + platform: xpt2046 + id: ts_touch + spi_id: my_touchscreen + cs_pin: 33 + # Polling avoids GPIO36 pull-up errors and noisy PENIRQ edges. + update_interval: 20ms + # Diagnosis caught a false corner contact at pressure 422. Deliberate + # contacts measured 936-2643; reject the low-pressure tail on release. + threshold: 800 + + calibration: + x_min: ${TOUCH_CAL_X_MIN} + x_max: ${TOUCH_CAL_X_MAX} + y_min: ${TOUCH_CAL_Y_MIN} + y_max: ${TOUCH_CAL_Y_MAX} + + transform: + swap_xy: ${TOUCH_SWAP_XY} + mirror_x: ${TOUCH_MIRROR_X} + mirror_y: ${TOUCH_MIRROR_Y} + + on_touch: + - lambda: |- + id(last_touch_ms) = millis(); + id(touch_down) = true; + cyd::touch_guard.begin(millis(), touch.x, touch.y); + screen_calibration::press(); + ESP_LOGI("touch", "press native=%u,%u raw=%d,%d pressure=%d calibration=%d", touch.x, touch.y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y(), touch.z_raw, id(calibration_active)); + on_update: + - lambda: |- + id(last_touch_ms) = millis(); + for (const auto &point : touches) { + cyd::touch_guard.update(point.x, point.y); + } + on_release: + - lambda: |- + id(last_touch_ms) = millis(); + id(touch_down) = false; + screen_calibration::release(id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y()); + +# Display configuration (ILI9341) +display: + - platform: mipi_spi + id: my_display + spi_id: lcd + # Standard single-Micro-USB CYD; 40MHz matches ESPHome's board preset. + model: ILI9341 + data_rate: 40MHz + cs_pin: 15 + dc_pin: 2 + invert_colors: false + update_interval: never + auto_clear_enabled: false + + # Native panel dimensions (portrait). Do NOT swap these per orientation and + # do NOT add a display transform — rotation is handled by LVGL below. + # ESPHome 2026.6+ rejects a swapped transform here with "Invalid offsets". + dimensions: + width: 240 + height: 320 + +lvgl: + on_draw_start: + - lambda: 'id(rendered_frames)++;' + displays: + - my_display + touchscreens: + - touchscreen_id: ts_touch + + # De ESP32-WROOM-32 heeft geen PSRAM; een volledige framebuffer (320x240x2 + # = 153600 bytes) past niet in DRAM. 25% is de gebruikelijke waarde. + # 12% in plaats van 25%: laat ~20KB extra aaneengesloten geheugen vrij, + # en LVGL schaalt de stroken van een ARGB-laag mee met de beschikbare + # tekenruimte, dus ook de gevraagde allocaties worden kleiner. + buffer_size: 12% + + # Directe lokale feedback op de HELE UI, niet alleen op de tegels. + # Een thema zet de stijl per widget-type, dus dit dekt in een klap alle + # knoppen, tabs, sliders en de thermostaatring. Niet-klikbare widgets + # komen nooit in de pressed-state, dus die blijven onaangeroerd. + theme: + obj: + pressed: + # bg_opa en NIET opa: `opa` op widget-niveau dwingt LVGL het element + # naar een aparte laag te renderen, en die laag kost tientallen KB's + # per ingedrukte widget. Dat liep hier het geheugen uit. + bg_opa: 45% + slider: + knob: + pressed: + bg_opa: 65% + arc: + knob: + pressed: + bg_opa: 65% + + # LVGL owns the rotation (display + touch) since ESPHome 2026.4. + rotation: ${LVGL_ROTATION} + + style_definitions: + # Achtergrond: twee-stops verticale gradient in plaats van de 320x240 + # RGB565-wallpaper. Scheelt ~150 KB flash en een image-blit per hertekening, + # wat je direct terugziet in de vloeiendheid van het scrollen. + - id: style_bg_grad + bg_color: ${BG_TOP_COLOR} + bg_grad_color: ${BG_BOTTOM_COLOR} + bg_grad_dir: VER + bg_opa: COVER + border_width: 0 + radius: 0 + + - id: style_tile + bg_color: 0xFFFFFF + bg_opa: COVER + radius: 18 + border_width: 0 + pad_left: 8 + pad_right: 8 + pad_top: 8 + pad_bottom: 8 + shadow_width: 0 + shadow_opa: 40% + + - id: style_tile_disabled + bg_color: 0x939391 + bg_opa: COVER + radius: 18 + border_width: 0 + pad_left: 8 + pad_right: 8 + pad_top: 8 + pad_bottom: 8 + shadow_width: 0 + shadow_opa: 25% + + - id: style_icon_circle + bg_color: 0xFEC600 + bg_opa: COVER + radius: 999 + border_width: 0 + + - id: style_icon_circle_disabled + bg_color: 0x7B7B6F + bg_opa: COVER + radius: 999 + border_width: 0 + + - id: style_room + text_font: headline + text_color: 0xFFFFFF + text_opa: 100% + + - id: style_time + text_font: time_label + text_color: 0xFFFFFF + text_opa: 100% + + - id: style_title + text_font: label + text_color: 0x000000 + text_opa: 100% + + - id: style_value + text_font: sublabel + text_color: 0x7A7A7C + text_opa: 100% + + - id: style_value_big + text_font: sublabel_big + text_color: 0xFFFFFF + text_opa: 80% + + - id: style_title_disabled + text_font: label + text_color: 0xFFFFFF + text_opa: 100% + + - id: style_value_disabled + text_font: sublabel + text_color: 0xD9D9D9 + text_opa: 100% + + pages: + - id: home_page + styles: style_bg_grad + pad_all: 0 + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: lbl_room + x: 11 + y: ${HEADER_Y} + text: "${ROOM_NAME}" + styles: style_room + + - label: + id: lbl_time + align: TOP_RIGHT + x: -11 + y: ${HEADER_Y} + text: "" + styles: style_time + + # Six tiles on page 1, four on page 2. No dragging on resistive glass. + - obj: + id: tile_scroll + x: 0 + y: ${SCROLL_Y} + width: ${DISPLAY_W} + height: ${SCROLL_H} + bg_opa: TRANSP + border_width: 0 + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + scrollable: false + scrollbar_mode: 'OFF' + widgets: + # ---------- TILE 1 ---------- + - obj: + id: tile1 + x: ${GRID_COL1_X} + y: ${GRID_ROW1_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile1_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile1_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE1_ICON}" + clickable: false + - label: + id: t1_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE1_TITLE}" + styles: style_title + clickable: false + - label: + id: t1_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);' + then: + - script.execute: { id: publish_action, action: "tile1_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE1_TYPE}" + tap_action: "${TILE1_TAP_ACTION}" + service: "${TILE1_TAP_SERVICE}" + entity_id: "${TILE1_ENTITY}" + param_key: "${TILE1_TAP_PARAM_KEY}" + param_val: "${TILE1_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE1_TYPE}") == "cover") + return id(tile1_cover_state).state != "closed"; + return std::string(id(ha_state_tile1).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);' + then: + - script.execute: { id: publish_action, action: "tile1_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE1_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE1_TYPE}") : t; + tap_action: "activate" + service: "${TILE1_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE1_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE1_TITLE}" + entity: "${TILE1_ENTITY}" + tile_type: "${TILE1_TYPE}" + longpress_mode: "${TILE1_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE1_TYPE}") == "cover") + return id(tile1_cover_state).state != "closed"; + return std::string(id(ha_state_tile1).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE1_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile1_brightness).state;" + percentage_value: !lambda "return id(tile1_percentage).state;" + position_value: !lambda "return id(tile1_cover_position).state;" + icon_text: "${TILE1_ICON}" + slider_bg_color: ${TILE1_BG_DISABLED_COLOR} + slider_fill_color: ${TILE1_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE1_ICON_ACTIVE_COLOR} + label_off: "${TILE1_LABEL_OFF}" + label_on: "${TILE1_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile1_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile1_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile1_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile1_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile1_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile1_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE1_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE1_CLIMATE_MAX_TEMP}" + climate_step: "${TILE1_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE1_TITLE}" + entity: "${TILE1_ENTITY}" + + # ---------- TILE 2 ---------- + - obj: + id: tile2 + x: ${GRID_COL2_X} + y: ${GRID_ROW1_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile2_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile2_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE2_ICON}" + clickable: false + - label: + id: t2_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE2_TITLE}" + styles: style_title + clickable: false + - label: + id: t2_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);' + then: + - script.execute: { id: publish_action, action: "tile2_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE2_TYPE}" + tap_action: "${TILE2_TAP_ACTION}" + service: "${TILE2_TAP_SERVICE}" + entity_id: "${TILE2_ENTITY}" + param_key: "${TILE2_TAP_PARAM_KEY}" + param_val: "${TILE2_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE2_TYPE}") == "cover") + return id(tile2_cover_state).state != "closed"; + return std::string(id(ha_state_tile2).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);' + then: + - script.execute: { id: publish_action, action: "tile2_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE2_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE2_TYPE}") : t; + tap_action: "activate" + service: "${TILE2_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE2_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE2_TITLE}" + entity: "${TILE2_ENTITY}" + tile_type: "${TILE2_TYPE}" + longpress_mode: "${TILE2_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE2_TYPE}") == "cover") + return id(tile2_cover_state).state != "closed"; + return std::string(id(ha_state_tile2).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE2_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile2_brightness).state;" + percentage_value: !lambda "return id(tile2_percentage).state;" + position_value: !lambda "return id(tile2_cover_position).state;" + icon_text: "${TILE2_ICON}" + slider_bg_color: ${TILE2_BG_DISABLED_COLOR} + slider_fill_color: ${TILE2_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE2_ICON_ACTIVE_COLOR} + label_off: "${TILE2_LABEL_OFF}" + label_on: "${TILE2_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile2_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile2_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile2_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile2_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile2_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile2_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE2_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE2_CLIMATE_MAX_TEMP}" + climate_step: "${TILE2_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE2_TITLE}" + entity: "${TILE2_ENTITY}" + + # ---------- TILE 3 ---------- + - obj: + id: tile3 + x: ${GRID_COL1_X} + y: ${GRID_ROW2_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile3_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile3_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE3_ICON}" + clickable: false + - label: + id: t3_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE3_TITLE}" + styles: style_title + clickable: false + - label: + id: t3_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);' + then: + - script.execute: { id: publish_action, action: "tile3_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE3_TYPE}" + tap_action: "${TILE3_TAP_ACTION}" + service: "${TILE3_TAP_SERVICE}" + entity_id: "${TILE3_ENTITY}" + param_key: "${TILE3_TAP_PARAM_KEY}" + param_val: "${TILE3_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE3_TYPE}") == "cover") + return id(tile3_cover_state).state != "closed"; + return std::string(id(ha_state_tile3).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);' + then: + - script.execute: { id: publish_action, action: "tile3_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE3_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE3_TYPE}") : t; + tap_action: "activate" + service: "${TILE3_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE3_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE3_TITLE}" + entity: "${TILE3_ENTITY}" + tile_type: "${TILE3_TYPE}" + longpress_mode: "${TILE3_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE3_TYPE}") == "cover") + return id(tile3_cover_state).state != "closed"; + return std::string(id(ha_state_tile3).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE3_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile3_brightness).state;" + percentage_value: !lambda "return id(tile3_percentage).state;" + position_value: !lambda "return id(tile3_cover_position).state;" + icon_text: "${TILE3_ICON}" + slider_bg_color: ${TILE3_BG_DISABLED_COLOR} + slider_fill_color: ${TILE3_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE3_ICON_ACTIVE_COLOR} + label_off: "${TILE3_LABEL_OFF}" + label_on: "${TILE3_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile3_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile3_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile3_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile3_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile3_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile3_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE3_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE3_CLIMATE_MAX_TEMP}" + climate_step: "${TILE3_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE3_TITLE}" + entity: "${TILE3_ENTITY}" + + # ---------- TILE 4 ---------- + - obj: + id: tile4 + x: ${GRID_COL2_X} + y: ${GRID_ROW2_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile4_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile4_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE4_ICON}" + clickable: false + - label: + id: t4_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE4_TITLE}" + styles: style_title + clickable: false + - label: + id: t4_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);' + then: + - script.execute: { id: publish_action, action: "tile4_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE4_TYPE}" + tap_action: "${TILE4_TAP_ACTION}" + service: "${TILE4_TAP_SERVICE}" + entity_id: "${TILE4_ENTITY}" + param_key: "${TILE4_TAP_PARAM_KEY}" + param_val: "${TILE4_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE4_TYPE}") == "cover") + return id(tile4_cover_state).state != "closed"; + return std::string(id(ha_state_tile4).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);' + then: + - script.execute: { id: publish_action, action: "tile4_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE4_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE4_TYPE}") : t; + tap_action: "activate" + service: "${TILE4_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE4_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE4_TITLE}" + entity: "${TILE4_ENTITY}" + tile_type: "${TILE4_TYPE}" + longpress_mode: "${TILE4_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE4_TYPE}") == "cover") + return id(tile4_cover_state).state != "closed"; + return std::string(id(ha_state_tile4).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE4_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile4_brightness).state;" + percentage_value: !lambda "return id(tile4_percentage).state;" + position_value: !lambda "return id(tile4_cover_position).state;" + icon_text: "${TILE4_ICON}" + slider_bg_color: ${TILE4_BG_DISABLED_COLOR} + slider_fill_color: ${TILE4_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE4_ICON_ACTIVE_COLOR} + label_off: "${TILE4_LABEL_OFF}" + label_on: "${TILE4_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile4_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile4_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile4_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile4_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile4_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile4_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE4_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE4_CLIMATE_MAX_TEMP}" + climate_step: "${TILE4_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE4_TITLE}" + entity: "${TILE4_ENTITY}" + + # ---------- TILE 5 ---------- + - obj: + id: tile5 + x: ${GRID_COL1_X} + y: ${GRID_ROW3_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile5_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile5_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE5_ICON}" + clickable: false + - label: + id: t5_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE5_TITLE}" + styles: style_title + clickable: false + - label: + id: t5_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);' + then: + - script.execute: { id: publish_action, action: "tile5_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE5_TYPE}" + tap_action: "${TILE5_TAP_ACTION}" + service: "${TILE5_TAP_SERVICE}" + entity_id: "${TILE5_ENTITY}" + param_key: "${TILE5_TAP_PARAM_KEY}" + param_val: "${TILE5_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE5_TYPE}") == "cover") + return id(tile5_cover_state).state != "closed"; + return std::string(id(ha_state_tile5).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);' + then: + - script.execute: { id: publish_action, action: "tile5_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE5_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE5_TYPE}") : t; + tap_action: "activate" + service: "${TILE5_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE5_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE5_TITLE}" + entity: "${TILE5_ENTITY}" + tile_type: "${TILE5_TYPE}" + longpress_mode: "${TILE5_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE5_TYPE}") == "cover") + return id(tile5_cover_state).state != "closed"; + return std::string(id(ha_state_tile5).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE5_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile5_brightness).state;" + percentage_value: !lambda "return id(tile5_percentage).state;" + position_value: !lambda "return id(tile5_cover_position).state;" + icon_text: "${TILE5_ICON}" + slider_bg_color: ${TILE5_BG_DISABLED_COLOR} + slider_fill_color: ${TILE5_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE5_ICON_ACTIVE_COLOR} + label_off: "${TILE5_LABEL_OFF}" + label_on: "${TILE5_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile5_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile5_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile5_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile5_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile5_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile5_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE5_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE5_CLIMATE_MAX_TEMP}" + climate_step: "${TILE5_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE5_TITLE}" + entity: "${TILE5_ENTITY}" + + # ---------- TILE 6 ---------- + - obj: + id: tile6 + x: ${GRID_COL2_X} + y: ${GRID_ROW3_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile6_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile6_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE6_ICON}" + clickable: false + - label: + id: t6_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE6_TITLE}" + styles: style_title + clickable: false + - label: + id: t6_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);' + then: + - script.execute: { id: publish_action, action: "tile6_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE6_TYPE}" + tap_action: "${TILE6_TAP_ACTION}" + service: "${TILE6_TAP_SERVICE}" + entity_id: "${TILE6_ENTITY}" + param_key: "${TILE6_TAP_PARAM_KEY}" + param_val: "${TILE6_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE6_TYPE}") == "cover") + return id(tile6_cover_state).state != "closed"; + return std::string(id(ha_state_tile6).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);' + then: + - script.execute: { id: publish_action, action: "tile6_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE6_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE6_TYPE}") : t; + tap_action: "activate" + service: "${TILE6_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE6_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE6_TITLE}" + entity: "${TILE6_ENTITY}" + tile_type: "${TILE6_TYPE}" + longpress_mode: "${TILE6_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE6_TYPE}") == "cover") + return id(tile6_cover_state).state != "closed"; + return std::string(id(ha_state_tile6).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE6_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile6_brightness).state;" + percentage_value: !lambda "return id(tile6_percentage).state;" + position_value: !lambda "return id(tile6_cover_position).state;" + icon_text: "${TILE6_ICON}" + slider_bg_color: ${TILE6_BG_DISABLED_COLOR} + slider_fill_color: ${TILE6_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE6_ICON_ACTIVE_COLOR} + label_off: "${TILE6_LABEL_OFF}" + label_on: "${TILE6_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile6_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile6_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile6_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile6_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile6_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile6_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE6_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE6_CLIMATE_MAX_TEMP}" + climate_step: "${TILE6_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE6_TITLE}" + entity: "${TILE6_ENTITY}" + + # ---------- TILE 7 — Tafellamp ---------- + # Alleen-helderheid lamp: er zijn bewust geen percentage/cover/ + # climate-sensors voor deze tegel aangemaakt, die zouden alleen + # maar HA-subscriptions en heap kosten. De overlay krijgt daarom + # NAN / "" voor de velden die een light nooit gebruikt. + - obj: + id: tile7 + hidden: true + x: ${GRID_COL1_X} + y: ${GRID_ROW4_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile7_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile7_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE7_ICON}" + clickable: false + - label: + id: t7_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE7_TITLE}" + styles: style_title + clickable: false + - label: + id: t7_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);' + then: + - script.execute: { id: publish_action, action: "tile7_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE7_TYPE}" + tap_action: "${TILE7_TAP_ACTION}" + service: "${TILE7_TAP_SERVICE}" + entity_id: "${TILE7_ENTITY}" + param_key: "${TILE7_TAP_PARAM_KEY}" + param_val: "${TILE7_TAP_PARAM_VAL}" + was_on: !lambda "return std::string(id(ha_state_tile7).state.c_str()) == \"on\";" + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);' + then: + - script.execute: { id: publish_action, action: "tile7_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE7_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE7_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE7_TYPE}") : t; + tap_action: "activate" + service: "${TILE7_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE7_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE7_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE7_TITLE}" + entity: "${TILE7_ENTITY}" + tile_type: "${TILE7_TYPE}" + longpress_mode: "${TILE7_LONGPRESS_SLIDER}" + was_on: !lambda "return std::string(id(ha_state_tile7).state.c_str()) == \"on\";" + off_value: !lambda "return atoi(\"${TILE7_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile7_brightness).state;" + percentage_value: !lambda "return NAN;" + position_value: !lambda "return NAN;" + icon_text: "${TILE7_ICON}" + slider_bg_color: ${TILE7_BG_DISABLED_COLOR} + slider_fill_color: ${TILE7_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE7_ICON_ACTIVE_COLOR} + label_off: "${TILE7_LABEL_OFF}" + label_on: "${TILE7_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile7_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return NAN;" + climate_target_temperature: !lambda "return NAN;" + climate_humidity: !lambda "return NAN;" + climate_mode: "" + climate_supported_modes: "" + climate_min_temp: "5.0" + climate_max_temp: "30.0" + climate_step: "0.5" + + # ---------- TILE 8 — Brievenbus reset ---------- + # input_select met opties Vol/Leeg. De tegel licht op zodra de + # status TILE8_ACTIVE_STATE ("Vol") is; een korte tik stuurt + # input_select.select_option met option: "Leeg". + # Geen slider-modus, dus deze tegel heeft alleen ha_state_tile8 + # nodig — verder geen enkele HA-subscription. + - obj: + id: tile8 + hidden: true + x: ${GRID_COL2_X} + y: ${GRID_ROW4_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile8_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile8_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE8_ICON}" + clickable: false + - label: + id: t8_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE8_TITLE}" + styles: style_title + clickable: false + - label: + id: t8_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);' + then: + - script.execute: { id: publish_action, action: "tile8_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE8_TYPE}" + tap_action: "${TILE8_TAP_ACTION}" + service: "${TILE8_TAP_SERVICE}" + entity_id: "${TILE8_ENTITY}" + param_key: "${TILE8_TAP_PARAM_KEY}" + param_val: "${TILE8_TAP_PARAM_VAL}" + was_on: !lambda "return std::string(id(ha_state_tile8).state.c_str()) == \"${TILE8_ACTIVE_STATE}\";" + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);' + then: + - script.execute: { id: publish_action, action: "tile8_long_press" } + # Slider-modus kan hier niet zonder eerst de bijbehorende + # sensors aan te maken; "action" werkt wel out of the box. + - if: + condition: + lambda: "return std::string(\"${TILE8_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE8_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE8_TYPE}") : t; + tap_action: "activate" + service: "${TILE8_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE8_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + + # ---------- TILE 9 — Woonkamer (verhuisd van tegel 2) ---------- + - obj: + id: tile9 + hidden: true + x: ${GRID_COL1_X} + y: ${GRID_ROW5_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile9_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile9_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE9_ICON}" + clickable: false + - label: + id: t9_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE9_TITLE}" + styles: style_title + clickable: false + - label: + id: t9_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);' + then: + - script.execute: { id: publish_action, action: "tile9_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE9_TYPE}" + tap_action: "${TILE9_TAP_ACTION}" + service: "${TILE9_TAP_SERVICE}" + entity_id: "${TILE9_ENTITY}" + param_key: "${TILE9_TAP_PARAM_KEY}" + param_val: "${TILE9_TAP_PARAM_VAL}" + was_on: !lambda "return std::string(id(ha_state_tile9).state.c_str()) == \"on\";" + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);' + then: + - script.execute: { id: publish_action, action: "tile9_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE9_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE9_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE9_TYPE}") : t; + tap_action: "activate" + service: "${TILE9_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE9_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE9_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE9_TITLE}" + entity: "${TILE9_ENTITY}" + tile_type: "${TILE9_TYPE}" + longpress_mode: "${TILE9_LONGPRESS_SLIDER}" + was_on: !lambda "return std::string(id(ha_state_tile9).state.c_str()) == \"on\";" + off_value: !lambda "return atoi(\"${TILE9_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile9_brightness).state;" + percentage_value: !lambda "return NAN;" + position_value: !lambda "return NAN;" + icon_text: "${TILE9_ICON}" + slider_bg_color: ${TILE9_BG_DISABLED_COLOR} + slider_fill_color: ${TILE9_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE9_ICON_ACTIVE_COLOR} + label_off: "${TILE9_LABEL_OFF}" + label_on: "${TILE9_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile9_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return NAN;" + climate_target_temperature: !lambda "return NAN;" + climate_humidity: !lambda "return NAN;" + climate_mode: "" + climate_supported_modes: "" + climate_min_temp: "5.0" + climate_max_temp: "30.0" + climate_step: "0.5" + + # ---------- TILE 10 — Alarm aan/uit ---------- + - obj: + id: tile10 + hidden: true + x: ${GRID_COL2_X} + y: ${GRID_ROW5_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile10_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 36 + height: 36 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile10_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE10_ICON}" + clickable: false + - label: + id: t10_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE10_TITLE}" + styles: style_title + clickable: false + - label: + id: t10_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);' + then: + - script.execute: { id: publish_action, action: "tile10_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE10_TYPE}" + tap_action: "${TILE10_TAP_ACTION}" + service: "${TILE10_TAP_SERVICE}" + entity_id: "${TILE10_ENTITY}" + param_key: "${TILE10_TAP_PARAM_KEY}" + param_val: "${TILE10_TAP_PARAM_VAL}" + was_on: !lambda "return std::string(id(ha_state_tile10).state.c_str()) == \"on\";" + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);' + then: + - script.execute: { id: publish_action, action: "tile10_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE10_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE10_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE10_TYPE}") : t; + tap_action: "activate" + service: "${TILE10_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE10_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + + # ---------- VALUE SLIDER OVERLAY ---------- + - obj: + id: page_prev + align: BOTTOM_LEFT + x: 9 + y: -2 + width: 100 + height: 32 + pad_all: 0 + radius: 8 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 0 + scrollable: false + on_short_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept(millis(), 11);' + then: + - lambda: 'id(tile_page) = 0;' + - script.execute: show_tile_page + widgets: + - label: + align: CENTER + text: "< Vorige" + text_font: sublabel + text_color: 0xFFFFFF + clickable: false + - label: + id: page_number + align: BOTTOM_MID + y: -9 + text: "1 / 2" + text_font: sublabel + text_color: 0xFFFFFF + - obj: + id: page_next + align: BOTTOM_RIGHT + x: -9 + y: -2 + width: 100 + height: 32 + pad_all: 0 + radius: 8 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 0 + scrollable: false + on_short_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept(millis(), 12);' + then: + - lambda: 'id(tile_page) = 1;' + - script.execute: show_tile_page + widgets: + - label: + align: CENTER + text: "Volgende >" + text_font: sublabel + text_color: 0xFFFFFF + clickable: false + + - obj: + id: brightness_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_opa: TRANSP + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: brightness_overlay_bg + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: style_bg_grad + scrollable: false + clickable: true + on_click: + - lvgl.widget.hide: brightness_overlay + + - obj: + id: brightness_overlay_dim + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x000000 + bg_opa: 18% + border_width: 0 + clickable: false + scrollable: false + + - label: + id: overlay_title + align: TOP_MID + y: 10 + text: "Light" + styles: style_room + + - label: + id: overlay_value + align: TOP_MID + y: 35 + text: "100 %" + styles: style_value_big + + - slider: + id: overlay_slider + align: CENTER + y: 18 + width: ${OVERLAY_SLIDER_W} + height: ${OVERLAY_SLIDER_H} + min_value: 0 + max_value: 100 + value: 100 + adv_hittest: false + + bg_color: 0x939391 + bg_opa: 100% + radius: 28 + # Enige widget met ZOWEL radius ALS een kind (overlay_icon). + # LVGL snijdt kinderen dan bij op de ronding, en dat kan + # alleen via een ARGB-laag van 96x160 = 46KB aaneengesloten. + # Lukt die allocatie niet, dan blijft LVGL het proberen tot + # de task watchdog het bord herstart. Ronding blijft, het + # bijsnijden gaat eraf. + clip_corner: false + border_width: 0 + + indicator: + bg_color: 0xFEC600 + bg_opa: 100% + radius: 28 + + knob: + bg_opa: 0% + border_width: 0 + shadow_width: 0 + pad_all: 10 + + on_value: + - script.execute: + id: slider_preview + value: !lambda "return x;" + + on_release: + - script.execute: + id: slider_commit + value: !lambda "return lv_slider_get_value(id(overlay_slider));" + + + # Het icoon zat als KIND in de slider. Een widget met een radius + # en kinderen moet die kinderen bijsnijden op de ronding, en dat + # kan LVGL alleen via een ARGB-laag van tientallen KB's. Zonder + # PSRAM mislukt dat soms, waarna LVGL blijft hertesten tot de + # watchdog het bord herstart. Als broer eroverheen is er geen + # laag nodig en ziet het er identiek uit. + - label: + id: overlay_icon + align: CENTER + y: ${OVERLAY_ICON_Y} + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE1_ICON}" + clickable: false + + - obj: + id: overlay_color_button + align: BOTTOM_RIGHT + x: -13 + y: -13 + width: 44 + height: 44 + radius: 999 + hidden: true + clickable: true + bg_opa: TRANSP + border_width: 0 + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + scrollable: false + scrollbar_mode: 'OFF' + on_click: + - script.execute: open_color_detail + widgets: + - arc: + align: CENTER + width: 40 + height: 40 + start_angle: 0 + end_angle: 45 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 10 + arc_rounded: false + arc_color: 0xFF3B30 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 40 + height: 40 + start_angle: 45 + end_angle: 90 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 10 + arc_rounded: false + arc_color: 0xFF9500 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 40 + height: 40 + start_angle: 90 + end_angle: 135 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 10 + arc_rounded: false + arc_color: 0xFFD60A + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 40 + height: 40 + start_angle: 135 + end_angle: 180 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 10 + arc_rounded: false + arc_color: 0x30D158 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 40 + height: 40 + start_angle: 180 + end_angle: 225 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 10 + arc_rounded: false + arc_color: 0x32D6FF + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 40 + height: 40 + start_angle: 225 + end_angle: 270 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 10 + arc_rounded: false + arc_color: 0x0A84FF + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 40 + height: 40 + start_angle: 270 + end_angle: 315 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 10 + arc_rounded: false + arc_color: 0x5856D6 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 40 + height: 40 + start_angle: 315 + end_angle: 360 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 10 + arc_rounded: false + arc_color: 0xBF5AF2 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + + # ---------- LIGHT COLOR DETAIL OVERLAY ---------- + - obj: + id: color_detail_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x171719 + bg_opa: COVER + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: color_detail_bg + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: style_bg_grad + scrollable: false + clickable: false + - obj: + id: color_detail_scrim + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x000000 + bg_opa: 72% + border_width: 0 + clickable: false + scrollable: false + + - obj: + id: color_close_button + align: TOP_LEFT + x: 10 + y: 9 + width: 36 + height: 36 + radius: 999 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 20% + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: color_detail_overlay + widgets: + - label: + align: CENTER + text: "x" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - label: + id: color_detail_title + width: 210 + long_mode: DOT + text_align: CENTER + align: TOP_MID + y: 16 + text: "Light Color" + styles: style_room + + - obj: + id: color_done_button + align: TOP_RIGHT + x: -10 + y: 9 + width: 36 + height: 36 + radius: 999 + bg_color: 0xFF9F0A + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: color_detail_overlay + widgets: + - label: + align: CENTER + text: "\U000F012C" + text_font: materialdesign_icons + text_color: 0xFFFFFF + clickable: false + + # ---------- CLIMATE DETAIL OVERLAY ---------- + - obj: + id: climate_detail_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x171719 + bg_opa: COVER + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: climate_detail_bg + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: style_bg_grad + scrollable: false + clickable: false + - obj: + id: climate_detail_scrim + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x000000 + bg_opa: 62% + border_width: 0 + clickable: false + scrollable: false + + - obj: + id: climate_close_button + align: TOP_LEFT + x: 10 + y: 9 + # 44 i.p.v. 36: iets groter doelvlak, groeit weg van de arc + # (top-left anchor blijft staan) i.p.v. ernaartoe. + width: 44 + height: 44 + radius: 999 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 20% + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_detail_overlay + widgets: + - label: + align: CENTER + text: "x" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_done_button + align: TOP_RIGHT + x: -10 + y: 9 + width: 44 + height: 44 + radius: 999 + bg_color: 0xFEC600 + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_detail_overlay + widgets: + - label: + align: CENTER + text: "\U000F012C" + text_font: materialdesign_icons + text_color: 0xFFFFFF + clickable: false + + - label: + id: climate_detail_title + align: TOP_MID + y: 16 + text: "Climate" + styles: style_room + + - label: + id: climate_current_label + align: TOP_MID + y: 42 + text: "Current: --" + text_font: sublabel_big + text_color: 0xD1E8FF + text_opa: 85% + clickable: false + + - arc: + id: climate_arc + align: CENTER + y: 20 + width: 184 + height: 184 + # Zonder dit claimt de arc een onzichtbare rand extra + # klikgebied buiten de 184x184 box — vlakbij de kruisje- + # knop linksboven ontstaat daardoor overlap. + ext_click_area: 0 + start_angle: 135 + end_angle: 45 + min_value: 50 + max_value: 300 + value: 200 + adjustable: true + arc_width: 20 + arc_rounded: true + arc_color: 0x4B4B4F + arc_opa: 80% + bg_opa: TRANSP + border_width: 0 + indicator: + arc_color: 0xFEC600 + arc_opa: COVER + arc_width: 20 + arc_rounded: true + knob: + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 0 + shadow_width: 0 + on_value: + - script.execute: + id: climate_target_preview + value: !lambda "return x;" + on_release: + - script.execute: + id: climate_target_commit + value: !lambda "return lv_arc_get_value(id(climate_arc));" + + - label: + id: climate_set_label + align: CENTER + y: -16 + text: "SET TO" + text_font: sublabel_big + text_color: 0xFFFFFF + text_opa: 68% + clickable: false + + - label: + id: climate_target_label + align: CENTER + y: 20 + text: "20.0°" + text_font: headline + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_minus_button + align: BOTTOM_LEFT + x: 38 + y: -16 + width: 48 + height: 36 + radius: 18 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 16% + clickable: true + scrollable: false + on_click: + - script.execute: + id: climate_adjust_target + direction: -1 + widgets: + - label: + align: CENTER + text: "-" + text_font: headline + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_plus_button + align: BOTTOM_RIGHT + x: -38 + y: -16 + width: 48 + height: 36 + radius: 18 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 16% + clickable: true + scrollable: false + on_click: + - script.execute: + id: climate_adjust_target + direction: 1 + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_mode_pill + align: BOTTOM_MID + y: -18 + width: 126 + height: 34 + radius: 17 + bg_color: 0x2C2C30 + bg_opa: 82% + border_width: 0 + clickable: true + scrollable: false + on_click: + - script.execute: open_climate_mode_picker + widgets: + - label: + id: climate_mode_label + align: CENTER + text: "Auto" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + # ---------- CLIMATE MODE PICKER ---------- + # Vervangt het doorcyclen van modi. Cyclen stuurt bij elke tik een + # set_hvac_mode, dus om van 'cool' naar 'dry' te komen zet je een + # warmtepomp onderweg ook echt in 'heat' en 'fan_only'. Hier kies je + # rechtstreeks, en wordt er precies een opdracht verstuurd. + - obj: + id: climate_mode_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + bg_color: 0x000000 + bg_opa: 90% + border_width: 0 + clickable: true + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + align: TOP_MID + y: 16 + text: "Mode" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + # Flex zorgt dat niet-ondersteunde modi geen gat achterlaten: + # LVGL slaat verborgen kinderen over bij het schikken. + - obj: + id: climate_mode_list + align: TOP_MID + y: 50 + width: 98% + height: 178 + bg_opa: TRANSP + border_width: 0 + scrollable: false + pad_all: 3 + layout: + type: FLEX + flex_flow: ROW_WRAP + flex_align_main: CENTER + flex_align_cross: CENTER + flex_align_track: CENTER + widgets: + - obj: + id: cmode_off + width: 132 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "off" } + widgets: + - label: + align: CENTER + text: "Off" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_heat + width: 132 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "heat" } + widgets: + - label: + align: CENTER + text: "Heat" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_cool + width: 132 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "cool" } + widgets: + - label: + align: CENTER + text: "Cool" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_heat_cool + width: 132 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "heat_cool" } + widgets: + - label: + align: CENTER + text: "Heat/Cool" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_auto + width: 132 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "auto" } + widgets: + - label: + align: CENTER + text: "Auto" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_dry + width: 132 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "dry" } + widgets: + - label: + align: CENTER + text: "Dry" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_fan_only + width: 132 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "fan_only" } + widgets: + - label: + align: CENTER + text: "Fan" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_mode_overlay_x + align: TOP_LEFT + x: 10 + y: 9 + width: 36 + height: 36 + radius: 999 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 20% + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_mode_overlay + widgets: + - label: + align: CENTER + text: "x" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_mode_overlay_ok + align: TOP_RIGHT + x: -10 + y: 9 + width: 36 + height: 36 + radius: 999 + bg_color: 0xFEC600 + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_mode_overlay + widgets: + - label: + align: CENTER + text: "\U000F012C" + text_font: materialdesign_icons + text_color: 0xFFFFFF + clickable: false + + # ---------- VACUUM CONTROL ---------- + # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar + # het dock zijn losse opdrachten, en de zuigkracht is een aparte as. + - obj: + id: vacuum_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + bg_color: 0x000000 + bg_opa: 90% + border_width: 0 + clickable: true + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: vacuum_title + align: TOP_MID + y: 16 + text: "Vacuum" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: vacuum_actions + align: TOP_MID + y: 52 + width: 98% + height: 80 + bg_opa: TRANSP + border_width: 0 + scrollable: false + pad_all: 3 + layout: + type: FLEX + flex_flow: ROW_WRAP + flex_align_main: CENTER + flex_align_cross: CENTER + flex_align_track: CENTER + widgets: + - obj: + id: vac_start + width: 72 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: vacuum_action, service: "vacuum.start" } + widgets: + - label: + align: CENTER + text: "Start" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vac_pause + width: 72 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: vacuum_action, service: "vacuum.pause" } + widgets: + - label: + align: CENTER + text: "Pause" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vac_return_to_base + width: 72 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: vacuum_action, service: "vacuum.return_to_base" } + widgets: + - label: + align: CENTER + text: "Dock" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vac_stop + width: 72 + height: 38 + radius: 19 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: vacuum_action, service: "vacuum.stop" } + widgets: + - label: + align: CENTER + text: "Stop" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - label: + align: TOP_MID + y: 138 + text: "Suction" + text_font: sublabel + text_color: 0xFFFFFF + clickable: false + + - obj: + id: vacuum_speeds + align: TOP_MID + y: 156 + width: 98% + height: 74 + bg_opa: TRANSP + border_width: 0 + scrollable: false + pad_all: 3 + layout: + type: FLEX + flex_flow: ROW_WRAP + flex_align_main: CENTER + flex_align_cross: CENTER + flex_align_track: CENTER + widgets: + - obj: + id: vacspd_quiet + width: 72 + height: 32 + radius: 19 + bg_color: 0x1F1F23 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(0) : "quiet";' + widgets: + - label: + align: CENTER + text: "Quiet" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vacspd_balanced + width: 72 + height: 32 + radius: 19 + bg_color: 0x1F1F23 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(1) : "balanced";' + widgets: + - label: + align: CENTER + text: "Balanced" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vacspd_turbo + width: 72 + height: 32 + radius: 19 + bg_color: 0x1F1F23 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(2) : "turbo";' + widgets: + - label: + align: CENTER + text: "Turbo" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vacspd_max + width: 72 + height: 32 + radius: 19 + bg_color: 0x1F1F23 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(3) : "max";' + widgets: + - label: + align: CENTER + text: "Max" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: vacuum_overlay_x + align: TOP_LEFT + x: 10 + y: 9 + width: 36 + height: 36 + radius: 999 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 20% + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: vacuum_overlay + widgets: + - label: + align: CENTER + text: "x" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: vacuum_overlay_ok + align: TOP_RIGHT + x: -10 + y: 9 + width: 36 + height: 36 + radius: 999 + bg_color: 0xFEC600 + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: vacuum_overlay + widgets: + - label: + align: CENTER + text: "\U000F012C" + text_font: materialdesign_icons + text_color: 0xFFFFFF + clickable: false + + # ---------- DIM / WAKE OVERLAY ---------- + - obj: + id: dim_wake_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + bg_opa: TRANSP + border_width: 0 + scrollable: false + scrollbar_mode: 'OFF' + clickable: true + on_click: + - script.execute: wake_display + + - id: touch_cal_page + bg_color: 0x101820 + bg_opa: COVER + pad_all: 0 + border_width: 0 + scrollable: false + widgets: + - label: + align: TOP_MID + y: 65 + text: "Volg de kalibratiewizard" + text_font: sublabel + text_color: 0xFFFFFF + - obj: + x: 10 + y: 10 + width: 20 + height: 20 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 289 + y: 10 + width: 20 + height: 20 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 289 + y: 209 + width: 20 + height: 20 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 10 + y: 209 + width: 20 + height: 20 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 150 + y: 110 + width: 20 + height: 20 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + +script: + # Render every overlay and both pages repeatedly without controlling devices. + - id: ui_self_test + mode: single + then: + - logger.log: "UI_TEST START: page/overlay render stress, no HA actions" + - lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");' + - lambda: 'id(calibration_active) = false;' + - lvgl.page.show: home_page + - script.execute: wake_display + - repeat: + count: 10 + then: + - lambda: |- + id(last_touch_ms) = millis(); + id(tile_page) = 1; + - script.execute: show_tile_page + - delay: 150ms + - lambda: |- + lv_obj_update_layout(id(tile_scroll)); + const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); + const bool paginated = count > 6; + lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), + id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; + bool ok = true; + for (int i = 0; i < 10; ++i) { + bool visible = i < count && (paginated ? i >= 6 : i < 6); + ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible); + } + for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) + ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != paginated); + lv_area_t nav, grid; + lv_obj_get_coords(id(page_next), &nav); + lv_obj_get_coords(id(tile_scroll), &grid); + ok = ok && (!paginated || grid.y2 < nav.y1); + ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1); + id(tile_page) = 0; + - script.execute: show_tile_page + - delay: 150ms + - lvgl.widget.show: brightness_overlay + - delay: 150ms + - lvgl.widget.hide: brightness_overlay + - lvgl.widget.show: color_detail_overlay + - delay: 150ms + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.show: climate_detail_overlay + - delay: 150ms + - lvgl.widget.hide: climate_detail_overlay + - lvgl.widget.show: climate_mode_overlay + - delay: 150ms + - lvgl.widget.hide: climate_mode_overlay + - lvgl.widget.show: vacuum_overlay + - delay: 150ms + - lvgl.widget.hide: vacuum_overlay + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + - logger.log: "UI_TEST COMPLETE" + + - id: show_tile_page + then: + - lambda: |- + lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), + id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; + const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; + const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); + const bool paginated = count > 6; + if (!paginated) id(tile_page) = 0; + for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { + if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN); + } + for (int i = 0; i < 10; ++i) { + bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6); + if (visible) { + lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]); + lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); + } + } + lv_label_set_text(id(page_number), id(tile_page) == 0 ? "1 / 2" : "2 / 2"); + if (id(tile_page) == 0) { + lv_obj_add_state(id(page_prev), LV_STATE_DISABLED); + lv_obj_clear_state(id(page_next), LV_STATE_DISABLED); + } else { + lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED); + lv_obj_add_state(id(page_next), LV_STATE_DISABLED); + } + + - id: wake_display + mode: restart + then: + - lambda: |- + id(last_touch_ms) = millis(); + id(display_dimmed) = false; + - light.turn_on: + id: back_light + # Bijna instant omhoog. De ESPHome-standaard is 1s, en dat voelt traag + # als je het scherm aanraakt en op licht staat te wachten. + transition_length: 80ms + brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;" + - lvgl.widget.hide: dim_wake_overlay + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.hide: climate_detail_overlay + - lvgl.widget.hide: climate_mode_overlay + - lvgl.widget.hide: vacuum_overlay + + - id: dim_display + mode: restart + then: + - if: + condition: + lambda: 'return std::string("${AUTO_DIM}") == "true";' + then: + - lambda: |- + int dim_value = atoi("${DIM_BRIGHTNESS}"); + int max_value = atoi("${MAX_BRIGHTNESS}"); + + if (std::string("${NIGHT_MODE}") == "true") { + auto now = id(homeassistant_time).now(); + // Vóór de eerste tijd-sync (zie time: hierboven) is now() + // epoch 0 — dan geen nachtmodus-beslissing nemen op basis + // van een ongeldig uur, gewoon normale DIM_BRIGHTNESS. + if (now.is_valid()) { + int hour = now.hour; + int start_hour = atoi("${NIGHT_START_HOUR}"); + int end_hour = atoi("${NIGHT_END_HOUR}"); + bool is_night = false; + if (start_hour < end_hour) { + is_night = (hour >= start_hour && hour < end_hour); + } else { + is_night = (hour >= start_hour || hour < end_hour); + } + if (is_night) { + dim_value = atoi("${NIGHT_DIM_BRIGHTNESS}"); + } + } + } + + // Rustig omlaag faden is prettig; omhoog moet juist snel. + id(back_light).turn_on().set_transition_length(900).set_brightness(dim_value / 100.0f).perform(); + + // Always mark as dimmed to stop the interval from re-firing + id(display_dimmed) = true; + // Only show the touch-blocking overlay when brightness actually changes + if (dim_value < max_value) { + lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); + } + + # Terug naar het basisscherm bij standby. Bewust via de ESPHome + # acties en NIET met lv_obj_add_flag in een lambda: dit script + # draait vanuit een interval, dus buiten LVGL's eigen afhandeling. + - lvgl.widget.hide: brightness_overlay + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.hide: climate_detail_overlay + - lvgl.widget.hide: climate_mode_overlay + - lvgl.widget.hide: vacuum_overlay + + - id: publish_action + mode: restart + parameters: + action: string + then: + - logger.log: + level: INFO + format: "publish_action: %s" + args: ["action.c_str()"] + - text_sensor.template.publish: + id: smartdisplay_action + state: !lambda 'return action;' + - delay: 500ms + - text_sensor.template.publish: + id: smartdisplay_action + state: "" + + - id: climate_target_preview + parameters: + value: int + then: + - lambda: |- + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + int min_x10 = id(active_climate_min_x10); + int max_x10 = id(active_climate_max_x10); + value = cyd::quantize_temperature(value, min_x10, max_x10, + id(active_climate_step_x10)); + id(active_climate_target_x10) = value; + + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f°", value / 10.0f); + lv_label_set_text(id(climate_target_label), buf); + lv_arc_set_value(id(climate_arc), value); + + - id: climate_target_commit + parameters: + value: int + then: + - script.execute: + id: climate_target_preview + value: !lambda "return value;" + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' + then: + - homeassistant.action: + action: climate.set_temperature + data: + entity_id: !lambda "return id(active_entity);" + temperature: !lambda "return id(active_climate_target_x10) / 10.0f;" + + - id: climate_adjust_target + parameters: + direction: int + then: + - lambda: |- + int step_x10 = id(active_climate_step_x10); + if (step_x10 < 5) step_x10 = 5; + int next = id(active_climate_target_x10) + (direction * step_x10); + if (next < id(active_climate_min_x10)) next = id(active_climate_min_x10); + if (next > id(active_climate_max_x10)) next = id(active_climate_max_x10); + id(active_climate_target_x10) = next; + - script.execute: + id: climate_target_commit + value: !lambda "return id(active_climate_target_x10);" + + # Opent de moduskiezer. Verbergt modi die de entiteit niet ondersteunt en + # markeert de huidige, zodat je ziet waar je staat voor je iets kiest. + # Opent de stofzuigerbediening en onthoudt om welke entiteit het gaat. + - id: open_vacuum_overlay + mode: restart + parameters: + title: string + entity: string + then: + - lambda: |- + id(active_entity) = entity; + lv_label_set_text(id(vacuum_title), title.c_str()); + - lvgl.widget.show: vacuum_overlay + + # Een losse opdracht: start, pause, stop of return_to_base. + - id: vacuum_action + mode: queued + parameters: + service: string + then: + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' + then: + - homeassistant.action: + action: !lambda "return service;" + data: + entity_id: !lambda "return id(active_entity);" + + # Zuigkracht is een aparte as, los van start/stop. + - id: vacuum_fan_speed + mode: queued + parameters: + speed: string + then: + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' + then: + - homeassistant.action: + action: vacuum.set_fan_speed + data: + entity_id: !lambda "return id(active_entity);" + fan_speed: !lambda "return speed;" + + - id: open_climate_mode_picker + mode: restart + then: + - lambda: |- + auto lower = [](std::string v) { + for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); } + return v; + }; + std::string supported = lower(id(active_climate_supported_modes)); + std::string current = lower(id(active_climate_mode)); + + auto has = [&](const char* m) { + std::string mode(m); + if (supported.empty() || supported == "unknown" || supported == "unavailable") return false; + return supported.find("'" + mode + "'") != std::string::npos || + supported.find("\"" + mode + "\"") != std::string::npos; + }; + auto apply = [&](lv_obj_t* btn, const char* m) { + if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); + bool selected = (current == std::string(m)); + lv_obj_set_style_bg_color(btn, + lv_color_hex(selected ? 0xFF9F0A : 0x2C2C30), LV_PART_MAIN); + }; + apply(id(cmode_off), "off"); + apply(id(cmode_heat), "heat"); + apply(id(cmode_cool), "cool"); + apply(id(cmode_heat_cool), "heat_cool"); + apply(id(cmode_auto), "auto"); + apply(id(cmode_dry), "dry"); + apply(id(cmode_fan_only), "fan_only"); + - lvgl.widget.show: climate_mode_overlay + + # Zet precies een modus. Geen tussenstappen, dus geen ongewenste + # commando's naar de warmtepomp onderweg. + - id: set_climate_mode + mode: restart + parameters: + mode: string + then: + - lambda: |- + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + id(active_climate_mode) = mode; + if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); + if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); + if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); + if (mode == "heat_cool") lv_label_set_text(id(climate_mode_label), "Heat/Cool"); + if (mode == "auto") lv_label_set_text(id(climate_mode_label), "Auto"); + if (mode == "dry") lv_label_set_text(id(climate_mode_label), "Dry"); + if (mode == "fan_only") lv_label_set_text(id(climate_mode_label), "Fan"); + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' + then: + - homeassistant.action: + action: climate.set_hvac_mode + data: + entity_id: !lambda "return id(active_entity);" + hvac_mode: !lambda "return id(active_climate_mode);" + + # Isolated 40-second preview; every light commit is suppressed in the helper. + - id: light_controls_preview + mode: single + then: + - script.execute: wake_display + - lambda: |- + light_controls::demo = true; + light_controls::fallback.minimum = 2000; + light_controls::fallback.maximum = 6500; + light_controls::fallback.kelvin = 3000; + light_controls::fallback.hue = 200; + light_controls::open("diagnostic", true, true, 75); + lv_label_set_text(id(color_detail_title), "Voorbeeld lamp"); + - lvgl.widget.hide: color_close_button + - lvgl.widget.hide: color_done_button + - lvgl.widget.show: color_detail_overlay + - delay: 40s + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.show: color_close_button + - lvgl.widget.show: color_done_button + - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' + + - id: open_color_detail + mode: restart + then: + - lambda: |- + lv_label_set_text(id(color_detail_title), id(active_title).c_str()); + light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); + - lvgl.widget.hide: brightness_overlay + - lvgl.widget.show: color_detail_overlay + + - id: color_brightness_commit + parameters: + value: int + then: + - lambda: 'id(active_brightness_pct) = value;' + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_entity).rfind("light.", 0) == 0;' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + brightness_pct: !lambda "return value;" + + - id: set_light_hue + parameters: + value: int + then: + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + data_template: + hs_color: "{{ [hue | int, 100] }}" + variables: + hue: !lambda "return light_controls::clamp(value, 0, 360);" + + - id: set_light_color_temperature + parameters: + kelvin: int + then: + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" + + - id: slider_preview + parameters: + value: int + then: + - lambda: |- + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str()); + lv_label_set_text(id(overlay_value), buf); + int pct = value; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + std::string value_text; + + if (id(active_control_kind) == "cover_position") { + std::string closed_label = id(active_label_off).c_str(); + std::string open_label = id(active_label_on).c_str(); + + if (pct <= 0) { + value_text = closed_label; + } else if (pct >= 100) { + value_text = open_label; + } else { + char buf[40]; + snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct); + value_text = buf; + } + } else { + if (pct <= 0) { + value_text = id(active_label_off).c_str(); + } else { + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); + value_text = buf; + } + } + + lv_label_set_text(id(overlay_value), value_text.c_str()); + + + - id: slider_commit + parameters: + value: int + then: + - if: + condition: + lambda: |- + return id(active_control_kind) == "light_brightness"; + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + brightness_pct: !lambda "return value;" + else: + - if: + condition: + lambda: |- + return id(active_control_kind) == "fan_percentage"; + then: + - if: + condition: + lambda: "return value <= 0;" + then: + - homeassistant.action: + action: fan.turn_off + data: + entity_id: !lambda "return id(active_entity);" + else: + - homeassistant.action: + action: fan.turn_on + data: + entity_id: !lambda "return id(active_entity);" + - delay: 150ms + - homeassistant.action: + action: fan.set_percentage + data: + entity_id: !lambda "return id(active_entity);" + percentage: !lambda "return value;" + else: + - if: + condition: + lambda: |- + return id(active_control_kind) == "cover_position"; + then: + - homeassistant.action: + action: cover.set_cover_position + data: + entity_id: !lambda "return id(active_entity);" + position: !lambda "return value;" + + # ---------------------------------------------------------- + # do_tile_action + # + # Centralized handler for executing Home Assistant actions + # triggered by tile taps. + # + # Parameters + # - tile_type: light | fan | switch | scene | script | cover + # - tap_action: auto | toggle | activate | fan_toggle_preset + # - service: optional Home Assistant service override + # - entity_id: target entity + # - param_key / param_val: optional parameters for custom services + # - was_on: previous entity state (pass false for longpress actions) + # + # Behavior + # + # toggle + # -> toggles the entity + # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on) + # + # activate + # -> activates entity + # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover + # + # auto + # -> resolves automatically based on tile_type: + # light/switch/fan/cover -> toggle + # scene/script -> activate + # + # fan_toggle_preset + # -> if fan was ON: fan.turn_off + # -> if fan was OFF: + # fan.turn_on + # wait 150ms + # fan.set_preset_mode(param_val) + # + # Custom service + # If "service" is set, it will be called directly. + # param_key / param_val may be used for parameters + # like preset_mode, brightness_pct, percentage, etc. + # ---------------------------------------------------------- + - id: do_tile_action + mode: queued + parameters: + tile_type: string + tap_action: string + service: string + entity_id: string + param_key: string + param_val: string + was_on: bool + then: + - if: + condition: + lambda: |- + if (std::string("${DIRECT_ACTIONS}") != "true") return false; + // Debounce: zelfde entity binnen 600ms genegeerd. Voorkomt dat + // touch-ruis dezelfde HA-actie meerdere keren afvuurt (zie + // globals hierboven voor waarom dit nodig is). + uint32_t now = millis(); + if (entity_id == id(last_action_entity) && (now - id(last_action_ms)) < 600) { + ESP_LOGD("tile", "do_tile_action: debounced repeat for %s (%ums na vorige)", + entity_id.c_str(), now - id(last_action_ms)); + return false; + } + id(last_action_entity) = entity_id; + id(last_action_ms) = now; + return true; + then: + - lambda: |- + ESP_LOGD("tile", "do_tile_action type=%s action=%s service=%s entity=%s key=%s val=%s was_on=%d", + tile_type.c_str(), tap_action.c_str(), service.c_str(), entity_id.c_str(), + param_key.c_str(), param_val.c_str(), (int)was_on); + + - if: + condition: + lambda: |- + return tap_action == "fan_toggle_preset"; + then: + - if: + condition: + lambda: |- + return was_on; + then: + - homeassistant.action: + action: fan.turn_off + data: + entity_id: !lambda 'return entity_id;' + else: + - homeassistant.action: + action: fan.turn_on + data: + entity_id: !lambda 'return entity_id;' + - delay: 150ms + - homeassistant.action: + action: fan.set_preset_mode + data: + entity_id: !lambda 'return entity_id;' + preset_mode: !lambda 'return param_val;' + + else: + - if: + condition: + lambda: |- + std::string resolved = tap_action; + if (resolved == "auto") { + if (tile_type == "scene" || tile_type == "script") resolved = "activate"; + else resolved = "toggle"; + } + return resolved == "toggle"; + then: + - if: + condition: + lambda: |- + return service.size() > 0; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "light"; + then: + - homeassistant.action: + action: light.toggle + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "fan"; + then: + - homeassistant.action: + action: fan.toggle + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "switch"; + then: + - homeassistant.action: + action: switch.toggle + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "cover"; + then: + - homeassistant.action: + action: cover.set_cover_position + data: + entity_id: !lambda 'return entity_id;' + position: !lambda |- + return was_on ? 0 : 100; + + else: + - if: + condition: + lambda: |- + std::string resolved = tap_action; + if (resolved == "auto") { + if (tile_type == "scene" || tile_type == "script") resolved = "activate"; + else resolved = "toggle"; + } + return resolved == "activate"; + then: + - if: + condition: + lambda: |- + return service.size() > 0; + then: + - if: + condition: + lambda: |- + return param_key == "preset_mode"; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + preset_mode: !lambda 'return param_val;' + else: + - if: + condition: + lambda: |- + return param_key == "brightness_pct"; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + brightness_pct: !lambda 'return atoi(param_val.c_str());' + else: + - if: + condition: + lambda: |- + return param_key == "percentage"; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + percentage: !lambda 'return atoi(param_val.c_str());' + else: + - if: + condition: + lambda: |- + return param_key == "option"; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + option: !lambda 'return param_val;' + else: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "scene"; + then: + - homeassistant.action: + action: scene.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "script"; + then: + - homeassistant.action: + action: script.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "light"; + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "fan"; + then: + - homeassistant.action: + action: fan.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "switch"; + then: + - homeassistant.action: + action: switch.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "cover"; + then: + - homeassistant.action: + action: cover.open_cover + data: + entity_id: !lambda 'return entity_id;' + + - id: open_value_overlay + parameters: + title: string + entity: string + tile_type: string + longpress_mode: string + was_on: bool + off_value: int + brightness_value: float + percentage_value: float + position_value: float + icon_text: string + slider_bg_color: uint32_t + slider_fill_color: uint32_t + slider_icon_color: uint32_t + label_off: string + label_on: string + supported_color_modes: string + climate_current_temperature: float + climate_target_temperature: float + climate_humidity: float + climate_mode: string + climate_supported_modes: string + climate_min_temp: string + climate_max_temp: string + climate_step: string + then: + - lambda: |- + // Accentkleur uit het Home Assistant-domein: het stuk voor de punt in + // het entity_id. script/scene/button vuur je af (ACCENT_ACTION), een + // light is een light (ACCENT_LIGHT), de rest is apparaat. + // LET OP: deze helper staat twee keer in dit bestand — hier en in + // open_value_overlay. Pas ze samen aan. + auto accent_for = [](const std::string& entity_id) -> uint32_t { + std::string d = entity_id.substr(0, entity_id.find('.')); + if (d == "script" || d == "scene" || d == "button" || + d == "input_button" || d == "automation") return ${ACCENT_ACTION}; + if (d == "light") return ${ACCENT_LIGHT}; + return ${ACCENT_DEVICE}; + }; + + // De sliderkleur komt van de tegel, dus die kan ook ${ACCENT_AUTO} zijn. + uint32_t fill_color = (slider_fill_color == ${ACCENT_AUTO}) + ? accent_for(entity) : slider_fill_color; + + std::string mode = longpress_mode; + auto contains_mode = [](const std::string& modes, const std::string& needle) { + return modes.find(needle) != std::string::npos; + }; + if (mode == "auto") { + if (tile_type == "light") mode = "brightness"; + else if (tile_type == "fan") mode = "percentage"; + else if (tile_type == "cover") mode = "cover_position"; + else if (tile_type == "climate") mode = "climate_temperature"; + else mode = "none"; + } + + if (mode == "none") return; + + id(active_entity) = entity; + id(active_value_suffix) = " %"; + id(active_label_off) = label_off; + id(active_label_on) = label_on; + id(active_title) = title; + id(active_icon_text) = icon_text; + id(active_supports_color) = false; + id(active_supports_temperature) = false; + lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); + + if (mode == "climate_temperature") { + auto to_x10 = [](float value, int fallback) -> int { + if (isnan(value)) return fallback; + return (int) lround(value * 10.0f); + }; + auto parse_x10 = [](const std::string& value, int fallback) -> int { + if (value.empty()) return fallback; + return (int) lround(atof(value.c_str()) * 10.0f); + }; + id(climate_edit_pending) = false; + id(active_control_kind) = "climate_temperature"; + id(active_climate_min_x10) = parse_x10(climate_min_temp, 50); + id(active_climate_max_x10) = parse_x10(climate_max_temp, 300); + id(active_climate_step_x10) = parse_x10(climate_step, 5); + if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5; + if (id(active_climate_max_x10) <= id(active_climate_min_x10)) { + id(active_climate_min_x10) = 50; + id(active_climate_max_x10) = 300; + } + + id(active_climate_current_x10) = to_x10(climate_current_temperature, 0); + int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10); + id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target); + if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); + if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); + id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10); + if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); + if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); + id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity); + id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode; + id(active_climate_supported_modes) = climate_supported_modes; + + lv_label_set_text(id(climate_detail_title), title.c_str()); + + char current_buf[48]; + if (isnan(climate_current_temperature)) { + snprintf(current_buf, sizeof(current_buf), "Current: --"); + } else if (id(active_climate_humidity) >= 0) { + snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", climate_current_temperature, id(active_climate_humidity)); + } else { + snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", climate_current_temperature); + } + lv_label_set_text(id(climate_current_label), current_buf); + + char target_buf[16]; + snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); + lv_label_set_text(id(climate_target_label), target_buf); + lv_label_set_text(id(climate_mode_label), id(active_climate_mode).c_str()); + + lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10)); + lv_arc_set_value(id(climate_arc), id(active_climate_target_x10)); + + if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) { + lv_obj_set_size(id(climate_arc), 196, 196); + lv_obj_set_y(id(climate_arc), 8); + lv_obj_set_y(id(climate_current_label), 48); + lv_obj_set_y(id(climate_minus_button), -62); + lv_obj_set_y(id(climate_plus_button), -62); + lv_obj_set_y(id(climate_mode_pill), -18); + } else { + lv_obj_set_size(id(climate_arc), 176, 176); + lv_obj_set_y(id(climate_arc), 18); + lv_obj_set_y(id(climate_current_label), 40); + lv_obj_set_y(id(climate_minus_button), -14); + lv_obj_set_y(id(climate_plus_button), -14); + lv_obj_set_y(id(climate_mode_pill), -15); + } + + lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); + return; + } + + int pct = off_value; + + if (mode == "brightness") { + id(active_control_kind) = "light_brightness"; + if (was_on) { + if (isnan(brightness_value)) pct = 100; + else pct = (int) lround((brightness_value / 255.0f) * 100.0f); + } + } else if (mode == "percentage") { + id(active_control_kind) = "fan_percentage"; + if (was_on) { + if (isnan(percentage_value)) pct = 0; + else pct = (int) lround(percentage_value); + } + } else if (mode == "cover_position") { + id(active_control_kind) = "cover_position"; + if (isnan(position_value)) pct = was_on ? 100 : 0; + else pct = (int) lround(position_value); + } else { + return; + } + + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + id(active_brightness_pct) = pct; + + if (tile_type == "light" && std::string("${DIRECT_ACTIONS}") == "true") { + id(active_supports_color) = + contains_mode(supported_color_modes, "hs") || + contains_mode(supported_color_modes, "rgb") || + contains_mode(supported_color_modes, "rgbw") || + contains_mode(supported_color_modes, "rgbww") || + contains_mode(supported_color_modes, "xy"); + id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp"); + + if (id(active_supports_color) || id(active_supports_temperature)) { + lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); + } + } + + if (tile_type == "cover") { + lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(fill_color), LV_PART_MAIN); + lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(slider_bg_color), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); + } + else{ + lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(slider_bg_color), LV_PART_MAIN); + lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(fill_color), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); + } + lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN); + lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR); + + lv_label_set_text(id(overlay_title), title.c_str()); + lv_label_set_text(id(overlay_icon), icon_text.c_str()); + lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF); + + std::string value_text; + + if (tile_type == "cover") { + if (pct <= 0) { + value_text = label_off; + } else if (pct >= 100) { + value_text = label_on; + } else { + char buf[40]; + snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct); + value_text = buf; + } + } else { + if (pct <= 0) { + value_text = label_off; + } else { + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); + value_text = buf; + } + } + + lv_label_set_text(id(overlay_value), value_text.c_str()); + + lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR); + + lv_obj_set_style_text_color(id(overlay_icon), lv_color_hex(slider_icon_color), 0); + + - if: + condition: + lambda: 'return id(active_control_kind) != "climate_temperature";' + then: + - if: + condition: + lambda: 'return id(active_supports_color) || id(active_supports_temperature);' + then: + - script.execute: open_color_detail + else: + - lvgl.widget.show: brightness_overlay + + # UI refresh (values + time + styles) + render + - id: ui_refresh + mode: single + then: + - delay: 100ms + - lambda: |- + if (runtime_tiles::enabled) { + runtime_tiles::render(id(lbl_room)); + auto now = id(homeassistant_time).now(); + if (now.is_valid()) runtime_tiles::label(id(lbl_time), now.strftime("%H:%M")); + return; + } + auto set_lbl = [](lv_obj_t* lbl, const std::string& s) { + if (lbl && s != lv_label_get_text(lbl)) lv_label_set_text(lbl, s.c_str()); + }; + + auto set_value_auto = [&](lv_obj_t* lbl, + bool on, + const std::string& tile_type, + const std::string& value_mode, + float brightness, + float percentage, + const std::string& preset, + float climate_current_temperature, + float climate_target_temperature, + float climate_humidity, + const std::string& label_on, + const std::string& label_off) { + std::string mode = value_mode; + + if (mode == "auto" || mode.empty()) { + if (tile_type == "light") { + mode = "brightness"; + } else if (tile_type == "fan") { + mode = preset.empty() ? "percentage" : "preset"; + } else if (tile_type == "climate") { + mode = "climate"; + } else { + mode = "text"; + } + } + + if (mode == "brightness") { + if (!on) { + set_lbl(lbl, label_off); + return; + } + if (isnan(brightness)) { + set_lbl(lbl, "100 %"); + return; + } + int pct = (int) lround((brightness / 255.0f) * 100.0f); + char buf[8]; + snprintf(buf, sizeof(buf), "%d %%", pct); + set_lbl(lbl, std::string(buf)); + return; + } + + if (mode == "percentage") { + if (!on) { + set_lbl(lbl, label_off); + return; + } + int pct = isnan(percentage) ? 0 : (int) lround(percentage); + char buf[8]; + snprintf(buf, sizeof(buf), "%d %%", pct); + set_lbl(lbl, std::string(buf)); + return; + } + + if (mode == "preset") { + if (!on) { + set_lbl(lbl, label_off); + return; + } + if (!preset.empty()) { + set_lbl(lbl, preset); + } else { + set_lbl(lbl, label_on); + } + return; + } + + if (mode == "climate") { + if (!on) { + set_lbl(lbl, label_off); + return; + } + + float temp = isnan(climate_target_temperature) ? climate_current_temperature : climate_target_temperature; + if (isnan(temp)) { + set_lbl(lbl, label_on); + return; + } + + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f °", temp); + set_lbl(lbl, std::string(buf)); + return; + } + + if (mode == "text") { + if (tile_type == "scene" || tile_type == "script") { + set_lbl(lbl, label_on); + } else { + set_lbl(lbl, on ? label_on : label_off); + } + return; + } + + set_lbl(lbl, "—"); + }; + + // active_state niet leeg? Dan telt alleen die exacte HA-state als "aan". + // Zo kan een input_select ("Vol"/"Leeg") ook een tegel laten oplichten. + auto is_active = [](const std::string& tile_type, const std::string& state, + const std::string& active_state = std::string()) { + if (!active_state.empty()) return state == active_state; + if (tile_type == "climate") { + return !state.empty() && state != "off" && state != "unavailable" && state != "unknown"; + } + if (tile_type == "vacuum") return state == "cleaning" || state == "returning"; + if (tile_type == "scene" || tile_type == "script") return true; + return state == "on"; + }; + + // Cover-aware active state: "closed" = off, anything else (open/opening/closing) = on + bool t1_on = (std::string("${TILE1_TYPE}") == "cover") ? (id(tile1_cover_state).state != "closed") : is_active("${TILE1_TYPE}", std::string(id(ha_state_tile1).state.c_str()), "${TILE1_ACTIVE_STATE}"); + bool t2_on = (std::string("${TILE2_TYPE}") == "cover") ? (id(tile2_cover_state).state != "closed") : is_active("${TILE2_TYPE}", std::string(id(ha_state_tile2).state.c_str()), "${TILE2_ACTIVE_STATE}"); + bool t3_on = (std::string("${TILE3_TYPE}") == "cover") ? (id(tile3_cover_state).state != "closed") : is_active("${TILE3_TYPE}", std::string(id(ha_state_tile3).state.c_str()), "${TILE3_ACTIVE_STATE}"); + bool t4_on = (std::string("${TILE4_TYPE}") == "cover") ? (id(tile4_cover_state).state != "closed") : is_active("${TILE4_TYPE}", std::string(id(ha_state_tile4).state.c_str()), "${TILE4_ACTIVE_STATE}"); + bool t5_on = (std::string("${TILE5_TYPE}") == "cover") ? (id(tile5_cover_state).state != "closed") : is_active("${TILE5_TYPE}", std::string(id(ha_state_tile5).state.c_str()), "${TILE5_ACTIVE_STATE}"); + bool t6_on = (std::string("${TILE6_TYPE}") == "cover") ? (id(tile6_cover_state).state != "closed") : is_active("${TILE6_TYPE}", std::string(id(ha_state_tile6).state.c_str()), "${TILE6_ACTIVE_STATE}"); + // Tegel 7/8 hebben geen cover_state-sensor, dus geen cover-tak nodig. + bool t7_on = is_active("${TILE7_TYPE}", std::string(id(ha_state_tile7).state.c_str()), "${TILE7_ACTIVE_STATE}"); + bool t8_on = is_active("${TILE8_TYPE}", std::string(id(ha_state_tile8).state.c_str()), "${TILE8_ACTIVE_STATE}"); + bool t9_on = is_active("${TILE9_TYPE}", std::string(id(ha_state_tile9).state.c_str()), "${TILE9_ACTIVE_STATE}"); + bool t10_on = is_active("${TILE10_TYPE}", std::string(id(ha_state_tile10).state.c_str()), "${TILE10_ACTIVE_STATE}"); + + set_value_auto(id(t1_value), t1_on, "${TILE1_TYPE}", "${TILE1_VALUE_MODE}", + id(tile1_brightness).state, id(tile1_percentage).state, std::string(id(tile1_preset).state.c_str()), + id(tile1_climate_current_temperature).state, id(tile1_climate_target_temperature).state, id(tile1_climate_humidity).state, + "${TILE1_LABEL_ON}", "${TILE1_LABEL_OFF}"); + + set_value_auto(id(t2_value), t2_on, "${TILE2_TYPE}", "${TILE2_VALUE_MODE}", + id(tile2_brightness).state, id(tile2_percentage).state, std::string(id(tile2_preset).state.c_str()), + id(tile2_climate_current_temperature).state, id(tile2_climate_target_temperature).state, id(tile2_climate_humidity).state, + "${TILE2_LABEL_ON}", "${TILE2_LABEL_OFF}"); + + set_value_auto(id(t3_value), t3_on, "${TILE3_TYPE}", "${TILE3_VALUE_MODE}", + id(tile3_brightness).state, id(tile3_percentage).state, std::string(id(tile3_preset).state.c_str()), + id(tile3_climate_current_temperature).state, id(tile3_climate_target_temperature).state, id(tile3_climate_humidity).state, + "${TILE3_LABEL_ON}", "${TILE3_LABEL_OFF}"); + + set_value_auto(id(t4_value), t4_on, "${TILE4_TYPE}", "${TILE4_VALUE_MODE}", + id(tile4_brightness).state, id(tile4_percentage).state, std::string(id(tile4_preset).state.c_str()), + id(tile4_climate_current_temperature).state, id(tile4_climate_target_temperature).state, id(tile4_climate_humidity).state, + "${TILE4_LABEL_ON}", "${TILE4_LABEL_OFF}"); + + set_value_auto(id(t5_value), t5_on, "${TILE5_TYPE}", "${TILE5_VALUE_MODE}", + id(tile5_brightness).state, id(tile5_percentage).state, std::string(id(tile5_preset).state.c_str()), + id(tile5_climate_current_temperature).state, id(tile5_climate_target_temperature).state, id(tile5_climate_humidity).state, + "${TILE5_LABEL_ON}", "${TILE5_LABEL_OFF}"); + + set_value_auto(id(t6_value), t6_on, "${TILE6_TYPE}", "${TILE6_VALUE_MODE}", + id(tile6_brightness).state, id(tile6_percentage).state, std::string(id(tile6_preset).state.c_str()), + id(tile6_climate_current_temperature).state, id(tile6_climate_target_temperature).state, id(tile6_climate_humidity).state, + "${TILE6_LABEL_ON}", "${TILE6_LABEL_OFF}"); + + // Tegel 7: light met alleen brightness. Tegel 8: puur tekst (Vol/Leeg). + set_value_auto(id(t7_value), t7_on, "${TILE7_TYPE}", "${TILE7_VALUE_MODE}", + id(tile7_brightness).state, NAN, std::string(), + NAN, NAN, NAN, + "${TILE7_LABEL_ON}", "${TILE7_LABEL_OFF}"); + + set_value_auto(id(t8_value), t8_on, "${TILE8_TYPE}", "${TILE8_VALUE_MODE}", + NAN, NAN, std::string(), + NAN, NAN, NAN, + "${TILE8_LABEL_ON}", "${TILE8_LABEL_OFF}"); + + // Tegel 9: kleurenlamp (brightness). Tegel 10: puur tekst (Aan/Uit). + set_value_auto(id(t9_value), t9_on, "${TILE9_TYPE}", "${TILE9_VALUE_MODE}", + id(tile9_brightness).state, NAN, std::string(), + NAN, NAN, NAN, + "${TILE9_LABEL_ON}", "${TILE9_LABEL_OFF}"); + + set_value_auto(id(t10_value), t10_on, "${TILE10_TYPE}", "${TILE10_VALUE_MODE}", + NAN, NAN, std::string(), + NAN, NAN, NAN, + "${TILE10_LABEL_ON}", "${TILE10_LABEL_OFF}"); + + // Missing state is not an off state. Preserve meaningful vacuum states. + const char* vacuum_state = id(ha_state_tile6).state.c_str(); + if (std::string("${TILE6_TYPE}") == "vacuum") { + std::string state(vacuum_state); + if (state == "cleaning") set_lbl(id(t6_value), "Bezig"); + else if (state == "docked") set_lbl(id(t6_value), "In dock"); + else if (state == "returning") set_lbl(id(t6_value), "Naar dock"); + else if (state == "paused") set_lbl(id(t6_value), "Gepauzeerd"); + else if (state == "error") set_lbl(id(t6_value), "Fout"); + else if (!state.empty()) set_lbl(id(t6_value), state); + } + if (!id(ha_state_tile1).has_state() || id(ha_state_tile1).state == "unknown" || id(ha_state_tile1).state == "unavailable") + set_lbl(id(t1_value), "Niet beschikbaar"); + if (!id(ha_state_tile2).has_state() || id(ha_state_tile2).state == "unknown" || id(ha_state_tile2).state == "unavailable") + set_lbl(id(t2_value), "Niet beschikbaar"); + if (!id(ha_state_tile3).has_state() || id(ha_state_tile3).state == "unknown" || id(ha_state_tile3).state == "unavailable") + set_lbl(id(t3_value), "Niet beschikbaar"); + if (!id(ha_state_tile4).has_state() || id(ha_state_tile4).state == "unknown" || id(ha_state_tile4).state == "unavailable") + set_lbl(id(t4_value), "Niet beschikbaar"); + if (!id(ha_state_tile5).has_state() || id(ha_state_tile5).state == "unknown" || id(ha_state_tile5).state == "unavailable") + set_lbl(id(t5_value), "Niet beschikbaar"); + if (!id(ha_state_tile6).has_state() || id(ha_state_tile6).state == "unknown" || id(ha_state_tile6).state == "unavailable") + set_lbl(id(t6_value), "Niet beschikbaar"); + if (!id(ha_state_tile7).has_state() || id(ha_state_tile7).state == "unknown" || id(ha_state_tile7).state == "unavailable") + set_lbl(id(t7_value), "Niet beschikbaar"); + if (!id(ha_state_tile8).has_state() || id(ha_state_tile8).state == "unknown" || id(ha_state_tile8).state == "unavailable") + set_lbl(id(t8_value), "Niet beschikbaar"); + if (!id(ha_state_tile9).has_state() || id(ha_state_tile9).state == "unknown" || id(ha_state_tile9).state == "unavailable") + set_lbl(id(t9_value), "Niet beschikbaar"); + if (!id(ha_state_tile10).has_state() || id(ha_state_tile10).state == "unknown" || id(ha_state_tile10).state == "unavailable") + set_lbl(id(t10_value), "Niet beschikbaar"); + if (id(active_control_kind) == "climate_temperature" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { + auto label_for_climate_mode = [](std::string mode) { + for (char &c : mode) { + if (c >= 'A' && c <= 'Z') c = (char) (c - 'A' + 'a'); + } + if (mode == "off") return std::string("Off"); + if (mode == "heat") return std::string("Heat"); + if (mode == "cool") return std::string("Cool"); + if (mode == "heat_cool") return std::string("Heat/Cool"); + if (mode == "auto") return std::string("Auto"); + if (mode == "dry") return std::string("Dry"); + if (mode == "fan_only") return std::string("Fan"); + return mode.empty() ? std::string("Auto") : mode; + }; + auto sync_open_climate = [&](const std::string& entity, + float current_temperature, + float target_temperature, + float humidity, + const std::string& climate_mode) { + if (id(active_entity) != entity) return; + auto to_x10 = [](float value, int fallback) -> int { + if (isnan(value)) return fallback; + return (int) lround(value * 10.0f); + }; + + id(active_climate_current_x10) = to_x10(current_temperature, id(active_climate_current_x10)); + id(active_climate_humidity) = isnan(humidity) ? -1 : (int) lround(humidity); + if (!climate_mode.empty() && !id(touch_down) && + !(id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000)) { + id(active_climate_mode) = climate_mode; + lv_label_set_text(id(climate_mode_label), label_for_climate_mode(climate_mode).c_str()); + } + + const bool editing = id(touch_down) || + (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000); + if (!editing) id(climate_edit_pending) = false; + int target_x10 = editing ? id(active_climate_target_x10) : + to_x10(target_temperature, id(active_climate_target_x10)); + if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10); + if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10); + int step_x10 = id(active_climate_step_x10); + if (step_x10 < 5) step_x10 = 5; + target_x10 = id(active_climate_min_x10) + (int) lround((target_x10 - id(active_climate_min_x10)) / (float) step_x10) * step_x10; + if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10); + if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10); + id(active_climate_target_x10) = target_x10; + + char current_buf[48]; + if (isnan(current_temperature)) { + snprintf(current_buf, sizeof(current_buf), "Current: --"); + } else if (id(active_climate_humidity) >= 0) { + snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", current_temperature, id(active_climate_humidity)); + } else { + snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", current_temperature); + } + lv_label_set_text(id(climate_current_label), current_buf); + + char target_buf[16]; + snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); + lv_label_set_text(id(climate_target_label), target_buf); + lv_arc_set_value(id(climate_arc), id(active_climate_target_x10)); + }; + + if (std::string("${TILE1_TYPE}") == "climate") sync_open_climate("${TILE1_ENTITY}", id(tile1_climate_current_temperature).state, id(tile1_climate_target_temperature).state, id(tile1_climate_humidity).state, std::string(id(tile1_climate_mode).state.c_str())); + if (std::string("${TILE2_TYPE}") == "climate") sync_open_climate("${TILE2_ENTITY}", id(tile2_climate_current_temperature).state, id(tile2_climate_target_temperature).state, id(tile2_climate_humidity).state, std::string(id(tile2_climate_mode).state.c_str())); + if (std::string("${TILE3_TYPE}") == "climate") sync_open_climate("${TILE3_ENTITY}", id(tile3_climate_current_temperature).state, id(tile3_climate_target_temperature).state, id(tile3_climate_humidity).state, std::string(id(tile3_climate_mode).state.c_str())); + if (std::string("${TILE4_TYPE}") == "climate") sync_open_climate("${TILE4_ENTITY}", id(tile4_climate_current_temperature).state, id(tile4_climate_target_temperature).state, id(tile4_climate_humidity).state, std::string(id(tile4_climate_mode).state.c_str())); + if (std::string("${TILE5_TYPE}") == "climate") sync_open_climate("${TILE5_ENTITY}", id(tile5_climate_current_temperature).state, id(tile5_climate_target_temperature).state, id(tile5_climate_humidity).state, std::string(id(tile5_climate_mode).state.c_str())); + if (std::string("${TILE6_TYPE}") == "climate") sync_open_climate("${TILE6_ENTITY}", id(tile6_climate_current_temperature).state, id(tile6_climate_target_temperature).state, id(tile6_climate_humidity).state, std::string(id(tile6_climate_mode).state.c_str())); + } + + { + auto now = id(homeassistant_time).now(); + // Vóór de eerste tijd-sync is now() epoch 0 (1970) — dan geen + // misleidende "01:00" tonen, gewoon leeg laten tot er een + // echte tijd binnen is (na de eerste Home Assistant-tijdsynchronisatie). + if (now.is_valid()) { + char buf[10]; + + if (std::string("${TIME_24H}") == "true") { + snprintf(buf, sizeof(buf), "%02d:%02d", now.hour, now.minute); + } else { + int hour12 = now.hour % 12; + if (hour12 == 0) hour12 = 12; + snprintf(buf, sizeof(buf), "%d:%02d %s", hour12, now.minute, now.hour >= 12 ? "PM" : "AM"); + } + + set_lbl(id(lbl_time), std::string(buf)); + } + } + + // Accentkleur uit het Home Assistant-domein: het stuk voor de punt in + // het entity_id. script/scene/button vuur je af (ACCENT_ACTION), een + // light is een light (ACCENT_LIGHT), de rest is apparaat. + // LET OP: deze helper staat twee keer in dit bestand — hier en in + // open_value_overlay. Pas ze samen aan. + auto accent_for = [](const std::string& entity_id) -> uint32_t { + std::string d = entity_id.substr(0, entity_id.find('.')); + if (d == "script" || d == "scene" || d == "button" || + d == "input_button" || d == "automation") return ${ACCENT_ACTION}; + if (d == "light") return ${ACCENT_LIGHT}; + return ${ACCENT_DEVICE}; + }; + + auto apply_tile = [&](bool active, + const std::string& entity_id, + lv_obj_t* tile, + lv_obj_t* circle, + lv_obj_t* title, + lv_obj_t* value, + lv_obj_t* icon_lbl, + uint32_t circle_active, + uint32_t circle_disabled, + uint32_t icon_active, + uint32_t icon_disabled, + uint32_t bg_active, + uint32_t bg_disabled, + uint32_t title_active, + uint32_t title_disabled, + uint32_t value_active, + uint32_t value_disabled) + { + static lv_obj_t* cached_tiles[10] = {}; + static bool cached_active[10] = {}; + for (int i = 0; i < 10; ++i) { + if (cached_tiles[i] == tile) { + if (cached_active[i] == active) return; + cached_active[i] = active; + break; + } + if (cached_tiles[i] == nullptr) { + cached_tiles[i] = tile; + cached_active[i] = active; + break; + } + } + // ${ACCENT_AUTO} = "bepaal zelf"; een echte hexwaarde wint altijd. + uint32_t accent = accent_for(entity_id); + if (circle_active == ${ACCENT_AUTO}) circle_active = accent; + if (icon_disabled == ${ACCENT_AUTO}) icon_disabled = accent; + + lv_obj_set_style_bg_color(tile, lv_color_hex(active ? bg_active : bg_disabled), 0); + lv_obj_set_style_bg_color(circle, lv_color_hex(active ? circle_active : circle_disabled), 0); + lv_obj_set_style_text_color(icon_lbl, lv_color_hex(active ? icon_active : icon_disabled), 0); + lv_obj_set_style_text_color(title, lv_color_hex(active ? title_active : title_disabled), 0); + lv_obj_set_style_text_color(value, lv_color_hex(active ? value_active : value_disabled), 0); + }; + + apply_tile(t1_on, "${TILE1_ENTITY}", + id(tile1), id(tile1_icon_circle), id(t1_title), id(t1_value), id(tile1_icon_lbl), + ${TILE1_CIRCLE_ACTIVE_COLOR}, ${TILE1_CIRCLE_DISABLED_COLOR}, + ${TILE1_ICON_ACTIVE_COLOR}, ${TILE1_ICON_DISABLED_COLOR}, + ${TILE1_BG_ACTIVE_COLOR}, ${TILE1_BG_DISABLED_COLOR}, + ${TILE1_TITLE_ACTIVE_COLOR}, ${TILE1_TITLE_DISABLED_COLOR}, + ${TILE1_VALUE_ACTIVE_COLOR}, ${TILE1_VALUE_DISABLED_COLOR}); + + apply_tile(t2_on, "${TILE2_ENTITY}", + id(tile2), id(tile2_icon_circle), id(t2_title), id(t2_value), id(tile2_icon_lbl), + ${TILE2_CIRCLE_ACTIVE_COLOR}, ${TILE2_CIRCLE_DISABLED_COLOR}, + ${TILE2_ICON_ACTIVE_COLOR}, ${TILE2_ICON_DISABLED_COLOR}, + ${TILE2_BG_ACTIVE_COLOR}, ${TILE2_BG_DISABLED_COLOR}, + ${TILE2_TITLE_ACTIVE_COLOR}, ${TILE2_TITLE_DISABLED_COLOR}, + ${TILE2_VALUE_ACTIVE_COLOR}, ${TILE2_VALUE_DISABLED_COLOR}); + + apply_tile(t3_on, "${TILE3_ENTITY}", + id(tile3), id(tile3_icon_circle), id(t3_title), id(t3_value), id(tile3_icon_lbl), + ${TILE3_CIRCLE_ACTIVE_COLOR}, ${TILE3_CIRCLE_DISABLED_COLOR}, + ${TILE3_ICON_ACTIVE_COLOR}, ${TILE3_ICON_DISABLED_COLOR}, + ${TILE3_BG_ACTIVE_COLOR}, ${TILE3_BG_DISABLED_COLOR}, + ${TILE3_TITLE_ACTIVE_COLOR}, ${TILE3_TITLE_DISABLED_COLOR}, + ${TILE3_VALUE_ACTIVE_COLOR}, ${TILE3_VALUE_DISABLED_COLOR}); + + apply_tile(t4_on, "${TILE4_ENTITY}", + id(tile4), id(tile4_icon_circle), id(t4_title), id(t4_value), id(tile4_icon_lbl), + ${TILE4_CIRCLE_ACTIVE_COLOR}, ${TILE4_CIRCLE_DISABLED_COLOR}, + ${TILE4_ICON_ACTIVE_COLOR}, ${TILE4_ICON_DISABLED_COLOR}, + ${TILE4_BG_ACTIVE_COLOR}, ${TILE4_BG_DISABLED_COLOR}, + ${TILE4_TITLE_ACTIVE_COLOR}, ${TILE4_TITLE_DISABLED_COLOR}, + ${TILE4_VALUE_ACTIVE_COLOR}, ${TILE4_VALUE_DISABLED_COLOR}); + + apply_tile(t5_on, "${TILE5_ENTITY}", + id(tile5), id(tile5_icon_circle), id(t5_title), id(t5_value), id(tile5_icon_lbl), + ${TILE5_CIRCLE_ACTIVE_COLOR}, ${TILE5_CIRCLE_DISABLED_COLOR}, + ${TILE5_ICON_ACTIVE_COLOR}, ${TILE5_ICON_DISABLED_COLOR}, + ${TILE5_BG_ACTIVE_COLOR}, ${TILE5_BG_DISABLED_COLOR}, + ${TILE5_TITLE_ACTIVE_COLOR}, ${TILE5_TITLE_DISABLED_COLOR}, + ${TILE5_VALUE_ACTIVE_COLOR}, ${TILE5_VALUE_DISABLED_COLOR}); + + apply_tile(t6_on, "${TILE6_ENTITY}", + id(tile6), id(tile6_icon_circle), id(t6_title), id(t6_value), id(tile6_icon_lbl), + ${TILE6_CIRCLE_ACTIVE_COLOR}, ${TILE6_CIRCLE_DISABLED_COLOR}, + ${TILE6_ICON_ACTIVE_COLOR}, ${TILE6_ICON_DISABLED_COLOR}, + ${TILE6_BG_ACTIVE_COLOR}, ${TILE6_BG_DISABLED_COLOR}, + ${TILE6_TITLE_ACTIVE_COLOR}, ${TILE6_TITLE_DISABLED_COLOR}, + ${TILE6_VALUE_ACTIVE_COLOR}, ${TILE6_VALUE_DISABLED_COLOR}); + + apply_tile(t7_on, "${TILE7_ENTITY}", + id(tile7), id(tile7_icon_circle), id(t7_title), id(t7_value), id(tile7_icon_lbl), + ${TILE7_CIRCLE_ACTIVE_COLOR}, ${TILE7_CIRCLE_DISABLED_COLOR}, + ${TILE7_ICON_ACTIVE_COLOR}, ${TILE7_ICON_DISABLED_COLOR}, + ${TILE7_BG_ACTIVE_COLOR}, ${TILE7_BG_DISABLED_COLOR}, + ${TILE7_TITLE_ACTIVE_COLOR}, ${TILE7_TITLE_DISABLED_COLOR}, + ${TILE7_VALUE_ACTIVE_COLOR}, ${TILE7_VALUE_DISABLED_COLOR}); + + apply_tile(t8_on, "${TILE8_ENTITY}", + id(tile8), id(tile8_icon_circle), id(t8_title), id(t8_value), id(tile8_icon_lbl), + ${TILE8_CIRCLE_ACTIVE_COLOR}, ${TILE8_CIRCLE_DISABLED_COLOR}, + ${TILE8_ICON_ACTIVE_COLOR}, ${TILE8_ICON_DISABLED_COLOR}, + ${TILE8_BG_ACTIVE_COLOR}, ${TILE8_BG_DISABLED_COLOR}, + ${TILE8_TITLE_ACTIVE_COLOR}, ${TILE8_TITLE_DISABLED_COLOR}, + ${TILE8_VALUE_ACTIVE_COLOR}, ${TILE8_VALUE_DISABLED_COLOR}); + + apply_tile(t9_on, "${TILE9_ENTITY}", + id(tile9), id(tile9_icon_circle), id(t9_title), id(t9_value), id(tile9_icon_lbl), + ${TILE9_CIRCLE_ACTIVE_COLOR}, ${TILE9_CIRCLE_DISABLED_COLOR}, + ${TILE9_ICON_ACTIVE_COLOR}, ${TILE9_ICON_DISABLED_COLOR}, + ${TILE9_BG_ACTIVE_COLOR}, ${TILE9_BG_DISABLED_COLOR}, + ${TILE9_TITLE_ACTIVE_COLOR}, ${TILE9_TITLE_DISABLED_COLOR}, + ${TILE9_VALUE_ACTIVE_COLOR}, ${TILE9_VALUE_DISABLED_COLOR}); + + apply_tile(t10_on, "${TILE10_ENTITY}", + id(tile10), id(tile10_icon_circle), id(t10_title), id(t10_value), id(tile10_icon_lbl), + ${TILE10_CIRCLE_ACTIVE_COLOR}, ${TILE10_CIRCLE_DISABLED_COLOR}, + ${TILE10_ICON_ACTIVE_COLOR}, ${TILE10_ICON_DISABLED_COLOR}, + ${TILE10_BG_ACTIVE_COLOR}, ${TILE10_BG_DISABLED_COLOR}, + ${TILE10_TITLE_ACTIVE_COLOR}, ${TILE10_TITLE_DISABLED_COLOR}, + ${TILE10_VALUE_ACTIVE_COLOR}, ${TILE10_VALUE_DISABLED_COLOR}); + + +# HA blueprint transport; never publishes the JSON command as an HA state. +text: + - platform: smart_display + id: dashboard_inbox + name: "Tegelinstellingen" + internal: ${DASHBOARD_INBOX_INTERNAL} + +button: + - platform: template + name: "Touch kalibreren" + internal: ${DASHBOARD_INBOX_INTERNAL} + entity_category: config + on_press: + - lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();' diff --git a/packages/guition.yaml b/packages/guition.yaml new file mode 100644 index 0000000..e614774 --- /dev/null +++ b/packages/guition.yaml @@ -0,0 +1,5776 @@ +# GENERATED by tools/generate_packages.py; edit the source board profile. +# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. +# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile. +substitutions: + DYNAMIC_TILES: "true" + DASHBOARD_INBOX_INTERNAL: "false" + DEVICE_NAME: "guition-new" + DEVICE_FRIENDLY_NAME: "Mijn Guition" + CALIBRATION_ON_BOOT: "false" + + TILE_COUNT: "1" # 1-10; <=6 hides page navigation + + DIRECT_ACTIONS: "true" # true = call Home Assistant services directly, false = only emit action events + ROOM_NAME: "Kies tegels in HA" # room name shown in the top-left header + TIME_24H: "true" # true = 24h format, false = 12h format + + ## DIM settings + AUTO_DIM: "true" # true = enable auto dimming + AUTO_DIM_TIMEOUT: "600" # seconds after last touch + MAX_BRIGHTNESS: "100" # backlight brightness in percent + DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent + NIGHT_MODE: "true" # true = use separate dim level at night + NIGHT_START_HOUR: "22" # 0-23 + NIGHT_END_HOUR: "7" # 0-23 + NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night + + # ============================================================ + # ACCENTKLEUREN — automatisch bepaald uit het Home Assistant-domein + # + # Het domein is het stuk voor de punt in het entity_id: + # script.goede_morgen -> "script" -> ACCENT_ACTION + # light.woonkamer_2 -> "light" -> ACCENT_LIGHT + # vacuum.s8 -> "vacuum" -> ACCENT_DEVICE + # + # Dat is Home Assistant's eigen indeling, dus een tegel krijgt vanzelf de + # juiste kleur zodra je er een entity in zet — je hoeft per tegel geen + # kleuren meer te kiezen. Het onderscheid dat telt: een script/scene vuur + # je af, een apparaat zet je aan of uit. Die twee horen niet hetzelfde + # te zijn. + # + # Zet TILE*_CIRCLE_ACTIVE_COLOR / TILE*_ICON_DISABLED_COLOR op + # ${ACCENT_AUTO} om dit te gebruiken, of op een echte hexwaarde om die + # ene tegel handmatig te kleuren (zie tegel 8 — rood zodra er post ligt). + # ============================================================ + ACCENT_AUTO: "0x1000000" # sentinel: bewust buiten het 24-bits kleurbereik + ACCENT_ACTION: "0x8B5CF6" # script / scene / button / input_button / automation + ACCENT_LIGHT: "0xFEC600" # light + ACCENT_DEVICE: "0x00C5EC" # switch, fan, cover, climate, vacuum, media_player, rest + + # ============================================================ + # FONT GLYPHS + # List every unique MDI icon used across your tiles — each value must appear exactly once. + # Reusing the same icon on multiple tiles is fine: just list that glyph once here. + # If you use fewer than 6 unique icons, leave the extra slots with their default values + # (unused glyphs add a few bytes to the font but cause no errors). + # ============================================================ + MDI_GLYPH_1: "\U000F001B" + MDI_GLYPH_2: "\U000F0387" # mdi:music + MDI_GLYPH_3: "\U000F02E3" + MDI_GLYPH_4: "\U000F059C" + MDI_GLYPH_5: "\U000F0425" + MDI_GLYPH_6: "\U000F070D" + MDI_GLYPH_7: "\U000F06B5" # mdi:lamp + MDI_GLYPH_8: "\U000F06EE" # mdi:mailbox + MDI_GLYPH_9: "\U000F04B9" # mdi:sofa + MDI_GLYPH_10: "\U000F002A" # mdi:alarm-light + + # ============================================================ + # TILE 1 + # ============================================================ + + # --- basic settings --- + TILE1_ENTITY: "light.voorbeeld_lamp" # main Home Assistant entity for this tile + TILE1_STATE_ENTITY: "light.voorbeeld_lamp" # entity used for active/inactive state styling + TILE1_TITLE: "Lamp" # tile title shown in the UI + TILE1_ICON: "\U000F001B" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE1_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate + TILE1_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE1_LONGPRESS: "slider" # long press mode: none | slider | action + TILE1_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE1_LABEL_OFF: "Uit" # text shown when entity is off + TILE1_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE1_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE1_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE1_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE1_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE1_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE1_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE1_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE1_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE1_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE1_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE1_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE1_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE1_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE1_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE1_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE1_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE1_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE1_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE1_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE1_CLIMATE_MIN_TEMP: "16.0" # minimum target temperature shown in the thermostat overlay + TILE1_CLIMATE_MAX_TEMP: "32.0" # maximum target temperature shown in the thermostat overlay + TILE1_CLIMATE_STEP: "1.0" # target temperature step size + TILE1_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE1_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE1_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 2 + # ============================================================ + + # --- basic settings --- + TILE2_ENTITY: "switch.voorbeeld_stekker" # main Home Assistant entity for this tile + TILE2_STATE_ENTITY: "switch.voorbeeld_stekker" # entity used for active/inactive state styling + TILE2_TITLE: "Stekker" # tile title shown in the UI + TILE2_ICON: "\U000F0387" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE2_TYPE: "switch" # tile type: light | fan | switch | scene | script | cover | climate + TILE2_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE2_LONGPRESS: "none" # long press mode: none | slider | action + TILE2_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE2_LABEL_OFF: "Uit" # text shown when entity is off + TILE2_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE2_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE2_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE2_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE2_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE2_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE2_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE2_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE2_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE2_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE2_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE2_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE2_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE2_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE2_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE2_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE2_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE2_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE2_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE2_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE2_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE2_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE2_CLIMATE_STEP: "0.5" # target temperature step size + TILE2_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE2_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE2_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 3 + # ============================================================ + + # --- basic settings --- + TILE3_ENTITY: "scene.voorbeeld_avond" # main Home Assistant entity for this tile + TILE3_STATE_ENTITY: "scene.voorbeeld_avond" # entity used for active/inactive state styling + TILE3_TITLE: "Avond" # tile title shown in the UI + TILE3_ICON: "\U000F02E3" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE3_TYPE: "scene" # tile type: light | fan | switch | scene | script | cover | climate + TILE3_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE3_LONGPRESS: "none" # long press mode: none | slider | action + TILE3_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE3_LABEL_OFF: "Uit" # text shown when entity is off + TILE3_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE3_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE3_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE3_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE3_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE3_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE3_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE3_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE3_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE3_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE3_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE3_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE3_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE3_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE3_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE3_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE3_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE3_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE3_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE3_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE3_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE3_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE3_CLIMATE_STEP: "0.5" # target temperature step size + TILE3_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE3_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE3_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 4 + # ============================================================ + + # --- basic settings --- + TILE4_ENTITY: "script.voorbeeld_uit" # main Home Assistant entity for this tile + TILE4_STATE_ENTITY: "script.voorbeeld_uit" # entity used for active/inactive state styling + TILE4_TITLE: "Alles uit" # tile title shown in the UI + TILE4_ICON: "\U000F059C" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE4_TYPE: "script" # tile type: light | fan | switch | scene | script | cover | climate + TILE4_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE4_LONGPRESS: "none" # long press mode: none | slider | action + TILE4_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE4_LABEL_OFF: "Uit" # text shown when entity is off + TILE4_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE4_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE4_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE4_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE4_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE4_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE4_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE4_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE4_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE4_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE4_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE4_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE4_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE4_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE4_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE4_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE4_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE4_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE4_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE4_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE4_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE4_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE4_CLIMATE_STEP: "0.5" # target temperature step size + TILE4_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE4_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE4_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 5 + # ============================================================ + + # --- basic settings --- + TILE5_ENTITY: "climate.voorbeeld_airco" # main Home Assistant entity for this tile + TILE5_STATE_ENTITY: "climate.voorbeeld_airco" # entity used for active/inactive state styling + TILE5_TITLE: "Airco" # tile title shown in the UI + TILE5_ICON: "\U000F0425" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE5_TYPE: "climate" # tile type: light | fan | switch | scene | script | cover | climate + TILE5_TAP_ACTION: "toggle" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE5_LONGPRESS: "slider" # long press mode: none | slider | action + TILE5_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE5_LABEL_OFF: "Uit" # text shown when entity is off (closed) + TILE5_LABEL_ON: "Aan" # text shown when entity is on (open) + TILE5_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE5_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE5_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE5_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE5_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE5_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE5_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE5_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE5_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE5_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE5_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE5_TAP_SERVICE: "climate.toggle" # optional service override, e.g. light.toggle + TILE5_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE5_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE5_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE5_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE5_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE5_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE5_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE5_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE5_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE5_CLIMATE_STEP: "0.5" # target temperature step size + TILE5_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE5_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE5_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + + + # ============================================================ + # TILE 6 + # ============================================================ + + # --- basic settings --- + TILE6_ENTITY: "vacuum.voorbeeld_robot" # main Home Assistant entity for this tile + TILE6_STATE_ENTITY: "vacuum.voorbeeld_robot" # entity used for active/inactive state styling + TILE6_TITLE: "Stofzuiger" # tile title shown in the UI + TILE6_ICON: "\U000F070D" # MDI glyph — also add to MDI_GLYPH_* list above if not already there + TILE6_TYPE: "vacuum" # tile type: light | fan | switch | scene | script | cover | climate + TILE6_TAP_ACTION: "custom" # tap action: auto | toggle | activate | fan_toggle_preset | custom + TILE6_LONGPRESS: "vacuum" # long press mode: none | slider | action + TILE6_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE6_LABEL_OFF: "Uit" # text shown when entity is off + TILE6_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE6_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE6_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE6_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE6_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE6_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE6_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE6_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE6_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE6_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE6_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE6_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE6_TAP_SERVICE: "vacuum.start" # optional service override, e.g. fan.toggle + TILE6_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE6_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE6_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position + TILE6_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE6_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE6_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE) + TILE6_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead) + # if TYPE = climate: + TILE6_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay + TILE6_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay + TILE6_CLIMATE_STEP: "0.5" # target temperature step size + TILE6_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature + TILE6_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature + TILE6_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable + # ============================================================ + # TILE 7 — Tafellamp + # Alleen-helderheid lamp (HA meldt supported_color_modes: ['brightness']), + # dus de kleurenknop in de slider-overlay blijft vanzelf verborgen. + # ============================================================ + + # --- basic settings --- + TILE7_ENTITY: "light.ongebruikt_7" # main Home Assistant entity for this tile + TILE7_STATE_ENTITY: "light.ongebruikt_7" # entity used for active/inactive state styling + TILE7_TITLE: "Tegel 7" # tile title shown in the UI + TILE7_ICON: "\U000F06B5" # mdi:lamp — also listed in MDI_GLYPH_7 above + TILE7_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate + TILE7_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset + TILE7_LONGPRESS: "none" # long press mode: none | slider | action | vacuum + TILE7_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE7_LABEL_OFF: "Uit" # text shown when entity is off + TILE7_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE7_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE7_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active + TILE7_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE7_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE7_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE7_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE7_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE7_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE7_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE7_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE7_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE7_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE7_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE7_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE7_LONGPRESS_SLIDER: "auto" # auto = brightness voor een light + TILE7_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE7_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE7_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE) + TILE7_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on) + + # ============================================================ + # TILE 8 — Brievenbus reset + # input_select.brievenbus_status heeft opties ['Vol', 'Leeg']. + # De tegel licht op zodra de status "Vol" is (ACTIVE_STATE), en een korte + # tik zet hem terug op "Leeg" via input_select.select_option. + # ============================================================ + + # --- basic settings --- + TILE8_ENTITY: "light.ongebruikt_8" # main Home Assistant entity for this tile + TILE8_STATE_ENTITY: "light.ongebruikt_8" # entity used for active/inactive state styling + TILE8_TITLE: "Tegel 8" # tile title shown in the UI + TILE8_ICON: "\U000F06EE" # mdi:mailbox — also listed in MDI_GLYPH_8 above + TILE8_TYPE: "light" # generiek type; het echte gedrag komt uit TAP_SERVICE hieronder + TILE8_TAP_ACTION: "auto" # activate + TAP_SERVICE = losse service call + TILE8_LONGPRESS: "none" # long press stuurt alleen het event naar HA + TILE8_VALUE_MODE: "auto" # toont LABEL_ON / LABEL_OFF + TILE8_LABEL_OFF: "Uit" # text shown when entity is off / inactive + TILE8_LABEL_ON: "Aan" # text shown when entity is active + TILE8_ACTIVE_STATE: "" # tegel is "aan" zodra de status exact "Vol" is + + # --- colors --- + TILE8_CIRCLE_ACTIVE_COLOR: "0xE5484D" # rood zodra er post ligt + TILE8_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE8_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE8_ICON_DISABLED_COLOR: "0xE5484D" # icon color when inactive + TILE8_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE8_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE8_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE8_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE8_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE8_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE8_TAP_SERVICE: "" # zet de input_select op een vaste optie + TILE8_TAP_PARAM_KEY: "" # parameternaam voor input_select.select_option + TILE8_TAP_PARAM_VAL: "" # de "reset"-waarde + # if LONGPRESS = slider: + TILE8_LONGPRESS_SLIDER: "auto" # niet in gebruik (LONGPRESS = none) + TILE8_LONGPRESS_OFF_VALUE: "0" # niet in gebruik + # if LONGPRESS = action: + TILE8_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE8_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE) + TILE8_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on) + + # ============================================================ + # TILE 9 — Woonkamer + # Verhuisd van tegel 2 naar rij 5 om plaats te maken voor Ambient. + # Kleurenlamp (color_temp / rgb / xy), dus de kleurknop in de + # slider-overlay verschijnt vanzelf. + # ============================================================ + + # --- basic settings --- + TILE9_ENTITY: "light.ongebruikt_9" # main Home Assistant entity for this tile + TILE9_STATE_ENTITY: "light.ongebruikt_9" # entity used for active/inactive state styling + TILE9_TITLE: "Tegel 9" # tile title shown in the UI + TILE9_ICON: "\U000F04B9" # mdi:sofa — also listed in MDI_GLYPH_9 above + TILE9_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate + TILE9_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset + TILE9_LONGPRESS: "none" # long press mode: none | slider | action | vacuum + TILE9_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text + TILE9_LABEL_OFF: "Uit" # text shown when entity is off + TILE9_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on + TILE9_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on") + + # --- colors --- + TILE9_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # light -> ACCENT_LIGHT (amber) + TILE9_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE9_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE9_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE9_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE9_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE9_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE9_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE9_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE9_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE9_TAP_SERVICE: "" # optional service override, e.g. light.toggle + TILE9_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE9_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE9_LONGPRESS_SLIDER: "auto" # auto = brightness voor een light + TILE9_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off + # if LONGPRESS = action: + TILE9_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE9_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE) + TILE9_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on) + + # ============================================================ + # TILE 10 — Alarm aan/uit + # input_boolean.alarm_engaged. Een input_boolean reageert niet op + # switch.toggle, vandaar de expliciete TAP_SERVICE. + # ============================================================ + + # --- basic settings --- + TILE10_ENTITY: "light.ongebruikt_10" # main Home Assistant entity for this tile + TILE10_STATE_ENTITY: "light.ongebruikt_10" # entity used for active/inactive state styling + TILE10_TITLE: "Tegel 10" # tile title shown in the UI + TILE10_ICON: "\U000F002A" # mdi:alarm-light — also listed in MDI_GLYPH_10 above + TILE10_TYPE: "light" # generiek aan/uit-type; de service hieronder doet het echte werk + TILE10_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset + TILE10_LONGPRESS: "none" # long press stuurt alleen het event naar HA + TILE10_VALUE_MODE: "auto" # toont LABEL_ON / LABEL_OFF + TILE10_LABEL_OFF: "Uit" # text shown when entity is off + TILE10_LABEL_ON: "Aan" # text shown when entity is on + TILE10_ACTIVE_STATE: "" # input_boolean gebruikt gewoon on/off + + # --- colors --- + TILE10_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # input_boolean -> ACCENT_DEVICE (cyaan) + TILE10_CIRCLE_DISABLED_COLOR: "0x1B2D3C" # icon circle color when inactive + TILE10_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active + TILE10_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive + TILE10_BG_ACTIVE_COLOR: "0xF5F3EB" # tile background color when active + TILE10_BG_DISABLED_COLOR: "0x293F50" # tile background color when inactive + TILE10_TITLE_ACTIVE_COLOR: "0x142334" # title color when active + TILE10_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive + TILE10_VALUE_ACTIVE_COLOR: "0x526477" # value color when active + TILE10_VALUE_DISABLED_COLOR: "0xB9CDDB" # value color when inactive + + # --- advanced settings --- + TILE10_TAP_SERVICE: "" # switch.toggle werkt niet op een input_boolean + TILE10_TAP_PARAM_KEY: "" # optional extra service parameter key + TILE10_TAP_PARAM_VAL: "" # optional extra service parameter value + # if LONGPRESS = slider: + TILE10_LONGPRESS_SLIDER: "auto" # niet in gebruik (LONGPRESS = none) + TILE10_LONGPRESS_OFF_VALUE: "0" # niet in gebruik + # if LONGPRESS = action: + TILE10_LONGPRESS_ACTION: "" # entity_id to fire on long press + TILE10_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE) + TILE10_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on) + + + # Square 480x480 layout; GT911 coordinates are already in pixels. + ORIENTATION: "0" + DISPLAY_W: "480" + DISPLAY_H: "480" + LVGL_ROTATION: "0" + TOUCH_SWAP_XY: "false" + TOUCH_MIRROR_X: "true" + TOUCH_MIRROR_Y: "true" + GRID_COL1_X: "16" + GRID_COL2_X: "246" + GRID_ROW1_Y: "0" + GRID_ROW2_Y: "120" + GRID_ROW3_Y: "240" + GRID_ROW4_Y: "360" + GRID_ROW5_Y: "480" + SCROLL_Y: "72" + SCROLL_H: "348" + TILE_W: "218" + TILE_H: "108" + HEADER_Y: "22" + OVERLAY_SLIDER_W: "144" + OVERLAY_SLIDER_H: "300" + OVERLAY_ICON_Y: "148" + BG_TOP_COLOR: "0x111D2B" + BG_BOTTOM_COLOR: "0x28485A" + TILE_ICON_X: "0" + TILE_ICON_Y: "12" + TILE_TITLE_X: "64" + TILE_TITLE_Y: "17" + TILE_VALUE_X: "64" + TILE_VALUE_Y: "45" + + +# ============================================================ +# No need to edit below this line. +# All configuration is done via the substitutions above. +# Credentials are managed in secrets.yaml (see secrets.yaml.example). +# The Material Design Icons font is included at fonts/materialdesignicons-webfont.ttf (Apache 2.0). +# Source: https://github.com/Templarian/MaterialDesign-Webfont +# ============================================================ + +external_components: + - source: + type: git + url: https://github.com/MaxGramser/homeassistant_espscreen.git + ref: main + path: components + refresh: 0s + components: [smart_display] + +esphome: + min_version: 2026.6.2 + project: + name: maxgramser.esp_screen + version: "0.1.0" + platformio_options: + build_flags: + - -DLV_USE_SNAPSHOT=1 + includes: + - + - + - + name: ${DEVICE_NAME} + friendly_name: ${DEVICE_FRIENDLY_NAME} + on_boot: + priority: -100 + then: + - lambda: |- + id(last_touch_ms) = millis(); + id(display_dimmed) = false; + lv_obj_set_style_pad_row(id(vacuum_actions), 4, 0); + lv_obj_set_style_pad_column(id(vacuum_actions), 4, 0); + lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0); + lv_obj_set_style_pad_column(id(vacuum_speeds), 4, 0); + - lambda: |- + runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true"; + if (!runtime_tiles::enabled) return; + runtime_tiles::dismiss = []() { + id(active_entity).clear(); + lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(vacuum_overlay), LV_OBJ_FLAG_HIDDEN); + }; + runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; + runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; + runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); + runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); + runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); + runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); + runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); + runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); + runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); + runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); + runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); + runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); + runtime_tiles::detail = [](runtime_tiles::Tile &t) { + if (t.domain() == "vacuum") { + lv_obj_t *buttons[] = {id(vacspd_quiet), id(vacspd_balanced), id(vacspd_turbo), id(vacspd_max)}; + for (unsigned i = 0; i < 4; ++i) { + if (i < t.fan_speed_count) { + lv_obj_clear_flag(buttons[i], LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(lv_obj_get_child(buttons[i], 0), t.fan_speeds[i].c_str()); + } else lv_obj_add_flag(buttons[i], LV_OBJ_FLAG_HIDDEN); + } + id(open_vacuum_overlay).execute(t.name, t.entity); + return; + } + light_controls::fallback = light_controls::State{}; + light_controls::fallback.hue = t.hue; + light_controls::fallback.kelvin = t.kelvin; + light_controls::fallback.minimum = t.min_kelvin; + light_controls::fallback.maximum = t.max_kelvin; + id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, + t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), + 0x565C66, 0xE4B23C, 0xFFFFFF, "Uit", "Aan", t.modes, + t.current, t.target, t.humidity, t.state, t.hvac_modes, + std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); + }; + runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { + if (id(active_entity) != t.entity || id(touch_down)) return; + if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { + if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; + id(climate_edit_pending) = false; + id(active_climate_mode) = t.state; + id(active_climate_supported_modes) = t.hvac_modes; + lv_label_set_text(id(climate_mode_label), t.state.c_str()); + char buf[72]; + if (std::isfinite(t.current)) snprintf(buf, sizeof(buf), "Nu: %.1f°", t.current); + else snprintf(buf, sizeof(buf), "Nu: --"); + lv_label_set_text(id(climate_current_label), buf); + if (std::isfinite(t.target)) { + int target = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); + id(active_climate_target_x10) = target; + snprintf(buf, sizeof(buf), "%.1f°", target / 10.0f); + lv_label_set_text(id(climate_target_label), buf); + lv_arc_set_value(id(climate_arc), target); + } + } + }; + - lambda: |- + static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; + if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8); + light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); + light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; + light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; + light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; + - light.turn_on: + id: back_light + brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;" + - script.execute: show_tile_page + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + - if: + condition: + lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";' + then: + - lambda: 'id(calibration_active) = true;' + - lvgl.page.show: touch_cal_page + - lambda: |- + lv_obj_invalidate(id(touch_cal_page)->obj); + lv_refr_now(nullptr); + ESP_LOGI("ui_diag", "GT911 touch test ready; no tile actions"); + + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + cpu_frequency: 240MHz + framework: + type: esp-idf + version: recommended + advanced: + execute_from_psram: true + sdkconfig_options: + CONFIG_ESP32S3_DATA_CACHE_64KB: "y" + CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" + CONFIG_LCD_RGB_RESTART_IN_VSYNC: "y" + +psram: + mode: octal + speed: 80MHz + +logger: + tx_buffer_size: 2048 + hardware_uart: UART0 + # Voor diagnose tijdelijk op DEBUG of VERBOSE zetten. Let op: ESPHome logt + # zijn configuratieregels op niveau CONFIG, die zijn op INFO onzichtbaar. + level: DEBUG + logs: + sensor: WARN + text_sensor: WARN + xpt2046: WARN + homeassistant.sensor: WARN + homeassistant.text_sensor: WARN + + +# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values. +api: + actions: + - action: ui_snapshot + then: + - script.execute: capture_ui_snapshot + - action: touch_diagnostics + then: + - lambda: 'id(calibration_active) = true;' + - script.execute: wake_display + - lvgl.page.show: touch_cal_page + - lambda: |- + lv_obj_invalidate(id(touch_cal_page)->obj); + lv_refr_now(nullptr); + ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj); + - action: end_touch_diagnostics + then: + - lambda: 'id(calibration_active) = false;' + - lvgl.page.show: home_page + - action: ui_state + then: + - lambda: |- + lv_area_t nav, grid; + lv_obj_update_layout(id(home_page)->obj); + lv_obj_get_coords(id(page_next), &nav); + lv_obj_get_coords(id(tile_scroll), &grid); + ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", + lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, + grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); + - action: light_controls_preview + then: + - script.execute: light_controls_preview + - action: ui_self_test + then: + - script.execute: ui_self_test + +captive_portal: + +interval: + - interval: 1s + then: + - if: + condition: + lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;' + then: + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + - if: + condition: + lambda: |- + if (std::string("${AUTO_DIM}") != "true") return false; + if (id(display_dimmed) || id(touch_down) || id(calibration_active)) return false; + return (millis() - id(last_touch_ms)) > (atoi("${AUTO_DIM_TIMEOUT}") * 1000UL); + then: + - script.execute: dim_display + +time: + - platform: homeassistant + id: homeassistant_time + # Refresh immediately on time sync; do not wait for an HA entity update. + update_interval: 60s + on_time_sync: + then: + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + on_time: + - seconds: 0 + minutes: /1 + then: + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + + - seconds: 0 + minutes: 0 + hours: ${NIGHT_START_HOUR} + then: + - if: + condition: + lambda: 'return id(display_dimmed);' + then: + - script.execute: dim_display + + - seconds: 0 + minutes: 0 + hours: ${NIGHT_END_HOUR} + then: + - if: + condition: + lambda: 'return id(display_dimmed);' + then: + - script.execute: dim_display + +globals: + - id: rendered_frames + type: uint32_t + initial_value: '0' + - id: calibration_active + type: bool + initial_value: 'false' + - id: tile_page + type: int + initial_value: '0' + - id: touch_down + type: bool + initial_value: 'false' + - id: climate_edit_ms + type: uint32_t + initial_value: '0' + - id: climate_edit_pending + type: bool + initial_value: 'false' + - id: active_entity + type: std::string + restore_value: no + + - id: active_control_kind + type: std::string + restore_value: no + + - id: active_value_suffix + type: std::string + restore_value: no + + - id: last_touch_ms + type: uint32_t + restore_value: no + initial_value: '0' + + # Debounce voor do_tile_action: bescherm tegen herhaalde events bij + # meerdere losse press/release-cycli voor één fysieke aanraking (ruis in + # het weerstandslaagje), waardoor dezelfde HA-actie meerdere keren binnen + # een paar honderd ms vuurt (gezien: script.ambient_morning 6x in 8s). + - id: last_action_entity + type: std::string + restore_value: no + + - id: last_action_ms + type: uint32_t + restore_value: no + initial_value: '0' + + - id: display_dimmed + type: bool + restore_value: no + initial_value: 'false' + + - id: active_label_off + type: std::string + restore_value: no + + - id: active_label_on + type: std::string + restore_value: no + + - id: active_title + type: std::string + restore_value: no + + - id: active_icon_text + type: std::string + restore_value: no + + - id: active_brightness_pct + type: int + restore_value: no + initial_value: '100' + + - id: active_supports_color + type: bool + restore_value: no + initial_value: 'false' + + - id: active_supports_temperature + type: bool + restore_value: no + initial_value: 'false' + + - id: active_color_detail_tab + type: int + restore_value: no + initial_value: '0' + + - id: active_climate_current_x10 + type: int + restore_value: no + initial_value: '0' + + - id: active_climate_target_x10 + type: int + restore_value: no + initial_value: '200' + + - id: active_climate_humidity + type: int + restore_value: no + initial_value: '-1' + + - id: active_climate_min_x10 + type: int + restore_value: no + initial_value: '50' + + - id: active_climate_max_x10 + type: int + restore_value: no + initial_value: '300' + + - id: active_climate_step_x10 + type: int + restore_value: no + initial_value: '5' + + - id: active_climate_mode + type: std::string + restore_value: no + + - id: active_climate_supported_modes + type: std::string + restore_value: no + +font: + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-700.ttf" + id: climate_number + size: 46 + bpp: 4 + glyphs: "0123456789.-° " + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" + id: time_label + size: 27 + bpp: 4 + glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P'] + + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" + id: headline + size: 27 + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-700.ttf" + id: label + size: 18 + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/'] + + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" + id: sublabel + size: 16 + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" + id: sublabel_big + size: 21 + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…'] + + # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) + # Source: https://github.com/Templarian/MaterialDesign-Webfont + - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" + id: materialdesign_icons + size: 42 + glyphs: + - "\U000F0335" + - "\U000F0210" + - "\U000F111C" + - "\U000F029A" + - "${MDI_GLYPH_1}" + - "${MDI_GLYPH_2}" + - "${MDI_GLYPH_3}" + - "${MDI_GLYPH_4}" + - "${MDI_GLYPH_5}" + - "${MDI_GLYPH_6}" + - "${MDI_GLYPH_7}" + - "${MDI_GLYPH_8}" + - "${MDI_GLYPH_9}" + - "${MDI_GLYPH_10}" + - "\U000F012C" # check + +# Geheugen zichtbaar maken: dit bord heeft geen PSRAM, dus de vrije heap +# bepaalt hoeveel UI erbij kan. Verschijnt ook als entity in Home Assistant. +debug: + update_interval: 30s + +sensor: + - platform: debug + free: + name: "Heap Free" + id: heap_free + on_value: + - lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);' + fragmentation: + name: "Heap Fragmentation" + block: + name: "Heap Largest Block" + on_value: + - lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);' + - platform: wifi_signal + name: "Wifi Signal" + update_interval: 600s + + - platform: uptime + name: "Uptime" + id: uptime_s + update_interval: 15s + + - platform: template + update_interval: never + id: tile1_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_percentage + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + # Tegel 7 (Tafellamp) is een alleen-helderheid light: alleen brightness. + # Bewust geen percentage / cover_position / climate-sensors — elke + # HA-subscription kost heap op een bord zonder PSRAM. + - platform: template + update_interval: never + id: tile7_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + # Tegel 9 (Woonkamer) is een kleurenlamp: brightness + supported_color_modes. + # Geen percentage / cover / climate — die leest deze tegel nooit. + - platform: template + update_interval: never + id: tile9_brightness + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_cover_position + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_climate_current_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_climate_target_temperature + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_climate_humidity + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + +binary_sensor: + - platform: status + name: "Node Status" + id: system_status + +# ---------------------------------------------------------- +# Text sensors +# +# smartdisplay_action: +# - Main touch event channel exported to Home Assistant +# - On short tap, publishes one of: +# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press +# - The value is reset shortly after publishing +# +# tile*_preset: +# - Mirrors the Home Assistant preset_mode attribute for each tile entity +# - Used by the UI to display fan presets when applicable +# ---------------------------------------------------------- +text_sensor: + - platform: template + name: "Schermfirmware" + entity_category: diagnostic + lambda: 'return {"0.1.0"};' + update_interval: 60s + - platform: template + id: smartdisplay_action + name: "SmartDisplay Action" + icon: mdi:gesture-tap-button + + - platform: template + update_interval: never + id: ha_state_tile1 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile2 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile3 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile4 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile5 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile6 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile7 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile8 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile9 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: ha_state_tile10 + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + # Woonkamer meldt color_temp/rgb/xy, dus hier verschijnt de kleurknop wel. + - platform: template + update_interval: never + id: tile9_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + # Gebruikt door de slider-overlay om te bepalen of de kleurknop nodig is. + # Deze lamp meldt ['brightness'], dus de knop blijft vanzelf verborgen. + - platform: template + update_interval: never + id: tile7_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_preset + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_supported_color_modes + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_climate_mode + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile1_climate_supported_modes + + - platform: template + update_interval: never + id: tile2_climate_supported_modes + + - platform: template + update_interval: never + id: tile3_climate_supported_modes + + - platform: template + update_interval: never + id: tile4_climate_supported_modes + + - platform: template + update_interval: never + id: tile5_climate_supported_modes + + - platform: template + update_interval: never + id: tile6_climate_supported_modes + + - platform: template + update_interval: never + id: tile1_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile2_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile3_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile4_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile5_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + + - platform: template + update_interval: never + id: tile6_cover_state + on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] } + +switch: + - platform: restart + name: "Restart" + +# Two SPI buses used by the CYD: +# - lcd: built-in display +# - my_touchscreen: built-in touch controller +# Pinout from ESPHome's GUITION-4848S040 preset. No relay outputs are configured. +spi: + id: lcd + clk_pin: GPIO48 + mosi_pin: GPIO47 + +i2c: + id: touch_bus + sda: GPIO19 + scl: + number: GPIO45 + ignore_strapping_warning: true + scan: true + +output: + - platform: ledc + pin: GPIO38 + id: gpio_backlight_pwm + frequency: 20000Hz + +light: + - platform: monochromatic + output: gpio_backlight_pwm + name: "Display Backlight" + id: back_light + restore_mode: ALWAYS_ON + default_transition_length: 150ms + +touchscreen: + platform: gt911 + id: ts_touch + i2c_id: touch_bus + display: my_display + update_interval: 20ms + transform: + swap_xy: ${TOUCH_SWAP_XY} + mirror_x: ${TOUCH_MIRROR_X} + mirror_y: ${TOUCH_MIRROR_Y} + on_touch: + - lambda: |- + id(last_touch_ms) = millis(); + id(touch_down) = true; + cyd::touch_guard.begin(millis(), touch.x, touch.y); + ESP_LOGI("touch", "GT911 press x=%u y=%u test=%d", touch.x, touch.y, id(calibration_active)); + on_update: + - lambda: |- + id(last_touch_ms) = millis(); + for (const auto &point : touches) { + cyd::touch_guard.update(point.x, point.y); + } + on_release: + - lambda: |- + id(last_touch_ms) = millis(); + id(touch_down) = false; + + +display: + - platform: mipi_rgb + id: my_display + model: GUITION-4848S040 + # 16MHz reduces visible low-refresh flicker on this physical 480x480 panel. + pclk_frequency: 16MHz + spi_id: lcd + update_interval: never + auto_clear_enabled: false + + +lvgl: + on_draw_start: + - lambda: 'id(rendered_frames)++;' + displays: + - my_display + touchscreens: + - touchscreen_id: ts_touch + + # S3 octal PSRAM holds RGB framebuffer and LVGL draw buffers. + buffer_size: 25% + + # Directe lokale feedback op de HELE UI, niet alleen op de tegels. + # Een thema zet de stijl per widget-type, dus dit dekt in een klap alle + # knoppen, tabs, sliders en de thermostaatring. Niet-klikbare widgets + # komen nooit in de pressed-state, dus die blijven onaangeroerd. + theme: + obj: + pressed: + # bg_opa en NIET opa: `opa` op widget-niveau dwingt LVGL het element + # naar een aparte laag te renderen, en die laag kost tientallen KB's + # per ingedrukte widget. Dat liep hier het geheugen uit. + bg_opa: 45% + slider: + knob: + pressed: + bg_opa: 65% + arc: + knob: + pressed: + bg_opa: 65% + + # LVGL owns the rotation (display + touch) since ESPHome 2026.4. + rotation: ${LVGL_ROTATION} + + style_definitions: + # Achtergrond: twee-stops verticale gradient in plaats van de 320x240 + # RGB565-wallpaper. Scheelt ~150 KB flash en een image-blit per hertekening, + # wat je direct terugziet in de vloeiendheid van het scrollen. + - id: style_bg_grad + bg_color: ${BG_TOP_COLOR} + bg_grad_color: ${BG_BOTTOM_COLOR} + bg_grad_dir: VER + bg_opa: COVER + border_width: 0 + radius: 0 + + - id: style_tile + bg_color: 0xFFFFFF + bg_opa: COVER + radius: 22 + border_width: 0 + pad_left: 12 + pad_right: 12 + pad_top: 12 + pad_bottom: 12 + shadow_width: 0 + shadow_opa: 40% + + - id: style_tile_disabled + bg_color: 0x293F50 + bg_opa: COVER + radius: 22 + border_width: 0 + pad_left: 12 + pad_right: 12 + pad_top: 12 + pad_bottom: 12 + shadow_width: 0 + shadow_opa: 25% + + - id: style_icon_circle + bg_color: 0xFEC600 + bg_opa: COVER + radius: 999 + border_width: 0 + + - id: style_icon_circle_disabled + bg_color: 0x7B7B6F + bg_opa: COVER + radius: 999 + border_width: 0 + + - id: style_room + text_font: headline + text_color: 0xFFFFFF + text_opa: 100% + + - id: style_time + text_font: time_label + text_color: 0xFFFFFF + text_opa: 100% + + - id: style_title + text_font: label + text_color: 0x000000 + text_opa: 100% + + - id: style_value + text_font: sublabel + text_color: 0x7A7A7C + text_opa: 100% + + - id: style_value_big + text_font: sublabel_big + text_color: 0xFFFFFF + text_opa: 80% + + - id: style_title_disabled + text_font: label + text_color: 0xFFFFFF + text_opa: 100% + + - id: style_value_disabled + text_font: sublabel + text_color: 0xD9D9D9 + text_opa: 100% + + pages: + - id: home_page + styles: style_bg_grad + pad_all: 0 + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: lbl_room + x: 16 + y: ${HEADER_Y} + text: "${ROOM_NAME}" + styles: style_room + + - label: + id: lbl_time + align: TOP_RIGHT + x: -16 + y: ${HEADER_Y} + text: "" + styles: style_time + + # Six tiles on page 1, four on page 2. Fixed pages keep navigation predictable. + - obj: + id: tile_scroll + x: 0 + y: ${SCROLL_Y} + width: ${DISPLAY_W} + height: ${SCROLL_H} + bg_opa: TRANSP + border_width: 0 + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + scrollable: false + scrollbar_mode: 'OFF' + widgets: + # ---------- TILE 1 ---------- + - obj: + id: tile1 + x: ${GRID_COL1_X} + y: ${GRID_ROW1_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile1_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile1_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE1_ICON}" + clickable: false + - label: + id: t1_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE1_TITLE}" + styles: style_title + clickable: false + - label: + id: t1_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);' + then: + - script.execute: { id: publish_action, action: "tile1_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE1_TYPE}" + tap_action: "${TILE1_TAP_ACTION}" + service: "${TILE1_TAP_SERVICE}" + entity_id: "${TILE1_ENTITY}" + param_key: "${TILE1_TAP_PARAM_KEY}" + param_val: "${TILE1_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE1_TYPE}") == "cover") + return id(tile1_cover_state).state != "closed"; + return std::string(id(ha_state_tile1).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);' + then: + - script.execute: { id: publish_action, action: "tile1_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE1_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE1_TYPE}") : t; + tap_action: "activate" + service: "${TILE1_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE1_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE1_TITLE}" + entity: "${TILE1_ENTITY}" + tile_type: "${TILE1_TYPE}" + longpress_mode: "${TILE1_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE1_TYPE}") == "cover") + return id(tile1_cover_state).state != "closed"; + return std::string(id(ha_state_tile1).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE1_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile1_brightness).state;" + percentage_value: !lambda "return id(tile1_percentage).state;" + position_value: !lambda "return id(tile1_cover_position).state;" + icon_text: "${TILE1_ICON}" + slider_bg_color: ${TILE1_BG_DISABLED_COLOR} + slider_fill_color: ${TILE1_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE1_ICON_ACTIVE_COLOR} + label_off: "${TILE1_LABEL_OFF}" + label_on: "${TILE1_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile1_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile1_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile1_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile1_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile1_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile1_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE1_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE1_CLIMATE_MAX_TEMP}" + climate_step: "${TILE1_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE1_TITLE}" + entity: "${TILE1_ENTITY}" + + # ---------- TILE 2 ---------- + - obj: + id: tile2 + x: ${GRID_COL2_X} + y: ${GRID_ROW1_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile2_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile2_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE2_ICON}" + clickable: false + - label: + id: t2_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE2_TITLE}" + styles: style_title + clickable: false + - label: + id: t2_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);' + then: + - script.execute: { id: publish_action, action: "tile2_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE2_TYPE}" + tap_action: "${TILE2_TAP_ACTION}" + service: "${TILE2_TAP_SERVICE}" + entity_id: "${TILE2_ENTITY}" + param_key: "${TILE2_TAP_PARAM_KEY}" + param_val: "${TILE2_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE2_TYPE}") == "cover") + return id(tile2_cover_state).state != "closed"; + return std::string(id(ha_state_tile2).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);' + then: + - script.execute: { id: publish_action, action: "tile2_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE2_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE2_TYPE}") : t; + tap_action: "activate" + service: "${TILE2_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE2_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE2_TITLE}" + entity: "${TILE2_ENTITY}" + tile_type: "${TILE2_TYPE}" + longpress_mode: "${TILE2_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE2_TYPE}") == "cover") + return id(tile2_cover_state).state != "closed"; + return std::string(id(ha_state_tile2).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE2_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile2_brightness).state;" + percentage_value: !lambda "return id(tile2_percentage).state;" + position_value: !lambda "return id(tile2_cover_position).state;" + icon_text: "${TILE2_ICON}" + slider_bg_color: ${TILE2_BG_DISABLED_COLOR} + slider_fill_color: ${TILE2_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE2_ICON_ACTIVE_COLOR} + label_off: "${TILE2_LABEL_OFF}" + label_on: "${TILE2_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile2_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile2_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile2_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile2_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile2_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile2_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE2_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE2_CLIMATE_MAX_TEMP}" + climate_step: "${TILE2_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE2_TITLE}" + entity: "${TILE2_ENTITY}" + + # ---------- TILE 3 ---------- + - obj: + id: tile3 + x: ${GRID_COL1_X} + y: ${GRID_ROW2_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile3_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile3_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE3_ICON}" + clickable: false + - label: + id: t3_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE3_TITLE}" + styles: style_title + clickable: false + - label: + id: t3_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);' + then: + - script.execute: { id: publish_action, action: "tile3_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE3_TYPE}" + tap_action: "${TILE3_TAP_ACTION}" + service: "${TILE3_TAP_SERVICE}" + entity_id: "${TILE3_ENTITY}" + param_key: "${TILE3_TAP_PARAM_KEY}" + param_val: "${TILE3_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE3_TYPE}") == "cover") + return id(tile3_cover_state).state != "closed"; + return std::string(id(ha_state_tile3).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);' + then: + - script.execute: { id: publish_action, action: "tile3_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE3_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE3_TYPE}") : t; + tap_action: "activate" + service: "${TILE3_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE3_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE3_TITLE}" + entity: "${TILE3_ENTITY}" + tile_type: "${TILE3_TYPE}" + longpress_mode: "${TILE3_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE3_TYPE}") == "cover") + return id(tile3_cover_state).state != "closed"; + return std::string(id(ha_state_tile3).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE3_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile3_brightness).state;" + percentage_value: !lambda "return id(tile3_percentage).state;" + position_value: !lambda "return id(tile3_cover_position).state;" + icon_text: "${TILE3_ICON}" + slider_bg_color: ${TILE3_BG_DISABLED_COLOR} + slider_fill_color: ${TILE3_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE3_ICON_ACTIVE_COLOR} + label_off: "${TILE3_LABEL_OFF}" + label_on: "${TILE3_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile3_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile3_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile3_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile3_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile3_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile3_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE3_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE3_CLIMATE_MAX_TEMP}" + climate_step: "${TILE3_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE3_TITLE}" + entity: "${TILE3_ENTITY}" + + # ---------- TILE 4 ---------- + - obj: + id: tile4 + x: ${GRID_COL2_X} + y: ${GRID_ROW2_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile4_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile4_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE4_ICON}" + clickable: false + - label: + id: t4_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE4_TITLE}" + styles: style_title + clickable: false + - label: + id: t4_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);' + then: + - script.execute: { id: publish_action, action: "tile4_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE4_TYPE}" + tap_action: "${TILE4_TAP_ACTION}" + service: "${TILE4_TAP_SERVICE}" + entity_id: "${TILE4_ENTITY}" + param_key: "${TILE4_TAP_PARAM_KEY}" + param_val: "${TILE4_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE4_TYPE}") == "cover") + return id(tile4_cover_state).state != "closed"; + return std::string(id(ha_state_tile4).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);' + then: + - script.execute: { id: publish_action, action: "tile4_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE4_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE4_TYPE}") : t; + tap_action: "activate" + service: "${TILE4_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE4_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE4_TITLE}" + entity: "${TILE4_ENTITY}" + tile_type: "${TILE4_TYPE}" + longpress_mode: "${TILE4_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE4_TYPE}") == "cover") + return id(tile4_cover_state).state != "closed"; + return std::string(id(ha_state_tile4).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE4_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile4_brightness).state;" + percentage_value: !lambda "return id(tile4_percentage).state;" + position_value: !lambda "return id(tile4_cover_position).state;" + icon_text: "${TILE4_ICON}" + slider_bg_color: ${TILE4_BG_DISABLED_COLOR} + slider_fill_color: ${TILE4_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE4_ICON_ACTIVE_COLOR} + label_off: "${TILE4_LABEL_OFF}" + label_on: "${TILE4_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile4_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile4_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile4_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile4_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile4_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile4_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE4_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE4_CLIMATE_MAX_TEMP}" + climate_step: "${TILE4_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE4_TITLE}" + entity: "${TILE4_ENTITY}" + + # ---------- TILE 5 ---------- + - obj: + id: tile5 + x: ${GRID_COL1_X} + y: ${GRID_ROW3_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile5_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile5_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE5_ICON}" + clickable: false + - label: + id: t5_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE5_TITLE}" + styles: style_title + clickable: false + - label: + id: t5_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);' + then: + - script.execute: { id: publish_action, action: "tile5_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE5_TYPE}" + tap_action: "${TILE5_TAP_ACTION}" + service: "${TILE5_TAP_SERVICE}" + entity_id: "${TILE5_ENTITY}" + param_key: "${TILE5_TAP_PARAM_KEY}" + param_val: "${TILE5_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE5_TYPE}") == "cover") + return id(tile5_cover_state).state != "closed"; + return std::string(id(ha_state_tile5).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);' + then: + - script.execute: { id: publish_action, action: "tile5_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE5_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE5_TYPE}") : t; + tap_action: "activate" + service: "${TILE5_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE5_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE5_TITLE}" + entity: "${TILE5_ENTITY}" + tile_type: "${TILE5_TYPE}" + longpress_mode: "${TILE5_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE5_TYPE}") == "cover") + return id(tile5_cover_state).state != "closed"; + return std::string(id(ha_state_tile5).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE5_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile5_brightness).state;" + percentage_value: !lambda "return id(tile5_percentage).state;" + position_value: !lambda "return id(tile5_cover_position).state;" + icon_text: "${TILE5_ICON}" + slider_bg_color: ${TILE5_BG_DISABLED_COLOR} + slider_fill_color: ${TILE5_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE5_ICON_ACTIVE_COLOR} + label_off: "${TILE5_LABEL_OFF}" + label_on: "${TILE5_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile5_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile5_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile5_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile5_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile5_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile5_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE5_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE5_CLIMATE_MAX_TEMP}" + climate_step: "${TILE5_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE5_TITLE}" + entity: "${TILE5_ENTITY}" + + # ---------- TILE 6 ---------- + - obj: + id: tile6 + x: ${GRID_COL2_X} + y: ${GRID_ROW3_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Directe lokale feedback: LVGL past dit toe zodra de vinger het + # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile6_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile6_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE6_ICON}" + clickable: false + - label: + id: t6_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE6_TITLE}" + styles: style_title + clickable: false + - label: + id: t6_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);' + then: + - script.execute: { id: publish_action, action: "tile6_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE6_TYPE}" + tap_action: "${TILE6_TAP_ACTION}" + service: "${TILE6_TAP_SERVICE}" + entity_id: "${TILE6_ENTITY}" + param_key: "${TILE6_TAP_PARAM_KEY}" + param_val: "${TILE6_TAP_PARAM_VAL}" + was_on: !lambda |- + if (std::string("${TILE6_TYPE}") == "cover") + return id(tile6_cover_state).state != "closed"; + return std::string(id(ha_state_tile6).state.c_str()) == "on"; + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);' + then: + - script.execute: { id: publish_action, action: "tile6_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE6_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE6_TYPE}") : t; + tap_action: "activate" + service: "${TILE6_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE6_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE6_TITLE}" + entity: "${TILE6_ENTITY}" + tile_type: "${TILE6_TYPE}" + longpress_mode: "${TILE6_LONGPRESS_SLIDER}" + was_on: !lambda |- + if (std::string("${TILE6_TYPE}") == "cover") + return id(tile6_cover_state).state != "closed"; + return std::string(id(ha_state_tile6).state.c_str()) == "on"; + off_value: !lambda "return atoi(\"${TILE6_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile6_brightness).state;" + percentage_value: !lambda "return id(tile6_percentage).state;" + position_value: !lambda "return id(tile6_cover_position).state;" + icon_text: "${TILE6_ICON}" + slider_bg_color: ${TILE6_BG_DISABLED_COLOR} + slider_fill_color: ${TILE6_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE6_ICON_ACTIVE_COLOR} + label_off: "${TILE6_LABEL_OFF}" + label_on: "${TILE6_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile6_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return id(tile6_climate_current_temperature).state;" + climate_target_temperature: !lambda "return id(tile6_climate_target_temperature).state;" + climate_humidity: !lambda "return id(tile6_climate_humidity).state;" + climate_mode: !lambda "return std::string(id(tile6_climate_mode).state.c_str());" + climate_supported_modes: !lambda "return std::string(id(tile6_climate_supported_modes).state.c_str());" + climate_min_temp: "${TILE6_CLIMATE_MIN_TEMP}" + climate_max_temp: "${TILE6_CLIMATE_MAX_TEMP}" + climate_step: "${TILE6_CLIMATE_STEP}" + # Long-press modus "vacuum": opent de stofzuigerbediening. + # Valt buiten de action/slider-keten, dus die blijft ongewijzigd. + - if: + condition: + lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"vacuum\";" + then: + - script.execute: + id: open_vacuum_overlay + title: "${TILE6_TITLE}" + entity: "${TILE6_ENTITY}" + + # ---------- TILE 7 — Tafellamp ---------- + # Alleen-helderheid lamp: er zijn bewust geen percentage/cover/ + # climate-sensors voor deze tegel aangemaakt, die zouden alleen + # maar HA-subscriptions en heap kosten. De overlay krijgt daarom + # NAN / "" voor de velden die een light nooit gebruikt. + - obj: + id: tile7 + hidden: true + x: ${GRID_COL1_X} + y: ${GRID_ROW4_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile7_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile7_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE7_ICON}" + clickable: false + - label: + id: t7_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE7_TITLE}" + styles: style_title + clickable: false + - label: + id: t7_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);' + then: + - script.execute: { id: publish_action, action: "tile7_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE7_TYPE}" + tap_action: "${TILE7_TAP_ACTION}" + service: "${TILE7_TAP_SERVICE}" + entity_id: "${TILE7_ENTITY}" + param_key: "${TILE7_TAP_PARAM_KEY}" + param_val: "${TILE7_TAP_PARAM_VAL}" + was_on: !lambda "return std::string(id(ha_state_tile7).state.c_str()) == \"on\";" + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);' + then: + - script.execute: { id: publish_action, action: "tile7_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE7_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE7_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE7_TYPE}") : t; + tap_action: "activate" + service: "${TILE7_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE7_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE7_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE7_TITLE}" + entity: "${TILE7_ENTITY}" + tile_type: "${TILE7_TYPE}" + longpress_mode: "${TILE7_LONGPRESS_SLIDER}" + was_on: !lambda "return std::string(id(ha_state_tile7).state.c_str()) == \"on\";" + off_value: !lambda "return atoi(\"${TILE7_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile7_brightness).state;" + percentage_value: !lambda "return NAN;" + position_value: !lambda "return NAN;" + icon_text: "${TILE7_ICON}" + slider_bg_color: ${TILE7_BG_DISABLED_COLOR} + slider_fill_color: ${TILE7_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE7_ICON_ACTIVE_COLOR} + label_off: "${TILE7_LABEL_OFF}" + label_on: "${TILE7_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile7_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return NAN;" + climate_target_temperature: !lambda "return NAN;" + climate_humidity: !lambda "return NAN;" + climate_mode: "" + climate_supported_modes: "" + climate_min_temp: "5.0" + climate_max_temp: "30.0" + climate_step: "0.5" + + # ---------- TILE 8 — Brievenbus reset ---------- + # input_select met opties Vol/Leeg. De tegel licht op zodra de + # status TILE8_ACTIVE_STATE ("Vol") is; een korte tik stuurt + # input_select.select_option met option: "Leeg". + # Geen slider-modus, dus deze tegel heeft alleen ha_state_tile8 + # nodig — verder geen enkele HA-subscription. + - obj: + id: tile8 + hidden: true + x: ${GRID_COL2_X} + y: ${GRID_ROW4_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile8_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile8_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE8_ICON}" + clickable: false + - label: + id: t8_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE8_TITLE}" + styles: style_title + clickable: false + - label: + id: t8_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);' + then: + - script.execute: { id: publish_action, action: "tile8_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE8_TYPE}" + tap_action: "${TILE8_TAP_ACTION}" + service: "${TILE8_TAP_SERVICE}" + entity_id: "${TILE8_ENTITY}" + param_key: "${TILE8_TAP_PARAM_KEY}" + param_val: "${TILE8_TAP_PARAM_VAL}" + was_on: !lambda "return std::string(id(ha_state_tile8).state.c_str()) == \"${TILE8_ACTIVE_STATE}\";" + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);' + then: + - script.execute: { id: publish_action, action: "tile8_long_press" } + # Slider-modus kan hier niet zonder eerst de bijbehorende + # sensors aan te maken; "action" werkt wel out of the box. + - if: + condition: + lambda: "return std::string(\"${TILE8_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE8_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE8_TYPE}") : t; + tap_action: "activate" + service: "${TILE8_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE8_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + + # ---------- TILE 9 — Woonkamer (verhuisd van tegel 2) ---------- + - obj: + id: tile9 + hidden: true + x: ${GRID_COL1_X} + y: ${GRID_ROW5_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile9_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile9_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE9_ICON}" + clickable: false + - label: + id: t9_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE9_TITLE}" + styles: style_title + clickable: false + - label: + id: t9_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);' + then: + - script.execute: { id: publish_action, action: "tile9_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE9_TYPE}" + tap_action: "${TILE9_TAP_ACTION}" + service: "${TILE9_TAP_SERVICE}" + entity_id: "${TILE9_ENTITY}" + param_key: "${TILE9_TAP_PARAM_KEY}" + param_val: "${TILE9_TAP_PARAM_VAL}" + was_on: !lambda "return std::string(id(ha_state_tile9).state.c_str()) == \"on\";" + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);' + then: + - script.execute: { id: publish_action, action: "tile9_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE9_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE9_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE9_TYPE}") : t; + tap_action: "activate" + service: "${TILE9_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE9_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + else: + - if: + condition: + lambda: "return std::string(\"${TILE9_LONGPRESS}\") == \"slider\";" + then: + - script.execute: + id: open_value_overlay + title: "${TILE9_TITLE}" + entity: "${TILE9_ENTITY}" + tile_type: "${TILE9_TYPE}" + longpress_mode: "${TILE9_LONGPRESS_SLIDER}" + was_on: !lambda "return std::string(id(ha_state_tile9).state.c_str()) == \"on\";" + off_value: !lambda "return atoi(\"${TILE9_LONGPRESS_OFF_VALUE}\");" + brightness_value: !lambda "return id(tile9_brightness).state;" + percentage_value: !lambda "return NAN;" + position_value: !lambda "return NAN;" + icon_text: "${TILE9_ICON}" + slider_bg_color: ${TILE9_BG_DISABLED_COLOR} + slider_fill_color: ${TILE9_CIRCLE_ACTIVE_COLOR} + slider_icon_color: ${TILE9_ICON_ACTIVE_COLOR} + label_off: "${TILE9_LABEL_OFF}" + label_on: "${TILE9_LABEL_ON}" + supported_color_modes: !lambda "return std::string(id(tile9_supported_color_modes).state.c_str());" + climate_current_temperature: !lambda "return NAN;" + climate_target_temperature: !lambda "return NAN;" + climate_humidity: !lambda "return NAN;" + climate_mode: "" + climate_supported_modes: "" + climate_min_temp: "5.0" + climate_max_temp: "30.0" + climate_step: "0.5" + + # ---------- TILE 10 — Alarm aan/uit ---------- + - obj: + id: tile10 + hidden: true + x: ${GRID_COL2_X} + y: ${GRID_ROW5_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile10_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: 54 + height: 54 + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile10_icon_lbl + align: CENTER + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE10_ICON}" + clickable: false + - label: + id: t10_title + width: 130 + long_mode: DOT + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "${TILE10_TITLE}" + styles: style_title + clickable: false + - label: + id: t10_value + width: 130 + long_mode: DOT + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + on_short_click: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);' + then: + - script.execute: { id: publish_action, action: "tile10_press" } + - script.execute: + id: do_tile_action + tile_type: "${TILE10_TYPE}" + tap_action: "${TILE10_TAP_ACTION}" + service: "${TILE10_TAP_SERVICE}" + entity_id: "${TILE10_ENTITY}" + param_key: "${TILE10_TAP_PARAM_KEY}" + param_val: "${TILE10_TAP_PARAM_VAL}" + was_on: !lambda "return std::string(id(ha_state_tile10).state.c_str()) == \"on\";" + on_long_press: + - if: + condition: + lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);' + then: + - script.execute: { id: publish_action, action: "tile10_long_press" } + - if: + condition: + lambda: "return std::string(\"${TILE10_LONGPRESS}\") == \"action\";" + then: + - script.execute: + id: do_tile_action + tile_type: !lambda |- + std::string t = "${TILE10_LONGPRESS_ACTION_TYPE}"; + return t.empty() ? std::string("${TILE10_TYPE}") : t; + tap_action: "activate" + service: "${TILE10_LONGPRESS_ACTION_SERVICE}" + entity_id: "${TILE10_LONGPRESS_ACTION}" + param_key: "" + param_val: "" + was_on: false + + # ---------- VALUE SLIDER OVERLAY ---------- + - obj: + id: page_prev + align: BOTTOM_LEFT + x: 14 + y: -4 + width: 150 + height: 44 + pad_all: 0 + radius: 12 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 0 + scrollable: false + on_short_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept(millis(), 11);' + then: + - lambda: 'id(tile_page) = 0;' + - script.execute: show_tile_page + widgets: + - label: + align: CENTER + text: "< Vorige" + text_font: sublabel + text_color: 0xFFFFFF + clickable: false + - label: + id: page_number + align: BOTTOM_MID + y: -18 + text: "1 / 2" + text_font: sublabel + text_color: 0xFFFFFF + - obj: + id: page_next + align: BOTTOM_RIGHT + x: -14 + y: -4 + width: 150 + height: 44 + pad_all: 0 + radius: 12 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 0 + scrollable: false + on_short_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept(millis(), 12);' + then: + - lambda: 'id(tile_page) = 1;' + - script.execute: show_tile_page + widgets: + - label: + align: CENTER + text: "Volgende >" + text_font: sublabel + text_color: 0xFFFFFF + clickable: false + + - obj: + id: brightness_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_opa: TRANSP + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: brightness_overlay_bg + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: style_bg_grad + scrollable: false + clickable: true + on_click: + - lvgl.widget.hide: brightness_overlay + + - obj: + id: brightness_overlay_dim + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x000000 + bg_opa: 18% + border_width: 0 + clickable: false + scrollable: false + + - label: + id: overlay_title + align: TOP_MID + y: 20 + text: "Light" + styles: style_room + + - label: + id: overlay_value + align: TOP_MID + y: 70 + text: "100 %" + styles: style_value_big + + - slider: + id: overlay_slider + align: CENTER + y: 36 + width: ${OVERLAY_SLIDER_W} + height: ${OVERLAY_SLIDER_H} + min_value: 0 + max_value: 100 + value: 100 + adv_hittest: false + + bg_color: 0x293F50 + bg_opa: 100% + radius: 42 + # Enige widget met ZOWEL radius ALS een kind (overlay_icon). + # LVGL snijdt kinderen dan bij op de ronding, en dat kan + # alleen via een ARGB-laag van 96x160 = 46KB aaneengesloten. + # Lukt die allocatie niet, dan blijft LVGL het proberen tot + # de task watchdog het bord herstart. Ronding blijft, het + # bijsnijden gaat eraf. + clip_corner: false + border_width: 0 + + indicator: + bg_color: 0xFEC600 + bg_opa: 100% + radius: 42 + + knob: + bg_opa: 0% + border_width: 0 + shadow_width: 0 + pad_all: 15 + + on_value: + - script.execute: + id: slider_preview + value: !lambda "return x;" + + on_release: + - script.execute: + id: slider_commit + value: !lambda "return lv_slider_get_value(id(overlay_slider));" + + + # Het icoon zat als KIND in de slider. Een widget met een radius + # en kinderen moet die kinderen bijsnijden op de ronding, en dat + # kan LVGL alleen via een ARGB-laag van tientallen KB's. Zonder + # PSRAM mislukt dat soms, waarna LVGL blijft hertesten tot de + # watchdog het bord herstart. Als broer eroverheen is er geen + # laag nodig en ziet het er identiek uit. + - label: + id: overlay_icon + align: CENTER + y: ${OVERLAY_ICON_Y} + text_font: materialdesign_icons + text_color: 0xFFFFFF + text: "${TILE1_ICON}" + clickable: false + + - obj: + id: overlay_color_button + align: BOTTOM_RIGHT + x: -20 + y: -26 + width: 66 + height: 66 + radius: 999 + hidden: true + clickable: true + bg_opa: TRANSP + border_width: 0 + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + scrollable: false + scrollbar_mode: 'OFF' + on_click: + - script.execute: open_color_detail + widgets: + - arc: + align: CENTER + width: 60 + height: 60 + start_angle: 0 + end_angle: 45 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 15 + arc_rounded: false + arc_color: 0xFF3B30 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 60 + height: 60 + start_angle: 45 + end_angle: 90 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 15 + arc_rounded: false + arc_color: 0xFF9500 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 60 + height: 60 + start_angle: 90 + end_angle: 135 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 15 + arc_rounded: false + arc_color: 0xFFD60A + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 60 + height: 60 + start_angle: 135 + end_angle: 180 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 15 + arc_rounded: false + arc_color: 0x30D158 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 60 + height: 60 + start_angle: 180 + end_angle: 225 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 15 + arc_rounded: false + arc_color: 0x32D6FF + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 60 + height: 60 + start_angle: 225 + end_angle: 270 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 15 + arc_rounded: false + arc_color: 0x0A84FF + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 60 + height: 60 + start_angle: 270 + end_angle: 315 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 15 + arc_rounded: false + arc_color: 0x5856D6 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: 60 + height: 60 + start_angle: 315 + end_angle: 360 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: 15 + arc_rounded: false + arc_color: 0xBF5AF2 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + + # ---------- LIGHT COLOR DETAIL OVERLAY ---------- + - obj: + id: color_detail_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x171719 + bg_opa: COVER + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: color_detail_bg + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: style_bg_grad + scrollable: false + clickable: false + - obj: + id: color_detail_scrim + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x000000 + bg_opa: 72% + border_width: 0 + clickable: false + scrollable: false + + - obj: + id: color_close_button + align: TOP_LEFT + x: 15 + y: 18 + width: 54 + height: 54 + radius: 999 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 20% + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: color_detail_overlay + widgets: + - label: + align: CENTER + text: "x" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - label: + id: color_detail_title + width: 310 + long_mode: DOT + text_align: CENTER + align: TOP_MID + y: 32 + text: "Light Color" + styles: style_room + + - obj: + id: color_done_button + align: TOP_RIGHT + x: -15 + y: 18 + width: 54 + height: 54 + radius: 999 + bg_color: 0xFF9F0A + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: color_detail_overlay + widgets: + - label: + align: CENTER + text: "\U000F012C" + text_font: materialdesign_icons + text_color: 0xFFFFFF + clickable: false + + # ---------- CLIMATE DETAIL OVERLAY ---------- + - obj: + id: climate_detail_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x171719 + bg_opa: COVER + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: climate_detail_bg + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: style_bg_grad + scrollable: false + clickable: false + - obj: + id: climate_detail_scrim + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_color: 0x000000 + bg_opa: 62% + border_width: 0 + clickable: false + scrollable: false + + - obj: + id: climate_close_button + align: TOP_LEFT + x: 15 + y: 18 + # 44 i.p.v. 36: iets groter doelvlak, groeit weg van de arc + # (top-left anchor blijft staan) i.p.v. ernaartoe. + width: 66 + height: 66 + radius: 999 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 20% + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_detail_overlay + widgets: + - label: + align: CENTER + text: "x" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_done_button + align: TOP_RIGHT + x: -15 + y: 18 + width: 66 + height: 66 + radius: 999 + bg_color: 0xFEC600 + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_detail_overlay + widgets: + - label: + align: CENTER + text: "\U000F012C" + text_font: materialdesign_icons + text_color: 0xFFFFFF + clickable: false + + - label: + id: climate_detail_title + align: TOP_MID + y: 32 + text: "Climate" + styles: style_room + + - label: + id: climate_current_label + align: TOP_MID + y: 84 + text: "Current: --" + text_font: sublabel_big + text_color: 0xD1E8FF + text_opa: 85% + clickable: false + + - arc: + id: climate_arc + align: CENTER + y: 40 + width: 276 + height: 276 + # Zonder dit claimt de arc een onzichtbare rand extra + # klikgebied buiten de 184x184 box — vlakbij de kruisje- + # knop linksboven ontstaat daardoor overlap. + ext_click_area: 0 + start_angle: 135 + end_angle: 45 + min_value: 50 + max_value: 300 + value: 200 + adjustable: true + arc_width: 30 + arc_rounded: true + arc_color: 0x4B4B4F + arc_opa: 80% + bg_opa: TRANSP + border_width: 0 + indicator: + arc_color: 0xFEC600 + arc_opa: COVER + arc_width: 30 + arc_rounded: true + knob: + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 0 + shadow_width: 0 + on_value: + - script.execute: + id: climate_target_preview + value: !lambda "return x;" + on_release: + - script.execute: + id: climate_target_commit + value: !lambda "return lv_arc_get_value(id(climate_arc));" + + - label: + id: climate_set_label + align: CENTER + y: -32 + text: "SET TO" + text_font: sublabel_big + text_color: 0xFFFFFF + text_opa: 68% + clickable: false + + - label: + id: climate_target_label + align: CENTER + y: 40 + text: "20.0°" + text_font: climate_number + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_minus_button + align: BOTTOM_LEFT + x: 57 + y: -32 + width: 72 + height: 72 + radius: 22 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 16% + clickable: true + scrollable: false + on_click: + - script.execute: + id: climate_adjust_target + direction: -1 + widgets: + - label: + align: CENTER + text: "-" + text_font: headline + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_plus_button + align: BOTTOM_RIGHT + x: -57 + y: -32 + width: 72 + height: 72 + radius: 22 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 16% + clickable: true + scrollable: false + on_click: + - script.execute: + id: climate_adjust_target + direction: 1 + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_mode_pill + align: BOTTOM_MID + y: -36 + width: 189 + height: 68 + radius: 26 + bg_color: 0x2C2C30 + bg_opa: 82% + border_width: 0 + clickable: true + scrollable: false + on_click: + - script.execute: open_climate_mode_picker + widgets: + - label: + id: climate_mode_label + align: CENTER + text: "Auto" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + # ---------- CLIMATE MODE PICKER ---------- + # Vervangt het doorcyclen van modi. Cyclen stuurt bij elke tik een + # set_hvac_mode, dus om van 'cool' naar 'dry' te komen zet je een + # warmtepomp onderweg ook echt in 'heat' en 'fan_only'. Hier kies je + # rechtstreeks, en wordt er precies een opdracht verstuurd. + - obj: + id: climate_mode_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + bg_color: 0x000000 + bg_opa: 90% + border_width: 0 + clickable: true + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + align: TOP_MID + y: 32 + text: "Mode" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + # Flex zorgt dat niet-ondersteunde modi geen gat achterlaten: + # LVGL slaat verborgen kinderen over bij het schikken. + - obj: + id: climate_mode_list + align: TOP_MID + y: 100 + width: 98% + height: 356 + bg_opa: TRANSP + border_width: 0 + scrollable: false + pad_all: 4 + layout: + type: FLEX + flex_flow: ROW_WRAP + flex_align_main: CENTER + flex_align_cross: CENTER + flex_align_track: CENTER + widgets: + - obj: + id: cmode_off + width: 198 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "off" } + widgets: + - label: + align: CENTER + text: "Off" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_heat + width: 198 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "heat" } + widgets: + - label: + align: CENTER + text: "Heat" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_cool + width: 198 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "cool" } + widgets: + - label: + align: CENTER + text: "Cool" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_heat_cool + width: 198 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "heat_cool" } + widgets: + - label: + align: CENTER + text: "Heat/Cool" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_auto + width: 198 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "auto" } + widgets: + - label: + align: CENTER + text: "Auto" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_dry + width: 198 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "dry" } + widgets: + - label: + align: CENTER + text: "Dry" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: cmode_fan_only + width: 198 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "fan_only" } + widgets: + - label: + align: CENTER + text: "Fan" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_mode_overlay_x + align: TOP_LEFT + x: 15 + y: 18 + width: 54 + height: 54 + radius: 999 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 20% + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_mode_overlay + widgets: + - label: + align: CENTER + text: "x" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: climate_mode_overlay_ok + align: TOP_RIGHT + x: -15 + y: 18 + width: 54 + height: 54 + radius: 999 + bg_color: 0xFEC600 + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_mode_overlay + widgets: + - label: + align: CENTER + text: "\U000F012C" + text_font: materialdesign_icons + text_color: 0xFFFFFF + clickable: false + + # ---------- VACUUM CONTROL ---------- + # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar + # het dock zijn losse opdrachten, en de zuigkracht is een aparte as. + - obj: + id: vacuum_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + bg_color: 0x000000 + bg_opa: 90% + border_width: 0 + clickable: true + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: vacuum_title + align: TOP_MID + y: 32 + text: "Vacuum" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: vacuum_actions + align: TOP_MID + y: 104 + width: 98% + height: 160 + bg_opa: TRANSP + border_width: 0 + scrollable: false + pad_all: 4 + layout: + type: FLEX + flex_flow: ROW_WRAP + flex_align_main: CENTER + flex_align_cross: CENTER + flex_align_track: CENTER + widgets: + - obj: + id: vac_start + width: 108 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: vacuum_action, service: "vacuum.start" } + widgets: + - label: + align: CENTER + text: "Start" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vac_pause + width: 108 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: vacuum_action, service: "vacuum.pause" } + widgets: + - label: + align: CENTER + text: "Pause" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vac_return_to_base + width: 108 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: vacuum_action, service: "vacuum.return_to_base" } + widgets: + - label: + align: CENTER + text: "Dock" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vac_stop + width: 108 + height: 76 + radius: 28 + bg_color: 0x2C2C30 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: vacuum_action, service: "vacuum.stop" } + widgets: + - label: + align: CENTER + text: "Stop" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - label: + align: TOP_MID + y: 276 + text: "Suction" + text_font: sublabel + text_color: 0xFFFFFF + clickable: false + + - obj: + id: vacuum_speeds + align: TOP_MID + y: 312 + width: 98% + height: 148 + bg_opa: TRANSP + border_width: 0 + scrollable: false + pad_all: 4 + layout: + type: FLEX + flex_flow: ROW_WRAP + flex_align_main: CENTER + flex_align_cross: CENTER + flex_align_track: CENTER + widgets: + - obj: + id: vacspd_quiet + width: 108 + height: 44 + radius: 28 + bg_color: 0x1F1F23 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(0) : "quiet";' + widgets: + - label: + align: CENTER + text: "Quiet" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vacspd_balanced + width: 108 + height: 44 + radius: 28 + bg_color: 0x1F1F23 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(1) : "balanced";' + widgets: + - label: + align: CENTER + text: "Balanced" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vacspd_turbo + width: 108 + height: 44 + radius: 28 + bg_color: 0x1F1F23 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(2) : "turbo";' + widgets: + - label: + align: CENTER + text: "Turbo" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + - obj: + id: vacspd_max + width: 108 + height: 44 + radius: 28 + bg_color: 0x1F1F23 + bg_opa: 92% + border_width: 0 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: + id: vacuum_fan_speed + speed: !lambda 'return runtime_tiles::enabled ? runtime_tiles::vacuum_option(3) : "max";' + widgets: + - label: + align: CENTER + text: "Max" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: vacuum_overlay_x + align: TOP_LEFT + x: 15 + y: 18 + width: 54 + height: 54 + radius: 999 + bg_color: 0x3C3C40 + bg_opa: COVER + border_width: 1 + border_color: 0xFFFFFF + border_opa: 20% + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: vacuum_overlay + widgets: + - label: + align: CENTER + text: "x" + text_font: sublabel_big + text_color: 0xFFFFFF + clickable: false + + - obj: + id: vacuum_overlay_ok + align: TOP_RIGHT + x: -15 + y: 18 + width: 54 + height: 54 + radius: 999 + bg_color: 0xFEC600 + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: vacuum_overlay + widgets: + - label: + align: CENTER + text: "\U000F012C" + text_font: materialdesign_icons + text_color: 0xFFFFFF + clickable: false + + # ---------- DIM / WAKE OVERLAY ---------- + - obj: + id: dim_wake_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + bg_opa: TRANSP + border_width: 0 + scrollable: false + scrollbar_mode: 'OFF' + clickable: true + on_click: + - script.execute: wake_display + + - id: touch_cal_page + bg_color: 0x111D2B + bg_opa: COVER + pad_all: 0 + border_width: 0 + scrollable: false + widgets: + - label: + align: TOP_MID + y: 110 + text: "GT911 touchcontrole" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 12 + y: 12 + width: 24 + height: 24 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 443 + y: 12 + width: 24 + height: 24 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 443 + y: 443 + width: 24 + height: 24 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 12 + y: 443 + width: 24 + height: 24 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + - obj: + x: 228 + y: 228 + width: 24 + height: 24 + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + text_color: 0xFFFFFF + +script: + - id: capture_ui_snapshot + mode: single + then: + - lambda: guition::snapshot_begin(); + - repeat: + count: 240 + then: + - lambda: guition::snapshot_next_row(); + - delay: 100ms + # Render every overlay and both pages repeatedly without controlling devices. + - id: ui_self_test + mode: single + then: + - logger.log: "UI_TEST START: page/overlay render stress, no HA actions" + - lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");' + - lambda: 'id(calibration_active) = false;' + - lvgl.page.show: home_page + - script.execute: wake_display + - repeat: + count: 10 + then: + - lambda: |- + id(last_touch_ms) = millis(); + id(tile_page) = 1; + - script.execute: show_tile_page + - delay: 150ms + - lambda: |- + lv_obj_update_layout(id(tile_scroll)); + const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); + const bool paginated = count > 6; + lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), + id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; + bool ok = guition::check_geometry(id(home_page)->obj); + for (int i = 0; i < 10; ++i) { + bool visible = i < count && (paginated ? i >= 6 : i < 6); + ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible); + } + for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) + ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != paginated); + lv_area_t nav, grid; + lv_obj_get_coords(id(page_next), &nav); + lv_obj_get_coords(id(tile_scroll), &grid); + ok = ok && (!paginated || grid.y2 < nav.y1); + ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1); + id(tile_page) = 0; + - script.execute: show_tile_page + - delay: 150ms + - lvgl.widget.show: brightness_overlay + - delay: 150ms + - lambda: guition::check_geometry(id(brightness_overlay)); + - lvgl.widget.hide: brightness_overlay + - lvgl.widget.show: color_detail_overlay + - delay: 150ms + - lambda: guition::check_geometry(id(color_detail_overlay)); + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.show: climate_detail_overlay + - delay: 150ms + - lambda: guition::check_geometry(id(climate_detail_overlay)); + - lvgl.widget.hide: climate_detail_overlay + - lvgl.widget.show: climate_mode_overlay + - delay: 150ms + - lambda: guition::check_geometry(id(climate_mode_overlay)); + - lvgl.widget.hide: climate_mode_overlay + - lvgl.widget.show: vacuum_overlay + - delay: 150ms + - lambda: guition::check_geometry(id(vacuum_overlay)); + - lvgl.widget.hide: vacuum_overlay + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + - logger.log: "UI_TEST COMPLETE" + + - id: show_tile_page + then: + - lambda: |- + lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), + id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; + const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; + const int count = runtime_tiles::enabled ? runtime_tiles::model.count : std::clamp(atoi("${TILE_COUNT}"), 1, 10); + const bool paginated = count > 6; + if (!paginated) id(tile_page) = 0; + for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { + if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN); + } + for (int i = 0; i < 10; ++i) { + bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6); + if (visible) { + lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]); + lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); + } + } + lv_label_set_text(id(page_number), id(tile_page) == 0 ? "1 / 2" : "2 / 2"); + if (id(tile_page) == 0) { + lv_obj_add_state(id(page_prev), LV_STATE_DISABLED); + lv_obj_clear_state(id(page_next), LV_STATE_DISABLED); + } else { + lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED); + lv_obj_add_state(id(page_next), LV_STATE_DISABLED); + } + + - id: wake_display + mode: restart + then: + - lambda: |- + id(last_touch_ms) = millis(); + id(display_dimmed) = false; + - light.turn_on: + id: back_light + # Bijna instant omhoog. De ESPHome-standaard is 1s, en dat voelt traag + # als je het scherm aanraakt en op licht staat te wachten. + transition_length: 80ms + brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;" + - lvgl.widget.hide: dim_wake_overlay + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.hide: climate_detail_overlay + - lvgl.widget.hide: climate_mode_overlay + - lvgl.widget.hide: vacuum_overlay + + - id: dim_display + mode: restart + then: + - if: + condition: + lambda: 'return std::string("${AUTO_DIM}") == "true";' + then: + - lambda: |- + int dim_value = atoi("${DIM_BRIGHTNESS}"); + int max_value = atoi("${MAX_BRIGHTNESS}"); + + if (std::string("${NIGHT_MODE}") == "true") { + auto now = id(homeassistant_time).now(); + // Vóór de eerste tijd-sync (zie time: hierboven) is now() + // epoch 0 — dan geen nachtmodus-beslissing nemen op basis + // van een ongeldig uur, gewoon normale DIM_BRIGHTNESS. + if (now.is_valid()) { + int hour = now.hour; + int start_hour = atoi("${NIGHT_START_HOUR}"); + int end_hour = atoi("${NIGHT_END_HOUR}"); + bool is_night = false; + if (start_hour < end_hour) { + is_night = (hour >= start_hour && hour < end_hour); + } else { + is_night = (hour >= start_hour || hour < end_hour); + } + if (is_night) { + dim_value = atoi("${NIGHT_DIM_BRIGHTNESS}"); + } + } + } + + // Rustig omlaag faden is prettig; omhoog moet juist snel. + id(back_light).turn_on().set_transition_length(900).set_brightness(dim_value / 100.0f).perform(); + + // Always mark as dimmed to stop the interval from re-firing + id(display_dimmed) = true; + // Only show the touch-blocking overlay when brightness actually changes + if (dim_value < max_value) { + lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); + } + + # Terug naar het basisscherm bij standby. Bewust via de ESPHome + # acties en NIET met lv_obj_add_flag in een lambda: dit script + # draait vanuit een interval, dus buiten LVGL's eigen afhandeling. + - lvgl.widget.hide: brightness_overlay + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.hide: climate_detail_overlay + - lvgl.widget.hide: climate_mode_overlay + - lvgl.widget.hide: vacuum_overlay + + - id: publish_action + mode: restart + parameters: + action: string + then: + - logger.log: + level: INFO + format: "publish_action: %s" + args: ["action.c_str()"] + - text_sensor.template.publish: + id: smartdisplay_action + state: !lambda 'return action;' + - delay: 500ms + - text_sensor.template.publish: + id: smartdisplay_action + state: "" + + - id: climate_target_preview + parameters: + value: int + then: + - lambda: |- + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + int min_x10 = id(active_climate_min_x10); + int max_x10 = id(active_climate_max_x10); + value = cyd::quantize_temperature(value, min_x10, max_x10, + id(active_climate_step_x10)); + id(active_climate_target_x10) = value; + + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f°", value / 10.0f); + lv_label_set_text(id(climate_target_label), buf); + lv_arc_set_value(id(climate_arc), value); + + - id: climate_target_commit + parameters: + value: int + then: + - script.execute: + id: climate_target_preview + value: !lambda "return value;" + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' + then: + - homeassistant.action: + action: climate.set_temperature + data: + entity_id: !lambda "return id(active_entity);" + temperature: !lambda "return id(active_climate_target_x10) / 10.0f;" + + - id: climate_adjust_target + parameters: + direction: int + then: + - lambda: |- + int step_x10 = id(active_climate_step_x10); + if (step_x10 < 5) step_x10 = 5; + int next = id(active_climate_target_x10) + (direction * step_x10); + if (next < id(active_climate_min_x10)) next = id(active_climate_min_x10); + if (next > id(active_climate_max_x10)) next = id(active_climate_max_x10); + id(active_climate_target_x10) = next; + - script.execute: + id: climate_target_commit + value: !lambda "return id(active_climate_target_x10);" + + # Opent de moduskiezer. Verbergt modi die de entiteit niet ondersteunt en + # markeert de huidige, zodat je ziet waar je staat voor je iets kiest. + # Opent de stofzuigerbediening en onthoudt om welke entiteit het gaat. + - id: open_vacuum_overlay + mode: restart + parameters: + title: string + entity: string + then: + - lambda: |- + id(active_entity) = entity; + lv_label_set_text(id(vacuum_title), title.c_str()); + - lvgl.widget.show: vacuum_overlay + + # Een losse opdracht: start, pause, stop of return_to_base. + - id: vacuum_action + mode: queued + parameters: + service: string + then: + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' + then: + - homeassistant.action: + action: !lambda "return service;" + data: + entity_id: !lambda "return id(active_entity);" + + # Zuigkracht is een aparte as, los van start/stop. + - id: vacuum_fan_speed + mode: queued + parameters: + speed: string + then: + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' + then: + - homeassistant.action: + action: vacuum.set_fan_speed + data: + entity_id: !lambda "return id(active_entity);" + fan_speed: !lambda "return speed;" + + - id: open_climate_mode_picker + mode: restart + then: + - lambda: |- + auto lower = [](std::string v) { + for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); } + return v; + }; + std::string supported = lower(id(active_climate_supported_modes)); + std::string current = lower(id(active_climate_mode)); + + auto has = [&](const char* m) { + std::string mode(m); + if (supported.empty() || supported == "unknown" || supported == "unavailable") return false; + return supported.find("'" + mode + "'") != std::string::npos || + supported.find("\"" + mode + "\"") != std::string::npos; + }; + auto apply = [&](lv_obj_t* btn, const char* m) { + if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); + bool selected = (current == std::string(m)); + lv_obj_set_style_bg_color(btn, + lv_color_hex(selected ? 0xFF9F0A : 0x2C2C30), LV_PART_MAIN); + }; + apply(id(cmode_off), "off"); + apply(id(cmode_heat), "heat"); + apply(id(cmode_cool), "cool"); + apply(id(cmode_heat_cool), "heat_cool"); + apply(id(cmode_auto), "auto"); + apply(id(cmode_dry), "dry"); + apply(id(cmode_fan_only), "fan_only"); + - lvgl.widget.show: climate_mode_overlay + + # Zet precies een modus. Geen tussenstappen, dus geen ongewenste + # commando's naar de warmtepomp onderweg. + - id: set_climate_mode + mode: restart + parameters: + mode: string + then: + - lambda: |- + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + id(active_climate_mode) = mode; + if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off"); + if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat"); + if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool"); + if (mode == "heat_cool") lv_label_set_text(id(climate_mode_label), "Heat/Cool"); + if (mode == "auto") lv_label_set_text(id(climate_mode_label), "Auto"); + if (mode == "dry") lv_label_set_text(id(climate_mode_label), "Dry"); + if (mode == "fan_only") lv_label_set_text(id(climate_mode_label), "Fan"); + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";' + then: + - homeassistant.action: + action: climate.set_hvac_mode + data: + entity_id: !lambda "return id(active_entity);" + hvac_mode: !lambda "return id(active_climate_mode);" + + # Isolated 40-second preview; every light commit is suppressed in the helper. + - id: light_controls_preview + mode: single + then: + - script.execute: wake_display + - lambda: |- + light_controls::demo = true; + light_controls::fallback.minimum = 2000; + light_controls::fallback.maximum = 6500; + light_controls::fallback.kelvin = 3000; + light_controls::fallback.hue = 200; + light_controls::open("diagnostic", true, true, 75); + lv_label_set_text(id(color_detail_title), "Voorbeeld lamp"); + - lvgl.widget.hide: color_close_button + - lvgl.widget.hide: color_done_button + - lvgl.widget.show: color_detail_overlay + - delay: 40s + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.show: color_close_button + - lvgl.widget.show: color_done_button + - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' + + - id: open_color_detail + mode: restart + then: + - lambda: |- + lv_label_set_text(id(color_detail_title), id(active_title).c_str()); + light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); + - lvgl.widget.hide: brightness_overlay + - lvgl.widget.show: color_detail_overlay + + - id: color_brightness_commit + parameters: + value: int + then: + - lambda: 'id(active_brightness_pct) = value;' + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_entity).rfind("light.", 0) == 0;' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + brightness_pct: !lambda "return value;" + + - id: set_light_hue + parameters: + value: int + then: + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + data_template: + hs_color: "{{ [hue | int, 100] }}" + variables: + hue: !lambda "return light_controls::clamp(value, 0, 360);" + + - id: set_light_color_temperature + parameters: + kelvin: int + then: + - if: + condition: + lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" + + - id: slider_preview + parameters: + value: int + then: + - lambda: |- + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str()); + lv_label_set_text(id(overlay_value), buf); + int pct = value; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + std::string value_text; + + if (id(active_control_kind) == "cover_position") { + std::string closed_label = id(active_label_off).c_str(); + std::string open_label = id(active_label_on).c_str(); + + if (pct <= 0) { + value_text = closed_label; + } else if (pct >= 100) { + value_text = open_label; + } else { + char buf[40]; + snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct); + value_text = buf; + } + } else { + if (pct <= 0) { + value_text = id(active_label_off).c_str(); + } else { + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); + value_text = buf; + } + } + + lv_label_set_text(id(overlay_value), value_text.c_str()); + + + - id: slider_commit + parameters: + value: int + then: + - if: + condition: + lambda: |- + return id(active_control_kind) == "light_brightness"; + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + brightness_pct: !lambda "return value;" + else: + - if: + condition: + lambda: |- + return id(active_control_kind) == "fan_percentage"; + then: + - if: + condition: + lambda: "return value <= 0;" + then: + - homeassistant.action: + action: fan.turn_off + data: + entity_id: !lambda "return id(active_entity);" + else: + - homeassistant.action: + action: fan.turn_on + data: + entity_id: !lambda "return id(active_entity);" + - delay: 150ms + - homeassistant.action: + action: fan.set_percentage + data: + entity_id: !lambda "return id(active_entity);" + percentage: !lambda "return value;" + else: + - if: + condition: + lambda: |- + return id(active_control_kind) == "cover_position"; + then: + - homeassistant.action: + action: cover.set_cover_position + data: + entity_id: !lambda "return id(active_entity);" + position: !lambda "return value;" + + # ---------------------------------------------------------- + # do_tile_action + # + # Centralized handler for executing Home Assistant actions + # triggered by tile taps. + # + # Parameters + # - tile_type: light | fan | switch | scene | script | cover + # - tap_action: auto | toggle | activate | fan_toggle_preset + # - service: optional Home Assistant service override + # - entity_id: target entity + # - param_key / param_val: optional parameters for custom services + # - was_on: previous entity state (pass false for longpress actions) + # + # Behavior + # + # toggle + # -> toggles the entity + # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on) + # + # activate + # -> activates entity + # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover + # + # auto + # -> resolves automatically based on tile_type: + # light/switch/fan/cover -> toggle + # scene/script -> activate + # + # fan_toggle_preset + # -> if fan was ON: fan.turn_off + # -> if fan was OFF: + # fan.turn_on + # wait 150ms + # fan.set_preset_mode(param_val) + # + # Custom service + # If "service" is set, it will be called directly. + # param_key / param_val may be used for parameters + # like preset_mode, brightness_pct, percentage, etc. + # ---------------------------------------------------------- + - id: do_tile_action + mode: queued + parameters: + tile_type: string + tap_action: string + service: string + entity_id: string + param_key: string + param_val: string + was_on: bool + then: + - if: + condition: + lambda: |- + if (std::string("${DIRECT_ACTIONS}") != "true") return false; + // Debounce: zelfde entity binnen 600ms genegeerd. Voorkomt dat + // touch-ruis dezelfde HA-actie meerdere keren afvuurt (zie + // globals hierboven voor waarom dit nodig is). + uint32_t now = millis(); + if (entity_id == id(last_action_entity) && (now - id(last_action_ms)) < 600) { + ESP_LOGD("tile", "do_tile_action: debounced repeat for %s (%ums na vorige)", + entity_id.c_str(), now - id(last_action_ms)); + return false; + } + id(last_action_entity) = entity_id; + id(last_action_ms) = now; + return true; + then: + - lambda: |- + ESP_LOGD("tile", "do_tile_action type=%s action=%s service=%s entity=%s key=%s val=%s was_on=%d", + tile_type.c_str(), tap_action.c_str(), service.c_str(), entity_id.c_str(), + param_key.c_str(), param_val.c_str(), (int)was_on); + + - if: + condition: + lambda: |- + return tap_action == "fan_toggle_preset"; + then: + - if: + condition: + lambda: |- + return was_on; + then: + - homeassistant.action: + action: fan.turn_off + data: + entity_id: !lambda 'return entity_id;' + else: + - homeassistant.action: + action: fan.turn_on + data: + entity_id: !lambda 'return entity_id;' + - delay: 150ms + - homeassistant.action: + action: fan.set_preset_mode + data: + entity_id: !lambda 'return entity_id;' + preset_mode: !lambda 'return param_val;' + + else: + - if: + condition: + lambda: |- + std::string resolved = tap_action; + if (resolved == "auto") { + if (tile_type == "scene" || tile_type == "script") resolved = "activate"; + else resolved = "toggle"; + } + return resolved == "toggle"; + then: + - if: + condition: + lambda: |- + return service.size() > 0; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "light"; + then: + - homeassistant.action: + action: light.toggle + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "fan"; + then: + - homeassistant.action: + action: fan.toggle + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "switch"; + then: + - homeassistant.action: + action: switch.toggle + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "cover"; + then: + - homeassistant.action: + action: cover.set_cover_position + data: + entity_id: !lambda 'return entity_id;' + position: !lambda |- + return was_on ? 0 : 100; + + else: + - if: + condition: + lambda: |- + std::string resolved = tap_action; + if (resolved == "auto") { + if (tile_type == "scene" || tile_type == "script") resolved = "activate"; + else resolved = "toggle"; + } + return resolved == "activate"; + then: + - if: + condition: + lambda: |- + return service.size() > 0; + then: + - if: + condition: + lambda: |- + return param_key == "preset_mode"; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + preset_mode: !lambda 'return param_val;' + else: + - if: + condition: + lambda: |- + return param_key == "brightness_pct"; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + brightness_pct: !lambda 'return atoi(param_val.c_str());' + else: + - if: + condition: + lambda: |- + return param_key == "percentage"; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + percentage: !lambda 'return atoi(param_val.c_str());' + else: + - if: + condition: + lambda: |- + return param_key == "option"; + then: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + option: !lambda 'return param_val;' + else: + - homeassistant.action: + action: !lambda 'return service;' + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "scene"; + then: + - homeassistant.action: + action: scene.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "script"; + then: + - homeassistant.action: + action: script.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "light"; + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "fan"; + then: + - homeassistant.action: + action: fan.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "switch"; + then: + - homeassistant.action: + action: switch.turn_on + data: + entity_id: !lambda 'return entity_id;' + else: + - if: + condition: + lambda: |- + return tile_type == "cover"; + then: + - homeassistant.action: + action: cover.open_cover + data: + entity_id: !lambda 'return entity_id;' + + - id: open_value_overlay + parameters: + title: string + entity: string + tile_type: string + longpress_mode: string + was_on: bool + off_value: int + brightness_value: float + percentage_value: float + position_value: float + icon_text: string + slider_bg_color: uint32_t + slider_fill_color: uint32_t + slider_icon_color: uint32_t + label_off: string + label_on: string + supported_color_modes: string + climate_current_temperature: float + climate_target_temperature: float + climate_humidity: float + climate_mode: string + climate_supported_modes: string + climate_min_temp: string + climate_max_temp: string + climate_step: string + then: + - lambda: |- + // Accentkleur uit het Home Assistant-domein: het stuk voor de punt in + // het entity_id. script/scene/button vuur je af (ACCENT_ACTION), een + // light is een light (ACCENT_LIGHT), de rest is apparaat. + // LET OP: deze helper staat twee keer in dit bestand — hier en in + // open_value_overlay. Pas ze samen aan. + auto accent_for = [](const std::string& entity_id) -> uint32_t { + std::string d = entity_id.substr(0, entity_id.find('.')); + if (d == "script" || d == "scene" || d == "button" || + d == "input_button" || d == "automation") return ${ACCENT_ACTION}; + if (d == "light") return ${ACCENT_LIGHT}; + return ${ACCENT_DEVICE}; + }; + + // De sliderkleur komt van de tegel, dus die kan ook ${ACCENT_AUTO} zijn. + uint32_t fill_color = (slider_fill_color == ${ACCENT_AUTO}) + ? accent_for(entity) : slider_fill_color; + + std::string mode = longpress_mode; + auto contains_mode = [](const std::string& modes, const std::string& needle) { + return modes.find(needle) != std::string::npos; + }; + if (mode == "auto") { + if (tile_type == "light") mode = "brightness"; + else if (tile_type == "fan") mode = "percentage"; + else if (tile_type == "cover") mode = "cover_position"; + else if (tile_type == "climate") mode = "climate_temperature"; + else mode = "none"; + } + + if (mode == "none") return; + + id(active_entity) = entity; + id(active_value_suffix) = " %"; + id(active_label_off) = label_off; + id(active_label_on) = label_on; + id(active_title) = title; + id(active_icon_text) = icon_text; + id(active_supports_color) = false; + id(active_supports_temperature) = false; + lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); + + if (mode == "climate_temperature") { + auto to_x10 = [](float value, int fallback) -> int { + if (isnan(value)) return fallback; + return (int) lround(value * 10.0f); + }; + auto parse_x10 = [](const std::string& value, int fallback) -> int { + if (value.empty()) return fallback; + return (int) lround(atof(value.c_str()) * 10.0f); + }; + id(climate_edit_pending) = false; + id(active_control_kind) = "climate_temperature"; + id(active_climate_min_x10) = parse_x10(climate_min_temp, 50); + id(active_climate_max_x10) = parse_x10(climate_max_temp, 300); + id(active_climate_step_x10) = parse_x10(climate_step, 5); + if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5; + if (id(active_climate_max_x10) <= id(active_climate_min_x10)) { + id(active_climate_min_x10) = 50; + id(active_climate_max_x10) = 300; + } + + id(active_climate_current_x10) = to_x10(climate_current_temperature, 0); + int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10); + id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target); + if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); + if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); + id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10); + if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); + if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); + id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity); + id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode; + id(active_climate_supported_modes) = climate_supported_modes; + + lv_label_set_text(id(climate_detail_title), title.c_str()); + + char current_buf[48]; + if (isnan(climate_current_temperature)) { + snprintf(current_buf, sizeof(current_buf), "Current: --"); + } else if (id(active_climate_humidity) >= 0) { + snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", climate_current_temperature, id(active_climate_humidity)); + } else { + snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", climate_current_temperature); + } + lv_label_set_text(id(climate_current_label), current_buf); + + char target_buf[16]; + snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); + lv_label_set_text(id(climate_target_label), target_buf); + lv_label_set_text(id(climate_mode_label), id(active_climate_mode).c_str()); + + lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10)); + lv_arc_set_value(id(climate_arc), id(active_climate_target_x10)); + + if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) { + lv_obj_set_size(id(climate_arc), 294, 294); + lv_obj_set_y(id(climate_arc), 16); + lv_obj_set_y(id(climate_current_label), 96); + lv_obj_set_y(id(climate_minus_button), -124); + lv_obj_set_y(id(climate_plus_button), -124); + lv_obj_set_y(id(climate_mode_pill), -36); + } else { + lv_obj_set_size(id(climate_arc), 264, 264); + lv_obj_set_y(id(climate_arc), 36); + lv_obj_set_y(id(climate_current_label), 80); + lv_obj_set_y(id(climate_minus_button), -28); + lv_obj_set_y(id(climate_plus_button), -28); + lv_obj_set_y(id(climate_mode_pill), -30); + } + + lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); + return; + } + + int pct = off_value; + + if (mode == "brightness") { + id(active_control_kind) = "light_brightness"; + if (was_on) { + if (isnan(brightness_value)) pct = 100; + else pct = (int) lround((brightness_value / 255.0f) * 100.0f); + } + } else if (mode == "percentage") { + id(active_control_kind) = "fan_percentage"; + if (was_on) { + if (isnan(percentage_value)) pct = 0; + else pct = (int) lround(percentage_value); + } + } else if (mode == "cover_position") { + id(active_control_kind) = "cover_position"; + if (isnan(position_value)) pct = was_on ? 100 : 0; + else pct = (int) lround(position_value); + } else { + return; + } + + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + id(active_brightness_pct) = pct; + + if (tile_type == "light" && std::string("${DIRECT_ACTIONS}") == "true") { + id(active_supports_color) = + contains_mode(supported_color_modes, "hs") || + contains_mode(supported_color_modes, "rgb") || + contains_mode(supported_color_modes, "rgbw") || + contains_mode(supported_color_modes, "rgbww") || + contains_mode(supported_color_modes, "xy"); + id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp"); + + if (id(active_supports_color) || id(active_supports_temperature)) { + lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); + } + } + + if (tile_type == "cover") { + lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(fill_color), LV_PART_MAIN); + lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(slider_bg_color), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); + } + else{ + lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(slider_bg_color), LV_PART_MAIN); + lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(fill_color), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); + } + lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN); + lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR); + + lv_label_set_text(id(overlay_title), title.c_str()); + lv_label_set_text(id(overlay_icon), icon_text.c_str()); + lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF); + + std::string value_text; + + if (tile_type == "cover") { + if (pct <= 0) { + value_text = label_off; + } else if (pct >= 100) { + value_text = label_on; + } else { + char buf[40]; + snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct); + value_text = buf; + } + } else { + if (pct <= 0) { + value_text = label_off; + } else { + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); + value_text = buf; + } + } + + lv_label_set_text(id(overlay_value), value_text.c_str()); + + lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR); + + lv_obj_set_style_text_color(id(overlay_icon), lv_color_hex(slider_icon_color), 0); + + - if: + condition: + lambda: 'return id(active_control_kind) != "climate_temperature";' + then: + - if: + condition: + lambda: 'return id(active_supports_color) || id(active_supports_temperature);' + then: + - script.execute: open_color_detail + else: + - lvgl.widget.show: brightness_overlay + + # UI refresh (values + time + styles) + render + - id: ui_refresh + mode: single + then: + - delay: 100ms + - lambda: |- + if (runtime_tiles::enabled) { + runtime_tiles::render(id(lbl_room)); + auto now = id(homeassistant_time).now(); + if (now.is_valid()) runtime_tiles::label(id(lbl_time), now.strftime("%H:%M")); + return; + } + auto set_lbl = [](lv_obj_t* lbl, const std::string& s) { + if (lbl && s != lv_label_get_text(lbl)) lv_label_set_text(lbl, s.c_str()); + }; + + auto set_value_auto = [&](lv_obj_t* lbl, + bool on, + const std::string& tile_type, + const std::string& value_mode, + float brightness, + float percentage, + const std::string& preset, + float climate_current_temperature, + float climate_target_temperature, + float climate_humidity, + const std::string& label_on, + const std::string& label_off) { + std::string mode = value_mode; + + if (mode == "auto" || mode.empty()) { + if (tile_type == "light") { + mode = "brightness"; + } else if (tile_type == "fan") { + mode = preset.empty() ? "percentage" : "preset"; + } else if (tile_type == "climate") { + mode = "climate"; + } else { + mode = "text"; + } + } + + if (mode == "brightness") { + if (!on) { + set_lbl(lbl, label_off); + return; + } + if (isnan(brightness)) { + set_lbl(lbl, "100 %"); + return; + } + int pct = (int) lround((brightness / 255.0f) * 100.0f); + char buf[8]; + snprintf(buf, sizeof(buf), "%d %%", pct); + set_lbl(lbl, std::string(buf)); + return; + } + + if (mode == "percentage") { + if (!on) { + set_lbl(lbl, label_off); + return; + } + int pct = isnan(percentage) ? 0 : (int) lround(percentage); + char buf[8]; + snprintf(buf, sizeof(buf), "%d %%", pct); + set_lbl(lbl, std::string(buf)); + return; + } + + if (mode == "preset") { + if (!on) { + set_lbl(lbl, label_off); + return; + } + if (!preset.empty()) { + set_lbl(lbl, preset); + } else { + set_lbl(lbl, label_on); + } + return; + } + + if (mode == "climate") { + if (!on) { + set_lbl(lbl, label_off); + return; + } + + float temp = isnan(climate_target_temperature) ? climate_current_temperature : climate_target_temperature; + if (isnan(temp)) { + set_lbl(lbl, label_on); + return; + } + + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f °", temp); + set_lbl(lbl, std::string(buf)); + return; + } + + if (mode == "text") { + if (tile_type == "scene" || tile_type == "script") { + set_lbl(lbl, label_on); + } else { + set_lbl(lbl, on ? label_on : label_off); + } + return; + } + + set_lbl(lbl, "—"); + }; + + // active_state niet leeg? Dan telt alleen die exacte HA-state als "aan". + // Zo kan een input_select ("Vol"/"Leeg") ook een tegel laten oplichten. + auto is_active = [](const std::string& tile_type, const std::string& state, + const std::string& active_state = std::string()) { + if (!active_state.empty()) return state == active_state; + if (tile_type == "climate") { + return !state.empty() && state != "off" && state != "unavailable" && state != "unknown"; + } + if (tile_type == "vacuum") return state == "cleaning" || state == "returning"; + if (tile_type == "scene" || tile_type == "script") return true; + return state == "on"; + }; + + // Cover-aware active state: "closed" = off, anything else (open/opening/closing) = on + bool t1_on = (std::string("${TILE1_TYPE}") == "cover") ? (id(tile1_cover_state).state != "closed") : is_active("${TILE1_TYPE}", std::string(id(ha_state_tile1).state.c_str()), "${TILE1_ACTIVE_STATE}"); + bool t2_on = (std::string("${TILE2_TYPE}") == "cover") ? (id(tile2_cover_state).state != "closed") : is_active("${TILE2_TYPE}", std::string(id(ha_state_tile2).state.c_str()), "${TILE2_ACTIVE_STATE}"); + bool t3_on = (std::string("${TILE3_TYPE}") == "cover") ? (id(tile3_cover_state).state != "closed") : is_active("${TILE3_TYPE}", std::string(id(ha_state_tile3).state.c_str()), "${TILE3_ACTIVE_STATE}"); + bool t4_on = (std::string("${TILE4_TYPE}") == "cover") ? (id(tile4_cover_state).state != "closed") : is_active("${TILE4_TYPE}", std::string(id(ha_state_tile4).state.c_str()), "${TILE4_ACTIVE_STATE}"); + bool t5_on = (std::string("${TILE5_TYPE}") == "cover") ? (id(tile5_cover_state).state != "closed") : is_active("${TILE5_TYPE}", std::string(id(ha_state_tile5).state.c_str()), "${TILE5_ACTIVE_STATE}"); + bool t6_on = (std::string("${TILE6_TYPE}") == "cover") ? (id(tile6_cover_state).state != "closed") : is_active("${TILE6_TYPE}", std::string(id(ha_state_tile6).state.c_str()), "${TILE6_ACTIVE_STATE}"); + // Tegel 7/8 hebben geen cover_state-sensor, dus geen cover-tak nodig. + bool t7_on = is_active("${TILE7_TYPE}", std::string(id(ha_state_tile7).state.c_str()), "${TILE7_ACTIVE_STATE}"); + bool t8_on = is_active("${TILE8_TYPE}", std::string(id(ha_state_tile8).state.c_str()), "${TILE8_ACTIVE_STATE}"); + bool t9_on = is_active("${TILE9_TYPE}", std::string(id(ha_state_tile9).state.c_str()), "${TILE9_ACTIVE_STATE}"); + bool t10_on = is_active("${TILE10_TYPE}", std::string(id(ha_state_tile10).state.c_str()), "${TILE10_ACTIVE_STATE}"); + + set_value_auto(id(t1_value), t1_on, "${TILE1_TYPE}", "${TILE1_VALUE_MODE}", + id(tile1_brightness).state, id(tile1_percentage).state, std::string(id(tile1_preset).state.c_str()), + id(tile1_climate_current_temperature).state, id(tile1_climate_target_temperature).state, id(tile1_climate_humidity).state, + "${TILE1_LABEL_ON}", "${TILE1_LABEL_OFF}"); + + set_value_auto(id(t2_value), t2_on, "${TILE2_TYPE}", "${TILE2_VALUE_MODE}", + id(tile2_brightness).state, id(tile2_percentage).state, std::string(id(tile2_preset).state.c_str()), + id(tile2_climate_current_temperature).state, id(tile2_climate_target_temperature).state, id(tile2_climate_humidity).state, + "${TILE2_LABEL_ON}", "${TILE2_LABEL_OFF}"); + + set_value_auto(id(t3_value), t3_on, "${TILE3_TYPE}", "${TILE3_VALUE_MODE}", + id(tile3_brightness).state, id(tile3_percentage).state, std::string(id(tile3_preset).state.c_str()), + id(tile3_climate_current_temperature).state, id(tile3_climate_target_temperature).state, id(tile3_climate_humidity).state, + "${TILE3_LABEL_ON}", "${TILE3_LABEL_OFF}"); + + set_value_auto(id(t4_value), t4_on, "${TILE4_TYPE}", "${TILE4_VALUE_MODE}", + id(tile4_brightness).state, id(tile4_percentage).state, std::string(id(tile4_preset).state.c_str()), + id(tile4_climate_current_temperature).state, id(tile4_climate_target_temperature).state, id(tile4_climate_humidity).state, + "${TILE4_LABEL_ON}", "${TILE4_LABEL_OFF}"); + + set_value_auto(id(t5_value), t5_on, "${TILE5_TYPE}", "${TILE5_VALUE_MODE}", + id(tile5_brightness).state, id(tile5_percentage).state, std::string(id(tile5_preset).state.c_str()), + id(tile5_climate_current_temperature).state, id(tile5_climate_target_temperature).state, id(tile5_climate_humidity).state, + "${TILE5_LABEL_ON}", "${TILE5_LABEL_OFF}"); + + set_value_auto(id(t6_value), t6_on, "${TILE6_TYPE}", "${TILE6_VALUE_MODE}", + id(tile6_brightness).state, id(tile6_percentage).state, std::string(id(tile6_preset).state.c_str()), + id(tile6_climate_current_temperature).state, id(tile6_climate_target_temperature).state, id(tile6_climate_humidity).state, + "${TILE6_LABEL_ON}", "${TILE6_LABEL_OFF}"); + + // Tegel 7: light met alleen brightness. Tegel 8: puur tekst (Vol/Leeg). + set_value_auto(id(t7_value), t7_on, "${TILE7_TYPE}", "${TILE7_VALUE_MODE}", + id(tile7_brightness).state, NAN, std::string(), + NAN, NAN, NAN, + "${TILE7_LABEL_ON}", "${TILE7_LABEL_OFF}"); + + set_value_auto(id(t8_value), t8_on, "${TILE8_TYPE}", "${TILE8_VALUE_MODE}", + NAN, NAN, std::string(), + NAN, NAN, NAN, + "${TILE8_LABEL_ON}", "${TILE8_LABEL_OFF}"); + + // Tegel 9: kleurenlamp (brightness). Tegel 10: puur tekst (Aan/Uit). + set_value_auto(id(t9_value), t9_on, "${TILE9_TYPE}", "${TILE9_VALUE_MODE}", + id(tile9_brightness).state, NAN, std::string(), + NAN, NAN, NAN, + "${TILE9_LABEL_ON}", "${TILE9_LABEL_OFF}"); + + set_value_auto(id(t10_value), t10_on, "${TILE10_TYPE}", "${TILE10_VALUE_MODE}", + NAN, NAN, std::string(), + NAN, NAN, NAN, + "${TILE10_LABEL_ON}", "${TILE10_LABEL_OFF}"); + + // Missing state is not an off state. Preserve meaningful vacuum states. + const char* vacuum_state = id(ha_state_tile6).state.c_str(); + if (std::string("${TILE6_TYPE}") == "vacuum") { + std::string state(vacuum_state); + if (state == "cleaning") set_lbl(id(t6_value), "Bezig"); + else if (state == "docked") set_lbl(id(t6_value), "In dock"); + else if (state == "returning") set_lbl(id(t6_value), "Naar dock"); + else if (state == "paused") set_lbl(id(t6_value), "Gepauzeerd"); + else if (state == "error") set_lbl(id(t6_value), "Fout"); + else if (!state.empty()) set_lbl(id(t6_value), state); + } + if (!id(ha_state_tile1).has_state() || id(ha_state_tile1).state == "unknown" || id(ha_state_tile1).state == "unavailable") + set_lbl(id(t1_value), "Niet beschikbaar"); + if (!id(ha_state_tile2).has_state() || id(ha_state_tile2).state == "unknown" || id(ha_state_tile2).state == "unavailable") + set_lbl(id(t2_value), "Niet beschikbaar"); + if (!id(ha_state_tile3).has_state() || id(ha_state_tile3).state == "unknown" || id(ha_state_tile3).state == "unavailable") + set_lbl(id(t3_value), "Niet beschikbaar"); + if (!id(ha_state_tile4).has_state() || id(ha_state_tile4).state == "unknown" || id(ha_state_tile4).state == "unavailable") + set_lbl(id(t4_value), "Niet beschikbaar"); + if (!id(ha_state_tile5).has_state() || id(ha_state_tile5).state == "unknown" || id(ha_state_tile5).state == "unavailable") + set_lbl(id(t5_value), "Niet beschikbaar"); + if (!id(ha_state_tile6).has_state() || id(ha_state_tile6).state == "unknown" || id(ha_state_tile6).state == "unavailable") + set_lbl(id(t6_value), "Niet beschikbaar"); + if (!id(ha_state_tile7).has_state() || id(ha_state_tile7).state == "unknown" || id(ha_state_tile7).state == "unavailable") + set_lbl(id(t7_value), "Niet beschikbaar"); + if (!id(ha_state_tile8).has_state() || id(ha_state_tile8).state == "unknown" || id(ha_state_tile8).state == "unavailable") + set_lbl(id(t8_value), "Niet beschikbaar"); + if (!id(ha_state_tile9).has_state() || id(ha_state_tile9).state == "unknown" || id(ha_state_tile9).state == "unavailable") + set_lbl(id(t9_value), "Niet beschikbaar"); + if (!id(ha_state_tile10).has_state() || id(ha_state_tile10).state == "unknown" || id(ha_state_tile10).state == "unavailable") + set_lbl(id(t10_value), "Niet beschikbaar"); + if (id(active_control_kind) == "climate_temperature" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { + auto label_for_climate_mode = [](std::string mode) { + for (char &c : mode) { + if (c >= 'A' && c <= 'Z') c = (char) (c - 'A' + 'a'); + } + if (mode == "off") return std::string("Off"); + if (mode == "heat") return std::string("Heat"); + if (mode == "cool") return std::string("Cool"); + if (mode == "heat_cool") return std::string("Heat/Cool"); + if (mode == "auto") return std::string("Auto"); + if (mode == "dry") return std::string("Dry"); + if (mode == "fan_only") return std::string("Fan"); + return mode.empty() ? std::string("Auto") : mode; + }; + auto sync_open_climate = [&](const std::string& entity, + float current_temperature, + float target_temperature, + float humidity, + const std::string& climate_mode) { + if (id(active_entity) != entity) return; + auto to_x10 = [](float value, int fallback) -> int { + if (isnan(value)) return fallback; + return (int) lround(value * 10.0f); + }; + + id(active_climate_current_x10) = to_x10(current_temperature, id(active_climate_current_x10)); + id(active_climate_humidity) = isnan(humidity) ? -1 : (int) lround(humidity); + if (!climate_mode.empty() && !id(touch_down) && + !(id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000)) { + id(active_climate_mode) = climate_mode; + lv_label_set_text(id(climate_mode_label), label_for_climate_mode(climate_mode).c_str()); + } + + const bool editing = id(touch_down) || + (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000); + if (!editing) id(climate_edit_pending) = false; + int target_x10 = editing ? id(active_climate_target_x10) : + to_x10(target_temperature, id(active_climate_target_x10)); + if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10); + if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10); + int step_x10 = id(active_climate_step_x10); + if (step_x10 < 5) step_x10 = 5; + target_x10 = id(active_climate_min_x10) + (int) lround((target_x10 - id(active_climate_min_x10)) / (float) step_x10) * step_x10; + if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10); + if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10); + id(active_climate_target_x10) = target_x10; + + char current_buf[48]; + if (isnan(current_temperature)) { + snprintf(current_buf, sizeof(current_buf), "Current: --"); + } else if (id(active_climate_humidity) >= 0) { + snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", current_temperature, id(active_climate_humidity)); + } else { + snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", current_temperature); + } + lv_label_set_text(id(climate_current_label), current_buf); + + char target_buf[16]; + snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f); + lv_label_set_text(id(climate_target_label), target_buf); + lv_arc_set_value(id(climate_arc), id(active_climate_target_x10)); + }; + + if (std::string("${TILE1_TYPE}") == "climate") sync_open_climate("${TILE1_ENTITY}", id(tile1_climate_current_temperature).state, id(tile1_climate_target_temperature).state, id(tile1_climate_humidity).state, std::string(id(tile1_climate_mode).state.c_str())); + if (std::string("${TILE2_TYPE}") == "climate") sync_open_climate("${TILE2_ENTITY}", id(tile2_climate_current_temperature).state, id(tile2_climate_target_temperature).state, id(tile2_climate_humidity).state, std::string(id(tile2_climate_mode).state.c_str())); + if (std::string("${TILE3_TYPE}") == "climate") sync_open_climate("${TILE3_ENTITY}", id(tile3_climate_current_temperature).state, id(tile3_climate_target_temperature).state, id(tile3_climate_humidity).state, std::string(id(tile3_climate_mode).state.c_str())); + if (std::string("${TILE4_TYPE}") == "climate") sync_open_climate("${TILE4_ENTITY}", id(tile4_climate_current_temperature).state, id(tile4_climate_target_temperature).state, id(tile4_climate_humidity).state, std::string(id(tile4_climate_mode).state.c_str())); + if (std::string("${TILE5_TYPE}") == "climate") sync_open_climate("${TILE5_ENTITY}", id(tile5_climate_current_temperature).state, id(tile5_climate_target_temperature).state, id(tile5_climate_humidity).state, std::string(id(tile5_climate_mode).state.c_str())); + if (std::string("${TILE6_TYPE}") == "climate") sync_open_climate("${TILE6_ENTITY}", id(tile6_climate_current_temperature).state, id(tile6_climate_target_temperature).state, id(tile6_climate_humidity).state, std::string(id(tile6_climate_mode).state.c_str())); + } + + { + auto now = id(homeassistant_time).now(); + // Vóór de eerste tijd-sync is now() epoch 0 (1970) — dan geen + // misleidende "01:00" tonen, gewoon leeg laten tot er een + // echte tijd binnen is (na de eerste Home Assistant-tijdsynchronisatie). + if (now.is_valid()) { + char buf[10]; + + if (std::string("${TIME_24H}") == "true") { + snprintf(buf, sizeof(buf), "%02d:%02d", now.hour, now.minute); + } else { + int hour12 = now.hour % 12; + if (hour12 == 0) hour12 = 12; + snprintf(buf, sizeof(buf), "%d:%02d %s", hour12, now.minute, now.hour >= 12 ? "PM" : "AM"); + } + + set_lbl(id(lbl_time), std::string(buf)); + } + } + + // Accentkleur uit het Home Assistant-domein: het stuk voor de punt in + // het entity_id. script/scene/button vuur je af (ACCENT_ACTION), een + // light is een light (ACCENT_LIGHT), de rest is apparaat. + // LET OP: deze helper staat twee keer in dit bestand — hier en in + // open_value_overlay. Pas ze samen aan. + auto accent_for = [](const std::string& entity_id) -> uint32_t { + std::string d = entity_id.substr(0, entity_id.find('.')); + if (d == "script" || d == "scene" || d == "button" || + d == "input_button" || d == "automation") return ${ACCENT_ACTION}; + if (d == "light") return ${ACCENT_LIGHT}; + return ${ACCENT_DEVICE}; + }; + + auto apply_tile = [&](bool active, + const std::string& entity_id, + lv_obj_t* tile, + lv_obj_t* circle, + lv_obj_t* title, + lv_obj_t* value, + lv_obj_t* icon_lbl, + uint32_t circle_active, + uint32_t circle_disabled, + uint32_t icon_active, + uint32_t icon_disabled, + uint32_t bg_active, + uint32_t bg_disabled, + uint32_t title_active, + uint32_t title_disabled, + uint32_t value_active, + uint32_t value_disabled) + { + static lv_obj_t* cached_tiles[10] = {}; + static bool cached_active[10] = {}; + for (int i = 0; i < 10; ++i) { + if (cached_tiles[i] == tile) { + if (cached_active[i] == active) return; + cached_active[i] = active; + break; + } + if (cached_tiles[i] == nullptr) { + cached_tiles[i] = tile; + cached_active[i] = active; + break; + } + } + // ${ACCENT_AUTO} = "bepaal zelf"; een echte hexwaarde wint altijd. + uint32_t accent = accent_for(entity_id); + if (circle_active == ${ACCENT_AUTO}) circle_active = accent; + if (icon_disabled == ${ACCENT_AUTO}) icon_disabled = accent; + + lv_obj_set_style_bg_color(tile, lv_color_hex(active ? bg_active : bg_disabled), 0); + lv_obj_set_style_bg_color(circle, lv_color_hex(active ? circle_active : circle_disabled), 0); + lv_obj_set_style_text_color(icon_lbl, lv_color_hex(active ? icon_active : icon_disabled), 0); + lv_obj_set_style_text_color(title, lv_color_hex(active ? title_active : title_disabled), 0); + lv_obj_set_style_text_color(value, lv_color_hex(active ? value_active : value_disabled), 0); + }; + + apply_tile(t1_on, "${TILE1_ENTITY}", + id(tile1), id(tile1_icon_circle), id(t1_title), id(t1_value), id(tile1_icon_lbl), + ${TILE1_CIRCLE_ACTIVE_COLOR}, ${TILE1_CIRCLE_DISABLED_COLOR}, + ${TILE1_ICON_ACTIVE_COLOR}, ${TILE1_ICON_DISABLED_COLOR}, + ${TILE1_BG_ACTIVE_COLOR}, ${TILE1_BG_DISABLED_COLOR}, + ${TILE1_TITLE_ACTIVE_COLOR}, ${TILE1_TITLE_DISABLED_COLOR}, + ${TILE1_VALUE_ACTIVE_COLOR}, ${TILE1_VALUE_DISABLED_COLOR}); + + apply_tile(t2_on, "${TILE2_ENTITY}", + id(tile2), id(tile2_icon_circle), id(t2_title), id(t2_value), id(tile2_icon_lbl), + ${TILE2_CIRCLE_ACTIVE_COLOR}, ${TILE2_CIRCLE_DISABLED_COLOR}, + ${TILE2_ICON_ACTIVE_COLOR}, ${TILE2_ICON_DISABLED_COLOR}, + ${TILE2_BG_ACTIVE_COLOR}, ${TILE2_BG_DISABLED_COLOR}, + ${TILE2_TITLE_ACTIVE_COLOR}, ${TILE2_TITLE_DISABLED_COLOR}, + ${TILE2_VALUE_ACTIVE_COLOR}, ${TILE2_VALUE_DISABLED_COLOR}); + + apply_tile(t3_on, "${TILE3_ENTITY}", + id(tile3), id(tile3_icon_circle), id(t3_title), id(t3_value), id(tile3_icon_lbl), + ${TILE3_CIRCLE_ACTIVE_COLOR}, ${TILE3_CIRCLE_DISABLED_COLOR}, + ${TILE3_ICON_ACTIVE_COLOR}, ${TILE3_ICON_DISABLED_COLOR}, + ${TILE3_BG_ACTIVE_COLOR}, ${TILE3_BG_DISABLED_COLOR}, + ${TILE3_TITLE_ACTIVE_COLOR}, ${TILE3_TITLE_DISABLED_COLOR}, + ${TILE3_VALUE_ACTIVE_COLOR}, ${TILE3_VALUE_DISABLED_COLOR}); + + apply_tile(t4_on, "${TILE4_ENTITY}", + id(tile4), id(tile4_icon_circle), id(t4_title), id(t4_value), id(tile4_icon_lbl), + ${TILE4_CIRCLE_ACTIVE_COLOR}, ${TILE4_CIRCLE_DISABLED_COLOR}, + ${TILE4_ICON_ACTIVE_COLOR}, ${TILE4_ICON_DISABLED_COLOR}, + ${TILE4_BG_ACTIVE_COLOR}, ${TILE4_BG_DISABLED_COLOR}, + ${TILE4_TITLE_ACTIVE_COLOR}, ${TILE4_TITLE_DISABLED_COLOR}, + ${TILE4_VALUE_ACTIVE_COLOR}, ${TILE4_VALUE_DISABLED_COLOR}); + + apply_tile(t5_on, "${TILE5_ENTITY}", + id(tile5), id(tile5_icon_circle), id(t5_title), id(t5_value), id(tile5_icon_lbl), + ${TILE5_CIRCLE_ACTIVE_COLOR}, ${TILE5_CIRCLE_DISABLED_COLOR}, + ${TILE5_ICON_ACTIVE_COLOR}, ${TILE5_ICON_DISABLED_COLOR}, + ${TILE5_BG_ACTIVE_COLOR}, ${TILE5_BG_DISABLED_COLOR}, + ${TILE5_TITLE_ACTIVE_COLOR}, ${TILE5_TITLE_DISABLED_COLOR}, + ${TILE5_VALUE_ACTIVE_COLOR}, ${TILE5_VALUE_DISABLED_COLOR}); + + apply_tile(t6_on, "${TILE6_ENTITY}", + id(tile6), id(tile6_icon_circle), id(t6_title), id(t6_value), id(tile6_icon_lbl), + ${TILE6_CIRCLE_ACTIVE_COLOR}, ${TILE6_CIRCLE_DISABLED_COLOR}, + ${TILE6_ICON_ACTIVE_COLOR}, ${TILE6_ICON_DISABLED_COLOR}, + ${TILE6_BG_ACTIVE_COLOR}, ${TILE6_BG_DISABLED_COLOR}, + ${TILE6_TITLE_ACTIVE_COLOR}, ${TILE6_TITLE_DISABLED_COLOR}, + ${TILE6_VALUE_ACTIVE_COLOR}, ${TILE6_VALUE_DISABLED_COLOR}); + + apply_tile(t7_on, "${TILE7_ENTITY}", + id(tile7), id(tile7_icon_circle), id(t7_title), id(t7_value), id(tile7_icon_lbl), + ${TILE7_CIRCLE_ACTIVE_COLOR}, ${TILE7_CIRCLE_DISABLED_COLOR}, + ${TILE7_ICON_ACTIVE_COLOR}, ${TILE7_ICON_DISABLED_COLOR}, + ${TILE7_BG_ACTIVE_COLOR}, ${TILE7_BG_DISABLED_COLOR}, + ${TILE7_TITLE_ACTIVE_COLOR}, ${TILE7_TITLE_DISABLED_COLOR}, + ${TILE7_VALUE_ACTIVE_COLOR}, ${TILE7_VALUE_DISABLED_COLOR}); + + apply_tile(t8_on, "${TILE8_ENTITY}", + id(tile8), id(tile8_icon_circle), id(t8_title), id(t8_value), id(tile8_icon_lbl), + ${TILE8_CIRCLE_ACTIVE_COLOR}, ${TILE8_CIRCLE_DISABLED_COLOR}, + ${TILE8_ICON_ACTIVE_COLOR}, ${TILE8_ICON_DISABLED_COLOR}, + ${TILE8_BG_ACTIVE_COLOR}, ${TILE8_BG_DISABLED_COLOR}, + ${TILE8_TITLE_ACTIVE_COLOR}, ${TILE8_TITLE_DISABLED_COLOR}, + ${TILE8_VALUE_ACTIVE_COLOR}, ${TILE8_VALUE_DISABLED_COLOR}); + + apply_tile(t9_on, "${TILE9_ENTITY}", + id(tile9), id(tile9_icon_circle), id(t9_title), id(t9_value), id(tile9_icon_lbl), + ${TILE9_CIRCLE_ACTIVE_COLOR}, ${TILE9_CIRCLE_DISABLED_COLOR}, + ${TILE9_ICON_ACTIVE_COLOR}, ${TILE9_ICON_DISABLED_COLOR}, + ${TILE9_BG_ACTIVE_COLOR}, ${TILE9_BG_DISABLED_COLOR}, + ${TILE9_TITLE_ACTIVE_COLOR}, ${TILE9_TITLE_DISABLED_COLOR}, + ${TILE9_VALUE_ACTIVE_COLOR}, ${TILE9_VALUE_DISABLED_COLOR}); + + apply_tile(t10_on, "${TILE10_ENTITY}", + id(tile10), id(tile10_icon_circle), id(t10_title), id(t10_value), id(tile10_icon_lbl), + ${TILE10_CIRCLE_ACTIVE_COLOR}, ${TILE10_CIRCLE_DISABLED_COLOR}, + ${TILE10_ICON_ACTIVE_COLOR}, ${TILE10_ICON_DISABLED_COLOR}, + ${TILE10_BG_ACTIVE_COLOR}, ${TILE10_BG_DISABLED_COLOR}, + ${TILE10_TITLE_ACTIVE_COLOR}, ${TILE10_TITLE_DISABLED_COLOR}, + ${TILE10_VALUE_ACTIVE_COLOR}, ${TILE10_VALUE_DISABLED_COLOR}); + +# HA blueprint transport; never publishes the JSON command as an HA state. +text: + - platform: smart_display + id: dashboard_inbox + name: "Tegelinstellingen" + internal: ${DASHBOARD_INBOX_INTERNAL} diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..2997945 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: Home Assistant ESP Screens +url: https://github.com/MaxGramser/homeassistant_espscreen +maintainer: MaxGramser diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md new file mode 100644 index 0000000..2cdc9d0 --- /dev/null +++ b/screen_manager/CHANGELOG.md @@ -0,0 +1,7 @@ +# 0.1.0 + +- Eerste ESP Screen Manager met Home Assistant Ingress. +- Zoek op entiteit, apparaat of ruimte; tien geordende tegels per scherm. +- Tegelwijzigingen en actuele waarden zonder firmwareflash. +- Installatie-YAML met unieke sleutels voor CYD en Guition. +- Permanente indelingen, automatisch herstel na HA-/schermherstart. diff --git a/screen_manager/DOCS.md b/screen_manager/DOCS.md new file mode 100644 index 0000000..3a74fa9 --- /dev/null +++ b/screen_manager/DOCS.md @@ -0,0 +1,24 @@ +# Aan de slag + +Start de app en open de webinterface. Klik **Nieuw scherm**, kies het exacte bord +en download je eigen YAML. De wizard legt de eerste USB-flash via ESPHome Device +Builder, wifi, HA-koppeling en kalibratie uit. Kies daarna je tegels in deze app. + +De volledige handleiding staat op +[GitHub: Easy Setup](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md). + +## Updates + +Update de app via de appwinkel. Indelingen staan in de permanente `/data`-map en +blijven bij een update behouden. Verwijder de app niet om een update te installeren. +Voor nieuwe schermfuncties kies je in ESPHome bij je **bestaande apparaat**: +Install → Wirelessly. Houd je eigen YAML, apparaatnaam en sleutels gelijk. + +Maak vóór updates een HA-back-up inclusief deze app en ESPHome Device Builder. + +## Werking + +De app leest entiteiten, apparaten, ruimtes en status uit jouw HA en schrijft +uitsluitend configuratieberichten naar de ESPHome-tekstentiteit Tegelinstellingen. +Het scherm voert de gekozen HA-acties uit. Geef het daarvoor toestemming in de +ESPHome-integratie. De app moet draaien om actuele gegevens te blijven versturen. diff --git a/screen_manager/Dockerfile b/screen_manager/Dockerfile new file mode 100644 index 0000000..c8c9fcf --- /dev/null +++ b/screen_manager/Dockerfile @@ -0,0 +1,8 @@ +FROM ghcr.io/home-assistant/base:3.22 +ARG BUILD_VERSION +ARG BUILD_ARCH +LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${BUILD_ARCH}" +RUN apk add --no-cache python3 py3-aiohttp py3-yaml +COPY app /app +WORKDIR /app +CMD ["/command/with-contenv", "python3", "/app/server.py"] diff --git a/screen_manager/README.md b/screen_manager/README.md new file mode 100644 index 0000000..67a84e6 --- /dev/null +++ b/screen_manager/README.md @@ -0,0 +1,8 @@ +# ESP Screen Manager + +Zoek je Home Assistant-entiteiten, orden maximaal tien tegels en stuur wijzigingen +naar een CYD- of Guition-scherm zonder opnieuw flashen. Met installatiehulp, +unieke apparaat-YAML en ondersteuning voor meerdere schermen. + +Open de webinterface na installatie. Zie **Documentatie** voor de eerste flash. +De app gebruikt Home Assistant Ingress en heeft geen eigen login of token nodig. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py new file mode 100644 index 0000000..ca7315e --- /dev/null +++ b/screen_manager/app/core.py @@ -0,0 +1,137 @@ +"""Pure validation, firmware generation and bounded display protocol.""" +import base64 +import json +import math +import re +import secrets + +DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select button input_button'.split()) +REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' +REFS = {'cyd': 'main', 'guition': 'main'} +ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement'.split()) + +def entity_id(value): + return isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value) and value.split('.')[0] in DOMAINS + +def short(value, limit): + return str(value).encode('utf-8')[:limit].decode('utf-8', errors='ignore') + +def validate_layout(data): + if not isinstance(data, dict): + raise ValueError('Ongeldige indeling.') + title, tiles = data.get('title'), data.get('tiles') + if not isinstance(title, str) or not title.strip() or len(title.encode()) > 96: + raise ValueError('Geef het scherm een titel van maximaal 96 bytes.') + if not isinstance(tiles, list) or len(tiles) > 10: + raise ValueError('Kies maximaal 10 tegels.') + clean, seen = [], set() + for tile in tiles: + if not isinstance(tile, dict) or not entity_id(tile.get('entity')): + raise ValueError('Deze entiteit wordt niet ondersteund.') + if tile['entity'] in seen: + raise ValueError('Een entiteit kan maar één keer op een scherm staan.') + name = tile.get('name', '') + if not isinstance(name, str) or len(name.encode()) > 80: + raise ValueError('Een tegelnaam mag maximaal 80 bytes bevatten.') + seen.add(tile['entity']) + clean.append({'entity': tile['entity'], 'name': name.strip()}) + return {'title': title.strip(), 'tiles': clean} + +def state_message(index, tile, states): + state = states.get(tile['entity'], {}) + attrs = state.get('attributes', {}) + bounded = {} + for key in ATTRS: + value = attrs.get(key) + if isinstance(value, bool) or value is None: + continue + if isinstance(value, (int, float)): + if math.isfinite(value) and abs(value) <= 1000000: + bounded[key] = value + elif isinstance(value, str): + bounded[key] = short(value, 48) + elif isinstance(value, list): + # Attribute lists have bounded lengths, strings and numeric ranges. + limit = 2 if key == 'hs_color' else 4 if key == 'fan_speed_list' else 8 + bounded[key] = [short(v, 48) if isinstance(v, str) else v for v in value[:limit] + if isinstance(v, str) or isinstance(v, (float, int)) and math.isfinite(v) and abs(v) <= 1000000] + return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'], + 'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80), + 'state': short(state.get('state', 'unavailable'), 160), 'a': bounded} + +def packets(message, token=None): + raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode() + if len(raw) > 4096: + raise ValueError('Het schermbericht is te groot.') + encoded = base64.b64encode(raw).decode('ascii') + token = token or secrets.token_hex(8) + chunks = [encoded[i:i+200] for i in range(0, len(encoded), 200)] + return [f'{token}|{i}|{int(i == len(chunks)-1)}|{part}' for i, part in enumerate(chunks)] + +def discover(registry, states, devices, areas): + device_map = {d['id']: d for d in devices} + area_map = {a['area_id']: a['name'] for a in areas} + versions = {item.get('device_id'): states.get(item['entity_id'], {}).get('state', 'onbekend') for item in registry + if item.get('platform') == 'esphome' and item.get('original_name') == 'Schermfirmware'} + screens, entities = [], [] + for item in registry: + eid = item['entity_id'] + device = device_map.get(item.get('device_id'), {}) + state = states.get(eid, {}) + area = area_map.get(item.get('area_id') or device.get('area_id'), '') + if item.get('platform') == 'esphome' and eid.startswith('text.') and item.get('original_name') == 'Tegelinstellingen' and not item.get('disabled_by'): + screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid, + 'firmware': versions.get(item.get('device_id'), 'onbekend'), + 'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'), + 'status': state.get('state', 'Niet verbonden')}) + if not entity_id(eid) or item.get('disabled_by'): + continue + entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name') or item.get('name') or item.get('original_name') or eid, + 'device': device.get('name_by_user') or device.get('name') or '', 'area': area, + 'state': state.get('state', 'unavailable')}) + # YAML entities may not have an entity-registry entry. + registered = {e['id'] for e in entities} + for eid, state in states.items(): + if entity_id(eid) and eid not in registered and not any(r['entity_id'] == eid for r in registry): + entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name', eid), 'device': '', 'area': '', 'state': state['state']}) + return screens, sorted(entities, key=lambda e: e['name'].casefold()) + +def installation_yaml(data): + board, name, friendly = data.get('board'), data.get('name'), data.get('friendly_name') + if board not in REFS or not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,29}', name): + raise ValueError('Kies een bord en een unieke naam (kleine letters, cijfers, streepjes; maximaal 30 tekens).') + if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60: + raise ValueError('Geef het scherm een herkenbare naam (maximaal 60 tekens).') + quote = lambda s: json.dumps(s, ensure_ascii=False) + key, ota, ap = base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(12) + return f'''# Bewaar dit bestand: het bevat de unieke sleutels voor dit scherm. +# Wifi komt uit de secrets.yaml van ESPHome Device Builder. +substitutions: + DEVICE_NAME: {quote(name)} + DEVICE_FRIENDLY_NAME: {quote(friendly.strip())} + +esphome: + name: {quote(name)} + friendly_name: {quote(friendly.strip())} + +packages: + display: + url: {REPO} + ref: {REFS[board]} + files: [packages/{board}.yaml] + refresh: 0s + +api: + encryption: + key: {quote(key)} +ota: + - platform: esphome + password: {quote(ota)} +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: {quote(name + ' Setup')} + password: {quote(ap)} +captive_portal: +''' diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py new file mode 100644 index 0000000..e60a656 --- /dev/null +++ b/screen_manager/app/server.py @@ -0,0 +1,261 @@ +"""HA Ingress app. The only HA write is text.set_value on discovered inboxes.""" +import asyncio +import contextlib +import json +import logging +import os +from pathlib import Path +import secrets +import time + +from aiohttp import ClientSession, ClientTimeout, WSMsgType, web +from core import discover, installation_yaml, packets, state_message, validate_layout + +LOG = logging.getLogger('screen_manager') + +class HomeAssistant: + def __init__(self, session, base, token): + self.session, self.base, self.token = session, base.rstrip('/'), token + self.ws = None + self.next_id = 0 + self.pending = {} + self.states = {} + self.registry, self.devices, self.areas = [], [], [] + self.online = False + self.changed = asyncio.Event() + + async def request(self, kind, **data): + if self.ws is None or self.ws.closed: + raise ConnectionError('Home Assistant is niet verbonden.') + self.next_id += 1 + key = self.next_id + future = asyncio.get_running_loop().create_future() + self.pending[key] = future + try: + await self.ws.send_json({'id': key, 'type': kind, **data}) + return await asyncio.wait_for(future, 15) + finally: + self.pending.pop(key, None) + + async def read(self): + async for msg in self.ws: + if msg.type != WSMsgType.TEXT: + continue + data = msg.json() + if data.get('type') == 'result': + future = self.pending.get(data.get('id')) + if future and not future.done(): + if data.get('success'): + future.set_result(data.get('result')) + else: + future.set_exception(ConnectionError('Home Assistant heeft de opdracht geweigerd.')) + elif data.get('type') == 'event': + event = data.get('event', {}) + body = event.get('data', {}) + if event.get('event_type') == 'state_changed': + eid = body.get('entity_id') + if body.get('new_state'): + self.states[eid] = body['new_state'] + else: + self.states.pop(eid, None) + self.changed.set() + raise ConnectionError('Home Assistant-verbinding verbroken.') + + async def registries(self): + self.registry, self.devices, self.areas = await asyncio.gather( + self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list')) + + async def run(self): + url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api' + else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket') + while True: + reader = None + try: + async with self.session.ws_connect(url, heartbeat=30, max_msg_size=16*1024*1024) as ws: + self.ws = ws + await ws.receive_json(timeout=15) + await ws.send_json({'type': 'auth', 'access_token': self.token}) + if (await ws.receive_json(timeout=15)).get('type') != 'auth_ok': + raise ConnectionError('Home Assistant-authenticatie mislukt.') + reader = asyncio.create_task(self.read()) + await self.request('subscribe_events', event_type='state_changed') + self.states = {s['entity_id']: s for s in await self.request('get_states')} + await self.registries() + self.online = True + self.changed.set() + LOG.info('Home Assistant verbonden') + # Refresh registry for newly paired screens and renamed entities. + while True: + done, _ = await asyncio.wait([reader], timeout=30) + if done: + await reader + await self.registries() + self.changed.set() + except (ConnectionError, TimeoutError, OSError, ValueError) as error: + LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)', type(error).__name__) + except Exception as error: + LOG.warning('Verbinding opnieuw starten (%s)', type(error).__name__) + finally: + self.online = False + self.ws = None + if reader: + reader.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await reader + for future in self.pending.values(): + if not future.done(): + future.set_exception(ConnectionError('Verbinding verbroken.')) + await asyncio.sleep(5) + + async def send(self, inbox, message): + for packet in packets(message): + await self.request('call_service', domain='text', service='set_value', + service_data={'entity_id': inbox, 'value': packet}) + +class Manager: + def __init__(self, ha, path): + self.ha, self.path = ha, Path(path) + self.layouts, self.sent, self.status, self.last = {}, {}, {}, {} + if self.path.exists(): + raw = json.loads(self.path.read_text()) + # Versioned persistent data. Never silently overwrite an unknown schema. + if raw.get('version') != 1 or not isinstance(raw.get('screens'), dict): + raise ValueError('Onbekende opslagversie; gegevens blijven ongewijzigd.') + self.layouts = {key: validate_layout(value) for key, value in raw['screens'].items()} + + def inventory(self): + return discover(self.ha.registry, self.ha.states, self.ha.devices, self.ha.areas) + + def save(self, inbox, data): + if inbox not in {s['id'] for s in self.inventory()[0]}: + raise ValueError('Dit is geen gekoppeld ESP-scherm. Vernieuw het overzicht.') + layout = validate_layout(data) + known = {e['id'] for e in self.inventory()[1]} + if any(t['entity'] not in known for t in layout['tiles']): + raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.') + updated = {**self.layouts, inbox: layout} + self.path.parent.mkdir(parents=True, exist_ok=True) + temp = self.path.with_suffix('.tmp') + with open(temp, 'w', encoding='utf8') as handle: + os.chmod(temp, 0o600) + json.dump({'version': 1, 'screens': updated}, handle, ensure_ascii=False) + handle.flush() + os.fsync(handle.fileno()) + temp.replace(self.path) + self.layouts = updated + self.sent.pop(inbox, None) + self.status[inbox] = 'Opgeslagen; wacht op synchronisatie' + self.ha.changed.set() + + async def sync_one(self, inbox, layout, force=False): + messages = [{'v': 1, 'op': 'layout', 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}] + messages += [state_message(i, tile, self.ha.states) for i, tile in enumerate(layout['tiles'])] + previous = self.sent.get(inbox, []) + force = force or not previous or messages[0] != previous[0] + for i, message in enumerate(messages): + # Save during transmission aborts the old batch, then starts a full new layout. + if self.layouts.get(inbox) != layout: + self.sent.pop(inbox, None) + return + if force or i >= len(previous) or message != previous[i]: + await self.ha.send(inbox, message) + self.sent[inbox] = messages + self.last[inbox] = time.monotonic() + self.status[inbox] = 'Verzonden naar Home Assistant' + + async def run(self): + while True: + try: + await asyncio.wait_for(self.ha.changed.wait(), timeout=20) + except TimeoutError: + pass + self.ha.changed.clear() + await asyncio.sleep(0.25) + if not self.ha.online: + self.sent.clear() + continue + for screen in self.inventory()[0]: + inbox = screen['id'] + if not screen['online']: + self.sent.pop(inbox, None) + self.status[inbox] = 'Scherm offline; wijzigingen bewaard' + continue + if inbox not in self.layouts: + continue + try: + force = time.monotonic() - self.last.get(inbox, 0) >= 25 + # last records full keepalive, not unrelated HA state events. + before = self.last.get(inbox, 0) + await self.sync_one(inbox, self.layouts[inbox], force) + if not force: + self.last[inbox] = before + except Exception as error: + self.sent.pop(inbox, None) + self.status[inbox] = 'Verzenden mislukt; automatisch opnieuw proberen' + LOG.warning('Schermsynchronisatie opnieuw proberen (%s)', type(error).__name__) + await asyncio.sleep(1) + +def create_app(manager, development=False): + csrf = secrets.token_urlsafe(32) + @web.middleware + async def guard(request, handler): + allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'} + if request.remote not in allowed: + raise web.HTTPForbidden(text='Open deze pagina via Home Assistant.') + if request.method not in {'GET', 'HEAD'} and request.headers.get('X-Screen-CSRF') != csrf: + raise web.HTTPForbidden(text='Vernieuw deze pagina en probeer opnieuw.') + try: + response = await handler(request) + except (ValueError, TypeError, KeyError): + return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400) + response.headers['Cache-Control'] = 'no-store' + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'" + return response + + app = web.Application(middlewares=[guard], client_max_size=16*1024) + static = Path(__file__).parent / 'static' + + async def index(request): + return web.FileResponse(static / 'index.html') + async def inventory(request): + screens, entities = manager.inventory() + for screen in screens: + screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Thuis', 'tiles': []}) + screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels') + return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities}) + async def save(request): + manager.save(request.match_info['inbox'], await request.json()) + return web.json_response({'saved': True}) + async def download(request): + data = await request.json() + content = installation_yaml(data) + return web.Response(text=content, content_type='text/yaml', headers={'Content-Disposition': f'attachment; filename="{data["name"]}.yaml"'}) + app.router.add_get('/', index) + app.router.add_get('/api/inventory', inventory) + app.router.add_put('/api/screens/{inbox}', save) + app.router.add_post('/api/install', download) + app.router.add_static('/static/', static) + return app + +async def main(): + development = os.environ.get('SCREEN_DEV') == '1' + token = os.environ.get('SUPERVISOR_TOKEN', '') + if development and os.environ.get('HA_TOKEN_FILE'): + token = Path(os.environ['HA_TOKEN_FILE']).read_text().strip() + if not token: + raise SystemExit('Geen Home Assistant-toegang. Start de app via Supervisor.') + async with ClientSession(timeout=ClientTimeout(total=20)) as session: + ha = HomeAssistant(session, os.environ.get('HA_API', 'http://supervisor/core/api'), token) + manager = Manager(ha, Path(os.environ.get('SCREEN_DATA', '/data')) / 'screens.json') + runner = web.AppRunner(create_app(manager, development), access_log=None) + await runner.setup() + await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start() + try: + await asyncio.gather(ha.run(), manager.run()) + finally: + await runner.cleanup() + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s') + asyncio.run(main()) diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js new file mode 100644 index 0000000..00d4f23 --- /dev/null +++ b/screen_manager/app/static/app.js @@ -0,0 +1,64 @@ +'use strict'; +const $ = s => document.querySelector(s); +let inventory = {screens: [], entities: []}, selected = null, layout = null, dirty = false, filter = '', dragIndex = -1, busy = false; +const node = (tag, text, cls) => { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; }; +function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 5000); } +async function api(path, options = {}) { + const response = await fetch(`api/${path}`, {...options, headers: {'Content-Type': 'application/json', 'X-Screen-CSRF': inventory.csrf || '', ...options.headers}}); + if (!response.ok) { let message = 'Dit lukte niet. Vernieuw de pagina en probeer opnieuw.'; try { message = (await response.json()).error || message; } catch {} throw new Error(message); } + return response; +} +function markDirty() { dirty = true; $('#dirty').textContent = 'Niet-opgeslagen wijzigingen'; $('#save-detail').textContent = 'Klik op opslaan om je scherm bij te werken.'; } +function select(id) { + if (id !== selected && dirty && !confirm('Je hebt niet-opgeslagen wijzigingen. Toch een ander scherm openen?')) return; + selected = id; const screen = inventory.screens.find(s => s.id === id); if (!screen) return; + layout = structuredClone(screen.layout); dirty = false; $('#title').value = layout.title; $('#screen-name').textContent = screen.name; + $('#editor').hidden = false; $('#empty').hidden = true; $('#dirty').textContent = 'Alles opgeslagen'; $('#save-detail').textContent = 'Aanpassen kan zonder opnieuw flashen.'; + renderScreens(); renderTiles(); renderResults(); +} +function renderScreens() { + $('#screens').replaceChildren(); + for (const screen of inventory.screens) { const b = node('button', undefined, `screen ${screen.id === selected ? 'selected' : ''}`); b.append(node('strong', screen.name), node('small', `${screen.online ? '● Online' : '○ Offline'}${screen.area ? ' · '+screen.area : ''} · firmware ${screen.firmware || 'onbekend'}`)); b.onclick = () => select(screen.id); $('#screens').append(b); } + const screen = inventory.screens.find(s => s.id === selected); + if (screen) { $('#delivery').textContent = screen.online ? `${screen.delivery} · ${screen.status}` : 'Offline · wijzigingen worden bewaard'; $('#delivery').classList.toggle('online', screen.online); } +} +function move(from, to) { if (to < 0 || to >= layout.tiles.length) return; const [tile] = layout.tiles.splice(from, 1); layout.tiles.splice(to, 0, tile); markDirty(); renderTiles(); } +function renderTiles() { + $('#tiles').replaceChildren(); $('#count').textContent = `${layout.tiles.length} / 10`; $('#no-tiles').hidden = layout.tiles.length > 0; + layout.tiles.forEach((tile, i) => { + if (i === 6) $('#tiles').append(node('li', 'Pagina 2', 'page-break')); + const li = node('li', undefined, 'tile'); li.draggable = true; + li.ondragstart = e => { if (e.target instanceof HTMLInputElement) { e.preventDefault(); return; } dragIndex = i; e.dataTransfer.setData('text/plain', String(i)); li.classList.add('dragging'); }; + li.ondragend = () => { dragIndex = -1; li.classList.remove('dragging'); }; + li.ondragover = e => { if (dragIndex >= 0) e.preventDefault(); }; + li.ondrop = e => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, i); }; + const content = node('div'), input = node('input'); input.value = tile.name; input.placeholder = inventory.entities.find(e => e.id === tile.entity)?.name || tile.entity; input.maxLength = 60; input.setAttribute('aria-label', `Naam voor tegel ${i+1}`); input.oninput = () => { tile.name = input.value; markDirty(); }; content.append(input, node('small', tile.entity)); + const actions = node('div', undefined, 'tile-actions'); for (const [label, title, action, disabled] of [['↑','Omhoog',()=>move(i,i-1),i===0],['↓','Omlaag',()=>move(i,i+1),i===layout.tiles.length-1],['✕','Verwijderen',()=>{layout.tiles.splice(i,1);markDirty();renderTiles();renderResults();},false]]) { const b = node('button',label); b.title = title; b.setAttribute('aria-label',title); b.disabled = disabled; b.onclick = action; actions.append(b); } + li.append(node('span','⠿','grip'), content, actions); $('#tiles').append(li); + }); +} +function renderResults() { + if (!layout) return; + const query = $('#search').value.toLocaleLowerCase(); const chosen = new Set(layout.tiles.map(t => t.entity)); + const matches = inventory.entities.filter(e => (!filter || e.id.startsWith(filter+'.')) && `${e.name} ${e.id} ${e.device} ${e.area}`.toLocaleLowerCase().includes(query)); + $('#results').replaceChildren(); + for (const entity of matches.slice(0, 80)) { + const b = node('button', undefined, 'result'), description = node('span'); description.append(node('span',entity.name), node('small',[entity.area,entity.device].filter(Boolean).join(' · ')), node('small',entity.id)); b.append(description,node('span',chosen.has(entity.id)?'✓':'+','plus')); b.disabled = chosen.has(entity.id) || layout.tiles.length >= 10; b.onclick = () => { layout.tiles.push({entity:entity.id,name:''}); markDirty();renderTiles();renderResults(); }; $('#results').append(b); + } + if (!matches.length) $('#results').append(node('p','Geen entiteiten gevonden. Probeer een andere naam.','hint')); + if (matches.length > 80) $('#results').append(node('p',`${matches.length} resultaten. Typ verder om te verfijnen.`,'hint')); +} +async function refresh() { + try { inventory = await (await api('inventory')).json(); $('#connection').textContent = inventory.connected ? '● Home Assistant verbonden' : 'Verbinding met Home Assistant herstellen…'; $('#connection').classList.toggle('online',inventory.connected); renderScreens(); if (!selected && inventory.screens.length) select(inventory.screens[0].id); } + catch { $('#connection').textContent = 'Beheerpagina niet bereikbaar · opnieuw proberen…'; } +} +$('#save').onclick = async () => { if (busy) return; busy = true; $('#save').disabled = true; try { layout.title = $('#title').value; await api(`screens/${encodeURIComponent(selected)}`, {method:'PUT',body:JSON.stringify(layout)}); dirty = false; $('#dirty').textContent = 'Opgeslagen'; $('#save-detail').textContent = 'Synchronisatie volgt automatisch, ook na opnieuw verbinden.'; toast('Opgeslagen. Je scherm wordt bijgewerkt.'); await refresh(); } catch(e) { toast(e.message); } finally { busy = false; $('#save').disabled = false; } }; +$('#title').oninput = markDirty; $('#search').oninput = renderResults; $('#refresh').onclick = refresh; +for (const [value,label] of [['','Alles'],['light','Lampen'],['climate','Klimaat'],['scene','Scènes'],['vacuum','Vacuum'],['sensor','Sensoren']]) { const b = node('button',label,value===''?'active':''); b.onclick=()=>{filter=value;$('#filters').querySelectorAll('button').forEach(n=>n.classList.remove('active'));b.classList.add('active');renderResults();};$('#filters').append(b); } +$('#new-screen').onclick = $('#start').onclick = () => $('#installer').showModal(); $('#close-install').onclick = () => $('#installer').close(); +let generatedName = ''; +$('#install-form').onsubmit = async e => { e.preventDefault(); $('#download').disabled = true; try { const data = Object.fromEntries(new FormData(e.target)); const response = await api('install',{method:'POST',body:JSON.stringify(data)}); $('#yaml-output').value = await response.text(); generatedName = data.name; $('#yaml-result').hidden = false; $('#install-status').textContent='Je YAML is klaar. Kopieer hem naar ESPHome en bewaar het bestand met sleutels.'; } catch(e) { $('#install-status').textContent=e.message; } finally { $('#download').disabled=false; } }; +$('#copy-yaml').onclick = async () => { try { if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText($('#yaml-output').value); else { $('#yaml-output').select(); if (!document.execCommand('copy')) throw new Error(); } $('#install-status').textContent='Gekopieerd. Plak dit bij Edit in ESPHome Device Builder.'; } catch { $('#yaml-output').select(); $('#install-status').textContent='De tekst is geselecteerd. Kopieer met Ctrl+C of Command+C.'; } }; +$('#download-yaml').onclick = () => { const url = URL.createObjectURL(new Blob([$('#yaml-output').value],{type:'text/plain'})); const a=node('a');a.href=url;a.download=`${generatedName}.yaml`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);$('#install-status').textContent='Download gestart. Blokkeert je browser deze? Gebruik Kopieer YAML.'; }; +window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}}); +refresh(); setInterval(refresh, 10000); diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html new file mode 100644 index 0000000..f13a838 --- /dev/null +++ b/screen_manager/app/static/index.html @@ -0,0 +1,21 @@ + +ESP Screens + +
▦
ESP ScreensJe huis. Jouw bediening.
+
+

HOME ASSISTANT

Een plek voor je favorieten.

Kies wat je wilt bedienen. Je scherm verandert met je mee.

Verbinden…
+
+ +
▦

Begin met een scherm

Sluit een CYD of Guition aan. We maken de installatie-YAML voor je en helpen je met de eerste flash.

+
+
Hoe werkt het dagelijks?

Zoek een entiteit, voeg deze toe en klik op Opslaan & naar scherm. De app houdt de status bij. Voor een kleurenlamp: houd de tegel ingedrukt voor kleur, temperatuur en helderheid. Climate, vacuum en cover openen hun kaart direct. Sensoren tonen alleen hun waarde.

Alleen nieuwe firmware vraagt om een installatie via ESPHome Device Builder. Je tegels blijven in deze app bewaard. Een offline scherm krijgt de wijzigingen zodra het terug is.

+
+

NIEUW SCHERM

Van USB naar aan de muur.

Kies je scherm. Elk bestand krijgt eigen sleutels voor een veilige koppeling en draadloze updates.

Welk scherm heb je?

  1. Open ESPHome Device Builder. Voeg een nieuw apparaat toe, sla de eerste installatie over en vervang de inhoud via Edit door het gedownloade bestand.
  2. Vul wifi eenmalig in bij Secrets. Gebruik wifi_ssid en wifi_password.
  3. Sluit USB aan op je Home Assistant Raspberry. Klik Install → Plug into the computer running ESPHome Device Builder en kies de USB-poort.
  4. Koppel in Instellingen → Apparaten & diensten → ESPHome. Gebruik de api.encryption.key uit je bestand en sta Home Assistant-acties toe bij Configureren.
  5. Open deze pagina en kies je tegels. CYD toont bij de eerste start een kalibratie: tik ieder kruisje drie keer. Guition hoeft niet gekalibreerd.

Bewaar je YAML met sleutels. Gebruik later Install → Wirelessly voor firmware-updates. Tegels wijzigen doe je hier.

+ + diff --git a/screen_manager/app/static/style.css b/screen_manager/app/static/style.css new file mode 100644 index 0000000..95c3f73 --- /dev/null +++ b/screen_manager/app/static/style.css @@ -0,0 +1,5 @@ +:root{color-scheme:light;--bg:#f5f6f8;--ink:#202c3c;--muted:#697689;--line:#e1e6ed;--accent:#245cde;--soft:#edf3ff;font:15px/1.5 system-ui,-apple-system,sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}button,input{font:inherit}button{border:0;border-radius:11px;padding:11px 18px;background:var(--accent);color:white;font-weight:600;cursor:pointer}button:hover{filter:brightness(.95)}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid #83b5ff;outline-offset:3px}input{border:1px solid #cdd5df;border-radius:10px;padding:11px 12px;width:100%;background:white;color:var(--ink)}label{display:block;font-weight:600}label input{margin-top:7px}small,.hint{display:block;color:var(--muted);font-size:13px;font-weight:400}header{display:flex;align-items:center;justify-content:space-between;padding:21px 4vw;background:white;border-bottom:1px solid var(--line);gap:20px}.brand{display:flex;align-items:center;gap:12px}.brand strong{font-size:19px}.mark{width:44px;height:44px;background:var(--ink);color:white;border-radius:12px;display:grid;place-items:center;font-size:30px}main{max-width:1440px;margin:auto;padding:38px 4vw}.intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:30px}h1{font-size:clamp(25px,3vw,36px);letter-spacing:-1.1px;margin:4px 0 8px}h2{font-size:21px;letter-spacing:-.4px;margin:0 0 14px}h3{font-size:16px;margin:0}p{margin:8px 0 16px}.intro p:not(.eyebrow){color:var(--muted)}.eyebrow{font-size:11px;font-weight:750;letter-spacing:1.6px;color:var(--muted);margin:0 0 7px}.badge{display:inline-block;border:1px solid var(--line);border-radius:30px;padding:6px 11px;font-size:12px;white-space:normal;background:white;color:var(--muted)}.badge.online{background:#e9f6ef;color:#21704b;border-color:#d6eede}.workspace{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px}aside{padding-top:10px}aside h2{font-size:14px}#screens{display:grid;gap:8px;margin-bottom:18px}.screen{text-align:left;background:transparent;color:var(--ink);border:1px solid transparent;padding:14px}.screen.selected{background:white;border-color:var(--line);box-shadow:0 3px 10px #202c3c05}.screen small{margin-top:3px}.quiet{background:transparent;color:var(--muted);padding:7px}#editor,.empty{background:white;border:1px solid var(--line);border-radius:18px;padding:27px;min-width:0;box-shadow:0 5px 20px #202c3c03}.editor-head{display:flex;justify-content:space-between;gap:12px;align-items:start;margin-bottom:15px}.editor-head .badge{max-width:240px}.columns{display:grid;grid-template-columns:1fr 1fr;gap:25px;margin-top:30px}.section-title{display:flex;justify-content:space-between}.section-title span{font-size:12px;color:var(--muted)}#tiles{padding:0;list-style:none;margin:18px 0}.tile{border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:10px;background:#fcfcfd;display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:8px;align-items:center}.tile.dragging{opacity:.35}.grip{color:#9aa7b8;cursor:grab;font-size:19px}.tile input{font-size:14px;padding:5px 6px;margin:0;background:transparent;border-color:transparent}.tile input:focus{border-color:#aac4fa;background:white}.tile small{font-size:11px;overflow-wrap:anywhere;padding-left:6px}.tile-actions{display:flex;flex-direction:column}.tile-actions button{padding:1px 6px;font-size:12px;line-height:1.6;background:transparent;color:var(--muted)}.page-break{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin:18px 0 9px}.picker{background:#f7f9fc;padding:18px;border-radius:13px;align-self:start}.picker label{font-size:14px}.filters{display:flex;gap:5px;flex-wrap:wrap;margin:13px 0}.filters button{font-size:11px;padding:5px 9px;background:white;color:var(--muted);border:1px solid var(--line);border-radius:20px}.filters button.active{background:var(--soft);color:var(--accent);border-color:#c9dafa}#results{max-height:450px;overflow:auto}.result{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;text-align:left;background:transparent;color:var(--ink);padding:11px 5px;border-bottom:1px solid var(--line);border-radius:0;font-size:13px;font-weight:500}.result small{font-size:11px;overflow-wrap:anywhere}.result .plus{color:var(--accent);font-size:22px}.savebar{margin-top:25px;padding-top:20px;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:15px}.savebar strong{font-size:13px}.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:400px;text-align:center}.empty p{max-width:400px;color:var(--muted)}.empty-icon{font-size:60px;color:#9cafd0}.help{margin:26px 0 0 238px;color:var(--muted);font-size:13px}.help summary{cursor:pointer;font-weight:600}.help p{margin-top:15px}dialog{border:1px solid var(--line);border-radius:20px;max-width:620px;width:calc(100% - 28px);padding:28px;color:var(--ink);max-height:90vh}dialog::backdrop{background:#17223280;backdrop-filter:blur(3px)}.dialog-head{display:flex;align-items:start;justify-content:space-between;gap:15px}dialog p{color:var(--muted);font-size:14px}fieldset{border:0;padding:0;margin:23px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px}legend{font-weight:600;margin-bottom:9px}.board{border:1px solid var(--line);border-radius:12px;padding:13px;display:flex;align-items:center;gap:9px;font-size:13px}.board:has(input:checked){background:var(--soft);border-color:#9bb9fa}.board input{width:auto;margin:0}.board small{font-size:11px}dialog label{margin-bottom:15px;font-size:14px}.steps{padding-left:20px;font-size:13px;color:var(--muted)}.steps li{padding-left:4px;margin-bottom:12px}.steps b{color:var(--ink)}#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:14px 22px;background:var(--ink);color:white;border-radius:13px;box-shadow:0 5px 30px #0003;max-width:90vw;z-index:10}[hidden]{display:none!important}@media(max-width:1000px){.workspace{grid-template-columns:160px minmax(0,1fr);gap:18px}.columns{grid-template-columns:1fr}.help{margin-left:178px}.editor-head{display:block}.editor-head .badge{margin-bottom:10px}}@media(max-width:620px){main{padding:25px 16px}.workspace{display:block}aside{margin-bottom:18px}#screens{display:flex;overflow-x:auto}.screen{min-width:160px}aside>.hint{display:none}.intro{display:block}.intro>.badge{margin-top:8px}#editor{padding:18px}.help{margin-left:0}.savebar{align-items:stretch;flex-direction:column}header{padding:15px}.brand small{display:none}header button{font-size:12px}fieldset{grid-template-columns:1fr}} + +textarea{font:12px/1.5 ui-monospace,monospace;width:100%;resize:vertical;border:1px solid var(--line);border-radius:10px;padding:12px;margin-top:8px;background:#f7f9fc;color:var(--ink)}.yaml-actions{display:flex;gap:10px;margin-bottom:20px} + +.firmware-link{margin:26px 0 0 238px;font-size:13px}.firmware-link a{color:var(--accent);text-decoration:none;font-weight:600}@media(max-width:1000px){.firmware-link{margin-left:178px}}@media(max-width:620px){.firmware-link{margin-left:0}} diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml new file mode 100644 index 0000000..d57b407 --- /dev/null +++ b/screen_manager/config.yaml @@ -0,0 +1,17 @@ +name: ESP Screen Manager +version: "0.1.0" +slug: esp_screen_manager +description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen. +url: https://github.com/MaxGramser/homeassistant_espscreen +arch: [aarch64, amd64] +startup: application +boot: auto +init: false +homeassistant_api: true +ingress: true +ingress_port: 8099 +panel_icon: mdi:monitor-dashboard +panel_title: ESP Screens +panel_admin: true +options: {} +schema: {} diff --git a/tests/test_calibration_math.cpp b/tests/test_calibration_math.cpp new file mode 100644 index 0000000..a1ed240 --- /dev/null +++ b/tests/test_calibration_math.cpp @@ -0,0 +1,14 @@ +#include "../components/smart_display/calibration_math.h" +#include +int main() { + using namespace screen_calibration; + Calibration c; + std::array raw; + for (int i=0;i<5;++i) raw[i] = {280+TARGETS[i].y*3580/240,340+TARGETS[i].x*3520/320}; + assert(fit(raw,c)); + for(int i=0;i<5;++i) { auto p=project(c,raw[i]); assert(std::hypot(p.x-TARGETS[i].x,p.y-TARGETS[i].y)<0.01); } + raw[4].x+=600; assert(!fit(raw,c)); // independent center check + for (auto &r:raw) r={1000,1000}; assert(!fit(raw,c)); + for (int i=0;i<5;++i) raw[i] = {280+TARGETS[i].y*3580/240+TARGETS[i].x*0.2,340+TARGETS[i].x*3520/320}; + assert(fit(raw,c)); // skew correction +} diff --git a/tests/test_chunk_transport.cpp b/tests/test_chunk_transport.cpp new file mode 100644 index 0000000..6ebad8f --- /dev/null +++ b/tests/test_chunk_transport.cpp @@ -0,0 +1,20 @@ +#include "../components/smart_display/chunk_transport.h" +#include +int main() { + runtime_tiles::Chunks c; + assert(c.accept("a|0|0|YWJj", 100)); assert(!c.complete); + assert(c.accept("a|1|1|ZA==", 101)); assert(c.complete && c.data == "YWJjZA=="); + assert(!c.accept("a|1|1|ZA==", 102)); // duplicate cannot reapply a command + assert(c.accept("b|0|0|YWJj", 200)); + assert(!c.accept("b|2|1|ZA==", 201)); // dropped chunk + assert(c.accept("c|0|0|YWJj", 300)); + assert(!c.accept("other|1|1|ZA==", 301)); // another automation interleaved + assert(c.accept("d|0|0|YWJj", 400)); + assert(!c.accept("d|1|1|ZA==", 10401)); // expired transfer + assert(!c.accept("bad", 20000)); + assert(!c.accept("x|-1|1|YWJj", 20000)); + assert(!c.accept("x|0|1|not base64", 20000)); + assert(!c.accept("x|0|1|" + std::string(201, 'A'), 20000)); + assert(c.accept("e|0|0|YWJj", 20000)); + assert(c.accept("f|0|1|ZA==", 20001)); assert(c.complete && c.data == "ZA=="); +} diff --git a/tests/test_portal.py b/tests/test_portal.py new file mode 100644 index 0000000..41b094f --- /dev/null +++ b/tests/test_portal.py @@ -0,0 +1,121 @@ +import asyncio +import base64 +import importlib.util +import json +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app')) +from core import discover, installation_yaml, packets, state_message, validate_layout + +class ProtocolTests(unittest.TestCase): + def test_unicode_chunks_and_limits(self): + message = {'v':1,'op':'layout','title':'Woonkamer 🌈', 'entities':['light.lamp']*10} + parts = packets(message) + self.assertTrue(all(len(part)<=255 for part in parts)) + self.assertEqual(json.loads(base64.b64decode(''.join(p.split('|')[3] for p in parts))),message) + self.assertTrue(parts[-1].split('|')[2]=='1') + with self.assertRaises(ValueError): packets({'huge':'x'*4096}) + + def test_layout_rejects_duplicates_unsupported_and_overflow(self): + for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(11)],[{'entity':'light.a;restart'}]]: + with self.assertRaises(ValueError): validate_layout({'title':'Thuis','tiles':tiles}) + self.assertEqual(validate_layout({'title':'Thuis','tiles':[]})['tiles'],[]) + + def test_message_has_only_bounded_display_attributes(self): + state={'state':'on','attributes':{'friendly_name':'é'*90,'access_token':'private','hs_color':[float('nan'),300], 'brightness':float('inf')}} + msg=state_message(0,{'entity':'light.a','name':''},{'light.a':state}) + self.assertNotIn('access_token',msg['a']); self.assertNotIn('brightness',msg['a']) + self.assertLessEqual(len(msg['name'].encode()),80) + packets(msg) + + def test_discovery_only_enabled_esphome_inboxes(self): + registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'}, + {'entity_id':'text.wrong','platform':'template','original_name':'Tegelinstellingen'}, + {'entity_id':'text.disabled','platform':'esphome','original_name':'Tegelinstellingen','disabled_by':'user'}] + screens,_=discover(registry,{'text.screen':{'state':'Gesynchroniseerd'}},[],[]) + self.assertEqual([s['id'] for s in screens],['text.screen']) + + def test_unique_credentials_and_remote_profiles(self): + data={'board':'cyd','name':'woonkamer','friendly_name':'Woonkamer'} + first,second=installation_yaml(data),installation_yaml(data) + self.assertNotEqual(first,second) + self.assertIn('DEVICE_NAME: "woonkamer"',first) + self.assertIn('packages/cyd.yaml',first) + self.assertIn('!secret wifi_password',first) + import re + self.assertEqual(len(base64.b64decode(re.search(r'key: "([^"]+)"',first)[1])),32) + for name in ['../bad','Bad Name','a\napi:']: + with self.assertRaises(ValueError): installation_yaml({**data,'name':name}) + +HAS_AIOHTTP=importlib.util.find_spec('aiohttp') is not None +if HAS_AIOHTTP: + from server import Manager, create_app + from aiohttp.test_utils import TestClient, TestServer + +@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests') +class ManagerTests(unittest.IsolatedAsyncioTestCase): + def setup_manager(self,path): + class HA: + online=True + registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'}] + devices=[];areas=[] + states={'text.screen':{'state':'Ready'},'light.a':{'state':'on','attributes':{'friendly_name':'Lamp'}}} + changed=asyncio.Event() + def __init__(self): self.messages=[] + async def send(self,inbox,message): self.messages.append((inbox,message)) + return Manager(HA(),path) + + async def test_update_restart_preserves_layout(self): + with tempfile.TemporaryDirectory() as temp: + path=Path(temp)/'screens.json';m=self.setup_manager(path) + layout={'title':'Mijn huis','tiles':[{'entity':'light.a','name':'Eigen naam'}]} + m.save('text.screen',layout) + fresh=self.setup_manager(path) + self.assertEqual(fresh.layouts['text.screen'],layout) + await fresh.sync_one('text.screen',layout) + self.assertEqual(len(fresh.ha.messages),2) + await fresh.sync_one('text.screen',layout) + self.assertEqual(len(fresh.ha.messages),2,'unchanged states should not resend') + fresh.ha.states['light.a']['state']='off' + await fresh.sync_one('text.screen',layout) + self.assertEqual(fresh.ha.messages[-1][1]['state'],'off') + self.assertEqual(len(fresh.ha.messages),3) + original=path.read_text();path.write_text('{"version":99,"screens":{}}') + with self.assertRaises(ValueError): self.setup_manager(path) + self.assertIn('99',path.read_text(),'unknown schema must never be overwritten') + + async def test_reorder_aborts_old_batch(self): + with tempfile.TemporaryDirectory() as temp: + m=self.setup_manager(Path(temp)/'screens.json');layout={'title':'Thuis','tiles':[{'entity':'light.a','name':''}]} + m.save('text.screen',layout) + original=m.ha.send + async def interrupt(inbox,message): + await original(inbox,message) + m.save(inbox,{'title':'Leeg','tiles':[]}) + m.ha.send=interrupt + await m.sync_one('text.screen',layout) + self.assertEqual(len(m.ha.messages),1) + self.assertNotIn('text.screen',m.sent) + + async def test_http_csrf_validation_download_and_no_token(self): + with tempfile.TemporaryDirectory() as temp: + m=self.setup_manager(Path(temp)/'screens.json') + async with TestClient(TestServer(create_app(m,True))) as client: + response=await client.get('/api/inventory');inventory=await response.json() + self.assertNotIn('token',inventory) + response=await client.put('/api/screens/text.screen',json={'title':'Test','tiles':[]}) + self.assertEqual(response.status,403) + headers={'X-Screen-CSRF':inventory['csrf']} + response=await client.put('/api/screens/text.other',headers=headers,json={'title':'Test','tiles':[]}) + self.assertEqual(response.status,400) + response=await client.put('/api/screens/text.screen',headers=headers,json={'title':'Test','tiles':[]}) + self.assertEqual(response.status,200) + response=await client.post('/api/install',headers=headers,json={'board':'guition','name':'screen-new','friendly_name':'Nieuw'}) + self.assertEqual(response.status,200) + self.assertEqual(response.headers['Cache-Control'],'no-store') + self.assertIn('packages/guition.yaml',await response.text()) + +if __name__=='__main__':unittest.main() diff --git a/tests/test_runtime_model.cpp b/tests/test_runtime_model.cpp new file mode 100644 index 0000000..649ac3c --- /dev/null +++ b/tests/test_runtime_model.cpp @@ -0,0 +1,28 @@ +#include "../components/smart_display/runtime_model.h" +#include +int main() { + using namespace runtime_tiles; + Model m; bool changed = false; + assert(!m.ready()); + assert(!m.set_layout({"light.a", "light.a"}, "Thuis", changed)); + assert(!m.set_layout({"light.a;script.bad"}, "Thuis", changed)); + assert(!m.set_layout({"light."}, "Thuis", changed)); + assert(!m.set_layout({"lock.frontdoor"}, "Thuis", changed)); + assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Thuis", changed)); + assert(changed && !m.ready()); + for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "off"; } + assert(m.ready()); + assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Keuken", changed)); + assert(!changed && m.ready()); // periodic sync never interrupts an open card + assert(!m.accepts(10, "light.a")); + assert(!m.accepts(0, "light.removed")); + assert(m.accepts(0, "light.a")); + assert(m.set_layout({"vacuum.robot", "light.new"}, "Keuken", changed)); + assert(changed && !m.ready()); + assert(m.tiles[0].state.empty()); // never reuse the old slot's state/actions + assert(!m.accepts(0, "light.a")); + assert(m.set_layout({}, "Leeg", changed)); assert(changed && m.ready() && m.count == 0); + std::vector many; + for (int i = 0; i < 11; ++i) many.push_back("light.a" + std::to_string(i)); + assert(!m.set_layout(many, "Te veel", changed)); assert(m.count == 0); +} diff --git a/tools/export_bundle.py b/tools/export_bundle.py index 9f3741a..4b01225 100644 --- a/tools/export_bundle.py +++ b/tools/export_bundle.py @@ -7,7 +7,7 @@ import zipfile ROOT = Path(__file__).resolve().parents[1] FILES = ['.gitignore', 'README.md', 'AGENTS.md', 'requirements.txt', 'device.example.yaml', 'calibration.example.yaml', 'secrets.yaml.example', 'cyd_ui.h', 'light_controls.h', 'LICENSE', - 'guition_diagnostics.h', 'guition-4848s040.yaml', 'guition-device.example.yaml', 'TILE_CONFIGURATION.md', 'CYD_STABILITY.md', 'TEST_RESULTS.md'] + 'guition_diagnostics.h', 'guition-4848s040.yaml', 'guition-device.example.yaml', 'TILE_CONFIGURATION.md', 'CYD_STABILITY.md', 'TEST_RESULTS.md', 'repository.yaml'] FOLDERS = ['docs', 'tools', 'tests', 'components', 'diagnostics', 'fonts'] SUFFIXES = {'.py', '.cpp', '.h', '.md', '.ttf', '.txt'} @@ -29,6 +29,11 @@ def export(root, output): for path in (root / folder).rglob('*'): if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES or path.name == 'LICENSE'): paths.append(path) + # Dedicated distribution directories only; never recursively include owner YAML. + for folder in ['screen_manager', 'packages', 'installers']: + for path in (root / folder).rglob('*'): + if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES | {'.yaml', '.html', '.js', '.css'} or path.name == 'Dockerfile'): + paths.append(path) for path in paths: if path.is_symlink() or not path.resolve().is_relative_to(root): raise ValueError(f'Geen bestanden buiten de bronmap of symlinks toegestaan: {path}') diff --git a/tools/generate_packages.py b/tools/generate_packages.py new file mode 100644 index 0000000..ef2af6b --- /dev/null +++ b/tools/generate_packages.py @@ -0,0 +1,68 @@ +"""Generate self-contained remote packages from the maintained board profiles.""" +import argparse +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] +REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' +REFS = {'cyd': 'main', 'guition': 'main'} + +def generate(board): + source = ROOT / ('home-like-2432s028.yaml' if board == 'cyd' else 'guition-4848s040.yaml') + s = source.read_text() + defaults = (ROOT / ('device.example.yaml' if board == 'cyd' else 'guition-device.example.yaml')).read_text() + defaults += '\n' + (ROOT / 'calibration.example.yaml').read_text() + replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M)) + replacements.update(DYNAMIC_TILES='"true"', DASHBOARD_INBOX_INTERNAL='"false"', DIRECT_ACTIONS='"true"', + ROOM_NAME='"Kies tegels in HA"', TILE_COUNT='"1"') + s = re.sub(r'^ (\w+): ("[^"\n]*")', lambda m: f' {m[1]}: {replacements.get(m[1],m[2])}', s, flags=re.M) + # The owner's wizard YAML supplies wifi/API/OTA credentials. No shared keys. + s = re.sub(r'^ encryption:\n key: !secret api_encryption_key\n', '', s, flags=re.M) + s = re.sub(r'^ota:\n.*?(?=^\w)', '', s, flags=re.M | re.S) + s = re.sub(r'^wifi:\n.*?(?=^\w)', '', s, flags=re.M | re.S) + # Referenced files are fetched as an external component or over HTTPS. + s = re.sub(r'^ - (cyd_ui.h|light_controls.h|guition_diagnostics.h)$', r' - ', s, flags=re.M) + components = '[xpt2046, smart_display]' if board == 'cyd' else '[smart_display]' + external = f'''external_components: + - source: + type: git + url: {REPO}.git + ref: {REFS[board]} + path: components + refresh: 0s + components: {components} + +''' + s = re.sub(r'^external_components:\n.*?(?=^\w)', lambda m: external, s, flags=re.M | re.S) + s = re.sub(r'file: ["\']fonts/([^"\']+)["\']', lambda m: 'file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/' + m[1] + '"', s) + # Keep the established card bindings, but remove all fixed HA subscriptions. + # Runtime values arrive through ESP Screen Manager, including after changing entities. + for section in ['sensor', 'text_sensor']: + match = re.search(r'^' + section + r':\n.*?(?=^[a-zA-Z_]+:|\Z)', s, re.M | re.S) + if not match: continue + block = match[0] + def sensor(m): + body = re.sub(r'^ (entity_id|attribute):.*\n', '', m[1], flags=re.M) + return ' - platform: template\n update_interval: never\n' + body + block = re.sub(r'^ - platform: homeassistant\n(.*?)(?=^ - platform:|\Z)', sensor, block, flags=re.M | re.S) + s = s[:match.start()] + block + s[match.end():] + if '!secret' in s: raise ValueError('Remote package still depends on local secrets') + return '# GENERATED by tools/generate_packages.py; edit the source board profile.\n' + s + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + for board in REFS: + source = ROOT / ('home-like-2432s028.yaml' if board == 'cyd' else 'guition-4848s040.yaml') + if not source.exists(): continue + path = ROOT / 'packages' / f'{board}.yaml' + expected = generate(board) + if args.check: + if not path.exists() or path.read_text() != expected: raise SystemExit(f'Outdated: {path}') + else: + path.parent.mkdir(exist_ok=True) + path.write_text(expected) + print(f'{board}: package ' + ('verified' if args.check else 'generated')) + +if __name__ == '__main__': main()