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