Add Home Assistant screen manager and runtime tile configuration

This commit is contained in:
Max Gramser
2026-09-12 12:09:58 +02:00
parent 427e4049ab
commit d271a63241
43 changed files with 13991 additions and 388 deletions
+4
View File
@@ -36,3 +36,7 @@ __pycache__/
/guition-device.yaml
/diagnostics/*.png
/.venv-portal/
/easy-guition-device.yaml
/easy-cyd-test.yaml
/portal-test-data/
+13
View File
@@ -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
+14 -3
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Runtime tile configuration transported through the normal ESPHome API."""
CODEOWNERS = []
AUTO_LOAD = ["json"]
@@ -0,0 +1,57 @@
#pragma once
#include <array>
#include <algorithm>
#include <cmath>
namespace screen_calibration {
struct Point { double x{}, y{}; };
struct Calibration { float c[6] = {1,0,0,0,1,0}; int bounds[4] = {280,3860,340,3860}; };
constexpr Point TARGETS[5] = {{20,20}, {299,20}, {299,219}, {20,219}, {160,120}};
inline bool solve(double matrix[3][4], double out[3]) {
for (int column = 0; column < 3; ++column) {
int pivot = column;
for (int row = column + 1; row < 3; ++row) if (std::abs(matrix[row][column]) > std::abs(matrix[pivot][column])) pivot = row;
for (int j = 0; j < 4; ++j) std::swap(matrix[column][j], matrix[pivot][j]);
double scale = matrix[column][column];
if (std::abs(scale) < 1e-8) return false;
for (int j = 0; j < 4; ++j) matrix[column][j] /= scale;
for (int row = 0; row < 3; ++row) if (row != column) {
double factor = matrix[row][column];
for (int j = 0; j < 4; ++j) matrix[row][j] -= factor * matrix[column][j];
}
}
for (int i = 0; i < 3; ++i) out[i] = matrix[i][3];
return true;
}
inline Point project(const Calibration &cal, Point raw) {
auto c = cal.c; auto b = cal.bounds;
return {(c[3]*raw.x+c[4]*raw.y+c[5]-b[2])*320/(b[3]-b[2]),
(c[0]*raw.x+c[1]*raw.y+c[2]-b[0])*240/(b[1]-b[0])};
}
inline bool fit(const std::array<Point,5> &raw, Calibration &cal) {
if (cal.bounds[1] <= cal.bounds[0] || cal.bounds[3] <= cal.bounds[2]) return false;
for (int axis = 0; axis < 2; ++axis) {
double matrix[3][4] = {};
for (int n = 0; n < 4; ++n) {
double row[3] = {raw[n].x/4095, raw[n].y/4095, 1};
double target = axis == 0 ? cal.bounds[0]+TARGETS[n].y*(cal.bounds[1]-cal.bounds[0])/240
: cal.bounds[2]+TARGETS[n].x*(cal.bounds[3]-cal.bounds[2])/320;
for (int i = 0; i < 3; ++i) {
for (int j = 0; j < 3; ++j) matrix[i][j] += row[i]*row[j];
matrix[i][3] += row[i]*target;
}
}
double coefficients[3];
if (!solve(matrix, coefficients)) return false;
for (int i = 0; i < 3; ++i) {
cal.c[axis*3+i] = coefficients[i] / (i == 2 ? 1 : 4095);
if (!std::isfinite(cal.c[axis*3+i]) || (i != 2 && std::abs(cal.c[axis*3+i]) > 4)) return false;
}
}
// Fifth point is held out of the fit: it independently checks the result.
for (int n = 0; n < 5; ++n) {
Point p = project(cal, raw[n]);
if (std::hypot(p.x-TARGETS[n].x,p.y-TARGETS[n].y) > 12) return false;
}
return true;
}
}
@@ -0,0 +1,38 @@
#pragma once
#include <string>
#include <cstdint>
namespace runtime_tiles {
// HA text.set_value is limited to 255 characters. Chunks contain base64 ASCII.
// A complete, ordered message is required before any configuration is applied.
class Chunks {
public:
std::string data;
bool complete = false;
bool accept(const std::string &packet, uint32_t now) {
complete = false;
if (packet.size() > 255) return false;
auto a = packet.find('|'), b = packet.find('|', a + 1), c = packet.find('|', b + 1);
if (a == std::string::npos || b == std::string::npos || c == std::string::npos || a == 0 || a > 24) return false;
std::string token = packet.substr(0, a), number = packet.substr(a + 1, b - a - 1);
if (number.empty() || number.size() > 2) return false;
unsigned index = 0;
for (char ch : number) { if (ch < '0' || ch > '9') return false; index = index * 10 + ch - '0'; }
if (index > 31 || c != b + 2 || (packet[b+1] != '0' && packet[b+1] != '1')) return false;
auto chunk = packet.substr(c + 1);
if (chunk.empty() || chunk.size() > 200) return false;
for (char ch : chunk) if (!(ch >= 'A' && ch <= 'Z') && !(ch >= 'a' && ch <= 'z') && !(ch >= '0' && ch <= '9') && ch != '+' && ch != '/' && ch != '=') return false;
if (index == 0) { data.clear(); token_ = token; next_ = 0; }
else if (now - last_ > 10000) { reset(); return false; }
if (token != token_ || index != next_ || data.size() + chunk.size() > 5464) { reset(); return false; }
data += chunk; next_++; last_ = now;
complete = packet[b+1] == '1';
if (complete) token_.clear();
return true;
}
void reset() { data.clear(); token_.clear(); next_ = 0; complete = false; }
private:
std::string token_;
unsigned next_ = 0;
uint32_t last_ = 0;
};
}
@@ -0,0 +1,80 @@
#pragma once
#include "calibration_math.h"
#include "esphome/core/preferences.h"
#include "esphome/core/hal.h"
#include "lvgl.h"
#include <functional>
namespace screen_calibration {
inline bool active = false, pressed = false;
inline unsigned point = 0, sample = 0;
inline uint32_t down_at = 0;
inline lv_obj_t *screen{}, *home{}, *cross{}, *instructions{}, *status{};
inline Calibration calibration;
inline std::array<std::array<Point,3>,5> samples;
inline esphome::ESPPreferenceObject preference;
inline std::function<void(const Calibration &)> apply;
inline std::function<void(bool)> isolation;
inline void prompt(const char *message = "Houd elke tik even vast") {
lv_obj_set_pos(cross, TARGETS[point].x-12, TARGETS[point].y-12);
lv_label_set_text_fmt(instructions, "Tik op het kruisje\nPunt %u/5 - tik %u/3", point+1, sample+1);
lv_label_set_text(status, message);
}
inline void begin() {
if (!screen) return;
active = true; pressed = false; point = sample = 0;
if (isolation) isolation(true);
prompt(); lv_screen_load(screen);
}
inline void setup(lv_obj_t *home_screen, const lv_font_t *font, const lv_font_t *large,
int xmin, int xmax, int ymin, int ymax) {
if (screen) return;
home = home_screen;
calibration.bounds[0]=xmin; calibration.bounds[1]=xmax; calibration.bounds[2]=ymin; calibration.bounds[3]=ymax;
preference = esphome::global_preferences->make_preference<Calibration>(0x43594403);
Calibration stored;
bool valid = preference.load(&stored);
for (int i = 0; i < 4; ++i) valid &= stored.bounds[i] == calibration.bounds[i];
for (float coefficient : stored.c) valid &= std::isfinite(coefficient);
screen = lv_obj_create(nullptr);
lv_obj_set_style_bg_color(screen, lv_color_hex(0x101820), 0);
lv_obj_set_style_text_color(screen, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(screen, font, 0);
instructions = lv_label_create(screen);
lv_obj_set_style_text_align(instructions, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(instructions, LV_ALIGN_CENTER, 0, -40);
status = lv_label_create(screen); lv_obj_align(status, LV_ALIGN_BOTTOM_MID, 0, -42);
cross = lv_label_create(screen); lv_label_set_text(cross, "+");
lv_obj_set_size(cross, 24, 24); lv_obj_set_style_text_font(cross, large, 0);
lv_obj_set_style_text_align(cross, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(cross, lv_color_hex(0xFFD34D), 0);
if (valid) { calibration = stored; if (apply) apply(calibration); }
else begin();
}
inline void press() { if (active) { down_at = esphome::millis(); pressed = true; } }
inline void release(int raw_x, int raw_y) {
if (!active || !pressed) return;
pressed = false;
if (esphome::millis()-down_at < 80 || raw_x < 0 || raw_x > 4095 || raw_y < 0 || raw_y > 4095) { prompt("Tik rustig, iets langer"); return; }
samples[point][sample++] = {double(raw_x), double(raw_y)};
if (sample < 3) { prompt(); return; }
double xs[3], ys[3];
for (int i=0;i<3;++i) { xs[i]=samples[point][i].x; ys[i]=samples[point][i].y; }
std::sort(xs,xs+3); std::sort(ys,ys+3);
if (xs[2]-xs[0]>180 || ys[2]-ys[0]>180) { sample=0; prompt("Tik 3x op hetzelfde kruisje"); return; }
point++; sample=0;
if (point < 5) { prompt(); return; }
std::array<Point,5> raw;
for (unsigned n=0;n<5;++n) {
for (int i=0;i<3;++i) { xs[i]=samples[n][i].x; ys[i]=samples[n][i].y; }
std::sort(xs,xs+3);std::sort(ys,ys+3);raw[n]={xs[1],ys[1]};
}
Calibration next = calibration;
if (!fit(raw,next)) { point=0;prompt("Meting afgekeurd; probeer opnieuw");return; }
if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt("Opslaan mislukt; probeer opnieuw");return; }
calibration=next;
if (apply) apply(calibration);
active=false;
if (isolation) isolation(false);
lv_screen_load(home);
}
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace cyd {
// Work in tenths of a degree and always clamp AFTER rounding.
inline int quantize_temperature(int value, int minimum, int maximum, int step) {
step = std::max(1, step);
value = std::clamp(value, minimum, maximum);
const int snapped = minimum + static_cast<int>(std::lround(
static_cast<double>(value - minimum) / step)) * step;
return std::clamp(snapped, minimum, maximum);
}
class TouchGuard {
public:
void begin(uint32_t now, int x = 0, int y = 0) {
started_ = now;
accepted_ = false;
moved_ = false;
start_x_ = x;
start_y_ = y;
}
void update(int x, int y) {
// A contact that jumps to another target must never become a tile tap.
if (std::abs(x - start_x_) > 18 || std::abs(y - start_y_) > 18) moved_ = true;
}
bool accept(uint32_t now, int tile) {
if (accepted_ || moved_ || now - started_ < 60) return false;
if (has_previous_ && tile == previous_tile_ && now - previous_ < 600) return false;
accepted_ = true;
has_previous_ = true;
previous_tile_ = tile;
previous_ = now;
return true;
}
private:
uint32_t started_{0}, previous_{0};
int previous_tile_{-1};
int start_x_{0}, start_y_{0};
bool moved_{false};
bool accepted_{false}, has_previous_{false};
};
inline TouchGuard touch_guard;
} // namespace cyd
@@ -0,0 +1,59 @@
#pragma once
#include "lvgl.h"
#include "esphome/core/log.h"
// On-demand 240x240 diagnostic image of LVGL's rendered screen. Nothing is
// allocated or streamed during normal use. The RGB display signal itself
// still needs a physical visual check.
namespace guition {
inline bool check_geometry(lv_obj_t *obj) {
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return true;
bool ok = true;
lv_area_t area;
lv_obj_get_coords(obj, &area);
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_CLICKABLE) &&
(area.x1 < 0 || area.y1 < 0 || area.x2 >= 480 || area.y2 >= 480)) {
ESP_LOGE("ui_test", "GEOMETRY FAIL clickable=(%d,%d)-(%d,%d)",
area.x1, area.y1, area.x2, area.y2);
ok = false;
}
for (uint32_t i = 0; i < lv_obj_get_child_count(obj); ++i)
ok = check_geometry(lv_obj_get_child(obj, i)) && ok;
return ok;
}
#if LV_USE_SNAPSHOT
inline lv_draw_buf_t *snapshot = nullptr;
inline int snapshot_row = 0;
inline void snapshot_begin() {
if (snapshot) lv_draw_buf_destroy(snapshot);
snapshot = lv_snapshot_take(lv_screen_active(), LV_COLOR_FORMAT_RGB565);
snapshot_row = 0;
if (!snapshot || snapshot->header.w != 480 || snapshot->header.h != 480) {
if (snapshot) lv_draw_buf_destroy(snapshot);
snapshot = nullptr;
ESP_LOGE("ui_image", "IMAGE FAIL allocation/dimensions");
return;
}
ESP_LOGI("ui_image", "IMAGE BEGIN 240 240");
}
inline void snapshot_next_row() {
if (!snapshot) return;
const auto *row = reinterpret_cast<const uint16_t *>(
snapshot->data + snapshot_row * 2 * snapshot->header.stride);
static constexpr char hex[] = "0123456789abcdef";
char line[961];
for (int x = 0; x < 240; ++x) {
uint16_t pixel = row[x * 2];
for (int digit = 0; digit < 4; ++digit)
line[x * 4 + digit] = hex[(pixel >> (12 - digit * 4)) & 15];
}
line[960] = 0;
ESP_LOGI("ui_image", "ROW %03d %s", snapshot_row, line);
if (++snapshot_row == 240) {
lv_draw_buf_destroy(snapshot);
snapshot = nullptr;
ESP_LOGI("ui_image", "IMAGE COMPLETE");
}
}
#endif
} // namespace guition
+221
View File
@@ -0,0 +1,221 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
namespace light_controls {
inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); }
// HA serializes list attributes as either JSON lists or Python tuples.
inline bool first_number(const std::string &text, float &value) {
const char *p = text.c_str();
while (*p == ' ' || *p == '[' || *p == '(') ++p;
char *end;
float parsed = std::strtof(p, &end);
if (end == p || !std::isfinite(parsed)) return false;
value = parsed;
return true;
}
struct State {
const char *entity = "";
int hue = 0, kelvin = 3000, minimum = 0, maximum = 0;
bool temperature_ready() const { return minimum >= 1000 && maximum <= 15000 && maximum > minimum; }
void update(unsigned attribute, const std::string &text) {
float number;
if (!first_number(text, number)) return;
if (attribute == 0 && number >= 0 && number <= 360) hue = clamp(std::lround(number), 0, 360);
if (attribute == 1 && number >= 1000 && number <= 15000) kelvin = std::lround(number);
if (attribute == 2) minimum = number >= 1000 && number <= 15000 ? std::lround(number) : 0;
if (attribute == 3) maximum = number >= 1000 && number <= 15000 ? std::lround(number) : 0;
}
};
}
#ifndef LIGHT_CONTROLS_TEST
#include "esphome/components/api/api_server.h"
#include "lvgl.h"
#include <functional>
namespace light_controls {
inline State states[8];
inline State fallback;
inline State *active = &fallback;
struct Row { lv_obj_t *box{}, *slider{}, *value{}; bool dirty = false; unsigned index{}; };
inline Row rows[3];
inline std::function<void(int)> commits[3];
inline bool ready = false;
inline bool demo = false;
inline void state_updated(State *state);
inline int top, spacing;
inline void subscribe(const char *const *entities, size_t count) {
#ifdef USE_API_HOMEASSISTANT_STATES
static const char *attributes[] = {"hs_color", "color_temp_kelvin", "min_color_temp_kelvin", "max_color_temp_kelvin"};
size_t slot = 0;
for (size_t i = 0; i < count && slot < 8; ++i) {
if (std::string(entities[i]).rfind("light.", 0) != 0) continue;
bool duplicate = false;
for (size_t j = 0; j < slot; ++j) if (std::string(states[j].entity) == entities[i]) duplicate = true;
if (duplicate) continue;
State *state = &states[slot++];
state->entity = entities[i];
for (unsigned a = 0; a < 4; ++a)
esphome::api::global_api_server->subscribe_home_assistant_state(state->entity, attributes[a],
[state, a](esphome::StringRef value) { state->update(a, std::string(value.c_str(), value.size())); state_updated(state); });
}
#else
(void) entities;
(void) count;
#endif
}
inline void preview(Row &row) {
int value = lv_slider_get_value(row.slider);
if (row.index == 0) {
lv_label_set_text_fmt(row.value, "%d°", value);
lv_obj_set_style_bg_color(row.slider, lv_color_hsv_to_rgb(value % 360, 100, 100), LV_PART_KNOB);
} else if (row.index == 1) {
lv_label_set_text_fmt(row.value, "%d K", value);
} else lv_label_set_text_fmt(row.value, "%d %%", value);
}
inline void event(lv_event_t *e) {
auto &row = *static_cast<Row *>(lv_event_get_user_data(e));
auto code = lv_event_get_code(e);
if (code == LV_EVENT_VALUE_CHANGED) { row.dirty = true; preview(row); }
if (code == LV_EVENT_PRESS_LOST) row.dirty = false;
if (code == LV_EVENT_RELEASED && row.dirty) {
row.dirty = false;
int value = lv_slider_get_value(row.slider);
if (row.index == 0) active->hue = value;
if (row.index == 1) {
if (!active->temperature_ready()) return;
value = clamp(value, active->minimum, active->maximum);
active->kelvin = value;
}
if (!demo && commits[row.index]) commits[row.index](value);
}
}
inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
auto *obj = lv_obj_create(parent);
lv_obj_remove_style_all(obj);
lv_obj_remove_flag(obj, static_cast<lv_obj_flag_t>(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE));
lv_obj_set_pos(obj, x, y); lv_obj_set_size(obj, w, h);
return obj;
}
inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height) {
if (ready) return;
ready = true;
bool large = width >= 480;
top = large ? 94 : 55; spacing = large ? 110 : 58;
int margin = large ? 24 : 16, w = width - 2 * margin;
int track_x = 14, track_y = large ? 43 : 28, track_h = large ? 28 : 14;
int track_w = w - 28;
const char *names[] = {"Kleur", "Wittemperatuur", "Helderheid"};
const uint32_t rainbow[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i]; row.index = i;
row.box = plain(parent, margin, top + i * spacing, w, spacing - 2);
lv_obj_set_style_text_font(row.box, font, 0);
lv_obj_set_style_text_color(row.box, lv_color_hex(0xEEEEF2), 0);
auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]);
lv_obj_set_pos(label, 2, 0);
row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -2, 0);
unsigned segments = i == 0 ? 6 : 1;
for (unsigned n = 0; n < segments; ++n) {
int start = n * track_w / segments, end = (n + 1) * track_w / segments;
auto *stripe = plain(row.box, track_x + start, track_y, end - start, track_h);
lv_obj_set_style_bg_opa(stripe, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(stripe, lv_color_hex(i == 0 ? rainbow[n] : i == 1 ? 0xFF9C32 : 0x40404A), 0);
lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? rainbow[n + 1] : i == 1 ? 0xC6E6FF : 0xFFFFFF), 0);
lv_obj_set_style_bg_grad_dir(stripe, LV_GRAD_DIR_HOR, 0);
}
row.slider = lv_slider_create(row.box);
lv_obj_remove_style_all(row.slider);
lv_obj_remove_flag(row.slider, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h);
lv_obj_set_ext_click_area(row.slider, large ? 12 : 8);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB);
lv_obj_set_style_pad_all(row.slider, large ? 5 : 6, LV_PART_KNOB);
lv_obj_set_style_border_width(row.slider, 2, LV_PART_KNOB);
lv_obj_set_style_border_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : i == 1 ? 2000 : 1, i == 0 ? 360 : i == 1 ? 6500 : 100);
lv_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row);
preview(row);
}
}
// Late capability attributes may arrive after opening the card. Enable the
// waiting temperature slider without disturbing an in-progress gesture.
inline void state_updated(State *state) {
if (!ready || state != active || !state->temperature_ready()) return;
auto &row = rows[1];
if (!lv_obj_has_state(row.slider, LV_STATE_DISABLED)) return;
lv_slider_set_range(row.slider, state->minimum, state->maximum);
lv_slider_set_value(row.slider, state->kelvin, LV_ANIM_OFF);
lv_obj_remove_state(row.slider, LV_STATE_DISABLED);
preview(row); row.dirty = false;
}
inline void open(const std::string &entity, bool color, bool temperature, int brightness) {
active = &fallback;
for (auto &state : states) if (entity == state.entity) { active = &state; break; }
int y = top;
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i];
bool visible = i == 0 ? color : i == 1 ? temperature : true;
if (!visible) { lv_obj_add_flag(row.box, LV_OBJ_FLAG_HIDDEN); continue; }
lv_obj_remove_flag(row.box, LV_OBJ_FLAG_HIDDEN); lv_obj_set_y(row.box, y); y += spacing;
lv_obj_remove_state(row.slider, LV_STATE_DISABLED);
if (i == 1 && !active->temperature_ready()) lv_obj_add_state(row.slider, LV_STATE_DISABLED);
if (i == 1 && active->temperature_ready()) lv_slider_set_range(row.slider, active->minimum, active->maximum);
lv_slider_set_value(row.slider, i == 0 ? active->hue : i == 1 ? active->kelvin : clamp(brightness, 1, 100), LV_ANIM_OFF);
preview(row); row.dirty = false;
if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Even wachten");
}
}
inline bool self_test() {
auto *previous = active;
State saved_fallback = fallback;
int saved_values[3]; bool saved_hidden[3];
std::function<void(int)> saved[3];
int calls[3] = {0, 0, 0};
for (unsigned i = 0; i < 3; ++i) {
saved[i] = commits[i]; saved_values[i] = lv_slider_get_value(rows[i].slider);
saved_hidden[i] = lv_obj_has_flag(rows[i].box, LV_OBJ_FLAG_HIDDEN);
commits[i] = [&, i](int value) { if (value == (i == 0 ? 180 : i == 1 ? 4000 : 65)) ++calls[i]; };
}
fallback.minimum = 2200; fallback.maximum = 5000;
open("diagnostic", true, true, 50);
bool ok = true;
for (unsigned i = 0; i < 3; ++i) {
auto *slider = rows[i].slider;
lv_slider_set_value(slider, i == 0 ? 180 : i == 1 ? 4000 : 65, LV_ANIM_OFF);
for (int n = 0; n < 20; ++n) lv_obj_send_event(slider, LV_EVENT_VALUE_CHANGED, nullptr);
ok &= calls[i] == 0;
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
ok &= calls[i] == 1;
lv_obj_send_event(slider, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_send_event(slider, LV_EVENT_PRESS_LOST, nullptr);
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
ok &= calls[i] == 1;
}
for (unsigned mask = 0; mask < 4; ++mask) {
open("diagnostic", mask & 1, mask & 2, 50);
ok &= lv_obj_has_flag(rows[0].box, LV_OBJ_FLAG_HIDDEN) == !(mask & 1);
ok &= lv_obj_has_flag(rows[1].box, LV_OBJ_FLAG_HIDDEN) == !(mask & 2);
ok &= !lv_obj_has_flag(rows[2].box, LV_OBJ_FLAG_HIDDEN);
}
fallback = saved_fallback;
open(previous->entity, !saved_hidden[0], !saved_hidden[1], saved_values[2]);
active = previous;
for (unsigned i = 0; i < 3; ++i) {
commits[i] = saved[i];
lv_slider_set_value(rows[i].slider, saved_values[i], LV_ANIM_OFF);
preview(rows[i]); rows[i].dirty = false;
}
return ok;
}
}
#endif
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <array>
#include <string>
#include <vector>
#include <cmath>
#include <cstdint>
namespace runtime_tiles {
constexpr size_t MAX_TILES = 10;
inline bool valid_entity(const std::string &entity) {
if (entity.size() > 120) return false;
auto dot = entity.find('.');
if (dot == std::string::npos || dot == 0 || dot + 1 == entity.size()) return false;
for (size_t i = 0; i < entity.size(); ++i)
if (i != dot && !(entity[i] >= 'a' && entity[i] <= 'z') &&
!(entity[i] >= '0' && entity[i] <= '9') && entity[i] != '_') return false;
std::string domain = entity.substr(0, dot);
for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "button", "input_button"})
if (domain == allowed) return true;
return false;
}
struct Tile {
std::string entity, name, state, unit, modes, hvac_modes;
std::array<std::string, 4> fan_speeds;
unsigned fan_speed_count = 0;
float brightness = NAN, percentage = NAN, position = NAN;
float current = NAN, target = NAN, humidity = NAN, minimum = 7, maximum = 35, step = 0.5f;
int hue = 0, kelvin = 3000, min_kelvin = 0, max_kelvin = 0;
bool received = false;
std::string domain() const { return entity.substr(0, entity.find('.')); }
bool available() const { return received && state != "unknown" && state != "unavailable" && !state.empty(); }
bool active() const { return state == "on" || state == "cleaning" || (domain() == "climate" && available() && state != "off"); }
};
struct Model {
std::array<Tile, MAX_TILES> tiles;
size_t count = 0;
std::string title = "Kies tegels in HA";
bool configured = false;
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed) {
if (entities.size() > MAX_TILES || name.size() > 96) return false;
for (size_t i = 0; i < entities.size(); ++i) {
if (!valid_entity(entities[i])) return false;
for (size_t j = 0; j < i; ++j) if (entities[i] == entities[j]) return false;
}
changed = !configured || count != entities.size();
for (size_t i = 0; i < entities.size(); ++i) if (tiles[i].entity != entities[i]) changed = true;
// A title-only update must not interrupt an open control card.
title = name.empty() ? "Thuis" : name;
if (changed) {
for (auto &tile : tiles) tile = Tile{};
count = entities.size();
for (size_t i = 0; i < count; ++i) tiles[i].entity = entities[i];
}
configured = true;
return true;
}
bool ready() const {
if (!configured) return false;
for (size_t i = 0; i < count; ++i) if (!tiles[i].received) return false;
return true;
}
bool accepts(size_t index, const std::string &entity) const {
return configured && index < count && tiles[index].entity == entity;
}
};
} // namespace runtime_tiles
+190
View File
@@ -0,0 +1,190 @@
#pragma once
#include "runtime_model.h"
#include "cyd_ui.h"
#include "light_controls.h"
#include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/hal.h"
#include "lvgl.h"
#include <functional>
#include <algorithm>
namespace runtime_tiles {
inline bool enabled = false;
inline Model model;
inline int active_index = -1;
inline uint32_t last_received = 0;
inline std::function<void()> layout_changed, refresh, dismiss;
inline std::function<void(Tile &)> detail, detail_update;
struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; };
inline std::array<Widgets, MAX_TILES> widgets;
inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; }
inline float number(JsonVariant value, float fallback = NAN) {
if (!value.is<float>() && !value.is<int>()) return fallback;
float n = value.as<float>();
return std::isfinite(n) ? n : fallback;
}
inline std::string string(JsonVariant value, size_t maximum = 160) {
if (!value.is<const char *>()) return {};
std::string s = value.as<std::string>();
if (s.size() > maximum) {
while (maximum > 0 && (static_cast<unsigned char>(s[maximum]) & 0xC0) == 0x80) --maximum;
s.resize(maximum);
}
return s;
}
inline std::string list(JsonVariant value) {
if (!value.is<JsonArray>()) return {};
std::string out;
serializeJson(value, out);
return out.size() <= 512 ? out : "";
}
inline std::string receive(const std::string &payload) {
if (!enabled) return "Gebruik het Easy Setup-profiel";
if (payload.size() > 4096) return "Fout: bericht te groot";
std::string result = "Fout: ongeldig bericht";
esphome::json::parse_json(payload, [&](JsonObject root) -> bool {
if (root["v"].as<int>() != 1) { result = "Fout: protocolversie"; return false; }
auto op = string(root["op"]);
if (op == "layout") {
if (!root["entities"].is<JsonArray>() || !root["title"].is<const char *>()) return false;
std::vector<std::string> entities;
for (JsonVariant entity : root["entities"].as<JsonArray>()) {
if (!entity.is<const char *>() || entities.size() == MAX_TILES) return false;
entities.push_back(entity.as<std::string>());
}
bool changed = false;
if (!model.set_layout(entities, string(root["title"], 96), changed)) return false;
if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); }
last_received = esphome::millis();
if (layout_changed) layout_changed();
if (refresh) refresh();
result = "Indeling ontvangen";
return true;
}
if (op != "state" || !root["i"].is<unsigned>() || !root["a"].is<JsonObject>()) return false;
unsigned index = root["i"].as<unsigned>();
std::string entity = string(root["entity"], 120);
if (!model.accepts(index, entity)) { result = "Fout: verouderde tegel"; return false; }
Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>();
tile.name = string(root["name"], 80);
tile.state = string(root["state"], 160);
tile.unit = string(a["unit_of_measurement"], 20);
tile.brightness = number(a["brightness"]);
tile.percentage = number(a["percentage"]);
tile.position = number(a["current_position"]);
tile.current = number(a["current_temperature"]);
tile.target = number(a["temperature"]);
tile.humidity = number(a["current_humidity"]);
tile.minimum = number(a["min_temp"], 7);
tile.maximum = number(a["max_temp"], 35);
tile.step = number(a["target_temp_step"], 0.5f);
tile.modes = list(a["supported_color_modes"]);
tile.hvac_modes = list(a["hvac_modes"]);
float hue = number(a["hs_color"][0]);
if (std::isfinite(hue)) tile.hue = std::lround(std::clamp(hue, 0.0f, 360.0f));
float kelvin = number(a["color_temp_kelvin"]);
if (std::isfinite(kelvin)) tile.kelvin = std::lround(std::clamp(kelvin, 1000.0f, 15000.0f));
tile.min_kelvin = std::clamp(number(a["min_color_temp_kelvin"], 0), 0.0f, 15000.0f);
tile.max_kelvin = std::clamp(number(a["max_color_temp_kelvin"], 0), 0.0f, 15000.0f);
tile.fan_speed_count = 0;
if (a["fan_speed_list"].is<JsonArray>()) for (JsonVariant speed : a["fan_speed_list"].as<JsonArray>()) {
if (tile.fan_speed_count == 4) break;
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48);
}
tile.received = true;
last_received = esphome::millis();
if (refresh) refresh();
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden";
return true;
});
return result;
}
inline void action(const std::string &service, const std::string &entity) {
if (!fresh() || !valid_entity(entity)) return;
esphome::api::HomeassistantActionRequest request;
request.service = esphome::StringRef(service);
request.data.init(1);
esphome::api::HomeassistantServiceMap entry;
entry.key = esphome::StringRef("entity_id");
entry.value = esphome::StringRef(entity);
request.data.push_back(entry);
esphome::api::global_api_server->send_homeassistant_action(request);
}
inline const char *icon_for(const Tile &tile) {
auto d = tile.domain();
if (d == "light") return "\U000F0335";
if (d == "climate") return "\U000F001B";
if (d == "vacuum") return "\U000F070D";
if (d == "fan") return "\U000F0210";
if (d == "cover") return "\U000F111C";
if (d == "scene" || d == "script") return "\U000F04B9";
if (d == "sensor" || d == "binary_sensor") return "\U000F029A";
return "\U000F0425";
}
inline void event(lv_event_t *event) {
auto &w = *static_cast<Widgets *>(lv_event_get_user_data(event));
if (!enabled || !fresh() || w.index >= model.count) return;
auto code = lv_event_get_code(event);
if (code != LV_EVENT_SHORT_CLICKED && code != LV_EVENT_LONG_PRESSED) return;
if (!cyd::touch_guard.accept(esphome::millis(), 100 + w.index)) return;
auto &tile = model.tiles[w.index];
auto d = tile.domain();
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
if (!tile.available()) return;
bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover";
if (open) {
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan" || d == "cover") {
active_index = w.index;
if (detail) detail(tile);
}
return;
}
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity);
if (d == "scene" || d == "script") action(d + ".turn_on", tile.entity);
if (d == "button" || d == "input_button") action(d + ".press", tile.entity);
}
inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) {
widgets[index] = {tile, title, value, circle, icon, index};
lv_obj_add_event_cb(tile, event, LV_EVENT_SHORT_CLICKED, &widgets[index]);
lv_obj_add_event_cb(tile, event, LV_EVENT_LONG_PRESSED, &widgets[index]);
}
inline void label(lv_obj_t *obj, const std::string &text) {
if (text != lv_label_get_text(obj)) lv_label_set_text(obj, text.c_str());
}
inline void render(lv_obj_t *room) {
if (!enabled) return;
label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !fresh() ? "HA niet verbonden" : model.title);
for (size_t i = 0; i < model.count; ++i) {
const auto &t = model.tiles[i]; auto &w = widgets[i];
label(w.title, t.name.empty() ? t.entity : t.name);
label(w.icon, icon_for(t));
std::string value = t.state;
if (!fresh() || !t.available()) value = "Niet beschikbaar";
else if (t.domain() == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (t.domain() == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (value == "on") value = "Aan";
else if (value == "off") value = "Uit";
else if (value == "cleaning") value = "Bezig";
else if (value == "docked") value = "In dock";
else if (!t.unit.empty()) value += " " + t.unit;
label(w.value, value);
bool on = fresh() && t.active();
if (w.cached_active == static_cast<int>(on)) continue;
w.cached_active = on;
uint32_t accent = t.domain() == "light" ? 0xE4B23C : t.domain() == "climate" ? 0xF18750 : 0x548BD4;
lv_obj_set_style_bg_color(w.tile, lv_color_hex(on ? 0xF5F1E8 : 0x202A38), 0);
lv_obj_set_style_bg_color(w.circle, lv_color_hex(on ? accent : 0x303A48), 0);
lv_obj_set_style_text_color(w.icon, lv_color_hex(on ? 0xFFFFFF : accent), 0);
lv_obj_set_style_text_color(w.title, lv_color_hex(on ? 0x172232 : 0xF3F5F7), 0);
lv_obj_set_style_text_color(w.value, lv_color_hex(on ? 0x46525E : 0xAFBAC8), 0);
}
}
inline std::string vacuum_option(unsigned index) {
if (active_index < 0 || static_cast<size_t>(active_index) >= model.count) return {};
auto &tile = model.tiles[active_index];
return index < tile.fan_speed_count ? tile.fan_speeds[index] : "";
}
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "runtime_tiles.h"
#include "chunk_transport.h"
#include "esphome/components/text/text.h"
#include "esphome/core/component.h"
namespace esphome::smart_display {
// Command transport, not a stored JSON state. HA states stay well below 255 chars.
class DashboardInbox : public text::Text, public Component {
public:
void setup() override { this->publish_state("Klaar voor tegelconfiguratie"); }
protected:
runtime_tiles::Chunks chunks_;
void control(const std::string &value) override {
if (!chunks_.accept(value, millis())) { this->publish_state("Fout: onvolledig bericht"); return; }
if (!chunks_.complete) return;
std::string decoded(chunks_.data.size() * 3 / 4 + 1, '\0');
size_t length = base64_decode(chunks_.data, reinterpret_cast<uint8_t *>(decoded.data()), decoded.size());
chunks_.reset();
if (length == 0 || length > 4096) { this->publish_state("Fout: ongeldige codering"); return; }
decoded.resize(length);
this->publish_state(runtime_tiles::receive(decoded));
}
};
}
+15
View File
@@ -0,0 +1,15 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import text
from esphome.const import CONF_ID
AUTO_LOAD = ["json"]
ns = cg.esphome_ns.namespace("smart_display")
DashboardInbox = ns.class_("DashboardInbox", text.Text, cg.Component)
CONFIG_SCHEMA = text.text_schema(DashboardInbox, mode="TEXT", entity_category="config").extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await text.register_text(var, config, min_length=0, max_length=255, pattern=None)
+5 -45
View File
@@ -1,46 +1,6 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace cyd {
// Work in tenths of a degree and always clamp AFTER rounding.
inline int quantize_temperature(int value, int minimum, int maximum, int step) {
step = std::max(1, step);
value = std::clamp(value, minimum, maximum);
const int snapped = minimum + static_cast<int>(std::lround(
static_cast<double>(value - minimum) / step)) * step;
return std::clamp(snapped, minimum, maximum);
}
class TouchGuard {
public:
void begin(uint32_t now, int x = 0, int y = 0) {
started_ = now;
accepted_ = false;
moved_ = false;
start_x_ = x;
start_y_ = y;
}
void update(int x, int y) {
// A contact that jumps to another target must never become a tile tap.
if (std::abs(x - start_x_) > 18 || std::abs(y - start_y_) > 18) moved_ = true;
}
bool accept(uint32_t now, int tile) {
if (accepted_ || moved_ || now - started_ < 60) return false;
if (has_previous_ && tile == previous_tile_ && now - previous_ < 600) return false;
accepted_ = true;
has_previous_ = true;
previous_tile_ = tile;
previous_ = now;
return true;
}
private:
uint32_t started_{0}, previous_{0};
int previous_tile_{-1};
int start_x_{0}, start_y_{0};
bool moved_{false};
bool accepted_{false}, has_previous_{false};
};
inline TouchGuard touch_guard;
} // namespace cyd
#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
+51
View File
@@ -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())
+164
View File
@@ -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/).
+58
View File
@@ -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.
+143 -34
View File
@@ -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}
+1 -56
View File
@@ -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<const uint16_t *>(
snapshot->data + snapshot_row * 2 * snapshot->header.stride);
static constexpr char hex[] = "0123456789abcdef";
char line[961];
for (int x = 0; x < 240; ++x) {
uint16_t pixel = row[x * 2];
for (int digit = 0; digit < 4; ++digit)
line[x * 4 + digit] = hex[(pixel >> (12 - digit * 4)) & 15];
}
line[960] = 0;
ESP_LOGI("ui_image", "ROW %03d %s", snapshot_row, line);
if (++snapshot_row == 240) {
lv_draw_buf_destroy(snapshot);
snapshot = nullptr;
ESP_LOGI("ui_image", "IMAGE COMPLETE");
}
}
} // namespace guition
#include "esphome/components/smart_display/guition_diagnostics.h"
+156 -35
View File
@@ -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();'
+9
View File
@@ -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
+9
View File
@@ -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
+4 -214
View File
@@ -1,216 +1,6 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
namespace light_controls {
inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); }
// HA serializes list attributes as either JSON lists or Python tuples.
inline bool first_number(const std::string &text, float &value) {
const char *p = text.c_str();
while (*p == ' ' || *p == '[' || *p == '(') ++p;
char *end;
float parsed = std::strtof(p, &end);
if (end == p || !std::isfinite(parsed)) return false;
value = parsed;
return true;
}
struct State {
const char *entity = "";
int hue = 0, kelvin = 3000, minimum = 0, maximum = 0;
bool temperature_ready() const { return minimum >= 1000 && maximum <= 15000 && maximum > minimum; }
void update(unsigned attribute, const std::string &text) {
float number;
if (!first_number(text, number)) return;
if (attribute == 0 && number >= 0 && number <= 360) hue = clamp(std::lround(number), 0, 360);
if (attribute == 1 && number >= 1000 && number <= 15000) kelvin = std::lround(number);
if (attribute == 2) minimum = number >= 1000 && number <= 15000 ? std::lround(number) : 0;
if (attribute == 3) maximum = number >= 1000 && number <= 15000 ? std::lround(number) : 0;
}
};
}
#ifndef LIGHT_CONTROLS_TEST
#include "esphome/components/api/api_server.h"
#include "lvgl.h"
#include <functional>
namespace light_controls {
inline State states[8];
inline State fallback;
inline State *active = &fallback;
struct Row { lv_obj_t *box{}, *slider{}, *value{}; bool dirty = false; unsigned index{}; };
inline Row rows[3];
inline std::function<void(int)> commits[3];
inline bool ready = false;
inline bool demo = false;
inline void state_updated(State *state);
inline int top, spacing;
inline void subscribe(const char *const *entities, size_t count) {
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<Row *>(lv_event_get_user_data(e));
auto code = lv_event_get_code(e);
if (code == LV_EVENT_VALUE_CHANGED) { row.dirty = true; preview(row); }
if (code == LV_EVENT_PRESS_LOST) row.dirty = false;
if (code == LV_EVENT_RELEASED && row.dirty) {
row.dirty = false;
int value = lv_slider_get_value(row.slider);
if (row.index == 0) active->hue = value;
if (row.index == 1) {
if (!active->temperature_ready()) return;
value = clamp(value, active->minimum, active->maximum);
active->kelvin = value;
}
if (!demo && commits[row.index]) commits[row.index](value);
}
}
inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
auto *obj = lv_obj_create(parent);
lv_obj_remove_style_all(obj);
lv_obj_remove_flag(obj, static_cast<lv_obj_flag_t>(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE));
lv_obj_set_pos(obj, x, y); lv_obj_set_size(obj, w, h);
return obj;
}
inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height) {
if (ready) return;
ready = true;
bool large = width >= 480;
top = large ? 94 : 55; spacing = large ? 110 : 58;
int margin = large ? 24 : 16, w = width - 2 * margin;
int track_x = 14, track_y = large ? 43 : 28, track_h = large ? 28 : 14;
int track_w = w - 28;
const char *names[] = {"Kleur", "Wittemperatuur", "Helderheid"};
const uint32_t rainbow[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i]; row.index = i;
row.box = plain(parent, margin, top + i * spacing, w, spacing - 2);
lv_obj_set_style_text_font(row.box, font, 0);
lv_obj_set_style_text_color(row.box, lv_color_hex(0xEEEEF2), 0);
auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]);
lv_obj_set_pos(label, 2, 0);
row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -2, 0);
unsigned segments = i == 0 ? 6 : 1;
for (unsigned n = 0; n < segments; ++n) {
int start = n * track_w / segments, end = (n + 1) * track_w / segments;
auto *stripe = plain(row.box, track_x + start, track_y, end - start, track_h);
lv_obj_set_style_bg_opa(stripe, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(stripe, lv_color_hex(i == 0 ? rainbow[n] : i == 1 ? 0xFF9C32 : 0x40404A), 0);
lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? rainbow[n + 1] : i == 1 ? 0xC6E6FF : 0xFFFFFF), 0);
lv_obj_set_style_bg_grad_dir(stripe, LV_GRAD_DIR_HOR, 0);
}
row.slider = lv_slider_create(row.box);
lv_obj_remove_style_all(row.slider);
lv_obj_remove_flag(row.slider, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h);
lv_obj_set_ext_click_area(row.slider, large ? 12 : 8);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB);
lv_obj_set_style_pad_all(row.slider, large ? 5 : 6, LV_PART_KNOB);
lv_obj_set_style_border_width(row.slider, 2, LV_PART_KNOB);
lv_obj_set_style_border_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : i == 1 ? 2000 : 1, i == 0 ? 360 : i == 1 ? 6500 : 100);
lv_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row);
preview(row);
}
}
// Late capability attributes may arrive after opening the card. Enable the
// waiting temperature slider without disturbing an in-progress gesture.
inline void state_updated(State *state) {
if (!ready || state != active || !state->temperature_ready()) return;
auto &row = rows[1];
if (!lv_obj_has_state(row.slider, LV_STATE_DISABLED)) return;
lv_slider_set_range(row.slider, state->minimum, state->maximum);
lv_slider_set_value(row.slider, state->kelvin, LV_ANIM_OFF);
lv_obj_remove_state(row.slider, LV_STATE_DISABLED);
preview(row); row.dirty = false;
}
inline void open(const std::string &entity, bool color, bool temperature, int brightness) {
active = &fallback;
for (auto &state : states) if (entity == state.entity) { active = &state; break; }
int y = top;
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i];
bool visible = i == 0 ? color : i == 1 ? temperature : true;
if (!visible) { lv_obj_add_flag(row.box, LV_OBJ_FLAG_HIDDEN); continue; }
lv_obj_remove_flag(row.box, LV_OBJ_FLAG_HIDDEN); lv_obj_set_y(row.box, y); y += spacing;
lv_obj_remove_state(row.slider, LV_STATE_DISABLED);
if (i == 1 && !active->temperature_ready()) lv_obj_add_state(row.slider, LV_STATE_DISABLED);
if (i == 1 && active->temperature_ready()) lv_slider_set_range(row.slider, active->minimum, active->maximum);
lv_slider_set_value(row.slider, i == 0 ? active->hue : i == 1 ? active->kelvin : clamp(brightness, 1, 100), LV_ANIM_OFF);
preview(row); row.dirty = false;
if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Even wachten");
}
}
inline bool self_test() {
auto *previous = active;
State saved_fallback = fallback;
int saved_values[3]; bool saved_hidden[3];
std::function<void(int)> saved[3];
int calls[3] = {0, 0, 0};
for (unsigned i = 0; i < 3; ++i) {
saved[i] = commits[i]; saved_values[i] = lv_slider_get_value(rows[i].slider);
saved_hidden[i] = lv_obj_has_flag(rows[i].box, LV_OBJ_FLAG_HIDDEN);
commits[i] = [&, i](int value) { if (value == (i == 0 ? 180 : i == 1 ? 4000 : 65)) ++calls[i]; };
}
fallback.minimum = 2200; fallback.maximum = 5000;
open("diagnostic", true, true, 50);
bool ok = true;
for (unsigned i = 0; i < 3; ++i) {
auto *slider = rows[i].slider;
lv_slider_set_value(slider, i == 0 ? 180 : i == 1 ? 4000 : 65, LV_ANIM_OFF);
for (int n = 0; n < 20; ++n) lv_obj_send_event(slider, LV_EVENT_VALUE_CHANGED, nullptr);
ok &= calls[i] == 0;
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
ok &= calls[i] == 1;
lv_obj_send_event(slider, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_send_event(slider, LV_EVENT_PRESS_LOST, nullptr);
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
ok &= calls[i] == 1;
}
for (unsigned mask = 0; mask < 4; ++mask) {
open("diagnostic", mask & 1, mask & 2, 50);
ok &= lv_obj_has_flag(rows[0].box, LV_OBJ_FLAG_HIDDEN) == !(mask & 1);
ok &= lv_obj_has_flag(rows[1].box, LV_OBJ_FLAG_HIDDEN) == !(mask & 2);
ok &= !lv_obj_has_flag(rows[2].box, LV_OBJ_FLAG_HIDDEN);
}
fallback = saved_fallback;
open(previous->entity, !saved_hidden[0], !saved_hidden[1], saved_values[2]);
active = previous;
for (unsigned i = 0; i < 3; ++i) {
commits[i] = saved[i];
lv_slider_set_value(rows[i].slider, saved_values[i], LV_ANIM_OFF);
preview(rows[i]); rows[i].dirty = false;
}
return ok;
}
}
#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
+5973
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
name: Home Assistant ESP Screens
url: https://github.com/MaxGramser/homeassistant_espscreen
maintainer: MaxGramser
+7
View File
@@ -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.
+24
View File
@@ -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.
+8
View File
@@ -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"]
+8
View File
@@ -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.
+137
View File
@@ -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:
'''
+261
View File
@@ -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())
+64
View File
@@ -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);
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ESP Screens</title><link rel="stylesheet" href="static/style.css"><script src="static/app.js" defer></script></head>
<body>
<header><div class="brand"><span class="mark">▦</span><div><strong>ESP Screens</strong><small>Je huis. Jouw bediening.</small></div></div><button id="new-screen">+ Nieuw scherm</button></header>
<main>
<div class="intro"><div><p class="eyebrow">HOME ASSISTANT</p><h1>Een plek voor je favorieten.</h1><p>Kies wat je wilt bedienen. Je scherm verandert met je mee.</p></div><span id="connection" role="status" class="badge">Verbinden…</span></div>
<div class="workspace">
<aside><h2>Mijn schermen</h2><div id="screens"></div><p class="hint">Nieuw scherm gekoppeld? Het verschijnt hier automatisch binnen 30 seconden.</p><button class="quiet" id="refresh">↻ Vernieuwen</button></aside>
<section id="empty" class="empty"><span class="empty-icon">▦</span><h2>Begin met een scherm</h2><p>Sluit een CYD of Guition aan. We maken de installatie-YAML voor je en helpen je met de eerste flash.</p><button id="start">Nieuw scherm installeren</button></section>
<section id="editor" hidden>
<div class="editor-head"><div><p class="eyebrow" id="screen-name"></p><h2>Maak het jouw scherm</h2></div><span id="delivery" class="badge"></span></div>
<label>Naam bovenaan het scherm<input id="title" maxlength="60" placeholder="Bijvoorbeeld: Woonkamer"></label>
<div class="columns"><div class="tile-editor"><div class="section-title"><h3>Je tegels</h3><span id="count">0 / 10</span></div><p class="hint">Sleep om te ordenen of gebruik de pijltjes. Zes tegels per pagina.</p><ol id="tiles"></ol><p id="no-tiles" class="hint">Voeg rechts je eerste lamp, scène of apparaat toe.</p></div>
<div class="picker"><label for="search">Voeg een entiteit toe</label><input id="search" type="search" placeholder="Zoek op naam, apparaat of ruimte…" autocomplete="off"><div class="filters" id="filters"></div><div id="results" aria-live="polite"></div></div></div>
<footer class="savebar"><div><strong id="dirty">Alles opgeslagen</strong><small id="save-detail">Aanpassen kan zonder opnieuw flashen.</small></div><button id="save">Opslaan & naar scherm</button></footer>
</section></div>
<p class="firmware-link"><a href="/5c53de3b_esphome" target="_top">Firmware bijwerken in ESPHome ↗</a><small>Open je bestaande apparaat → Install → Wirelessly. Je tegels en sleutels blijven behouden.</small></p><details class="help"><summary>Hoe werkt het dagelijks?</summary><p>Zoek een entiteit, voeg deze toe en klik op <b>Opslaan & naar scherm</b>. 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.</p><p>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.</p></details>
</main>
<dialog id="installer"><form id="install-form"><div class="dialog-head"><div><p class="eyebrow">NIEUW SCHERM</p><h2>Van USB naar aan de muur.</h2></div><button type="button" class="quiet" id="close-install" aria-label="Sluiten">✕</button></div><p>Kies je scherm. Elk bestand krijgt eigen sleutels voor een veilige koppeling en draadloze updates.</p><fieldset><legend>Welk scherm heb je?</legend><label class="board"><input type="radio" name="board" value="cyd" checked><span><b>CYD · 2,8 inch</b><small>ESP32-2432S028 · 320 × 240</small></span></label><label class="board"><input type="radio" name="board" value="guition"><span><b>Guition · 4 inch</b><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span></label></fieldset><label>Herkenbare naam<input name="friendly_name" required maxlength="60" placeholder="Scherm woonkamer"></label><label>Unieke apparaatnaam<input name="name" required pattern="[a-z][a-z0-9-]{0,29}" maxlength="30" placeholder="scherm-woonkamer"><small>Kleine letters, cijfers en streepjes. Elk scherm een andere naam.</small></label><button type="submit" id="download">Maak installatie-YAML</button><p id="install-status" role="status"></p><div id="yaml-result" hidden><label>Je eigen ESPHome-YAML<textarea id="yaml-output" readonly rows="9" spellcheck="false"></textarea></label><div class="yaml-actions"><button type="button" id="copy-yaml">Kopieer YAML</button><button type="button" class="quiet" id="download-yaml">Download bestand</button></div></div><ol class="steps"><li><b>Open ESPHome Device Builder.</b> Voeg een nieuw apparaat toe, sla de eerste installatie over en vervang de inhoud via Edit door het gedownloade bestand.</li><li><b>Vul wifi eenmalig in bij Secrets.</b> Gebruik <code>wifi_ssid</code> en <code>wifi_password</code>.</li><li><b>Sluit USB aan op je Home Assistant Raspberry.</b> Klik Install → Plug into the computer running ESPHome Device Builder en kies de USB-poort.</li><li><b>Koppel in Instellingen → Apparaten & diensten → ESPHome.</b> Gebruik de <code>api.encryption.key</code> uit je bestand en sta Home Assistant-acties toe bij Configureren.</li><li><b>Open deze pagina en kies je tegels.</b> CYD toont bij de eerste start een kalibratie: tik ieder kruisje drie keer. Guition hoeft niet gekalibreerd.</li></ol><p class="hint">Bewaar je YAML met sleutels. Gebruik later Install → Wirelessly voor firmware-updates. Tegels wijzigen doe je hier.</p></form></dialog>
<div id="toast" role="status" hidden></div>
</body></html>
File diff suppressed because one or more lines are too long
+17
View File
@@ -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: {}
+14
View File
@@ -0,0 +1,14 @@
#include "../components/smart_display/calibration_math.h"
#include <cassert>
int main() {
using namespace screen_calibration;
Calibration c;
std::array<Point,5> 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
}
+20
View File
@@ -0,0 +1,20 @@
#include "../components/smart_display/chunk_transport.h"
#include <cassert>
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==");
}
+121
View File
@@ -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()
+28
View File
@@ -0,0 +1,28 @@
#include "../components/smart_display/runtime_model.h"
#include <cassert>
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<std::string> 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);
}
+6 -1
View File
@@ -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}')
+68
View File
@@ -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' - <esphome/components/smart_display/\1>', 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()