Put ESP Screens in English and keep layouts when entity IDs change (0.2.40)

Everything a user sees or reads is now English: both boards' labels,
status replies and logs, the ESP Screens editor and Alerts cheatsheet,
and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md
and TROUBLESHOOTING.md replace the Dutch file names). Labels follow
Home Assistant's own English names (Heat/Cool modes, Heating/Cooling
actions, Away, Returning to dock, weather conditions), dates use English
abbreviations and the top bar writes numbers the English way. Docs use
HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools).
Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed.

Firmware 0.2.34 names its entities in English. Home Assistant registers
a renamed ESPHome entity under a new entity ID, so the inbox
text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and
the layout stored under the old ID would be lost. The manager now
recognises the screen by its device, during a run or after a restart,
and moves its layout and update history along; the Update button and
the nightly round follow the screen through the rename. Screens that
still run 0.2.33 or older keep working: both languages are matched.

Main is the only release branch from now on.

Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards:
see docs/TEST_RESULTS_0240.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 02:08:43 +02:00
parent f60eed3ff6
commit 3e959b3df9
87 changed files with 5049 additions and 11167 deletions
+70 -70
View File
@@ -1,80 +1,80 @@
## Easy Setup en releases ## Easy Setup and releases
De voorkeursroute voor nieuwe gebruikers is docs/EASY_SETUP.md: ESP Screen Manager The preferred route for new users is docs/EASY_SETUP.md: ESP Screen Manager
plus remote ESPHome-pakketten. Geen token of blueprint nodig. Tegels staan in de plus remote ESPHome packages. No token or blueprint needed. Tiles live in the
permanente add-ondata; wifi/API/OTA blijven in de eigen ESPHome-YAML. Lees persistent add-on data; Wi-Fi/API/OTA stay in the device's own ESPHome YAML. Read
docs/RELEASING.md voordat je updates publiceert. Main distribueert beide borden. docs/RELEASING.md before publishing updates. Main distributes both boards.
Elke push naar GitHub is een release: verhoog dan ook altijd de add-onversie in Every push to GitHub is a release: always also bump the add-on version in
screen_manager/config.yaml (met CHANGELOG-regel), anders ziet HA geen update. screen_manager/config.yaml (with a CHANGELOG line), otherwise HA won't see an update.
Genereer packages met tools/generate_packages.py; bewerk ze niet handmatig. Generate packages with tools/generate_packages.py; don't edit them by hand.
Runtime-modus ondersteunt alle kaarten op alle twintig posities. De positiebeperkingen Runtime mode supports all cards in all twenty positions. The position constraints
hieronder gelden uitsluitend voor de oude handmatige profielen. below apply only to the old manual profiles.
Behoud gegevensschema, protocolcompatibiliteit, unieke sleutels en CYD-preferences. Preserve the data schema, protocol compatibility, unique keys, and CYD preferences.
Test updates met bestaande gegevens. Publiceer geen onbekende opslagversie zonder Test updates against existing data. Don't publish an unknown storage version without
migratie. Productie-Ingress heeft geen long-lived token of publieke poort nodig. a migration. Production Ingress needs no long-lived token or public port.
## Guition-branch ## Guition board
De Guition 4848S040 heeft een apart profiel `guition-4848s040.yaml` met The Guition 4848S040 has a separate profile `guition-4848s040.yaml` with
480×480, ST7701S RGB en GT911. Lees docs/GUITION.md. Gebruik de eigen lokale 480×480, ST7701S RGB, and GT911. Read docs/GUITION.md. Use its own local
`guition-device.yaml`; neem geen CYD-layout of XPT2046-kalibratie over. `guition-device.yaml`; don't carry over the CYD layout or XPT2046 calibration.
Controleer touch met tools/verify_gt911.py en houd de CYD-regressies groen. Verify touch with tools/verify_gt911.py and keep the CYD regressions green.
Configureer geen wallbox-relais als onderdeel van displayondersteuning. Don't configure wallbox relays as part of display support.
# Werkinstructies voor LLM's en developers # Working instructions for LLMs and developers
Dit project bedient een ESP32-2432S028 met ILI9341 + XPT2046 (320×240, This project drives an ESP32-2432S028 with ILI9341 + XPT2046 (320×240,
LVGL 90°). Lees README.md en docs/ voordat je installeert. De eigenaar kan LVGL 90°). Read README.md and docs/ before installing. The owner can
fysiek tikken; een agent kan dat niet vervangen door softwarecoördinaten. physically tap; an agent cannot replace that with software coordinates.
## Een nieuw scherm installeren ## Installing a new screen
1. Identificeer USB-poort en bordvariant. Gebruik ESPHome 2026.6.2 met Python 1. Identify the USB port and board variant. Use ESPHome 2026.6.2 with Python
3.11–3.14. Controleer eerst of er lokale configuratie bestaat. 3.11–3.14. First check whether local configuration already exists.
2. Maak in een verse kopie met `tools/new_device.py` een eigen `device.yaml`, 2. In a fresh copy, use `tools/new_device.py` to create your own `device.yaml`,
`calibration.yaml`, `secrets.yaml`. Overschrijf nooit een werkend profiel. `calibration.yaml`, `secrets.yaml`. Never overwrite a working profile.
Laat de eigenaar wifi lokaal invullen. Toon geen sleutels in logs/chat. Let the owner fill in Wi-Fi locally. Don't show keys in logs/chat.
3. Flash `device.yaml` met de CLI-substitutie `CALIBRATION_ON_BOOT=true`. 3. Flash `device.yaml` with the CLI substitution `CALIBRATION_ON_BOOT=true`.
Het geïsoleerde scherm heeft vijf kruisjes en werkt zonder HA. The isolated screen shows five crosshairs and works without HA.
4. Volg docs/CALIBREREN.md: geleide USB-capture, fit, flash, nieuwe capture, 4. Follow docs/CALIBRATING.md: guided USB capture, fit, flash, new capture,
onafhankelijke verify. Vraag fysieke tikken per doel. Wacht op bevestiging independent verification. Ask for physical taps per target. Wait for confirmation
dat het meetscherm werkelijk zichtbaar is; een geslaagde build is geen flash. that the measurement screen is actually visible; a successful build is not a flash.
5. Flash zonder die override. Koppel de eigen HA via de ESPHome-integratie. 5. Flash without that override. Pair with the owner's own HA via the ESPHome integration.
Lees echte entity-ID's en ondersteunde attributen; verzin geen entiteiten. Read real entity IDs and supported attributes; don't make up entities.
6. Configureer alle gebruikte tegels volgens docs/TEGELS.md. De vacuumkaart 6. Configure every tile you use according to docs/TILES.md. The vacuum card
zit op positie 6. Posities 8/10 hebben geen volledige slider/climatebinding. sits at position 6. Positions 8/10 don't have a full slider/climate binding.
Test geen echte apparaat-acties zonder toestemming van de eigenaar. Don't test real device actions without the owner's permission.
7. Doorloop docs/ACCEPTATIE.md en rapporteer werkelijk uitgevoerde tests, 7. Go through docs/ACCEPTANCE.md and report the tests actually carried out,
beperkingen en de geobserveerde stabiliteitsduur. limitations, and the observed stability duration.
## Code en regressies ## Code and regressions
- Houd basishardware en UI in `home-like-2432s028.yaml`; eigen gegevens horen - Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs
in de genegeerde lokale profielen. `home-like.yaml` is een ander oud profiel. in the gitignored local profiles.
- Behoud vaste pagina's, verborgen navigatie bij maximaal zes tegels en - Preserve fixed pages, hidden navigation at six tiles or fewer, and a
minimaal 600 seconden standaard-standby. Geen vrij scrollen herintroduceren minimum default standby of 600 seconds. Don't reintroduce free scrolling
zonder touch-/navigatieregressies fysiek te testen. without physically testing for touch/navigation regressions.
- Behoud touchfilter en eventguard vóór acties; kalibratie verwerkt gefilterde - Preserve the touch filter and event guard before actions; calibration processes
fysieke ADC-waarden. Zet de affine correctie niet dubbel in driver en UI. filtered physical ADC values. Don't apply the affine correction twice, in both the driver and the UI.
- Kalibratiewizard veronderstelt swap_xy=false, mirror_x=true, mirror_y=false - The calibration wizard assumes swap_xy=false, mirror_x=true, mirror_y=false,
en LVGL 90°. Een gewijzigde oriëntatie vereist ook nieuwe projectie/tests. and LVGL 90°. A changed orientation also requires a new projection/tests.
- Run Python-tests, beide C++-tests en ESPHome-validatie/build bij codewijzigingen. - Run the Python tests, both C++ test suites, and ESPHome validation/build on code changes.
Firmwaretests en hardwareacceptatie zijn verschillende controles. Firmware tests and hardware acceptance are different checks.
- `diagnostics/run_ui_test.py` rendert zonder HA-acties; raak tijdens die test - `diagnostics/run_ui_test.py` renders without HA actions; don't touch the screen
het scherm niet aan. Gebruik `--name` voor de verwachte apparaatidentiteit. during that test. Use `--name` for the expected device identity.
`diagnostics/send_layout.py` zet een demo-indeling met alle kaarttypen op een `diagnostics/send_layout.py` pushes a demo layout with every card type to a
scherm via de API-inbox (geen HA-acties); de manager herstelt de echte screen via the API inbox (no HA actions); the manager restores the real
indeling binnen ~25 s. Guition: `capture_ui.py` bewaart de LVGL-render als PNG. layout within ~25 s. Guition: `capture_ui.py` saves the LVGL render as a PNG.
Zonder scherm: `tools/render_topbar.py` rendert de echte bovenbalkcode van beide Without a screen: `tools/render_topbar.py` renders the real top-bar code for both
borden via ESPHome host + SDL2 naar `.esphome/render-topbar/out/sheet.png`. boards via the ESPHome host + SDL2 to `.esphome/render-topbar/out/sheet.png`.
- Deel via `tools/export_bundle.py` of Git. Stage geen secrets, metingen, - Share via `tools/export_bundle.py` or Git. Don't stage secrets, measurements,
binaries, logs, buildcache of lokale apparaatprofielen. binaries, logs, build caches, or local device profiles.
- Geen automatische firmware-upload naar een willekeurige aangesloten poort. - No automatic firmware upload to an arbitrary connected port.
Bij meerdere borden eerst de bedoelde poort vaststellen. With multiple boards, first determine the intended port.
- Profielen met dezelfde `DEVICE_NAME` (Easy Setup en handmatig) delen - Profiles with the same `DEVICE_NAME` (Easy Setup and manual) share
`.esphome/build/<naam>`. Compileer of upload ze nooit gelijktijdig; controleer `.esphome/build/<name>`. Never compile or upload them concurrently; check
in het uploadlog het pad van `firmware.bin` en daarna de compileertijd via the `firmware.bin` path in the upload log, and then the compile time via
`device_info`. Een verkeerd profiel haalt het scherm uit ESP Screen Manager. `device_info`. A wrong profile knocks the screen out of ESP Screen Manager.
Historische diagnose is achtergrond, geen bewijs dat een nieuw paneel goed Historical diagnostics are background context, not proof that a new panel
werkt. Maak tijdens onboarding geen claims over niet-uitgevoerde fysieke tests. works correctly. During onboarding, don't make claims about physical tests that weren't actually performed.
+85 -85
View File
@@ -1,60 +1,60 @@
> Historisch onderzoek aan één paneel. Voor een nieuw scherm: [README.md](README.md) > Historical investigation on a single panel. For a new screen, see [README.md](README.md)
> en [de vijfpuntskalibratie](docs/CALIBREREN.md); neem geen meetwaarden over. > and [the five-point calibration](docs/CALIBRATING.md); don't reuse these measurements.
# ESP32-2432S028: stabiele bediening # ESP32-2432S028: stable operation
Gebruik **home-like-2432s028.yaml** voor de aangesloten 2,8-inch CYD met Use **home-like-2432s028.yaml** for the connected 2.8-inch CYD with
ILI9341 en XPT2046. `home-like.yaml` hoort bij het andere ST7789V/GT911-bord. ILI9341 and XPT2046. The Wi-Fi, API, and OTA secrets stay in `secrets.yaml`.
De wifi-, API- en OTA-geheimen blijven in `secrets.yaml`.
## Bediening ## Operation
- Stel `TILE_COUNT` in op het aantal gebruikte tegels (1–10). Bij maximaal - Set `TILE_COUNT` to the number of tiles in use (1–10). With six tiles or
zes tegels verdwijnen beide paginaknoppen en de pagina-aanduiding; hogere fewer, both page buttons and the page indicator disappear; higher
tegelnummers zijn verborgen. tile numbers stay hidden.
- Pagina 1 bevat tegels 1–6; pagina 2 bevat 7–10. Gebruik de knoppen - Page 1 holds tiles 1–6; page 2 holds 7–10. Use the **Previous**
**Vorige** en **Volgende** onderaan. Slepen is uitgeschakeld. and **Next** buttons at the bottom. Swiping is disabled.
- Kort tikken voert de bestaande tegelactie uit. Lang indrukken opent de - A short tap runs the tile's existing action. A long press opens the
bestaande licht-, climate- of vacuumkaart, of de ingestelde lange actie. existing light, climate, or vacuum card, or the configured long-press action.
- Standby begint na tien minuten zonder aanraking (`AUTO_DIM_TIMEOUT: "600"`). - Standby starts after ten minutes without touch (`AUTO_DIM_TIMEOUT: "600"`).
- De eerste tik op het gedimde scherm maakt het scherm wakker. - The first tap on the dimmed screen wakes the screen.
- Contacten korter dan `TOUCH_MIN_PRESS_MS` (60 ms op de CYD, 20 ms op de - Contacts shorter than `TOUCH_MIN_PRESS_MS` (60 ms on the CYD, 20 ms on the
Guition) worden genegeerd. Eén aanraking kan maximaal één tegelactie geven; Guition) are ignored. One touch can produce at most one tile action;
een verplaatsing groter dan `TOUCH_MOVE_LIMIT_PX` (56 px, circa 1 cm, op de CYD; a movement greater than `TOUCH_MOVE_LIMIT_PX` (56 px, about 1 cm, on the CYD;
0 op de Guition: geen grens, LVGL beslist en loslaten binnen de tegel telt) ten 0 on the Guition: no limit, LVGL decides and releasing inside the tile counts)
opzichte van het gesettelde eerste contactpunt annuleert de tik; alleen het eerste relative to the settled first contact point cancels the tap; only the first
contact telt; dezelfde tegel wordt 600 ms tegen contactdender beschermd. Een geweigerde tik staat met reden in het log (tag `touch`). contact counts; the same tile is protected against contact bounce for 600 ms.
- De thermostaat verstuurt de afgeronde, begrensde temperatuur die hij toont. A rejected tap is logged with its reason (tag `touch`).
Een lokale temperatuur- of moduskeuze krijgt drie seconden om met HA te - The thermostat sends the rounded, clamped temperature it displays.
synchroniseren. Een ingedrukte bediening voorkomt automatisch dimmen. A local temperature or mode choice gets three seconds to sync with HA.
- Pippa gebruikt de echte `vacuum.s8`-status, zodat docken, schoonmaken, An active control press prevents automatic dimming.
pauzeren en terugkeren onderscheiden worden. Ontbrekende HA-statussen - Pippa uses the real `vacuum.s8` status, so docking, cleaning,
worden als `Niet beschikbaar` getoond. pausing, and returning are distinguished. Missing HA states
are shown as `Unavailable`.
## Technische keuzes ## Technical choices
ESPHome 2026.6.2 met ESP-IDF; er zijn geen Arduino-componenten nodig. ESPHome 2026.6.2 with ESP-IDF; no Arduino components are needed.
De displaybus draait op 40 MHz, zoals ESPHome's standaard CYD-profiel. The display bus runs at 40 MHz, like ESPHome's standard CYD profile.
XPT2046 wordt elke 20 ms uitgelezen zonder GPIO36-interrupt. De drukdrempel XPT2046 is polled every 20 ms without the GPIO36 interrupt. The pressure
is 800, na een verdachte hoekmeting bij druk 422. Een lokale XPT2046-driver threshold is 800, after a suspect corner reading at pressure 422. A local XPT2046
vereist drie consistente ADC-samples voor een nieuwe aanraking, negeert losse driver requires three consistent ADC samples for a new touch, ignores isolated
uitschieters en wacht twee drukloze samples af voor loslaten. Dit voegt circa outliers, and waits for two pressure-free samples before release. This adds about
40–60 ms toe bij aanraken en voorkomt dat een onstabiele eerste meting meteen 40–60 ms on touch and prevents an unstable first reading from being immediately
als een andere knop wordt geïnterpreteerd. De driver staat onder `components/`. De geïnstalleerde interpreted as a different button. The driver lives under `components/`. The stock
interruptdriver probeert op die input-only pin een niet-beschikbare interne interrupt driver tries to enable an unavailable internal pull-up on that
pull-up te activeren; de oude firmware logde hiervoor een GPIO-fout. input-only pin; the old firmware logged a GPIO error for this.
LVGL krijgt 12% tekenbuffer. Geen scrollanimaties en geen extra flitspuls na LVGL gets a 12% draw buffer. No scroll animations and no extra flash pulse after
elke tik. Tegelachtergronden zijn ondoorzichtig. Updates binnen 100 ms worden each tap. Tile backgrounds are opaque. Updates within 100 ms are
samengevoegd; ongewijzigde tegellabels en kleuren worden niet opnieuw gezet. coalesced; unchanged tile labels and colors are not redrawn.
De UI-initialisatie draait pas nadat LVGL is gestart. De klok ververst direct UI initialization only runs after LVGL has started. The clock refreshes immediately
bij tijdsynchronisatie. Roboto staat lokaal in `fonts/`, inclusief OFL-licentie. on time sync. Roboto is bundled locally in `fonts/`, including the OFL license.
De actieve indeling is 320×240. De oude, uitgecommentarieerde portretpresets The active layout is 320×240. The old, commented-out portrait presets
zijn nog geen aangepaste paginalay-outs: reserveer bij een andere oriëntatie are not adapted page layouts yet: with a different orientation, also reserve
ook onderaan 34 pixels voor de navigatie en controleer de tegelposities. 34 pixels at the bottom for navigation and check the tile positions.
## Bouwen en flashen ## Building and flashing
```sh ```sh
esphome compile home-like-2432s028.yaml esphome compile home-like-2432s028.yaml
@@ -62,12 +62,12 @@ esphome upload home-like-2432s028.yaml --device /dev/cu.usbserial-130
esphome logs 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 Close any running serial log reader before flashing. The previous configuration
staat lokaal in `diagnostics/before-2432s028.yaml.bak`; de bijbehorende vorige is stored locally in `diagnostics/before-2432s028.yaml.bak`; the corresponding previous
factorybinary in `diagnostics/before-2432s028.factory.bin`. Deze lokale backups factory binary is in `diagnostics/before-2432s028.factory.bin`. These local backups
worden niet in Git opgenomen. Teruggaan kan door de backup als configuratie are not included in Git. You can roll back by restoring the backup as the configuration
te herstellen en opnieuw te bouwen/flashen; herstel de oude Arduino-toolchain and rebuilding/flashing; restore the old Arduino toolchain
als je de oude configuratie bouwt. if you build the old configuration.
## Tests ## Tests
@@ -79,46 +79,46 @@ clang++ -std=c++17 -Wall -Wextra -Werror tests/test_touch_filter.cpp -o /tmp/tes
/tmp/test_touch_filter /tmp/test_touch_filter
``` ```
De C++-test controleert contactdender, korte ruispulsen, opeenvolgende tegels, The C++ test checks contact bounce, short noise pulses, consecutive tiles,
herhaling binnen één aanraking, `millis()`-overflow en temperatuurafronding. repetition within a single touch, `millis()` overflow, and temperature rounding.
De indelingstest controleert overlap, schermgrenzen, ruimte voor navigatie The layout test checks overlap, screen bounds, space for navigation,
en het filter vóór beide handlers van alle tien tegels. and the filter ahead of both handlers for all ten tiles.
Met de Python-omgeving van ESPHome (met aioesphomeapi, PyYAML en pyserial): With ESPHome's Python environment (with aioesphomeapi, PyYAML, and pyserial):
```sh ```sh
python diagnostics/capture_serial.py --reset --seconds 120 > diagnostics/runtime.log python diagnostics/capture_serial.py --reset --seconds 120 > diagnostics/runtime.log
python diagnostics/run_ui_test.py --host cyd-2432s028.local python diagnostics/run_ui_test.py --host cyd-2432s028.local
``` ```
De API-test leest de encryptiesleutel intern uit `secrets.yaml` en drukt deze The API test reads the encryption key internally from `secrets.yaml` and doesn't
niet af. Hij wisselt tien keer van pagina en tekent de vijf overlays in elke print it. It switches pages ten times and draws the five overlays on each
ronde. Hij roept geen Home Assistant-acties aan. Raak het scherm tijdens round. It doesn't call any Home Assistant actions. Don't touch the screen during
deze circa 11 seconden niet aan. Dit is een teken-/geheugentest; hij vervangt these roughly 11 seconds. This is a rendering/memory test; it doesn't replace
geen fysieke toets van de touchkalibratie of een test van HA-service-effecten. a physical check of the touch calibration or a test of HA service effects.
Diagnostiek logt iedere 30 seconden vrije heap en grootste vrije geheugenblok. Diagnostics logs free heap and the largest free memory block every 30 seconds.
`DEBUG` is tijdelijk nuttig voor touch- en verbindingsdiagnose. Voor dagelijks `DEBUG` is useful temporarily for touch and connection diagnostics. For everyday
gebruik kan `logger.level` op `INFO` zodra de fysieke bediening goed is. use, `logger.level` can go back to `INFO` once physical operation is confirmed good.
## Touchdiagnose ## Touch diagnostics
`python diagnostics/control_ui.py touch_diagnostics` opent een apart scherm `python diagnostics/control_ui.py touch_diagnostics` opens a separate screen
met vier kruizen. Tijdens deze meting kunnen aanrakingen geen tegelactie with four crosshairs. During this measurement, touches can't trigger a tile
uitvoeren. Tik nauwkeurig achtereenvolgens linksboven, rechtsboven, action. Tap accurately, in order: top-left, top-right,
rechtsonder en linksonder. De ruwe en genormaliseerde coördinaten staan in bottom-right, and bottom-left. The raw and normalized coordinates appear in
de seriële logs met tag `touch`. Sluit af met the serial logs with tag `touch`. Exit with
`python diagnostics/control_ui.py end_touch_diagnostics`. De kruizen staan `python diagnostics/control_ui.py end_touch_diagnostics`. The crosshairs sit
op (20,20), (299,20), (299,219) en (20,219) voor landschap 320×240. at (20,20), (299,20), (299,219), and (20,219) for 320×240 landscape.
De huidige kalibratie (X 225–3800, Y 274–3609) is afgeleid uit de drie The current calibration (X 225–3800, Y 274–3609) is derived from the three
herhaalde metingen rechtsboven en linksonder op dit fysieke bord. De eerdere repeated measurements at top-right and bottom-left on this physical board. The earlier
metingen linksboven waren onstabiel en zijn bewust niet gebruikt om het top-left measurements were unstable and were deliberately not used, to avoid
hele scherm naar een verkeerde positie te verschuiven. shifting the entire screen to the wrong position.
Daarbovenop corrigeert `set_raw_correction(...)` in `on_boot` de gemeten On top of that, `set_raw_correction(...)` in `on_boot` corrects the measured
scheefstand. De fit gebruikt de gefilterde hoekmedianen (830,556), (544,3461), skew. The fit uses the filtered corner medians (830,556), (544,3461),
(3410,3391) en (3492,372). De vier medianen vallen na correctie binnen vijf (3410,3391), and (3492,372). After correction, the four medians land within five
pixels van hun meetdoel in de regressietest. Dit zijn bord-specifieke pixels of their measurement target in the regression test. These are board-specific
coëfficiënten; neem ze niet blind over op een ander paneel. De `raw=`-logs coefficients; don't carry them over blindly to another panel. The `raw=` logs
blijven de fysieke, gefilterde ADC-metingen tonen. still show the physical, filtered ADC readings.
+342 -343
View File
@@ -1,329 +1,328 @@
# Home Assistant ESP Screens # Home Assistant ESP Screens
Een Home Assistant-bedieningsscherm dat je zelf indeelt. **ESP Screen Manager** A Home Assistant control screen that you lay out yourself. **ESP Screen Manager**
beheert je tegels én bouwt en installeert de ESPHome-firmware vanuit Home Assistant. manages your tiles and builds and installs the ESPHome firmware from Home Assistant.
Geen blueprint, MQTT of long-lived token nodig voor normaal gebruik. No blueprint, MQTT, or long-lived token needed for normal use.
**Nieuw scherm? Volg de [complete installatiehandleiding](docs/EASY_SETUP.md).** **New screen? Follow the [complete installation guide](docs/EASY_SETUP.md).**
Voor elke nieuwe gebruiker en elk nieuw apparaat maak je een eigen profiel. For every new user and every new device, you create your own profile.
## Ondersteunde schermen ## Supported screens
| Scherm | Resolutie | Display / touch | | Screen | Resolution | Display / touch |
| --- | --- | --- | | --- | --- | --- |
| CYD ESP32-2432S028 | 320 × 240 | ILI9341 / resistieve XPT2046 | | CYD ESP32-2432S028 | 320 × 240 | ILI9341 / resistive XPT2046 |
| Guition ESP32-S3-4848S040, 4 inch | 480 × 480 | ST7701S RGB / capacitieve GT911 | | Guition ESP32-S3-4848S040, 4 inch | 480 × 480 | ST7701S RGB / capacitive GT911 |
Gebruik deze exacte bordvarianten: gelijkende productnamen kunnen andere Use these exact board variants: similar-looking product names can have different
controllers of aansluitingen hebben. Wallbox-relais worden niet aangestuurd. controllers or connectors. Wallbox relays are not controlled.
De firmware en ingebouwde CLI zijn getest met **ESPHome 2026.6.2**. The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
## Wat je kunt instellen ## What you can configure
- **Tot twintig tegels**, verdeeld over maximaal vier vaste pagina's met zes - **Up to twenty tiles**, spread across up to four fixed pages of six
tegels. Zoek op entiteit, apparaat of ruimte en sleep om te ordenen. tiles each. Search by entity, device, or room, and drag to reorder.
- **Instellingen per tegel:** eigen naam, klikgedrag, een kleine slider waar - **Per-tile settings:** a custom name, click behavior, a small slider where
ondersteund, of een grote waarde voor bijvoorbeeld temperatuur en verbruik. supported, or a large value for things like temperature and power usage.
De grote waarde toont vanaf firmware 0.2.13 een klein domeinicoon naast de From firmware 0.2.13, the large value shows a small domain icon next to the
titel; een te lang getal wordt met puntjes afgekort, de eenheid blijft staan. title; a number that's too long is truncated with an ellipsis, the unit stays visible.
- **Pastel achtergronden per tegel:** kies rood voor een alles-uit-script, - **Pastel backgrounds per tile:** choose red for an all-off script,
groen voor alles-aan, of een andere kleur. Titel en status blijven donker en green for all-on, or any other color. The title and status stay dark and
leesbaar. De kleur verschijnt ook in het schermvoorbeeld; **Standaard** herstelt readable. The color also appears in the screen preview; **Default** restores
de normale kleuren. Vereist firmware 0.2.10 of nieuwer. **Geen** laat de kaart the normal colors. Requires firmware 0.2.10 or newer. **None** drops the card
helemaal weg: de inhoud staat dan even groot direct op de schermachtergrond entirely: the content then sits at the same size directly on the screen background
(firmware 0.2.16). (firmware 0.2.16).
- **Klok:** digitaal of analoog. De analoge klok heeft streepjes met 12, 3, 6 en 9 - **Clock:** digital or analog. The analog clock has tick marks at 12, 3, 6, and 9
en toont op een enkele tegel een kalenderblok (weekdag, dag, maand) naast de and, on a single tile, shows a calendar block (weekday, day, month) next to the
wijzerplaat; dubbelbreed staat de digitale tijd met datum ernaast. dial; double-width shows the digital time with the date beside it.
- **Lampbediening:** helderheid, regenboogkleur en wittemperatuur volgens de - **Light control:** brightness, rainbow color, and white temperature according to
mogelijkheden van de lamp. Open de detailbediening met een lange aanraking. the light's capabilities. Open the detailed control with a long touch.
- **Meer kaarten:** climate, vacuum, fan, cover, media player, sensoren, - **More cards:** climate, vacuum, fan, cover, media player, sensors,
select/input_select, number/input_number, schakelaars, scènes, scripts en select/input_select, number/input_number, switches, scenes, scripts, and
buttons. Een sensor kan een historiekaart voor 1, 6 of 24 uur openen. buttons. A sensor can open a history card for 1, 6, or 24 hours.
- **Speciale kaarten (firmware 0.2.14+):** een **klok** (digitaal of analoog) - **Special cards (firmware 0.2.14+):** a **clock** (digital or analog)
als ingebouwde tegel, een **weersvoorspelling** met vijf dagen op een as a built-in tile, a **weather forecast** with five days on a
dubbelbrede kaart, een **grafiek** van de sensorgeschiedenis in de tegel, double-width card, a **graph** of the sensor history in the tile,
een **zonnebaan** (`sun.sun`: horizon met de zon tussen opkomst en a **sun path** (`sun.sun`: horizon with the sun between sunrise and
ondergang), een **kookwekker** (`timer.*`, tikken start of pauzeert) en sunset), a **timer** (`timer.*`, tapping starts or pauses it), and
**aanwezigheid** (`person.*`). Kies ze in de kiezer als elke andere tegel of **presence** (`person.*`). Pick them in the picker like any other tile, or
sleep ze direct in de schermmockup; **Dubbelbreed** is een optie voor iedere tegel. drag them straight into the screen mockup; **Double-width** is an option for every tile.
- **Directe bediening op dubbelbrede tegels** (firmware 0.2.19+), zoals de rijen in - **Direct control on double-width tiles** (firmware 0.2.19+), like the rows in
Home Assistant: temperatuur − / + of modusknoppen (klimaat), een toggle (schakelaar, Home Assistant: temperature − / + or mode buttons (climate), a toggle (switch,
lamp, ventilator), start/stop/dock (stofzuiger), open/stop/dicht of een light, fan), start/stop/dock (vacuum), open/stop/close or a
positieschuif (zonwering), volume met dempen of vorige/play/volgende (media), position slider (cover), volume with mute or previous/play/next (media),
− / + of een schuif (getallen), vorige/volgende (keuzelijst), start/pauze en − / + or a slider (numbers), previous/next (select), start/pause and
annuleren (kookwekker) en één knop voor scènes, scripts en knoppen. Per tegel cancel (timer), and a single button for scenes, scripts, and buttons. Configurable
instelbaar; **Geen** houdt de gewone kaart. per tile; **None** keeps the regular card.
- **Weerkaart** met actueel weer, de komende uren en dagen inclusief regenkans of - **Weather card** with current weather, the coming hours and days including chance of
millimeters; **klimaatkaart** met aan/uit-knop, modus-, ventilator- en zwenkstanden. rain or millimeters; **climate card** with an on/off button, mode, fan, and swing settings.
Scènes, scripts en knoppen tonen wanneer ze voor het laatst liepen. Een tegel die op Scenes, scripts, and buttons show when they last ran. A tile that's waiting on
Home Assistant wacht toont een klein spinnertje op een lichte laag. Home Assistant shows a small spinner on a light overlay.
- **Bovenbalk per scherm** (firmware 0.2.32+): links de naam, rechts tot zes - **Top bar per screen** (firmware 0.2.32+): the name on the left, up to six
onderdelen naar keuze: de tijd, een analoge klok, de datum, of een entiteit uit items of your choice on the right: the time, an analog clock, the date, or an
Home Assistant met icoon, zoals temperatuur, luchtvochtigheid, verbruik, een deur entity from Home Assistant with an icon, such as temperature, humidity, power usage, a door
(open/dicht), het alarm, wie er thuis is of wanneer iemand of iets het laatst (open/closed), the alarm, who's home, or when something or someone last
veranderde ("5 min geleden", "Gisteren"). Zie [Bovenbalk](#bovenbalk). changed ("5 min ago", "Yesterday"). See [Top bar](#top-bar).
- **Scherminstellingen:** standby-tijd, normale en gedimde helderheid, - **Screen settings:** standby time, normal and dimmed brightness,
nachturen, 24- of 12-uursklok, terug naar de hoofdpagina en optioneel swipen night hours, 24- or 12-hour clock, return to the home page, and optional swiping
tussen pagina's. between pages.
- **Guition-rotatie:** 0°, 90°, 180° of 270°, direct vanuit de beheerpagina. - **Guition rotation:** 0°, 90°, 180°, or 270°, directly from the management page.
De native LVGL-rotatie draait beeld en touch samen. De CYD behoudt zijn vaste Native LVGL rotation turns the display and touch together. The CYD keeps its fixed
oriëntatie en eigen kalibratie. orientation and its own calibration.
- **Inspector:** controleer entiteiten, status en configuratie in ESP Screens. - **Inspector:** check entities, status, and configuration in ESP Screens.
Actiefeedback laat zien dat een opdracht onderweg is. Action feedback shows that a command is on its way.
Functies hangen af van de mogelijkheden die Home Assistant voor een entiteit Features depend on the capabilities Home Assistant reports for an entity.
meldt. De app moet blijven draaien om de schermen van actuele gegevens te voorzien. The app must keep running to keep the screens supplied with current data.
Vanaf firmware **0.2.12** toont lang indrukken op een switch een grote From firmware **0.2.12**, a long press on a switch shows a large
schakelaar. Een korte tik schakelt direct; de uitstand krijgt een grijs icoon. toggle. A short tap switches immediately; the off state gets a gray icon.
De feedback stopt zodra Home Assistant de gewijzigde stand meldt, met een The feedback stops as soon as Home Assistant reports the changed state, with a
minimum van 150 ms voor switches. Tegels met een mini-slider behouden hun minimum of 150 ms for switches. Tiles with a mini-slider keep their
icoon; op CYD staan het icoon en tekstblok verticaal gecentreerd. icon; on the CYD, the icon and text block are vertically centered.
## Alert vanuit een automatisering ## Alert from an automation
Elk scherm heeft de actie **`esphome.<scherm>_show_alert`** (firmware 0.2.31+). Die legt Every screen has the action **`esphome.<screen>_show_alert`** (firmware 0.2.31+). It places
een kaart over het hele scherm, wekt het scherm en houdt de backlight op de normale a card over the entire screen, wakes the screen, and keeps the backlight at normal
helderheid tot iemand op **Oké** tikt. In een automatisering: brightness until someone taps **OK**. In an automation:
```yaml ```yaml
action: esphome.keuken_scherm_show_alert action: esphome.kitchen_screen_show_alert
data: data:
title: "Iemand belt aan" title: "Someone is at the door"
subtitle: "Deur 3, achterkant" subtitle: "Door 3, back"
icon: doorbell icon: doorbell
color: orange color: orange
button_text: "Ik kom" button_text: "Coming"
timeout: 0 timeout: 0
flash: true flash: true
``` ```
- **`title`** en **`subtitle`**: een titel op één regel (te lang krijgt puntjes) en een - **`title`** and **`subtitle`**: a single-line title (truncated with an ellipsis if too long) and an
toelichting die over meerdere regels loopt. Leeg mag; een lege titel wordt "Melding". explanation that wraps across multiple lines. Empty is allowed; an empty title becomes "Notification".
- **`icon`**: een naam uit de tegelkiezer, zoals `doorbell`, `bell`, `alert-outline`, - **`icon`**: a name from the tile picker, such as `doorbell`, `bell`, `alert-outline`,
`lock`, `door-open`, `motion-sensor`, `smoke-detector`, `water-alert`, `mailbox`, `car` `lock`, `door-open`, `motion-sensor`, `smoke-detector`, `water-alert`, `mailbox`, `car`,
of `account`. `mdi:doorbell` en de hex-codepoint (`F12E6`) mogen ook, zolang het glyph or `account`. `mdi:doorbell` and the hex codepoint (`F12E6`) also work, as long as the glyph
in de firmware zit. Onbekend geeft de waarschuwingsdriehoek. is included in the firmware. Unknown falls back to the warning triangle.
- **`color`**: `red`, `orange`, `yellow`, `green`, `mint`, `blue`, `purple`, `pink` of - **`color`**: `red`, `orange`, `yellow`, `green`, `mint`, `blue`, `purple`, `pink`, or
`gray`, dezelfde pasteltinten als de tegels. Leeg geeft de witte kaart. `gray` — the same pastel shades as the tiles. Empty gives the white card.
- **`button_text`**: de tekst op de knop; leeg is "Oké". - **`button_text`**: the text on the button; empty is "OK".
- **`timeout`**: seconden waarna de kaart vanzelf verdwijnt; `0` is tot de knop, hoe lang - **`timeout`**: seconds after which the card disappears on its own; `0` means it waits for the button,
dat ook duurt. De knop sluit de kaart altijd direct, ook met een timeout. Standby en however long that takes. The button always closes the card immediately, even with a timeout. Standby and
nachtstand wachten zolang de kaart staat. night mode wait as long as the card is showing.
- **`flash`**: `true` laat de backlight vier keer knipperen bij binnenkomst. - **`flash`**: `true` makes the backlight blink four times when the alert arrives.
In ESP Screens opent de knop **Alerts** een cheatsheet met per scherm de exacte actienaam, In ESP Screens, the **Alerts** button opens a cheatsheet with the exact action name for each screen,
een voorbeeld om te plakken, alle velden, iconen en kleuren. Home Assistant vraagt alle zeven velden; laat een veld leeg (`""`, `0`, `false`) als je het a ready-to-paste example, and all fields, icons, and colors. Home Assistant asks for all seven fields; leave a field empty (`""`, `0`, `false`) if you
niet gebruikt. Een nieuwe alert vervangt de huidige. Elk einde meldt zich als event don't use it. A new alert replaces the current one. Every end is reported as the event
**`esphome.screen_alert`** met `action` (`ok`, `timeout`, `replaced` of `remote`), `title`, **`esphome.screen_alert`** with `action` (`ok`, `timeout`, `replaced`, or `remote`), `title`,
`screen` en het `device_id` dat Home Assistant toevoegt, zodat een automatisering op Oké `screen`, and the `device_id` that Home Assistant adds, so an automation can wait for OK.
kan wachten. **`esphome.<scherm>_dismiss_alert`** haalt de kaart op afstand weg. **`esphome.<screen>_dismiss_alert`** clears the card remotely.
## Installeren vanuit Home Assistant ## Installing from Home Assistant
### Heb ik ESPHome nodig? ### Do I need ESPHome?
**Je hoeft de aparte ESPHome Device Builder-app niet te installeren.** **You don't need to install the separate ESPHome Device Builder app.**
ESP Screen Manager bevat de ESPHome-CLI al en kan zelf firmware bouwen, ESP Screen Manager already includes the ESPHome CLI and can build firmware itself,
via USB installeren en later draadloos via OTA bijwerken. install it via USB, and later update it wirelessly over OTA.
**Je moet het geflashte scherm wel koppelen via de ESPHome-integratie in HA.** **You do need to pair the flashed screen via the ESPHome integration in HA.**
Die koppeling staat onder **Instellingen → Apparaten & diensten**, niet in de That pairing lives under **Settings → Devices & services**, not in the
appwinkel. Voeg daar het ontdekte apparaat toe. Verschijnt het niet automatisch, App store. Add the discovered device there. If it doesn't appear automatically,
kies dan **Integratie toevoegen → ESPHome** en vul het IP-adres van het scherm in. choose **Add integration → ESPHome** and enter the screen's IP address.
Gebruik bij een sleutelvraag de `api.encryption.key` uit je eigen apparaat-YAML If asked for a key, use the `api.encryption.key` from your own device YAML,
en geef het apparaat toestemming om Home Assistant-acties uit te voeren. and grant the device permission to perform Home Assistant actions.
| Onderdeel | Nodig? | Waarvoor? | | Component | Needed? | What for? |
| --- | --- | --- | | --- | --- | --- |
| ESP Screen Manager-app | Ja, voor deze installatieroute | Firmware installeren, tegels beheren en actuele gegevens naar het scherm sturen | | ESP Screen Manager app | Yes, for this installation route | Installing firmware, managing tiles, and sending current data to the screen |
| ESPHome Device Builder-app | Nee, optioneel | Alternatieve editor en firmware-installatie; dezelfde CLI zit al in ESP Screens | | ESPHome Device Builder app | No, optional | Alternative editor and firmware installer; the same CLI is already in ESP Screens |
| ESPHome-integratie in HA | Ja, koppel ieder scherm | De verbinding tussen Home Assistant en het fysieke scherm | | ESPHome integration in HA | Yes, pair every screen | The connection between Home Assistant and the physical screen |
Een verse installatie zonder ESPHome Device Builder werkt dus ook. Als er nog So a fresh installation without ESPHome Device Builder also works. If there's
geen ESPHome `secrets.yaml` bestaat, vraagt onze wizard de wifi eenmalig en no ESPHome `secrets.yaml` yet, our wizard asks for Wi-Fi once and
bewaart die lokaal. Bestaande wifi-secrets worden hergebruikt. API- en OTA-sleutels stores it locally. Existing Wi-Fi secrets are reused. API and OTA keys
worden per nieuw scherm aangemaakt en blijven in het eigen apparaatprofiel. are generated per new screen and stay in that device's own profile.
### Stap voor stap ### Step by step
Voor Home Assistant OS met Apps/Add-ons op **aarch64 of amd64**: For Home Assistant OS with Apps/Add-ons on **aarch64 or amd64**:
1. Open de appwinkel en voeg deze repository toe: 1. Open the App store and add this repository:
`https://github.com/MaxGramser/homeassistant_espscreen`. `https://github.com/MaxGramser/homeassistant_espscreen`.
2. Installeer **ESP Screen Manager**, start de app en open **ESP Screens**. 2. Install **ESP Screen Manager**, start the app, and open **ESP Screens**.
ESPHome Device Builder is optioneel: de ESPHome-CLI zit al in deze app. ESPHome Device Builder is optional: the ESPHome CLI is already in this app.
3. Sluit het scherm met een USB-datakabel aan op de **Home Assistant-machine** 3. Connect the screen with a USB data cable to the **Home Assistant machine**
en kies **Nieuw scherm**: CYD of Guition, een naam, de USB-poort en and choose **New screen**: CYD or Guition, a name, the USB port, and
**Installeren**. Ontbreekt wifi in de ESPHome `secrets.yaml`, dan vraagt het **Install**. If Wi-Fi is missing from the ESPHome `secrets.yaml`, the window
venster die eenmalig en zet ESP Screens alleen de ontbrekende regels erin. asks for it once and ESP Screens only adds the missing lines. The profile
Het profiel met unieke API- en OTA-sleutels komt in de ESPHome-map; de build with unique API and OTA keys goes into the ESPHome folder; the build
en de flash lopen in hetzelfde venster (een eerste build duurt op een and flash run in the same window (a first build takes a few minutes on a
Raspberry enkele minuten). Elk scherm krijgt zijn eigen profiel. Raspberry Pi). Each screen gets its own profile.
4. **CYD:** doorloop de kalibratie op het scherm. **Guition:** gebruikt GT911 4. **CYD:** go through the calibration on the screen. **Guition:** uses GT911
zonder resistieve kalibratie. Koppel daarna het ontdekte ESPHome-apparaat in without resistive calibration. Then pair the discovered ESPHome device in
**Instellingen → Apparaten & diensten** met de API-sleutel die het venster **Settings → Devices & services** using the API key the window
na de installatie toont (kopieerknop). Geef het apparaat toestemming om shows after installation (copy button). Grant the device permission to
Home Assistant-acties uit te voeren. perform Home Assistant actions.
5. Selecteer het scherm in ESP Screens, kies je tegels en klik 5. Select the screen in ESP Screens, choose your tiles, and click
**Opslaan & naar scherm**. Test vervolgens de fysieke bediening. **Save & send to screen**. Then test the physical controls.
Je kunt later vanuit **Firmware & USB → Wifi / OTA** nieuwe firmware installeren. You can install new firmware later from **Firmware & USB → Wi-Fi / OTA**.
Gebruik voor een bestaand scherm altijd het bestaande profiel; opnieuw een For an existing screen, always use the existing profile; creating a new
installatieprofiel aanmaken genereert nieuwe sleutels. installation profile generates new keys.
## Tegels en kleuren aanpassen ## Customizing tiles and colors
Klik een tegel in het schermvoorbeeld. Kies bij **Pastel achtergrond** een kleur, Click a tile in the screen preview. Under **Pastel background**, choose a color,
zoals rood of groen. Pas eventueel de naam, klikactie, mini-slider of grote such as red or green. Optionally adjust the name, click action, mini-slider, or large
waarde aan. Klik **Opslaan & naar scherm** om de wijzigingen toe te passen. value. Click **Save & send to screen** to apply the changes.
Dit vereist na de eerste ondersteunende firmware-update geen nieuwe flash. After the first supporting firmware update, this requires no new flash.
Een kleur is een vaste keuze voor die tegel: hij blijft dus bijvoorbeeld rood A color is a fixed choice for that tile: it stays red, for example,
wanneer je het alles-uit-script gebruikt. De entiteitsstatus en actiefeedback even when you run the all-off script. The entity status and action feedback
blijven afzonderlijk zichtbaar. stay separately visible.
## Bovenbalk ## Top bar
Bovenaan de editor staat per scherm de **Bovenbalk**: links de naam, rechts tot At the top of the editor, each screen has its **Top bar**: the name on the left, up to
zes onderdelen. **+ Toevoegen** biedt de tijd, een analoge klok en de datum (die six items on the right. **+ Add** offers the time, an analog clock, and the date (which
tellen op het scherm zelf door, ook zonder Home Assistant), suggesties uit je eigen keep ticking on the screen itself, even without Home Assistant), suggestions from your own
huis (temperatuur en verbruik uit de ruimte van het scherm, het weer, aantal thuis, home (temperature and power usage from the screen's room, the weather, how many people are home,
zon op en onder) en een zoekveld voor elke entiteit, ook een telefoon sunrise and sunset), and a search field for any entity, including a phone
(`device_tracker`), een slot, het alarmpaneel of `zone.home`. Sleep de onderdelen (`device_tracker`), a lock, the alarm panel, or `zone.home`. Drag the items
om de volgorde te wijzigen; tik erop om ze in te stellen: to change their order; tap one to configure it:
- **Wat laten zien:** de status zoals Home Assistant hem schrijft (21,3 °C, 65%, - **What to show:** the state as Home Assistant writes it (21.3 °C, 65%,
1.249 W, Open/Dicht, Thuis/Weg, Afwezig), of **Laatst gewijzigd**: "Zojuist", 1,249 W, Open/Closed, Home/Away, Armed away), or **Last changed**: "Just now",
"5 min geleden", "Gisteren". Een tijdstempel-sensor telt ook vooruit ("Over 2 uur"). "5 min ago", "Yesterday". A timestamp sensor can also count forward ("In 2 hours").
- **Icoon:** automatisch zoals Home Assistant (een open deur krijgt een open - **Icon:** automatic, matching Home Assistant (an open door gets an open-
deur-icoon), een eigen icoon uit de lijst of geen icoon. door icon), a custom icon from the list, or no icon.
- **Tonen:** altijd, of **alleen als actief**: het onderdeel verschijnt pas als het - **Show:** always, or **only when active**: the item only appears when it's
aan, open, thuis of meer dan 0 is. Handig voor een open deur of een draaiende on, open, home, or greater than 0. Handy for an open door or a running
wasmachine. Actieve onderdelen kleuren zoals in Home Assistant (open deur amber, washing machine. Active items are colored like in Home Assistant (open door amber,
alarm ingeschakeld groen, alarm afgegaan rood). alarm armed green, alarm triggered red).
Het schermvoorbeeld tekent de balk met dezelfde letters en regels als het scherm: The screen preview draws the bar with the same letters and rules as the screen:
alle waarden op één lijn met de naam, iconen op cijferhoogte, gelijke afstanden. all values on one line with the name, icons aligned to digit height, equal spacing.
Past niet alles naast de naam, dan krijgt de naam puntjes en laat het scherm de If not everything fits next to the name, the name gets an ellipsis and the screen drops the
voorste onderdelen weg; de editor markeert die met strepen. Oudere firmware toont leading items; the editor marks those with dashes. Until updated, older firmware shows
tot de update alleen de naam en de tijd (als die in de balk staat). only the name and the time (if that's in the bar).
## Updates en behoud van je instellingen ## Updates and keeping your settings
| Wijziging | Actie | | Change | Action |
| --- | --- | | --- | --- |
| Tegels, namen, kleuren, volgorde of scherminstellingen | Opslaan in ESP Screens; geen firmwareflash | | Tiles, names, colors, order, or screen settings | Save in ESP Screens; no firmware flash |
| Nieuwe versie van de beheerpagina | ESP Screen Manager updaten in de HA-appwinkel | | New version of the management page | Update ESP Screen Manager in the HA App store |
| Nieuwe functie op het fysieke scherm | Knop **Bijwerken** bij het scherm (badge *Update x.y.z*), of het vinkje **Elke nacht automatisch bijwerken** | | New feature on the physical screen | The **Update** button on the screen (badge *Update x.y.z*), or the **Update automatically every night** checkbox |
Elke appversie hoort bij één firmwareversie. Na een app-update toont de lijst Every app version belongs to one firmware version. After an app update, the list
per scherm of er nieuwere firmware is. **Bijwerken** bouwt het eigen profiel shows per screen whether newer firmware is available. **Update** builds that screen's own profile
met de ingebouwde CLI, installeert draadloos en wacht tot het scherm terug is. with the built-in CLI, installs it wirelessly, and waits until the screen is back.
Met het vinkje gebeurt dat 's nachts vanzelf, één scherm tegelijk; een With the checkbox enabled, that happens automatically at night, one screen at a time; a
mislukking stopt de ronde en geeft een melding in Home Assistant. Firmware failure stops the round and posts a notification in Home Assistant. Firmware
0.2.17+ meldt daarvoor zelf apparaatnaam en IP-adres; een ouder scherm vraagt 0.2.17+ reports its own device name and IP address for this; an older screen asks
eenmalig het IP-adres. Handmatig kan nog steeds via Firmware & USB → Wifi / OTA. for the IP address once. You can still do it manually via Firmware & USB → Wi-Fi / OTA.
De eigen YAML en wifi/API/OTA-instellingen blijven in de ESPHome-configmap. The device's own YAML and Wi-Fi/API/OTA settings stay in the ESPHome config folder.
Tegelindelingen en opties staan in de permanente appdata. CYD-kalibratie en Tile layouts and options live in the app's persistent data. CYD calibration and
schermvoorkeuren blijven op het apparaat opgeslagen. Updates vervangen deze screen preferences stay stored on the device. Updates don't replace this
gebruikersgegevens niet. Maak wel normale Home Assistant-back-ups en bewaar je user data. Do still make normal Home Assistant backups and keep your
apparaatprofielen; een app verwijderen of flashgeheugen wissen is geen update. device profiles; removing an app or wiping flash memory is not an update.
**Zuinig, ook met veel schermen (0.2.39 / firmware 0.2.33).** De app stuurt een **Efficient, even with many screens (0.2.39 / firmware 0.2.33).** The app sends a
scherm alleen de tegel die veranderde, als één actie screen only the tile that changed, as a single action
(`esphome.<apparaatnaam>_screen_message`) in plaats van blokjes in een tekstveld. (`esphome.<device_name>_screen_message`) instead of chunks in a text field.
Elke twee minuten volgt een kleine ping met de revisie van de indeling; meldt het Every two minutes, a small ping follows with the layout revision; if the
scherm dat die niet klopt (na een herstart bijvoorbeeld), dan komt alles opnieuw. screen reports that it doesn't match (after a restart, for example), everything is resent.
Grafieken van sensortegels komen uit de statistieken van Home Assistant, in één Graphs on sensor tiles come from Home Assistant's statistics, in one
vraag voor alle schermen. Oudere firmware blijft werken via het tekstveld en de query for all screens. Older firmware still works via the text field and the
volledige herhaling per twee minuten. De diagnostische sensor `Uptime` is full resend every two minutes. The diagnostic sensor `Uptime` has been
vervangen door het tijdstempel `Opgestart`. replaced by the `Last boot` timestamp.
Zie [releasegeschiedenis](screen_manager/CHANGELOG.md) en See the [release history](screen_manager/CHANGELOG.md) and
[releases en protocolcompatibiliteit](docs/RELEASING.md). [releases and protocol compatibility](docs/RELEASING.md).
**Voor wie zelf publiceert:** elke push naar GitHub is een release. Verhoog dan **If you publish your own fork:** every push to GitHub is a release. Always also
altijd ook de add-onversie in `screen_manager/config.yaml` en zet de wijziging in bump the add-on version in `screen_manager/config.yaml` and log the change in
de CHANGELOG, anders biedt de HA-appwinkel geen update aan. Een wijziging aan het the CHANGELOG, otherwise the HA App store won't offer an update. A change to the
scherm krijgt daarnaast een nieuwe `SCREEN_FIRMWARE_VERSION` in beide bordprofielen. screen also gets a new `SCREEN_FIRMWARE_VERSION` in both board profiles.
## Handleidingen en hulp bij installatie ## Guides and installation help
- [Complete installatie vanuit ESP Screens](docs/EASY_SETUP.md) - [Complete installation from ESP Screens](docs/EASY_SETUP.md)
- [Guition-hardware, montage en rotatie](docs/GUITION.md) - [Guition hardware, mounting, and rotation](docs/GUITION.md)
- [CYD-kalibratie en USB-diagnose](docs/CALIBREREN.md) - [CYD calibration and USB diagnostics](docs/CALIBRATING.md)
- [Fysieke acceptatietest](docs/ACCEPTATIE.md) - [Physical acceptance test](docs/ACCEPTANCE.md)
- [Instructies voor developers en LLM's](AGENTS.md) - [Instructions for developers and LLMs](AGENTS.md)
Geef een developer of LLM een schone kopie van deze repository en bijvoorbeeld: Give a developer or LLM a clean copy of this repository and, for example:
> Lees AGENTS.md, README.md en docs/EASY_SETUP.md. Help me dit CYD- of > Read AGENTS.md, README.md, and docs/EASY_SETUP.md. Help me install this CYD or
> Guition-scherm via USB op mijn Home Assistant te installeren. Identificeer > Guition screen via USB on my Home Assistant. Identify
> het bord en gebruik mijn bestaande profiel als dat er al is. Begeleid > the board and use my existing profile if one already exists. Guide me through
> kalibratie, HA-koppeling, tegelkeuze en fysieke tests. Houd sleutels lokaal > calibration, HA pairing, tile selection, and physical tests. Keep keys local,
> en geef aan welke controles werkelijk zijn uitgevoerd. > and state which checks were actually carried out.
Een build bewijst niet dat fysieke touch of het paneelbeeld goed is. De eigenaar A successful build doesn't prove the physical touch or panel image is correct. The owner
moet het beeld controleren en de gevraagde tikken uitvoeren. must check the display and perform the requested taps.
<details> <details>
<summary>Oudere handmatige CYD-installatie via een computer</summary> <summary>Older manual CYD installation via a computer</summary>
Onderstaande route gebruikt het oudere handmatige profiel met maximaal tien The route below uses the older manual profile with up to ten
tegels. De positiebeperkingen hiervan gelden niet voor de twintig runtime-tegels tiles. Its position constraints don't apply to ESP Screen Manager's twenty
van ESP Screen Manager. Gebruik voor nieuwe installaties bij voorkeur de route runtime tiles. For new installations, prefer the route
hierboven; de handmatige instructies blijven beschikbaar voor onderhoud. above; the manual instructions remain available for maintenance.
# CYD Home Assistant-bedieningsscherm # CYD Home Assistant control screen
Van een nieuwe **ESP32-2432S028 met ILI9341 + XPT2046** naar een gekalibreerd From a fresh **ESP32-2432S028 with ILI9341 + XPT2046** to a calibrated
Home Assistant-scherm, via USB. Met lichtbediening, scènes/scripts, climate, Home Assistant screen, over USB. With light control, scenes/scripts, climate,
vacuum, maximaal tien tegels en vaste pagina's. Bij maximaal zes tegels vacuum, up to ten tiles, and fixed pages. With six tiles or fewer,
verdwijnt de paginering. Standby begint standaard na tien minuten. pagination disappears. Standby starts after ten minutes by default.
**Handmatige installatie via de computer:** je hoeft geen Home Assistant-token te maken **Manual installation via a computer:** you don't need to create a Home Assistant token
of een ESPHome-add-on te installeren om via de computer te flashen. or install an ESPHome add-on to flash from a computer.
## Met een LLM of developer werken ## Working with an LLM or developer
Geef de uitgepakte map en deze opdracht: Give it the unpacked folder and this prompt:
> Lees eerst AGENTS.md en README.md. Begeleid mij bij een nieuwe installatie > First read AGENTS.md and README.md. Guide me through a new installation
> op een ESP32-2432S028 die via USB is aangesloten, met mijn eigen Home > on an ESP32-2432S028 connected via USB, with my own Home
> Assistant. Controleer het bord, de seriële poort en de Python-omgeving. > Assistant. Check the board, the serial port, and the Python environment.
> Maak een lokaal device.yaml-profiel; gebruik geen bestaande persoonlijke > Create a local device.yaml profile; don't use any existing personal
> entiteiten of paneelkalibratie. Doorloop USB-kalibratie, HA-koppeling, > entities or panel calibration. Walk through USB calibration, HA pairing,
> tegelconfiguratie en de acceptatietest. Vraag mij om fysieke tikken wanneer > tile configuration, and the acceptance test. Ask me for physical taps when
> nodig. Houd sleutels en wachtwoorden in lokale bestanden en rapporteer > needed. Keep keys and passwords in local files, and report
> welke controles echt zijn uitgevoerd. > which checks were actually carried out.
De begeleider kan de code en logs lezen, maar kan het fysieke scherm niet The assistant can read the code and logs, but can't see the physical screen
vanzelf zien. Jij controleert het beeld en voert de gevraagde tikken uit. on its own. You check the display and perform the requested taps.
## 1. Benodigdheden controleren ## 1. Check what you need
- Het bedoelde bord: ESP32-2432S028, 320×240, **ILI9341-display en resistieve - The intended board: ESP32-2432S028, 320×240, **ILI9341 display and resistive
XPT2046-touch**. Er bestaan borden met bijna dezelfde naam en een andere XPT2046 touch**. Boards exist with almost the same name and a different
schermcontroller. Deze handleiding garandeert die varianten niet. display controller. This guide doesn't cover those variants.
- Een USB-**datakabel** en een computer met macOS, Linux of Windows. - A USB **data cable** and a computer running macOS, Linux, or Windows.
- Python **3.11–3.14**, internet voor de eerste build en enkele GB vrije ruimte. - Python **3.11–3.14**, internet access for the first build, and a few GB of free space.
De versiegrens komt uit het geteste ESPHome-pakket; gebruik geen Python 3.15. The version limit comes from the tested ESPHome package; don't use Python 3.15.
- Een 2,4GHz-wifinetwerk; Home Assistant moet het bord via het netwerk kunnen - A 2.4GHz Wi-Fi network; Home Assistant must be able to reach the board over
bereiken. Houd het wifiwachtwoord lokaal beschikbaar. the network. Keep the Wi-Fi password available locally.
- Toegang tot Home Assistant om entiteiten te kiezen en ESPHome toe te voegen. - Access to Home Assistant to choose entities and add ESPHome.
Gebruik bij voorkeur één aangesloten ESP-bord tegelijk. Noteer de variant en Preferably keep only one ESP board connected at a time. Note the variant and
het MAC-adres uit de boot-/uploadlogs. `home-like.yaml` en `buttons.yaml` uit the MAC address from the boot/upload logs.
het oorspronkelijke project zijn **niet** de startersconfiguratie voor dit bord.
## 2. De werkomgeving installeren ## 2. Set up the work environment
Kloon [de repository](https://github.com/MaxGramser/homeassistant_espscreen) Clone [the repository](https://github.com/MaxGramser/homeassistant_espscreen)
of pak de starter-ZIP uit. Open een terminal **in de codefolder**. Alle opdrachten hieronder or unpack the starter ZIP. Open a terminal **in the code folder**. All commands below
worden vanuit die folder uitgevoerd. are run from that folder.
macOS/Linux: macOS/Linux:
@@ -343,9 +342,9 @@ python -m pip install --upgrade pip
python -m pip install -r requirements.txt python -m pip install -r requirements.txt
``` ```
Als PowerShell activeren blokkeert, gebruik rechtstreeks If activating PowerShell is blocked, use
`.\.venv\Scripts\python.exe` in plaats van `python`; een systeemwijziging is `.\.venv\Scripts\python.exe` directly instead of `python`; no system change is
niet nodig. Controleer daarna: needed. Then check:
```sh ```sh
python --version python --version
@@ -353,97 +352,97 @@ python -m esphome version
python -m serial.tools.list_ports python -m serial.tools.list_ports
``` ```
Verwacht **ESPHome 2026.6.2**. Deze repo bevat een aangepaste touchdriver die Expect **ESPHome 2026.6.2**. This repo includes a custom touch driver that's
met deze versie is getest. Upgrade niet stilzwijgend tijdens een installatie. tested against this version. Don't silently upgrade during an installation.
Zie ook de [officiële ESPHome-installatiehandleiding](https://esphome.io/install/). See also the [official ESPHome installation guide](https://esphome.io/install/).
Noteer de poort: bijvoorbeeld `/dev/cu.usbserial-130` op macOS, Note the port: for example `/dev/cu.usbserial-130` on macOS,
`/dev/ttyUSB0` op Linux of `COM3` op Windows. Verderop staat `<USB_POORT>`; `/dev/ttyUSB0` on Linux, or `COM3` on Windows. Further down, `<USB_PORT>`
vervang dat steeds door de werkelijke poort, zonder punthaken. appears; always replace it with the actual port, without angle brackets.
## 3. Een eigen apparaatprofiel maken ## 3. Create your own device profile
Kies een unieke naam, bijvoorbeeld `display-keuken`: Choose a unique name, for example `display-kitchen`:
```sh ```sh
python tools/new_device.py --name display-keuken --friendly-name "Display keuken" python tools/new_device.py --name display-kitchen --friendly-name "Display kitchen"
``` ```
Dit maakt drie lokale bestanden en overschrijft niets: This creates three local files and overwrites nothing:
| Bestand | Wat je erin aanpast | | File | What you edit in it |
|---|---| |---|---|
| `device.yaml` | Apparaatnaam, ruimte, tegels en gedrag | | `device.yaml` | Device name, room, tiles, and behavior |
| `calibration.yaml` | Kalibratie van dit ene fysieke paneel; de wizard schrijft dit | | `calibration.yaml` | Calibration for this one physical panel; the wizard writes this |
| `secrets.yaml` | Wifi, API-encryptiesleutel, OTA- en fallbackwachtwoord | | `secrets.yaml` | Wi-Fi, API encryption key, OTA and fallback password |
De sleutels worden uniek gegenereerd. Vul in `secrets.yaml` alleen jouw The keys are generated uniquely. In `secrets.yaml`, only fill in your
`wifi_ssid` en `wifi_password` in. Laat de gegenereerde sleutels staan en `wifi_ssid` and `wifi_password`. Leave the generated keys as they are and
bewaar dit bestand bij je eigen configuratie. Deel het niet mee in de ZIP. keep this file with your own configuration. Don't include it in the ZIP.
De API-encryptiesleutel heb je later nodig bij de HA-koppeling; kopieer die You'll need the API encryption key later for HA pairing; copy it
lokaal uit het bestand, niet via een openbare chat. locally from the file, not through a public chat.
Bestaan deze bestanden al? Werk in een nieuwe kopie van de map voor een nieuw Do these files already exist? Work in a new copy of the folder for a new
scherm. Wis de sleutels/kalibratie van een werkend scherm niet. screen. Don't erase the keys/calibration of a working screen.
`device.yaml` importeert de basis en jouw kalibratie. Het overschrijft ook `device.yaml` imports the base configuration and your calibration. It also overwrites
**alle verborgen tegelposities**, zodat die geen entiteiten van iemand anders **all hidden tile positions**, so they don't keep tracking someone else's
blijven volgen. Begin met de zes voorbeeldtegels. `DIRECT_ACTIONS: "false"` entities. Start with the six example tiles. `DIRECT_ACTIONS: "false"`
voorkomt directe HA-aansturing tijdens de eerste installatie. Maak in deze prevents direct HA control during the initial installation. At this
fase nog geen HA-automations die op de actie-sensor reageren. stage, don't yet create HA automations that react to the action sensor.
## 4. Eerste USB-flash en kalibratie ## 4. First USB flash and calibration
Controleer eerst de configuratie (ESPHome schermt secrets standaard af): First check the configuration (ESPHome masks secrets by default):
```sh ```sh
python -m esphome config device.yaml python -m esphome config device.yaml
``` ```
Flash de kalibratiemodus, die zonder HA-verbinding werkt: Flash calibration mode, which works without an HA connection:
```sh ```sh
python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device <USB_POORT> python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device <USB_PORT>
``` ```
De eerste build kan meerdere minuten duren. Na upload verschijnen vijf The first build can take several minutes. After uploading, five
kruisjes op een donker scherm. `run` blijft logs tonen: stop **alleen de crosshairs appear on a dark screen. `run` keeps showing logs: stop **only the
loglezer met Ctrl+C** voordat je de kalibratiewizard start. Het bord blijft aan. log reader with Ctrl+C** before starting the calibration wizard. The board stays on.
Volg nu [docs/CALIBREREN.md](docs/CALIBREREN.md): per doel worden drie tikken Now follow [docs/CALIBRATING.md](docs/CALIBRATING.md): three taps are
verzameld, de vier hoeken bepalen de correctie en het midden is een collected per target, the four corners determine the correction, and the center is an
onafhankelijke controle. Daarna flash je de uitkomst en meet je opnieuw. independent check. Then you flash the result and measure again.
**Kopieer nooit de meetwaarden van een ander paneel.** **Never copy the measurements from another panel.**
## 5. Home Assistant koppelen ## 5. Pair with Home Assistant
Na een geslaagde kalibratie flash je de normale modus: After a successful calibration, flash the normal mode:
```sh ```sh
python -m esphome run device.yaml --device <USB_POORT> python -m esphome run device.yaml --device <USB_PORT>
``` ```
Controleer in de logs de wifi-IP, stabiele boot en apparaatnaam. Ctrl+C sluit Check the logs for the Wi-Fi IP, a stable boot, and the device name. Ctrl+C closes
de loglezer. Het scherm moet nu de normale tegelpagina tonen. the log reader. The screen should now show the normal tile page.
1. Open in HA **Instellingen → Apparaten & diensten**. 1. In HA, open **Settings → Devices & services**.
2. Kies het ontdekte ESPHome-apparaat, of **Integratie toevoegen → ESPHome**. 2. Choose the discovered ESPHome device, or **Add integration → ESPHome**.
3. Gebruik `display-keuken.local` of het IP-adres uit de logs; API-poort **6053**. 3. Use `display-kitchen.local` or the IP address from the logs; API port **6053**.
4. Vul de `api_encryption_key` uit je lokale `secrets.yaml` in als daarom wordt gevraagd. 4. Enter the `api_encryption_key` from your local `secrets.yaml` if asked.
5. Controleer of de apparaatnaam klopt en de status online wordt. 5. Check that the device name is correct and the status becomes online.
6. Open de opties/configuratie van deze ESPHome-integratie en schakel 6. Open this ESPHome integration's options/configuration and enable
**Allow the device to perform Home Assistant actions** in. **Allow the device to perform Home Assistant actions**.
De precieze vertaling/plaats van de optie verschilt per HA-versie. Zie de The exact wording/location of this option varies by HA version. See the
[officiële integratiehandleiding](https://www.home-assistant.io/integrations/esphome). [official integration guide](https://www.home-assistant.io/integrations/esphome).
De HA-integratie verbindt met het bord; de ESPHome Device Builder/add-on is The HA integration connects to the board; the ESPHome Device Builder/add-on is
optioneel en is een andere functie. optional and is a separate feature.
## 6. Eigen tegels instellen ## 6. Configure your own tiles
Volg [docs/TEGELS.md](docs/TEGELS.md). Verzamel de echte entity-ID's in HA, Follow [docs/TILES.md](docs/TILES.md). Collect the real entity IDs in HA,
controleer ondersteunde attributen/acties en pas `device.yaml` aan. Gebruik check supported attributes/actions, and edit `device.yaml`. Use
korte titels. Als je alles hebt gecontroleerd: short titles. Once you've checked everything:
```yaml ```yaml
substitutions: substitutions:
@@ -451,52 +450,52 @@ substitutions:
TILE_COUNT: "6" TILE_COUNT: "6"
``` ```
Dit zijn wijzigingen in de **bestaande** substitutions-map, geen tweede map These are changes in the **existing** substitutions map, not a second map
onderaan het bestand. Bouw en flash opnieuw met `run device.yaml`. De tegels at the bottom of the file. Build and flash again with `run device.yaml`. The tiles
volgen daarna de werkelijke HA-status. Een actieve API-verbinding alleen will then follow the real HA state. An active API connection alone
bewijst nog niet dat een actie door HA is toegestaan. doesn't prove that HA has been granted permission for actions.
## 7. Opleveren en testen ## 7. Handover and testing
Voer [docs/ACCEPTATIE.md](docs/ACCEPTATIE.md) uit. Dat omvat een onafhankelijke Run through [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md). That covers an independent
touchmeting, beide pagina's indien aanwezig, lange druk, sliders, climate, touch measurement, both pages if present, long press, sliders, climate,
vacuum, werkelijke HA-status en de juiste actie-effecten. De ingebouwde vacuum, real HA state, and the correct action effects. The built-in
renderproef stuurt zelf geen HA-apparaten aan: render test doesn't control any HA devices itself:
```sh ```sh
python diagnostics/run_ui_test.py --host display-keuken.local --name display-keuken python diagnostics/run_ui_test.py --host display-kitchen.local --name display-kitchen
``` ```
Bewaar bij deze persoon: `device.yaml`, `calibration.yaml`, `secrets.yaml`, de Keep with this person: `device.yaml`, `calibration.yaml`, `secrets.yaml`, the
meetbestanden, de gebruikte ESPHome-versie en een kort opleverrapport. measurement files, the ESPHome version used, and a short handover report.
Voor later bijwerken kun je USB blijven gebruiken of OTA gebruiken: For later updates you can keep using USB or use OTA:
```sh ```sh
python -m esphome run device.yaml --device display-keuken.local python -m esphome run device.yaml --device display-kitchen.local
``` ```
Verander naam/API-sleutel niet zonder ook de HA-koppeling bij te werken. Don't change the name/API key without also updating the HA pairing.
Bij storing: [docs/PROBLEMEN.md](docs/PROBLEMEN.md). For troubleshooting: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
## Deze code aan de volgende persoon geven ## Handing this code to the next person
Gebruik de exporteur in plaats van je hele werkmap te kopiëren: Use the exporter instead of copying your entire working folder:
```sh ```sh
python tools/export_bundle.py --output dist/cyd-starter.zip python tools/export_bundle.py --output dist/cyd-starter.zip
``` ```
De ZIP bevat de code, lokale fonts, licenties, handleidingen en neutrale The ZIP contains the code, local fonts, licenses, guides, and neutral
voorbeelden. De basisdefaults worden voor de ontvanger geneutraliseerd. examples. The base defaults are neutralized for the recipient.
Geen `secrets.yaml`, eigen `device.yaml`, metingen, logs, `.git`, `.esphome`, No `secrets.yaml`, personal `device.yaml`, measurements, logs, `.git`, `.esphome`,
Python-omgeving of firmwarebackups. De exporteur weigert een bestaande ZIP Python environment, or firmware backups. The exporter refuses to overwrite an
te overschrijven; kies bij een nieuwe versie een andere bestandsnaam. existing ZIP; choose a different filename for a new version.
De originele basis is van The original base is from
[akuehlewind/ESPHome-touch-display-mount](https://github.com/akuehlewind/ESPHome-touch-display-mount). [akuehlewind/ESPHome-touch-display-mount](https://github.com/akuehlewind/ESPHome-touch-display-mount).
Project-, ESPHome-driver- en fontlicenties staan respectievelijk in `LICENSE`, Project, ESPHome driver, and font licenses are in `LICENSE`,
`components/xpt2046/LICENSE` en `fonts/`. Historische borddiagnose staat in `components/xpt2046/LICENSE`, and `fonts/` respectively. Historical board diagnostics are in
[CYD_STABILITY.md](CYD_STABILITY.md); volg voor een **nieuwe** installatie de [CYD_STABILITY.md](CYD_STABILITY.md); for a **new** installation, follow the
handleidingen hierboven. guides above.
</details> </details>
+55 -55
View File
@@ -1,68 +1,68 @@
# Testresultaten ESP32-2432S028 — 12 september 2026 # Test results — ESP32-2432S028 — September 12, 2026
Getest op de via USB aangesloten `cyd-2432s028`, ESP32 rev3.1, ESPHome Tested on the USB-connected `cyd-2432s028`, ESP32 rev3.1, ESPHome
2026.6.2 / ESP-IDF. De firmware meldt compilatietijd 2026.6.2 / ESP-IDF. The firmware reports compile time
`2026-09-12 09:54:02 +0200` en is via USB geschreven en geverifieerd. `2026-09-12 09:54:02 +0200` and was written and verified over USB.
## Geslaagd ## Passed
- C++-regressietests voor ruis, contactdender, verspringende aanrakingen, - C++ regression tests for noise, contact bounce, jumping touches,
echte sleepbewegingen, temperatuurafronding en `millis()`-overflow. real drag movements, temperature rounding, and `millis()` overflow.
- Vijf indelings-/configuratietests voor schermgrenzen, overlap, modale - Five layout/configuration tests for screen bounds, overlap, modal
lagen, voorwaardelijke paginering en guards vóór alle tegelhandlers. layers, conditional pagination, and guards ahead of all tile handlers.
- ESPHome-validatie met `TILE_COUNT: 6`; navigatie is in de code aan - ESPHome validation with `TILE_COUNT: 6`; navigation is tied in the code to
`count > 6` gekoppeld en tegels boven het ingestelde aantal zijn verborgen. `count > 6`, and tiles above the configured count stay hidden.
- Volledige ESP-IDF-build en USB-upload met verificatie van de geschreven data. - Full ESP-IDF build and USB upload with verification of the written data.
- Verbinding met de bestaande Home Assistant-installatie en ontvangst van - Connection to the existing Home Assistant installation and receipt of
entiteitsstatussen. entity states.
- **10 paginacontroles en 50 overlay-cycli op het echte apparaat.** - **10 page checks and 50 overlay cycles on the real device.**
Render-startteller steeg van 38 naar 101 tussen de eerste en The render-start counter rose from 38 to 101 between the first and
laatste paginacontrole: de test controleert ook werkelijke tekenactiviteit. last page check: the test also verifies actual draw activity.
- Runtime-geometrie: tegelgebied `(0,40)–(319,203)`, knop Volgende - Runtime geometry: tile area `(0,40)–(319,203)`, Next button
`(211,206)–(310,237)`. De gebieden overlappen niet. `(211,206)–(310,237)`. The areas don't overlap.
- Vrije heap tijdens de tien testmetingen: 86,416–139,696 bytes - Free heap during the ten test measurements: 86,416–139,696 bytes
volgens `esp_get_free_heap_size()`. Geen oplopend geheugenverlies of reset per `esp_get_free_heap_size()`. No growing memory loss or reset
in deze korte tekenproef. during this short render test.
## Fysieke touchdiagnose ## Physical touch diagnostics
De gebruiker tikte herhaaldelijk de vier schermhoeken aan. De oorspronkelijke The user repeatedly tapped the four screen corners. The raw
meting sprong linksboven soms meer dan honderd pixels weg. Na de driverfilter measurement at top-left sometimes jumped more than a hundred pixels off. After the driver filter,
bleven de samples in herkenbare hoekgroepen. De overblijvende scheefstand is the samples stayed within recognizable corner clusters. The remaining skew was
met een affine correctie op de hoekmedianen gecorrigeerd; de regressietest corrected with an affine correction on the corner medians; the regression test
plaatst die medianen binnen vijf pixels van het doel. De bronwaarden en places those medians within five pixels of the target. The source values and
coëfficiënten staan in de configuratie, tests en CYD_STABILITY.md. coefficients are in the configuration, tests, and CYD_STABILITY.md.
De gebruiker bevestigde na de laatste flash dat de juiste knoppen reageren After the final flash, the user confirmed that the correct buttons respond
en de bediening nu goed werkt. and the controls now work well.
## Grenzen ## Limitations
Dit is geen langdurige duurproef. Home Assistant-acties zijn niet automatisch This is not a long-duration endurance test. Home Assistant actions were not automatically
uitgevoerd tijdens de schermtest. De gebruiker heeft de fysieke bediening na de laatste kalibratiecorrectie performed during the screen test. The user confirmed the physical controls after the final calibration correction
bevestigd: "Ja, dit werkt nu goed". De tien minuten with: "Yes, this works well now". The ten-minute
standby zijn ingesteld; er is niet tien minuten gewacht om de volledige standby is configured; nobody waited the full ten minutes to let the
idle-timeout af te laten lopen. complete idle timeout run out.
Lokale ruwe logs staan in `diagnostics/acceptance-ui-test.log` en Local raw logs are in `diagnostics/acceptance-ui-test.log` and
`diagnostics/acceptance-runtime.log` en worden niet in Git opgenomen. `diagnostics/acceptance-runtime.log` and are not included in Git.
## Overdraagbare installatie — aanvullende softwarecontrole ## Portable installation — additional software check
Op 12 september 2026 is de nieuwe onboarding getest: On September 12, 2026, the new onboarding flow was tested:
- 17 Python-tests slagen: layout, kalibratie-fit, onafhankelijke verificatie, - 17 Python tests pass: layout, calibration fit, independent verification,
foutieve/ruisende metingen, unieke secrets, niet overschrijven en export. faulty/noisy measurements, unique secrets, not overwriting, and export.
- Beide C++17-regressieprogramma's slagen (touchfilter en UI-guard). - Both C++17 regression programs pass (touch filter and UI guard).
- Starter-ZIP uitgepakt in een verse tijdelijke map; nieuw profiel met eigen - Starter ZIP unpacked into a fresh temporary folder; a new profile was created with its own
gegenereerde sleutels aangemaakt. Volledige ESPHome 2026.6.2 / ESP-IDF-build generated keys. A full ESPHome 2026.6.2 / ESP-IDF build
met kalibratie-opstartmodus geslaagd. with calibration boot mode succeeded.
- Gegenereerde C++ gecontroleerd op identiteitcorrectie voor het nieuwe bord. - Generated C++ checked for identity correction on the new board.
Het aparte lokale profiel behoudt de eerder gemeten zes correctiecoëfficiënten. The separate local profile retains the six previously measured correction coefficients.
- Git-index en bestaande historie gecontroleerd op de lokale credentials; - The Git index and existing history were checked for the local credentials;
geen overeenkomsten. Lokale profielen en secrets zijn uitgesloten. no matches. Local profiles and secrets are excluded.
De vijfpuntswizard is met synthetische meetgegevens getest. Hij is in deze The five-point wizard was tested with synthetic measurement data. In this
ronde niet opnieuw fysiek uitgevoerd of naar het bestaande bord geflasht; round it was not physically run again or flashed to the existing board;
voor ieder nieuw paneel blijft de fysieke acceptatie verplicht. De bovenstaande physical acceptance remains mandatory for every new panel. The hardware test
oudere hardwaretest betreft de eerder geïnstalleerde firmware. above concerns the previously installed firmware.
+15 -11
View File
@@ -1,14 +1,16 @@
> Nieuwe ESP32-2432S028-installatie: begin bij [README.md](README.md) en > New ESP32-2432S028 installation: start at [README.md](README.md) and
> [de actuele tegelhandleiding](docs/TEGELS.md). Hieronder staat ook legacygedrag. > [the current tile guide](docs/TILES.md). Legacy behavior is also described below.
# Tile Configuration Reference # Tile Configuration Reference
For the ESP32-2432S028 profile, use `home-like-2432s028.yaml` and For the ESP32-2432S028 profile, use `home-like-2432s028.yaml` and
[CYD_STABILITY.md](CYD_STABILITY.md). That profile uses fixed pages instead of [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. scrolling, hides pagination when `TILE_COUNT <= 6`, and dims after ten minutes.
The scrolling description below applies to `home-like.yaml`. The scrolling section below describes `home-like.yaml`, an early personal
profile that has since been removed from the repository; it's kept here only
as a historical record.
This document explains every substitution in `home-like.yaml` and provides copy-paste examples for all supported entity types. This document explains every substitution used across the tile profiles and provides copy-paste examples for all supported entity types.
--- ---
@@ -55,11 +57,11 @@ of the entity id before the dot:
| Entity | Domain | Accent | | Entity | Domain | Accent |
|--------|--------|--------| |--------|--------|--------|
| `script.goede_morgen` | `script` | `ACCENT_ACTION` (violet) | | `script.good_morning` | `script` | `ACCENT_ACTION` (violet) |
| `scene.avondlicht` | `scene` | `ACCENT_ACTION` (violet) | | `scene.evening_lights` | `scene` | `ACCENT_ACTION` (violet) |
| `light.woonkamer_2` | `light` | `ACCENT_LIGHT` (amber) | | `light.living_room_2` | `light` | `ACCENT_LIGHT` (amber) |
| `vacuum.s8` | `vacuum` | `ACCENT_DEVICE` (cyan) | | `vacuum.s8` | `vacuum` | `ACCENT_DEVICE` (cyan) |
| `climate.airco` | `climate` | `ACCENT_DEVICE` (cyan) | | `climate.ac` | `climate` | `ACCENT_DEVICE` (cyan) |
The distinction that matters: a script or scene is something you *fire*, a The distinction that matters: a script or scene is something you *fire*, a
device is something you switch *on and off*. Those should not look the same. device is something you switch *on and off*. Those should not look the same.
@@ -74,7 +76,7 @@ Because the color follows the entity, dropping a different entity into a tile
gives it the right color without touching any color key. gives it the right color without touching any color key.
> The domain lookup lives in a small `accent_for` lambda that appears **twice** > The domain lookup lives in a small `accent_for` lambda that appears **twice**
> in `home-like.yaml`: once in `ui_refresh` (for the tiles) and once in > in each profile: once in `ui_refresh` (for the tiles) and once in
> `open_value_overlay` (for the slider fill). Keep the two copies in sync. > `open_value_overlay` (for the slider fill). Keep the two copies in sync.
--- ---
@@ -534,8 +536,10 @@ TILE1_LONGPRESS_ACTION_SERVICE: "light.toggle" # must set toggle explicitly, de
## Adding a tile ## Adding a tile
All ten slots are filled. To grow the grid, add a `GRID_ROW6_Y` to every All ten slots are filled. To grow the grid, add a `GRID_ROW6_Y` to every
orientation preset first. A tile is not a single block — it is six places in orientation preset first (this applied to the now-removed `home-like.yaml`;
`home-like.yaml`. Copy tile 9 (a light with a slider) or tile 10 (a service the maintained fixed-page profiles cap at ten tiles by design — see
[docs/TILES.md](docs/TILES.md)). A tile is not a single block — it is six
places. Copy tile 9 (a light with a slider) or tile 10 (a service
call) and change the number: call) and change the number:
1. **Substitutions** — a `TILE9_*` block. Copy an existing one wholesale. 1. **Substitutions** — a `TILE9_*` block. Copy an existing one wholesale.
BIN
View File
Binary file not shown.
-618
View File
@@ -1,618 +0,0 @@
############################################################
# SmartDisplay (Cheap Yellow Display / CYD) – ESPHome + LVGL lockscreen UI
#
# Target hardware
# - Cheap Yellow Display (CYD)
# (ESP32 + ILI9341 320x240 display + XPT2046 touchscreen)
#
# Purpose
# - LVGL renders a simple “lockscreen” UI: time/date + 4 toggle buttons.
#
# Touch input → Home Assistant
# - Every touch publishes an “action” string via a text_sensor:
# sensor.smartdisplay_action = "btn1_press" / "btn2_press" / "btn3_press" / "btn4_press"
# - The sensor is auto-reset to "" after each press so repeated presses trigger reliably.
#
# Two supported control modes (choose what fits your setup)
# 1) Direct control (DEFAULT, simplest)
# - DIRECT_ACTIONS: "true" (default)
# - Each touch will:
# • publish the action string AND
# • directly call a Home Assistant service
# (default: light.toggle on BTN*_ENTITY)
# - Use this if you simply want to toggle lights without creating automations.
#
# 2) Automation-only control (custom logic)
# - DIRECT_ACTIONS: "false"
# - Each touch will ONLY publish the action string.
# - You implement the behavior in Home Assistant automations using:
# Trigger → State → sensor.smartdisplay_action → To: btn1_press (etc.)
#
# If you do NOT want to toggle lights
# - Either change the homeassistant.action blocks to a different service
# (e.g. switch.toggle, script.turn_on, scene.turn_on, ...)
# - Or set DIRECT_ACTIONS to "false" and handle everything via HA automations.
#
# UI updates
# - On boot, every minute, and whenever the configured HA entities change
# (mirrored into ESPHome to keep LVGL button states in sync).
#
# What you must adapt before flashing
# 1) Secrets: api.encryption.key, ota.password, WiFi credentials (prefer secrets.yaml)
# 2) BTN*_ENTITY: Home Assistant entity IDs you want to control/mirror
# 3) Touch calibration + transform:
# - calibration is panel-specific
# - touchscreen.transform MUST match display.transform exactly
#
# Home Assistant note
# - In the ESPHome device settings, enable:
# “Allow the device to perform Home Assistant actions”
# Otherwise homeassistant.action calls are blocked.
############################################################
# ==========================================================
# USER CONFIG (edit this section to match your HA setup)
#
# - DIRECT_ACTIONS:
# "true" -> touch publishes action + calls HA service (light.toggle by default)
# "false" -> touch only publishes action (all logic handled in HA)
#
# - BTN*_ENTITY:
# The Home Assistant entity to control and mirror for the button state.
# Must exist in Home Assistant.
#
# - BTN*_LABEL / BTN*_ICON:
# Only affects what is shown on the display.
# ==========================================================
substitutions:
# Enable/disable direct Home Assistant service calls on touch.
# - "true" -> touching a button also calls light.toggle (plug & play)
# - "false" -> touching a button ONLY publishes the action text_sensor
DIRECT_ACTIONS: "true"
# Button 1
BTN1_ENTITY: "light.desk_lamp"
BTN1_LABEL: "Desk"
BTN1_ICON: "\U000F095F"
# Button 2
BTN2_ENTITY: "light.ceiling_light"
BTN2_LABEL: "Ceiling"
BTN2_ICON: "\U000F0769"
# Button 3
BTN3_ENTITY: "light.globe_lamp"
BTN3_LABEL: "Globe"
BTN3_ICON: "\U000F08DD"
# Button 4
BTN4_ENTITY: "light.ambient_light"
BTN4_LABEL: "Ambient"
BTN4_ICON: "\U000F1051"
# ==========================================================
# ============================================================
# No need to edit below this line.
# All configuration is done via the substitutions above.
# Credentials are managed in secrets.yaml (see secrets.yaml.example).
# ============================================================
esphome:
name: smartdisplay
friendly_name: SmartDisplay
on_boot:
priority: 600
then:
- script.execute: ui_refresh
esp32:
board: esp32dev
framework:
type: arduino
logger:
# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values.
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
password: !secret ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Smartdisplay Fallback Hotspot"
password: !secret ap_password
captive_portal:
# Use Home Assistant time so the display always matches your HA timezone.
time:
- platform: homeassistant
id: homeassistant_time
on_time:
- seconds: 0
minutes: /1
then:
- script.execute: ui_refresh
font:
- file: "gfonts://Roboto@500"
id: big_text
size: 60
bpp: 4
glyphs: ['&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','å','Ä','ä','Ö','ö','Ü','ü','/']
- file: "gfonts://Roboto@300"
id: notice_text
size: 16
bpp: 4
glyphs: ['&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','å','Ä','ä','Ö','ö','Ü','ü','/']
- file: "gfonts://Roboto@400"
id: label
size: 13
bpp: 4
glyphs: ['&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','å','Ä','ä','Ö','ö','Ü','ü','/']
# Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont
- file: 'fonts/materialdesignicons-webfont.ttf'
id: materialdesign_icons
size: 35
glyphs: ["\U000F095F", "\U000F0769", "\U000F08DD", "\U000F1051"]
color:
- id: ACTIVE
hex: "FEC600"
- id: INACTIVE
hex: "808080"
sensor:
- platform: wifi_signal
name: "Wifi Signal"
update_interval: 600s
- platform: uptime
name: "Uptime"
id: uptime_s
update_interval: 15s
binary_sensor:
- platform: status
name: "Node Status"
id: system_status
# Mirror HA entities (used to render the "checked" state of each LVGL button)
# These are read-only "views" of HA state for UI sync.
- platform: homeassistant
id: ha_state_btn1
entity_id: ${BTN1_ENTITY}
on_state:
then:
- script.execute: ui_refresh
- platform: homeassistant
id: ha_state_btn2
entity_id: ${BTN2_ENTITY}
on_state:
then:
- script.execute: ui_refresh
- platform: homeassistant
id: ha_state_btn3
entity_id: ${BTN3_ENTITY}
on_state:
then:
- script.execute: ui_refresh
- platform: homeassistant
id: ha_state_btn4
entity_id: ${BTN4_ENTITY}
on_state:
then:
- script.execute: ui_refresh
text_sensor:
# ----------------------------------------------------------
# SmartDisplay Action
#
# This is the main "touch trigger" exported to Home Assistant:
# - On touch, we publish one of:
# btn1_press / btn2_press / btn3_press / btn4_press
# - Then we auto-reset to "" so repeated presses still trigger.
#
# In HA automations, use a simple state trigger:
# - Entity: sensor.smartdisplay_action
# - To: btn1_press (etc.)
# ----------------------------------------------------------
- platform: template
id: smartdisplay_action
name: "SmartDisplay Action"
icon: mdi:gesture-tap-button
- platform: template
name: "Uptime (formatted)"
lambda: |-
uint32_t dur = (uint32_t) id(uptime_s).state;
int dys = 0;
int hrs = 0;
int mnts = 0;
if (dur >= 86400) { dys = dur / 86400; dur = dur - (dys * 86400); }
if (dur >= 3600) { hrs = dur / 3600; dur = dur - (hrs * 3600); }
if (dur >= 60) { mnts = dur / 60; dur = dur - (mnts * 60); }
static char buffer[20];
snprintf(buffer, sizeof(buffer), "%dd %02dh %02dm %02ds", dys, hrs, mnts, dur);
return {buffer};
icon: mdi:clock-start
update_interval: 60s
switch:
- platform: restart
name: "Restart"
# Two SPI buses:
# - lcd: display
# - my_touchscreen: touch controller
spi:
- id: lcd
clk_pin: GPIO14
mosi_pin: GPIO13
- id: my_touchscreen
clk_pin: GPIO25
mosi_pin: GPIO32
miso_pin: GPIO39
output:
- platform: ledc
pin: GPIO21
id: gpio_backlight_pwm
- platform: ledc
id: output_red
pin: GPIO4
inverted: true
- platform: ledc
id: output_green
pin: GPIO16
inverted: true
- platform: ledc
id: output_blue
pin: GPIO17
inverted: true
light:
- platform: monochromatic
output: gpio_backlight_pwm
name: "Power Display Backlight"
id: back_light
restore_mode: ALWAYS_ON
- platform: rgb
name: LED
red: output_red
id: led
green: output_green
blue: output_blue
restore_mode: ALWAYS_OFF
# Touchscreen configuration (XPT2046)
# IMPORTANT:
# - calibration values are device-specific
# - touchscreen.transform must match display.transform exactly
touchscreen:
platform: xpt2046
id: ts_touch
spi_id: my_touchscreen
cs_pin: 33
interrupt_pin: 36
update_interval: 50ms
threshold: 400
calibration:
x_min: 280
x_max: 3860
y_min: 340
y_max: 3860
transform:
swap_xy: true
mirror_x: false
mirror_y: false
# Display configuration (ILI9341)
display:
- platform: mipi_spi
id: my_display
spi_id: lcd
model: ILI9341
cs_pin: 15
dc_pin: 2
invert_colors: false
update_interval: never
auto_clear_enabled: false
transform:
swap_xy: true
mirror_x: false
mirror_y: false
dimensions:
width: 320
height: 240
lvgl:
displays:
- my_display
touchscreens:
- touchscreen_id: ts_touch
theme:
obj:
bg_color: 0x000000
bg_opa: COVER
button:
radius: 30
width: 60
height: 60
border_width: 0
shadow_width: 0
bg_color: !lambda return id(INACTIVE);
bg_opa: COVER
checked:
bg_color: 0xFFFFFF
bg_opa: COVER
pages:
- id: lockscreen_page
bg_color: 0x000000
bg_opa: COVER
widgets:
- label:
id: lbl_date
x: 0
y: 30
width: 320
text_align: CENTER
text_font: notice_text
text_color: 0xFFFFFF
text: ""
- label:
id: lbl_time
x: 0
y: 55
width: 320
text_align: CENTER
text_font: big_text
text_color: 0xFFFFFF
text: ""
# ---------------------------------------------------
# LVGL Button 1
# - ALWAYS publishes a HA-friendly action: btn1_press
# - Optional direct action: light.toggle for BTN1_ENTITY
# ---------------------------------------------------
- button:
id: btn1
x: 25
y: 140
checkable: true
widgets:
- label:
align: CENTER
text_font: materialdesign_icons
text_color: !lambda return id(ACTIVE);
text: "${BTN1_ICON}"
on_click:
# ALWAYS: publish action to Home Assistant (state trigger friendly)
- script.execute:
id: publish_action
action: "btn1_press"
# OPTIONAL: direct HA service call (plug & play)
- if:
condition:
lambda: |-
return std::string("${DIRECT_ACTIONS}") == "true";
then:
- homeassistant.action:
action: light.toggle
data:
entity_id: ${BTN1_ENTITY}
- label:
x: 25
y: 205
width: 60
text_align: CENTER
text_font: label
text_color: 0xFFFFFF
text: "${BTN1_LABEL}"
# ---------------------------------------------------
# LVGL Button 2
# - ALWAYS publishes a HA-friendly action: btn2_press
# ---------------------------------------------------
- button:
id: btn2
x: 95
y: 140
checkable: true
widgets:
- label:
align: CENTER
text_font: materialdesign_icons
text_color: !lambda return id(ACTIVE);
text: "${BTN2_ICON}"
on_click:
- script.execute:
id: publish_action
action: "btn2_press"
- if:
condition:
lambda: |-
return std::string("${DIRECT_ACTIONS}") == "true";
then:
- homeassistant.action:
action: light.toggle
data:
entity_id: ${BTN2_ENTITY}
- label:
x: 95
y: 205
width: 60
text_align: CENTER
text_font: label
text_color: 0xFFFFFF
text: "${BTN2_LABEL}"
# ---------------------------------------------------
# LVGL Button 3
# - ALWAYS publishes a HA-friendly action: btn3_press
# ---------------------------------------------------
- button:
id: btn3
x: 165
y: 140
checkable: true
widgets:
- label:
align: CENTER
text_font: materialdesign_icons
text_color: !lambda return id(ACTIVE);
text: "${BTN3_ICON}"
on_click:
- script.execute:
id: publish_action
action: "btn3_press"
- if:
condition:
lambda: |-
return std::string("${DIRECT_ACTIONS}") == "true";
then:
- homeassistant.action:
action: light.toggle
data:
entity_id: ${BTN3_ENTITY}
- label:
x: 165
y: 205
width: 60
text_align: CENTER
text_font: label
text_color: 0xFFFFFF
text: "${BTN3_LABEL}"
# ---------------------------------------------------
# LVGL Button 4
# - ALWAYS publishes a HA-friendly action: btn4_press
# ---------------------------------------------------
- button:
id: btn4
x: 235
y: 140
checkable: true
widgets:
- label:
align: CENTER
text_font: materialdesign_icons
text_color: !lambda return id(ACTIVE);
text: "${BTN4_ICON}"
on_click:
- script.execute:
id: publish_action
action: "btn4_press"
- if:
condition:
lambda: |-
return std::string("${DIRECT_ACTIONS}") == "true";
then:
- homeassistant.action:
action: light.toggle
data:
entity_id: ${BTN4_ENTITY}
- label:
x: 235
y: 205
width: 60
text_align: CENTER
text_font: label
text_color: 0xFFFFFF
text: "${BTN4_LABEL}"
script:
# ----------------------------------------------------------
# publish_action
#
# Publishes a string to the "SmartDisplay Action" text_sensor and
# then clears it again.
# ----------------------------------------------------------
- id: publish_action
mode: queued
parameters:
action: string
then:
- text_sensor.template.publish:
id: smartdisplay_action
state: !lambda |-
return action;
- delay: 200ms
- text_sensor.template.publish:
id: smartdisplay_action
state: ""
- id: ui_refresh
mode: queued
then:
# Sync LVGL "checked" state with the mirrored HA entity states
- lvgl.widget.update:
id: btn1
state:
checked: !lambda return id(ha_state_btn1).state;
- lvgl.widget.update:
id: btn2
state:
checked: !lambda return id(ha_state_btn2).state;
- lvgl.widget.update:
id: btn3
state:
checked: !lambda return id(ha_state_btn3).state;
- lvgl.widget.update:
id: btn4
state:
checked: !lambda return id(ha_state_btn4).state;
# Render current time (HH:MM)
- lvgl.label.update:
id: lbl_time
text: !lambda |-
auto now = id(homeassistant_time).now();
static char buf[6];
snprintf(buf, sizeof(buf), "%02d:%02d", now.hour, now.minute);
return std::string(buf);
# Render localized date in German (adjust to your language if needed)
- lvgl.label.update:
id: lbl_date
text: !lambda |-
static const char* days[] = {"", "Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"};
static const char* months[] = {"","Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"};
auto now = id(homeassistant_time).now();
static char buf[40];
snprintf(buf, sizeof(buf), "%s, %d. %s", days[now.day_of_week], now.day_of_month, months[now.month]);
return std::string(buf);
+2 -2
View File
@@ -1,5 +1,5 @@
# Identiteit: startpunt voor een NIEUW paneel, geen gemeten kalibratie. # Identity: starting point for a NEW panel, not a measured calibration.
# tools/calibrate.py schrijft een gemeten versie van dit bestand. # tools/calibrate.py writes a measured version of this file.
substitutions: substitutions:
TOUCH_CAL_X_MIN: "280" TOUCH_CAL_X_MIN: "280"
TOUCH_CAL_X_MAX: "3860" TOUCH_CAL_X_MAX: "3860"
+3 -3
View File
@@ -11,15 +11,15 @@
// tests/test_alert_overlay.cpp can check them; the profiles do the LVGL work. // tests/test_alert_overlay.cpp can check them; the profiles do the LVGL work.
namespace screen_alert { namespace screen_alert {
constexpr const char *FALLBACK_ICON = "alert-outline"; constexpr const char *FALLBACK_ICON = "alert-outline";
constexpr const char *FALLBACK_TITLE = "Melding"; constexpr const char *FALLBACK_TITLE = "Notification";
constexpr const char *FALLBACK_BUTTON = "Oké"; constexpr const char *FALLBACK_BUTTON = "OK";
constexpr uint32_t DEFAULT_CARD_COLOR = 0xFFFFFF; constexpr uint32_t DEFAULT_CARD_COLOR = 0xFFFFFF;
constexpr int MAX_TIMEOUT_SECONDS = 86400; constexpr int MAX_TIMEOUT_SECONDS = 86400;
struct Alert { struct Alert {
std::string title, subtitle, icon, button; // icon: UTF-8 glyph for the icon font std::string title, subtitle, icon, button; // icon: UTF-8 glyph for the icon font
uint32_t color = DEFAULT_CARD_COLOR; uint32_t color = DEFAULT_CARD_COLOR;
int timeout_seconds = 0; // 0: stays until Oké int timeout_seconds = 0; // 0: stays until OK
bool flash = false; bool flash = false;
}; };
+6 -6
View File
@@ -14,9 +14,9 @@ inline std::array<std::array<Point,3>,5> samples;
inline esphome::ESPPreferenceObject preference; inline esphome::ESPPreferenceObject preference;
inline std::function<void(const Calibration &)> apply; inline std::function<void(const Calibration &)> apply;
inline std::function<void(bool)> isolation; inline std::function<void(bool)> isolation;
inline void prompt(const char *message = "Houd elke tik even vast") { inline void prompt(const char *message = "Hold each tap for a moment") {
lv_obj_set_pos(cross, TARGETS[point].x-12, TARGETS[point].y-12); lv_obj_set_pos(cross, TARGETS[point].x-12, TARGETS[point].y-12);
lv_label_set_text_fmt(instructions, "Tik op het kruisje\nPunt %u/5 - tik %u/3", point+1, sample+1); lv_label_set_text_fmt(instructions, "Tap the crosshair\nPoint %u/5 - tap %u/3", point+1, sample+1);
lv_label_set_text(status, message); lv_label_set_text(status, message);
} }
inline void begin() { inline void begin() {
@@ -54,13 +54,13 @@ inline void press() { if (active) { down_at = esphome::millis(); pressed = true;
inline void release(int raw_x, int raw_y) { inline void release(int raw_x, int raw_y) {
if (!active || !pressed) return; if (!active || !pressed) return;
pressed = false; pressed = false;
if (esphome::millis()-down_at < 80 || raw_x < 0 || raw_x > 4095 || raw_y < 0 || raw_y > 4095) { prompt("Tik rustig, iets langer"); return; } if (esphome::millis()-down_at < 80 || raw_x < 0 || raw_x > 4095 || raw_y < 0 || raw_y > 4095) { prompt("Tap gently, a bit longer"); return; }
samples[point][sample++] = {double(raw_x), double(raw_y)}; samples[point][sample++] = {double(raw_x), double(raw_y)};
if (sample < 3) { prompt(); return; } if (sample < 3) { prompt(); return; }
double xs[3], ys[3]; double xs[3], ys[3];
for (int i=0;i<3;++i) { xs[i]=samples[point][i].x; ys[i]=samples[point][i].y; } for (int i=0;i<3;++i) { xs[i]=samples[point][i].x; ys[i]=samples[point][i].y; }
std::sort(xs,xs+3); std::sort(ys,ys+3); std::sort(xs,xs+3); std::sort(ys,ys+3);
if (xs[2]-xs[0]>180 || ys[2]-ys[0]>180) { sample=0; prompt("Tik 3x op hetzelfde kruisje"); return; } if (xs[2]-xs[0]>180 || ys[2]-ys[0]>180) { sample=0; prompt("Tap the same crosshair 3 times"); return; }
point++; sample=0; point++; sample=0;
if (point < 5) { prompt(); return; } if (point < 5) { prompt(); return; }
std::array<Point,5> raw; std::array<Point,5> raw;
@@ -69,8 +69,8 @@ inline void release(int raw_x, int raw_y) {
std::sort(xs,xs+3);std::sort(ys,ys+3);raw[n]={xs[1],ys[1]}; std::sort(xs,xs+3);std::sort(ys,ys+3);raw[n]={xs[1],ys[1]};
} }
Calibration next = calibration; Calibration next = calibration;
if (!fit(raw,next)) { point=0;prompt("Meting afgekeurd; probeer opnieuw");return; } if (!fit(raw,next)) { point=0;prompt("Measurement rejected; try again");return; }
if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt("Opslaan mislukt; probeer opnieuw");return; } if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt("Save failed; try again");return; }
calibration=next; calibration=next;
if (apply) apply(calibration); if (apply) apply(calibration);
active=false; active=false;
+4 -4
View File
@@ -95,10 +95,10 @@ class TouchGuard {
// Why the last accept() refused, for the touch log; empty after a success. // Why the last accept() refused, for the touch log; empty after a success.
std::string reason() const { std::string reason() const {
switch (reject_) { switch (reject_) {
case MOVED: return "verplaatst " + std::to_string(distance_) + " px (grens " + std::to_string(move_limit_) + ")"; case MOVED: return "moved " + std::to_string(distance_) + " px (limit " + std::to_string(move_limit_) + ")";
case TOO_SHORT: return "te kort (" + std::to_string(duration_) + " ms, minimaal " + std::to_string(min_press_) + ")"; case TOO_SHORT: return "too short (" + std::to_string(duration_) + " ms, minimum " + std::to_string(min_press_) + ")";
case USED: return "al verwerkt in dit contact"; case USED: return "already handled in this contact";
case BOUNCE: return "dezelfde knop binnen de dendertijd"; case BOUNCE: return "same button within the debounce window";
default: return ""; default: return "";
} }
} }
+18 -18
View File
@@ -6,7 +6,7 @@
namespace header_bar { namespace header_bar {
// The top bar right of the screen name (firmware 0.2.32+). ESP Screen Manager decides what shows // The top bar right of the screen name (firmware 0.2.32+). ESP Screen Manager decides what shows
// and writes an entity's text; the screen draws it, ticks its clocks and counts "5 min geleden" // and writes an entity's text; the screen draws it, ticks its clocks and counts "5 min ago"
// itself. Everything here is free of LVGL, so tests/test_header_bar.cpp covers it on a PC. // itself. Everything here is free of LVGL, so tests/test_header_bar.cpp covers it on a PC.
constexpr size_t MAX_ITEMS = 6; constexpr size_t MAX_ITEMS = 6;
constexpr size_t TEXT_BYTES = 48; constexpr size_t TEXT_BYTES = 48;
@@ -64,32 +64,32 @@ inline uint32_t next_codepoint(const std::string &s, size_t &i) {
return cp; return cp;
} }
// Relative time in the editor's words (app.js agoText): "Zojuist", "5 min geleden", "Gisteren", // Relative time in the editor's words (app.js agoText): "Just now", "5 min ago", "Yesterday",
// "Over 2 uur". `now` 0 means the clock is not set yet. // "In 2 hours". `now` 0 means the clock is not set yet.
inline std::string ago_text(int64_t then, int64_t now) { inline std::string ago_text(int64_t then, int64_t now) {
if (now <= 0 || then <= 0) return "—"; if (now <= 0 || then <= 0) return "—";
int64_t seconds = now - then, span = seconds < 0 ? -seconds : seconds; int64_t seconds = now - then, span = seconds < 0 ? -seconds : seconds;
auto n = [&](int64_t unit) { return std::to_string(span / unit); }; auto n = [&](int64_t unit) { return std::to_string(span / unit); };
if (seconds < 0) { if (seconds < 0) {
if (span < 3600) return "Over " + std::to_string(std::max<int64_t>(1, span / 60)) + " min"; if (span < 3600) return "In " + std::to_string(std::max<int64_t>(1, span / 60)) + " min";
if (span < 86400) return "Over " + n(3600) + " uur"; if (span < 86400) return span / 3600 == 1 ? "In 1 hour" : "In " + n(3600) + " hours";
if (span < 172800) return "Morgen"; if (span < 172800) return "Tomorrow";
return "Over " + n(86400) + " dagen"; return "In " + n(86400) + " days";
} }
if (span < 60) return "Zojuist"; if (span < 60) return "Just now";
if (span < 3600) return n(60) + " min geleden"; if (span < 3600) return n(60) + " min ago";
if (span < 86400) return n(3600) + " uur geleden"; if (span < 86400) return span / 3600 == 1 ? "1 hour ago" : n(3600) + " hours ago";
if (span < 172800) return "Gisteren"; if (span < 172800) return "Yesterday";
if (span < 604800) return n(86400) + " dagen geleden"; if (span < 604800) return n(86400) + " days ago";
if (span < 2592000) return span / 604800 == 1 ? "1 week geleden" : n(604800) + " weken geleden"; if (span < 2592000) return span / 604800 == 1 ? "1 week ago" : n(604800) + " weeks ago";
if (span < 31536000) return span / 2592000 == 1 ? "1 maand geleden" : n(2592000) + " maanden geleden"; if (span < 31536000) return span / 2592000 == 1 ? "1 month ago" : n(2592000) + " months ago";
return n(31536000) + " jaar geleden"; return span / 31536000 == 1 ? "1 year ago" : n(31536000) + " years ago";
} }
// "ma 14 sep"; day_of_week 1 is Sunday, as ESPHome counts. // "Mo 14 Sep"; day_of_week 1 is Sunday, as ESPHome counts.
inline std::string date_text(int day_of_week, int day_of_month, int month) { inline std::string date_text(int day_of_week, int day_of_month, int month) {
static const char *days[] = {"zo", "ma", "di", "wo", "do", "vr", "za"}; static const char *days[] = {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"};
static const char *months[] = {"jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"}; static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
if (day_of_week < 1 || day_of_week > 7 || month < 1 || month > 12) return "—"; if (day_of_week < 1 || day_of_week > 7 || month < 1 || month > 12) return "—";
return std::string(days[day_of_week - 1]) + " " + std::to_string(day_of_month) + " " + months[month - 1]; return std::string(days[day_of_week - 1]) + " " + std::to_string(day_of_month) + " " + months[month - 1];
} }
+2 -2
View File
@@ -108,7 +108,7 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
int margin = large ? 24 : 16, w = width - 2 * margin; int margin = large ? 24 : 16, w = width - 2 * margin;
int track_x = 14, track_y = large ? 43 : 28, track_h = large ? 28 : 14; int track_x = 14, track_y = large ? 43 : 28, track_h = large ? 28 : 14;
int track_w = w - 28; int track_w = w - 28;
const char *names[] = {"Kleur", "Wittemperatuur", "Helderheid"}; const char *names[] = {"Color", "Color temperature", "Brightness"};
const uint32_t rainbow[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000}; const uint32_t rainbow[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};
for (unsigned i = 0; i < 3; ++i) { for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i]; row.index = i; auto &row = rows[i]; row.index = i;
@@ -170,7 +170,7 @@ inline void open(const std::string &entity, bool color, bool temperature, int br
if (i == 1 && active->temperature_ready()) lv_slider_set_range(row.slider, active->minimum, active->maximum); if (i == 1 && active->temperature_ready()) lv_slider_set_range(row.slider, active->minimum, active->maximum);
lv_slider_set_value(row.slider, i == 0 ? active->hue : i == 1 ? active->kelvin : clamp(brightness, 1, 100), LV_ANIM_OFF); lv_slider_set_value(row.slider, i == 0 ? active->hue : i == 1 ? active->kelvin : clamp(brightness, 1, 100), LV_ANIM_OFF);
preview(row); row.dirty = false; preview(row); row.dirty = false;
if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Even wachten"); if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Waiting...");
} }
} }
inline bool self_test() { inline bool self_test() {
+3 -3
View File
@@ -67,7 +67,7 @@ struct Tile {
uint32_t timer_end = 0; uint32_t timer_end = 0;
float battery = NAN, volume = NAN; float battery = NAN, volume = NAN;
uint32_t supported = 0, background = 0; uint32_t supported = 0, background = 0;
bool transparent = false; // "Achtergrond: geen": card fill and border hidden, contents unchanged. bool transparent = false; // "Background: none": card fill and border hidden, contents unchanged.
std::string icon; // UTF-8 glyph of a chosen icon the icon fonts contain; empty keeps the domain icon. std::string icon; // UTF-8 glyph of a chosen icon the icon fonts contain; empty keeps the domain icon.
std::string revision, pending_revision; std::string revision, pending_revision;
uint32_t pending_since = 0; uint32_t pending_since = 0;
@@ -113,7 +113,7 @@ struct Model {
// Pages the manager wants shown even when the last ones are still empty (0.2.26+). // Pages the manager wants shown even when the last ones are still empty (0.2.26+).
uint8_t pages = 1; uint8_t pages = 1;
size_t count = 0; size_t count = 0;
std::string title = "Kies tegels in HA"; std::string title = "Choose tiles in HA";
bool configured = false; bool configured = false;
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed) { bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed) {
bool moved = false; bool moved = false;
@@ -140,7 +140,7 @@ struct Model {
moved = !changed && (explicit_slots != !positions.empty()); moved = !changed && (explicit_slots != !positions.empty());
for (size_t i = 0; i < positions.size() && !changed; ++i) if (slots[i] != positions[i]) moved = true; for (size_t i = 0; i < positions.size() && !changed; ++i) if (slots[i] != positions[i]) moved = true;
// A title-only update must not interrupt an open control card. // A title-only update must not interrupt an open control card.
title = name.empty() ? "Thuis" : name; title = name.empty() ? "Home" : name;
if (changed) { if (changed) {
for (auto &tile : tiles) tile = Tile{}; for (auto &tile : tiles) tile = Tile{};
count = entities.size(); count = entities.size();
+98 -98
View File
@@ -123,7 +123,7 @@ inline bool fresh() { return model.ready() && ha_connected() && feed_alive(); }
// instead of guessed: moved too far, too short, already used by this contact, or bounce. // instead of guessed: moved too far, too short, already used by this contact, or bounce.
inline bool allowed(uint32_t now, int tile, const std::string &what) { inline bool allowed(uint32_t now, int tile, const std::string &what) {
if (cyd::touch_guard.accept(now, tile)) return true; if (cyd::touch_guard.accept(now, tile)) return true;
ESP_LOGI("touch", "tik op %s genegeerd: %s", what.c_str(), cyd::touch_guard.reason().c_str()); ESP_LOGI("touch", "tap on %s ignored: %s", what.c_str(), cyd::touch_guard.reason().c_str());
return false; return false;
} }
inline float number(JsonVariant value, float fallback = NAN) { inline float number(JsonVariant value, float fallback = NAN) {
@@ -147,18 +147,18 @@ inline std::string list(JsonVariant value) {
return out.size() <= 512 ? out : ""; return out.size() <= 512 ? out : "";
} }
inline std::string receive(const std::string &payload) { inline std::string receive(const std::string &payload) {
if (!enabled) return "Gebruik het Easy Setup-profiel"; if (!enabled) return "Use the Easy Setup profile";
if (payload.size() > 4096) return "Fout: bericht te groot"; if (payload.size() > 4096) return "Error: message too large";
std::string result = "Fout: ongeldig bericht"; std::string result = "Error: invalid message";
esphome::json::parse_json(payload, [&](JsonObject root) -> bool { esphome::json::parse_json(payload, [&](JsonObject root) -> bool {
if (root["v"].as<int>() != 1) { result = "Fout: protocolversie"; return false; } if (root["v"].as<int>() != 1) { result = "Error: protocol version"; return false; }
auto op = string(root["op"]); auto op = string(root["op"]);
if (op == "layout") { if (op == "layout") {
if (!root["entities"].is<JsonArray>() || !root["title"].is<const char *>()) return false; if (!root["entities"].is<JsonArray>() || !root["title"].is<const char *>()) return false;
auto settings = screen_settings::current; auto settings = screen_settings::current;
if (!root["settings"].isNull() && (!root["settings"].is<JsonObject>() || if (!root["settings"].isNull() && (!root["settings"].is<JsonObject>() ||
!parse_settings(root["settings"].as<JsonObject>(), settings))) { !parse_settings(root["settings"].as<JsonObject>(), settings))) {
result = "Fout: scherminstellingen"; return false; result = "Error: screen settings"; return false;
} }
std::vector<std::string> entities; std::vector<std::string> entities;
for (JsonVariant entity : root["entities"].as<JsonArray>()) { for (JsonVariant entity : root["entities"].as<JsonArray>()) {
@@ -198,14 +198,14 @@ inline std::string receive(const std::string &payload) {
} }
if(rotation_changed && settings_changed)settings_changed(); if(rotation_changed && settings_changed)settings_changed();
if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); } if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); }
if (moved) ESP_LOGI("runtime", "tegels verplaatst: pagina's opnieuw ingedeeld"); if (moved) ESP_LOGI("runtime", "tiles moved: pages rearranged");
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>(); if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
layout_rev = string(root["rev"], 16); layout_rev = string(root["rev"], 16);
last_received = esphome::millis(); last_received = esphome::millis();
if (layout_changed) layout_changed(); if (layout_changed) layout_changed();
if (refresh) refresh(); if (refresh) refresh();
// A repeat of the same layout keeps the inbox state as it is: no new recorder row. // A repeat of the same layout keeps the inbox state as it is: no new recorder row.
result = changed || moved || !was_configured ? "Indeling ontvangen" : model.ready() ? "Gesynchroniseerd" : "Tegels laden"; result = changed || moved || !was_configured ? "Layout received" : model.ready() ? "Synced" : "Loading tiles";
return true; return true;
} }
if (op == "ping") { if (op == "ping") {
@@ -215,8 +215,8 @@ inline std::string receive(const std::string &payload) {
root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false; root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false;
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>(); if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
last_received = esphome::millis(); last_received = esphome::millis();
if (!model.configured || layout_rev != string(root["rev"], 16)) { result = "Indeling opnieuw nodig"; return true; } if (!model.configured || layout_rev != string(root["rev"], 16)) { result = "Resend needed"; return true; }
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden"; result = model.ready() ? "Synced" : "Loading tiles";
return true; return true;
} }
if (op == "header") { if (op == "header") {
@@ -243,13 +243,13 @@ inline std::string receive(const std::string &payload) {
last_received = esphome::millis(); last_received = esphome::millis();
if (!same && refresh) refresh(); if (!same && refresh) refresh();
// The same status as a tile state, so a changing value never flips the inbox entity. // The same status as a tile state, so a changing value never flips the inbox entity.
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden"; result = model.ready() ? "Synced" : "Loading tiles";
return true; return true;
} }
if (op != "state" || !root["i"].is<unsigned>() || !root["a"].is<JsonObject>()) return false; if (op != "state" || !root["i"].is<unsigned>() || !root["a"].is<JsonObject>()) return false;
unsigned index = root["i"].as<unsigned>(); unsigned index = root["i"].as<unsigned>();
std::string entity = string(root["entity"], 120); std::string entity = string(root["entity"], 120);
if (!model.accepts(index, entity)) { result = "Fout: verouderde tegel"; return false; } if (!model.accepts(index, entity)) { result = "Error: outdated tile"; return false; }
Tile &tile = model.tiles[index]; Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>(); auto a = root["a"].as<JsonObject>();
// ArduinoJson clears its destination string: serialize attributes first, then add state. // ArduinoJson clears its destination string: serialize attributes first, then add state.
@@ -354,7 +354,7 @@ inline std::string receive(const std::string &payload) {
if (refresh) refresh(); if (refresh) refresh();
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile); if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
refresh_detail(index); refresh_detail(index);
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden"; result = model.ready() ? "Synced" : "Loading tiles";
return true; return true;
}); });
return result; return result;
@@ -399,19 +399,19 @@ inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int
lv_label_set_long_mode(label,LV_LABEL_LONG_DOT);lv_obj_set_height(label,lv_font_get_line_height(detail_font));return label; lv_label_set_long_mode(label,LV_LABEL_LONG_DOT);lv_obj_set_height(label,lv_font_get_line_height(detail_font));return label;
} }
inline std::string detail_state(const Tile &t){ inline std::string detail_state(const Tile &t){
if(t.domain()=="person")return t.state=="home"?"Thuis":t.state=="not_home"?"Niet thuis":t.state; if(t.domain()=="person")return t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
if(t.domain()=="sun")return t.state=="above_horizon"?"Boven de horizon":"Onder de horizon"; if(t.domain()=="sun")return t.state=="above_horizon"?"Above the horizon":"Below the horizon";
if(t.domain()=="timer")return timer_text(t); if(t.domain()=="timer")return timer_text(t);
if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Bezig...":last_run_text(t.last_run); if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Running...":last_run_text(t.last_run);
if(t.state=="on")return "Aan"; if(t.state=="on")return "On";
if(t.state=="off")return "Uit"; if(t.state=="off")return "Off";
if(t.state=="docked")return "In dock"; if(t.state=="docked")return "Docked";
if(t.state=="cleaning")return "Bezig met schoonmaken"; if(t.state=="cleaning")return "Cleaning";
if(t.state=="paused")return "Gepauzeerd"; if(t.state=="paused")return "Paused";
if(t.state=="returning")return "Onderweg naar dock"; if(t.state=="returning")return "Returning to dock";
if(t.state=="idle")return "Klaar"; if(t.state=="idle")return "Idle";
if(t.state=="error")return "Controleer de robot in HA"; if(t.state=="error")return "Check the robot in HA";
if(!t.available())return "Niet beschikbaar"; if(!t.available())return "Unavailable";
return t.state; return t.state;
} }
inline void hide_detail(){if(detail_root)lv_obj_add_flag(detail_root,LV_OBJ_FLAG_HIDDEN);} inline void hide_detail(){if(detail_root)lv_obj_add_flag(detail_root,LV_OBJ_FLAG_HIDDEN);}
@@ -457,7 +457,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,lv_color_hex(0xFFFFFF),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE); auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,lv_color_hex(0xFFFFFF),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(button,[](lv_event_t *e){ lv_obj_add_event_cb(button,[](lv_event_t *e){
int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;} int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"kaartknop "+model.tiles[detail_index].entity))return; if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
auto &t=model.tiles[detail_index];if(!t.available()||t.loading(esphome::millis()))return; auto &t=model.tiles[detail_index];if(!t.available()||t.loading(esphome::millis()))return;
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);} if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
if(cmd>=10 && cmd<14 && cmd-10<(int)t.fan_speed_count)action("vacuum.set_fan_speed",t.entity,"fan_speed",t.fan_speeds[cmd-10]); if(cmd>=10 && cmd<14 && cmd-10<(int)t.fan_speed_count)action("vacuum.set_fan_speed",t.entity,"fan_speed",t.fan_speeds[cmd-10]);
@@ -533,9 +533,9 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
detail_text(now,degrees(t.current),temp_x,cy+(hero-big_h)/2,temp_w,big,LV_TEXT_ALIGN_LEFT,ink); detail_text(now,degrees(t.current),temp_x,cy+(hero-big_h)/2,temp_w,big,LV_TEXT_ALIGN_LEFT,ink);
int text_x=temp_x+temp_w+(large?4:2),text_w=width-2*pad-card_pad-text_x; int text_x=temp_x+temp_w+(large?4:2),text_w=width-2*pad-card_pad-text_x;
int lines_h=text_h+small_h+(large?2:0); int lines_h=text_h+small_h+(large?2:0);
detail_text(now,t.available()?weather_text(t.state):"Niet beschikbaar",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink); detail_text(now,t.available()?weather_text(t.state):"Unavailable",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
std::string details; std::string details;
if(std::isfinite(t.feels)){snprintf(b,sizeof(b),"Voelt als %.0f°",t.feels);details=b;} if(std::isfinite(t.feels)){snprintf(b,sizeof(b),"Feels like %.0f°",t.feels);details=b;}
if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);} if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);}
if(std::isfinite(t.wind)){snprintf(b,sizeof(b),"%.0f %s",t.wind,t.wind_unit.empty()?"km/h":t.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);} if(std::isfinite(t.wind)){snprintf(b,sizeof(b),"%.0f %s",t.wind,t.wind_unit.empty()?"km/h":t.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);}
detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted); detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted);
@@ -552,8 +552,8 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
} }
y+=card_a_h+(large?12:6); y+=card_a_h+(large?12:6);
// Coming days: a heading and a card with one row per day. // Coming days: a heading and a card with one row per day.
if(!t.forecast.size()){detail_text(detail_root,"Geen dagvoorspelling van Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;} if(!t.forecast.size()){detail_text(detail_root,"No daily forecast from Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(large){detail_text(detail_root,"Komende dagen",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;} if(large){detail_text(detail_root,"Coming days",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
int card_b_h=height-y-(large?10:4); int card_b_h=height-y-(large?10:4);
auto *days=detail_card(pad,y,width-2*pad,card_b_h); auto *days=detail_card(pad,y,width-2*pad,card_b_h);
int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)t.forecast.size(); int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)t.forecast.size();
@@ -588,7 +588,7 @@ inline void show_detail(unsigned index){
detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?60:35,width-2*pad); detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?60:35,width-2*pad);
auto d=t.domain(); auto d=t.domain();
if(t.is_switch()){ if(t.is_switch()){
detail_label(detail_root,"Tik om te schakelen",pad,top,width-2*pad); detail_label(detail_root,"Tap to toggle",pad,top,width-2*pad);
detail_switch=lv_switch_create(detail_root); detail_switch=lv_switch_create(detail_root);
lv_obj_set_size(detail_switch,large?240:140,large?112:64); lv_obj_set_size(detail_switch,large?240:140,large?112:64);
lv_obj_set_pos(detail_switch,(width-(large?240:140))/2,top+(large?65:30)); lv_obj_set_pos(detail_switch,(width-(large?240:140))/2,top+(large?65:30));
@@ -623,31 +623,31 @@ inline void show_detail(unsigned index){
lv_obj_set_style_border_width(robot,2,0);lv_obj_set_style_border_color(robot,lv_color_hex(0xCEDDE6),0); lv_obj_set_style_border_width(robot,2,0);lv_obj_set_style_border_color(robot,lv_color_hex(0xCEDDE6),0);
shape(robot,27,10,30,30,0xE3EBEF,15);shape(robot,35,18,14,14,0xA8BCC8,7); shape(robot,27,10,30,30,0xE3EBEF,15);shape(robot,35,18,14,14,0xA8BCC8,7);
shape(robot,29,59,26,5,0x00A6ED,3); shape(robot,29,59,26,5,0x00A6ED,3);
detail_label(hero,t.state=="cleaning"?"Aan het werk":t.state=="returning"?"Even opladen":t.state=="paused"?"Even pauze":"Klaar voor je huis",154,24,260); detail_label(hero,t.state=="cleaning"?"Working":t.state=="returning"?"Charging":t.state=="paused"?"Paused":"Ready for your home",154,24,260);
auto *badge=shape(hero,154,59,240,32,muted,16); auto *badge=shape(hero,154,59,240,32,muted,16);
auto *status=detail_label(badge,t.awaiting_action(esphome::millis())?"Opdracht verstuurd...":state,12,5,218); auto *status=detail_label(badge,t.awaiting_action(esphome::millis())?"Command sent...":state,12,5,218);
detail_badge_status=status; detail_badge_status=status;
lv_obj_set_style_text_color(status,lv_color_hex(0x087BA8),0); lv_obj_set_style_text_color(status,lv_color_hex(0x087BA8),0);
detail_label(hero,std::isfinite(t.battery)?"Batterij "+std::to_string((int)t.battery)+"%":"Verbonden via Home Assistant",154,107,260); detail_label(hero,std::isfinite(t.battery)?"Battery "+std::to_string((int)t.battery)+"%":"Connected via Home Assistant",154,107,260);
detail_button(t.state=="cleaning"?"Pauzeer schoonmaken":"Start schoonmaken",pad,260,width-2*pad,58,t.state=="cleaning"?1:0); detail_button(t.state=="cleaning"?"Pause cleaning":"Start cleaning",pad,260,width-2*pad,58,t.state=="cleaning"?1:0);
detail_button("Terug naar dock",pad,330,cw,48,2); detail_button("Return to dock",pad,330,cw,48,2);
detail_button("Vind mijn robot",pad+cw+gap,330,cw,48,3); detail_button("Find my robot",pad+cw+gap,330,cw,48,3);
detail_label(detail_root,"Zuigkracht",pad,394,width-2*pad); detail_label(detail_root,"Suction power",pad,394,width-2*pad);
top=424; top=424;
}else{ }else{
auto *robot=shape(detail_root,pad,64,46,46,muted,23); auto *robot=shape(detail_root,pad,64,46,46,muted,23);
shape(robot,16,8,14,14,0xA8BCC8,7);shape(robot,15,32,16,3,0x00A6ED,2); shape(robot,16,8,14,14,0xA8BCC8,7);shape(robot,15,32,16,3,0x00A6ED,2);
detail_label(detail_root,std::isfinite(t.battery)?"Batterij "+std::to_string((int)t.battery)+"%":"Robotstofzuiger",pad+58,66,width-2*pad-58); detail_label(detail_root,std::isfinite(t.battery)?"Battery "+std::to_string((int)t.battery)+"%":"Robot vacuum",pad+58,66,width-2*pad-58);
detail_label(detail_root,"Kies een actie",pad+58,87,width-2*pad-58); detail_label(detail_root,"Choose an action",pad+58,87,width-2*pad-58);
detail_button(t.state=="cleaning"?"Pauzeren":"Schoonmaken",pad,120,cw,38,t.state=="cleaning"?1:0); detail_button(t.state=="cleaning"?"Pause":"Clean",pad,120,cw,38,t.state=="cleaning"?1:0);
detail_button("Naar dock",pad+cw+gap,120,cw,38,2); detail_button("To dock",pad+cw+gap,120,cw,38,2);
detail_label(detail_root,"Zuigkracht",pad,168,width-2*pad);top=194; detail_label(detail_root,"Suction power",pad,168,width-2*pad);top=194;
} }
if(!t.fan_speed_count)detail_label(detail_root,"Automatische zuigkracht",pad,top,width-2*pad); if(!t.fan_speed_count)detail_label(detail_root,"Automatic suction power",pad,top,width-2*pad);
int count=std::max(1,(int)t.fan_speed_count),sw=(width-2*pad-gap*(count-1))/count; int count=std::max(1,(int)t.fan_speed_count),sw=(width-2*pad-gap*(count-1))/count;
for(unsigned i=0;i<t.fan_speed_count;++i){ for(unsigned i=0;i<t.fan_speed_count;++i){
std::string name=t.fan_speeds[i]; std::string name=t.fan_speeds[i];
if(name=="quiet")name="Stil";else if(name=="balanced")name="Normaal";else if(name=="turbo")name="Turbo";else if(name=="max")name="Max"; if(name=="quiet")name="Quiet";else if(name=="balanced")name="Normal";else if(name=="turbo")name="Turbo";else if(name=="max")name="Max";
auto *button=detail_button(name.c_str(),pad+i*(sw+gap),top,sw,large?36:30,10+i); auto *button=detail_button(name.c_str(),pad+i*(sw+gap),top,sw,large?36:30,10+i);
bool selected=t.fan_speed==t.fan_speeds[i]; bool selected=t.fan_speed==t.fan_speeds[i];
lv_obj_set_style_bg_color(button,lv_color_hex(selected?0x009FE3:surface),0); lv_obj_set_style_bg_color(button,lv_color_hex(selected?0x009FE3:surface),0);
@@ -655,33 +655,33 @@ inline void show_detail(unsigned index){
} }
}else if(d=="sensor"){ }else if(d=="sensor"){
float minimum=INFINITY,maximum=-INFINITY;for(float value:t.history)if(t.has_history&&std::isfinite(value)){minimum=std::min(minimum,value);maximum=std::max(maximum,value);} float minimum=INFINITY,maximum=-INFINITY;for(float value:t.history)if(t.has_history&&std::isfinite(value)){minimum=std::min(minimum,value);maximum=std::max(maximum,value);}
if(!std::isfinite(minimum)){detail_label(detail_root,"Geen numerieke HA-historie",pad,top,width-2*pad);return;} if(!std::isfinite(minimum)){detail_label(detail_root,"No numeric HA history",pad,top,width-2*pad);return;}
char text[100];snprintf(text,sizeof(text),"%u uur / %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad); char text[100];snprintf(text,sizeof(text),"%u hours / %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad);
int chart_y=top+(large?46:28),chart_h=height-chart_y-30,bar_w=(width-pad*2)/24; int chart_y=top+(large?46:28),chart_h=height-chart_y-30,bar_w=(width-pad*2)/24;
for(unsigned i=0;i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2; for(unsigned i=0;i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2;
auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,pad+i*bar_w,chart_y+chart_h-h);lv_obj_set_size(bar,std::max(2,bar_w-2),h);lv_obj_set_style_bg_color(bar,lv_color_hex(0x16A5E6),0);lv_obj_set_style_bg_opa(bar,LV_OPA_COVER,0);} auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,pad+i*bar_w,chart_y+chart_h-h);lv_obj_set_size(bar,std::max(2,bar_w-2),h);lv_obj_set_style_bg_color(bar,lv_color_hex(0x16A5E6),0);lv_obj_set_style_bg_opa(bar,LV_OPA_COVER,0);}
detail_label(detail_root,std::to_string(t.history_hours)+" uur geleden",pad,height-24,(width-2*pad)/2); detail_label(detail_root,std::to_string(t.history_hours)+" hours ago",pad,height-24,(width-2*pad)/2);
auto *now=detail_label(detail_root,"Nu",width/2,height-24,width/2-pad);lv_obj_set_style_text_align(now,LV_TEXT_ALIGN_RIGHT,0); auto *now=detail_label(detail_root,"Now",width/2,height-24,width/2-pad);lv_obj_set_style_text_align(now,LV_TEXT_ALIGN_RIGHT,0);
}else if(d=="select"||d=="input_select"){ }else if(d=="select"||d=="input_select"){
for(unsigned i=0;i<t.option_count;++i)detail_button(t.options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i); for(unsigned i=0;i<t.option_count;++i)detail_button(t.options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
}else if(d=="number"||d=="input_number"||d=="media_player"){ }else if(d=="number"||d=="input_number"||d=="media_player"){
if(d=="media_player"){ if(d=="media_player"){
detail_label(detail_root,t.media_title,pad,top,width-2*pad);top+=large?45:25; detail_label(detail_root,t.media_title,pad,top,width-2*pad);top+=large?45:25;
int w=(width-pad*2-2*gap)/3; int w=(width-pad*2-2*gap)/3;
detail_button("Vorige",pad,top,w,bh,21);detail_button("Play/pauze",pad+w+gap,top,w,bh,20);detail_button("Volgende",pad+2*(w+gap),top,w,bh,22);top+=bh+gap; detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
} }
detail_label(detail_root,d=="media_player"?"Volume":"Waarde",pad,top,width-2*pad); detail_label(detail_root,d=="media_player"?"Volume":"Value",pad,top,width-2*pad);
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,lv_color_hex(0x111111),LV_PART_KNOB); auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,lv_color_hex(0x111111),LV_PART_KNOB);
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index); lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
}else if(d=="weather"){ }else if(d=="weather"){
render_weather_detail(t,large,width,height,pad); render_weather_detail(t,large,width,height,pad);
}else if(d=="timer"){ }else if(d=="timer"){
detail_label(detail_root,t.state=="active"?"Loopt":t.state=="paused"?"Gepauzeerd":"Staat stil",pad,top,width-2*pad); detail_label(detail_root,t.state=="active"?"Running":t.state=="paused"?"Paused":"Stopped",pad,top,width-2*pad);
detail_button(t.state=="active"?"Pauzeer":"Start",pad,top+(large?50:30),cw,bh,40); detail_button(t.state=="active"?"Pause":"Start",pad,top+(large?50:30),cw,bh,40);
detail_button("Annuleer",pad+cw+gap,top+(large?50:30),cw,bh,41); detail_button("Cancel",pad+cw+gap,top+(large?50:30),cw,bh,41);
}else if(d=="sun"){ }else if(d=="sun"){
detail_label(detail_root,"Zonsopgang "+t.sunrise,pad,top,width-2*pad); detail_label(detail_root,"Sunrise "+t.sunrise,pad,top,width-2*pad);
detail_label(detail_root,"Zonsondergang "+t.sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad); detail_label(detail_root,"Sunset "+t.sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
} }
} }
} }
@@ -711,20 +711,20 @@ inline const char *weather_icon(const std::string &condition) {
return "\U000F0595"; return "\U000F0595";
} }
inline const char *weather_text(const std::string &condition) { inline const char *weather_text(const std::string &condition) {
if (condition == "sunny") return "Zonnig"; if (condition == "sunny") return "Sunny";
if (condition == "clear-night") return "Heldere nacht"; if (condition == "clear-night") return "Clear, night";
if (condition == "cloudy") return "Bewolkt"; if (condition == "cloudy") return "Cloudy";
if (condition == "partlycloudy") return "Half bewolkt"; if (condition == "partlycloudy") return "Partly cloudy";
if (condition == "rainy") return "Regen"; if (condition == "rainy") return "Rainy";
if (condition == "pouring") return "Stortregen"; if (condition == "pouring") return "Pouring";
if (condition == "snowy") return "Sneeuw"; if (condition == "snowy") return "Snowy";
if (condition == "snowy-rainy") return "Natte sneeuw"; if (condition == "snowy-rainy") return "Snowy, rainy";
if (condition == "fog") return "Mist"; if (condition == "fog") return "Fog";
if (condition == "hail") return "Hagel"; if (condition == "hail") return "Hail";
if (condition == "lightning") return "Onweer"; if (condition == "lightning") return "Lightning";
if (condition == "lightning-rainy") return "Onweer en regen"; if (condition == "lightning-rainy") return "Lightning, rainy";
if (condition == "windy" || condition == "windy-variant") return "Winderig"; if (condition == "windy" || condition == "windy-variant") return "Windy";
if (condition == "exceptional") return "Bijzonder weer"; if (condition == "exceptional") return "Exceptional";
return condition.c_str(); return condition.c_str();
} }
inline const char *icon_for(const Tile &tile) { inline const char *icon_for(const Tile &tile) {
@@ -757,33 +757,33 @@ inline std::string countdown(uint32_t seconds) {
else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60); else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60);
return b; return b;
} }
// "Laatst 14:32" today, "Gisteren 14:32", else "Laatst 13 sep"; scripts and scenes have no useful on/off. // "Last 14:32" today, "Yesterday 14:32", else "Last 13 Sep"; scripts and scenes have no useful on/off.
inline std::string month_short(const esphome::ESPTime &now); inline std::string month_short(const esphome::ESPTime &now);
inline std::string last_run_text(uint32_t epoch) { inline std::string last_run_text(uint32_t epoch) {
if (!epoch) return "Nog niet gestart"; if (!epoch) return "Never run";
auto when = esphome::ESPTime::from_epoch_local(epoch); auto when = esphome::ESPTime::from_epoch_local(epoch);
auto now = now_time ? now_time() : esphome::ESPTime{}; auto now = now_time ? now_time() : esphome::ESPTime{};
if (!when.is_valid()) return "Nog niet gestart"; if (!when.is_valid()) return "Never run";
char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute); char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute);
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return std::string("Laatst ") + clock; if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return std::string("Last ") + clock;
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return std::string("Gisteren ") + clock; if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return std::string("Yesterday ") + clock;
return "Laatst " + std::to_string(when.day_of_month) + " " + month_short(when); return "Last " + std::to_string(when.day_of_month) + " " + month_short(when);
} }
inline std::string timer_text(const Tile &t) { inline std::string timer_text(const Tile &t) {
if (t.state == "active") { uint32_t now = now_epoch(); return countdown(t.timer_end > now && now ? t.timer_end - now : 0); } if (t.state == "active") { uint32_t now = now_epoch(); return countdown(t.timer_end > now && now ? t.timer_end - now : 0); }
if (t.state == "paused") return "Pauze " + countdown(duration_seconds(t.remaining)); if (t.state == "paused") return "Paused " + countdown(duration_seconds(t.remaining));
return t.duration.empty() ? "Uit" : countdown(duration_seconds(t.duration)); return t.duration.empty() ? "Off" : countdown(duration_seconds(t.duration));
} }
inline std::string weekday_text(const esphome::ESPTime &now) { inline std::string weekday_text(const esphome::ESPTime &now) {
static const char *days[] = {"zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"}; static const char *days[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? days[now.day_of_week - 1] : ""; return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? days[now.day_of_week - 1] : "";
} }
inline std::string month_short(const esphome::ESPTime &now) { inline std::string month_short(const esphome::ESPTime &now) {
static const char *months[] = {"jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"}; static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
return now.is_valid() && now.month >= 1 && now.month <= 12 ? months[now.month - 1] : ""; return now.is_valid() && now.month >= 1 && now.month <= 12 ? months[now.month - 1] : "";
} }
inline std::string date_text(const esphome::ESPTime &now) { inline std::string date_text(const esphome::ESPTime &now) {
static const char *months[] = {"januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"}; static const char *months[] = {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"};
if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return ""; if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return "";
return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1]; return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1];
} }
@@ -803,7 +803,7 @@ inline void event(lv_event_t *event) {
if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;} if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;}
if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;} if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;}
if(tile.builtin())return; if(tile.builtin())return;
// A short tap runs or pauses the kitchen timer; holding opens the card with a cancel button. // A short tap runs or pauses the timer; holding opens the card with a cancel button.
if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;} if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;}
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; } if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
if (open) { if (open) {
@@ -1080,9 +1080,9 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei
const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN); const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font); int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font);
int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0; int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0;
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Zon":t.name); part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Sun":t.name);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"op "+t.sunrise); part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"rise "+t.sunrise);
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"onder "+t.sunset); part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.sunset);
auto now=now_time?now_time():esphome::ESPTime{}; auto now=now_time?now_time():esphome::ESPTime{};
int rise=minutes_of(t.sunrise),set=minutes_of(t.sunset),minute=now.is_valid()?now.hour*60+now.minute:-1; int rise=minutes_of(t.sunrise),set=minutes_of(t.sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
bool day=t.state=="above_horizon";float fraction=0.5f; bool day=t.state=="above_horizon";float fraction=0.5f;
@@ -1296,8 +1296,8 @@ inline void control_event(lv_event_t *e) {
bool step=command==tile_controls::STEP_DOWN || command==tile_controls::STEP_UP; bool step=command==tile_controls::STEP_DOWN || command==tile_controls::STEP_UP;
bool held=lv_event_get_code(e)==LV_EVENT_LONG_PRESSED_REPEAT; bool held=lv_event_get_code(e)==LV_EVENT_LONG_PRESSED_REPEAT;
if(held){ if(!step || now-t.edit_since<300)return; } // three steps a second while holding if(held){ if(!step || now-t.edit_since<300)return; } // three steps a second while holding
else if(step){ if(!cyd::touch_guard.accept_repeat(now,400+slot*16+n)){ESP_LOGI("touch","tik op bediening %u genegeerd: %s",(unsigned)slot,cyd::touch_guard.reason().c_str());return;} } else if(step){ if(!cyd::touch_guard.accept_repeat(now,400+slot*16+n)){ESP_LOGI("touch","tap on control %u ignored: %s",(unsigned)slot,cyd::touch_guard.reason().c_str());return;} }
else if(!allowed(now,400+slot*16+n,"bediening "+std::to_string(slot)))return; else if(!allowed(now,400+slot*16+n,"control "+std::to_string(slot)))return;
if(!t.available())return; if(!t.available())return;
if(step){ if(step){
// Local at once, tap after tap; tick() sends the last value after a short pause. // Local at once, tap after tap; tick() sends the last value after a short pause.
@@ -1337,7 +1337,7 @@ inline void set_busy(Widgets &w,bool busy,bool large){
inline void render(lv_obj_t *room) { inline void render(lv_obj_t *room) {
if (!enabled) return; if (!enabled) return;
room_label=room; room_label=room;
label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !ha_connected() ? "HA niet verbonden" : !feed_alive() ? "ESP Screens niet actief" : model.title); label(room, !model.configured ? "Choose tiles in HA" : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title);
render_header(); render_header();
for (size_t slot = 0; slot < 6; ++slot) { for (size_t slot = 0; slot < 6; ++slot) {
auto &w=widgets[slot]; auto &w=widgets[slot];
@@ -1349,17 +1349,17 @@ inline void render(lv_obj_t *room) {
bool watch=t.display=="watch"; bool watch=t.display=="watch";
std::string unit=watch?t.unit:""; std::string unit=watch?t.unit:"";
std::string value = t.state; std::string value = t.state;
if (!fresh() || !t.available()) value = "Niet beschikbaar"; if (!fresh() || !t.available()) value = "Unavailable";
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %"; else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; } else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Thuis":t.state=="not_home"?"Weg":t.state; else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
else if (d == "sun") value = !t.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Boven de horizon":"Onder de horizon"; else if (d == "sun") value = !t.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "timer") value = timer_text(t); else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Bezig..." : last_run_text(t.last_run); else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
else if (value == "on") value = "Aan"; else if (value == "on") value = "On";
else if (value == "off") value = "Uit"; else if (value == "off") value = "Off";
else if (value == "cleaning") value = "Bezig"; else if (value == "cleaning") value = "Cleaning";
else if (value == "docked") value = "In dock"; else if (value == "docked") value = "Docked";
else if (!t.unit.empty() && !watch) value += " " + t.unit; else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis()); bool pending=t.loading(esphome::millis());
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}} if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
@@ -1451,7 +1451,7 @@ inline void render(lv_obj_t *room) {
lv_obj_set_style_bg_color(w.slider,lv_color_mix(color,lv_color_hex(0xFFFFFF),30),LV_PART_MAIN); lv_obj_set_style_bg_color(w.slider,lv_color_mix(color,lv_color_hex(0xFFFFFF),30),LV_PART_MAIN);
lv_obj_set_style_bg_color(w.tile, lv_color_hex(t.background ? t.background : 0xFFFFFF), 0); lv_obj_set_style_bg_color(w.tile, lv_color_hex(t.background ? t.background : 0xFFFFFF), 0);
lv_obj_set_style_border_width(w.tile, 1, 0); lv_obj_set_style_border_width(w.tile, 1, 0);
// "Achtergrond: geen" hides only the card; geometry and padding stay identical, // "Background: none" hides only the card; geometry and padding stay identical,
// and the pressed flash still shows because it lives on the PRESSED state. // and the pressed flash still shows because it lives on the PRESSED state.
lv_obj_set_style_bg_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0); lv_obj_set_style_bg_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0);
lv_obj_set_style_border_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0); lv_obj_set_style_border_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0);
@@ -1821,7 +1821,7 @@ inline void tick() {
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){ if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis()); auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
for(unsigned i=0;i<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);} for(unsigned i=0;i<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);}
std::string status=waiting?(t.confirmed?"Bevestigd door Home Assistant":"Opdracht verstuurd..."):detail_state(t); std::string status=waiting?(t.confirmed?"Confirmed by Home Assistant":"Command sent..."):detail_state(t);
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:"")); if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
if(detail_badge_status)label(detail_badge_status,status); if(detail_badge_status)label(detail_badge_status,status);
if(detail_switch && !lv_obj_has_state(detail_switch,LV_STATE_PRESSED)){ if(detail_switch && !lv_obj_has_state(detail_switch,LV_STATE_PRESSED)){
+3 -3
View File
@@ -7,16 +7,16 @@ namespace esphome::smart_display {
// Command transport, not a stored JSON state. HA states stay well below 255 chars. // Command transport, not a stored JSON state. HA states stay well below 255 chars.
class DashboardInbox : public text::Text, public Component { class DashboardInbox : public text::Text, public Component {
public: public:
void setup() override { this->publish_state("Klaar voor tegelconfiguratie"); } void setup() override { this->publish_state("Ready for tile configuration"); }
protected: protected:
runtime_tiles::Chunks chunks_; runtime_tiles::Chunks chunks_;
void control(const std::string &value) override { void control(const std::string &value) override {
if (!chunks_.accept(value, millis())) { this->publish_state("Fout: onvolledig bericht"); return; } if (!chunks_.accept(value, millis())) { this->publish_state("Error: incomplete message"); return; }
if (!chunks_.complete) return; if (!chunks_.complete) return;
std::string decoded(chunks_.data.size() * 3 / 4 + 1, '\0'); std::string decoded(chunks_.data.size() * 3 / 4 + 1, '\0');
size_t length = base64_decode(chunks_.data, reinterpret_cast<uint8_t *>(decoded.data()), decoded.size()); size_t length = base64_decode(chunks_.data, reinterpret_cast<uint8_t *>(decoded.data()), decoded.size());
chunks_.reset(); chunks_.reset();
if (length == 0 || length > 4096) { this->publish_state("Fout: ongeldige codering"); return; } if (length == 0 || length > 4096) { this->publish_state("Error: invalid encoding"); return; }
decoded.resize(length); decoded.resize(length);
this->publish_state(runtime_tiles::receive(decoded)); this->publish_state(runtime_tiles::receive(decoded));
} }
+28 -28
View File
@@ -85,41 +85,41 @@ inline std::string format_value(float value, float step, const char *suffix) {
char b[24]; snprintf(b, sizeof(b), step >= 1 ? "%.0f%s" : "%.1f%s", value, suffix); return b; char b[24]; snprintf(b, sizeof(b), step >= 1 ? "%.0f%s" : "%.1f%s", value, suffix); return b;
} }
inline const char *climate_mode_text(const std::string &mode) { inline const char *climate_mode_text(const std::string &mode) {
if (mode == "off") return "Uit"; if (mode == "off") return "Off";
if (mode == "heat") return "Verwarmen"; if (mode == "heat") return "Heat";
if (mode == "cool") return "Koelen"; if (mode == "cool") return "Cool";
if (mode == "heat_cool") return "Verwarmen/koelen"; if (mode == "heat_cool") return "Heat/Cool";
if (mode == "auto") return "Automatisch"; if (mode == "auto") return "Auto";
if (mode == "dry") return "Drogen"; if (mode == "dry") return "Dry";
if (mode == "fan_only") return "Ventileren"; if (mode == "fan_only") return "Fan only";
return mode.c_str(); return mode.c_str();
} }
inline const char *climate_action_text(const std::string &action) { inline const char *climate_action_text(const std::string &action) {
if (action == "heating") return "Verwarmt"; if (action == "heating") return "Heating";
if (action == "cooling") return "Koelt"; if (action == "cooling") return "Cooling";
if (action == "idle") return "Inactief"; if (action == "idle") return "Idle";
if (action == "off") return "Uit"; if (action == "off") return "Off";
if (action == "drying") return "Droogt"; if (action == "drying") return "Drying";
if (action == "fan") return "Ventileert"; if (action == "fan") return "Fan";
if (action == "preheating") return "Voorverwarmen"; if (action == "preheating") return "Preheating";
if (action == "defrosting") return "Ontdooit"; if (action == "defrosting") return "Defrosting";
return ""; return "";
} }
inline const char *cover_state_text(const std::string &state) { inline const char *cover_state_text(const std::string &state) {
if (state == "open") return "Open"; if (state == "open") return "Open";
if (state == "closed") return "Dicht"; if (state == "closed") return "Closed";
if (state == "opening") return "Gaat open"; if (state == "opening") return "Opening";
if (state == "closing") return "Gaat dicht"; if (state == "closing") return "Closing";
return state.c_str(); return state.c_str();
} }
inline const char *media_state_text(const std::string &state) { inline const char *media_state_text(const std::string &state) {
if (state == "playing") return "Speelt"; if (state == "playing") return "Playing";
if (state == "paused") return "Gepauzeerd"; if (state == "paused") return "Paused";
if (state == "idle") return "Inactief"; if (state == "idle") return "Idle";
if (state == "standby") return "Stand-by"; if (state == "standby") return "Standby";
if (state == "buffering") return "Laden"; if (state == "buffering") return "Loading";
if (state == "on") return "Aan"; if (state == "on") return "On";
if (state == "off") return "Uit"; if (state == "off") return "Off";
return state.c_str(); return state.c_str();
} }
// Status line beside a control panel: what Home Assistant shows under the name. // Status line beside a control panel: what Home Assistant shows under the name.
@@ -189,9 +189,9 @@ inline std::string neighbour_option(const Tile &t, int direction) {
return t.options[next]; return t.options[next];
} }
inline const char *run_label(const std::string &domain) { inline const char *run_label(const std::string &domain) {
if (domain == "scene") return "Activeren"; if (domain == "scene") return "Activate";
if (domain == "script") return "Uitvoeren"; if (domain == "script") return "Run";
return "Indrukken"; return "Press";
} }
// The Home Assistant action behind a key. STEP_* are handled locally (debounced) and return nothing. // The Home Assistant action behind a key. STEP_* are handled locally (debounced) and return nothing.
inline Action key_action(const Tile &t, int command, const std::string &arg = "") { inline Action key_action(const Tile &t, int command, const std::string &arg = "") {
+1 -1
View File
@@ -16,6 +16,6 @@ inline uint32_t color(const std::string &name) {
if(name=="gray")return 0xE5E7EB; if(name=="gray")return 0xE5E7EB;
return 0; // Automatic/default, including unknown future palette names. return 0; // Automatic/default, including unknown future palette names.
} }
// "Geen": no card behind the tile contents. Older firmware treats it as automatic. // "None": no card behind the tile contents. Older firmware treats it as automatic.
inline bool transparent(const std::string &name) { return name=="none"; } inline bool transparent(const std::string &name) { return name=="none"; }
} }
+64 -64
View File
@@ -1,21 +1,21 @@
# Kopieer via: python tools/new_device.py --name display-keuken # Copy via: python tools/new_device.py --name display-kitchen
# Bewerk alleen dit lokale profiel en calibration.yaml voor jouw scherm. # Only edit this local profile and calibration.yaml for your screen.
packages: packages:
display: !include home-like-2432s028.yaml display: !include home-like-2432s028.yaml
calibration: !include calibration.yaml calibration: !include calibration.yaml
substitutions: substitutions:
DEVICE_NAME: "cyd-new" DEVICE_NAME: "cyd-new"
DEVICE_FRIENDLY_NAME: "Mijn CYD" DEVICE_FRIENDLY_NAME: "My CYD"
ROOM_NAME: "Woonkamer" ROOM_NAME: "Living room"
TILE_COUNT: "6" TILE_COUNT: "6"
DIRECT_ACTIONS: "false" # Pas na koppelen/testen op true zetten. DIRECT_ACTIONS: "false" # Set to true only after pairing/testing.
AUTO_DIM_TIMEOUT: "600" AUTO_DIM_TIMEOUT: "600"
CALIBRATION_ON_BOOT: "false" CALIBRATION_ON_BOOT: "false"
# Tegel 1 # Tile 1
TILE1_ENTITY: "light.voorbeeld_lamp" TILE1_ENTITY: "light.example_lamp"
TILE1_STATE_ENTITY: "light.voorbeeld_lamp" TILE1_STATE_ENTITY: "light.example_lamp"
TILE1_TITLE: "Lamp" TILE1_TITLE: "Lamp"
TILE1_TYPE: "light" TILE1_TYPE: "light"
TILE1_TAP_ACTION: "auto" TILE1_TAP_ACTION: "auto"
@@ -29,14 +29,14 @@ substitutions:
TILE1_LONGPRESS_ACTION_TYPE: "" TILE1_LONGPRESS_ACTION_TYPE: ""
TILE1_LONGPRESS_ACTION_SERVICE: "" TILE1_LONGPRESS_ACTION_SERVICE: ""
TILE1_VALUE_MODE: "auto" TILE1_VALUE_MODE: "auto"
TILE1_LABEL_ON: "Aan" TILE1_LABEL_ON: "On"
TILE1_LABEL_OFF: "Uit" TILE1_LABEL_OFF: "Off"
TILE1_ACTIVE_STATE: "" TILE1_ACTIVE_STATE: ""
# Tegel 2 # Tile 2
TILE2_ENTITY: "switch.voorbeeld_stekker" TILE2_ENTITY: "switch.example_plug"
TILE2_STATE_ENTITY: "switch.voorbeeld_stekker" TILE2_STATE_ENTITY: "switch.example_plug"
TILE2_TITLE: "Stekker" TILE2_TITLE: "Plug"
TILE2_TYPE: "switch" TILE2_TYPE: "switch"
TILE2_TAP_ACTION: "auto" TILE2_TAP_ACTION: "auto"
TILE2_TAP_SERVICE: "" TILE2_TAP_SERVICE: ""
@@ -49,14 +49,14 @@ substitutions:
TILE2_LONGPRESS_ACTION_TYPE: "" TILE2_LONGPRESS_ACTION_TYPE: ""
TILE2_LONGPRESS_ACTION_SERVICE: "" TILE2_LONGPRESS_ACTION_SERVICE: ""
TILE2_VALUE_MODE: "auto" TILE2_VALUE_MODE: "auto"
TILE2_LABEL_ON: "Aan" TILE2_LABEL_ON: "On"
TILE2_LABEL_OFF: "Uit" TILE2_LABEL_OFF: "Off"
TILE2_ACTIVE_STATE: "" TILE2_ACTIVE_STATE: ""
# Tegel 3 # Tile 3
TILE3_ENTITY: "scene.voorbeeld_avond" TILE3_ENTITY: "scene.example_evening"
TILE3_STATE_ENTITY: "scene.voorbeeld_avond" TILE3_STATE_ENTITY: "scene.example_evening"
TILE3_TITLE: "Avond" TILE3_TITLE: "Evening"
TILE3_TYPE: "scene" TILE3_TYPE: "scene"
TILE3_TAP_ACTION: "auto" TILE3_TAP_ACTION: "auto"
TILE3_TAP_SERVICE: "" TILE3_TAP_SERVICE: ""
@@ -69,14 +69,14 @@ substitutions:
TILE3_LONGPRESS_ACTION_TYPE: "" TILE3_LONGPRESS_ACTION_TYPE: ""
TILE3_LONGPRESS_ACTION_SERVICE: "" TILE3_LONGPRESS_ACTION_SERVICE: ""
TILE3_VALUE_MODE: "auto" TILE3_VALUE_MODE: "auto"
TILE3_LABEL_ON: "Aan" TILE3_LABEL_ON: "On"
TILE3_LABEL_OFF: "Uit" TILE3_LABEL_OFF: "Off"
TILE3_ACTIVE_STATE: "" TILE3_ACTIVE_STATE: ""
# Tegel 4 # Tile 4
TILE4_ENTITY: "script.voorbeeld_uit" TILE4_ENTITY: "script.example_all_off"
TILE4_STATE_ENTITY: "script.voorbeeld_uit" TILE4_STATE_ENTITY: "script.example_all_off"
TILE4_TITLE: "Alles uit" TILE4_TITLE: "All off"
TILE4_TYPE: "script" TILE4_TYPE: "script"
TILE4_TAP_ACTION: "auto" TILE4_TAP_ACTION: "auto"
TILE4_TAP_SERVICE: "" TILE4_TAP_SERVICE: ""
@@ -89,14 +89,14 @@ substitutions:
TILE4_LONGPRESS_ACTION_TYPE: "" TILE4_LONGPRESS_ACTION_TYPE: ""
TILE4_LONGPRESS_ACTION_SERVICE: "" TILE4_LONGPRESS_ACTION_SERVICE: ""
TILE4_VALUE_MODE: "auto" TILE4_VALUE_MODE: "auto"
TILE4_LABEL_ON: "Aan" TILE4_LABEL_ON: "On"
TILE4_LABEL_OFF: "Uit" TILE4_LABEL_OFF: "Off"
TILE4_ACTIVE_STATE: "" TILE4_ACTIVE_STATE: ""
# Tegel 5 # Tile 5
TILE5_ENTITY: "climate.voorbeeld_airco" TILE5_ENTITY: "climate.example_ac"
TILE5_STATE_ENTITY: "climate.voorbeeld_airco" TILE5_STATE_ENTITY: "climate.example_ac"
TILE5_TITLE: "Airco" TILE5_TITLE: "AC"
TILE5_TYPE: "climate" TILE5_TYPE: "climate"
TILE5_TAP_ACTION: "toggle" TILE5_TAP_ACTION: "toggle"
TILE5_TAP_SERVICE: "climate.toggle" TILE5_TAP_SERVICE: "climate.toggle"
@@ -109,14 +109,14 @@ substitutions:
TILE5_LONGPRESS_ACTION_TYPE: "" TILE5_LONGPRESS_ACTION_TYPE: ""
TILE5_LONGPRESS_ACTION_SERVICE: "" TILE5_LONGPRESS_ACTION_SERVICE: ""
TILE5_VALUE_MODE: "auto" TILE5_VALUE_MODE: "auto"
TILE5_LABEL_ON: "Aan" TILE5_LABEL_ON: "On"
TILE5_LABEL_OFF: "Uit" TILE5_LABEL_OFF: "Off"
TILE5_ACTIVE_STATE: "" TILE5_ACTIVE_STATE: ""
# Tegel 6 # Tile 6
TILE6_ENTITY: "vacuum.voorbeeld_robot" TILE6_ENTITY: "vacuum.example_robot"
TILE6_STATE_ENTITY: "vacuum.voorbeeld_robot" TILE6_STATE_ENTITY: "vacuum.example_robot"
TILE6_TITLE: "Stofzuiger" TILE6_TITLE: "Vacuum"
TILE6_TYPE: "vacuum" TILE6_TYPE: "vacuum"
TILE6_TAP_ACTION: "custom" TILE6_TAP_ACTION: "custom"
TILE6_TAP_SERVICE: "vacuum.start" TILE6_TAP_SERVICE: "vacuum.start"
@@ -129,14 +129,14 @@ substitutions:
TILE6_LONGPRESS_ACTION_TYPE: "" TILE6_LONGPRESS_ACTION_TYPE: ""
TILE6_LONGPRESS_ACTION_SERVICE: "" TILE6_LONGPRESS_ACTION_SERVICE: ""
TILE6_VALUE_MODE: "auto" TILE6_VALUE_MODE: "auto"
TILE6_LABEL_ON: "Aan" TILE6_LABEL_ON: "On"
TILE6_LABEL_OFF: "Uit" TILE6_LABEL_OFF: "Off"
TILE6_ACTIVE_STATE: "" TILE6_ACTIVE_STATE: ""
# Tegel 7 (verborgen zolang TILE_COUNT lager is) # Tile 7 (hidden while TILE_COUNT is lower)
TILE7_ENTITY: "light.ongebruikt_7" TILE7_ENTITY: "light.unused_7"
TILE7_STATE_ENTITY: "light.ongebruikt_7" TILE7_STATE_ENTITY: "light.unused_7"
TILE7_TITLE: "Tegel 7" TILE7_TITLE: "Tile 7"
TILE7_TYPE: "light" TILE7_TYPE: "light"
TILE7_TAP_ACTION: "auto" TILE7_TAP_ACTION: "auto"
TILE7_TAP_SERVICE: "" TILE7_TAP_SERVICE: ""
@@ -149,14 +149,14 @@ substitutions:
TILE7_LONGPRESS_ACTION_TYPE: "" TILE7_LONGPRESS_ACTION_TYPE: ""
TILE7_LONGPRESS_ACTION_SERVICE: "" TILE7_LONGPRESS_ACTION_SERVICE: ""
TILE7_VALUE_MODE: "auto" TILE7_VALUE_MODE: "auto"
TILE7_LABEL_ON: "Aan" TILE7_LABEL_ON: "On"
TILE7_LABEL_OFF: "Uit" TILE7_LABEL_OFF: "Off"
TILE7_ACTIVE_STATE: "" TILE7_ACTIVE_STATE: ""
# Tegel 8 (verborgen zolang TILE_COUNT lager is) # Tile 8 (hidden while TILE_COUNT is lower)
TILE8_ENTITY: "light.ongebruikt_8" TILE8_ENTITY: "light.unused_8"
TILE8_STATE_ENTITY: "light.ongebruikt_8" TILE8_STATE_ENTITY: "light.unused_8"
TILE8_TITLE: "Tegel 8" TILE8_TITLE: "Tile 8"
TILE8_TYPE: "light" TILE8_TYPE: "light"
TILE8_TAP_ACTION: "auto" TILE8_TAP_ACTION: "auto"
TILE8_TAP_SERVICE: "" TILE8_TAP_SERVICE: ""
@@ -169,14 +169,14 @@ substitutions:
TILE8_LONGPRESS_ACTION_TYPE: "" TILE8_LONGPRESS_ACTION_TYPE: ""
TILE8_LONGPRESS_ACTION_SERVICE: "" TILE8_LONGPRESS_ACTION_SERVICE: ""
TILE8_VALUE_MODE: "auto" TILE8_VALUE_MODE: "auto"
TILE8_LABEL_ON: "Aan" TILE8_LABEL_ON: "On"
TILE8_LABEL_OFF: "Uit" TILE8_LABEL_OFF: "Off"
TILE8_ACTIVE_STATE: "" TILE8_ACTIVE_STATE: ""
# Tegel 9 (verborgen zolang TILE_COUNT lager is) # Tile 9 (hidden while TILE_COUNT is lower)
TILE9_ENTITY: "light.ongebruikt_9" TILE9_ENTITY: "light.unused_9"
TILE9_STATE_ENTITY: "light.ongebruikt_9" TILE9_STATE_ENTITY: "light.unused_9"
TILE9_TITLE: "Tegel 9" TILE9_TITLE: "Tile 9"
TILE9_TYPE: "light" TILE9_TYPE: "light"
TILE9_TAP_ACTION: "auto" TILE9_TAP_ACTION: "auto"
TILE9_TAP_SERVICE: "" TILE9_TAP_SERVICE: ""
@@ -189,14 +189,14 @@ substitutions:
TILE9_LONGPRESS_ACTION_TYPE: "" TILE9_LONGPRESS_ACTION_TYPE: ""
TILE9_LONGPRESS_ACTION_SERVICE: "" TILE9_LONGPRESS_ACTION_SERVICE: ""
TILE9_VALUE_MODE: "auto" TILE9_VALUE_MODE: "auto"
TILE9_LABEL_ON: "Aan" TILE9_LABEL_ON: "On"
TILE9_LABEL_OFF: "Uit" TILE9_LABEL_OFF: "Off"
TILE9_ACTIVE_STATE: "" TILE9_ACTIVE_STATE: ""
# Tegel 10 (verborgen zolang TILE_COUNT lager is) # Tile 10 (hidden while TILE_COUNT is lower)
TILE10_ENTITY: "light.ongebruikt_10" TILE10_ENTITY: "light.unused_10"
TILE10_STATE_ENTITY: "light.ongebruikt_10" TILE10_STATE_ENTITY: "light.unused_10"
TILE10_TITLE: "Tegel 10" TILE10_TITLE: "Tile 10"
TILE10_TYPE: "light" TILE10_TYPE: "light"
TILE10_TAP_ACTION: "auto" TILE10_TAP_ACTION: "auto"
TILE10_TAP_SERVICE: "" TILE10_TAP_SERVICE: ""
@@ -209,7 +209,7 @@ substitutions:
TILE10_LONGPRESS_ACTION_TYPE: "" TILE10_LONGPRESS_ACTION_TYPE: ""
TILE10_LONGPRESS_ACTION_SERVICE: "" TILE10_LONGPRESS_ACTION_SERVICE: ""
TILE10_VALUE_MODE: "auto" TILE10_VALUE_MODE: "auto"
TILE10_LABEL_ON: "Aan" TILE10_LABEL_ON: "On"
TILE10_LABEL_OFF: "Uit" TILE10_LABEL_OFF: "Off"
TILE10_ACTIVE_STATE: "" TILE10_ACTIVE_STATE: ""
+3 -3
View File
@@ -28,7 +28,7 @@ async def main():
p.add_argument('--secrets',type=Path,default=Path(__file__).resolve().parents[1]/'secrets.yaml') p.add_argument('--secrets',type=Path,default=Path(__file__).resolve().parents[1]/'secrets.yaml')
p.add_argument('--output',type=Path,required=True) p.add_argument('--output',type=Path,required=True)
args=p.parse_args() args=p.parse_args()
if args.output.exists():raise ValueError('Output bestaat al; kies een andere naam.') if args.output.exists():raise ValueError('Output already exists; choose a different name.')
client=APIClient(args.host,6053,noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'],expected_name=args.name) client=APIClient(args.host,6053,noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'],expected_name=args.name)
await client.connect(login=True) await client.connect(login=True)
try: try:
@@ -43,9 +43,9 @@ async def main():
client.subscribe_logs(log,log_level=LogLevel.LOG_LEVEL_INFO,dump_config=False) client.subscribe_logs(log,log_level=LogLevel.LOG_LEVEL_INFO,dump_config=False)
await client.execute_service(next(s for s in services if s.name=='ui_snapshot'),{}) await client.execute_service(next(s for s in services if s.name=='ui_snapshot'),{})
await asyncio.wait_for(done.wait(),timeout=60) await asyncio.wait_for(done.wait(),timeout=60)
if errors or set(rows)!=set(range(240)):raise ValueError(f'Onvolledig beeld: {len(rows)}/240 rijen; {errors}') if errors or set(rows)!=set(range(240)):raise ValueError(f'Incomplete image: {len(rows)}/240 rows; {errors}')
with args.output.open('xb') as f:f.write(png(rows)) with args.output.open('xb') as f:f.write(png(rows))
print(f'PASS: LVGL-render opgeslagen in {args.output}') print(f'PASS: LVGL render saved to {args.output}')
finally:await client.disconnect() finally:await client.disconnect()
if __name__=='__main__':asyncio.run(main()) if __name__=='__main__':asyncio.run(main())
+1 -1
View File
@@ -9,7 +9,7 @@ async def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['touch_diagnostics', 'end_touch_diagnostics', 'ui_state', 'light_controls_preview']) parser.add_argument('action', choices=['touch_diagnostics', 'end_touch_diagnostics', 'ui_state', 'light_controls_preview'])
parser.add_argument('--host', default='cyd-2432s028.local') parser.add_argument('--host', default='cyd-2432s028.local')
parser.add_argument('--name', default='cyd-2432s028', help='Verwachte DEVICE_NAME') parser.add_argument('--name', default='cyd-2432s028', help='Expected DEVICE_NAME')
parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml') parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
args = parser.parse_args() args = parser.parse_args()
secret = yaml.safe_load(args.secrets.read_text()) secret = yaml.safe_load(args.secrets.read_text())
+1 -1
View File
@@ -9,7 +9,7 @@ from aioesphomeapi import APIClient, LogLevel
async def main(): async def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('--host', default='cyd-2432s028.local') parser.add_argument('--host', default='cyd-2432s028.local')
parser.add_argument('--name', default='cyd-2432s028', help='Verwachte DEVICE_NAME') parser.add_argument('--name', default='cyd-2432s028', help='Expected DEVICE_NAME')
parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml') parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
args = parser.parse_args() args = parser.parse_args()
secrets = yaml.safe_load(args.secrets.read_text()) secrets = yaml.safe_load(args.secrets.read_text())
+54 -51
View File
@@ -17,71 +17,71 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app
from core import extras, packets, state_message, validate_layout # noqa: E402 from core import extras, packets, state_message, validate_layout # noqa: E402
def demo_layout(): def demo_layout():
return validate_layout({'title': 'Demo kaarten', 'tiles': [ return validate_layout({'title': 'Demo cards', 'tiles': [
{'entity': 'screen.clock', 'name': '', 'options': {'display': 'analog', 'background': 'none'}}, {'entity': 'screen.clock', 'name': '', 'options': {'display': 'analog', 'background': 'none'}},
{'entity': 'weather.demo', 'name': 'Buiten', 'options': {'display': 'forecast'}}, {'entity': 'weather.demo', 'name': 'Outside', 'options': {'display': 'forecast'}},
{'entity': 'sensor.demo_temperatuur', 'name': 'Woonkamer', 'options': {'display': 'graph', 'icon': 'thermometer'}}, {'entity': 'sensor.demo_temperature', 'name': 'Living room', 'options': {'display': 'graph', 'icon': 'thermometer'}},
{'entity': 'person.demo', 'name': 'Max', 'options': {}}, {'entity': 'person.demo', 'name': 'Max', 'options': {}},
{'entity': 'timer.demo', 'name': 'Eieren', 'options': {'icon': 'chef-hat'}}, {'entity': 'timer.demo', 'name': 'Eggs', 'options': {'icon': 'chef-hat'}},
{'entity': 'sun.sun', 'name': 'Zon', 'options': {'display': 'sunpath'}}, {'entity': 'sun.sun', 'name': 'Sun', 'options': {'display': 'sunpath'}},
{'entity': 'sensor.demo_energie', 'name': 'Verbruik vandaag', 'options': {'display': 'graph', 'size': 'wide'}}, {'entity': 'sensor.demo_energy', 'name': 'Usage today', 'options': {'display': 'graph', 'size': 'wide'}},
{'entity': 'light.demo', 'name': 'Tafellamp', 'options': {'display': 'watch', 'size': 'wide', 'icon': 'lamp'}}, {'entity': 'light.demo', 'name': 'Table lamp', 'options': {'display': 'watch', 'size': 'wide', 'icon': 'lamp'}},
{'entity': 'weather.demo_standaard', 'name': 'Weer nu', 'options': {}}, {'entity': 'weather.demo_default', 'name': 'Weather now', 'options': {}},
]}) ]})
# Direct controls on wide cards (firmware 0.2.19+): one card per control set. # Direct controls on wide cards (firmware 0.2.19+): one card per control set.
def controls_layout(): def controls_layout():
return validate_layout({'title': 'Directe bediening', 'tiles': [ return validate_layout({'title': 'Direct control', 'tiles': [
{'entity': 'cover.demo_gordijn', 'name': 'Gordijnen Raambekleding', 'options': {'size': 'wide', 'icon': 'curtains'}}, {'entity': 'cover.demo_curtain', 'name': 'Curtains Window covering', 'options': {'size': 'wide', 'icon': 'curtains'}},
{'entity': 'climate.demo_airco', 'name': 'Airco', 'options': {'size': 'wide'}}, {'entity': 'climate.demo_ac', 'name': 'AC', 'options': {'size': 'wide'}},
{'entity': 'media_player.demo_sonos', 'name': 'Sonos', 'options': {'size': 'wide', 'icon': 'speaker'}}, {'entity': 'media_player.demo_sonos', 'name': 'Sonos', 'options': {'size': 'wide', 'icon': 'speaker'}},
{'entity': 'climate.demo_cv', 'name': 'Verwarming', 'options': {'size': 'wide', 'controls': 'mode', 'icon': 'radiator'}}, {'entity': 'climate.demo_heating', 'name': 'Heating', 'options': {'size': 'wide', 'controls': 'mode', 'icon': 'radiator'}},
{'entity': 'media_player.demo_radio', 'name': 'Sonos slaapkamer', 'options': {'size': 'wide', 'controls': 'playback', 'icon': 'radio'}}, {'entity': 'media_player.demo_radio', 'name': 'Sonos bedroom', 'options': {'size': 'wide', 'controls': 'playback', 'icon': 'radio'}},
{'entity': 'vacuum.demo_robot', 'name': 'Pippa', 'options': {'size': 'wide'}}, {'entity': 'vacuum.demo_robot', 'name': 'Pippa', 'options': {'size': 'wide'}},
{'entity': 'switch.demo_bureau', 'name': 'Bureau', 'options': {'size': 'wide', 'background': 'blue', 'icon': 'power-socket-eu'}}, {'entity': 'switch.demo_desk', 'name': 'Desk', 'options': {'size': 'wide', 'background': 'blue', 'icon': 'power-socket-eu'}},
{'entity': 'light.demo_tafellamp', 'name': 'Tafellamp', 'options': {'size': 'wide', 'controls': 'brightness', 'icon': 'lamp'}}, {'entity': 'light.demo_table_lamp', 'name': 'Table lamp', 'options': {'size': 'wide', 'controls': 'brightness', 'icon': 'lamp'}},
{'entity': 'number.demo_doel', 'name': 'Doel luchtvochtigheid', 'options': {'size': 'wide', 'icon': 'water-percent'}}, {'entity': 'number.demo_target', 'name': 'Target humidity', 'options': {'size': 'wide', 'icon': 'water-percent'}},
{'entity': 'select.demo_stand', 'name': 'Verwarmingsstand', 'options': {'size': 'wide', 'icon': 'thermostat'}}, {'entity': 'select.demo_mode', 'name': 'Heating mode', 'options': {'size': 'wide', 'icon': 'thermostat'}},
{'entity': 'timer.demo_eieren', 'name': 'Eieren', 'options': {'size': 'wide', 'icon': 'chef-hat'}}, {'entity': 'timer.demo_eggs', 'name': 'Eggs', 'options': {'size': 'wide', 'icon': 'chef-hat'}},
{'entity': 'scene.demo_avond', 'name': 'Avond', 'options': {'size': 'wide', 'icon': 'sofa'}}, {'entity': 'scene.demo_evening', 'name': 'Evening', 'options': {'size': 'wide', 'icon': 'sofa'}},
{'entity': 'fan.demo_ventilator', 'name': 'Ventilator', 'options': {'size': 'wide'}}, {'entity': 'fan.demo_fan', 'name': 'Fan', 'options': {'size': 'wide'}},
{'entity': 'cover.demo_rolluik', 'name': 'Rolluik', 'options': {'size': 'wide', 'controls': 'position', 'icon': 'window-shutter'}}, {'entity': 'cover.demo_shutter', 'name': 'Shutter', 'options': {'size': 'wide', 'controls': 'position', 'icon': 'window-shutter'}},
{'entity': 'light.demo_plafond', 'name': 'Plafondlamp', 'options': {'size': 'wide', 'icon': 'ceiling-light'}}, {'entity': 'light.demo_ceiling', 'name': 'Ceiling light', 'options': {'size': 'wide', 'icon': 'ceiling-light'}},
{'entity': 'weather.demo_buiten', 'name': 'Buiten', 'options': {'display': 'forecast'}}, {'entity': 'weather.demo_outside', 'name': 'Outside', 'options': {'display': 'forecast'}},
{'entity': 'script.demo_tv', 'name': 'TV aanzetten', 'options': {'icon': 'television'}}, {'entity': 'script.demo_tv', 'name': 'Turn on TV', 'options': {'icon': 'television'}},
{'entity': 'scene.demo_ochtend', 'name': 'Ochtend', 'options': {'icon': 'weather-sunset-up'}}, {'entity': 'scene.demo_morning', 'name': 'Morning', 'options': {'icon': 'weather-sunset-up'}},
]}) ]})
def controls_states(now): def controls_states(now):
end = now + timedelta(minutes=4, seconds=32) end = now + timedelta(minutes=4, seconds=32)
return { return {
'cover.demo_gordijn': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}}, 'cover.demo_curtain': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
'climate.demo_airco': {'state': 'cool', 'attributes': {'current_temperature': 21.5, 'temperature': 20, 'min_temp': 16, 'max_temp': 32, 'target_temp_step': 1.0, 'hvac_modes': ['off', 'heat_cool', 'cool', 'heat', 'fan_only', 'dry'], 'fan_modes': ['auto', 'low', 'medium', 'high'], 'fan_mode': 'low', 'swing_modes': ['off', 'both', 'vertical', 'horizontal'], 'swing_mode': 'off', 'supported_features': 425}}, 'climate.demo_ac': {'state': 'cool', 'attributes': {'current_temperature': 21.5, 'temperature': 20, 'min_temp': 16, 'max_temp': 32, 'target_temp_step': 1.0, 'hvac_modes': ['off', 'heat_cool', 'cool', 'heat', 'fan_only', 'dry'], 'fan_modes': ['auto', 'low', 'medium', 'high'], 'fan_mode': 'low', 'swing_modes': ['off', 'both', 'vertical', 'horizontal'], 'swing_mode': 'off', 'supported_features': 425}},
'media_player.demo_sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599}}, 'media_player.demo_sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599}},
'climate.demo_cv': {'state': 'heat', 'attributes': {'current_temperature': 19.5, 'temperature': 21, 'hvac_action': 'heating', 'hvac_modes': ['off', 'heat', 'auto']}}, 'climate.demo_heating': {'state': 'heat', 'attributes': {'current_temperature': 19.5, 'temperature': 21, 'hvac_action': 'heating', 'hvac_modes': ['off', 'heat', 'auto']}},
'media_player.demo_radio': {'state': 'playing', 'attributes': {'volume_level': 0.15, 'media_title': 'NPO Radio 2', 'supported_features': 8321599}}, 'media_player.demo_radio': {'state': 'playing', 'attributes': {'volume_level': 0.15, 'media_title': 'NPO Radio 2', 'supported_features': 8321599}},
'vacuum.demo_robot': {'state': 'docked', 'attributes': {'battery_level': 100, 'fan_speed': 'max', 'fan_speed_list': ['quiet', 'balanced', 'turbo', 'max'], 'supported_features': 30524}}, 'vacuum.demo_robot': {'state': 'docked', 'attributes': {'battery_level': 100, 'fan_speed': 'max', 'fan_speed_list': ['quiet', 'balanced', 'turbo', 'max'], 'supported_features': 30524}},
'switch.demo_bureau': {'state': 'on', 'attributes': {}}, 'switch.demo_desk': {'state': 'on', 'attributes': {}},
'light.demo_tafellamp': {'state': 'on', 'attributes': {'brightness': 163}}, 'light.demo_table_lamp': {'state': 'on', 'attributes': {'brightness': 163}},
'number.demo_doel': {'state': '55', 'attributes': {'min': 30, 'max': 70, 'step': 5, 'unit_of_measurement': '%'}}, 'number.demo_target': {'state': '55', 'attributes': {'min': 30, 'max': 70, 'step': 5, 'unit_of_measurement': '%'}},
'select.demo_stand': {'state': 'Comfort', 'attributes': {'options': ['Eco', 'Comfort', 'Boost']}}, 'select.demo_mode': {'state': 'Comfort', 'attributes': {'options': ['Eco', 'Comfort', 'Boost']}},
'timer.demo_eieren': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}}, 'timer.demo_eggs': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
'scene.demo_avond': {'state': (now - timedelta(hours=3)).isoformat(), 'attributes': {}}, 'scene.demo_evening': {'state': (now - timedelta(hours=3)).isoformat(), 'attributes': {}},
'fan.demo_ventilator': {'state': 'off', 'attributes': {'percentage': 0}}, 'fan.demo_fan': {'state': 'off', 'attributes': {'percentage': 0}},
'cover.demo_rolluik': {'state': 'open', 'attributes': {'current_position': 35, 'supported_features': 15}}, 'cover.demo_shutter': {'state': 'open', 'attributes': {'current_position': 35, 'supported_features': 15}},
'light.demo_plafond': {'state': 'off', 'attributes': {}}, 'light.demo_ceiling': {'state': 'off', 'attributes': {}},
'weather.demo_buiten': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C', 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h', 'apparent_temperature': 17.1}}, 'weather.demo_outside': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C', 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h', 'apparent_temperature': 17.1}},
'script.demo_tv': {'state': 'off', 'attributes': {'last_triggered': (now - timedelta(hours=2, minutes=8)).isoformat()}}, 'script.demo_tv': {'state': 'off', 'attributes': {'last_triggered': (now - timedelta(hours=2, minutes=8)).isoformat()}},
'scene.demo_ochtend': {'state': (now - timedelta(days=1, hours=5)).isoformat(), 'attributes': {}}, 'scene.demo_morning': {'state': (now - timedelta(days=1, hours=5)).isoformat(), 'attributes': {}},
} }
def demo_states(now): def demo_states(now):
end = now + timedelta(minutes=4, seconds=32) end = now + timedelta(minutes=4, seconds=32)
return { return {
'weather.demo': {'state': 'partlycloudy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C'}}, 'weather.demo': {'state': 'partlycloudy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C'}},
'weather.demo_standaard': {'state': 'rainy', 'attributes': {'temperature': 12.0, 'temperature_unit': '°C'}}, 'weather.demo_default': {'state': 'rainy', 'attributes': {'temperature': 12.0, 'temperature_unit': '°C'}},
'sensor.demo_temperatuur': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C'}}, 'sensor.demo_temperature': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C'}},
'sensor.demo_energie': {'state': '7.42', 'attributes': {'unit_of_measurement': 'kWh'}}, 'sensor.demo_energy': {'state': '7.42', 'attributes': {'unit_of_measurement': 'kWh'}},
'person.demo': {'state': 'home', 'attributes': {'icon': 'mdi:account-child'}}, 'person.demo': {'state': 'home', 'attributes': {'icon': 'mdi:account-child'}},
'timer.demo': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}}, 'timer.demo': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
'sun.sun': {'state': 'above_horizon', 'attributes': {'next_rising': (now + timedelta(hours=9)).isoformat(), 'next_setting': (now + timedelta(hours=2)).isoformat()}}, 'sun.sun': {'state': 'above_horizon', 'attributes': {'next_rising': (now + timedelta(hours=9)).isoformat(), 'next_setting': (now + timedelta(hours=2)).isoformat()}},
@@ -122,19 +122,20 @@ def messages(inbox, rotate=0, digital=False, wide=False, controls=False):
async def main(): async def main():
p = argparse.ArgumentParser(description=__doc__) p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--host', required=True) p.add_argument('--host', required=True)
p.add_argument('--name', required=True, help='Verwachte DEVICE_NAME') p.add_argument('--name', required=True, help='Expected DEVICE_NAME')
p.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml') p.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
p.add_argument('--rotate', type=int, default=0, help='Zet tegel N vooraan, zodat latere pagina\'s op pagina 1 komen') p.add_argument('--rotate', type=int, default=0, help='Put tile N first, so later pages land on page 1')
p.add_argument('--digital', action='store_true', help='Digitale klok op een enkele tegel in plaats van de analoge kalenderkaart zonder achtergrond') p.add_argument('--digital', action='store_true', help='Digital clock on a single tile instead of the analog calendar card without a background')
p.add_argument('--wide', action='store_true', help='Analoge klok dubbelbreed (wijzerplaat met digitale tijd en datum)') p.add_argument('--wide', action='store_true', help='Analog clock double-width (dial with digital time and date)')
p.add_argument('--controls', action='store_true', help='Dubbelbrede kaarten met directe bediening (firmware 0.2.19+), drie per pagina') p.add_argument('--controls', action='store_true', help='Double-width cards with direct control (firmware 0.2.19+), three per page')
args = p.parse_args() args = p.parse_args()
client = APIClient(args.host, 6053, noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'], client = APIClient(args.host, 6053, noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'],
client_info='Demo layout', expected_name=args.name) client_info='Demo layout', expected_name=args.name)
await client.connect(login=True) await client.connect(login=True)
try: try:
entities, _ = await client.list_entities_services() entities, _ = await client.list_entities_services()
inbox = next(e for e in entities if type(e).__name__ == 'TextInfo' and e.name == 'Tegelinstellingen') # Firmware built before the English translation still names this entity in Dutch.
inbox = next(e for e in entities if type(e).__name__ == 'TextInfo' and e.name in ('Tile settings', 'Tegelinstellingen'))
# The firmware answers through the inbox entity's state, not the log. # The firmware answers through the inbox entity's state, not the log.
replies = [] replies = []
def state(update): def state(update):
@@ -148,9 +149,11 @@ async def main():
client.text_command(inbox.key, packet) client.text_command(inbox.key, packet)
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
print(f'Demo-indeling verstuurd in {time.monotonic() - started:.1f}s; inbox meldt: {" / ".join(dict.fromkeys(replies)) or "(geen statuswijziging)"}') print(f'Demo layout sent in {time.monotonic() - started:.1f}s; inbox reports: {" / ".join(dict.fromkeys(replies)) or "(no status change)"}')
if not any(r in ('Indeling ontvangen', 'Gesynchroniseerd', 'Tegels laden') for r in replies): # Firmware built before the English translation still reports the Dutch originals.
raise SystemExit('De firmware accepteerde de demo-indeling niet.') accepted = ('Layout received', 'Synced', 'Loading tiles', 'Indeling ontvangen', 'Gesynchroniseerd', 'Tegels laden')
if not any(r in accepted for r in replies):
raise SystemExit('The firmware did not accept the demo layout.')
finally: finally:
await client.disconnect() await client.disconnect()
+4 -3
View File
@@ -20,7 +20,8 @@ async def main():
await client.connect(login=True) await client.connect(login=True)
try: try:
entities, _ = await client.list_entities_services() entities, _ = await client.list_entities_services()
inbox = next(e for e in entities if isinstance(e, TextInfo) and e.name == 'Tegelinstellingen') # Firmware built before the English translation still names this entity in Dutch.
inbox = next(e for e in entities if isinstance(e, TextInfo) and e.name in ('Tile settings', 'Tegelinstellingen'))
assert inbox.max_length == 255 assert inbox.max_length == 255
queue = asyncio.Queue() queue = asyncio.Queue()
client.subscribe_states(lambda s: queue.put_nowait(s.state) if isinstance(s, TextState) and s.key == inbox.key else None) client.subscribe_states(lambda s: queue.put_nowait(s.state) if isinstance(s, TextState) and s.key == inbox.key else None)
@@ -35,13 +36,13 @@ async def main():
client.text_command(inbox.key, secrets.token_hex(6) + '|0|1|' + encoded) client.text_command(inbox.key, secrets.token_hex(6) + '|0|1|' + encoded)
while True: while True:
ack = await asyncio.wait_for(queue.get(), 5) ack = await asyncio.wait_for(queue.get(), 5)
if ack.startswith('Fout:'): if ack.startswith(('Error:', 'Fout:')):
print('PASS: rejected invalid command — ' + ack) print('PASS: rejected invalid command — ' + ack)
break break
client.text_command(inbox.key, secrets.token_hex(6) + '|2|1|e30=') client.text_command(inbox.key, secrets.token_hex(6) + '|2|1|e30=')
while True: while True:
ack = await asyncio.wait_for(queue.get(), 5) ack = await asyncio.wait_for(queue.get(), 5)
if ack == 'Fout: onvolledig bericht': if ack in ('Error: incomplete message', 'Fout: onvolledig bericht'):
print('PASS: missing chunks rejected') print('PASS: missing chunks rejected')
break break
finally: finally:
+98
View File
@@ -0,0 +1,98 @@
# Acceptance test and handover
Note the date, board variant/MAC, Git commit, ESPHome version, device name, and the
names of the two measurement files. Don't include passwords in the report.
## Without real HA actions
1. Calibrate using [CALIBRATING.md](CALIBRATING.md); flash the result and run a
new, independent measurement with `verify`. A successful fit alone
doesn't prove the firmware coordinates are correct.
2. Flash normal mode. Check that the measurement screen disappears.
3. Check every visible tile and its edges: no other tile should respond.
Tap calmly and rapidly in succession, but expect no repeat within the
deliberate debounce period. A swipe should not trigger a tile action afterward.
4. With 1–6 tiles: no page button. With 7–10: repeat at least twenty times:
Next → tile → Previous. The bottom tile should not respond to the
page button. Open/close overlays; a tap inside one should not leak through.
5. Run the built-in render test via the encrypted API:
```sh
python diagnostics/run_ui_test.py --host display-kitchen.local --name display-kitchen
```
Requires ten successful page checks, fifty overlay cycles, and progress
from real draw callbacks. This doesn't test physical touch or HA actions.
6. Leave the board on for at least 15 minutes. Check that it doesn't restart,
that standby only starts after ten minutes without touch, and that the first
tap wakes the screen without toggling a device.
## With Home Assistant
Only now enable direct actions, reflash, and choose safe test devices.
Check every configured tile with its real HA entity:
- A tap performs exactly one intended action; the feedback is correct.
- Changes from HA also appear on the screen.
- Light slider/color, climate setpoint, and supported modes work where used.
- Vacuum actions and speeds work where supported. Note any missing functionality.
- After a power interruption, Wi-Fi and HA come back; name, calibration, and tiles persist.
- After a brief Wi-Fi/HA interruption, the connection recovers without a USB flash.
Keep logs local only. Check for resets, watchdog messages, repeated
connection errors, and noticeably slow components. A short render test doesn't
prove stability over days; note the duration actually observed.
## Automated development checks
From the repo, with the Python environment active:
```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
c++ -std=c++17 -Wall -Wextra -pedantic tests/test_light_controls.cpp -o /tmp/test_light_controls
/tmp/test_light_controls
/tmp/test_touch_filter
c++ -std=c++17 -Wall -Wextra -pedantic tests/test_light_controls.cpp -o /tmp/test_light_controls
/tmp/test_light_controls
python -m esphome config device.yaml
python -m esphome compile device.yaml
```
The C++ commands above are for macOS/Linux with a compiler. On Windows,
the developer can use whatever C++17 toolchain is available. Only flash the
checked profile to the identified board.
## Handover report
```text
Board / MAC:
Git commit / ESPHome version:
Device name / HA entities:
Calibration measurement + independent verification:
Render test:
Physical pagination/touch/standby:
Real HA actions:
Observed stable duration:
Limitations / not yet tested:
Local profile files safely stored:
```
Give the owner the three local YAML files and the measurement files through a
suitable private channel. For a different owner, use the clean repository
or starter ZIP, so they get their own keys and their own panel calibration.
## Light card
- RGB+white light: three sliders; RGB-only: color and brightness; white-only:
white temperature and brightness. A plain dimmer keeps the brightness card.
- Drag slowly across the rainbow, release, and check the physical light.
Repeat for white temperature and brightness. Brightness keeps the color mode.
- Reopen the card and check the received values. Test two lights with
different temperature ranges; no value should leak from the other light.
- The firmware render diagnostic includes `Light sliders: PASS`: twenty preview events
per slider, exactly one commit on release, no duplicate commit, no commit
after an aborted touch, and four capability combinations. This runs with
swapped-in callbacks and doesn't control any real lights.
-98
View File
@@ -1,98 +0,0 @@
# 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
c++ -std=c++17 -Wall -Wextra -pedantic tests/test_light_controls.cpp -o /tmp/test_light_controls
/tmp/test_light_controls
/tmp/test_touch_filter
c++ -std=c++17 -Wall -Wextra -pedantic tests/test_light_controls.cpp -o /tmp/test_light_controls
/tmp/test_light_controls
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.
## Lichtkaart
- RGB+wit-lamp: drie schuiven; RGB-only: kleur en helderheid; wit-only:
wittemperatuur en helderheid. Een gewone dimmer behoudt de helderheidskaart.
- Sleep langzaam over de regenboog, laat los en controleer de fysieke lamp.
Herhaal voor wittemperatuur en helderheid. Helderheid behoudt de kleurmodus.
- Heropen de kaart en controleer de ontvangen waarden. Test twee lampen met
verschillende temperatuurbereiken; er mag geen waarde van de andere lamp lekken.
- Firmware-renderdiagnose bevat `Light sliders: PASS`: twintig preview-events
per schuif, precies één commit bij loslaten, geen dubbele commit, geen commit
na een afgebroken aanraking en vier capabilitycombinaties. Dit gebeurt met
vervangen callbacks en bedient geen echte lampen.
+127
View File
@@ -0,0 +1,127 @@
# Calibrating a new panel
This protocol is for **320×240, LVGL rotation 90°, swap_xy=false,
mirror_x=true, mirror_y=false**. Other orientations, GT911 touch, and other
controllers require a separate profile. Don't change rotation or mirroring
partway through the measurement.
The filter in this repo stabilizes the XPT2046 measurements. Calibration then
corrects offset, scale, and skew. Calibration does not fix a defective
panel, a loose connection, unstable power, or random outliers.
## Preparing
1. Use the `device.yaml` from `tools/new_device.py`, with your own
`calibration.yaml` that starts as identity. Don't carry over the personal
correction from the historical base profile.
2. Check the data cable and the USB port with `python -m serial.tools.list_ports`.
3. Flash from the repo root:
```sh
python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device <USB_PORT>
```
4. Physically check for the **dark screen with five white + marks**.
It stays on, and tile actions aren't accessible here.
5. Stop the ESPHome log reader with Ctrl+C. Leave the board and USB cable connected.
Only one program may use the serial port at a time.
Wi-Fi/HA aren't needed for this USB route. If the board is already on Wi-Fi,
the screen can also be opened via the encrypted API:
```sh
python diagnostics/control_ui.py touch_diagnostics --host display-kitchen.local --name display-kitchen
```
That doesn't change the boot mode. For a new installation, use the
USB route above, so calibration mode also stays active after a reset.
## Measurement A: collecting data
```sh
python tools/calibrate.py capture --port <USB_PORT> --device-name display-kitchen --output measurements-before.json
```
The wizard guides you through **one target at a time**:
1. Read in the terminal which crosshair is up next.
2. Press Enter, then tap only that crosshair **three times**.
3. Hold each tap for about half a second and release fully in between.
4. Wait for the next terminal prompt. Don't move on to another corner yourself.
Order: top-left, top-right, bottom-right, bottom-left, center.
Use a suitably blunt stylus or a small, deliberate fingertip tap; no
sharp point. The targets are at `(20,20)`, `(299,20)`, `(299,219)`, `(20,219)`,
and `(160,120)`.
The wizard only reads touch logs with `calibration=1`. It doesn't flash anything, make
any network connection, or send any HA actions. An incomplete measurement is
not saved as a valid file. On a mistaken tap: Ctrl+C and start
over. Use a new filename if a measurement already exists.
## Calculating a correction
```sh
python tools/calibrate.py fit measurements-before.json --output calibration.yaml --replace
```
The script:
- takes the median of the three raw ADC measurements per corner;
- determines an affine correction from the four corners;
- checks the **center point, which is not used for the fit**;
- rejects errors that are too large (>12 px), spread (>18 px), and unusable measurement geometry;
- saves the existing `calibration.yaml` as a dated `.bak` before
placing a new, complete version.
The output contains only the four base bounds and six affine coefficients in
an ESPHome substitutions map. `device.yaml` imports this as the calibration
package. Don't change the coefficients by feel, and don't add a second correction
in the driver. The `raw=` logs contain the filtered **physical ADC values**,
even once a correction is already set; recalibrating therefore doesn't stack twice.
A successful fit on existing data is not yet a successful physical test.
## Measurement B: independently testing the flashed result
Flash the correction, still with the measurement screen:
```sh
python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device <USB_PORT>
```
Close the log reader with Ctrl+C and collect **new taps**:
```sh
python tools/calibrate.py capture --port <USB_PORT> --device-name display-kitchen --output measurements-after.json
python tools/calibrate.py verify measurements-after.json
```
`verify` uses the firmware's real `native=` coordinates, rotates them
to screen coordinates, and compares them with the targets. It does **not
run a new fit**. All five points must pass. Don't accidentally
use the pre-flash file for this check.
Doesn't work? Check which point fails, check the order/pressure, measure again,
and read [TROUBLESHOOTING.md](TROUBLESHOOTING.md). Don't just raise the tolerance to get the
test to pass. Report any remaining hardware deviation honestly.
## Back to normal operation
```sh
python -m esphome run device.yaml --device <USB_PORT>
```
No `-s CALIBRATION_ON_BOOT true` is given here. In `device.yaml`, the
setting stays `false`. Check the actual tiles and navigation per
[ACCEPTANCE.md](ACCEPTANCE.md).
If calibration mode was opened only via the API, you can close it:
```sh
python diagnostics/control_ui.py end_touch_diagnostics --host display-kitchen.local --name display-kitchen
```
Keep both measurement files and `calibration.yaml` with this physical screen.
Start over after replacing the panel; give only the neutral
starter files to a next owner.
-127
View File
@@ -1,127 +0,0 @@
# 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.
+53 -53
View File
@@ -1,66 +1,66 @@
# Upgrade bestaand CYD-paneel naar runtime 0.2.7 # Upgrading an existing CYD panel to runtime 0.2.7
Bord: ESP32-2432S028, ESP32-D0WD-V3; MAC b0:cb:d8:e7:bd:7c. Board: ESP32-2432S028, ESP32-D0WD-V3; MAC b0:cb:d8:e7:bd:7c.
Bestaande identiteit CYD 2.8in Display / cyd-2432s028 behouden. USB-poort werd Kept the existing identity CYD 2.8in Display / cyd-2432s028. The USB port was
vooraf met esptool geïdentificeerd; Guition zat op een andere USB-poort. identified beforehand with esptool; the Guition was on a different USB port.
Eigen profiel: genegeerd easy-cyd-device.yaml, met de bestaande lokale Own profile: gitignored easy-cyd-device.yaml, with the existing local
calibration.yaml en API/OTA/wifi-secretverwijzingen. Een back-up van de eerste calibration.yaml and API/OTA/Wi-Fi secret references. A backup of the first
64 KiB (partitie/configuratieruimte) is lokaal opgeslagen. Een volledige snelle 64 KiB (partition/configuration space) is stored locally. A full fast
uitlezing gaf een seriële fout; de volledige langzame uitlezing is afgebroken. read gave a serial error; the full slow read was aborted.
Dit is dus geen volledige oude-firmwareback-up. USB-upload op 115200 geslaagd; So this is not a complete old-firmware backup. USB upload at 115200 succeeded;
verdere correctie via de geverifieerde OTA-host. further correction via the verified OTA host.
De bestaande affine kalibratie is eenmalig in preference-key 0x43594403 gezet. The existing affine calibration was written once into preference key 0x43594403.
Daarna gebruikt het definitieve profiel de standaard opstart en bestaande NVS. After that, the final profile uses the standard boot and existing NVS.
## Tijdens de upgrade gevonden ## Found during the upgrade
Een tijdelijk on_boot-list in het lokale migratieprofiel verving het on_boot- A temporary on_boot list in the local migration profile replaced the package's
object van het pakket. Daardoor ontbrak de normale UI-initialisatie en crashte on_boot object. As a result, the normal UI initialization was missing and
het begin van de renderdiagnose. De tijdelijke import is verwijderd nadat de the start of the render diagnostic crashed. The temporary import was removed after the
kalibratie was opgeslagen. In de definitieve gegenereerde C++ zijn runtime-bind, calibration had been saved. In the final generated C++, runtime-bind,
light_controls::setup en screen_calibration::setup weer aanwezig. De volledige light_controls::setup, and screen_calibration::setup are present again. The full
renderdiagnose slaagt daarna (tien paginacontroles en vijftig overlays). render diagnostic then passes (ten page checks and fifty overlays).
Gebruik dit tijdelijke migratieprofiel niet voor toekomstige flashes. Don't use this temporary migration profile for future flashes.
HA ontdekte de nieuwe Tegelinstellingen-entity zonder nieuwe API-sleutel. De oude HA discovered the new Tegelinstellingen entity without a new API key. The old
tien entiteiten zijn overgezet; daarna heeft de gebruiker de indeling gewijzigd. ten entities were carried over; the user then changed the layout.
Die actuele indeling is behouden. Swipe is aan, standby 600 seconden. Bij maximaal That current layout is preserved. Swipe is on, standby 600 seconds. With six
zes tegels blijft de paginering verborgen. tiles or fewer, pagination stays hidden.
Definitieve build: ESPHome 2026.6.2, 2026-09-12 17:50:49 +0200, Final build: ESPHome 2026.6.2, 2026-09-12 17:50:49 +0200,
config_hash=0x0a15a59f, broncommit a14a66f. config_hash=0x0a15a59f, source commit a14a66f.
## Verificatie ## Verification
- Definitieve build en OTA-upload geslaagd; identiteit en firmwareversie via de - Final build and OTA upload succeeded; identity and firmware version checked
versleutelde API gecontroleerd. via the encrypted API.
- Renderdiagnose: tien geslaagde paginacontroles, vijftig overlaycycli en - Render diagnostic: ten successful page checks, fifty overlay cycles, and
`Light sliders: PASS`. De diagnose gebruikte de toen ingestelde twee tegels; `Light sliders: PASS`. The diagnostic used the two tiles configured at the time;
dit is geen fysieke swipecontrole van vier pagina's. this is not a physical swipe check of four pages.
- Home Assistant en ESP Screen Manager melden het paneel online met versie 0.2.7. - Home Assistant and ESP Screen Manager report the panel online with version 0.2.7.
Wijzigingen van de gebruiker in de tegelindeling blijven behouden. The user's changes to the tile layout are preserved.
- Bestaande kalibratie hergebruikt; tijdens deze upgrade is geen nieuwe - Existing calibration reused; no new independent physical calibration
onafhankelijke fysieke kalibratiemeting uitgevoerd. measurement was performed during this upgrade.
De 0.2.7-observatie is vijftien minuten voltooid met een levende API-respons elke The 0.2.7 observation ran for fifteen minutes complete, with a live API response every
dertig seconden, zonder herstart of verbindingsverlies. Vrij geheugen circa thirty seconds, with no restart or connection loss. Free memory around
111–119 kB. Door fysieke bediening werd tien minuten onafgebroken inactiviteit 111–119 kB. Because of physical use, ten minutes of uninterrupted inactivity
niet bereikt; standby/ontwaken is in deze observatie niet bevestigd. was not reached; standby/wake was not confirmed in this observation.
## Vervolg: tekstlayout 0.2.8 ## Follow-up: text layout 0.2.8
De eigenaar meldde vervolgens afvallende titels, te vroeg afgekorte ondertitels The owner then reported titles running off the edge, subtitles truncated too early,
en een overlappende mini-slider. Stabiliteit alleen bevestigt dus niet de and an overlapping mini-slider. So stability alone doesn't confirm the
bruikbaarheid van de layout. De titels hadden geen expliciete breedte en de layout's usability. The titles had no explicit width, and the
ondertitel nam de inhoudsbreedte van de titel over. De compacte kaart had daarnaast subtitle inherited the title's content width. The compact card also had
onvoldoende verticale ruimte voor de extra slider. insufficient vertical space for the extra slider.
Runtime 0.2.8 begrenst beide regels op de beschikbare inhoudsbreedte, vergroot die Runtime 0.2.8 bounds both lines to the available content width, expands that
bij een verborgen icoon en reserveert op de CYD aparte ruimte voor de schuif. when an icon is hidden, and reserves separate space for the slider on the CYD.
De renderdiagnose controleert nu de echte LVGL-coördinaten op afkapping buiten het The render diagnostic now checks the real LVGL coordinates for clipping outside the
vak en overlapping van tekstregels/slider. Met de actuele vier tegels, waaronder box and overlap between text lines/slider. With the current four tiles, including
een lamp met mini-slider: tien paginacontroles en vijftig overlaycycli geslaagd. a light with a mini-slider: ten page checks and fifty overlay cycles passed.
De slidertest slaagt ook. Fysieke bevestiging van de nieuwe layout staat nog open. The slider test also passes. Physical confirmation of the new layout is still open.
De vijftien minuten hierboven gelden voor 0.2.7, niet voor deze vervolgbuild. The fifteen minutes above apply to 0.2.7, not to this follow-up build.
+215 -215
View File
@@ -1,260 +1,260 @@
# Installeren en beheren vanuit ESP Screens # Installing and managing from ESP Screens
Een nieuw scherm, van USB naar dagelijks gebruik A new screen, from USB to everyday use
Met **ESP Screen Manager** kies je je tegels in Home Assistant. Je zoekt op With **ESP Screen Manager**, you choose your tiles in Home Assistant. You search by
naam, apparaat, ruimte of entity-ID, zet ze in de gewenste volgorde en klikt name, device, room, or entity ID, arrange them in the order you want, and click
op **Opslaan & naar scherm**. Daarna blijft de status automatisch actueel. **Save & send to screen**. After that, the status stays automatically up to date.
Je hoeft voor andere tegels **niet opnieuw te flashen**. You don't need to **reflash** for different tiles.
Dit werkt met Home Assistant OS op een 64-bits Raspberry Pi of een amd64-machine, This works with Home Assistant OS on a 64-bit Raspberry Pi or an amd64 machine,
en een van deze exacte schermvarianten. De ESPHome-CLI zit in ESP Screen Manager; and one of these exact screen variants. The ESPHome CLI is included in ESP Screen Manager;
ESPHome Device Builder is optioneel: ESPHome Device Builder is optional:
| Keuze | Hardware | | Choice | Hardware |
| --- | --- | | --- | --- |
| CYD | ESP32-2432S028, 320×240, ILI9341 en XPT2046 | | CYD | ESP32-2432S028, 320×240, ILI9341 and XPT2046 |
| Guition | ESP32-S3-4848S040, 480×480, ST7701S en GT911 | | Guition | ESP32-S3-4848S040, 480×480, ST7701S and GT911 |
Andere schermen met ongeveer dezelfde naam kunnen andere pinnen hebben. Gebruik Other screens with roughly the same name can have different pins. Use
het bordprofiel dat bij de hardware hoort. Gebruik een USB-kabel die data ondersteunt. the board profile that matches the hardware. Use a USB cable that supports data.
De wallbox-relais worden niet gebruikt. The wallbox relays are not used.
## 1. Installeer ESP Screen Manager ## 1. Install ESP Screen Manager
1. Open **Instellingen → Apps → Installeer een app** (in oudere HA-versies: 1. Open **Settings → Apps → Install app** (on older HA versions:
**Instellingen → Add-ons → Add-onwinkel**). **Settings → Add-ons → Add-on Store**).
2. Open het menu rechtsboven → **Repositories** en voeg toe: 2. Open the menu in the top right → **Repositories** and add:
`https://github.com/MaxGramser/homeassistant_espscreen`. `https://github.com/MaxGramser/homeassistant_espscreen`.
3. Installeer en start **ESP Screen Manager**. Zet **Starten bij opstarten** 3. Install and start **ESP Screen Manager**. Turn on **Start on boot**
en **Tonen in zijbalk** aan. Open de webinterface **ESP Screens**. and **Show in sidebar**. Open the **ESP Screens** web interface.
Deze app bevat de geteste ESPHome 2026.6.2-CLI en draait binnen je HA-aanmelding. This app includes the tested ESPHome 2026.6.2 CLI and runs within your HA login.
Een tweede ESPHome-beheerpagina, MQTT, blueprint of long-lived token is niet nodig. A second ESPHome management page, MQTT, blueprint, or long-lived token is not needed.
Gebruik de GitHub-versie voor updates; een lokale test-add-on is een andere app. Use the GitHub version for updates; a local test add-on is a separate app.
## 2. Nieuw scherm: aansluiten en installeren ## 2. New screen: connecting and installing
1. Sluit het scherm met een **USB-datakabel** aan op de machine waarop Home 1. Connect the screen with a **USB data cable** to the machine running Home
Assistant draait. Bij meerdere borden: sluit ze voor de eerste installatie Assistant. With multiple boards: connect them one at a time for the first
één voor één aan, of controleer welke poort bij dit scherm hoort. installation, or check which port belongs to this screen.
2. Klik **Nieuw scherm**. Kies CYD of Guition en geef het scherm een naam, 2. Click **New screen**. Choose CYD or Guition and give the screen a name,
bijvoorbeeld `Keuken`. De apparaatnaam (`keuken`) volgt daaruit; met for example `Kitchen`. The device name (`kitchen`) follows from that; use
**aanpassen** kies je een andere. **customize** to choose a different one.
3. Wifi: staan `wifi_ssid` en `wifi_password` al in de ESPHome `secrets.yaml`, 3. Wi-Fi: if `wifi_ssid` and `wifi_password` are already in the ESPHome `secrets.yaml`,
dan gebruikt het scherm die automatisch. Ontbreken ze, of bestaat het bestand the screen uses them automatically. If they're missing, or the file doesn't
nog niet, dan vraagt het venster ze eenmalig en zet ESP Screens alleen de exist yet, the window asks for them once and ESP Screens only adds the
ontbrekende regels in `secrets.yaml`; commentaar en andere secrets blijven missing lines to `secrets.yaml`; comments and other secrets are left
staan. Een `secrets.yaml` die geen geldige YAML is, herstel je eerst zelf. alone. If `secrets.yaml` isn't valid YAML, fix that yourself first.
4. Kies bij **Installeren via** de USB-poort van dit scherm en klik 4. Under **Install via**, choose this screen's USB port and click
**Installeren**. ESP Screens bewaart het profiel (`keuken.yaml`, met unieke **Install**. ESP Screens stores the profile (`kitchen.yaml`, with unique
API- en OTA-sleutels) in de ESPHome-map, bouwt de firmware en schrijft die API and OTA keys) in the ESPHome folder, builds the firmware, and writes it
via USB. Het ESPHome-log en de fase (bouwen, schrijven) staan in hetzelfde over USB. The ESPHome log and the current phase (building, writing) are in the same
venster; een eerste build duurt op een Raspberry enkele minuten. Je mag het window; a first build takes a few minutes on a Raspberry Pi. You can close the
venster sluiten: de installatie loopt door en staat bij heropenen weer klaar. window: the installation keeps running and picks back up when you reopen it.
5. Na **Klaar** toont het venster de API-sleutel met een kopieerknop en de 5. After **Done**, the window shows the API key with a copy button and the
koppelstappen uit hoofdstuk 3. Mislukt de build, dan staat het log open en pairing steps from chapter 3. If the build fails, the log stays open and
kun je **Opnieuw proberen**. you can **Retry**.
Elk scherm krijgt zijn eigen profiel: vier schermen betekent vier keer **Nieuw Every screen gets its own profile: four screens means going through **New
scherm** met vier verschillende namen. Het gedeelde bordpakket is voor alle screen** four times, with four different names. The shared board package is the
schermen gelijk; het profiel bevat alleen naam, sleutels en de wifi-verwijzing. same for every screen; the profile only holds the name, keys, and the Wi-Fi reference.
Bewaar dat profiel en gebruik het opnieuw bij updates. Voor een bestaand scherm Keep that profile and reuse it for updates. Going through **New screen**
opnieuw **Nieuw scherm** doorlopen maakt nieuwe sleutels aan en is niet de again for an existing screen generates new keys and is not the
updateroute. update route.
Geen USB-poort in de lijst? Een kabel aan je laptop is niet zichtbaar voor de No USB port in the list? A cable plugged into your laptop isn't visible to the
Raspberry. Kies dan **Later · alleen het profiel bewaren**: het profiel staat Raspberry Pi. In that case, choose **Later · save profile only**: the profile then
daarna in dezelfde map die ESPHome Device Builder gebruikt, dus je kunt het daar sits in the same folder ESPHome Device Builder uses, so you can open it there
openen en vanuit je browser flashen (**Install → Plug into this computer**), of and flash from your browser (**Install → Plug into this computer**), or
het later via **Firmware & USB** alsnog aan de HA-machine installeren. install it to the HA machine later via **Firmware & USB**.
**CYD:** bij de eerste start verschijnt de kalibratie. Tik het zichtbare kruisje **CYD:** calibration appears on first boot. Calmly tap the visible crosshair
drie keer rustig aan, houd elke tik kort vast en volg steeds het volgende kruisje. three times, hold each tap briefly, and follow each next crosshair in turn.
Er zijn vijf posities. Het midden controleert de nauwkeurigheid. Bij een mislukte There are five positions. The center checks accuracy. On a failed
meting vraagt het scherm opnieuw te beginnen. De correctie wordt lokaal opgeslagen measurement, the screen asks you to start over. The correction is stored locally
en blijft bij OTA-updates behouden. Via de HA-apparaatknop **Touch kalibreren** kun and survives OTA updates. Use the HA device button **Calibrate touch** to
je later opnieuw meten. Voor diagnose en de oudere handmatige installatie: measure again later. For diagnostics and the older manual installation, see:
[CALIBREREN.md](CALIBREREN.md). [CALIBRATING.md](CALIBRATING.md).
**Guition:** de GT911-touchmapping zit in het bordprofiel; er is geen ADC-kalibratie. **Guition:** the GT911 touch mapping is baked into the board profile; there's no ADC calibration.
## 3. Koppel het scherm aan Home Assistant ## 3. Pair the screen with Home Assistant
Dit gebeurt in Home Assistant zelf, buiten ESP Screens. Zolang een profiel nog This happens in Home Assistant itself, outside ESP Screens. As long as a profile
niet aan Home Assistant is toegevoegd, staat het links onder **Mijn schermen** hasn't been added to Home Assistant yet, it appears on the left under **My screens**
als kaart *nog niet in Home Assistant*, met de knop **Open Apparaten & diensten** as a *not yet in Home Assistant* card, with an **Open Devices & services**
en **Kopieer API-sleutel**; het klaar-scherm van **Nieuw scherm** heeft dezelfde button and **Copy API key**; the done screen of **New screen** has the same
knop. De kaart verdwijnt zodra het scherm in de lijst staat. button. The card disappears once the screen is in the list.
1. Open **Instellingen → Apparaten & diensten**. Voeg het ontdekte ESPHome-apparaat 1. Open **Settings → Devices & services**. Add the discovered ESPHome device.
toe. Niet ontdekt? Voeg de integratie **ESPHome** handmatig toe met het IP-adres Not discovered? Manually add the **ESPHome** integration with the screen's
van het scherm, poort 6053. IP address, port 6053.
2. Vraagt HA om een encryptiesleutel? Plak de API-sleutel die het venster na de 2. Does HA ask for an encryption key? Paste the API key the window shows after
installatie toont (ook terug te vinden als **api → encryption → key** in het installation (also found as **api → encryption → key** in the
profiel). Gebruik niet het OTA-wachtwoord. profile). Don't use the OTA password.
3. Open bij de ESPHome-integratie **Configureren** en zet **Allow the device to 3. On the ESPHome integration, open **Configure** and enable **Allow the device to
perform Home Assistant actions** aan. Zonder deze toestemming verschijnen perform Home Assistant actions**. Without this permission, values still show up,
waarden wel, maar kan het scherm lampen en apparaten niet bedienen. but the screen can't control lights and devices.
4. Open ESP Screens. Het scherm verschijnt binnen ongeveer 30 seconden. 4. Open ESP Screens. The screen appears within about 30 seconds.
## 4. Kies en wijzig je tegels ## 4. Choose and edit your tiles
Tip: zet in je ESPHome-YAML onder `wifi:` de regel `power_save_mode: none` (nieuwe Tip: in your ESPHome YAML, add the line `power_save_mode: none` under `wifi:` (new
wizard-YAML's hebben hem al); het scherm hangt aan het lichtnet en reageert dan zonder wizard YAMLs already have it); the screen is mains-powered, so this keeps it responding to
wifi-slaapvertraging op Home Assistant. Home Assistant without Wi-Fi sleep delay.
Selecteer je scherm, vul de titel in en zoek entiteiten. De kiezer heeft Select your screen, fill in the title, and search for entities. The picker has
domeinfilters met gekleurde iconen. Je kunt maximaal twintig tegels toevoegen. domain filters with colored icons. You can add up to twenty tiles.
Het schermvoorbeeld toont hun plaats: twee kolommen, zes plekken per pagina, The screen preview shows their placement: two columns, six slots per page,
maximaal acht pagina's. Elke tegel heeft een vaste plek die alleen verandert als up to eight pages. Every tile has a fixed slot that only changes if
jij hem versleept; lege plekken blijven leeg, waar je ze ook laat. Sleep een tegel you drag it; empty slots stay empty, wherever you leave them. Drag a tile
op een lege plek en hij staat daar; sleep hem op een andere tegel en die twee onto an empty slot and it stays there; drag it onto another tile and the two
wisselen (de ander neemt de vrijgekomen plek, of anders de dichtstbijzijnde vrije swap (the other tile takes the freed-up slot, or otherwise the nearest free
plek); de rest blijft staan. Tijdens het slepen zie je in het voorbeeld al waar slot); everything else stays put. While dragging, the preview already shows where
alles komt; onder de laatste pagina staat dan een lege pagina klaar. **Pagina everything will land; an empty page then stands ready below the last page. **Add
toevoegen** maakt een lege pagina die bewaard blijft; een lege pagina heeft page** creates an empty page that's kept; an empty page gets
**Pagina weghalen**. Klik op een lege plek om de volgende tegel uit de kiezer **Remove page**. Click an empty slot to place the next tile from the picker
daar te zetten. Met de pijltjestoetsen verplaats je een gefocuste tegel. there. Use the arrow keys to move a focused tile.
Klik op een tegel voor een eigen naam en **Bediening & weergave instellen**: Click a tile for a custom name and **Configure control & display**:
klikgedrag, een mini-slider, een grote waarde, een grafiek (sensoren), een click behavior, a mini-slider, a large value, a graph (sensors), a
weersvoorspelling (weer) of de breedte **Dubbelbreed**. Een dubbelbrede tegel van weather forecast (weather), or the **Double-width** setting. A double-width tile for
een klimaat, schakelaar, lamp, ventilator, stofzuiger, zonwering, mediaspeler, a climate, switch, light, fan, vacuum, cover, media player,
getal, keuzelijst, kookwekker, scène, script of knop krijgt rechts **directe number, select, timer, scene, script, or button gets **direct
bediening** zoals de rijen in Home Assistant (bijvoorbeeld temperatuur − / +, control** on the right, like the rows in Home Assistant (for example temperature − / +,
open/stop/dicht, volume met dempen, een toggle); kies onder **Directe bediening open/stop/close, volume with mute, a toggle); under **Direct control
op de tegel** welke set, of **Geen** (firmware 0.2.19+). **Bediening openen** op on the tile**, choose which set, or **None** (firmware 0.2.19+). **Open control** on
een weertegel toont de weerkaart met de komende uren en dagen (regen inbegrepen); a weather tile shows the weather card with the coming hours and days (rain included);
op een klimaattegel de kaart met aan/uit-knop en de modus-, ventilator- en on a climate tile, the card with an on/off button and the mode, fan, and
zwenkstanden. De ingebouwde **Klok** swing settings. The built-in **Clock**
staat bovenaan de kiezer; zon, kookwekkers en personen vind je via de filters. Kies bij sits at the top of the picker; find sun, timers, and people via the filters. Under
**Pastel achtergrond** een eigen kleur met donkere tekst; **Standaard** herstelt **Pastel background**, choose a custom color with dark text; **Default** restores
de normale weergave en **Geen** laat de kaart weg, zodat de inhoud even groot the normal look, and **None** drops the card, so the content sits the same size
direct op de schermachtergrond staat (firmware 0.2.16+). Hiervoor is firmware 0.2.10+ nodig. directly on the screen background (firmware 0.2.16+). This requires firmware 0.2.10+.
Toevoegen zonder gekozen plek vult de eerste vrije plek. Vaste plekken en lege Adding without a chosen slot fills the first free slot. Fixed slots and empty
plekken werken op het scherm vanaf firmware 0.2.26; oudere firmware schuift de slots work on the screen from firmware 0.2.26 on; older firmware shifts the
tegels aan tot de eerste vrije plek, de editor meldt dat onder het voorbeeld. tiles up to the first free slot, and the editor notes that below the preview.
Het voorbeeld toont de indeling, geen live sensorwaarden. The preview shows the layout, not live sensor values.
Klik **Opslaan & naar scherm** om je wijzigingen door te sturen. Click **Save & send to screen** to send your changes.
- Lamp, switch, input_boolean en fan: tik om aan/uit te zetten. - Light, switch, input_boolean, and fan: tap to turn on/off.
- Lamp lang indrukken: helderheid, regenboogkleur en wittemperatuur, voor zover - Long press a light: brightness, rainbow color, and white temperature, as far as
de lamp die functies ondersteunt. the light supports those features.
- Climate, vacuum en cover: tik om de bedieningskaart te openen. - Climate, vacuum, and cover: tap to open the control card.
- Fan lang indrukken: snelheid als het apparaat percentages ondersteunt. - Long press a fan: speed, if the device supports percentages.
- Scene/script: tik om uit te voeren; button/input_button: tik om in te drukken. - Scene/script: tap to run; button/input_button: tap to press.
- Sensor: tik voor de historiekaart; stel bij de tegel 1, 6 of 24 uur in. - Sensor: tap for the history card; set 1, 6, or 24 hours on the tile.
- Binary sensor: status bekijken. Select/input_select: open het keuzemenu. - Binary sensor: view status. Select/input_select: open the picker menu.
Vanaf firmware 0.2.7 passen twintig tegels op maximaal vier pagina’s. Oudere From firmware 0.2.7, twenty tiles fit across up to four pages. Older
firmware houdt de limiet van tien totdat je bijwerkt. Via **Algemene instellingen firmware keeps the limit of ten until you update. Under **General settings
→ Vegen tussen pagina’s** kun je vegen inschakelen. Op de Guition (firmware → Swipe between pages**, you can enable swiping. On the Guition (firmware
0.2.24+) veeg je dan vanaf de linker- of rechterrand naar binnen, zoals 0.2.24+), you then swipe inward from the left or right edge, like the
terug-vegen op een telefoon; langzaam of snel, en een veeg die midden op het back-swipe gesture on a phone; slow or fast, and a swipe starting in the
scherm begint doet niets, zodat tikken en slepen op tegels nooit per ongeluk van middle of the screen does nothing, so tapping and dragging tiles never
pagina wisselen. Op de CYD blijft het een snelle veeg over het scherm. Sliders accidentally changes pages. On the CYD, it stays a quick swipe across the screen. Sliders
bedienen alleen hun waarde; detailmenu’s en standby wisselen niet van pagina. only control their value; detail menus and standby don't change pages.
Er staan zes tegels op een pagina. Bij maximaal zes verdwijnen Vorige/Volgende. Six tiles sit on a page. With six or fewer, Previous/Next disappear.
De standby-tijd is standaard tien minuten. Bij offline apparaten blokkeert het The default standby time is ten minutes. For offline devices, the screen blocks
scherm acties. Valt de verbinding met Home Assistant weg, dan meldt het scherm actions. If the connection to Home Assistant drops, the screen immediately shows
direct "HA niet verbonden"; stuurt de app twee rondes (circa vijf minuten) niets, "HA not connected"; if the app sends nothing for two rounds (about five minutes),
dan "ESP Screens niet actief". In beide gevallen is de bediening geblokkeerd it shows "ESP Screens not active". In both cases, control is blocked
totdat de gegevens opnieuw ontvangen zijn. until data is received again.
Je mag indelingen opslaan terwijl een scherm offline is. De app verstuurt ze You can save layouts while a screen is offline. The app sends them
zodra het scherm terugkomt. De app moet blijven draaien voor actuele tegeldata. as soon as the screen comes back. The app must keep running for current tile data.
## 5. Updates zonder je instellingen kwijt te raken ## 5. Updates without losing your settings
| Wat verandert? | Wat doe je? | Wat blijft behouden? | | What changes? | What do you do? | What's kept? |
| --- | --- | --- | | --- | --- | --- |
| Andere entiteiten, namen of volgorde | Opslaan in ESP Screens | Wifi, sleutels, kalibratie | | Different entities, names, or order | Save in ESP Screens | Wi-Fi, keys, calibration |
| Nieuwe beheerpagina/appversie | Appwinkel → ESP Screen Manager → Update | Alle indelingen in `/data/screens.json` | | New management page/app version | App store → ESP Screen Manager → Update | All layouts in `/data/screens.json` |
| Nieuwe schermfunctie/kaart | Knop **Bijwerken** bij het scherm, of vinkje **Elke nacht automatisch bijwerken** (handmatig: Firmware & USB → Wifi / OTA) | Eigen YAML, sleutels en CYD-kalibratie; app stuurt tegels opnieuw | | New screen feature/card | The **Update** button on the screen, or the **Update automatically every night** checkbox (manually: Firmware & USB → Wi-Fi / OTA) | Own YAML, keys, and CYD calibration; the app resends tiles |
De eigen YAML verwijst naar de firmwarepakketten in `main`. Bij een nieuwe build The device's own YAML references the firmware packages on `main`. On a new build,
haalt ESPHome de nieuwste gepubliceerde pakket- en componentcode op. Je vervangt ESPHome fetches the latest published package and component code. So you don't
je eigen YAML dus niet door een nieuw downloadbestand. Wifi, naam en sleutels replace your own YAML with a new downloaded file. Wi-Fi, name, and keys
staan buiten het gedeelde pakket en blijven gelijk. live outside the shared package and stay the same.
Maak vóór updates een Home Assistant-back-up inclusief ESP Screen Manager en Make a Home Assistant backup before updates, including ESP Screen Manager and
de eigen ESPHome-configuraties. **Verwijderen/herinstalleren** van een app is niet hetzelfde the device's own ESPHome configurations. **Removing/reinstalling** an app is not the same
als updaten; daarmee kun je de gegevensmap wissen. Houd de apparaatnaam en de as updating; that can wipe the data folder. Keep the device name and the
entity-ID van **Tegelinstellingen** gelijk, zodat de bestaande indeling gekoppeld blijft. entity ID of **Tile settings** the same, so the existing layout stays linked.
Voor een nieuwe ondersteunde kaart update je eerst de app, daarna de firmware. For a newly supported card, update the app first, then the firmware.
De beheerder moet protocol- en gegevensmigraties achterwaarts compatibel maken; The maintainer keeps protocol and data migrations backward compatible;
zie [RELEASING.md](RELEASING.md). Voor terugrollen kun je `ref: main` in je eigen see [RELEASING.md](RELEASING.md). To roll back, you can temporarily replace `ref: main`
YAML tijdelijk vervangen door een eerdere releasetag, zonder de sleutels te wijzigen. in your own YAML with an earlier release tag, without changing the keys.
## Als iets niet werkt ## If something doesn't work
- **Geen scherm in de lijst:** controleer of de nieuwe Easy Setup-firmware draait, - **No screen in the list:** check that the new Easy Setup firmware is running,
de ESPHome-integratie verbonden is en de tekstentiteit **Tegelinstellingen** the ESPHome integration is connected, and the **Tile settings** text entity
niet uitgeschakeld is. De oude handmatige firmware publiceert die standaard niet. isn't disabled. The old manual firmware doesn't publish that by default.
- **Wel tegels, geen acties:** geef het apparaat toestemming voor HA-acties. - **Tiles show but no actions:** grant the device permission for HA actions.
- **Niet beschikbaar:** controleer of de geselecteerde entiteit in HA bestaat en - **Unavailable:** check that the selected entity exists in HA and is
beschikbaar is. Een hernoemde entity-ID moet je opnieuw kiezen. available. A renamed entity ID needs to be chosen again.
- **Geen OTA:** controleer wifi/IP en het oorspronkelijke OTA-wachtwoord. Gebruik - **No OTA:** check Wi-Fi/IP and the original OTA password. If needed,
zo nodig dezelfde eigen YAML via USB. Genereer geen nieuwe identiteit. use the same own YAML over USB. Don't generate a new identity.
- **Build mislukt:** lees de eerste fout, controleer ESPHome-versie en internet voor - **Build fails:** read the first error, check the ESPHome version and internet for
GitHub/fontdownloads. Gebruik bij te weinig Raspberry-geheugen tijdelijk een GitHub/font downloads. If the Raspberry Pi is low on memory, temporarily use a
krachtigere computer voor compileren; de YAML blijft hetzelfde. more powerful computer to compile; the YAML stays the same.
- **Bestaand handmatig scherm migreren:** bewaar de oude YAML en neem de bestaande - **Migrating an existing manual screen:** keep the old YAML and carry over the
apparaatnaam, API-sleutel en OTA-wachtwoord over in het nieuwe installatieprofiel. existing device name, API key, and OTA password into the new installation profile.
Kies daarna de tegels in de app. De oude vaste tegel-substituties worden niet Then choose the tiles in the app. The old fixed tile substitutions aren't
automatisch in de nieuwe beheerpagina geïmporteerd. automatically imported into the new management page.
HA Container zonder Supervisor heeft geen appwinkel. Deze installatiehandleiding HA Container without Supervisor has no App store. This installation guide
richt zich op Home Assistant OS; de ontwikkelserver is geen productieroute voor targets Home Assistant OS; the development server is not a production route for
een los openbaar portal. a standalone public portal.
Gebruikte HA-mechanismen: [Ingress](https://developers.home-assistant.io/docs/apps/presentation/), HA mechanisms used: [Ingress](https://developers.home-assistant.io/docs/apps/presentation/),
[interne HA-API](https://developers.home-assistant.io/docs/apps/communication/) en [the internal HA API](https://developers.home-assistant.io/docs/apps/communication/), and
[ESPHome-pakketten](https://esphome.io/components/packages/). [ESPHome packages](https://esphome.io/components/packages/).
## Scherminstellingen aanpassen (vanaf 0.1.2) ## Adjusting screen settings (from 0.1.2)
Update ESP Screen Manager naar 0.1.2 en installeer één keer de nieuwe firmware Update ESP Screen Manager to 0.1.2 and install the new firmware once
via je **bestaande** ESPHome-apparaat → Install → Wirelessly. Behoud de eigen YAML via your **existing** ESPHome device → Install → Wirelessly. Keep your own YAML
met wifi en sleutels. Open daarna het scherm in ESP Screen Manager en klap with Wi-Fi and keys. Then open the screen in ESP Screen Manager and expand
**Scherminstellingen** open. Klik na aanpassen op **Opslaan & naar scherm**. **Screen settings**. After adjusting, click **Save & send to screen**.
Hierna vragen wijzigingen aan deze instellingen geen nieuwe firmwareflash. After this, changes to these settings don't need a new firmware flash.
| Instelling | Mogelijkheden | Standaard | | Setting | Options | Default |
|---|---|---| |---|---|---|
| Automatisch standby | Aan/uit | Aan | | Auto standby | On/off | On |
| Standby na | 1–1440 minuten na de laatste aanraking | 10 minuten | | Standby after | 1–1440 minutes after the last touch | 10 minutes |
| Helderheid normaal | 5–100% | 100% | | Normal brightness | 5–100% | 100% |
| Helderheid standby | 0–100%, maximaal normale helderheid | 20% | | Standby brightness | 0–100%, capped at normal brightness | 20% |
| Nachtstand | Aan/uit; geldt tijdens standby | Aan | | Night mode | On/off; applies during standby | On |
| Begin/einde nacht | Uur en minuut, ook over middernacht | 22:00–07:00 | | Night start/end | Hour and minute, can span midnight | 22:00–07:00 |
| Helderheid nacht | 0–100%, maximaal normale helderheid | 10% | | Night brightness | 0–100%, capped at normal brightness | 10% |
| Klok tonen | Aan/uit | Aan | | Show clock | On/off | On |
| Tijdnotatie | 24 of 12 uur, zonder AM/PM | 24 uur | | Time format | 24 or 12 hour, without AM/PM | 24 hour |
| Terug naar pagina 1 | Sluit ook detailmenu’s bij standby | Uit | | Return to page 1 | Also closes detail menus on standby | Off |
| Vegen tussen pagina’s | Native horizontale swipe, firmware 0.2.7+ | Uit | | Swipe between pages | Native horizontal swipe, firmware 0.2.7+ | Off |
| Guition draaien | 0°, 90°, 180°, 270°, firmware 0.2.9+ | 0° | | Guition rotation | 0°, 90°, 180°, 270°, firmware 0.2.9+ | 0° |
Nachturen gebruiken de tijdzone van het ESPHome-apparaat en de tijd uit HA. Night hours use the ESPHome device's timezone and the time from HA.
Zonder geldige tijd gebruikt het scherm de gewone standbyhelderheid; gelijke Without a valid time, the screen uses the regular standby brightness; matching
begin- en eindtijd schakelen het nachtvenster uit. Op 0% gaat alleen de start and end times turn the night window off. At 0%, only the
achtergrondverlichting uit: dit is geen deep sleep en geen schermbeveiliging. backlight turns off: this is not deep sleep and not a screensaver.
De eerste tik wekt het scherm zonder een apparaat te bedienen. The first tap wakes the screen without controlling a device.
De add-on bewaart alles per scherm in zijn permanente gegevens. Het scherm bewaart The add-on stores everything per screen in its persistent data. The screen also
de laatst ontvangen instellingen ook in preferences; ESPHome bundelt die stores the last received settings in preferences; ESPHome batches those
schrijfacties (normaal maximaal een minuut). Trek daarom niet direct na opslaan write operations (normally up to a minute). So don't unplug the power right after
de voeding los. Bestaande CYD-kalibratie, tegels, API- en OTA-sleutels blijven staan. saving. Existing CYD calibration, tiles, API, and OTA keys are preserved.
Een offline scherm krijgt wijzigingen zodra het terugkomt. Gewone HA-statusupdates An offline screen gets the changes as soon as it comes back. Regular HA status
wekken het scherm niet en veranderen de standbytimer niet. updates don't wake the screen and don't reset the standby timer.
Bij oudere firmware toont de beheerpagina dat eerst een update nodig is. With older firmware, the management page shows that an update is needed first.
De tegels blijven bruikbaar. Handmatige YAML-profielen blijven hun substitutions The tiles remain usable. Manual YAML profiles keep using their substitutions;
gebruiken; voor beheer via deze app gebruik je het Easy Setup-pakket. to manage through this app, use the Easy Setup package.
De huidige Guition gebruikt de native ST7701S-configuratie; zie The current Guition uses the native ST7701S configuration; see
[de hardwarevergelijking](GUITION_FACTORY_REFERENCE.md). Instellingen en [the hardware comparison](GUITION_FACTORY_REFERENCE.md). Settings and
tegelkleuren veranderen geen paneeltimings. Controleer beeld en touch fysiek. tile colors don't change panel timings. Physically check the display and touch.
+113 -113
View File
@@ -1,173 +1,173 @@
# Guition ESP32-S3 4-inch wallbox — 480 × 480 # Guition ESP32-S3 4-inch wallbox — 480 × 480
Deze branch voegt een apart profiel toe voor de **Guition 4848S040** met This branch adds a separate profile for the **Guition 4848S040** with an
ST7701S RGB-display en **GT911 capacitieve touch**, ESP32-S3, 16 MB flash en ST7701S RGB display and **GT911 capacitive touch**, ESP32-S3, 16 MB flash, and
8 MB octal PSRAM. De naam/2mm-wandplaat beschrijft ook de behuizing; controleer 8 MB octal PSRAM. The name/2mm wall plate also describes the enclosure; always
altijd de elektronica. Dit profiel configureert geen relais. check the electronics. This profile doesn't configure any relay.
Hardwarebron: [ESPHome Guition-bordbeschrijving](https://devices.esphome.io/devices/guition-esp32-s3-4848s040/) Hardware source: the [ESPHome Guition board writeup](https://devices.esphome.io/devices/guition-esp32-s3-4848s040/)
en de ingebouwde [ST7701S-driver](https://esphome.io/components/display/st7701s/). and the built-in [ST7701S driver](https://esphome.io/components/display/st7701s/).
De paneelconfiguratie volgt de originele, fysiek schoon bevonden fabrikantdemo; The panel configuration follows the original manufacturer demo, found physically clean;
zie [de vergelijking](GUITION_FACTORY_REFERENCE.md). Er is geen lokale RGB-driver see [the comparison](GUITION_FACTORY_REFERENCE.md). No local RGB driver
of periodiek herstel-script nodig. De GT911 gebruikt ongemirrorde coördinaten. or periodic recovery script is needed. The GT911 uses unmirrored coordinates.
## Bestanden ## Files
- `guition-4848s040.yaml`: neutrale hardware + 480×480-interface. - `guition-4848s040.yaml`: neutral hardware + 480×480 interface.
- `guition-device.example.yaml`: eigen apparaat-/tegelinstellingen. - `guition-device.example.yaml`: personal device/tile settings.
- `guition-device.yaml`: jouw lokale profiel, buiten Git. - `guition-device.yaml`: your local profile, outside Git.
- `secrets.yaml`: lokale wifi/API/OTA-instellingen, buiten Git. - `secrets.yaml`: local Wi-Fi/API/OTA settings, outside Git.
- `tools/verify_gt911.py`: fysieke pixelcontrole; geen resistieve ADC-kalibratie. - `tools/verify_gt911.py`: physical pixel check; no resistive ADC calibration.
Het 2,8-inch CYD-profiel blijft apart. Flash dat niet naar de Guition en neem The 2.8-inch CYD profile stays separate. Don't flash that to the Guition, and don't
geen `calibration.yaml` of XPT2046-correctie over. carry over `calibration.yaml` or the XPT2046 correction.
## Interface ## Interface
### Montage en schermrotatie (runtime 0.2.9+) ### Mounting and screen rotation (runtime 0.2.9+)
Installeer Guition-firmware 0.2.9 en ESP Screen Manager 0.2.9. Open het scherm Install Guition firmware 0.2.9 and ESP Screen Manager 0.2.9. Open the screen
in de beheerpagina, ga naar **Scherminstellingen** en kies bij **Scherm draaien** in the management page, go to **Screen settings**, and choose 0°, 90°, 180°,
0°, 90°, 180° of 270°, met de klok mee. Klik op opslaan. Een firmwareflash is or 270° (clockwise) under **Rotate screen**. Click save. After that, a
daarna niet meer nodig om de hoek te wijzigen. De instelling blijft in de appdata firmware flash is no longer needed to change the angle. The setting is kept
en op het scherm bewaard na een herstart. in the app data and on the screen after a restart.
Dit gebruikt de native ESPHome/LVGL-rotatie voor beeld én touch, zonder wijzigingen This uses native ESPHome/LVGL rotation for both display and touch, with no changes
aan de paneelinitialisatie of GT911-spiegeling. Controleer na montage de vier to the panel initialization or GT911 mirroring. After mounting, physically check the
hoeken en de navigatie fysiek; een renderproef test geen aanrakingen. De optie is four corners and navigation; a render test doesn't test touches. The option is
alleen zichtbaar voor de Guition nadat zijn nieuwe firmware door HA is ontdekt. only visible for the Guition once its new firmware has been discovered by HA.
De CYD blijft op zijn bestaande oriëntatie met zijn eigen kalibratie. The CYD stays on its existing orientation with its own calibration.
Zes tegels van **218 × 108 pixels** per pagina, 12px tussenruimte en een Six tiles of **218 × 108 pixels** per page, 12px spacing, and a
aparte navigatiestrook. In ESP Screen Manager passen twintig tegels op maximaal separate navigation strip. In ESP Screen Manager, twenty tiles fit across up to
vier pagina’s; tot zes verdwijnt de navigatie. De huidige interface heeft een four pages; navigation disappears at six or fewer. The current interface has a
lichtgrijze achtergrond, witte kaarten en gekleurde domeiniconen. Vanaf 0.2.10 kun light gray background, white cards, and colored domain icons. From 0.2.10, you
je per tegel een pastel achtergrond kiezen met donkere tekst. Standby begint can choose a pastel background with dark text per tile. Standby starts
standaard na tien minuten zonder aanraking en is instelbaar in de beheerpagina. after ten minutes without touch by default and is adjustable in the management page.
Vanaf 0.2.13 dimt de backlight via de LEDC-hardwarefader (`backlight_fade.h`, From 0.2.13, the backlight dims via the LEDC hardware fader (`backlight_fade.h`,
1,5 s naar standby, 80 ms wakker): de volledige LVGL-herteken bij standby 1.5 s to standby, 80 ms to wake): the full LVGL redraw on standby no longer
onderbreekt de overgang dan niet meer. ESPHome's lichtstatus wordt na de fade interrupts the transition. ESPHome's light state is synced after the fade,
gesynchroniseerd, zodat de entity in HA en latere overgangen kloppen. so the entity in HA and later transitions stay correct.
De bestaande tegelacties, climatebediening en vacuumkaart zijn behouden. The existing tile actions, climate control, and vacuum card are preserved.
Alle kaarttypen zijn beschikbaar op alle twintig runtime-posities. Alleen in All card types are available at all twenty runtime positions. Only in
het oude handmatige profiel blijft de vacuumkaart op tegel 6 en hebben posities the old manual profile does the vacuum card stay on tile 6 and do positions
8/10 beperkingen; zie [TEGELS.md](TEGELS.md). 8/10 have limitations; see [TILES.md](TILES.md).
## Nieuwe installatie ## New installation
Volg de Python-/USB-voorbereiding in [README.md](../README.md), in een verse Follow the Python/USB setup in [README.md](../README.md), in a fresh
kopie. Maak dit profiel: copy. Create this profile:
```sh ```sh
python tools/new_device.py --board guition --name wallbox-keuken --friendly-name "Wallbox keuken" python tools/new_device.py --board guition --name wallbox-kitchen --friendly-name "Wallbox kitchen"
``` ```
Dit maakt `guition-device.yaml` en nieuwe secrets; bestaande bestanden worden This creates `guition-device.yaml` and new secrets; existing files are
niet overschreven. Vul wifi in en wijzig de tegelentiteiten. Gebruik bij not overwritten. Fill in Wi-Fi and change the tile entities. With
meerdere borden een aparte map per apparaat. Het huidige lokale Guition-profiel multiple boards, use a separate folder per device. The owner's current local
van de eigenaar mag diens bestaande secrets gebruiken. Guition profile may use their existing secrets.
Controleer de seriële poort en chip voordat je uploadt. Voor het geteste bord Check the serial port and chip before uploading. On the tested board,
bleken hogere seriële snelheden bij uitlezen onbetrouwbaar. Gebruik 115200 baud: higher serial speeds proved unreliable when reading. Use 115200 baud:
```sh ```sh
python -m esphome config guition-device.yaml python -m esphome config guition-device.yaml
python -m esphome compile guition-device.yaml python -m esphome compile guition-device.yaml
``` ```
Flash via `esphome run` als de USB-verbinding betrouwbaar is. Voor expliciet Flash via `esphome run` if the USB connection is reliable. For explicit
115200 baud kun je de gecombineerde factory-image gebruiken. Vervang zowel 115200 baud, you can use the combined factory image. Replace both the
poort als apparaatnaam in het pad door jouw waarden: port and the device name in the path with your own values:
```sh ```sh
python -m esptool --chip esp32s3 --port <USB_POORT> --baud 115200 write-flash 0 .esphome/build/wallbox-keuken/.pioenvs/wallbox-keuken/firmware.factory.bin python -m esptool --chip esp32s3 --port <USB_PORT> --baud 115200 write-flash 0 .esphome/build/wallbox-kitchen/.pioenvs/wallbox-kitchen/firmware.factory.bin
``` ```
De factory-image hoort bij dit S3-profiel en begint op adres **0**, anders dan The factory image belongs to this S3 profile and starts at address **0**, unlike
sommige klassieke ESP32-images. Bewaar een herstelkopie vóór vervanging van some classic ESP32 images. Keep a recovery copy before replacing
bestaande firmware als die later nog nodig is. Houd binaries en logs lokaal. existing firmware if you might need it later. Keep binaries and logs local.
## GT911 en oriëntatie testen ## Testing GT911 and orientation
De GT911 geeft pixels door. Meestal is geen kalibratie nodig; een verkeerde The GT911 reports pixels directly. Usually no calibration is needed; a wrong
rotatie/transform mag niet met de CYD-affinewizard worden weggewerkt. rotation/transform should not be papered over with the CYD affine wizard.
Met een werkende wifi/API-verbinding: With a working Wi-Fi/API connection:
```sh ```sh
python diagnostics/control_ui.py touch_diagnostics --host wallbox-keuken.local --name wallbox-keuken python diagnostics/control_ui.py touch_diagnostics --host wallbox-kitchen.local --name wallbox-kitchen
python tools/verify_gt911.py --port <USB_POORT> --output measurements-guition.json python tools/verify_gt911.py --port <USB_PORT> --output measurements-guition.json
python diagnostics/control_ui.py end_touch_diagnostics --host wallbox-keuken.local --name wallbox-keuken python diagnostics/control_ui.py end_touch_diagnostics --host wallbox-kitchen.local --name wallbox-kitchen
``` ```
Er verschijnen vijf kruisjes. De wizard vraagt drie afzonderlijke tikken per Five crosshairs appear. The wizard asks for three separate taps per
punt; eerst Enter en daarna uitsluitend het genoemde kruisje aanraken. Fout point; press Enter first, then touch only the named crosshair. Error
maximaal 16px, spreiding maximaal 18px. De test stuurt geen HA-acties. at most 16px, spread at most 18px. The test doesn't send any HA actions.
Zonder wifi kun je eerst een build maken met `-s CALIBRATION_ON_BOOT true`, Without Wi-Fi, you can first build with `-s CALIBRATION_ON_BOOT true`,
die na USB-upload direct het GT911-meetscherm toont. Na controle opnieuw which shows the GT911 measurement screen right after the USB upload. After
bouwen/flashen zonder die override. Deze compatibiliteitsnaam opent alleen checking, rebuild/reflash without that override. This compatibility name only
een pixeltest; hij voert geen ADC-kalibratie uit. opens a pixel test; it doesn't perform any ADC calibration.
Op het geteste paneel zijn beide GT911-assen gespiegeld: `TOUCH_MIRROR_X` en On the tested panel, both GT911 axes are mirrored: `TOUCH_MIRROR_X` and
`TOUCH_MIRROR_Y` staan op `true`. Standaard is `LVGL_ROTATION: "0"`. Bij een andere montage stel je de gewenste `TOUCH_MIRROR_Y` are set to `true`. The default is `LVGL_ROTATION: "0"`. For a different mounting, set the
LVGL-rotatie in en geef je dezelfde waarde aan de wizard, bijvoorbeeld desired LVGL rotation and give the wizard the same value, for example
`--rotation 90`. Controleer alle hoeken; verander de GT911 `TOUCH_SWAP_XY` / `--rotation 90`. Check every corner; only change the GT911 `TOUCH_SWAP_XY` /
`TOUCH_MIRROR_X` / `TOUCH_MIRROR_Y` alleen als de fysieke meting dat vereist. `TOUCH_MIRROR_X` / `TOUCH_MIRROR_Y` if the physical measurement requires it.
## HA en acceptatie ## HA and acceptance
Voeg het nieuwe apparaat toe via de ESPHome-integratie (naam/IP, poort 6053, Add the new device via the ESPHome integration (name/IP, port 6053,
API-sleutel uit secrets). Geef toestemming voor HA-acties en gebruik API key from secrets). Grant permission for HA actions and use
`DIRECT_ACTIONS: "true"` zodra de juiste entiteiten gecontroleerd zijn. `DIRECT_ACTIONS: "true"` once the correct entities have been checked.
```sh ```sh
python diagnostics/run_ui_test.py --host wallbox-keuken.local --name wallbox-keuken python diagnostics/run_ui_test.py --host wallbox-kitchen.local --name wallbox-kitchen
python -m unittest discover -s tests -p 'test_*.py' python -m unittest discover -s tests -p 'test_*.py'
``` ```
Doorloop ook de fysieke controles uit [ACCEPTATIE.md](ACCEPTATIE.md), met de Also go through the physical checks in [ACCEPTANCE.md](ACCEPTANCE.md), using the
GT911-wizard in plaats van de XPT2046-kalibratie. Controleer beeld, juiste GT911 wizard instead of the XPT2046 calibration. Check the display, correct
aanrakingen, paginering, lange druk, climate/vacuum, echte HA-terugmelding, touches, pagination, long press, climate/vacuum, real HA feedback,
standby en herstel na herstart. Een compilatie/renderproef vervangt die standby, and recovery after a restart. A compile/render test doesn't replace
fysieke controle niet. De testuitkomst van het oorspronkelijke CYD-bord that physical check. The original CYD board's test outcome
zegt niets over dit nieuwe bord. says nothing about this new board.
## Gerenderde interface inspecteren ## Inspecting the rendered interface
```sh ```sh
python diagnostics/capture_ui.py --host wallbox-keuken.local --name wallbox-keuken --output diagnostics/guition-home.png python diagnostics/capture_ui.py --host wallbox-kitchen.local --name wallbox-kitchen --output diagnostics/guition-home.png
``` ```
Dit maakt alleen op verzoek een LVGL-snapshot en stuurt een 240×240-preview This only takes an LVGL snapshot on request and sends a 240×240 preview
via de versleutelde API. De tijdelijke buffer wordt na ongeveer 24 seconden over the encrypted API. The temporary buffer is released after about 24 seconds.
vrijgegeven. Het beeld controleert de renderer; het bewijst niet dat de fysieke The image verifies the renderer; it doesn't prove the physical
RGB-signalen, schermkleuren of montageoriëntatie goed zijn. De renderproef RGB signals, screen colors, or mounting orientation are correct. The render test
controleert ook of zichtbare klikgebieden binnen het 480×480-scherm vallen. also checks that visible click areas fall within the 480×480 screen.
## Herstelkopie van dit testbord ## Recovery copy of this test board
De oorspronkelijke gebruikte firmware inclusief bootloader, partitietabel The original firmware in use, including bootloader, partition table,
en NVS is lokaal bewaard als `diagnostics/guition-original-recovery.bin`. and NVS, is stored locally as `diagnostics/guition-original-recovery.bin`.
De lengtes van de vijf applicatiesegmenten bepaalden de benodigde 4.788.224 The lengths of the five application segments determined the needed 4,788,224
bytes; dit is geen kopie van alle 16 MB flash. De stub controleerde de MD5 bytes; this is not a copy of the full 16 MB flash. The stub verified the MD5
van ieder uitgelezen blok. SHA-256 van het complete herstelbestand: of every block read. SHA-256 of the complete recovery file:
```text ```text
a656c793c422848b5ad72bc16545fb92c844b7dfba029f4e4399d22444a9e046 a656c793c422848b5ad72bc16545fb92c844b7dfba029f4e4399d22444a9e046
``` ```
Deze kopie is privé, hoort uitsluitend bij het geteste bord en wordt niet This copy is private, belongs only to the tested board, and is not
meegecommit of geëxporteerd. Herstellen gebeurt op adres 0, met dezelfde committed or exported. Recovery happens at address 0, with the same
115200-baudopdracht als de factory-image, maar met dit herstelbestand. 115200-baud command as the factory image, but with this recovery file.
## RGB-geheugeninstellingen ## RGB memory settings
De pixelklok staat op 16 MHz: op het testbord verdween daarmee het zachte The pixel clock is set to 16 MHz: on the test board, this removed the faint
flikkeren van de 12MHz-instelling. Voor de RGB-bouncebuffer worden code en flicker seen at the 12MHz setting. For the RGB bounce buffer, code and
constante data vanuit octal PSRAM uitgevoerd, met 64KB datacache en 64-byte constant data run from octal PSRAM, with a 64KB data cache and 64-byte
cachelijnen; herstel van de RGB-stroom gebeurt op VSYNC. Deze instellingen cache lines; RGB stream recovery happens on VSYNC. These settings
volgen [Espressifs RGB-LCD-aanbevelingen](https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32s3/api-reference/peripherals/lcd/rgb_lcd.html). follow [Espressif's RGB LCD recommendations](https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32s3/api-reference/peripherals/lcd/rgb_lcd.html).
Controleer fysiek op incidentele glitches onder wifi-/renderbelasting; een Physically check for occasional glitches under Wi-Fi/render load; a
software-snapshot kan een verstoring van het paneelsignaal niet bewijzen. software snapshot can't prove a disturbance in the panel signal.
+77 -77
View File
@@ -1,99 +1,99 @@
# Guition 4848S040: onafhankelijke fabrikanttest # Guition 4848S040: independent manufacturer test
Deze test onderscheidt onze ESPHome/UI van de oorspronkelijke paneelaansturing. This test distinguishes our ESPHome/UI from the original panel driving.
Een passende demo is gevonden in het fabrikantpakket dat SpotPear aanbiedt: A matching demo was found in the manufacturer package offered by SpotPear:
- [Verkoper met downloadlink](https://spotpear.com/ESP32-S3-4-inch-LCD-Touchscreen-Display-SHT20-Temperature-Humidity-480x480-RS485-Relay-GC9503-ST7701-FT6336U-GT911-86-TVbox/forum-answer/357.html) - [Vendor with download link](https://spotpear.com/ESP32-S3-4-inch-LCD-Touchscreen-Display-SHT20-Temperature-Humidity-480x480-RS485-Relay-GC9503-ST7701-FT6336U-GT911-86-TVbox/forum-answer/357.html)
- [Origineel archief](https://cdn.static.spotpear.com/uploads/picture/learn/ESP32/ESP32-S3-4inch/4.0inch_ESP32-4848S040.zip) - [Original archive](https://cdn.static.spotpear.com/uploads/picture/learn/ESP32/ESP32-S3-4inch/4.0inch_ESP32-4848S040.zip)
- [Community-uitleg voor bouwen met LVGL 8/9](https://github.com/paulhamsh/LVGL_GUITION) - [Community write-up for building with LVGL 8/9](https://github.com/paulhamsh/LVGL_GUITION)
In het archief: In the archive:
- `1-Demo/Demo_Arduino/1_2_4.0_LvglWidgets/4.0_LvglWidgets/`: broncode. - `1-Demo/Demo_Arduino/1_2_4.0_LvglWidgets/4.0_LvglWidgets/`: source code.
- `1-Demo/Demo_Arduino/Libraries/Arduino_GFX-master/`: meegeleverde paneeldriver. - `1-Demo/Demo_Arduino/Libraries/Arduino_GFX-master/`: bundled panel driver.
- `8-Burn operation/Burn files/4.0_LvglWidgets.bin`: samengevoegde firmware, - `8-Burn operation/Burn files/4.0_LvglWidgets.bin`: combined firmware,
652640 bytes, flashadres 0. Bootloader op 0, partitietabel op 0x8000, 652640 bytes, flash address 0. Bootloader at 0, partition table at 0x8000,
applicatie op 0x10000. application at 0x10000.
- SHA256 van die demo: `9662c193fc52407bba2ed1462c582d40148f7d8eedfc806db4e8fff7685abba1`. - SHA256 of that demo: `9662c193fc52407bba2ed1462c582d40148f7d8eedfc806db4e8fff7685abba1`.
Gebruik uitsluitend de Widgets-demo voor de displaytest. De andere meegeleverde Use only the Widgets demo for the display test. The other bundled
voorbeelden zijn relay- en muziektoepassingen. De Widgets-bron gebruikt alleen examples are relay and music applications. The Widgets source only uses the
paneel, GT911 en achtergrondverlichting en heeft geen HA-/wifi-configuratie. panel, GT911, and backlight, and has no HA/Wi-Fi configuration.
## Opvallende referentie-instellingen ## Notable reference settings
De oorspronkelijke bron gebruikt ArduinoGFX, `st7701_type1_init_operations`, The original source uses ArduinoGFX, `st7701_type1_init_operations`,
16 MHz pixelklok, RGB-pinnen R=11/12/13/14/0, G=8/20/3/46/9/10, a 16 MHz pixel clock, RGB pins R=11/12/13/14/0, G=8/20/3/46/9/10,
B=4/5/6/7/15. Horizontaal front/pulse/back=10/8/50; verticaal=10/8/20. B=4/5/6/7/15. Horizontal front/pulse/back=10/8/50; vertical=10/8/20.
De paneelmodus is 0x3A=0x60, RGB666 op een 16-bits bedrade bus, met 0xCD=0x00. The panel mode is 0x3A=0x60, RGB666 on a 16-bit wired bus, with 0xCD=0x00.
Rotatie 0 schrijft C7=0 in registerbank 0x10 en MADCTL=0 in bank 0. Rotation 0 writes C7=0 in register bank 0x10 and MADCTL=0 in bank 0.
De achtergrondverlichting staat via GPIO38 continu aan, zonder PWM. The backlight is on continuously via GPIO38, without PWM.
De gamma- en spanningswaarden lijken op ESPHome's ST7701S-basis, maar de volgorde, The gamma and voltage values resemble ESPHome's ST7701S base, but the order,
porches en rotatiecommando's verschillen. Dat is aanleiding voor vergelijking, porches, and rotation commands differ. That's grounds for comparison,
geen bewijs dat een specifieke wijziging strepen oplost. not proof that a specific change fixes banding.
## Back-up en terugkeer ## Backup and recovery
Verifieer eerst de chip/MAC en de echte USB-poort. Een volledige flashback-up is First verify the chip/MAC and the actual USB port. A full flash backup is
het veiligst. Voor deze specifieke demo worden alleen sectoren binnen de eerste safest. For this specific demo, only sectors within the first
1 MiB overschreven. Een exacte back-up van dat bereik is voldoende om die 1 MiB are overwritten. An exact backup of that range is enough to
overschrijving terug te draaien als de demo geen andere flashgebieden beschrijft. undo that overwrite, as long as the demo doesn't write to other flash regions.
Bewaar daarnaast de eigen firmware/YAML. Flashback-ups bevatten persoonlijke Also keep the device's own firmware/YAML. Flash backups contain personal
sleutels: alleen lokaal opslaan, nooit in Git. keys: keep them local only, never in Git.
Gebruik geen erase-all. Herstel na de test de geback-upte sectoren en verifieer de Don't use erase-all. After the test, restore the backed-up sectors and verify the
ESPHome-identiteit, versie, voorkeuren en HA-koppeling. Leg het fysieke resultaat ESPHome identity, version, preferences, and HA pairing. Record the physical result
apart vast: een succesvolle upload zegt niets over het zichtbare beeld. separately: a successful upload says nothing about the visible image.
## Uitgevoerde test op 12 september 2026 ## Test performed on September 12, 2026
- Fabrikantarchief opgehaald en de Widgets-bron plus meegeleverde driver gelezen. - Fetched the manufacturer archive and read the Widgets source plus bundled driver.
- De eerste 1 MiB (alle door deze demo overschreven sectoren) via USB geback-upt; - Backed up the first 1 MiB (every sector this demo overwrites) over USB;
921600/460800 baud gaven communicatieproblemen, 115200 baud werkte. 921600/460800 baud caused communication problems, 115200 baud worked.
- Originele Widgets-binary ongewijzigd op adres 0 geschreven; hashverificatie geslaagd. - Wrote the original Widgets binary unchanged at address 0; hash verification passed.
- USB-bootlog meldt `LVGL Widgets Demo` en `Setup done`, vervolgens GT911-polling. - The USB boot log reports `LVGL Widgets Demo` and `Setup done`, then GT911 polling.
Ook meldt de oorspronkelijke demo GPIO-fouten bij het opzetten; daarom blijft The original demo also reports GPIO errors during setup; physical
fysieke bevestiging nodig. De eigenaar bevestigt daarna: de fabrikantdemo is schoon, zonder strepen. confirmation therefore remains necessary. The owner then confirmed: the manufacturer demo is clean, no banding.
Daarmee is goede beeldweergave op dit fysieke paneel aangetoond. Dit sluit That demonstrates good image output on this physical panel. This doesn't
niet iedere hardware-/voedingsfactor uit, maar maakt onze aansturing de rule out every hardware/power factor, but it makes our own driving the
eerste onderzoekslijn. first line of investigation.
## Native ESPHome-kandidaat 0.1.3 ## Native ESPHome candidate 0.1.3
De standaard `st7701s`-component krijgt dezelfde 16 MHz-klok en horizontale The standard `st7701s` component gets the same 16 MHz clock and horizontal
10/8/50 en verticale 10/8/20 timing als de fabrikantdemo. SPI gebruikt MODE0; 10/8/50 and vertical 10/8/20 timing as the manufacturer demo. SPI uses MODE0;
kleurvolgorde RGB, geen inversie of hardwarematige spiegeling. Na de ingebouwde color order RGB, no inversion or hardware mirroring. After the built-in
initialisatie worden de 16-bits busconfiguratie CD=00 en de standaard registerbank initialization, the 16-bit bus configuration CD=00 and the standard register bank
expliciet gekozen, gevolgd door 3A=60 en de wachttijd na sleep-out. De GT911 blijft are explicitly selected, followed by 3A=60 and the wait after sleep-out. The GT911 stays
in dezelfde ongemirrorde oriëntatie. De ESPHome RGB-driver wordt niet gekopieerd in the same unmirrored orientation. The ESPHome RGB driver isn't copied
of gepatcht; de eerdere extra SDK-cache-/restart-instellingen zijn verwijderd. or patched; the earlier extra SDK cache/restart settings have been removed.
De eigenaar meldt dat deze kandidaat schoner is, maar nog enkele vaste verticale The owner reports that this candidate is cleaner but still shows a few fixed vertical
banden toont. Dit is dus geen afgeronde oplossing. De fabrikantdemo blijft de bands. So this is not a finished solution. The manufacturer demo remains the
schone referentie. Verschillen zijn onder meer ESP-IDF/LVGL-versies, framebuffer- clean reference. Differences include ESP-IDF/LVGL versions, framebuffer
en bouncebuffergebruik, wifi/API, PWM en de extra initialisatiestappen van de and bounce buffer usage, Wi-Fi/API, PWM, and the extra initialization steps of the
standaarddriver (inclusief software-reset). Native betekent hier de ingebouwde standard driver (including a software reset). "Native" here means the built-in
ESPHome-driver, niet byte-voor-byte dezelfde uitvoering als de fabrikantdemo. ESPHome driver, not a byte-for-byte identical execution to the manufacturer demo.
Daarna is alleen de Guition-interface licht vormgegeven: achtergrond F7F7F7, After that, only the Guition interface was given a light design: background F7F7F7,
witte kaarten, rand DDDDDD, donkere tekst en blauwe accenten. Ook de detailkaarten white cards, border DDDDDD, dark text, and blue accents. The detail cards also
gebruiken lichte bedieningselementen. Geometrie, paginering, touch, HA-indeling en use light controls. Geometry, pagination, touch, HA layout, and
opgeslagen instellingen blijven behouden. Deze stijlwijziging is geen strepenfix. saved settings are preserved. This style change is not a banding fix.
### Lichte interface: softwarecontrole ### Light interface: software check
De Python-suite (33 tests), zeven C++-regressietests en packagegeneratorcontrole The Python suite (33 tests), seven C++ regression tests, and the package generator check
slagen. De eerste lichte firmware is via OTA geïnstalleerd; tien paginacontroles pass. The first light firmware was installed over OTA; ten page checks
plus vijftig overlaywisselingen slagen zonder HA-acties. De schermopname toont de plus fifty overlay switches pass without HA actions. The screen capture shows the
nieuwe lichte kaarten met de bestaande HA-indeling. Lange namen kregen daarna new light cards with the existing HA layout. Long names then got
vaste regelhoogtes met afkapping, zodat titel en status niet overlappen. fixed line heights with truncation, so the title and status don't overlap.
Een interne schermopname bewijst geen storingsvrije fysieke paneeluitvoer. An internal screen capture doesn't prove glitch-free physical panel output.
De definitieve lichte versie (config_hash 0x0a70a194) is via OTA geïnstalleerd. The final light version (config_hash 0x0a70a194) was installed over OTA.
Beide Easy Setup- en beide handmatige profielen bouwen succesvol. De eigenaar Both Easy Setup and both manual profiles build successfully. The owner
bevestigt vervolgens: ‘Stijl goed, geen banden zichtbaar’. Dit is fysieke then confirmed: 'Style good, no bands visible'. This is physical
acceptatie van het huidige beeld, geen duurtest na langdurige standby en geen acceptance of the current image, not an endurance test after prolonged standby, and not
bewijs dat alleen een driverwijziging de oorzaak heeft opgelost. proof that a driver change alone fixed the root cause.
-32
View File
@@ -1,32 +0,0 @@
# 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.
+380 -349
View File
@@ -1,52 +1,51 @@
# Updates publiceren zonder gebruikersconfiguratie te vervangen # Publishing updates without replacing user configuration
## Indeling van de code en gegevens ## Layout of the code and data
- `main` is de distributiebranch voor de app **en beide** firmwarepakketten. - `main` is the only distribution branch for the app **and both** firmware packages.
De Guition-branch blijft beschikbaar voor bordwerk; publiceer gedeelde releases The old Guition branch is no longer updated; all board work goes to main.
altijd ook naar main. - `screen_manager/config.yaml` holds the app version. Bump it on every app release.
- `screen_manager/config.yaml` bevat de appversie. Verhoog die bij iedere apprelease. A Git push alone isn't enough to offer an existing app an update.
Git push alleen is niet genoeg om een bestaande app een update aan te bieden. - The app image contains only code. Layouts live in `/data/screens.json`
- De appimage bevat uitsluitend code. Indelingen leven in `/data/screens.json` (`version: 1`, `screens: {...}`). An update/rebuild preserves this volume data.
(`version: 1`, `screens: {...}`). Een update/herbuild behoudt deze volumegegevens. - The device's own ESPHome YAML contains the name, Wi-Fi references, and unique API/OTA keys.
- De eigen ESPHome-YAML bevat naam, wifi-verwijzingen en unieke API/OTA-sleutels. Shared packages contain no secrets, fixed owner entities, or Wi-Fi.
Gedeelde pakketten bevatten geen secrets, vaste eigenaarsentiteiten of wifi. - CYD calibration lives in ESP32 preferences. Preserve the preference key, structure,
- CYD-kalibratie staat in ESP32-preferences. Behoud de preference-key, structuur and partition layout, or write an explicit migration.
en partitie-indeling, of schrijf een expliciete migratie. - The tile protocol uses `v: 1`. Keep older fields and domains usable.
- Het tegelprotocol gebruikt `v: 1`. Houd oudere velden en domeinen bruikbaar. A changed storage version must get a tested migration with a backup.
Een gewijzigde opslagversie moet een geteste migratie met back-up krijgen. The app refuses unknown versions instead of overwriting the data blank.
De app weigert onbekende versies in plaats van de data leeg te overschrijven.
## Voor elke release ## For every release
1. Pas de bronprofielen en gedeelde componenten aan. Draai 1. Update the source profiles and shared components. Run
`python3 tools/generate_packages.py`; bewerk `packages/*.yaml` nooit rechtstreeks. `python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly.
2. Draai alle Python-tests met aiohttp geïnstalleerd, alle `tests/*.cpp`, de 2. Run all Python tests with aiohttp installed, all `tests/*.cpp`, the
generator met `--check`, en compileer beide Easy Setup-profielen plus de generator with `--check`, and compile both Easy Setup profiles plus the
bestaande handmatige profielen. Doe dat na elkaar: profielen met dezelfde existing manual profiles. Do that sequentially: profiles with the same
`DEVICE_NAME` delen één build-map, en een parallelle build laat een upload `DEVICE_NAME` share one build folder, and a parallel build can make an upload
het verkeerde `firmware.bin` kiezen. Controleer dat er geen secrets in Git staan. pick the wrong `firmware.bin`. Check that no secrets are in Git.
3. Test appstart, opslaan, opnieuw starten/updaten met bestaande indelingen, 3. Test app start, saving, restarting/updating with existing layouts,
opnieuw verbinden met HA en een ESP-herstart. Test een nieuwe kaart op echte reconnecting to HA, and an ESP restart. Test a new card on real
hardware. Een goede build vervangt fysieke touchacceptatie niet. hardware. A good build doesn't replace physical touch acceptance.
4. Verhoog appversie en firmwareprojectversie; schrijf CHANGELOG en concrete 4. Bump the app version and firmware project version; write the CHANGELOG and concrete
testresultaten. Publiceer alleen compatibele wijzigingen rechtstreeks op main. test results. Only publish compatible changes directly to main.
5. Commit en push main. Maak een onveranderlijke tag `screens-vX.Y.Z` van dezelfde 5. Commit and push main (the only release branch). Create an immutable tag
commit. Werk de Guition-branch bij. Test de remote YAML in een lege map: `screens-vX.Y.Z` from the same commit. Test the remote YAML in an empty folder:
alle componenten/fonts moeten via GitHub kunnen worden opgehaald. all components/fonts must be fetchable via GitHub.
6. De gebruiker controleert de appwinkel op updates en update ESP Screen Manager. 6. The user checks the App store for updates and updates ESP Screen Manager.
Voor nieuwe schermfuncties: bestaand ESPHome-apparaat → Install → Wirelessly. For new screen features: existing ESPHome device → Install → Wirelessly.
De bestaande YAML blijft staan; `refresh: 0s` haalt bij elke build actuele code. The existing YAML stays in place; `refresh: 0s` fetches current code on every build.
## Lokale ontwikkeling ## Local development
Kopieer alleen `screen_manager/` naar de gedeelde `addons/esp_screen_manager/`. Copy only `screen_manager/` to the shared `addons/esp_screen_manager/`.
Herlaad de appwinkel, installeer de lokale versie en bouw na codewijzigingen opnieuw. Reload the App store, install the local version, and rebuild after code changes.
Een lokale testversie heeft een andere add-onidentiteit dan de GitHub-versie; de A local test version has a different add-on identity than the GitHub version; the
gegevens verhuizen niet automatisch. Test voor de uiteindelijke installatie de data doesn't move over automatically. For the final installation, test the
GitHub-versie en zet de lokale versie uit om twee schrijvers te voorkomen. GitHub version and turn off the local version to avoid two writers.
Voor backendtests op een ontwikkelcomputer: For backend tests on a development machine:
```sh ```sh
python3 -m venv .venv-portal python3 -m venv .venv-portal
@@ -54,371 +53,403 @@ python3 -m venv .venv-portal
.venv-portal/bin/python -m unittest discover -s tests .venv-portal/bin/python -m unittest discover -s tests
``` ```
Een tijdelijke ontwikkelserver ondersteunt `SCREEN_DEV=1`, `HA_API` (eindigt op A temporary development server supports `SCREEN_DEV=1`, `HA_API` (ending in
`/api`), `HA_TOKEN_FILE` en `SCREEN_DATA`. Hij bindt alleen op localhost. Zet een `/api`), `HA_TOKEN_FILE`, and `SCREEN_DATA`. It only binds on localhost. Never put a
token nooit in broncode, URL's of Git. Productie gebruikt Supervisor en accepteert token in source code, URLs, or Git. Production uses Supervisor and only accepts
alleen het Ingress-proxyadres; er is geen extra publieke poort. the Ingress proxy address; there is no additional public port.
### Instellingencompatibiliteit 0.1.2 ### Settings compatibility 0.1.2
`settings` is een optioneel object binnen een schermindeling en binnen het bestaande `settings` is an optional object within a screen layout and within the existing
`v: 1, op: layout`-bericht. Een ontbrekend object behoudt eerder gedrag. Oude `v: 1, op: layout` message. A missing object preserves prior behavior. Old
firmware negeert dit extra veld en blijft tegels ontvangen. De beheerpagina meldt firmware ignores this extra field and keeps receiving tiles. The management page reports
dat nieuwe firmware nodig is. Een oude browser die alleen tegels opslaat, wist that new firmware is needed. An old browser that only saves tiles doesn't wipe
opgeslagen instellingen niet. Onbekende instellingen worden geweigerd. saved settings. Unknown settings are refused.
Firmware gebruikt een afzonderlijke preferences-key `0x53435231` met een vaste Firmware uses a separate preferences key `0x53435231` with a fixed
versie-1 structuur (elf int32-velden en een uint32-versie). Wijzig key, structuur version-1 structure (eleven int32 fields and a uint32 version). Don't change the key,
of versie niet zonder migratie. Alleen gewijzigde instellingen worden opgeslagen; structure, or version without a migration. Only changed settings are saved;
periodieke herhaling veroorzaakt geen flashwrites en geen nieuwe idle-timer. periodic repeats cause no flash writes and no new idle timer.
### Compatibiliteit 0.2.0 ### Compatibility 0.2.0
Opslagversie en tegelprotocol blijven 1. Optionele `tiles[].options`, `o` in Storage version and tile protocol stay 1. Optional `tiles[].options`, `o` in
statusberichten, `history` en `inbox` in het layoutbericht zijn additief. Een status messages, `history`, and `inbox` in the layout message are additive. An
oudere browser die opties niet meestuurt behoudt de bestaande opties voor dezelfde older browser that doesn't send options keeps the existing options for the same
entiteit. Oudere firmware negeert extra velden. Nieuwe entiteitsdomeinen vereisen entity. Older firmware ignores extra fields. New entity domains do
wel firmware 0.2.0; bij terugrollen naar een oudere app eerst de gegevensback-up require firmware 0.2.0; when rolling back to an older app, restore the data backup
herstellen, omdat die app nieuwe domeinen nog niet kan laden. Geen oude opslag first, since that app can't yet load new domains. Don't silently
stilzwijgend overschrijven. Bestaande preference-keys blijven ongewijzigd. overwrite old storage. Existing preference keys stay unchanged.
De app gebruikt nu de officiële ESPHome-container, mapped `homeassistant_config` The app now uses the official ESPHome container, maps `homeassistant_config`
naar `/homeassistant` en vraagt UART/USB voor de door de gebruiker gekozen poort. to `/homeassistant`, and requests UART/USB for the port the user chose.
Config/secrets blijven in de eigen HA-configmap; builds in `/data`. App-updates Config/secrets stay in the device's own HA config folder; builds in `/data`. App updates
vervangen deze mappen niet. Native getalinstellingen melden wijzigingen via don't replace these folders. Native number settings report changes via
`esphome.screen_setting`; de manager valideert inbox, sleutel en waarde. `esphome.screen_setting`; the manager validates the inbox, key, and value.
De ingebouwde CLI bewaart opnieuw te downloaden caches onder `/data/build` en The built-in CLI stores re-downloadable caches under `/data/build` and
`/data/platformio`. Alleen deze mappen zijn uitgesloten van appback-ups via `/data/platformio`. Only these folders are excluded from app backups via
`backup_exclude`. `/data/screens.json` en eigen ESPHome-configuraties blijven `backup_exclude`. `/data/screens.json` and the device's own ESPHome configurations remain
back-upgegevens; sluit nooit `/data` als geheel uit. backup data; never exclude `/data` as a whole.
### Compatibiliteit 0.2.7 ### Compatibility 0.2.7
Twintig runtime-tegels vereisen firmware 0.2.7+. De manager controleert die versie Twenty runtime tiles require firmware 0.2.7+. The manager checks that version
voor opslaan en verzenden van grotere indelingen. Bestaande indelingen blijven before saving and sending larger layouts. Existing layouts are
behouden bij oudere firmware. Oude appversies ondersteunen niet meer dan tien; preserved on older firmware. Old app versions don't support more than ten;
breng de indeling eerst terug tot tien of herstel hun gegevensback-up bij rollback. first bring the layout back down to ten, or restore their data backup on rollback.
`settings.swipe_pages` is additief in opslagversie 1. Op de draad staat het buiten `settings.swipe_pages` is additive in storage version 1. On the wire, it sits outside
het ongewijzigde elfvelden-settingsobject. De bestaande preferences blijven gelijk; the unchanged eleven-field settings object. Existing preferences stay the same;
alleen swipe gebruikt een nieuwe eigen uint32-key `0x53575031`, standaard uit. only swipe uses a new, separate uint32 key `0x53575031`, off by default.
### Compatibiliteit 0.2.9 ### Compatibility 0.2.9
Guition-rotatie is additief als `settings.rotation` in appdata, uitsluitend Guition rotation is additive as `settings.rotation` in app data, restricted to
0/90/180/270. Oude beheerpagina's die het veld weglaten behouden de opgeslagen 0/90/180/270. Old management pages that omit the field keep the stored
hoek. Op de draad staat `rotation` naast het ongewijzigde elfvelden-settingsobject. angle. On the wire, `rotation` sits alongside the unchanged eleven-field settings object.
De firmware bewaart de hoek als afzonderlijke uint32 op key `0x524F5431`; de The firmware stores the angle as a separate uint32 at key `0x524F5431`; the
bestaande Settings-structuur en CYD-kalibratie blijven ongewijzigd. Alleen de existing Settings structure and CYD calibration stay unchanged. Only the
Guition activeert de rotatiecallback. De diagnostische entity `Guition schermtype` Guition activates the rotation callback. The diagnostic entity `Guition schermtype`
maakt deze capability herkenbaar, ook als het scherm offline of hernoemd is. makes this capability recognizable, even when the screen is offline or renamed.
### Compatibiliteit 0.2.10 ### Compatibility 0.2.10
`tiles[].options.background` is een optionele paletnaam binnen opslagversie 1. `tiles[].options.background` is an optional palette name within storage version 1.
Op de draad staat dit in het bestaande `o`-object. Oudere firmware negeert het On the wire, this sits in the existing `o` object. Older firmware ignores the
veld; nieuwe firmware valt bij onbekende namen terug op de normale kleuren. field; newer firmware falls back to the normal colors for unknown names.
De app accepteert uitsluitend het lichte palet uit `TILE_BACKGROUNDS`. Een oude The app only accepts the light palette from `TILE_BACKGROUNDS`. An old
editor die `background` weglaat behoudt de opgeslagen kleur voor dezelfde entity; editor that omits `background` keeps the stored color for the same entity;
expliciet `auto` herstelt de standaard. Geen gewijzigde voorkeurstructuur of explicit `auto` restores the default. No changed preference structure or
sleutels, geen nieuwe firmwareflash nodig voor latere kleurkeuzes. keys, no new firmware flash needed for later color choices.
Een oude apprelease van vóór 0.2.10 kent dit optieveld niet. Bij terugrollen van An old app release from before 0.2.10 doesn't know this option field. When rolling back
de app herstel je daarom de bijbehorende gegevensback-up; wis of negeer geen the app, restore the corresponding data backup; don't wipe or ignore
onbekende opties om een oud opslagbestand toch te openen. unknown options just to open an old storage file anyway.
### Compatibiliteit app 0.2.15 / firmware 0.2.16 ### Compatibility app 0.2.15 / firmware 0.2.16
Opslagversie en tegelprotocol blijven 1. `tiles[].options.background` krijgt de Storage version and tile protocol stay 1. `tiles[].options.background` gets the
extra paletnaam `none` ("Geen"): geen kaart achter de tegelinhoud. Oudere extra palette name `none` ("None"): no card behind the tile content. Older
firmware kent de naam niet en valt terug op de normale kaart; omdat dat voor de firmware doesn't know the name and falls back to the normal card; because that fails
gebruiker onzichtbaar misgaat, stuurt de manager zo'n indeling pas na firmware invisibly for the user, the manager only sends such a layout once firmware
0.2.16 (`min_firmware`) en bewaart haar ondertussen. De firmware verbergt 0.2.16 or newer is detected (`min_firmware`), holding it back in the meantime. The firmware only hides
uitsluitend vulling en rand (`bg_opa`/`border_opa`); maten, padding en de the fill and border (`bg_opa`/`border_opa`); sizes, padding, and the
pressed-feedback op de PRESSED-state blijven gelijk. De analoge klok wijzigt pressed feedback on the PRESSED state stay the same. The analog clock only changes
alleen de tekening (streepjes, cijfers, kalenderblok op enkele tegels); er is the drawing (ticks, numerals, calendar block on single tiles); no
geen nieuw veld voor nodig. Geen gewijzigde preferences of sleutels. new field is needed for it. No changed preferences or keys.
### Compatibiliteit 0.2.16 / firmware 0.2.17 ### Compatibility 0.2.16 / firmware 0.2.17
`FIRMWARE_VERSION` in `screen_manager/app/core.py` is de firmware die bij deze `FIRMWARE_VERSION` in `screen_manager/app/core.py` is the firmware that belongs to this
app hoort; `tests/test_updates.py` eist dat hij gelijk is aan app; `tests/test_updates.py` requires it to equal
`SCREEN_FIRMWARE_VERSION` in beide bordprofielen en pakketten. Verhoog ze samen. `SCREEN_FIRMWARE_VERSION` in both board profiles and packages. Bump them together.
Een scherm met een lagere `Schermfirmware` krijgt een update-aanbod; een build A screen with a lower `Schermfirmware` gets an update offer; a build
haalt `main`, dus publiceer firmware en app in dezelfde commit. fetches `main`, so publish firmware and app in the same commit.
Firmware 0.2.17 voegt de diagnostische text sensors `Apparaatnaam` Firmware 0.2.17 adds the diagnostic text sensors `Apparaatnaam`
(`${DEVICE_NAME}`) en `IP-adres` (`wifi_info`) toe. De app koppelt een scherm (`${DEVICE_NAME}`) and `IP-adres` (`wifi_info`). The app links a screen
via `Apparaatnaam` aan het profiel met dezelfde `esphome.name`; oudere firmware to the profile with the same `esphome.name` via `Apparaatnaam`; older firmware
valt terug op één profiel met gelijke `friendly_name` en een handmatig ingevuld falls back to a single profile with a matching `friendly_name` and a manually entered
adres. Hernoem deze sensors niet zonder `core.discover` aan te passen. address. Don't rename these sensors without also updating `core.discover`.
Updatestatus staat in `/data/updates.json` (`version: 1`: `auto`, `hosts`, Update status lives in `/data/updates.json` (`version: 1`: `auto`, `hosts`,
`results`, `last_round`), los van `screens.json`. Onbekende versies worden `results`, `last_round`), separate from `screens.json`. Unknown versions are
geweigerd. De ronde draait één scherm tegelijk, wacht op `Schermfirmware >=` refused. The round runs one screen at a time, waits for `Schermfirmware >=`
doelversie en één minuut stabiliteit, en stopt bij de eerste fout. Alleen de target version and one minute of stability, and stops on the first error. Only the
nachtelijke ronde schrijft een `persistent_notification` in HA. nightly round writes a `persistent_notification` in HA.
### Compatibiliteit 0.2.18 / firmware 0.2.18 ### Compatibility 0.2.18 / firmware 0.2.18
Opslagversie en tegelprotocol blijven 1. `tiles[].options.icon` is additief: Storage version and tile protocol stay 1. `tiles[].options.icon` is additive:
`auto` of een naam uit `screen_manager/app/tile_icons.py`. Op de draad stuurt de `auto` or a name from `screen_manager/app/tile_icons.py`. On the wire, the
manager in `o.icon` alleen de opgeloste codepoint in hex (`F06B5`): de gekozen manager sends only the resolved codepoint in hex (`F06B5`) in `o.icon`: the chosen
naam, of bij `auto` het `mdi:`-icoon uit de HA-attributen als dat in de set zit. name, or, for `auto`, the `mdi:` icon from the HA attributes if it's in the set.
Oudere firmware negeert het veld; nieuwe firmware controleert of de glyph in de Older firmware ignores the field; newer firmware checks whether the glyph is in the
font zit en valt anders terug op het domeinicoon. Daarom geen `min_firmware`. font and otherwise falls back to the domain icon. Hence no `min_firmware`.
Een oude editor die `icon` weglaat behoudt de opgeslagen keuze. An old editor that omits `icon` keeps the stored choice.
`tile_icons.py` is de enige lijst. `tools/generate_icons.py` schrijft de glyphs `tile_icons.py` is the single source list. `tools/generate_icons.py` writes the glyphs
als YAML-anker in de drie MDI-fonts van beide bordprofielen en bouwt as a YAML anchor into the three MDI fonts of both board profiles and builds
`static/tile-icons.woff` voor de editor (met left bearing gelijk aan xMin, anders `static/tile-icons.woff` for the editor (with left bearing equal to xMin, otherwise
staan iconen in de browser uit het midden); draai daarna `generate_packages.py`. icons sit off-center in the browser); run `generate_packages.py` afterward.
De substituties `MDI_GLYPH_*` zijn vervallen: `TILEn_ICON` in handmatige The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profielen moet uit de set komen. Geen gewijzigde preferences of sleutels. profiles must come from the set. No changed preferences or keys.
### Compatibiliteit 0.2.38 / firmware 0.2.32 ### Compatibility 0.2.40 / firmware 0.2.34
Opslagversie en tegelprotocol blijven 1. Een indeling mag een optioneel object Storage version and tile protocol stay 1; no new message fields, preferences or keys.
`header: {items: [...]}` hebben (maximaal zes): `{type: clock|analog|date}` of Firmware 0.2.34 changes only text: every on-screen label, log line and status reply is
`{type: entity, entity, content: state|last_changed, icon: auto|none|<naam>, show: English, and so are the entity names. The status replies the manager reacts to are now
always|active}`. Zonder `header` geldt wat de firmware altijd deed (de klok van `Ready for tile configuration`, `Loading tiles`, `Resend needed`, `Synced` and
`show_clock`); een oude editor die `header` weglaat behoudt de opgeslagen balk. Bij `Error: …`; `RESEND_STATES` in `server.py` and `diagnostics/*.py` accept both
opslaan volgt `settings.show_clock` de klok in de balk, zodat oudere firmware de tijd languages, so screens on firmware 0.2.33 and older keep working unchanged.
alleen toont als hij erin staat. Entiteiten in de balk mogen naast de tegeldomeinen
ook `device_tracker`, `zone`, `lock`, `alarm_control_panel`, `counter`, `event`,
`input_datetime`, `input_text`, `water_heater` en `humidifier` zijn; de inventaris
markeert die met `tile: false` zodat de tegelkiezer ze overslaat.
Op de draad stuurt de manager direct na het layoutbericht `{v:1, op: header, items}` The entity names are part of the compatibility contract. Home Assistant's ESPHome
met per onderdeel `k` (`clock`, `analog`, `date`, `text`, `ago`), `i` (codepoint), integration builds the unique ID from the entity name (`<MAC>/0/text_info/Tile settings`),
`t` (tekst, al in het Nederlands opgemaakt en beperkt tot de glyphs van so a renamed entity gets a **new entity ID** and the old one is removed as soon as the
`sublabel_big`), `e` (unix-tijd voor `ago`) en `c` (accentkleur). Onderdelen met screen reconnects (seen on HA 2026.9.1: `text.cyd_2_8in_display_tegelinstellingen` →
`show: active` die niet actief zijn stuurt de manager niet. Alleen firmware 0.2.32+ `text.cyd_2_8in_display_tile_settings` within the same second). Renamed: `Tegelinstellingen` →
krijgt dit bericht (`HEADER_MIN_FIRMWARE`, op basis van `Schermfirmware`): oudere `Tile settings`, `Schermfirmware` → `Screen firmware`, `Apparaatnaam` → `Device name`,
firmware zou `op: header` weigeren met "Fout: ongeldig bericht". Het bericht wordt `IP-adres` → `IP address`, `Guition schermtype` → `Guition screen type`, `Opgestart` →
apart vergeleken; een gewijzigde waarde verstuurt alleen de balk, niet de indeling. `Last boot`, `Helderheid normaal/standby/nacht` → `Normal/Standby/Night brightness`,
De firmware antwoordt met dezelfde status als een tegelstatus, dus de `Standby na` → `Standby after`, `Touch kalibreren` → `Calibrate touch`.
Tegelinstellingen-entiteit flipt niet bij iedere waarde.
Firmware: `header_bar.h` (LVGL-vrij: parsen, "5 min geleden", datum, afstanden, `core.discover_screens` matches both names (`NAME_*` pairs). Because the manager keys
plaatsing) en `runtime_tiles::draw_header()`. Het tijdlabel van het profiel blijft `screens.json` and `updates.json` by the inbox entity ID, `Manager.follow_renamed_inboxes`
bestaan als referentie (rechtermarge, tekenvolgorde) maar is in runtime-modus (run whenever the screen list is rebuilt) moves a screen's layout, update address and last
verborgen. Waarden gebruiken `sublabel_big`, iconen `materialdesign_icons_mini`; result to its new inbox ID: first by the HA device an inbox was seen on during this run,
`sublabel_big` kreeg `'`, `#`, `*`, `=`, `;`, `²`, `³`, `µ`, `–` en veelvoorkomende otherwise (after a restart) when a stored ID's device part (`core.inbox_prefix`) matches
accenten; `tests/test_header_bar.py` houdt `header_bar.GLYPHS` gelijk aan die lijst. the entity ID prefix of one of the device's ESPHome entities (`core.device_prefixes`; the
De editor (`barLayout` in `app.js`) en de firmware (`header_bar::gaps/place`) delen unchanged `Node Status`, `Restart` and `Wifi Signal` entities keep the old prefix). An existing
dezelfde gehele rekensommen; de test controleert dat. `tools/render_topbar.py` layout on the new ID is never overwritten; layouts of other screens are never taken.
rendert de echte `draw_header()` op een Mac/Linux-machine (ESPHome host + SDL2) naar `Manager.aliases` maps old to new IDs so `save()`, the inspector and the updater
PNG, zonder scherm. Geen gewijzigde preferences of sleutels. (`Updater.current_id`, `Updater.renamed`) follow a screen that is renamed mid-update: the
Update button and the nightly round verify the new ID and record success there. Don't rename
these entities again without keeping this path. Tests: `tests/test_renamed_inbox.py`.
### Compatibiliteit 0.2.35 / firmware 0.2.30 ### Compatibility 0.2.38 / firmware 0.2.32
Alleen firmware (Guition). `on_boot` haalt `LV_OBJ_FLAG_CLICKABLE` van `home_page` en Storage version and tile protocol stay 1. A layout may have an optional object
`tile_scroll`: een druk buiten de tegels heeft geen LVGL-object meer (geen pressed-stijl, `header: {items: [...]}` (six max): `{type: clock|analog|date}` or
geen hertekening, geen events); de randveeg loopt via de touchscreen-triggers en heeft dat `{type: entity, entity, content: state|last_changed, icon: auto|none|<name>, show:
niet nodig, `lv_indev_wait_release` werkt ook zonder actief object. Trace-regels `veeg …` always|active}`. Without `header`, the firmware's prior always-on behavior applies (the clock from
op INFO alleen zolang `edge_swipe.armed()`. Verder ongewijzigd. `show_clock`); an old editor that omits `header` keeps the stored bar. When
saving, `settings.show_clock` follows the clock in the bar, so older firmware only shows the
time if it's in the bar. Besides the tile domains, entities in the bar may
also be `device_tracker`, `zone`, `lock`, `alarm_control_panel`, `counter`, `event`,
`input_datetime`, `input_text`, `water_heater`, and `humidifier`; the inventory
marks those with `tile: false` so the tile picker skips them.
### Compatibiliteit 0.2.34 / firmware 0.2.29 On the wire, right after the layout message, the manager sends `{v:1, op: header, items}`
with, per item, `k` (`clock`, `analog`, `date`, `text`, `ago`), `i` (codepoint),
`t` (text, already formatted — in English since app 0.2.40 — and limited to the glyphs of
`sublabel_big`), `e` (unix time for `ago`), and `c` (accent color). Items with
`show: active` that aren't active are not sent by the manager. Only firmware 0.2.32+
gets this message (`HEADER_MIN_FIRMWARE`, based on the reported screen firmware): older
firmware would refuse `op: header` with "Fout: ongeldig bericht" (that firmware still reports in Dutch). The message is
compared separately; a changed value sends only the bar, not the full layout.
The firmware replies with the same status as a tile status, so the
inbox entity doesn't flip on every value.
Alleen firmware. LVGL 9.5.0's `send_event()` in `lv_indev.c` stuurt het invoerapparaat Firmware: `header_bar.h` (LVGL-free: parsing, "5 min ago", date, distances,
PRESSED, RELEASED, CLICKED, LONG_PRESSED en KEY, maar geen PRESSING; een placement) and `runtime_tiles::draw_header()`. The profile's time label still
`lv_indev_add_event_cb` krijgt dus geen positie-updates tijdens een druk. `cyd::EdgeSwipe` exists as a reference (right margin, draw order) but is hidden in runtime
werkt daarom weer op de native touchpunten uit `on_touch`/`on_update`/`on_release` mode. Values use `sublabel_big`, icons `materialdesign_icons_mini`;
(`configure(width, height, band, travel)`, `begin(x, y, rotation)` met ESPHome's `sublabel_big` gained `'`, `#`, `*`, `=`, `;`, `²`, `³`, `µ`, `–`, and common
rotatiemapping, `update()`, `end()`), de indev-callback is weg. Verder ongewijzigd. accented characters; `tests/test_header_bar.py` keeps `header_bar.GLYPHS` matching that list.
The editor (`barLayout` in `app.js`) and the firmware (`header_bar::gaps/place`) share
the same integer math; the test checks that. `tools/render_topbar.py`
renders the real `draw_header()` on a Mac/Linux machine (ESPHome host + SDL2) to
a PNG, without a screen. No changed preferences or keys.
### Compatibiliteit 0.2.33 / firmware 0.2.28 ### Compatibility 0.2.35 / firmware 0.2.30
Alleen firmware. `TouchGuard::configure(0, ...)` schakelt de verplaatsingsgrens uit Firmware only (Guition). `on_boot` removes `LV_OBJ_FLAG_CLICKABLE` from `home_page` and
(`moved_` wordt dan nooit gezet; LVGL's press-lost bepaalt of een tik doorgaat). Het `tile_scroll`: a press outside the tiles no longer has an LVGL object (no pressed style,
Guition-profiel zet `TOUCH_MOVE_LIMIT_PX` op 0, de CYD houdt 56. Verder ongewijzigd. no redraw, no events); the edge swipe runs via the touchscreen triggers and doesn't need
that, `lv_indev_wait_release` also works without an active object. Trace lines `swipe …`
(`veeg …` before firmware 0.2.34) at INFO only while `edge_swipe.armed()`. Otherwise unchanged.
### Compatibiliteit 0.2.32 / firmware 0.2.27 ### Compatibility 0.2.34 / firmware 0.2.29
Alleen firmware. `cyd::EdgeSwipe` werkt op LVGL-pointercoördinaten: `configure(band, travel)`, Firmware only. LVGL 9.5.0's `send_event()` in `lv_indev.c` sends the input device
`begin(x, y, width)` met `lv_display_get_horizontal_resolution()`, `update()` met de 45°-regel, PRESSED, RELEASED, CLICKED, LONG_PRESSED, and KEY, but no PRESSING; an
plus `inward()`/`sideways()` voor het log. De Guition registreert één `lv_indev_add_event_cb` `lv_indev_add_event_cb` therefore gets no position updates during a press. `cyd::EdgeSwipe`
(PRESSED/PRESSING/RELEASED) in `on_boot`; de touchscreen-triggers voeden alleen nog de therefore works again on the native touch points from `on_touch`/`on_update`/`on_release`
`TouchGuard`. Beide bordprofielen krijgen het script `close_cards` (roept (`configure(width, height, band, travel)`, `begin(x, y, rotation)` with ESPHome's
`runtime_tiles::dismiss()` aan, anders overlays verbergen en `active_entity` wissen); de rotation mapping, `update()`, `end()`); the indev callback is gone. Otherwise unchanged.
kruisjes en achtergrondtikken van de kaarten en `wake_display` gebruiken het. Protocol, opslag,
preferences en sleutels ongewijzigd.
### Compatibiliteit 0.2.31 / firmware 0.2.26 ### Compatibility 0.2.33 / firmware 0.2.28
Opslagversie en tegelprotocol blijven 1. `tiles[].slot` is additief: de absolute plek Firmware only. `TouchGuard::configure(0, ...)` disables the movement limit
van een tegel (pagina × 6 + rij × 2 + kolom, 0–47, `MAX_SLOTS` in `core.py`); een (`moved_` is then never set; LVGL's press-lost decides whether a tap goes through). The
dubbelbrede tegel staat op een even plek en bedekt ook de plek rechts ervan. Lege plekken Guition profile sets `TOUCH_MOVE_LIMIT_PX` to 0, the CYD keeps 56. Otherwise unchanged.
zijn toegestaan. `validate_layout` eist plekken voor alle tegels of voor geen: zonder
plekken (oude editor, oude opslag) krijgt de indeling de plekken van de oude
volgorde-packing (`pack_slots`, hetzelfde als `pack()` in de firmware), zodat er op
het scherm niets verandert; met plekken worden overlap, oneven dubbelbreed en bereik
geweigerd en de tegels op plek gesorteerd. `save()` herhaalt de packing na het
terugzetten van opgeslagen opties (die kunnen een tegel verbreden). `pages` (1–8) is
een optioneel, additief veld: pagina's die de gebruiker leeg wil houden.
Op de draad krijgt het layoutbericht `slots` (één plek per entity, in dezelfde ### Compatibility 0.2.32 / firmware 0.2.27
volgorde) en `pages`. Firmware 0.2.26 (`Model::set_layout` met posities, `place()`
naast het oude `pack()`, `MAX_PAGES` 8) tekent de tegels op die plekken; een wijziging
van alleen plekken (`moved`) herplaatst de pagina's zonder tegelstatussen te wissen.
Oudere firmware negeert beide velden en pakt de entities in volgorde; omdat de
entities op plek gesorteerd zijn is dat de oude weergave zonder gaten, daarom geen
`min_firmware`. De editor meldt het (`has_gaps`/`hasGaps`) bij firmware < 0.2.26. Geen
gewijzigde preferences of sleutels.
### Compatibiliteit 0.2.30 / firmware 0.2.25 Firmware only. `cyd::EdgeSwipe` works on LVGL pointer coordinates: `configure(band, travel)`,
`begin(x, y, width)` with `lv_display_get_horizontal_resolution()`, `update()` with the 45° rule,
plus `inward()`/`sideways()` for the log. The Guition registers a single `lv_indev_add_event_cb`
(PRESSED/PRESSING/RELEASED) in `on_boot`; the touchscreen triggers now only feed the
`TouchGuard`. Both board profiles get the `close_cards` script (calls
`runtime_tiles::dismiss()`, otherwise hiding overlays and clearing `active_entity`); the
cards' close buttons and background taps, and `wake_display`, use it. Protocol, storage,
preferences, and keys unchanged.
Alleen de editor: het statusbolletje in de schermlijst is een eigen `span.dot` ### Compatibility 0.2.31 / firmware 0.2.26
(groen met `.online`). Firmware krijgt alleen een nieuw versienummer; protocol,
opslag, preferences en sleutels ongewijzigd.
### Compatibiliteit 0.2.29 / firmware 0.2.24 Storage version and tile protocol stay 1. `tiles[].slot` is additive: the absolute slot
of a tile (page × 6 + row × 2 + column, 0–47, `MAX_SLOTS` in `core.py`); a
double-width tile sits on an even slot and also covers the slot to its right. Empty slots
are allowed. `validate_layout` requires slots for all tiles or for none: without
slots (old editor, old storage), the layout gets the slots from the old
order-based packing (`pack_slots`, the same as `pack()` in the firmware), so
nothing changes on the screen; with slots, overlap, odd double-width, and out-of-range are
refused, and the tiles are sorted by slot. `save()` repeats the packing after
restoring saved options (which can widen a tile). `pages` (1–8) is
an optional, additive field: pages the user wants to keep empty.
Alleen firmware (Guition). `cyd::EdgeSwipe` (in `cyd_ui.h`, getest in On the wire, the layout message gets `slots` (one slot per entity, in the same
`test_cyd_ui.cpp`) vervangt op de Guition de LVGL-gesture op `home_page`: `begin()` order) and `pages`. Firmware 0.2.26 (`Model::set_layout` with positions, `place()`
in `on_touch` met de LVGL-rotatie, `update()` in `on_update` voor het eerste contact; alongside the old `pack()`, `MAX_PAGES` 8) draws the tiles at those slots; a change
bij een treffer `touch_guard.consume()`, `lv_indev_wait_release()` op alle indevs en to slots only (`moved`) repositions the pages without clearing tile statuses.
`show_tile_page`. Substituties `EDGE_SWIPE_BAND_PX`/`EDGE_SWIPE_TRAVEL_PX`; Older firmware ignores both fields and packs the entities in order; since the
`DISPLAY_W`/`DISPLAY_H` bepalen de rotatiemapping (ESPHome: 90° `x=y, y=W-x-1`, entities are sorted by slot, that's the old gap-free display, hence no
270° `x=H-y-1, y=x`, 180° spiegelt beide). De CYD houdt de LVGL-gesture. De `min_firmware`. The editor reports this (`has_gaps`/`hasGaps`) for firmware < 0.2.26. No
instelling `swipe_pages` blijft de schakelaar; protocol, opslag en sleutels ongewijzigd. changed preferences or keys.
### Compatibiliteit 0.2.28 / firmware 0.2.23 ### Compatibility 0.2.30 / firmware 0.2.25
Alleen firmware. `cyd::TouchGuard` krijgt `configure(move_limit_px, min_press_ms)` Editor only: the status dot in the screen list is its own `span.dot`
(aangeroepen in `on_boot` uit de substituties `TOUCH_MOVE_LIMIT_PX` en (green with `.online`). Firmware only gets a new version number; protocol,
`TOUCH_MIN_PRESS_MS`; zonder aanroep gelden de oude 18 px en 60 ms), volgt alleen storage, preferences, and keys unchanged.
het contact-id waarmee de aanraking begon, meet de verplaatsing als afstand tot een
over vier metingen gesetteld referentiepunt en meldt via `reason()` waarom een tik
is geweigerd; `runtime_tiles::allowed()` logt dat op INFO met tag `touch`.
Protocol, opslag, preferences en sleutels ongewijzigd.
### Compatibiliteit 0.2.27 (firmware 0.2.22 blijft) ### Compatibility 0.2.29 / firmware 0.2.24
Alleen de app. `POST /api/install` (YAML-download) is verwijderd; `POST Firmware only (Guition). `cyd::EdgeSwipe` (in `cyd_ui.h`, tested in
/api/firmware/profiles` schrijft het profiel, vult ontbrekende wifi-sleutels in `test_cyd_ui.cpp`) replaces the LVGL gesture on `home_page` on the Guition: `begin()`
`secrets.yaml` aan (alleen de ontbrekende regels, geverifieerd door het resultaat in `on_touch` with the LVGL rotation, `update()` in `on_update` for the first contact;
opnieuw te parsen; een ongeldig bestand blijft onaangeraakt) en start met `target` on a hit, `touch_guard.consume()`, `lv_indev_wait_release()` on all indevs, and
(een gemelde USB-poort) direct `install`. Poort en bouwslot worden vóór het `show_tile_page`. Substitutions `EDGE_SWIPE_BAND_PX`/`EDGE_SWIPE_TRAVEL_PX`;
schrijven gecontroleerd. Het antwoord bevat `api_key`; de pagina toont hem eenmalig `DISPLAY_W`/`DISPLAY_H` determine the rotation mapping (ESPHome: 90° `x=y, y=W-x-1`,
voor de HA-koppeling. Het jobobject krijgt `stage` (`config`, `compile`, `upload`). 270° `x=H-y-1, y=x`, 180° mirrors both). The CYD keeps the LVGL gesture. The
`profile_meta` levert extra `screen` (profiel gebruikt het bordpakket van dit `swipe_pages` setting remains the toggle; protocol, storage, and keys unchanged.
project) en `api_key`; `/api/inventory` krijgt de additieve lijst `pending` met
zulke profielen zonder gekoppeld scherm. Opslag, protocol, preferences en sleutels
ongewijzigd.
### Compatibiliteit 0.2.26 / firmware 0.2.22 ### Compatibility 0.2.28 / firmware 0.2.23
Opslagversie en tegelprotocol blijven 1. Het layoutbericht krijgt het additieve veld Firmware only. `cyd::TouchGuard` gets `configure(move_limit_px, min_press_ms)`
`keepalive` (seconden, 5–3600): het interval waarin de app de hele indeling herhaalt (called in `on_boot` from the substitutions `TOUCH_MOVE_LIMIT_PX` and
(`KEEPALIVE_SECONDS` in `server.py`, 120 s sinds 0.2.20). De firmware leidt daar zijn `TOUCH_MIN_PRESS_MS`; without the call, the old 18 px and 60 ms apply), tracks only
bewaking van de gegevensstroom uit af (twee rondes plus 60 s) en meldt daarna the contact id the touch started with, measures displacement as distance to a
"ESP Screens niet actief"; "HA niet verbonden" komt nu van ESPHome's eigen reference point settled over four measurements, and reports via `reason()` why a tap
API-verbindingsstatus (`api_is_connected()`), niet meer uit de leeftijd van het laatste was rejected; `runtime_tiles::allowed()` logs that at INFO with tag `touch`.
bericht. Zonder het veld rekent firmware 0.2.22 met 120 s; firmware tot 0.2.21 negeert Protocol, storage, preferences, and keys unchanged.
het veld en houdt zijn vaste 95 s, wat met een keepalive van 120 s de bekende
"HA niet verbonden"-flikkering geeft. Verander `KEEPALIVE_SECONDS` daarom alleen samen
met een firmwareflash van bestaande schermen, of houd hem onder 40 s. Geen gewijzigde
preferences of sleutels.
### Compatibiliteit 0.2.24 / firmware 0.2.20 ### Compatibility 0.2.27 (firmware stays 0.2.22)
Alleen intern: `Tile::forecast` en `Tile::hours` zijn vectoren (voorheen vaste arrays in App only. `POST /api/install` (YAML download) has been removed; `POST
elke tegel); protocol en opslag ongewijzigd. De wizard-YAML krijgt `power_save_mode: none` /api/firmware/profiles` writes the profile, fills in missing Wi-Fi keys in
onder `wifi:`; de gedeelde pakketten bevatten geen wifi-blok, dus bestaande schermen `secrets.yaml` (only the missing lines, verified by re-parsing the result;
veranderen pas als de gebruiker de regel zelf toevoegt. an invalid file is left untouched), and, given `target` (a reported USB port),
immediately starts `install`. Port and build slot are checked before
writing. The response contains `api_key`; the page shows it once
for the HA pairing. The job object gets `stage` (`config`, `compile`, `upload`).
`profile_meta` provides extra `screen` (profile uses this project's board package)
and `api_key`; `/api/inventory` gets the additive list `pending`, with
such profiles that have no paired screen. Storage, protocol, preferences, and keys
unchanged.
### Compatibiliteit 0.2.23 / firmware 0.2.19 ### Compatibility 0.2.26 / firmware 0.2.22
Opslagversie en tegelprotocol blijven 1. `tiles[].options.controls` is additief: Storage version and tile protocol stay 1. The layout message gets the additive field
`none` of een set uit `CONTROLS` in `core.py` per domein (climate `setpoint`/`mode`, `keepalive` (seconds, 5–3600): the interval at which the app repeats the entire layout
(`KEEPALIVE_SECONDS` in `server.py`, 120 s since 0.2.20). The firmware derives its
data-flow monitoring from that (two rounds plus 60 s) and then reports
"ESP Screens not active"; "HA not connected" now comes from ESPHome's own
API connection status (`api_is_connected()`), no longer from the age of the last
message. Without the field, firmware 0.2.22 assumes 120 s; firmware up to 0.2.21 ignores
the field and keeps its fixed 95 s, which, with a 120 s keepalive, causes the known
"HA not connected" flicker. So only change `KEEPALIVE_SECONDS` together
with a firmware flash of existing screens, or keep it under 40 s. No changed
preferences or keys.
### Compatibility 0.2.24 / firmware 0.2.20
Internal only: `Tile::forecast` and `Tile::hours` are vectors (previously fixed arrays in
each tile); protocol and storage unchanged. The wizard YAML gets `power_save_mode: none`
under `wifi:`; the shared packages contain no Wi-Fi block, so existing screens
only change once the user adds the line themselves.
### Compatibility 0.2.23 / firmware 0.2.19
Storage version and tile protocol stay 1. `tiles[].options.controls` is additive:
`none` or a set from `CONTROLS` in `core.py` per domain (climate `setpoint`/`mode`,
switch/light/fan `toggle` plus `brightness`/`speed`, vacuum/cover/timer `buttons`, switch/light/fan `toggle` plus `brightness`/`speed`, vacuum/cover/timer `buttons`,
cover `position`, media_player `volume`/`playback`, number `stepper`/`slider`, cover `position`, media_player `volume`/`playback`, number `stepper`/`slider`,
select `stepper`, scene/script/button `run`). Op de draad stuurt de manager in select `stepper`, scene/script/button `run`). On the wire, the manager sends in
`o.controls` alleen de set die de kaart werkelijk toont: uitsluitend dubbelbreed, `o.controls` only the set the card actually shows: double-width only,
weergave standaard en zonder mini-schuif; zonder keuze de eerste set van het default display, and without a mini-slider; without a choice, the domain's first
domein, bij `none` niets. Statusberichten krijgen de attributen `device_class`, set; with `none`, nothing. Status messages get the attributes `device_class`,
`hvac_action` en `is_volume_muted` (de enige boolean die meegaat). Oudere `hvac_action`, and `is_volume_muted` (the only boolean included). Older
firmware negeert het veld en toont de gewone brede kaart, daarom geen firmware ignores the field and shows the regular wide card, hence no
`min_firmware`; het tegelpaneel meldt vanaf welke firmware het werkt. Een oude `min_firmware`; the tile panel reports from which firmware it works. An old
editor die `controls` weglaat behoudt de opgeslagen keuze (zoals `background`). editor that omits `controls` keeps the stored choice (like `background`).
De firmware bouwt het paneel lui per set (`layout_panel`), stuurt −/+ na 700 ms The firmware builds the panel lazily per set (`layout_panel`), sends −/+ after 700 ms
als één `set_temperature`/`set_value` en laat de lokale waarde staan tot HA hem as a single `set_temperature`/`set_value`, and leaves the local value in place until HA
meldt (of 10 s). De iconfonts krijgen 18 vaste bedieningsglyphs reports it (or 10 s). The icon fonts get 18 fixed control glyphs
(`tile_icons.FIXED`). Geen gewijzigde preferences of sleutels. (`tile_icons.FIXED`). No changed preferences or keys.
Ook additief in dezelfde release: `x.hours` (maximaal acht uren `t`, `c`, `h`, Also additive in the same release: `x.hours` (up to eight hours of `t`, `c`, `h`,
`p`, `r`) en `p`/`r` per dag in `x.days` voor de weerkaart; `x.last` (unix-tijd) `p`, `r`) and `p`/`r` per day in `x.days` for the weather card; `x.last` (unix time)
voor scènes, scripts en knoppen; de attributen `humidity`, `wind_speed`, for scenes, scripts, and buttons; the attributes `humidity`, `wind_speed`,
`wind_speed_unit`, `apparent_temperature`, `fan_modes`, `swing_modes`, `fan_mode` `wind_speed_unit`, `apparent_temperature`, `fan_modes`, `swing_modes`, `fan_mode`,
en `swing_mode`. `supported_features` mag boven de grens van een miljoen. Oudere and `swing_mode`. `supported_features` may exceed the one-million limit. Older
firmware negeert al deze velden. De bezig-status gebruikt LVGL's spinner; beide firmware ignores all these fields. The busy status uses LVGL's spinner; both
bordprofielen bevatten daarvoor een verborgen `spinner` (`busy_spinner_seed`), board profiles include a hidden `spinner` for that (`busy_spinner_seed`),
anders compileert ESPHome `LV_USE_SPINNER` als 0. otherwise ESPHome compiles `LV_USE_SPINNER` as 0.
### Compatibiliteit 0.2.12 / firmware 0.2.14 ### Compatibility 0.2.12 / firmware 0.2.14
Opslagversie en tegelprotocol blijven 1. Additief: `tiles[].options.size` Storage version and tile protocol stay 1. Additive: `tiles[].options.size`
(`single`/`wide`), nieuwe `display`-waarden (`forecast` voor weer, `graph` voor (`single`/`wide`), new `display` values (`forecast` for weather, `graph` for
sensoren, `digital`/`analog` voor `screen.clock`), de ingebouwde entity sensors, `digital`/`analog` for `screen.clock`), the built-in entity
`screen.clock` en de HA-domeinen `sun`, `timer` en `person`. Statusberichten `screen.clock`, and the HA domains `sun`, `timer`, and `person`. Status messages
krijgen een optioneel object `x` met door de manager berekende waarden get an optional object `x` with values computed by the manager
(voorspellingsdagen, zontijden in de HA-tijdzone, timer-eindtijd als epoch). (forecast days, sun times in the HA timezone, timer end time as epoch).
Oudere firmware negeert `size`, nieuwe displays en `x`, maar weigert onbekende Older firmware ignores `size`, new displays, and `x`, but refuses unknown
domeinen in de hele indeling; de manager stuurt zo'n indeling daarom pas na domains across the whole layout; the manager therefore only sends such a layout once
firmware 0.2.14 (`min_firmware`) en bewaart haar ondertussen. Voorspellingen firmware 0.2.14 or newer is detected (`min_firmware`), holding it back in the meantime. Forecasts
komen via `weather.get_forecasts` met `return_response`; zonder antwoord blijft come via `weather.get_forecasts` with `return_response`; without a response,
de weerkaart de gewone kaart. Geen gewijzigde preferences of sleutels. the weather card stays the regular card. No changed preferences or keys.
### Compatibiliteit 0.2.39 / firmware 0.2.33 ### Compatibility 0.2.39 / firmware 0.2.33
Opslagversie en tegelprotocol blijven 1; geen nieuwe velden in `screens.json`, geen Storage version and tile protocol stay 1; no new fields in `screens.json`, no
gewijzigde preferences of sleutels. Alles is additief en per scherm gekoppeld aan de changed preferences or keys. Everything is additive and, per screen, gated on the
gemelde `Schermfirmware`: reported screen firmware:
- **Transport.** Firmware 0.2.33 heeft de API-actie `screen_message` (één string - **Transport.** Firmware 0.2.33 has the API action `screen_message` (one string
`message`, het complete JSON-bericht, maximaal 4096 bytes). De manager roept `message`, the complete JSON message, 4096 bytes max). The manager calls
`esphome.<apparaatnaam>_screen_message` aan zodra het scherm 0.2.33+ meldt `esphome.<device_name>_screen_message` as soon as the screen reports 0.2.33+
(`TRANSPORT_MIN_FIRMWARE` in `core.py`, `Manager.transport`); daaronder blijven de (`TRANSPORT_MIN_FIRMWARE` in `core.py`, `Manager.transport`); below that, the
base64-blokjes in de tekstentiteit `Tegelinstellingen`. De actie publiceert het base64 chunks in the inbox text entity are still used. The action publishes the
resultaat op dezelfde inbox-entiteit, dus de status op de beheerpagina blijft gelijk. result to the same inbox entity, so the status on the management page stays the same.
Het API-frame van ESPHome op de ESP32 is 32 KiB; `receive()` weigert boven 4096. ESPHome's API frame on the ESP32 is 32 KiB; `receive()` refuses anything above 4096.
- **Keepalive.** Het layoutbericht krijgt `rev` (twaalf hex-tekens, `core.revision` - **Keepalive.** The layout message gets `rev` (twelve hex characters, `core.revision`
van het layoutbericht zonder `rev`); oudere firmware negeert het veld. Firmware of the layout message without `rev`); older firmware ignores the field. Firmware
0.2.33 bewaart de revisie en beantwoordt `{v:1, op:"ping", rev, keepalive}` met 0.2.33 stores the revision and replies to `{v:1, op:"ping", rev, keepalive}` with
`Gesynchroniseerd`/`Tegels laden` (revisie gelijk) of `Indeling opnieuw nodig` `Synced`/`Loading tiles` (revision matches) or `Resend needed`
(anders, of na een herstart). Een ping ververst het feed-venster (`last_received`) (otherwise, or after a restart; firmware before 0.2.34 says `Gesynchroniseerd`/`Tegels laden`/`Indeling opnieuw nodig`). A ping refreshes the feed window (`last_received`)
net als een layout. De manager pingt elke `KEEPALIVE_SECONDS` (120 s) en herhaalt just like a layout does. The manager pings every `KEEPALIVE_SECONDS` (120 s) and only repeats
alles pas na `FULL_REPEAT_SECONDS` (3600 s), of direct wanneer de inbox een status everything after `FULL_REPEAT_SECONDS` (3600 s), or immediately when the inbox reports a status
uit `RESEND_STATES` meldt (`Klaar voor tegelconfiguratie`, `Indeling opnieuw from `RESEND_STATES` (`Ready for tile configuration`, `Resend needed`, `Loading tiles`,
nodig`, `Tegels laden`), met een guard van 120 s na de laatste volledige verzending. and the Dutch originals of older firmware), with a 120 s guard after the last full send.
Firmware onder 0.2.33 krijgt elke 120 s de volledige herhaling zoals voorheen. Firmware below 0.2.33 still gets the full repeat every 120 s as before.
Een herhaald, ongewijzigd layoutbericht antwoordt nu `Gesynchroniseerd` in plaats A repeated, unchanged layout message now replies `Synced` instead
van `Indeling ontvangen`; `diagnostics/send_layout.py` accepteert beide. of `Layout received`; `diagnostics/send_layout.py` accepts both, in both languages.
- **Incrementeel.** `HomeAssistant.dirty` verzamelt de gewijzigde entity-id's; - **Incremental.** `HomeAssistant.dirty` collects the changed entity IDs;
`Manager.sync_one(..., dirty=...)` bouwt alleen die tegels en, als een van hen in `Manager.sync_one(..., dirty=...)` builds only those tiles and, if one of them is in
de bovenbalk staat, de balk. `dirty=None` (eerste ronde, nieuw register, offline the top bar, the bar too. `dirty=None` (first round, new registry, was
geweest) bouwt alles en stuurt de verschillen; `force=True` stuurt alles. `sent` offline) builds everything and sends the diffs; `force=True` sends everything. `sent`
bewaart per scherm `{'layout','header','states','rev'}`. `Manager.screens()` stores `{'layout','header','states','rev'}` per screen. `Manager.screens()`
vervangt `inventory()[0]` in de lus en de updater: alleen de ESPHome-schermentiteiten replaces `inventory()[0]` in the loop and the updater: only the ESPHome screen entities
worden gelezen, gecachet op register-identiteit plus de status van die entiteiten. are read, cached on registry identity plus the status of those entities.
`inventory()` (volledig) blijft voor de editor en `save()`. `inventory()` (full) remains for the editor and `save()`.
- **Historie.** `Manager.history_loop` (eigen taak in `main()`) haalt per venster - **History.** `Manager.history_loop` (its own task in `main()`) fetches one
(1/6/24 uur) één `recorder/statistics_during_period` op (`hour` voor 24 uur, `recorder/statistics_during_period` call per window (1/6/24 hours) (`hour` for 24 hours,
`5minute` daaronder, `types: [mean, state]`; `mean`, anders `state`), valt per `5minute` below that, `types: [mean, state]`; `mean`, otherwise `state`), and, per
sensor zonder rijen terug op `GET /history/period`, en markeert gewijzigde sensor with no rows, falls back to `GET /history/period`, marking changed
entiteiten dirty. Het `history`-veld in het statusbericht is ongewijzigd (24 entities dirty. The `history` field in the status message is unchanged (24
waarden, `None` tot de eerste). Een sensortegel gaat de eerste keer zonder values, `None` until the first). A sensor tile goes out the door without
`history` de deur uit en krijgt hem seconden later; firmware toont dan de grafiek. `history` the first time and gets it seconds later; the firmware then shows the graph.
- **Diagnostiek (firmware).** De sensor `Uptime` (seconden, elke 15 s) is weg; - **Diagnostics (firmware).** The `Uptime` sensor (seconds, every 15 s) is gone;
ervoor in de plaats `Opgestart` (`platform: uptime`, `type: timestamp`, device replaced by `Opgestart` (`Last boot` since firmware 0.2.34; `platform: uptime`, `type: timestamp`, device
class timestamp, één waarde per opstart; `time:` is al aanwezig). Bewust een nieuwe class timestamp, one value per boot; `time:` is already present). Deliberately a new
entiteit: het entity-register van Home Assistant houdt bij de oude de eenheid `s` entity: Home Assistant's entity registry keeps the unit `s` for the old
vast en weigert dan een tijdstempel ("has a unit of measurement ... non-numeric one and then refuses a timestamp ("has a unit of measurement ... non-numeric
device class: timestamp", gezien op 2026-09-14). De ESPHome-integratie verwijdert device class: timestamp", seen on 2026-09-14). The ESPHome integration removes
de oude `sensor.<scherm>_uptime` zelf zodra het scherm met de nieuwe firmware the old `sensor.<screen>_uptime` itself as soon as the screen connects with the
verbindt. Verder `debug: update_interval` 300 s en de vier template-numbers op new firmware. Also `debug: update_interval` 300 s, and the four template numbers set to
`update_interval: never`, gepubliceerd vanuit `apply_screen_settings` als de waarde `update_interval: never`, published from `apply_screen_settings` when the value
afwijkt van hun laatste state (dus ook eenmaal na opstarten). differs from their last state (so also once after boot).
-183
View File
@@ -1,183 +0,0 @@
# 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.
## Kleur, wittemperatuur en helderheid
Een RGB-/kleurenlamp met `LONGPRESS: "slider"` opent direct de lampkaart.
Deze bevat maximaal drie horizontale schuiven:
- **Kleur:** een doorlopende regenboog (0–360° hue, 100% verzadiging).
- **Wittemperatuur:** warm naar koel, binnen de minimum-/maximum-Kelvinwaarden
die de lamp in Home Assistant meldt.
- **Helderheid:** 1–100%; aan/uit blijft via de tegel beschikbaar.
De kleur- en witschuif verschijnen alleen als `supported_color_modes` deze
ondersteunt. Een uitsluitend dimbare lamp houdt de gewone helderheidskaart.
Er zijn geen extra tegelinstellingen nodig. Gebruik voor een lamp dezelfde
`ENTITY` en `STATE_ENTITY`. De kleurkaart volgt `hs_color`, `color_temp_kelvin`,
`min_color_temp_kelvin` en `max_color_temp_kelvin` van de bediende entiteit.
Ontbreekt het temperatuurbereik, dan wacht de witschuif op HA-gegevens.
De kaart neemt bij openen de laatst ontvangen lampwaarden over. Slepen toont
lokaal de gekozen waarde; pas loslaten verstuurt één opdracht. Kleur kiest de
kleurmodus, wittemperatuur kiest de witmodus. Helderheid verandert de kleurmodus
niet. De regenboog regelt hue; pastel/verzadiging is geen aparte regelaar.
De hue-lijst wordt via ESPHome `data_template` naar HA gestuurd; zie de
[officiële API-documentatie](https://esphome.io/components/api/#homeassistantaction-action).
De gedeelde implementatie staat in `light_controls.h`. Beide schermprofielen
gebruiken deze; de Guition heeft grotere regelaars, de CYD een compacte kaart.
`diagnostics/run_ui_test.py` controleert ook de schuifevents met vervangen
callbacks: geen echte HA-acties. Voor een veilige 40-secondenvoorbeeldkaart:
```sh
python diagnostics/control_ui.py light_controls_preview --host guition-wallbox.local --name guition-wallbox
```
Gebruik voor CYD de eigen host en naam. Tijdens deze voorbeeldkaart worden
lichtopdrachten onderdrukt en verdwijnt de kaart vanzelf. Test vervolgens de
werkelijke lamp: lang indrukken, kleur kiezen, wit kiezen, helderheid aanpassen,
sluiten en heropenen. Controleer de fysieke lamp en de waarden in HA.
## 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.
De iconfonts bevatten de tegeliconenset uit `screen_manager/app/tile_icons.py`;
kies `TILEn_ICON` daaruit. Een ander icoon voeg je daar toe en draai je
`tools/generate_icons.py`. 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.
+93
View File
@@ -0,0 +1,93 @@
# Test results app 0.2.40 / firmware 0.2.34 (2026-09-15)
ESP Screens in English: the screens, the management page, the Home Assistant entities
and the docs. See CHANGELOG 0.2.40 and RELEASING "Compatibility 0.2.40".
## What changed
- Text only on both boards and in the app: every on-screen label, status reply, log
line, editor string and guide. Labels follow Home Assistant's English names (climate
modes Heat/Cool, actions Heating/Cooling, Away, Returning to dock, weather conditions),
dates use English abbreviations (Mo 14 Sep), the top bar writes 21.5 °C and 1,249 W.
- Entity names in English (Tile settings, Screen firmware, Device name, IP address,
Guition screen type, Last boot, Normal/Standby/Night brightness, Standby after,
Calibrate touch). Home Assistant gives renamed ESPHome entities new entity IDs.
- App: `core.NAME_*` pairs and `RESEND_STATES` accept both languages;
`Manager.follow_renamed_inboxes` moves a screen's layout and update history to its new
inbox ID (`core.inbox_prefix`, `core.device_prefixes`, `Manager.aliases`,
`Updater.current_id`/`renamed`).
- Docs use Home Assistant 2026.9's menu names (Settings → Apps, App store, Settings → Tools);
guides renamed to `docs/TILES.md`, `CALIBRATING.md`, `ACCEPTANCE.md`, `TROUBLESHOOTING.md`.
## Review method
- Token-level diff of every changed Python, JavaScript, C++ and YAML file with string
literals and comments neutralised (YAML compared as parsed trees, lambdas as C++): 33
code changes, all intended (dual matching, singular hour/year, number format, a log
filter). Every changed string that sits in a comparison, key or subscript was checked.
- No Home Assistant identifier (entity ID, `domain.service`, state or attribute name,
`mdi:` icon, event type, YAML key in doc examples) disappeared from any translated string.
UI terms and state labels were checked against the English translations of the running
HA 2026.9.1 (`frontend/get_translations` and the frontend's own `en` files).
- The 0.2.39 test suites were run against the new code: 15 Python and 4 C++ failures,
every one an assertion on Dutch text; nothing behavioural.
- A dictionary sweep of all string literals and every `text:` value in both board
profiles found Dutch labels the translation had missed (page navigation, climate
card, vacuum status, calibration hints, one entity name); fixed.
## Automated
- Python (`.venv-portal`): 139 tests OK, 9 new in `test_renamed_inbox.py` (slugs and
prefixes, discovery in both languages, a reflash moves layout plus update address and
result, lookups and saves through the old ID, inventory, inspector and save endpoints
called with the old ID, restart after a reflash adopts by device
prefix, other screens' orphaned layouts stay put, an existing layout on the new ID is
never overwritten, an update round that renames mid-update records success under the new
ID, a failing nightly round still notifies).
- C++ (clang++ -std=c++17 -Wall -Wextra -Werror -I.): 13/13 PASS.
- `generate_packages.py --check` green.
- ESPHome 2026.6.2 compile SUCCESS: `easy-cyd-device.yaml` (RAM 31.5%, flash 77.3%),
`easy-guition-device.yaml` (RAM 32.5%, flash 18.6%), manual `device.yaml` and
`guition-device.yaml`.
## Hardware and Home Assistant (Home Assistant Yellow, HA 2026.9.1)
- Local add-on 0.2.40 (SMB + Supervisor), GitHub add-on 0.2.39 stopped with its data kept
(Supervisor backup first).
- Guition on firmware 0.2.33 (Dutch entities) with app 0.2.40: discovered by its Dutch
names (firmware, board, node, IP), layout sent, status `Gesynchroniseerd`, top bar in
English number format, update offer 0.2.34 with profile and address.
- USB flash of the CYD with English entities: HA removed `text.cyd_2_8in_display_tegelinstellingen`
and created `text.cyd_2_8in_display_tile_settings` in the same second (also the other
renamed entities); the inbox reported `Ready for tile configuration`.
- USB flash of the Guition to 0.2.34 while app 0.2.40 ran: log
`Screen text.guition_wallbox_tegelinstellingen now reports as text.guition_wallbox_tile_settings; moved its layout`;
all 10 tiles, 2 pages, settings and top bar back on screen within seconds.
- Both boards on 0.2.34 (`device_info` compile times match the builds), status `Synced`.
- Rollback: USB flash of the CYD back to 0.2.33 while app 0.2.40 ran; the app moved the layout
back to `text.cyd_2_8in_display_tegelinstellingen` (status `Gesynchroniseerd`, 8 tiles).
- **Update** button on the Yellow for that 0.2.33 CYD (profile pointed at a local copy of this
commit's package): ESPHome inside the add-on built and uploaded over Wi-Fi (23 min, first build
with a fresh toolchain); the screen came back as `text.cyd_2_8in_display_tile_settings` on 0.2.34,
the round followed it through verify and settle and recorded "Updated to firmware 0.2.34." under
the new ID; layout intact, no update offered afterwards.
- Guition LVGL captures of every card type and detail card (demo layout, direct controls,
weather forecast, sensor graphs, person, timer, sun path, climate, vacuum, media,
cover, fan, number, select, scene, script), the climate mode picker and light controls:
all English, nothing clipped except long weather names in the five-day list (ellipsis, as before).
- Firmware self-tests: CYD 10 page checks and 50 overlay cycles PASS; Guition page checks
PASS with 18 tiles, light sliders PASS.
- Alerts through Home Assistant: `show_alert` with a timeout (event `timeout`), empty
title falls back to "Notification", a new alert replaces the old one (`replaced`),
`dismiss_alert` (`remote`); every ending arrived as `esphome.screen_alert`.
- Settings: `number.guition_wallbox_normal_brightness` 100 → 90 → 100 in HA; the app
stored each value within a second.
- Editor through ingress: screens, top bar sheet (date preview "Tu 15 Sep"), tile sheet,
Firmware & USB, New screen, Alerts cheatsheet (action names, fields, icons), saving a
new title reached the screen and was restored; no console errors.
## Not tested / for the owner
- The physical look of the alert card: the LVGL capture does not include the top layer.
- Touch on the physical screens after the flash (the owner's check).
- The Supervisor reports an add-on as `error` after a stop; 0.2.39 does the same, not changed here.
+184
View File
@@ -0,0 +1,184 @@
# Configuring tiles for your Home Assistant
Edit `device.yaml`, within the existing `substitutions:`. You don't need to
touch the base code for the examples below. Every change requires a rebuild
and reflash. Go through the [installation](../README.md) first.
## Collecting entity IDs
In HA, open **Settings → Tools → States** (**Developer tools → States** on older
HA versions). Find the entity you want and
copy the full ID, for example `light.kitchen`. Check its state and
attributes. The tile's title is free to choose; the entity ID must be exact.
Under **Settings → Tools → Actions**, test the intended action with that entity.
An action can control the real device: choose a suitable test.
See [HA actions](https://www.home-assistant.io/docs/scripts/perform-actions/).
First make a list of position, title, entity ID, tap action, and long-press action.
Use existing HA scripts/scenes for multiple devices or complex logic.
## Positions and limits
| Positions | Intended use in this firmware |
|---|---|
| 1–6 | Main tiles; light, switch, scene, script, fan, cover, and climate |
| 6 | Also the vacuum card; the card is specifically tied to tile 6 |
| 7 and 9 | Extra light tiles with brightness/color control |
| 8 and 10 | Extra status/action tiles; no full number/climate card |
`TILE_COUNT: "1"` through `"10"` determines how many **consecutive** positions are
visible. Up to and including six, Previous/Next stay hidden; from seven on, 1–6 sit
on page one and the rest on page two. Free scrolling has been replaced by fixed
pages. Leave unused positions on the neutral example entities.
Don't change grid dimensions to add tiles.
## Key settings
| Setting (`N` = tile number) | Meaning |
|---|---|
| `TILEN_ENTITY` | Entity the action is performed on |
| `TILEN_STATE_ENTITY` | Entity for status/attributes; usually the same one |
| `TILEN_TITLE` | Short title, ideally about 12 characters max |
| `TILEN_TYPE` | Domain, for example `light`, `scene`, `climate` |
| `TILEN_TAP_ACTION` | `auto`, `toggle`, or `custom` for the examples below |
| `TILEN_TAP_SERVICE` | Explicit HA action when using custom, for example `script.turn_on` |
| `TILEN_LONGPRESS` | `none`, `slider`, or (tile 6 only) `vacuum` |
| `TILEN_LONGPRESS_SLIDER` | `auto`; picks a suitable control based on type |
| `TILEN_LABEL_ON/OFF` | Text for a simple on/off status |
The example config also fills in the remaining fields. Leave those as they are unless
you deliberately use them. For a scene/script, `STATE_ENTITY` can follow a real light or
switch; a scene itself has no reliable on/off status.
## Color, white temperature, and brightness
An RGB/color light with `LONGPRESS: "slider"` opens the light card directly.
It contains up to three horizontal sliders:
- **Color:** a continuous rainbow (0–360° hue, 100% saturation).
- **White temperature:** warm to cool, within the minimum/maximum Kelvin values
the light reports in Home Assistant.
- **Brightness:** 1–100%; on/off stays available via the tile.
The color and white sliders only appear if `supported_color_modes` supports
them. A dimmable-only light keeps the regular brightness card.
No extra tile settings are needed. For a light, use the same
`ENTITY` and `STATE_ENTITY`. The color card follows `hs_color`, `color_temp_kelvin`,
`min_color_temp_kelvin`, and `max_color_temp_kelvin` of the controlled entity.
If the temperature range is missing, the white slider waits for HA data.
When opened, the card takes over the last received light values. Dragging shows
the chosen value locally; only releasing sends a single command. Color selects the
color mode, white temperature selects the white mode. Brightness doesn't change the
color mode. The rainbow controls hue; pastel/saturation isn't a separate control.
The hue list is sent to HA via ESPHome `data_template`; see the
[official API documentation](https://esphome.io/components/api/#homeassistantaction-action).
The shared implementation lives in `light_controls.h`. Both screen profiles
use it; the Guition has larger controls, the CYD a compact card.
`diagnostics/run_ui_test.py` also checks the slider events with swapped-in
callbacks: no real HA actions. For a safe 40-second preview card:
```sh
python diagnostics/control_ui.py light_controls_preview --host guition-wallbox.local --name guition-wallbox
```
For the CYD, use its own host and name. During this preview card,
light commands are suppressed and the card disappears on its own. Then test the
actual light: long press, choose a color, choose white, adjust brightness,
close, and reopen. Check the physical light and the values in HA.
## Examples
Change these keys in the existing map; don't paste `substitutions:` a second time.
Dimmable light at position 1 (tap toggles, long press opens the control):
```yaml
TILE1_ENTITY: "light.kitchen"
TILE1_STATE_ENTITY: "light.kitchen"
TILE1_TITLE: "Kitchen"
TILE1_TYPE: "light"
TILE1_TAP_ACTION: "auto"
TILE1_LONGPRESS: "slider"
```
A non-dimmable light/switch gets `LONGPRESS: "none"`. Color control
requires that the light supports the relevant color mode.
Scene at position 3:
```yaml
TILE3_ENTITY: "scene.evening"
TILE3_STATE_ENTITY: "light.kitchen"
TILE3_TITLE: "Evening"
TILE3_TYPE: "scene"
TILE3_TAP_ACTION: "auto"
TILE3_LONGPRESS: "none"
```
Script at position 4:
```yaml
TILE4_ENTITY: "script.all_off"
TILE4_STATE_ENTITY: "script.all_off"
TILE4_TITLE: "All off"
TILE4_TYPE: "script"
TILE4_TAP_ACTION: "auto"
TILE4_LONGPRESS: "none"
```
AC at position 5 (long press opens climate):
```yaml
TILE5_ENTITY: "climate.living_room"
TILE5_STATE_ENTITY: "climate.living_room"
TILE5_TITLE: "AC"
TILE5_TYPE: "climate"
TILE5_TAP_ACTION: "toggle"
TILE5_TAP_SERVICE: "climate.toggle"
TILE5_LONGPRESS: "slider"
```
Check `min_temp`, `max_temp`, `target_temp_step`, `hvac_modes`, and the
supported fan/swing modes in HA. This card is primarily for a **single
setpoint**, not a thermostat that only uses a temperature range.
Test your integration's modes individually; a different AC unit
may have different capabilities.
Robot vacuum at position 6 (tap starts; long press opens the card):
```yaml
TILE6_ENTITY: "vacuum.robot"
TILE6_STATE_ENTITY: "vacuum.robot"
TILE6_TITLE: "Vacuum"
TILE6_TYPE: "vacuum"
TILE6_TAP_ACTION: "custom"
TILE6_TAP_SERVICE: "vacuum.start"
TILE6_LONGPRESS: "vacuum"
```
The vacuum card uses start/pause/stop/return/locate and the
speed names `quiet`, `balanced`, `turbo`, `max`. Compare these with the
supported actions and `fan_speed_list` in HA. The speed buttons need to be
adjusted in the base code if your integration expects different names.
This is not a universal card for every robot model.
## Enabling and checking actions
After calibration and HA pairing, set `DIRECT_ACTIONS: "true"`. In the
ESPHome integration, grant permission for Home Assistant actions. Don't use an
HA automation that also runs the same screen action again separately; that can
cause a double toggle. With `DIRECT_ACTIONS: "false"`, the action sensor can
still publish events for your own automations.
Flash `device.yaml` and test one tile at a time: correct status, correct physical
action, feedback from HA, and long press. Also check that external
changes in HA appear on the screen. Then run the
[acceptance test](ACCEPTANCE.md).
The icon fonts contain the tile icon set from `screen_manager/app/tile_icons.py`;
pick `TILEn_ICON` from there. To add a different icon, add it there and run
`tools/generate_icons.py`. The bundle includes the fonts locally. The older, more extensive
[TILE_CONFIGURATION.md](../TILE_CONFIGURATION.md) also describes the removed
scroll profile as a historical record; don't use it as hardware/layout instructions for this board.
+32
View File
@@ -0,0 +1,32 @@
# Troubleshooting
| Symptom | Check first |
|---|---|
| No USB port | Data cable, a different USB port, board power; compare `python -m serial.tools.list_ports` before/after connecting. Install the USB chip vendor's driver if needed. |
| Permission denied on Linux | Access to the serial device group (often `dialout`); log in again after a group change. |
| Port busy | Stop ESPHome logs, the IDE monitor, and other serial readers. One reader at a time. |
| Upload keeps connecting | Check the correct board/port. Hold BOOT while connecting and release afterward if the board requires it. |
| Build fails | Python 3.11–3.14 and ESPHome 2026.6.2; the full unpacked folder, local fonts/components present, internet, and disk space. Save the first real error from the build log. |
| White/black or wrongly rotated image | Check the ILI9341 + XPT2046 variant. Don't blindly copy the pins, rotation, or SPI settings from a different CYD model. |
| No measurement screen | The upload must be complete, not just the build. Flash with `-s CALIBRATION_ON_BOOT true`. Look for `USB calibration ready` in the USB logs; a normal boot shows no crosshairs. |
| No measurements | Five crosshairs visible, other log reader closed, correct port. Press Enter in the wizard first, then tap the requested point three times, releasing in between. |
| Tap jumps to a different spot | Check the independent `verify`, your own `calibration.yaml`, and a fixed orientation. Don't use the same taps to both fit and verify. |
| Fit reports spread/error | Measure again following the prompts. Don't fix unstable ADC readings by raising the tolerance; check power, cable, and touch hardware. |
| Duplicate HA action | Check both the touch logs and HA automations. Don't use direct actions together with an automation on the action sensor for the same operation. |
| Wi-Fi doesn't connect | 2.4GHz, correct local secrets, network range. USB calibration doesn't need HA. |
| HA doesn't see the board | Manually use the IP from the logs; port 6053 reachable, correct encryption key, no guest network isolation. |
| Status works but action doesn't | Check the HA option for allowed device actions, `DIRECT_ACTIONS: "true"`, the real entity ID, and a supported action. |
| Vacuum/climate partly usable | Supported modes/attributes differ per integration; see TILES.md and test the same action in HA first. |
| Wrong pagination | `TILE_COUNT` is 1–10; hidden buttons up to six, two pages from seven on. Reflash after changing it. |
| Freeze/reset | Keep the USB log, check the reset reason/power, and run the render test. Note the action and time. |
For a USB log (stop other readers):
```sh
python diagnostics/capture_serial.py --port <USB_PORT> --seconds 120
```
The existing `CYD_STABILITY.md` and `TEST_RESULTS.md` describe earlier
investigation on one specific board. Their measurements are not calibration for
a new board. Start at [README.md](../README.md), keep a working
local configuration, and change one cause at a time.
+285 -286
View File
File diff suppressed because it is too large Load Diff
+64 -64
View File
@@ -1,20 +1,20 @@
# Kopieer via: python tools/new_device.py --board guition --name display-keuken # Copy via: python tools/new_device.py --board guition --name display-kitchen
# Bewerk alleen dit lokale profiel voor jouw scherm. # Only edit this local profile for your screen.
packages: packages:
display: !include guition-4848s040.yaml display: !include guition-4848s040.yaml
substitutions: substitutions:
DEVICE_NAME: "guition-new" DEVICE_NAME: "guition-new"
DEVICE_FRIENDLY_NAME: "Mijn Guition" DEVICE_FRIENDLY_NAME: "My Guition"
ROOM_NAME: "Woonkamer" ROOM_NAME: "Living room"
TILE_COUNT: "6" TILE_COUNT: "6"
DIRECT_ACTIONS: "false" # Pas na koppelen/testen op true zetten. DIRECT_ACTIONS: "false" # Set to true only after pairing/testing.
AUTO_DIM_TIMEOUT: "600" AUTO_DIM_TIMEOUT: "600"
CALIBRATION_ON_BOOT: "false" CALIBRATION_ON_BOOT: "false"
# Tegel 1 # Tile 1
TILE1_ENTITY: "light.voorbeeld_lamp" TILE1_ENTITY: "light.example_lamp"
TILE1_STATE_ENTITY: "light.voorbeeld_lamp" TILE1_STATE_ENTITY: "light.example_lamp"
TILE1_TITLE: "Lamp" TILE1_TITLE: "Lamp"
TILE1_TYPE: "light" TILE1_TYPE: "light"
TILE1_TAP_ACTION: "auto" TILE1_TAP_ACTION: "auto"
@@ -28,14 +28,14 @@ substitutions:
TILE1_LONGPRESS_ACTION_TYPE: "" TILE1_LONGPRESS_ACTION_TYPE: ""
TILE1_LONGPRESS_ACTION_SERVICE: "" TILE1_LONGPRESS_ACTION_SERVICE: ""
TILE1_VALUE_MODE: "auto" TILE1_VALUE_MODE: "auto"
TILE1_LABEL_ON: "Aan" TILE1_LABEL_ON: "On"
TILE1_LABEL_OFF: "Uit" TILE1_LABEL_OFF: "Off"
TILE1_ACTIVE_STATE: "" TILE1_ACTIVE_STATE: ""
# Tegel 2 # Tile 2
TILE2_ENTITY: "switch.voorbeeld_stekker" TILE2_ENTITY: "switch.example_plug"
TILE2_STATE_ENTITY: "switch.voorbeeld_stekker" TILE2_STATE_ENTITY: "switch.example_plug"
TILE2_TITLE: "Stekker" TILE2_TITLE: "Plug"
TILE2_TYPE: "switch" TILE2_TYPE: "switch"
TILE2_TAP_ACTION: "auto" TILE2_TAP_ACTION: "auto"
TILE2_TAP_SERVICE: "" TILE2_TAP_SERVICE: ""
@@ -48,14 +48,14 @@ substitutions:
TILE2_LONGPRESS_ACTION_TYPE: "" TILE2_LONGPRESS_ACTION_TYPE: ""
TILE2_LONGPRESS_ACTION_SERVICE: "" TILE2_LONGPRESS_ACTION_SERVICE: ""
TILE2_VALUE_MODE: "auto" TILE2_VALUE_MODE: "auto"
TILE2_LABEL_ON: "Aan" TILE2_LABEL_ON: "On"
TILE2_LABEL_OFF: "Uit" TILE2_LABEL_OFF: "Off"
TILE2_ACTIVE_STATE: "" TILE2_ACTIVE_STATE: ""
# Tegel 3 # Tile 3
TILE3_ENTITY: "scene.voorbeeld_avond" TILE3_ENTITY: "scene.example_evening"
TILE3_STATE_ENTITY: "scene.voorbeeld_avond" TILE3_STATE_ENTITY: "scene.example_evening"
TILE3_TITLE: "Avond" TILE3_TITLE: "Evening"
TILE3_TYPE: "scene" TILE3_TYPE: "scene"
TILE3_TAP_ACTION: "auto" TILE3_TAP_ACTION: "auto"
TILE3_TAP_SERVICE: "" TILE3_TAP_SERVICE: ""
@@ -68,14 +68,14 @@ substitutions:
TILE3_LONGPRESS_ACTION_TYPE: "" TILE3_LONGPRESS_ACTION_TYPE: ""
TILE3_LONGPRESS_ACTION_SERVICE: "" TILE3_LONGPRESS_ACTION_SERVICE: ""
TILE3_VALUE_MODE: "auto" TILE3_VALUE_MODE: "auto"
TILE3_LABEL_ON: "Aan" TILE3_LABEL_ON: "On"
TILE3_LABEL_OFF: "Uit" TILE3_LABEL_OFF: "Off"
TILE3_ACTIVE_STATE: "" TILE3_ACTIVE_STATE: ""
# Tegel 4 # Tile 4
TILE4_ENTITY: "script.voorbeeld_uit" TILE4_ENTITY: "script.example_all_off"
TILE4_STATE_ENTITY: "script.voorbeeld_uit" TILE4_STATE_ENTITY: "script.example_all_off"
TILE4_TITLE: "Alles uit" TILE4_TITLE: "All off"
TILE4_TYPE: "script" TILE4_TYPE: "script"
TILE4_TAP_ACTION: "auto" TILE4_TAP_ACTION: "auto"
TILE4_TAP_SERVICE: "" TILE4_TAP_SERVICE: ""
@@ -88,14 +88,14 @@ substitutions:
TILE4_LONGPRESS_ACTION_TYPE: "" TILE4_LONGPRESS_ACTION_TYPE: ""
TILE4_LONGPRESS_ACTION_SERVICE: "" TILE4_LONGPRESS_ACTION_SERVICE: ""
TILE4_VALUE_MODE: "auto" TILE4_VALUE_MODE: "auto"
TILE4_LABEL_ON: "Aan" TILE4_LABEL_ON: "On"
TILE4_LABEL_OFF: "Uit" TILE4_LABEL_OFF: "Off"
TILE4_ACTIVE_STATE: "" TILE4_ACTIVE_STATE: ""
# Tegel 5 # Tile 5
TILE5_ENTITY: "climate.voorbeeld_airco" TILE5_ENTITY: "climate.example_ac"
TILE5_STATE_ENTITY: "climate.voorbeeld_airco" TILE5_STATE_ENTITY: "climate.example_ac"
TILE5_TITLE: "Airco" TILE5_TITLE: "AC"
TILE5_TYPE: "climate" TILE5_TYPE: "climate"
TILE5_TAP_ACTION: "toggle" TILE5_TAP_ACTION: "toggle"
TILE5_TAP_SERVICE: "climate.toggle" TILE5_TAP_SERVICE: "climate.toggle"
@@ -108,14 +108,14 @@ substitutions:
TILE5_LONGPRESS_ACTION_TYPE: "" TILE5_LONGPRESS_ACTION_TYPE: ""
TILE5_LONGPRESS_ACTION_SERVICE: "" TILE5_LONGPRESS_ACTION_SERVICE: ""
TILE5_VALUE_MODE: "auto" TILE5_VALUE_MODE: "auto"
TILE5_LABEL_ON: "Aan" TILE5_LABEL_ON: "On"
TILE5_LABEL_OFF: "Uit" TILE5_LABEL_OFF: "Off"
TILE5_ACTIVE_STATE: "" TILE5_ACTIVE_STATE: ""
# Tegel 6 # Tile 6
TILE6_ENTITY: "vacuum.voorbeeld_robot" TILE6_ENTITY: "vacuum.example_robot"
TILE6_STATE_ENTITY: "vacuum.voorbeeld_robot" TILE6_STATE_ENTITY: "vacuum.example_robot"
TILE6_TITLE: "Stofzuiger" TILE6_TITLE: "Vacuum"
TILE6_TYPE: "vacuum" TILE6_TYPE: "vacuum"
TILE6_TAP_ACTION: "custom" TILE6_TAP_ACTION: "custom"
TILE6_TAP_SERVICE: "vacuum.start" TILE6_TAP_SERVICE: "vacuum.start"
@@ -128,14 +128,14 @@ substitutions:
TILE6_LONGPRESS_ACTION_TYPE: "" TILE6_LONGPRESS_ACTION_TYPE: ""
TILE6_LONGPRESS_ACTION_SERVICE: "" TILE6_LONGPRESS_ACTION_SERVICE: ""
TILE6_VALUE_MODE: "auto" TILE6_VALUE_MODE: "auto"
TILE6_LABEL_ON: "Aan" TILE6_LABEL_ON: "On"
TILE6_LABEL_OFF: "Uit" TILE6_LABEL_OFF: "Off"
TILE6_ACTIVE_STATE: "" TILE6_ACTIVE_STATE: ""
# Tegel 7 (verborgen zolang TILE_COUNT lager is) # Tile 7 (hidden while TILE_COUNT is lower)
TILE7_ENTITY: "light.ongebruikt_7" TILE7_ENTITY: "light.unused_7"
TILE7_STATE_ENTITY: "light.ongebruikt_7" TILE7_STATE_ENTITY: "light.unused_7"
TILE7_TITLE: "Tegel 7" TILE7_TITLE: "Tile 7"
TILE7_TYPE: "light" TILE7_TYPE: "light"
TILE7_TAP_ACTION: "auto" TILE7_TAP_ACTION: "auto"
TILE7_TAP_SERVICE: "" TILE7_TAP_SERVICE: ""
@@ -148,14 +148,14 @@ substitutions:
TILE7_LONGPRESS_ACTION_TYPE: "" TILE7_LONGPRESS_ACTION_TYPE: ""
TILE7_LONGPRESS_ACTION_SERVICE: "" TILE7_LONGPRESS_ACTION_SERVICE: ""
TILE7_VALUE_MODE: "auto" TILE7_VALUE_MODE: "auto"
TILE7_LABEL_ON: "Aan" TILE7_LABEL_ON: "On"
TILE7_LABEL_OFF: "Uit" TILE7_LABEL_OFF: "Off"
TILE7_ACTIVE_STATE: "" TILE7_ACTIVE_STATE: ""
# Tegel 8 (verborgen zolang TILE_COUNT lager is) # Tile 8 (hidden while TILE_COUNT is lower)
TILE8_ENTITY: "light.ongebruikt_8" TILE8_ENTITY: "light.unused_8"
TILE8_STATE_ENTITY: "light.ongebruikt_8" TILE8_STATE_ENTITY: "light.unused_8"
TILE8_TITLE: "Tegel 8" TILE8_TITLE: "Tile 8"
TILE8_TYPE: "light" TILE8_TYPE: "light"
TILE8_TAP_ACTION: "auto" TILE8_TAP_ACTION: "auto"
TILE8_TAP_SERVICE: "" TILE8_TAP_SERVICE: ""
@@ -168,14 +168,14 @@ substitutions:
TILE8_LONGPRESS_ACTION_TYPE: "" TILE8_LONGPRESS_ACTION_TYPE: ""
TILE8_LONGPRESS_ACTION_SERVICE: "" TILE8_LONGPRESS_ACTION_SERVICE: ""
TILE8_VALUE_MODE: "auto" TILE8_VALUE_MODE: "auto"
TILE8_LABEL_ON: "Aan" TILE8_LABEL_ON: "On"
TILE8_LABEL_OFF: "Uit" TILE8_LABEL_OFF: "Off"
TILE8_ACTIVE_STATE: "" TILE8_ACTIVE_STATE: ""
# Tegel 9 (verborgen zolang TILE_COUNT lager is) # Tile 9 (hidden while TILE_COUNT is lower)
TILE9_ENTITY: "light.ongebruikt_9" TILE9_ENTITY: "light.unused_9"
TILE9_STATE_ENTITY: "light.ongebruikt_9" TILE9_STATE_ENTITY: "light.unused_9"
TILE9_TITLE: "Tegel 9" TILE9_TITLE: "Tile 9"
TILE9_TYPE: "light" TILE9_TYPE: "light"
TILE9_TAP_ACTION: "auto" TILE9_TAP_ACTION: "auto"
TILE9_TAP_SERVICE: "" TILE9_TAP_SERVICE: ""
@@ -188,14 +188,14 @@ substitutions:
TILE9_LONGPRESS_ACTION_TYPE: "" TILE9_LONGPRESS_ACTION_TYPE: ""
TILE9_LONGPRESS_ACTION_SERVICE: "" TILE9_LONGPRESS_ACTION_SERVICE: ""
TILE9_VALUE_MODE: "auto" TILE9_VALUE_MODE: "auto"
TILE9_LABEL_ON: "Aan" TILE9_LABEL_ON: "On"
TILE9_LABEL_OFF: "Uit" TILE9_LABEL_OFF: "Off"
TILE9_ACTIVE_STATE: "" TILE9_ACTIVE_STATE: ""
# Tegel 10 (verborgen zolang TILE_COUNT lager is) # Tile 10 (hidden while TILE_COUNT is lower)
TILE10_ENTITY: "light.ongebruikt_10" TILE10_ENTITY: "light.unused_10"
TILE10_STATE_ENTITY: "light.ongebruikt_10" TILE10_STATE_ENTITY: "light.unused_10"
TILE10_TITLE: "Tegel 10" TILE10_TITLE: "Tile 10"
TILE10_TYPE: "light" TILE10_TYPE: "light"
TILE10_TAP_ACTION: "auto" TILE10_TAP_ACTION: "auto"
TILE10_TAP_SERVICE: "" TILE10_TAP_SERVICE: ""
@@ -208,6 +208,6 @@ substitutions:
TILE10_LONGPRESS_ACTION_TYPE: "" TILE10_LONGPRESS_ACTION_TYPE: ""
TILE10_LONGPRESS_ACTION_SERVICE: "" TILE10_LONGPRESS_ACTION_SERVICE: ""
TILE10_VALUE_MODE: "auto" TILE10_VALUE_MODE: "auto"
TILE10_LABEL_ON: "Aan" TILE10_LABEL_ON: "On"
TILE10_LABEL_OFF: "Uit" TILE10_LABEL_OFF: "Off"
TILE10_ACTIVE_STATE: "" TILE10_ACTIVE_STATE: ""
+309 -311
View File
File diff suppressed because it is too large Load Diff
-5995
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
# Handmatig alternatief: voeg dit blok toe aan de ESPHome-wizardconfiguratie. # Manual alternative: add this block to the ESPHome wizard configuration.
# Verwijder het oude esp32:-blok; behoud je eigen esphome:, api:, ota: en wifi:. # Remove the old esp32: block; keep your own esphome:, api:, ota: and wifi:.
# De makkelijke route genereert een compleet eigen bestand: ESP Screen Manager. # The easy route generates a complete file of its own: ESP Screen Manager.
packages: packages:
scherm: screen:
url: https://github.com/MaxGramser/homeassistant_espscreen url: https://github.com/MaxGramser/homeassistant_espscreen
ref: main ref: main
files: [packages/cyd.yaml] files: [packages/cyd.yaml]
+4 -4
View File
@@ -1,8 +1,8 @@
# Handmatig alternatief: voeg dit blok toe aan de ESPHome-wizardconfiguratie. # Manual alternative: add this block to the ESPHome wizard configuration.
# Verwijder het oude esp32:-blok; behoud je eigen esphome:, api:, ota: en wifi:. # Remove the old esp32: block; keep your own esphome:, api:, ota: and wifi:.
# De makkelijke route genereert een compleet eigen bestand: ESP Screen Manager. # The easy route generates a complete file of its own: ESP Screen Manager.
packages: packages:
scherm: screen:
url: https://github.com/MaxGramser/homeassistant_espscreen url: https://github.com/MaxGramser/homeassistant_espscreen
ref: main ref: main
files: [packages/guition.yaml] files: [packages/guition.yaml]
+309 -311
View File
File diff suppressed because it is too large Load Diff
+286 -287
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,3 +1,14 @@
## 0.2.40 (firmware 0.2.34)
ESP Screens is now in English: the screens, the management page, the Home Assistant entities and the documentation.
- **English everywhere.** Every label on both boards (tiles, detail cards, the climate and vacuum cards, page navigation, calibration), the ESP Screens editor, the Alerts cheatsheet, error messages and logs, and the README and guides. The top bar writes numbers the English way (21.5 °C, 1,249 W). State labels follow Home Assistant's own English names, for example Heat and Cool for climate modes, Heating and Cooling for what the device is doing, Away, Returning to dock, and weather conditions such as Lightning, rainy. Dates use English abbreviations (Mo 14 Sep).
- **English entity names.** Firmware 0.2.34 names its entities Tile settings, Screen firmware, Device name, IP address, Guition screen type, Last boot, Normal brightness, Standby brightness, Night brightness, Standby after and Calibrate touch. Home Assistant registers a renamed ESPHome entity under a new entity id and removes the old one, so `text.kitchen_screen_tegelinstellingen` becomes `text.kitchen_screen_tile_settings`. Dashboards or automations that use one of these diagnostic entities need the new id.
- **Your layouts move along.** ESP Screen Manager recognises a screen that comes back under a new inbox id and moves its tiles, settings, top bar and update history to it, also when the app restarts in between. The **Update** button and the nightly round follow the screen through the rename and report the update as successful.
- **Older firmware keeps working.** Screens on firmware 0.2.33 or older still report the Dutch entity names and status messages; the app recognises both until they are updated.
- Docs use Home Assistant's current menu names (**Settings → Apps**, **App store**, **Settings → Tools**), and the guides are now `docs/TILES.md`, `docs/CALIBRATING.md`, `docs/ACCEPTANCE.md` and `docs/TROUBLESHOOTING.md`.
- Firmware 0.2.34 for both boards; the app offers the update. Besides the English text and entity names it changes nothing on the screen.
## 0.2.39 (firmware 0.2.33) ## 0.2.39 (firmware 0.2.33)
Zuiniger in dagelijks gebruik, ook met tientallen schermen. Niets verandert aan wat je ziet of instelt. Zuiniger in dagelijks gebruik, ook met tientallen schermen. Niets verandert aan wat je ziet of instelt.
+19 -19
View File
@@ -1,23 +1,23 @@
# Installatie en dagelijks gebruik # Installation and everyday use
Zie [de volledige 0.2.0-handleiding](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/WHATS_NEW_020.md). See [the full installation guide](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md).
Open **Nieuw scherm** om een scherm te installeren: sluit het via USB aan op de Open **New screen** to install a screen: connect it via USB to the
Home Assistant-machine, kies bord, naam en USB-poort en klik **Installeren**. Home Assistant machine, choose the board, name and USB port, then click **Install**.
Het profiel met unieke sleutels komt in de ESPHome-map van de HA-configuratie, The profile with unique keys goes into the ESPHome folder of the HA configuration,
ontbrekende wifi gaat in `secrets.yaml` (bestaande secrets blijven staan), en de any missing Wi-Fi goes into `secrets.yaml` (existing secrets are left untouched), and the
build en de flash lopen in hetzelfde venster; daarna toont het de API-sleutel build and flash run in the same window; it then shows the API key
voor de koppeling. De koppeling zelf doe je in Home Assistant onder **Instellingen → Apparaten & diensten** (knop in het venster en op de kaart *nog niet in Home Assistant* onder Mijn schermen). Elk scherm heeft zijn eigen profiel. **Firmware & USB** is for pairing. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Firmware & USB** is
voor bestaande profielen: controleren, bouwen of opnieuw installeren via USB of for existing profiles: check, build or reinstall via USB or
het IP-adres (OTA). Bestaande ESPHome-profielen in de HA-configmap worden the IP address (OTA). Existing ESPHome profiles in the HA config folder are
automatisch gevonden. found automatically.
Bovenaan de editor stel je per scherm de **Bovenbalk** in: links de naam, rechts At the top of the editor you set the **Top bar** for each screen: the name on the left, up
tot zes onderdelen (tijd, analoge klok, datum of een entiteit met icoon, zoals to six items on the right (time, analog clock, date, or an entity with an icon, such as
temperatuur, een deur, het alarm of "laatst gewijzigd"). Sleep om te ordenen, tik temperature, a door, the alarm, or "last changed"). Drag to reorder, tap
om in te stellen; firmware 0.2.32 of nieuwer tekent ze. to configure; firmware 0.2.32 or newer renders them.
Na koppeling via de HA ESPHome-integratie kies je in ESP Screens de tegels. After pairing via the HA ESPHome integration, choose the tiles in ESP Screens.
Onder **Tegelinstellingen** staan klikgedrag, grotere waarden en mini-schuiven. **Tile settings** has click behavior, larger values and mini-sliders.
Onder **Scherminstellingen** staan helderheid en standby. **Inspector** helpt bij **Screen settings** has brightness and standby. **Inspector** helps with
ontbrekende attributen of een offline scherm. Nieuwe kaarten vereisen firmware 0.2.0. missing attributes or an offline screen. New cards require firmware 0.2.0.
+5 -5
View File
@@ -1,8 +1,8 @@
# ESP Screen Manager # ESP Screen Manager
Zoek je Home Assistant-entiteiten, orden maximaal twintig tegels en stuur wijzigingen Find your Home Assistant entities, arrange up to twenty tiles, and push changes
naar een CYD- of Guition-scherm zonder opnieuw flashen. Met installatiehulp, to a CYD or Guition screen without reflashing. Includes installation help,
unieke apparaat-YAML en ondersteuning voor meerdere schermen. a unique per-device YAML, and support for multiple screens.
Open de webinterface na installatie. Zie **Documentatie** voor de eerste flash. Open the web interface after installation. See **Documentation** for the first flash.
De app gebruikt Home Assistant Ingress en heeft geen eigen login of token nodig. The app uses Home Assistant Ingress and needs no login or token of its own.
+124 -89
View File
@@ -11,31 +11,31 @@ import tile_icons
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split()) DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split())
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them. # Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
BUILTIN = {'screen.clock': 'Klok'} BUILTIN = {'screen.clock': 'Clock'}
NEW_DOMAINS = frozenset('sun timer person screen'.split()) NEW_DOMAINS = frozenset('sun timer person screen'.split())
WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'] WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'} REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer. # Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.33' FIRMWARE_VERSION = '0.2.34'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split()) ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind. # Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted']) BOOL_ATTRS = frozenset(['is_volume_muted'])
TILE_BACKGROUNDS = { TILE_BACKGROUNDS = {
'auto': {'label': 'Standaard', 'color': None}, 'auto': {'label': 'Default', 'color': None},
# No card behind the tile: contents keep their size and place on the screen background. # No card behind the tile: contents keep their size and place on the screen background.
'none': {'label': 'Geen', 'color': None}, 'none': {'label': 'None', 'color': None},
'red': {'label': 'Rood', 'color': '#FADADD'}, 'red': {'label': 'Red', 'color': '#FADADD'},
'orange': {'label': 'Oranje', 'color': '#FFE1C6'}, 'orange': {'label': 'Orange', 'color': '#FFE1C6'},
'yellow': {'label': 'Geel', 'color': '#FFF0C2'}, 'yellow': {'label': 'Yellow', 'color': '#FFF0C2'},
'green': {'label': 'Groen', 'color': '#D9EEDC'}, 'green': {'label': 'Green', 'color': '#D9EEDC'},
'mint': {'label': 'Mint', 'color': '#D5F0EA'}, 'mint': {'label': 'Mint', 'color': '#D5F0EA'},
'blue': {'label': 'Blauw', 'color': '#D9EAFB'}, 'blue': {'label': 'Blue', 'color': '#D9EAFB'},
'purple': {'label': 'Paars', 'color': '#E9DDF5'}, 'purple': {'label': 'Purple', 'color': '#E9DDF5'},
'pink': {'label': 'Roze', 'color': '#F7DDEC'}, 'pink': {'label': 'Pink', 'color': '#F7DDEC'},
'gray': {'label': 'Grijs', 'color': '#E5E7EB'}, 'gray': {'label': 'Gray', 'color': '#E5E7EB'},
} }
# Display modes per domain; everything else offers standard and watch (large value). # Display modes per domain; everything else offers standard and watch (large value).
@@ -77,28 +77,28 @@ def has_gaps(tiles):
# Home Assistant's own entity rows. The first choice is what a wide card shows # Home Assistant's own entity rows. The first choice is what a wide card shows
# when the tile has no explicit choice; 'none' keeps the plain card. # when the tile has no explicit choice; 'none' keeps the plain card.
CONTROLS = { CONTROLS = {
'climate': (('setpoint', 'Temperatuur − / +'), ('mode', 'Uit, verwarmen, koelen')), 'climate': (('setpoint', 'Temperature − / +'), ('mode', 'Off, heat, cool')),
'switch': (('toggle', 'Aan/uit-schakelaar'),), 'switch': (('toggle', 'On/off switch'),),
'input_boolean': (('toggle', 'Aan/uit-schakelaar'),), 'input_boolean': (('toggle', 'On/off switch'),),
'light': (('toggle', 'Aan/uit-schakelaar'), ('brightness', 'Helderheidsschuif')), 'light': (('toggle', 'On/off switch'), ('brightness', 'Brightness slider')),
'fan': (('toggle', 'Aan/uit-schakelaar'), ('speed', 'Snelheidsschuif')), 'fan': (('toggle', 'On/off switch'), ('speed', 'Speed slider')),
'vacuum': (('buttons', 'Start, stop, naar dock'),), 'vacuum': (('buttons', 'Start, stop, dock'),),
'cover': (('buttons', 'Open, stop, dicht'), ('position', 'Positieschuif')), 'cover': (('buttons', 'Open, stop, close'), ('position', 'Position slider')),
'media_player': (('volume', 'Volume en dempen'), ('playback', 'Vorige, play/pauze, volgende')), 'media_player': (('volume', 'Volume and mute'), ('playback', 'Previous, play/pause, next')),
'number': (('stepper', 'Waarde − / +'), ('slider', 'Schuif')), 'number': (('stepper', 'Value − / +'), ('slider', 'Slider')),
'input_number': (('stepper', 'Waarde − / +'), ('slider', 'Schuif')), 'input_number': (('stepper', 'Value − / +'), ('slider', 'Slider')),
'select': (('stepper', 'Vorige / volgende keuze'),), 'select': (('stepper', 'Previous / next choice'),),
'input_select': (('stepper', 'Vorige / volgende keuze'),), 'input_select': (('stepper', 'Previous / next choice'),),
'timer': (('buttons', 'Start/pauze en annuleren'),), 'timer': (('buttons', 'Start/pause and cancel'),),
'scene': (('run', 'Knop Activeren'),), 'scene': (('run', 'Activate button'),),
'script': (('run', 'Knop Uitvoeren'),), 'script': (('run', 'Run button'),),
'button': (('run', 'Knop Indrukken'),), 'button': (('run', 'Press button'),),
'input_button': (('run', 'Knop Indrukken'),), 'input_button': (('run', 'Press button'),),
} }
def controls_catalogue(): def controls_catalogue():
"""Editor choices per domain: the default first, then 'none'.""" """Editor choices per domain: the default first, then 'none'."""
return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': label} for key, label in choices] + [{'key': 'none', 'label': 'Geen'}]} return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': label} for key, label in choices] + [{'key': 'none', 'label': 'None'}]}
for domain, choices in CONTROLS.items()} for domain, choices in CONTROLS.items()}
def resolve_controls(tile): def resolve_controls(tile):
@@ -111,7 +111,42 @@ def resolve_controls(tile):
return None if choice == 'none' else choice return None if choice == 'none' else choice
# Diagnostic entities every ESP Screens firmware exposes; the manager watches them for screens. # Diagnostic entities every ESP Screens firmware exposes; the manager watches them for screens.
SCREEN_ENTITY_NAMES = frozenset({'Tegelinstellingen', 'Schermfirmware', 'Guition schermtype', 'Apparaatnaam', 'IP-adres'}) # Firmware built before the English translation still registers the Dutch originals, so both
# forms are recognised until every board has been reflashed with an English `name:`.
NAME_TILE_SETTINGS = ('Tile settings', 'Tegelinstellingen')
NAME_SCREEN_FIRMWARE = ('Screen firmware', 'Schermfirmware')
NAME_GUITION_TYPE = ('Guition screen type', 'Guition schermtype')
NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam')
NAME_IP_ADDRESS = ('IP address', 'IP-adres')
SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS)
def entity_slug(name):
"""The end of an entity id Home Assistant derives from an entity name (ASCII names)."""
return re.sub(r'[^a-z0-9]+', '_', str(name).lower()).strip('_')
# An inbox entity id is text.<device>_tile_settings, or text.<device>_tegelinstellingen before firmware 0.2.34.
INBOX_SUFFIXES = tuple('_' + entity_slug(name) for name in NAME_TILE_SETTINGS)
def inbox_prefix(entity):
"""The device part of an inbox entity id ('office_1' for text.office_1_tile_settings), or None."""
if isinstance(entity, str) and entity.startswith('text.'):
for suffix in INBOX_SUFFIXES:
if entity.endswith(suffix) and len(entity) > len('text.') + len(suffix):
return entity[len('text.'):-len(suffix)]
return None
def device_prefixes(registry, device_id):
"""Entity id prefixes Home Assistant gave the ESPHome entities of one device: the device name when each
entity was created. Entities whose name never changed keep the prefix an older inbox id carried."""
prefixes = set()
for item in registry:
name = item.get('original_name')
if item.get('device_id') != device_id or item.get('platform') != 'esphome' or not isinstance(name, str):
continue
object_id, suffix = item['entity_id'].split('.', 1)[-1], '_' + entity_slug(name)
if len(suffix) > 1 and object_id.endswith(suffix) and len(object_id) > len(suffix):
prefixes.add(object_id[:-len(suffix)])
return prefixes
# Additive schema 1 extension. An absent object retains old firmware/YAML defaults. # Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
SETTING_RULES = { SETTING_RULES = {
@@ -132,7 +167,7 @@ SETTING_RULES = {
def validate_settings(data): def validate_settings(data):
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys(): if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
raise ValueError('Onbekende scherminstellingen; vernieuw de beheerpagina.') raise ValueError('Unknown screen settings; refresh the management page.')
clean = {} clean = {}
for key, (default, minimum, maximum) in SETTING_RULES.items(): for key, (default, minimum, maximum) in SETTING_RULES.items():
value = data.get(key, default) value = data.get(key, default)
@@ -142,10 +177,10 @@ def validate_settings(data):
valid = type(value) is int and minimum <= value <= maximum valid = type(value) is int and minimum <= value <= maximum
if key == "rotation": valid = valid and value in (0, 90, 180, 270) if key == "rotation": valid = valid and value in (0, 90, 180, 270)
if not valid: if not valid:
raise ValueError(f'Ongeldige waarde voor {key}.') raise ValueError(f'Invalid value for {key}.')
clean[key] = value clean[key] = value
if max(clean['standby_brightness'], clean['night_brightness']) > clean['brightness']: if max(clean['standby_brightness'], clean['night_brightness']) > clean['brightness']:
raise ValueError('Helderheid in standby en nacht mag niet hoger zijn dan normaal.') raise ValueError('Standby and night brightness may not be higher than normal.')
return clean return clean
def entity_id(value): def entity_id(value):
@@ -163,7 +198,7 @@ TRANSPORT_MIN_FIRMWARE = (0, 2, 33)
MESSAGE_ACTION = 'screen_message' MESSAGE_ACTION = 'screen_message'
HEADER_MAX_ITEMS = 6 HEADER_MAX_ITEMS = 6
# Items the screen draws on its own clock, without Home Assistant. # Items the screen draws on its own clock, without Home Assistant.
HEADER_BUILTIN = {'clock': 'Tijd', 'analog': 'Analoge klok', 'date': 'Datum'} HEADER_BUILTIN = {'clock': 'Time', 'analog': 'Analog clock', 'date': 'Date'}
# Only shown, never controlled: the top bar takes these besides every tile domain. # Only shown, never controlled: the top bar takes these besides every tile domain.
HEADER_ONLY_DOMAINS = frozenset('device_tracker zone lock alarm_control_panel counter event input_datetime input_text water_heater humidifier'.split()) HEADER_ONLY_DOMAINS = frozenset('device_tracker zone lock alarm_control_panel counter event input_datetime input_text water_heater humidifier'.split())
HEADER_CONTENTS = ('state', 'last_changed') HEADER_CONTENTS = ('state', 'last_changed')
@@ -181,32 +216,32 @@ def header_items(layout):
def validate_header(data): def validate_header(data):
if not isinstance(data, dict) or set(data) - {'items'} or not isinstance(data.get('items'), list): if not isinstance(data, dict) or set(data) - {'items'} or not isinstance(data.get('items'), list):
raise ValueError('Ongeldige bovenbalk; vernieuw de beheerpagina.') raise ValueError('Invalid top bar; refresh the management page.')
if len(data['items']) > HEADER_MAX_ITEMS: if len(data['items']) > HEADER_MAX_ITEMS:
raise ValueError(f'De bovenbalk heeft plaats voor maximaal {HEADER_MAX_ITEMS} onderdelen.') raise ValueError(f'The top bar has room for at most {HEADER_MAX_ITEMS} items.')
items, seen = [], set() items, seen = [], set()
for item in data['items']: for item in data['items']:
kind = item.get('type') if isinstance(item, dict) else None kind = item.get('type') if isinstance(item, dict) else None
if kind in HEADER_BUILTIN: if kind in HEADER_BUILTIN:
if set(item) != {'type'}: if set(item) != {'type'}:
raise ValueError('Ongeldige instelling in de bovenbalk.') raise ValueError('Invalid setting in the top bar.')
clean = {'type': kind} clean = {'type': kind}
elif kind == 'entity': elif kind == 'entity':
if set(item) - {'type', 'entity', 'content', 'icon', 'show'}: if set(item) - {'type', 'entity', 'content', 'icon', 'show'}:
raise ValueError('Onbekende instelling in de bovenbalk; vernieuw de beheerpagina.') raise ValueError('Unknown setting in the top bar; refresh the management page.')
if not header_entity(item.get('entity')): if not header_entity(item.get('entity')):
raise ValueError('Deze entiteit kan niet in de bovenbalk.') raise ValueError("This entity can't go in the top bar.")
clean = {'type': 'entity', 'entity': item['entity'], 'content': item.get('content', 'state'), clean = {'type': 'entity', 'entity': item['entity'], 'content': item.get('content', 'state'),
'icon': item.get('icon', 'auto'), 'show': item.get('show', 'always')} 'icon': item.get('icon', 'auto'), 'show': item.get('show', 'always')}
if clean['content'] not in HEADER_CONTENTS or clean['show'] not in HEADER_SHOWS: if clean['content'] not in HEADER_CONTENTS or clean['show'] not in HEADER_SHOWS:
raise ValueError('Ongeldige instelling in de bovenbalk.') raise ValueError('Invalid setting in the top bar.')
if not (clean['icon'] in ('auto', 'none') or isinstance(clean['icon'], str) and clean['icon'] in tile_icons.ICONS): if not (clean['icon'] in ('auto', 'none') or isinstance(clean['icon'], str) and clean['icon'] in tile_icons.ICONS):
raise ValueError('Kies een icoon uit de lijst.') raise ValueError('Choose an icon from the list.')
else: else:
raise ValueError('Onbekend onderdeel in de bovenbalk; vernieuw de beheerpagina.') raise ValueError('Unknown item in the top bar; refresh the management page.')
key = json.dumps(clean, sort_keys=True) key = json.dumps(clean, sort_keys=True)
if key in seen: if key in seen:
raise ValueError('Dit onderdeel staat al in de bovenbalk.') raise ValueError('This item is already in the top bar.')
seen.add(key) seen.add(key)
items.append(clean) items.append(clean)
return {'items': items} return {'items': items}
@@ -226,52 +261,52 @@ def short(value, limit):
def validate_layout(data): def validate_layout(data):
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError('Ongeldige indeling.') raise ValueError('Invalid layout.')
title, tiles = data.get('title'), data.get('tiles') title, tiles = data.get('title'), data.get('tiles')
if not isinstance(title, str) or not title.strip() or len(title.encode()) > 96: if not isinstance(title, str) or not title.strip() or len(title.encode()) > 96:
raise ValueError('Geef het scherm een titel van maximaal 96 bytes.') raise ValueError('Give the screen a title of at most 96 bytes.')
if not isinstance(tiles, list) or len(tiles) > 20: if not isinstance(tiles, list) or len(tiles) > 20:
raise ValueError('Kies maximaal 20 tegels.') raise ValueError('Choose at most 20 tiles.')
clean, seen = [], set() clean, seen = [], set()
for tile in tiles: for tile in tiles:
if not isinstance(tile, dict) or not entity_id(tile.get('entity')): if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
raise ValueError('Deze entiteit wordt niet ondersteund.') raise ValueError("This entity isn't supported.")
if tile['entity'] in seen: if tile['entity'] in seen:
raise ValueError('Een entiteit kan maar één keer op een scherm staan.') raise ValueError('An entity can only appear once on a screen.')
name = tile.get('name', '') name = tile.get('name', '')
if not isinstance(name, str) or len(name.encode()) > 80: if not isinstance(name, str) or len(name.encode()) > 80:
raise ValueError('Een tegelnaam mag maximaal 80 bytes bevatten.') raise ValueError('A tile name may contain at most 80 bytes.')
seen.add(tile['entity']) seen.add(tile['entity'])
item = {'entity': tile['entity'], 'name': name.strip()} item = {'entity': tile['entity'], 'name': name.strip()}
if 'options' in tile: if 'options' in tile:
options = tile['options'] options = tile['options']
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls'}: if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls'}:
raise ValueError('Onbekende tegelinstellingen.') raise ValueError('Unknown tile settings.')
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS): if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
raise ValueError('Kies een pastel achtergrondkleur uit het palet.') raise ValueError('Choose a pastel background color from the palette.')
if 'icon' in options and not (options['icon'] == 'auto' or isinstance(options['icon'], str) and options['icon'] in tile_icons.ICONS): if 'icon' in options and not (options['icon'] == 'auto' or isinstance(options['icon'], str) and options['icon'] in tile_icons.ICONS):
raise ValueError('Kies een icoon uit de lijst.') raise ValueError('Choose an icon from the list.')
domain = tile['entity'].split('.')[0] domain = tile['entity'].split('.')[0]
displays = DISPLAYS.get(domain, ('standard', 'watch')) displays = DISPLAYS.get(domain, ('standard', 'watch'))
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')} choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
for key, allowed in choices.items(): for key, allowed in choices.items():
if key in options and options[key] not in allowed: if key in options and options[key] not in allowed:
raise ValueError('Ongeldige tegelinstelling: ' + key) raise ValueError('Invalid tile setting: ' + key)
# The five-day strip and the sun path only fit a double-width card. # The five-day strip and the sun path only fit a double-width card.
if options.get('display') in WIDE_ONLY: if options.get('display') in WIDE_ONLY:
options = {**options, 'size': 'wide'} options = {**options, 'size': 'wide'}
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player','climate'}: if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player','climate'}:
raise ValueError('Deze entiteit ondersteunt geen aan/uit-actie.') raise ValueError("This entity doesn't support an on/off action.")
if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}: if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}:
raise ValueError('Deze entiteit ondersteunt geen mini-schuif.') raise ValueError("This entity doesn't support a mini-slider.")
if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)): if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)):
raise ValueError('Geschiedenis: kies 1, 6 of 24 uur.') raise ValueError('History: choose 1, 6, or 24 hours.')
if options.get('display') == 'watch' and options.get('inline') == 'slider': if options.get('display') == 'watch' and options.get('inline') == 'slider':
raise ValueError('Kies grote waarde of mini-schuif.') raise ValueError('Choose large value or mini-slider.')
if 'controls' in options: if 'controls' in options:
allowed = ('none',) + tuple(key for key, _ in CONTROLS.get(domain, ())) allowed = ('none',) + tuple(key for key, _ in CONTROLS.get(domain, ()))
if not isinstance(options['controls'], str) or options['controls'] not in allowed: if not isinstance(options['controls'], str) or options['controls'] not in allowed:
raise ValueError('Deze entiteit ondersteunt die directe bediening niet.') raise ValueError("This entity doesn't support that direct control.")
item['options'] = dict(options) item['options'] = dict(options)
clean.append(item) clean.append(item)
# Positions: every tile or none (an older editor sends none and keeps its order). # Positions: every tile or none (an older editor sends none and keeps its order).
@@ -280,12 +315,12 @@ def validate_layout(data):
occupied = set() occupied = set()
for item, slot in zip(clean, given): for item, slot in zip(clean, given):
if type(slot) is not int or not 0 <= slot < MAX_SLOTS: if type(slot) is not int or not 0 <= slot < MAX_SLOTS:
raise ValueError('Ongeldige tegelpositie; vernieuw de beheerpagina.') raise ValueError('Invalid tile position; refresh the management page.')
if is_wide(item) and slot % 2: if is_wide(item) and slot % 2:
raise ValueError('Een dubbelbrede tegel begint in de linkerkolom.') raise ValueError('A double-width tile starts in the left column.')
for cell in footprint(slot, is_wide(item)): for cell in footprint(slot, is_wide(item)):
if cell in occupied: if cell in occupied:
raise ValueError('Twee tegels staan op dezelfde plek.') raise ValueError('Two tiles are in the same spot.')
occupied.add(cell) occupied.add(cell)
item['slot'] = slot item['slot'] = slot
clean.sort(key=lambda item: item['slot']) clean.sort(key=lambda item: item['slot'])
@@ -296,7 +331,7 @@ def validate_layout(data):
# Pages kept on purpose, empty ones included; the screen shows at least what the tiles need. # Pages kept on purpose, empty ones included; the screen shows at least what the tiles need.
if 'pages' in data: if 'pages' in data:
if type(data['pages']) is not int or not 1 <= data['pages'] <= MAX_PAGES: if type(data['pages']) is not int or not 1 <= data['pages'] <= MAX_PAGES:
raise ValueError(f'Een scherm heeft 1 tot {MAX_PAGES} pagina\'s.') raise ValueError(f'A screen has 1 to {MAX_PAGES} pages.')
result['pages'] = data['pages'] result['pages'] = data['pages']
if 'settings' in data: if 'settings' in data:
result['settings'] = validate_settings(data['settings']) result['settings'] = validate_settings(data['settings'])
@@ -451,7 +486,7 @@ def encode(message):
"""The message as the firmware parses it: compact JSON, at most 4096 bytes.""" """The message as the firmware parses it: compact JSON, at most 4096 bytes."""
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False) raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False)
if len(raw.encode()) > 4096: if len(raw.encode()) > 4096:
raise ValueError('Het schermbericht is te groot.') raise ValueError('The screen message is too large.')
return raw return raw
def revision(message): def revision(message):
@@ -473,20 +508,20 @@ def message_action(node):
# keeps every value here equal to what the board profiles compile. ----- # keeps every value here equal to what the board profiles compile. -----
ALERT_MIN_FIRMWARE = '0.2.31' ALERT_MIN_FIRMWARE = '0.2.31'
ALERT_EVENT = 'esphome.screen_alert' ALERT_EVENT = 'esphome.screen_alert'
ALERT_ENDINGS = (('ok', 'De knop is ingedrukt'), ('timeout', 'De timeout is verstreken'), ALERT_ENDINGS = (('ok', 'The button was pressed'), ('timeout', 'The timeout ran out'),
('replaced', 'Een nieuwe alert kwam eroverheen'), ('remote', 'dismiss_alert vanuit Home Assistant')) ('replaced', 'A new alert came over it'), ('remote', 'dismiss_alert from Home Assistant'))
ALERT_FALLBACK_ICON = 'alert-outline' ALERT_FALLBACK_ICON = 'alert-outline'
# (field, ESPHome type, label, explanation, example) in the order Home Assistant shows them. # (field, ESPHome type, label, explanation, example) in the order Home Assistant shows them.
ALERT_FIELDS = ( ALERT_FIELDS = (
('title', 'string', 'Titel', 'Eén regel bovenaan de kaart; wat niet past krijgt puntjes. Leeg wordt "Melding".', 'Iemand belt aan'), ('title', 'string', 'Title', 'A single line at the top of the card; what doesn\'t fit gets an ellipsis. Empty becomes "Notification".', 'Someone is at the door'),
('subtitle', 'string', 'Subtitle', 'Toelichting onder de titel, over meerdere regels; een regeleinde mag. Leeg mag.', 'Deur 3, achterkant'), ('subtitle', 'string', 'Subtitle', 'Explanation under the title, across multiple lines; a line break is fine. Empty is fine.', 'Door 3, back'),
('icon', 'string', 'Icoon', 'Een naam uit de lijst, ook als mdi:naam of als hex-codepoint (F12E6). Onbekend of leeg geeft de waarschuwingsdriehoek.', 'doorbell'), ('icon', 'string', 'Icon', 'A name from the list, also as mdi:name or as a hex codepoint (F12E6). Unknown or empty gives the warning triangle.', 'doorbell'),
('color', 'string', 'Kleur', 'Een van de negen pastelkleuren van de tegels. Leeg geeft de witte kaart.', 'orange'), ('color', 'string', 'Color', 'One of the nine pastel colors of the tiles. Empty gives the white card.', 'orange'),
('button_text', 'string', 'Knoptekst', 'De tekst op de knop. Leeg is "Oké".', 'Ik kom'), ('button_text', 'string', 'Button text', 'The text on the button. Empty is "OK".', 'Coming'),
('timeout', 'int', 'Timeout', 'Seconden waarna de kaart vanzelf verdwijnt. 0 wacht op de knop, hoe lang dat ook duurt. De knop sluit altijd direct, ook met een timeout.', 0), ('timeout', 'int', 'Timeout', 'Seconds after which the card disappears on its own. 0 waits for the button, however long that takes. The button always closes it immediately, even with a timeout.', 0),
('flash', 'bool', 'Knipperen', 'Aan laat de backlight vier keer knipperen als de alert binnenkomt; daarna blijft het scherm gewoon aan.', True), ('flash', 'bool', 'Blinking', 'On makes the backlight blink four times when the alert arrives; the screen then just stays on.', True),
) )
# Bytes per field the firmware keeps (the profiles' ALERT_*_MAX); a Dutch letter with accent takes two. # Bytes per field the firmware keeps (the profiles' ALERT_*_MAX); an accented letter takes two.
ALERT_LIMITS = {'cyd': {'title': 48, 'subtitle': 160, 'button_text': 12}, 'guition': {'title': 64, 'subtitle': 240, 'button_text': 16}} ALERT_LIMITS = {'cyd': {'title': 48, 'subtitle': 160, 'button_text': 12}, 'guition': {'title': 64, 'subtitle': 240, 'button_text': 16}}
ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alarm-light', 'lock', 'lock-open-variant', 'door-open', ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alarm-light', 'lock', 'lock-open-variant', 'door-open',
'window-closed-variant', 'motion-sensor', 'cctv', 'smoke-detector', 'water-alert', 'fire', 'mailbox', 'car', 'window-closed-variant', 'motion-sensor', 'cctv', 'smoke-detector', 'water-alert', 'fire', 'mailbox', 'car',
@@ -515,35 +550,35 @@ def discover_screens(registry, states, devices, areas):
"""Paired screens: every enabled ESPHome inbox with the diagnostics of its device.""" """Paired screens: every enabled ESPHome inbox with the diagnostics of its device."""
device_map = {d['id']: d for d in devices} device_map = {d['id']: d for d in devices}
area_map = {a['area_id']: a['name'] for a in areas} area_map = {a['area_id']: a['name'] for a in areas}
versions = {item.get('device_id'): states.get(item['entity_id'], {}).get('state', 'onbekend') for item in registry versions = {item.get('device_id'): states.get(item['entity_id'], {}).get('state', 'unknown') for item in registry
if item.get('platform') == 'esphome' and item.get('original_name') == 'Schermfirmware'} if item.get('platform') == 'esphome' and item.get('original_name') in NAME_SCREEN_FIRMWARE}
boards = {item.get("device_id"): "guition" for item in registry boards = {item.get("device_id"): "guition" for item in registry
if item.get("platform") == "esphome" and item.get("original_name") == "Guition schermtype"} if item.get("platform") == "esphome" and item.get("original_name") in NAME_GUITION_TYPE}
def diagnostic(name, pattern): def diagnostic(names, pattern):
found = {} found = {}
for item in registry: for item in registry:
if item.get('platform') == 'esphome' and item.get('original_name') == name: if item.get('platform') == 'esphome' and item.get('original_name') in names:
value = states.get(item['entity_id'], {}).get('state', '') value = states.get(item['entity_id'], {}).get('state', '')
if isinstance(value, str) and re.fullmatch(pattern, value): if isinstance(value, str) and re.fullmatch(pattern, value):
found[item.get('device_id')] = value found[item.get('device_id')] = value
return found return found
nodes = diagnostic('Apparaatnaam', r'[a-z0-9][a-z0-9-]{0,30}') nodes = diagnostic(NAME_DEVICE_NAME, r'[a-z0-9][a-z0-9-]{0,30}')
addresses = diagnostic('IP-adres', r'\d{1,3}(\.\d{1,3}){3}') addresses = diagnostic(NAME_IP_ADDRESS, r'\d{1,3}(\.\d{1,3}){3}')
screens = [] screens = []
for item in registry: for item in registry:
eid = item['entity_id'] eid = item['entity_id']
if not (item.get('platform') == 'esphome' and eid.startswith('text.') and item.get('original_name') == 'Tegelinstellingen' and not item.get('disabled_by')): if not (item.get('platform') == 'esphome' and eid.startswith('text.') and item.get('original_name') in NAME_TILE_SETTINGS and not item.get('disabled_by')):
continue continue
device = device_map.get(item.get('device_id'), {}) device = device_map.get(item.get('device_id'), {})
state = states.get(eid, {}) state = states.get(eid, {})
area = area_map.get(item.get('area_id') or device.get('area_id'), '') area = area_map.get(item.get('area_id') or device.get('area_id'), '')
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid, screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
'firmware': versions.get(item.get('device_id'), 'onbekend'), 'firmware': versions.get(item.get('device_id'), 'unknown'),
'board': boards.get(item.get('device_id'), 'unknown'), 'board': boards.get(item.get('device_id'), 'unknown'),
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')), 'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
'device': device.get('name') or '', 'device': device.get('name') or '',
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'), 'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
'status': state.get('state', 'Niet verbonden')}) 'status': state.get('state', 'Not connected')})
return screens return screens
def discover(registry, states, devices, areas): def discover(registry, states, devices, areas):
@@ -578,13 +613,13 @@ def discover(registry, states, devices, areas):
def installation_yaml(data): def installation_yaml(data):
board, name, friendly = data.get('board'), data.get('name'), data.get('friendly_name') board, name, friendly = data.get('board'), data.get('name'), data.get('friendly_name')
if board not in REFS or not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,29}', name): if board not in REFS or not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,29}', name):
raise ValueError('Kies een bord en een unieke naam (kleine letters, cijfers, streepjes; maximaal 30 tekens).') raise ValueError('Choose a board and a unique name (lowercase letters, digits, dashes; 30 characters max).')
if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60: if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
raise ValueError('Geef het scherm een herkenbare naam (maximaal 60 tekens).') raise ValueError('Give the screen a recognizable name (60 characters max).')
quote = lambda s: json.dumps(s, ensure_ascii=False) quote = lambda s: json.dumps(s, ensure_ascii=False)
key, ota, ap = base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(12) key, ota, ap = base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(12)
return f'''# Bewaar dit bestand: het bevat de unieke sleutels voor dit scherm. return f'''# Keep this file safe: it contains the unique keys for this screen.
# Wifi komt uit de secrets.yaml van ESPHome Device Builder. # Wi-Fi comes from the secrets.yaml of ESPHome Device Builder.
substitutions: substitutions:
DEVICE_NAME: {quote(name)} DEVICE_NAME: {quote(name)}
DEVICE_FRIENDLY_NAME: {quote(friendly.strip())} DEVICE_FRIENDLY_NAME: {quote(friendly.strip())}
+17 -17
View File
@@ -82,10 +82,10 @@ class Firmware:
def profile(self, name): def profile(self, name):
if not isinstance(name,str) or not re.fullmatch(r'[a-zA-Z0-9_-]+\.yaml', name): if not isinstance(name,str) or not re.fullmatch(r'[a-zA-Z0-9_-]+\.yaml', name):
raise ValueError('Kies een bestaand YAML-profiel.') raise ValueError('Choose an existing YAML profile.')
p = self.root / name p = self.root / name
if p.is_symlink() or p.resolve().parent != self.root or not p.is_file(): if p.is_symlink() or p.resolve().parent != self.root or not p.is_file():
raise ValueError('Profiel bestaat niet in de ESPHome-map.') raise ValueError("Profile doesn't exist in the ESPHome folder.")
return p return p
def wifi_status(self): def wifi_status(self):
@@ -116,12 +116,12 @@ class Firmware:
if wifi['state'] == 'ready': if wifi['state'] == 'ready':
return return
if wifi['state'] == 'invalid': if wifi['state'] == 'invalid':
raise ValueError('secrets.yaml in de ESPHome-map is geen geldige YAML. Herstel het bestand eerst; het wordt niet overschreven.') raise ValueError("secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten.")
values = {} values = {}
for key in wifi['missing']: for key in wifi['missing']:
value = data.get(key) value = data.get(key)
if not isinstance(value, str) or (key == 'wifi_ssid' and not value.strip()): if not isinstance(value, str) or (key == 'wifi_ssid' and not value.strip()):
raise ValueError('Vul de wifi-naam en het wachtwoord in; ze worden in ESPHome secrets.yaml bewaard.') raise ValueError("Fill in the Wi-Fi name and password; they'll be saved in ESPHome secrets.yaml.")
values[key] = value values[key] = value
path = self.root / 'secrets.yaml' path = self.root / 'secrets.yaml'
if wifi['state'] == 'new': if wifi['state'] == 'new':
@@ -140,7 +140,7 @@ class Firmware:
text = text + ('' if not text or text.endswith('\n') else '\n') + line + '\n' text = text + ('' if not text or text.endswith('\n') else '\n') + line + '\n'
check = yaml.safe_load(text) check = yaml.safe_load(text)
if not isinstance(check, dict) or any(check.get(key) != value for key, value in values.items()): if not isinstance(check, dict) or any(check.get(key) != value for key, value in values.items()):
raise ValueError('Wifi kon niet in secrets.yaml worden gezet. Vul wifi_ssid en wifi_password daar zelf in.') raise ValueError("Wi-Fi couldn't be set in secrets.yaml. Fill in wifi_ssid and wifi_password there yourself.")
path.write_text(text) path.write_text(text)
def create(self, data): def create(self, data):
@@ -149,13 +149,13 @@ class Firmware:
self.root.mkdir(parents=True,exist_ok=True) self.root.mkdir(parents=True,exist_ok=True)
profile = self.root / (data['name']+'.yaml') profile = self.root / (data['name']+'.yaml')
if profile.exists() or profile.is_symlink(): if profile.exists() or profile.is_symlink():
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.') raise ValueError('This name already exists. Use the existing profile for updates.')
self.store_wifi(data) self.store_wifi(data)
try: try:
with profile.open('x') as f: with profile.open('x') as f:
os.chmod(profile,0o600);f.write(content) os.chmod(profile,0o600);f.write(content)
except FileExistsError: except FileExistsError:
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.') raise ValueError('This name already exists. Use the existing profile for updates.')
key = yaml.load(content, Loader=LenientLoader)['api']['encryption']['key'] key = yaml.load(content, Loader=LenientLoader)['api']['encryption']['key']
# The key is what Home Assistant asks for when pairing; the page shows it once. # The key is what Home Assistant asks for when pairing; the page shows it once.
return {'file': profile.name, 'node': data['name'], 'api_key': key} return {'file': profile.name, 'node': data['name'], 'api_key': key}
@@ -168,11 +168,11 @@ class Firmware:
target = data.get('target') or '' target = data.get('target') or ''
if target: if target:
if not isinstance(target, str) or target not in self.ports(): if not isinstance(target, str) or target not in self.ports():
raise ValueError('Kies de aangesloten USB-poort uit de lijst.') raise ValueError('Choose the connected USB port from the list.')
if self.task and not self.task.done(): if self.task and not self.task.done():
raise ValueError('Er loopt al een build of installatie. Wacht tot die klaar is.') raise ValueError('A build or installation is already running. Wait for it to finish.')
if not shutil.which('esphome'): if not shutil.which('esphome'):
raise ValueError('ESPHome CLI ontbreekt in deze installatie.') raise ValueError('The ESPHome CLI is missing from this installation.')
result = self.create(data) result = self.create(data)
if target: if target:
result['job'] = self.start({'file': result['file'], 'action': 'install', 'target': target}) result['job'] = self.start({'file': result['file'], 'action': 'install', 'target': target})
@@ -185,17 +185,17 @@ class Firmware:
return re.sub(r'(?i)((?:password|encryption.key|token|ssid)\s*[:=]\s*).+',r'\1[redacted]',text)[:1500] return re.sub(r'(?i)((?:password|encryption.key|token|ssid)\s*[:=]\s*).+',r'\1[redacted]',text)[:1500]
def start(self, data): def start(self, data):
if self.task and not self.task.done(): raise ValueError('Er loopt al een build of installatie.') if self.task and not self.task.done(): raise ValueError('A build or installation is already running.')
profile = self.profile(data.get('file')) profile = self.profile(data.get('file'))
action = data.get('action') action = data.get('action')
if action not in ('validate','build','install'): raise ValueError('Onbekende firmwareactie.') if action not in ('validate','build','install'): raise ValueError('Unknown firmware action.')
if not shutil.which('esphome'): raise ValueError('ESPHome CLI ontbreekt in deze installatie.') if not shutil.which('esphome'): raise ValueError('The ESPHome CLI is missing from this installation.')
target = data.get('target','') target = data.get('target','')
if action == 'install': if action == 'install':
if target.startswith('/dev/'): if target.startswith('/dev/'):
if target not in self.ports(): raise ValueError('Kies de aangesloten USB-poort uit de lijst.') if target not in self.ports(): raise ValueError('Choose the connected USB port from the list.')
elif not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}',target): elif not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}',target):
raise ValueError('Vul het IP-adres of de hostnaam van het bedoelde scherm in.') raise ValueError('Enter the IP address or hostname of the intended screen.')
self._secret_values=set() self._secret_values=set()
# Collect scalar literals, including !secret values, without executing YAML tags. # Collect scalar literals, including !secret values, without executing YAML tags.
def collect(node): def collect(node):
@@ -232,8 +232,8 @@ class Firmware:
async for line in self.process.stdout: async for line in self.process.stdout:
self.logs.append(self.redact(line.decode(errors='replace').rstrip())) self.logs.append(self.redact(line.decode(errors='replace').rstrip()))
code=await self.process.wait() code=await self.process.wait()
if code: raise RuntimeError('ESPHome '+stage+' mislukt; bekijk het log.') if code: raise RuntimeError('ESPHome '+stage+' failed; see the log.')
self.job['state']='success'; self.logs.append('Geslaagd: '+action) self.job['state']='success'; self.logs.append('Succeeded: '+action)
if action=='install': self.installed.add(profile.name) if action=='install': self.installed.add(profile.name)
except asyncio.CancelledError: except asyncio.CancelledError:
self.job['state']='interrupted' self.job['state']='interrupted'
+37 -37
View File
@@ -1,9 +1,9 @@
"""Top bar of a screen ("bovenbalk"): what the firmware draws right of the screen name. """Top bar of a screen: what the firmware draws right of the screen name.
Firmware 0.2.32+ only draws. This module decides everything that needs Home Assistant: an Firmware 0.2.32+ only draws. This module decides everything that needs Home Assistant: an
entity's text in Dutch (number format, units, translated states), its icon, its accent colour entity's text (number format, units, translated states), its icon, its accent colour
and whether it shows at all. Clock, analog clock and date tick on the screen itself, and a and whether it shows at all. Clock, analog clock and date tick on the screen itself, and a
relative time ("5 min geleden") travels as a unix time, so the screen counts the minutes relative time ("5 min ago") travels as a unix time, so the screen counts the minutes
without any traffic. without any traffic.
Wire format, one message after the layout: {"v":1,"op":"header","items":[...]} with per item Wire format, one message after the layout: {"v":1,"op":"header","items":[...]} with per item
@@ -21,33 +21,33 @@ from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_F
# tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes. # tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes.
GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–") GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–")
TEXT_BYTES = 40 TEXT_BYTES = 40
MONTHS = ('jan', 'feb', 'mrt', 'apr', 'mei', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec') MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
UNAVAILABLE = '—' UNAVAILABLE = '—'
CONTENT_LABELS = {'state': 'Status', 'last_changed': 'Laatst gewijzigd'} CONTENT_LABELS = {'state': 'Status', 'last_changed': 'Last changed'}
SHOW_LABELS = {'always': 'Altijd', 'active': 'Alleen als actief'} SHOW_LABELS = {'always': 'Always', 'active': 'Only when active'}
# Home Assistant's own words where they fit a small bar; the screen shows the rest as they come. # Home Assistant's own words where they fit a small bar; the screen shows the rest as they come.
STATES = { STATES = {
'on': 'Aan', 'off': 'Uit', 'home': 'Thuis', 'not_home': 'Weg', 'open': 'Open', 'closed': 'Dicht', 'on': 'On', 'off': 'Off', 'home': 'Home', 'not_home': 'Away', 'open': 'Open', 'closed': 'Closed',
'opening': 'Gaat open', 'closing': 'Gaat dicht', 'locked': 'Op slot', 'unlocked': 'Open', 'locking': 'Sluiten', 'opening': 'Opening', 'closing': 'Closing', 'locked': 'Locked', 'unlocked': 'Unlocked', 'locking': 'Locking',
'unlocking': 'Openen', 'jammed': 'Vast', 'playing': 'Speelt', 'paused': 'Pauze', 'idle': 'Inactief', 'unlocking': 'Unlocking', 'jammed': 'Jammed', 'playing': 'Playing', 'paused': 'Paused', 'idle': 'Idle',
'standby': 'Stand-by', 'buffering': 'Laden', 'cleaning': 'Bezig', 'docked': 'In dock', 'returning': 'Naar dock', 'standby': 'Standby', 'buffering': 'Buffering', 'cleaning': 'Cleaning', 'docked': 'Docked', 'returning': 'Returning',
'error': 'Fout', 'heat': 'Verwarmen', 'cool': 'Koelen', 'heat_cool': 'Auto', 'auto': 'Auto', 'dry': 'Drogen', 'error': 'Error', 'heat': 'Heat', 'cool': 'Cool', 'heat_cool': 'Auto', 'auto': 'Auto', 'dry': 'Dry',
'fan_only': 'Ventileren', 'disarmed': 'Uit', 'armed_home': 'Thuis', 'armed_away': 'Afwezig', 'armed_night': 'Nacht', 'fan_only': 'Fan only', 'disarmed': 'Disarmed', 'armed_home': 'Armed home', 'armed_away': 'Armed away', 'armed_night': 'Armed night',
'armed_vacation': 'Vakantie', 'armed_custom_bypass': 'Aan', 'arming': 'Inschakelen', 'pending': 'Wachten', 'armed_vacation': 'Armed vacation', 'armed_custom_bypass': 'Armed', 'arming': 'Arming', 'pending': 'Pending',
'triggered': 'Alarm', 'active': 'Loopt', 'above_horizon': 'Op', 'below_horizon': 'Onder', 'triggered': 'Triggered', 'active': 'Active', 'above_horizon': 'Up', 'below_horizon': 'Down',
} }
# device_class: (on, off) # device_class: (on, off)
BINARY_STATES = { BINARY_STATES = {
'battery': ('Laag', 'Normaal'), 'battery_charging': ('Laden', 'Niet laden'), 'carbon_monoxide': ('Gevaar', 'Veilig'), 'battery': ('Low', 'Normal'), 'battery_charging': ('Charging', 'Not charging'), 'carbon_monoxide': ('Danger', 'Safe'),
'cold': ('Koud', 'Normaal'), 'connectivity': ('Verbonden', 'Verbroken'), 'door': ('Open', 'Dicht'), 'cold': ('Cold', 'Normal'), 'connectivity': ('Connected', 'Disconnected'), 'door': ('Open', 'Closed'),
'garage_door': ('Open', 'Dicht'), 'gas': ('Gevaar', 'Veilig'), 'heat': ('Heet', 'Normaal'), 'light': ('Licht', 'Donker'), 'garage_door': ('Open', 'Closed'), 'gas': ('Danger', 'Safe'), 'heat': ('Hot', 'Normal'), 'light': ('Light', 'Dark'),
'lock': ('Open', 'Op slot'), 'moisture': ('Nat', 'Droog'), 'motion': ('Beweging', 'Geen beweging'), 'lock': ('Open', 'Locked'), 'moisture': ('Wet', 'Dry'), 'motion': ('Motion', 'No motion'),
'moving': ('Beweegt', 'Stil'), 'occupancy': ('Bezet', 'Vrij'), 'opening': ('Open', 'Dicht'), 'plug': ('Aangesloten', 'Los'), 'moving': ('Moving', 'Still'), 'occupancy': ('Occupied', 'Clear'), 'opening': ('Open', 'Closed'), 'plug': ('Plugged in', 'Unplugged'),
'power': ('Aan', 'Uit'), 'presence': ('Thuis', 'Weg'), 'problem': ('Probleem', 'OK'), 'running': ('Actief', 'Inactief'), 'power': ('On', 'Off'), 'presence': ('Home', 'Away'), 'problem': ('Problem', 'OK'), 'running': ('Active', 'Inactive'),
'safety': ('Onveilig', 'Veilig'), 'smoke': ('Rook', 'Geen rook'), 'sound': ('Geluid', 'Stil'), 'tamper': ('Sabotage', 'OK'), 'safety': ('Unsafe', 'Safe'), 'smoke': ('Smoke', 'No smoke'), 'sound': ('Sound', 'Silent'), 'tamper': ('Tampering', 'OK'),
'update': ('Update', 'Bijgewerkt'), 'vibration': ('Trilling', 'Stil'), 'window': ('Open', 'Dicht'), 'update': ('Update', 'Up to date'), 'vibration': ('Vibration', 'Still'), 'window': ('Open', 'Closed'),
} }
# Icon names from tile_icons; the fonts of both boards carry every one of them. # Icon names from tile_icons; the fonts of both boards carry every one of them.
BINARY_ICONS = { # device_class: (on, off) BINARY_ICONS = { # device_class: (on, off)
@@ -101,7 +101,7 @@ def clean_text(text):
return short(''.join(out).strip(), TEXT_BYTES) return short(''.join(out).strip(), TEXT_BYTES)
def number_text(value, precision=None): def number_text(value, precision=None):
"""A number as Home Assistant writes it in Dutch: decimal comma, dots between thousands.""" """A number as Home Assistant writes it: decimal point, commas between thousands."""
if precision is None: if precision is None:
# Without a display precision HA keeps integers whole and shows at most three decimals. # Without a display precision HA keeps integers whole and shows at most three decimals.
digits = 0 if float(value).is_integer() else 3 digits = 0 if float(value).is_integer() else 3
@@ -112,13 +112,13 @@ def number_text(value, precision=None):
text = f'{value:.{precision}f}' text = f'{value:.{precision}f}'
negative = text.startswith('-') negative = text.startswith('-')
whole, _, fraction = text.lstrip('-').partition('.') whole, _, fraction = text.lstrip('-').partition('.')
grouped = f'{int(whole):,}'.replace(',', '.') grouped = f'{int(whole):,}'
result = grouped + (',' + fraction if fraction else '') result = grouped + ('.' + fraction if fraction else '')
# "-0" and "-0,0" are just zero. # "-0" and "-0.0" are just zero.
return '-' + result if negative and any(c not in '0.,' for c in result) else result return '-' + result if negative and any(c not in '0.,' for c in result) else result
def with_unit(text, unit): def with_unit(text, unit):
"""Home Assistant's spacing in Dutch: "21,3 °C", "65%", "18°".""" """Home Assistant's spacing: "21.3 °C", "65%", "18°"."""
if not unit: if not unit:
return text return text
return text + unit if unit in ('%', '°') else f'{text} {unit}' return text + unit if unit in ('%', '°') else f'{text} {unit}'
@@ -152,7 +152,7 @@ def value(entity, state, entry=None, units=None, tz=None):
return (None, epoch(raw)) if epoch(raw) else (UNAVAILABLE, None) return (None, epoch(raw)) if epoch(raw) else (UNAVAILABLE, None)
if domain == 'script': if domain == 'script':
moment = epoch(attrs.get('last_triggered')) moment = epoch(attrs.get('last_triggered'))
return ('Bezig', None) if raw == 'on' else (None, moment) if moment else ('Nog niet gestart', None) return ('Running', None) if raw == 'on' else (None, moment) if moment else ('Never run', None)
if domain == 'sun': if domain == 'sun':
# The next event: sunset while the sun is up, sunrise while it is down. # The next event: sunset while the sun is up, sunrise while it is down.
clock = local_clock(attrs.get('next_setting' if raw == 'above_horizon' else 'next_rising'), tz) clock = local_clock(attrs.get('next_setting' if raw == 'above_horizon' else 'next_rising'), tz)
@@ -169,7 +169,7 @@ def value(entity, state, entry=None, units=None, tz=None):
if domain in ('person', 'device_tracker'): if domain in ('person', 'device_tracker'):
return STATES.get(raw, raw), None return STATES.get(raw, raw), None
if domain == 'binary_sensor': if domain == 'binary_sensor':
pair = BINARY_STATES.get(attrs.get('device_class'), ('Aan', 'Uit')) pair = BINARY_STATES.get(attrs.get('device_class'), ('On', 'Off'))
return (pair[0] if raw == 'on' else pair[1] if raw == 'off' else raw), None return (pair[0] if raw == 'on' else pair[1] if raw == 'off' else raw), None
if domain == 'input_datetime': if domain == 'input_datetime':
# "2026-09-15 07:30:00", "2026-09-15" or just "07:30:00". # "2026-09-15 07:30:00", "2026-09-15" or just "07:30:00".
@@ -197,7 +197,7 @@ def value(entity, state, entry=None, units=None, tz=None):
return raw[:1].upper() + raw[1:], None return raw[:1].upper() + raw[1:], None
def active(entity, state): def active(entity, state):
""""Alleen als actief": on, open, home, detected, playing, heating, or a number other than zero.""" """"Only when active": on, open, home, detected, playing, heating, or a number other than zero."""
raw = state.get('state') if state else None raw = state.get('state') if state else None
domain = entity.split('.')[0] domain = entity.split('.')[0]
if raw in (None, '', 'unavailable', 'unknown'): if raw in (None, '', 'unavailable', 'unknown'):
@@ -324,13 +324,13 @@ def suggestions(screen, entities, states, registry=None, limit=8):
def sensor(device_class): def sensor(device_class):
return lambda e: e['id'].startswith('sensor.') and attrs(e).get('device_class') == device_class and numeric(states[e['id']]['state']) is not None return lambda e: e['id'].startswith('sensor.') and attrs(e).get('device_class') == device_class and numeric(states[e['id']]['state']) is not None
picks = [] picks = []
pick('Temperatuur', sensor('temperature')) pick('Temperature', sensor('temperature'))
pick('Luchtvochtigheid', sensor('humidity')) pick('Humidity', sensor('humidity'))
pick('Stroomverbruik', sensor('power')) pick('Power usage', sensor('power'))
pick('Laatste beweging', lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed') pick('Last motion', lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed')
pick('Weer', lambda e: e['id'].startswith('weather.')) pick('Weather', lambda e: e['id'].startswith('weather.'))
pick('Aantal thuis', lambda e: e['id'] == 'zone.home') pick('People home', lambda e: e['id'] == 'zone.home')
pick('Zon op en onder', lambda e: e['id'] == 'sun.sun') pick('Sunrise and sunset', lambda e: e['id'] == 'sun.sun')
pick('CO₂', sensor('carbon_dioxide')) pick('CO₂', sensor('carbon_dioxide'))
return picks[:limit] return picks[:limit]
+114 -46
View File
@@ -14,7 +14,7 @@ import tile_icons
from updates import Updater from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BUILTIN, HEADER_MIN_FIRMWARE, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_reference, alert_service, controls_catalogue, discover, discover_screens, encode, extras, header_items, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings from core import BUILTIN, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_reference, alert_service, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
import header_bar import header_bar
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -31,7 +31,12 @@ FULL_REPEAT_SECONDS = 3600
HISTORY_SECONDS = 300 HISTORY_SECONDS = 300
# Inbox states after which a screen needs the whole layout again: a restart, a ping that # Inbox states after which a screen needs the whole layout again: a restart, a ping that
# did not match, or a tile state that never arrived. Guarded so a slow batch cannot loop. # did not match, or a tile state that never arrived. Guarded so a slow batch cannot loop.
RESEND_STATES = frozenset({'Klaar voor tegelconfiguratie', 'Indeling opnieuw nodig', 'Tegels laden'}) # Firmware built before the English translation still reports the Dutch originals, so both
# forms are recognised until every board has been reflashed.
RESEND_STATES = frozenset({
'Ready for tile configuration', 'Resend needed', 'Loading tiles',
'Klaar voor tegelconfiguratie', 'Indeling opnieuw nodig', 'Tegels laden',
})
RESEND_GUARD_SECONDS = 120 RESEND_GUARD_SECONDS = 120
FORECAST_SECONDS = 1800 FORECAST_SECONDS = 1800
@@ -78,7 +83,7 @@ class HomeAssistant:
async def request(self, kind, **data): async def request(self, kind, **data):
if self.ws is None or self.ws.closed: if self.ws is None or self.ws.closed:
raise ConnectionError('Home Assistant is niet verbonden.') raise ConnectionError("Home Assistant isn't connected.")
self.next_id += 1 self.next_id += 1
key = self.next_id key = self.next_id
future = asyncio.get_running_loop().create_future() future = asyncio.get_running_loop().create_future()
@@ -100,7 +105,7 @@ class HomeAssistant:
if data.get('success'): if data.get('success'):
future.set_result(data.get('result')) future.set_result(data.get('result'))
else: else:
future.set_exception(ConnectionError('Home Assistant heeft de opdracht geweigerd.')) future.set_exception(ConnectionError('Home Assistant refused the command.'))
elif data.get('type') == 'event': elif data.get('type') == 'event':
event = data.get('event', {}) event = data.get('event', {})
body = event.get('data', {}) body = event.get('data', {})
@@ -118,7 +123,7 @@ class HomeAssistant:
self.changed.set() self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS: elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set() self.registry_changed.set()
raise ConnectionError('Home Assistant-verbinding verbroken.') raise ConnectionError('Home Assistant connection lost.')
def describe_close(self, connected): def describe_close(self, connected):
"""Why and after how long the websocket ended; helps explain unexpected reconnects in the log.""" """Why and after how long the websocket ended; helps explain unexpected reconnects in the log."""
@@ -126,7 +131,7 @@ class HomeAssistant:
return '' return ''
ws = self.ws ws = self.ws
reason = ws.exception() if ws is not None else None reason = ws.exception() if ws is not None else None
return ' na %d s, close-code %s%s' % (time.monotonic() - connected, ws.close_code if ws is not None else None, return ' after %d s, close code %s%s' % (time.monotonic() - connected, ws.close_code if ws is not None else None,
f', {type(reason).__name__}' if reason else '') f', {type(reason).__name__}' if reason else '')
async def registries(self): async def registries(self):
@@ -144,7 +149,7 @@ class HomeAssistant:
await ws.receive_json(timeout=15) await ws.receive_json(timeout=15)
await ws.send_json({'type': 'auth', 'access_token': self.token}) await ws.send_json({'type': 'auth', 'access_token': self.token})
if (await ws.receive_json(timeout=15)).get('type') != 'auth_ok': if (await ws.receive_json(timeout=15)).get('type') != 'auth_ok':
raise ConnectionError('Home Assistant-authenticatie mislukt.') raise ConnectionError('Home Assistant authentication failed.')
reader = asyncio.create_task(self.read()) reader = asyncio.create_task(self.read())
await self.request('subscribe_events', event_type='state_changed') await self.request('subscribe_events', event_type='state_changed')
await self.request('subscribe_events', event_type='esphome.screen_setting') await self.request('subscribe_events', event_type='esphome.screen_setting')
@@ -161,7 +166,7 @@ class HomeAssistant:
self.online = True self.online = True
self.changed.set() self.changed.set()
connected = time.monotonic() connected = time.monotonic()
LOG.info('Home Assistant verbonden') LOG.info('Home Assistant connected')
# Refresh the registry when HA reports a change (debounced), with a slow # Refresh the registry when HA reports a change (debounced), with a slow
# fallback; a full fetch is about 1 MB of JSON and used to run every 30 s. # fallback; a full fetch is about 1 MB of JSON and used to run every 30 s.
fetched = time.monotonic() fetched = time.monotonic()
@@ -182,12 +187,12 @@ class HomeAssistant:
fetched = time.monotonic() fetched = time.monotonic()
self.changed.set() self.changed.set()
except (ConnectionError, TimeoutError, OSError, ValueError) as error: except (ConnectionError, TimeoutError, OSError, ValueError) as error:
LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)%s', type(error).__name__, self.describe_close(connected)) LOG.warning('Home Assistant temporarily unavailable (%s)%s', type(error).__name__, self.describe_close(connected))
except Exception as error: except Exception as error:
LOG.warning('Verbinding opnieuw starten (%s)%s', type(error).__name__, self.describe_close(connected)) LOG.warning('Restarting connection (%s)%s', type(error).__name__, self.describe_close(connected))
finally: finally:
if self.online: if self.online:
LOG.info('Home Assistant-verbinding gesloten%s', self.describe_close(connected)) LOG.info('Home Assistant connection closed%s', self.describe_close(connected))
self.online = False self.online = False
self.ws = None self.ws = None
if reader: if reader:
@@ -196,7 +201,7 @@ class HomeAssistant:
await reader await reader
for future in self.pending.values(): for future in self.pending.values():
if not future.done(): if not future.done():
future.set_exception(ConnectionError('Verbinding verbroken.')) future.set_exception(ConnectionError('Connection lost.'))
await asyncio.sleep(5) await asyncio.sleep(5)
async def send(self, inbox, message, action=None): async def send(self, inbox, message, action=None):
@@ -249,7 +254,7 @@ class HomeAssistant:
raw = bytearray() raw = bytearray()
async for chunk in response.content.iter_chunked(65536): async for chunk in response.content.iter_chunked(65536):
raw.extend(chunk) raw.extend(chunk)
if len(raw)>2*1024*1024: raise ValueError('Geschiedenis te groot.') if len(raw)>2*1024*1024: raise ValueError('History too large.')
rows=json.loads(raw) rows=json.loads(raw)
events=[] events=[]
begin=datetime.fromisoformat(start).timestamp(); span=hours*3600 begin=datetime.fromisoformat(start).timestamp(); span=hours*3600
@@ -278,11 +283,15 @@ class Manager:
self._screens_key, self._screens = None, [] self._screens_key, self._screens = None, []
self._watched_key, self._watched = None, set() self._watched_key, self._watched = None, set()
self._seen_registry = None self._seen_registry = None
# Inbox entity ids seen this run with their Home Assistant device, and old inbox ids that a screen
# now reports under a new id (see follow_renamed_inboxes); the updater follows a screen through them.
self._inbox_devices, self.aliases = {}, {}
self._prefix_source, self._prefixes = None, {}
if self.path.exists(): if self.path.exists():
raw = json.loads(self.path.read_text()) raw = json.loads(self.path.read_text())
# Versioned persistent data. Never silently overwrite an unknown schema. # Versioned persistent data. Never silently overwrite an unknown schema.
if raw.get('version') != 1 or not isinstance(raw.get('screens'), dict): if raw.get('version') != 1 or not isinstance(raw.get('screens'), dict):
raise ValueError('Onbekende opslagversie; gegevens blijven ongewijzigd.') raise ValueError('Unknown storage version; data stays unchanged.')
self.layouts = {key: validate_layout(value) for key, value in raw['screens'].items()} self.layouts = {key: validate_layout(value) for key, value in raw['screens'].items()}
def inventory(self): def inventory(self):
@@ -306,10 +315,74 @@ class Manager:
key = (id(ha.registry), id(ha.devices), id(ha.areas), tuple(ha.states.get(item['entity_id'], {}).get('state') for item in items)) key = (id(ha.registry), id(ha.devices), id(ha.areas), tuple(ha.states.get(item['entity_id'], {}).get('state') for item in items))
if key != self._screens_key: if key != self._screens_key:
self._screens_key, self._screens = key, discover_screens(items, ha.states, ha.devices, ha.areas) self._screens_key, self._screens = key, discover_screens(items, ha.states, ha.devices, ha.areas)
self.follow_renamed_inboxes(items)
return [dict(screen) for screen in self._screens] return [dict(screen) for screen in self._screens]
def screen(self, inbox): def screen(self, inbox):
return next((s for s in self.screens() if s['id'] == inbox), None) screens = self.screens()
inbox = self.aliases.get(inbox, inbox)
return next((s for s in screens if s['id'] == inbox), None)
def device_prefixes(self, device):
registry = getattr(self.ha, 'registry', [])
if self._prefix_source is not registry:
self._prefix_source, self._prefixes = registry, {}
if device not in self._prefixes:
self._prefixes[device] = device_prefixes(registry, device)
return self._prefixes[device]
def follow_renamed_inboxes(self, items):
"""Keep a screen's layout and update history when its inbox entity gets a new entity id.
Firmware 0.2.34 renamed the inbox from "Tegelinstellingen" to "Tile settings". Home Assistant then
removes the old entity and registers the renamed one under a new id, which would orphan the layout
stored under the old id. The screen is recognised by its device: an inbox this run saw on the same
device, or (after a restart) a stored inbox id that carries one of the device's entity id prefixes."""
current = {item['entity_id']: item.get('device_id') for item in items
if item.get('platform') == 'esphome' and item['entity_id'].startswith('text.') and item.get('device_id')
and item.get('original_name') in NAME_TILE_SETTINGS and not item.get('disabled_by')}
stored = (set(self.layouts) | set(self.updates.hosts) | set(self.updates.results)) - set(current) - set(self.aliases)
for new, device in current.items():
olds = {old for old, seen in self._inbox_devices.items() if seen == device and old not in current}
candidates = [old for old in stored if inbox_prefix(old) is not None]
if candidates:
prefixes = self.device_prefixes(device)
olds |= {old for old in candidates if inbox_prefix(old) in prefixes}
for old in sorted(olds - set(self.aliases)):
self.rename_inbox(old, new)
self._inbox_devices.update(current)
def write_layouts(self, layouts):
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix('.tmp')
with open(temp, 'w', encoding='utf8') as handle:
os.chmod(temp, 0o600)
json.dump({'version': 1, 'screens': layouts}, handle, ensure_ascii=False)
handle.flush()
os.fsync(handle.fileno())
temp.replace(self.path)
def rename_inbox(self, old, new):
"""Move everything kept under an old inbox id to the id the same screen reports under now."""
moved = []
if old in self.layouts and new not in self.layouts:
layouts = {(new if key == old else key): value for key, value in self.layouts.items()}
self.write_layouts(layouts)
self.layouts = layouts
moved.append('layout')
elif old in self.layouts:
LOG.warning('Screen %s already has a layout; the one stored under %s stays untouched', new, old)
if self.updates.renamed(old, new):
moved.append('update history')
for store in (self.sent, self.status, self.last, self.pinged):
store.pop(old, None)
self.aliases = {**{key: (new if value == old else value) for key, value in self.aliases.items()}, old: new}
self._inbox_devices.pop(old, None)
LOG.info('Screen %s now reports as %s%s', old, new, f"; moved its {' and '.join(moved)}" if moved else '')
if moved:
self.history_wake.set()
self.ha.changed.set()
self.notify()
def firmware_version(self, inbox, screen=None): def firmware_version(self, inbox, screen=None):
if screen is None: if screen is None:
@@ -356,13 +429,15 @@ class Manager:
def save(self, inbox, data): def save(self, inbox, data):
screens, entities = self.inventory() screens, entities = self.inventory()
# A page opened before a screen's inbox got a new id still saves to the right screen.
inbox = self.aliases.get(inbox, inbox)
screen=next((s for s in screens if s['id']==inbox), None) screen=next((s for s in screens if s['id']==inbox), None)
if screen is None: if screen is None:
raise ValueError('Dit is geen gekoppeld ESP-scherm. Vernieuw het overzicht.') raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
layout = validate_layout(data) layout = validate_layout(data)
needed = self.needs_firmware(inbox, layout, screen) needed = self.needs_firmware(inbox, layout, screen)
if needed: if needed:
raise ValueError(f"Installeer eerst schermfirmware {needed} of nieuwer voor deze tegels.") raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
# A still-open older UI may save tiles without the new optional settings or top bar. # A still-open older UI may save tiles without the new optional settings or top bar.
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}): if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy() layout['settings'] = self.layouts[inbox]['settings'].copy()
@@ -376,7 +451,7 @@ class Manager:
if 'settings' in layout and 'rotation' not in data.get('settings',{}): if 'settings' in layout and 'rotation' not in data.get('settings',{}):
layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0) layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0)
if layout.get('settings',{}).get('rotation',0) and screen.get('board')!='guition': if layout.get('settings',{}).get('rotation',0) and screen.get('board')!='guition':
raise ValueError('Rotatie vereist een Guition met firmware 0.2.9 of nieuwer.') raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])} old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
for tile in layout['tiles']: for tile in layout['tiles']:
old_options=old_tiles.get(tile['entity'],{}).get('options',{}) old_options=old_tiles.get(tile['entity'],{}).get('options',{})
@@ -393,21 +468,14 @@ class Manager:
layout = validate_layout(layout) layout = validate_layout(layout)
known = {e['id'] for e in entities} | set(BUILTIN) known = {e['id'] for e in entities} | set(BUILTIN)
if any(t['entity'] not in known for t in layout['tiles']): if any(t['entity'] not in known for t in layout['tiles']):
raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.') raise ValueError('A chosen entity no longer exists. Look up the new entity.')
if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)): if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)):
raise ValueError('Een entiteit in de bovenbalk bestaat niet meer. Kies een andere.') raise ValueError('An entity in the top bar no longer exists. Choose a different one.')
updated = {**self.layouts, inbox: layout} updated = {**self.layouts, inbox: layout}
self.path.parent.mkdir(parents=True, exist_ok=True) self.write_layouts(updated)
temp = self.path.with_suffix('.tmp')
with open(temp, 'w', encoding='utf8') as handle:
os.chmod(temp, 0o600)
json.dump({'version': 1, 'screens': updated}, handle, ensure_ascii=False)
handle.flush()
os.fsync(handle.fileno())
temp.replace(self.path)
self.layouts = updated self.layouts = updated
self.sent.pop(inbox, None) self.sent.pop(inbox, None)
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie' self.status[inbox] = 'Saved; waiting for sync'
self.history_wake.set() self.history_wake.set()
self.ha.changed.set() self.ha.changed.set()
self.notify() self.notify()
@@ -497,7 +565,7 @@ class Manager:
screen = self.screen(inbox) or {} screen = self.screen(inbox) or {}
needed = self.needs_firmware(inbox, layout, screen) needed = self.needs_firmware(inbox, layout, screen)
if needed: if needed:
self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels" self.status[inbox]=f"Layout saved; firmware {needed}+ needed for these tiles"
return False return False
tiles = layout['tiles'] tiles = layout['tiles']
layout_msg = self.layout_message(inbox, layout, screen) layout_msg = self.layout_message(inbox, layout, screen)
@@ -540,7 +608,7 @@ class Manager:
self.last[inbox] = time.monotonic() self.last[inbox] = time.monotonic()
if outgoing: if outgoing:
self.pinged[inbox] = time.monotonic() self.pinged[inbox] = time.monotonic()
self.status[inbox] = 'Verzonden naar Home Assistant' self.status[inbox] = 'Sent to Home Assistant'
return bool(outgoing) return bool(outgoing)
async def ping(self, inbox, screen): async def ping(self, inbox, screen):
@@ -599,7 +667,7 @@ class Manager:
before = self.status.get(inbox) before = self.status.get(inbox)
if not screen['online']: if not screen['online']:
self.sent.pop(inbox, None) self.sent.pop(inbox, None)
self.status[inbox] = 'Scherm offline; wijzigingen bewaard' self.status[inbox] = 'Screen offline; changes saved'
changed = changed or self.status[inbox] != before changed = changed or self.status[inbox] != before
continue continue
if inbox not in self.layouts: if inbox not in self.layouts:
@@ -617,8 +685,8 @@ class Manager:
await self.ping(inbox, screen) await self.ping(inbox, screen)
except Exception as error: except Exception as error:
self.sent.pop(inbox, None) self.sent.pop(inbox, None)
self.status[inbox] = 'Verzenden mislukt; automatisch opnieuw proberen' self.status[inbox] = 'Sending failed; retrying automatically'
LOG.warning('Schermsynchronisatie opnieuw proberen (%s)', type(error).__name__) LOG.warning('Retrying screen sync (%s)', type(error).__name__)
await asyncio.sleep(1) await asyncio.sleep(1)
changed = changed or self.status.get(inbox) != before changed = changed or self.status.get(inbox) != before
if changed: if changed:
@@ -647,7 +715,7 @@ class Manager:
try: try:
found.update({(entity, hours): values for entity, values in (await self.ha.statistics(entities, hours)).items()}) found.update({(entity, hours): values for entity, values in (await self.ha.statistics(entities, hours)).items()})
except Exception as error: except Exception as error:
LOG.warning('Statistieken ophalen mislukt (%s); geschiedenis per sensor', type(error).__name__) LOG.warning('Fetching statistics failed (%s); falling back to per-sensor history', type(error).__name__)
changed = set() changed = set()
for key in due: for key in due:
values = found.get(key) values = found.get(key)
@@ -677,7 +745,7 @@ class Manager:
try: try:
await self.refresh_histories() await self.refresh_histories()
except Exception as error: except Exception as error:
LOG.warning('Geschiedenis vernieuwen mislukt (%s)', type(error).__name__) LOG.warning('Refreshing history failed (%s)', type(error).__name__)
def create_app(manager, development=False): def create_app(manager, development=False):
csrf = secrets.token_urlsafe(32) csrf = secrets.token_urlsafe(32)
@@ -685,15 +753,15 @@ def create_app(manager, development=False):
async def guard(request, handler): async def guard(request, handler):
allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'} allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'}
if request.remote not in allowed: if request.remote not in allowed:
raise web.HTTPForbidden(text='Open deze pagina via Home Assistant.') raise web.HTTPForbidden(text='Open this page through Home Assistant.')
if request.method not in {'GET', 'HEAD'} and request.headers.get('X-Screen-CSRF') != csrf: if request.method not in {'GET', 'HEAD'} and request.headers.get('X-Screen-CSRF') != csrf:
raise web.HTTPForbidden(text='Vernieuw deze pagina en probeer opnieuw.') raise web.HTTPForbidden(text='Refresh this page and try again.')
try: try:
response = await handler(request) response = await handler(request)
except ValueError as error: except ValueError as error:
return web.json_response({'error': str(error)}, status=400) return web.json_response({'error': str(error)}, status=400)
except (TypeError, KeyError): except (TypeError, KeyError):
return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400) return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
if response.prepared: if response.prepared:
return response # streamed (SSE) responses set their headers before prepare() return response # streamed (SSE) responses set their headers before prepare()
response.headers['Cache-Control'] = 'no-store' response.headers['Cache-Control'] = 'no-store'
@@ -712,8 +780,8 @@ def create_app(manager, development=False):
screens = manager.screens() screens = manager.screens()
profiles = manager.firmware.profile_names() profiles = manager.firmware.profile_names()
for screen in screens: for screen in screens:
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Thuis', 'tiles': []}) screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Home', 'tiles': []})
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels') screen['delivery'] = manager.status.get(screen['id'], 'Choose your first tiles')
screen['update'] = manager.updates.state_for(screen, profiles) screen['update'] = manager.updates.state_for(screen, profiles)
screen['alert_action'] = alert_service(screen.get('node')) screen['alert_action'] = alert_service(screen.get('node'))
screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert') screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert')
@@ -731,7 +799,7 @@ def create_app(manager, development=False):
payload['controls'] = controls_catalogue() payload['controls'] = controls_catalogue()
payload['icons'] = tile_icons.editor() payload['icons'] = tile_icons.editor()
payload['alerts'] = alert_reference() payload['alerts'] = alert_reference()
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()] payload['builtin'] = [{'id': key, 'name': name, 'device': 'Built into the screen', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
payload['header'] = {**header_bar.catalogue(), 'suggestions': { payload['header'] = {**header_bar.catalogue(), 'suggestions': {
screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}} screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}}
return web.json_response(payload) return web.json_response(payload)
@@ -778,9 +846,9 @@ def create_app(manager, development=False):
manager.updates.set_auto((await request.json()).get('auto')) manager.updates.set_auto((await request.json()).get('auto'))
return web.json_response(manager.updates.summary()) return web.json_response(manager.updates.summary())
async def inspector(request): async def inspector(request):
inbox=request.match_info['inbox'] screen=manager.screen(request.match_info['inbox'])
screen=manager.screen(inbox) inbox=manager.aliases.get(request.match_info['inbox'], request.match_info['inbox'])
if screen is None: raise ValueError('Onbekend scherm.') if screen is None: raise ValueError('Unknown screen.')
layout=manager.layouts.get(inbox,{'tiles':[]}) layout=manager.layouts.get(inbox,{'tiles':[]})
return web.json_response({'screen':screen, return web.json_response({'screen':screen,
'delivery':manager.status.get(inbox), 'layout':layout, 'delivery':manager.status.get(inbox), 'layout':layout,
@@ -819,7 +887,7 @@ async def main():
if development and os.environ.get('HA_TOKEN_FILE'): if development and os.environ.get('HA_TOKEN_FILE'):
token = Path(os.environ['HA_TOKEN_FILE']).read_text().strip() token = Path(os.environ['HA_TOKEN_FILE']).read_text().strip()
if not token: if not token:
raise SystemExit('Geen Home Assistant-toegang. Start de app via Supervisor.') raise SystemExit('No Home Assistant access. Start the app via Supervisor.')
async with ClientSession(timeout=ClientTimeout(total=20)) as session: async with ClientSession(timeout=ClientTimeout(total=20)) as session:
ha = HomeAssistant(session, os.environ.get('HA_API', 'http://supervisor/core/api'), token) ha = HomeAssistant(session, os.environ.get('HA_API', 'http://supervisor/core/api'), token)
manager = Manager(ha, Path(os.environ.get('SCREEN_DATA', '/data')) / 'screens.json') manager = Manager(ha, Path(os.environ.get('SCREEN_DATA', '/data')) / 'screens.json')
File diff suppressed because it is too large Load Diff
+163 -163
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="nl"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
@@ -13,28 +13,28 @@
<span class="mark">▦</span> <span class="mark">▦</span>
<div> <div>
<strong>ESP Screens</strong <strong>ESP Screens</strong
><small>Tegels voor je CYD en Guition, zonder opnieuw flashen</small> ><small>Tiles for your CYD and Guition, without reflashing</small>
</div> </div>
</div> </div>
<nav class="toolbar" aria-label="Acties"> <nav class="toolbar" aria-label="Actions">
<button id="new-screen" class="tool tool-primary"> <button id="new-screen" class="tool tool-primary">
<span class="tool-icon" aria-hidden="true">+</span> <span class="tool-icon" aria-hidden="true">+</span>
<span class="tool-text" <span class="tool-text"
><b>Nieuw scherm</b><small>Aansluiten en installeren</small></span ><b>New screen</b><small>Connect and install</small></span
> >
</button> </button>
<button id="open-firmware" class="tool"> <button id="open-firmware" class="tool">
<span class="tool-icon" aria-hidden="true">⇪</span> <span class="tool-icon" aria-hidden="true">⇪</span>
<span class="tool-text" <span class="tool-text"
><b>Firmware & USB</b ><b>Firmware & USB</b
><small>Bouwen en installeren via USB of wifi</small></span ><small>Build and install via USB or Wi-Fi</small></span
> >
</button> </button>
<button id="open-alerts" class="tool"> <button id="open-alerts" class="tool">
<span class="tool-icon" aria-hidden="true">!</span> <span class="tool-icon" aria-hidden="true">!</span>
<span class="tool-text" <span class="tool-text"
><b>Alerts</b ><b>Alerts</b
><small>Meldingen over het hele scherm, vanuit een automatisering</small></span ><small>Full-screen notifications, from an automation</small></span
> >
</button> </button>
</nav> </nav>
@@ -43,26 +43,26 @@
<div class="intro"> <div class="intro">
<div> <div>
<p class="eyebrow">HOME ASSISTANT</p> <p class="eyebrow">HOME ASSISTANT</p>
<h1>Beheer je remote screens hier.</h1> <h1>Manage your remote screens here.</h1>
</div> </div>
<span id="connection" role="status" class="badge">Verbinden…</span> <span id="connection" role="status" class="badge">Connecting…</span>
</div> </div>
<div class="workspace"> <div class="workspace">
<aside> <aside>
<h2>Mijn schermen</h2> <h2>My screens</h2>
<div id="screens"></div> <div id="screens"></div>
<div id="pending"></div> <div id="pending"></div>
<button class="quiet" id="refresh">↻ Vernieuwen</button> <button class="quiet" id="refresh">↻ Refresh</button>
<div class="updates" id="updates" hidden> <div class="updates" id="updates" hidden>
<h2>Firmware-updates</h2> <h2>Firmware updates</h2>
<p class="hint" id="updates-hint"></p> <p class="hint" id="updates-hint"></p>
<button id="update-all" hidden>Alle schermen bijwerken</button> <button id="update-all" hidden>Update all screens</button>
<label class="check"> <label class="check">
<input type="checkbox" id="auto-update" /> <input type="checkbox" id="auto-update" />
<span <span
>Elke nacht automatisch bijwerken<small >Update automatically every night<small
>Tussen 03:00 en 06:00, één scherm tegelijk. Stopt bij een >Between 03:00 and 06:00, one screen at a time. Stops on an
fout en meldt dat in Home Assistant.</small error and reports it in Home Assistant.</small
></span ></span
> >
</label> </label>
@@ -70,35 +70,35 @@
</aside> </aside>
<section id="empty" class="empty"> <section id="empty" class="empty">
<span class="empty-icon">▦</span> <span class="empty-icon">▦</span>
<h2>Begin met een scherm</h2> <h2>Start with a screen</h2>
<p> <p>
Sluit een CYD of Guition via USB aan op je Home Assistant-machine. Connect a CYD or Guition via USB to your Home Assistant machine.
Profiel, firmware en de eerste flash gebeuren in één venster. Profile, firmware, and the first flash happen in one window.
</p> </p>
<button id="start">Nieuw scherm installeren</button> <button id="start">Install new screen</button>
</section> </section>
<section id="editor" hidden> <section id="editor" hidden>
<div class="editor-head"> <div class="editor-head">
<div> <div>
<p class="eyebrow" id="screen-name"></p> <p class="eyebrow" id="screen-name"></p>
<h2>Maak het jouw scherm</h2> <h2>Make it your screen</h2>
</div> </div>
<span id="delivery" class="badge"></span> <span id="delivery" class="badge"></span>
</div> </div>
<section class="topbar-editor" id="topbar" aria-labelledby="topbar-title"> <section class="topbar-editor" id="topbar" aria-labelledby="topbar-title">
<div class="section-title"> <div class="section-title">
<h3 id="topbar-title">Bovenbalk</h3> <h3 id="topbar-title">Top bar</h3>
<span id="topbar-count"></span> <span id="topbar-count"></span>
</div> </div>
<div class="topbar-fields"> <div class="topbar-fields">
<label class="topbar-name" <label class="topbar-name"
>Links: naam<input >Left: name<input
id="title" id="title"
maxlength="60" maxlength="60"
placeholder="Bijvoorbeeld: Woonkamer" placeholder="For example: Living room"
/></label> /></label>
<div class="topbar-right"> <div class="topbar-right">
<span class="topbar-caption" id="topbar-caption">Rechts: tijd, sensoren of een klok</span> <span class="topbar-caption" id="topbar-caption">Right: time, sensors, or a clock</span>
<div <div
id="topbar-chips" id="topbar-chips"
class="topbar-chips" class="topbar-chips"
@@ -112,24 +112,24 @@
<div class="columns" id="tile-section"> <div class="columns" id="tile-section">
<div class="tile-editor"> <div class="tile-editor">
<div class="section-title"> <div class="section-title">
<h3>Je tegels</h3> <h3>Your tiles</h3>
<span id="count">0 / 10</span> <span id="count">0 / 10</span>
</div> </div>
<div id="layout-preview" aria-label="Schermindeling"></div> <div id="layout-preview" aria-label="Screen layout"></div>
<button id="add-page" class="quiet add-page" type="button"> <button id="add-page" class="quiet add-page" type="button">
+ Pagina toevoegen + Add page
</button> </button>
<p id="no-tiles" class="hint"> <p id="no-tiles" class="hint">
Voeg je eerste lamp, scène of apparaat toe via de kiezer. Add your first light, scene, or device via the picker.
</p> </p>
<p id="positions-hint" class="hint" hidden></p> <p id="positions-hint" class="hint" hidden></p>
</div> </div>
<div class="picker"> <div class="picker">
<label for="search">Voeg een entiteit toe</label <label for="search">Add an entity</label
><input ><input
id="search" id="search"
type="search" type="search"
placeholder="Zoek op naam, apparaat of ruimte…" placeholder="Search by name, device, or room…"
autocomplete="off" autocomplete="off"
/> />
<div class="filters" id="filters"></div> <div class="filters" id="filters"></div>
@@ -138,36 +138,36 @@
</div> </div>
<details class="screen-settings" id="general-settings" open> <details class="screen-settings" id="general-settings" open>
<summary> <summary>
Scherminstellingen Screen settings
<span class="hint">Helderheid, rustmomenten en weergave</span> <span class="hint">Brightness, quiet hours, and display</span>
</summary> </summary>
<p class="hint" id="settings-support"></p> <p class="hint" id="settings-support"></p>
<div class="settings-grid" id="settings-fields"></div> <div class="settings-grid" id="settings-fields"></div>
<p class="hint"> <p class="hint">
Nachturen gelden voor de helderheid tijdens standby. De eerste Night hours apply to the brightness during standby. The first
aanraking maakt het scherm wakker; de volgende bedient je huis. touch wakes the screen; the next one controls your home.
Gelijke begin- en eindtijd schakelen het nachtvenster uit. Matching start and end times turn the night window off.
</p> </p>
</details> </details>
<details class="screen-settings" id="inspector-section"> <details class="screen-settings" id="inspector-section">
<summary> <summary>
Inspector Inspector
<span class="hint" <span class="hint"
>Verbinding, status en ondersteunde eigenschappen</span >Connection, status, and supported properties</span
> >
</summary> </summary>
<button id="inspect" class="quiet">Lees actuele gegevens</button> <button id="inspect" class="quiet">Read current data</button>
<div id="inspection-summary"></div> <div id="inspection-summary"></div>
<pre id="inspection">Selecteer een scherm en lees de gegevens.</pre> <pre id="inspection">Select a screen and read the data.</pre>
</details> </details>
<footer class="savebar"> <footer class="savebar">
<div> <div>
<strong id="dirty">Alles opgeslagen</strong <strong id="dirty">All saved</strong
><small id="save-detail" ><small id="save-detail"
>Aanpassen kan zonder opnieuw flashen.</small >Changes apply without reflashing.</small
> >
</div> </div>
<button id="save">Opslaan & naar scherm</button> <button id="save">Save & send to screen</button>
</footer> </footer>
</section> </section>
</div> </div>
@@ -176,29 +176,29 @@
<form id="install-form"> <form id="install-form">
<div class="dialog-head"> <div class="dialog-head">
<div> <div>
<p class="eyebrow">NIEUW SCHERM</p> <p class="eyebrow">NEW SCREEN</p>
<h2 id="install-title">Aansluiten en installeren.</h2> <h2 id="install-title">Connect and install.</h2>
</div> </div>
<button <button
type="button" type="button"
class="quiet" class="quiet"
id="close-install" id="close-install"
aria-label="Sluiten" aria-label="Close"
> >
✕ ✕
</button> </button>
</div> </div>
<div id="install-setup"> <div id="install-setup">
<p> <p>
Sluit het scherm met een USB-datakabel aan op de machine waarop Home Connect the screen with a USB data cable to the machine running Home
Assistant draait. ESP Screens maakt een eigen profiel met unieke Assistant. ESP Screens creates its own profile with unique
sleutels, bouwt de firmware en zet die direct op het scherm. keys, builds the firmware, and puts it straight on the screen.
</p> </p>
<fieldset> <fieldset>
<legend>Welk scherm heb je?</legend> <legend>Which screen do you have?</legend>
<label class="board" <label class="board"
><input type="radio" name="board" value="cyd" checked /><span ><input type="radio" name="board" value="cyd" checked /><span
><b>CYD · 2,8 inch</b ><b>CYD · 2.8 inch</b
><small>ESP32-2432S028 · 320 × 240</small></span ><small>ESP32-2432S028 · 320 × 240</small></span
></label ></label
><label class="board" ><label class="board"
@@ -209,51 +209,51 @@
> >
</fieldset> </fieldset>
<label <label
>Naam<input >Name<input
name="friendly_name" name="friendly_name"
required required
maxlength="60" maxlength="60"
placeholder="Scherm woonkamer" placeholder="Living room screen"
autocomplete="off" /><small class="node-line" autocomplete="off" /><small class="node-line"
>Apparaatnaam <code id="node-preview">scherm</code >Device name <code id="node-preview">screen</code
><button type="button" class="link" id="edit-node"> ><button type="button" class="link" id="edit-node">
aanpassen customize
</button></small </button></small
><small ><small
>Elk scherm krijgt een eigen profiel met eigen sleutels. Vier >Every screen gets its own profile with its own keys. Four
schermen? Doorloop dit vier keer met een andere naam.</small screens? Go through this four times with a different name.</small
></label ></label
> >
<label id="node-label" hidden <label id="node-label" hidden
>Apparaatnaam<input >Device name<input
name="name" name="name"
pattern="[a-z][a-z0-9-]{0,29}" pattern="[a-z][a-z0-9-]{0,29}"
maxlength="30" maxlength="30"
autocomplete="off" /><small autocomplete="off" /><small
>Kleine letters, cijfers en streepjes. Elk scherm een andere >Lowercase letters, digits, and dashes. A different name for
naam.</small each screen.</small
></label ></label
> >
<fieldset id="wifi-fields" class="wifi" hidden disabled> <fieldset id="wifi-fields" class="wifi" hidden disabled>
<legend>Wifi</legend> <legend>Wi-Fi</legend>
<p class="hint" id="wifi-status"></p> <p class="hint" id="wifi-status"></p>
<label id="wifi-ssid-label" <label id="wifi-ssid-label"
>Wifi-naam (2,4 GHz)<input name="wifi_ssid" autocomplete="off" >Wi-Fi name (2.4 GHz)<input name="wifi_ssid" autocomplete="off"
/></label> /></label>
<label id="wifi-password-label" <label id="wifi-password-label"
>Wifiwachtwoord<input >Wi-Fi password<input
name="wifi_password" name="wifi_password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
/></label> /></label>
</fieldset> </fieldset>
<label <label
>Installeren via<select name="target" id="install-target"></select >Install via<select name="target" id="install-target"></select
><small id="target-hint"></small ><small id="target-hint"></small
></label> ></label>
<p class="hint" id="install-note"></p> <p class="hint" id="install-note"></p>
<div class="install-actions"> <div class="install-actions">
<button type="submit" id="install-go">Installeren</button> <button type="submit" id="install-go">Install</button>
<span id="install-status" role="status"></span> <span id="install-status" role="status"></span>
</div> </div>
</div> </div>
@@ -266,21 +266,21 @@
<p id="progress-detail"></p> <p id="progress-detail"></p>
<div id="install-result" hidden> <div id="install-result" hidden>
<div class="key-box"> <div class="key-box">
<span>API-sleutel</span><code id="api-key"></code> <span>API key</span><code id="api-key"></code>
<button type="button" class="mini" id="copy-key">Kopieer</button> <button type="button" class="mini" id="copy-key">Copy</button>
</div> </div>
<ol class="steps" id="install-steps"></ol> <ol class="steps" id="install-steps"></ol>
</div> </div>
<details id="install-log-wrap"> <details id="install-log-wrap">
<summary>Log van ESPHome</summary> <summary>ESPHome log</summary>
<pre id="install-log"></pre> <pre id="install-log"></pre>
</details> </details>
<div class="install-actions"> <div class="install-actions">
<button type="button" id="install-retry" hidden> <button type="button" id="install-retry" hidden>
Opnieuw proberen Retry
</button> </button>
<button type="button" class="quiet" id="install-close"> <button type="button" class="quiet" id="install-close">
Sluiten Close
</button> </button>
</div> </div>
</div> </div>
@@ -290,187 +290,187 @@
<div class="dialog-head"> <div class="dialog-head">
<div> <div>
<p class="eyebrow">FIRMWARE & USB</p> <p class="eyebrow">FIRMWARE & USB</p>
<h2>Schermfirmware bouwen en installeren</h2> <h2>Build and install screen firmware</h2>
</div> </div>
<button id="close-firmware" class="quiet" aria-label="Sluiten">✕</button> <button id="close-firmware" class="quiet" aria-label="Close">✕</button>
</div> </div>
<p> <p>
De ingebouwde ESPHome CLI gebruikt je eigen profiel (YAML met sleutels) The built-in ESPHome CLI uses your own profile (YAML with keys)
en secrets. Kies het profiel van het scherm, dan een USB-poort (scherm and secrets. Choose the screen's profile, then a USB port (screen
aan de Home Assistant-machine) of <b>Wifi / OTA</b> met de hostnaam. connected to the Home Assistant machine) or <b>Wi-Fi / OTA</b> with the hostname.
<b>Controleren</b> valideert alleen, <b>Alleen bouwen</b> compileert, <b>Check</b> only validates, <b>Build only</b> compiles,
<b>Bouwen & installeren</b> zet de firmware op het scherm. Je tegels <b>Build & install</b> puts the firmware on the screen. Your tiles
blijven in ESP Screens staan. De eerste build duurt enkele minuten. stay as they are in ESP Screens. The first build takes a few minutes.
</p> </p>
<label>ESPHome-profiel<select id="firmware-file"></select></label> <label>ESPHome profile<select id="firmware-file"></select></label>
<label <label
>Installeren naar<select id="firmware-port"> >Install to<select id="firmware-port">
<option value="ota">Wifi / OTA</option> <option value="ota">Wi-Fi / OTA</option>
</select></label </select></label
> >
<label id="firmware-host-label" <label id="firmware-host-label"
>IP-adres of hostnaam<input >IP address or hostname<input
id="firmware-host" id="firmware-host"
placeholder="scherm-woonkamer.local" placeholder="screen-livingroom.local"
/></label> /></label>
<div class="yaml-actions"> <div class="yaml-actions">
<button id="firmware-validate" class="quiet">Controleren</button <button id="firmware-validate" class="quiet">Check</button
><button id="firmware-build" class="quiet">Alleen bouwen</button ><button id="firmware-build" class="quiet">Build only</button
><button id="firmware-install">Bouwen & installeren</button> ><button id="firmware-install">Build & install</button>
</div> </div>
<p id="firmware-status" role="status"></p> <p id="firmware-status" role="status"></p>
<pre id="firmware-log"></pre> <pre id="firmware-log"></pre>
</dialog> </dialog>
<dialog id="tile-sheet" class="tile-sheet" aria-label="Tegel instellen"></dialog> <dialog id="tile-sheet" class="tile-sheet" aria-label="Configure tile"></dialog>
<dialog id="topbar-sheet" class="tile-sheet" aria-label="Bovenbalk"></dialog> <dialog id="topbar-sheet" class="tile-sheet" aria-label="Top bar"></dialog>
<dialog id="alerts-dialog" class="alerts" aria-label="Alerts"> <dialog id="alerts-dialog" class="alerts" aria-label="Alerts">
<div class="dialog-head"> <div class="dialog-head">
<div> <div>
<p class="eyebrow">ALERTS · CHEATSHEET</p> <p class="eyebrow">ALERTS · CHEATSHEET</p>
<h2>Een melding over het hele scherm, vanuit een automatisering.</h2> <h2>A full-screen notification, from an automation.</h2>
</div> </div>
<button id="close-alerts" class="quiet" aria-label="Sluiten">✕</button> <button id="close-alerts" class="quiet" aria-label="Close">✕</button>
</div> </div>
<p> <p>
Elk scherm heeft de actie <code>show_alert</code>. Die legt een kaart over alles Every screen has the <code>show_alert</code> action. It places a card over everything,
heen, wekt het scherm en houdt de backlight op de normale helderheid tot iemand op wakes the screen, and keeps the backlight at normal brightness until someone taps
de knop tikt of de timeout verstrijkt. Home Assistant toont bij ESPHome-acties the button or the timeout runs out. Home Assistant itself shows no explanation or
zelf geen uitleg of keuzelijsten; alles wat je kunt invullen staat hier. dropdowns for ESPHome actions; everything you can fill in is listed here.
</p> </p>
<div class="alert-hero"> <div class="alert-hero">
<div class="alert-mock" id="alert-mock" aria-hidden="true"> <div class="alert-mock" id="alert-mock" aria-hidden="true">
<div class="alert-mock-card"> <div class="alert-mock-card">
<span class="mdi alert-mock-icon" id="alert-mock-icon"></span> <span class="mdi alert-mock-icon" id="alert-mock-icon"></span>
<strong id="alert-mock-title">Iemand belt aan</strong> <strong id="alert-mock-title">Someone is at the door</strong>
<span id="alert-mock-subtitle">Deur 3, achterkant</span> <span id="alert-mock-subtitle">Door 3, back</span>
<button type="button" id="alert-mock-button" tabindex="-1">Ik kom</button> <button type="button" id="alert-mock-button" tabindex="-1">Coming</button>
</div> </div>
</div> </div>
<nav class="alerts-nav" aria-label="Onderdelen"> <nav class="alerts-nav" aria-label="Sections">
<button type="button" data-jump="alerts-screens">Jouw schermen</button> <button type="button" data-jump="alerts-screens">Your screens</button>
<button type="button" data-jump="alerts-howto">In een automatisering</button> <button type="button" data-jump="alerts-howto">In an automation</button>
<button type="button" data-jump="alerts-fields">De velden</button> <button type="button" data-jump="alerts-fields">The fields</button>
<button type="button" data-jump="alerts-icons">Iconen</button> <button type="button" data-jump="alerts-icons">Icons</button>
<button type="button" data-jump="alerts-colors">Kleuren</button> <button type="button" data-jump="alerts-colors">Colors</button>
<button type="button" data-jump="alerts-behaviour">Gedrag</button> <button type="button" data-jump="alerts-behaviour">Behavior</button>
<button type="button" data-jump="alerts-events">Events</button> <button type="button" data-jump="alerts-events">Events</button>
<button type="button" data-jump="alerts-tips">Tips</button> <button type="button" data-jump="alerts-tips">Tips</button>
</nav> </nav>
</div> </div>
<section id="alerts-screens"> <section id="alerts-screens">
<h3>Jouw schermen</h3> <h3>Your screens</h3>
<p> <p>
Elke scherm heeft zijn eigen actie, met de apparaatnaam erin. Vanaf firmware Every screen has its own action, with the device name in it. From firmware
<code id="alerts-min-firmware">0.2.31</code>; een ouder scherm werk je bij onder <code id="alerts-min-firmware">0.2.31</code>; update an older screen under
Firmware-updates. Firmware updates.
</p> </p>
<div id="alerts-screen-list"></div> <div id="alerts-screen-list"></div>
</section> </section>
<section id="alerts-howto"> <section id="alerts-howto">
<h3>In een automatisering</h3> <h3>In an automation</h3>
<ol class="steps"> <ol class="steps">
<li><b>Actie toevoegen</b> en zoek op <b>ESPHome</b>. Kies de actie van je scherm: <li><b>Add action</b> and search for <b>ESPHome</b>. Choose your screen's action:
Home Assistant noemt hem "Performs the action show_alert of the node Home Assistant calls it "Performs the action show_alert of the node
<i>apparaatnaam</i>".</li> <i>device name</i>".</li>
<li><b>Vul de velden in.</b> Home Assistant vraagt alle zeven; laat wat je niet <li><b>Fill in the fields.</b> Home Assistant asks for all seven; leave what you don't
gebruikt leeg (<code>""</code>, <code>0</code>, uit).</li> use empty (<code>""</code>, <code>0</code>, off).</li>
<li>Liever YAML? Kies <b>Bewerken in YAML</b> bij de actie en plak het voorbeeld <li>Prefer YAML? Choose <b>Edit in YAML</b> on the action and paste the example
hieronder.</li> below.</li>
</ol> </ol>
<div class="alert-example"> <div class="alert-example">
<label <label
>Voorbeeld voor<select id="alerts-example-screen"></select >Example for<select id="alerts-example-screen"></select
></label> ></label>
<div class="copy-line"> <div class="copy-line">
<pre id="alerts-example"></pre> <pre id="alerts-example"></pre>
<button type="button" class="mini" id="alerts-example-copy">Kopieer YAML</button> <button type="button" class="mini" id="alerts-example-copy">Copy YAML</button>
</div> </div>
</div> </div>
</section> </section>
<section id="alerts-fields"> <section id="alerts-fields">
<h3>De velden</h3> <h3>The fields</h3>
<p> <p>
Teksten zijn vrij; de limiet is in bytes en een letter met accent telt voor twee. Text is free-form; the limit is in bytes, and an accented letter counts as two.
Wat niet past krijgt puntjes (titel) of valt weg (subtitle). What doesn't fit gets an ellipsis (title) or is dropped (subtitle).
</p> </p>
<div class="table-scroll"><table id="alerts-field-table"></table></div> <div class="table-scroll"><table id="alerts-field-table"></table></div>
</section> </section>
<section id="alerts-icons"> <section id="alerts-icons">
<h3>Iconen</h3> <h3>Icons</h3>
<p> <p>
Vul de naam in bij <code>icon</code>, bijvoorbeeld <code>doorbell</code>. Fill in the name in <code>icon</code>, for example <code>doorbell</code>.
<code>mdi:doorbell</code> en de hex-codepoint (<code>F12E6</code>) mogen ook. <code>mdi:doorbell</code> and the hex codepoint (<code>F12E6</code>) also work.
Alleen deze iconen zitten in de firmware; een andere naam geeft de Only these icons are included in the firmware; any other name shows the
waarschuwingsdriehoek. Tik op een icoon om de naam te kopiëren. warning triangle. Tap an icon to copy its name.
</p> </p>
<p class="alerts-subhead">Handig voor meldingen</p> <p class="alerts-subhead">Handy for notifications</p>
<div class="chips" id="alerts-suggested"></div> <div class="chips" id="alerts-suggested"></div>
<label class="alerts-search" <label class="alerts-search"
>Zoek een icoon<input >Search for an icon<input
id="alerts-icon-search" id="alerts-icon-search"
type="search" type="search"
placeholder="Zoek op naam of omschrijving…" placeholder="Search by name or description…"
autocomplete="off" autocomplete="off"
/></label> /></label>
<div id="alerts-icon-groups"></div> <div id="alerts-icon-groups"></div>
</section> </section>
<section id="alerts-colors"> <section id="alerts-colors">
<h3>Kleuren</h3> <h3>Colors</h3>
<p> <p>
Vul de naam in bij <code>color</code>. Dit zijn de pasteltinten van de tegels, Fill in the name in <code>color</code>. These are the pastel shades of the tiles,
met donkere tekst. Leeg of onbekend geeft de witte kaart. Tik om te kopiëren. with dark text. Empty or unknown gives the white card. Tap to copy.
</p> </p>
<div class="swatches" id="alerts-swatches"></div> <div class="swatches" id="alerts-swatches"></div>
</section> </section>
<section id="alerts-behaviour"> <section id="alerts-behaviour">
<h3>Gedrag</h3> <h3>Behavior</h3>
<ul class="alerts-list"> <ul class="alerts-list">
<li><b>Wekken.</b> Een alert maakt een gedimd of donker scherm meteen wakker en <li><b>Wakes the screen.</b> An alert immediately wakes a dimmed or dark screen and
komt boven elke pagina, elke open lichtkaart en de standby-laag.</li> appears above every page, every open light card, and the standby layer.</li>
<li><b>Aan tot de knop.</b> Zolang de kaart staat blijft de backlight op de <li><b>On until the button.</b> As long as the card is showing, the backlight stays at
normale helderheid. Standby en de nachturen wachten. Na de knop start de gewone normal brightness. Standby and night hours wait. After the button, the regular
standby-tijd opnieuw.</li> standby time starts again.</li>
<li><b>Timeout.</b> <code>timeout: 0</code> wacht op de knop, hoe lang ook. Met <li><b>Timeout.</b> <code>timeout: 0</code> waits for the button, however long that
<code>timeout: 60</code> verdwijnt de kaart na een minuut, maar de knop sluit takes. With <code>timeout: 60</code> the card disappears after a minute, but the
hem eerder.</li> button closes it sooner.</li>
<li><b>Knipperen.</b> <code>flash: true</code> knippert de backlight vier keer <li><b>Blinking.</b> <code>flash: true</code> blinks the backlight four times
bij binnenkomst (ongeveer een seconde) en laat het scherm daarna aan.</li> when the alert arrives (about a second) and leaves the screen on afterward.</li>
<li><b>Vervangen.</b> Een nieuwe alert vervangt de huidige; er is geen stapel. <li><b>Replacing.</b> A new alert replaces the current one; there's no stack.
De vervangen alert meldt zich als <code>replaced</code>.</li> The replaced alert reports itself as <code>replaced</code>.</li>
<li><b>Op afstand sluiten.</b> <code>dismiss_alert</code> haalt de kaart weg, <li><b>Closing remotely.</b> <code>dismiss_alert</code> clears the card,
bijvoorbeeld als de deur al open is. Zonder alert doet die actie niets.</li> for example if the door is already open. Without an alert, that action does nothing.</li>
<li><b>Tikken naast de kaart</b> doet niets; alleen de knop sluit. Zo drukt een <li><b>Tapping beside the card</b> does nothing; only the button closes it. That way a
wek-tik de alert nooit per ongeluk weg.</li> wake-up tap never accidentally dismisses the alert.</li>
</ul> </ul>
</section> </section>
<section id="alerts-events"> <section id="alerts-events">
<h3>Events</h3> <h3>Events</h3>
<p> <p>
Elk einde vuurt het event <code id="alerts-event-name">esphome.screen_alert</code> Every end fires the event <code id="alerts-event-name">esphome.screen_alert</code>
met <code>action</code>, <code>title</code>, <code>screen</code> (de apparaatnaam) with <code>action</code>, <code>title</code>, <code>screen</code> (the device name)
en het <code>device_id</code> dat Home Assistant toevoegt. Te zien onder and the <code>device_id</code> that Home Assistant adds. Visible under
Ontwikkelhulpmiddelen → Events. Settings → Tools → Events.
</p> </p>
<div class="table-scroll"><table id="alerts-ending-table"></table></div> <div class="table-scroll"><table id="alerts-ending-table"></table></div>
<p class="alerts-subhead">Wachten tot iemand op de knop drukt</p> <p class="alerts-subhead">Waiting until someone presses the button</p>
<div class="copy-line"> <div class="copy-line">
<pre id="alerts-wait-example"></pre> <pre id="alerts-wait-example"></pre>
<button type="button" class="mini" id="alerts-wait-copy">Kopieer YAML</button> <button type="button" class="mini" id="alerts-wait-copy">Copy YAML</button>
</div> </div>
</section> </section>
<section id="alerts-tips"> <section id="alerts-tips">
<h3>Tips</h3> <h3>Tips</h3>
<ul class="alerts-list"> <ul class="alerts-list">
<li>Gebruik een <b>kleur per soort melding</b>: oranje voor de deur, rood voor <li>Use a <b>color per kind of notification</b>: orange for the door, red for
rook of water, groen voor "klaar". De tekst blijft altijd donker en leesbaar.</li> smoke or water, green for "done". The text always stays dark and readable.</li>
<li>Houd de titel <b>kort</b>; de CYD toont ongeveer veertig tekens op één regel. <li>Keep the title <b>short</b>; the CYD shows about forty characters on one line.
Zet details in de subtitle.</li> Put details in the subtitle.</li>
<li>Een <b>eigen knoptekst</b> maakt de melding een vraag: "Ik kom", "Gezien", <li>A <b>custom button text</b> turns the notification into a question: "Coming", "Seen",
"Uitzetten". Het event zegt dan wie reageerde.</li> "Turn off". The event then tells you who responded.</li>
<li>Een alert die zichzelf oplost (de deur gaat open, de was is eruit gehaald) <li>For an alert that resolves itself (the door opens, the laundry gets taken out)
sluit je in dezelfde automatisering met <code>dismiss_alert</code>.</li> close it in the same automation with <code>dismiss_alert</code>.</li>
<li>Meerdere schermen? Roep de actie van elk scherm aan; ze werken los van <li>Multiple screens? Call each screen's action; they work independently and
elkaar en melden zich elk met hun eigen <code>screen</code>.</li> each reports with its own <code>screen</code>.</li>
</ul> </ul>
</section> </section>
</dialog> </dialog>
+1 -1
View File
@@ -929,7 +929,7 @@ pre {
.icon-picker .hint { margin: 0; } .icon-picker .hint { margin: 0; }
.icon-hint { font-weight: 400; } .icon-hint { font-weight: 400; }
/* Nieuw scherm: profiel, wifi en de eerste flash in één venster. */ /* New screen: profile, Wi-Fi, and the first flash in one window. */
.link { .link {
background: none; background: none;
color: var(--accent); color: var(--accent);
+135 -135
View File
@@ -8,179 +8,179 @@ fonts/materialdesignicons-webfont.ttf. Firmware 0.2.18+ carries every glyph here
# (group, ((MDI name, codepoint, label), ...)) in picker order. # (group, ((MDI name, codepoint, label), ...)) in picker order.
GROUPS = ( GROUPS = (
('Verlichting', ( ('Lighting', (
('lightbulb', 'F0335', 'Lamp'), ('lightbulb', 'F0335', 'Light bulb'),
('lightbulb-group', 'F1253', 'Lampen'), ('lightbulb-group', 'F1253', 'Lights'),
('ceiling-light', 'F0769', 'Plafondlamp'), ('ceiling-light', 'F0769', 'Ceiling light'),
('chandelier', 'F1793', 'Kroonluchter'), ('chandelier', 'F1793', 'Chandelier'),
('lamp', 'F06B5', 'Tafellamp'), ('lamp', 'F06B5', 'Table lamp'),
('floor-lamp', 'F08DD', 'Vloerlamp'), ('floor-lamp', 'F08DD', 'Floor lamp'),
('desk-lamp', 'F095F', 'Bureaulamp'), ('desk-lamp', 'F095F', 'Desk lamp'),
('wall-sconce-flat', 'F091D', 'Wandlamp'), ('wall-sconce-flat', 'F091D', 'Wall light'),
('light-recessed', 'F179B', 'Inbouwspot'), ('light-recessed', 'F179B', 'Recessed light'),
('spotlight-beam', 'F04C9', 'Spot'), ('spotlight-beam', 'F04C9', 'Spotlight'),
('led-strip-variant', 'F1051', 'Ledstrip'), ('led-strip-variant', 'F1051', 'LED strip'),
('outdoor-lamp', 'F1054', 'Buitenlamp'), ('outdoor-lamp', 'F1054', 'Outdoor light'),
)), )),
('Ruimtes', ( ('Rooms', (
('home', 'F02DC', 'Huis'), ('home', 'F02DC', 'House'),
('sofa', 'F04B9', 'Woonkamer'), ('sofa', 'F04B9', 'Living room'),
('bed', 'F02E3', 'Slaapkamer'), ('bed', 'F02E3', 'Bedroom'),
('silverware-fork-knife', 'F0A70', 'Eetkamer'), ('silverware-fork-knife', 'F0A70', 'Dining room'),
('chef-hat', 'F0B7C', 'Keuken'), ('chef-hat', 'F0B7C', 'Kitchen'),
('shower', 'F09A0', 'Douche'), ('shower', 'F09A0', 'Shower'),
('bathtub', 'F1818', 'Badkamer'), ('bathtub', 'F1818', 'Bathroom'),
('toilet', 'F09AB', 'Toilet'), ('toilet', 'F09AB', 'Toilet'),
('desk', 'F1239', 'Werkkamer'), ('desk', 'F1239', 'Office'),
('garage', 'F06D9', 'Garage'), ('garage', 'F06D9', 'Garage'),
('fireplace', 'F0E2E', 'Open haard'), ('fireplace', 'F0E2E', 'Fireplace'),
)), )),
('Klimaat', ( ('Climate', (
('thermometer', 'F050F', 'Temperatuur'), ('thermometer', 'F050F', 'Temperature'),
('thermostat', 'F0393', 'Thermostaat'), ('thermostat', 'F0393', 'Thermostat'),
('air-conditioner', 'F001B', 'Airco'), ('air-conditioner', 'F001B', 'AC'),
('radiator', 'F0438', 'Radiator'), ('radiator', 'F0438', 'Radiator'),
('heat-pump', 'F1A43', 'Warmtepomp'), ('heat-pump', 'F1A43', 'Heat pump'),
('fire', 'F0238', 'Verwarming'), ('fire', 'F0238', 'Heating'),
('snowflake', 'F0717', 'Koelen'), ('snowflake', 'F0717', 'Cooling'),
('water-percent', 'F058E', 'Luchtvochtigheid'), ('water-percent', 'F058E', 'Humidity'),
('fan', 'F0210', 'Ventilator'), ('fan', 'F0210', 'Fan'),
('ceiling-fan', 'F1797', 'Plafondventilator'), ('ceiling-fan', 'F1797', 'Ceiling fan'),
('air-purifier', 'F0D44', 'Luchtreiniger'), ('air-purifier', 'F0D44', 'Air purifier'),
('molecule-co2', 'F07E4', 'CO₂'), ('molecule-co2', 'F07E4', 'CO₂'),
)), )),
('Weer', ( ('Weather', (
('weather-sunny', 'F0599', 'Zon'), ('weather-sunny', 'F0599', 'Sun'),
('weather-partly-cloudy', 'F0595', 'Half bewolkt'), ('weather-partly-cloudy', 'F0595', 'Partly cloudy'),
('weather-cloudy', 'F0590', 'Bewolkt'), ('weather-cloudy', 'F0590', 'Cloudy'),
('weather-rainy', 'F0597', 'Regen'), ('weather-rainy', 'F0597', 'Rain'),
('weather-snowy', 'F0598', 'Sneeuw'), ('weather-snowy', 'F0598', 'Snow'),
('weather-windy', 'F059D', 'Wind'), ('weather-windy', 'F059D', 'Wind'),
('weather-night', 'F0594', 'Nacht'), ('weather-night', 'F0594', 'Night'),
('weather-sunset-up', 'F059C', 'Zonsopkomst'), ('weather-sunset-up', 'F059C', 'Sunrise'),
)), )),
('Media en muziek', ( ('Media and music', (
('music-note', 'F0387', 'Muziek'), ('music-note', 'F0387', 'Music'),
('music', 'F075A', 'Muzieknoten'), ('music', 'F075A', 'Music notes'),
('playlist-music', 'F0CB8', 'Afspeellijst'), ('playlist-music', 'F0CB8', 'Playlist'),
('album', 'F0025', 'Album'), ('album', 'F0025', 'Album'),
('record-player', 'F099A', 'Platenspeler'), ('record-player', 'F099A', 'Record player'),
('spotify', 'F04C7', 'Spotify'), ('spotify', 'F04C7', 'Spotify'),
('radio', 'F0439', 'Radio'), ('radio', 'F0439', 'Radio'),
('podcast', 'F0994', 'Podcast'), ('podcast', 'F0994', 'Podcast'),
('speaker', 'F04C3', 'Speaker'), ('speaker', 'F04C3', 'Speaker'),
('speaker-multiple', 'F0D38', 'Speakers'), ('speaker-multiple', 'F0D38', 'Speakers'),
('soundbar', 'F17DB', 'Soundbar'), ('soundbar', 'F17DB', 'Soundbar'),
('headphones', 'F02CB', 'Koptelefoon'), ('headphones', 'F02CB', 'Headphones'),
('piano', 'F067D', 'Piano'), ('piano', 'F067D', 'Piano'),
('guitar-acoustic', 'F0771', 'Gitaar'), ('guitar-acoustic', 'F0771', 'Guitar'),
('television', 'F0502', 'Televisie'), ('television', 'F0502', 'TV'),
('projector', 'F042E', 'Beamer'), ('projector', 'F042E', 'Projector'),
('movie-open', 'F0FCE', 'Film'), ('movie-open', 'F0FCE', 'Movie'),
('cast', 'F0118', 'Casten'), ('cast', 'F0118', 'Cast'),
('gamepad-variant', 'F0297', 'Gameconsole'), ('gamepad-variant', 'F0297', 'Game console'),
('remote-tv', 'F0EC5', 'Afstandsbediening'), ('remote-tv', 'F0EC5', 'Remote control'),
)), )),
('Beveiliging', ( ('Security', (
('lock', 'F033E', 'Slot'), ('lock', 'F033E', 'Lock'),
('lock-open-variant', 'F0FC6', 'Slot open'), ('lock-open-variant', 'F0FC6', 'Lock open'),
('shield-home', 'F068A', 'Alarm'), ('shield-home', 'F068A', 'Alarm'),
('alarm-light', 'F078F', 'Alarmlicht'), ('alarm-light', 'F078F', 'Alarm light'),
('bell', 'F009A', 'Bel'), ('bell', 'F009A', 'Bell'),
('doorbell', 'F12E6', 'Deurbel'), ('doorbell', 'F12E6', 'Doorbell'),
('cctv', 'F07AE', 'Camera'), ('cctv', 'F07AE', 'Camera'),
('motion-sensor', 'F0D91', 'Bewegingsmelder'), ('motion-sensor', 'F0D91', 'Motion sensor'),
('door-closed', 'F081B', 'Deur'), ('door-closed', 'F081B', 'Door'),
('door-open', 'F081C', 'Deur open'), ('door-open', 'F081C', 'Door open'),
('window-closed-variant', 'F11DB', 'Raam'), ('window-closed-variant', 'F11DB', 'Window'),
('smoke-detector', 'F0392', 'Rookmelder'), ('smoke-detector', 'F0392', 'Smoke detector'),
('water-alert', 'F1502', 'Waterlek'), ('water-alert', 'F1502', 'Water leak'),
)), )),
('Apparaten', ( ('Appliances', (
('washing-machine', 'F072A', 'Wasmachine'), ('washing-machine', 'F072A', 'Washing machine'),
('tumble-dryer', 'F0917', 'Droger'), ('tumble-dryer', 'F0917', 'Dryer'),
('dishwasher', 'F0AAC', 'Vaatwasser'), ('dishwasher', 'F0AAC', 'Dishwasher'),
('fridge', 'F0290', 'Koelkast'), ('fridge', 'F0290', 'Fridge'),
('stove', 'F04DE', 'Fornuis'), ('stove', 'F04DE', 'Stove'),
('microwave', 'F0C99', 'Magnetron'), ('microwave', 'F0C99', 'Microwave'),
('toaster-oven', 'F0CD3', 'Oven'), ('toaster-oven', 'F0CD3', 'Oven'),
('coffee-maker', 'F109F', 'Koffie'), ('coffee-maker', 'F109F', 'Coffee'),
('kettle', 'F05FA', 'Waterkoker'), ('kettle', 'F05FA', 'Kettle'),
('robot-vacuum', 'F070D', 'Robotstofzuiger'), ('robot-vacuum', 'F070D', 'Robot vacuum'),
('robot-mower', 'F11F7', 'Robotmaaier'), ('robot-mower', 'F11F7', 'Robot mower'),
('printer-3d', 'F042B', '3D-printer'), ('printer-3d', 'F042B', '3D printer'),
('power-plug', 'F06A5', 'Stekker'), ('power-plug', 'F06A5', 'Plug'),
('power-socket-eu', 'F07E7', 'Stopcontact'), ('power-socket-eu', 'F07E7', 'Socket'),
('laptop', 'F0322', 'Laptop'), ('laptop', 'F0322', 'Laptop'),
('router-wireless', 'F0469', 'Router'), ('router-wireless', 'F0469', 'Router'),
)), )),
('Energie', ( ('Energy', (
('flash', 'F0241', 'Stroom'), ('flash', 'F0241', 'Power'),
('solar-power', 'F0A72', 'Zonne-energie'), ('solar-power', 'F0A72', 'Solar power'),
('solar-panel', 'F0D9B', 'Zonnepanelen'), ('solar-panel', 'F0D9B', 'Solar panels'),
('home-battery', 'F1901', 'Thuisbatterij'), ('home-battery', 'F1901', 'Home battery'),
('battery-high', 'F12A3', 'Batterij'), ('battery-high', 'F12A3', 'Battery'),
('ev-station', 'F05F1', 'Laadpaal'), ('ev-station', 'F05F1', 'Charging station'),
('car-electric', 'F0B6C', 'Elektrische auto'), ('car-electric', 'F0B6C', 'Electric car'),
('meter-electric', 'F1A57', 'Stroommeter'), ('meter-electric', 'F1A57', 'Power meter'),
('meter-gas', 'F1A59', 'Gasmeter'), ('meter-gas', 'F1A59', 'Gas meter'),
('water', 'F058C', 'Water'), ('water', 'F058C', 'Water'),
('water-boiler', 'F0F92', 'Boiler'), ('water-boiler', 'F0F92', 'Boiler'),
('gauge', 'F029A', 'Meter'), ('gauge', 'F029A', 'Meter'),
)), )),
('Zonwering', ( ('Window coverings', (
('window-shutter', 'F111C', 'Rolluik'), ('window-shutter', 'F111C', 'Shutter'),
('window-shutter-open', 'F111E', 'Rolluik open'), ('window-shutter-open', 'F111E', 'Shutter open'),
('blinds', 'F00AC', 'Jaloezie'), ('blinds', 'F00AC', 'Blinds'),
('blinds-open', 'F1011', 'Jaloezie open'), ('blinds-open', 'F1011', 'Blinds open'),
('roller-shade-closed', 'F1A6C', 'Rolgordijn'), ('roller-shade-closed', 'F1A6C', 'Roller blind'),
('curtains', 'F1846', 'Gordijnen'), ('curtains', 'F1846', 'Curtains'),
('curtains-closed', 'F1847', 'Gordijnen dicht'), ('curtains-closed', 'F1847', 'Curtains closed'),
('awning-outline', 'F1B88', 'Zonnescherm'), ('awning-outline', 'F1B88', 'Awning'),
)), )),
('Tuin en huisdieren', ( ('Garden and pets', (
('flower', 'F024A', 'Bloem'), ('flower', 'F024A', 'Flower'),
('tree', 'F0531', 'Boom'), ('tree', 'F0531', 'Tree'),
('sprout', 'F0E66', 'Plant'), ('sprout', 'F0E66', 'Plant'),
('grass', 'F1510', 'Gazon'), ('grass', 'F1510', 'Lawn'),
('sprinkler', 'F105F', 'Sproeier'), ('sprinkler', 'F105F', 'Sprinkler'),
('pool', 'F0606', 'Zwembad'), ('pool', 'F0606', 'Pool'),
('grill', 'F0E45', 'Barbecue'), ('grill', 'F0E45', 'Barbecue'),
('mailbox', 'F06EE', 'Brievenbus'), ('mailbox', 'F06EE', 'Mailbox'),
('trash-can', 'F0A79', 'Afval'), ('trash-can', 'F0A79', 'Trash'),
('recycle', 'F044C', 'Recycling'), ('recycle', 'F044C', 'Recycling'),
('dog', 'F0A43', 'Hond'), ('dog', 'F0A43', 'Dog'),
('cat', 'F011B', 'Kat'), ('cat', 'F011B', 'Cat'),
)), )),
('Mensen en onderweg', ( ('People and travel', (
('account', 'F0004', 'Persoon'), ('account', 'F0004', 'Person'),
('account-group', 'F0849', 'Gezin'), ('account-group', 'F0849', 'Family'),
('account-child', 'F0A89', 'Kind'), ('account-child', 'F0A89', 'Child'),
('home-account', 'F0826', 'Thuis'), ('home-account', 'F0826', 'Home'),
('sleep', 'F04B2', 'Slapen'), ('sleep', 'F04B2', 'Sleeping'),
('run', 'F070E', 'Sporten'), ('run', 'F070E', 'Exercising'),
('briefcase', 'F00D6', 'Werk'), ('briefcase', 'F00D6', 'Work'),
('car', 'F010B', 'Auto'), ('car', 'F010B', 'Car'),
('bike', 'F00A3', 'Fiets'), ('bike', 'F00A3', 'Bike'),
('airplane', 'F001D', 'Vakantie'), ('airplane', 'F001D', 'Vacation'),
('map-marker', 'F034E', 'Locatie'), ('map-marker', 'F034E', 'Location'),
)), )),
('Overig', ( ('Other', (
('power', 'F0425', 'Aan/uit'), ('power', 'F0425', 'On/off'),
('toggle-switch', 'F0521', 'Schakelaar'), ('toggle-switch', 'F0521', 'Switch'),
('gesture-tap-button', 'F12A8', 'Knop'), ('gesture-tap-button', 'F12A8', 'Button'),
('play', 'F040A', 'Start'), ('play', 'F040A', 'Start'),
('script-text', 'F0BC2', 'Script'), ('script-text', 'F0BC2', 'Script'),
('palette', 'F03D8', 'Scène'), ('palette', 'F03D8', 'Scene'),
('alarm', 'F0020', 'Wekker'), ('alarm', 'F0020', 'Alarm clock'),
('clock-outline', 'F0150', 'Klok'), ('clock-outline', 'F0150', 'Clock'),
('timer-outline', 'F051B', 'Timer'), ('timer-outline', 'F051B', 'Timer'),
('calendar', 'F00ED', 'Agenda'), ('calendar', 'F00ED', 'Calendar'),
('bell-ring', 'F009E', 'Melding'), ('bell-ring', 'F009E', 'Notification'),
('heart', 'F02D1', 'Favoriet'), ('heart', 'F02D1', 'Favorite'),
('alert-outline', 'F002A', 'Waarschuwing'), ('alert-outline', 'F002A', 'Warning'),
('cog', 'F0493', 'Instellingen'), ('cog', 'F0493', 'Settings'),
('broom', 'F00E2', 'Schoonmaken'), ('broom', 'F00E2', 'Cleaning'),
)), )),
) )
+46 -24
View File
@@ -34,7 +34,7 @@ class Updater:
if self.path.exists(): if self.path.exists():
raw = json.loads(self.path.read_text()) raw = json.loads(self.path.read_text())
if raw.get('version') != 1: if raw.get('version') != 1:
raise ValueError('Onbekende opslagversie voor updates; gegevens blijven ongewijzigd.') raise ValueError('Unknown storage version for updates; data stays unchanged.')
self.auto = raw.get('auto') is True self.auto = raw.get('auto') is True
self.hosts = {k: v for k, v in raw.get('hosts', {}).items() if isinstance(v, str)} self.hosts = {k: v for k, v in raw.get('hosts', {}).items() if isinstance(v, str)}
self.results = {k: v for k, v in raw.get('results', {}).items() if isinstance(v, dict)} self.results = {k: v for k, v in raw.get('results', {}).items() if isinstance(v, dict)}
@@ -57,6 +57,26 @@ class Updater:
def screen(self, inbox): def screen(self, inbox):
return self.manager.screen(inbox) return self.manager.screen(inbox)
def current_id(self, inbox):
"""The id a screen reports under now; firmware 0.2.34 gave the inbox entity a new id."""
return getattr(self.manager, 'aliases', {}).get(inbox, inbox)
def renamed(self, old, new):
"""A screen's inbox entity got a new id: its address, last result and place in a running round follow."""
changed = False
for store in (self.hosts, self.results):
if old in store:
if new not in store:
store[new] = store[old]
del store[old]
changed = True
if changed:
self.save()
if self.current == old:
self.current = new
self.queue = [new if inbox == old else inbox for inbox in self.queue]
return changed
def resolve(self, screen, profiles=None): def resolve(self, screen, profiles=None):
"""Profile file and OTA address for a screen; None when the add-on cannot tell.""" """Profile file and OTA address for a screen; None when the add-on cannot tell."""
if profiles is None: if profiles is None:
@@ -97,39 +117,40 @@ class Updater:
def set_auto(self, enabled): def set_auto(self, enabled):
if not isinstance(enabled, bool): if not isinstance(enabled, bool):
raise ValueError('Kies aan of uit voor automatisch bijwerken.') raise ValueError('Choose on or off for automatic updates.')
self.auto = enabled self.auto = enabled
self.save() self.save()
def start(self, inbox, host=None): def start(self, inbox, host=None):
if self.busy(): if self.busy():
raise ValueError('Er loopt al een update. Wacht tot die klaar is.') raise ValueError('An update is already running. Wait for it to finish.')
screen = self.screen(inbox) screen = self.screen(inbox)
inbox = self.current_id(inbox)
if not screen: if not screen:
raise ValueError('Onbekend scherm. Vernieuw het overzicht.') raise ValueError('Unknown screen. Refresh the overview.')
if not screen['online']: if not screen['online']:
raise ValueError('Dit scherm is offline; bijwerken kan zodra het terug is.') raise ValueError("This screen is offline; updating is possible once it's back.")
if not self.state_for(screen)['available']: if not self.state_for(screen)['available']:
raise ValueError('Dit scherm heeft al de nieuwste firmware.') raise ValueError('This screen already has the latest firmware.')
if host is not None: if host is not None:
if not isinstance(host, str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', host.strip()): if not isinstance(host, str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', host.strip()):
raise ValueError('Vul het IP-adres van dit scherm in.') raise ValueError("Enter this screen's IP address.")
self.hosts[inbox] = host.strip() self.hosts[inbox] = host.strip()
self.save() self.save()
profile, host = self.resolve(screen) profile, host = self.resolve(screen)
if not profile: if not profile:
raise ValueError('Geen ESPHome-profiel gevonden voor dit scherm. Controleer de naam in de ESPHome-map.') raise ValueError('No ESPHome profile found for this screen. Check the name in the ESPHome folder.')
if not host: if not host:
raise ValueError('IP-adres onbekend. Vul het eenmalig in; nieuwe firmware meldt het daarna zelf.') raise ValueError('IP address unknown. Enter it once; new firmware reports it itself after that.')
self.launch([inbox]) self.launch([inbox])
return self.state_for(screen) return self.state_for(screen)
def start_all(self): def start_all(self):
if self.busy(): if self.busy():
raise ValueError('Er loopt al een update. Wacht tot die klaar is.') raise ValueError('An update is already running. Wait for it to finish.')
pending = self.pending() pending = self.pending()
if not pending: if not pending:
raise ValueError('Alle bereikbare schermen zijn al bijgewerkt.') raise ValueError('All reachable screens are already updated.')
self.launch(pending) self.launch(pending)
return pending return pending
@@ -139,19 +160,20 @@ class Updater:
self.task = asyncio.create_task(self.run_round(inboxes, automatic)) self.task = asyncio.create_task(self.run_round(inboxes, automatic))
def record(self, inbox, state, message): def record(self, inbox, state, message):
self.results[inbox] = {'time': time.time(), 'state': state, 'message': message, 'version': FIRMWARE_VERSION} self.results[self.current_id(inbox)] = {'time': time.time(), 'state': state, 'message': message, 'version': FIRMWARE_VERSION}
self.save() self.save()
async def run_round(self, inboxes, automatic=False): async def run_round(self, inboxes, automatic=False):
"""Update screens one by one; a failure ends the round so a bad build never reaches the next screen.""" """Update screens one by one; a failure ends the round so a bad build never reaches the next screen."""
try: try:
for index, inbox in enumerate(inboxes): for index, inbox in enumerate(inboxes):
self.current, self.queue = inbox, inboxes[index+1:] inbox = self.current_id(inbox)
self.current, self.queue = inbox, [self.current_id(i) for i in inboxes[index+1:]]
outcome = await self.update_one(inbox) outcome = await self.update_one(inbox)
if outcome == 'failed': if outcome == 'failed':
if automatic: if automatic:
await self.notify(f"Automatisch bijwerken is gestopt bij {self.name(inbox)}: " await self.notify(f"Automatic update stopped at {self.name(inbox)}: "
f"{self.results[inbox]['message']} De overige schermen zijn niet aangeraakt.") f"{self.results[self.current_id(inbox)]['message']} The remaining screens were not touched.")
break break
if self.queue and outcome == 'success': if self.queue and outcome == 'success':
await asyncio.sleep(self.pause_seconds) await asyncio.sleep(self.pause_seconds)
@@ -165,11 +187,11 @@ class Updater:
async def update_one(self, inbox): async def update_one(self, inbox):
screen = self.screen(inbox) screen = self.screen(inbox)
if not screen or not screen['online']: if not screen or not screen['online']:
self.record(inbox, 'skipped', 'Scherm was offline; volgende keer opnieuw.') self.record(inbox, 'skipped', 'Screen was offline; will retry next time.')
return 'skipped' return 'skipped'
profile, host = self.resolve(screen) profile, host = self.resolve(screen)
if not profile or not host: if not profile or not host:
self.record(inbox, 'skipped', 'Profiel of IP-adres onbekend; werk dit scherm handmatig bij.') self.record(inbox, 'skipped', 'Profile or IP address unknown; update this screen manually.')
return 'skipped' return 'skipped'
self.phase = 'install' self.phase = 'install'
try: try:
@@ -179,19 +201,19 @@ class Updater:
self.record(inbox, 'failed', str(error)) self.record(inbox, 'failed', str(error))
return 'failed' return 'failed'
if self.manager.firmware.job.get('state') != 'success': if self.manager.firmware.job.get('state') != 'success':
self.record(inbox, 'failed', 'Bouwen of installeren mislukt; bekijk het log onder Firmware & USB.') self.record(inbox, 'failed', 'Build or install failed; see the log under Firmware & USB.')
return 'failed' return 'failed'
self.phase = 'verify' self.phase = 'verify'
if not await self.wait_for_target(inbox): if not await self.wait_for_target(inbox):
self.record(inbox, 'failed', f'Scherm meldde zich niet met firmware {FIRMWARE_VERSION}; controleer het scherm.') self.record(inbox, 'failed', f"Screen didn't report back with firmware {FIRMWARE_VERSION}; check the screen.")
return 'failed' return 'failed'
self.phase = 'settle' self.phase = 'settle'
await asyncio.sleep(self.settle_seconds) await asyncio.sleep(self.settle_seconds)
current = self.screen(inbox) current = self.screen(inbox)
if not current or not current['online']: if not current or not current['online']:
self.record(inbox, 'failed', 'Scherm viel weg na de update; controleer het scherm.') self.record(inbox, 'failed', 'Screen dropped off after the update; check the screen.')
return 'failed' return 'failed'
self.record(inbox, 'success', f'Bijgewerkt naar firmware {FIRMWARE_VERSION}.') self.record(inbox, 'success', f'Updated to firmware {FIRMWARE_VERSION}.')
return 'success' return 'success'
async def wait_for_target(self, inbox): async def wait_for_target(self, inbox):
@@ -209,7 +231,7 @@ class Updater:
await self.manager.ha.request('call_service', domain='persistent_notification', service='create', await self.manager.ha.request('call_service', domain='persistent_notification', service='create',
service_data={'notification_id': 'esp_screens_update', 'title': 'ESP Screens', 'message': message}) service_data={'notification_id': 'esp_screens_update', 'title': 'ESP Screens', 'message': message})
except Exception as error: except Exception as error:
LOG.warning('Melding naar Home Assistant mislukt (%s)', type(error).__name__) LOG.warning('Notifying Home Assistant failed (%s)', type(error).__name__)
def due(self, now): def due(self, now):
return self.auto and now.hour in NIGHT_HOURS and self.last_round != now.date().isoformat() return self.auto and now.hour in NIGHT_HOURS and self.last_round != now.date().isoformat()
@@ -227,7 +249,7 @@ class Updater:
self.save() self.save()
pending = self.pending() pending = self.pending()
if pending: if pending:
LOG.info('Nachtelijke firmwareronde: %d scherm(en)', len(pending)) LOG.info('Nightly firmware round: %d screen(s)', len(pending))
self.launch(pending, automatic=True) self.launch(pending, automatic=True)
except Exception as error: except Exception as error:
LOG.warning('Nachtelijke updatecontrole overgeslagen (%s)', type(error).__name__) LOG.warning('Nightly update check skipped (%s)', type(error).__name__)
+2 -2
View File
@@ -1,7 +1,7 @@
name: ESP Screen Manager name: ESP Screen Manager
version: "0.2.39" version: "0.2.40"
slug: esp_screen_manager slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen. description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen url: https://github.com/MaxGramser/homeassistant_espscreen
arch: [aarch64, amd64] arch: [aarch64, amd64]
startup: application startup: application
+6 -6
View File
@@ -1,6 +1,6 @@
# tools/new_device.py maakt unieke sleutels; bewerk wifi daarna lokaal. # tools/new_device.py creates unique keys; edit Wi-Fi locally afterward.
wifi_ssid: "VUL_JE_2_4_GHZ_WIFI_IN" wifi_ssid: "FILL_IN_YOUR_2_4_GHZ_WIFI"
wifi_password: "VUL_JE_WIFI_WACHTWOORD_IN" wifi_password: "FILL_IN_YOUR_WIFI_PASSWORD"
api_encryption_key: "WORDT_UNIEK_GEGENEREERD" api_encryption_key: "WILL_BE_UNIQUELY_GENERATED"
ota_password: "WORDT_UNIEK_GEGENEREERD" ota_password: "WILL_BE_UNIQUELY_GENERATED"
ap_password: "WORDT_UNIEK_GEGENEREERD" ap_password: "WILL_BE_UNIQUELY_GENERATED"
+8 -8
View File
@@ -3,8 +3,8 @@
#include <cstdio> #include <cstdio>
int main() { int main() {
using screen_alert::make; using screen_alert::make;
const auto alert = make("Iemand belt aan", "Deur 3, achterkant", "doorbell", "orange", "Ik kom", 60, true, 48, 160, 12); const auto alert = make("Someone's at the door", "Door 3, back entrance", "doorbell", "orange", "Coming", 60, true, 48, 160, 12);
assert(alert.title == "Iemand belt aan" && alert.subtitle == "Deur 3, achterkant" && alert.button == "Ik kom"); assert(alert.title == "Someone's at the door" && alert.subtitle == "Door 3, back entrance" && alert.button == "Coming");
assert(alert.icon == tile_icon::utf8(0xF12E6) && alert.color == 0xFFE1C6 && alert.timeout_seconds == 60 && alert.flash); assert(alert.icon == tile_icon::utf8(0xF12E6) && alert.color == 0xFFE1C6 && alert.timeout_seconds == 60 && alert.flash);
// Icon: a name from the set, with mdi: prefix, spaces or capitals, or the hex codepoint of a carried glyph. // Icon: a name from the set, with mdi: prefix, spaces or capitals, or the hex codepoint of a carried glyph.
for (const char *icon : {"doorbell", "mdi:doorbell", " Doorbell ", "F12E6", "f12e6", "mdi:F12E6"}) for (const char *icon : {"doorbell", "mdi:doorbell", " Doorbell ", "F12E6", "f12e6", "mdi:F12E6"})
@@ -17,20 +17,20 @@ int main() {
assert(make("x", "", "", " mint ", "", 0, false, 48, 160, 12).color == 0xD5F0EA); assert(make("x", "", "", " mint ", "", 0, false, 48, 160, 12).color == 0xD5F0EA);
for (const char *color : {"", "none", "auto", "#ff0000", "0xFF0000"}) for (const char *color : {"", "none", "auto", "#ff0000", "0xFF0000"})
assert(make("x", "", "", color, "", 0, false, 48, 160, 12).color == 0xFFFFFF); assert(make("x", "", "", color, "", 0, false, 48, 160, 12).color == 0xFFFFFF);
// Timeout: 0 and negatives wait for Oké; a day is the ceiling. // Timeout: 0 and negatives wait for OK; a day is the ceiling.
assert(make("x", "", "", "", "", 0, false, 48, 160, 12).timeout_seconds == 0); assert(make("x", "", "", "", "", 0, false, 48, 160, 12).timeout_seconds == 0);
assert(make("x", "", "", "", "", -5, false, 48, 160, 12).timeout_seconds == 0); assert(make("x", "", "", "", "", -5, false, 48, 160, 12).timeout_seconds == 0);
assert(make("x", "", "", "", "", 999999, false, 48, 160, 12).timeout_seconds == 86400); assert(make("x", "", "", "", "", 999999, false, 48, 160, 12).timeout_seconds == 86400);
assert(!make("x", "", "", "", "", 0, false, 48, 160, 12).flash); assert(!make("x", "", "", "", "", 0, false, 48, 160, 12).flash);
// Title: trimmed, never empty, cut on a UTF-8 boundary; the subtitle keeps its line breaks. // Title: trimmed, never empty, cut on a UTF-8 boundary; the subtitle keeps its line breaks.
assert(make(" ", "", "", "", "", 0, false, 48, 160, 12).title == "Melding"); assert(make(" ", "", "", "", "", 0, false, 48, 160, 12).title == "Notification");
assert(make(" Hoi \n", "", "", "", "", 0, false, 48, 160, 12).title == "Hoi"); assert(make(" Hi \n", "", "", "", "", 0, false, 48, 160, 12).title == "Hi");
assert(make("\xC3\xA9\xC3\xA9\xC3\xA9", "", "", "", "", 0, false, 3, 160, 12).title == "\xC3\xA9"); assert(make("\xC3\xA9\xC3\xA9\xC3\xA9", "", "", "", "", 0, false, 3, 160, 12).title == "\xC3\xA9");
assert(make("x", "regel 1\nregel 2", "", "", "", 0, false, 48, 160, 12).subtitle == "regel 1\nregel 2"); assert(make("x", "line 1\nline 2", "", "", "", 0, false, 48, 160, 12).subtitle == "line 1\nline 2");
assert(make("x", std::string(300, 'a'), "", "", "", 0, false, 48, 160, 12).subtitle.size() == 160); assert(make("x", std::string(300, 'a'), "", "", "", 0, false, 48, 160, 12).subtitle.size() == 160);
assert(make(std::string(100, 'b'), "", "", "", "", 0, false, 48, 160, 12).title.size() == 48); assert(make(std::string(100, 'b'), "", "", "", "", 0, false, 48, 160, 12).title.size() == 48);
// Button: trimmed and capped; empty falls back to Oké. // Button: trimmed and capped; empty falls back to OK.
assert(make("x", "", "", "", "", 0, false, 48, 160, 12).button == "Ok\xC3\xA9"); assert(make("x", "", "", "", "", 0, false, 48, 160, 12).button == "OK");
assert(make("x", "", "", "", " Open ", 0, false, 48, 160, 12).button == "Open"); assert(make("x", "", "", "", " Open ", 0, false, 48, 160, 12).button == "Open");
assert(make("x", "", "", "", std::string(40, 'c'), 0, false, 48, 160, 12).button.size() == 12); assert(make("x", "", "", "", std::string(40, 'c'), 0, false, 48, 160, 12).button.size() == 12);
// The generated table carries every font glyph by name. // The generated table carries every font glyph by name.
+2 -2
View File
@@ -49,7 +49,7 @@ class ReferenceTests(unittest.TestCase):
def test_action_names_follow_home_assistant_and_the_firmware_is_current(self): def test_action_names_follow_home_assistant_and_the_firmware_is_current(self):
self.assertEqual(alert_service('studio-1'), 'esphome.studio_1_show_alert') self.assertEqual(alert_service('studio-1'), 'esphome.studio_1_show_alert')
self.assertEqual(alert_service('keuken-scherm', 'dismiss_alert'), 'esphome.keuken_scherm_dismiss_alert') self.assertEqual(alert_service('kitchen-screen', 'dismiss_alert'), 'esphome.kitchen_screen_dismiss_alert')
for missing in (None, '', 3): for missing in (None, '', 3):
self.assertIsNone(alert_service(missing)) self.assertIsNone(alert_service(missing))
self.assertLessEqual(parse_version(ALERT_MIN_FIRMWARE), parse_version(FIRMWARE_VERSION)) self.assertLessEqual(parse_version(ALERT_MIN_FIRMWARE), parse_version(FIRMWARE_VERSION))
@@ -77,7 +77,7 @@ class InventoryTests(unittest.IsolatedAsyncioTestCase):
{'entity_id': 'sensor.studio_1_apparaatnaam', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'}, {'entity_id': 'sensor.studio_1_apparaatnaam', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'},
{'entity_id': 'sensor.studio_1_schermfirmware', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'}, {'entity_id': 'sensor.studio_1_schermfirmware', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'},
{'entity_id': 'text.oud_tegelinstellingen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd2'}] {'entity_id': 'text.oud_tegelinstellingen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd2'}]
devices = [{'id': 'd1', 'name': 'Studio 1'}, {'id': 'd2', 'name': 'Oud scherm'}] devices = [{'id': 'd1', 'name': 'Studio 1'}, {'id': 'd2', 'name': 'Old screen'}]
areas = [] areas = []
states = {'text.studio_1_tegelinstellingen': {'state': 'Ready'}, 'sensor.studio_1_apparaatnaam': {'state': 'studio-1'}, states = {'text.studio_1_tegelinstellingen': {'state': 'Ready'}, 'sensor.studio_1_apparaatnaam': {'state': 'studio-1'},
'sensor.studio_1_schermfirmware': {'state': '0.2.31'}, 'text.oud_tegelinstellingen': {'state': 'Ready'}} 'sensor.studio_1_schermfirmware': {'state': '0.2.31'}, 'text.oud_tegelinstellingen': {'state': 'Ready'}}
+4 -4
View File
@@ -61,21 +61,21 @@ int main() {
wide.update(275, 200, 3); wide.update(300, 200, 3); wide.update(340, 200, 3); // a real swipe: the reference wide.update(275, 200, 3); wide.update(300, 200, 3); wide.update(340, 200, 3); // a real swipe: the reference
wide.update(380, 200, 3); // settles, the finger keeps going wide.update(380, 200, 3); // settles, the finger keeps going
assert(!wide.accept(1100, 1)); assert(!wide.accept(1100, 1));
assert(wide.reason().rfind("verplaatst", 0) == 0); assert(wide.reason().rfind("moved", 0) == 0);
wide.begin(2000, 200, 200, 3); wide.begin(2000, 200, 200, 3);
wide.update(400, 400, 5); // a second finger elsewhere wide.update(400, 400, 5); // a second finger elsewhere
assert(wide.accept(2050, 1)); // ignored: only contact 3 is followed; 50 ms is long enough here assert(wide.accept(2050, 1)); // ignored: only contact 3 is followed; 50 ms is long enough here
wide.begin(3000, 200, 200, 3); wide.begin(3000, 200, 200, 3);
assert(!wide.accept(3010, 2)); // 10 ms is below the capacitive minimum assert(!wide.accept(3010, 2)); // 10 ms is below the capacitive minimum
assert(wide.reason().rfind("te kort", 0) == 0); assert(wide.reason().rfind("too short", 0) == 0);
wide.begin(3100, 200, 200, 3); wide.begin(3100, 200, 200, 3);
assert(wide.accept(3160, 2)); assert(wide.accept(3160, 2));
assert(wide.reason().empty()); assert(wide.reason().empty());
assert(!wide.accept(3170, 2)); assert(!wide.accept(3170, 2));
assert(wide.reason() == "al verwerkt in dit contact"); assert(wide.reason() == "already handled in this contact");
wide.begin(3200, 200, 200, 3); wide.begin(3200, 200, 200, 3);
assert(!wide.accept(3260, 2)); // same tile within 600 ms assert(!wide.accept(3260, 2)); // same tile within 600 ms
assert(wide.reason() == "dezelfde knop binnen de dendertijd"); assert(wide.reason() == "same button within the debounce window");
// No movement limit (0): a capacitive board leaves the drift decision to LVGL's press-lost. // No movement limit (0): a capacitive board leaves the drift decision to LVGL's press-lost.
cyd::TouchGuard free; cyd::TouchGuard free;
free.configure(0, 20); free.configure(0, 20);
+8 -8
View File
@@ -19,7 +19,7 @@ class ExtendedTests(unittest.TestCase):
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'sensor.x','options':options}]}) with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'sensor.x','options':options}]})
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'light.x','options':{'display':'watch','inline':'slider'}}]}) with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'light.x','options':{'display':'watch','inline':'slider'}}]})
def test_attributes_and_history_fit_protocol(self): def test_attributes_and_history_fit_protocol(self):
tile={'entity':'sensor.a','name':'Temperatuur','options':{'history_hours':24,'display':'watch'}} tile={'entity':'sensor.a','name':'Temperature','options':{'history_hours':24,'display':'watch'}}
msg=state_message(0,tile,{'sensor.a':{'state':'21','attributes':{'unit_of_measurement':'°C','password':'hidden'}}}) msg=state_message(0,tile,{'sensor.a':{'state':'21','attributes':{'unit_of_measurement':'°C','password':'hidden'}}})
msg['history']={'hours':24,'values':[None,21.5]*12} msg['history']={'hours':24,'values':[None,21.5]*12}
self.assertNotIn('password',msg['a']);self.assertEqual(msg['o'],tile['options']);self.assertTrue(all(len(p)<=255 for p in packets(msg))) self.assertNotIn('password',msg['a']);self.assertEqual(msg['o'],tile['options']);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
@@ -53,7 +53,7 @@ class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json') m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]}) m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]})
m.save('text.screen',{'title':'Nieuw','tiles':[{'entity':'light.a','name':'Lamp'}]}) m.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','name':'Lamp'}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'}) self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})
class SpecialTiles(unittest.TestCase): class SpecialTiles(unittest.TestCase):
@@ -79,11 +79,11 @@ class SpecialTiles(unittest.TestCase):
self.assertEqual(extras({'entity':'timer.egg'},states,None,tz),{'end':1789300800,'dur':'0:05:00','rem':'0:05:00'}) self.assertEqual(extras({'entity':'timer.egg'},states,None,tz),{'end':1789300800,'dur':'0:05:00','rem':'0:05:00'})
forecast=[{'datetime':f'2026-09-{13+i}T10:00:00+00:00','condition':'partlycloudy','temperature':20+i,'templow':11.5} for i in range(7)]+['junk'] forecast=[{'datetime':f'2026-09-{13+i}T10:00:00+00:00','condition':'partlycloudy','temperature':20+i,'templow':11.5} for i in range(7)]+['junk']
days=extras({'entity':'weather.home'},states,forecast,tz)['days'] days=extras({'entity':'weather.home'},states,forecast,tz)['days']
self.assertEqual([d['d'] for d in days],['zo','ma','di','wo','do']) self.assertEqual([d['d'] for d in days],['Su','Mo','Tu','We','Th'])
self.assertEqual(days[0],{'d':'zo','c':'partlycloudy','h':20,'l':11.5}) self.assertEqual(days[0],{'d':'Su','c':'partlycloudy','h':20,'l':11.5})
self.assertIsNone(extras({'entity':'light.a'},states,None,tz)) self.assertIsNone(extras({'entity':'light.a'},states,None,tz))
msg=state_message(0,{'entity':'screen.clock','name':'','options':{'display':'analog'}},{}) msg=state_message(0,{'entity':'screen.clock','name':'','options':{'display':'analog'}},{})
self.assertEqual((msg['state'],msg['name'],msg['o']),('ok','Klok',{'display':'analog'})) self.assertEqual((msg['state'],msg['name'],msg['o']),('ok','Clock',{'display':'analog'}))
msg=state_message(1,{'entity':'weather.home','name':''},states,extras({'entity':'weather.home'},states,forecast,tz)) msg=state_message(1,{'entity':'weather.home','name':''},states,extras({'entity':'weather.home'},states,forecast,tz))
self.assertEqual(len(msg['x']['days']),5);self.assertTrue(all(len(p)<=255 for p in packets(msg))) self.assertEqual(len(msg['x']['days']),5);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
@@ -92,9 +92,9 @@ class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json') m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Home','tiles':[{'entity':'screen.clock','name':''},{'entity':'sun.sun','name':''}]} layout={'title':'Home','tiles':[{'entity':'screen.clock','name':''},{'entity':'sun.sun','name':''}]}
m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Zon','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}} m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Sun','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}}
with self.assertRaises(ValueError):m.save('text.screen',layout) with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry.append({'entity_id':'sensor.screen_firmware','platform':'esphome','original_name':'Schermfirmware'}) m.ha.registry.append({'entity_id':'sensor.screen_firmware','platform':'esphome','original_name':'Screen firmware'})
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'} m.ha.states['sensor.screen_firmware']={'state':'0.2.13'}
with self.assertRaises(ValueError):m.save('text.screen',layout) with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.states['sensor.screen_firmware']={'state':'0.2.14'} m.ha.states['sensor.screen_firmware']={'state':'0.2.14'}
@@ -103,7 +103,7 @@ class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
sent=[message for _,message in m.ha.messages] sent=[message for _,message in m.ha.messages]
self.assertEqual(sent[0]['entities'],['screen.clock','sun.sun']) self.assertEqual(sent[0]['entities'],['screen.clock','sun.sun'])
self.assertEqual(sent[0]['keepalive'],KEEPALIVE_SECONDS,'the screen sizes its feed watchdog from the declared cadence') self.assertEqual(sent[0]['keepalive'],KEEPALIVE_SECONDS,'the screen sizes its feed watchdog from the declared cadence')
self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Klok')) self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Clock'))
self.assertEqual(sent[2]['x'],{'rise':'05:15','set':'17:50'},'fake HA has no time zone: UTC') self.assertEqual(sent[2]['x'],{'rise':'05:15','set':'17:50'},'fake HA has no time zone: UTC')
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'};m.ha.messages.clear() m.ha.states['sensor.screen_firmware']={'state':'0.2.13'};m.ha.messages.clear()
await m.sync_one('text.screen',m.layouts['text.screen']) await m.sync_one('text.screen',m.layouts['text.screen'])
+20 -19
View File
@@ -5,7 +5,7 @@ using namespace header_bar;
int main() { int main() {
assert(kind("clock") == Kind::clock && kind("analog") == Kind::analog && kind("date") == Kind::date); assert(kind("clock") == Kind::clock && kind("analog") == Kind::analog && kind("date") == Kind::date);
assert(kind("text") == Kind::text && kind("ago") == Kind::ago); assert(kind("text") == Kind::text && kind("ago") == Kind::ago);
for (const char *bad : {"", "Clock", "tijd", "state"}) assert(kind(bad) == Kind::none); for (const char *bad : {"", "Clock", "time", "state"}) assert(kind(bad) == Kind::none);
uint32_t c = 0; uint32_t c = 0;
assert(color("FFB300", c) && c == 0xFFB300); assert(color("FFB300", c) && c == 0xFFB300);
@@ -25,25 +25,26 @@ int main() {
// The editor's agoText() uses the same words and thresholds. // The editor's agoText() uses the same words and thresholds.
const int64_t now = 1789401840; const int64_t now = 1789401840;
assert(ago_text(now, 0) == "—" && ago_text(0, now) == "—"); assert(ago_text(now, 0) == "—" && ago_text(0, now) == "—");
assert(ago_text(now - 59, now) == "Zojuist"); assert(ago_text(now - 59, now) == "Just now");
assert(ago_text(now - 60, now) == "1 min geleden"); assert(ago_text(now - 60, now) == "1 min ago");
assert(ago_text(now - 3599, now) == "59 min geleden"); assert(ago_text(now - 3599, now) == "59 min ago");
assert(ago_text(now - 3600, now) == "1 uur geleden"); assert(ago_text(now - 3600, now) == "1 hour ago");
assert(ago_text(now - 86399, now) == "23 uur geleden"); assert(ago_text(now - 86399, now) == "23 hours ago");
assert(ago_text(now - 86400, now) == "Gisteren"); assert(ago_text(now - 86400, now) == "Yesterday");
assert(ago_text(now - 172800, now) == "2 dagen geleden"); assert(ago_text(now - 172800, now) == "2 days ago");
assert(ago_text(now - 604800, now) == "1 week geleden"); assert(ago_text(now - 604800, now) == "1 week ago");
assert(ago_text(now - 2 * 604800, now) == "2 weken geleden"); assert(ago_text(now - 2 * 604800, now) == "2 weeks ago");
assert(ago_text(now - 2592000, now) == "1 maand geleden"); assert(ago_text(now - 2592000, now) == "1 month ago");
assert(ago_text(now - 3 * 2592000, now) == "3 maanden geleden"); assert(ago_text(now - 3 * 2592000, now) == "3 months ago");
assert(ago_text(now - 31536000, now) == "1 jaar geleden"); assert(ago_text(now - 31536000, now) == "1 year ago");
assert(ago_text(now + 30, now) == "Over 1 min"); assert(ago_text(now + 30, now) == "In 1 min");
assert(ago_text(now + 600, now) == "Over 10 min"); assert(ago_text(now + 600, now) == "In 10 min");
assert(ago_text(now + 7200, now) == "Over 2 uur"); assert(ago_text(now + 3600, now) == "In 1 hour");
assert(ago_text(now + 90000, now) == "Morgen"); assert(ago_text(now + 7200, now) == "In 2 hours");
assert(ago_text(now + 3 * 86400, now) == "Over 3 dagen"); assert(ago_text(now + 90000, now) == "Tomorrow");
assert(ago_text(now + 3 * 86400, now) == "In 3 days");
assert(date_text(2, 14, 9) == "ma 14 sep" && date_text(1, 1, 1) == "zo 1 jan" && date_text(7, 31, 12) == "za 31 dec"); assert(date_text(2, 14, 9) == "Mo 14 Sep" && date_text(1, 1, 1) == "Su 1 Jan" && date_text(7, 31, 12) == "Sa 31 Dec");
assert(date_text(0, 1, 1) == "—" && date_text(1, 1, 13) == "—"); assert(date_text(0, 1, 1) == "—" && date_text(1, 1, 13) == "—");
// Guition: digits 15 px high; CYD: 10 px. // Guition: digits 15 px high; CYD: 10 px.
+85 -85
View File
@@ -1,4 +1,4 @@
"""Top bar (app 0.2.38 / firmware 0.2.32): validation, Dutch text, icons, visibility and delivery.""" """Top bar (app 0.2.38 / firmware 0.2.32): validation, text, icons, visibility and delivery."""
import asyncio import asyncio
import importlib.util import importlib.util
import json import json
@@ -28,10 +28,10 @@ def entity(eid, content='state', icon='auto', show='always'):
class ValidationTests(unittest.TestCase): class ValidationTests(unittest.TestCase):
def test_items_fill_defaults_and_keep_order(self): def test_items_fill_defaults_and_keep_order(self):
header = validate_header({'items': [{'type': 'clock'}, {'type': 'entity', 'entity': 'binary_sensor.voordeur'}, {'type': 'analog'}]}) header = validate_header({'items': [{'type': 'clock'}, {'type': 'entity', 'entity': 'binary_sensor.front_door'}, {'type': 'analog'}]})
self.assertEqual(header['items'], [{'type': 'clock'}, entity('binary_sensor.voordeur'), {'type': 'analog'}]) self.assertEqual(header['items'], [{'type': 'clock'}, entity('binary_sensor.front_door'), {'type': 'analog'}])
# Top-bar-only domains: a phone's tracker, a lock, the alarm, the home zone. # Top-bar-only domains: a phone's tracker, a lock, the alarm, the home zone.
for eid in ('device_tracker.telefoon', 'lock.voordeur', 'alarm_control_panel.huis', 'zone.home', 'event.deurbel'): for eid in ('device_tracker.phone', 'lock.front_door', 'alarm_control_panel.house', 'zone.home', 'event.doorbell'):
validate_header({'items': [entity(eid)]}) validate_header({'items': [entity(eid)]})
def test_rejects_what_the_firmware_cannot_draw(self): def test_rejects_what_the_firmware_cannot_draw(self):
@@ -56,22 +56,22 @@ class ValidationTests(unittest.TestCase):
validate_header({'items': [entity('person.max'), entity('person.max', content='last_changed')]}) validate_header({'items': [entity('person.max'), entity('person.max', content='last_changed')]})
def test_layout_keeps_header_and_old_layouts_keep_their_clock(self): def test_layout_keeps_header_and_old_layouts_keep_their_clock(self):
layout = validate_layout({'title': 'Woonkamer', 'tiles': [], 'header': {'items': [{'type': 'date'}]}}) layout = validate_layout({'title': 'Living room', 'tiles': [], 'header': {'items': [{'type': 'date'}]}})
self.assertEqual(header_items(layout), [{'type': 'date'}]) self.assertEqual(header_items(layout), [{'type': 'date'}])
self.assertEqual(header_items(validate_layout({'title': 'Thuis', 'tiles': []})), [{'type': 'clock'}]) self.assertEqual(header_items(validate_layout({'title': 'Home', 'tiles': []})), [{'type': 'clock'}])
without_clock = validate_layout({'title': 'Thuis', 'tiles': [], 'settings': {'show_clock': False}}) without_clock = validate_layout({'title': 'Home', 'tiles': [], 'settings': {'show_clock': False}})
self.assertEqual(header_items(without_clock), []) self.assertEqual(header_items(without_clock), [])
class TextTests(unittest.TestCase): class TextTests(unittest.TestCase):
def test_numbers_as_home_assistant_writes_them_in_dutch(self): def test_numbers_as_home_assistant_writes_them(self):
self.assertEqual(header_bar.number_text(21.34, 1), '21,3') self.assertEqual(header_bar.number_text(21.34, 1), '21.3')
self.assertEqual(header_bar.number_text(1249.0, 0), '1.249') self.assertEqual(header_bar.number_text(1249.0, 0), '1,249')
self.assertEqual(header_bar.number_text(1249.04, 1), '1.249,0') self.assertEqual(header_bar.number_text(1249.04, 1), '1,249.0')
self.assertEqual(header_bar.number_text(234.038566388889), '234,039') self.assertEqual(header_bar.number_text(234.038566388889), '234.039')
self.assertEqual(header_bar.number_text(23.5), '23,5') self.assertEqual(header_bar.number_text(23.5), '23.5')
self.assertEqual(header_bar.number_text(-3.5, 1), '-3,5') self.assertEqual(header_bar.number_text(-3.5, 1), '-3.5')
self.assertEqual(header_bar.number_text(-0.04, 1), '0,0') self.assertEqual(header_bar.number_text(-0.04, 1), '0.0')
self.assertEqual(header_bar.with_unit('21,3', '°C'), '21,3 °C') self.assertEqual(header_bar.with_unit('21.3', '°C'), '21.3 °C')
self.assertEqual(header_bar.with_unit('65', '%'), '65%') self.assertEqual(header_bar.with_unit('65', '%'), '65%')
self.assertEqual(header_bar.with_unit('18', '°'), '18°') self.assertEqual(header_bar.with_unit('18', '°'), '18°')
self.assertEqual(header_bar.with_unit('4', None), '4') self.assertEqual(header_bar.with_unit('4', None), '4')
@@ -81,63 +81,63 @@ class TextTests(unittest.TestCase):
registry = {'sensor.t': {'options': {'sensor': {'suggested_display_precision': 1}}}, registry = {'sensor.t': {'options': {'sensor': {'suggested_display_precision': 1}}},
'sensor.p': {'options': {'sensor': {'suggested_display_precision': 2, 'display_precision': 0}}}} 'sensor.p': {'options': {'sensor': {'suggested_display_precision': 2, 'display_precision': 0}}}}
cases = [ cases = [
('sensor.t', state('21.34', unit_of_measurement='°C', device_class='temperature'), ('21,3 °C', None)), ('sensor.t', state('21.34', unit_of_measurement='°C', device_class='temperature'), ('21.3 °C', None)),
('sensor.p', state('1249.4', unit_of_measurement='W'), ('1.249 W', None)), ('sensor.p', state('1249.4', unit_of_measurement='W'), ('1,249 W', None)),
('sensor.h', state('unavailable', unit_of_measurement='%'), ('—', None)), ('sensor.h', state('unavailable', unit_of_measurement='%'), ('—', None)),
('binary_sensor.deur', state('on', device_class='door'), ('Open', None)), ('binary_sensor.door', state('on', device_class='door'), ('Open', None)),
('binary_sensor.deur', state('off', device_class='door'), ('Dicht', None)), ('binary_sensor.door', state('off', device_class='door'), ('Closed', None)),
('binary_sensor.rook', state('on', device_class='smoke'), ('Rook', None)), ('binary_sensor.smoke', state('on', device_class='smoke'), ('Smoke', None)),
('binary_sensor.iets', state('off'), ('Uit', None)), ('binary_sensor.something', state('off'), ('Off', None)),
('alarm_control_panel.huis', state('armed_away'), ('Afwezig', None)), ('alarm_control_panel.house', state('armed_away'), ('Armed away', None)),
('alarm_control_panel.huis', state('disarmed'), ('Uit', None)), ('alarm_control_panel.house', state('disarmed'), ('Disarmed', None)),
('alarm_control_panel.huis', state('triggered'), ('Alarm', None)), ('alarm_control_panel.house', state('triggered'), ('Triggered', None)),
('lock.voordeur', state('locked'), ('Op slot', None)), ('lock.front_door', state('locked'), ('Locked', None)),
('person.max', state('not_home'), ('Weg', None)), ('person.max', state('not_home'), ('Away', None)),
('person.max', state('Kantoor'), ('Kantoor', None)), ('person.max', state('Office'), ('Office', None)),
('zone.home', state('4'), ('4', None)), ('zone.home', state('4'), ('4', None)),
('weather.thuis', state('rainy', temperature=17.6, temperature_unit='°C'), ('18 °C', None)), ('weather.home', state('rainy', temperature=17.6, temperature_unit='°C'), ('18 °C', None)),
('climate.woonkamer', state('heat', current_temperature=20.46), ('20,5 °C', None)), ('climate.living_room', state('heat', current_temperature=20.46), ('20.5 °C', None)),
('sun.sun', state('above_horizon', next_setting='2026-09-14T17:57:31+00:00'), ('19:57', None)), ('sun.sun', state('above_horizon', next_setting='2026-09-14T17:57:31+00:00'), ('19:57', None)),
('input_datetime.wekker', state('07:30:00', has_date=False, has_time=True), ('07:30', None)), ('input_datetime.alarm', state('07:30:00', has_date=False, has_time=True), ('07:30', None)),
('sensor.backup', state('2026-09-15T03:34:32+00:00', device_class='timestamp'), (None, 1789443272)), ('sensor.backup', state('2026-09-15T03:34:32+00:00', device_class='timestamp'), (None, 1789443272)),
('scene.film', state('2026-09-14T15:32:14+00:00'), (None, 1789399934)), ('scene.film', state('2026-09-14T15:32:14+00:00'), (None, 1789399934)),
('script.alles_uit', state('off', last_triggered='2026-09-14T15:32:14+00:00'), (None, 1789399934)), ('script.all_off', state('off', last_triggered='2026-09-14T15:32:14+00:00'), (None, 1789399934)),
] ]
for eid, value, expected in cases: for eid, value, expected in cases:
self.assertEqual(header_bar.value(eid, value, registry.get(eid), {'temperature': '°C'}, tz), expected, eid) self.assertEqual(header_bar.value(eid, value, registry.get(eid), {'temperature': '°C'}, tz), expected, eid)
def test_text_keeps_to_the_font_and_its_length(self): def test_text_keeps_to_the_font_and_its_length(self):
self.assertEqual(header_bar.clean_text('Café “Zon” 25 m³'), 'Café Zon 25 m³') self.assertEqual(header_bar.clean_text('Café “Sun” 25 m³'), 'Café Sun 25 m³')
self.assertEqual(header_bar.clean_text('Ångström ✓'), 'Angström') self.assertEqual(header_bar.clean_text('Ångström ✓'), 'Angström')
self.assertLessEqual(len(header_bar.clean_text('é' * 60).encode()), header_bar.TEXT_BYTES) self.assertLessEqual(len(header_bar.clean_text('é' * 60).encode()), header_bar.TEXT_BYTES)
def test_active_colour_and_icon(self): def test_active_colour_and_icon(self):
door_open, door_closed = state('on', device_class='door'), state('off', device_class='door') door_open, door_closed = state('on', device_class='door'), state('off', device_class='door')
self.assertTrue(header_bar.active('binary_sensor.deur', door_open)) self.assertTrue(header_bar.active('binary_sensor.door', door_open))
self.assertFalse(header_bar.active('binary_sensor.deur', door_closed)) self.assertFalse(header_bar.active('binary_sensor.door', door_closed))
self.assertFalse(header_bar.active('sensor.p', state('0.0'))) self.assertFalse(header_bar.active('sensor.p', state('0.0')))
self.assertTrue(header_bar.active('sensor.p', state('12'))) self.assertTrue(header_bar.active('sensor.p', state('12')))
self.assertTrue(header_bar.active('person.max', state('home'))) self.assertTrue(header_bar.active('person.max', state('home')))
self.assertFalse(header_bar.active('person.max', state('Kantoor'))) self.assertFalse(header_bar.active('person.max', state('Office')))
self.assertFalse(header_bar.active('lock.voordeur', state('locked'))) self.assertFalse(header_bar.active('lock.front_door', state('locked')))
self.assertFalse(header_bar.active('sensor.x', state('unavailable'))) self.assertFalse(header_bar.active('sensor.x', state('unavailable')))
self.assertEqual(header_bar.accent('binary_sensor.deur', door_open), header_bar.AMBER) self.assertEqual(header_bar.accent('binary_sensor.door', door_open), header_bar.AMBER)
self.assertIsNone(header_bar.accent('binary_sensor.deur', door_closed)) self.assertIsNone(header_bar.accent('binary_sensor.door', door_closed))
self.assertEqual(header_bar.accent('binary_sensor.lek', state('on', device_class='moisture')), header_bar.RED) self.assertEqual(header_bar.accent('binary_sensor.leak', state('on', device_class='moisture')), header_bar.RED)
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('armed_home')), header_bar.GREEN) self.assertEqual(header_bar.accent('alarm_control_panel.house', state('armed_home')), header_bar.GREEN)
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('arming')), header_bar.ORANGE) self.assertEqual(header_bar.accent('alarm_control_panel.house', state('arming')), header_bar.ORANGE)
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('triggered')), header_bar.RED) self.assertEqual(header_bar.accent('alarm_control_panel.house', state('triggered')), header_bar.RED)
self.assertIsNone(header_bar.accent('alarm_control_panel.huis', state('disarmed'))) self.assertIsNone(header_bar.accent('alarm_control_panel.house', state('disarmed')))
self.assertEqual(header_bar.accent('lock.voordeur', state('unlocked')), header_bar.RED) self.assertEqual(header_bar.accent('lock.front_door', state('unlocked')), header_bar.RED)
self.assertEqual(header_bar.accent('person.max', state('home')), header_bar.GREEN) self.assertEqual(header_bar.accent('person.max', state('home')), header_bar.GREEN)
glyph = tile_icons.GLYPHS glyph = tile_icons.GLYPHS
self.assertEqual(header_bar.auto_icon('binary_sensor.deur', door_open), glyph['door-open']) self.assertEqual(header_bar.auto_icon('binary_sensor.door', door_open), glyph['door-open'])
self.assertEqual(header_bar.auto_icon('binary_sensor.deur', door_closed), glyph['door-closed']) self.assertEqual(header_bar.auto_icon('binary_sensor.door', door_closed), glyph['door-closed'])
self.assertEqual(header_bar.auto_icon('sensor.t', state('21', device_class='temperature')), glyph['thermometer']) self.assertEqual(header_bar.auto_icon('sensor.t', state('21', device_class='temperature')), glyph['thermometer'])
self.assertEqual(header_bar.auto_icon('sensor.t', state('21', device_class='temperature', icon='mdi:fire')), glyph['fire']) self.assertEqual(header_bar.auto_icon('sensor.t', state('21', device_class='temperature', icon='mdi:fire')), glyph['fire'])
self.assertEqual(header_bar.auto_icon('weather.thuis', state('rainy')), glyph['weather-rainy']) self.assertEqual(header_bar.auto_icon('weather.home', state('rainy')), glyph['weather-rainy'])
self.assertEqual(header_bar.auto_icon('lock.voordeur', state('unlocked')), glyph['lock-open-variant']) self.assertEqual(header_bar.auto_icon('lock.front_door', state('unlocked')), glyph['lock-open-variant'])
self.assertEqual(header_bar.auto_icon('counter.bezoekers', None), glyph['gauge']) self.assertEqual(header_bar.auto_icon('counter.visitors', None), glyph['gauge'])
def test_every_icon_name_is_in_the_fonts(self): def test_every_icon_name_is_in_the_fonts(self):
names = set(header_bar.SENSOR_ICONS.values()) | set(header_bar.DOMAIN_ICONS.values()) names = set(header_bar.SENSOR_ICONS.values()) | set(header_bar.DOMAIN_ICONS.values())
@@ -148,45 +148,45 @@ class TextTests(unittest.TestCase):
class MessageTests(unittest.TestCase): class MessageTests(unittest.TestCase):
def test_message_leaves_hidden_items_out_and_keeps_builtins(self): def test_message_leaves_hidden_items_out_and_keeps_builtins(self):
states = {'binary_sensor.deur': state('off', device_class='door'), 'person.max': state('home', last_changed='2026-09-14T15:00:00+00:00'), states = {'binary_sensor.door': state('off', device_class='door'), 'person.max': state('home', last_changed='2026-09-14T15:00:00+00:00'),
'sensor.t': state('21.3', unit_of_measurement='°C', device_class='temperature')} 'sensor.t': state('21.3', unit_of_measurement='°C', device_class='temperature')}
layout = {'title': 'Hal', 'tiles': [], 'header': {'items': [ layout = {'title': 'Hallway', 'tiles': [], 'header': {'items': [
entity('binary_sensor.deur', show='active'), entity('person.max', content='last_changed'), entity('binary_sensor.door', show='active'), entity('person.max', content='last_changed'),
entity('sensor.t', icon='none'), {'type': 'analog'}, {'type': 'clock'}]}} entity('sensor.t', icon='none'), {'type': 'analog'}, {'type': 'clock'}]}}
message = header_bar.message(layout, states) message = header_bar.message(layout, states)
self.assertEqual(message['op'], 'header') self.assertEqual(message['op'], 'header')
self.assertEqual(message['items'], [ self.assertEqual(message['items'], [
{'k': 'ago', 'i': tile_icons.GLYPHS['account'], 'e': 1789398000, 'c': header_bar.GREEN}, {'k': 'ago', 'i': tile_icons.GLYPHS['account'], 'e': 1789398000, 'c': header_bar.GREEN},
{'k': 'text', 't': '21,3 °C'}, {'k': 'analog'}, {'k': 'clock'}]) {'k': 'text', 't': '21.3 °C'}, {'k': 'analog'}, {'k': 'clock'}])
states['binary_sensor.deur'] = state('on', device_class='door') states['binary_sensor.door'] = state('on', device_class='door')
opened = header_bar.message(layout, states)['items'][0] opened = header_bar.message(layout, states)['items'][0]
self.assertEqual(opened, {'k': 'text', 'i': tile_icons.GLYPHS['door-open'], 't': 'Open', 'c': header_bar.AMBER}) self.assertEqual(opened, {'k': 'text', 'i': tile_icons.GLYPHS['door-open'], 't': 'Open', 'c': header_bar.AMBER})
preview = header_bar.preview(layout['header'], {**states, 'binary_sensor.deur': state('off', device_class='door')}) preview = header_bar.preview(layout['header'], {**states, 'binary_sensor.door': state('off', device_class='door')})
self.assertEqual([p['shown'] for p in preview], [False, True, True, True, True]) self.assertEqual([p['shown'] for p in preview], [False, True, True, True, True])
self.assertEqual(preview[0]['name'], 'binary_sensor.deur') self.assertEqual(preview[0]['name'], 'binary_sensor.door')
def test_old_layout_sends_the_clock_of_show_clock(self): def test_old_layout_sends_the_clock_of_show_clock(self):
self.assertEqual(header_bar.message({'title': 'Thuis', 'tiles': []}, {})['items'], [{'k': 'clock'}]) self.assertEqual(header_bar.message({'title': 'Home', 'tiles': []}, {})['items'], [{'k': 'clock'}])
self.assertEqual(header_bar.message({'title': 'Thuis', 'tiles': [], 'settings': {'show_clock': False}}, {})['items'], []) self.assertEqual(header_bar.message({'title': 'Home', 'tiles': [], 'settings': {'show_clock': False}}, {})['items'], [])
def test_suggestions_prefer_the_screens_room_and_skip_diagnostics(self): def test_suggestions_prefer_the_screens_room_and_skip_diagnostics(self):
states = {'sensor.plug_temp': state('30', device_class='temperature'), 'sensor.woonkamer_temp': state('21', device_class='temperature'), states = {'sensor.plug_temp': state('30', device_class='temperature'), 'sensor.living_room_temp': state('21', device_class='temperature'),
'sensor.zolder_temp': state('18', device_class='temperature'), 'zone.home': state('3'), 'sun.sun': state('above_horizon')} 'sensor.attic_temp': state('18', device_class='temperature'), 'zone.home': state('3'), 'sun.sun': state('above_horizon')}
entities = [{'id': 'sensor.plug_temp', 'name': 'Plug', 'area': 'Woonkamer'}, {'id': 'sensor.woonkamer_temp', 'name': 'Woonkamer', 'area': 'Woonkamer'}, entities = [{'id': 'sensor.plug_temp', 'name': 'Plug', 'area': 'Living room'}, {'id': 'sensor.living_room_temp', 'name': 'Living room', 'area': 'Living room'},
{'id': 'sensor.zolder_temp', 'name': 'Zolder', 'area': 'Zolder'}, {'id': 'zone.home', 'name': 'Thuis', 'area': ''}, {'id': 'sensor.attic_temp', 'name': 'Attic', 'area': 'Attic'}, {'id': 'zone.home', 'name': 'Home', 'area': ''},
{'id': 'sun.sun', 'name': 'Zon', 'area': ''}] {'id': 'sun.sun', 'name': 'Sun', 'area': ''}]
picks = header_bar.suggestions({'name': 'Woonkamer'}, entities, states, {'sensor.plug_temp': {'entity_category': 'diagnostic'}}) picks = header_bar.suggestions({'name': 'Living room'}, entities, states, {'sensor.plug_temp': {'entity_category': 'diagnostic'}})
self.assertEqual([(p['label'], p['item']['entity']) for p in picks], self.assertEqual([(p['label'], p['item']['entity']) for p in picks],
[('Temperatuur', 'sensor.woonkamer_temp'), ('Aantal thuis', 'zone.home'), ('Zon op en onder', 'sun.sun')]) [('Temperature', 'sensor.living_room_temp'), ('People home', 'zone.home'), ('Sunrise and sunset', 'sun.sun')])
for pick in picks: for pick in picks:
validate_header({'items': [pick['item']]}) validate_header({'items': [pick['item']]})
def test_discovery_offers_top_bar_domains_outside_the_tile_picker(self): def test_discovery_offers_top_bar_domains_outside_the_tile_picker(self):
registry = [{'entity_id': 'lock.voordeur'}, {'entity_id': 'light.hal'}, {'entity_id': 'update.core'}] registry = [{'entity_id': 'lock.front_door'}, {'entity_id': 'light.hall'}, {'entity_id': 'update.core'}]
states = {'lock.voordeur': state('locked'), 'light.hal': state('on'), 'update.core': state('off'), 'zone.home': state('2')} states = {'lock.front_door': state('locked'), 'light.hall': state('on'), 'update.core': state('off'), 'zone.home': state('2')}
_, entities = discover(registry, states, [], []) _, entities = discover(registry, states, [], [])
flags = {e['id']: e.get('tile', True) for e in entities} flags = {e['id']: e.get('tile', True) for e in entities}
self.assertEqual(flags, {'lock.voordeur': False, 'light.hal': True, 'zone.home': False}) self.assertEqual(flags, {'lock.front_door': False, 'light.hall': True, 'zone.home': False})
class ParityTests(unittest.TestCase): class ParityTests(unittest.TestCase):
def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self): def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self):
@@ -198,8 +198,8 @@ class ParityTests(unittest.TestCase):
self.assertEqual({a or b for a, b in glyphs}, set(header_bar.GLYPHS), name) self.assertEqual({a or b for a, b in glyphs}, set(header_bar.GLYPHS), name)
def test_editor_and_firmware_share_words_and_spacing(self): def test_editor_and_firmware_share_words_and_spacing(self):
for phrase in ('Zojuist', ' min geleden', ' uur geleden', 'Gisteren', ' dagen geleden', '1 week geleden', ' weken geleden', for phrase in ('Just now', ' min ago', ' hour ago', ' hours ago', 'Yesterday', ' days ago', '1 week ago', ' weeks ago',
'1 maand geleden', ' maanden geleden', ' jaar geleden', 'Over ', 'Morgen'): '1 month ago', ' months ago', ' year ago', ' years ago', 'In ', 'Tomorrow'):
self.assertIn(phrase, FIRMWARE) self.assertIn(phrase, FIRMWARE)
self.assertIn(phrase, EDITOR) self.assertIn(phrase, EDITOR)
for threshold in ('3600', '86400', '172800', '604800', '2592000', '31536000'): for threshold in ('3600', '86400', '172800', '604800', '2592000', '31536000'):
@@ -232,10 +232,10 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
def manager(self, path, firmware='0.2.32'): def manager(self, path, firmware='0.2.32'):
class HA: class HA:
online = True online = True
registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd'}, registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd'},
{'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd'}, {'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd'},
{'entity_id': 'sensor.t', 'options': {'sensor': {'suggested_display_precision': 1}}}, {'entity_id': 'sensor.t', 'options': {'sensor': {'suggested_display_precision': 1}}},
{'entity_id': 'binary_sensor.deur'}, {'entity_id': 'light.a'}] {'entity_id': 'binary_sensor.door'}, {'entity_id': 'light.a'}]
devices, areas = [], [] devices, areas = [], []
time_zone, units = None, {'temperature': '°C'} time_zone, units = None, {'temperature': '°C'}
def __init__(self): def __init__(self):
@@ -243,7 +243,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.changed = asyncio.Event() self.changed = asyncio.Event()
self.states = {'text.screen': state('Ready'), 'sensor.fw': state(firmware), 'light.a': state('on'), self.states = {'text.screen': state('Ready'), 'sensor.fw': state(firmware), 'light.a': state('on'),
'sensor.t': state('21.34', unit_of_measurement='°C', device_class='temperature'), 'sensor.t': state('21.34', unit_of_measurement='°C', device_class='temperature'),
'binary_sensor.deur': state('off', device_class='door')} 'binary_sensor.door': state('off', device_class='door')}
async def send(self, inbox, message, action=None): async def send(self, inbox, message, action=None):
self.messages.append(message) self.messages.append(message)
return Manager(HA(), path) return Manager(HA(), path)
@@ -251,21 +251,21 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_header_follows_layout_only_on_new_firmware(self): async def test_header_follows_layout_only_on_new_firmware(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
m = self.manager(Path(temp) / 'screens.json') m = self.manager(Path(temp) / 'screens.json')
layout = {'title': 'Hal', 'tiles': [{'entity': 'light.a', 'name': ''}], 'settings': {'show_clock': True}, layout = {'title': 'Hallway', 'tiles': [{'entity': 'light.a', 'name': ''}], 'settings': {'show_clock': True},
'header': {'items': [entity('sensor.t'), entity('binary_sensor.deur', show='active')]}} 'header': {'items': [entity('sensor.t'), entity('binary_sensor.door', show='active')]}}
m.save('text.screen', layout) m.save('text.screen', layout)
saved = m.layouts['text.screen'] saved = m.layouts['text.screen']
self.assertFalse(saved['settings']['show_clock'], 'older firmware hides its clock when the bar has none') self.assertFalse(saved['settings']['show_clock'], 'older firmware hides its clock when the bar has none')
await m.sync_one('text.screen', saved) await m.sync_one('text.screen', saved)
self.assertEqual([msg['op'] for msg in m.ha.messages], ['layout', 'header', 'state']) self.assertEqual([msg['op'] for msg in m.ha.messages], ['layout', 'header', 'state'])
self.assertEqual(m.ha.messages[1]['items'], [{'k': 'text', 'i': tile_icons.GLYPHS['thermometer'], 't': '21,3 °C'}]) self.assertEqual(m.ha.messages[1]['items'], [{'k': 'text', 'i': tile_icons.GLYPHS['thermometer'], 't': '21.3 °C'}])
await m.sync_one('text.screen', saved) await m.sync_one('text.screen', saved)
self.assertEqual(len(m.ha.messages), 3, 'an unchanged bar is not sent again') self.assertEqual(len(m.ha.messages), 3, 'an unchanged bar is not sent again')
m.ha.states['binary_sensor.deur'] = state('on', device_class='door') m.ha.states['binary_sensor.door'] = state('on', device_class='door')
await m.sync_one('text.screen', saved) await m.sync_one('text.screen', saved)
self.assertEqual(len(m.ha.messages), 4) self.assertEqual(len(m.ha.messages), 4)
self.assertEqual(m.ha.messages[-1]['items'][1]['t'], 'Open') self.assertEqual(m.ha.messages[-1]['items'][1]['t'], 'Open')
self.assertIn('binary_sensor.deur', m.watched_entities()) self.assertIn('binary_sensor.door', m.watched_entities())
old = self.manager(Path(temp) / 'old.json', firmware='0.2.31') old = self.manager(Path(temp) / 'old.json', firmware='0.2.31')
old.save('text.screen', layout) old.save('text.screen', layout)
await old.sync_one('text.screen', old.layouts['text.screen']) await old.sync_one('text.screen', old.layouts['text.screen'])
@@ -275,11 +275,11 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
m = self.manager(Path(temp) / 'screens.json') m = self.manager(Path(temp) / 'screens.json')
header = {'items': [{'type': 'date'}, {'type': 'clock'}]} header = {'items': [{'type': 'date'}, {'type': 'clock'}]}
m.save('text.screen', {'title': 'Hal', 'tiles': [], 'header': header}) m.save('text.screen', {'title': 'Hallway', 'tiles': [], 'header': header})
m.save('text.screen', {'title': 'Andere naam', 'tiles': []}) m.save('text.screen', {'title': 'Different name', 'tiles': []})
self.assertEqual(m.layouts['text.screen']['header'], header) self.assertEqual(m.layouts['text.screen']['header'], header)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
m.save('text.screen', {'title': 'Hal', 'tiles': [], 'header': {'items': [entity('sensor.bestaat_niet')]}}) m.save('text.screen', {'title': 'Hallway', 'tiles': [], 'header': {'items': [entity('sensor.does_not_exist')]}})
self.assertEqual(m.layouts['text.screen']['header'], header) self.assertEqual(m.layouts['text.screen']['header'], header)
async def test_preview_endpoint_formats_unsaved_items(self): async def test_preview_endpoint_formats_unsaved_items(self):
@@ -291,11 +291,11 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(inventory['header']['max_items'], 6) self.assertEqual(inventory['header']['max_items'], 6)
self.assertEqual(inventory['header']['min_firmware'], '0.2.32') self.assertEqual(inventory['header']['min_firmware'], '0.2.32')
headers = {'X-Screen-CSRF': inventory['csrf']} headers = {'X-Screen-CSRF': inventory['csrf']}
items = [entity('binary_sensor.deur', show='active'), {'type': 'clock'}] items = [entity('binary_sensor.door', show='active'), {'type': 'clock'}]
response = await client.post('/api/header-preview', json={'header': {'items': items}}, headers=headers) response = await client.post('/api/header-preview', json={'header': {'items': items}}, headers=headers)
data = await response.json() data = await response.json()
self.assertEqual([p['shown'] for p in data['items']], [False, True]) self.assertEqual([p['shown'] for p in data['items']], [False, True])
self.assertEqual(data['items'][0]['t'], 'Dicht') self.assertEqual(data['items'][0]['t'], 'Closed')
bad = await client.post('/api/header-preview', json={'header': {'items': [{'type': 'nope'}]}}, headers=headers) bad = await client.post('/api/header-preview', json={'header': {'items': [{'type': 'nope'}]}}, headers=headers)
self.assertEqual(bad.status, 400) self.assertEqual(bad.status, 400)
+3 -3
View File
@@ -46,7 +46,7 @@ class CalibrationTests(unittest.TestCase):
def test_center_is_not_used_to_hide_bad_fit(self): def test_center_is_not_used_to_hide_bad_fit(self):
data = measurements() data = measurements()
for s in data['points'][4]['samples']: s['raw_x'] += 600 for s in data['points'][4]['samples']: s['raw_x'] += 600
with self.assertRaisesRegex(ValueError, 'midden'): calibrate.fit(data) with self.assertRaisesRegex(ValueError, 'center'): calibrate.fit(data)
def test_jitter_rejected(self): def test_jitter_rejected(self):
data = measurements() data = measurements()
@@ -93,7 +93,7 @@ class InstallationTests(unittest.TestCase):
new_device.create(b, 'display-b', 'Display B') new_device.create(b, 'display-b', 'Display B')
original = (Path(a)/'secrets.yaml').read_text() original = (Path(a)/'secrets.yaml').read_text()
self.assertNotEqual(original, (Path(b)/'secrets.yaml').read_text()) self.assertNotEqual(original, (Path(b)/'secrets.yaml').read_text())
self.assertNotIn('WORDT_UNIEK_GEGENEREERD', original) self.assertNotIn('WILL_BE_UNIQUELY_GENERATED', original)
with self.assertRaises(ValueError): new_device.create(a, 'new', 'New') with self.assertRaises(ValueError): new_device.create(a, 'new', 'New')
self.assertEqual(original, (Path(a)/'secrets.yaml').read_text()) self.assertEqual(original, (Path(a)/'secrets.yaml').read_text())
@@ -117,7 +117,7 @@ class InstallationTests(unittest.TestCase):
self.assertEqual(values['TOUCH_AFFINE_XX'], '1.000000000') self.assertEqual(values['TOUCH_AFFINE_XX'], '1.000000000')
self.assertEqual(values['DIRECT_ACTIONS'], 'false') self.assertEqual(values['DIRECT_ACTIONS'], 'false')
for i in range(1,11): for i in range(1,11):
self.assertRegex(values[f'TILE{i}_ENTITY'], r'\.(voorbeeld_|ongebruikt_)') self.assertRegex(values[f'TILE{i}_ENTITY'], r'\.(example_|unused_)')
archive.extractall(tmp) archive.extractall(tmp)
new_device.create(Path(tmp)/'cyd-display', 'fresh-board', 'Fresh board') new_device.create(Path(tmp)/'cyd-display', 'fresh-board', 'Fresh board')
with self.assertRaises(FileExistsError): export_bundle.export(ROOT, output) with self.assertRaises(FileExistsError): export_bundle.export(ROOT, output)
+19 -19
View File
@@ -12,7 +12,7 @@ from core import discover, installation_yaml, packets, state_message, validate_l
class ProtocolTests(unittest.TestCase): class ProtocolTests(unittest.TestCase):
def test_unicode_chunks_and_limits(self): def test_unicode_chunks_and_limits(self):
message = {'v':1,'op':'layout','title':'Woonkamer 🌈', 'entities':['light.lamp']*10} message = {'v':1,'op':'layout','title':'Living room 🌈', 'entities':['light.lamp']*10}
parts = packets(message) parts = packets(message)
self.assertTrue(all(len(part)<=255 for part in parts)) self.assertTrue(all(len(part)<=255 for part in parts))
self.assertEqual(json.loads(base64.b64decode(''.join(p.split('|')[3] for p in parts))),message) self.assertEqual(json.loads(base64.b64decode(''.join(p.split('|')[3] for p in parts))),message)
@@ -21,11 +21,11 @@ class ProtocolTests(unittest.TestCase):
def test_layout_rejects_duplicates_unsupported_and_overflow(self): def test_layout_rejects_duplicates_unsupported_and_overflow(self):
for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(21)],[{'entity':'light.a;restart'}]]: for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(21)],[{'entity':'light.a;restart'}]]:
with self.assertRaises(ValueError): validate_layout({'title':'Thuis','tiles':tiles}) with self.assertRaises(ValueError): validate_layout({'title':'Home','tiles':tiles})
self.assertEqual(validate_layout({'title':'Thuis','tiles':[]})['tiles'],[]) self.assertEqual(validate_layout({'title':'Home','tiles':[]})['tiles'],[])
def test_settings_defaults_ranges_and_old_layout(self): def test_settings_defaults_ranges_and_old_layout(self):
self.assertNotIn('settings', validate_layout({'title':'Thuis','tiles':[]})) self.assertNotIn('settings', validate_layout({'title':'Home','tiles':[]}))
defaults = validate_settings({}) defaults = validate_settings({})
self.assertEqual(defaults['standby_seconds'], 600) self.assertEqual(defaults['standby_seconds'], 600)
self.assertEqual(defaults['night_start'], 22*60) self.assertEqual(defaults['night_start'], 22*60)
@@ -37,7 +37,7 @@ class ProtocolTests(unittest.TestCase):
with self.assertRaises(ValueError): validate_settings(bad) with self.assertRaises(ValueError): validate_settings(bad)
settings = validate_settings({'brightness':40,'standby_brightness':0,'night_brightness':0}) settings = validate_settings({'brightness':40,'standby_brightness':0,'night_brightness':0})
self.assertEqual(settings['brightness'],40) self.assertEqual(settings['brightness'],40)
message={'v':1,'op':'layout','title':'Thuis','entities':[], 'settings':settings} message={'v':1,'op':'layout','title':'Home','entities':[], 'settings':settings}
self.assertTrue(all(len(p)<=255 for p in packets(message))) self.assertTrue(all(len(p)<=255 for p in packets(message)))
def test_message_has_only_bounded_display_attributes(self): def test_message_has_only_bounded_display_attributes(self):
@@ -48,17 +48,17 @@ class ProtocolTests(unittest.TestCase):
packets(msg) packets(msg)
def test_discovery_only_enabled_esphome_inboxes(self): def test_discovery_only_enabled_esphome_inboxes(self):
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'}, registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tile settings'},
{'entity_id':'text.wrong','platform':'template','original_name':'Tegelinstellingen'}, {'entity_id':'text.wrong','platform':'template','original_name':'Tile settings'},
{'entity_id':'text.disabled','platform':'esphome','original_name':'Tegelinstellingen','disabled_by':'user'}] {'entity_id':'text.disabled','platform':'esphome','original_name':'Tile settings','disabled_by':'user'}]
screens,_=discover(registry,{'text.screen':{'state':'Gesynchroniseerd'}},[],[]) screens,_=discover(registry,{'text.screen':{'state':'Synced'}},[],[])
self.assertEqual([s['id'] for s in screens],['text.screen']) self.assertEqual([s['id'] for s in screens],['text.screen'])
def test_unique_credentials_and_remote_profiles(self): def test_unique_credentials_and_remote_profiles(self):
data={'board':'cyd','name':'woonkamer','friendly_name':'Woonkamer'} data={'board':'cyd','name':'living-room','friendly_name':'Living room'}
first,second=installation_yaml(data),installation_yaml(data) first,second=installation_yaml(data),installation_yaml(data)
self.assertNotEqual(first,second) self.assertNotEqual(first,second)
self.assertIn('DEVICE_NAME: "woonkamer"',first) self.assertIn('DEVICE_NAME: "living-room"',first)
self.assertIn('packages/cyd.yaml',first) self.assertIn('packages/cyd.yaml',first)
self.assertIn('!secret wifi_password',first) self.assertIn('!secret wifi_password',first)
import re import re
@@ -76,7 +76,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
def setup_manager(self,path): def setup_manager(self,path):
class HA: class HA:
online=True online=True
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'}] registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tile settings'}]
devices=[];areas=[] devices=[];areas=[]
states={'text.screen':{'state':'Ready'},'light.a':{'state':'on','attributes':{'friendly_name':'Lamp'}}} states={'text.screen':{'state':'Ready'},'light.a':{'state':'on','attributes':{'friendly_name':'Lamp'}}}
changed=asyncio.Event() changed=asyncio.Event()
@@ -87,7 +87,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_update_restart_preserves_layout(self): async def test_update_restart_preserves_layout(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'screens.json';m=self.setup_manager(path) path=Path(temp)/'screens.json';m=self.setup_manager(path)
layout={'title':'Mijn huis','tiles':[{'entity':'light.a','name':'Eigen naam'}]} layout={'title':'My house','tiles':[{'entity':'light.a','name':'Custom name'}]}
m.save('text.screen',layout) m.save('text.screen',layout)
fresh=self.setup_manager(path) fresh=self.setup_manager(path)
# Stored layouts always carry a grid position; a save without one packs in order. # Stored layouts always carry a grid position; a save without one packs in order.
@@ -108,7 +108,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_settings_survive_update_and_old_client_save(self): async def test_settings_survive_update_and_old_client_save(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'screens.json'; m=self.setup_manager(path) path=Path(temp)/'screens.json'; m=self.setup_manager(path)
legacy={'title':'Thuis','tiles':[{'entity':'light.a','name':'Mijn lamp'}]} legacy={'title':'Home','tiles':[{'entity':'light.a','name':'My lamp'}]}
path.write_text(json.dumps({'version':1,'screens':{'text.screen':legacy}})) path.write_text(json.dumps({'version':1,'screens':{'text.screen':legacy}}))
m=self.setup_manager(path) m=self.setup_manager(path)
stored={**legacy,'tiles':[{**legacy['tiles'][0],'slot':0}]} stored={**legacy,'tiles':[{**legacy['tiles'][0],'slot':0}]}
@@ -117,7 +117,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
m.save('text.screen',{**legacy,'settings':settings}) m.save('text.screen',{**legacy,'settings':settings})
fresh=self.setup_manager(path) fresh=self.setup_manager(path)
self.assertEqual(fresh.layouts['text.screen']['settings'],settings) self.assertEqual(fresh.layouts['text.screen']['settings'],settings)
fresh.save('text.screen',{**legacy,'title':'Andere titel'}) fresh.save('text.screen',{**legacy,'title':'Different title'})
saved=fresh.layouts['text.screen'] saved=fresh.layouts['text.screen']
self.assertEqual(saved['settings'],settings) self.assertEqual(saved['settings'],settings)
self.assertEqual(saved['tiles'],stored['tiles']) self.assertEqual(saved['tiles'],stored['tiles'])
@@ -133,12 +133,12 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_reorder_aborts_old_batch(self): async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
m=self.setup_manager(Path(temp)/'screens.json');layout={'title':'Thuis','tiles':[{'entity':'light.a','name':''}]} m=self.setup_manager(Path(temp)/'screens.json');layout={'title':'Home','tiles':[{'entity':'light.a','name':''}]}
m.save('text.screen',layout) m.save('text.screen',layout)
original=m.ha.send original=m.ha.send
async def interrupt(inbox,message,action=None): async def interrupt(inbox,message,action=None):
await original(inbox,message) await original(inbox,message)
m.save(inbox,{'title':'Leeg','tiles':[]}) m.save(inbox,{'title':'Empty','tiles':[]})
m.ha.send=interrupt m.ha.send=interrupt
await m.sync_one('text.screen',m.layouts['text.screen']) await m.sync_one('text.screen',m.layouts['text.screen'])
self.assertEqual(len(m.ha.messages),1) self.assertEqual(len(m.ha.messages),1)
@@ -159,7 +159,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status,200) self.assertEqual(response.status,200)
from firmware import Firmware from firmware import Firmware
m.firmware=Firmware(Path(temp)/'esphome',Path(temp)) m.firmware=Firmware(Path(temp)/'esphome',Path(temp))
profile={'board':'guition','name':'screen-new','friendly_name':'Nieuw','wifi_ssid':'net','wifi_password':'pw'} profile={'board':'guition','name':'screen-new','friendly_name':'New','wifi_ssid':'net','wifi_password':'pw'}
# A refused USB target leaves no profile behind. # A refused USB target leaves no profile behind.
response=await client.post('/api/firmware/profiles',headers=headers,json={**profile,'target':'/dev/ttyUSB9'}) response=await client.post('/api/firmware/profiles',headers=headers,json={**profile,'target':'/dev/ttyUSB9'})
self.assertEqual(response.status,400) self.assertEqual(response.status,400)
@@ -175,6 +175,6 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn('pw',json.dumps((await (await client.get('/api/firmware')).json())['wifi'])) self.assertNotIn('pw',json.dumps((await (await client.get('/api/firmware')).json())['wifi']))
# Until Home Assistant lists the screen, the page shows the profile as "not yet in HA" with its key. # Until Home Assistant lists the screen, the page shows the profile as "not yet in HA" with its key.
pending=(await (await client.get('/api/inventory?light=1')).json())['pending'] pending=(await (await client.get('/api/inventory?light=1')).json())['pending']
self.assertEqual([(p['file'],p['friendly'],p['installed'],p['api_key']==result['api_key']) for p in pending],[('screen-new.yaml','Nieuw',False,True)]) self.assertEqual([(p['file'],p['friendly'],p['installed'],p['api_key']==result['api_key']) for p in pending],[('screen-new.yaml','New',False,True)])
if __name__=='__main__':unittest.main() if __name__=='__main__':unittest.main()
+14 -14
View File
@@ -18,13 +18,13 @@ def tiles(*specs):
class Positions(unittest.TestCase): class Positions(unittest.TestCase):
def test_layout_without_positions_packs_in_order(self): def test_layout_without_positions_packs_in_order(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a',), ('weather.w', None, True), ('light.b',), ('light.c',))}) layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a',), ('weather.w', None, True), ('light.b',), ('light.c',))})
self.assertEqual([t['slot'] for t in layout['tiles']], [0, 2, 4, 5]) self.assertEqual([t['slot'] for t in layout['tiles']], [0, 2, 4, 5])
self.assertEqual(pack_slots(layout['tiles']), [0, 2, 4, 5]) self.assertEqual(pack_slots(layout['tiles']), [0, 2, 4, 5])
self.assertFalse(has_gaps(layout['tiles'])) self.assertFalse(has_gaps(layout['tiles']))
def test_explicit_positions_keep_gaps_and_sort_by_slot(self): def test_explicit_positions_keep_gaps_and_sort_by_slot(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.b', 7), ('light.a', 1), ('weather.w', 4, True), ('light.c', 46))}) layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.b', 7), ('light.a', 1), ('weather.w', 4, True), ('light.c', 46))})
self.assertEqual([(t['entity'], t['slot']) for t in layout['tiles']], [('light.a', 1), ('weather.w', 4), ('light.b', 7), ('light.c', 46)]) self.assertEqual([(t['entity'], t['slot']) for t in layout['tiles']], [('light.a', 1), ('weather.w', 4), ('light.b', 7), ('light.c', 46)])
self.assertTrue(has_gaps(layout['tiles'])) self.assertTrue(has_gaps(layout['tiles']))
# A second pass (reload from disk) is stable. # A second pass (reload from disk) is stable.
@@ -37,20 +37,20 @@ class Positions(unittest.TestCase):
tiles(('light.a', MAX_SLOTS)), # beyond page eight tiles(('light.a', MAX_SLOTS)), # beyond page eight
tiles(('light.a', -1)), tiles(('light.a', '2')), tiles(('light.a', 2.0)), tiles(('light.a', -1)), tiles(('light.a', '2')), tiles(('light.a', 2.0)),
tiles(('light.a', 0), ('light.b',))]: # some tiles without a position tiles(('light.a', 0), ('light.b',))]: # some tiles without a position
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Thuis', 'tiles': bad}) with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Home', 'tiles': bad})
# Forecast forces a wide card, so the right column is refused there too. # Forecast forces a wide card, so the right column is refused there too.
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
validate_layout({'title': 'Thuis', 'tiles': [{'entity': 'weather.w', 'slot': 1, 'options': {'display': 'forecast'}}]}) validate_layout({'title': 'Home', 'tiles': [{'entity': 'weather.w', 'slot': 1, 'options': {'display': 'forecast'}}]})
def test_pages_kept_on_purpose(self): def test_pages_kept_on_purpose(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a', 0)), 'pages': 3}) layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a', 0)), 'pages': 3})
self.assertEqual(layout['pages'], 3) self.assertEqual(layout['pages'], 3)
self.assertNotIn('pages', validate_layout({'title': 'Thuis', 'tiles': []})) self.assertNotIn('pages', validate_layout({'title': 'Home', 'tiles': []}))
for bad in [0, 9, '2', 2.0, True]: for bad in [0, 9, '2', 2.0, True]:
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Thuis', 'tiles': [], 'pages': bad}) with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Home', 'tiles': [], 'pages': bad})
def test_last_cell_of_page_eight_and_wide_on_last_row(self): def test_last_cell_of_page_eight_and_wide_on_last_row(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a', MAX_SLOTS - 1), ('weather.w', MAX_SLOTS - 4, True))}) layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a', MAX_SLOTS - 1), ('weather.w', MAX_SLOTS - 4, True))})
self.assertEqual([t['slot'] for t in layout['tiles']], [MAX_SLOTS - 4, MAX_SLOTS - 1]) self.assertEqual([t['slot'] for t in layout['tiles']], [MAX_SLOTS - 4, MAX_SLOTS - 1])
class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase): class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase):
@@ -58,7 +58,7 @@ class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json') m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
m.ha.states['light.b'] = {'state': 'off', 'attributes': {}} m.ha.states['light.b'] = {'state': 'off', 'attributes': {}}
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 7), ('light.a', 2))}) m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 7), ('light.a', 2))})
await m.sync_one('text.screen', m.layouts['text.screen']) await m.sync_one('text.screen', m.layouts['text.screen'])
wire = m.ha.messages[0][1] wire = m.ha.messages[0][1]
self.assertEqual(wire['entities'], ['light.a', 'light.b']) self.assertEqual(wire['entities'], ['light.a', 'light.b'])
@@ -67,29 +67,29 @@ class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase):
self.assertEqual([(msg['i'], msg['entity']) for _, msg in m.ha.messages[1:]], [(0, 'light.a'), (1, 'light.b')]) self.assertEqual([(msg['i'], msg['entity']) for _, msg in m.ha.messages[1:]], [(0, 'light.a'), (1, 'light.b')])
# Moving a tile changes the layout message, so the whole layout goes out again. # Moving a tile changes the layout message, so the whole layout goes out again.
m.ha.messages.clear() m.ha.messages.clear()
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 6), ('light.a', 2))}) m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 6), ('light.a', 2))})
await m.sync_one('text.screen', m.layouts['text.screen']) await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[0][1]['slots'], [2, 6]) self.assertEqual(m.ha.messages[0][1]['slots'], [2, 6])
self.assertEqual(len(m.ha.messages), 3) self.assertEqual(len(m.ha.messages), 3)
self.assertNotIn('pages', m.ha.messages[0][1]) self.assertNotIn('pages', m.ha.messages[0][1])
m.ha.messages.clear() m.ha.messages.clear()
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 6), ('light.a', 2)), 'pages': 4}) m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 6), ('light.a', 2)), 'pages': 4})
await m.sync_one('text.screen', m.layouts['text.screen']) await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[0][1]['pages'], 4) self.assertEqual(m.ha.messages[0][1]['pages'], 4)
async def test_old_storage_gets_positions_and_old_editor_keeps_order(self): async def test_old_storage_gets_positions_and_old_editor_keeps_order(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json' path = Path(tmp) / 'screens.json'
legacy = {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': '', 'options': {'size': 'wide'}}]} legacy = {'title': 'Home', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': '', 'options': {'size': 'wide'}}]}
path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': legacy}})) path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': legacy}}))
m = test_portal.ManagerTests().setup_manager(path) m = test_portal.ManagerTests().setup_manager(path)
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [0, 2]) self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [0, 2])
m.ha.states['light.b'] = {'state': 'off', 'attributes': {}} m.ha.states['light.b'] = {'state': 'off', 'attributes': {}}
# The editor moves light.b to page two; an older editor that saves without positions packs again. # The editor moves light.b to page two; an older editor that saves without positions packs again.
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'slot': 1}, {'entity': 'light.b', 'slot': 6, 'options': {'size': 'wide'}}]}) m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'slot': 1}, {'entity': 'light.b', 'slot': 6, 'options': {'size': 'wide'}}]})
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [1, 6]) self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [1, 6])
self.assertEqual(json.loads(path.read_text())['screens']['text.screen']['tiles'][1]['slot'], 6) self.assertEqual(json.loads(path.read_text())['screens']['text.screen']['tiles'][1]['slot'], 6)
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.b'}, {'entity': 'light.a'}]}) m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.b'}, {'entity': 'light.a'}]})
saved = m.layouts['text.screen']['tiles'] saved = m.layouts['text.screen']['tiles']
self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.b', 0), ('light.a', 2)]) self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.b', 0), ('light.a', 2)])
self.assertEqual(saved[0]['options'], {'size': 'wide'}, 'options survive an old editor, as before') self.assertEqual(saved[0]['options'], {'size': 'wide'}, 'options survive an old editor, as before')
+232
View File
@@ -0,0 +1,232 @@
"""Firmware 0.2.34 gave the screen entities English names. Home Assistant removes a renamed ESPHome entity
and registers it under a new entity id, so the manager moves a screen's layout and update history to the
new inbox id instead of orphaning them (app 0.2.40)."""
import asyncio
from datetime import timezone
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import FIRMWARE_VERSION, device_prefixes, discover_screens, entity_slug, inbox_prefix
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
DUTCH = {'text': 'Tegelinstellingen', 'firmware': 'Schermfirmware', 'node': 'Apparaatnaam', 'ip': 'IP-adres'}
ENGLISH = {'text': 'Tile settings', 'firmware': 'Screen firmware', 'node': 'Device name', 'ip': 'IP address'}
def entities(prefix, device, names, firmware, node, ip='10.0.0.5'):
"""Registry entries and states one screen creates; 'Node Status' is a name the translation kept."""
ids = {'text': f'text.{prefix}_{entity_slug(names["text"])}', 'firmware': f'sensor.{prefix}_{entity_slug(names["firmware"])}',
'node': f'sensor.{prefix}_{entity_slug(names["node"])}', 'ip': f'sensor.{prefix}_{entity_slug(names["ip"])}',
'status': f'binary_sensor.{prefix}_node_status'}
registry = [{'entity_id': ids[key], 'platform': 'esphome', 'original_name': names[key], 'device_id': device} for key in names]
registry.append({'entity_id': ids['status'], 'platform': 'esphome', 'original_name': 'Node Status', 'device_id': device})
states = {ids['text']: {'state': 'Synced'}, ids['firmware']: {'state': firmware}, ids['node']: {'state': node},
ids['ip']: {'state': ip}, ids['status']: {'state': 'on'}}
return registry, states
class PrefixTests(unittest.TestCase):
def test_slugs_and_prefixes(self):
self.assertEqual([entity_slug(n) for n in ('Tile settings', 'IP-adres', 'Node Status', 'Guition schermtype')],
['tile_settings', 'ip_adres', 'node_status', 'guition_schermtype'])
self.assertEqual(inbox_prefix('text.office_1_tegelinstellingen'), 'office_1')
self.assertEqual(inbox_prefix('text.office_1_tile_settings'), 'office_1')
for other in ('text.tile_settings', 'sensor.office_1_tile_settings', 'text.office_1_tile_settings_2', 'text.my_inbox', None):
self.assertIsNone(inbox_prefix(other), other)
registry, _ = entities('office_1', 'd1', ENGLISH, '0.2.34', 'office-1')
registry.append({'entity_id': 'text.renamed_by_hand', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'})
registry.append({'entity_id': 'light.office_1_lamp', 'platform': 'hue', 'original_name': 'Lamp', 'device_id': 'd1'})
self.assertEqual(device_prefixes(registry, 'd1'), {'office_1'})
self.assertEqual(device_prefixes(registry, 'd2'), set())
def test_both_languages_are_discovered(self):
for names, inbox in ((DUTCH, 'text.office_1_tegelinstellingen'), (ENGLISH, 'text.office_1_tile_settings')):
registry, states = entities('office_1', 'd1', names, '0.2.33', 'office-1')
[screen] = discover_screens(registry, states, [{'id': 'd1', 'name': 'Office 1'}], [])
self.assertEqual((screen['id'], screen['firmware'], screen['node'], screen['ip']), (inbox, '0.2.33', 'office-1', '10.0.0.5'))
class FakeFirmware:
"""The ESPHome CLI stand-in: an install reflashes the screen with English entity names."""
def __init__(self, ha):
self.ha, self.task, self.job, self.calls = ha, None, None, []
def profile_names(self):
return {'office-1.yaml': {'node': 'office-1', 'friendly': 'Office 1'}}
def start(self, data):
self.calls.append(data)
self.job = {'state': 'running', **data}
self.task = asyncio.create_task(self.run())
return self.job
async def run(self):
await asyncio.sleep(0)
registry, states = entities('office_1', 'd1', ENGLISH, FIRMWARE_VERSION, 'office-1')
self.ha.registry = registry + [item for item in self.ha.registry if item.get('device_id') != 'd1']
self.ha.states = {**{k: v for k, v in self.ha.states.items() if 'office_1' not in k}, **states}
self.job['state'] = 'success'
def fake_ha():
class HA:
online = True
time_zone = timezone.utc
def __init__(self):
office, office_states = entities('office_1', 'd1', DUTCH, '0.2.30', 'office-1')
kitchen, kitchen_states = entities('kitchen', 'd2', ENGLISH, FIRMWARE_VERSION, 'kitchen', '10.0.0.6')
self.registry = office + kitchen + [{'entity_id': 'light.a', 'platform': 'hue'}]
self.states = {**office_states, **kitchen_states, 'light.a': {'state': 'on', 'attributes': {'friendly_name': 'Lamp'}}}
self.devices, self.areas = [{'id': 'd1', 'name': 'Office 1'}, {'id': 'd2', 'name': 'Kitchen'}], []
self.changed, self.dirty, self.relevant, self.messages, self.calls = asyncio.Event(), set(), None, [], []
async def send(self, inbox, message, action=None):
self.messages.append((inbox, message, action))
async def request(self, kind, **data):
self.calls.append((kind, data))
return HA()
OLD, NEW, KITCHEN = 'text.office_1_tegelinstellingen', 'text.office_1_tile_settings', 'text.kitchen_tile_settings'
LAYOUT = {'title': 'Office', 'tiles': [{'entity': 'light.a', 'name': 'Lamp', 'slot': 0}]}
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class RenamedInboxTests(unittest.IsolatedAsyncioTestCase):
def manager(self, path, stored=None, updates=None, ha=None):
from server import Manager
if stored is not None:
(Path(path) / 'screens.json').write_text(json.dumps({'version': 1, 'screens': stored}))
if updates is not None:
(Path(path) / 'updates.json').write_text(json.dumps({'version': 1, 'auto': False, **updates}))
m = Manager(ha or fake_ha(), Path(path) / 'screens.json')
m.firmware = FakeFirmware(m.ha)
for attr in ('verify_timeout', 'settle_seconds', 'pause_seconds', 'poll_seconds'):
setattr(m.updates, attr, 1 if attr == 'verify_timeout' else 0)
return m
def reflash(self, m):
registry, states = entities('office_1', 'd1', ENGLISH, FIRMWARE_VERSION, 'office-1')
m.ha.registry = registry + [item for item in m.ha.registry if item.get('device_id') != 'd1']
m.ha.states = {**{k: v for k, v in m.ha.states.items() if 'office_1' not in k}, **states}
async def test_reflash_moves_layout_and_update_history(self):
with tempfile.TemporaryDirectory() as tmp:
kitchen = {'title': 'Kitchen', 'tiles': []}
m = self.manager(tmp, {OLD: LAYOUT, KITCHEN: kitchen},
{'hosts': {OLD: '10.0.0.5'}, 'results': {OLD: {'state': 'success', 'message': 'Updated.'}}})
self.assertEqual(sorted(s['id'] for s in m.screens()), [KITCHEN, OLD])
self.assertEqual(m.aliases, {})
self.reflash(m)
self.assertEqual(sorted(s['id'] for s in m.screens()), [KITCHEN, NEW])
self.assertEqual(m.layouts[NEW]['title'], 'Office')
self.assertNotIn(OLD, m.layouts)
self.assertEqual(m.layouts[KITCHEN], kitchen)
self.assertEqual(json.loads((Path(tmp) / 'screens.json').read_text())['screens'].keys(), {NEW, KITCHEN})
self.assertEqual((m.updates.hosts, list(m.updates.results)), ({NEW: '10.0.0.5'}, [NEW]))
self.assertEqual(json.loads((Path(tmp) / 'updates.json').read_text())['hosts'], {NEW: '10.0.0.5'})
self.assertEqual(m.aliases, {OLD: NEW})
self.assertEqual(m.screen(OLD)['id'], NEW, 'lookups by the old id follow the screen')
# A page opened before the rename still saves to the right screen.
m.save(OLD, {**LAYOUT, 'title': 'Office 2'})
self.assertEqual((m.layouts[NEW]['title'], OLD in m.layouts), ('Office 2', False))
# The sync loop sends the layout to the new inbox.
m.ha.changed.set()
runner = asyncio.create_task(m.run())
for _ in range(50):
await asyncio.sleep(0.02)
if any(inbox == NEW for inbox, _, _ in m.ha.messages):
break
runner.cancel()
self.assertTrue(any(inbox == NEW and msg['op'] == 'layout' and msg['inbox'] == NEW for inbox, msg, _ in m.ha.messages))
async def test_pages_opened_before_the_rename_keep_working(self):
from aiohttp.test_utils import TestClient, TestServer
from server import create_app
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, {OLD: LAYOUT})
async with TestClient(TestServer(create_app(m, True))) as client:
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
self.reflash(m)
light = await (await client.get('/api/inventory?light=1')).json()
office = next(s for s in light['screens'] if s['node'] == 'office-1')
self.assertEqual((office['id'], office['layout']['title']), (NEW, 'Office'))
inspected = await (await client.get(f'/api/screens/{OLD}/inspect')).json()
self.assertEqual((inspected['screen']['id'], [t['entity'] for t in inspected['tiles']]), (NEW, ['light.a']))
saved = await client.put(f'/api/screens/{OLD}', headers={'X-Screen-CSRF': csrf}, json={**LAYOUT, 'title': 'Office 3'})
self.assertEqual(saved.status, 200)
self.assertEqual((m.layouts[NEW]['title'], OLD in m.layouts), ('Office 3', False))
async def test_restart_after_reflash_adopts_by_device_prefix(self):
with tempfile.TemporaryDirectory() as tmp:
ha = fake_ha()
registry, states = entities('office_1', 'd1', ENGLISH, FIRMWARE_VERSION, 'office-1')
ha.registry = registry + [item for item in ha.registry if item.get('device_id') != 'd1']
ha.states = {**{k: v for k, v in ha.states.items() if 'office_1' not in k}, **states}
m = self.manager(tmp, {OLD: LAYOUT}, ha=ha)
self.assertEqual(sorted(s['id'] for s in m.screens()), [KITCHEN, NEW])
self.assertEqual(list(m.layouts), [NEW])
self.assertIn(NEW, [screen['id'] for screen in m.inventory()[0]])
async def test_orphaned_layouts_of_other_screens_stay_put(self):
with tempfile.TemporaryDirectory() as tmp:
hall = 'text.hall_tegelinstellingen'
m = self.manager(tmp, {hall: LAYOUT})
self.reflash(m)
m.screens()
self.assertEqual(list(m.layouts), [hall], 'a removed screen keeps its layout; nobody else takes it')
self.assertEqual(m.aliases, {})
async def test_a_layout_on_the_new_id_is_never_overwritten(self):
with tempfile.TemporaryDirectory() as tmp:
newer = {'title': 'New', 'tiles': []}
m = self.manager(tmp, {OLD: LAYOUT, NEW: newer})
self.reflash(m)
m.screens()
self.assertEqual((m.layouts[NEW], m.layouts[OLD]), (newer, LAYOUT))
self.assertEqual(m.aliases, {OLD: NEW})
m.ha.registry = list(m.ha.registry) # a later registry refresh does not try again
before = dict(m.layouts)
m.screens()
self.assertEqual((m.layouts, m.aliases), (before, {OLD: NEW}))
async def test_update_round_follows_the_screen_through_the_rename(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, {OLD: LAYOUT})
[screen] = [s for s in m.screens() if s['id'] == OLD]
self.assertTrue(m.updates.state_for(screen)['available'])
m.updates.start(OLD)
await m.updates.task
self.assertEqual(m.firmware.calls, [{'file': 'office-1.yaml', 'action': 'install', 'target': '10.0.0.5'}])
self.assertEqual(list(m.updates.results), [NEW])
self.assertEqual(m.updates.results[NEW]['state'], 'success', m.updates.results[NEW]['message'])
self.assertEqual((m.updates.current, m.updates.queue), (None, []))
self.assertEqual(list(m.layouts), [NEW])
[after] = [s for s in m.screens() if s['id'] == NEW]
self.assertFalse(m.updates.state_for(after)['available'])
async def test_nightly_round_reports_through_the_new_id(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, {OLD: LAYOUT})
async def fails_after_rename():
await asyncio.sleep(0)
self.reflash(m)
m.firmware.job['state'] = 'failed'
m.firmware.run = fails_after_rename
await m.updates.run_round([OLD], automatic=True)
self.assertEqual(m.updates.results[NEW]['state'], 'failed')
self.assertEqual(m.ha.calls[0][1]['domain'], 'persistent_notification')
self.assertIn('Office 1', m.ha.calls[0][1]['service_data']['message'])
if __name__ == '__main__':
unittest.main()
+17 -17
View File
@@ -30,35 +30,35 @@ def daily():
class WeatherExtras(unittest.TestCase): class WeatherExtras(unittest.TestCase):
def test_hours_start_now_and_carry_rain(self): def test_hours_start_now_and_carry_rain(self):
states = {'weather.huis': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h'}}} states = {'weather.house': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h'}}}
extra = extras({'entity': 'weather.huis'}, states, daily(), TZ, hourly(), NOW) extra = extras({'entity': 'weather.house'}, states, daily(), TZ, hourly(), NOW)
self.assertEqual([h['t'] for h in extra['hours']], ['13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'], 'local time, from the current hour') self.assertEqual([h['t'] for h in extra['hours']], ['13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'], 'local time, from the current hour')
self.assertEqual(extra['hours'][0], {'t': '13:00', 'c': 'rainy', 'h': 19.1, 'p': 60, 'r': 0.2}) self.assertEqual(extra['hours'][0], {'t': '13:00', 'c': 'rainy', 'h': 19.1, 'p': 60, 'r': 0.2})
self.assertEqual(extra['hours'][1]['r'], 0.0) self.assertEqual(extra['hours'][1]['r'], 0.0)
self.assertEqual(len(extra['days']), 5) self.assertEqual(len(extra['days']), 5)
self.assertEqual(extra['days'][1], {'d': 'ma', 'c': 'cloudy', 'h': 22, 'l': 12.1, 'r': 1.7}, 'no probability when the provider gives none') self.assertEqual(extra['days'][1], {'d': 'Mo', 'c': 'cloudy', 'h': 22, 'l': 12.1, 'r': 1.7}, 'no probability when the provider gives none')
message = state_message(0, {'entity': 'weather.huis', 'name': 'Buiten', 'options': {'display': 'forecast'}}, states, extra) message = state_message(0, {'entity': 'weather.house', 'name': 'Outside', 'options': {'display': 'forecast'}}, states, extra)
self.assertEqual(message['a']['wind_speed_unit'], 'km/h') self.assertEqual(message['a']['wind_speed_unit'], 'km/h')
self.assertEqual(message['a']['humidity'], 92) self.assertEqual(message['a']['humidity'], 92)
self.assertLessEqual(len(packets(message)), 8, 'a full weather card still fits comfortably') self.assertLessEqual(len(packets(message)), 8, 'a full weather card still fits comfortably')
def test_weather_without_forecasts_sends_nothing_extra(self): def test_weather_without_forecasts_sends_nothing_extra(self):
self.assertIsNone(extras({'entity': 'weather.huis'}, {}, None, TZ, None, NOW)) self.assertIsNone(extras({'entity': 'weather.house'}, {}, None, TZ, None, NOW))
self.assertEqual(extras({'entity': 'weather.huis'}, {}, daily(), TZ, [], NOW).keys(), {'days'}) self.assertEqual(extras({'entity': 'weather.house'}, {}, daily(), TZ, [], NOW).keys(), {'days'})
class LastRun(unittest.TestCase): class LastRun(unittest.TestCase):
def test_scripts_scenes_and_buttons_report_when_they_last_ran(self): def test_scripts_scenes_and_buttons_report_when_they_last_ran(self):
states = {'script.tv': {'state': 'off', 'attributes': {'last_triggered': '2026-09-13T08:10:49.403953+00:00'}}, states = {'script.tv': {'state': 'off', 'attributes': {'last_triggered': '2026-09-13T08:10:49.403953+00:00'}},
'scene.avond': {'state': '2026-09-12T16:33:37.040291+00:00', 'attributes': {}}, 'scene.evening': {'state': '2026-09-12T16:33:37.040291+00:00', 'attributes': {}},
'button.bel': {'state': '2026-09-13T09:00:00+00:00', 'attributes': {}}, 'button.bell': {'state': '2026-09-13T09:00:00+00:00', 'attributes': {}},
'script.nieuw': {'state': 'off', 'attributes': {'last_triggered': None}}, 'script.new': {'state': 'off', 'attributes': {'last_triggered': None}},
'scene.nooit': {'state': 'unknown', 'attributes': {}}} 'scene.never': {'state': 'unknown', 'attributes': {}}}
self.assertEqual(extras({'entity': 'script.tv'}, states, None, TZ), {'last': 1789287049}) self.assertEqual(extras({'entity': 'script.tv'}, states, None, TZ), {'last': 1789287049})
self.assertEqual(extras({'entity': 'scene.avond'}, states, None, TZ), {'last': 1789230817}) self.assertEqual(extras({'entity': 'scene.evening'}, states, None, TZ), {'last': 1789230817})
self.assertEqual(extras({'entity': 'button.bel'}, states, None, TZ), {'last': 1789290000}) self.assertEqual(extras({'entity': 'button.bell'}, states, None, TZ), {'last': 1789290000})
self.assertIsNone(extras({'entity': 'script.nieuw'}, states, None, TZ)) self.assertIsNone(extras({'entity': 'script.new'}, states, None, TZ))
self.assertIsNone(extras({'entity': 'scene.nooit'}, states, None, TZ)) self.assertIsNone(extras({'entity': 'scene.never'}, states, None, TZ))
message = state_message(0, {'entity': 'script.tv', 'name': ''}, states, extras({'entity': 'script.tv'}, states, None, TZ)) message = state_message(0, {'entity': 'script.tv', 'name': ''}, states, extras({'entity': 'script.tv'}, states, None, TZ))
self.assertEqual(message['x'], {'last': 1789287049}) self.assertEqual(message['x'], {'last': 1789287049})
self.assertNotIn('last_triggered', message['a'], 'the raw timestamp stays behind; the firmware gets the epoch') self.assertNotIn('last_triggered', message['a'], 'the raw timestamp stays behind; the firmware gets the epoch')
@@ -68,16 +68,16 @@ class HourlySync(unittest.IsolatedAsyncioTestCase):
async def test_manager_asks_for_daily_and_hourly_forecasts(self): async def test_manager_asks_for_daily_and_hourly_forecasts(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json') m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
m.ha.states['weather.huis'] = {'state': 'rainy', 'attributes': {'friendly_name': 'Huis', 'temperature': 18.4}} m.ha.states['weather.house'] = {'state': 'rainy', 'attributes': {'friendly_name': 'House', 'temperature': 18.4}}
asked = [] asked = []
async def forecast(entity, kind='daily'): async def forecast(entity, kind='daily'):
asked.append(kind) asked.append(kind)
# The manager reads the real clock, so the hourly strip is anchored to today. # The manager reads the real clock, so the hourly strip is anchored to today.
return daily() if kind == 'daily' else hourly(datetime.now(timezone.utc)) return daily() if kind == 'daily' else hourly(datetime.now(timezone.utc))
m.ha.forecast = forecast m.ha.forecast = forecast
m.ha.registry.append({'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware'}) m.ha.registry.append({'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Screen firmware'})
m.ha.states['sensor.fw'] = {'state': '0.2.19'} m.ha.states['sensor.fw'] = {'state': '0.2.19'}
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'weather.huis', 'name': '', 'options': {'display': 'forecast'}}]}) m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'weather.house', 'name': '', 'options': {'display': 'forecast'}}]})
await m.sync_one('text.screen', m.layouts['text.screen']) await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(sorted(asked), ['daily', 'hourly']) self.assertEqual(sorted(asked), ['daily', 'hourly'])
sent = m.ha.messages[1][1] sent = m.ha.messages[1][1]
+3 -3
View File
@@ -14,10 +14,10 @@ class RotationTests(unittest.IsolatedAsyncioTestCase):
async def test_legacy_wire_and_saved_rotation_survive_old_browser(self): async def test_legacy_wire_and_saved_rotation_survive_old_browser(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json') m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Thuis','tiles':[{'entity':'light.a'}],'settings':{'rotation':90}} layout={'title':'Home','tiles':[{'entity':'light.a'}],'settings':{'rotation':90}}
with self.assertRaises(ValueError):m.save('text.screen',layout) with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry[0]['device_id']='guition' m.ha.registry[0]['device_id']='guition'
m.ha.registry.append({'entity_id':'sensor.board','device_id':'guition','platform':'esphome','original_name':'Guition schermtype'}) m.ha.registry.append({'entity_id':'sensor.board','device_id':'guition','platform':'esphome','original_name':'Guition screen type'})
# Capability stays discoverable with the panel offline or renamed. # Capability stays discoverable with the panel offline or renamed.
m.ha.states['sensor.board']={'state':'unavailable'} m.ha.states['sensor.board']={'state':'unavailable'}
m.save('text.screen',layout) m.save('text.screen',layout)
@@ -26,7 +26,7 @@ class RotationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(wire['rotation'],90) self.assertEqual(wire['rotation'],90)
self.assertEqual(len(wire['settings']),11) self.assertEqual(len(wire['settings']),11)
self.assertNotIn('rotation',wire['settings']) self.assertNotIn('rotation',wire['settings'])
m.save('text.screen',{'title':'Anders','tiles':layout['tiles'],'settings':{'brightness':80}}) m.save('text.screen',{'title':'Different','tiles':layout['tiles'],'settings':{'brightness':80}})
self.assertEqual(m.layouts['text.screen']['settings']['rotation'],90) self.assertEqual(m.layouts['text.screen']['settings']['rotation'],90)
fresh=test_portal.ManagerTests().setup_manager(m.path) fresh=test_portal.ManagerTests().setup_manager(m.path)
self.assertEqual(fresh.layouts['text.screen']['settings']['rotation'],90) self.assertEqual(fresh.layouts['text.screen']['settings']['rotation'],90)
+24 -24
View File
@@ -4,28 +4,28 @@ int main() {
using namespace runtime_tiles; using namespace runtime_tiles;
Model m; bool changed = false; Model m; bool changed = false;
assert(!m.ready()); assert(!m.ready());
assert(!m.set_layout({"light.a", "light.a"}, "Thuis", changed)); assert(!m.set_layout({"light.a", "light.a"}, "Home", changed));
assert(!m.set_layout({"light.a;script.bad"}, "Thuis", changed)); assert(!m.set_layout({"light.a;script.bad"}, "Home", changed));
assert(!m.set_layout({"light."}, "Thuis", changed)); assert(!m.set_layout({"light."}, "Home", changed));
assert(!m.set_layout({"lock.frontdoor"}, "Thuis", changed)); assert(!m.set_layout({"lock.frontdoor"}, "Home", changed));
assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Thuis", changed)); assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Home", changed));
assert(changed && !m.ready()); assert(changed && !m.ready());
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "off"; } for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "off"; }
assert(m.ready()); assert(m.ready());
assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Keuken", changed)); assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Kitchen", changed));
assert(!changed && m.ready()); // periodic sync never interrupts an open card assert(!changed && m.ready()); // periodic sync never interrupts an open card
assert(!m.accepts(10, "light.a")); assert(!m.accepts(10, "light.a"));
assert(!m.accepts(0, "light.removed")); assert(!m.accepts(0, "light.removed"));
assert(m.accepts(0, "light.a")); assert(m.accepts(0, "light.a"));
assert(m.set_layout({"vacuum.robot", "light.new"}, "Keuken", changed)); assert(m.set_layout({"vacuum.robot", "light.new"}, "Kitchen", changed));
assert(changed && !m.ready()); assert(changed && !m.ready());
assert(m.tiles[0].state.empty()); // never reuse the old slot's state/actions assert(m.tiles[0].state.empty()); // never reuse the old slot's state/actions
assert(!m.accepts(0, "light.a")); assert(!m.accepts(0, "light.a"));
assert(m.set_layout({}, "Leeg", changed)); assert(changed && m.ready() && m.count == 0); assert(m.set_layout({}, "Empty", changed)); assert(changed && m.ready() && m.count == 0);
std::vector<std::string> many; std::vector<std::string> many;
for (int i = 0; i < 21; ++i) many.push_back("light.a" + std::to_string(i)); for (int i = 0; i < 21; ++i) many.push_back("light.a" + std::to_string(i));
assert(!m.set_layout(many, "Te veel", changed)); assert(m.count == 0); assert(!m.set_layout(many, "Too many", changed)); assert(m.count == 0);
many.pop_back(); assert(m.set_layout(many,"Twintig",changed)); many.pop_back(); assert(m.set_layout(many,"Twenty",changed));
assert(m.count==20 && m.accepts(19,"light.a19")); assert(m.count==20 && m.accepts(19,"light.a19"));
for(size_t i=0;i<m.count;++i){m.tiles[i].received=true;m.tiles[i].state="off";} for(size_t i=0;i<m.count;++i){m.tiles[i].received=true;m.tiles[i].state="off";}
assert(m.ready()); assert(m.ready());
@@ -36,7 +36,7 @@ static void test_domains_and_packing() {
Model m; bool changed = false; Model m; bool changed = false;
assert(valid_entity("screen.clock") && !valid_entity("screen.other")); assert(valid_entity("screen.clock") && !valid_entity("screen.other"));
assert(valid_entity("sun.sun") && valid_entity("timer.kitchen") && valid_entity("person.max")); assert(valid_entity("sun.sun") && valid_entity("timer.kitchen") && valid_entity("person.max"));
assert(m.set_layout({"screen.clock", "weather.home", "light.a", "sensor.b", "person.max", "timer.egg", "sun.sun"}, "Thuis", changed)); assert(m.set_layout({"screen.clock", "weather.home", "light.a", "sensor.b", "person.max", "timer.egg", "sun.sun"}, "Home", changed));
assert(m.tiles[0].available() && !m.tiles[1].available()); // built-in cards need no HA state assert(m.tiles[0].available() && !m.tiles[1].available()); // built-in cards need no HA state
m.tiles[4].state = "home"; assert(m.tiles[4].active()); m.tiles[4].state = "home"; assert(m.tiles[4].active());
m.tiles[5].state = "active"; assert(m.tiles[5].active()); m.tiles[5].state = "active"; assert(m.tiles[5].active());
@@ -63,41 +63,41 @@ static void test_explicit_slots() {
Model m; bool changed = false, moved = false; Model m; bool changed = false, moved = false;
std::array<Placement, MAX_TILES> p; std::array<Placement, MAX_TILES> p;
// Validation: one slot per entity, inside eight pages, no duplicates. // Validation: one slot per entity, inside eight pages, no duplicates.
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {0}, moved)); assert(!m.set_layout({"light.a", "light.b"}, "Home", changed, {0}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {0, 48}, moved)); assert(!m.set_layout({"light.a", "light.b"}, "Home", changed, {0, 48}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {3, 3}, moved)); assert(!m.set_layout({"light.a", "light.b"}, "Home", changed, {3, 3}, moved));
assert(!m.configured); assert(!m.configured);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 5, 7}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 5, 7}, moved));
assert(changed && !moved && m.explicit_slots); assert(changed && !moved && m.explicit_slots);
assert(place(m, p) == 2 && p[0].page == 0 && p[0].slot == 0 && p[1].slot == 5 && p[2].page == 1 && p[2].slot == 1); assert(place(m, p) == 2 && p[0].page == 0 && p[0].slot == 0 && p[1].slot == 5 && p[2].page == 1 && p[2].slot == 1);
// Same tiles elsewhere: states survive, only the pages re-place. // Same tiles elsewhere: states survive, only the pages re-place.
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "on"; } for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "on"; }
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 7}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {2, 5, 7}, moved));
assert(!changed && moved && m.ready() && m.tiles[0].state == "on"); assert(!changed && moved && m.ready() && m.tiles[0].state == "on");
assert(place(m, p) == 2 && p[0].slot == 2); assert(place(m, p) == 2 && p[0].slot == 2);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 7}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {2, 5, 7}, moved));
assert(!changed && !moved); assert(!changed && !moved);
// A wide card on an odd slot snaps to its row start; the page count follows its footprint. // A wide card on an odd slot snaps to its row start; the page count follows its footprint.
m.tiles[2].wide = true; m.tiles[2].wide = true;
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 11}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {2, 5, 11}, moved));
assert(!changed && moved && place(m, p) == 2 && p[2].page == 1 && p[2].slot == 4); assert(!changed && moved && place(m, p) == 2 && p[2].page == 1 && p[2].slot == 4);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 12}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {2, 5, 12}, moved));
assert(place(m, p) == 3 && p[2].page == 2 && p[2].slot == 0); assert(place(m, p) == 3 && p[2].page == 2 && p[2].slot == 0);
// An empty page between two used pages stays a page; the last slot of page eight is allowed. // An empty page between two used pages stays a page; the last slot of page eight is allowed.
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 1, 46}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 1, 46}, moved));
assert(place(m, p) == 8 && p[2].page == 7 && p[2].slot == 4); assert(place(m, p) == 8 && p[2].page == 7 && p[2].slot == 4);
// Pages kept on purpose extend the count, never shrink it. // Pages kept on purpose extend the count, never shrink it.
m.pages = 3; assert(place(m, p) == 8); m.pages = 3; assert(place(m, p) == 8);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 1, 2}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 1, 2}, moved));
assert(place(m, p) == 3); m.pages = 1; assert(place(m, p) == 1); m.pages = 200; assert(place(m, p) == MAX_PAGES); m.pages = 1; assert(place(m, p) == 3); m.pages = 1; assert(place(m, p) == 1); m.pages = 200; assert(place(m, p) == MAX_PAGES); m.pages = 1;
// Without slots (older manager) the in-order packing returns. // Without slots (older manager) the in-order packing returns.
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {}, moved));
assert(!changed && moved && !m.explicit_slots); assert(!changed && moved && !m.explicit_slots);
assert(place(m, p) == 1 && p[0].slot == 0 && p[1].slot == 1 && p[2].slot == 2); assert(place(m, p) == 1 && p[0].slot == 0 && p[1].slot == 1 && p[2].slot == 2);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {}, moved)); assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {}, moved));
assert(!changed && !moved); assert(!changed && !moved);
// Changing the tiles resets everything, including the positions. // Changing the tiles resets everything, including the positions.
assert(m.set_layout({"light.a", "light.z"}, "Thuis", changed, {1, 6}, moved)); assert(m.set_layout({"light.a", "light.z"}, "Home", changed, {1, 6}, moved));
assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1); assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1);
} }
struct RunSlots { RunSlots() { test_explicit_slots(); } } run_slots; struct RunSlots { RunSlots() { test_explicit_slots(); } } run_slots;
+16 -16
View File
@@ -21,7 +21,7 @@ if HAS_AIOHTTP:
class ProtocolTests(unittest.TestCase): class ProtocolTests(unittest.TestCase):
def test_encode_revision_and_action(self): def test_encode_revision_and_action(self):
message = {'v': 1, 'op': 'layout', 'title': 'Kantoor 1', 'entities': ['light.a']} message = {'v': 1, 'op': 'layout', 'title': 'Office 1', 'entities': ['light.a']}
self.assertEqual(json.loads(encode(message)), message) self.assertEqual(json.loads(encode(message)), message)
self.assertNotIn(', ', encode(message), 'compact separators') self.assertNotIn(', ', encode(message), 'compact separators')
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -30,10 +30,10 @@ class ProtocolTests(unittest.TestCase):
import base64 import base64
self.assertEqual(base64.b64decode(''.join(p.split('|')[3] for p in packets(message))).decode(), encode(message)) self.assertEqual(base64.b64decode(''.join(p.split('|')[3] for p in packets(message))).decode(), encode(message))
# Same content, any key order: same revision; any change: another one. # Same content, any key order: same revision; any change: another one.
self.assertEqual(revision(message), revision({'entities': ['light.a'], 'title': 'Kantoor 1', 'op': 'layout', 'v': 1})) self.assertEqual(revision(message), revision({'entities': ['light.a'], 'title': 'Office 1', 'op': 'layout', 'v': 1}))
self.assertNotEqual(revision(message), revision({**message, 'title': 'Kantoor 2'})) self.assertNotEqual(revision(message), revision({**message, 'title': 'Office 2'}))
self.assertEqual(len(revision(message)), 12) self.assertEqual(len(revision(message)), 12)
self.assertEqual(message_action('kantoor-1'), 'esphome.kantoor_1_screen_message') self.assertEqual(message_action('office-1'), 'esphome.office_1_screen_message')
self.assertIsNone(message_action(None)) self.assertIsNone(message_action(None))
self.assertEqual(TRANSPORT_MIN_FIRMWARE, (0, 2, 33)) self.assertEqual(TRANSPORT_MIN_FIRMWARE, (0, 2, 33))
@@ -47,21 +47,21 @@ class ProtocolTests(unittest.TestCase):
self.assertEqual(samples([], 0, 2400), [None] * 24) self.assertEqual(samples([], 0, 2400), [None] * 24)
def fake_ha(firmware='0.2.33', node='kantoor-1'): def fake_ha(firmware='0.2.33', node='office-1'):
class HA: class HA:
online = True online = True
def __init__(self): def __init__(self):
self.registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd1'}, self.registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'}, {'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'},
{'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'}, {'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
{'entity_id': 'light.a', 'platform': 'hue'}, {'entity_id': 'light.b', 'platform': 'hue'}, {'entity_id': 'light.a', 'platform': 'hue'}, {'entity_id': 'light.b', 'platform': 'hue'},
{'entity_id': 'sensor.t', 'platform': 'mqtt'}, {'entity_id': 'sensor.co2', 'platform': 'mqtt'}] {'entity_id': 'sensor.t', 'platform': 'mqtt'}, {'entity_id': 'sensor.co2', 'platform': 'mqtt'}]
self.devices, self.areas = [{'id': 'd1', 'name': 'Kantoor 1'}], [] self.devices, self.areas = [{'id': 'd1', 'name': 'Office 1'}], []
self.states = {'text.screen': {'state': 'Gesynchroniseerd'}, 'sensor.fw': {'state': firmware}, 'text.node': {'state': node}, self.states = {'text.screen': {'state': 'Synced'}, 'sensor.fw': {'state': firmware}, 'text.node': {'state': node},
'light.a': {'state': 'on', 'attributes': {'friendly_name': 'Lamp A'}}, 'light.a': {'state': 'on', 'attributes': {'friendly_name': 'Lamp A'}},
'light.b': {'state': 'off', 'attributes': {'friendly_name': 'Lamp B'}}, 'light.b': {'state': 'off', 'attributes': {'friendly_name': 'Lamp B'}},
'sensor.t': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C', 'friendly_name': 'Temperatuur'}}, 'sensor.t': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C', 'friendly_name': 'Temperature'}},
'sensor.co2': {'state': '600', 'attributes': {'unit_of_measurement': 'ppm', 'friendly_name': 'CO2'}}} 'sensor.co2': {'state': '600', 'attributes': {'unit_of_measurement': 'ppm', 'friendly_name': 'CO2'}}}
self.changed = asyncio.Event() self.changed = asyncio.Event()
self.dirty = set() self.dirty = set()
@@ -87,7 +87,7 @@ def fake_ha(firmware='0.2.33', node='kantoor-1'):
class IncrementalSync(unittest.IsolatedAsyncioTestCase): class IncrementalSync(unittest.IsolatedAsyncioTestCase):
def manager(self, tmp, **kw): def manager(self, tmp, **kw):
m = Manager(fake_ha(**kw), Path(tmp) / 'screens.json') m = Manager(fake_ha(**kw), Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Kantoor 1', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': ''}], m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': ''}],
'header': {'items': [{'type': 'clock'}, {'type': 'entity', 'entity': 'sensor.t', 'content': 'state', 'icon': 'auto', 'show': 'always'}]}}) 'header': {'items': [{'type': 'clock'}, {'type': 'entity', 'entity': 'sensor.t', 'content': 'state', 'icon': 'auto', 'show': 'always'}]}})
return m return m
@@ -99,7 +99,7 @@ class IncrementalSync(unittest.IsolatedAsyncioTestCase):
ops = [msg['op'] for _, msg, _ in m.ha.messages] ops = [msg['op'] for _, msg, _ in m.ha.messages]
self.assertEqual(ops, ['layout', 'header', 'state', 'state']) self.assertEqual(ops, ['layout', 'header', 'state', 'state'])
self.assertIn('rev', m.ha.messages[0][1]) self.assertIn('rev', m.ha.messages[0][1])
self.assertTrue(all(action == 'esphome.kantoor_1_screen_message' for _, _, action in m.ha.messages), 'firmware 0.2.33: one action per message') self.assertTrue(all(action == 'esphome.office_1_screen_message' for _, _, action in m.ha.messages), 'firmware 0.2.33: one action per message')
m.ha.messages.clear() m.ha.messages.clear()
# Both lamps change in HA, but only light.a is reported dirty: light.b's message is reused as sent. # Both lamps change in HA, but only light.a is reported dirty: light.b's message is reused as sent.
m.ha.states['light.a']['state'] = 'off' m.ha.states['light.a']['state'] = 'off'
@@ -135,7 +135,7 @@ class IncrementalSync(unittest.IsolatedAsyncioTestCase):
rev = m.ha.messages[0][1]['rev'] rev = m.ha.messages[0][1]['rev']
m.ha.messages.clear() m.ha.messages.clear()
await m.ping('text.screen', m.screen('text.screen')) await m.ping('text.screen', m.screen('text.screen'))
self.assertEqual(m.ha.messages, [('text.screen', {'v': 1, 'op': 'ping', 'rev': rev, 'keepalive': KEEPALIVE_SECONDS}, 'esphome.kantoor_1_screen_message')]) self.assertEqual(m.ha.messages, [('text.screen', {'v': 1, 'op': 'ping', 'rev': rev, 'keepalive': KEEPALIVE_SECONDS}, 'esphome.office_1_screen_message')])
self.assertEqual(rev, revision({k: v for k, v in m.sent['text.screen']['layout'].items() if k != 'rev'})) self.assertEqual(rev, revision({k: v for k, v in m.sent['text.screen']['layout'].items() if k != 'rev'}))
# Firmware before 0.2.33: base64 chunks in the text inbox, no ping. # Firmware before 0.2.33: base64 chunks in the text inbox, no ping.
old = self.manager(tmp, firmware='0.2.32') old = self.manager(tmp, firmware='0.2.32')
@@ -176,7 +176,7 @@ class IncrementalSync(unittest.IsolatedAsyncioTestCase):
self.assertEqual([msg['op'] for _, msg, _ in m.ha.messages], ['ping']) self.assertEqual([msg['op'] for _, msg, _ in m.ha.messages], ['ping'])
m.ha.messages.clear() m.ha.messages.clear()
# The screen answered a ping with a mismatch (or restarted): everything again, once. # The screen answered a ping with a mismatch (or restarted): everything again, once.
m.ha.states['text.screen'] = {'state': 'Indeling opnieuw nodig'} m.ha.states['text.screen'] = {'state': 'Resend needed'}
m.last['text.screen'] = time.monotonic() - 130 m.last['text.screen'] = time.monotonic() - 130
m.ha.changed.set() m.ha.changed.set()
await self.run_loop_for(m, 0.6) await self.run_loop_for(m, 0.6)
@@ -187,7 +187,7 @@ class IncrementalSync(unittest.IsolatedAsyncioTestCase):
await self.run_loop_for(m, 0.6) await self.run_loop_for(m, 0.6)
self.assertEqual(m.ha.messages, []) self.assertEqual(m.ha.messages, [])
# The hourly safety net repeats everything. # The hourly safety net repeats everything.
m.ha.states['text.screen'] = {'state': 'Gesynchroniseerd'} m.ha.states['text.screen'] = {'state': 'Synced'}
m.last['text.screen'] = time.monotonic() - FULL_REPEAT_SECONDS m.last['text.screen'] = time.monotonic() - FULL_REPEAT_SECONDS
m.ha.changed.set() m.ha.changed.set()
await self.run_loop_for(m, 0.6) await self.run_loop_for(m, 0.6)
+14 -14
View File
@@ -24,17 +24,17 @@ int main() {
assert(format_value(NAN, 0.5f, "°") == "--"); assert(format_value(NAN, 0.5f, "°") == "--");
// Curtain: horizontal arrows, open disabled when fully open, stop only when supported. // Curtain: horizontal arrows, open disabled when fully open, stop only when supported.
Tile curtain = make("cover.gordijnen", "open", 15); curtain.device_class = "curtain"; curtain.position = 100; curtain.controls = "buttons"; Tile curtain = make("cover.curtains", "open", 15); curtain.device_class = "curtain"; curtain.position = 100; curtain.controls = "buttons";
std::array<Key, 3> keys; std::array<Key, 3> keys;
assert(keys_for(curtain, keys) == 3); assert(keys_for(curtain, keys) == 3);
assert(!strcmp(keys[0].icon, glyph::EXPAND) && keys[0].command == COVER_OPEN && keys[0].disabled); assert(!strcmp(keys[0].icon, glyph::EXPAND) && keys[0].command == COVER_OPEN && keys[0].disabled);
assert(keys[1].command == COVER_STOP && !keys[1].disabled); assert(keys[1].command == COVER_STOP && !keys[1].disabled);
assert(!strcmp(keys[2].icon, glyph::COLLAPSE) && keys[2].command == COVER_CLOSE && !keys[2].disabled); assert(!strcmp(keys[2].icon, glyph::COLLAPSE) && keys[2].command == COVER_CLOSE && !keys[2].disabled);
assert(key_action(curtain, COVER_CLOSE).service == "cover.close_cover"); assert(key_action(curtain, COVER_CLOSE).service == "cover.close_cover");
Tile shutter = make("cover.rolluik", "closed", 3); shutter.controls = "buttons"; Tile shutter = make("cover.shutter", "closed", 3); shutter.controls = "buttons";
assert(keys_for(shutter, keys) == 2 && !strcmp(keys[0].icon, glyph::UP) && keys[1].disabled); assert(keys_for(shutter, keys) == 2 && !strcmp(keys[0].icon, glyph::UP) && keys[1].disabled);
assert(status_text(curtain) == "Open · 100%"); assert(status_text(curtain) == "Open · 100%");
assert(status_text(shutter) == "Dicht"); assert(status_text(shutter) == "Closed");
// Vacuum: play while docked, pause while cleaning, dock disabled in the dock. // Vacuum: play while docked, pause while cleaning, dock disabled in the dock.
Tile robot = make("vacuum.s8", "docked", 30524); robot.controls = "buttons"; Tile robot = make("vacuum.s8", "docked", 30524); robot.controls = "buttons";
@@ -57,23 +57,23 @@ int main() {
sonos.muted = true; assert(key_action(sonos, MEDIA_MUTE).value == "false"); sonos.muted = true; assert(key_action(sonos, MEDIA_MUTE).value == "false");
Tile radio = make("media_player.radio", "idle", feature::MEDIA_PLAY | feature::MEDIA_PAUSE); radio.controls = "playback"; Tile radio = make("media_player.radio", "idle", feature::MEDIA_PLAY | feature::MEDIA_PAUSE); radio.controls = "playback";
assert(keys_for(radio, keys) == 1 && !strcmp(keys[0].icon, glyph::PLAY)); assert(keys_for(radio, keys) == 1 && !strcmp(keys[0].icon, glyph::PLAY));
assert(status_text(radio) == "Inactief"); assert(status_text(radio) == "Idle");
// Climate: mode keys in priority order, at most three, the active one checked. // Climate: mode keys in priority order, at most three, the active one checked.
Tile ac = make("climate.airco", "cool"); ac.hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1; Tile ac = make("climate.ac", "cool"); ac.hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1;
assert(keys_for(ac, keys) == 3); assert(keys_for(ac, keys) == 3);
assert(keys[0].arg == "off" && keys[1].arg == "heat" && keys[2].arg == "cool" && keys[2].checked && !keys[1].checked); assert(keys[0].arg == "off" && keys[1].arg == "heat" && keys[2].arg == "cool" && keys[2].checked && !keys[1].checked);
Action mode = key_action(ac, HVAC_MODE, keys[1].arg); Action mode = key_action(ac, HVAC_MODE, keys[1].arg);
assert(mode.service == "climate.set_hvac_mode" && mode.key == "hvac_mode" && mode.value == "heat"); assert(mode.service == "climate.set_hvac_mode" && mode.key == "hvac_mode" && mode.value == "heat");
assert(status_text(ac) == "Koelen · 21.5°"); assert(status_text(ac) == "Cool · 21.5°");
ac.hvac_action = "cooling"; assert(status_text(ac) == "Koelt · 21.5°"); ac.hvac_action = "cooling"; assert(status_text(ac) == "Cooling · 21.5°");
assert(edit_target(ac) == 20 && edit_step(ac) == 1); assert(edit_target(ac) == 20 && edit_step(ac) == 1);
Action set = edit_action(ac, step_value(edit_target(ac), edit_step(ac), ac.minimum, ac.maximum, 1)); Action set = edit_action(ac, step_value(edit_target(ac), edit_step(ac), ac.minimum, ac.maximum, 1));
assert(set.service == "climate.set_temperature" && set.key == "temperature" && set.value == "21"); assert(set.service == "climate.set_temperature" && set.key == "temperature" && set.value == "21");
assert(edit_action(ac, 20.5f).value == "20.5"); assert(edit_action(ac, 20.5f).value == "20.5");
// Numbers edit their own state; selects step through their options with wrap-around. // Numbers edit their own state; selects step through their options with wrap-around.
Tile number = make("number.doel", "55"); number.minimum = 0; number.maximum = 100; number.step = 5; Tile number = make("number.target", "55"); number.minimum = 0; number.maximum = 100; number.step = 5;
assert(edit_target(number) == 55 && edit_action(number, step_value(55, 5, 0, 100, -1)).value == "50"); assert(edit_target(number) == 55 && edit_action(number, step_value(55, 5, 0, 100, -1)).value == "50");
assert(edit_action(number, 50).service == "number.set_value"); assert(edit_action(number, 50).service == "number.set_value");
Tile select = make("select.stand", "Comfort"); select.options = {"Eco", "Comfort", "Boost"}; select.option_count = 3; select.controls = "stepper"; Tile select = make("select.stand", "Comfort"); select.options = {"Eco", "Comfort", "Boost"}; select.option_count = 3; select.controls = "stepper";
@@ -84,14 +84,14 @@ int main() {
select.option_count = 1; assert(keys_for(select, keys) == 2 && keys[0].disabled); select.option_count = 1; assert(keys_for(select, keys) == 2 && keys[0].disabled);
// Timer, run buttons and toggles. // Timer, run buttons and toggles.
Tile timer = make("timer.eieren", "active"); timer.controls = "buttons"; Tile timer = make("timer.eggs", "active"); timer.controls = "buttons";
assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_PAUSE && !keys[1].disabled); assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_PAUSE && !keys[1].disabled);
timer.state = "idle"; assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_START && keys[1].disabled); timer.state = "idle"; assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_START && keys[1].disabled);
assert(key_action(make("scene.avond", "unknown"), RUN).service == "scene.turn_on"); assert(key_action(make("scene.evening", "unknown"), RUN).service == "scene.turn_on");
assert(key_action(make("script.alles", "off"), RUN).service == "script.turn_on"); assert(key_action(make("script.all", "off"), RUN).service == "script.turn_on");
assert(key_action(make("input_button.bel", "unknown"), RUN).service == "input_button.press"); assert(key_action(make("input_button.bell", "unknown"), RUN).service == "input_button.press");
assert(!strcmp(run_label("scene"), "Activeren") && !strcmp(run_label("button"), "Indrukken")); assert(!strcmp(run_label("scene"), "Activate") && !strcmp(run_label("button"), "Press"));
assert(key_action(make("switch.bureau", "on"), TOGGLE).service == "switch.turn_off"); assert(key_action(make("switch.desk", "on"), TOGGLE).service == "switch.turn_off");
assert(key_action(make("light.lamp", "off"), TOGGLE).service == "light.turn_on"); assert(key_action(make("light.lamp", "off"), TOGGLE).service == "light.turn_on");
assert(!key_action(make("sensor.x", "1"), TOGGLE).valid()); assert(!key_action(make("sensor.x", "1"), TOGGLE).valid());
assert(!key_action(make("sensor.x", "1"), RUN).valid()); assert(!key_action(make("sensor.x", "1"), RUN).valid());
+10 -10
View File
@@ -26,7 +26,7 @@ class ControlChoices(unittest.TestCase):
self.assertNotIn('sensor', catalogue) self.assertNotIn('sensor', catalogue)
def test_validation_accepts_own_sets_and_rejects_others(self): def test_validation_accepts_own_sets_and_rejects_others(self):
layout = validate_layout({'title': 'T', 'tiles': [{'entity': 'cover.gordijn', 'options': {'size': 'wide', 'controls': 'position'}}]}) layout = validate_layout({'title': 'T', 'tiles': [{'entity': 'cover.curtain', 'options': {'size': 'wide', 'controls': 'position'}}]})
self.assertEqual(layout['tiles'][0]['options']['controls'], 'position') self.assertEqual(layout['tiles'][0]['options']['controls'], 'position')
validate_layout({'title': 'T', 'tiles': [{'entity': 'light.a', 'options': {'controls': 'none'}}]}) validate_layout({'title': 'T', 'tiles': [{'entity': 'light.a', 'options': {'controls': 'none'}}]})
for entity, choice in (('cover.a', 'volume'), ('sensor.a', 'toggle'), ('switch.a', 'brightness'), ('climate.a', 42), ('light.a', 'run')): for entity, choice in (('cover.a', 'volume'), ('sensor.a', 'toggle'), ('switch.a', 'brightness'), ('climate.a', 42), ('light.a', 'run')):
@@ -44,25 +44,25 @@ class ControlChoices(unittest.TestCase):
def test_wire_carries_only_the_shown_set_and_the_attributes_the_panels_need(self): def test_wire_carries_only_the_shown_set_and_the_attributes_the_panels_need(self):
states = {'media_player.sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599, 'assumed_state': True}}, states = {'media_player.sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599, 'assumed_state': True}},
'cover.gordijn': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}}, 'cover.curtain': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
'climate.airco': {'state': 'cool', 'attributes': {'hvac_action': 'cooling', 'hvac_modes': ['off', 'cool', 'heat'], 'temperature': 20, 'target_temp_step': 1.0}}} 'climate.ac': {'state': 'cool', 'attributes': {'hvac_action': 'cooling', 'hvac_modes': ['off', 'cool', 'heat'], 'temperature': 20, 'target_temp_step': 1.0}}}
msg = state_message(0, {'entity': 'media_player.sonos', 'name': '', 'options': {'size': 'wide'}}, states) msg = state_message(0, {'entity': 'media_player.sonos', 'name': '', 'options': {'size': 'wide'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'volume'}) self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'volume'})
self.assertIs(msg['a']['is_volume_muted'], False) self.assertIs(msg['a']['is_volume_muted'], False)
self.assertNotIn('assumed_state', msg['a']) self.assertNotIn('assumed_state', msg['a'])
msg = state_message(1, {'entity': 'cover.gordijn', 'name': '', 'options': {'size': 'wide', 'controls': 'none', 'background': 'blue'}}, states) msg = state_message(1, {'entity': 'cover.curtain', 'name': '', 'options': {'size': 'wide', 'controls': 'none', 'background': 'blue'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'background': 'blue'}, 'an explicit none is not sent; the stored choice stays') self.assertEqual(msg['o'], {'size': 'wide', 'background': 'blue'}, 'an explicit none is not sent; the stored choice stays')
self.assertEqual(msg['a']['device_class'], 'curtain') self.assertEqual(msg['a']['device_class'], 'curtain')
msg = state_message(2, {'entity': 'climate.airco', 'name': '', 'options': {'size': 'wide', 'controls': 'mode'}}, states) msg = state_message(2, {'entity': 'climate.ac', 'name': '', 'options': {'size': 'wide', 'controls': 'mode'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'mode'}) self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'mode'})
self.assertEqual(msg['a']['hvac_action'], 'cooling') self.assertEqual(msg['a']['hvac_action'], 'cooling')
self.assertTrue(all(len(p) <= 255 for p in packets(msg))) self.assertTrue(all(len(p) <= 255 for p in packets(msg)))
msg = state_message(3, {'entity': 'climate.airco', 'name': '', 'options': {'controls': 'mode'}}, states) msg = state_message(3, {'entity': 'climate.ac', 'name': '', 'options': {'controls': 'mode'}}, states)
self.assertEqual(msg['o'], {}, 'a single card never shows a panel') self.assertEqual(msg['o'], {}, 'a single card never shows a panel')
self.assertIsNone(min_firmware({'tiles': [{'entity': 'climate.airco', 'options': {'size': 'wide', 'controls': 'mode'}}]}), 'older firmware ignores the field') self.assertIsNone(min_firmware({'tiles': [{'entity': 'climate.ac', 'options': {'size': 'wide', 'controls': 'mode'}}]}), 'older firmware ignores the field')
def test_screen_options_keeps_the_choice_out_of_the_wire_but_in_storage(self): def test_screen_options_keeps_the_choice_out_of_the_wire_but_in_storage(self):
tile = {'entity': 'switch.bureau', 'name': '', 'options': {'size': 'wide', 'controls': 'toggle', 'icon': 'auto'}} tile = {'entity': 'switch.desk', 'name': '', 'options': {'size': 'wide', 'controls': 'toggle', 'icon': 'auto'}}
self.assertEqual(screen_options(tile, {}), {'size': 'wide', 'controls': 'toggle'}) self.assertEqual(screen_options(tile, {}), {'size': 'wide', 'controls': 'toggle'})
self.assertEqual(validate_layout({'title': 'T', 'tiles': [tile]})['tiles'][0]['options'], tile['options']) self.assertEqual(validate_layout({'title': 'T', 'tiles': [tile]})['tiles'][0]['options'], tile['options'])
@@ -86,8 +86,8 @@ class ControlInventory(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json') m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
self.assertEqual(m.inventory()[0][0]['id'], 'text.screen') self.assertEqual(m.inventory()[0][0]['id'], 'text.screen')
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide', 'controls': 'brightness'}}]}) m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide', 'controls': 'brightness'}}]})
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide'}}]}) m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide'}}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'], {'size': 'wide', 'controls': 'brightness'}) self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'], {'size': 'wide', 'controls': 'brightness'})
await m.sync_one('text.screen', m.layouts['text.screen']) await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[1][1]['o'], {'size': 'wide', 'controls': 'brightness'}) self.assertEqual(m.ha.messages[1][1]['o'], {'size': 'wide', 'controls': 'brightness'})
+6 -6
View File
@@ -15,7 +15,7 @@ HEADER = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
class IconSetTests(unittest.TestCase): class IconSetTests(unittest.TestCase):
def test_set_is_complete_and_carried_by_every_icon_font(self): def test_set_is_complete_and_carried_by_every_icon_font(self):
self.assertEqual(len(tile_icons.ICONS), 150) self.assertEqual(len(tile_icons.ICONS), 150)
self.assertTrue(any(group == 'Media en muziek' and len(icons) >= 20 for group, icons in tile_icons.GROUPS)) self.assertTrue(any(group == 'Media and music' and len(icons) >= 20 for group, icons in tile_icons.GROUPS))
self.assertEqual(len(set(tile_icons.GLYPHS.values())), len(tile_icons.GLYPHS)) self.assertEqual(len(set(tile_icons.GLYPHS.values())), len(tile_icons.GLYPHS))
for code in tile_icons.GLYPHS.values(): for code in tile_icons.GLYPHS.values():
self.assertRegex(code, r'^F[0-9A-F]{4}$') self.assertRegex(code, r'^F[0-9A-F]{4}$')
@@ -60,11 +60,11 @@ class IconSetTests(unittest.TestCase):
class IconOptionTests(unittest.IsolatedAsyncioTestCase): class IconOptionTests(unittest.IsolatedAsyncioTestCase):
def test_validation_accepts_the_set_and_auto_only(self): def test_validation_accepts_the_set_and_auto_only(self):
for value in ('auto', 'lamp', 'spotify', 'speaker'): for value in ('auto', 'lamp', 'spotify', 'speaker'):
layout = validate_layout({'title': 'Thuis', 'tiles': [{'entity': 'media_player.a', 'options': {'icon': value}}]}) layout = validate_layout({'title': 'Home', 'tiles': [{'entity': 'media_player.a', 'options': {'icon': value}}]})
self.assertEqual(layout['tiles'][0]['options']['icon'], value) self.assertEqual(layout['tiles'][0]['options']['icon'], value)
for value in ('mdi:lamp', 'F06B5', 'weather-fog', 'unknown', '', None, 42, ['lamp']): for value in ('mdi:lamp', 'F06B5', 'weather-fog', 'unknown', '', None, 42, ['lamp']):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
validate_layout({'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'icon': value}}]}) validate_layout({'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'icon': value}}]})
def test_wire_carries_the_codepoint_chosen_or_from_home_assistant(self): def test_wire_carries_the_codepoint_chosen_or_from_home_assistant(self):
states = {'light.a': {'state': 'on', 'attributes': {'icon': 'mdi:floor-lamp'}}, states = {'light.a': {'state': 'on', 'attributes': {'icon': 'mdi:floor-lamp'}},
@@ -88,12 +88,12 @@ class IconOptionTests(unittest.IsolatedAsyncioTestCase):
async def test_old_editor_preserves_icon_and_explicit_auto_clears(self): async def test_old_editor_preserves_icon_and_explicit_auto_clears(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json') m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'icon': 'spotify'}}]}) m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'icon': 'spotify'}}]})
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'inline': 'none'}}]}) m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'inline': 'none'}}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'], {'inline': 'none', 'icon': 'spotify'}) self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'], {'inline': 'none', 'icon': 'spotify'})
await m.sync_one('text.screen', m.layouts['text.screen']) await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[1][1]['o']['icon'], 'F04C7') self.assertEqual(m.ha.messages[1][1]['o']['icon'], 'F04C7')
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'icon': 'auto'}}]}) m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'icon': 'auto'}}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options']['icon'], 'auto') self.assertEqual(m.layouts['text.screen']['tiles'][0]['options']['icon'], 'auto')
if __name__ == '__main__': if __name__ == '__main__':
+1 -1
View File
@@ -6,7 +6,7 @@ int main() {
assert(tile_palette::color("unknown-future-choice")==0); assert(tile_palette::color("unknown-future-choice")==0);
assert(tile_palette::color("red")!=0); assert(tile_palette::color("red")!=0);
assert(tile_palette::color("green")!=tile_palette::color("red")); assert(tile_palette::color("green")!=tile_palette::color("red"));
// "Geen" is not a colour: the card is hidden, and old firmware falls back to automatic. // "None" is not a colour: the card is hidden, and old firmware falls back to automatic.
assert(tile_palette::color("none")==0); assert(tile_palette::color("none")==0);
assert(tile_palette::transparent("none")); assert(tile_palette::transparent("none"));
assert(!tile_palette::transparent("auto") && !tile_palette::transparent("red") && !tile_palette::transparent("")); assert(!tile_palette::transparent("auto") && !tile_palette::transparent("red") && !tile_palette::transparent(""));
+5 -5
View File
@@ -23,15 +23,15 @@ class PaletteTests(unittest.IsolatedAsyncioTestCase):
async def test_old_editor_preserves_color_and_explicit_auto_clears(self): async def test_old_editor_preserves_color_and_explicit_auto_clears(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json') m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Thuis','tiles':[{'entity':'light.a','options':{'background':'red','inline':'slider'}}]} layout={'title':'Home','tiles':[{'entity':'light.a','options':{'background':'red','inline':'slider'}}]}
m.save('text.screen',layout) m.save('text.screen',layout)
m.save('text.screen',{'title':'Nieuw','tiles':[{'entity':'light.a','options':{'inline':'none'}}]}) m.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','options':{'inline':'none'}}]})
fresh=test_portal.ManagerTests().setup_manager(m.path) fresh=test_portal.ManagerTests().setup_manager(m.path)
tile=fresh.layouts['text.screen']['tiles'][0] tile=fresh.layouts['text.screen']['tiles'][0]
self.assertEqual(tile['options'],{'background':'red','inline':'none'}) self.assertEqual(tile['options'],{'background':'red','inline':'none'})
await fresh.sync_one('text.screen',fresh.layouts['text.screen']) await fresh.sync_one('text.screen',fresh.layouts['text.screen'])
self.assertEqual(fresh.ha.messages[1][1]['o']['background'],'red') self.assertEqual(fresh.ha.messages[1][1]['o']['background'],'red')
fresh.save('text.screen',{'title':'Nieuw','tiles':[{'entity':'light.a','options':{'background':'auto'}}]}) fresh.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','options':{'background':'auto'}}]})
self.assertEqual(fresh.layouts['text.screen']['tiles'][0]['options']['background'],'auto') self.assertEqual(fresh.layouts['text.screen']['tiles'][0]['options']['background'],'auto')
def test_none_hides_the_card_and_needs_firmware_0216(self): def test_none_hides_the_card_and_needs_firmware_0216(self):
@@ -40,11 +40,11 @@ class PaletteTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('name=="none"',header) self.assertIn('name=="none"',header)
self.assertIsNone(TILE_BACKGROUNDS['none']['color']) self.assertIsNone(TILE_BACKGROUNDS['none']['color'])
for entity in ('screen.clock','light.a'): for entity in ('screen.clock','light.a'):
layout=validate_layout({'title':'Thuis','tiles':[{'entity':entity,'options':{'background':'none'}}]}) layout=validate_layout({'title':'Home','tiles':[{'entity':entity,'options':{'background':'none'}}]})
self.assertEqual(layout['tiles'][0]['options']['background'],'none') self.assertEqual(layout['tiles'][0]['options']['background'],'none')
self.assertEqual(min_firmware(layout),(0,2,16)) self.assertEqual(min_firmware(layout),(0,2,16))
self.assertEqual(min_firmware({'tiles':[{'entity':'light.a','options':{'background':'red'}}]}),None) self.assertEqual(min_firmware({'tiles':[{'entity':'light.a','options':{'background':'red'}}]}),None)
def test_unapproved_colors_rejected(self): def test_unapproved_colors_rejected(self):
for value in ('#000000','url(test)','unknown',None,{},42): for value in ('#000000','url(test)','unknown',None,{},42):
with self.assertRaises(ValueError):validate_layout({'title':'Thuis','tiles':[{'entity':'light.a','options':{'background':value}}]}) with self.assertRaises(ValueError):validate_layout({'title':'Home','tiles':[{'entity':'light.a','options':{'background':value}}]})
+2 -2
View File
@@ -9,12 +9,12 @@ class TwentyTiles(unittest.IsolatedAsyncioTestCase):
async def test_twenty_and_old_firmware_guard(self): async def test_twenty_and_old_firmware_guard(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json') m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Twintig','tiles':[{'entity':f'light.item{i}'} for i in range(20)], layout={'title':'Twenty','tiles':[{'entity':f'light.item{i}'} for i in range(20)],
'settings':validate_settings({'swipe_pages':True})} 'settings':validate_settings({'swipe_pages':True})}
for tile in layout['tiles']:m.ha.states[tile['entity']]={'state':'on','attributes':{}} for tile in layout['tiles']:m.ha.states[tile['entity']]={'state':'on','attributes':{}}
with self.assertRaises(ValueError):m.save('text.screen',layout) with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry[0]['device_id']='screen-device' m.ha.registry[0]['device_id']='screen-device'
m.ha.registry.append({'entity_id':'sensor.version','platform':'esphome','original_name':'Schermfirmware','device_id':'screen-device'}) m.ha.registry.append({'entity_id':'sensor.version','platform':'esphome','original_name':'Screen firmware','device_id':'screen-device'})
m.ha.states['sensor.version']={'state':'0.2.7'} m.ha.states['sensor.version']={'state':'0.2.7'}
m.save('text.screen',layout) m.save('text.screen',layout)
await m.sync_one('text.screen',m.layouts['text.screen']) await m.sync_one('text.screen',m.layouts['text.screen'])
+35 -35
View File
@@ -26,26 +26,26 @@ class VersionSourceTests(unittest.TestCase):
for name in ('packages/cyd.yaml', 'packages/guition.yaml', 'home-like-2432s028.yaml', 'guition-4848s040.yaml'): for name in ('packages/cyd.yaml', 'packages/guition.yaml', 'home-like-2432s028.yaml', 'guition-4848s040.yaml'):
text = (ROOT / name).read_text() text = (ROOT / name).read_text()
self.assertIn(f'SCREEN_FIRMWARE_VERSION: "{FIRMWARE_VERSION}"', text, name) self.assertIn(f'SCREEN_FIRMWARE_VERSION: "{FIRMWARE_VERSION}"', text, name)
self.assertIn('name: "Apparaatnaam"', text, name) self.assertIn('name: "Device name"', text, name)
self.assertIn('platform: wifi_info', text, name) self.assertIn('platform: wifi_info', text, name)
self.assertEqual(parse_version(FIRMWARE_VERSION), tuple(int(p) for p in FIRMWARE_VERSION.split('.'))) self.assertEqual(parse_version(FIRMWARE_VERSION), tuple(int(p) for p in FIRMWARE_VERSION.split('.')))
self.assertIsNone(parse_version('onbekend')) self.assertIsNone(parse_version('unknown'))
self.assertIsNone(parse_version('1.2')) self.assertIsNone(parse_version('1.2'))
def test_discovery_reports_node_and_address(self): def test_discovery_reports_node_and_address(self):
registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd1'}, registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'}, {'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
{'entity_id': 'text.ip', 'platform': 'esphome', 'original_name': 'IP-adres', 'device_id': 'd1'}, {'entity_id': 'text.ip', 'platform': 'esphome', 'original_name': 'IP address', 'device_id': 'd1'},
{'entity_id': 'text.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'}] {'entity_id': 'text.fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
states = {'text.screen': {'state': 'Ready'}, 'text.node': {'state': 'woonkamer'}, states = {'text.screen': {'state': 'Ready'}, 'text.node': {'state': 'living-room'},
'text.ip': {'state': '192.168.1.50'}, 'text.fw': {'state': '0.2.16'}} 'text.ip': {'state': '192.168.1.50'}, 'text.fw': {'state': '0.2.16'}}
screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Woonkamer'}], []) screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Living room'}], [])
self.assertEqual(screens[0]['node'], 'woonkamer') self.assertEqual(screens[0]['node'], 'living-room')
self.assertEqual(screens[0]['ip'], '192.168.1.50') self.assertEqual(screens[0]['ip'], '192.168.1.50')
self.assertEqual(screens[0]['device'], 'Woonkamer') self.assertEqual(screens[0]['device'], 'Living room')
states['text.ip']['state'] = 'unavailable' states['text.ip']['state'] = 'unavailable'
states['text.node']['state'] = '../evil' states['text.node']['state'] = '../evil'
screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Woonkamer'}], []) screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Living room'}], [])
self.assertIsNone(screens[0]['ip']) self.assertIsNone(screens[0]['ip'])
self.assertIsNone(screens[0]['node']) self.assertIsNone(screens[0]['node'])
@@ -55,16 +55,16 @@ class ProfileNameTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
f = Firmware(tmp, tmp) f = Firmware(tmp, tmp)
(Path(tmp) / 'secrets.yaml').write_text('wifi_ssid: example-net\nwifi_password: example-pw\n') (Path(tmp) / 'secrets.yaml').write_text('wifi_ssid: example-net\nwifi_password: example-pw\n')
f.create({'board': 'cyd', 'name': 'woonkamer', 'friendly_name': 'Woonkamer'}) f.create({'board': 'cyd', 'name': 'living-room', 'friendly_name': 'Living room'})
(Path(tmp) / 'manual.yaml').write_text('substitutions:\n DEVICE_NAME: "keuken"\n DEVICE_FRIENDLY_NAME: "Keuken"\n' (Path(tmp) / 'manual.yaml').write_text('substitutions:\n DEVICE_NAME: "kitchen"\n DEVICE_FRIENDLY_NAME: "Kitchen"\n'
'esphome:\n name: ${DEVICE_NAME}\n friendly_name: ${DEVICE_FRIENDLY_NAME}\n' 'esphome:\n name: ${DEVICE_NAME}\n friendly_name: ${DEVICE_FRIENDLY_NAME}\n'
'packages:\n a: !include other.yaml\napi:\n encryption:\n key: !secret api\n') 'packages:\n a: !include other.yaml\napi:\n encryption:\n key: !secret api\n')
(Path(tmp) / 'broken.yaml').write_text('esphome: [\n') (Path(tmp) / 'broken.yaml').write_text('esphome: [\n')
names = f.profile_names() names = f.profile_names()
self.assertEqual(names['woonkamer.yaml'], {'node': 'woonkamer', 'friendly': 'Woonkamer', 'screen': True, 'api_key': names['woonkamer.yaml']['api_key']}) self.assertEqual(names['living-room.yaml'], {'node': 'living-room', 'friendly': 'Living room', 'screen': True, 'api_key': names['living-room.yaml']['api_key']})
self.assertEqual(len(names['woonkamer.yaml']['api_key']), 44) self.assertEqual(len(names['living-room.yaml']['api_key']), 44)
# A manual profile (no board package from this repo, key behind !secret) is not an ESP Screens profile. # A manual profile (no board package from this repo, key behind !secret) is not an ESP Screens profile.
self.assertEqual(names['manual.yaml'], {'node': 'keuken', 'friendly': 'Keuken', 'screen': False, 'api_key': None}) self.assertEqual(names['manual.yaml'], {'node': 'kitchen', 'friendly': 'Kitchen', 'screen': False, 'api_key': None})
self.assertNotIn('broken.yaml', names) self.assertNotIn('broken.yaml', names)
self.assertNotIn('secrets.yaml', names) self.assertNotIn('secrets.yaml', names)
@@ -98,13 +98,13 @@ class FakeFirmware:
"""Stands in for the ESPHome CLI: records jobs and flips the screen to the target version.""" """Stands in for the ESPHome CLI: records jobs and flips the screen to the target version."""
def __init__(self, ha, outcome='success'): def __init__(self, ha, outcome='success'):
self.ha, self.outcome, self.calls, self.task, self.job = ha, outcome, [], None, None self.ha, self.outcome, self.calls, self.task, self.job = ha, outcome, [], None, None
self.names = {'woonkamer.yaml': {'node': 'woonkamer', 'friendly': 'Woonkamer'}, self.names = {'living-room.yaml': {'node': 'living-room', 'friendly': 'Living room'},
'keuken.yaml': {'node': 'keuken', 'friendly': 'Keuken'}} 'kitchen.yaml': {'node': 'kitchen', 'friendly': 'Kitchen'}}
def profile_names(self): return self.names def profile_names(self): return self.names
def start(self, data): def start(self, data):
if self.task and not self.task.done(): raise ValueError('Er loopt al een build of installatie.') if self.task and not self.task.done(): raise ValueError('A build or install is already running.')
self.calls.append(data) self.calls.append(data)
self.job = {'state': 'running', **data} self.job = {'state': 'running', **data}
self.task = asyncio.create_task(self.run(data)) self.task = asyncio.create_task(self.run(data))
@@ -113,7 +113,7 @@ class FakeFirmware:
async def run(self, data): async def run(self, data):
await asyncio.sleep(0) await asyncio.sleep(0)
if self.outcome == 'success': if self.outcome == 'success':
for entity, node in (('text.fw1', 'woonkamer'), ('text.fw2', 'keuken')): for entity, node in (('text.fw1', 'living-room'), ('text.fw2', 'kitchen')):
if data['file'] == node + '.yaml': self.ha.states[entity] = {'state': FIRMWARE_VERSION} if data['file'] == node + '.yaml': self.ha.states[entity] = {'state': FIRMWARE_VERSION}
self.job['state'] = self.outcome self.job['state'] = self.outcome
@@ -125,15 +125,15 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
class HA: class HA:
online = True online = True
time_zone = timezone.utc time_zone = timezone.utc
registry = [{'entity_id': 'text.screen1', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd1'}, registry = [{'entity_id': 'text.screen1', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'text.node1', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'}, {'entity_id': 'text.node1', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
{'entity_id': 'text.ip1', 'platform': 'esphome', 'original_name': 'IP-adres', 'device_id': 'd1'}, {'entity_id': 'text.ip1', 'platform': 'esphome', 'original_name': 'IP address', 'device_id': 'd1'},
{'entity_id': 'text.fw1', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'}, {'entity_id': 'text.fw1', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'},
{'entity_id': 'text.screen2', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd2'}, {'entity_id': 'text.screen2', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd2'},
{'entity_id': 'text.fw2', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd2'}] {'entity_id': 'text.fw2', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd2'}]
devices = [{'id': 'd1', 'name': 'Woonkamer'}, {'id': 'd2', 'name': 'Keuken'}] devices = [{'id': 'd1', 'name': 'Living room'}, {'id': 'd2', 'name': 'Kitchen'}]
areas = [] areas = []
states = {'text.screen1': {'state': 'Ready'}, 'text.node1': {'state': 'woonkamer'}, 'text.ip1': {'state': '10.0.0.5'}, states = {'text.screen1': {'state': 'Ready'}, 'text.node1': {'state': 'living-room'}, 'text.ip1': {'state': '10.0.0.5'},
'text.fw1': {'state': '0.2.16'}, 'text.screen2': {'state': 'Ready'}, 'text.fw2': {'state': '0.2.16'}} 'text.fw1': {'state': '0.2.16'}, 'text.screen2': {'state': 'Ready'}, 'text.fw2': {'state': '0.2.16'}}
changed = asyncio.Event() changed = asyncio.Event()
def __init__(self): self.messages, self.calls = [], [] def __init__(self): self.messages, self.calls = [], []
@@ -149,21 +149,21 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp) m = self.setup_manager(tmp)
first, second = m.inventory()[0] first, second = m.inventory()[0]
self.assertEqual(m.updates.state_for(first)['profile'], 'woonkamer.yaml') self.assertEqual(m.updates.state_for(first)['profile'], 'living-room.yaml')
self.assertEqual(m.updates.state_for(first)['host'], '10.0.0.5') self.assertEqual(m.updates.state_for(first)['host'], '10.0.0.5')
self.assertTrue(m.updates.state_for(first)['available']) self.assertTrue(m.updates.state_for(first)['available'])
# Older firmware: no node name, so the friendly name decides; no address yet. # Older firmware: no node name, so the friendly name decides; no address yet.
self.assertEqual(m.updates.state_for(second)['profile'], 'keuken.yaml') self.assertEqual(m.updates.state_for(second)['profile'], 'kitchen.yaml')
self.assertIsNone(m.updates.state_for(second)['host']) self.assertIsNone(m.updates.state_for(second)['host'])
self.assertEqual(m.updates.pending(), ['text.screen1']) self.assertEqual(m.updates.pending(), ['text.screen1'])
with self.assertRaisesRegex(ValueError, 'IP-adres'): m.updates.start('text.screen2') with self.assertRaisesRegex(ValueError, 'IP address'): m.updates.start('text.screen2')
with self.assertRaises(ValueError): m.updates.start('text.screen2', host='bad host!') with self.assertRaises(ValueError): m.updates.start('text.screen2', host='bad host!')
m.updates.start('text.screen2', host='10.0.0.6') m.updates.start('text.screen2', host='10.0.0.6')
await m.updates.task await m.updates.task
self.assertEqual(m.firmware.calls, [{'file': 'keuken.yaml', 'action': 'install', 'target': '10.0.0.6'}]) self.assertEqual(m.firmware.calls, [{'file': 'kitchen.yaml', 'action': 'install', 'target': '10.0.0.6'}])
self.assertEqual(m.updates.results['text.screen2']['state'], 'success') self.assertEqual(m.updates.results['text.screen2']['state'], 'success')
self.assertEqual(json.loads((Path(tmp) / 'updates.json').read_text())['hosts'], {'text.screen2': '10.0.0.6'}) self.assertEqual(json.loads((Path(tmp) / 'updates.json').read_text())['hosts'], {'text.screen2': '10.0.0.6'})
with self.assertRaisesRegex(ValueError, 'nieuwste'): m.updates.start('text.screen2') with self.assertRaisesRegex(ValueError, 'latest'): m.updates.start('text.screen2')
m.ha.states['text.fw1'] = {'state': FIRMWARE_VERSION} m.ha.states['text.fw1'] = {'state': FIRMWARE_VERSION}
self.assertEqual(m.updates.pending(), []) self.assertEqual(m.updates.pending(), [])
@@ -180,7 +180,7 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(m.ha.calls, [], 'manual rounds do not notify') self.assertEqual(m.ha.calls, [], 'manual rounds do not notify')
await m.updates.run_round(['text.screen1'], automatic=True) await m.updates.run_round(['text.screen1'], automatic=True)
self.assertEqual(m.ha.calls[0][1]['domain'], 'persistent_notification') self.assertEqual(m.ha.calls[0][1]['domain'], 'persistent_notification')
self.assertIn('Woonkamer', m.ha.calls[0][1]['service_data']['message']) self.assertIn('Living room', m.ha.calls[0][1]['service_data']['message'])
async def test_screen_that_never_returns_fails(self): async def test_screen_that_never_returns_fails(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -218,7 +218,7 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
async def test_manager_watches_only_layout_and_screen_entities(self): async def test_manager_watches_only_layout_and_screen_entities(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp) m = self.setup_manager(tmp)
m.layouts['text.screen1'] = {'title': 'Thuis', 'tiles': [{'entity': 'light.lamp'}]} m.layouts['text.screen1'] = {'title': 'Home', 'tiles': [{'entity': 'light.lamp'}]}
watched = m.watched_entities() watched = m.watched_entities()
self.assertEqual(watched, {'light.lamp', 'text.screen1', 'text.node1', 'text.ip1', 'text.fw1', 'text.screen2', 'text.fw2'}) self.assertEqual(watched, {'light.lamp', 'text.screen1', 'text.node1', 'text.ip1', 'text.fw1', 'text.screen2', 'text.fw2'})
+31 -31
View File
@@ -13,8 +13,8 @@ import re
import statistics import statistics
import time import time
TARGETS = [('linksboven', (20, 20)), ('rechtsboven', (299, 20)), TARGETS = [('top-left', (20, 20)), ('top-right', (299, 20)),
('rechtsonder', (299, 219)), ('linksonder', (20, 219)), ('midden', (160, 120))] ('bottom-right', (299, 219)), ('bottom-left', (20, 219)), ('center', (160, 120))]
BOUNDS = (280, 3860, 340, 3860) BOUNDS = (280, 3860, 340, 3860)
PATTERN = re.compile(r'press native=(\d+),(\d+) raw=(\d+),(\d+) pressure=(\d+) calibration=([01])') PATTERN = re.compile(r'press native=(\d+),(\d+) raw=(\d+),(\d+) pressure=(\d+) calibration=([01])')
@@ -25,7 +25,7 @@ def parse_touch(line):
return None return None
nx, ny, rx, ry, pressure, isolated = map(int, match.groups()) nx, ny, rx, ry, pressure, isolated = map(int, match.groups())
if not isolated: if not isolated:
raise ValueError('Geen geïsoleerd meetscherm: start met CALIBRATION_ON_BOOT=true. Geen data opgeslagen.') raise ValueError('Not an isolated measurement screen: start with CALIBRATION_ON_BOOT=true. No data saved.')
return dict(native_x=nx, native_y=ny, raw_x=rx, raw_y=ry, pressure=pressure) return dict(native_x=nx, native_y=ny, raw_x=rx, raw_y=ry, pressure=pressure)
@@ -33,7 +33,7 @@ def write_file(path, contents, replace=False):
path = Path(path) path = Path(path)
if path.exists(): if path.exists():
if not replace: if not replace:
raise ValueError(f'{path} bestaat al; kies een andere naam of expliciet --replace.') raise ValueError(f'{path} already exists; choose a different name or pass --replace explicitly.')
backup = path.with_name(path.name + '.bak-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f')) backup = path.with_name(path.name + '.bak-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
backup.write_bytes(path.read_bytes()) backup.write_bytes(path.read_bytes())
# Write completely before replacing an existing configuration. # Write completely before replacing an existing configuration.
@@ -48,20 +48,20 @@ def write_file(path, contents, replace=False):
def validate(data): def validate(data):
if data.get('schema') != 1 or data.get('screen') != [320, 240]: 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.') raise ValueError('Expected schema 1 and landscape 320×240; other orientations are not supported.')
points = data.get('points', []) points = data.get('points', [])
if len(points) != len(TARGETS): if len(points) != len(TARGETS):
raise ValueError('Alle vier hoeken én het onafhankelijke middenpunt zijn nodig.') raise ValueError('All four corners and the independent center point are required.')
for point, (name, xy) in zip(points, TARGETS): for point, (name, xy) in zip(points, TARGETS):
if point.get('name') != name or point.get('screen') != list(xy): if point.get('name') != name or point.get('screen') != list(xy):
raise ValueError('Meetpunten/volgorde komen niet overeen met de wizard.') raise ValueError("Measurement points/order don't match the wizard.")
if len(point.get('samples', [])) < 3: if len(point.get('samples', [])) < 3:
raise ValueError(f'{name}: minimaal drie afzonderlijke tikken nodig.') raise ValueError(f'{name}: at least three separate taps required.')
for sample in point['samples']: for sample in point['samples']:
for key, maximum in [('raw_x', 4095), ('raw_y', 4095), ('native_x', 239), ('native_y', 319), ('pressure', 8190)]: for key, maximum in [('raw_x', 4095), ('raw_y', 4095), ('native_x', 239), ('native_y', 319), ('pressure', 8190)]:
value = sample.get(key) value = sample.get(key)
if type(value) is not int or not 0 <= value <= maximum: if type(value) is not int or not 0 <= value <= maximum:
raise ValueError(f'{name}: ongeldige {key}.') raise ValueError(f'{name}: invalid {key}.')
return points return points
@@ -77,7 +77,7 @@ def least_squares(rows, values):
matrix[column], matrix[pivot] = matrix[pivot], matrix[column] matrix[column], matrix[pivot] = matrix[pivot], matrix[column]
scale = matrix[column][column] scale = matrix[column][column]
if abs(scale) < 1e-9: if abs(scale) < 1e-9:
raise ValueError('Meetpunten zijn niet onafhankelijk; waarschijnlijk dezelfde hoek meermaals gemeten.') raise ValueError('Measurement points are not independent; likely the same corner was measured more than once.')
matrix[column] = [v / scale for v in matrix[column]] matrix[column] = [v / scale for v in matrix[column]]
for row in range(3): for row in range(3):
if row != column: if row != column:
@@ -104,10 +104,10 @@ def fit(data, tolerance=12):
cx, cy = least_squares(rows, values_x), least_squares(rows, values_y) 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]] 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): if not all(math.isfinite(v) for v in coefficients):
raise ValueError('Ongeldige kalibratiecoëfficiënten.') raise ValueError('Invalid calibration coefficients.')
# Reject captures that amplify small sensor jitter into huge screen movements. # 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: 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.') raise ValueError('Measurement range too small; tap the actual screen corners.')
report = [] report = []
for point, median in zip(points, medians): for point, median in zip(points, medians):
predicted = project(coefficients, *median) predicted = project(coefficients, *median)
@@ -115,14 +115,14 @@ def fit(data, tolerance=12):
spread = max(math.dist(project(coefficients, s['raw_x'], s['raw_y']), predicted) for s in point['samples']) 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))) 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: if error > tolerance or spread > 18:
raise ValueError(f"{point['name']}: fout {error:.1f}px, spreiding {spread:.1f}px. " raise ValueError(f"{point['name']}: error {error:.1f}px, spread {spread:.1f}px. "
'Meet opnieuw; een instabiel paneel niet wegkalibreren.') "Measure again; don't calibrate away an unstable panel.")
return coefficients, report return coefficients, report
def calibration_yaml(coefficients): def calibration_yaml(coefficients):
names = ['TOUCH_AFFINE_XX', 'TOUCH_AFFINE_XY', 'TOUCH_AFFINE_XC', 'TOUCH_AFFINE_YX', 'TOUCH_AFFINE_YY', 'TOUCH_AFFINE_YC'] 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:'] lines = ['# Measured with tools/calibrate.py; only valid for this physical panel.', 'substitutions:']
for name, value in zip(['TOUCH_CAL_X_MIN', 'TOUCH_CAL_X_MAX', 'TOUCH_CAL_Y_MIN', 'TOUCH_CAL_Y_MAX'], BOUNDS): 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.append(f' {name}: "{value}"')
lines += [f' {name}: "{value:.9f}"' for name, value in zip(names, coefficients)] lines += [f' {name}: "{value:.9f}"' for name, value in zip(names, coefficients)]
@@ -138,21 +138,21 @@ def verify(data, tolerance=12):
spread = max(math.dist(p, center) for p in measured) 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))) results.append(dict(name=point['name'], error_px=round(error, 2), spread_px=round(spread, 2)))
if error > tolerance or spread > 18: if error > tolerance or spread > 18:
raise ValueError(f"{point['name']}: echte firmwarecoördinaten wijken {error:.1f}px af (spreiding {spread:.1f}px).") raise ValueError(f"{point['name']}: real firmware coordinates deviate {error:.1f}px (spread {spread:.1f}px).")
return results return results
def capture(args): def capture(args):
import serial import serial
if args.output.exists(): if args.output.exists():
raise ValueError('Meetbestand bestaat al; kies een nieuwe naam.') raise ValueError('Measurement file already exists; choose a new name.')
data = dict(schema=1, device_name=args.device_name, screen=[320, 240], data = dict(schema=1, device_name=args.device_name, screen=[320, 240],
created_at=datetime.now(timezone.utc).isoformat(), points=[]) 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('Only for 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.') print('The screen must show the FIVE crosshairs. Close other serial log readers.')
with serial.Serial(args.port, 115200, timeout=0.2) as port: with serial.Serial(args.port, 115200, timeout=0.2) as port:
for name, xy in TARGETS: for name, xy in TARGETS:
input(f'\nVolgende punt: {name} {xy}. Druk ENTER en tik daarna alleen dit kruisje {args.samples} keer: ') input(f'\nNext point: {name} {xy}. Press ENTER, then tap only this crosshair {args.samples} times: ')
port.reset_input_buffer() port.reset_input_buffer()
samples = [] samples = []
deadline = time.monotonic() + args.timeout deadline = time.monotonic() + args.timeout
@@ -164,19 +164,19 @@ def capture(args):
last = time.monotonic() last = time.monotonic()
print(f' {name}: {len(samples)}/{args.samples}', flush=True) print(f' {name}: {len(samples)}/{args.samples}', flush=True)
if len(samples) != args.samples: if len(samples) != args.samples:
raise ValueError(f'{name}: te weinig geldige tikken. Controleer de firmware, het meetscherm en USB-poort.') raise ValueError(f'{name}: not enough valid taps. Check the firmware, the measurement screen, and the USB port.')
data['points'].append(dict(name=name, screen=list(xy), samples=samples)) data['points'].append(dict(name=name, screen=list(xy), samples=samples))
write_file(args.output, json.dumps(data, indent=2) + '\n') write_file(args.output, json.dumps(data, indent=2) + '\n')
print(f'Meting opgeslagen: {args.output}. Het script heeft niets geflasht of aangestuurd.') print(f'Measurement saved: {args.output}. The script did not flash or control anything.')
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest='command', required=True) 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 = commands.add_parser('capture', help='Read guided taps over USB; firmware must already show the measurement screen.')
c.add_argument('--port', required=True) c.add_argument('--port', required=True)
c.add_argument('--output', type=Path, required=True) c.add_argument('--output', type=Path, required=True)
c.add_argument('--device-name', default='onbekend') c.add_argument('--device-name', default='unknown')
c.add_argument('--samples', type=int, choices=range(3, 10), default=3) c.add_argument('--samples', type=int, choices=range(3, 10), default=3)
c.add_argument('--timeout', type=float, default=60) c.add_argument('--timeout', type=float, default=60)
for name in ('fit', 'verify'): for name in ('fit', 'verify'):
@@ -189,24 +189,24 @@ def main():
args = parser.parse_args() args = parser.parse_args()
try: try:
if args.command == 'capture': if args.command == 'capture':
if args.timeout <= 0: raise ValueError('Timeout moet positief zijn.') if args.timeout <= 0: raise ValueError('Timeout must be positive.')
capture(args) capture(args)
else: else:
if args.tolerance <= 0 or not math.isfinite(args.tolerance): raise ValueError('Tolerantie moet eindig en positief zijn.') if args.tolerance <= 0 or not math.isfinite(args.tolerance): raise ValueError('Tolerance must be finite and positive.')
data = json.loads(args.input.read_text()) data = json.loads(args.input.read_text())
if args.command == 'fit': if args.command == 'fit':
coefficients, report = fit(data, args.tolerance) coefficients, report = fit(data, args.tolerance)
write_file(args.output, calibration_yaml(coefficients), args.replace) write_file(args.output, calibration_yaml(coefficients), args.replace)
print(f'Kalibratie geschreven: {args.output}. Het middenpunt is onafhankelijk gecontroleerd.') print(f'Calibration written: {args.output}. The center point was independently checked.')
else: else:
report = verify(data, args.tolerance) report = verify(data, args.tolerance)
print('PASS: onafhankelijke fysieke controle van de geflashte coördinaten.') print('PASS: independent physical check of the flashed coordinates.')
for item in report: for item in report:
print(f"{item['name']}: fout={item['error_px']}px, spreiding={item['spread_px']}px") print(f"{item['name']}: error={item['error_px']}px, spread={item['spread_px']}px")
except (ValueError, OSError, KeyError, TypeError, EOFError) as exc: except (ValueError, OSError, KeyError, TypeError, EOFError) as exc:
parser.exit(1, f'Fout: {exc}\n') parser.exit(1, f'Error: {exc}\n')
except KeyboardInterrupt: except KeyboardInterrupt:
parser.exit(130, 'Afgebroken; geen gedeeltelijke meting opgeslagen.\n') parser.exit(130, 'Aborted; no partial measurement saved.\n')
if __name__ == '__main__': if __name__ == '__main__':
+5 -5
View File
@@ -36,9 +36,9 @@ def export(root, output):
paths.append(path) paths.append(path)
for path in paths: for path in paths:
if path.is_symlink() or not path.resolve().is_relative_to(root): if path.is_symlink() or not path.resolve().is_relative_to(root):
raise ValueError(f'Geen bestanden buiten de bronmap of symlinks toegestaan: {path}') raise ValueError(f'No files outside the source folder or symlinks allowed: {path}')
if not path.is_file(): if not path.is_file():
raise ValueError(f'Ontbrekend bundelbestand: {path}') raise ValueError(f'Missing bundle file: {path}')
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
# Exclusive mode refuses to replace a previously delivered bundle. # Exclusive mode refuses to replace a previously delivered bundle.
with zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive:
@@ -53,10 +53,10 @@ def main():
parser.add_argument('--output', type=Path, default=ROOT / 'dist' / 'cyd-starter.zip') parser.add_argument('--output', type=Path, default=ROOT / 'dist' / 'cyd-starter.zip')
args = parser.parse_args() args = parser.parse_args()
try: try:
print(f'Deel deze ZIP: {export(ROOT, args.output)}') print(f'Share this ZIP: {export(ROOT, args.output)}')
print('Geen lokale secrets, device.yaml, kalibratiemetingen, logs of firmwarebackups opgenomen.') print('No local secrets, device.yaml, calibration measurements, logs or firmware backups included.')
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
parser.exit(1, f'Fout: {exc}\n') parser.exit(1, f'Error: {exc}\n')
if __name__ == '__main__': if __name__ == '__main__':
+1 -1
View File
@@ -14,7 +14,7 @@ def generate(board):
defaults += '\n' + (ROOT / 'calibration.example.yaml').read_text() defaults += '\n' + (ROOT / 'calibration.example.yaml').read_text()
replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M)) replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M))
replacements.update(DYNAMIC_TILES='"true"', DASHBOARD_INBOX_INTERNAL='"false"', DIRECT_ACTIONS='"true"', replacements.update(DYNAMIC_TILES='"true"', DASHBOARD_INBOX_INTERNAL='"false"', DIRECT_ACTIONS='"true"',
ROOM_NAME='"Kies tegels in HA"', TILE_COUNT='"1"') ROOM_NAME='"Choose tiles in HA"', TILE_COUNT='"1"')
s = re.sub(r'^ (\w+): ("[^"\n]*")', lambda m: f' {m[1]}: {replacements.get(m[1],m[2])}', s, flags=re.M) s = re.sub(r'^ (\w+): ("[^"\n]*")', lambda m: f' {m[1]}: {replacements.get(m[1],m[2])}', s, flags=re.M)
# The owner's wizard YAML supplies wifi/API/OTA credentials. No shared keys. # The owner's wizard YAML supplies wifi/API/OTA credentials. No shared keys.
s = re.sub(r'^ encryption:\n key: !secret api_encryption_key\n', '', s, flags=re.M) s = re.sub(r'^ encryption:\n key: !secret api_encryption_key\n', '', s, flags=re.M)
+12 -12
View File
@@ -11,24 +11,24 @@ ROOT = Path(__file__).resolve().parents[1]
def create(directory, name, friendly_name, board="cyd"): def create(directory, name, friendly_name, board="cyd"):
if not re.fullmatch(r'[a-z][a-z0-9-]{0,30}', name) or name.endswith('-'): 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.') raise ValueError('Use a unique name of 1-31 characters: lowercase letters, digits, and dashes.')
if board not in ("cyd", "guition"): if board not in ("cyd", "guition"):
raise ValueError("Onbekend bordprofiel") raise ValueError("Unknown board profile")
import json import json
device = (ROOT / ('guition-device.example.yaml' if board == 'guition' else 'device.example.yaml')).read_text().replace('"guition-new"' if board == 'guition' else '"cyd-new"', json.dumps(name), 1) device = (ROOT / ('guition-device.example.yaml' if board == 'guition' else 'device.example.yaml')).read_text().replace('"guition-new"' if board == 'guition' else '"cyd-new"', json.dumps(name), 1)
device = device.replace('"Mijn Guition"' if board == 'guition' else '"Mijn CYD"', json.dumps(friendly_name, ensure_ascii=False), 1) device = device.replace('"My Guition"' if board == 'guition' else '"My CYD"', json.dumps(friendly_name, ensure_ascii=False), 1)
secret_text = (ROOT / 'secrets.yaml.example').read_text() secret_text = (ROOT / 'secrets.yaml.example').read_text()
replacements = [base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(18)] replacements = [base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(18)]
for value in replacements: for value in replacements:
secret_text = secret_text.replace('WORDT_UNIEK_GEGENEREERD', value, 1) secret_text = secret_text.replace('WILL_BE_UNIQUELY_GENERATED', value, 1)
files = {'guition-device.yaml' if board == 'guition' else 'device.yaml': device, 'secrets.yaml': secret_text} files = {'guition-device.yaml' if board == 'guition' else 'device.yaml': device, 'secrets.yaml': secret_text}
if board == 'cyd': files['calibration.yaml'] = (ROOT / 'calibration.example.yaml').read_text() if board == 'cyd': files['calibration.yaml'] = (ROOT / 'calibration.example.yaml').read_text()
directory = Path(directory) directory = Path(directory)
if not directory.is_dir(): if not directory.is_dir():
raise ValueError('De doelmap moet al bestaan en de uitgepakte code bevatten.') raise ValueError('The target folder must already exist and contain the unpacked code.')
for name in files: for name in files:
if (directory / name).exists(): if (directory / name).exists():
raise ValueError(f'{directory / name} bestaat al; niets overschreven. Gebruik een nieuwe kopie van de map.') raise ValueError(f'{directory / name} already exists; nothing overwritten. Use a fresh copy of the folder.')
created = [] created = []
try: try:
for name, contents in files.items(): for name, contents in files.items():
@@ -47,18 +47,18 @@ def create(directory, name, friendly_name, board="cyd"):
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--board', choices=['cyd', 'guition'], default='cyd') parser.add_argument('--board', choices=['cyd', 'guition'], default='cyd')
parser.add_argument('--name', required=True, help='Unieke ESPHome-naam, bijvoorbeeld display-keuken') parser.add_argument('--name', required=True, help='Unique ESPHome name, for example display-kitchen')
parser.add_argument('--friendly-name') parser.add_argument('--friendly-name')
parser.add_argument('--directory', type=Path, default=ROOT) parser.add_argument('--directory', type=Path, default=ROOT)
args = parser.parse_args() args = parser.parse_args()
args.friendly_name = args.friendly_name or ('Mijn Guition' if args.board == 'guition' else 'Mijn CYD') args.friendly_name = args.friendly_name or ('My Guition' if args.board == 'guition' else 'My CYD')
try: try:
for path in create(args.directory, args.name, args.friendly_name, args.board): for path in create(args.directory, args.name, args.friendly_name, args.board):
print(f'Gemaakt: {path}') print(f'Created: {path}')
print('Vul wifi lokaal in secrets.yaml in. Sleutels zijn uniek gegenereerd en worden niet getoond.') print('Fill in Wi-Fi locally in secrets.yaml. Keys are uniquely generated and are not shown.')
print('Volg docs/GUITION.md; flash guition-device.yaml.' if args.board == 'guition' else 'Volg README.md; flash device.yaml.') print('Follow docs/GUITION.md; flash guition-device.yaml.' if args.board == 'guition' else 'Follow README.md; flash device.yaml.')
except (ValueError, OSError) as exc: except (ValueError, OSError) as exc:
parser.exit(1, f'Fout: {exc}\n') parser.exit(1, f'Error: {exc}\n')
if __name__ == '__main__': if __name__ == '__main__':
+12 -12
View File
@@ -29,13 +29,13 @@ BANDS = {'guition': (480, 72), 'cyd': (320, 40)}
# (slug, title, items) with items as C++ expressions of the render lambda's helpers. # (slug, title, items) with items as C++ expressions of the render lambda's helpers.
SCENARIOS = ( SCENARIOS = (
('1-klok', 'Studio 1', ['builtin(Kind::clock)']), ('1-clock', 'Studio 1', ['builtin(Kind::clock)']),
('2-mockup', 'Woonkamer', ['text(0xF050F, "—")', 'text(0xF050F, "21,3 °C")', 'text(0xF058E, "—")', 'ago(0xF0004, 26 * 3600)']), ('2-mockup', 'Living room', ['text(0xF050F, "—")', 'text(0xF050F, "21.3 °C")', 'text(0xF058E, "—")', 'ago(0xF0004, 26 * 3600)']),
('3-deur-alarm', 'Studio 1', ['text(0xF081C, "Open", 0xFFB300)', 'text(0xF068A, "Afwezig", 0x43A047)', 'builtin(Kind::clock)']), ('3-door-alarm', 'Studio 1', ['text(0xF081C, "Open", 0xFFB300)', 'text(0xF068A, "Armed away", 0x43A047)', 'builtin(Kind::clock)']),
('4-analoog-datum', 'Keuken', ['text(0xF050F, "23,5 °C")', 'builtin(Kind::date)', 'builtin(Kind::analog)']), ('4-analog-date', 'Kitchen', ['text(0xF050F, "23.5 °C")', 'builtin(Kind::date)', 'builtin(Kind::analog)']),
('5-thuis-weer-stroom', 'Studio 1', ['text(0xF0849, "4")', 'text(0xF0595, "22 °C")', 'text(0xF0241, "1.249 W")', 'builtin(Kind::clock)']), ('5-home-weather-power', 'Studio 1', ['text(0xF0849, "4")', 'text(0xF0595, "22 °C")', 'text(0xF0241, "1,249 W")', 'builtin(Kind::clock)']),
('6-te-vol', 'Woonkamer beneden', ['text(0xF050F, "21,3 °C")', 'text(0xF058E, "48%")', 'ago(0xF0D91, 5 * 60)', 'text(0xF0241, "78,0 W")', 'builtin(Kind::analog)', 'builtin(Kind::clock)']), ('6-too-full', 'Living room downstairs', ['text(0xF050F, "21.3 °C")', 'text(0xF058E, "48%")', 'ago(0xF0D91, 5 * 60)', 'text(0xF0241, "78.0 W")', 'builtin(Kind::analog)', 'builtin(Kind::clock)']),
('7-mensen', 'Studio 1', ['text(0xF0004, "Weg")', 'text(0xF0849, "4")', 'builtin(Kind::analog)', 'builtin(Kind::clock)']), ('7-people', 'Studio 1', ['text(0xF0004, "Away")', 'text(0xF0849, "4")', 'builtin(Kind::analog)', 'builtin(Kind::clock)']),
) )
def font_blocks(board): def font_blocks(board):
@@ -152,7 +152,7 @@ for (auto &scenario : scenarios) {{
lv_draw_buf_destroy(buf); lv_draw_buf_destroy(buf);
}} }}
}} }}
ESP_LOGI("render", "klaar"); ESP_LOGI("render", "done");
fflush(stdout); fflush(stdout);
exit(0); exit(0);
''' '''
@@ -192,7 +192,7 @@ api:
text: text:
- platform: smart_display - platform: smart_display
name: "Tegelinstellingen" name: "Tile settings"
display: display:
- platform: sdl - platform: sdl
@@ -272,7 +272,7 @@ def main():
parser.add_argument('--esphome', default=os.environ.get('ESPHOME') or shutil.which('esphome') or str(Path.home() / '.local/pipx/venvs/esphome/bin/esphome')) parser.add_argument('--esphome', default=os.environ.get('ESPHOME') or shutil.which('esphome') or str(Path.home() / '.local/pipx/venvs/esphome/bin/esphome'))
args = parser.parse_args() args = parser.parse_args()
if not shutil.which('sdl2-config'): if not shutil.which('sdl2-config'):
raise SystemExit('SDL2 ontbreekt: installeer het eerst (bijvoorbeeld brew install sdl2).') raise SystemExit('SDL2 is missing: install it first (for example brew install sdl2).')
out = args.out.resolve() out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)
for old in out.glob('*.p[np][gm]'): for old in out.glob('*.p[np][gm]'):
@@ -288,10 +288,10 @@ def main():
build = subprocess.run([args.esphome, 'compile', str(config)], capture_output=True, text=True) build = subprocess.run([args.esphome, 'compile', str(config)], capture_output=True, text=True)
if build.returncode: if build.returncode:
sys.stdout.write(build.stdout[-4000:] + build.stderr[-2000:]) sys.stdout.write(build.stdout[-4000:] + build.stderr[-2000:])
raise SystemExit('Build mislukt.') raise SystemExit('Build failed.')
program = next(WORK.glob('.esphome/build/render-topbar/.pioenvs/render-topbar/program'), None) program = next(WORK.glob('.esphome/build/render-topbar/.pioenvs/render-topbar/program'), None)
if not program: if not program:
raise SystemExit('Geen host-programma gevonden na de build.') raise SystemExit('No host program found after the build.')
subprocess.run([str(program)], capture_output=True, timeout=60, check=True) subprocess.run([str(program)], capture_output=True, timeout=60, check=True)
print(sheet(out)) print(sheet(out))
+17 -17
View File
@@ -8,8 +8,8 @@ import statistics
import time import time
from calibrate import write_file from calibrate import write_file
TARGETS = [('linksboven', (24, 24)), ('rechtsboven', (455, 24)), TARGETS = [('top-left', (24, 24)), ('top-right', (455, 24)),
('rechtsonder', (455, 455)), ('linksonder', (24, 455)), ('midden', (240, 240))] ('bottom-right', (455, 455)), ('bottom-left', (24, 455)), ('center', (240, 240))]
PATTERN = re.compile(r'GT911 press x=(\d+) y=(\d+) test=([01])') PATTERN = re.compile(r'GT911 press x=(\d+) y=(\d+) test=([01])')
@@ -18,35 +18,35 @@ def screen_point(x, y, rotation=0):
if rotation == 90: return y, 479-x if rotation == 90: return y, 479-x
if rotation == 180: return 479-x, 479-y if rotation == 180: return 479-x, 479-y
if rotation == 270: return 479-y, x if rotation == 270: return 479-y, x
raise ValueError('Rotatie moet 0, 90, 180 of 270 zijn.') raise ValueError('Rotation must be 0, 90, 180, or 270.')
def verify(data): def verify(data):
if data.get('screen') != [480,480] or len(data.get('points',[])) != 5: if data.get('screen') != [480,480] or len(data.get('points',[])) != 5:
raise ValueError('Verwacht vijf meetpunten op een 480x480 GT911-scherm.') raise ValueError('Expected five measurement points on a 480x480 GT911 screen.')
result=[] result=[]
for p,(name,target) in zip(data['points'],TARGETS): for p,(name,target) in zip(data['points'],TARGETS):
if p.get('name') != name or len(p.get('samples',[])) != 3: if p.get('name') != name or len(p.get('samples',[])) != 3:
raise ValueError('Volg de vijf doelen; ieder doel vereist drie aparte tikken.') raise ValueError('Follow the five targets; each target requires three separate taps.')
samples=[] samples=[]
for xy in p['samples']: for xy in p['samples']:
if len(xy)!=2 or any(type(v) is not int or not 0<=v<480 for v in xy): if len(xy)!=2 or any(type(v) is not int or not 0<=v<480 for v in xy):
raise ValueError('Touchcoördinaten vallen buiten 480x480.') raise ValueError('Touch coordinates fall outside 480x480.')
samples.append(screen_point(*xy, data.get('rotation',0))) samples.append(screen_point(*xy, data.get('rotation',0)))
center=tuple(statistics.median(x[i] for x in samples) for i in (0,1)) center=tuple(statistics.median(x[i] for x in samples) for i in (0,1))
error=math.dist(center,target) error=math.dist(center,target)
spread=max(math.dist(x,center) for x in samples) spread=max(math.dist(x,center) for x in samples)
result.append(dict(name=name,error_px=round(error,1),spread_px=round(spread,1))) result.append(dict(name=name,error_px=round(error,1),spread_px=round(spread,1)))
if error>16 or spread>18: if error>16 or spread>18:
raise ValueError(f'{name}: fout {error:.1f}px / spreiding {spread:.1f}px. Controleer rotatie en GT911-transform; geen ADC-fit gebruiken.') raise ValueError(f'{name}: error {error:.1f}px / spread {spread:.1f}px. Check rotation and the GT911 transform; do not use an ADC fit.')
return result return result
def main(): def main():
parser=argparse.ArgumentParser(description=__doc__) parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', help='USB-poort; firmware moet het GT911-meetscherm tonen') parser.add_argument('--port', help='USB port; firmware must show the GT911 measurement screen')
parser.add_argument('--output',type=Path,default=Path('measurements-guition.json')) parser.add_argument('--output',type=Path,default=Path('measurements-guition.json'))
parser.add_argument('--input',type=Path,help='Controleer een bestaand meetbestand zonder USB') parser.add_argument('--input',type=Path,help='Check an existing measurement file without USB')
parser.add_argument('--rotation',type=int,choices=(0,90,180,270),default=0) parser.add_argument('--rotation',type=int,choices=(0,90,180,270),default=0)
args=parser.parse_args() args=parser.parse_args()
try: try:
@@ -54,30 +54,30 @@ def main():
data=json.loads(args.input.read_text()) data=json.loads(args.input.read_text())
else: else:
import serial import serial
if not args.port: raise ValueError('--port is nodig voor een fysieke meting.') if not args.port: raise ValueError('--port is required for a physical measurement.')
if args.output.exists(): raise ValueError('Meetbestand bestaat al; kies een andere naam.') if args.output.exists(): raise ValueError('Measurement file already exists; choose a different name.')
data=dict(screen=[480,480],rotation=args.rotation,points=[]) data=dict(screen=[480,480],rotation=args.rotation,points=[])
with serial.Serial(args.port,115200,timeout=.2) as port: with serial.Serial(args.port,115200,timeout=.2) as port:
for name,xy in TARGETS: for name,xy in TARGETS:
input(f'{name} {xy}: druk Enter en tik daarna drie keer alleen dit kruisje, met loslaten ertussen. ') input(f'{name} {xy}: press Enter, then tap only this crosshair three times, releasing in between. ')
port.reset_input_buffer() port.reset_input_buffer()
samples=[]; deadline=time.monotonic()+90; last=0 samples=[]; deadline=time.monotonic()+90; last=0
while len(samples)<3 and time.monotonic()<deadline: while len(samples)<3 and time.monotonic()<deadline:
m=PATTERN.search(port.readline().decode(errors='replace')) m=PATTERN.search(port.readline().decode(errors='replace'))
if not m: continue if not m: continue
if m[3]!='1': raise ValueError('Het geïsoleerde GT911-meetscherm is niet actief.') if m[3]!='1': raise ValueError('The isolated GT911 measurement screen is not active.')
if time.monotonic()-last < .4:continue if time.monotonic()-last < .4:continue
samples.append([int(m[1]),int(m[2])]);last=time.monotonic() samples.append([int(m[1]),int(m[2])]);last=time.monotonic()
print(f' {len(samples)}/3',flush=True) print(f' {len(samples)}/3',flush=True)
if len(samples)!=3:raise ValueError(f'{name}: onvoldoende tikken ontvangen.') if len(samples)!=3:raise ValueError(f'{name}: not enough taps received.')
data['points'].append(dict(name=name,samples=samples)) data['points'].append(dict(name=name,samples=samples))
write_file(args.output,json.dumps(data,indent=2)+'\n') write_file(args.output,json.dumps(data,indent=2)+'\n')
for row in verify(data):print(row) for row in verify(data):print(row)
print('PASS: alle vijf GT911-doelen geverifieerd; geen HA-acties uitgevoerd.') print('PASS: all five GT911 targets verified; no HA actions performed.')
except (ValueError,OSError,TypeError,KeyError,EOFError) as exc: except (ValueError,OSError,TypeError,KeyError,EOFError) as exc:
parser.exit(1,f'Fout: {exc}\n') parser.exit(1,f'Error: {exc}\n')
except KeyboardInterrupt: except KeyboardInterrupt:
parser.exit(130,'Meting afgebroken.\n') parser.exit(130,'Measurement aborted.\n')
if __name__=='__main__': main() if __name__=='__main__': main()