diff --git a/.gitignore b/.gitignore
index fc5d49d..45a0b12 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,25 @@ testdevice.yaml
# Handmatige backups van de config
*.yaml.bak*
+
+# Local rollback binaries and diagnostic captures
+/diagnostics/*.bin
+/diagnostics/*.log
+
+# Python caches from local ESPHome components
+__pycache__/
+*.pyc
+
+# Per-owner onboarding files and share bundles
+/device.yaml
+/calibration.yaml
+/calibration.proposed.yaml
+/measurements*.json
+/dist/
+/.venv/
+
+# Local editor state and measurement/temporary outputs
+/.idea/
+/calibration*.yaml.bak-*
+/calibration*.yaml.tmp-*
+/.DS_Store
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 30cf57e..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,10 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Ignored default folder with query files
-/queries/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/esphome-cyd-display.iml b/.idea/esphome-cyd-display.iml
deleted file mode 100644
index c956989..0000000
--- a/.idea/esphome-cyd-display.iml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index c5cc3aa..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/php.xml b/.idea/php.xml
deleted file mode 100644
index f324872..0000000
--- a/.idea/php.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..6c2c5e8
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,48 @@
+# Werkinstructies voor LLM's en developers
+
+Dit project bedient een ESP32-2432S028 met ILI9341 + XPT2046 (320×240,
+LVGL 90°). Lees README.md en docs/ voordat je installeert. De eigenaar kan
+fysiek tikken; een agent kan dat niet vervangen door softwarecoördinaten.
+
+## Een nieuw scherm installeren
+
+1. Identificeer USB-poort en bordvariant. Gebruik ESPHome 2026.6.2 met Python
+ 3.11–3.14. Controleer eerst of er lokale configuratie bestaat.
+2. Maak in een verse kopie met `tools/new_device.py` een eigen `device.yaml`,
+ `calibration.yaml`, `secrets.yaml`. Overschrijf nooit een werkend profiel.
+ Laat de eigenaar wifi lokaal invullen. Toon geen sleutels in logs/chat.
+3. Flash `device.yaml` met de CLI-substitutie `CALIBRATION_ON_BOOT=true`.
+ Het geïsoleerde scherm heeft vijf kruisjes en werkt zonder HA.
+4. Volg docs/CALIBREREN.md: geleide USB-capture, fit, flash, nieuwe capture,
+ onafhankelijke verify. Vraag fysieke tikken per doel. Wacht op bevestiging
+ dat het meetscherm werkelijk zichtbaar is; een geslaagde build is geen flash.
+5. Flash zonder die override. Koppel de eigen HA via de ESPHome-integratie.
+ Lees echte entity-ID's en ondersteunde attributen; verzin geen entiteiten.
+6. Configureer alle gebruikte tegels volgens docs/TEGELS.md. De vacuumkaart
+ zit op positie 6. Posities 8/10 hebben geen volledige slider/climatebinding.
+ Test geen echte apparaat-acties zonder toestemming van de eigenaar.
+7. Doorloop docs/ACCEPTATIE.md en rapporteer werkelijk uitgevoerde tests,
+ beperkingen en de geobserveerde stabiliteitsduur.
+
+## Code en regressies
+
+- Houd basishardware en UI in `home-like-2432s028.yaml`; eigen gegevens horen
+ in de genegeerde lokale profielen. `home-like.yaml` is een ander oud profiel.
+- Behoud vaste pagina's, verborgen navigatie bij maximaal zes tegels en
+ minimaal 600 seconden standaard-standby. Geen vrij scrollen herintroduceren
+ zonder touch-/navigatieregressies fysiek te testen.
+- Behoud touchfilter en eventguard vóór acties; kalibratie verwerkt gefilterde
+ fysieke ADC-waarden. Zet de affine correctie niet dubbel in driver en UI.
+- Kalibratiewizard veronderstelt swap_xy=false, mirror_x=true, mirror_y=false
+ en LVGL 90°. Een gewijzigde oriëntatie vereist ook nieuwe projectie/tests.
+- Run Python-tests, beide C++-tests en ESPHome-validatie/build bij codewijzigingen.
+ Firmwaretests en hardwareacceptatie zijn verschillende controles.
+- `diagnostics/run_ui_test.py` rendert zonder HA-acties; raak tijdens die test
+ het scherm niet aan. Gebruik `--name` voor de verwachte apparaatidentiteit.
+- Deel via `tools/export_bundle.py` of Git. Stage geen secrets, metingen,
+ binaries, logs, buildcache of lokale apparaatprofielen.
+- Geen automatische firmware-upload naar een willekeurige aangesloten poort.
+ Bij meerdere borden eerst de bedoelde poort vaststellen.
+
+Historische diagnose is achtergrond, geen bewijs dat een nieuw paneel goed
+werkt. Maak tijdens onboarding geen claims over niet-uitgevoerde fysieke tests.
diff --git a/CYD_STABILITY.md b/CYD_STABILITY.md
new file mode 100644
index 0000000..7da97a1
--- /dev/null
+++ b/CYD_STABILITY.md
@@ -0,0 +1,120 @@
+> Historisch onderzoek aan één paneel. Voor een nieuw scherm: [README.md](README.md)
+> en [de vijfpuntskalibratie](docs/CALIBREREN.md); neem geen meetwaarden over.
+
+# ESP32-2432S028: stabiele bediening
+
+Gebruik **home-like-2432s028.yaml** voor de aangesloten 2,8-inch CYD met
+ILI9341 en XPT2046. `home-like.yaml` hoort bij het andere ST7789V/GT911-bord.
+De wifi-, API- en OTA-geheimen blijven in `secrets.yaml`.
+
+## Bediening
+
+- Stel `TILE_COUNT` in op het aantal gebruikte tegels (1–10). Bij maximaal
+ zes tegels verdwijnen beide paginaknoppen en de pagina-aanduiding; hogere
+ tegelnummers zijn verborgen.
+- Pagina 1 bevat tegels 1–6; pagina 2 bevat 7–10. Gebruik de knoppen
+ **Vorige** en **Volgende** onderaan. Slepen is uitgeschakeld.
+- Kort tikken voert de bestaande tegelactie uit. Lang indrukken opent de
+ bestaande licht-, climate- of vacuumkaart, of de ingestelde lange actie.
+- Standby begint na tien minuten zonder aanraking (`AUTO_DIM_TIMEOUT: "600"`).
+- De eerste tik op het gedimde scherm maakt het scherm wakker.
+- Contacten korter dan 60 ms worden genegeerd. Eén aanraking kan maximaal
+ één tegelactie geven; een verplaatsing groter dan 18 pixels annuleert de tik; dezelfde tegel wordt 600 ms tegen contactdender beschermd.
+- De thermostaat verstuurt de afgeronde, begrensde temperatuur die hij toont.
+ Een lokale temperatuur- of moduskeuze krijgt drie seconden om met HA te
+ synchroniseren. Een ingedrukte bediening voorkomt automatisch dimmen.
+- Pippa gebruikt de echte `vacuum.s8`-status, zodat docken, schoonmaken,
+ pauzeren en terugkeren onderscheiden worden. Ontbrekende HA-statussen
+ worden als `Niet beschikbaar` getoond.
+
+## Technische keuzes
+
+ESPHome 2026.6.2 met ESP-IDF; er zijn geen Arduino-componenten nodig.
+De displaybus draait op 40 MHz, zoals ESPHome's standaard CYD-profiel.
+XPT2046 wordt elke 20 ms uitgelezen zonder GPIO36-interrupt. De drukdrempel
+is 800, na een verdachte hoekmeting bij druk 422. Een lokale XPT2046-driver
+vereist drie consistente ADC-samples voor een nieuwe aanraking, negeert losse
+uitschieters en wacht twee drukloze samples af voor loslaten. Dit voegt circa
+40–60 ms toe bij aanraken en voorkomt dat een onstabiele eerste meting meteen
+als een andere knop wordt geïnterpreteerd. De driver staat onder `components/`. De geïnstalleerde
+interruptdriver probeert op die input-only pin een niet-beschikbare interne
+pull-up te activeren; de oude firmware logde hiervoor een GPIO-fout.
+
+LVGL krijgt 12% tekenbuffer. Geen scrollanimaties en geen extra flitspuls na
+elke tik. Tegelachtergronden zijn ondoorzichtig. Updates binnen 100 ms worden
+samengevoegd; ongewijzigde tegellabels en kleuren worden niet opnieuw gezet.
+De UI-initialisatie draait pas nadat LVGL is gestart. De klok ververst direct
+bij tijdsynchronisatie. Roboto staat lokaal in `fonts/`, inclusief OFL-licentie.
+
+De actieve indeling is 320×240. De oude, uitgecommentarieerde portretpresets
+zijn nog geen aangepaste paginalay-outs: reserveer bij een andere oriëntatie
+ook onderaan 34 pixels voor de navigatie en controleer de tegelposities.
+
+## Bouwen en flashen
+
+```sh
+esphome compile home-like-2432s028.yaml
+esphome upload home-like-2432s028.yaml --device /dev/cu.usbserial-130
+esphome logs home-like-2432s028.yaml --device /dev/cu.usbserial-130
+```
+
+Sluit een lopende seriële loglezer voordat je flasht. De vorige configuratie
+staat lokaal in `diagnostics/before-2432s028.yaml.bak`; de bijbehorende vorige
+factorybinary in `diagnostics/before-2432s028.factory.bin`. Deze lokale backups
+worden niet in Git opgenomen. Teruggaan kan door de backup als configuratie
+te herstellen en opnieuw te bouwen/flashen; herstel de oude Arduino-toolchain
+als je de oude configuratie bouwt.
+
+## Tests
+
+```sh
+clang++ -std=c++17 -Wall -Wextra -Werror tests/test_cyd_ui.cpp -o /tmp/test_cyd_ui
+/tmp/test_cyd_ui
+python3 tests/test_layout.py
+clang++ -std=c++17 -Wall -Wextra -Werror tests/test_touch_filter.cpp -o /tmp/test_touch_filter
+/tmp/test_touch_filter
+```
+
+De C++-test controleert contactdender, korte ruispulsen, opeenvolgende tegels,
+herhaling binnen één aanraking, `millis()`-overflow en temperatuurafronding.
+De indelingstest controleert overlap, schermgrenzen, ruimte voor navigatie
+en het filter vóór beide handlers van alle tien tegels.
+
+Met de Python-omgeving van ESPHome (met aioesphomeapi, PyYAML en pyserial):
+
+```sh
+python diagnostics/capture_serial.py --reset --seconds 120 > diagnostics/runtime.log
+python diagnostics/run_ui_test.py --host cyd-2432s028.local
+```
+
+De API-test leest de encryptiesleutel intern uit `secrets.yaml` en drukt deze
+niet af. Hij wisselt tien keer van pagina en tekent de vijf overlays in elke
+ronde. Hij roept geen Home Assistant-acties aan. Raak het scherm tijdens
+deze circa 11 seconden niet aan. Dit is een teken-/geheugentest; hij vervangt
+geen fysieke toets van de touchkalibratie of een test van HA-service-effecten.
+
+Diagnostiek logt iedere 30 seconden vrije heap en grootste vrije geheugenblok.
+`DEBUG` is tijdelijk nuttig voor touch- en verbindingsdiagnose. Voor dagelijks
+gebruik kan `logger.level` op `INFO` zodra de fysieke bediening goed is.
+
+## Touchdiagnose
+
+`python diagnostics/control_ui.py touch_diagnostics` opent een apart scherm
+met vier kruizen. Tijdens deze meting kunnen aanrakingen geen tegelactie
+uitvoeren. Tik nauwkeurig achtereenvolgens linksboven, rechtsboven,
+rechtsonder en linksonder. De ruwe en genormaliseerde coördinaten staan in
+de seriële logs met tag `touch`. Sluit af met
+`python diagnostics/control_ui.py end_touch_diagnostics`. De kruizen staan
+op (20,20), (299,20), (299,219) en (20,219) voor landschap 320×240.
+
+De huidige kalibratie (X 225–3800, Y 274–3609) is afgeleid uit de drie
+herhaalde metingen rechtsboven en linksonder op dit fysieke bord. De eerdere
+metingen linksboven waren onstabiel en zijn bewust niet gebruikt om het
+hele scherm naar een verkeerde positie te verschuiven.
+
+Daarbovenop corrigeert `set_raw_correction(...)` in `on_boot` de gemeten
+scheefstand. De fit gebruikt de gefilterde hoekmedianen (830,556), (544,3461),
+(3410,3391) en (3492,372). De vier medianen vallen na correctie binnen vijf
+pixels van hun meetdoel in de regressietest. Dit zijn bord-specifieke
+coëfficiënten; neem ze niet blind over op een ander paneel. De `raw=`-logs
+blijven de fysieke, gefilterde ADC-metingen tonen.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..17e05d4
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Adrian Kuehlewind
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a953151
--- /dev/null
+++ b/README.md
@@ -0,0 +1,221 @@
+# CYD Home Assistant-bedieningsscherm
+
+Van een nieuwe **ESP32-2432S028 met ILI9341 + XPT2046** naar een gekalibreerd
+Home Assistant-scherm, via USB. Met lichtbediening, scènes/scripts, climate,
+vacuum, maximaal tien tegels en vaste pagina's. Bij maximaal zes tegels
+verdwijnt de paginering. Standby begint standaard na tien minuten.
+
+**Nieuw hier? Begin hieronder.** Je hoeft geen Home Assistant-token te maken
+of een ESPHome-add-on te installeren om via de computer te flashen.
+
+## Met een LLM of developer werken
+
+Geef de uitgepakte map en deze opdracht:
+
+> Lees eerst AGENTS.md en README.md. Begeleid mij bij een nieuwe installatie
+> op een ESP32-2432S028 die via USB is aangesloten, met mijn eigen Home
+> Assistant. Controleer het bord, de seriële poort en de Python-omgeving.
+> Maak een lokaal device.yaml-profiel; gebruik geen bestaande persoonlijke
+> entiteiten of paneelkalibratie. Doorloop USB-kalibratie, HA-koppeling,
+> tegelconfiguratie en de acceptatietest. Vraag mij om fysieke tikken wanneer
+> nodig. Houd sleutels en wachtwoorden in lokale bestanden en rapporteer
+> welke controles echt zijn uitgevoerd.
+
+De begeleider kan de code en logs lezen, maar kan het fysieke scherm niet
+vanzelf zien. Jij controleert het beeld en voert de gevraagde tikken uit.
+
+## 1. Benodigdheden controleren
+
+- Het bedoelde bord: ESP32-2432S028, 320×240, **ILI9341-display en resistieve
+ XPT2046-touch**. Er bestaan borden met bijna dezelfde naam en een andere
+ schermcontroller. Deze handleiding garandeert die varianten niet.
+- Een USB-**datakabel** en een computer met macOS, Linux of Windows.
+- Python **3.11–3.14**, internet voor de eerste build en enkele GB vrije ruimte.
+ De versiegrens komt uit het geteste ESPHome-pakket; gebruik geen Python 3.15.
+- Een 2,4GHz-wifinetwerk; Home Assistant moet het bord via het netwerk kunnen
+ bereiken. Houd het wifiwachtwoord lokaal beschikbaar.
+- Toegang tot Home Assistant om entiteiten te kiezen en ESPHome toe te voegen.
+
+Gebruik bij voorkeur één aangesloten ESP-bord tegelijk. Noteer de variant en
+het MAC-adres uit de boot-/uploadlogs. `home-like.yaml` en `buttons.yaml` uit
+het oorspronkelijke project zijn **niet** de startersconfiguratie voor dit bord.
+
+## 2. De werkomgeving installeren
+
+Kloon [de repository](https://github.com/MaxGramser/homeassistant_espscreen)
+of pak de starter-ZIP uit. Open een terminal **in de codefolder**. Alle opdrachten hieronder
+worden vanuit die folder uitgevoerd.
+
+macOS/Linux:
+
+```sh
+python3 -m venv .venv
+source .venv/bin/activate
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+```
+
+Windows PowerShell:
+
+```powershell
+py -3.13 -m venv .venv
+.\.venv\Scripts\Activate.ps1
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+```
+
+Als PowerShell activeren blokkeert, gebruik rechtstreeks
+`.\.venv\Scripts\python.exe` in plaats van `python`; een systeemwijziging is
+niet nodig. Controleer daarna:
+
+```sh
+python --version
+python -m esphome version
+python -m serial.tools.list_ports
+```
+
+Verwacht **ESPHome 2026.6.2**. Deze repo bevat een aangepaste touchdriver die
+met deze versie is getest. Upgrade niet stilzwijgend tijdens een installatie.
+Zie ook de [officiële ESPHome-installatiehandleiding](https://esphome.io/install/).
+
+Noteer de poort: bijvoorbeeld `/dev/cu.usbserial-130` op macOS,
+`/dev/ttyUSB0` op Linux of `COM3` op Windows. Verderop staat ``;
+vervang dat steeds door de werkelijke poort, zonder punthaken.
+
+## 3. Een eigen apparaatprofiel maken
+
+Kies een unieke naam, bijvoorbeeld `display-keuken`:
+
+```sh
+python tools/new_device.py --name display-keuken --friendly-name "Display keuken"
+```
+
+Dit maakt drie lokale bestanden en overschrijft niets:
+
+| Bestand | Wat je erin aanpast |
+|---|---|
+| `device.yaml` | Apparaatnaam, ruimte, tegels en gedrag |
+| `calibration.yaml` | Kalibratie van dit ene fysieke paneel; de wizard schrijft dit |
+| `secrets.yaml` | Wifi, API-encryptiesleutel, OTA- en fallbackwachtwoord |
+
+De sleutels worden uniek gegenereerd. Vul in `secrets.yaml` alleen jouw
+`wifi_ssid` en `wifi_password` in. Laat de gegenereerde sleutels staan en
+bewaar dit bestand bij je eigen configuratie. Deel het niet mee in de ZIP.
+De API-encryptiesleutel heb je later nodig bij de HA-koppeling; kopieer die
+lokaal uit het bestand, niet via een openbare chat.
+
+Bestaan deze bestanden al? Werk in een nieuwe kopie van de map voor een nieuw
+scherm. Wis de sleutels/kalibratie van een werkend scherm niet.
+
+`device.yaml` importeert de basis en jouw kalibratie. Het overschrijft ook
+**alle verborgen tegelposities**, zodat die geen entiteiten van iemand anders
+blijven volgen. Begin met de zes voorbeeldtegels. `DIRECT_ACTIONS: "false"`
+voorkomt directe HA-aansturing tijdens de eerste installatie. Maak in deze
+fase nog geen HA-automations die op de actie-sensor reageren.
+
+## 4. Eerste USB-flash en kalibratie
+
+Controleer eerst de configuratie (ESPHome schermt secrets standaard af):
+
+```sh
+python -m esphome config device.yaml
+```
+
+Flash de kalibratiemodus, die zonder HA-verbinding werkt:
+
+```sh
+python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device
+```
+
+De eerste build kan meerdere minuten duren. Na upload verschijnen vijf
+kruisjes op een donker scherm. `run` blijft logs tonen: stop **alleen de
+loglezer met Ctrl+C** voordat je de kalibratiewizard start. Het bord blijft aan.
+
+Volg nu [docs/CALIBREREN.md](docs/CALIBREREN.md): per doel worden drie tikken
+verzameld, de vier hoeken bepalen de correctie en het midden is een
+onafhankelijke controle. Daarna flash je de uitkomst en meet je opnieuw.
+**Kopieer nooit de meetwaarden van een ander paneel.**
+
+## 5. Home Assistant koppelen
+
+Na een geslaagde kalibratie flash je de normale modus:
+
+```sh
+python -m esphome run device.yaml --device
+```
+
+Controleer in de logs de wifi-IP, stabiele boot en apparaatnaam. Ctrl+C sluit
+de loglezer. Het scherm moet nu de normale tegelpagina tonen.
+
+1. Open in HA **Instellingen → Apparaten & diensten**.
+2. Kies het ontdekte ESPHome-apparaat, of **Integratie toevoegen → ESPHome**.
+3. Gebruik `display-keuken.local` of het IP-adres uit de logs; API-poort **6053**.
+4. Vul de `api_encryption_key` uit je lokale `secrets.yaml` in als daarom wordt gevraagd.
+5. Controleer of de apparaatnaam klopt en de status online wordt.
+6. Open de opties/configuratie van deze ESPHome-integratie en schakel
+ **Allow the device to perform Home Assistant actions** in.
+
+De precieze vertaling/plaats van de optie verschilt per HA-versie. Zie de
+[officiële integratiehandleiding](https://www.home-assistant.io/integrations/esphome).
+De HA-integratie verbindt met het bord; de ESPHome Device Builder/add-on is
+optioneel en is een andere functie.
+
+## 6. Eigen tegels instellen
+
+Volg [docs/TEGELS.md](docs/TEGELS.md). Verzamel de echte entity-ID's in HA,
+controleer ondersteunde attributen/acties en pas `device.yaml` aan. Gebruik
+korte titels. Als je alles hebt gecontroleerd:
+
+```yaml
+substitutions:
+ DIRECT_ACTIONS: "true"
+ TILE_COUNT: "6"
+```
+
+Dit zijn wijzigingen in de **bestaande** substitutions-map, geen tweede map
+onderaan het bestand. Bouw en flash opnieuw met `run device.yaml`. De tegels
+volgen daarna de werkelijke HA-status. Een actieve API-verbinding alleen
+bewijst nog niet dat een actie door HA is toegestaan.
+
+## 7. Opleveren en testen
+
+Voer [docs/ACCEPTATIE.md](docs/ACCEPTATIE.md) uit. Dat omvat een onafhankelijke
+touchmeting, beide pagina's indien aanwezig, lange druk, sliders, climate,
+vacuum, werkelijke HA-status en de juiste actie-effecten. De ingebouwde
+renderproef stuurt zelf geen HA-apparaten aan:
+
+```sh
+python diagnostics/run_ui_test.py --host display-keuken.local --name display-keuken
+```
+
+Bewaar bij deze persoon: `device.yaml`, `calibration.yaml`, `secrets.yaml`, de
+meetbestanden, de gebruikte ESPHome-versie en een kort opleverrapport.
+Voor later bijwerken kun je USB blijven gebruiken of OTA gebruiken:
+
+```sh
+python -m esphome run device.yaml --device display-keuken.local
+```
+
+Verander naam/API-sleutel niet zonder ook de HA-koppeling bij te werken.
+Bij storing: [docs/PROBLEMEN.md](docs/PROBLEMEN.md).
+
+## Deze code aan de volgende persoon geven
+
+Gebruik de exporteur in plaats van je hele werkmap te kopiëren:
+
+```sh
+python tools/export_bundle.py --output dist/cyd-starter.zip
+```
+
+De ZIP bevat de code, lokale fonts, licenties, handleidingen en neutrale
+voorbeelden. De basisdefaults worden voor de ontvanger geneutraliseerd.
+Geen `secrets.yaml`, eigen `device.yaml`, metingen, logs, `.git`, `.esphome`,
+Python-omgeving of firmwarebackups. De exporteur weigert een bestaande ZIP
+te overschrijven; kies bij een nieuwe versie een andere bestandsnaam.
+
+De originele basis is van
+[akuehlewind/ESPHome-touch-display-mount](https://github.com/akuehlewind/ESPHome-touch-display-mount).
+Project-, ESPHome-driver- en fontlicenties staan respectievelijk in `LICENSE`,
+`components/xpt2046/LICENSE` en `fonts/`. Historische borddiagnose staat in
+[CYD_STABILITY.md](CYD_STABILITY.md); volg voor een **nieuwe** installatie de
+handleidingen hierboven.
diff --git a/TEST_RESULTS.md b/TEST_RESULTS.md
new file mode 100644
index 0000000..90a8ffe
--- /dev/null
+++ b/TEST_RESULTS.md
@@ -0,0 +1,68 @@
+# Testresultaten ESP32-2432S028 — 12 september 2026
+
+Getest op de via USB aangesloten `cyd-2432s028`, ESP32 rev3.1, ESPHome
+2026.6.2 / ESP-IDF. De firmware meldt compilatietijd
+`2026-09-12 09:54:02 +0200` en is via USB geschreven en geverifieerd.
+
+## Geslaagd
+
+- C++-regressietests voor ruis, contactdender, verspringende aanrakingen,
+ echte sleepbewegingen, temperatuurafronding en `millis()`-overflow.
+- Vijf indelings-/configuratietests voor schermgrenzen, overlap, modale
+ lagen, voorwaardelijke paginering en guards vóór alle tegelhandlers.
+- ESPHome-validatie met `TILE_COUNT: 6`; navigatie is in de code aan
+ `count > 6` gekoppeld en tegels boven het ingestelde aantal zijn verborgen.
+- Volledige ESP-IDF-build en USB-upload met verificatie van de geschreven data.
+- Verbinding met de bestaande Home Assistant-installatie en ontvangst van
+ entiteitsstatussen.
+- **10 paginacontroles en 50 overlay-cycli op het echte apparaat.**
+ Render-startteller steeg van 38 naar 101 tussen de eerste en
+ laatste paginacontrole: de test controleert ook werkelijke tekenactiviteit.
+- Runtime-geometrie: tegelgebied `(0,40)–(319,203)`, knop Volgende
+ `(211,206)–(310,237)`. De gebieden overlappen niet.
+- Vrije heap tijdens de tien testmetingen: 86,416–139,696 bytes
+ volgens `esp_get_free_heap_size()`. Geen oplopend geheugenverlies of reset
+ in deze korte tekenproef.
+
+## Fysieke touchdiagnose
+
+De gebruiker tikte herhaaldelijk de vier schermhoeken aan. De oorspronkelijke
+meting sprong linksboven soms meer dan honderd pixels weg. Na de driverfilter
+bleven de samples in herkenbare hoekgroepen. De overblijvende scheefstand is
+met een affine correctie op de hoekmedianen gecorrigeerd; de regressietest
+plaatst die medianen binnen vijf pixels van het doel. De bronwaarden en
+coëfficiënten staan in de configuratie, tests en CYD_STABILITY.md.
+
+De gebruiker bevestigde na de laatste flash dat de juiste knoppen reageren
+en de bediening nu goed werkt.
+
+## Grenzen
+
+Dit is geen langdurige duurproef. Home Assistant-acties zijn niet automatisch
+uitgevoerd tijdens de schermtest. De gebruiker heeft de fysieke bediening na de laatste kalibratiecorrectie
+bevestigd: "Ja, dit werkt nu goed". De tien minuten
+standby zijn ingesteld; er is niet tien minuten gewacht om de volledige
+idle-timeout af te laten lopen.
+
+Lokale ruwe logs staan in `diagnostics/acceptance-ui-test.log` en
+`diagnostics/acceptance-runtime.log` en worden niet in Git opgenomen.
+
+## Overdraagbare installatie — aanvullende softwarecontrole
+
+Op 12 september 2026 is de nieuwe onboarding getest:
+
+- 17 Python-tests slagen: layout, kalibratie-fit, onafhankelijke verificatie,
+ foutieve/ruisende metingen, unieke secrets, niet overschrijven en export.
+- Beide C++17-regressieprogramma's slagen (touchfilter en UI-guard).
+- Starter-ZIP uitgepakt in een verse tijdelijke map; nieuw profiel met eigen
+ gegenereerde sleutels aangemaakt. Volledige ESPHome 2026.6.2 / ESP-IDF-build
+ met kalibratie-opstartmodus geslaagd.
+- Gegenereerde C++ gecontroleerd op identiteitcorrectie voor het nieuwe bord.
+ Het aparte lokale profiel behoudt de eerder gemeten zes correctiecoëfficiënten.
+- Git-index en bestaande historie gecontroleerd op de lokale credentials;
+ geen overeenkomsten. Lokale profielen en secrets zijn uitgesloten.
+
+De vijfpuntswizard is met synthetische meetgegevens getest. Hij is in deze
+ronde niet opnieuw fysiek uitgevoerd of naar het bestaande bord geflasht;
+voor ieder nieuw paneel blijft de fysieke acceptatie verplicht. De bovenstaande
+oudere hardwaretest betreft de eerder geïnstalleerde firmware.
diff --git a/TILE_CONFIGURATION.md b/TILE_CONFIGURATION.md
index 0b881ce..22dbcec 100644
--- a/TILE_CONFIGURATION.md
+++ b/TILE_CONFIGURATION.md
@@ -1,5 +1,13 @@
+> Nieuwe ESP32-2432S028-installatie: begin bij [README.md](README.md) en
+> [de actuele tegelhandleiding](docs/TEGELS.md). Hieronder staat ook legacygedrag.
+
# Tile Configuration Reference
+For the ESP32-2432S028 profile, use `home-like-2432s028.yaml` and
+[CYD_STABILITY.md](CYD_STABILITY.md). That profile uses fixed pages instead of
+scrolling, hides pagination when `TILE_COUNT <= 6`, and dims after ten minutes.
+The scrolling description below applies to `home-like.yaml`.
+
This document explains every substitution in `home-like.yaml` and provides copy-paste examples for all supported entity types.
---
diff --git a/calibration.example.yaml b/calibration.example.yaml
new file mode 100644
index 0000000..233b609
--- /dev/null
+++ b/calibration.example.yaml
@@ -0,0 +1,13 @@
+# Identiteit: startpunt voor een NIEUW paneel, geen gemeten kalibratie.
+# tools/calibrate.py schrijft een gemeten versie van dit bestand.
+substitutions:
+ TOUCH_CAL_X_MIN: "280"
+ TOUCH_CAL_X_MAX: "3860"
+ TOUCH_CAL_Y_MIN: "340"
+ TOUCH_CAL_Y_MAX: "3860"
+ TOUCH_AFFINE_XX: "1.000000000"
+ TOUCH_AFFINE_XY: "0.000000000"
+ TOUCH_AFFINE_XC: "0.000000000"
+ TOUCH_AFFINE_YX: "0.000000000"
+ TOUCH_AFFINE_YY: "1.000000000"
+ TOUCH_AFFINE_YC: "0.000000000"
diff --git a/components/xpt2046/LICENSE b/components/xpt2046/LICENSE
new file mode 100644
index 0000000..215460a
--- /dev/null
+++ b/components/xpt2046/LICENSE
@@ -0,0 +1,709 @@
+# ESPHome License
+
+Copyright (c) 2019 ESPHome
+
+The ESPHome License is made up of two base licenses: MIT and the GNU GENERAL PUBLIC LICENSE.
+The C++/runtime codebase of the ESPHome project (file extensions .c, .cpp, .h, .hpp, .tcc, .ino) are
+published under the GPLv3 license. The python codebase and all other parts of this codebase are
+published under the MIT license.
+
+Both MIT and GPLv3 licenses are attached to this document.
+
+## MIT License
+
+Copyright (c) 2019 ESPHome
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+## GPLv3 License
+
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/components/xpt2046/README.md b/components/xpt2046/README.md
new file mode 100644
index 0000000..0b666ed
--- /dev/null
+++ b/components/xpt2046/README.md
@@ -0,0 +1,21 @@
+# XPT2046 input stabilization
+
+Based on ESPHome 2026.6.2's `esphome/components/xpt2046` (license alongside).
+The sample handoff in `touchscreen/xpt2046.cpp` and filter/calibration members
+in `touchscreen/xpt2046.h` differ from upstream. `touch_filter.h` is local.
+
+The installed CYD produced widely separated coordinates at first contact.
+The filter requires three samples within 100 ADC units before reporting a
+new contact, ignores isolated large jumps, and requires two absent-pressure
+samples before reporting release. Normal continuous drags use a small
+moving average; large movements are accepted after three stable samples.
+
+Use polling (20 ms in the device configuration), not an IRQ-only setup: the
+filter needs follow-up samples while confirming a contact or release.
+Run `tests/test_touch_filter.cpp` when changing the filter or updating ESPHome.
+
+An optional affine raw-coordinate correction defaults to the identity.
+The device profile sets its six coefficients at boot, fitted to median
+filtered corner readings from this particular panel. Raw diagnostic getters
+still expose the original filtered ADC values, allowing recalibration without
+mistaking corrected coordinates for physical measurements.
diff --git a/components/xpt2046/__init__.py b/components/xpt2046/__init__.py
new file mode 100644
index 0000000..3b8a925
--- /dev/null
+++ b/components/xpt2046/__init__.py
@@ -0,0 +1,5 @@
+import esphome.config_validation as cv
+
+CONFIG_SCHEMA = cv.invalid(
+ "This component sould now be used as platform of the Touchscreen component."
+)
diff --git a/components/xpt2046/touchscreen/__init__.py b/components/xpt2046/touchscreen/__init__.py
new file mode 100644
index 0000000..d91ae44
--- /dev/null
+++ b/components/xpt2046/touchscreen/__init__.py
@@ -0,0 +1,43 @@
+from esphome import pins
+import esphome.codegen as cg
+from esphome.components import spi, touchscreen
+import esphome.config_validation as cv
+from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_THRESHOLD
+
+CODEOWNERS = ["@numo68", "@nielsnl68"]
+DEPENDENCIES = ["spi"]
+
+XPT2046_ns = cg.esphome_ns.namespace("xpt2046")
+XPT2046Component = XPT2046_ns.class_(
+ "XPT2046Component",
+ touchscreen.Touchscreen,
+ spi.SPIDevice,
+)
+
+CONFIG_SCHEMA = cv.All(
+ touchscreen.touchscreen_schema(calibration_required=True)
+ .extend(
+ cv.Schema(
+ {
+ cv.GenerateID(): cv.declare_id(XPT2046Component),
+ cv.Optional(CONF_INTERRUPT_PIN): cv.All(
+ pins.internal_gpio_input_pin_schema
+ ),
+ cv.Optional(CONF_THRESHOLD, default=400): cv.int_range(min=0, max=4095),
+ },
+ )
+ )
+ .extend(spi.spi_device_schema()),
+)
+
+
+async def to_code(config):
+ var = cg.new_Pvariable(config[CONF_ID])
+ await spi.register_spi_device(var, config)
+ await touchscreen.register_touchscreen(var, config)
+
+ cg.add(var.set_threshold(config[CONF_THRESHOLD]))
+
+ if CONF_INTERRUPT_PIN in config:
+ pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN])
+ cg.add(var.set_irq_pin(pin))
diff --git a/components/xpt2046/touchscreen/touch_filter.h b/components/xpt2046/touchscreen/touch_filter.h
new file mode 100644
index 0000000..97df610
--- /dev/null
+++ b/components/xpt2046/touchscreen/touch_filter.h
@@ -0,0 +1,68 @@
+#pragma once
+#include
+#include
+#include
+#include
+
+namespace esphome::xpt2046 {
+// Optional affine calibration corrects skew as well as scale/offset.
+// Identity by default; coefficients belong to an individual physical panel.
+class RawCorrection {
+ public:
+ void set(float xx, float xy, float xc, float yx, float yy, float yc) {
+ xx_ = xx; xy_ = xy; xc_ = xc; yx_ = yx; yy_ = yy; yc_ = yc;
+ }
+ int16_t x(int raw_x, int raw_y) const { return bound(xx_ * raw_x + xy_ * raw_y + xc_); }
+ int16_t y(int raw_x, int raw_y) const { return bound(yx_ * raw_x + yy_ * raw_y + yc_); }
+ private:
+ static int16_t bound(float v) { return static_cast(std::clamp(std::lround(v), 0L, 4095L)); }
+ float xx_{1}, xy_{0}, xc_{0}, yx_{0}, yy_{1}, yc_{0};
+};
+
+// XPT2046 ADC values can be wildly wrong as a finger first contacts/releases
+// the resistive sheet. Do not expose a lone sample as a new LVGL target.
+class TouchFilter {
+ public:
+ enum Result { HOLD, POSITION, RELEASE };
+ Result sample(bool pressed, int16_t raw_x, int16_t raw_y) {
+ if (!pressed) {
+ candidate_count_ = 0;
+ if (active_ && ++release_count_ < 2) return HOLD;
+ active_ = false;
+ release_count_ = 0;
+ return RELEASE;
+ }
+ release_count_ = 0;
+ if (active_ && near(raw_x, raw_y, x_, y_, 300)) {
+ candidate_count_ = 0;
+ x_ = (x_ + raw_x) / 2;
+ y_ = (y_ + raw_y) / 2;
+ return POSITION;
+ }
+ // Initial contact or a sudden jump: require three consistent samples.
+ if (!candidate_count_ || !near(raw_x, raw_y, candidate_x_, candidate_y_, 100)) {
+ candidate_x_ = raw_x;
+ candidate_y_ = raw_y;
+ candidate_count_ = 1;
+ return HOLD;
+ }
+ candidate_x_ = (candidate_x_ * candidate_count_ + raw_x) / (candidate_count_ + 1);
+ candidate_y_ = (candidate_y_ * candidate_count_ + raw_y) / (candidate_count_ + 1);
+ if (++candidate_count_ < 3) return HOLD;
+ x_ = candidate_x_;
+ y_ = candidate_y_;
+ active_ = true;
+ candidate_count_ = 0;
+ return POSITION;
+ }
+ int16_t x() const { return x_; }
+ int16_t y() const { return y_; }
+ private:
+ static bool near(int x1, int y1, int x2, int y2, int distance) {
+ return std::abs(x1 - x2) <= distance && std::abs(y1 - y2) <= distance;
+ }
+ int16_t x_{0}, y_{0}, candidate_x_{0}, candidate_y_{0};
+ uint8_t candidate_count_{0}, release_count_{0};
+ bool active_{false};
+};
+} // namespace esphome::xpt2046
diff --git a/components/xpt2046/touchscreen/xpt2046.cpp b/components/xpt2046/touchscreen/xpt2046.cpp
new file mode 100644
index 0000000..c3aaeb2
--- /dev/null
+++ b/components/xpt2046/touchscreen/xpt2046.cpp
@@ -0,0 +1,117 @@
+#include "xpt2046.h"
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+#include
+
+namespace esphome::xpt2046 {
+
+static const char *const TAG = "xpt2046";
+
+void XPT2046Component::setup() {
+ if (this->irq_pin_ != nullptr) {
+ // The pin reports a touch with a falling edge. Unfortunately the pin goes also changes state
+ // while the channels are read and wiring it as an interrupt is not straightforward and would
+ // need careful masking. A GPIO poll is cheap so we'll just use that.
+
+ this->irq_pin_->setup(); // INPUT
+ this->irq_pin_->pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP);
+ this->irq_pin_->setup();
+ this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE);
+ }
+ this->spi_setup();
+ this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin
+}
+
+void XPT2046Component::update_touches() {
+ int16_t data[6], x_raw, y_raw, z_raw;
+ bool touch = false;
+
+ enable();
+
+ int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */);
+ int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */);
+ z_raw = touch_pressure_1 + 0xfff - touch_pressure_2;
+ ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw);
+ touch = (z_raw >= this->threshold_);
+ if (touch) {
+ read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy
+ data[0] = this->read_adc_(0x91 /* Y */);
+ data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements
+ data[2] = this->read_adc_(0x91 /* Y */);
+ data[3] = this->read_adc_(0xD1 /* X */);
+ data[4] = this->read_adc_(0x91 /* Y */);
+ }
+
+ data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down
+
+ disable();
+
+ if (touch) {
+ x_raw = best_two_avg(data[1], data[3], data[5]);
+ y_raw = best_two_avg(data[0], data[2], data[4]);
+
+ ESP_LOGD(TAG, "Touchscreen Update [%d, %d], z = %d", x_raw, y_raw, z_raw);
+
+ if (this->filter_.sample(true, x_raw, y_raw) == TouchFilter::POSITION) {
+ this->add_raw_touch_position_(0,
+ this->correction_.x(this->filter_.x(), this->filter_.y()),
+ this->correction_.y(this->filter_.x(), this->filter_.y()), z_raw);
+ } else {
+ this->skip_update_ = true;
+ }
+ } else if (this->filter_.sample(false, 0, 0) == TouchFilter::HOLD) {
+ this->skip_update_ = true;
+ }
+}
+
+void XPT2046Component::dump_config() {
+ ESP_LOGCONFIG(TAG,
+ "XPT2046:\n"
+ " X min: %d\n"
+ " X max: %d\n"
+ " Y min: %d\n"
+ " Y max: %d\n"
+ " Swap X/Y: %s\n"
+ " Invert X: %s\n"
+ " Invert Y: %s\n"
+ " threshold: %d",
+ this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_, YESNO(this->swap_x_y_),
+ YESNO(this->invert_x_), YESNO(this->invert_y_), this->threshold_);
+ LOG_PIN(" IRQ Pin: ", this->irq_pin_);
+ LOG_UPDATE_INTERVAL(this);
+}
+
+// float XPT2046Component::get_setup_priority() const { return setup_priority::DATA; }
+
+int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t value3) {
+ int16_t delta_a, delta_b, delta_c;
+ int16_t reta = 0;
+
+ delta_a = (value1 > value2) ? value1 - value2 : value2 - value1;
+ delta_b = (value1 > value3) ? value1 - value3 : value3 - value1;
+ delta_c = (value3 > value2) ? value3 - value2 : value2 - value3;
+
+ if (delta_a <= delta_b && delta_a <= delta_c) {
+ reta = (value1 + value2) >> 1;
+ } else if (delta_b <= delta_a && delta_b <= delta_c) {
+ reta = (value1 + value3) >> 1;
+ } else {
+ reta = (value2 + value3) >> 1;
+ }
+
+ return reta;
+}
+
+int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT
+ uint8_t data[2];
+
+ this->write_byte(ctrl);
+ delay(1);
+ data[0] = this->read_byte();
+ data[1] = this->read_byte();
+
+ return ((data[0] << 8) | data[1]) >> 3;
+}
+
+} // namespace esphome::xpt2046
diff --git a/components/xpt2046/touchscreen/xpt2046.h b/components/xpt2046/touchscreen/xpt2046.h
new file mode 100644
index 0000000..4e123f0
--- /dev/null
+++ b/components/xpt2046/touchscreen/xpt2046.h
@@ -0,0 +1,47 @@
+#pragma once
+#include "touch_filter.h"
+
+#include "esphome/components/spi/spi.h"
+#include "esphome/components/touchscreen/touchscreen.h"
+#include "esphome/core/automation.h"
+#include "esphome/core/component.h"
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+namespace esphome::xpt2046 {
+
+using namespace touchscreen;
+
+class XPT2046Component : public Touchscreen,
+ public spi::SPIDevice {
+ public:
+ void set_raw_correction(float xx, float xy, float xc, float yx, float yy, float yc) {
+ correction_.set(xx, xy, xc, yx, yy, yc);
+ }
+ int16_t filtered_raw_x() const { return filter_.x(); }
+ int16_t filtered_raw_y() const { return filter_.y(); }
+ /// Set the threshold for the touch detection.
+ void set_threshold(int16_t threshold) { this->threshold_ = threshold; }
+ /// Set the pin used to detect the touch.
+ void set_irq_pin(InternalGPIOPin *pin) { this->irq_pin_ = pin; }
+
+ void setup() override;
+ void dump_config() override;
+ // float get_setup_priority() const override;
+
+ protected:
+ TouchFilter filter_;
+ RawCorrection correction_;
+ static int16_t best_two_avg(int16_t value1, int16_t value2, int16_t value3);
+
+ int16_t read_adc_(uint8_t ctrl);
+
+ void update_touches() override;
+
+ int16_t threshold_;
+
+ InternalGPIOPin *irq_pin_{nullptr};
+};
+
+} // namespace esphome::xpt2046
diff --git a/cyd_ui.h b/cyd_ui.h
new file mode 100644
index 0000000..22cb30c
--- /dev/null
+++ b/cyd_ui.h
@@ -0,0 +1,46 @@
+#pragma once
+#include
+#include
+#include
+
+namespace cyd {
+// Work in tenths of a degree and always clamp AFTER rounding.
+inline int quantize_temperature(int value, int minimum, int maximum, int step) {
+ step = std::max(1, step);
+ value = std::clamp(value, minimum, maximum);
+ const int snapped = minimum + static_cast(std::lround(
+ static_cast(value - minimum) / step)) * step;
+ return std::clamp(snapped, minimum, maximum);
+}
+
+class TouchGuard {
+ public:
+ void begin(uint32_t now, int x = 0, int y = 0) {
+ started_ = now;
+ accepted_ = false;
+ moved_ = false;
+ start_x_ = x;
+ start_y_ = y;
+ }
+ void update(int x, int y) {
+ // A contact that jumps to another target must never become a tile tap.
+ if (std::abs(x - start_x_) > 18 || std::abs(y - start_y_) > 18) moved_ = true;
+ }
+ bool accept(uint32_t now, int tile) {
+ if (accepted_ || moved_ || now - started_ < 60) return false;
+ if (has_previous_ && tile == previous_tile_ && now - previous_ < 600) return false;
+ accepted_ = true;
+ has_previous_ = true;
+ previous_tile_ = tile;
+ previous_ = now;
+ return true;
+ }
+ private:
+ uint32_t started_{0}, previous_{0};
+ int previous_tile_{-1};
+ int start_x_{0}, start_y_{0};
+ bool moved_{false};
+ bool accepted_{false}, has_previous_{false};
+};
+inline TouchGuard touch_guard;
+} // namespace cyd
diff --git a/device.example.yaml b/device.example.yaml
new file mode 100644
index 0000000..b65bcec
--- /dev/null
+++ b/device.example.yaml
@@ -0,0 +1,215 @@
+# Kopieer via: python tools/new_device.py --name display-keuken
+# Bewerk alleen dit lokale profiel en calibration.yaml voor jouw scherm.
+packages:
+ display: !include home-like-2432s028.yaml
+ calibration: !include calibration.yaml
+
+substitutions:
+ DEVICE_NAME: "cyd-new"
+ DEVICE_FRIENDLY_NAME: "Mijn CYD"
+ ROOM_NAME: "Woonkamer"
+ TILE_COUNT: "6"
+ DIRECT_ACTIONS: "false" # Pas na koppelen/testen op true zetten.
+ AUTO_DIM_TIMEOUT: "600"
+ CALIBRATION_ON_BOOT: "false"
+
+ # Tegel 1
+ TILE1_ENTITY: "light.voorbeeld_lamp"
+ TILE1_STATE_ENTITY: "light.voorbeeld_lamp"
+ TILE1_TITLE: "Lamp"
+ TILE1_TYPE: "light"
+ TILE1_TAP_ACTION: "auto"
+ TILE1_TAP_SERVICE: ""
+ TILE1_TAP_PARAM_KEY: ""
+ TILE1_TAP_PARAM_VAL: ""
+ TILE1_LONGPRESS: "slider"
+ TILE1_LONGPRESS_SLIDER: "auto"
+ TILE1_LONGPRESS_OFF_VALUE: "0"
+ TILE1_LONGPRESS_ACTION: ""
+ TILE1_LONGPRESS_ACTION_TYPE: ""
+ TILE1_LONGPRESS_ACTION_SERVICE: ""
+ TILE1_VALUE_MODE: "auto"
+ TILE1_LABEL_ON: "Aan"
+ TILE1_LABEL_OFF: "Uit"
+ TILE1_ACTIVE_STATE: ""
+
+ # Tegel 2
+ TILE2_ENTITY: "switch.voorbeeld_stekker"
+ TILE2_STATE_ENTITY: "switch.voorbeeld_stekker"
+ TILE2_TITLE: "Stekker"
+ TILE2_TYPE: "switch"
+ TILE2_TAP_ACTION: "auto"
+ TILE2_TAP_SERVICE: ""
+ TILE2_TAP_PARAM_KEY: ""
+ TILE2_TAP_PARAM_VAL: ""
+ TILE2_LONGPRESS: "none"
+ TILE2_LONGPRESS_SLIDER: "auto"
+ TILE2_LONGPRESS_OFF_VALUE: "0"
+ TILE2_LONGPRESS_ACTION: ""
+ TILE2_LONGPRESS_ACTION_TYPE: ""
+ TILE2_LONGPRESS_ACTION_SERVICE: ""
+ TILE2_VALUE_MODE: "auto"
+ TILE2_LABEL_ON: "Aan"
+ TILE2_LABEL_OFF: "Uit"
+ TILE2_ACTIVE_STATE: ""
+
+ # Tegel 3
+ TILE3_ENTITY: "scene.voorbeeld_avond"
+ TILE3_STATE_ENTITY: "scene.voorbeeld_avond"
+ TILE3_TITLE: "Avond"
+ TILE3_TYPE: "scene"
+ TILE3_TAP_ACTION: "auto"
+ TILE3_TAP_SERVICE: ""
+ TILE3_TAP_PARAM_KEY: ""
+ TILE3_TAP_PARAM_VAL: ""
+ TILE3_LONGPRESS: "none"
+ TILE3_LONGPRESS_SLIDER: "auto"
+ TILE3_LONGPRESS_OFF_VALUE: "0"
+ TILE3_LONGPRESS_ACTION: ""
+ TILE3_LONGPRESS_ACTION_TYPE: ""
+ TILE3_LONGPRESS_ACTION_SERVICE: ""
+ TILE3_VALUE_MODE: "auto"
+ TILE3_LABEL_ON: "Aan"
+ TILE3_LABEL_OFF: "Uit"
+ TILE3_ACTIVE_STATE: ""
+
+ # Tegel 4
+ TILE4_ENTITY: "script.voorbeeld_uit"
+ TILE4_STATE_ENTITY: "script.voorbeeld_uit"
+ TILE4_TITLE: "Alles uit"
+ TILE4_TYPE: "script"
+ TILE4_TAP_ACTION: "auto"
+ TILE4_TAP_SERVICE: ""
+ TILE4_TAP_PARAM_KEY: ""
+ TILE4_TAP_PARAM_VAL: ""
+ TILE4_LONGPRESS: "none"
+ TILE4_LONGPRESS_SLIDER: "auto"
+ TILE4_LONGPRESS_OFF_VALUE: "0"
+ TILE4_LONGPRESS_ACTION: ""
+ TILE4_LONGPRESS_ACTION_TYPE: ""
+ TILE4_LONGPRESS_ACTION_SERVICE: ""
+ TILE4_VALUE_MODE: "auto"
+ TILE4_LABEL_ON: "Aan"
+ TILE4_LABEL_OFF: "Uit"
+ TILE4_ACTIVE_STATE: ""
+
+ # Tegel 5
+ TILE5_ENTITY: "climate.voorbeeld_airco"
+ TILE5_STATE_ENTITY: "climate.voorbeeld_airco"
+ TILE5_TITLE: "Airco"
+ TILE5_TYPE: "climate"
+ TILE5_TAP_ACTION: "toggle"
+ TILE5_TAP_SERVICE: "climate.toggle"
+ TILE5_TAP_PARAM_KEY: ""
+ TILE5_TAP_PARAM_VAL: ""
+ TILE5_LONGPRESS: "slider"
+ TILE5_LONGPRESS_SLIDER: "auto"
+ TILE5_LONGPRESS_OFF_VALUE: "0"
+ TILE5_LONGPRESS_ACTION: ""
+ TILE5_LONGPRESS_ACTION_TYPE: ""
+ TILE5_LONGPRESS_ACTION_SERVICE: ""
+ TILE5_VALUE_MODE: "auto"
+ TILE5_LABEL_ON: "Aan"
+ TILE5_LABEL_OFF: "Uit"
+ TILE5_ACTIVE_STATE: ""
+
+ # Tegel 6
+ TILE6_ENTITY: "vacuum.voorbeeld_robot"
+ TILE6_STATE_ENTITY: "vacuum.voorbeeld_robot"
+ TILE6_TITLE: "Stofzuiger"
+ TILE6_TYPE: "vacuum"
+ TILE6_TAP_ACTION: "custom"
+ TILE6_TAP_SERVICE: "vacuum.start"
+ TILE6_TAP_PARAM_KEY: ""
+ TILE6_TAP_PARAM_VAL: ""
+ TILE6_LONGPRESS: "vacuum"
+ TILE6_LONGPRESS_SLIDER: "auto"
+ TILE6_LONGPRESS_OFF_VALUE: "0"
+ TILE6_LONGPRESS_ACTION: ""
+ TILE6_LONGPRESS_ACTION_TYPE: ""
+ TILE6_LONGPRESS_ACTION_SERVICE: ""
+ TILE6_VALUE_MODE: "auto"
+ TILE6_LABEL_ON: "Aan"
+ TILE6_LABEL_OFF: "Uit"
+ TILE6_ACTIVE_STATE: ""
+
+ # Tegel 7 (verborgen zolang TILE_COUNT lager is)
+ TILE7_ENTITY: "light.ongebruikt_7"
+ TILE7_STATE_ENTITY: "light.ongebruikt_7"
+ TILE7_TITLE: "Tegel 7"
+ TILE7_TYPE: "light"
+ TILE7_TAP_ACTION: "auto"
+ TILE7_TAP_SERVICE: ""
+ TILE7_TAP_PARAM_KEY: ""
+ TILE7_TAP_PARAM_VAL: ""
+ TILE7_LONGPRESS: "none"
+ TILE7_LONGPRESS_SLIDER: "auto"
+ TILE7_LONGPRESS_OFF_VALUE: "0"
+ TILE7_LONGPRESS_ACTION: ""
+ TILE7_LONGPRESS_ACTION_TYPE: ""
+ TILE7_LONGPRESS_ACTION_SERVICE: ""
+ TILE7_VALUE_MODE: "auto"
+ TILE7_LABEL_ON: "Aan"
+ TILE7_LABEL_OFF: "Uit"
+ TILE7_ACTIVE_STATE: ""
+
+ # Tegel 8 (verborgen zolang TILE_COUNT lager is)
+ TILE8_ENTITY: "light.ongebruikt_8"
+ TILE8_STATE_ENTITY: "light.ongebruikt_8"
+ TILE8_TITLE: "Tegel 8"
+ TILE8_TYPE: "light"
+ TILE8_TAP_ACTION: "auto"
+ TILE8_TAP_SERVICE: ""
+ TILE8_TAP_PARAM_KEY: ""
+ TILE8_TAP_PARAM_VAL: ""
+ TILE8_LONGPRESS: "none"
+ TILE8_LONGPRESS_SLIDER: "auto"
+ TILE8_LONGPRESS_OFF_VALUE: "0"
+ TILE8_LONGPRESS_ACTION: ""
+ TILE8_LONGPRESS_ACTION_TYPE: ""
+ TILE8_LONGPRESS_ACTION_SERVICE: ""
+ TILE8_VALUE_MODE: "auto"
+ TILE8_LABEL_ON: "Aan"
+ TILE8_LABEL_OFF: "Uit"
+ TILE8_ACTIVE_STATE: ""
+
+ # Tegel 9 (verborgen zolang TILE_COUNT lager is)
+ TILE9_ENTITY: "light.ongebruikt_9"
+ TILE9_STATE_ENTITY: "light.ongebruikt_9"
+ TILE9_TITLE: "Tegel 9"
+ TILE9_TYPE: "light"
+ TILE9_TAP_ACTION: "auto"
+ TILE9_TAP_SERVICE: ""
+ TILE9_TAP_PARAM_KEY: ""
+ TILE9_TAP_PARAM_VAL: ""
+ TILE9_LONGPRESS: "none"
+ TILE9_LONGPRESS_SLIDER: "auto"
+ TILE9_LONGPRESS_OFF_VALUE: "0"
+ TILE9_LONGPRESS_ACTION: ""
+ TILE9_LONGPRESS_ACTION_TYPE: ""
+ TILE9_LONGPRESS_ACTION_SERVICE: ""
+ TILE9_VALUE_MODE: "auto"
+ TILE9_LABEL_ON: "Aan"
+ TILE9_LABEL_OFF: "Uit"
+ TILE9_ACTIVE_STATE: ""
+
+ # Tegel 10 (verborgen zolang TILE_COUNT lager is)
+ TILE10_ENTITY: "light.ongebruikt_10"
+ TILE10_STATE_ENTITY: "light.ongebruikt_10"
+ TILE10_TITLE: "Tegel 10"
+ TILE10_TYPE: "light"
+ TILE10_TAP_ACTION: "auto"
+ TILE10_TAP_SERVICE: ""
+ TILE10_TAP_PARAM_KEY: ""
+ TILE10_TAP_PARAM_VAL: ""
+ TILE10_LONGPRESS: "none"
+ TILE10_LONGPRESS_SLIDER: "auto"
+ TILE10_LONGPRESS_OFF_VALUE: "0"
+ TILE10_LONGPRESS_ACTION: ""
+ TILE10_LONGPRESS_ACTION_TYPE: ""
+ TILE10_LONGPRESS_ACTION_SERVICE: ""
+ TILE10_VALUE_MODE: "auto"
+ TILE10_LABEL_ON: "Aan"
+ TILE10_LABEL_OFF: "Uit"
+ TILE10_ACTIVE_STATE: ""
+
diff --git a/diagnostics/capture_serial.py b/diagnostics/capture_serial.py
new file mode 100644
index 0000000..3829535
--- /dev/null
+++ b/diagnostics/capture_serial.py
@@ -0,0 +1,19 @@
+"""Capture ESP32 boot/runtime diagnostics. Requires pyserial (in ESPHome's venv)."""
+import argparse
+import time
+import serial
+
+parser = argparse.ArgumentParser()
+parser.add_argument('--port', default='/dev/cu.usbserial-130')
+parser.add_argument('--seconds', type=int, default=90)
+parser.add_argument('--reset', action='store_true')
+args = parser.parse_args()
+with serial.Serial(args.port, 115200, timeout=0.2) as port:
+ if args.reset:
+ port.dtr = False
+ port.rts = True
+ time.sleep(0.1)
+ port.rts = False
+ end = time.monotonic() + args.seconds
+ while time.monotonic() < end:
+ print(port.read(4096).decode('utf-8', errors='replace'), end='', flush=True)
diff --git a/diagnostics/control_ui.py b/diagnostics/control_ui.py
new file mode 100644
index 0000000..bfb7d1b
--- /dev/null
+++ b/diagnostics/control_ui.py
@@ -0,0 +1,32 @@
+"""Open/close the isolated touch diagnostic screen via encrypted ESPHome API."""
+import argparse
+import asyncio
+from pathlib import Path
+import yaml
+from aioesphomeapi import APIClient, LogLevel
+
+async def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('action', choices=['touch_diagnostics', 'end_touch_diagnostics', 'ui_state'])
+ parser.add_argument('--host', default='cyd-2432s028.local')
+ parser.add_argument('--name', default='cyd-2432s028', help='Verwachte DEVICE_NAME')
+ parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
+ args = parser.parse_args()
+ secret = yaml.safe_load(args.secrets.read_text())
+ client = APIClient(args.host, 6053, noise_psk=secret['api_encryption_key'], expected_name=args.name, client_info='CYD touch diagnostics')
+ await client.connect(login=True)
+ try:
+ def log(msg):
+ text = msg.message.decode(errors='replace') if isinstance(msg.message, bytes) else msg.message
+ if 'ui_diag' in text:
+ print(text, flush=True)
+ client.subscribe_logs(log, log_level=LogLevel.LOG_LEVEL_DEBUG, dump_config=False)
+ _, services = await client.list_entities_services()
+ await client.execute_service(next(s for s in services if s.name == args.action), {})
+ await asyncio.sleep(0.5)
+ print(args.action + ': sent')
+ finally:
+ await client.disconnect()
+
+if __name__ == '__main__':
+ asyncio.run(main())
diff --git a/diagnostics/run_ui_test.py b/diagnostics/run_ui_test.py
new file mode 100644
index 0000000..0d7e655
--- /dev/null
+++ b/diagnostics/run_ui_test.py
@@ -0,0 +1,48 @@
+"""Run the firmware's render test through its encrypted API; no HA actions."""
+import argparse
+import asyncio
+import re
+from pathlib import Path
+import yaml
+from aioesphomeapi import APIClient, LogLevel
+
+async def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--host', default='cyd-2432s028.local')
+ parser.add_argument('--name', default='cyd-2432s028', help='Verwachte DEVICE_NAME')
+ parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
+ args = parser.parse_args()
+ secrets = yaml.safe_load(args.secrets.read_text())
+ client = APIClient(args.host, 6053, noise_psk=secrets['api_encryption_key'], client_info='CYD UI diagnostics', expected_name=args.name)
+ await client.connect(login=True)
+ try:
+ info = await client.device_info()
+ print(f'Connected: {info.name}, ESPHome {info.esphome_version}, compiled {info.compilation_time}', flush=True)
+ _, services = await client.list_entities_services()
+ test = next(s for s in services if s.name == 'ui_self_test')
+ done = asyncio.Event()
+ failures = []
+ passes = []
+ frames = []
+ def log(msg):
+ text = msg.message.decode(errors='replace') if isinstance(msg.message, bytes) else msg.message
+ if any(tag in text for tag in ('ui_test', 'UI_TEST', '[health', 'took a long')):
+ print(text, flush=True)
+ if 'page2=PASS' in text or 'page_check=PASS' in text:
+ passes.append(text)
+ match = re.search(r'frames=(\d+)', text)
+ if match: frames.append(int(match[1]))
+ if 'FAIL' in text: failures.append(text)
+ if 'UI_TEST COMPLETE' in text: done.set()
+ client.subscribe_logs(log, log_level=LogLevel.LOG_LEVEL_DEBUG, dump_config=False)
+ await client.execute_service(test, {})
+ await asyncio.wait_for(done.wait(), timeout=45)
+ assert len(passes) == 10, f'Expected 10 page checks, got {len(passes)}'
+ assert not failures, failures
+ assert len(frames) == 10 and frames[-1] - frames[0] >= 40, f'Render callbacks stalled: {frames}'
+ print('PASS: 10 page checks and 50 overlay render cycles completed', flush=True)
+ finally:
+ await client.disconnect()
+
+if __name__ == '__main__':
+ asyncio.run(main())
diff --git a/docs/ACCEPTATIE.md b/docs/ACCEPTATIE.md
new file mode 100644
index 0000000..c8e786b
--- /dev/null
+++ b/docs/ACCEPTATIE.md
@@ -0,0 +1,81 @@
+# Acceptatietest en oplevering
+
+Noteer datum, bordvariant/MAC, Git-commit, ESPHome-versie, apparaatnaam en de
+namen van de twee meetbestanden. Neem geen wachtwoorden in het rapport op.
+
+## Zonder echte HA-acties
+
+1. Kalibreer met [CALIBREREN.md](CALIBREREN.md); flash de uitkomst en voer een
+ nieuwe, onafhankelijke meting met `verify` uit. Een geslaagde fit alleen
+ bewijst niet dat de firmwarecoördinaten kloppen.
+2. Flash de normale modus. Controleer dat het meetscherm verdwijnt.
+3. Controleer elk zichtbaar vak en de randen: geen andere tegel mag reageren.
+ Tik rustig en snel achter elkaar, maar verwacht geen herhaling binnen de
+ bewuste debounceperiode. Een veeg mag geen tegelactie achteraf veroorzaken.
+4. Bij 1–6 tegels: geen paginaknop. Bij 7–10: herhaal minstens twintig keer
+ Volgende → tegel → Vorige. De onderste tegel mag niet reageren op de
+ paginaknop. Open/sluit overlays; een tik daarin mag niet doorlekken.
+5. Draai de ingebouwde renderproef via de versleutelde API:
+
+ ```sh
+ python diagnostics/run_ui_test.py --host display-keuken.local --name display-keuken
+ ```
+
+ Vereist tien geslaagde paginacontroles, vijftig overlaycycli en voortgang
+ van echte draw-callbacks. Dit test geen fysieke touch of HA-acties.
+6. Laat het bord minstens 15 minuten aan. Controleer dat het niet herstart,
+ dat standby pas na tien minuten zonder aanraking begint en dat de eerste
+ tik het scherm wekt zonder een apparaat te schakelen.
+
+## Met Home Assistant
+
+Schakel directe acties pas nu in, flash opnieuw en kies veilige testapparaten.
+Controleer elke geconfigureerde tegel met zijn echte HA-entiteit:
+
+- Een tik voert precies één bedoelde actie uit; de terugmelding klopt.
+- Veranderen vanuit HA wordt ook op het scherm zichtbaar.
+- Lichtslider/kleur, climate-setpoint en ondersteunde modi werken waar gebruikt.
+- Vacuumacties en snelheden werken waar ondersteund. Noteer ontbrekende functies.
+- Na stroomonderbreking komen wifi en HA terug; naam, kalibratie en tegels blijven.
+- Na een korte wifi-/HA-onderbreking herstelt de verbinding zonder USB-flash.
+
+Bewaar logs alleen lokaal. Controleer op resets, watchdogmeldingen, herhaalde
+connectiefouten en opvallend trage componenten. Een korte renderproef bewijst
+geen stabiliteit over dagen; noteer de werkelijk geobserveerde duur.
+
+## Geautomatiseerde ontwikkelchecks
+
+Vanuit de repo, met de Python-omgeving actief:
+
+```sh
+python -m unittest discover -s tests -p 'test_*.py'
+c++ -std=c++17 -Wall -Wextra -pedantic tests/test_cyd_ui.cpp -o /tmp/test_cyd_ui
+/tmp/test_cyd_ui
+c++ -std=c++17 -Wall -Wextra -pedantic tests/test_touch_filter.cpp -o /tmp/test_touch_filter
+/tmp/test_touch_filter
+python -m esphome config device.yaml
+python -m esphome compile device.yaml
+```
+
+De C++-commando's hierboven zijn voor macOS/Linux met een compiler. Op Windows
+kan de developer een beschikbare C++17-toolchain gebruiken. Flash alleen het
+gecontroleerde profiel naar het geïdentificeerde bord.
+
+## Opleverrapport
+
+```text
+Bord / MAC:
+Git-commit / ESPHome-versie:
+Apparaatnaam / HA-entiteiten:
+Kalibratiemeting + onafhankelijke verificatie:
+Renderproef:
+Fysieke paginering/touch/standby:
+Werkelijke HA-acties:
+Geobserveerde stabiele duur:
+Beperkingen / nog niet getest:
+Lokale profielbestanden veilig bewaard:
+```
+
+Geef de eigenaar de drie lokale YAML-bestanden en de meetbestanden via een
+passend privé-kanaal. Gebruik voor een andere eigenaar de schone repository
+of starter-ZIP, zodat die eigen sleutels en eigen paneelkalibratie krijgt.
diff --git a/docs/CALIBREREN.md b/docs/CALIBREREN.md
new file mode 100644
index 0000000..10ea381
--- /dev/null
+++ b/docs/CALIBREREN.md
@@ -0,0 +1,127 @@
+# Een nieuw paneel kalibreren
+
+Dit protocol is voor **320×240, LVGL-rotatie 90°, swap_xy=false,
+mirror_x=true, mirror_y=false**. Andere oriëntaties, GT911-touch en andere
+controllers vereisen een apart profiel. Verander rotatie of mirroring niet
+halverwege de meting.
+
+De filter in deze repo stabiliseert de XPT2046-metingen. Kalibratie corrigeert
+vervolgens offset, schaal en scheefstand. Kalibratie repareert geen defect
+paneel, losse verbinding, instabiele voeding of willekeurige uitschieters.
+
+## Voorbereiden
+
+1. Gebruik `device.yaml` van `tools/new_device.py`, met een eigen
+ `calibration.yaml` die begint als identiteit. De persoonlijke correctie
+ in het historische basisprofiel mag niet worden overgenomen.
+2. Controleer de datakabel en de USB-poort met `python -m serial.tools.list_ports`.
+3. Flash vanaf de reporoot:
+
+```sh
+python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device
+```
+
+4. Controleer fysiek het **donkere scherm met vijf witte + tekens**.
+ Het blijft aan en de tegelacties zijn hier niet toegankelijk.
+5. Stop de ESPHome-loglezer met Ctrl+C. Laat het bord en de USB-kabel zitten.
+ Slechts één programma mag tegelijk de seriële poort gebruiken.
+
+Wifi/HA zijn voor deze USB-route niet nodig. Als het bord al op wifi staat,
+kan het scherm ook via de versleutelde API worden geopend:
+
+```sh
+python diagnostics/control_ui.py touch_diagnostics --host display-keuken.local --name display-keuken
+```
+
+Dat verandert de startmodus niet. Gebruik voor een nieuwe installatie de
+USB-route hierboven, zodat de kalibratiemodus ook na een reset actief blijft.
+
+## Meting A: gegevens verzamelen
+
+```sh
+python tools/calibrate.py capture --port --device-name display-keuken --output measurements-before.json
+```
+
+De wizard begeleidt **één doel tegelijk**:
+
+1. Lees in de terminal welk kruisje aan de beurt is.
+2. Druk op Enter en tik daarna alleen dat kruisje **drie keer**.
+3. Houd elke tik circa een halve seconde vast en laat tussendoor volledig los.
+4. Wacht op de volgende terminalprompt. Ga niet zelf alvast naar een andere hoek.
+
+Volgorde: linksboven, rechtsboven, rechtsonder, linksonder, midden.
+Gebruik een geschikte stompe stylus of een kleine, bewuste vingertik; geen
+scherpe punt. De doelen staan op `(20,20)`, `(299,20)`, `(299,219)`, `(20,219)`
+en `(160,120)`.
+
+De wizard leest alleen touchlogs met `calibration=1`. Hij flasht niets, maakt
+geen netwerkverbinding en stuurt geen HA-acties. Een onvolledige meting wordt
+niet als geldig bestand opgeslagen. Bij een verkeerde tik: Ctrl+C en opnieuw
+beginnen. Gebruik een nieuwe bestandsnaam als een meting al bestaat.
+
+## Een correctie berekenen
+
+```sh
+python tools/calibrate.py fit measurements-before.json --output calibration.yaml --replace
+```
+
+Het script:
+
+- neemt per hoek de mediaan van de drie ruwe ADC-metingen;
+- bepaalt een affine correctie uit de vier hoeken;
+- controleert het **niet voor de fit gebruikte middenpunt**;
+- weigert te grote fouten (>12 px), spreiding (>18 px) en onbruikbare meetgeometrie;
+- bewaart de bestaande `calibration.yaml` als een gedateerde `.bak` voordat
+ een nieuwe, complete versie wordt geplaatst.
+
+De output bevat alleen de vier basisgrenzen en zes affine coëfficiënten in
+een ESPHome-substitutions-map. `device.yaml` importeert deze als calibration-
+package. Verander de coëfficiënten niet op gevoel en voeg geen tweede correctie
+in de driver toe. De `raw=`-logs bevatten de gefilterde **fysieke ADC-waarden**,
+ook als al een correctie is ingesteld; opnieuw kalibreren telt dus niet dubbel op.
+
+Een geslaagde fit op bestaande data is nog geen geslaagde fysieke test.
+
+## Meting B: de geflashte uitkomst onafhankelijk testen
+
+Flash de correctie, nog steeds met het meetscherm:
+
+```sh
+python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device
+```
+
+Sluit de loglezer met Ctrl+C en verzamel **nieuwe tikken**:
+
+```sh
+python tools/calibrate.py capture --port --device-name display-keuken --output measurements-after.json
+python tools/calibrate.py verify measurements-after.json
+```
+
+`verify` gebruikt de echte `native=`-coördinaten van de firmware, draait die
+naar de schermcoördinaten en vergelijkt ze met de doelen. Hij voert **geen
+nieuwe fit** uit. Alle vijf punten moeten slagen. Gebruik niet per ongeluk
+het bestand van vóór de flash voor deze controle.
+
+Lukt dit niet? Bekijk welk punt faalt, controleer de volgorde/druk, meet opnieuw
+en lees [PROBLEMEN.md](PROBLEMEN.md). Verhoog niet alleen de tolerantie om de
+test groen te krijgen. Rapporteer een resterende hardwareafwijking eerlijk.
+
+## Terug naar normale bediening
+
+```sh
+python -m esphome run device.yaml --device
+```
+
+Hier is **geen** `-s CALIBRATION_ON_BOOT true` opgegeven. In `device.yaml` blijft
+de instelling `false`. Controleer de werkelijke tegels en navigatie volgens
+[ACCEPTATIE.md](ACCEPTATIE.md).
+
+Als de kalibratiemodus uitsluitend via de API was geopend, kun je hem sluiten:
+
+```sh
+python diagnostics/control_ui.py end_touch_diagnostics --host display-keuken.local --name display-keuken
+```
+
+Bewaar beide meetbestanden en `calibration.yaml` bij dit fysieke scherm.
+Na vervangen van het paneel opnieuw beginnen; geef alleen de neutrale
+starterbestanden aan een volgende eigenaar.
diff --git a/docs/PROBLEMEN.md b/docs/PROBLEMEN.md
new file mode 100644
index 0000000..d44b426
--- /dev/null
+++ b/docs/PROBLEMEN.md
@@ -0,0 +1,32 @@
+# Problemen oplossen
+
+| Symptoom | Eerst controleren |
+|---|---|
+| Geen USB-poort | Datakabel, andere USB-poort, bordvoeding; vergelijk `python -m serial.tools.list_ports` voor/na aansluiten. Installeer zo nodig de driver van de USB-chipfabrikant. |
+| Permission denied op Linux | Toegang tot de seriële devicegroep (vaak `dialout`); opnieuw inloggen na een groepswijziging. |
+| Poort bezet | Stop ESPHome-logs, IDE-monitor en andere seriële lezers. Eén lezer tegelijk. |
+| Upload blijft verbinden | Controleer juiste bord/poort. Houd BOOT ingedrukt tijdens verbinden en laat daarna los als het bord dat vereist. |
+| Build faalt | Python 3.11–3.14 en ESPHome 2026.6.2; volledige uitgepakte map, lokale fonts/components aanwezig, internet en schijfruimte. Bewaar eerste echte fout uit de buildlog. |
+| Wit/zwart of verkeerd gedraaid beeld | Controleer ILI9341 + XPT2046-variant. Niet willekeurig de pinnen, rotatie of SPI-instellingen van een ander CYD-model kopiëren. |
+| Geen meetscherm | De upload moet klaar zijn, niet alleen de build. Flash met `-s CALIBRATION_ON_BOOT true`. Zoek in USB-logs `USB calibration ready`; normaal opstarten toont geen kruisjes. |
+| Geen metingen | Vijf kruisjes zichtbaar, andere loglezer dicht, juiste poort. Druk eerst Enter in de wizard en tik daarna drie keer hetzelfde gevraagde punt, met loslaten ertussen. |
+| Tik springt naar andere plek | Controleer onafhankelijke `verify`, eigen `calibration.yaml` en vaste oriëntatie. Niet dezelfde tikken gebruiken om zowel te fitten als te verifiëren. |
+| Fit meldt spreiding/fout | Meet opnieuw volgens de prompts. Instabiele ADC-metingen niet oplossen door de tolerantie te verhogen; controleer voeding, kabel en touchhardware. |
+| Dubbele HA-actie | Controleer zowel touchlogs als HA-automations. Gebruik niet tegelijk directe acties én een automation op de actie-sensor voor dezelfde handeling. |
+| Wifi verbindt niet | 2,4GHz, juiste lokale secrets, netwerkbereik. USB-kalibratie heeft HA niet nodig. |
+| HA ziet het bord niet | Handmatig IP uit logs gebruiken; poort 6053 bereikbaar, juiste encryptiesleutel, geen gastnetwerkisolatie. |
+| Status werkt maar actie niet | HA-optie voor toegestane apparaat-acties, `DIRECT_ACTIONS: "true"`, echte entity-ID en ondersteunde actie controleren. |
+| Vacuum/climate deels bruikbaar | Ondersteunde modi/attributen verschillen per integratie; zie TEGELS.md en test dezelfde actie eerst in HA. |
+| Verkeerde paginering | `TILE_COUNT` is 1–10; tot zes verborgen knoppen, vanaf zeven twee pagina's. Opnieuw flashen na wijzigen. |
+| Vastloper/reset | USB-log bewaren, resetreden/voeding controleren en renderproef draaien. Noteer handeling en tijdstip. |
+
+Voor een USB-log (stop andere lezers):
+
+```sh
+python diagnostics/capture_serial.py --port --seconds 120
+```
+
+De bestaande `CYD_STABILITY.md` en `TEST_RESULTS.md` beschrijven eerder
+onderzoek aan één specifiek bord. Hun meetwaarden zijn geen kalibratie voor
+een nieuw bord. Begin bij [README.md](../README.md), behoud een werkende
+lokale configuratie en verander één oorzaak tegelijk.
diff --git a/docs/TEGELS.md b/docs/TEGELS.md
new file mode 100644
index 0000000..46b00ed
--- /dev/null
+++ b/docs/TEGELS.md
@@ -0,0 +1,144 @@
+# Tegels instellen voor jouw Home Assistant
+
+Bewerk `device.yaml`, binnen de bestaande `substitutions:`. De basiscode hoef
+je voor de onderstaande voorbeelden niet aan te passen. Elke wijziging vraagt
+opnieuw bouwen en flashen. Doorloop eerst de [installatie](../README.md).
+
+## Entity-ID's verzamelen
+
+Open in HA **Ontwikkelaarstools → Statussen**. Zoek de gewenste entiteit en
+kopieer de volledige ID, bijvoorbeeld `light.keuken`. Controleer status en
+attributen. De schermtitel is vrij te kiezen; de entity-ID moet exact kloppen.
+Test onder **Ontwikkelaarstools → Acties** de gewenste actie met die entiteit.
+Een actie kan het echte apparaat bedienen: kies een geschikte test.
+Zie [HA-acties](https://www.home-assistant.io/docs/scripts/perform-actions/).
+
+Maak eerst een lijst met positie, titel, entity-ID, tikactie en lange-drukactie.
+Gebruik bestaande HA-scripts/scènes voor meerdere apparaten of complexe logica.
+
+## Posities en grenzen
+
+| Posities | Bedoeld gebruik in deze firmware |
+|---|---|
+| 1–6 | Hoofdtegels; light, switch, scene, script, fan, cover en climate |
+| 6 | Ook de vacuumkaart; de kaart is specifiek met tegel 6 verbonden |
+| 7 en 9 | Extra lichttegels met helderheid/kleurbediening |
+| 8 en 10 | Extra status-/actietegels; geen volledige numerieke/climatekaart |
+
+`TILE_COUNT: "1"` tot `"10"` bepaalt hoeveel **opeenvolgende** posities zichtbaar
+zijn. Tot en met zes zijn Vorige/Volgende verborgen; vanaf zeven staat 1–6 op
+pagina één en de rest op pagina twee. Vrij scrollen is vervangen door vaste
+pagina's. Laat ongebruikte posities op de neutrale voorbeeldentiteiten staan.
+Verander geen rastermaten om tegels toe te voegen.
+
+## Belangrijkste instellingen
+
+| Instelling (`N` = tegelnummer) | Betekenis |
+|---|---|
+| `TILEN_ENTITY` | Entiteit waarop de actie wordt uitgevoerd |
+| `TILEN_STATE_ENTITY` | Entiteit voor status/attributen; meestal dezelfde |
+| `TILEN_TITLE` | Korte titel, liefst maximaal circa 12 tekens |
+| `TILEN_TYPE` | Domein, bijvoorbeeld `light`, `scene`, `climate` |
+| `TILEN_TAP_ACTION` | `auto`, `toggle` of `custom` voor onderstaande voorbeelden |
+| `TILEN_TAP_SERVICE` | Expliciete HA-actie bij custom, bijvoorbeeld `script.turn_on` |
+| `TILEN_LONGPRESS` | `none`, `slider` of (alleen tegel 6) `vacuum` |
+| `TILEN_LONGPRESS_SLIDER` | `auto`; kiest passende regeling op basis van type |
+| `TILEN_LABEL_ON/OFF` | Tekst voor eenvoudige aan/uitstatus |
+
+De voorbeeldconfig vult ook de overige velden in. Laat die staan tenzij je
+ze bewust gebruikt. `STATE_ENTITY` kan bij een scène/script een echte lamp of
+schakelaar volgen; een scène heeft zelf geen betrouwbare aan/uitstatus.
+
+## Voorbeelden
+
+Wijzig deze sleutels in de bestaande map; plak niet nogmaals `substitutions:`.
+
+Dimbare lamp op positie 1 (tik schakelt, lang indrukken opent de regeling):
+
+```yaml
+ TILE1_ENTITY: "light.keuken"
+ TILE1_STATE_ENTITY: "light.keuken"
+ TILE1_TITLE: "Keuken"
+ TILE1_TYPE: "light"
+ TILE1_TAP_ACTION: "auto"
+ TILE1_LONGPRESS: "slider"
+```
+
+Een niet-dimbare lamp/schakelaar krijgt `LONGPRESS: "none"`. Kleurbediening
+vereist dat de lamp de betreffende kleurmodus ondersteunt.
+
+Scène op positie 3:
+
+```yaml
+ TILE3_ENTITY: "scene.avond"
+ TILE3_STATE_ENTITY: "light.keuken"
+ TILE3_TITLE: "Avond"
+ TILE3_TYPE: "scene"
+ TILE3_TAP_ACTION: "auto"
+ TILE3_LONGPRESS: "none"
+```
+
+Script op positie 4:
+
+```yaml
+ TILE4_ENTITY: "script.alles_uit"
+ TILE4_STATE_ENTITY: "script.alles_uit"
+ TILE4_TITLE: "Alles uit"
+ TILE4_TYPE: "script"
+ TILE4_TAP_ACTION: "auto"
+ TILE4_LONGPRESS: "none"
+```
+
+Airco op positie 5 (lang indrukken opent climate):
+
+```yaml
+ TILE5_ENTITY: "climate.woonkamer"
+ TILE5_STATE_ENTITY: "climate.woonkamer"
+ TILE5_TITLE: "Airco"
+ TILE5_TYPE: "climate"
+ TILE5_TAP_ACTION: "toggle"
+ TILE5_TAP_SERVICE: "climate.toggle"
+ TILE5_LONGPRESS: "slider"
+```
+
+Controleer `min_temp`, `max_temp`, `target_temp_step`, `hvac_modes` en de
+ondersteunde fan-/swingmodes in HA. Deze kaart is primair voor een **enkel
+setpoint**, niet een thermostaat die uitsluitend een temperatuurinterval
+gebruikt. Test de modi van jouw integratie afzonderlijk; een andere airco
+kan andere mogelijkheden hebben.
+
+Robotstofzuiger op positie 6 (tik start; lang indrukken opent de kaart):
+
+```yaml
+ TILE6_ENTITY: "vacuum.robot"
+ TILE6_STATE_ENTITY: "vacuum.robot"
+ TILE6_TITLE: "Stofzuiger"
+ TILE6_TYPE: "vacuum"
+ TILE6_TAP_ACTION: "custom"
+ TILE6_TAP_SERVICE: "vacuum.start"
+ TILE6_LONGPRESS: "vacuum"
+```
+
+De vacuumkaart gebruikt start/pauze/stop/terugkeren/lokaliseren en de
+snelheidsnamen `quiet`, `balanced`, `turbo`, `max`. Vergelijk deze met de
+ondersteunde acties en `fan_speed_list` in HA. De snelheidsknoppen moeten in
+de basiscode aangepast worden als jouw integratie andere namen verwacht.
+Dit is geen universele kaart voor ieder robotmodel.
+
+## Acties inschakelen en controleren
+
+Zet na kalibratie en HA-koppeling `DIRECT_ACTIONS: "true"`. Geef in de
+ESPHome-integratie toestemming voor Home Assistant-acties. Gebruik geen
+HA-automation die dezelfde schermactie daarnaast nogmaals uitvoert; dat kan
+een dubbele toggle veroorzaken. Bij `DIRECT_ACTIONS: "false"` kan de
+actie-sensor nog gebeurtenissen publiceren voor eigen automations.
+
+Flash `device.yaml` en test één tegel tegelijk: juiste status, juiste fysieke
+actie, terugmelding vanuit HA en lange druk. Controleer ook dat externe
+wijzigingen in HA op het scherm verschijnen. Voer daarna de
+[acceptatietest](ACCEPTATIE.md) uit.
+
+Extra iconen vereisen een glyph in de `materialdesign_icons`-fontlijst. De
+bundel bevat de fonts lokaal. De oudere uitgebreide
+[TILE_CONFIGURATION.md](../TILE_CONFIGURATION.md) beschrijft ook het oude
+scrollprofiel; gebruik die niet als hardware-/layoutinstructie voor dit bord.
diff --git a/fonts/MDI-LICENSE.txt b/fonts/MDI-LICENSE.txt
new file mode 100644
index 0000000..382f8a1
--- /dev/null
+++ b/fonts/MDI-LICENSE.txt
@@ -0,0 +1,20 @@
+Pictogrammers Free License
+--------------------------
+
+This icon collection is released as free, open source, and GPL friendly by
+the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
+for commercial projects, open source projects, or anything really.
+
+# Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+Some of the icons are redistributed under the Apache 2.0 license. All other
+icons are either redistributed under their respective licenses or are
+distributed under the Apache 2.0 license.
+
+# Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+All web and desktop fonts are distributed under the Apache 2.0 license. Web
+and desktop fonts contain some icons that are redistributed under the Apache
+2.0 license. All other icons are either redistributed under their respective
+licenses or are distributed under the Apache 2.0 license.
+
+# Code: MIT (https://opensource.org/licenses/MIT)
+The MIT license applies to all non-font and non-icon files.
diff --git a/fonts/Roboto-400.ttf b/fonts/Roboto-400.ttf
new file mode 100644
index 0000000..60b906e
Binary files /dev/null and b/fonts/Roboto-400.ttf differ
diff --git a/fonts/Roboto-500.ttf b/fonts/Roboto-500.ttf
new file mode 100644
index 0000000..310c9e4
Binary files /dev/null and b/fonts/Roboto-500.ttf differ
diff --git a/fonts/Roboto-700.ttf b/fonts/Roboto-700.ttf
new file mode 100644
index 0000000..db17c0a
Binary files /dev/null and b/fonts/Roboto-700.ttf differ
diff --git a/fonts/Roboto-OFL.txt b/fonts/Roboto-OFL.txt
new file mode 100644
index 0000000..9c48e05
--- /dev/null
+++ b/fonts/Roboto-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml
new file mode 100644
index 0000000..260f600
--- /dev/null
+++ b/home-like-2432s028.yaml
@@ -0,0 +1,6321 @@
+############################################################
+# SmartDisplay – iOS Home app–inspired Tiles UI
+# Target device: ESP32-2432S028 / Cheap Yellow Display (CYD)
+# Resolution: configurable via ORIENTATION substitutions (landscape 320×240 default)
+#
+# Target hardware
+# - Cheap Yellow Display (ESP32-2432S028 or compatible)
+# - Built-in ILI9341 320x240 display
+# - Built-in XPT2046 touchscreen
+#
+# What this config does
+# - Renders a gradient background + two fixed pages of “tiles” (6 + 4) (LVGL).
+# - Each tile shows: icon (MDI font), title, and a value line.
+# - Tile taps publish a short-lived text_sensor event (smartdisplay_action)
+# so you can build Home Assistant automations on top.
+# - Optional: the device can also call Home Assistant services directly
+# (toggle lights/switches/fans/covers, activate scenes/scripts, set fan preset,
+# set light brightness, set fan percentage, set cover position).
+#
+# The layout is inspired by common modern smart home UIs.
+#
+# How to customize
+# 1) Create device.yaml with tools/new_device.py; edit its TILE1..TILE10 overrides.
+# 2) If you change icons, update the FONT GLYPHS block in the substitutions below.
+# Each glyph must appear exactly once — same icon on multiple tiles is fine, just list it once.
+# 3) The background is a gradient (BG_TOP_COLOR / BG_BOTTOM_COLOR), not an image.
+# Required fonts are bundled in fonts/ (Roboto and Material Design Icons).
+#
+# Tile configuration model
+# - TILE*_TYPE defines the entity kind: light | fan | switch | scene | script | cover
+# - TILE*_TAP_ACTION defines what happens on short tap:
+# auto | toggle | activate | fan_toggle_preset | custom
+# - TILE*_LONGPRESS defines the long press mode: none | slider | action
+# slider: opens a value overlay — configure with TILE*_LONGPRESS_SLIDER + TILE*_LONGPRESS_OFF_VALUE
+# action: fires a different entity — configure with TILE*_LONGPRESS_ACTION*
+# - TILE*_VALUE_MODE defines how the value line is rendered:
+# auto | brightness | percentage | preset | text
+#
+# Notes
+# - DIRECT_ACTIONS="true" enables direct HA service calls from the device.
+# If "false", only the smartdisplay_action event is emitted.
+# - In auto mode, values and long-press behavior are derived from TILE*_TYPE:
+# light -> brightness, fan -> percentage/preset, scene/script -> text
+#
+# Device configuration: home-like-2432s028.yaml (ESP32-2432S028).
+# home-like.yaml targets the separate ST7789V/GT911 board.
+# Credentials stay in secrets.yaml. Fonts are bundled for offline validation.
+# Start at README.md; docs/ covers calibration, HA setup and acceptance tests.
+#
+# What you must adapt before flashing
+# 1) Secrets: api_encryption_key, ota_password, WiFi credentials (see secrets.yaml)
+# 2) Pins, touch calibration and transform if your CYD variant differs
+# 3) TILE*_ENTITY / TILE*_STATE_ENTITY: Home Assistant entity IDs you want to control/mirror
+# 4) Orientation + touch calibration:
+# - rotation is set via LVGL_ROTATION in the ORIENTATION presets (LVGL
+# rotates display + touch together, ESPHome 2026.4+)
+# - calibration is device-specific
+############################################################
+
+substitutions:
+ DEVICE_NAME: "cyd-new"
+ DEVICE_FRIENDLY_NAME: "Mijn CYD"
+ CALIBRATION_ON_BOOT: "false"
+ TOUCH_AFFINE_XX: "1.000000000"
+ TOUCH_AFFINE_XY: "0.000000000"
+ TOUCH_AFFINE_XC: "0.000000000"
+ TOUCH_AFFINE_YX: "0.000000000"
+ TOUCH_AFFINE_YY: "1.000000000"
+ TOUCH_AFFINE_YC: "0.000000000"
+
+ TILE_COUNT: "6" # 1-10; <=6 hides page navigation
+
+ DIRECT_ACTIONS: "false" # true = call Home Assistant services directly, false = only emit action events
+ ROOM_NAME: "Woonkamer" # room name shown in the top-left header
+ TIME_24H: "true" # true = 24h format, false = 12h format
+
+ ## DIM settings
+ AUTO_DIM: "true" # true = enable auto dimming
+ AUTO_DIM_TIMEOUT: "600" # seconds after last touch
+ MAX_BRIGHTNESS: "100" # backlight brightness in percent
+ DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent
+ NIGHT_MODE: "true" # true = use separate dim level at night
+ NIGHT_START_HOUR: "22" # 0-23
+ NIGHT_END_HOUR: "7" # 0-23
+ NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night
+
+ # ============================================================
+ # ACCENTKLEUREN — automatisch bepaald uit het Home Assistant-domein
+ #
+ # Het domein is het stuk voor de punt in het entity_id:
+ # script.goede_morgen -> "script" -> ACCENT_ACTION
+ # light.woonkamer_2 -> "light" -> ACCENT_LIGHT
+ # vacuum.s8 -> "vacuum" -> ACCENT_DEVICE
+ #
+ # Dat is Home Assistant's eigen indeling, dus een tegel krijgt vanzelf de
+ # juiste kleur zodra je er een entity in zet — je hoeft per tegel geen
+ # kleuren meer te kiezen. Het onderscheid dat telt: een script/scene vuur
+ # je af, een apparaat zet je aan of uit. Die twee horen niet hetzelfde
+ # te zijn.
+ #
+ # Zet TILE*_CIRCLE_ACTIVE_COLOR / TILE*_ICON_DISABLED_COLOR op
+ # ${ACCENT_AUTO} om dit te gebruiken, of op een echte hexwaarde om die
+ # ene tegel handmatig te kleuren (zie tegel 8 — rood zodra er post ligt).
+ # ============================================================
+ ACCENT_AUTO: "0x1000000" # sentinel: bewust buiten het 24-bits kleurbereik
+ ACCENT_ACTION: "0x8B5CF6" # script / scene / button / input_button / automation
+ ACCENT_LIGHT: "0xFEC600" # light
+ ACCENT_DEVICE: "0x00C5EC" # switch, fan, cover, climate, vacuum, media_player, rest
+
+ # ============================================================
+ # FONT GLYPHS
+ # List every unique MDI icon used across your tiles — each value must appear exactly once.
+ # Reusing the same icon on multiple tiles is fine: just list that glyph once here.
+ # If you use fewer than 6 unique icons, leave the extra slots with their default values
+ # (unused glyphs add a few bytes to the font but cause no errors).
+ # ============================================================
+ MDI_GLYPH_1: "\U000F001B"
+ MDI_GLYPH_2: "\U000F0387" # mdi:music
+ MDI_GLYPH_3: "\U000F02E3"
+ MDI_GLYPH_4: "\U000F059C"
+ MDI_GLYPH_5: "\U000F0425"
+ MDI_GLYPH_6: "\U000F070D"
+ MDI_GLYPH_7: "\U000F06B5" # mdi:lamp
+ MDI_GLYPH_8: "\U000F06EE" # mdi:mailbox
+ MDI_GLYPH_9: "\U000F04B9" # mdi:sofa
+ MDI_GLYPH_10: "\U000F002A" # mdi:alarm-light
+
+ # ============================================================
+ # TILE 1
+ # ============================================================
+
+ # --- basic settings ---
+ TILE1_ENTITY: "light.voorbeeld_lamp" # main Home Assistant entity for this tile
+ TILE1_STATE_ENTITY: "light.voorbeeld_lamp" # entity used for active/inactive state styling
+ TILE1_TITLE: "Lamp" # tile title shown in the UI
+ TILE1_ICON: "\U000F001B" # MDI glyph — also add to MDI_GLYPH_* list above if not already there
+ TILE1_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate
+ TILE1_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom
+ TILE1_LONGPRESS: "slider" # long press mode: none | slider | action
+ TILE1_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text
+ TILE1_LABEL_OFF: "Uit" # text shown when entity is off
+ TILE1_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on
+ TILE1_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on")
+
+ # --- colors ---
+ TILE1_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active
+ TILE1_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE1_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE1_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE1_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE1_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE1_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE1_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE1_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE1_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE1_TAP_SERVICE: "" # optional service override, e.g. light.toggle
+ TILE1_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE1_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE1_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position
+ TILE1_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off
+ # if LONGPRESS = action:
+ TILE1_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE1_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE)
+ TILE1_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead)
+ # if TYPE = climate:
+ TILE1_CLIMATE_MIN_TEMP: "16.0" # minimum target temperature shown in the thermostat overlay
+ TILE1_CLIMATE_MAX_TEMP: "32.0" # maximum target temperature shown in the thermostat overlay
+ TILE1_CLIMATE_STEP: "1.0" # target temperature step size
+ TILE1_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature
+ TILE1_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature
+ TILE1_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable
+
+
+ # ============================================================
+ # TILE 2
+ # ============================================================
+
+ # --- basic settings ---
+ TILE2_ENTITY: "switch.voorbeeld_stekker" # main Home Assistant entity for this tile
+ TILE2_STATE_ENTITY: "switch.voorbeeld_stekker" # entity used for active/inactive state styling
+ TILE2_TITLE: "Stekker" # tile title shown in the UI
+ TILE2_ICON: "\U000F0387" # MDI glyph — also add to MDI_GLYPH_* list above if not already there
+ TILE2_TYPE: "switch" # tile type: light | fan | switch | scene | script | cover | climate
+ TILE2_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom
+ TILE2_LONGPRESS: "none" # long press mode: none | slider | action
+ TILE2_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text
+ TILE2_LABEL_OFF: "Uit" # text shown when entity is off
+ TILE2_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on
+ TILE2_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on")
+
+ # --- colors ---
+ TILE2_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active
+ TILE2_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE2_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE2_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE2_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE2_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE2_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE2_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE2_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE2_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE2_TAP_SERVICE: "" # optional service override, e.g. light.toggle
+ TILE2_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE2_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE2_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position
+ TILE2_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off
+ # if LONGPRESS = action:
+ TILE2_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE2_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE)
+ TILE2_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead)
+ # if TYPE = climate:
+ TILE2_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay
+ TILE2_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay
+ TILE2_CLIMATE_STEP: "0.5" # target temperature step size
+ TILE2_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature
+ TILE2_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature
+ TILE2_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable
+
+
+ # ============================================================
+ # TILE 3
+ # ============================================================
+
+ # --- basic settings ---
+ TILE3_ENTITY: "scene.voorbeeld_avond" # main Home Assistant entity for this tile
+ TILE3_STATE_ENTITY: "scene.voorbeeld_avond" # entity used for active/inactive state styling
+ TILE3_TITLE: "Avond" # tile title shown in the UI
+ TILE3_ICON: "\U000F02E3" # MDI glyph — also add to MDI_GLYPH_* list above if not already there
+ TILE3_TYPE: "scene" # tile type: light | fan | switch | scene | script | cover | climate
+ TILE3_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom
+ TILE3_LONGPRESS: "none" # long press mode: none | slider | action
+ TILE3_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text
+ TILE3_LABEL_OFF: "Uit" # text shown when entity is off
+ TILE3_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on
+ TILE3_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on")
+
+ # --- colors ---
+ TILE3_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active
+ TILE3_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE3_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE3_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE3_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE3_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE3_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE3_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE3_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE3_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE3_TAP_SERVICE: "" # optional service override, e.g. light.toggle
+ TILE3_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE3_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE3_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position
+ TILE3_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off
+ # if LONGPRESS = action:
+ TILE3_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE3_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE)
+ TILE3_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead)
+ # if TYPE = climate:
+ TILE3_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay
+ TILE3_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay
+ TILE3_CLIMATE_STEP: "0.5" # target temperature step size
+ TILE3_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature
+ TILE3_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature
+ TILE3_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable
+
+
+ # ============================================================
+ # TILE 4
+ # ============================================================
+
+ # --- basic settings ---
+ TILE4_ENTITY: "script.voorbeeld_uit" # main Home Assistant entity for this tile
+ TILE4_STATE_ENTITY: "script.voorbeeld_uit" # entity used for active/inactive state styling
+ TILE4_TITLE: "Alles uit" # tile title shown in the UI
+ TILE4_ICON: "\U000F059C" # MDI glyph — also add to MDI_GLYPH_* list above if not already there
+ TILE4_TYPE: "script" # tile type: light | fan | switch | scene | script | cover | climate
+ TILE4_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset | custom
+ TILE4_LONGPRESS: "none" # long press mode: none | slider | action
+ TILE4_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text
+ TILE4_LABEL_OFF: "Uit" # text shown when entity is off
+ TILE4_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on
+ TILE4_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on")
+
+ # --- colors ---
+ TILE4_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active
+ TILE4_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE4_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE4_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE4_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE4_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE4_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE4_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE4_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE4_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE4_TAP_SERVICE: "" # optional service override, e.g. light.toggle
+ TILE4_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE4_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE4_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position
+ TILE4_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off
+ # if LONGPRESS = action:
+ TILE4_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE4_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE)
+ TILE4_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead)
+ # if TYPE = climate:
+ TILE4_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay
+ TILE4_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay
+ TILE4_CLIMATE_STEP: "0.5" # target temperature step size
+ TILE4_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature
+ TILE4_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature
+ TILE4_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable
+
+
+ # ============================================================
+ # TILE 5
+ # ============================================================
+
+ # --- basic settings ---
+ TILE5_ENTITY: "climate.voorbeeld_airco" # main Home Assistant entity for this tile
+ TILE5_STATE_ENTITY: "climate.voorbeeld_airco" # entity used for active/inactive state styling
+ TILE5_TITLE: "Airco" # tile title shown in the UI
+ TILE5_ICON: "\U000F0425" # MDI glyph — also add to MDI_GLYPH_* list above if not already there
+ TILE5_TYPE: "climate" # tile type: light | fan | switch | scene | script | cover | climate
+ TILE5_TAP_ACTION: "toggle" # tap action: auto | toggle | activate | fan_toggle_preset | custom
+ TILE5_LONGPRESS: "slider" # long press mode: none | slider | action
+ TILE5_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text
+ TILE5_LABEL_OFF: "Uit" # text shown when entity is off (closed)
+ TILE5_LABEL_ON: "Aan" # text shown when entity is on (open)
+ TILE5_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on")
+
+ # --- colors ---
+ TILE5_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active
+ TILE5_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE5_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE5_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE5_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE5_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE5_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE5_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE5_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE5_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE5_TAP_SERVICE: "climate.toggle" # optional service override, e.g. light.toggle
+ TILE5_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE5_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE5_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position
+ TILE5_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off
+ # if LONGPRESS = action:
+ TILE5_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE5_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE)
+ TILE5_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead)
+ # if TYPE = climate:
+ TILE5_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay
+ TILE5_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay
+ TILE5_CLIMATE_STEP: "0.5" # target temperature step size
+ TILE5_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature
+ TILE5_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature
+ TILE5_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable
+
+
+ # ============================================================
+ # TILE 6
+ # ============================================================
+
+ # --- basic settings ---
+ TILE6_ENTITY: "vacuum.voorbeeld_robot" # main Home Assistant entity for this tile
+ TILE6_STATE_ENTITY: "vacuum.voorbeeld_robot" # entity used for active/inactive state styling
+ TILE6_TITLE: "Stofzuiger" # tile title shown in the UI
+ TILE6_ICON: "\U000F070D" # MDI glyph — also add to MDI_GLYPH_* list above if not already there
+ TILE6_TYPE: "vacuum" # tile type: light | fan | switch | scene | script | cover | climate
+ TILE6_TAP_ACTION: "custom" # tap action: auto | toggle | activate | fan_toggle_preset | custom
+ TILE6_LONGPRESS: "vacuum" # long press mode: none | slider | action
+ TILE6_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text
+ TILE6_LABEL_OFF: "Uit" # text shown when entity is off
+ TILE6_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on
+ TILE6_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on")
+
+ # --- colors ---
+ TILE6_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active
+ TILE6_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE6_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE6_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE6_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE6_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE6_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE6_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE6_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE6_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE6_TAP_SERVICE: "vacuum.start" # optional service override, e.g. fan.toggle
+ TILE6_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE6_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE6_LONGPRESS_SLIDER: "auto" # slider type: auto | brightness | percentage | cover_position
+ TILE6_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off
+ # if LONGPRESS = action:
+ TILE6_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE6_LONGPRESS_ACTION_TYPE: "" # entity type: light | fan | switch | scene | script | cover | climate (empty = same as TILE*_TYPE)
+ TILE6_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on — set e.g. "light.toggle" to toggle instead)
+ # if TYPE = climate:
+ TILE6_CLIMATE_MIN_TEMP: "5.0" # minimum target temperature shown in the thermostat overlay
+ TILE6_CLIMATE_MAX_TEMP: "30.0" # maximum target temperature shown in the thermostat overlay
+ TILE6_CLIMATE_STEP: "0.5" # target temperature step size
+ TILE6_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature" # HA attribute for current room temperature
+ TILE6_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature" # HA attribute for target temperature
+ TILE6_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity" # HA attribute for humidity; leave as-is if unavailable
+ # ============================================================
+ # TILE 7 — Tafellamp
+ # Alleen-helderheid lamp (HA meldt supported_color_modes: ['brightness']),
+ # dus de kleurenknop in de slider-overlay blijft vanzelf verborgen.
+ # ============================================================
+
+ # --- basic settings ---
+ TILE7_ENTITY: "light.ongebruikt_7" # main Home Assistant entity for this tile
+ TILE7_STATE_ENTITY: "light.ongebruikt_7" # entity used for active/inactive state styling
+ TILE7_TITLE: "Tegel 7" # tile title shown in the UI
+ TILE7_ICON: "\U000F06B5" # mdi:lamp — also listed in MDI_GLYPH_7 above
+ TILE7_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate
+ TILE7_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset
+ TILE7_LONGPRESS: "none" # long press mode: none | slider | action | vacuum
+ TILE7_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text
+ TILE7_LABEL_OFF: "Uit" # text shown when entity is off
+ TILE7_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on
+ TILE7_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on")
+
+ # --- colors ---
+ TILE7_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # icon circle color when active
+ TILE7_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE7_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE7_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE7_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE7_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE7_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE7_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE7_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE7_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE7_TAP_SERVICE: "" # optional service override, e.g. light.toggle
+ TILE7_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE7_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE7_LONGPRESS_SLIDER: "auto" # auto = brightness voor een light
+ TILE7_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off
+ # if LONGPRESS = action:
+ TILE7_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE7_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE)
+ TILE7_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on)
+
+ # ============================================================
+ # TILE 8 — Brievenbus reset
+ # input_select.brievenbus_status heeft opties ['Vol', 'Leeg'].
+ # De tegel licht op zodra de status "Vol" is (ACTIVE_STATE), en een korte
+ # tik zet hem terug op "Leeg" via input_select.select_option.
+ # ============================================================
+
+ # --- basic settings ---
+ TILE8_ENTITY: "light.ongebruikt_8" # main Home Assistant entity for this tile
+ TILE8_STATE_ENTITY: "light.ongebruikt_8" # entity used for active/inactive state styling
+ TILE8_TITLE: "Tegel 8" # tile title shown in the UI
+ TILE8_ICON: "\U000F06EE" # mdi:mailbox — also listed in MDI_GLYPH_8 above
+ TILE8_TYPE: "light" # generiek type; het echte gedrag komt uit TAP_SERVICE hieronder
+ TILE8_TAP_ACTION: "auto" # activate + TAP_SERVICE = losse service call
+ TILE8_LONGPRESS: "none" # long press stuurt alleen het event naar HA
+ TILE8_VALUE_MODE: "auto" # toont LABEL_ON / LABEL_OFF
+ TILE8_LABEL_OFF: "Uit" # text shown when entity is off / inactive
+ TILE8_LABEL_ON: "Aan" # text shown when entity is active
+ TILE8_ACTIVE_STATE: "" # tegel is "aan" zodra de status exact "Vol" is
+
+ # --- colors ---
+ TILE8_CIRCLE_ACTIVE_COLOR: "0xE5484D" # rood zodra er post ligt
+ TILE8_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE8_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE8_ICON_DISABLED_COLOR: "0xE5484D" # icon color when inactive
+ TILE8_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE8_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE8_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE8_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE8_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE8_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE8_TAP_SERVICE: "" # zet de input_select op een vaste optie
+ TILE8_TAP_PARAM_KEY: "" # parameternaam voor input_select.select_option
+ TILE8_TAP_PARAM_VAL: "" # de "reset"-waarde
+ # if LONGPRESS = slider:
+ TILE8_LONGPRESS_SLIDER: "auto" # niet in gebruik (LONGPRESS = none)
+ TILE8_LONGPRESS_OFF_VALUE: "0" # niet in gebruik
+ # if LONGPRESS = action:
+ TILE8_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE8_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE)
+ TILE8_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on)
+
+ # ============================================================
+ # TILE 9 — Woonkamer
+ # Verhuisd van tegel 2 naar rij 5 om plaats te maken voor Ambient.
+ # Kleurenlamp (color_temp / rgb / xy), dus de kleurknop in de
+ # slider-overlay verschijnt vanzelf.
+ # ============================================================
+
+ # --- basic settings ---
+ TILE9_ENTITY: "light.ongebruikt_9" # main Home Assistant entity for this tile
+ TILE9_STATE_ENTITY: "light.ongebruikt_9" # entity used for active/inactive state styling
+ TILE9_TITLE: "Tegel 9" # tile title shown in the UI
+ TILE9_ICON: "\U000F04B9" # mdi:sofa — also listed in MDI_GLYPH_9 above
+ TILE9_TYPE: "light" # tile type: light | fan | switch | scene | script | cover | climate
+ TILE9_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset
+ TILE9_LONGPRESS: "none" # long press mode: none | slider | action | vacuum
+ TILE9_VALUE_MODE: "auto" # value mode: auto | brightness | percentage | preset | text
+ TILE9_LABEL_OFF: "Uit" # text shown when entity is off
+ TILE9_LABEL_ON: "Aan" # text shown for text-mode tiles when entity is on
+ TILE9_ACTIVE_STATE: "" # optional: exact HA state that counts as "active" (empty = default "on")
+
+ # --- colors ---
+ TILE9_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # light -> ACCENT_LIGHT (amber)
+ TILE9_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE9_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE9_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE9_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE9_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE9_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE9_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE9_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE9_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE9_TAP_SERVICE: "" # optional service override, e.g. light.toggle
+ TILE9_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE9_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE9_LONGPRESS_SLIDER: "auto" # auto = brightness voor een light
+ TILE9_LONGPRESS_OFF_VALUE: "0" # initial slider value when entity is off
+ # if LONGPRESS = action:
+ TILE9_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE9_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE)
+ TILE9_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on)
+
+ # ============================================================
+ # TILE 10 — Alarm aan/uit
+ # input_boolean.alarm_engaged. Een input_boolean reageert niet op
+ # switch.toggle, vandaar de expliciete TAP_SERVICE.
+ # ============================================================
+
+ # --- basic settings ---
+ TILE10_ENTITY: "light.ongebruikt_10" # main Home Assistant entity for this tile
+ TILE10_STATE_ENTITY: "light.ongebruikt_10" # entity used for active/inactive state styling
+ TILE10_TITLE: "Tegel 10" # tile title shown in the UI
+ TILE10_ICON: "\U000F002A" # mdi:alarm-light — also listed in MDI_GLYPH_10 above
+ TILE10_TYPE: "light" # generiek aan/uit-type; de service hieronder doet het echte werk
+ TILE10_TAP_ACTION: "auto" # tap action: auto | toggle | activate | fan_toggle_preset
+ TILE10_LONGPRESS: "none" # long press stuurt alleen het event naar HA
+ TILE10_VALUE_MODE: "auto" # toont LABEL_ON / LABEL_OFF
+ TILE10_LABEL_OFF: "Uit" # text shown when entity is off
+ TILE10_LABEL_ON: "Aan" # text shown when entity is on
+ TILE10_ACTIVE_STATE: "" # input_boolean gebruikt gewoon on/off
+
+ # --- colors ---
+ TILE10_CIRCLE_ACTIVE_COLOR: "${ACCENT_AUTO}" # input_boolean -> ACCENT_DEVICE (cyaan)
+ TILE10_CIRCLE_DISABLED_COLOR: "0x7B7B6F" # icon circle color when inactive
+ TILE10_ICON_ACTIVE_COLOR: "0xFFFFFF" # icon color when active
+ TILE10_ICON_DISABLED_COLOR: "${ACCENT_AUTO}" # icon color when inactive
+ TILE10_BG_ACTIVE_COLOR: "0xFFFFFF" # tile background color when active
+ TILE10_BG_DISABLED_COLOR: "0x939391" # tile background color when inactive
+ TILE10_TITLE_ACTIVE_COLOR: "0x000000" # title color when active
+ TILE10_TITLE_DISABLED_COLOR: "0xFFFFFF" # title color when inactive
+ TILE10_VALUE_ACTIVE_COLOR: "0x7A7A7C" # value color when active
+ TILE10_VALUE_DISABLED_COLOR: "0xD9D9D9" # value color when inactive
+
+ # --- advanced settings ---
+ TILE10_TAP_SERVICE: "" # switch.toggle werkt niet op een input_boolean
+ TILE10_TAP_PARAM_KEY: "" # optional extra service parameter key
+ TILE10_TAP_PARAM_VAL: "" # optional extra service parameter value
+ # if LONGPRESS = slider:
+ TILE10_LONGPRESS_SLIDER: "auto" # niet in gebruik (LONGPRESS = none)
+ TILE10_LONGPRESS_OFF_VALUE: "0" # niet in gebruik
+ # if LONGPRESS = action:
+ TILE10_LONGPRESS_ACTION: "" # entity_id to fire on long press
+ TILE10_LONGPRESS_ACTION_TYPE: "" # entity type (empty = same as TILE*_TYPE)
+ TILE10_LONGPRESS_ACTION_SERVICE: "" # service to call (default: activate/turn_on)
+
+
+ # ============================================================
+ # ORIENTATION / LAYOUT
+ # Uncomment ONE preset block below, comment out the others.
+ # Touch calibration values (TOUCH_CAL_*) are device-specific —
+ # portrait/flipped values are approximate and may need tuning.
+ #
+ # LVGL_ROTATION (0/90/180/270) drives the rotation — LVGL rotates both the
+ # display output and the touch coordinates (ESPHome 2026.4+). DISPLAY_W/H are
+ # the *logical* (post-rotation) canvas size used by the tile layout below;
+ # the physical panel is always driven at its native 240×320 (see display:).
+ #
+ # Tile content layout:
+ # Landscape — icon left, title + value to its right
+ # Portrait — icon centered on top, title + value stacked below
+ # ============================================================
+
+ # ----- 0° — Landscape 320×240 (default, USB on right) -----
+ ORIENTATION: "0"
+ DISPLAY_W: "320"
+ DISPLAY_H: "240"
+ LVGL_ROTATION: "90"
+ TOUCH_SWAP_XY: "false"
+ # true op dit bord (resistieve XPT2046) — was false op het 032c-bord
+ # (capacitieve GT911). Dit is de originele upstream-waarde voor dit paneel.
+ TOUCH_MIRROR_X: "true"
+ TOUCH_MIRROR_Y: "false"
+ # Measured on this board: median top-right raw=(523,3390),
+ # bottom-left raw=(3487,482), at screen (299,20) and (20,219).
+ # Extrapolated to panel edges; verify with the isolated corner test.
+ TOUCH_CAL_X_MIN: "280"
+ TOUCH_CAL_X_MAX: "3860"
+ TOUCH_CAL_Y_MIN: "340"
+ TOUCH_CAL_Y_MAX: "3860"
+ GRID_COL1_X: "9"
+ GRID_COL2_X: "164"
+ GRID_ROW1_Y: "0"
+ GRID_ROW2_Y: "56"
+ GRID_ROW3_Y: "112"
+ GRID_ROW4_Y: "168"
+ GRID_ROW5_Y: "224"
+ SCROLL_Y: "40"
+ SCROLL_H: "164"
+ TILE_W: "147"
+ TILE_H: "52"
+ HEADER_Y: "14"
+ OVERLAY_SLIDER_W: "96"
+ OVERLAY_SLIDER_H: "160"
+ OVERLAY_ICON_Y: "74"
+ BG_TOP_COLOR: "0xAEC4D9" # achtergrond boven (zacht blauw)
+ BG_BOTTOM_COLOR: "0xD6BB9E" # achtergrond onder (warm zand)
+ TILE_ICON_X: "0"
+ TILE_ICON_Y: "0"
+ TILE_TITLE_X: "48"
+ TILE_TITLE_Y: "4"
+ TILE_VALUE_X: "48"
+ TILE_VALUE_Y: "18"
+
+ # ----- 90° — Portrait 240×320 (USB on bottom) -----
+ # ORIENTATION: "90"
+ # DISPLAY_W: "240"
+ # DISPLAY_H: "320"
+ # LVGL_ROTATION: "180"
+ # TOUCH_SWAP_XY: "false"
+ # TOUCH_MIRROR_X: "true"
+ # TOUCH_MIRROR_Y: "false"
+ # TOUCH_CAL_X_MIN: "280"
+ # TOUCH_CAL_X_MAX: "3860"
+ # TOUCH_CAL_Y_MIN: "340"
+ # TOUCH_CAL_Y_MAX: "3860"
+ # GRID_COL1_X: "6"
+ # GRID_COL2_X: "123"
+ # GRID_ROW1_Y: "8"
+ # GRID_ROW2_Y: "96"
+ # GRID_ROW3_Y: "184"
+ # GRID_ROW4_Y: "272"
+ # GRID_ROW5_Y: "360"
+ # SCROLL_Y: "44"
+ # SCROLL_H: "276"
+ # TILE_W: "111"
+ # TILE_H: "80"
+ # HEADER_Y: "14"
+ # OVERLAY_SLIDER_W: "96"
+ # OVERLAY_SLIDER_H: "200"
+ # OVERLAY_ICON_Y: "94"
+ # BG_TOP_COLOR: "0xAEC4D9" # achtergrond boven (zacht blauw)
+ # BG_BOTTOM_COLOR: "0xD6BB9E" # achtergrond onder (warm zand)
+ # TILE_ICON_X: "0"
+ # TILE_ICON_Y: "0"
+ # TILE_TITLE_X: "4"
+ # TILE_TITLE_Y: "38"
+ # TILE_VALUE_X: "4"
+ # TILE_VALUE_Y: "52"
+
+ # ----- 180° — Landscape flipped 320×240 (USB on left) -----
+ # ORIENTATION: "180"
+ # DISPLAY_W: "320"
+ # DISPLAY_H: "240"
+ # LVGL_ROTATION: "270"
+ # TOUCH_SWAP_XY: "false"
+ # TOUCH_MIRROR_X: "true"
+ # TOUCH_MIRROR_Y: "false"
+ # TOUCH_CAL_X_MIN: "280"
+ # TOUCH_CAL_X_MAX: "3860"
+ # TOUCH_CAL_Y_MIN: "340"
+ # TOUCH_CAL_Y_MAX: "3860"
+ # GRID_COL1_X: "9"
+ # GRID_COL2_X: "164"
+ # GRID_ROW1_Y: "8"
+ # GRID_ROW2_Y: "68"
+ # GRID_ROW3_Y: "128"
+ # GRID_ROW4_Y: "188"
+ # GRID_ROW5_Y: "248"
+ # SCROLL_Y: "44"
+ # SCROLL_H: "196"
+ # TILE_W: "147"
+ # TILE_H: "52"
+ # HEADER_Y: "14"
+ # OVERLAY_SLIDER_W: "96"
+ # OVERLAY_SLIDER_H: "160"
+ # OVERLAY_ICON_Y: "74"
+ # BG_TOP_COLOR: "0xAEC4D9" # achtergrond boven (zacht blauw)
+ # BG_BOTTOM_COLOR: "0xD6BB9E" # achtergrond onder (warm zand)
+ # TILE_ICON_X: "0"
+ # TILE_ICON_Y: "0"
+ # TILE_TITLE_X: "48"
+ # TILE_TITLE_Y: "4"
+ # TILE_VALUE_X: "48"
+ # TILE_VALUE_Y: "18"
+
+ # ----- 270° — Portrait flipped 240×320 (USB on top) -----
+ # ORIENTATION: "270"
+ # DISPLAY_W: "240"
+ # DISPLAY_H: "320"
+ # LVGL_ROTATION: "0"
+ # TOUCH_SWAP_XY: "false"
+ # TOUCH_MIRROR_X: "true"
+ # TOUCH_MIRROR_Y: "false"
+ # TOUCH_CAL_X_MIN: "280"
+ # TOUCH_CAL_X_MAX: "3860"
+ # TOUCH_CAL_Y_MIN: "340"
+ # TOUCH_CAL_Y_MAX: "3860"
+ # GRID_COL1_X: "6"
+ # GRID_COL2_X: "123"
+ # GRID_ROW1_Y: "8"
+ # GRID_ROW2_Y: "96"
+ # GRID_ROW3_Y: "184"
+ # GRID_ROW4_Y: "272"
+ # GRID_ROW5_Y: "360"
+ # SCROLL_Y: "44"
+ # SCROLL_H: "276"
+ # TILE_W: "111"
+ # TILE_H: "80"
+ # HEADER_Y: "14"
+ # OVERLAY_SLIDER_W: "96"
+ # OVERLAY_SLIDER_H: "200"
+ # OVERLAY_ICON_Y: "94"
+ # BG_TOP_COLOR: "0xAEC4D9" # achtergrond boven (zacht blauw)
+ # BG_BOTTOM_COLOR: "0xD6BB9E" # achtergrond onder (warm zand)
+ # TILE_ICON_X: "0"
+ # TILE_ICON_Y: "0"
+ # TILE_TITLE_X: "4"
+ # TILE_TITLE_Y: "38"
+ # TILE_VALUE_X: "4"
+ # TILE_VALUE_Y: "52"
+
+# ============================================================
+# No need to edit below this line.
+# All configuration is done via the substitutions above.
+# Credentials are managed in secrets.yaml (see secrets.yaml.example).
+# The Material Design Icons font is included at fonts/materialdesignicons-webfont.ttf (Apache 2.0).
+# Source: https://github.com/Templarian/MaterialDesign-Webfont
+# ============================================================
+
+external_components:
+ - source:
+ type: local
+ path: components
+ components: [xpt2046]
+
+esphome:
+ includes:
+ - cyd_ui.h
+ name: ${DEVICE_NAME}
+ friendly_name: ${DEVICE_FRIENDLY_NAME}
+ on_boot:
+ priority: -100
+ then:
+ - lambda: |-
+ // Per-panel affine correction; shared defaults are neutral.
+ id(ts_touch).set_raw_correction(${TOUCH_AFFINE_XX}f, ${TOUCH_AFFINE_XY}f, ${TOUCH_AFFINE_XC}f, ${TOUCH_AFFINE_YX}f, ${TOUCH_AFFINE_YY}f, ${TOUCH_AFFINE_YC}f);
+ id(last_touch_ms) = millis();
+ id(display_dimmed) = false;
+ lv_obj_set_style_pad_row(id(vacuum_actions), 4, 0);
+ lv_obj_set_style_pad_column(id(vacuum_actions), 4, 0);
+ lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0);
+ lv_obj_set_style_pad_column(id(vacuum_speeds), 4, 0);
+ - light.turn_on:
+ id: back_light
+ brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;"
+ - script.execute: show_tile_page
+ - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
+ - if:
+ condition:
+ lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";'
+ then:
+ - lambda: 'id(calibration_active) = true;'
+ - lvgl.page.show: touch_cal_page
+ - lambda: |-
+ lv_obj_invalidate(id(touch_cal_page)->obj);
+ lv_refr_now(nullptr);
+ ESP_LOGI("ui_diag", "USB calibration ready; no tile actions");
+
+
+esp32:
+ board: esp32dev
+ framework:
+ type: esp-idf
+ advanced:
+ # ESPHome adviseert dit zelf in de boot-log. Levert ~40KB extra IRAM
+ # en een kleinere binary op, en op dit bord telt elke KB.
+ sram1_as_iram: true
+ # minimum_chip_revision bewust weggelaten: dat stond hier voor het
+ # 032c-bord (rev 3.1 bevestigd), maar dit is een ander fysiek bordje
+ # met een onbekende chip-revisie. Met een onterecht hoge minimum
+ # weigert de firmware simpelweg te booten.
+ version: recommended
+ variant: esp32
+
+logger:
+ # Voor diagnose tijdelijk op DEBUG of VERBOSE zetten. Let op: ESPHome logt
+ # zijn configuratieregels op niveau CONFIG, die zijn op INFO onzichtbaar.
+ level: DEBUG
+ logs:
+ sensor: WARN
+ text_sensor: WARN
+ xpt2046: WARN
+ homeassistant.sensor: WARN
+ homeassistant.text_sensor: WARN
+
+
+# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values.
+api:
+ actions:
+ - action: touch_diagnostics
+ then:
+ - lambda: 'id(calibration_active) = true;'
+ - script.execute: wake_display
+ - lvgl.page.show: touch_cal_page
+ - lambda: |-
+ lv_obj_invalidate(id(touch_cal_page)->obj);
+ lv_refr_now(nullptr);
+ ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj);
+ - action: end_touch_diagnostics
+ then:
+ - lambda: 'id(calibration_active) = false;'
+ - lvgl.page.show: home_page
+ - action: ui_state
+ then:
+ - lambda: |-
+ lv_area_t nav, grid;
+ lv_obj_update_layout(id(home_page)->obj);
+ lv_obj_get_coords(id(page_next), &nav);
+ lv_obj_get_coords(id(tile_scroll), &grid);
+ ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d",
+ lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj,
+ grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2);
+ - action: ui_self_test
+ then:
+ - script.execute: ui_self_test
+ encryption:
+ key: !secret api_encryption_key
+
+ota:
+ - platform: esphome
+ password: !secret ota_password
+
+wifi:
+ ssid: !secret wifi_ssid
+ password: !secret wifi_password
+ ap:
+ ssid: "Smartdisplay Fallback Hotspot"
+ password: !secret ap_password
+
+captive_portal:
+
+interval:
+ - interval: 1s
+ then:
+ - if:
+ condition:
+ lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;'
+ then:
+ - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
+ - if:
+ condition:
+ lambda: |-
+ if (std::string("${AUTO_DIM}") != "true") return false;
+ if (id(display_dimmed) || id(touch_down) || id(calibration_active)) return false;
+ return (millis() - id(last_touch_ms)) > (atoi("${AUTO_DIM_TIMEOUT}") * 1000UL);
+ then:
+ - script.execute: dim_display
+
+time:
+ - platform: homeassistant
+ id: homeassistant_time
+ # Refresh immediately on time sync; do not wait for an HA entity update.
+ update_interval: 60s
+ on_time_sync:
+ then:
+ - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
+ on_time:
+ - seconds: 0
+ minutes: /1
+ then:
+ - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
+
+ - seconds: 0
+ minutes: 0
+ hours: ${NIGHT_START_HOUR}
+ then:
+ - if:
+ condition:
+ lambda: 'return id(display_dimmed);'
+ then:
+ - script.execute: dim_display
+
+ - seconds: 0
+ minutes: 0
+ hours: ${NIGHT_END_HOUR}
+ then:
+ - if:
+ condition:
+ lambda: 'return id(display_dimmed);'
+ then:
+ - script.execute: dim_display
+
+globals:
+ - id: rendered_frames
+ type: uint32_t
+ initial_value: '0'
+ - id: calibration_active
+ type: bool
+ initial_value: 'false'
+ - id: tile_page
+ type: int
+ initial_value: '0'
+ - id: touch_down
+ type: bool
+ initial_value: 'false'
+ - id: climate_edit_ms
+ type: uint32_t
+ initial_value: '0'
+ - id: climate_edit_pending
+ type: bool
+ initial_value: 'false'
+ - id: active_entity
+ type: std::string
+ restore_value: no
+
+ - id: active_control_kind
+ type: std::string
+ restore_value: no
+
+ - id: active_value_suffix
+ type: std::string
+ restore_value: no
+
+ - id: last_touch_ms
+ type: uint32_t
+ restore_value: no
+ initial_value: '0'
+
+ # Debounce voor do_tile_action: dit resistieve XPT2046-paneel geeft soms
+ # meerdere losse press/release-cycli voor één fysieke aanraking (ruis in
+ # het weerstandslaagje), waardoor dezelfde HA-actie meerdere keren binnen
+ # een paar honderd ms vuurt (gezien: script.ambient_morning 6x in 8s).
+ - id: last_action_entity
+ type: std::string
+ restore_value: no
+
+ - id: last_action_ms
+ type: uint32_t
+ restore_value: no
+ initial_value: '0'
+
+ - id: display_dimmed
+ type: bool
+ restore_value: no
+ initial_value: 'false'
+
+ - id: active_label_off
+ type: std::string
+ restore_value: no
+
+ - id: active_label_on
+ type: std::string
+ restore_value: no
+
+ - id: active_title
+ type: std::string
+ restore_value: no
+
+ - id: active_icon_text
+ type: std::string
+ restore_value: no
+
+ - id: active_brightness_pct
+ type: int
+ restore_value: no
+ initial_value: '100'
+
+ - id: active_supports_color
+ type: bool
+ restore_value: no
+ initial_value: 'false'
+
+ - id: active_supports_temperature
+ type: bool
+ restore_value: no
+ initial_value: 'false'
+
+ - id: active_color_detail_tab
+ type: int
+ restore_value: no
+ initial_value: '0'
+
+ - id: active_climate_current_x10
+ type: int
+ restore_value: no
+ initial_value: '0'
+
+ - id: active_climate_target_x10
+ type: int
+ restore_value: no
+ initial_value: '200'
+
+ - id: active_climate_humidity
+ type: int
+ restore_value: no
+ initial_value: '-1'
+
+ - id: active_climate_min_x10
+ type: int
+ restore_value: no
+ initial_value: '50'
+
+ - id: active_climate_max_x10
+ type: int
+ restore_value: no
+ initial_value: '300'
+
+ - id: active_climate_step_x10
+ type: int
+ restore_value: no
+ initial_value: '5'
+
+ - id: active_climate_mode
+ type: std::string
+ restore_value: no
+
+ - id: active_climate_supported_modes
+ type: std::string
+ restore_value: no
+
+font:
+ - file: "fonts/Roboto-500.ttf"
+ id: time_label
+ size: 18
+ bpp: 4
+ glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P']
+
+ - file: "fonts/Roboto-500.ttf"
+ id: headline
+ size: 18
+ bpp: 4
+ glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
+ '0','1','2','3','4','5','6','7','8','9',
+ 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
+ ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
+ 'ä','ö','ü','Ä','Ö','Ü','ß','/']
+
+ - file: "fonts/Roboto-700.ttf"
+ id: label
+ size: 11
+ bpp: 4
+ glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
+ '0','1','2','3','4','5','6','7','8','9',
+ 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
+ ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
+ 'ä','ö','ü','Ä','Ö','Ü','ß','/']
+
+ - file: "fonts/Roboto-400.ttf"
+ id: sublabel
+ size: 11
+ bpp: 4
+ glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
+ '0','1','2','3','4','5','6','7','8','9',
+ 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
+ ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
+ 'ä','ö','ü','Ä','Ö','Ü','ß','/','…']
+
+ - file: "fonts/Roboto-400.ttf"
+ id: sublabel_big
+ size: 14
+ bpp: 4
+ glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
+ '0','1','2','3','4','5','6','7','8','9',
+ 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
+ ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
+ 'ä','ö','ü','Ä','Ö','Ü','ß','/','…']
+
+ # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
+ # Source: https://github.com/Templarian/MaterialDesign-Webfont
+ - file: 'fonts/materialdesignicons-webfont.ttf'
+ id: materialdesign_icons
+ size: 28
+ glyphs:
+ - "${MDI_GLYPH_1}"
+ - "${MDI_GLYPH_2}"
+ - "${MDI_GLYPH_3}"
+ - "${MDI_GLYPH_4}"
+ - "${MDI_GLYPH_5}"
+ - "${MDI_GLYPH_6}"
+ - "${MDI_GLYPH_7}"
+ - "${MDI_GLYPH_8}"
+ - "${MDI_GLYPH_9}"
+ - "${MDI_GLYPH_10}"
+ - "\U000F012C" # check
+
+# Geheugen zichtbaar maken: dit bord heeft geen PSRAM, dus de vrije heap
+# bepaalt hoeveel UI erbij kan. Verschijnt ook als entity in Home Assistant.
+debug:
+ update_interval: 30s
+
+sensor:
+ - platform: debug
+ free:
+ name: "Heap Free"
+ id: heap_free
+ on_value:
+ - lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);'
+ fragmentation:
+ name: "Heap Fragmentation"
+ block:
+ name: "Heap Largest Block"
+ on_value:
+ - lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);'
+ - platform: wifi_signal
+ name: "Wifi Signal"
+ update_interval: 600s
+
+ - platform: uptime
+ name: "Uptime"
+ id: uptime_s
+ update_interval: 15s
+
+ - platform: homeassistant
+ id: tile1_brightness
+ entity_id: ${TILE1_ENTITY}
+ attribute: brightness
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_percentage
+ entity_id: ${TILE1_ENTITY}
+ attribute: percentage
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_brightness
+ entity_id: ${TILE2_ENTITY}
+ attribute: brightness
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_percentage
+ entity_id: ${TILE2_ENTITY}
+ attribute: percentage
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_brightness
+ entity_id: ${TILE3_ENTITY}
+ attribute: brightness
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_percentage
+ entity_id: ${TILE3_ENTITY}
+ attribute: percentage
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_brightness
+ entity_id: ${TILE4_ENTITY}
+ attribute: brightness
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_percentage
+ entity_id: ${TILE4_ENTITY}
+ attribute: percentage
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_brightness
+ entity_id: ${TILE5_ENTITY}
+ attribute: brightness
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_percentage
+ entity_id: ${TILE5_ENTITY}
+ attribute: percentage
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_brightness
+ entity_id: ${TILE6_ENTITY}
+ attribute: brightness
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_percentage
+ entity_id: ${TILE6_ENTITY}
+ attribute: percentage
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ # Tegel 7 (Tafellamp) is een alleen-helderheid light: alleen brightness.
+ # Bewust geen percentage / cover_position / climate-sensors — elke
+ # HA-subscription kost heap op een bord zonder PSRAM.
+ - platform: homeassistant
+ id: tile7_brightness
+ entity_id: ${TILE7_ENTITY}
+ attribute: brightness
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ # Tegel 9 (Woonkamer) is een kleurenlamp: brightness + supported_color_modes.
+ # Geen percentage / cover / climate — die leest deze tegel nooit.
+ - platform: homeassistant
+ id: tile9_brightness
+ entity_id: ${TILE9_ENTITY}
+ attribute: brightness
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_cover_position
+ entity_id: ${TILE1_ENTITY}
+ attribute: current_position
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_cover_position
+ entity_id: ${TILE2_ENTITY}
+ attribute: current_position
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_cover_position
+ entity_id: ${TILE3_ENTITY}
+ attribute: current_position
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_cover_position
+ entity_id: ${TILE4_ENTITY}
+ attribute: current_position
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_cover_position
+ entity_id: ${TILE5_ENTITY}
+ attribute: current_position
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_cover_position
+ entity_id: ${TILE6_ENTITY}
+ attribute: current_position
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_climate_current_temperature
+ entity_id: ${TILE1_ENTITY}
+ attribute: ${TILE1_CLIMATE_CURRENT_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_climate_target_temperature
+ entity_id: ${TILE1_ENTITY}
+ attribute: ${TILE1_CLIMATE_TARGET_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_climate_humidity
+ entity_id: ${TILE1_ENTITY}
+ attribute: ${TILE1_CLIMATE_HUMIDITY_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_climate_current_temperature
+ entity_id: ${TILE2_ENTITY}
+ attribute: ${TILE2_CLIMATE_CURRENT_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_climate_target_temperature
+ entity_id: ${TILE2_ENTITY}
+ attribute: ${TILE2_CLIMATE_TARGET_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_climate_humidity
+ entity_id: ${TILE2_ENTITY}
+ attribute: ${TILE2_CLIMATE_HUMIDITY_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_climate_current_temperature
+ entity_id: ${TILE3_ENTITY}
+ attribute: ${TILE3_CLIMATE_CURRENT_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_climate_target_temperature
+ entity_id: ${TILE3_ENTITY}
+ attribute: ${TILE3_CLIMATE_TARGET_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_climate_humidity
+ entity_id: ${TILE3_ENTITY}
+ attribute: ${TILE3_CLIMATE_HUMIDITY_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_climate_current_temperature
+ entity_id: ${TILE4_ENTITY}
+ attribute: ${TILE4_CLIMATE_CURRENT_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_climate_target_temperature
+ entity_id: ${TILE4_ENTITY}
+ attribute: ${TILE4_CLIMATE_TARGET_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_climate_humidity
+ entity_id: ${TILE4_ENTITY}
+ attribute: ${TILE4_CLIMATE_HUMIDITY_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_climate_current_temperature
+ entity_id: ${TILE5_ENTITY}
+ attribute: ${TILE5_CLIMATE_CURRENT_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_climate_target_temperature
+ entity_id: ${TILE5_ENTITY}
+ attribute: ${TILE5_CLIMATE_TARGET_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_climate_humidity
+ entity_id: ${TILE5_ENTITY}
+ attribute: ${TILE5_CLIMATE_HUMIDITY_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_climate_current_temperature
+ entity_id: ${TILE6_ENTITY}
+ attribute: ${TILE6_CLIMATE_CURRENT_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_climate_target_temperature
+ entity_id: ${TILE6_ENTITY}
+ attribute: ${TILE6_CLIMATE_TARGET_TEMP_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_climate_humidity
+ entity_id: ${TILE6_ENTITY}
+ attribute: ${TILE6_CLIMATE_HUMIDITY_ATTRIBUTE}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+binary_sensor:
+ - platform: status
+ name: "Node Status"
+ id: system_status
+
+# ----------------------------------------------------------
+# Text sensors
+#
+# smartdisplay_action:
+# - Main touch event channel exported to Home Assistant
+# - On short tap, publishes one of:
+# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press
+# - The value is reset shortly after publishing
+#
+# tile*_preset:
+# - Mirrors the Home Assistant preset_mode attribute for each tile entity
+# - Used by the UI to display fan presets when applicable
+# ----------------------------------------------------------
+text_sensor:
+ - platform: template
+ id: smartdisplay_action
+ name: "SmartDisplay Action"
+ icon: mdi:gesture-tap-button
+
+ - platform: homeassistant
+ id: ha_state_tile1
+ entity_id: ${TILE1_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile2
+ entity_id: ${TILE2_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile3
+ entity_id: ${TILE3_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile4
+ entity_id: ${TILE4_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile5
+ entity_id: ${TILE5_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile6
+ entity_id: ${TILE6_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile7
+ entity_id: ${TILE7_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile8
+ entity_id: ${TILE8_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile9
+ entity_id: ${TILE9_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: ha_state_tile10
+ entity_id: ${TILE10_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ # Woonkamer meldt color_temp/rgb/xy, dus hier verschijnt de kleurknop wel.
+ - platform: homeassistant
+ id: tile9_supported_color_modes
+ entity_id: ${TILE9_ENTITY}
+ attribute: supported_color_modes
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ # Gebruikt door de slider-overlay om te bepalen of de kleurknop nodig is.
+ # Deze lamp meldt ['brightness'], dus de knop blijft vanzelf verborgen.
+ - platform: homeassistant
+ id: tile7_supported_color_modes
+ entity_id: ${TILE7_ENTITY}
+ attribute: supported_color_modes
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_preset
+ entity_id: ${TILE1_ENTITY}
+ attribute: preset_mode
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_preset
+ entity_id: ${TILE2_ENTITY}
+ attribute: preset_mode
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_preset
+ entity_id: ${TILE3_ENTITY}
+ attribute: preset_mode
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_preset
+ entity_id: ${TILE4_ENTITY}
+ attribute: preset_mode
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_preset
+ entity_id: ${TILE5_ENTITY}
+ attribute: preset_mode
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_preset
+ entity_id: ${TILE6_ENTITY}
+ attribute: preset_mode
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_supported_color_modes
+ entity_id: ${TILE1_ENTITY}
+ attribute: supported_color_modes
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_supported_color_modes
+ entity_id: ${TILE2_ENTITY}
+ attribute: supported_color_modes
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_supported_color_modes
+ entity_id: ${TILE3_ENTITY}
+ attribute: supported_color_modes
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_supported_color_modes
+ entity_id: ${TILE4_ENTITY}
+ attribute: supported_color_modes
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_supported_color_modes
+ entity_id: ${TILE5_ENTITY}
+ attribute: supported_color_modes
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_supported_color_modes
+ entity_id: ${TILE6_ENTITY}
+ attribute: supported_color_modes
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_climate_mode
+ entity_id: ${TILE1_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_climate_mode
+ entity_id: ${TILE2_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_climate_mode
+ entity_id: ${TILE3_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_climate_mode
+ entity_id: ${TILE4_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_climate_mode
+ entity_id: ${TILE5_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_climate_mode
+ entity_id: ${TILE6_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile1_climate_supported_modes
+ entity_id: ${TILE1_ENTITY}
+ attribute: hvac_modes
+
+ - platform: homeassistant
+ id: tile2_climate_supported_modes
+ entity_id: ${TILE2_ENTITY}
+ attribute: hvac_modes
+
+ - platform: homeassistant
+ id: tile3_climate_supported_modes
+ entity_id: ${TILE3_ENTITY}
+ attribute: hvac_modes
+
+ - platform: homeassistant
+ id: tile4_climate_supported_modes
+ entity_id: ${TILE4_ENTITY}
+ attribute: hvac_modes
+
+ - platform: homeassistant
+ id: tile5_climate_supported_modes
+ entity_id: ${TILE5_ENTITY}
+ attribute: hvac_modes
+
+ - platform: homeassistant
+ id: tile6_climate_supported_modes
+ entity_id: ${TILE6_ENTITY}
+ attribute: hvac_modes
+
+ - platform: homeassistant
+ id: tile1_cover_state
+ entity_id: ${TILE1_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile2_cover_state
+ entity_id: ${TILE2_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile3_cover_state
+ entity_id: ${TILE3_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile4_cover_state
+ entity_id: ${TILE4_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile5_cover_state
+ entity_id: ${TILE5_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+ - platform: homeassistant
+ id: tile6_cover_state
+ entity_id: ${TILE6_STATE_ENTITY}
+ on_value: { then: [ lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' ] }
+
+switch:
+ - platform: restart
+ name: "Restart"
+
+# Two SPI buses used by the CYD:
+# - lcd: built-in display
+# - my_touchscreen: built-in touch controller
+# Twee SPI bussen op de 2432S028: een voor het display, een losse voor de
+# resistieve XPT2046 touch (geen I2C op dit bord, in tegenstelling tot 032c).
+spi:
+ - id: lcd
+ clk_pin: GPIO14
+ mosi_pin: GPIO13
+
+ - id: my_touchscreen
+ clk_pin: GPIO25
+ mosi_pin: GPIO32
+ miso_pin: GPIO39
+
+output:
+ - platform: ledc
+ pin: GPIO21
+ id: gpio_backlight_pwm
+ # ESPHome's ledc default is 1kHz. Bij stilstand zie je dat niet (ruim
+ # boven wat het oog als flikkering waarneemt), maar bij beweging van het
+ # scherm onthult motion-blur de aan/uit-pulsen als strepen (zelfde effect
+ # als een PWM-LED die "schokt" als je hem beweegt). 20kHz zit ruim boven
+ # de snelheid waarbij beweging dat nog zichtbaar maakt, en laat nog altijd
+ # ruim voldoende duty-cycle resolutie over voor vloeiend dimmen.
+ frequency: 20000Hz
+
+ - platform: ledc
+ id: output_red
+ pin: GPIO4
+ inverted: true
+ - platform: ledc
+ id: output_green
+ pin: GPIO16
+ inverted: true
+ - platform: ledc
+ id: output_blue
+ pin: GPIO17
+ inverted: true
+
+light:
+ - platform: monochromatic
+ output: gpio_backlight_pwm
+ name: "Power Display Backlight"
+ id: back_light
+ restore_mode: ALWAYS_ON
+
+ - platform: rgb
+ name: LED
+ red: output_red
+ id: led
+ green: output_green
+ blue: output_blue
+ restore_mode: ALWAYS_OFF
+
+# Touchscreen configuration (XPT2046, resistief, via SPI)
+# XPT2046 calibration is board-specific. Poll at 50Hz; LVGL rotates touch
+# with the display. GPIO36 IRQ is deliberately unused (driver requests a
+# pull-up that this input-only pin cannot provide).
+touchscreen:
+ platform: xpt2046
+ id: ts_touch
+ spi_id: my_touchscreen
+ cs_pin: 33
+ # Polling avoids GPIO36 pull-up errors and noisy PENIRQ edges.
+ update_interval: 20ms
+ # Diagnosis caught a false corner contact at pressure 422. Deliberate
+ # contacts measured 936-2643; reject the low-pressure tail on release.
+ threshold: 800
+
+ calibration:
+ x_min: ${TOUCH_CAL_X_MIN}
+ x_max: ${TOUCH_CAL_X_MAX}
+ y_min: ${TOUCH_CAL_Y_MIN}
+ y_max: ${TOUCH_CAL_Y_MAX}
+
+ transform:
+ swap_xy: ${TOUCH_SWAP_XY}
+ mirror_x: ${TOUCH_MIRROR_X}
+ mirror_y: ${TOUCH_MIRROR_Y}
+
+ on_touch:
+ - lambda: |-
+ id(last_touch_ms) = millis();
+ id(touch_down) = true;
+ cyd::touch_guard.begin(millis(), touch.x, touch.y);
+ ESP_LOGI("touch", "press native=%u,%u raw=%d,%d pressure=%d calibration=%d", touch.x, touch.y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y(), touch.z_raw, id(calibration_active));
+ on_update:
+ - lambda: |-
+ id(last_touch_ms) = millis();
+ for (const auto &point : touches) {
+ cyd::touch_guard.update(point.x, point.y);
+ }
+ on_release:
+ - lambda: |-
+ id(last_touch_ms) = millis();
+ id(touch_down) = false;
+
+# Display configuration (ILI9341)
+display:
+ - platform: mipi_spi
+ id: my_display
+ spi_id: lcd
+ # Standard single-Micro-USB CYD; 40MHz matches ESPHome's board preset.
+ model: ILI9341
+ data_rate: 40MHz
+ cs_pin: 15
+ dc_pin: 2
+ invert_colors: false
+ update_interval: never
+ auto_clear_enabled: false
+
+ # Native panel dimensions (portrait). Do NOT swap these per orientation and
+ # do NOT add a display transform — rotation is handled by LVGL below.
+ # ESPHome 2026.6+ rejects a swapped transform here with "Invalid offsets".
+ dimensions:
+ width: 240
+ height: 320
+
+lvgl:
+ on_draw_start:
+ - lambda: 'id(rendered_frames)++;'
+ displays:
+ - my_display
+ touchscreens:
+ - touchscreen_id: ts_touch
+
+ # De ESP32-WROOM-32 heeft geen PSRAM; een volledige framebuffer (320x240x2
+ # = 153600 bytes) past niet in DRAM. 25% is de gebruikelijke waarde.
+ # 12% in plaats van 25%: laat ~20KB extra aaneengesloten geheugen vrij,
+ # en LVGL schaalt de stroken van een ARGB-laag mee met de beschikbare
+ # tekenruimte, dus ook de gevraagde allocaties worden kleiner.
+ buffer_size: 12%
+
+ # Directe lokale feedback op de HELE UI, niet alleen op de tegels.
+ # Een thema zet de stijl per widget-type, dus dit dekt in een klap alle
+ # knoppen, tabs, sliders en de thermostaatring. Niet-klikbare widgets
+ # komen nooit in de pressed-state, dus die blijven onaangeroerd.
+ theme:
+ obj:
+ pressed:
+ # bg_opa en NIET opa: `opa` op widget-niveau dwingt LVGL het element
+ # naar een aparte laag te renderen, en die laag kost tientallen KB's
+ # per ingedrukte widget. Dat liep hier het geheugen uit.
+ bg_opa: 45%
+ slider:
+ knob:
+ pressed:
+ bg_opa: 65%
+ arc:
+ knob:
+ pressed:
+ bg_opa: 65%
+
+ # LVGL owns the rotation (display + touch) since ESPHome 2026.4.
+ rotation: ${LVGL_ROTATION}
+
+ style_definitions:
+ # Achtergrond: twee-stops verticale gradient in plaats van de 320x240
+ # RGB565-wallpaper. Scheelt ~150 KB flash en een image-blit per hertekening,
+ # wat je direct terugziet in de vloeiendheid van het scrollen.
+ - id: style_bg_grad
+ bg_color: ${BG_TOP_COLOR}
+ bg_grad_color: ${BG_BOTTOM_COLOR}
+ bg_grad_dir: VER
+ bg_opa: COVER
+ border_width: 0
+ radius: 0
+
+ - id: style_tile
+ bg_color: 0xFFFFFF
+ bg_opa: COVER
+ radius: 18
+ border_width: 0
+ pad_left: 8
+ pad_right: 8
+ pad_top: 8
+ pad_bottom: 8
+ shadow_width: 0
+ shadow_opa: 40%
+
+ - id: style_tile_disabled
+ bg_color: 0x939391
+ bg_opa: COVER
+ radius: 18
+ border_width: 0
+ pad_left: 8
+ pad_right: 8
+ pad_top: 8
+ pad_bottom: 8
+ shadow_width: 0
+ shadow_opa: 25%
+
+ - id: style_icon_circle
+ bg_color: 0xFEC600
+ bg_opa: COVER
+ radius: 999
+ border_width: 0
+
+ - id: style_icon_circle_disabled
+ bg_color: 0x7B7B6F
+ bg_opa: COVER
+ radius: 999
+ border_width: 0
+
+ - id: style_room
+ text_font: headline
+ text_color: 0xFFFFFF
+ text_opa: 100%
+
+ - id: style_time
+ text_font: time_label
+ text_color: 0xFFFFFF
+ text_opa: 100%
+
+ - id: style_title
+ text_font: label
+ text_color: 0x000000
+ text_opa: 100%
+
+ - id: style_value
+ text_font: sublabel
+ text_color: 0x7A7A7C
+ text_opa: 100%
+
+ - id: style_value_big
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ text_opa: 80%
+
+ - id: style_title_disabled
+ text_font: label
+ text_color: 0xFFFFFF
+ text_opa: 100%
+
+ - id: style_value_disabled
+ text_font: sublabel
+ text_color: 0xD9D9D9
+ text_opa: 100%
+
+ pages:
+ - id: home_page
+ styles: style_bg_grad
+ pad_all: 0
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: lbl_room
+ x: 11
+ y: ${HEADER_Y}
+ text: "${ROOM_NAME}"
+ styles: style_room
+
+ - label:
+ id: lbl_time
+ align: TOP_RIGHT
+ x: -11
+ y: ${HEADER_Y}
+ text: ""
+ styles: style_time
+
+ # Six tiles on page 1, four on page 2. No dragging on resistive glass.
+ - obj:
+ id: tile_scroll
+ x: 0
+ y: ${SCROLL_Y}
+ width: ${DISPLAY_W}
+ height: ${SCROLL_H}
+ bg_opa: TRANSP
+ border_width: 0
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ # ---------- TILE 1 ----------
+ - obj:
+ id: tile1
+ x: ${GRID_COL1_X}
+ y: ${GRID_ROW1_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ # Directe lokale feedback: LVGL past dit toe zodra de vinger het
+ # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant.
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile1_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile1_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE1_ICON}"
+ clickable: false
+ - label:
+ id: t1_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE1_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t1_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 1);'
+ then:
+ - script.execute: { id: publish_action, action: "tile1_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE1_TYPE}"
+ tap_action: "${TILE1_TAP_ACTION}"
+ service: "${TILE1_TAP_SERVICE}"
+ entity_id: "${TILE1_ENTITY}"
+ param_key: "${TILE1_TAP_PARAM_KEY}"
+ param_val: "${TILE1_TAP_PARAM_VAL}"
+ was_on: !lambda |-
+ if (std::string("${TILE1_TYPE}") == "cover")
+ return id(tile1_cover_state).state != "closed";
+ return std::string(id(ha_state_tile1).state.c_str()) == "on";
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 1);'
+ then:
+ - script.execute: { id: publish_action, action: "tile1_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE1_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE1_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE1_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE1_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+ else:
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"slider\";"
+ then:
+ - script.execute:
+ id: open_value_overlay
+ title: "${TILE1_TITLE}"
+ entity: "${TILE1_ENTITY}"
+ tile_type: "${TILE1_TYPE}"
+ longpress_mode: "${TILE1_LONGPRESS_SLIDER}"
+ was_on: !lambda |-
+ if (std::string("${TILE1_TYPE}") == "cover")
+ return id(tile1_cover_state).state != "closed";
+ return std::string(id(ha_state_tile1).state.c_str()) == "on";
+ off_value: !lambda "return atoi(\"${TILE1_LONGPRESS_OFF_VALUE}\");"
+ brightness_value: !lambda "return id(tile1_brightness).state;"
+ percentage_value: !lambda "return id(tile1_percentage).state;"
+ position_value: !lambda "return id(tile1_cover_position).state;"
+ icon_text: "${TILE1_ICON}"
+ slider_bg_color: ${TILE1_BG_DISABLED_COLOR}
+ slider_fill_color: ${TILE1_CIRCLE_ACTIVE_COLOR}
+ slider_icon_color: ${TILE1_ICON_ACTIVE_COLOR}
+ label_off: "${TILE1_LABEL_OFF}"
+ label_on: "${TILE1_LABEL_ON}"
+ supported_color_modes: !lambda "return std::string(id(tile1_supported_color_modes).state.c_str());"
+ climate_current_temperature: !lambda "return id(tile1_climate_current_temperature).state;"
+ climate_target_temperature: !lambda "return id(tile1_climate_target_temperature).state;"
+ climate_humidity: !lambda "return id(tile1_climate_humidity).state;"
+ climate_mode: !lambda "return std::string(id(tile1_climate_mode).state.c_str());"
+ climate_supported_modes: !lambda "return std::string(id(tile1_climate_supported_modes).state.c_str());"
+ climate_min_temp: "${TILE1_CLIMATE_MIN_TEMP}"
+ climate_max_temp: "${TILE1_CLIMATE_MAX_TEMP}"
+ climate_step: "${TILE1_CLIMATE_STEP}"
+ # Long-press modus "vacuum": opent de stofzuigerbediening.
+ # Valt buiten de action/slider-keten, dus die blijft ongewijzigd.
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE1_LONGPRESS}\") == \"vacuum\";"
+ then:
+ - script.execute:
+ id: open_vacuum_overlay
+ title: "${TILE1_TITLE}"
+ entity: "${TILE1_ENTITY}"
+
+ # ---------- TILE 2 ----------
+ - obj:
+ id: tile2
+ x: ${GRID_COL2_X}
+ y: ${GRID_ROW1_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ # Directe lokale feedback: LVGL past dit toe zodra de vinger het
+ # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant.
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile2_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile2_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE2_ICON}"
+ clickable: false
+ - label:
+ id: t2_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE2_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t2_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 2);'
+ then:
+ - script.execute: { id: publish_action, action: "tile2_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE2_TYPE}"
+ tap_action: "${TILE2_TAP_ACTION}"
+ service: "${TILE2_TAP_SERVICE}"
+ entity_id: "${TILE2_ENTITY}"
+ param_key: "${TILE2_TAP_PARAM_KEY}"
+ param_val: "${TILE2_TAP_PARAM_VAL}"
+ was_on: !lambda |-
+ if (std::string("${TILE2_TYPE}") == "cover")
+ return id(tile2_cover_state).state != "closed";
+ return std::string(id(ha_state_tile2).state.c_str()) == "on";
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 2);'
+ then:
+ - script.execute: { id: publish_action, action: "tile2_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE2_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE2_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE2_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE2_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+ else:
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"slider\";"
+ then:
+ - script.execute:
+ id: open_value_overlay
+ title: "${TILE2_TITLE}"
+ entity: "${TILE2_ENTITY}"
+ tile_type: "${TILE2_TYPE}"
+ longpress_mode: "${TILE2_LONGPRESS_SLIDER}"
+ was_on: !lambda |-
+ if (std::string("${TILE2_TYPE}") == "cover")
+ return id(tile2_cover_state).state != "closed";
+ return std::string(id(ha_state_tile2).state.c_str()) == "on";
+ off_value: !lambda "return atoi(\"${TILE2_LONGPRESS_OFF_VALUE}\");"
+ brightness_value: !lambda "return id(tile2_brightness).state;"
+ percentage_value: !lambda "return id(tile2_percentage).state;"
+ position_value: !lambda "return id(tile2_cover_position).state;"
+ icon_text: "${TILE2_ICON}"
+ slider_bg_color: ${TILE2_BG_DISABLED_COLOR}
+ slider_fill_color: ${TILE2_CIRCLE_ACTIVE_COLOR}
+ slider_icon_color: ${TILE2_ICON_ACTIVE_COLOR}
+ label_off: "${TILE2_LABEL_OFF}"
+ label_on: "${TILE2_LABEL_ON}"
+ supported_color_modes: !lambda "return std::string(id(tile2_supported_color_modes).state.c_str());"
+ climate_current_temperature: !lambda "return id(tile2_climate_current_temperature).state;"
+ climate_target_temperature: !lambda "return id(tile2_climate_target_temperature).state;"
+ climate_humidity: !lambda "return id(tile2_climate_humidity).state;"
+ climate_mode: !lambda "return std::string(id(tile2_climate_mode).state.c_str());"
+ climate_supported_modes: !lambda "return std::string(id(tile2_climate_supported_modes).state.c_str());"
+ climate_min_temp: "${TILE2_CLIMATE_MIN_TEMP}"
+ climate_max_temp: "${TILE2_CLIMATE_MAX_TEMP}"
+ climate_step: "${TILE2_CLIMATE_STEP}"
+ # Long-press modus "vacuum": opent de stofzuigerbediening.
+ # Valt buiten de action/slider-keten, dus die blijft ongewijzigd.
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE2_LONGPRESS}\") == \"vacuum\";"
+ then:
+ - script.execute:
+ id: open_vacuum_overlay
+ title: "${TILE2_TITLE}"
+ entity: "${TILE2_ENTITY}"
+
+ # ---------- TILE 3 ----------
+ - obj:
+ id: tile3
+ x: ${GRID_COL1_X}
+ y: ${GRID_ROW2_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ # Directe lokale feedback: LVGL past dit toe zodra de vinger het
+ # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant.
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile3_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile3_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE3_ICON}"
+ clickable: false
+ - label:
+ id: t3_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE3_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t3_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 3);'
+ then:
+ - script.execute: { id: publish_action, action: "tile3_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE3_TYPE}"
+ tap_action: "${TILE3_TAP_ACTION}"
+ service: "${TILE3_TAP_SERVICE}"
+ entity_id: "${TILE3_ENTITY}"
+ param_key: "${TILE3_TAP_PARAM_KEY}"
+ param_val: "${TILE3_TAP_PARAM_VAL}"
+ was_on: !lambda |-
+ if (std::string("${TILE3_TYPE}") == "cover")
+ return id(tile3_cover_state).state != "closed";
+ return std::string(id(ha_state_tile3).state.c_str()) == "on";
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 3);'
+ then:
+ - script.execute: { id: publish_action, action: "tile3_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE3_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE3_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE3_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE3_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+ else:
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"slider\";"
+ then:
+ - script.execute:
+ id: open_value_overlay
+ title: "${TILE3_TITLE}"
+ entity: "${TILE3_ENTITY}"
+ tile_type: "${TILE3_TYPE}"
+ longpress_mode: "${TILE3_LONGPRESS_SLIDER}"
+ was_on: !lambda |-
+ if (std::string("${TILE3_TYPE}") == "cover")
+ return id(tile3_cover_state).state != "closed";
+ return std::string(id(ha_state_tile3).state.c_str()) == "on";
+ off_value: !lambda "return atoi(\"${TILE3_LONGPRESS_OFF_VALUE}\");"
+ brightness_value: !lambda "return id(tile3_brightness).state;"
+ percentage_value: !lambda "return id(tile3_percentage).state;"
+ position_value: !lambda "return id(tile3_cover_position).state;"
+ icon_text: "${TILE3_ICON}"
+ slider_bg_color: ${TILE3_BG_DISABLED_COLOR}
+ slider_fill_color: ${TILE3_CIRCLE_ACTIVE_COLOR}
+ slider_icon_color: ${TILE3_ICON_ACTIVE_COLOR}
+ label_off: "${TILE3_LABEL_OFF}"
+ label_on: "${TILE3_LABEL_ON}"
+ supported_color_modes: !lambda "return std::string(id(tile3_supported_color_modes).state.c_str());"
+ climate_current_temperature: !lambda "return id(tile3_climate_current_temperature).state;"
+ climate_target_temperature: !lambda "return id(tile3_climate_target_temperature).state;"
+ climate_humidity: !lambda "return id(tile3_climate_humidity).state;"
+ climate_mode: !lambda "return std::string(id(tile3_climate_mode).state.c_str());"
+ climate_supported_modes: !lambda "return std::string(id(tile3_climate_supported_modes).state.c_str());"
+ climate_min_temp: "${TILE3_CLIMATE_MIN_TEMP}"
+ climate_max_temp: "${TILE3_CLIMATE_MAX_TEMP}"
+ climate_step: "${TILE3_CLIMATE_STEP}"
+ # Long-press modus "vacuum": opent de stofzuigerbediening.
+ # Valt buiten de action/slider-keten, dus die blijft ongewijzigd.
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE3_LONGPRESS}\") == \"vacuum\";"
+ then:
+ - script.execute:
+ id: open_vacuum_overlay
+ title: "${TILE3_TITLE}"
+ entity: "${TILE3_ENTITY}"
+
+ # ---------- TILE 4 ----------
+ - obj:
+ id: tile4
+ x: ${GRID_COL2_X}
+ y: ${GRID_ROW2_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ # Directe lokale feedback: LVGL past dit toe zodra de vinger het
+ # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant.
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile4_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile4_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE4_ICON}"
+ clickable: false
+ - label:
+ id: t4_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE4_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t4_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 4);'
+ then:
+ - script.execute: { id: publish_action, action: "tile4_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE4_TYPE}"
+ tap_action: "${TILE4_TAP_ACTION}"
+ service: "${TILE4_TAP_SERVICE}"
+ entity_id: "${TILE4_ENTITY}"
+ param_key: "${TILE4_TAP_PARAM_KEY}"
+ param_val: "${TILE4_TAP_PARAM_VAL}"
+ was_on: !lambda |-
+ if (std::string("${TILE4_TYPE}") == "cover")
+ return id(tile4_cover_state).state != "closed";
+ return std::string(id(ha_state_tile4).state.c_str()) == "on";
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 4);'
+ then:
+ - script.execute: { id: publish_action, action: "tile4_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE4_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE4_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE4_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE4_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+ else:
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"slider\";"
+ then:
+ - script.execute:
+ id: open_value_overlay
+ title: "${TILE4_TITLE}"
+ entity: "${TILE4_ENTITY}"
+ tile_type: "${TILE4_TYPE}"
+ longpress_mode: "${TILE4_LONGPRESS_SLIDER}"
+ was_on: !lambda |-
+ if (std::string("${TILE4_TYPE}") == "cover")
+ return id(tile4_cover_state).state != "closed";
+ return std::string(id(ha_state_tile4).state.c_str()) == "on";
+ off_value: !lambda "return atoi(\"${TILE4_LONGPRESS_OFF_VALUE}\");"
+ brightness_value: !lambda "return id(tile4_brightness).state;"
+ percentage_value: !lambda "return id(tile4_percentage).state;"
+ position_value: !lambda "return id(tile4_cover_position).state;"
+ icon_text: "${TILE4_ICON}"
+ slider_bg_color: ${TILE4_BG_DISABLED_COLOR}
+ slider_fill_color: ${TILE4_CIRCLE_ACTIVE_COLOR}
+ slider_icon_color: ${TILE4_ICON_ACTIVE_COLOR}
+ label_off: "${TILE4_LABEL_OFF}"
+ label_on: "${TILE4_LABEL_ON}"
+ supported_color_modes: !lambda "return std::string(id(tile4_supported_color_modes).state.c_str());"
+ climate_current_temperature: !lambda "return id(tile4_climate_current_temperature).state;"
+ climate_target_temperature: !lambda "return id(tile4_climate_target_temperature).state;"
+ climate_humidity: !lambda "return id(tile4_climate_humidity).state;"
+ climate_mode: !lambda "return std::string(id(tile4_climate_mode).state.c_str());"
+ climate_supported_modes: !lambda "return std::string(id(tile4_climate_supported_modes).state.c_str());"
+ climate_min_temp: "${TILE4_CLIMATE_MIN_TEMP}"
+ climate_max_temp: "${TILE4_CLIMATE_MAX_TEMP}"
+ climate_step: "${TILE4_CLIMATE_STEP}"
+ # Long-press modus "vacuum": opent de stofzuigerbediening.
+ # Valt buiten de action/slider-keten, dus die blijft ongewijzigd.
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE4_LONGPRESS}\") == \"vacuum\";"
+ then:
+ - script.execute:
+ id: open_vacuum_overlay
+ title: "${TILE4_TITLE}"
+ entity: "${TILE4_ENTITY}"
+
+ # ---------- TILE 5 ----------
+ - obj:
+ id: tile5
+ x: ${GRID_COL1_X}
+ y: ${GRID_ROW3_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ # Directe lokale feedback: LVGL past dit toe zodra de vinger het
+ # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant.
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile5_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile5_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE5_ICON}"
+ clickable: false
+ - label:
+ id: t5_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE5_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t5_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 5);'
+ then:
+ - script.execute: { id: publish_action, action: "tile5_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE5_TYPE}"
+ tap_action: "${TILE5_TAP_ACTION}"
+ service: "${TILE5_TAP_SERVICE}"
+ entity_id: "${TILE5_ENTITY}"
+ param_key: "${TILE5_TAP_PARAM_KEY}"
+ param_val: "${TILE5_TAP_PARAM_VAL}"
+ was_on: !lambda |-
+ if (std::string("${TILE5_TYPE}") == "cover")
+ return id(tile5_cover_state).state != "closed";
+ return std::string(id(ha_state_tile5).state.c_str()) == "on";
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 5);'
+ then:
+ - script.execute: { id: publish_action, action: "tile5_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE5_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE5_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE5_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE5_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+ else:
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"slider\";"
+ then:
+ - script.execute:
+ id: open_value_overlay
+ title: "${TILE5_TITLE}"
+ entity: "${TILE5_ENTITY}"
+ tile_type: "${TILE5_TYPE}"
+ longpress_mode: "${TILE5_LONGPRESS_SLIDER}"
+ was_on: !lambda |-
+ if (std::string("${TILE5_TYPE}") == "cover")
+ return id(tile5_cover_state).state != "closed";
+ return std::string(id(ha_state_tile5).state.c_str()) == "on";
+ off_value: !lambda "return atoi(\"${TILE5_LONGPRESS_OFF_VALUE}\");"
+ brightness_value: !lambda "return id(tile5_brightness).state;"
+ percentage_value: !lambda "return id(tile5_percentage).state;"
+ position_value: !lambda "return id(tile5_cover_position).state;"
+ icon_text: "${TILE5_ICON}"
+ slider_bg_color: ${TILE5_BG_DISABLED_COLOR}
+ slider_fill_color: ${TILE5_CIRCLE_ACTIVE_COLOR}
+ slider_icon_color: ${TILE5_ICON_ACTIVE_COLOR}
+ label_off: "${TILE5_LABEL_OFF}"
+ label_on: "${TILE5_LABEL_ON}"
+ supported_color_modes: !lambda "return std::string(id(tile5_supported_color_modes).state.c_str());"
+ climate_current_temperature: !lambda "return id(tile5_climate_current_temperature).state;"
+ climate_target_temperature: !lambda "return id(tile5_climate_target_temperature).state;"
+ climate_humidity: !lambda "return id(tile5_climate_humidity).state;"
+ climate_mode: !lambda "return std::string(id(tile5_climate_mode).state.c_str());"
+ climate_supported_modes: !lambda "return std::string(id(tile5_climate_supported_modes).state.c_str());"
+ climate_min_temp: "${TILE5_CLIMATE_MIN_TEMP}"
+ climate_max_temp: "${TILE5_CLIMATE_MAX_TEMP}"
+ climate_step: "${TILE5_CLIMATE_STEP}"
+ # Long-press modus "vacuum": opent de stofzuigerbediening.
+ # Valt buiten de action/slider-keten, dus die blijft ongewijzigd.
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE5_LONGPRESS}\") == \"vacuum\";"
+ then:
+ - script.execute:
+ id: open_vacuum_overlay
+ title: "${TILE5_TITLE}"
+ entity: "${TILE5_ENTITY}"
+
+ # ---------- TILE 6 ----------
+ - obj:
+ id: tile6
+ x: ${GRID_COL2_X}
+ y: ${GRID_ROW3_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ # Directe lokale feedback: LVGL past dit toe zodra de vinger het
+ # glas raakt, in dezelfde frame. Onafhankelijk van Home Assistant.
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile6_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile6_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE6_ICON}"
+ clickable: false
+ - label:
+ id: t6_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE6_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t6_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 6);'
+ then:
+ - script.execute: { id: publish_action, action: "tile6_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE6_TYPE}"
+ tap_action: "${TILE6_TAP_ACTION}"
+ service: "${TILE6_TAP_SERVICE}"
+ entity_id: "${TILE6_ENTITY}"
+ param_key: "${TILE6_TAP_PARAM_KEY}"
+ param_val: "${TILE6_TAP_PARAM_VAL}"
+ was_on: !lambda |-
+ if (std::string("${TILE6_TYPE}") == "cover")
+ return id(tile6_cover_state).state != "closed";
+ return std::string(id(ha_state_tile6).state.c_str()) == "on";
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 6);'
+ then:
+ - script.execute: { id: publish_action, action: "tile6_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE6_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE6_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE6_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE6_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+ else:
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"slider\";"
+ then:
+ - script.execute:
+ id: open_value_overlay
+ title: "${TILE6_TITLE}"
+ entity: "${TILE6_ENTITY}"
+ tile_type: "${TILE6_TYPE}"
+ longpress_mode: "${TILE6_LONGPRESS_SLIDER}"
+ was_on: !lambda |-
+ if (std::string("${TILE6_TYPE}") == "cover")
+ return id(tile6_cover_state).state != "closed";
+ return std::string(id(ha_state_tile6).state.c_str()) == "on";
+ off_value: !lambda "return atoi(\"${TILE6_LONGPRESS_OFF_VALUE}\");"
+ brightness_value: !lambda "return id(tile6_brightness).state;"
+ percentage_value: !lambda "return id(tile6_percentage).state;"
+ position_value: !lambda "return id(tile6_cover_position).state;"
+ icon_text: "${TILE6_ICON}"
+ slider_bg_color: ${TILE6_BG_DISABLED_COLOR}
+ slider_fill_color: ${TILE6_CIRCLE_ACTIVE_COLOR}
+ slider_icon_color: ${TILE6_ICON_ACTIVE_COLOR}
+ label_off: "${TILE6_LABEL_OFF}"
+ label_on: "${TILE6_LABEL_ON}"
+ supported_color_modes: !lambda "return std::string(id(tile6_supported_color_modes).state.c_str());"
+ climate_current_temperature: !lambda "return id(tile6_climate_current_temperature).state;"
+ climate_target_temperature: !lambda "return id(tile6_climate_target_temperature).state;"
+ climate_humidity: !lambda "return id(tile6_climate_humidity).state;"
+ climate_mode: !lambda "return std::string(id(tile6_climate_mode).state.c_str());"
+ climate_supported_modes: !lambda "return std::string(id(tile6_climate_supported_modes).state.c_str());"
+ climate_min_temp: "${TILE6_CLIMATE_MIN_TEMP}"
+ climate_max_temp: "${TILE6_CLIMATE_MAX_TEMP}"
+ climate_step: "${TILE6_CLIMATE_STEP}"
+ # Long-press modus "vacuum": opent de stofzuigerbediening.
+ # Valt buiten de action/slider-keten, dus die blijft ongewijzigd.
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE6_LONGPRESS}\") == \"vacuum\";"
+ then:
+ - script.execute:
+ id: open_vacuum_overlay
+ title: "${TILE6_TITLE}"
+ entity: "${TILE6_ENTITY}"
+
+ # ---------- TILE 7 — Tafellamp ----------
+ # Alleen-helderheid lamp: er zijn bewust geen percentage/cover/
+ # climate-sensors voor deze tegel aangemaakt, die zouden alleen
+ # maar HA-subscriptions en heap kosten. De overlay krijgt daarom
+ # NAN / "" voor de velden die een light nooit gebruikt.
+ - obj:
+ id: tile7
+ hidden: true
+ x: ${GRID_COL1_X}
+ y: ${GRID_ROW4_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile7_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile7_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE7_ICON}"
+ clickable: false
+ - label:
+ id: t7_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE7_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t7_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 7);'
+ then:
+ - script.execute: { id: publish_action, action: "tile7_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE7_TYPE}"
+ tap_action: "${TILE7_TAP_ACTION}"
+ service: "${TILE7_TAP_SERVICE}"
+ entity_id: "${TILE7_ENTITY}"
+ param_key: "${TILE7_TAP_PARAM_KEY}"
+ param_val: "${TILE7_TAP_PARAM_VAL}"
+ was_on: !lambda "return std::string(id(ha_state_tile7).state.c_str()) == \"on\";"
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 7);'
+ then:
+ - script.execute: { id: publish_action, action: "tile7_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE7_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE7_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE7_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE7_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE7_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+ else:
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE7_LONGPRESS}\") == \"slider\";"
+ then:
+ - script.execute:
+ id: open_value_overlay
+ title: "${TILE7_TITLE}"
+ entity: "${TILE7_ENTITY}"
+ tile_type: "${TILE7_TYPE}"
+ longpress_mode: "${TILE7_LONGPRESS_SLIDER}"
+ was_on: !lambda "return std::string(id(ha_state_tile7).state.c_str()) == \"on\";"
+ off_value: !lambda "return atoi(\"${TILE7_LONGPRESS_OFF_VALUE}\");"
+ brightness_value: !lambda "return id(tile7_brightness).state;"
+ percentage_value: !lambda "return NAN;"
+ position_value: !lambda "return NAN;"
+ icon_text: "${TILE7_ICON}"
+ slider_bg_color: ${TILE7_BG_DISABLED_COLOR}
+ slider_fill_color: ${TILE7_CIRCLE_ACTIVE_COLOR}
+ slider_icon_color: ${TILE7_ICON_ACTIVE_COLOR}
+ label_off: "${TILE7_LABEL_OFF}"
+ label_on: "${TILE7_LABEL_ON}"
+ supported_color_modes: !lambda "return std::string(id(tile7_supported_color_modes).state.c_str());"
+ climate_current_temperature: !lambda "return NAN;"
+ climate_target_temperature: !lambda "return NAN;"
+ climate_humidity: !lambda "return NAN;"
+ climate_mode: ""
+ climate_supported_modes: ""
+ climate_min_temp: "5.0"
+ climate_max_temp: "30.0"
+ climate_step: "0.5"
+
+ # ---------- TILE 8 — Brievenbus reset ----------
+ # input_select met opties Vol/Leeg. De tegel licht op zodra de
+ # status TILE8_ACTIVE_STATE ("Vol") is; een korte tik stuurt
+ # input_select.select_option met option: "Leeg".
+ # Geen slider-modus, dus deze tegel heeft alleen ha_state_tile8
+ # nodig — verder geen enkele HA-subscription.
+ - obj:
+ id: tile8
+ hidden: true
+ x: ${GRID_COL2_X}
+ y: ${GRID_ROW4_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile8_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile8_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE8_ICON}"
+ clickable: false
+ - label:
+ id: t8_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE8_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t8_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 8);'
+ then:
+ - script.execute: { id: publish_action, action: "tile8_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE8_TYPE}"
+ tap_action: "${TILE8_TAP_ACTION}"
+ service: "${TILE8_TAP_SERVICE}"
+ entity_id: "${TILE8_ENTITY}"
+ param_key: "${TILE8_TAP_PARAM_KEY}"
+ param_val: "${TILE8_TAP_PARAM_VAL}"
+ was_on: !lambda "return std::string(id(ha_state_tile8).state.c_str()) == \"${TILE8_ACTIVE_STATE}\";"
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 8);'
+ then:
+ - script.execute: { id: publish_action, action: "tile8_long_press" }
+ # Slider-modus kan hier niet zonder eerst de bijbehorende
+ # sensors aan te maken; "action" werkt wel out of the box.
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE8_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE8_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE8_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE8_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE8_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+
+ # ---------- TILE 9 — Woonkamer (verhuisd van tegel 2) ----------
+ - obj:
+ id: tile9
+ hidden: true
+ x: ${GRID_COL1_X}
+ y: ${GRID_ROW5_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile9_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile9_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE9_ICON}"
+ clickable: false
+ - label:
+ id: t9_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE9_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t9_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 9);'
+ then:
+ - script.execute: { id: publish_action, action: "tile9_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE9_TYPE}"
+ tap_action: "${TILE9_TAP_ACTION}"
+ service: "${TILE9_TAP_SERVICE}"
+ entity_id: "${TILE9_ENTITY}"
+ param_key: "${TILE9_TAP_PARAM_KEY}"
+ param_val: "${TILE9_TAP_PARAM_VAL}"
+ was_on: !lambda "return std::string(id(ha_state_tile9).state.c_str()) == \"on\";"
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 9);'
+ then:
+ - script.execute: { id: publish_action, action: "tile9_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE9_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE9_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE9_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE9_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE9_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+ else:
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE9_LONGPRESS}\") == \"slider\";"
+ then:
+ - script.execute:
+ id: open_value_overlay
+ title: "${TILE9_TITLE}"
+ entity: "${TILE9_ENTITY}"
+ tile_type: "${TILE9_TYPE}"
+ longpress_mode: "${TILE9_LONGPRESS_SLIDER}"
+ was_on: !lambda "return std::string(id(ha_state_tile9).state.c_str()) == \"on\";"
+ off_value: !lambda "return atoi(\"${TILE9_LONGPRESS_OFF_VALUE}\");"
+ brightness_value: !lambda "return id(tile9_brightness).state;"
+ percentage_value: !lambda "return NAN;"
+ position_value: !lambda "return NAN;"
+ icon_text: "${TILE9_ICON}"
+ slider_bg_color: ${TILE9_BG_DISABLED_COLOR}
+ slider_fill_color: ${TILE9_CIRCLE_ACTIVE_COLOR}
+ slider_icon_color: ${TILE9_ICON_ACTIVE_COLOR}
+ label_off: "${TILE9_LABEL_OFF}"
+ label_on: "${TILE9_LABEL_ON}"
+ supported_color_modes: !lambda "return std::string(id(tile9_supported_color_modes).state.c_str());"
+ climate_current_temperature: !lambda "return NAN;"
+ climate_target_temperature: !lambda "return NAN;"
+ climate_humidity: !lambda "return NAN;"
+ climate_mode: ""
+ climate_supported_modes: ""
+ climate_min_temp: "5.0"
+ climate_max_temp: "30.0"
+ climate_step: "0.5"
+
+ # ---------- TILE 10 — Alarm aan/uit ----------
+ - obj:
+ id: tile10
+ hidden: true
+ x: ${GRID_COL2_X}
+ y: ${GRID_ROW5_Y}
+ width: ${TILE_W}
+ height: ${TILE_H}
+ styles: style_tile
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pressed:
+ bg_opa: 45%
+ widgets:
+ - obj:
+ id: tile10_icon_circle
+ x: ${TILE_ICON_X}
+ y: ${TILE_ICON_Y}
+ width: 36
+ height: 36
+ styles: style_icon_circle
+ clickable: false
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: tile10_icon_lbl
+ align: CENTER
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE10_ICON}"
+ clickable: false
+ - label:
+ id: t10_title
+ x: ${TILE_TITLE_X}
+ y: ${TILE_TITLE_Y}
+ text: "${TILE10_TITLE}"
+ styles: style_title
+ clickable: false
+ - label:
+ id: t10_value
+ x: ${TILE_VALUE_X}
+ y: ${TILE_VALUE_Y}
+ text: "—"
+ styles: style_value
+ clickable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 10);'
+ then:
+ - script.execute: { id: publish_action, action: "tile10_press" }
+ - script.execute:
+ id: do_tile_action
+ tile_type: "${TILE10_TYPE}"
+ tap_action: "${TILE10_TAP_ACTION}"
+ service: "${TILE10_TAP_SERVICE}"
+ entity_id: "${TILE10_ENTITY}"
+ param_key: "${TILE10_TAP_PARAM_KEY}"
+ param_val: "${TILE10_TAP_PARAM_VAL}"
+ was_on: !lambda "return std::string(id(ha_state_tile10).state.c_str()) == \"on\";"
+ on_long_press:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 10);'
+ then:
+ - script.execute: { id: publish_action, action: "tile10_long_press" }
+ - if:
+ condition:
+ lambda: "return std::string(\"${TILE10_LONGPRESS}\") == \"action\";"
+ then:
+ - script.execute:
+ id: do_tile_action
+ tile_type: !lambda |-
+ std::string t = "${TILE10_LONGPRESS_ACTION_TYPE}";
+ return t.empty() ? std::string("${TILE10_TYPE}") : t;
+ tap_action: "activate"
+ service: "${TILE10_LONGPRESS_ACTION_SERVICE}"
+ entity_id: "${TILE10_LONGPRESS_ACTION}"
+ param_key: ""
+ param_val: ""
+ was_on: false
+
+ # ---------- VALUE SLIDER OVERLAY ----------
+ - obj:
+ id: page_prev
+ align: BOTTOM_LEFT
+ x: 9
+ y: -2
+ width: 100
+ height: 32
+ pad_all: 0
+ radius: 8
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 0
+ scrollable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 11);'
+ then:
+ - lambda: 'id(tile_page) = 0;'
+ - script.execute: show_tile_page
+ widgets:
+ - label:
+ align: CENTER
+ text: "< Vorige"
+ text_font: sublabel
+ text_color: 0xFFFFFF
+ clickable: false
+ - label:
+ id: page_number
+ align: BOTTOM_MID
+ y: -9
+ text: "1 / 2"
+ text_font: sublabel
+ text_color: 0xFFFFFF
+ - obj:
+ id: page_next
+ align: BOTTOM_RIGHT
+ x: -9
+ y: -2
+ width: 100
+ height: 32
+ pad_all: 0
+ radius: 8
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 0
+ scrollable: false
+ on_short_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 12);'
+ then:
+ - lambda: 'id(tile_page) = 1;'
+ - script.execute: show_tile_page
+ widgets:
+ - label:
+ align: CENTER
+ text: "Volgende >"
+ text_font: sublabel
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: brightness_overlay
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ bg_opa: TRANSP
+ hidden: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ border_width: 0
+ widgets:
+ - obj:
+ id: brightness_overlay_bg
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ styles: style_bg_grad
+ scrollable: false
+ clickable: true
+ on_click:
+ - lvgl.widget.hide: brightness_overlay
+
+ - obj:
+ id: brightness_overlay_dim
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ bg_color: 0x000000
+ bg_opa: 18%
+ border_width: 0
+ clickable: false
+ scrollable: false
+
+ - label:
+ id: overlay_title
+ align: TOP_MID
+ y: 10
+ text: "Light"
+ styles: style_room
+
+ - label:
+ id: overlay_value
+ align: TOP_MID
+ y: 35
+ text: "100 %"
+ styles: style_value_big
+
+ - slider:
+ id: overlay_slider
+ align: CENTER
+ y: 18
+ width: ${OVERLAY_SLIDER_W}
+ height: ${OVERLAY_SLIDER_H}
+ min_value: 0
+ max_value: 100
+ value: 100
+ adv_hittest: false
+
+ bg_color: 0x939391
+ bg_opa: 100%
+ radius: 28
+ # Enige widget met ZOWEL radius ALS een kind (overlay_icon).
+ # LVGL snijdt kinderen dan bij op de ronding, en dat kan
+ # alleen via een ARGB-laag van 96x160 = 46KB aaneengesloten.
+ # Lukt die allocatie niet, dan blijft LVGL het proberen tot
+ # de task watchdog het bord herstart. Ronding blijft, het
+ # bijsnijden gaat eraf.
+ clip_corner: false
+ border_width: 0
+
+ indicator:
+ bg_color: 0xFEC600
+ bg_opa: 100%
+ radius: 28
+
+ knob:
+ bg_opa: 0%
+ border_width: 0
+ shadow_width: 0
+ pad_all: 10
+
+ on_value:
+ - script.execute:
+ id: slider_preview
+ value: !lambda "return x;"
+
+ on_release:
+ - script.execute:
+ id: slider_commit
+ value: !lambda "return lv_slider_get_value(id(overlay_slider));"
+
+
+ # Het icoon zat als KIND in de slider. Een widget met een radius
+ # en kinderen moet die kinderen bijsnijden op de ronding, en dat
+ # kan LVGL alleen via een ARGB-laag van tientallen KB's. Zonder
+ # PSRAM mislukt dat soms, waarna LVGL blijft hertesten tot de
+ # watchdog het bord herstart. Als broer eroverheen is er geen
+ # laag nodig en ziet het er identiek uit.
+ - label:
+ id: overlay_icon
+ align: CENTER
+ y: ${OVERLAY_ICON_Y}
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ text: "${TILE1_ICON}"
+ clickable: false
+
+ - obj:
+ id: overlay_color_button
+ align: BOTTOM_RIGHT
+ x: -13
+ y: -13
+ width: 44
+ height: 44
+ radius: 999
+ hidden: true
+ clickable: true
+ bg_opa: TRANSP
+ border_width: 0
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ on_click:
+ - script.execute: open_color_detail
+ widgets:
+ - arc:
+ align: CENTER
+ width: 40
+ height: 40
+ start_angle: 0
+ end_angle: 45
+ min_value: 0
+ max_value: 100
+ value: 0
+ adjustable: false
+ arc_width: 10
+ arc_rounded: false
+ arc_color: 0xFF3B30
+ arc_opa: COVER
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: false
+ indicator:
+ arc_opa: TRANSP
+ knob:
+ bg_opa: TRANSP
+ border_width: 0
+ - arc:
+ align: CENTER
+ width: 40
+ height: 40
+ start_angle: 45
+ end_angle: 90
+ min_value: 0
+ max_value: 100
+ value: 0
+ adjustable: false
+ arc_width: 10
+ arc_rounded: false
+ arc_color: 0xFF9500
+ arc_opa: COVER
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: false
+ indicator:
+ arc_opa: TRANSP
+ knob:
+ bg_opa: TRANSP
+ border_width: 0
+ - arc:
+ align: CENTER
+ width: 40
+ height: 40
+ start_angle: 90
+ end_angle: 135
+ min_value: 0
+ max_value: 100
+ value: 0
+ adjustable: false
+ arc_width: 10
+ arc_rounded: false
+ arc_color: 0xFFD60A
+ arc_opa: COVER
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: false
+ indicator:
+ arc_opa: TRANSP
+ knob:
+ bg_opa: TRANSP
+ border_width: 0
+ - arc:
+ align: CENTER
+ width: 40
+ height: 40
+ start_angle: 135
+ end_angle: 180
+ min_value: 0
+ max_value: 100
+ value: 0
+ adjustable: false
+ arc_width: 10
+ arc_rounded: false
+ arc_color: 0x30D158
+ arc_opa: COVER
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: false
+ indicator:
+ arc_opa: TRANSP
+ knob:
+ bg_opa: TRANSP
+ border_width: 0
+ - arc:
+ align: CENTER
+ width: 40
+ height: 40
+ start_angle: 180
+ end_angle: 225
+ min_value: 0
+ max_value: 100
+ value: 0
+ adjustable: false
+ arc_width: 10
+ arc_rounded: false
+ arc_color: 0x32D6FF
+ arc_opa: COVER
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: false
+ indicator:
+ arc_opa: TRANSP
+ knob:
+ bg_opa: TRANSP
+ border_width: 0
+ - arc:
+ align: CENTER
+ width: 40
+ height: 40
+ start_angle: 225
+ end_angle: 270
+ min_value: 0
+ max_value: 100
+ value: 0
+ adjustable: false
+ arc_width: 10
+ arc_rounded: false
+ arc_color: 0x0A84FF
+ arc_opa: COVER
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: false
+ indicator:
+ arc_opa: TRANSP
+ knob:
+ bg_opa: TRANSP
+ border_width: 0
+ - arc:
+ align: CENTER
+ width: 40
+ height: 40
+ start_angle: 270
+ end_angle: 315
+ min_value: 0
+ max_value: 100
+ value: 0
+ adjustable: false
+ arc_width: 10
+ arc_rounded: false
+ arc_color: 0x5856D6
+ arc_opa: COVER
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: false
+ indicator:
+ arc_opa: TRANSP
+ knob:
+ bg_opa: TRANSP
+ border_width: 0
+ - arc:
+ align: CENTER
+ width: 40
+ height: 40
+ start_angle: 315
+ end_angle: 360
+ min_value: 0
+ max_value: 100
+ value: 0
+ adjustable: false
+ arc_width: 10
+ arc_rounded: false
+ arc_color: 0xBF5AF2
+ arc_opa: COVER
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: false
+ indicator:
+ arc_opa: TRANSP
+ knob:
+ bg_opa: TRANSP
+ border_width: 0
+
+ # ---------- LIGHT COLOR DETAIL OVERLAY ----------
+ - obj:
+ id: color_detail_overlay
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ bg_color: 0x171719
+ bg_opa: COVER
+ hidden: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ border_width: 0
+ widgets:
+ - obj:
+ id: color_detail_bg
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ styles: style_bg_grad
+ scrollable: false
+ clickable: false
+ - obj:
+ id: color_detail_scrim
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ bg_color: 0x000000
+ bg_opa: 72%
+ border_width: 0
+ clickable: false
+ scrollable: false
+
+ - obj:
+ id: color_close_button
+ align: TOP_LEFT
+ x: 10
+ y: 9
+ width: 36
+ height: 36
+ radius: 999
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 1
+ border_color: 0xFFFFFF
+ border_opa: 20%
+ clickable: true
+ scrollable: false
+ on_click:
+ - lvgl.widget.hide: color_detail_overlay
+ - lvgl.widget.show: brightness_overlay
+ widgets:
+ - label:
+ align: CENTER
+ text: "x"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - label:
+ id: color_detail_title
+ align: TOP_MID
+ y: 16
+ text: "Light Color"
+ styles: style_room
+
+ - obj:
+ id: color_done_button
+ align: TOP_RIGHT
+ x: -10
+ y: 9
+ width: 36
+ height: 36
+ radius: 999
+ bg_color: 0xFF9F0A
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ scrollable: false
+ on_click:
+ - lvgl.widget.hide: color_detail_overlay
+ widgets:
+ - label:
+ align: CENTER
+ text: "\U000F012C"
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: color_mode_tabs
+ align: TOP_MID
+ y: 58
+ width: 190
+ height: 28
+ radius: 14
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 0
+ scrollable: false
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ widgets:
+ - obj:
+ id: color_tab_indicator
+ x: 2
+ y: 2
+ width: 93
+ height: 24
+ radius: 12
+ bg_color: 0x8E8E93
+ bg_opa: COVER
+ border_width: 0
+ clickable: false
+ scrollable: false
+ - obj:
+ x: 0
+ y: 0
+ width: 95
+ height: 28
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ on_click:
+ - script.execute:
+ id: show_color_detail_tab
+ tab: 0
+ widgets:
+ - label:
+ id: color_tab_label
+ align: CENTER
+ text: "Color"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ x: 95
+ y: 0
+ width: 95
+ height: 28
+ bg_opa: TRANSP
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ on_click:
+ - script.execute:
+ id: show_color_detail_tab
+ tab: 1
+ widgets:
+ - label:
+ id: temp_tab_label
+ align: CENTER
+ text: "Temp."
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: color_palette_card
+ align: TOP_MID
+ y: 98
+ width: 286
+ height: 48
+ bg_opa: TRANSP
+ border_width: 0
+ scrollable: false
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ widgets:
+ - obj:
+ id: color_preset_red
+ x: 14
+ y: 8
+ width: 30
+ height: 30
+ radius: 999
+ bg_color: 0xFF3B30
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ on_click:
+ - script.execute: { id: set_light_color_name, color: "red" }
+ - obj:
+ id: color_preset_orange
+ x: 61
+ y: 8
+ width: 30
+ height: 30
+ radius: 999
+ bg_color: 0xFF9F0A
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ on_click:
+ - script.execute: { id: set_light_color_name, color: "orange" }
+ - obj:
+ id: color_preset_yellow
+ x: 108
+ y: 8
+ width: 30
+ height: 30
+ radius: 999
+ bg_color: 0xFFD60A
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ on_click:
+ - script.execute: { id: set_light_color_name, color: "yellow" }
+ - obj:
+ id: color_preset_green
+ x: 155
+ y: 8
+ width: 30
+ height: 30
+ radius: 999
+ bg_color: 0x30D158
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ on_click:
+ - script.execute: { id: set_light_color_name, color: "green" }
+ - obj:
+ id: color_preset_blue
+ x: 202
+ y: 8
+ width: 30
+ height: 30
+ radius: 999
+ bg_color: 0x0A84FF
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ on_click:
+ - script.execute: { id: set_light_color_name, color: "blue" }
+ - obj:
+ id: color_preset_purple
+ x: 249
+ y: 8
+ width: 30
+ height: 30
+ radius: 999
+ bg_color: 0xBF5AF2
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ on_click:
+ - script.execute: { id: set_light_color_name, color: "purple" }
+
+ - obj:
+ id: temp_palette_card
+ align: TOP_MID
+ y: 98
+ width: 286
+ height: 48
+ bg_opa: TRANSP
+ border_width: 0
+ scrollable: false
+ hidden: true
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ widgets:
+ - obj:
+ id: temp_preset_2700
+ x: 24
+ y: 8
+ width: 62
+ height: 30
+ radius: 15
+ bg_color: 0xFFD4A3
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ on_click:
+ - script.execute: { id: set_light_color_temperature, kelvin: 2700 }
+ widgets:
+ - label:
+ align: CENTER
+ text: "2700K"
+ text_font: sublabel_big
+ text_color: 0x3A2A18
+ clickable: false
+ - obj:
+ id: temp_preset_4000
+ x: 112
+ y: 8
+ width: 62
+ height: 30
+ radius: 15
+ bg_color: 0xFFE8C8
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ on_click:
+ - script.execute: { id: set_light_color_temperature, kelvin: 4000 }
+ widgets:
+ - label:
+ align: CENTER
+ text: "4000K"
+ text_font: sublabel_big
+ text_color: 0x3A2A18
+ clickable: false
+ - obj:
+ id: temp_preset_6500
+ x: 200
+ y: 8
+ width: 62
+ height: 30
+ radius: 15
+ bg_color: 0xEAF4FF
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ on_click:
+ - script.execute: { id: set_light_color_temperature, kelvin: 6500 }
+ widgets:
+ - label:
+ align: CENTER
+ text: "6500K"
+ text_font: sublabel_big
+ text_color: 0x1E2A36
+ clickable: false
+
+ - obj:
+ id: color_brightness_card
+ align: TOP_MID
+ y: 165
+ width: 188
+ height: 46
+ bg_opa: TRANSP
+ border_width: 0
+ scrollable: false
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ widgets:
+ - label:
+ x: 13
+ y: 6
+ text: "Brightness"
+ text_font: sublabel
+ text_color: 0xD1D1D6
+ hidden: true
+ clickable: false
+ - slider:
+ id: color_brightness_slider
+ x: 0
+ y: 0
+ width: 188
+ height: 46
+ min_value: 1
+ max_value: 100
+ value: 100
+ bg_color: 0x939391
+ bg_opa: COVER
+ radius: 23
+ border_width: 0
+ indicator:
+ bg_color: 0xFEC600
+ bg_opa: COVER
+ radius: 23
+ knob:
+ bg_opa: 0%
+ border_width: 0
+ shadow_width: 0
+ pad_all: 10
+ on_value:
+ - script.execute:
+ id: color_brightness_preview
+ value: !lambda "return x;"
+ on_release:
+ - script.execute:
+ id: color_brightness_commit
+ value: !lambda "return lv_slider_get_value(id(color_brightness_slider));"
+ - label:
+ id: color_brightness_value
+ align: RIGHT_MID
+ x: -13
+ text: "100 %"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ hidden: true
+ clickable: false
+
+ # ---------- CLIMATE DETAIL OVERLAY ----------
+ - obj:
+ id: climate_detail_overlay
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ bg_color: 0x171719
+ bg_opa: COVER
+ hidden: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pad_left: 0
+ pad_right: 0
+ pad_top: 0
+ pad_bottom: 0
+ border_width: 0
+ widgets:
+ - obj:
+ id: climate_detail_bg
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ styles: style_bg_grad
+ scrollable: false
+ clickable: false
+ - obj:
+ id: climate_detail_scrim
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ bg_color: 0x000000
+ bg_opa: 62%
+ border_width: 0
+ clickable: false
+ scrollable: false
+
+ - obj:
+ id: climate_close_button
+ align: TOP_LEFT
+ x: 10
+ y: 9
+ # 44 i.p.v. 36: iets groter doelvlak, groeit weg van de arc
+ # (top-left anchor blijft staan) i.p.v. ernaartoe.
+ width: 44
+ height: 44
+ radius: 999
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 1
+ border_color: 0xFFFFFF
+ border_opa: 20%
+ clickable: true
+ scrollable: false
+ on_click:
+ - lvgl.widget.hide: climate_detail_overlay
+ widgets:
+ - label:
+ align: CENTER
+ text: "x"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: climate_done_button
+ align: TOP_RIGHT
+ x: -10
+ y: 9
+ width: 44
+ height: 44
+ radius: 999
+ bg_color: 0xFEC600
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ scrollable: false
+ on_click:
+ - lvgl.widget.hide: climate_detail_overlay
+ widgets:
+ - label:
+ align: CENTER
+ text: "\U000F012C"
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - label:
+ id: climate_detail_title
+ align: TOP_MID
+ y: 16
+ text: "Climate"
+ styles: style_room
+
+ - label:
+ id: climate_current_label
+ align: TOP_MID
+ y: 42
+ text: "Current: --"
+ text_font: sublabel_big
+ text_color: 0xD1E8FF
+ text_opa: 85%
+ clickable: false
+
+ - arc:
+ id: climate_arc
+ align: CENTER
+ y: 20
+ width: 184
+ height: 184
+ # Zonder dit claimt de arc een onzichtbare rand extra
+ # klikgebied buiten de 184x184 box — vlakbij de kruisje-
+ # knop linksboven ontstaat daardoor overlap.
+ ext_click_area: 0
+ start_angle: 135
+ end_angle: 45
+ min_value: 50
+ max_value: 300
+ value: 200
+ adjustable: true
+ arc_width: 20
+ arc_rounded: true
+ arc_color: 0x4B4B4F
+ arc_opa: 80%
+ bg_opa: TRANSP
+ border_width: 0
+ indicator:
+ arc_color: 0xFEC600
+ arc_opa: COVER
+ arc_width: 20
+ arc_rounded: true
+ knob:
+ bg_color: 0xFFFFFF
+ bg_opa: COVER
+ border_width: 0
+ shadow_width: 0
+ on_value:
+ - script.execute:
+ id: climate_target_preview
+ value: !lambda "return x;"
+ on_release:
+ - script.execute:
+ id: climate_target_commit
+ value: !lambda "return lv_arc_get_value(id(climate_arc));"
+
+ - label:
+ id: climate_set_label
+ align: CENTER
+ y: -16
+ text: "SET TO"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ text_opa: 68%
+ clickable: false
+
+ - label:
+ id: climate_target_label
+ align: CENTER
+ y: 20
+ text: "20.0°"
+ text_font: headline
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: climate_minus_button
+ align: BOTTOM_LEFT
+ x: 38
+ y: -16
+ width: 48
+ height: 36
+ radius: 18
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 1
+ border_color: 0xFFFFFF
+ border_opa: 16%
+ clickable: true
+ scrollable: false
+ on_click:
+ - script.execute:
+ id: climate_adjust_target
+ direction: -1
+ widgets:
+ - label:
+ align: CENTER
+ text: "-"
+ text_font: headline
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: climate_plus_button
+ align: BOTTOM_RIGHT
+ x: -38
+ y: -16
+ width: 48
+ height: 36
+ radius: 18
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 1
+ border_color: 0xFFFFFF
+ border_opa: 16%
+ clickable: true
+ scrollable: false
+ on_click:
+ - script.execute:
+ id: climate_adjust_target
+ direction: 1
+ widgets:
+ - label:
+ align: CENTER
+ text: "+"
+ text_font: headline
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: climate_mode_pill
+ align: BOTTOM_MID
+ y: -18
+ width: 126
+ height: 34
+ radius: 17
+ bg_color: 0x2C2C30
+ bg_opa: 82%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ on_click:
+ - script.execute: open_climate_mode_picker
+ widgets:
+ - label:
+ id: climate_mode_label
+ align: CENTER
+ text: "Auto"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ # ---------- CLIMATE MODE PICKER ----------
+ # Vervangt het doorcyclen van modi. Cyclen stuurt bij elke tik een
+ # set_hvac_mode, dus om van 'cool' naar 'dry' te komen zet je een
+ # warmtepomp onderweg ook echt in 'heat' en 'fan_only'. Hier kies je
+ # rechtstreeks, en wordt er precies een opdracht verstuurd.
+ - obj:
+ id: climate_mode_overlay
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ hidden: true
+ bg_color: 0x000000
+ bg_opa: 90%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ align: TOP_MID
+ y: 16
+ text: "Mode"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ # Flex zorgt dat niet-ondersteunde modi geen gat achterlaten:
+ # LVGL slaat verborgen kinderen over bij het schikken.
+ - obj:
+ id: climate_mode_list
+ align: TOP_MID
+ y: 50
+ width: 98%
+ height: 178
+ bg_opa: TRANSP
+ border_width: 0
+ scrollable: false
+ pad_all: 3
+ layout:
+ type: FLEX
+ flex_flow: ROW_WRAP
+ flex_align_main: CENTER
+ flex_align_cross: CENTER
+ flex_align_track: CENTER
+ widgets:
+ - obj:
+ id: cmode_off
+ width: 132
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: set_climate_mode, mode: "off" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Off"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: cmode_heat
+ width: 132
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: set_climate_mode, mode: "heat" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Heat"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: cmode_cool
+ width: 132
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: set_climate_mode, mode: "cool" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Cool"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: cmode_heat_cool
+ width: 132
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: set_climate_mode, mode: "heat_cool" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Heat/Cool"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: cmode_auto
+ width: 132
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: set_climate_mode, mode: "auto" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Auto"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: cmode_dry
+ width: 132
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: set_climate_mode, mode: "dry" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Dry"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: cmode_fan_only
+ width: 132
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: set_climate_mode, mode: "fan_only" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Fan"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: climate_mode_overlay_x
+ align: TOP_LEFT
+ x: 10
+ y: 9
+ width: 36
+ height: 36
+ radius: 999
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 1
+ border_color: 0xFFFFFF
+ border_opa: 20%
+ clickable: true
+ scrollable: false
+ on_click:
+ - lvgl.widget.hide: climate_mode_overlay
+ widgets:
+ - label:
+ align: CENTER
+ text: "x"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: climate_mode_overlay_ok
+ align: TOP_RIGHT
+ x: -10
+ y: 9
+ width: 36
+ height: 36
+ radius: 999
+ bg_color: 0xFEC600
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ scrollable: false
+ on_click:
+ - lvgl.widget.hide: climate_mode_overlay
+ widgets:
+ - label:
+ align: CENTER
+ text: "\U000F012C"
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ clickable: false
+
+ # ---------- VACUUM CONTROL ----------
+ # Een stofzuiger is geen schakelaar: start, pauze, stop en terug naar
+ # het dock zijn losse opdrachten, en de zuigkracht is een aparte as.
+ - obj:
+ id: vacuum_overlay
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ hidden: true
+ bg_color: 0x000000
+ bg_opa: 90%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ widgets:
+ - label:
+ id: vacuum_title
+ align: TOP_MID
+ y: 16
+ text: "Vacuum"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: vacuum_actions
+ align: TOP_MID
+ y: 52
+ width: 98%
+ height: 80
+ bg_opa: TRANSP
+ border_width: 0
+ scrollable: false
+ pad_all: 3
+ layout:
+ type: FLEX
+ flex_flow: ROW_WRAP
+ flex_align_main: CENTER
+ flex_align_cross: CENTER
+ flex_align_track: CENTER
+ widgets:
+ - obj:
+ id: vac_start
+ width: 72
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: vacuum_action, service: "vacuum.start" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Start"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: vac_pause
+ width: 72
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: vacuum_action, service: "vacuum.pause" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Pause"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: vac_return_to_base
+ width: 72
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: vacuum_action, service: "vacuum.return_to_base" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Dock"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: vac_stop
+ width: 72
+ height: 38
+ radius: 19
+ bg_color: 0x2C2C30
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: vacuum_action, service: "vacuum.stop" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Stop"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - label:
+ align: TOP_MID
+ y: 138
+ text: "Suction"
+ text_font: sublabel
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: vacuum_speeds
+ align: TOP_MID
+ y: 156
+ width: 98%
+ height: 74
+ bg_opa: TRANSP
+ border_width: 0
+ scrollable: false
+ pad_all: 3
+ layout:
+ type: FLEX
+ flex_flow: ROW_WRAP
+ flex_align_main: CENTER
+ flex_align_cross: CENTER
+ flex_align_track: CENTER
+ widgets:
+ - obj:
+ id: vacspd_quiet
+ width: 72
+ height: 32
+ radius: 19
+ bg_color: 0x1F1F23
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: vacuum_fan_speed, speed: "quiet" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Quiet"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: vacspd_balanced
+ width: 72
+ height: 32
+ radius: 19
+ bg_color: 0x1F1F23
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: vacuum_fan_speed, speed: "balanced" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Balanced"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: vacspd_turbo
+ width: 72
+ height: 32
+ radius: 19
+ bg_color: 0x1F1F23
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: vacuum_fan_speed, speed: "turbo" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Turbo"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+ - obj:
+ id: vacspd_max
+ width: 72
+ height: 32
+ radius: 19
+ bg_color: 0x1F1F23
+ bg_opa: 92%
+ border_width: 0
+ clickable: true
+ scrollable: false
+ pad_all: 0
+ on_click:
+ - script.execute: { id: vacuum_fan_speed, speed: "max" }
+ widgets:
+ - label:
+ align: CENTER
+ text: "Max"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: vacuum_overlay_x
+ align: TOP_LEFT
+ x: 10
+ y: 9
+ width: 36
+ height: 36
+ radius: 999
+ bg_color: 0x3C3C40
+ bg_opa: COVER
+ border_width: 1
+ border_color: 0xFFFFFF
+ border_opa: 20%
+ clickable: true
+ scrollable: false
+ on_click:
+ - lvgl.widget.hide: vacuum_overlay
+ widgets:
+ - label:
+ align: CENTER
+ text: "x"
+ text_font: sublabel_big
+ text_color: 0xFFFFFF
+ clickable: false
+
+ - obj:
+ id: vacuum_overlay_ok
+ align: TOP_RIGHT
+ x: -10
+ y: 9
+ width: 36
+ height: 36
+ radius: 999
+ bg_color: 0xFEC600
+ bg_opa: COVER
+ border_width: 0
+ clickable: true
+ scrollable: false
+ on_click:
+ - lvgl.widget.hide: vacuum_overlay
+ widgets:
+ - label:
+ align: CENTER
+ text: "\U000F012C"
+ text_font: materialdesign_icons
+ text_color: 0xFFFFFF
+ clickable: false
+
+ # ---------- DIM / WAKE OVERLAY ----------
+ - obj:
+ id: dim_wake_overlay
+ x: 0
+ y: 0
+ width: ${DISPLAY_W}
+ height: ${DISPLAY_H}
+ hidden: true
+ bg_opa: TRANSP
+ border_width: 0
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ clickable: true
+ on_click:
+ - script.execute: wake_display
+
+ - id: touch_cal_page
+ bg_color: 0x101820
+ bg_opa: COVER
+ pad_all: 0
+ border_width: 0
+ scrollable: false
+ widgets:
+ - label:
+ align: TOP_MID
+ y: 65
+ text: "Volg de kalibratiewizard"
+ text_font: sublabel
+ text_color: 0xFFFFFF
+ - obj:
+ x: 10
+ y: 10
+ width: 20
+ height: 20
+ pad_all: 0
+ border_width: 0
+ bg_opa: TRANSP
+ scrollable: false
+ widgets:
+ - label:
+ align: CENTER
+ text: "+"
+ text_font: headline
+ text_color: 0xFFFFFF
+ - obj:
+ x: 289
+ y: 10
+ width: 20
+ height: 20
+ pad_all: 0
+ border_width: 0
+ bg_opa: TRANSP
+ scrollable: false
+ widgets:
+ - label:
+ align: CENTER
+ text: "+"
+ text_font: headline
+ text_color: 0xFFFFFF
+ - obj:
+ x: 289
+ y: 209
+ width: 20
+ height: 20
+ pad_all: 0
+ border_width: 0
+ bg_opa: TRANSP
+ scrollable: false
+ widgets:
+ - label:
+ align: CENTER
+ text: "+"
+ text_font: headline
+ text_color: 0xFFFFFF
+ - obj:
+ x: 10
+ y: 209
+ width: 20
+ height: 20
+ pad_all: 0
+ border_width: 0
+ bg_opa: TRANSP
+ scrollable: false
+ widgets:
+ - label:
+ align: CENTER
+ text: "+"
+ text_font: headline
+ text_color: 0xFFFFFF
+ - obj:
+ x: 150
+ y: 110
+ width: 20
+ height: 20
+ pad_all: 0
+ border_width: 0
+ bg_opa: TRANSP
+ scrollable: false
+ widgets:
+ - label:
+ align: CENTER
+ text: "+"
+ text_font: headline
+ text_color: 0xFFFFFF
+
+script:
+ # Render every overlay and both pages repeatedly without controlling devices.
+ - id: ui_self_test
+ mode: single
+ then:
+ - logger.log: "UI_TEST START: page/overlay render stress, no HA actions"
+ - lambda: 'id(calibration_active) = false;'
+ - lvgl.page.show: home_page
+ - script.execute: wake_display
+ - repeat:
+ count: 10
+ then:
+ - lambda: |-
+ id(last_touch_ms) = millis();
+ id(tile_page) = 1;
+ - script.execute: show_tile_page
+ - delay: 150ms
+ - lambda: |-
+ lv_obj_update_layout(id(tile_scroll));
+ const int count = std::clamp(atoi("${TILE_COUNT}"), 1, 10);
+ const bool paginated = count > 6;
+ lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
+ id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
+ bool ok = true;
+ for (int i = 0; i < 10; ++i) {
+ bool visible = i < count && (paginated ? i >= 6 : i < 6);
+ ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
+ }
+ for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
+ ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != paginated);
+ lv_area_t nav, grid;
+ lv_obj_get_coords(id(page_next), &nav);
+ lv_obj_get_coords(id(tile_scroll), &grid);
+ ok = ok && (!paginated || grid.y2 < nav.y1);
+ ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1);
+ id(tile_page) = 0;
+ - script.execute: show_tile_page
+ - delay: 150ms
+ - lvgl.widget.show: brightness_overlay
+ - delay: 150ms
+ - lvgl.widget.hide: brightness_overlay
+ - lvgl.widget.show: color_detail_overlay
+ - delay: 150ms
+ - lvgl.widget.hide: color_detail_overlay
+ - lvgl.widget.show: climate_detail_overlay
+ - delay: 150ms
+ - lvgl.widget.hide: climate_detail_overlay
+ - lvgl.widget.show: climate_mode_overlay
+ - delay: 150ms
+ - lvgl.widget.hide: climate_mode_overlay
+ - lvgl.widget.show: vacuum_overlay
+ - delay: 150ms
+ - lvgl.widget.hide: vacuum_overlay
+ - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
+ - logger.log: "UI_TEST COMPLETE"
+
+ - id: show_tile_page
+ then:
+ - lambda: |-
+ lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
+ id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
+ const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}};
+ const int count = std::clamp(atoi("${TILE_COUNT}"), 1, 10);
+ const bool paginated = count > 6;
+ if (!paginated) id(tile_page) = 0;
+ for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) {
+ if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN);
+ else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN);
+ }
+ for (int i = 0; i < 10; ++i) {
+ bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6);
+ if (visible) {
+ lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]);
+ lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN);
+ } else {
+ lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN);
+ }
+ }
+ lv_label_set_text(id(page_number), id(tile_page) == 0 ? "1 / 2" : "2 / 2");
+ if (id(tile_page) == 0) {
+ lv_obj_add_state(id(page_prev), LV_STATE_DISABLED);
+ lv_obj_clear_state(id(page_next), LV_STATE_DISABLED);
+ } else {
+ lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED);
+ lv_obj_add_state(id(page_next), LV_STATE_DISABLED);
+ }
+
+ - id: wake_display
+ mode: restart
+ then:
+ - lambda: |-
+ id(last_touch_ms) = millis();
+ id(display_dimmed) = false;
+ - light.turn_on:
+ id: back_light
+ # Bijna instant omhoog. De ESPHome-standaard is 1s, en dat voelt traag
+ # als je het scherm aanraakt en op licht staat te wachten.
+ transition_length: 80ms
+ brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;"
+ - lvgl.widget.hide: dim_wake_overlay
+ - lvgl.widget.hide: color_detail_overlay
+ - lvgl.widget.hide: climate_detail_overlay
+ - lvgl.widget.hide: climate_mode_overlay
+ - lvgl.widget.hide: vacuum_overlay
+
+ - id: dim_display
+ mode: restart
+ then:
+ - if:
+ condition:
+ lambda: 'return std::string("${AUTO_DIM}") == "true";'
+ then:
+ - lambda: |-
+ int dim_value = atoi("${DIM_BRIGHTNESS}");
+ int max_value = atoi("${MAX_BRIGHTNESS}");
+
+ if (std::string("${NIGHT_MODE}") == "true") {
+ auto now = id(homeassistant_time).now();
+ // Vóór de eerste tijd-sync (zie time: hierboven) is now()
+ // epoch 0 — dan geen nachtmodus-beslissing nemen op basis
+ // van een ongeldig uur, gewoon normale DIM_BRIGHTNESS.
+ if (now.is_valid()) {
+ int hour = now.hour;
+ int start_hour = atoi("${NIGHT_START_HOUR}");
+ int end_hour = atoi("${NIGHT_END_HOUR}");
+ bool is_night = false;
+ if (start_hour < end_hour) {
+ is_night = (hour >= start_hour && hour < end_hour);
+ } else {
+ is_night = (hour >= start_hour || hour < end_hour);
+ }
+ if (is_night) {
+ dim_value = atoi("${NIGHT_DIM_BRIGHTNESS}");
+ }
+ }
+ }
+
+ // Rustig omlaag faden is prettig; omhoog moet juist snel.
+ id(back_light).turn_on().set_transition_length(900).set_brightness(dim_value / 100.0f).perform();
+
+ // Always mark as dimmed to stop the interval from re-firing
+ id(display_dimmed) = true;
+ // Only show the touch-blocking overlay when brightness actually changes
+ if (dim_value < max_value) {
+ lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
+ }
+
+ # Terug naar het basisscherm bij standby. Bewust via de ESPHome
+ # acties en NIET met lv_obj_add_flag in een lambda: dit script
+ # draait vanuit een interval, dus buiten LVGL's eigen afhandeling.
+ - lvgl.widget.hide: brightness_overlay
+ - lvgl.widget.hide: color_detail_overlay
+ - lvgl.widget.hide: climate_detail_overlay
+ - lvgl.widget.hide: climate_mode_overlay
+ - lvgl.widget.hide: vacuum_overlay
+
+ - id: publish_action
+ mode: restart
+ parameters:
+ action: string
+ then:
+ - logger.log:
+ level: INFO
+ format: "publish_action: %s"
+ args: ["action.c_str()"]
+ - text_sensor.template.publish:
+ id: smartdisplay_action
+ state: !lambda 'return action;'
+ - delay: 500ms
+ - text_sensor.template.publish:
+ id: smartdisplay_action
+ state: ""
+
+ - id: climate_target_preview
+ parameters:
+ value: int
+ then:
+ - lambda: |-
+ id(climate_edit_ms) = millis();
+ id(climate_edit_pending) = true;
+ int min_x10 = id(active_climate_min_x10);
+ int max_x10 = id(active_climate_max_x10);
+ value = cyd::quantize_temperature(value, min_x10, max_x10,
+ id(active_climate_step_x10));
+ id(active_climate_target_x10) = value;
+
+ char buf[16];
+ snprintf(buf, sizeof(buf), "%.1f°", value / 10.0f);
+ lv_label_set_text(id(climate_target_label), buf);
+ lv_arc_set_value(id(climate_arc), value);
+
+ - id: climate_target_commit
+ parameters:
+ value: int
+ then:
+ - script.execute:
+ id: climate_target_preview
+ value: !lambda "return value;"
+ - if:
+ condition:
+ lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
+ then:
+ - homeassistant.action:
+ action: climate.set_temperature
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ temperature: !lambda "return id(active_climate_target_x10) / 10.0f;"
+
+ - id: climate_adjust_target
+ parameters:
+ direction: int
+ then:
+ - lambda: |-
+ int step_x10 = id(active_climate_step_x10);
+ if (step_x10 < 5) step_x10 = 5;
+ int next = id(active_climate_target_x10) + (direction * step_x10);
+ if (next < id(active_climate_min_x10)) next = id(active_climate_min_x10);
+ if (next > id(active_climate_max_x10)) next = id(active_climate_max_x10);
+ id(active_climate_target_x10) = next;
+ - script.execute:
+ id: climate_target_commit
+ value: !lambda "return id(active_climate_target_x10);"
+
+ # Opent de moduskiezer. Verbergt modi die de entiteit niet ondersteunt en
+ # markeert de huidige, zodat je ziet waar je staat voor je iets kiest.
+ # Opent de stofzuigerbediening en onthoudt om welke entiteit het gaat.
+ - id: open_vacuum_overlay
+ mode: restart
+ parameters:
+ title: string
+ entity: string
+ then:
+ - lambda: |-
+ id(active_entity) = entity;
+ lv_label_set_text(id(vacuum_title), title.c_str());
+ - lvgl.widget.show: vacuum_overlay
+
+ # Een losse opdracht: start, pause, stop of return_to_base.
+ - id: vacuum_action
+ mode: queued
+ parameters:
+ service: string
+ then:
+ - if:
+ condition:
+ lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
+ then:
+ - homeassistant.action:
+ action: !lambda "return service;"
+ data:
+ entity_id: !lambda "return id(active_entity);"
+
+ # Zuigkracht is een aparte as, los van start/stop.
+ - id: vacuum_fan_speed
+ mode: queued
+ parameters:
+ speed: string
+ then:
+ - if:
+ condition:
+ lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
+ then:
+ - homeassistant.action:
+ action: vacuum.set_fan_speed
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ fan_speed: !lambda "return speed;"
+
+ - id: open_climate_mode_picker
+ mode: restart
+ then:
+ - lambda: |-
+ auto lower = [](std::string v) {
+ for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); }
+ return v;
+ };
+ std::string supported = lower(id(active_climate_supported_modes));
+ std::string current = lower(id(active_climate_mode));
+
+ auto has = [&](const char* m) {
+ std::string mode(m);
+ if (supported.empty() || supported == "unknown" || supported == "unavailable") return false;
+ return supported.find("'" + mode + "'") != std::string::npos ||
+ supported.find("\"" + mode + "\"") != std::string::npos;
+ };
+ auto apply = [&](lv_obj_t* btn, const char* m) {
+ if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN);
+ else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN);
+ bool selected = (current == std::string(m));
+ lv_obj_set_style_bg_color(btn,
+ lv_color_hex(selected ? 0xFF9F0A : 0x2C2C30), LV_PART_MAIN);
+ };
+ apply(id(cmode_off), "off");
+ apply(id(cmode_heat), "heat");
+ apply(id(cmode_cool), "cool");
+ apply(id(cmode_heat_cool), "heat_cool");
+ apply(id(cmode_auto), "auto");
+ apply(id(cmode_dry), "dry");
+ apply(id(cmode_fan_only), "fan_only");
+ - lvgl.widget.show: climate_mode_overlay
+
+ # Zet precies een modus. Geen tussenstappen, dus geen ongewenste
+ # commando's naar de warmtepomp onderweg.
+ - id: set_climate_mode
+ mode: restart
+ parameters:
+ mode: string
+ then:
+ - lambda: |-
+ id(climate_edit_ms) = millis();
+ id(climate_edit_pending) = true;
+ id(active_climate_mode) = mode;
+ if (mode == "off") lv_label_set_text(id(climate_mode_label), "Off");
+ if (mode == "heat") lv_label_set_text(id(climate_mode_label), "Heat");
+ if (mode == "cool") lv_label_set_text(id(climate_mode_label), "Cool");
+ if (mode == "heat_cool") lv_label_set_text(id(climate_mode_label), "Heat/Cool");
+ if (mode == "auto") lv_label_set_text(id(climate_mode_label), "Auto");
+ if (mode == "dry") lv_label_set_text(id(climate_mode_label), "Dry");
+ if (mode == "fan_only") lv_label_set_text(id(climate_mode_label), "Fan");
+ - lvgl.widget.hide: climate_mode_overlay
+ - if:
+ condition:
+ lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
+ then:
+ - homeassistant.action:
+ action: climate.set_hvac_mode
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ hvac_mode: !lambda "return id(active_climate_mode);"
+
+ - id: open_color_detail
+ mode: restart
+ then:
+ - lambda: |-
+ lv_label_set_text(id(color_detail_title), id(active_title).c_str());
+
+ char buf[12];
+ snprintf(buf, sizeof(buf), "%d %%", id(active_brightness_pct));
+ lv_label_set_text(id(color_brightness_value), buf);
+ lv_slider_set_value(id(color_brightness_slider), id(active_brightness_pct), LV_ANIM_OFF);
+
+ bool show_tabs = id(active_supports_color) && id(active_supports_temperature);
+ if (show_tabs) {
+ lv_obj_clear_flag(id(color_mode_tabs), LV_OBJ_FLAG_HIDDEN);
+ } else {
+ lv_obj_add_flag(id(color_mode_tabs), LV_OBJ_FLAG_HIDDEN);
+ }
+
+ if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) {
+ int palette_y = show_tabs ? 96 : 76;
+ lv_obj_set_size(id(color_palette_card), 150, 86);
+ lv_obj_set_y(id(color_palette_card), palette_y);
+ lv_obj_set_pos(id(color_preset_red), 13, 4);
+ lv_obj_set_pos(id(color_preset_orange), 60, 4);
+ lv_obj_set_pos(id(color_preset_yellow), 107, 4);
+ lv_obj_set_pos(id(color_preset_green), 13, 52);
+ lv_obj_set_pos(id(color_preset_blue), 60, 52);
+ lv_obj_set_pos(id(color_preset_purple), 107, 52);
+
+ lv_obj_set_size(id(temp_palette_card), 150, 126);
+ lv_obj_set_y(id(temp_palette_card), palette_y);
+ lv_obj_set_pos(id(temp_preset_2700), 44, 4);
+ lv_obj_set_pos(id(temp_preset_4000), 44, 48);
+ lv_obj_set_pos(id(temp_preset_6500), 44, 92);
+
+ lv_obj_set_y(id(color_brightness_card), 256);
+ } else {
+ int palette_y = show_tabs ? 98 : 78;
+ lv_obj_set_size(id(color_palette_card), 286, 48);
+ lv_obj_set_y(id(color_palette_card), palette_y);
+ lv_obj_set_pos(id(color_preset_red), 14, 8);
+ lv_obj_set_pos(id(color_preset_orange), 61, 8);
+ lv_obj_set_pos(id(color_preset_yellow), 108, 8);
+ lv_obj_set_pos(id(color_preset_green), 155, 8);
+ lv_obj_set_pos(id(color_preset_blue), 202, 8);
+ lv_obj_set_pos(id(color_preset_purple), 249, 8);
+
+ lv_obj_set_size(id(temp_palette_card), 286, 48);
+ lv_obj_set_y(id(temp_palette_card), palette_y);
+ lv_obj_set_pos(id(temp_preset_2700), 24, 8);
+ lv_obj_set_pos(id(temp_preset_4000), 112, 8);
+ lv_obj_set_pos(id(temp_preset_6500), 200, 8);
+
+ lv_obj_set_y(id(color_brightness_card), 165);
+ }
+
+ id(active_color_detail_tab) = id(active_supports_color) ? 0 : 1;
+ - lvgl.widget.hide: brightness_overlay
+ - lvgl.widget.show: color_detail_overlay
+ - script.execute:
+ id: show_color_detail_tab
+ tab: !lambda "return id(active_color_detail_tab);"
+
+ - id: show_color_detail_tab
+ mode: restart
+ parameters:
+ tab: int
+ then:
+ - lambda: |-
+ if (tab == 0 && !id(active_supports_color) && id(active_supports_temperature)) {
+ tab = 1;
+ } else if (tab == 1 && !id(active_supports_temperature) && id(active_supports_color)) {
+ tab = 0;
+ }
+ id(active_color_detail_tab) = tab;
+
+ bool show_tabs = id(active_supports_color) && id(active_supports_temperature);
+ if (show_tabs) {
+ lv_obj_clear_flag(id(color_mode_tabs), LV_OBJ_FLAG_HIDDEN);
+ } else {
+ lv_obj_add_flag(id(color_mode_tabs), LV_OBJ_FLAG_HIDDEN);
+ }
+
+ if (tab == 0 && id(active_supports_color)) {
+ lv_obj_clear_flag(id(color_palette_card), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(id(temp_palette_card), LV_OBJ_FLAG_HIDDEN);
+ if (show_tabs) lv_obj_set_x(id(color_tab_indicator), 2);
+ } else if (tab == 1 && id(active_supports_temperature)) {
+ lv_obj_add_flag(id(color_palette_card), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_clear_flag(id(temp_palette_card), LV_OBJ_FLAG_HIDDEN);
+ if (show_tabs) lv_obj_set_x(id(color_tab_indicator), 95);
+ } else {
+ lv_obj_add_flag(id(color_palette_card), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(id(temp_palette_card), LV_OBJ_FLAG_HIDDEN);
+ }
+
+ lv_obj_set_style_text_color(
+ id(color_tab_label),
+ id(active_supports_color) ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x8E8E93),
+ 0
+ );
+ lv_obj_set_style_text_color(
+ id(temp_tab_label),
+ id(active_supports_temperature) ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x8E8E93),
+ 0
+ );
+ if (tab == 0 && id(active_supports_color)) {
+ lv_obj_set_style_text_color(id(color_tab_label), lv_color_hex(0xFFFFFF), 0);
+ lv_obj_set_style_text_color(id(temp_tab_label), lv_color_hex(0xD1D1D6), 0);
+ } else if (tab == 1 && id(active_supports_temperature)) {
+ lv_obj_set_style_text_color(id(color_tab_label), lv_color_hex(0xD1D1D6), 0);
+ lv_obj_set_style_text_color(id(temp_tab_label), lv_color_hex(0xFFFFFF), 0);
+ }
+
+ - id: color_brightness_preview
+ parameters:
+ value: int
+ then:
+ - lambda: |-
+ id(active_brightness_pct) = value;
+ char buf[12];
+ snprintf(buf, sizeof(buf), "%d %%", value);
+ lv_label_set_text(id(color_brightness_value), buf);
+
+ - id: color_brightness_commit
+ parameters:
+ value: int
+ then:
+ - lambda: 'id(active_brightness_pct) = value;'
+ - if:
+ condition:
+ lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
+ then:
+ - homeassistant.action:
+ action: light.turn_on
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ brightness_pct: !lambda "return value;"
+
+ - id: set_light_color_name
+ mode: queued
+ parameters:
+ color: string
+ then:
+ - if:
+ condition:
+ lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
+ then:
+ - homeassistant.action:
+ action: light.turn_on
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ color_name: !lambda "return color;"
+ brightness_pct: !lambda "return id(active_brightness_pct);"
+
+ - id: set_light_color_temperature
+ mode: queued
+ parameters:
+ kelvin: int
+ then:
+ - if:
+ condition:
+ lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";'
+ then:
+ - homeassistant.action:
+ action: light.turn_on
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ color_temp_kelvin: !lambda "return kelvin;"
+ brightness_pct: !lambda "return id(active_brightness_pct);"
+
+ - id: slider_preview
+ parameters:
+ value: int
+ then:
+ - lambda: |-
+ char buf[20];
+ snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str());
+ lv_label_set_text(id(overlay_value), buf);
+ int pct = value;
+ if (pct < 0) pct = 0;
+ if (pct > 100) pct = 100;
+ std::string value_text;
+
+ if (id(active_control_kind) == "cover_position") {
+ std::string closed_label = id(active_label_off).c_str();
+ std::string open_label = id(active_label_on).c_str();
+
+ if (pct <= 0) {
+ value_text = closed_label;
+ } else if (pct >= 100) {
+ value_text = open_label;
+ } else {
+ char buf[40];
+ snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct);
+ value_text = buf;
+ }
+ } else {
+ if (pct <= 0) {
+ value_text = id(active_label_off).c_str();
+ } else {
+ char buf[20];
+ snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str());
+ value_text = buf;
+ }
+ }
+
+ lv_label_set_text(id(overlay_value), value_text.c_str());
+
+
+ - id: slider_commit
+ parameters:
+ value: int
+ then:
+ - if:
+ condition:
+ lambda: |-
+ return id(active_control_kind) == "light_brightness";
+ then:
+ - homeassistant.action:
+ action: light.turn_on
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ brightness_pct: !lambda "return value;"
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return id(active_control_kind) == "fan_percentage";
+ then:
+ - if:
+ condition:
+ lambda: "return value <= 0;"
+ then:
+ - homeassistant.action:
+ action: fan.turn_off
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ else:
+ - homeassistant.action:
+ action: fan.turn_on
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ - delay: 150ms
+ - homeassistant.action:
+ action: fan.set_percentage
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ percentage: !lambda "return value;"
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return id(active_control_kind) == "cover_position";
+ then:
+ - homeassistant.action:
+ action: cover.set_cover_position
+ data:
+ entity_id: !lambda "return id(active_entity);"
+ position: !lambda "return value;"
+
+ # ----------------------------------------------------------
+ # do_tile_action
+ #
+ # Centralized handler for executing Home Assistant actions
+ # triggered by tile taps.
+ #
+ # Parameters
+ # - tile_type: light | fan | switch | scene | script | cover
+ # - tap_action: auto | toggle | activate | fan_toggle_preset
+ # - service: optional Home Assistant service override
+ # - entity_id: target entity
+ # - param_key / param_val: optional parameters for custom services
+ # - was_on: previous entity state (pass false for longpress actions)
+ #
+ # Behavior
+ #
+ # toggle
+ # -> toggles the entity
+ # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on)
+ #
+ # activate
+ # -> activates entity
+ # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover
+ #
+ # auto
+ # -> resolves automatically based on tile_type:
+ # light/switch/fan/cover -> toggle
+ # scene/script -> activate
+ #
+ # fan_toggle_preset
+ # -> if fan was ON: fan.turn_off
+ # -> if fan was OFF:
+ # fan.turn_on
+ # wait 150ms
+ # fan.set_preset_mode(param_val)
+ #
+ # Custom service
+ # If "service" is set, it will be called directly.
+ # param_key / param_val may be used for parameters
+ # like preset_mode, brightness_pct, percentage, etc.
+ # ----------------------------------------------------------
+ - id: do_tile_action
+ mode: queued
+ parameters:
+ tile_type: string
+ tap_action: string
+ service: string
+ entity_id: string
+ param_key: string
+ param_val: string
+ was_on: bool
+ then:
+ - if:
+ condition:
+ lambda: |-
+ if (std::string("${DIRECT_ACTIONS}") != "true") return false;
+ // Debounce: zelfde entity binnen 600ms genegeerd. Voorkomt dat
+ // touch-ruis dezelfde HA-actie meerdere keren afvuurt (zie
+ // globals hierboven voor waarom dit nodig is).
+ uint32_t now = millis();
+ if (entity_id == id(last_action_entity) && (now - id(last_action_ms)) < 600) {
+ ESP_LOGD("tile", "do_tile_action: debounced repeat for %s (%ums na vorige)",
+ entity_id.c_str(), now - id(last_action_ms));
+ return false;
+ }
+ id(last_action_entity) = entity_id;
+ id(last_action_ms) = now;
+ return true;
+ then:
+ - lambda: |-
+ ESP_LOGD("tile", "do_tile_action type=%s action=%s service=%s entity=%s key=%s val=%s was_on=%d",
+ tile_type.c_str(), tap_action.c_str(), service.c_str(), entity_id.c_str(),
+ param_key.c_str(), param_val.c_str(), (int)was_on);
+
+ - if:
+ condition:
+ lambda: |-
+ return tap_action == "fan_toggle_preset";
+ then:
+ - if:
+ condition:
+ lambda: |-
+ return was_on;
+ then:
+ - homeassistant.action:
+ action: fan.turn_off
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - homeassistant.action:
+ action: fan.turn_on
+ data:
+ entity_id: !lambda 'return entity_id;'
+ - delay: 150ms
+ - homeassistant.action:
+ action: fan.set_preset_mode
+ data:
+ entity_id: !lambda 'return entity_id;'
+ preset_mode: !lambda 'return param_val;'
+
+ else:
+ - if:
+ condition:
+ lambda: |-
+ std::string resolved = tap_action;
+ if (resolved == "auto") {
+ if (tile_type == "scene" || tile_type == "script") resolved = "activate";
+ else resolved = "toggle";
+ }
+ return resolved == "toggle";
+ then:
+ - if:
+ condition:
+ lambda: |-
+ return service.size() > 0;
+ then:
+ - homeassistant.action:
+ action: !lambda 'return service;'
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "light";
+ then:
+ - homeassistant.action:
+ action: light.toggle
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "fan";
+ then:
+ - homeassistant.action:
+ action: fan.toggle
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "switch";
+ then:
+ - homeassistant.action:
+ action: switch.toggle
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "cover";
+ then:
+ - homeassistant.action:
+ action: cover.set_cover_position
+ data:
+ entity_id: !lambda 'return entity_id;'
+ position: !lambda |-
+ return was_on ? 0 : 100;
+
+ else:
+ - if:
+ condition:
+ lambda: |-
+ std::string resolved = tap_action;
+ if (resolved == "auto") {
+ if (tile_type == "scene" || tile_type == "script") resolved = "activate";
+ else resolved = "toggle";
+ }
+ return resolved == "activate";
+ then:
+ - if:
+ condition:
+ lambda: |-
+ return service.size() > 0;
+ then:
+ - if:
+ condition:
+ lambda: |-
+ return param_key == "preset_mode";
+ then:
+ - homeassistant.action:
+ action: !lambda 'return service;'
+ data:
+ entity_id: !lambda 'return entity_id;'
+ preset_mode: !lambda 'return param_val;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return param_key == "brightness_pct";
+ then:
+ - homeassistant.action:
+ action: !lambda 'return service;'
+ data:
+ entity_id: !lambda 'return entity_id;'
+ brightness_pct: !lambda 'return atoi(param_val.c_str());'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return param_key == "percentage";
+ then:
+ - homeassistant.action:
+ action: !lambda 'return service;'
+ data:
+ entity_id: !lambda 'return entity_id;'
+ percentage: !lambda 'return atoi(param_val.c_str());'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return param_key == "option";
+ then:
+ - homeassistant.action:
+ action: !lambda 'return service;'
+ data:
+ entity_id: !lambda 'return entity_id;'
+ option: !lambda 'return param_val;'
+ else:
+ - homeassistant.action:
+ action: !lambda 'return service;'
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "scene";
+ then:
+ - homeassistant.action:
+ action: scene.turn_on
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "script";
+ then:
+ - homeassistant.action:
+ action: script.turn_on
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "light";
+ then:
+ - homeassistant.action:
+ action: light.turn_on
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "fan";
+ then:
+ - homeassistant.action:
+ action: fan.turn_on
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "switch";
+ then:
+ - homeassistant.action:
+ action: switch.turn_on
+ data:
+ entity_id: !lambda 'return entity_id;'
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return tile_type == "cover";
+ then:
+ - homeassistant.action:
+ action: cover.open_cover
+ data:
+ entity_id: !lambda 'return entity_id;'
+
+ - id: open_value_overlay
+ parameters:
+ title: string
+ entity: string
+ tile_type: string
+ longpress_mode: string
+ was_on: bool
+ off_value: int
+ brightness_value: float
+ percentage_value: float
+ position_value: float
+ icon_text: string
+ slider_bg_color: uint32_t
+ slider_fill_color: uint32_t
+ slider_icon_color: uint32_t
+ label_off: string
+ label_on: string
+ supported_color_modes: string
+ climate_current_temperature: float
+ climate_target_temperature: float
+ climate_humidity: float
+ climate_mode: string
+ climate_supported_modes: string
+ climate_min_temp: string
+ climate_max_temp: string
+ climate_step: string
+ then:
+ - lambda: |-
+ // Accentkleur uit het Home Assistant-domein: het stuk voor de punt in
+ // het entity_id. script/scene/button vuur je af (ACCENT_ACTION), een
+ // light is een light (ACCENT_LIGHT), de rest is apparaat.
+ // LET OP: deze helper staat twee keer in dit bestand — hier en in
+ // open_value_overlay. Pas ze samen aan.
+ auto accent_for = [](const std::string& entity_id) -> uint32_t {
+ std::string d = entity_id.substr(0, entity_id.find('.'));
+ if (d == "script" || d == "scene" || d == "button" ||
+ d == "input_button" || d == "automation") return ${ACCENT_ACTION};
+ if (d == "light") return ${ACCENT_LIGHT};
+ return ${ACCENT_DEVICE};
+ };
+
+ // De sliderkleur komt van de tegel, dus die kan ook ${ACCENT_AUTO} zijn.
+ uint32_t fill_color = (slider_fill_color == ${ACCENT_AUTO})
+ ? accent_for(entity) : slider_fill_color;
+
+ std::string mode = longpress_mode;
+ auto contains_mode = [](const std::string& modes, const std::string& needle) {
+ return modes.find(needle) != std::string::npos;
+ };
+ if (mode == "auto") {
+ if (tile_type == "light") mode = "brightness";
+ else if (tile_type == "fan") mode = "percentage";
+ else if (tile_type == "cover") mode = "cover_position";
+ else if (tile_type == "climate") mode = "climate_temperature";
+ else mode = "none";
+ }
+
+ if (mode == "none") return;
+
+ id(active_entity) = entity;
+ id(active_value_suffix) = " %";
+ id(active_label_off) = label_off;
+ id(active_label_on) = label_on;
+ id(active_title) = title;
+ id(active_icon_text) = icon_text;
+ id(active_supports_color) = false;
+ id(active_supports_temperature) = false;
+ lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN);
+
+ if (mode == "climate_temperature") {
+ auto to_x10 = [](float value, int fallback) -> int {
+ if (isnan(value)) return fallback;
+ return (int) lround(value * 10.0f);
+ };
+ auto parse_x10 = [](const std::string& value, int fallback) -> int {
+ if (value.empty()) return fallback;
+ return (int) lround(atof(value.c_str()) * 10.0f);
+ };
+ id(climate_edit_pending) = false;
+ id(active_control_kind) = "climate_temperature";
+ id(active_climate_min_x10) = parse_x10(climate_min_temp, 50);
+ id(active_climate_max_x10) = parse_x10(climate_max_temp, 300);
+ id(active_climate_step_x10) = parse_x10(climate_step, 5);
+ if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5;
+ if (id(active_climate_max_x10) <= id(active_climate_min_x10)) {
+ id(active_climate_min_x10) = 50;
+ id(active_climate_max_x10) = 300;
+ }
+
+ id(active_climate_current_x10) = to_x10(climate_current_temperature, 0);
+ int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10);
+ id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target);
+ if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10);
+ if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10);
+ id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10);
+ if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10);
+ if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10);
+ id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity);
+ id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode;
+ id(active_climate_supported_modes) = climate_supported_modes;
+
+ lv_label_set_text(id(climate_detail_title), title.c_str());
+
+ char current_buf[48];
+ if (isnan(climate_current_temperature)) {
+ snprintf(current_buf, sizeof(current_buf), "Current: --");
+ } else if (id(active_climate_humidity) >= 0) {
+ snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", climate_current_temperature, id(active_climate_humidity));
+ } else {
+ snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", climate_current_temperature);
+ }
+ lv_label_set_text(id(climate_current_label), current_buf);
+
+ char target_buf[16];
+ snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f);
+ lv_label_set_text(id(climate_target_label), target_buf);
+ lv_label_set_text(id(climate_mode_label), id(active_climate_mode).c_str());
+
+ lv_arc_set_range(id(climate_arc), id(active_climate_min_x10), id(active_climate_max_x10));
+ lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
+
+ if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) {
+ lv_obj_set_size(id(climate_arc), 196, 196);
+ lv_obj_set_y(id(climate_arc), 8);
+ lv_obj_set_y(id(climate_current_label), 48);
+ lv_obj_set_y(id(climate_minus_button), -62);
+ lv_obj_set_y(id(climate_plus_button), -62);
+ lv_obj_set_y(id(climate_mode_pill), -18);
+ } else {
+ lv_obj_set_size(id(climate_arc), 176, 176);
+ lv_obj_set_y(id(climate_arc), 18);
+ lv_obj_set_y(id(climate_current_label), 40);
+ lv_obj_set_y(id(climate_minus_button), -14);
+ lv_obj_set_y(id(climate_plus_button), -14);
+ lv_obj_set_y(id(climate_mode_pill), -15);
+ }
+
+ lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN);
+ return;
+ }
+
+ int pct = off_value;
+
+ if (mode == "brightness") {
+ id(active_control_kind) = "light_brightness";
+ if (was_on) {
+ if (isnan(brightness_value)) pct = 100;
+ else pct = (int) lround((brightness_value / 255.0f) * 100.0f);
+ }
+ } else if (mode == "percentage") {
+ id(active_control_kind) = "fan_percentage";
+ if (was_on) {
+ if (isnan(percentage_value)) pct = 0;
+ else pct = (int) lround(percentage_value);
+ }
+ } else if (mode == "cover_position") {
+ id(active_control_kind) = "cover_position";
+ if (isnan(position_value)) pct = was_on ? 100 : 0;
+ else pct = (int) lround(position_value);
+ } else {
+ return;
+ }
+
+ if (pct < 0) pct = 0;
+ if (pct > 100) pct = 100;
+ id(active_brightness_pct) = pct;
+
+ if (tile_type == "light" && std::string("${DIRECT_ACTIONS}") == "true") {
+ id(active_supports_color) =
+ contains_mode(supported_color_modes, "hs") ||
+ contains_mode(supported_color_modes, "rgb") ||
+ contains_mode(supported_color_modes, "rgbw") ||
+ contains_mode(supported_color_modes, "rgbww") ||
+ contains_mode(supported_color_modes, "xy");
+ id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp");
+
+ if (id(active_supports_color) || id(active_supports_temperature)) {
+ lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN);
+ }
+ }
+
+ if (tile_type == "cover") {
+ lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(fill_color), LV_PART_MAIN);
+ lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(slider_bg_color), LV_PART_INDICATOR);
+ lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN);
+ }
+ else{
+ lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(slider_bg_color), LV_PART_MAIN);
+ lv_obj_set_style_bg_color(id(overlay_slider), lv_color_hex(fill_color), LV_PART_INDICATOR);
+ lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN);
+ }
+ lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN);
+ lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR);
+
+ lv_label_set_text(id(overlay_title), title.c_str());
+ lv_label_set_text(id(overlay_icon), icon_text.c_str());
+ lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF);
+
+ std::string value_text;
+
+ if (tile_type == "cover") {
+ if (pct <= 0) {
+ value_text = label_off;
+ } else if (pct >= 100) {
+ value_text = label_on;
+ } else {
+ char buf[40];
+ snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct);
+ value_text = buf;
+ }
+ } else {
+ if (pct <= 0) {
+ value_text = label_off;
+ } else {
+ char buf[20];
+ snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str());
+ value_text = buf;
+ }
+ }
+
+ lv_label_set_text(id(overlay_value), value_text.c_str());
+
+ lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR);
+
+ lv_obj_set_style_text_color(id(overlay_icon), lv_color_hex(slider_icon_color), 0);
+
+ - if:
+ condition:
+ lambda: 'return id(active_control_kind) != "climate_temperature";'
+ then:
+ - lvgl.widget.show: brightness_overlay
+
+ # UI refresh (values + time + styles) + render
+ - id: ui_refresh
+ mode: single
+ then:
+ - delay: 100ms
+ - lambda: |-
+ auto set_lbl = [](lv_obj_t* lbl, const std::string& s) {
+ if (lbl && s != lv_label_get_text(lbl)) lv_label_set_text(lbl, s.c_str());
+ };
+
+ auto set_value_auto = [&](lv_obj_t* lbl,
+ bool on,
+ const std::string& tile_type,
+ const std::string& value_mode,
+ float brightness,
+ float percentage,
+ const std::string& preset,
+ float climate_current_temperature,
+ float climate_target_temperature,
+ float climate_humidity,
+ const std::string& label_on,
+ const std::string& label_off) {
+ std::string mode = value_mode;
+
+ if (mode == "auto" || mode.empty()) {
+ if (tile_type == "light") {
+ mode = "brightness";
+ } else if (tile_type == "fan") {
+ mode = preset.empty() ? "percentage" : "preset";
+ } else if (tile_type == "climate") {
+ mode = "climate";
+ } else {
+ mode = "text";
+ }
+ }
+
+ if (mode == "brightness") {
+ if (!on) {
+ set_lbl(lbl, label_off);
+ return;
+ }
+ if (isnan(brightness)) {
+ set_lbl(lbl, "100 %");
+ return;
+ }
+ int pct = (int) lround((brightness / 255.0f) * 100.0f);
+ char buf[8];
+ snprintf(buf, sizeof(buf), "%d %%", pct);
+ set_lbl(lbl, std::string(buf));
+ return;
+ }
+
+ if (mode == "percentage") {
+ if (!on) {
+ set_lbl(lbl, label_off);
+ return;
+ }
+ int pct = isnan(percentage) ? 0 : (int) lround(percentage);
+ char buf[8];
+ snprintf(buf, sizeof(buf), "%d %%", pct);
+ set_lbl(lbl, std::string(buf));
+ return;
+ }
+
+ if (mode == "preset") {
+ if (!on) {
+ set_lbl(lbl, label_off);
+ return;
+ }
+ if (!preset.empty()) {
+ set_lbl(lbl, preset);
+ } else {
+ set_lbl(lbl, label_on);
+ }
+ return;
+ }
+
+ if (mode == "climate") {
+ if (!on) {
+ set_lbl(lbl, label_off);
+ return;
+ }
+
+ float temp = isnan(climate_target_temperature) ? climate_current_temperature : climate_target_temperature;
+ if (isnan(temp)) {
+ set_lbl(lbl, label_on);
+ return;
+ }
+
+ char buf[16];
+ snprintf(buf, sizeof(buf), "%.1f °", temp);
+ set_lbl(lbl, std::string(buf));
+ return;
+ }
+
+ if (mode == "text") {
+ if (tile_type == "scene" || tile_type == "script") {
+ set_lbl(lbl, label_on);
+ } else {
+ set_lbl(lbl, on ? label_on : label_off);
+ }
+ return;
+ }
+
+ set_lbl(lbl, "—");
+ };
+
+ // active_state niet leeg? Dan telt alleen die exacte HA-state als "aan".
+ // Zo kan een input_select ("Vol"/"Leeg") ook een tegel laten oplichten.
+ auto is_active = [](const std::string& tile_type, const std::string& state,
+ const std::string& active_state = std::string()) {
+ if (!active_state.empty()) return state == active_state;
+ if (tile_type == "climate") {
+ return !state.empty() && state != "off" && state != "unavailable" && state != "unknown";
+ }
+ if (tile_type == "vacuum") return state == "cleaning" || state == "returning";
+ if (tile_type == "scene" || tile_type == "script") return true;
+ return state == "on";
+ };
+
+ // Cover-aware active state: "closed" = off, anything else (open/opening/closing) = on
+ bool t1_on = (std::string("${TILE1_TYPE}") == "cover") ? (id(tile1_cover_state).state != "closed") : is_active("${TILE1_TYPE}", std::string(id(ha_state_tile1).state.c_str()), "${TILE1_ACTIVE_STATE}");
+ bool t2_on = (std::string("${TILE2_TYPE}") == "cover") ? (id(tile2_cover_state).state != "closed") : is_active("${TILE2_TYPE}", std::string(id(ha_state_tile2).state.c_str()), "${TILE2_ACTIVE_STATE}");
+ bool t3_on = (std::string("${TILE3_TYPE}") == "cover") ? (id(tile3_cover_state).state != "closed") : is_active("${TILE3_TYPE}", std::string(id(ha_state_tile3).state.c_str()), "${TILE3_ACTIVE_STATE}");
+ bool t4_on = (std::string("${TILE4_TYPE}") == "cover") ? (id(tile4_cover_state).state != "closed") : is_active("${TILE4_TYPE}", std::string(id(ha_state_tile4).state.c_str()), "${TILE4_ACTIVE_STATE}");
+ bool t5_on = (std::string("${TILE5_TYPE}") == "cover") ? (id(tile5_cover_state).state != "closed") : is_active("${TILE5_TYPE}", std::string(id(ha_state_tile5).state.c_str()), "${TILE5_ACTIVE_STATE}");
+ bool t6_on = (std::string("${TILE6_TYPE}") == "cover") ? (id(tile6_cover_state).state != "closed") : is_active("${TILE6_TYPE}", std::string(id(ha_state_tile6).state.c_str()), "${TILE6_ACTIVE_STATE}");
+ // Tegel 7/8 hebben geen cover_state-sensor, dus geen cover-tak nodig.
+ bool t7_on = is_active("${TILE7_TYPE}", std::string(id(ha_state_tile7).state.c_str()), "${TILE7_ACTIVE_STATE}");
+ bool t8_on = is_active("${TILE8_TYPE}", std::string(id(ha_state_tile8).state.c_str()), "${TILE8_ACTIVE_STATE}");
+ bool t9_on = is_active("${TILE9_TYPE}", std::string(id(ha_state_tile9).state.c_str()), "${TILE9_ACTIVE_STATE}");
+ bool t10_on = is_active("${TILE10_TYPE}", std::string(id(ha_state_tile10).state.c_str()), "${TILE10_ACTIVE_STATE}");
+
+ set_value_auto(id(t1_value), t1_on, "${TILE1_TYPE}", "${TILE1_VALUE_MODE}",
+ id(tile1_brightness).state, id(tile1_percentage).state, std::string(id(tile1_preset).state.c_str()),
+ id(tile1_climate_current_temperature).state, id(tile1_climate_target_temperature).state, id(tile1_climate_humidity).state,
+ "${TILE1_LABEL_ON}", "${TILE1_LABEL_OFF}");
+
+ set_value_auto(id(t2_value), t2_on, "${TILE2_TYPE}", "${TILE2_VALUE_MODE}",
+ id(tile2_brightness).state, id(tile2_percentage).state, std::string(id(tile2_preset).state.c_str()),
+ id(tile2_climate_current_temperature).state, id(tile2_climate_target_temperature).state, id(tile2_climate_humidity).state,
+ "${TILE2_LABEL_ON}", "${TILE2_LABEL_OFF}");
+
+ set_value_auto(id(t3_value), t3_on, "${TILE3_TYPE}", "${TILE3_VALUE_MODE}",
+ id(tile3_brightness).state, id(tile3_percentage).state, std::string(id(tile3_preset).state.c_str()),
+ id(tile3_climate_current_temperature).state, id(tile3_climate_target_temperature).state, id(tile3_climate_humidity).state,
+ "${TILE3_LABEL_ON}", "${TILE3_LABEL_OFF}");
+
+ set_value_auto(id(t4_value), t4_on, "${TILE4_TYPE}", "${TILE4_VALUE_MODE}",
+ id(tile4_brightness).state, id(tile4_percentage).state, std::string(id(tile4_preset).state.c_str()),
+ id(tile4_climate_current_temperature).state, id(tile4_climate_target_temperature).state, id(tile4_climate_humidity).state,
+ "${TILE4_LABEL_ON}", "${TILE4_LABEL_OFF}");
+
+ set_value_auto(id(t5_value), t5_on, "${TILE5_TYPE}", "${TILE5_VALUE_MODE}",
+ id(tile5_brightness).state, id(tile5_percentage).state, std::string(id(tile5_preset).state.c_str()),
+ id(tile5_climate_current_temperature).state, id(tile5_climate_target_temperature).state, id(tile5_climate_humidity).state,
+ "${TILE5_LABEL_ON}", "${TILE5_LABEL_OFF}");
+
+ set_value_auto(id(t6_value), t6_on, "${TILE6_TYPE}", "${TILE6_VALUE_MODE}",
+ id(tile6_brightness).state, id(tile6_percentage).state, std::string(id(tile6_preset).state.c_str()),
+ id(tile6_climate_current_temperature).state, id(tile6_climate_target_temperature).state, id(tile6_climate_humidity).state,
+ "${TILE6_LABEL_ON}", "${TILE6_LABEL_OFF}");
+
+ // Tegel 7: light met alleen brightness. Tegel 8: puur tekst (Vol/Leeg).
+ set_value_auto(id(t7_value), t7_on, "${TILE7_TYPE}", "${TILE7_VALUE_MODE}",
+ id(tile7_brightness).state, NAN, std::string(),
+ NAN, NAN, NAN,
+ "${TILE7_LABEL_ON}", "${TILE7_LABEL_OFF}");
+
+ set_value_auto(id(t8_value), t8_on, "${TILE8_TYPE}", "${TILE8_VALUE_MODE}",
+ NAN, NAN, std::string(),
+ NAN, NAN, NAN,
+ "${TILE8_LABEL_ON}", "${TILE8_LABEL_OFF}");
+
+ // Tegel 9: kleurenlamp (brightness). Tegel 10: puur tekst (Aan/Uit).
+ set_value_auto(id(t9_value), t9_on, "${TILE9_TYPE}", "${TILE9_VALUE_MODE}",
+ id(tile9_brightness).state, NAN, std::string(),
+ NAN, NAN, NAN,
+ "${TILE9_LABEL_ON}", "${TILE9_LABEL_OFF}");
+
+ set_value_auto(id(t10_value), t10_on, "${TILE10_TYPE}", "${TILE10_VALUE_MODE}",
+ NAN, NAN, std::string(),
+ NAN, NAN, NAN,
+ "${TILE10_LABEL_ON}", "${TILE10_LABEL_OFF}");
+
+ // Missing state is not an off state. Preserve meaningful vacuum states.
+ const char* vacuum_state = id(ha_state_tile6).state.c_str();
+ if (std::string("${TILE6_TYPE}") == "vacuum") {
+ std::string state(vacuum_state);
+ if (state == "cleaning") set_lbl(id(t6_value), "Bezig");
+ else if (state == "docked") set_lbl(id(t6_value), "In dock");
+ else if (state == "returning") set_lbl(id(t6_value), "Naar dock");
+ else if (state == "paused") set_lbl(id(t6_value), "Gepauzeerd");
+ else if (state == "error") set_lbl(id(t6_value), "Fout");
+ else if (!state.empty()) set_lbl(id(t6_value), state);
+ }
+ if (!id(ha_state_tile1).has_state() || id(ha_state_tile1).state == "unknown" || id(ha_state_tile1).state == "unavailable")
+ set_lbl(id(t1_value), "Niet beschikbaar");
+ if (!id(ha_state_tile2).has_state() || id(ha_state_tile2).state == "unknown" || id(ha_state_tile2).state == "unavailable")
+ set_lbl(id(t2_value), "Niet beschikbaar");
+ if (!id(ha_state_tile3).has_state() || id(ha_state_tile3).state == "unknown" || id(ha_state_tile3).state == "unavailable")
+ set_lbl(id(t3_value), "Niet beschikbaar");
+ if (!id(ha_state_tile4).has_state() || id(ha_state_tile4).state == "unknown" || id(ha_state_tile4).state == "unavailable")
+ set_lbl(id(t4_value), "Niet beschikbaar");
+ if (!id(ha_state_tile5).has_state() || id(ha_state_tile5).state == "unknown" || id(ha_state_tile5).state == "unavailable")
+ set_lbl(id(t5_value), "Niet beschikbaar");
+ if (!id(ha_state_tile6).has_state() || id(ha_state_tile6).state == "unknown" || id(ha_state_tile6).state == "unavailable")
+ set_lbl(id(t6_value), "Niet beschikbaar");
+ if (!id(ha_state_tile7).has_state() || id(ha_state_tile7).state == "unknown" || id(ha_state_tile7).state == "unavailable")
+ set_lbl(id(t7_value), "Niet beschikbaar");
+ if (!id(ha_state_tile8).has_state() || id(ha_state_tile8).state == "unknown" || id(ha_state_tile8).state == "unavailable")
+ set_lbl(id(t8_value), "Niet beschikbaar");
+ if (!id(ha_state_tile9).has_state() || id(ha_state_tile9).state == "unknown" || id(ha_state_tile9).state == "unavailable")
+ set_lbl(id(t9_value), "Niet beschikbaar");
+ if (!id(ha_state_tile10).has_state() || id(ha_state_tile10).state == "unknown" || id(ha_state_tile10).state == "unavailable")
+ set_lbl(id(t10_value), "Niet beschikbaar");
+ if (id(active_control_kind) == "climate_temperature" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
+ auto label_for_climate_mode = [](std::string mode) {
+ for (char &c : mode) {
+ if (c >= 'A' && c <= 'Z') c = (char) (c - 'A' + 'a');
+ }
+ if (mode == "off") return std::string("Off");
+ if (mode == "heat") return std::string("Heat");
+ if (mode == "cool") return std::string("Cool");
+ if (mode == "heat_cool") return std::string("Heat/Cool");
+ if (mode == "auto") return std::string("Auto");
+ if (mode == "dry") return std::string("Dry");
+ if (mode == "fan_only") return std::string("Fan");
+ return mode.empty() ? std::string("Auto") : mode;
+ };
+ auto sync_open_climate = [&](const std::string& entity,
+ float current_temperature,
+ float target_temperature,
+ float humidity,
+ const std::string& climate_mode) {
+ if (id(active_entity) != entity) return;
+ auto to_x10 = [](float value, int fallback) -> int {
+ if (isnan(value)) return fallback;
+ return (int) lround(value * 10.0f);
+ };
+
+ id(active_climate_current_x10) = to_x10(current_temperature, id(active_climate_current_x10));
+ id(active_climate_humidity) = isnan(humidity) ? -1 : (int) lround(humidity);
+ if (!climate_mode.empty() && !id(touch_down) &&
+ !(id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000)) {
+ id(active_climate_mode) = climate_mode;
+ lv_label_set_text(id(climate_mode_label), label_for_climate_mode(climate_mode).c_str());
+ }
+
+ const bool editing = id(touch_down) ||
+ (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000);
+ if (!editing) id(climate_edit_pending) = false;
+ int target_x10 = editing ? id(active_climate_target_x10) :
+ to_x10(target_temperature, id(active_climate_target_x10));
+ if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10);
+ if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10);
+ int step_x10 = id(active_climate_step_x10);
+ if (step_x10 < 5) step_x10 = 5;
+ target_x10 = id(active_climate_min_x10) + (int) lround((target_x10 - id(active_climate_min_x10)) / (float) step_x10) * step_x10;
+ if (target_x10 < id(active_climate_min_x10)) target_x10 = id(active_climate_min_x10);
+ if (target_x10 > id(active_climate_max_x10)) target_x10 = id(active_climate_max_x10);
+ id(active_climate_target_x10) = target_x10;
+
+ char current_buf[48];
+ if (isnan(current_temperature)) {
+ snprintf(current_buf, sizeof(current_buf), "Current: --");
+ } else if (id(active_climate_humidity) >= 0) {
+ snprintf(current_buf, sizeof(current_buf), "Current: %.1f° - Humidity: %d %%", current_temperature, id(active_climate_humidity));
+ } else {
+ snprintf(current_buf, sizeof(current_buf), "Current: %.1f°", current_temperature);
+ }
+ lv_label_set_text(id(climate_current_label), current_buf);
+
+ char target_buf[16];
+ snprintf(target_buf, sizeof(target_buf), "%.1f°", id(active_climate_target_x10) / 10.0f);
+ lv_label_set_text(id(climate_target_label), target_buf);
+ lv_arc_set_value(id(climate_arc), id(active_climate_target_x10));
+ };
+
+ if (std::string("${TILE1_TYPE}") == "climate") sync_open_climate("${TILE1_ENTITY}", id(tile1_climate_current_temperature).state, id(tile1_climate_target_temperature).state, id(tile1_climate_humidity).state, std::string(id(tile1_climate_mode).state.c_str()));
+ if (std::string("${TILE2_TYPE}") == "climate") sync_open_climate("${TILE2_ENTITY}", id(tile2_climate_current_temperature).state, id(tile2_climate_target_temperature).state, id(tile2_climate_humidity).state, std::string(id(tile2_climate_mode).state.c_str()));
+ if (std::string("${TILE3_TYPE}") == "climate") sync_open_climate("${TILE3_ENTITY}", id(tile3_climate_current_temperature).state, id(tile3_climate_target_temperature).state, id(tile3_climate_humidity).state, std::string(id(tile3_climate_mode).state.c_str()));
+ if (std::string("${TILE4_TYPE}") == "climate") sync_open_climate("${TILE4_ENTITY}", id(tile4_climate_current_temperature).state, id(tile4_climate_target_temperature).state, id(tile4_climate_humidity).state, std::string(id(tile4_climate_mode).state.c_str()));
+ if (std::string("${TILE5_TYPE}") == "climate") sync_open_climate("${TILE5_ENTITY}", id(tile5_climate_current_temperature).state, id(tile5_climate_target_temperature).state, id(tile5_climate_humidity).state, std::string(id(tile5_climate_mode).state.c_str()));
+ if (std::string("${TILE6_TYPE}") == "climate") sync_open_climate("${TILE6_ENTITY}", id(tile6_climate_current_temperature).state, id(tile6_climate_target_temperature).state, id(tile6_climate_humidity).state, std::string(id(tile6_climate_mode).state.c_str()));
+ }
+
+ {
+ auto now = id(homeassistant_time).now();
+ // Vóór de eerste tijd-sync is now() epoch 0 (1970) — dan geen
+ // misleidende "01:00" tonen, gewoon leeg laten tot er een
+ // echte tijd binnen is (na de eerste Home Assistant-tijdsynchronisatie).
+ if (now.is_valid()) {
+ char buf[10];
+
+ if (std::string("${TIME_24H}") == "true") {
+ snprintf(buf, sizeof(buf), "%02d:%02d", now.hour, now.minute);
+ } else {
+ int hour12 = now.hour % 12;
+ if (hour12 == 0) hour12 = 12;
+ snprintf(buf, sizeof(buf), "%d:%02d %s", hour12, now.minute, now.hour >= 12 ? "PM" : "AM");
+ }
+
+ set_lbl(id(lbl_time), std::string(buf));
+ }
+ }
+
+ // Accentkleur uit het Home Assistant-domein: het stuk voor de punt in
+ // het entity_id. script/scene/button vuur je af (ACCENT_ACTION), een
+ // light is een light (ACCENT_LIGHT), de rest is apparaat.
+ // LET OP: deze helper staat twee keer in dit bestand — hier en in
+ // open_value_overlay. Pas ze samen aan.
+ auto accent_for = [](const std::string& entity_id) -> uint32_t {
+ std::string d = entity_id.substr(0, entity_id.find('.'));
+ if (d == "script" || d == "scene" || d == "button" ||
+ d == "input_button" || d == "automation") return ${ACCENT_ACTION};
+ if (d == "light") return ${ACCENT_LIGHT};
+ return ${ACCENT_DEVICE};
+ };
+
+ auto apply_tile = [&](bool active,
+ const std::string& entity_id,
+ lv_obj_t* tile,
+ lv_obj_t* circle,
+ lv_obj_t* title,
+ lv_obj_t* value,
+ lv_obj_t* icon_lbl,
+ uint32_t circle_active,
+ uint32_t circle_disabled,
+ uint32_t icon_active,
+ uint32_t icon_disabled,
+ uint32_t bg_active,
+ uint32_t bg_disabled,
+ uint32_t title_active,
+ uint32_t title_disabled,
+ uint32_t value_active,
+ uint32_t value_disabled)
+ {
+ static lv_obj_t* cached_tiles[10] = {};
+ static bool cached_active[10] = {};
+ for (int i = 0; i < 10; ++i) {
+ if (cached_tiles[i] == tile) {
+ if (cached_active[i] == active) return;
+ cached_active[i] = active;
+ break;
+ }
+ if (cached_tiles[i] == nullptr) {
+ cached_tiles[i] = tile;
+ cached_active[i] = active;
+ break;
+ }
+ }
+ // ${ACCENT_AUTO} = "bepaal zelf"; een echte hexwaarde wint altijd.
+ uint32_t accent = accent_for(entity_id);
+ if (circle_active == ${ACCENT_AUTO}) circle_active = accent;
+ if (icon_disabled == ${ACCENT_AUTO}) icon_disabled = accent;
+
+ lv_obj_set_style_bg_color(tile, lv_color_hex(active ? bg_active : bg_disabled), 0);
+ lv_obj_set_style_bg_color(circle, lv_color_hex(active ? circle_active : circle_disabled), 0);
+ lv_obj_set_style_text_color(icon_lbl, lv_color_hex(active ? icon_active : icon_disabled), 0);
+ lv_obj_set_style_text_color(title, lv_color_hex(active ? title_active : title_disabled), 0);
+ lv_obj_set_style_text_color(value, lv_color_hex(active ? value_active : value_disabled), 0);
+ };
+
+ apply_tile(t1_on, "${TILE1_ENTITY}",
+ id(tile1), id(tile1_icon_circle), id(t1_title), id(t1_value), id(tile1_icon_lbl),
+ ${TILE1_CIRCLE_ACTIVE_COLOR}, ${TILE1_CIRCLE_DISABLED_COLOR},
+ ${TILE1_ICON_ACTIVE_COLOR}, ${TILE1_ICON_DISABLED_COLOR},
+ ${TILE1_BG_ACTIVE_COLOR}, ${TILE1_BG_DISABLED_COLOR},
+ ${TILE1_TITLE_ACTIVE_COLOR}, ${TILE1_TITLE_DISABLED_COLOR},
+ ${TILE1_VALUE_ACTIVE_COLOR}, ${TILE1_VALUE_DISABLED_COLOR});
+
+ apply_tile(t2_on, "${TILE2_ENTITY}",
+ id(tile2), id(tile2_icon_circle), id(t2_title), id(t2_value), id(tile2_icon_lbl),
+ ${TILE2_CIRCLE_ACTIVE_COLOR}, ${TILE2_CIRCLE_DISABLED_COLOR},
+ ${TILE2_ICON_ACTIVE_COLOR}, ${TILE2_ICON_DISABLED_COLOR},
+ ${TILE2_BG_ACTIVE_COLOR}, ${TILE2_BG_DISABLED_COLOR},
+ ${TILE2_TITLE_ACTIVE_COLOR}, ${TILE2_TITLE_DISABLED_COLOR},
+ ${TILE2_VALUE_ACTIVE_COLOR}, ${TILE2_VALUE_DISABLED_COLOR});
+
+ apply_tile(t3_on, "${TILE3_ENTITY}",
+ id(tile3), id(tile3_icon_circle), id(t3_title), id(t3_value), id(tile3_icon_lbl),
+ ${TILE3_CIRCLE_ACTIVE_COLOR}, ${TILE3_CIRCLE_DISABLED_COLOR},
+ ${TILE3_ICON_ACTIVE_COLOR}, ${TILE3_ICON_DISABLED_COLOR},
+ ${TILE3_BG_ACTIVE_COLOR}, ${TILE3_BG_DISABLED_COLOR},
+ ${TILE3_TITLE_ACTIVE_COLOR}, ${TILE3_TITLE_DISABLED_COLOR},
+ ${TILE3_VALUE_ACTIVE_COLOR}, ${TILE3_VALUE_DISABLED_COLOR});
+
+ apply_tile(t4_on, "${TILE4_ENTITY}",
+ id(tile4), id(tile4_icon_circle), id(t4_title), id(t4_value), id(tile4_icon_lbl),
+ ${TILE4_CIRCLE_ACTIVE_COLOR}, ${TILE4_CIRCLE_DISABLED_COLOR},
+ ${TILE4_ICON_ACTIVE_COLOR}, ${TILE4_ICON_DISABLED_COLOR},
+ ${TILE4_BG_ACTIVE_COLOR}, ${TILE4_BG_DISABLED_COLOR},
+ ${TILE4_TITLE_ACTIVE_COLOR}, ${TILE4_TITLE_DISABLED_COLOR},
+ ${TILE4_VALUE_ACTIVE_COLOR}, ${TILE4_VALUE_DISABLED_COLOR});
+
+ apply_tile(t5_on, "${TILE5_ENTITY}",
+ id(tile5), id(tile5_icon_circle), id(t5_title), id(t5_value), id(tile5_icon_lbl),
+ ${TILE5_CIRCLE_ACTIVE_COLOR}, ${TILE5_CIRCLE_DISABLED_COLOR},
+ ${TILE5_ICON_ACTIVE_COLOR}, ${TILE5_ICON_DISABLED_COLOR},
+ ${TILE5_BG_ACTIVE_COLOR}, ${TILE5_BG_DISABLED_COLOR},
+ ${TILE5_TITLE_ACTIVE_COLOR}, ${TILE5_TITLE_DISABLED_COLOR},
+ ${TILE5_VALUE_ACTIVE_COLOR}, ${TILE5_VALUE_DISABLED_COLOR});
+
+ apply_tile(t6_on, "${TILE6_ENTITY}",
+ id(tile6), id(tile6_icon_circle), id(t6_title), id(t6_value), id(tile6_icon_lbl),
+ ${TILE6_CIRCLE_ACTIVE_COLOR}, ${TILE6_CIRCLE_DISABLED_COLOR},
+ ${TILE6_ICON_ACTIVE_COLOR}, ${TILE6_ICON_DISABLED_COLOR},
+ ${TILE6_BG_ACTIVE_COLOR}, ${TILE6_BG_DISABLED_COLOR},
+ ${TILE6_TITLE_ACTIVE_COLOR}, ${TILE6_TITLE_DISABLED_COLOR},
+ ${TILE6_VALUE_ACTIVE_COLOR}, ${TILE6_VALUE_DISABLED_COLOR});
+
+ apply_tile(t7_on, "${TILE7_ENTITY}",
+ id(tile7), id(tile7_icon_circle), id(t7_title), id(t7_value), id(tile7_icon_lbl),
+ ${TILE7_CIRCLE_ACTIVE_COLOR}, ${TILE7_CIRCLE_DISABLED_COLOR},
+ ${TILE7_ICON_ACTIVE_COLOR}, ${TILE7_ICON_DISABLED_COLOR},
+ ${TILE7_BG_ACTIVE_COLOR}, ${TILE7_BG_DISABLED_COLOR},
+ ${TILE7_TITLE_ACTIVE_COLOR}, ${TILE7_TITLE_DISABLED_COLOR},
+ ${TILE7_VALUE_ACTIVE_COLOR}, ${TILE7_VALUE_DISABLED_COLOR});
+
+ apply_tile(t8_on, "${TILE8_ENTITY}",
+ id(tile8), id(tile8_icon_circle), id(t8_title), id(t8_value), id(tile8_icon_lbl),
+ ${TILE8_CIRCLE_ACTIVE_COLOR}, ${TILE8_CIRCLE_DISABLED_COLOR},
+ ${TILE8_ICON_ACTIVE_COLOR}, ${TILE8_ICON_DISABLED_COLOR},
+ ${TILE8_BG_ACTIVE_COLOR}, ${TILE8_BG_DISABLED_COLOR},
+ ${TILE8_TITLE_ACTIVE_COLOR}, ${TILE8_TITLE_DISABLED_COLOR},
+ ${TILE8_VALUE_ACTIVE_COLOR}, ${TILE8_VALUE_DISABLED_COLOR});
+
+ apply_tile(t9_on, "${TILE9_ENTITY}",
+ id(tile9), id(tile9_icon_circle), id(t9_title), id(t9_value), id(tile9_icon_lbl),
+ ${TILE9_CIRCLE_ACTIVE_COLOR}, ${TILE9_CIRCLE_DISABLED_COLOR},
+ ${TILE9_ICON_ACTIVE_COLOR}, ${TILE9_ICON_DISABLED_COLOR},
+ ${TILE9_BG_ACTIVE_COLOR}, ${TILE9_BG_DISABLED_COLOR},
+ ${TILE9_TITLE_ACTIVE_COLOR}, ${TILE9_TITLE_DISABLED_COLOR},
+ ${TILE9_VALUE_ACTIVE_COLOR}, ${TILE9_VALUE_DISABLED_COLOR});
+
+ apply_tile(t10_on, "${TILE10_ENTITY}",
+ id(tile10), id(tile10_icon_circle), id(t10_title), id(t10_value), id(tile10_icon_lbl),
+ ${TILE10_CIRCLE_ACTIVE_COLOR}, ${TILE10_CIRCLE_DISABLED_COLOR},
+ ${TILE10_ICON_ACTIVE_COLOR}, ${TILE10_ICON_DISABLED_COLOR},
+ ${TILE10_BG_ACTIVE_COLOR}, ${TILE10_BG_DISABLED_COLOR},
+ ${TILE10_TITLE_ACTIVE_COLOR}, ${TILE10_TITLE_DISABLED_COLOR},
+ ${TILE10_VALUE_ACTIVE_COLOR}, ${TILE10_VALUE_DISABLED_COLOR});
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..0bb7941
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+# Tested toolchain; do not silently upgrade during commissioning.
+esphome==2026.6.2
diff --git a/secrets.yaml.example b/secrets.yaml.example
new file mode 100644
index 0000000..c143c32
--- /dev/null
+++ b/secrets.yaml.example
@@ -0,0 +1,6 @@
+# tools/new_device.py maakt unieke sleutels; bewerk wifi daarna lokaal.
+wifi_ssid: "VUL_JE_2_4_GHZ_WIFI_IN"
+wifi_password: "VUL_JE_WIFI_WACHTWOORD_IN"
+api_encryption_key: "WORDT_UNIEK_GEGENEREERD"
+ota_password: "WORDT_UNIEK_GEGENEREERD"
+ap_password: "WORDT_UNIEK_GEGENEREERD"
diff --git a/tests/test_cyd_ui.cpp b/tests/test_cyd_ui.cpp
new file mode 100644
index 0000000..7e6c251
--- /dev/null
+++ b/tests/test_cyd_ui.cpp
@@ -0,0 +1,35 @@
+#include "../cyd_ui.h"
+#include
+#include
+int main() {
+ using cyd::quantize_temperature;
+ assert(quantize_temperature(223, 160, 300, 5) == 225);
+ assert(quantize_temperature(221, 160, 300, 5) == 220);
+ assert(quantize_temperature(-1, 160, 300, 5) == 160);
+ assert(quantize_temperature(999, 160, 299, 5) == 299);
+ assert(quantize_temperature(201, 160, 300, 0) == 201);
+ cyd::TouchGuard g;
+ g.begin(100);
+ assert(!g.accept(120, 1)); // resistive noise pulse
+ assert(g.accept(180, 1));
+ assert(!g.accept(800, 1)); // same physical gesture cannot fire twice
+ g.begin(200);
+ assert(!g.accept(280, 1)); // contact bounce cannot repeat the action
+ g.begin(300);
+ assert(g.accept(380, 2)); // another tile is still responsive
+ g.begin(1000);
+ assert(g.accept(1080, 2));
+ cyd::TouchGuard moving;
+ moving.begin(100, 50, 50);
+ moving.update(52, 48);
+ assert(moving.accept(180, 1)); // small resistive jitter remains usable
+ moving.begin(1000, 50, 50);
+ moving.update(50, 80);
+ moving.update(50, 50);
+ assert(!moving.accept(1200, 2)); // excursion stays cancelled, even on return
+ cyd::TouchGuard rollover;
+ rollover.begin(std::numeric_limits::max() - 30);
+ assert(rollover.accept(50, 1)); // millis wraps after 49 days
+ rollover.begin(60);
+ assert(!rollover.accept(130, 1));
+}
diff --git a/tests/test_layout.py b/tests/test_layout.py
new file mode 100644
index 0000000..bc24345
--- /dev/null
+++ b/tests/test_layout.py
@@ -0,0 +1,59 @@
+"""Regression checks for the 320x240 page geometry and event guards."""
+from pathlib import Path
+import re
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = (ROOT / 'home-like-2432s028.yaml').read_text()
+VALUES = dict(re.findall(r'^ (\w+): "([^"]*)"', SOURCE, re.M))
+
+class LayoutTests(unittest.TestCase):
+ def test_visible_rows_fit_viewport_and_leave_navigation_clear(self):
+ v = lambda k: int(VALUES[k])
+ rectangles = []
+ for row in range(1, 4):
+ for col in range(1, 3):
+ x, y = v(f'GRID_COL{col}_X'), v('SCROLL_Y') + v(f'GRID_ROW{row}_Y')
+ w, h = v('TILE_W'), v('TILE_H')
+ self.assertGreaterEqual(x, 0)
+ self.assertLessEqual(x + w, v('DISPLAY_W'))
+ self.assertLessEqual(y + h, v('SCROLL_Y') + v('SCROLL_H'))
+ self.assertLessEqual(y + h, v('DISPLAY_H') - 34)
+ for xx, yy, ww, hh in rectangles:
+ self.assertFalse(x < xx + ww and x + w > xx and y < yy + hh and y + h > yy)
+ rectangles.append((x, y, w, h))
+
+ def test_each_tile_filters_both_events_before_side_effects(self):
+ for n in range(1, 11):
+ start = SOURCE.index(f' id: tile{n}\n')
+ next_obj = re.search(r'^ - obj:', SOURCE[start:], re.M)
+ end = start + next_obj.start() if next_obj else len(SOURCE)
+ block = SOURCE[start:end]
+ for event in ('short_click', 'long_press'):
+ match = re.search(r'on_' + event + r':\s+- if:\s+condition:\s+lambda: (.*?)\n', block)
+ self.assertIsNotNone(match, (n, event))
+ self.assertIn(f'cyd::touch_guard.accept(millis(), {n})', match[1])
+
+ def test_navigation_is_above_grid_but_below_modal_overlays(self):
+ grid = SOURCE.index(' id: tile_scroll')
+ nav = SOURCE.index(' id: page_prev')
+ modal = SOURCE.index(' id: brightness_overlay')
+ self.assertLess(grid, nav)
+ self.assertLess(nav, modal)
+
+ def test_pagination_is_conditional_and_excess_tiles_are_hidden(self):
+ block = SOURCE.split(' - id: show_tile_page\n', 1)[1].split(' - id: wake_display', 1)[0]
+ self.assertIn('const bool paginated = count > 6;', block)
+ self.assertIn('i < count &&', block)
+ self.assertIn('if (!paginated) id(tile_page) = 0;', block)
+ self.assertIn('id(page_prev), id(page_next), id(page_number)', block)
+ self.assertIn('else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN);', block)
+
+ def test_self_test_cannot_call_a_home_assistant_action(self):
+ block = SOURCE.split(' - id: ui_self_test\n', 1)[1].split(' - id: show_tile_page', 1)[0]
+ self.assertNotIn('homeassistant.action:', block)
+ self.assertNotIn('do_tile_action', block)
+ self.assertNotIn('climate_target_commit', block)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py
new file mode 100644
index 0000000..22f9bf0
--- /dev/null
+++ b/tests/test_onboarding.py
@@ -0,0 +1,126 @@
+"""Offline regression tests for installation, calibration and safe export."""
+import copy
+from pathlib import Path
+import re
+import sys
+import tempfile
+import unittest
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'tools'))
+import calibrate
+import new_device
+import export_bundle
+
+
+def measurements():
+ points = []
+ for name, (x, y) in calibrate.TARGETS:
+ rx = round(280 + y * 3580 / 240)
+ ry = round(340 + x * 3520 / 320)
+ samples = [dict(raw_x=rx+d, raw_y=ry+d, native_x=239-y,
+ native_y=x, pressure=1500) for d in (-2, 0, 2)]
+ points.append(dict(name=name, screen=[x, y], samples=samples))
+ return dict(schema=1, screen=[320, 240], points=points)
+
+
+class CalibrationTests(unittest.TestCase):
+ def test_identity_fit_and_independent_verification(self):
+ coefficients, report = calibrate.fit(measurements())
+ self.assertAlmostEqual(coefficients[0], 1, places=3)
+ self.assertAlmostEqual(coefficients[4], 1, places=3)
+ self.assertTrue(all(p['error_px'] < 1 for p in report))
+ self.assertEqual(len(calibrate.verify(measurements())), 5)
+
+ def test_affine_skew_is_corrected(self):
+ data = measurements()
+ for point in data['points']:
+ for s in point['samples']:
+ x, y = s['raw_x'], s['raw_y']
+ s['raw_x'] = round(.9*x - .03*y + 190)
+ s['raw_y'] = round(.04*x + .95*y + 50)
+ _, report = calibrate.fit(data)
+ self.assertTrue(all(p['error_px'] < 1 for p in report))
+
+ def test_center_is_not_used_to_hide_bad_fit(self):
+ data = measurements()
+ for s in data['points'][4]['samples']: s['raw_x'] += 600
+ with self.assertRaisesRegex(ValueError, 'midden'): calibrate.fit(data)
+
+ def test_jitter_rejected(self):
+ data = measurements()
+ data['points'][0]['samples'][0]['raw_x'] += 700
+ with self.assertRaises(ValueError): calibrate.fit(data)
+
+ def test_repeated_corner_rejected(self):
+ data = measurements()
+ for p in data['points']: p['samples'] = copy.deepcopy(data['points'][0]['samples'])
+ with self.assertRaises(ValueError): calibrate.fit(data)
+
+ def test_wrong_firmware_coordinates_rejected(self):
+ data = measurements()
+ for s in data['points'][0]['samples']: s['native_y'] += 40
+ with self.assertRaises(ValueError): calibrate.verify(data)
+
+ def test_capture_requires_isolated_mode(self):
+ line = 'press native=20,30 raw=500,600 pressure=1500 calibration='
+ self.assertEqual(calibrate.parse_touch(line+'1')['raw_x'], 500)
+ with self.assertRaises(ValueError): calibrate.parse_touch(line+'0')
+ self.assertIsNone(calibrate.parse_touch('unrelated log'))
+
+ def test_wrong_screen_or_order_rejected(self):
+ for field in ('screen', 'order'):
+ data = measurements()
+ if field == 'screen': data['screen'] = [240,320]
+ else: data['points'].reverse()
+ with self.assertRaises(ValueError): calibrate.fit(data)
+
+ def test_output_requires_explicit_replace_and_preserves_backup(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp)/'calibration.yaml'
+ calibrate.write_file(path, 'old')
+ with self.assertRaises(ValueError): calibrate.write_file(path, 'new')
+ calibrate.write_file(path, 'new', replace=True)
+ self.assertEqual(path.read_text(), 'new')
+ self.assertEqual(next(Path(tmp).glob('*.bak-*')).read_text(), 'old')
+
+
+class InstallationTests(unittest.TestCase):
+ def test_unique_secrets_and_no_overwrite(self):
+ with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
+ new_device.create(a, 'display-a', 'Display A')
+ new_device.create(b, 'display-b', 'Display B')
+ original = (Path(a)/'secrets.yaml').read_text()
+ self.assertNotEqual(original, (Path(b)/'secrets.yaml').read_text())
+ self.assertNotIn('WORDT_UNIEK_GEGENEREERD', original)
+ with self.assertRaises(ValueError): new_device.create(a, 'new', 'New')
+ self.assertEqual(original, (Path(a)/'secrets.yaml').read_text())
+
+ def test_invalid_device_name(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ for name in ('../oops', 'UPPER', 'has space', 'trailing-'):
+ with self.assertRaises(ValueError): new_device.create(tmp, name, 'Test')
+ self.assertEqual(list(Path(tmp).iterdir()), [])
+
+ def test_export_is_neutral_and_has_required_sources(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ output = Path(tmp)/'starter.zip'
+ export_bundle.export(ROOT, output)
+ with zipfile.ZipFile(output) as archive:
+ names = archive.namelist()
+ for forbidden in ('secrets.yaml', 'device.yaml', 'calibration.yaml', '.git/', '.esphome/', '.log', '.bin', '.json'):
+ self.assertFalse(any(('/'+forbidden in n if forbidden.endswith('/') else n.endswith(forbidden) if forbidden.startswith('.') else n.endswith('/'+forbidden)) for n in names), forbidden)
+ self.assertIn('cyd-display/.gitignore', names)
+ base = archive.read('cyd-display/home-like-2432s028.yaml').decode()
+ values = dict(re.findall(r'^ (\w+): "([^"]*)"', base, re.M))
+ self.assertEqual(values['TOUCH_AFFINE_XX'], '1.000000000')
+ self.assertEqual(values['DIRECT_ACTIONS'], 'false')
+ for i in range(1,11):
+ self.assertRegex(values[f'TILE{i}_ENTITY'], r'\.(voorbeeld_|ongebruikt_)')
+ archive.extractall(tmp)
+ new_device.create(Path(tmp)/'cyd-display', 'fresh-board', 'Fresh board')
+ with self.assertRaises(FileExistsError): export_bundle.export(ROOT, output)
+
+
+if __name__ == '__main__': unittest.main()
diff --git a/tests/test_touch_filter.cpp b/tests/test_touch_filter.cpp
new file mode 100644
index 0000000..f0f047d
--- /dev/null
+++ b/tests/test_touch_filter.cpp
@@ -0,0 +1,43 @@
+#include "../components/xpt2046/touchscreen/touch_filter.h"
+#include
+int main() {
+ using F = esphome::xpt2046::TouchFilter;
+ esphome::xpt2046::RawCorrection correction;
+ assert(correction.x(100, 200) == 100 && correction.y(100, 200) == 200);
+ correction.set(1.073915801f, 0.065912977f, -345.080450614f, 0.044372873f, 0.983997898f, -69.394322384f);
+ const int corners[][4] = {{830,556,20,20}, {544,3461,299,20},
+ {3410,3391,299,219}, {3492,372,20,219}};
+ for (const auto &p : corners) {
+ const double sx = (correction.y(p[0], p[1]) - 274) * 320.0 / 3335;
+ const double sy = (correction.x(p[0], p[1]) - 225) * 240.0 / 3575;
+ assert(std::abs(sx - p[2]) < 5 && std::abs(sy - p[3]) < 5);
+ }
+ assert(correction.x(-30000, -30000) == 0);
+ assert(correction.y(30000, 30000) == 4095);
+ F f;
+ // Measured bad top-left first samples must not reach LVGL immediately.
+ assert(f.sample(true, 2067, 1873) == F::HOLD);
+ assert(f.sample(true, 535, 500) == F::HOLD);
+ assert(f.sample(true, 550, 510) == F::HOLD);
+ assert(f.sample(true, 530, 495) == F::POSITION);
+ assert(f.x() >= 530 && f.x() <= 550);
+ assert(f.y() >= 495 && f.y() <= 510);
+ // A far-away sample cannot move the pointer to another button.
+ assert(f.sample(true, 3000, 3000) == F::HOLD);
+ assert(f.sample(true, 540, 500) == F::POSITION);
+ assert(f.x() < 600);
+ // One dropped pressure sample doesn't create a second press/release.
+ assert(f.sample(false, 0, 0) == F::HOLD);
+ assert(f.sample(true, 550, 500) == F::POSITION);
+ // A deliberate drag still works, including a large consistent movement.
+ assert(f.sample(true, 700, 650) == F::POSITION);
+ assert(f.sample(true, 2000, 2000) == F::HOLD);
+ assert(f.sample(true, 2010, 2005) == F::HOLD);
+ assert(f.sample(true, 1990, 2000) == F::POSITION);
+ assert(f.x() > 1900);
+ assert(f.sample(false, 0, 0) == F::HOLD);
+ assert(f.sample(false, 0, 0) == F::RELEASE);
+ // A lone ghost pulse must never start a gesture.
+ assert(f.sample(true, 3900, 100) == F::HOLD);
+ assert(f.sample(false, 0, 0) == F::RELEASE);
+}
diff --git a/tools/calibrate.py b/tools/calibrate.py
new file mode 100644
index 0000000..486b5c2
--- /dev/null
+++ b/tools/calibrate.py
@@ -0,0 +1,213 @@
+"""USB calibration: guided capture, affine fit with center hold-out, verification.
+
+No network or Home Assistant access. The device must already run the firmware
+with CALIBRATION_ON_BOOT=true (or have its isolated diagnostic page opened).
+"""
+import argparse
+from datetime import datetime, timezone
+import json
+import math
+import os
+from pathlib import Path
+import re
+import statistics
+import time
+
+TARGETS = [('linksboven', (20, 20)), ('rechtsboven', (299, 20)),
+ ('rechtsonder', (299, 219)), ('linksonder', (20, 219)), ('midden', (160, 120))]
+BOUNDS = (280, 3860, 340, 3860)
+PATTERN = re.compile(r'press native=(\d+),(\d+) raw=(\d+),(\d+) pressure=(\d+) calibration=([01])')
+
+
+def parse_touch(line):
+ match = PATTERN.search(line)
+ if not match:
+ return None
+ nx, ny, rx, ry, pressure, isolated = map(int, match.groups())
+ if not isolated:
+ raise ValueError('Geen geïsoleerd meetscherm: start met CALIBRATION_ON_BOOT=true. Geen data opgeslagen.')
+ return dict(native_x=nx, native_y=ny, raw_x=rx, raw_y=ry, pressure=pressure)
+
+
+def write_file(path, contents, replace=False):
+ path = Path(path)
+ if path.exists():
+ if not replace:
+ raise ValueError(f'{path} bestaat al; kies een andere naam of expliciet --replace.')
+ backup = path.with_name(path.name + '.bak-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
+ backup.write_bytes(path.read_bytes())
+ # Write completely before replacing an existing configuration.
+ temporary = path.with_name(path.name + '.tmp-' + str(os.getpid()))
+ try:
+ with temporary.open('x', encoding='utf-8') as handle:
+ handle.write(contents)
+ os.replace(temporary, path)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def validate(data):
+ if data.get('schema') != 1 or data.get('screen') != [320, 240]:
+ raise ValueError('Verwacht schema 1 en landschap 320×240; andere oriëntaties zijn niet ondersteund.')
+ points = data.get('points', [])
+ if len(points) != len(TARGETS):
+ raise ValueError('Alle vier hoeken én het onafhankelijke middenpunt zijn nodig.')
+ for point, (name, xy) in zip(points, TARGETS):
+ if point.get('name') != name or point.get('screen') != list(xy):
+ raise ValueError('Meetpunten/volgorde komen niet overeen met de wizard.')
+ if len(point.get('samples', [])) < 3:
+ raise ValueError(f'{name}: minimaal drie afzonderlijke tikken nodig.')
+ for sample in point['samples']:
+ for key, maximum in [('raw_x', 4095), ('raw_y', 4095), ('native_x', 239), ('native_y', 319), ('pressure', 8190)]:
+ value = sample.get(key)
+ if type(value) is not int or not 0 <= value <= maximum:
+ raise ValueError(f'{name}: ongeldige {key}.')
+ return points
+
+
+def median_raw(point):
+ return tuple(statistics.median(s[key] for s in point['samples']) for key in ('raw_x', 'raw_y'))
+
+
+def least_squares(rows, values):
+ matrix = [[sum(row[i] * row[j] for row in rows) for j in range(3)] +
+ [sum(row[i] * value for row, value in zip(rows, values))] for i in range(3)]
+ for column in range(3):
+ pivot = max(range(column, 3), key=lambda row: abs(matrix[row][column]))
+ matrix[column], matrix[pivot] = matrix[pivot], matrix[column]
+ scale = matrix[column][column]
+ if abs(scale) < 1e-9:
+ raise ValueError('Meetpunten zijn niet onafhankelijk; waarschijnlijk dezelfde hoek meermaals gemeten.')
+ matrix[column] = [v / scale for v in matrix[column]]
+ for row in range(3):
+ if row != column:
+ factor = matrix[row][column]
+ matrix[row] = [a - factor * b for a, b in zip(matrix[row], matrix[column])]
+ return [matrix[i][3] for i in range(3)]
+
+
+def project(coefficients, rx, ry):
+ xx, xy, xc, yx, yy, yc = coefficients
+ xmin, xmax, ymin, ymax = BOUNDS
+ return ((yx * rx + yy * ry + yc - ymin) * 320 / (ymax - ymin),
+ (xx * rx + xy * ry + xc - xmin) * 240 / (xmax - xmin))
+
+
+def fit(data, tolerance=12):
+ points = validate(data)
+ medians = [median_raw(p) for p in points]
+ # Scale inputs to avoid poorly conditioned normal equations on 12-bit ADC data.
+ rows = [(x / 4095, y / 4095, 1) for x, y in medians[:4]]
+ xmin, xmax, ymin, ymax = BOUNDS
+ values_x = [xmin + p['screen'][1] * (xmax - xmin) / 240 for p in points[:4]]
+ values_y = [ymin + p['screen'][0] * (ymax - ymin) / 320 for p in points[:4]]
+ cx, cy = least_squares(rows, values_x), least_squares(rows, values_y)
+ coefficients = [cx[0] / 4095, cx[1] / 4095, cx[2], cy[0] / 4095, cy[1] / 4095, cy[2]]
+ if not all(math.isfinite(v) for v in coefficients):
+ raise ValueError('Ongeldige kalibratiecoëfficiënten.')
+ # Reject captures that amplify small sensor jitter into huge screen movements.
+ if max(abs(v) for v in (coefficients[0], coefficients[1], coefficients[3], coefficients[4])) > 4:
+ raise ValueError('Te klein meetbereik; tik de echte schermhoeken aan.')
+ report = []
+ for point, median in zip(points, medians):
+ predicted = project(coefficients, *median)
+ error = math.dist(predicted, point['screen'])
+ spread = max(math.dist(project(coefficients, s['raw_x'], s['raw_y']), predicted) for s in point['samples'])
+ report.append(dict(name=point['name'], median=list(median), error_px=round(error, 2), spread_px=round(spread, 2)))
+ if error > tolerance or spread > 18:
+ raise ValueError(f"{point['name']}: fout {error:.1f}px, spreiding {spread:.1f}px. "
+ 'Meet opnieuw; een instabiel paneel niet wegkalibreren.')
+ return coefficients, report
+
+
+def calibration_yaml(coefficients):
+ names = ['TOUCH_AFFINE_XX', 'TOUCH_AFFINE_XY', 'TOUCH_AFFINE_XC', 'TOUCH_AFFINE_YX', 'TOUCH_AFFINE_YY', 'TOUCH_AFFINE_YC']
+ lines = ['# Gemeten met tools/calibrate.py; uitsluitend voor dit fysieke paneel.', 'substitutions:']
+ for name, value in zip(['TOUCH_CAL_X_MIN', 'TOUCH_CAL_X_MAX', 'TOUCH_CAL_Y_MIN', 'TOUCH_CAL_Y_MAX'], BOUNDS):
+ lines.append(f' {name}: "{value}"')
+ lines += [f' {name}: "{value:.9f}"' for name, value in zip(names, coefficients)]
+ return '\n'.join(lines) + '\n'
+
+
+def verify(data, tolerance=12):
+ results = []
+ for point in validate(data):
+ measured = [(s['native_y'], 239 - s['native_x']) for s in point['samples']]
+ center = tuple(statistics.median(p[i] for p in measured) for i in (0, 1))
+ error = math.dist(center, point['screen'])
+ spread = max(math.dist(p, center) for p in measured)
+ results.append(dict(name=point['name'], error_px=round(error, 2), spread_px=round(spread, 2)))
+ if error > tolerance or spread > 18:
+ raise ValueError(f"{point['name']}: echte firmwarecoördinaten wijken {error:.1f}px af (spreiding {spread:.1f}px).")
+ return results
+
+
+def capture(args):
+ import serial
+ if args.output.exists():
+ raise ValueError('Meetbestand bestaat al; kies een nieuwe naam.')
+ data = dict(schema=1, device_name=args.device_name, screen=[320, 240],
+ created_at=datetime.now(timezone.utc).isoformat(), points=[])
+ print('Alleen voor 320×240 / LVGL 90° / swap_xy=false / mirror_x=true / mirror_y=false.')
+ print('Het scherm moet de VIJF kruisjes tonen. Sluit andere seriële loglezers af.')
+ with serial.Serial(args.port, 115200, timeout=0.2) as port:
+ for name, xy in TARGETS:
+ input(f'\nVolgende punt: {name} {xy}. Druk ENTER en tik daarna alleen dit kruisje {args.samples} keer: ')
+ port.reset_input_buffer()
+ samples = []
+ deadline = time.monotonic() + args.timeout
+ last = -math.inf
+ while len(samples) < args.samples and time.monotonic() < deadline:
+ sample = parse_touch(port.readline().decode('utf-8', errors='replace'))
+ if sample and time.monotonic() - last >= 0.4:
+ samples.append(sample)
+ last = time.monotonic()
+ print(f' {name}: {len(samples)}/{args.samples}', flush=True)
+ if len(samples) != args.samples:
+ raise ValueError(f'{name}: te weinig geldige tikken. Controleer de firmware, het meetscherm en USB-poort.')
+ data['points'].append(dict(name=name, screen=list(xy), samples=samples))
+ write_file(args.output, json.dumps(data, indent=2) + '\n')
+ print(f'Meting opgeslagen: {args.output}. Het script heeft niets geflasht of aangestuurd.')
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest='command', required=True)
+ c = commands.add_parser('capture', help='Lees geleide tikken via USB; firmware moet al het meetscherm tonen.')
+ c.add_argument('--port', required=True)
+ c.add_argument('--output', type=Path, required=True)
+ c.add_argument('--device-name', default='onbekend')
+ c.add_argument('--samples', type=int, choices=range(3, 10), default=3)
+ c.add_argument('--timeout', type=float, default=60)
+ for name in ('fit', 'verify'):
+ sub = commands.add_parser(name)
+ sub.add_argument('input', type=Path)
+ sub.add_argument('--tolerance', type=float, default=12)
+ if name == 'fit':
+ sub.add_argument('--output', type=Path, default=Path('calibration.proposed.yaml'))
+ sub.add_argument('--replace', action='store_true')
+ args = parser.parse_args()
+ try:
+ if args.command == 'capture':
+ if args.timeout <= 0: raise ValueError('Timeout moet positief zijn.')
+ capture(args)
+ else:
+ if args.tolerance <= 0 or not math.isfinite(args.tolerance): raise ValueError('Tolerantie moet eindig en positief zijn.')
+ data = json.loads(args.input.read_text())
+ if args.command == 'fit':
+ coefficients, report = fit(data, args.tolerance)
+ write_file(args.output, calibration_yaml(coefficients), args.replace)
+ print(f'Kalibratie geschreven: {args.output}. Het middenpunt is onafhankelijk gecontroleerd.')
+ else:
+ report = verify(data, args.tolerance)
+ print('PASS: onafhankelijke fysieke controle van de geflashte coördinaten.')
+ for item in report:
+ print(f"{item['name']}: fout={item['error_px']}px, spreiding={item['spread_px']}px")
+ except (ValueError, OSError, KeyError, TypeError, EOFError) as exc:
+ parser.exit(1, f'Fout: {exc}\n')
+ except KeyboardInterrupt:
+ parser.exit(130, 'Afgebroken; geen gedeeltelijke meting opgeslagen.\n')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/export_bundle.py b/tools/export_bundle.py
new file mode 100644
index 0000000..e6ca1e9
--- /dev/null
+++ b/tools/export_bundle.py
@@ -0,0 +1,58 @@
+"""Export an allowlisted, neutral starter ZIP (never copies local secrets/logs)."""
+import argparse
+from pathlib import Path
+import re
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+FILES = ['.gitignore', 'README.md', 'AGENTS.md', 'requirements.txt', 'device.example.yaml',
+ 'calibration.example.yaml', 'secrets.yaml.example', 'cyd_ui.h', 'LICENSE',
+ 'TILE_CONFIGURATION.md', 'CYD_STABILITY.md', 'TEST_RESULTS.md']
+FOLDERS = ['docs', 'tools', 'tests', 'components', 'diagnostics', 'fonts']
+SUFFIXES = {'.py', '.cpp', '.h', '.md', '.ttf', '.txt'}
+
+
+def neutral_base(root):
+ defaults = '\n'.join((root / name).read_text() for name in ['device.example.yaml', 'calibration.example.yaml'])
+ replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M))
+ base = (root / 'home-like-2432s028.yaml').read_text()
+ def replace(match):
+ key = match[1]
+ return f' {key}: {replacements.get(key, match[2])}'
+ return re.sub(r'^ (\w+): ("[^"\n]*")', replace, base, flags=re.M)
+
+
+def export(root, output):
+ root, output = Path(root).resolve(), Path(output)
+ paths = [root / name for name in FILES]
+ for folder in FOLDERS:
+ for path in (root / folder).rglob('*'):
+ if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES or path.name == 'LICENSE'):
+ paths.append(path)
+ for path in paths:
+ if path.is_symlink() or not path.resolve().is_relative_to(root):
+ raise ValueError(f'Geen bestanden buiten de bronmap of symlinks toegestaan: {path}')
+ if not path.is_file():
+ raise ValueError(f'Ontbrekend bundelbestand: {path}')
+ output.parent.mkdir(parents=True, exist_ok=True)
+ # Exclusive mode refuses to replace a previously delivered bundle.
+ with zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr('cyd-display/home-like-2432s028.yaml', neutral_base(root))
+ for path in sorted(set(paths)):
+ archive.write(path, 'cyd-display/' + path.relative_to(root).as_posix())
+ return output
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--output', type=Path, default=ROOT / 'dist' / 'cyd-starter.zip')
+ args = parser.parse_args()
+ try:
+ print(f'Deel deze ZIP: {export(ROOT, args.output)}')
+ print('Geen lokale secrets, device.yaml, kalibratiemetingen, logs of firmwarebackups opgenomen.')
+ except (OSError, ValueError) as exc:
+ parser.exit(1, f'Fout: {exc}\n')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/new_device.py b/tools/new_device.py
new file mode 100644
index 0000000..028cb02
--- /dev/null
+++ b/tools/new_device.py
@@ -0,0 +1,61 @@
+"""Create local device, calibration and secrets files without overwriting any."""
+import argparse
+import base64
+import os
+from pathlib import Path
+import re
+import secrets
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def create(directory, name, friendly_name):
+ if not re.fullmatch(r'[a-z][a-z0-9-]{0,30}', name) or name.endswith('-'):
+ raise ValueError('Gebruik een unieke naam van 1–31 tekens: kleine letters, cijfers en koppeltekens.')
+ import json
+ device = (ROOT / 'device.example.yaml').read_text().replace('"cyd-new"', json.dumps(name), 1)
+ device = device.replace('"Mijn CYD"', json.dumps(friendly_name, ensure_ascii=False), 1)
+ secret_text = (ROOT / 'secrets.yaml.example').read_text()
+ replacements = [base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(18)]
+ for value in replacements:
+ secret_text = secret_text.replace('WORDT_UNIEK_GEGENEREERD', value, 1)
+ files = {'device.yaml': device, 'calibration.yaml': (ROOT / 'calibration.example.yaml').read_text(),
+ 'secrets.yaml': secret_text}
+ directory = Path(directory)
+ if not directory.is_dir():
+ raise ValueError('De doelmap moet al bestaan en de uitgepakte code bevatten.')
+ for name in files:
+ if (directory / name).exists():
+ raise ValueError(f'{directory / name} bestaat al; niets overschreven. Gebruik een nieuwe kopie van de map.')
+ created = []
+ try:
+ for name, contents in files.items():
+ path = directory / name
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ created.append(path)
+ with os.fdopen(fd, 'w', encoding='utf-8') as handle:
+ handle.write(contents)
+ except Exception:
+ for path in created:
+ path.unlink(missing_ok=True)
+ raise
+ return created
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--name', required=True, help='Unieke ESPHome-naam, bijvoorbeeld display-keuken')
+ parser.add_argument('--friendly-name', default='Mijn CYD')
+ parser.add_argument('--directory', type=Path, default=ROOT)
+ args = parser.parse_args()
+ try:
+ for path in create(args.directory, args.name, args.friendly_name):
+ print(f'Gemaakt: {path}')
+ print('Vul wifi lokaal in secrets.yaml in. Sleutels zijn uniek gegenereerd en worden niet getoond.')
+ print('Volg nu README.md; flash device.yaml, niet rechtstreeks de basisconfiguratie.')
+ except (ValueError, OSError) as exc:
+ parser.exit(1, f'Fout: {exc}\n')
+
+
+if __name__ == '__main__':
+ main()