Add stable CYD firmware, USB calibration wizard and complete setup guide

This commit is contained in:
Max Gramser
2026-09-12 10:18:49 +02:00
parent 1eb65d8f71
commit 94546cf727
43 changed files with 9312 additions and 45 deletions
+22
View File
@@ -10,3 +10,25 @@ testdevice.yaml
# Handmatige backups van de config # Handmatige backups van de config
*.yaml.bak* *.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
-10
View File
@@ -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
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/esphome-cyd-display.iml" filepath="$PROJECT_DIR$/.idea/esphome-cyd-display.iml" />
</modules>
</component>
</project>
Generated
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>
+48
View File
@@ -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.
+120
View File
@@ -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.
+21
View File
@@ -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.
+221
View File
@@ -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 `<USB_POORT>`;
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 <USB_POORT>
```
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 <USB_POORT>
```
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.
+68
View File
@@ -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.
+8
View File
@@ -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 # 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. This document explains every substitution in `home-like.yaml` and provides copy-paste examples for all supported entity types.
--- ---
+13
View File
@@ -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"
+709
View File
@@ -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. <http://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <http://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<http://www.gnu.org/licenses/>.
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
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
+21
View File
@@ -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.
+5
View File
@@ -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."
)
@@ -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))
@@ -0,0 +1,68 @@
#pragma once
#include <cstdlib>
#include <algorithm>
#include <cmath>
#include <cstdint>
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<int16_t>(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
+117
View File
@@ -0,0 +1,117 @@
#include "xpt2046.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
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
+47
View File
@@ -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<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_2MHZ> {
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
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace cyd {
// Work in tenths of a degree and always clamp AFTER rounding.
inline int quantize_temperature(int value, int minimum, int maximum, int step) {
step = std::max(1, step);
value = std::clamp(value, minimum, maximum);
const int snapped = minimum + static_cast<int>(std::lround(
static_cast<double>(value - minimum) / step)) * step;
return std::clamp(snapped, minimum, maximum);
}
class TouchGuard {
public:
void begin(uint32_t now, int x = 0, int y = 0) {
started_ = now;
accepted_ = false;
moved_ = false;
start_x_ = x;
start_y_ = y;
}
void update(int x, int y) {
// A contact that jumps to another target must never become a tile tap.
if (std::abs(x - start_x_) > 18 || std::abs(y - start_y_) > 18) moved_ = true;
}
bool accept(uint32_t now, int tile) {
if (accepted_ || moved_ || now - started_ < 60) return false;
if (has_previous_ && tile == previous_tile_ && now - previous_ < 600) return false;
accepted_ = true;
has_previous_ = true;
previous_tile_ = tile;
previous_ = now;
return true;
}
private:
uint32_t started_{0}, previous_{0};
int previous_tile_{-1};
int start_x_{0}, start_y_{0};
bool moved_{false};
bool accepted_{false}, has_previous_{false};
};
inline TouchGuard touch_guard;
} // namespace cyd
+215
View File
@@ -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: ""
+19
View File
@@ -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)
+32
View File
@@ -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())
+48
View File
@@ -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())
+81
View File
@@ -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.
+127
View File
@@ -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 <USB_POORT>
```
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 <USB_POORT> --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 <USB_POORT>
```
Sluit de loglezer met Ctrl+C en verzamel **nieuwe tikken**:
```sh
python tools/calibrate.py capture --port <USB_POORT> --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 <USB_POORT>
```
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.
+32
View File
@@ -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 <USB_POORT> --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.
+144
View File
@@ -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.
+20
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
# Tested toolchain; do not silently upgrade during commissioning.
esphome==2026.6.2
+6
View File
@@ -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"
+35
View File
@@ -0,0 +1,35 @@
#include "../cyd_ui.h"
#include <cassert>
#include <limits>
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<uint32_t>::max() - 30);
assert(rollover.accept(50, 1)); // millis wraps after 49 days
rollover.begin(60);
assert(!rollover.accept(130, 1));
}
+59
View File
@@ -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()
+126
View File
@@ -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()
+43
View File
@@ -0,0 +1,43 @@
#include "../components/xpt2046/touchscreen/touch_filter.h"
#include <cassert>
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);
}
+213
View File
@@ -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()
+58
View File
@@ -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()
+61
View File
@@ -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()