One-window install: profile, wifi, USB flash and pairing hints (0.2.27)

"Nieuw scherm" used to span two dialogs plus a copy/download of the
installation YAML. It is now one dialog: board, name (node name derived,
editable), wifi only when secrets.yaml lacks it, the detected USB port,
and Installeren. The add-on writes the profile with unique API/OTA keys,
merges just the missing wifi lines into secrets.yaml (comments and other
secrets untouched, result re-parsed before writing), then builds and
flashes with the live ESPHome log and stage in the same dialog; a failed
build offers a retry without a second profile. The YAML download
endpoint is gone: the profile already lives in the ESPHome folder.

Pairing happens in Home Assistant itself, so the done view and a sidebar
card per not-yet-paired profile say so, with a button to Apparaten &
diensten, the API key to copy and the reminder to allow HA actions.
One profile per screen; the dialog says that too.

Tests: wifi merge cases, install pre-checks, pending list, profile
metadata; the date-anchored hourly forecast test now follows the clock.
Firmware 0.2.22 stays current.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-14 11:11:23 +02:00
parent 5d6651d68b
commit 2327d31261
16 changed files with 848 additions and 287 deletions
+11 -12
View File
@@ -109,20 +109,19 @@ Voor Home Assistant OS met Apps/Add-ons op **aarch64 of amd64**:
`https://github.com/MaxGramser/homeassistant_espscreen`.
2. Installeer **ESP Screen Manager**, start de app en open **ESP Screens**.
ESPHome Device Builder is optioneel: de ESPHome-CLI zit al in deze app.
3. Kies **Nieuw scherm**, selecteer CYD of Guition en geef een unieke naam.
Gebruik **Bewaar profiel in ESP Screens**. Bestaande `wifi_ssid` en
`wifi_password` in ESPHome `secrets.yaml` worden hergebruikt; bij een verse
installatie vraagt de wizard de wifi eenmalig. API- en OTA-sleutels worden
uniek aangemaakt en in je eigen apparaat-YAML bewaard.
4. Sluit het scherm met een USB-datakabel aan op de **Home Assistant-machine**.
Open **Firmware & USB**, kies het eigen profiel en de juiste USB-poort en
start **Bouwen & installeren**. Een eerste build kan meerdere minuten duren.
5. **CYD:** doorloop de kalibratie op het scherm. **Guition:** gebruikt GT911
3. Sluit het scherm met een USB-datakabel aan op de **Home Assistant-machine**
en kies **Nieuw scherm**: CYD of Guition, een naam, de USB-poort en
**Installeren**. Ontbreekt wifi in de ESPHome `secrets.yaml`, dan vraagt het
venster die eenmalig en zet ESP Screens alleen de ontbrekende regels erin.
Het profiel met unieke API- en OTA-sleutels komt in de ESPHome-map; de build
en de flash lopen in hetzelfde venster (een eerste build duurt op een
Raspberry enkele minuten). Elk scherm krijgt zijn eigen profiel.
4. **CYD:** doorloop de kalibratie op het scherm. **Guition:** gebruikt GT911
zonder resistieve kalibratie. Koppel daarna het ontdekte ESPHome-apparaat in
**Instellingen → Apparaten & diensten**. Gebruik bij een sleutelvraag de
`api.encryption.key` uit je eigen YAML. Geef het apparaat toestemming om
**Instellingen → Apparaten & diensten** met de API-sleutel die het venster
na de installatie toont (kopieerknop). Geef het apparaat toestemming om
Home Assistant-acties uit te voeren.
6. Selecteer het scherm in ESP Screens, kies je tegels en klik
5. Selecteer het scherm in ESP Screens, kies je tegels en klik
**Opslaan & naar scherm**. Test vervolgens de fysieke bediening.
Je kunt later vanuit **Firmware & USB → Wifi / OTA** nieuwe firmware installeren.
+45 -39
View File
@@ -33,42 +33,41 @@ Deze app bevat de geteste ESPHome 2026.6.2-CLI en draait binnen je HA-aanmelding
Een tweede ESPHome-beheerpagina, MQTT, blueprint of long-lived token is niet nodig.
Gebruik de GitHub-versie voor updates; een lokale test-add-on is een andere app.
## 2. Maak en bewaar je eigen apparaatprofiel
Klik **Nieuw scherm**. Kies CYD of Guition, een herkenbare naam en een unieke
apparaatnaam, bijvoorbeeld `scherm-keuken`. Klik **Bewaar profiel in ESP Screens**.
De wizard controleert de bestaande ESPHome `secrets.yaml`. Met `wifi_ssid` en
`wifi_password` aanwezig hoef je geen wifi in te vullen. Alleen als er nog geen
secretsbestand is, vraagt de wizard je 2,4GHz-wifi eenmalig. Een bestaand bestand
met ontbrekende of ongeldige wifi-sleutels moet je eerst herstellen; het wordt
niet stilzwijgend overschreven.
Je apparaat-YAML bevat unieke API- en OTA-sleutels. Bewaar dit profiel en gebruik
het opnieuw bij updates. Voor een bestaand scherm telkens een nieuw profiel
maken genereert nieuwe sleutels en is niet de updateroute.
Wil je ESPHome Device Builder gebruiken, kies dan **Maak installatie-YAML** en
kopieer/download die YAML naar een eigen apparaat daar. Beide routes gebruiken
dezelfde firmwarepakketten. Wifi blijft in ESPHome `secrets.yaml`; API- en
OTA-sleutels staan in het eigen apparaatprofiel.
## 3. Eerste flash via USB op de Raspberry
## 2. Nieuw scherm: aansluiten en installeren
1. Sluit het scherm met een **USB-datakabel** aan op de machine waarop Home
Assistant draait. Bij meerdere borden: identificeer eerst de juiste poort,
of sluit ze voor de eerste installatie één voor één aan.
2. Open **Firmware & USB** in ESP Screens en kies het zojuist bewaarde profiel.
3. Kies bij **Installeren naar** de USB-poort van dit scherm.
4. Klik **Bouwen & installeren** en wacht op een geslaagde build én upload.
De eerste build kan op een Raspberry meerdere minuten duren.
5. Het scherm herstart en verbindt met wifi. Latere firmware-updates kunnen via
**Wifi / OTA** in dezelfde pagina, met het bestaande profiel.
Assistant draait. Bij meerdere borden: sluit ze voor de eerste installatie
één voor één aan, of controleer welke poort bij dit scherm hoort.
2. Klik **Nieuw scherm**. Kies CYD of Guition en geef het scherm een naam,
bijvoorbeeld `Keuken`. De apparaatnaam (`keuken`) volgt daaruit; met
**aanpassen** kies je een andere.
3. Wifi: staan `wifi_ssid` en `wifi_password` al in de ESPHome `secrets.yaml`,
dan gebruikt het scherm die automatisch. Ontbreken ze, of bestaat het bestand
nog niet, dan vraagt het venster ze eenmalig en zet ESP Screens alleen de
ontbrekende regels in `secrets.yaml`; commentaar en andere secrets blijven
staan. Een `secrets.yaml` die geen geldige YAML is, herstel je eerst zelf.
4. Kies bij **Installeren via** de USB-poort van dit scherm en klik
**Installeren**. ESP Screens bewaart het profiel (`keuken.yaml`, met unieke
API- en OTA-sleutels) in de ESPHome-map, bouwt de firmware en schrijft die
via USB. Het ESPHome-log en de fase (bouwen, schrijven) staan in hetzelfde
venster; een eerste build duurt op een Raspberry enkele minuten. Je mag het
venster sluiten: de installatie loopt door en staat bij heropenen weer klaar.
5. Na **Klaar** toont het venster de API-sleutel met een kopieerknop en de
koppelstappen uit hoofdstuk 3. Mislukt de build, dan staat het log open en
kun je **Opnieuw proberen**.
Een USB-kabel aan je laptop is niet zichtbaar als USB-poort van de Raspberry.
Gebruik voor die route de ESPHome-CLI op de laptop met je eigen profiel, of de
browserinstallatie van ESPHome Device Builder. De stappen hierboven gebruiken
uitsluitend ESP Screens op de HA-machine.
Elk scherm krijgt zijn eigen profiel: vier schermen betekent vier keer **Nieuw
scherm** met vier verschillende namen. Het gedeelde bordpakket is voor alle
schermen gelijk; het profiel bevat alleen naam, sleutels en de wifi-verwijzing.
Bewaar dat profiel en gebruik het opnieuw bij updates. Voor een bestaand scherm
opnieuw **Nieuw scherm** doorlopen maakt nieuwe sleutels aan en is niet de
updateroute.
Geen USB-poort in de lijst? Een kabel aan je laptop is niet zichtbaar voor de
Raspberry. Kies dan **Later · alleen het profiel bewaren**: het profiel staat
daarna in dezelfde map die ESPHome Device Builder gebruikt, dus je kunt het daar
openen en vanuit je browser flashen (**Install → Plug into this computer**), of
het later via **Firmware & USB** alsnog aan de HA-machine installeren.
**CYD:** bij de eerste start verschijnt de kalibratie. Tik het zichtbare kruisje
drie keer rustig aan, houd elke tik kort vast en volg steeds het volgende kruisje.
@@ -80,19 +79,26 @@ je later opnieuw meten. Voor diagnose en de oudere handmatige installatie:
**Guition:** de GT911-touchmapping zit in het bordprofiel; er is geen ADC-kalibratie.
## 4. Koppel het scherm aan Home Assistant
## 3. Koppel het scherm aan Home Assistant
Dit gebeurt in Home Assistant zelf, buiten ESP Screens. Zolang een profiel nog
niet aan Home Assistant is toegevoegd, staat het links onder **Mijn schermen**
als kaart *nog niet in Home Assistant*, met de knop **Open Apparaten & diensten**
en **Kopieer API-sleutel**; het klaar-scherm van **Nieuw scherm** heeft dezelfde
knop. De kaart verdwijnt zodra het scherm in de lijst staat.
1. Open **Instellingen → Apparaten & diensten**. Voeg het ontdekte ESPHome-apparaat
toe. Niet ontdekt? Voeg de integratie **ESPHome** handmatig toe met het IP-adres
uit de ESPHome-logs, poort 6053.
2. Vraagt HA om een encryptiesleutel? Kopieer de waarde van **api → encryption → key**
uit je eigen YAML. Gebruik niet het OTA-wachtwoord.
van het scherm, poort 6053.
2. Vraagt HA om een encryptiesleutel? Plak de API-sleutel die het venster na de
installatie toont (ook terug te vinden als **api → encryption → key** in het
profiel). Gebruik niet het OTA-wachtwoord.
3. Open bij de ESPHome-integratie **Configureren** en zet **Allow the device to
perform Home Assistant actions** aan. Zonder deze toestemming verschijnen
waarden wel, maar kan het scherm lampen en apparaten niet bedienen.
4. Open ESP Screens. Het scherm verschijnt binnen ongeveer 30 seconden.
## 5. Kies en wijzig je tegels
## 4. Kies en wijzig je tegels
Tip: zet in je ESPHome-YAML onder `wifi:` de regel `power_save_mode: none` (nieuwe
wizard-YAML's hebben hem al); het scherm hangt aan het lichtnet en reageert dan zonder
@@ -145,7 +151,7 @@ totdat de gegevens opnieuw ontvangen zijn.
Je mag indelingen opslaan terwijl een scherm offline is. De app verstuurt ze
zodra het scherm terugkomt. De app moet blijven draaien voor actuele tegeldata.
## 6. Updates zonder je instellingen kwijt te raken
## 5. Updates zonder je instellingen kwijt te raken
| Wat verandert? | Wat doe je? | Wat blijft behouden? |
| --- | --- | --- |
+14
View File
@@ -177,6 +177,20 @@ staan iconen in de browser uit het midden); draai daarna `generate_packages.py`.
De substituties `MDI_GLYPH_*` zijn vervallen: `TILEn_ICON` in handmatige
profielen moet uit de set komen. Geen gewijzigde preferences of sleutels.
### Compatibiliteit 0.2.27 (firmware 0.2.22 blijft)
Alleen de app. `POST /api/install` (YAML-download) is verwijderd; `POST
/api/firmware/profiles` schrijft het profiel, vult ontbrekende wifi-sleutels in
`secrets.yaml` aan (alleen de ontbrekende regels, geverifieerd door het resultaat
opnieuw te parsen; een ongeldig bestand blijft onaangeraakt) en start met `target`
(een gemelde USB-poort) direct `install`. Poort en bouwslot worden vóór het
schrijven gecontroleerd. Het antwoord bevat `api_key`; de pagina toont hem eenmalig
voor de HA-koppeling. Het jobobject krijgt `stage` (`config`, `compile`, `upload`).
`profile_meta` levert extra `screen` (profiel gebruikt het bordpakket van dit
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
Opslagversie en tegelprotocol blijven 1. Het layoutbericht krijgt het additieve veld
+59
View File
@@ -0,0 +1,59 @@
# Testresultaten app 0.2.27 (2026-09-14, firmware 0.2.22 blijft)
Alleen de app: "Nieuw scherm" is één venster van aansluiten tot flash, ontbrekende
wifi gaat in `secrets.yaml`, en de koppeling in Home Assistant (buiten de add-on)
is zichtbaar gemaakt met een knop en een kaart per nog niet gekoppeld profiel.
## Geautomatiseerd
- Python: 84 tests OK (`.venv-portal`, aiohttp 3.13.3, libyaml). Nieuw of aangepast:
- `test_wifi_reuse`: ontbrekende `wifi_ssid`/`wifi_password` worden aangevuld met
behoud van commentaar en andere secrets (ook een lege `wifi_ssid: ""` wordt
vervangen, niet gedupliceerd; waarden met `: `, `\` en `"` overleven de
round-trip); een ongeldig `secrets.yaml` blijft onaangeraakt en er komt dan geen
profiel; `install()` weigert een onbekende poort en een bezet bouwslot vóór er
iets geschreven is; `profile_names()` herkent eigen profielen (`screen`) en levert
de API-sleutel, een handmatig ESPHome-profiel niet.
- `test_portal`: `POST /api/firmware/profiles` met een onbekende `target` geeft 400
zonder profiel; zonder `target` maakt het profiel en geeft `api_key` (32 bytes)
zonder job; `/api/inventory?light=1` toont het profiel in `pending` met
`installed: false`; `/api/firmware` lekt de wifi-waarden niet.
- `test_updates`: metadata-cache met de extra velden.
- `test_rich_cards.HourlySync` was datumgebonden (vaste 13 september) en faalde
sinds 14 september op main; de uurvoorspelling in de managertest volgt nu de
echte klok.
- `py_compile` van de add-on; `node --check` van `app.js`.
## In de browser (in-app browser tegen de echte server, nep-CLI)
`server.py` in `SCREEN_DEV`-modus met een nep-`esphome` op het pad (schrijft
compile-/uploadregels, faalt op een vlag) en een nep-USB-poort (`Firmware.ports`
gepatcht). Doorlopen en per stap bekeken:
- Nieuw venster: bordkeuze, naam → apparaatnaam (`Keuken Guition` → `keuken-guition`),
wifi-velden zichtbaar zolang `secrets.yaml` ontbreekt; na de eerste installatie
"Wifi komt uit je ESPHome secrets.yaml" en geen velden meer. `secrets.yaml` bevat
daarna precies `wifi_ssid`/`wifi_password` (0600), het profiel het bordpakket.
- Installeren via USB: fase "Firmware bouwen…" → "Firmware naar … schrijven…" (uit
`job.stage`), log inklapbaar, daarna "Klaar." met API-sleutel, kopieerknop, de
vier koppelstappen en de knop **Open Apparaten & diensten**; CYD-tekst noemt de
kalibratie, Guition niet.
- Mislukte build: "Installeren mislukt", log open met de foutregel, **Opnieuw
proberen** herstart alleen de job (geen tweede profiel) en slaagt na het weghalen
van de vlag.
- Zonder poort: "Later · alleen het profiel bewaren", knop "Profiel bewaren",
klaar-scherm met de vervolgroutes.
- Zijbalk: per niet-gekoppeld profiel een kaart ("geïnstalleerd, maar nog niet in
Home Assistant" voor een in deze sessie geflasht profiel, anders "nog niet in Home
Assistant … Firmware & USB → bestand"), met **Open Apparaten & diensten** (navigeert
het bovenste venster naar `/config/integrations/dashboard`; op de dev-server een
404, in HA de integratiepagina) en **Kopieer API-sleutel**.
## Nog niet geverifieerd
- De echte ESPHome-CLI en USB-detectie (`/dev/serial/by-id`) in de add-on-container
op de Yellow, en de eerste echte flash via dit venster.
- Of de knop **Open Apparaten & diensten** vanuit het HA-ingress-iframe naar de
integratiepagina springt (zelfde origin, dus verwacht wel).
- Of Home Assistant het scherm na de flash ontdekt en de kaart in de zijbalk
verdwijnt zodra `Tegelinstellingen` in het register staat.
+8
View File
@@ -1,3 +1,11 @@
## 0.2.27 (firmware 0.2.22 blijft actueel)
- **Nieuw scherm in één venster**: bord, naam, USB-poort, **Installeren**. Het venster maakt het profiel met unieke API- en OTA-sleutels in de ESPHome-map, bouwt de firmware en schrijft die via USB, met het ESPHome-log en de fase (bouwen, schrijven) in hetzelfde venster. Na afloop staat de API-sleutel klaar met een kopieerknop plus de koppelstappen voor Home Assistant; mislukt de build, dan staat het log open en is er **Opnieuw proberen**. De apparaatnaam volgt uit de naam (`Keuken` → `keuken`) en is aan te passen. Zonder aangesloten scherm kun je alleen het profiel bewaren; het staat dan in dezelfde map als ESPHome Device Builder. Elk scherm krijgt zijn eigen profiel; het venster zegt dat nu ook.
- **Waar is mijn scherm?** De koppeling gebeurt in Home Assistant zelf, buiten ESP Screens. Het klaar-scherm van het venster zegt dat nu expliciet, met een knop **Open Apparaten & diensten** die je daarheen brengt. Zolang een ESP Screens-profiel nog niet in Home Assistant staat, toont **Mijn schermen** er een kaart voor ("geïnstalleerd, maar nog niet in Home Assistant", of "nog niet geflasht"), met dezelfde knop, **Kopieer API-sleutel** en de herinnering om bij Configureren "Allow the device to perform Home Assistant actions" aan te zetten (anders ziet het scherm alles, maar bedient het niets). De kaart verdwijnt zodra het scherm in de lijst staat. `/api/inventory` levert daarvoor `pending` (profielen die het bordpakket van dit project gebruiken zonder gekoppeld scherm).
- **Wifi zonder handwerk**: ontbreken `wifi_ssid` of `wifi_password` in de ESPHome `secrets.yaml`, of bestaat het bestand niet, dan vraagt het venster ze en zet ESP Screens alleen de ontbrekende regels in het bestand; commentaar en andere secrets blijven staan (de wizard weigerde eerder bij een bestaand bestand zonder wifi). Een ongeldig `secrets.yaml` wordt nog steeds niet aangeraakt.
- Kopiëren en downloaden van de installatie-YAML is vervallen (`POST /api/install` bestaat niet meer): het profiel staat al in de ESPHome-map. `POST /api/firmware/profiles` accepteert `target` (USB-poort) en start dan direct de build en flash; poort en vrije bouwslot worden gecontroleerd vóór er iets wordt geschreven, en het antwoord bevat `api_key`. Het firmwarejob-object meldt de lopende fase in `stage`.
- Alleen de app verandert; firmware 0.2.22 blijft actueel.
## 0.2.26 (firmware 0.2.22)
- **"HA niet verbonden" om de twee minuten opgelost**: sinds 0.2.20 herhaalt de app de indeling elke 120 s, maar de firmware verwachtte binnen 95 s een bericht (gemaakt voor de oude 25 s). Op een rustig scherm stonden alle tegels daardoor 25 tot 45 s per ronde op "Niet beschikbaar". De firmware gebruikt nu ESPHome's eigen API-verbindingsstatus voor "HA niet verbonden" (direct bij wegvallen en terugkeren van Home Assistant) en bewaakt de gegevensstroom apart met het interval dat de app zelf declareert: het layoutbericht bevat `keepalive` (seconden). Pas na twee gemiste rondes plus marge meldt het scherm "ESP Screens niet actief". Zonder het veld (oudere app) rekent de firmware met 120 s. Firmware 0.2.22 voor beide borden; firmware tot 0.2.21 negeert het veld en houdt zijn 95 s.
+9 -5
View File
@@ -2,11 +2,15 @@
Zie [de volledige 0.2.0-handleiding](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/WHATS_NEW_020.md).
Open **Nieuw scherm** om een eigen profiel te bewaren. Open **Firmware & USB**
om een profiel te controleren, bouwen of installeren. Bestaande ESPHome-profielen
in de HA-configmap worden automatisch gevonden. Eerste flash: USB aan de Raspberry
en expliciet de bijbehorende poort kiezen. Later: hetzelfde profiel plus het
IP-adres van het scherm voor OTA. Bestaande secrets worden niet vervangen.
Open **Nieuw scherm** om een scherm te installeren: sluit het via USB aan op de
Home Assistant-machine, kies bord, naam en USB-poort en klik **Installeren**.
Het profiel met unieke sleutels komt in de ESPHome-map van de HA-configuratie,
ontbrekende wifi gaat in `secrets.yaml` (bestaande secrets blijven staan), en de
build en de flash lopen in hetzelfde venster; daarna toont het de API-sleutel
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
voor bestaande profielen: controleren, bouwen of opnieuw installeren via USB of
het IP-adres (OTA). Bestaande ESPHome-profielen in de HA-configmap worden
automatisch gevonden.
Na koppeling via de HA ESPHome-integratie kies je in ESP Screens de tegels.
Onder **Tegelinstellingen** staan klikgedrag, grotere waarden en mini-schuiven.
+71 -15
View File
@@ -10,7 +10,7 @@ import shutil
import signal
import time
import yaml
from core import installation_yaml
from core import REPO, installation_yaml
# libyaml parses a 300 KB profile roughly ten times faster than the pure-Python loader.
class LenientLoader(yaml.CSafeLoader if getattr(yaml, '__with_libyaml__', False) else yaml.SafeLoader):
@@ -28,7 +28,14 @@ def profile_meta(text):
if isinstance(value, str) and value.startswith('${') and value.endswith('}'):
value = substitutions.get(value[2:-1])
return value if isinstance(value, str) else None
return {'node': resolve(block.get('name')), 'friendly': resolve(block.get('friendly_name'))}
# 'screen': the profile pulls this project's board package, so it is one of ours (not any ESPHome device).
packages = data.get('packages') if isinstance(data.get('packages'), dict) else {}
ours = any(isinstance(entry, dict) and REPO in str(entry.get('url', '')) for entry in packages.values())
api = data.get('api') if isinstance(data.get('api'), dict) else {}
encryption = api.get('encryption') if isinstance(api.get('encryption'), dict) else {}
key = encryption.get('key')
return {'node': resolve(block.get('name')), 'friendly': resolve(block.get('friendly_name')),
'screen': ours, 'api_key': key if isinstance(key, str) else None}
class Firmware:
def __init__(self, root, data):
@@ -37,6 +44,7 @@ class Firmware:
self.task = None
self.logs = deque(maxlen=300)
self.process = None
self.installed = set() # profiles this process flashed successfully; the page nudges pairing for them
self._names = {} # file -> (stat signature, meta or None); parsed only when the file changes
def profiles(self):
@@ -101,28 +109,74 @@ class Firmware:
return {'available': bool(shutil.which('esphome')), 'profiles': self.profiles(),
'ports': self.ports(), 'job': self.job, 'logs': list(self.logs), 'wifi': self.wifi_status()}
def store_wifi(self, data):
"""Put wifi_ssid/wifi_password in secrets.yaml when they are missing. Existing values and
every other secret stay as they are; the browser never sees stored values."""
wifi = self.wifi_status()
if wifi['state'] == 'ready':
return
if wifi['state'] == 'invalid':
raise ValueError('secrets.yaml in de ESPHome-map is geen geldige YAML. Herstel het bestand eerst; het wordt niet overschreven.')
values = {}
for key in wifi['missing']:
value = data.get(key)
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.')
values[key] = value
path = self.root / 'secrets.yaml'
if wifi['state'] == 'new':
with path.open('x') as f:
os.chmod(path, 0o600)
yaml.safe_dump(values, f, width=4096)
return
# An existing file with missing keys: replace only that key's own line, or append it.
text = path.read_text()
for key, value in values.items():
line = yaml.safe_dump({key: value}, width=4096).strip()
pattern = re.compile(rf'^{key}\s*:.*$', re.M)
if pattern.search(text):
text = pattern.sub(lambda match: line, text, count=1)
else:
text = text + ('' if not text or text.endswith('\n') else '\n') + line + '\n'
check = yaml.safe_load(text)
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.')
path.write_text(text)
def create(self, data):
"""Write the device profile (unique API/OTA keys) and any missing wifi secrets."""
content = installation_yaml(data)
self.root.mkdir(parents=True,exist_ok=True)
# Existing wifi secrets remain untouched. New owners can supply them once.
secret_path = self.root / 'secrets.yaml'
wifi = self.wifi_status()
if wifi['state'] in ('missing', 'invalid'):
raise ValueError('Controleer wifi_ssid en wifi_password in ESPHome secrets.yaml. Bestaande secrets blijven behouden.')
if wifi['state'] == 'new':
ssid, password = data.get('wifi_ssid'), data.get('wifi_password')
if not isinstance(ssid,str) or not ssid.strip() or not isinstance(password,str):
raise ValueError('Vul wifi in voor deze eerste installatie.')
with secret_path.open('x') as f:
os.chmod(secret_path,0o600)
yaml.safe_dump({'wifi_ssid':ssid,'wifi_password':password}, f)
profile = self.root / (data['name']+'.yaml')
if profile.exists() or profile.is_symlink():
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.')
self.store_wifi(data)
try:
with profile.open('x') as f:
os.chmod(profile,0o600);f.write(content)
except FileExistsError:
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.')
return {'file':profile.name, 'yaml':content}
key = yaml.load(content, Loader=LenientLoader)['api']['encryption']['key']
# 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}
def install(self, data):
"""Profile plus, when a USB port is chosen, the build and flash in one go.
The port and the job slot are checked before anything is written, so a refused
install leaves no half-made profile behind."""
target = data.get('target') or ''
if target:
if not isinstance(target, str) or target not in self.ports():
raise ValueError('Kies de aangesloten USB-poort uit de lijst.')
if self.task and not self.task.done():
raise ValueError('Er loopt al een build of installatie. Wacht tot die klaar is.')
if not shutil.which('esphome'):
raise ValueError('ESPHome CLI ontbreekt in deze installatie.')
result = self.create(data)
if target:
result['job'] = self.start({'file': result['file'], 'action': 'install', 'target': target})
return result
def redact(self, text):
text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
@@ -171,6 +225,7 @@ class Firmware:
for stage in stages:
cmd=['esphome']+(['--quiet'] if stage=='config' else [])+[stage,str(profile)]
if stage=='upload': cmd += ['--device',target]
self.job['stage']=stage
self.logs.append('ESPHome: '+stage)
self.process=await asyncio.create_subprocess_exec(*cmd,cwd=self.root,env=env,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.STDOUT,limit=1024*1024,start_new_session=True)
async with asyncio.timeout(7200):
@@ -179,6 +234,7 @@ class Firmware:
code=await self.process.wait()
if code: raise RuntimeError('ESPHome '+stage+' mislukt; bekijk het log.')
self.job['state']='success'; self.logs.append('Geslaagd: '+action)
if action=='install': self.installed.add(profile.name)
except asyncio.CancelledError:
self.job['state']='interrupted'
raise
+18 -7
View File
@@ -14,7 +14,7 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BUILTIN, TILE_BACKGROUNDS, controls_catalogue, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings
from core import BUILTIN, TILE_BACKGROUNDS, controls_catalogue, discover, extras, min_firmware, packets, state_message, validate_layout, validate_settings
from zoneinfo import ZoneInfo
LOG = logging.getLogger('screen_manager')
@@ -291,6 +291,19 @@ class Manager:
for listener in self.listeners:
listener.set()
def pending_profiles(self, screens, profiles):
"""ESP Screens profiles without a paired screen: flashed but not yet added in Home Assistant, or not flashed yet.
Pairing happens in Home Assistant itself, outside this page; the sidebar shows these so nobody wonders
where the freshly flashed screen went."""
nodes = {s.get('node') for s in screens}
devices = {s.get('device') for s in screens}
installed = getattr(self.firmware, 'installed', set())
return [{'file': file, 'node': meta['node'], 'friendly': meta.get('friendly') or meta['node'] or file,
'installed': file in installed, 'api_key': meta.get('api_key')}
for file, meta in profiles.items()
if meta.get('screen') and meta.get('node') not in nodes and (meta.get('friendly') or None) not in devices]
def watched_entities(self):
"""Entities whose state changes matter: tiles on any layout plus the screens' own diagnostics."""
watched = {tile['entity'] for layout in self.layouts.values() for tile in layout['tiles']}
@@ -431,6 +444,7 @@ def create_app(manager, development=False):
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels')
screen['update'] = manager.updates.state_for(screen, profiles)
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
'pending': manager.pending_profiles(screens, profiles),
'updates': manager.updates.summary(screens, profiles)}, entities
async def inventory(request):
payload, entities = light_payload()
@@ -478,10 +492,6 @@ def create_app(manager, development=False):
async def update_settings(request):
manager.updates.set_auto((await request.json()).get('auto'))
return web.json_response(manager.updates.summary())
async def download(request):
data = await request.json()
content = installation_yaml(data)
return web.Response(text=content, content_type='text/yaml', headers={'Content-Disposition': f'attachment; filename="{data["name"]}.yaml"'})
async def inspector(request):
inbox=request.match_info['inbox']
if inbox not in {s['id'] for s in manager.inventory()[0]}: raise ValueError('Onbekend scherm.')
@@ -493,7 +503,9 @@ def create_app(manager, development=False):
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
async def firmware_status(request): return web.json_response(manager.firmware.status())
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json()))
async def firmware_create(request): return web.json_response(manager.firmware.create(await request.json()))
async def firmware_create(request):
# Profile, missing wifi secrets and (with a USB port) the build and flash in one request.
return web.json_response(manager.firmware.install(await request.json()))
async def shutdown(app):
for task in (manager.updates.task, manager.firmware.task):
if task and not task.done():
@@ -511,7 +523,6 @@ def create_app(manager, development=False):
app.router.add_get('/api/inventory', inventory)
app.router.add_get('/api/events', events)
app.router.add_put('/api/screens/{inbox}', save)
app.router.add_post('/api/install', download)
app.router.add_static('/static/', static)
return app
+285 -85
View File
@@ -260,7 +260,62 @@ function renderUpdate(screen) {
} else return null;
return row;
}
function openIntegrations() {
// Pairing happens in Home Assistant itself. This page lives in HA's ingress iframe,
// so send the top window to Apparaten & diensten (same origin); elsewhere open a tab.
const path = "/config/integrations/dashboard";
try {
window.top.location.assign(path);
} catch {
window.open(path, "_blank");
}
}
async function copyText(text, element) {
try {
if (!navigator.clipboard || !window.isSecureContext) throw new Error();
await navigator.clipboard.writeText(text);
toast("API-sleutel gekopieerd.");
} catch {
if (element) {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
toast(document.execCommand("copy") ? "API-sleutel gekopieerd." : "De sleutel is geselecteerd. Kopieer met Ctrl+C of Command+C.");
}
}
// Profiles that Home Assistant does not list yet: the freshly flashed screen is not lost, it
// still has to be added under Apparaten & diensten, outside this page.
function renderPending() {
const box = $("#pending");
box.replaceChildren();
for (const p of inventory.pending || []) {
const card = node("div", undefined, "pending");
card.append(
node("strong", p.friendly),
node("small", p.installed
? "Geïnstalleerd, maar nog niet in Home Assistant. Dat doe je buiten ESP Screens: voeg het ontdekte ESPHome-apparaat toe onder Instellingen → Apparaten & diensten, plak daar de API-sleutel en zet bij Configureren “Allow the device to perform Home Assistant actions” aan."
: `Nog niet in Home Assistant. Al geflasht? Voeg het ESPHome-apparaat toe onder Instellingen → Apparaten & diensten en sta daarna bij Configureren de Home Assistant-acties toe. Nog niet geflasht? Firmware & USB → ${p.file}.`),
);
const actions = node("div", undefined, "pending-actions");
const go = node("button", "Open Apparaten & diensten", "mini");
go.type = "button";
go.onclick = openIntegrations;
actions.append(go);
if (p.api_key) {
const copy = node("button", "Kopieer API-sleutel", "mini quiet");
copy.type = "button";
copy.onclick = () => copyText(p.api_key);
actions.append(copy);
}
card.append(actions);
box.append(card);
}
}
function renderScreens() {
renderPending();
// Keep an open address form alive across the periodic refresh.
if ($("#screens .screen-host")) {
renderUpdates();
@@ -976,85 +1031,250 @@ for (const [value, label] of [
};
$("#filters").append(b);
}
async function checkWifiSecrets() {
const fields = $("#wifi-fields"), status = $("#wifi-status");
fields.hidden = true;
fields.disabled = true;
$("#create-profile").disabled = true;
status.textContent = "ESPHome wifi-instellingen controleren…";
// ----- Nieuw scherm: profiel, wifi en de eerste flash in één venster -----
const installer = {
poll: null, view: "setup", file: null, friendly: "", board: "cyd", target: "",
apiKey: null, nodeEdited: false, ports: null, jobState: null,
};
// ESPHome's node-name rule: lowercase ASCII, digits and dashes, starting with a letter.
function slug(text) {
const clean = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 30).replace(/-+$/, "");
return clean || "scherm";
}
function portLabel(port) {
const id = port.replace(/^\/dev\/serial\/by-id\/usb-/, "").replace(/-if\d+(-port\d+)?$/, "").replace(/_/g, " ");
return `USB · ${id === port ? port.replace(/^\/dev\//, "") : id}`;
}
function syncNode() {
const form = $("#install-form");
if (!installer.nodeEdited) form.elements.name.value = slug(form.elements.friendly_name.value);
$("#node-preview").textContent = form.elements.name.value || "…";
}
function renderTargets(ports) {
const select = $("#install-target");
const key = ports.join("\n");
if (installer.ports === key) return;
installer.ports = key;
const current = select.value;
const later = node("option", "Later · alleen het profiel bewaren");
later.value = "";
select.replaceChildren(...ports.map((p) => { const o = node("option", portLabel(p)); o.value = p; return o; }), later);
select.value = ports.includes(current) ? current : ports[0] || "";
renderTargetHint();
}
function renderTargetHint() {
const select = $("#install-target");
const ports = select.options.length - 1;
$("#target-hint").textContent = !ports
? "Geen USB-poort gevonden. Sluit het scherm met een datakabel aan op de Home Assistant-machine; de lijst ververst vanzelf."
: !select.value
? "Het profiel komt in de ESPHome-map. Installeren kan later via Firmware & USB, of vanuit ESPHome Device Builder."
: ports > 1
? "Meer dan één bord aangesloten: kies de poort van dit scherm."
: "Eén keer via USB; daarna gaat alles draadloos.";
$("#install-go").textContent = select.value ? "Installeren" : "Profiel bewaren";
}
function renderWifi(wifi) {
const fields = $("#wifi-fields");
const ask = wifi?.state === "new" || wifi?.state === "missing";
fields.hidden = !ask;
fields.disabled = !ask;
if (ask) {
const missing = wifi.missing || [];
$("#wifi-ssid-label").hidden = !missing.includes("wifi_ssid");
$("#wifi-password-label").hidden = !missing.includes("wifi_password");
$("#wifi-status").textContent = wifi.state === "new"
? "Eén keer invullen: ESP Screens bewaart dit in ESPHome secrets.yaml, volgende schermen gebruiken het automatisch."
: "Je ESPHome secrets.yaml mist nog wifi-gegevens. ESP Screens vult alleen de ontbrekende regels aan.";
}
return wifi?.state === "ready"
? "Wifi komt uit je ESPHome secrets.yaml."
: wifi?.state === "invalid"
? "secrets.yaml in de ESPHome-map is geen geldige YAML. Herstel het bestand eerst; het wordt niet overschreven."
: "";
}
async function installerRefresh() {
let data;
try {
const {wifi} = await (await api("firmware")).json();
if (wifi?.state === "ready") {
status.textContent = "✓ Bestaande ESPHome-wifi gevonden. Dit scherm gebruikt automatisch wifi_ssid en wifi_password uit je secrets.yaml.";
$("#create-profile").disabled = false;
} else if (wifi?.state === "new") {
status.textContent = "Nog geen ESPHome-secrets gevonden. Vul wifi één keer in; volgende schermen gebruiken deze gegevens automatisch.";
fields.hidden = false;
fields.disabled = false;
$("#create-profile").disabled = false;
} else {
status.textContent = "Controleer wifi_ssid en wifi_password in ESPHome secrets.yaml. Je bestaande bestand blijft behouden. Sluit en open deze wizard opnieuw na aanpassen.";
}
} catch {
status.textContent = "ESPHome-secrets konden niet worden gecontroleerd. Sluit en open de wizard opnieuw om het nogmaals te proberen.";
data = await (await api("firmware")).json();
} catch (e) {
$("#install-note").textContent = e.message;
return;
}
const job = data.job;
const ours = job && installer.file && job.file === installer.file;
if (ours) installer.jobState = job.state;
if (installer.view === "setup") {
if (ours && job.state === "running") return showProgress(job, data.logs);
renderTargets(data.ports || []);
const wifiNote = renderWifi(data.wifi);
const busy = job?.state === "running";
const flashing = !!$("#install-target").value;
$("#install-note").textContent = busy
? `Er loopt al een build of installatie (${job.file}). Wacht tot die klaar is.`
: !data.available && flashing
? "ESPHome CLI ontbreekt in deze installatie; alleen het profiel bewaren kan."
: wifiNote;
$("#install-go").disabled = data.wifi?.state === "invalid" || (flashing && (busy || !data.available));
} else if (installer.view === "progress" && ours) {
renderProgress(job, data.logs);
}
}
$("#new-screen").onclick = $("#start").onclick = () => {
function showProgress(job, logs) {
installer.view = "progress";
installer.jobState = job.state;
$("#install-setup").hidden = true;
$("#install-progress").hidden = false;
$("#install-log-wrap").hidden = false;
renderProgress(job, logs);
}
function outcome(ok) {
$("#progress-spin").hidden = true;
$("#progress-mark").hidden = false;
$("#progress-mark").textContent = ok ? "✓" : "✕";
$("#progress-mark").className = `outcome ${ok ? "ok" : "bad"}`;
}
function renderProgress(job, logs) {
const running = job.state === "running";
const ok = job.state === "success";
if (running) {
$("#progress-spin").hidden = false;
$("#progress-mark").hidden = true;
} else outcome(ok);
$("#install-title").textContent = running ? "Even geduld…" : ok ? "Klaar." : "Dat lukte niet.";
$("#progress-title").textContent = running
? job.stage === "upload" ? `Firmware naar ${installer.friendly} schrijven…` : "Firmware bouwen…"
: ok ? `Firmware staat op ${installer.friendly}` : "Installeren mislukt";
$("#progress-detail").textContent = running
? job.stage === "upload"
? "Haal de USB-kabel nog niet los."
: "Een eerste build duurt op een Raspberry enkele minuten. Je mag dit venster sluiten: de installatie loopt door en je vindt hem terug onder Nieuw scherm."
: ok
? `Het scherm start op en verbindt met je wifi.${installer.board === "cyd" ? " De CYD vraagt eerst om een touch-kalibratie: tik de kruisjes aan." : ""} Koppel het nu aan Home Assistant:`
: logs.filter((l) => /error/i.test(l)).pop() || logs.filter((l) => /failed|mislukt|fout/i.test(l)).pop() || "Bekijk het log hieronder.";
const pre = $("#install-log");
const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8;
pre.textContent = logs.join("\n");
if (stick) pre.scrollTop = pre.scrollHeight;
if (!running && !ok) $("#install-log-wrap").open = true;
$("#install-result").hidden = !ok;
if (ok) renderSteps();
$("#install-retry").hidden = running || ok;
$("#install-close").textContent = ok ? "Klaar" : "Sluiten";
$("#install-close").classList.toggle("quiet", !ok);
}
function showSaved() {
installer.view = "done";
$("#install-setup").hidden = true;
$("#install-progress").hidden = false;
outcome(true);
$("#install-title").textContent = "Profiel bewaard.";
$("#progress-title").textContent = `${installer.file} staat in de ESPHome-map`;
$("#progress-detail").textContent =
"Installeren kan zodra het scherm aan de Home Assistant-machine hangt: Firmware & USB → dit profiel → USB-poort → Bouwen & installeren. Of open het profiel in ESPHome Device Builder (dezelfde map) en flash vanuit je browser. Bewaar de API-sleutel voor de koppeling:";
$("#install-result").hidden = false;
renderSteps();
$("#install-log-wrap").hidden = true;
$("#install-retry").hidden = true;
$("#install-close").textContent = "Klaar";
$("#install-close").classList.remove("quiet");
}
function renderSteps() {
$("#api-key").textContent = installer.apiKey || "";
const steps = [
["Ga naar Home Assistant → Instellingen → Apparaten & diensten.", ` Dit gebeurt buiten ESP Screens. Home Assistant ontdekt ${installer.friendly} als ESPHome-apparaat; klik op Toevoegen. Niet ontdekt? Voeg ESPHome handmatig toe met het IP-adres van het scherm. `],
["Plak de API-sleutel", " hierboven zodra Home Assistant om een encryptiesleutel vraagt."],
["Sta HA-acties toe:", " ESPHome-integratie → Configureren → “Allow the device to perform Home Assistant actions”. Zonder dit ziet het scherm alles, maar bedient het niets."],
["Kies je tegels.", " Terug in ESP Screens verschijnt het scherm binnen een halve minuut in de lijst links; tot die tijd staat het daar als “nog niet in Home Assistant”."],
];
$("#install-steps").replaceChildren(...steps.map(([b, t], i) => {
const li = node("li");
li.append(node("b", b), t);
if (i === 0) {
const go = node("button", "Open Apparaten & diensten", "mini");
go.type = "button";
go.onclick = openIntegrations;
li.append(go);
}
return li;
}));
}
function resetInstaller() {
Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, ports: null, jobState: null, target: "" });
const form = $("#install-form");
form.reset();
$("#node-label").hidden = true;
$("#install-setup").hidden = false;
$("#install-progress").hidden = true;
$("#install-result").hidden = true;
$("#install-log-wrap").open = false;
$("#install-log").textContent = "";
$("#install-title").textContent = "Aansluiten en installeren.";
$("#install-status").textContent = "";
$("#install-note").textContent = "";
$("#install-target").replaceChildren();
$("#target-hint").textContent = "";
$("#install-close").classList.add("quiet");
syncNode();
}
function openInstaller() {
if (installer.view !== "progress") resetInstaller();
$("#installer").showModal();
checkWifiSecrets();
installerRefresh();
clearInterval(installer.poll);
installer.poll = setInterval(installerRefresh, 3000);
}
$("#new-screen").onclick = $("#start").onclick = openInstaller;
$("#close-install").onclick = $("#install-close").onclick = () => $("#installer").close();
$("#installer").addEventListener("close", () => {
clearInterval(installer.poll);
// A finished job is shown once; the next open starts a fresh form.
if (installer.view === "progress" && installer.jobState !== "running") installer.view = "done";
});
$("#install-target").onchange = () => { renderTargetHint(); installerRefresh(); };
$("#install-form").elements.friendly_name.oninput = syncNode;
$("#install-form").elements.name.oninput = () => { installer.nodeEdited = true; syncNode(); };
$("#edit-node").onclick = () => {
installer.nodeEdited = true;
$("#node-label").hidden = false;
$("#install-form").elements.name.focus();
};
$("#close-install").onclick = () => $("#installer").close();
let generatedName = "";
$("#install-form").onsubmit = async (e) => {
e.preventDefault();
$("#download").disabled = true;
const form = e.target;
syncNode();
if (!form.reportValidity()) return;
const data = Object.fromEntries(new FormData(form));
$("#install-go").disabled = true;
$("#install-status").textContent = "";
try {
const data = Object.fromEntries(new FormData(e.target));
const response = await api("install", {
method: "POST",
body: JSON.stringify(data),
});
$("#yaml-output").value = await response.text();
generatedName = data.name;
$("#yaml-result").hidden = false;
$("#install-status").textContent =
"Je YAML is klaar. Kopieer hem naar ESPHome en bewaar het bestand met sleutels.";
} catch (e) {
$("#install-status").textContent = e.message;
const result = await (await api("firmware/profiles", { method: "POST", body: JSON.stringify(data) })).json();
Object.assign(installer, { file: result.file, apiKey: result.api_key, friendly: data.friendly_name.trim(), board: data.board, target: data.target });
form.elements.wifi_password.value = "";
if (result.job) showProgress(result.job, []);
else showSaved();
} catch (err) {
$("#install-status").textContent = err.message;
} finally {
$("#download").disabled = false;
$("#install-go").disabled = false;
}
};
$("#copy-yaml").onclick = async () => {
$("#install-retry").onclick = async () => {
try {
if (navigator.clipboard && window.isSecureContext)
await navigator.clipboard.writeText($("#yaml-output").value);
else {
$("#yaml-output").select();
if (!document.execCommand("copy")) throw new Error();
}
$("#install-status").textContent =
"Gekopieerd. Plak dit bij Edit in ESPHome Device Builder.";
} catch {
$("#yaml-output").select();
$("#install-status").textContent =
"De tekst is geselecteerd. Kopieer met Ctrl+C of Command+C.";
const { ports } = await (await api("firmware")).json();
// The board may have been replugged; a single visible port is unambiguous.
if (!ports.includes(installer.target) && ports.length === 1) installer.target = ports[0];
const job = await (await api("firmware/jobs", {
method: "POST",
body: JSON.stringify({ file: installer.file, action: "install", target: installer.target }),
})).json();
showProgress(job, []);
} catch (err) {
toast(err.message);
}
};
$("#download-yaml").onclick = () => {
const url = URL.createObjectURL(
new Blob([$("#yaml-output").value], { type: "text/plain" }),
);
const a = node("a");
a.href = url;
a.download = `${generatedName}.yaml`;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
$("#install-status").textContent =
"Download gestart. Blokkeert je browser deze? Gebruik Kopieer YAML.";
};
$("#copy-key").onclick = () => copyText(installer.apiKey || "", $("#api-key"));
window.addEventListener("beforeunload", (e) => {
if (dirty) {
e.preventDefault();
@@ -1184,26 +1404,6 @@ for (const action of ["validate", "build", "install"])
toast(e.message);
}
};
$("#create-profile").onclick = async () => {
const form = $("#install-form");
if (!form.reportValidity()) return;
try {
const data = await (
await api("firmware/profiles", {
method: "POST",
body: JSON.stringify(Object.fromEntries(new FormData(form))),
})
).json();
generatedName = data.file.replace(/\.yaml$/, " ").trim();
$("#yaml-output").value = data.yaml;
$("#yaml-result").hidden = false;
$("#install-status").textContent =
`${data.file} bewaard. Open Firmware & USB om te installeren.`;
form.elements.wifi_password.value = "";
} catch (e) {
toast(e.message);
}
};
function openSection(id) {
const section = $(id);
if (section.tagName === "DETAILS") section.open = true;
+112 -109
View File
@@ -20,7 +20,7 @@
<button id="new-screen" class="tool tool-primary">
<span class="tool-icon" aria-hidden="true">+</span>
<span class="tool-text"
><b>Nieuw scherm</b><small>Koppel een CYD of Guition</small></span
><b>Nieuw scherm</b><small>Aansluiten en installeren</small></span
>
</button>
<button id="open-firmware" class="tool">
@@ -47,9 +47,10 @@
</div>
<ol class="howto" aria-label="Zo werkt het">
<li>
<b>1 · Koppelen</b><span
>Kies <b>Nieuw scherm</b> voor de installatie-YAML met eigen
sleutels. Eén keer via USB flashen, daarna gaat alles draadloos.</span
<b>1 · Installeren</b><span
>Sluit het scherm via USB aan op de Home Assistant-machine en kies
<b>Nieuw scherm</b>: naam, wifi en klaar. Eén keer via USB, daarna
gaat alles draadloos.</span
>
</li>
<li>
@@ -61,7 +62,8 @@
<li>
<b>3 · Opslaan</b><span
>Met <b>Opslaan & naar scherm</b> staat het direct op je scherm.
Nieuwe schermfuncties installeer je via <b>Firmware & USB</b>.</span
Nieuwe schermfuncties komen met <b>Bijwerken</b> draadloos op je
scherm.</span
>
</li>
</ol>
@@ -69,9 +71,10 @@
<aside>
<h2>Mijn schermen</h2>
<div id="screens"></div>
<div id="pending"></div>
<p class="hint">
Nieuw scherm gekoppeld? Het verschijnt hier automatisch binnen 30
seconden.
Nieuw scherm gekoppeld in Home Assistant? Het verschijnt hier
automatisch binnen 30 seconden.
</p>
<button class="quiet" id="refresh">↻ Vernieuwen</button>
<div class="updates" id="updates" hidden>
@@ -93,8 +96,8 @@
<span class="empty-icon">▦</span>
<h2>Begin met een scherm</h2>
<p>
Sluit een CYD of Guition aan. We maken de installatie-YAML voor je
en helpen je met de eerste flash.
Sluit een CYD of Guition via USB aan op je Home Assistant-machine.
Profiel, firmware en de eerste flash gebeuren in één venster.
</p>
<button id="start">Nieuw scherm installeren</button>
</section>
@@ -184,8 +187,8 @@
</section>
</div>
<p class="firmware-link">
Firmware bouwen en via USB of wifi installeren kan via
<b>Firmware & USB</b> bovenaan. Je bestaande YAML en secrets blijven
Een bestaand profiel opnieuw bouwen of via USB of wifi installeren kan
via <b>Firmware & USB</b> bovenaan. Je eigen YAML en secrets blijven
bewaard.
</p>
<details class="help" id="help">
@@ -206,11 +209,13 @@
tegel op <b>Dubbelbreed</b> voor meer ruimte.
</p>
<p>
Nieuwe firmware bouw en installeer je via <b>Firmware & USB</b> met
de ingebouwde ESPHome CLI: kies je eigen profiel, dan een USB-poort of
wifi, en klik op <b>Bouwen & installeren</b>. Je tegels blijven in
deze app bewaard; een offline scherm krijgt de wijzigingen zodra het
terug is.
Een nieuw scherm installeer je via <b>Nieuw scherm</b>: het profiel
met eigen sleutels, ontbrekende wifi in ESPHome secrets.yaml, de build
en de USB-flash gebeuren in één venster, met de API-sleutel voor de
koppeling aan het eind. Nieuwe firmware voor bestaande schermen komt
via <b>Bijwerken</b> in de lijst, of handmatig via
<b>Firmware & USB</b>. Je tegels blijven in deze app bewaard; een
offline scherm krijgt de wijzigingen zodra het terug is.
</p>
</details>
</main>
@@ -219,7 +224,7 @@
<div class="dialog-head">
<div>
<p class="eyebrow">NIEUW SCHERM</p>
<h2>Van USB naar aan de muur.</h2>
<h2 id="install-title">Aansluiten en installeren.</h2>
</div>
<button
type="button"
@@ -230,104 +235,102 @@
✕
</button>
</div>
<p>
Kies je scherm. Elk bestand krijgt eigen sleutels voor een veilige
koppeling en draadloze updates.
</p>
<fieldset>
<legend>Welk scherm heb je?</legend>
<label class="board"
><input type="radio" name="board" value="cyd" checked /><span
><b>CYD · 2,8 inch</b
><small>ESP32-2432S028 · 320 × 240</small></span
></label
><label class="board"
><input type="radio" name="board" value="guition" /><span
><b>Guition · 4 inch</b
><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span
<div id="install-setup">
<p>
Sluit het scherm met een USB-datakabel aan op de machine waarop Home
Assistant draait. ESP Screens maakt een eigen profiel met unieke
sleutels, bouwt de firmware en zet die direct op het scherm.
</p>
<fieldset>
<legend>Welk scherm heb je?</legend>
<label class="board"
><input type="radio" name="board" value="cyd" checked /><span
><b>CYD · 2,8 inch</b
><small>ESP32-2432S028 · 320 × 240</small></span
></label
><label class="board"
><input type="radio" name="board" value="guition" /><span
><b>Guition · 4 inch</b
><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span
></label
>
</fieldset>
<label
>Naam<input
name="friendly_name"
required
maxlength="60"
placeholder="Scherm woonkamer"
autocomplete="off" /><small class="node-line"
>Apparaatnaam <code id="node-preview">scherm</code
><button type="button" class="link" id="edit-node">
aanpassen
</button></small
><small
>Elk scherm krijgt een eigen profiel met eigen sleutels. Vier
schermen? Doorloop dit vier keer met een andere naam.</small
></label
>
</fieldset>
<label
>Herkenbare naam<input
name="friendly_name"
required
maxlength="60"
placeholder="Scherm woonkamer" /></label
><label
>Unieke apparaatnaam<input
name="name"
required
pattern="[a-z][a-z0-9-]{0,29}"
maxlength="30"
placeholder="scherm-woonkamer"
/><small
>Kleine letters, cijfers en streepjes. Elk scherm een andere
naam.</small
></label>
<p id="wifi-status" role="status"></p>
<fieldset id="wifi-fields" hidden disabled>
<legend>Wifi voor je eerste ESPHome-apparaat</legend>
<label
>Wifi-naam<input
name="wifi_ssid"
autocomplete="off"
/></label>
<label
>Wifiwachtwoord<input
name="wifi_password"
type="password"
autocomplete="new-password"
/></label>
</fieldset>
<button type="button" id="create-profile">
Bewaar profiel in ESP Screens
</button>
<button type="submit" id="download">Maak installatie-YAML</button>
<p id="install-status" role="status"></p>
<div id="yaml-result" hidden>
<label id="node-label" hidden
>Apparaatnaam<input
name="name"
pattern="[a-z][a-z0-9-]{0,29}"
maxlength="30"
autocomplete="off" /><small
>Kleine letters, cijfers en streepjes. Elk scherm een andere
naam.</small
></label
>
<fieldset id="wifi-fields" class="wifi" hidden disabled>
<legend>Wifi</legend>
<p class="hint" id="wifi-status"></p>
<label id="wifi-ssid-label"
>Wifi-naam (2,4 GHz)<input name="wifi_ssid" autocomplete="off"
/></label>
<label id="wifi-password-label"
>Wifiwachtwoord<input
name="wifi_password"
type="password"
autocomplete="new-password"
/></label>
</fieldset>
<label
>Je eigen ESPHome-YAML<textarea
id="yaml-output"
readonly
rows="9"
spellcheck="false"
></textarea>
</label>
<div class="yaml-actions">
<button type="button" id="copy-yaml">Kopieer YAML</button
><button type="button" class="quiet" id="download-yaml">
Download bestand
>Installeren via<select name="target" id="install-target"></select
><small id="target-hint"></small
></label>
<p class="hint" id="install-note"></p>
<div class="install-actions">
<button type="submit" id="install-go">Installeren</button>
<span id="install-status" role="status"></span>
</div>
</div>
<div id="install-progress" hidden>
<div class="progress-head">
<span class="spin" id="progress-spin"></span>
<span class="outcome" id="progress-mark" hidden></span>
<strong id="progress-title"></strong>
</div>
<p id="progress-detail"></p>
<div id="install-result" hidden>
<div class="key-box">
<span>API-sleutel</span><code id="api-key"></code>
<button type="button" class="mini" id="copy-key">Kopieer</button>
</div>
<ol class="steps" id="install-steps"></ol>
</div>
<details id="install-log-wrap">
<summary>Log van ESPHome</summary>
<pre id="install-log"></pre>
</details>
<div class="install-actions">
<button type="button" id="install-retry" hidden>
Opnieuw proberen
</button>
<button type="button" class="quiet" id="install-close">
Sluiten
</button>
</div>
</div>
<ol class="steps">
<li>
<b>Bewaar je profiel.</b> Bestaande wifi-secrets worden behouden;
bij een eerste installatie vul je wifi hierboven in.
</li>
<li>
<b>Open Firmware & USB.</b> Kies dit profiel, sluit het scherm op je
Raspberry aan en selecteer de juiste USB-poort. Kies Bouwen &
installeren.
</li>
<li>
<b>Koppel in HA → Apparaten & diensten → ESPHome.</b> Gebruik de
API-sleutel uit je eigen YAML en sta HA-acties toe.
</li>
<li>
<b>Kies hier je tegels.</b> CYD vraagt bij de eerste start om
kalibratie; Guition niet.
</li>
<li>
<b>Later updaten?</b> Kies hetzelfde profiel en het IP-adres van je
scherm in Firmware & USB.
</li>
</ol>
<p class="hint">
Bewaar je YAML met sleutels. Gebruik later Firmware & USB met het
IP-adres voor draadloze updates. Tegels wijzigen doe je hier.
</p>
</form>
</dialog>
<dialog id="firmware-dialog">
+142
View File
@@ -989,3 +989,145 @@ pre {
.icon-choice.icon-auto .mdi { font-size: 22px; }
.icon-picker .hint { margin: 0; }
.icon-hint { font-weight: 400; }
/* Nieuw scherm: profiel, wifi en de eerste flash in één venster. */
.link {
background: none;
color: var(--accent);
padding: 0 0 0 8px;
font-weight: 600;
font-size: 12px;
}
.node-line code {
background: var(--soft);
padding: 1px 6px;
border-radius: 6px;
font-size: 12px;
}
#installer .wifi {
grid-template-columns: 1fr;
gap: 0;
margin: 0 0 16px;
padding: 12px 16px 2px;
border: 1px solid #f4e3c2;
background: #fff9ee;
border-radius: 12px;
}
#installer .wifi legend {
padding: 0 4px;
margin-bottom: 2px;
}
#installer .wifi .hint {
margin: 0 0 12px;
}
#installer select {
margin-top: 7px;
padding: 11px 12px;
border-radius: 10px;
border-color: #cdd5df;
}
.install-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 18px;
}
#install-status {
color: #b23b3b;
font-size: 13px;
}
#install-note:empty {
display: none;
}
.progress-head {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
margin-top: 6px;
}
.progress-head .spin {
width: 17px;
height: 17px;
border-width: 3px;
}
.outcome {
font-size: 22px;
line-height: 1;
font-weight: 700;
}
.outcome.ok {
color: #21704b;
}
.outcome.bad {
color: #b23b3b;
}
.key-box {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #f7f9fc;
margin: 4px 0 16px;
}
.key-box span {
flex: none;
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.key-box code {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
font-size: 12px;
}
#install-log-wrap {
margin-top: 12px;
}
#install-log-wrap summary {
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
#install-log {
max-height: 240px;
margin: 10px 0 0;
}
/* Profiles that Home Assistant does not list yet: pairing happens outside this page. */
#pending:empty {
display: none;
}
.pending {
border: 1px dashed #b9cbec;
border-radius: 14px;
padding: 12px 14px;
margin-bottom: 10px;
background: #f8fbff;
}
.pending strong {
display: block;
font-size: 14px;
}
.pending small {
margin: 3px 0 10px;
line-height: 1.4;
}
.pending-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pending-actions .quiet {
padding: 5px 9px;
font-size: 12px;
}
.steps .mini {
display: inline-block;
margin-top: 6px;
}
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.26"
version: "0.2.27"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen
+17 -2
View File
@@ -154,9 +154,24 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status,400)
response=await client.put('/api/screens/text.screen',headers=headers,json={'title':'Test','tiles':[]})
self.assertEqual(response.status,200)
response=await client.post('/api/install',headers=headers,json={'board':'guition','name':'screen-new','friendly_name':'Nieuw'})
from firmware import Firmware
m.firmware=Firmware(Path(temp)/'esphome',Path(temp))
profile={'board':'guition','name':'screen-new','friendly_name':'Nieuw','wifi_ssid':'net','wifi_password':'pw'}
# A refused USB target leaves no profile behind.
response=await client.post('/api/firmware/profiles',headers=headers,json={**profile,'target':'/dev/ttyUSB9'})
self.assertEqual(response.status,400)
self.assertFalse((Path(temp)/'esphome'/'screen-new.yaml').exists())
response=await client.post('/api/firmware/profiles',headers=headers,json=profile)
self.assertEqual(response.status,200)
self.assertEqual(response.headers['Cache-Control'],'no-store')
self.assertIn('packages/guition.yaml',await response.text())
result=await response.json()
self.assertEqual(result['file'],'screen-new.yaml')
self.assertEqual(len(base64.b64decode(result['api_key'])),32)
self.assertNotIn('job',result)
self.assertIn('packages/guition.yaml',(Path(temp)/'esphome'/'screen-new.yaml').read_text())
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.
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)])
if __name__=='__main__':unittest.main()
+7 -4
View File
@@ -1,6 +1,6 @@
"""Weather card data (hours, rain) and last-run times for scenes, scripts and buttons."""
import asyncio
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
import sys
import tempfile
@@ -16,8 +16,10 @@ TZ = ZoneInfo('Europe/Amsterdam')
NOW = datetime(2026, 9, 13, 11, 40, tzinfo=timezone.utc)
def hourly():
return [{'datetime': f'2026-09-13T{h:02d}:00:00+00:00', 'condition': 'rainy' if h < 12 else 'partlycloudy', 'temperature': 18 + h / 10,
def hourly(day=None):
# Whole hours from 08:00 on the given day (default: the fixed NOW day) into the next morning.
start = (day or NOW).replace(hour=0, minute=0, second=0, microsecond=0)
return [{'datetime': (start + timedelta(hours=h)).isoformat(), 'condition': 'rainy' if h < 12 else 'partlycloudy', 'temperature': 18 + h / 10,
'precipitation': 0.2 if h < 12 else 0.0, 'precipitation_probability': 60 if h < 12 else 5} for h in range(8, 30)]
@@ -70,7 +72,8 @@ class HourlySync(unittest.IsolatedAsyncioTestCase):
asked = []
async def forecast(entity, kind='daily'):
asked.append(kind)
return daily() if kind == 'daily' else hourly()
# 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))
m.ha.forecast = forecast
m.ha.registry.append({'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware'})
m.ha.states['sensor.fw'] = {'state': '0.2.19'}
+5 -3
View File
@@ -61,8 +61,10 @@ class ProfileNameTests(unittest.TestCase):
'packages:\n a: !include other.yaml\napi:\n encryption:\n key: !secret api\n')
(Path(tmp) / 'broken.yaml').write_text('esphome: [\n')
names = f.profile_names()
self.assertEqual(names['woonkamer.yaml'], {'node': 'woonkamer', 'friendly': 'Woonkamer'})
self.assertEqual(names['manual.yaml'], {'node': 'keuken', 'friendly': 'Keuken'})
self.assertEqual(names['woonkamer.yaml'], {'node': 'woonkamer', 'friendly': 'Woonkamer', 'screen': True, 'api_key': names['woonkamer.yaml']['api_key']})
self.assertEqual(len(names['woonkamer.yaml']['api_key']), 44)
# 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.assertNotIn('broken.yaml', names)
self.assertNotIn('secrets.yaml', names)
@@ -84,7 +86,7 @@ class ProfileNameTests(unittest.TestCase):
os.utime(b, ns=(b.stat().st_atime_ns, b.stat().st_mtime_ns + 1_000_000))
names = f.profile_names()
self.assertEqual(len(parsed), 3, 'only the changed file is parsed')
self.assertEqual(names['b.yaml'], {'node': 'b2', 'friendly': 'B2'})
self.assertEqual(names['b.yaml'], {'node': 'b2', 'friendly': 'B2', 'screen': False, 'api_key': None})
a.unlink()
self.assertEqual(set(f.profile_names()), {'b.yaml'})
self.assertEqual(len(parsed), 3)
+44 -5
View File
@@ -3,6 +3,7 @@ import sys
import tempfile
import unittest
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
from firmware import Firmware
@@ -22,18 +23,56 @@ class WifiReuse(unittest.TestCase):
self.assertIn('password: !secret wifi_password', profile)
self.assertNotIn('example-private', profile)
def test_missing_keys_do_not_overwrite_existing_secrets(self):
def test_missing_keys_are_added_without_touching_other_secrets(self):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(tmp, tmp)
path = Path(tmp) / 'secrets.yaml'
path.write_text('other: keep\n')
self.assertEqual(f.wifi_status()['state'], 'missing')
original = '# keep comments\nother: keep\nwifi_ssid: ""\n'
path.write_text(original)
self.assertEqual(f.wifi_status(), {'state': 'missing', 'missing': ['wifi_password', 'wifi_ssid']})
# Without values nothing is written: no profile, secrets untouched.
with self.assertRaises(ValueError):
f.create({'board': 'cyd', 'name': 'screen', 'friendly_name': 'Screen'})
self.assertEqual(path.read_text(), original)
self.assertFalse((Path(tmp) / 'screen.yaml').exists())
f.create({'board': 'cyd', 'name': 'screen', 'friendly_name': 'Screen',
'wifi_ssid': 'home: net', 'wifi_password': 'p\\ss "word"'})
text = path.read_text()
self.assertTrue(text.startswith('# keep comments\nother: keep\n'))
self.assertEqual(text.count('wifi_ssid'), 1)
self.assertEqual(yaml.safe_load(text), {'other': 'keep', 'wifi_ssid': 'home: net', 'wifi_password': 'p\\ss "word"'})
self.assertEqual(f.wifi_status()['state'], 'ready')
self.assertTrue((Path(tmp) / 'screen.yaml').exists())
def test_invalid_secrets_are_never_rewritten(self):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(tmp, tmp)
path = Path(tmp) / 'secrets.yaml'
path.write_text('wifi_ssid: [invalid')
with self.assertRaises(ValueError):
f.create({'board': 'cyd', 'name': 'screen', 'friendly_name': 'Screen',
'wifi_ssid': 'replacement', 'wifi_password': 'replacement'})
self.assertEqual(path.read_text(), 'other: keep\n')
'wifi_ssid': 'net', 'wifi_password': 'pw'})
self.assertEqual(path.read_text(), 'wifi_ssid: [invalid')
self.assertFalse((Path(tmp) / 'screen.yaml').exists())
def test_install_checks_port_and_busy_slot_before_writing(self):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(tmp, tmp)
data = {'board': 'cyd', 'name': 'screen', 'friendly_name': 'Screen', 'wifi_ssid': 'net', 'wifi_password': 'pw'}
with self.assertRaises(ValueError):
f.install({**data, 'target': '/dev/ttyUSB0'})
self.assertFalse((Path(tmp) / 'screen.yaml').exists())
self.assertFalse((Path(tmp) / 'secrets.yaml').exists())
result = f.install(data)
self.assertEqual(result['file'], 'screen.yaml')
self.assertEqual(len(result['api_key']), 44)
self.assertNotIn('job', result)
self.assertIn(result['api_key'], f.profile('screen.yaml').read_text())
meta = f.profile_names()['screen.yaml']
self.assertEqual((meta['screen'], meta['api_key']), (True, result['api_key']))
(Path(tmp) / 'other.yaml').write_text('esphome:\n name: other\napi:\n encryption:\n key: !secret k\n')
self.assertEqual((f.profile_names()['other.yaml']['screen'], f.profile_names()['other.yaml']['api_key']), (False, None))
def test_first_install_and_invalid_file(self):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(tmp, tmp)