From 94546cf727756f50032830ffa1b7b991c0a21ced Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sat, 12 Sep 2026 10:18:49 +0200 Subject: [PATCH] Add stable CYD firmware, USB calibration wizard and complete setup guide --- .gitignore | 22 + .idea/.gitignore | 10 - .idea/esphome-cyd-display.iml | 8 - .idea/modules.xml | 8 - .idea/php.xml | 19 - AGENTS.md | 48 + CYD_STABILITY.md | 120 + LICENSE | 21 + README.md | 221 + TEST_RESULTS.md | 68 + TILE_CONFIGURATION.md | 8 + calibration.example.yaml | 13 + components/xpt2046/LICENSE | 709 ++ components/xpt2046/README.md | 21 + components/xpt2046/__init__.py | 5 + components/xpt2046/touchscreen/__init__.py | 43 + components/xpt2046/touchscreen/touch_filter.h | 68 + components/xpt2046/touchscreen/xpt2046.cpp | 117 + components/xpt2046/touchscreen/xpt2046.h | 47 + cyd_ui.h | 46 + device.example.yaml | 215 + diagnostics/capture_serial.py | 19 + diagnostics/control_ui.py | 32 + diagnostics/run_ui_test.py | 48 + docs/ACCEPTATIE.md | 81 + docs/CALIBREREN.md | 127 + docs/PROBLEMEN.md | 32 + docs/TEGELS.md | 144 + fonts/MDI-LICENSE.txt | 20 + fonts/Roboto-400.ttf | Bin 0 -> 123720 bytes fonts/Roboto-500.ttf | Bin 0 -> 123904 bytes fonts/Roboto-700.ttf | Bin 0 -> 124292 bytes fonts/Roboto-OFL.txt | 93 + home-like-2432s028.yaml | 6321 +++++++++++++++++ requirements.txt | 2 + secrets.yaml.example | 6 + tests/test_cyd_ui.cpp | 35 + tests/test_layout.py | 59 + tests/test_onboarding.py | 126 + tests/test_touch_filter.cpp | 43 + tools/calibrate.py | 213 + tools/export_bundle.py | 58 + tools/new_device.py | 61 + 43 files changed, 9312 insertions(+), 45 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/esphome-cyd-display.iml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/php.xml create mode 100644 AGENTS.md create mode 100644 CYD_STABILITY.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 TEST_RESULTS.md create mode 100644 calibration.example.yaml create mode 100644 components/xpt2046/LICENSE create mode 100644 components/xpt2046/README.md create mode 100644 components/xpt2046/__init__.py create mode 100644 components/xpt2046/touchscreen/__init__.py create mode 100644 components/xpt2046/touchscreen/touch_filter.h create mode 100644 components/xpt2046/touchscreen/xpt2046.cpp create mode 100644 components/xpt2046/touchscreen/xpt2046.h create mode 100644 cyd_ui.h create mode 100644 device.example.yaml create mode 100644 diagnostics/capture_serial.py create mode 100644 diagnostics/control_ui.py create mode 100644 diagnostics/run_ui_test.py create mode 100644 docs/ACCEPTATIE.md create mode 100644 docs/CALIBREREN.md create mode 100644 docs/PROBLEMEN.md create mode 100644 docs/TEGELS.md create mode 100644 fonts/MDI-LICENSE.txt create mode 100644 fonts/Roboto-400.ttf create mode 100644 fonts/Roboto-500.ttf create mode 100644 fonts/Roboto-700.ttf create mode 100644 fonts/Roboto-OFL.txt create mode 100644 home-like-2432s028.yaml create mode 100644 requirements.txt create mode 100644 secrets.yaml.example create mode 100644 tests/test_cyd_ui.cpp create mode 100644 tests/test_layout.py create mode 100644 tests/test_onboarding.py create mode 100644 tests/test_touch_filter.cpp create mode 100644 tools/calibrate.py create mode 100644 tools/export_bundle.py create mode 100644 tools/new_device.py 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 0000000000000000000000000000000000000000..60b906e6c9bd43bb47f4f47aff0b65043c469787 GIT binary patch literal 123720 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
-c@=n?l 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 LBeU(#(0Bg_nPOco literal 0 HcmV?d00001 diff --git a/fonts/Roboto-700.ttf b/fonts/Roboto-700.ttf new file mode 100644 index 0000000000000000000000000000000000000000..db17c0a41049292807f5d95f19e0726de2ed723a GIT binary patch literal 124292 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 z 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

~I}{qC6NNO(fOMSC0)^H+Jm$nnCNw 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 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()