Add Home Assistant screen manager and runtime tile configuration
This commit is contained in:
@@ -36,3 +36,7 @@ __pycache__/
|
|||||||
/guition-device.yaml
|
/guition-device.yaml
|
||||||
|
|
||||||
/diagnostics/*.png
|
/diagnostics/*.png
|
||||||
|
/.venv-portal/
|
||||||
|
/easy-guition-device.yaml
|
||||||
|
/easy-cyd-test.yaml
|
||||||
|
/portal-test-data/
|
||||||
|
|||||||
@@ -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
|
## Guition-branch
|
||||||
|
|
||||||
De Guition 4848S040 heeft een apart profiel `guition-4848s040.yaml` met
|
De Guition 4848S040 heeft een apart profiel `guition-4848s040.yaml` met
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
> **Guition ESP32-S3 4-inch / 480×480:** gebruik [de Guition-handleiding](docs/GUITION.md)
|
# Home Assistant ESP Screens
|
||||||
> en `guition-device.yaml`. De handleiding hieronder betreft het 2,8-inch CYD.
|
|
||||||
|
**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
|
# 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
|
vacuum, maximaal tien tegels en vaste pagina's. Bij maximaal zes tegels
|
||||||
verdwijnt de paginering. Standby begint standaard na tien minuten.
|
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.
|
of een ESPHome-add-on te installeren om via de computer te flashen.
|
||||||
|
|
||||||
## Met een LLM of developer werken
|
## Met een LLM of developer werken
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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] : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -1,46 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <algorithm>
|
#if __has_include("esphome/components/smart_display/cyd_ui.h")
|
||||||
#include <cmath>
|
#include "esphome/components/smart_display/cyd_ui.h"
|
||||||
#include <cstdint>
|
#else
|
||||||
|
#include "components/smart_display/cyd_ui.h"
|
||||||
namespace cyd {
|
#endif
|
||||||
// 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,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())
|
||||||
@@ -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/).
|
||||||
@@ -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
@@ -1,6 +1,8 @@
|
|||||||
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
|
# 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.
|
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
|
||||||
substitutions:
|
substitutions:
|
||||||
|
DYNAMIC_TILES: "false"
|
||||||
|
DASHBOARD_INBOX_INTERNAL: "true"
|
||||||
DEVICE_NAME: "guition-wallbox"
|
DEVICE_NAME: "guition-wallbox"
|
||||||
DEVICE_FRIENDLY_NAME: "Guition Wallbox"
|
DEVICE_FRIENDLY_NAME: "Guition Wallbox"
|
||||||
CALIBRATION_ON_BOOT: "false"
|
CALIBRATION_ON_BOOT: "false"
|
||||||
@@ -570,7 +572,17 @@ substitutions:
|
|||||||
# Source: https://github.com/Templarian/MaterialDesign-Webfont
|
# Source: https://github.com/Templarian/MaterialDesign-Webfont
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
external_components:
|
||||||
|
- source:
|
||||||
|
type: local
|
||||||
|
path: components
|
||||||
|
components: [smart_display]
|
||||||
|
|
||||||
esphome:
|
esphome:
|
||||||
|
min_version: 2026.6.2
|
||||||
|
project:
|
||||||
|
name: maxgramser.esp_screen
|
||||||
|
version: "0.1.0"
|
||||||
platformio_options:
|
platformio_options:
|
||||||
build_flags:
|
build_flags:
|
||||||
- -DLV_USE_SNAPSHOT=1
|
- -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_column(id(vacuum_actions), 4, 0);
|
||||||
lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0);
|
lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0);
|
||||||
lv_obj_set_style_pad_column(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: |-
|
- lambda: |-
|
||||||
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"};
|
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::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[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[1] = [](int value) { id(set_light_color_temperature).execute(value); };
|
||||||
@@ -935,6 +1014,10 @@ font:
|
|||||||
id: materialdesign_icons
|
id: materialdesign_icons
|
||||||
size: 42
|
size: 42
|
||||||
glyphs:
|
glyphs:
|
||||||
|
- "\U000F0335"
|
||||||
|
- "\U000F0210"
|
||||||
|
- "\U000F111C"
|
||||||
|
- "\U000F029A"
|
||||||
- "${MDI_GLYPH_1}"
|
- "${MDI_GLYPH_1}"
|
||||||
- "${MDI_GLYPH_2}"
|
- "${MDI_GLYPH_2}"
|
||||||
- "${MDI_GLYPH_3}"
|
- "${MDI_GLYPH_3}"
|
||||||
@@ -1226,6 +1309,11 @@ binary_sensor:
|
|||||||
# - Used by the UI to display fan presets when applicable
|
# - Used by the UI to display fan presets when applicable
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
text_sensor:
|
text_sensor:
|
||||||
|
- platform: template
|
||||||
|
name: "Schermfirmware"
|
||||||
|
entity_category: diagnostic
|
||||||
|
lambda: 'return {"0.1.0"};'
|
||||||
|
update_interval: 60s
|
||||||
- platform: template
|
- platform: template
|
||||||
id: smartdisplay_action
|
id: smartdisplay_action
|
||||||
name: "SmartDisplay Action"
|
name: "SmartDisplay Action"
|
||||||
@@ -1741,7 +1829,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 1);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile1_press" }
|
- script.execute: { id: publish_action, action: "tile1_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -1759,7 +1847,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 1);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile1_long_press" }
|
- script.execute: { id: publish_action, action: "tile1_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -1876,7 +1964,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 2);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile2_press" }
|
- script.execute: { id: publish_action, action: "tile2_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -1894,7 +1982,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 2);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile2_long_press" }
|
- script.execute: { id: publish_action, action: "tile2_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2011,7 +2099,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 3);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile3_press" }
|
- script.execute: { id: publish_action, action: "tile3_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2029,7 +2117,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 3);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile3_long_press" }
|
- script.execute: { id: publish_action, action: "tile3_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2146,7 +2234,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 4);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile4_press" }
|
- script.execute: { id: publish_action, action: "tile4_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2164,7 +2252,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 4);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile4_long_press" }
|
- script.execute: { id: publish_action, action: "tile4_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2281,7 +2369,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 5);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile5_press" }
|
- script.execute: { id: publish_action, action: "tile5_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2299,7 +2387,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 5);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile5_long_press" }
|
- script.execute: { id: publish_action, action: "tile5_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2416,7 +2504,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 6);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile6_press" }
|
- script.execute: { id: publish_action, action: "tile6_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2434,7 +2522,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 6);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile6_long_press" }
|
- script.execute: { id: publish_action, action: "tile6_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2554,7 +2642,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 7);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile7_press" }
|
- script.execute: { id: publish_action, action: "tile7_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2569,7 +2657,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 7);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile7_long_press" }
|
- script.execute: { id: publish_action, action: "tile7_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2677,7 +2765,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 8);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile8_press" }
|
- script.execute: { id: publish_action, action: "tile8_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2692,7 +2780,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 8);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile8_long_press" }
|
- script.execute: { id: publish_action, action: "tile8_long_press" }
|
||||||
# Slider-modus kan hier niet zonder eerst de bijbehorende
|
# Slider-modus kan hier niet zonder eerst de bijbehorende
|
||||||
@@ -2766,7 +2854,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 9);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile9_press" }
|
- script.execute: { id: publish_action, action: "tile9_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2781,7 +2869,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 9);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile9_long_press" }
|
- script.execute: { id: publish_action, action: "tile9_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2884,7 +2972,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 10);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile10_press" }
|
- script.execute: { id: publish_action, action: "tile10_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2899,7 +2987,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 10);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile10_long_press" }
|
- script.execute: { id: publish_action, action: "tile10_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -4028,7 +4116,9 @@ lvgl:
|
|||||||
scrollable: false
|
scrollable: false
|
||||||
pad_all: 0
|
pad_all: 0
|
||||||
on_click:
|
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:
|
widgets:
|
||||||
- label:
|
- label:
|
||||||
align: CENTER
|
align: CENTER
|
||||||
@@ -4048,7 +4138,9 @@ lvgl:
|
|||||||
scrollable: false
|
scrollable: false
|
||||||
pad_all: 0
|
pad_all: 0
|
||||||
on_click:
|
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:
|
widgets:
|
||||||
- label:
|
- label:
|
||||||
align: CENTER
|
align: CENTER
|
||||||
@@ -4068,7 +4160,9 @@ lvgl:
|
|||||||
scrollable: false
|
scrollable: false
|
||||||
pad_all: 0
|
pad_all: 0
|
||||||
on_click:
|
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:
|
widgets:
|
||||||
- label:
|
- label:
|
||||||
align: CENTER
|
align: CENTER
|
||||||
@@ -4088,7 +4182,9 @@ lvgl:
|
|||||||
scrollable: false
|
scrollable: false
|
||||||
pad_all: 0
|
pad_all: 0
|
||||||
on_click:
|
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:
|
widgets:
|
||||||
- label:
|
- label:
|
||||||
align: CENTER
|
align: CENTER
|
||||||
@@ -4279,7 +4375,7 @@ script:
|
|||||||
- delay: 150ms
|
- delay: 150ms
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
lv_obj_update_layout(id(tile_scroll));
|
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;
|
const bool paginated = count > 6;
|
||||||
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
||||||
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
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),
|
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
||||||
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
||||||
const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}};
|
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;
|
const bool paginated = count > 6;
|
||||||
if (!paginated) id(tile_page) = 0;
|
if (!paginated) id(tile_page) = 0;
|
||||||
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) {
|
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) {
|
||||||
@@ -4465,7 +4561,7 @@ script:
|
|||||||
value: !lambda "return value;"
|
value: !lambda "return value;"
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
|
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: climate.set_temperature
|
action: climate.set_temperature
|
||||||
@@ -4510,7 +4606,7 @@ script:
|
|||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
|
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: !lambda "return service;"
|
action: !lambda "return service;"
|
||||||
@@ -4525,7 +4621,7 @@ script:
|
|||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
|
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: vacuum.set_fan_speed
|
action: vacuum.set_fan_speed
|
||||||
@@ -4587,7 +4683,7 @@ script:
|
|||||||
- lvgl.widget.hide: climate_mode_overlay
|
- lvgl.widget.hide: climate_mode_overlay
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
|
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: climate.set_hvac_mode
|
action: climate.set_hvac_mode
|
||||||
@@ -4633,7 +4729,7 @@ script:
|
|||||||
- lambda: 'id(active_brightness_pct) = value;'
|
- lambda: 'id(active_brightness_pct) = value;'
|
||||||
- if:
|
- if:
|
||||||
condition:
|
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:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: light.turn_on
|
action: light.turn_on
|
||||||
@@ -4647,7 +4743,7 @@ script:
|
|||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
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:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: light.turn_on
|
action: light.turn_on
|
||||||
@@ -4664,7 +4760,7 @@ script:
|
|||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
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:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: light.turn_on
|
action: light.turn_on
|
||||||
@@ -5295,6 +5391,12 @@ script:
|
|||||||
then:
|
then:
|
||||||
- delay: 100ms
|
- delay: 100ms
|
||||||
- lambda: |-
|
- 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) {
|
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());
|
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_BG_ACTIVE_COLOR}, ${TILE10_BG_DISABLED_COLOR},
|
||||||
${TILE10_TITLE_ACTIVE_COLOR}, ${TILE10_TITLE_DISABLED_COLOR},
|
${TILE10_TITLE_ACTIVE_COLOR}, ${TILE10_TITLE_DISABLED_COLOR},
|
||||||
${TILE10_VALUE_ACTIVE_COLOR}, ${TILE10_VALUE_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
@@ -1,57 +1,2 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "lvgl.h"
|
#include "esphome/components/smart_display/guition_diagnostics.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
|
|
||||||
|
|||||||
+156
-35
@@ -58,6 +58,8 @@
|
|||||||
############################################################
|
############################################################
|
||||||
|
|
||||||
substitutions:
|
substitutions:
|
||||||
|
DYNAMIC_TILES: "false"
|
||||||
|
DASHBOARD_INBOX_INTERNAL: "true"
|
||||||
DEVICE_NAME: "cyd-new"
|
DEVICE_NAME: "cyd-new"
|
||||||
DEVICE_FRIENDLY_NAME: "Mijn CYD"
|
DEVICE_FRIENDLY_NAME: "Mijn CYD"
|
||||||
CALIBRATION_ON_BOOT: "false"
|
CALIBRATION_ON_BOOT: "false"
|
||||||
@@ -769,9 +771,13 @@ external_components:
|
|||||||
- source:
|
- source:
|
||||||
type: local
|
type: local
|
||||||
path: components
|
path: components
|
||||||
components: [xpt2046]
|
components: [xpt2046, smart_display]
|
||||||
|
|
||||||
esphome:
|
esphome:
|
||||||
|
min_version: 2026.6.2
|
||||||
|
project:
|
||||||
|
name: maxgramser.esp_screen
|
||||||
|
version: "0.1.0"
|
||||||
includes:
|
includes:
|
||||||
- cyd_ui.h
|
- cyd_ui.h
|
||||||
- light_controls.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_column(id(vacuum_actions), 4, 0);
|
||||||
lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0);
|
lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0);
|
||||||
lv_obj_set_style_pad_column(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: |-
|
- lambda: |-
|
||||||
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"};
|
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::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[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[1] = [](int value) { id(set_light_color_temperature).execute(value); };
|
||||||
light_controls::commits[2] = [](int value) { id(color_brightness_commit).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:
|
- light.turn_on:
|
||||||
id: back_light
|
id: back_light
|
||||||
brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;"
|
brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;"
|
||||||
@@ -1120,6 +1201,10 @@ font:
|
|||||||
id: materialdesign_icons
|
id: materialdesign_icons
|
||||||
size: 28
|
size: 28
|
||||||
glyphs:
|
glyphs:
|
||||||
|
- "\U000F0335"
|
||||||
|
- "\U000F0210"
|
||||||
|
- "\U000F111C"
|
||||||
|
- "\U000F029A"
|
||||||
- "${MDI_GLYPH_1}"
|
- "${MDI_GLYPH_1}"
|
||||||
- "${MDI_GLYPH_2}"
|
- "${MDI_GLYPH_2}"
|
||||||
- "${MDI_GLYPH_3}"
|
- "${MDI_GLYPH_3}"
|
||||||
@@ -1411,6 +1496,11 @@ binary_sensor:
|
|||||||
# - Used by the UI to display fan presets when applicable
|
# - Used by the UI to display fan presets when applicable
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
text_sensor:
|
text_sensor:
|
||||||
|
- platform: template
|
||||||
|
name: "Schermfirmware"
|
||||||
|
entity_category: diagnostic
|
||||||
|
lambda: 'return {"0.1.0"};'
|
||||||
|
update_interval: 60s
|
||||||
- platform: template
|
- platform: template
|
||||||
id: smartdisplay_action
|
id: smartdisplay_action
|
||||||
name: "SmartDisplay Action"
|
name: "SmartDisplay Action"
|
||||||
@@ -1733,6 +1823,7 @@ touchscreen:
|
|||||||
id(last_touch_ms) = millis();
|
id(last_touch_ms) = millis();
|
||||||
id(touch_down) = true;
|
id(touch_down) = true;
|
||||||
cyd::touch_guard.begin(millis(), touch.x, touch.y);
|
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));
|
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:
|
on_update:
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
@@ -1744,6 +1835,7 @@ touchscreen:
|
|||||||
- lambda: |-
|
- lambda: |-
|
||||||
id(last_touch_ms) = millis();
|
id(last_touch_ms) = millis();
|
||||||
id(touch_down) = false;
|
id(touch_down) = false;
|
||||||
|
screen_calibration::release(id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y());
|
||||||
|
|
||||||
# Display configuration (ILI9341)
|
# Display configuration (ILI9341)
|
||||||
display:
|
display:
|
||||||
@@ -1975,7 +2067,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 1);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile1_press" }
|
- script.execute: { id: publish_action, action: "tile1_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -1993,7 +2085,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 1);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 1);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile1_long_press" }
|
- script.execute: { id: publish_action, action: "tile1_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2106,7 +2198,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 2);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile2_press" }
|
- script.execute: { id: publish_action, action: "tile2_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2124,7 +2216,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 2);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 2);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile2_long_press" }
|
- script.execute: { id: publish_action, action: "tile2_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2237,7 +2329,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 3);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile3_press" }
|
- script.execute: { id: publish_action, action: "tile3_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2255,7 +2347,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 3);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 3);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile3_long_press" }
|
- script.execute: { id: publish_action, action: "tile3_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2368,7 +2460,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 4);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile4_press" }
|
- script.execute: { id: publish_action, action: "tile4_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2386,7 +2478,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 4);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 4);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile4_long_press" }
|
- script.execute: { id: publish_action, action: "tile4_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2499,7 +2591,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 5);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile5_press" }
|
- script.execute: { id: publish_action, action: "tile5_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2517,7 +2609,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 5);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 5);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile5_long_press" }
|
- script.execute: { id: publish_action, action: "tile5_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2630,7 +2722,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 6);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile6_press" }
|
- script.execute: { id: publish_action, action: "tile6_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2648,7 +2740,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 6);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 6);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile6_long_press" }
|
- script.execute: { id: publish_action, action: "tile6_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2764,7 +2856,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 7);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile7_press" }
|
- script.execute: { id: publish_action, action: "tile7_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2779,7 +2871,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 7);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 7);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile7_long_press" }
|
- script.execute: { id: publish_action, action: "tile7_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -2883,7 +2975,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 8);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile8_press" }
|
- script.execute: { id: publish_action, action: "tile8_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2898,7 +2990,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 8);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 8);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile8_long_press" }
|
- script.execute: { id: publish_action, action: "tile8_long_press" }
|
||||||
# Slider-modus kan hier niet zonder eerst de bijbehorende
|
# Slider-modus kan hier niet zonder eerst de bijbehorende
|
||||||
@@ -2968,7 +3060,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 9);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile9_press" }
|
- script.execute: { id: publish_action, action: "tile9_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -2983,7 +3075,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 9);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 9);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile9_long_press" }
|
- script.execute: { id: publish_action, action: "tile9_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -3082,7 +3174,7 @@ lvgl:
|
|||||||
on_short_click:
|
on_short_click:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 10);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile10_press" }
|
- script.execute: { id: publish_action, action: "tile10_press" }
|
||||||
- script.execute:
|
- script.execute:
|
||||||
@@ -3097,7 +3189,7 @@ lvgl:
|
|||||||
on_long_press:
|
on_long_press:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return cyd::touch_guard.accept(millis(), 10);'
|
lambda: 'return !runtime_tiles::enabled && cyd::touch_guard.accept(millis(), 10);'
|
||||||
then:
|
then:
|
||||||
- script.execute: { id: publish_action, action: "tile10_long_press" }
|
- script.execute: { id: publish_action, action: "tile10_long_press" }
|
||||||
- if:
|
- if:
|
||||||
@@ -4226,7 +4318,9 @@ lvgl:
|
|||||||
scrollable: false
|
scrollable: false
|
||||||
pad_all: 0
|
pad_all: 0
|
||||||
on_click:
|
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:
|
widgets:
|
||||||
- label:
|
- label:
|
||||||
align: CENTER
|
align: CENTER
|
||||||
@@ -4246,7 +4340,9 @@ lvgl:
|
|||||||
scrollable: false
|
scrollable: false
|
||||||
pad_all: 0
|
pad_all: 0
|
||||||
on_click:
|
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:
|
widgets:
|
||||||
- label:
|
- label:
|
||||||
align: CENTER
|
align: CENTER
|
||||||
@@ -4266,7 +4362,9 @@ lvgl:
|
|||||||
scrollable: false
|
scrollable: false
|
||||||
pad_all: 0
|
pad_all: 0
|
||||||
on_click:
|
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:
|
widgets:
|
||||||
- label:
|
- label:
|
||||||
align: CENTER
|
align: CENTER
|
||||||
@@ -4286,7 +4384,9 @@ lvgl:
|
|||||||
scrollable: false
|
scrollable: false
|
||||||
pad_all: 0
|
pad_all: 0
|
||||||
on_click:
|
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:
|
widgets:
|
||||||
- label:
|
- label:
|
||||||
align: CENTER
|
align: CENTER
|
||||||
@@ -4468,7 +4568,7 @@ script:
|
|||||||
- delay: 150ms
|
- delay: 150ms
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
lv_obj_update_layout(id(tile_scroll));
|
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;
|
const bool paginated = count > 6;
|
||||||
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
||||||
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
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),
|
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
|
||||||
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
|
||||||
const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}};
|
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;
|
const bool paginated = count > 6;
|
||||||
if (!paginated) id(tile_page) = 0;
|
if (!paginated) id(tile_page) = 0;
|
||||||
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) {
|
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) {
|
||||||
@@ -4649,7 +4749,7 @@ script:
|
|||||||
value: !lambda "return value;"
|
value: !lambda "return value;"
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
|
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: climate.set_temperature
|
action: climate.set_temperature
|
||||||
@@ -4694,7 +4794,7 @@ script:
|
|||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
|
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: !lambda "return service;"
|
action: !lambda "return service;"
|
||||||
@@ -4709,7 +4809,7 @@ script:
|
|||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
|
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: vacuum.set_fan_speed
|
action: vacuum.set_fan_speed
|
||||||
@@ -4771,7 +4871,7 @@ script:
|
|||||||
- lvgl.widget.hide: climate_mode_overlay
|
- lvgl.widget.hide: climate_mode_overlay
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
|
lambda: 'return (!runtime_tiles::enabled || runtime_tiles::fresh()) && std::string("${DIRECT_ACTIONS}") == "true";'
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: climate.set_hvac_mode
|
action: climate.set_hvac_mode
|
||||||
@@ -4817,7 +4917,7 @@ script:
|
|||||||
- lambda: 'id(active_brightness_pct) = value;'
|
- lambda: 'id(active_brightness_pct) = value;'
|
||||||
- if:
|
- if:
|
||||||
condition:
|
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:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: light.turn_on
|
action: light.turn_on
|
||||||
@@ -4831,7 +4931,7 @@ script:
|
|||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
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:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: light.turn_on
|
action: light.turn_on
|
||||||
@@ -4848,7 +4948,7 @@ script:
|
|||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
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:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: light.turn_on
|
action: light.turn_on
|
||||||
@@ -5479,6 +5579,12 @@ script:
|
|||||||
then:
|
then:
|
||||||
- delay: 100ms
|
- delay: 100ms
|
||||||
- lambda: |-
|
- 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) {
|
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());
|
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_TITLE_ACTIVE_COLOR}, ${TILE10_TITLE_DISABLED_COLOR},
|
||||||
${TILE10_VALUE_ACTIVE_COLOR}, ${TILE10_VALUE_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();'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -1,216 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <algorithm>
|
#if __has_include("esphome/components/smart_display/light_controls.h")
|
||||||
#include <cmath>
|
#include "esphome/components/smart_display/light_controls.h"
|
||||||
#include <cstdio>
|
#else
|
||||||
#include <cstdlib>
|
#include "components/smart_display/light_controls.h"
|
||||||
#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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+5973
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
name: Home Assistant ESP Screens
|
||||||
|
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||||
|
maintainer: MaxGramser
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
'''
|
||||||
@@ -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())
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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: {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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==");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import zipfile
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
FILES = ['.gitignore', 'README.md', 'AGENTS.md', 'requirements.txt', 'device.example.yaml',
|
FILES = ['.gitignore', 'README.md', 'AGENTS.md', 'requirements.txt', 'device.example.yaml',
|
||||||
'calibration.example.yaml', 'secrets.yaml.example', 'cyd_ui.h', 'light_controls.h', 'LICENSE',
|
'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']
|
FOLDERS = ['docs', 'tools', 'tests', 'components', 'diagnostics', 'fonts']
|
||||||
SUFFIXES = {'.py', '.cpp', '.h', '.md', '.ttf', '.txt'}
|
SUFFIXES = {'.py', '.cpp', '.h', '.md', '.ttf', '.txt'}
|
||||||
|
|
||||||
@@ -29,6 +29,11 @@ def export(root, output):
|
|||||||
for path in (root / folder).rglob('*'):
|
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'):
|
if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES or path.name == 'LICENSE'):
|
||||||
paths.append(path)
|
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:
|
for path in paths:
|
||||||
if path.is_symlink() or not path.resolve().is_relative_to(root):
|
if path.is_symlink() or not path.resolve().is_relative_to(root):
|
||||||
raise ValueError(f'Geen bestanden buiten de bronmap of symlinks toegestaan: {path}')
|
raise ValueError(f'Geen bestanden buiten de bronmap of symlinks toegestaan: {path}')
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user