One-click and nightly firmware updates from ESP Screens

Screens now report their ESPHome name and IP address, so the add-on can
match each screen to its YAML profile and OTA address itself. The screen
list shows an update badge with a Bijwerken button, an update-all action
and a nightly checkbox; updates run one screen at a time, wait for the
new version to report back and stop the round on the first failure.

App 0.2.16, firmware 0.2.17.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 10:14:04 +02:00
parent 437b8c55b4
commit 437e3a5046
18 changed files with 875 additions and 23 deletions
+9 -1
View File
@@ -136,7 +136,15 @@ blijven afzonderlijk zichtbaar.
| --- | --- | | --- | --- |
| Tegels, namen, kleuren, volgorde of scherminstellingen | Opslaan in ESP Screens; geen firmwareflash | | Tegels, namen, kleuren, volgorde of scherminstellingen | Opslaan in ESP Screens; geen firmwareflash |
| Nieuwe versie van de beheerpagina | ESP Screen Manager updaten in de HA-appwinkel | | Nieuwe versie van de beheerpagina | ESP Screen Manager updaten in de HA-appwinkel |
| Nieuwe functie op het fysieke scherm | Bestaand profiel bijwerken via Firmware & USB → Wifi / OTA | | Nieuwe functie op het fysieke scherm | Knop **Bijwerken** bij het scherm (badge *Update x.y.z*), of het vinkje **Elke nacht automatisch bijwerken** |
Elke appversie hoort bij één firmwareversie. Na een app-update toont de lijst
per scherm of er nieuwere firmware is. **Bijwerken** bouwt het eigen profiel
met de ingebouwde CLI, installeert draadloos en wacht tot het scherm terug is.
Met het vinkje gebeurt dat 's nachts vanzelf, één scherm tegelijk; een
mislukking stopt de ronde en geeft een melding in Home Assistant. Firmware
0.2.17+ meldt daarvoor zelf apparaatnaam en IP-adres; een ouder scherm vraagt
eenmalig het IP-adres. Handmatig kan nog steeds via Firmware & USB → Wifi / OTA.
De eigen YAML en wifi/API/OTA-instellingen blijven in de ESPHome-configmap. De eigen YAML en wifi/API/OTA-instellingen blijven in de ESPHome-configmap.
Tegelindelingen en opties staan in de permanente appdata. CYD-kalibratie en Tegelindelingen en opties staan in de permanente appdata. CYD-kalibratie en
+1 -1
View File
@@ -137,7 +137,7 @@ zodra het scherm terugkomt. De app moet blijven draaien voor actuele tegeldata.
| --- | --- | --- | | --- | --- | --- |
| Andere entiteiten, namen of volgorde | Opslaan in ESP Screens | Wifi, sleutels, kalibratie | | Andere entiteiten, namen of volgorde | Opslaan in ESP Screens | Wifi, sleutels, kalibratie |
| Nieuwe beheerpagina/appversie | Appwinkel → ESP Screen Manager → Update | Alle indelingen in `/data/screens.json` | | Nieuwe beheerpagina/appversie | Appwinkel → ESP Screen Manager → Update | Alle indelingen in `/data/screens.json` |
| Nieuwe schermfunctie/kaart | Firmware & USB → bestaand profiel → Wifi / OTA | Eigen YAML, sleutels en CYD-kalibratie; app stuurt tegels opnieuw | | 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 |
De eigen YAML verwijst naar de firmwarepakketten in `main`. Bij een nieuwe build De eigen YAML verwijst naar de firmwarepakketten in `main`. Bij een nieuwe build
haalt ESPHome de nieuwste gepubliceerde pakket- en componentcode op. Je vervangt haalt ESPHome de nieuwste gepubliceerde pakket- en componentcode op. Je vervangt
+20
View File
@@ -140,6 +140,26 @@ pressed-feedback op de PRESSED-state blijven gelijk. De analoge klok wijzigt
alleen de tekening (streepjes, cijfers, kalenderblok op enkele tegels); er is alleen de tekening (streepjes, cijfers, kalenderblok op enkele tegels); er is
geen nieuw veld voor nodig. Geen gewijzigde preferences of sleutels. geen nieuw veld voor nodig. Geen gewijzigde preferences of sleutels.
### Compatibiliteit 0.2.16 / firmware 0.2.17
`FIRMWARE_VERSION` in `screen_manager/app/core.py` is de firmware die bij deze
app hoort; `tests/test_updates.py` eist dat hij gelijk is aan
`SCREEN_FIRMWARE_VERSION` in beide bordprofielen en pakketten. Verhoog ze samen.
Een scherm met een lagere `Schermfirmware` krijgt een update-aanbod; een build
haalt `main`, dus publiceer firmware en app in dezelfde commit.
Firmware 0.2.17 voegt de diagnostische text sensors `Apparaatnaam`
(`${DEVICE_NAME}`) en `IP-adres` (`wifi_info`) toe. De app koppelt een scherm
via `Apparaatnaam` aan het profiel met dezelfde `esphome.name`; oudere firmware
valt terug op één profiel met gelijke `friendly_name` en een handmatig ingevuld
adres. Hernoem deze sensors niet zonder `core.discover` aan te passen.
Updatestatus staat in `/data/updates.json` (`version: 1`: `auto`, `hosts`,
`results`, `last_round`), los van `screens.json`. Onbekende versies worden
geweigerd. De ronde draait één scherm tegelijk, wacht op `Schermfirmware >=`
doelversie en één minuut stabiliteit, en stopt bij de eerste fout. Alleen de
nachtelijke ronde schrijft een `persistent_notification` in HA.
### Compatibiliteit 0.2.12 / firmware 0.2.14 ### Compatibiliteit 0.2.12 / firmware 0.2.14
Opslagversie en tegelprotocol blijven 1. Additief: `tiles[].options.size` Opslagversie en tegelprotocol blijven 1. Additief: `tiles[].options.size`
+54
View File
@@ -0,0 +1,54 @@
# App 0.2.16 / firmware 0.2.17 — bijwerken met één knop en nachtelijke ronde
## Wijzigingen
- **Firmware**: twee diagnostische text sensors per scherm, `Apparaatnaam`
(`${DEVICE_NAME}`, elke 60 s) en `IP-adres` (`wifi_info`). Verder ongewijzigd;
geen preferences, protocol of layoutcode aangeraakt.
- **App** (`screen_manager/app/updates.py`): `Updater` met `FIRMWARE_VERSION`
als doelversie, profielkoppeling via `Apparaatnaam` → `esphome.name` in de
ESPHome-map (`Firmware.profile_names`, leest YAML zonder `!secret` uit te
voeren), terugval op één profiel met dezelfde `friendly_name` plus een
handmatig adres. Eén ronde tegelijk: `install` (bestaande `Firmware.start`),
wachten op `Schermfirmware >= doel` (max 240 s), 60 s stabiliteit, 120 s pauze
tot het volgende scherm; de eerste fout beëindigt de ronde. Nachtelijke ronde
tussen 03:00 en 06:00 in de HA-tijdzone, één keer per dag, alleen met het
vinkje aan; bij een fout een `persistent_notification` in HA.
- **UI**: schermkaart is nu een `div` met een selectieknop en een updaterij
(badge *Update x.y.z* + **Bijwerken**, spinner met fase, resultaat een dag
zichtbaar). Blok *Firmware-updates* met **Alle schermen bijwerken** (zichtbaar
bij twee of meer bij te werken schermen) en het vinkje voor de nachtronde.
Een open IP-formulier overleeft de periodieke verversing; polling versnelt naar
3 s zolang er een update loopt.
- Opslag `/data/updates.json` (`version: 1`), naast `screens.json`.
## Tests
- `.venv-portal/bin/python -m unittest discover -s tests`: 62 tests OK, waaronder
de nieuwe `tests/test_updates.py` (versiebron gelijk in app en beide pakketten,
discovery van naam en adres, profielen lezen zonder secrets, aanbod en
handmatig adres, ronde stopt na fout en meldt alleen automatisch, scherm dat
niet terugkomt faalt, nachtvenster en persistentie, HTTP-endpoints met CSRF).
- `python3 tools/generate_packages.py --check`: beide pakketten actueel.
- `esphome compile` voor `easy-cyd-device.yaml` en `easy-guition-device.yaml`
(lokale pakketten met dezelfde inhoud als `packages/*.yaml`): geslaagd.
- USB-flash na `esptool chip_id` per poort (`usbserial-130` = ESP32/CYD,
`usbserial-210` = ESP32-S3/Guition). Uploadlog toont
`.esphome/build/cyd-2432s028/…/firmware.bin` resp.
`.esphome/build/guition-wallbox/…/firmware.bin`.
- `device_info` via de API: CYD `cyd-2432s028` project_version 0.2.17,
Guition `guition-wallbox` project_version 0.2.17, beide gecompileerd
2026-09-13 10:03. Beide melden `Schermfirmware=0.2.17`, `Apparaatnaam` gelijk
aan de profielnaam en `IP-adres` 192.168.146.134 resp. 192.168.146.136.
- Lokale preview van de beheerpagina met een nep-HA en een trage nep-CLI:
badge en knop bij een scherm op 0.2.16, spinner met "Bouwen en installeren…",
daarna "Bijgewerkt naar firmware 0.2.17." bij het scherm; scherm zonder
gemeld IP vraagt eenmalig het adres en start daarna; scherm op 0.2.17 toont
geen aanbod; het vinkje bewaart `auto` via `PUT /api/updates`.
## Nog te doen in productie
- Add-on bijwerken vanuit de HA-appwinkel (0.2.16) en beide schermen (nu al
0.2.17 via USB) moeten *geen* aanbod tonen. De eerste echte OTA-ronde volgt
bij de volgende firmwareversie; controleer dan het log onder Firmware & USB,
de spinnerfases en de melding bij een geforceerde fout.
+11 -1
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. # Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile. # Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.16" SCREEN_FIRMWARE_VERSION: "0.2.17"
DYNAMIC_TILES: "false" DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true" DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "guition-wallbox" DEVICE_NAME: "guition-wallbox"
@@ -1464,6 +1464,16 @@ text_sensor:
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};'
update_interval: 60s update_interval: 60s
# ESP Screen Manager matches the screen to its YAML profile and OTA address.
- platform: template
name: "Apparaatnaam"
entity_category: diagnostic
lambda: 'return {"${DEVICE_NAME}"};'
update_interval: 60s
- platform: wifi_info
ip_address:
name: "IP-adres"
entity_category: diagnostic
- platform: template - platform: template
id: smartdisplay_action id: smartdisplay_action
name: "SmartDisplay Action" name: "SmartDisplay Action"
+11 -1
View File
@@ -58,7 +58,7 @@
############################################################ ############################################################
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.16" SCREEN_FIRMWARE_VERSION: "0.2.17"
DYNAMIC_TILES: "false" DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true" DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "cyd-new" DEVICE_NAME: "cyd-new"
@@ -1649,6 +1649,16 @@ text_sensor:
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};'
update_interval: 60s update_interval: 60s
# ESP Screen Manager matches the screen to its YAML profile and OTA address.
- platform: template
name: "Apparaatnaam"
entity_category: diagnostic
lambda: 'return {"${DEVICE_NAME}"};'
update_interval: 60s
- platform: wifi_info
ip_address:
name: "IP-adres"
entity_category: diagnostic
- platform: template - platform: template
id: smartdisplay_action id: smartdisplay_action
name: "SmartDisplay Action" name: "SmartDisplay Action"
+11 -1
View File
@@ -59,7 +59,7 @@
############################################################ ############################################################
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.16" SCREEN_FIRMWARE_VERSION: "0.2.17"
DYNAMIC_TILES: "true" DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false" DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "cyd-new" DEVICE_NAME: "cyd-new"
@@ -1602,6 +1602,16 @@ text_sensor:
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};'
update_interval: 60s update_interval: 60s
# ESP Screen Manager matches the screen to its YAML profile and OTA address.
- platform: template
name: "Apparaatnaam"
entity_category: diagnostic
lambda: 'return {"${DEVICE_NAME}"};'
update_interval: 60s
- platform: wifi_info
ip_address:
name: "IP-adres"
entity_category: diagnostic
- platform: template - platform: template
id: smartdisplay_action id: smartdisplay_action
name: "SmartDisplay Action" name: "SmartDisplay Action"
+11 -1
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. # Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile. # Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.16" SCREEN_FIRMWARE_VERSION: "0.2.17"
DYNAMIC_TILES: "true" DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false" DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "guition-new" DEVICE_NAME: "guition-new"
@@ -1417,6 +1417,16 @@ text_sensor:
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};'
update_interval: 60s update_interval: 60s
# ESP Screen Manager matches the screen to its YAML profile and OTA address.
- platform: template
name: "Apparaatnaam"
entity_category: diagnostic
lambda: 'return {"${DEVICE_NAME}"};'
update_interval: 60s
- platform: wifi_info
ip_address:
name: "IP-adres"
entity_category: diagnostic
- platform: template - platform: template
id: smartdisplay_action id: smartdisplay_action
name: "SmartDisplay Action" name: "SmartDisplay Action"
+8
View File
@@ -1,3 +1,11 @@
## 0.2.16 (firmware 0.2.17)
- **Bijwerken met één knop**: een scherm met oudere firmware krijgt in de lijst een badge *Update 0.2.17* en een knop **Bijwerken**. De app bouwt het eigen profiel, installeert draadloos en toont een spinner tot het scherm terug is met de nieuwe versie en een minuut stabiel blijft. Het resultaat blijft een dag zichtbaar bij het scherm.
- **Elke nacht automatisch bijwerken**: vinkje onder *Firmware-updates*. Tussen 03:00 en 06:00 (tijdzone van HA) werkt de app schermen met oudere firmware één voor één bij, met twee minuten pauze ertussen. Mislukt een scherm, dan stopt de ronde en verschijnt een melding in Home Assistant; de overige schermen blijven op hun oude firmware.
- **Alle schermen bijwerken** doet dezelfde ronde direct, bijvoorbeeld na een app-update.
- Nieuwe firmware is er zodra deze app een nieuwe versie heeft: de app kent de bijbehorende firmwareversie en vergelijkt die met `Schermfirmware` per scherm.
- Firmware 0.2.17 meldt twee diagnostische sensors extra: **Apparaatnaam** (de ESPHome-naam, gelijk aan het YAML-profiel) en **IP-adres**. Daarmee vindt de app zelf het profiel en het OTA-adres. Een scherm met oudere firmware wordt op apparaatnaam aan een profiel gekoppeld en vraagt eenmalig het IP-adres.
## 0.2.15 (firmware 0.2.16) ## 0.2.15 (firmware 0.2.16)
- **Achtergrond: Geen** als extra keuze in het tegelpalet: de kaart en de rand vallen weg en de tegelinhoud staat direct op de schermachtergrond, in dezelfde maat en op dezelfde plek als mét kaart. Werkt voor elke tegel; de mockup toont zo'n tegel met een stippellijn. Vereist schermfirmware 0.2.16; de app bewaart de indeling en meldt het als het scherm ouder is. - **Achtergrond: Geen** als extra keuze in het tegelpalet: de kaart en de rand vallen weg en de tegelinhoud staat direct op de schermachtergrond, in dezelfde maat en op dezelfde plek als mét kaart. Werkt voor elke tegel; de mockup toont zo'n tegel met een stippellijn. Vereist schermfirmware 0.2.16; de app bewaart de indeling en meldt het als het scherm ouder is.
+14
View File
@@ -13,6 +13,8 @@ NEW_DOMAINS = frozenset('sun timer person screen'.split())
WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'] WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
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_VERSION = '0.2.17'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes 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 media_title options min max step temperature_unit supported_features next_rising next_setting finishes_at duration remaining'.split()) ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes 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 media_title options min max step temperature_unit supported_features next_rising next_setting finishes_at duration remaining'.split())
@@ -229,6 +231,16 @@ def discover(registry, states, devices, areas):
if item.get('platform') == 'esphome' and item.get('original_name') == 'Schermfirmware'} if item.get('platform') == 'esphome' and item.get('original_name') == 'Schermfirmware'}
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") == "Guition schermtype"}
def diagnostic(name, pattern):
found = {}
for item in registry:
if item.get('platform') == 'esphome' and item.get('original_name') == name:
value = states.get(item['entity_id'], {}).get('state', '')
if isinstance(value, str) and re.fullmatch(pattern, value):
found[item.get('device_id')] = value
return found
nodes = diagnostic('Apparaatnaam', r'[a-z0-9][a-z0-9-]{0,30}')
addresses = diagnostic('IP-adres', r'\d{1,3}(\.\d{1,3}){3}')
screens, entities = [], [] screens, entities = [], []
for item in registry: for item in registry:
eid = item['entity_id'] eid = item['entity_id']
@@ -239,6 +251,8 @@ def discover(registry, states, devices, areas):
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'), 'onbekend'),
'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')),
'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', 'Niet verbonden')})
if not entity_id(eid) or item.get('disabled_by'): if not entity_id(eid) or item.get('disabled_by'):
+23
View File
@@ -12,6 +12,10 @@ import time
import yaml import yaml
from core import installation_yaml from core import installation_yaml
class LenientLoader(yaml.SafeLoader):
"""Reads profile metadata without resolving !secret or !include."""
LenientLoader.add_multi_constructor('!', lambda loader, suffix, node: None)
class Firmware: class Firmware:
def __init__(self, root, data): def __init__(self, root, data):
self.root, self.data = Path(root).resolve(), Path(data) self.root, self.data = Path(root).resolve(), Path(data)
@@ -25,6 +29,25 @@ class Firmware:
return [{'file': p.name} for p in sorted(self.root.glob('*.yaml')) return [{'file': p.name} for p in sorted(self.root.glob('*.yaml'))
if p.name != 'secrets.yaml' and p.is_file() and not p.is_symlink()] if p.name != 'secrets.yaml' and p.is_file() and not p.is_symlink()]
def profile_names(self):
"""{file: {'node': esphome name, 'friendly': friendly name}} for every readable profile."""
found = {}
for entry in self.profiles():
try:
data = yaml.load((self.root / entry['file']).read_text(), Loader=LenientLoader)
except (OSError, yaml.YAMLError, UnicodeError):
continue
block = data.get('esphome') if isinstance(data, dict) else None
if not isinstance(block, dict):
continue
substitutions = data.get('substitutions') if isinstance(data.get('substitutions'), dict) else {}
def resolve(value):
if isinstance(value, str) and value.startswith('${') and value.endswith('}'):
value = substitutions.get(value[2:-1])
return value if isinstance(value, str) else None
found[entry['file']] = {'node': resolve(block.get('name')), 'friendly': resolve(block.get('friendly_name'))}
return found
def ports(self): def ports(self):
return sorted(set(glob.glob('/dev/serial/by-id/*') or glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*'))) return sorted(set(glob.glob('/dev/serial/by-id/*') or glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*')))
+22 -7
View File
@@ -1,4 +1,4 @@
"""HA Ingress app. The only HA write is text.set_value on discovered inboxes.""" """HA Ingress app. HA writes: text.set_value on discovered inboxes and one persistent notification when a nightly update stops."""
import asyncio import asyncio
import contextlib import contextlib
import json import json
@@ -10,6 +10,7 @@ import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import math import math
from firmware import Firmware from firmware import Firmware
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BUILTIN, TILE_BACKGROUNDS, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings from core import BUILTIN, TILE_BACKGROUNDS, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings
@@ -169,6 +170,7 @@ class Manager:
self.histories = {} self.histories = {}
self.forecasts = {} self.forecasts = {}
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent) self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
self.updates = Updater(self, self.path.parent / 'updates.json')
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.
@@ -356,11 +358,21 @@ def create_app(manager, development=False):
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': 'Thuis', 'tiles': []})
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels') screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels')
screen['update'] = manager.updates.state_for(screen)
builtin = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()] builtin = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities, 'backgrounds': TILE_BACKGROUNDS, 'builtin': builtin}) return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities,
'backgrounds': TILE_BACKGROUNDS, 'builtin': builtin, 'updates': manager.updates.summary()})
async def save(request): async def save(request):
manager.save(request.match_info['inbox'], await request.json()) manager.save(request.match_info['inbox'], await request.json())
return web.json_response({'saved': True}) return web.json_response({'saved': True})
async def update_screen(request):
data = await request.json() if request.can_read_body else {}
return web.json_response(manager.updates.start(request.match_info['inbox'], data.get('host')))
async def update_all(request):
return web.json_response({'started': manager.updates.start_all()})
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): async def download(request):
data = await request.json() data = await request.json()
content = installation_yaml(data) content = installation_yaml(data)
@@ -378,12 +390,15 @@ def create_app(manager, development=False):
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json())) 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): return web.json_response(manager.firmware.create(await request.json()))
async def shutdown(app): async def shutdown(app):
task=manager.firmware.task for task in (manager.updates.task, manager.firmware.task):
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
with contextlib.suppress(asyncio.CancelledError): await task with contextlib.suppress(asyncio.CancelledError): await task
app.on_cleanup.append(shutdown) app.on_cleanup.append(shutdown)
app.router.add_get('/api/screens/{inbox}/inspect', inspector) app.router.add_get('/api/screens/{inbox}/inspect', inspector)
app.router.add_post('/api/screens/{inbox}/update', update_screen)
app.router.add_post('/api/updates/run', update_all)
app.router.add_put('/api/updates', update_settings)
app.router.add_get('/api/firmware', firmware_status) app.router.add_get('/api/firmware', firmware_status)
app.router.add_post('/api/firmware/jobs', firmware_start) app.router.add_post('/api/firmware/jobs', firmware_start)
app.router.add_post('/api/firmware/profiles', firmware_create) app.router.add_post('/api/firmware/profiles', firmware_create)
@@ -408,7 +423,7 @@ async def main():
await runner.setup() await runner.setup()
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start() await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
try: try:
await asyncio.gather(ha.run(), manager.run()) await asyncio.gather(ha.run(), manager.run(), manager.updates.run())
finally: finally:
await runner.cleanup() await runner.cleanup()
+145 -4
View File
@@ -175,14 +175,107 @@ function renderSettingsSupport() {
? "Opslaan stuurt je instellingen direct naar dit scherm. Ze blijven ook na een herstart bewaard." ? "Opslaan stuurt je instellingen direct naar dit scherm. Ze blijven ook na een herstart bewaard."
: "Eenmalig firmware 0.1.2 of nieuwer installeren via ESPHome. Je kunt de instellingen alvast bewaren; oudere firmware gebruikt ze nog niet."; : "Eenmalig firmware 0.1.2 of nieuwer installeren via ESPHome. Je kunt de instellingen alvast bewaren; oudere firmware gebruikt ze nog niet.";
} }
const updating = new Set();
const PHASES = {
install: "Bouwen en installeren…",
verify: "Wachten tot het scherm terug is…",
settle: "Controleren of het stabiel blijft…",
};
async function startUpdate(screen, host) {
updating.add(screen.id);
renderScreens();
try {
await api(`screens/${encodeURIComponent(screen.id)}/update`, {
method: "POST",
body: JSON.stringify(host ? { host } : {}),
});
await refresh();
} catch (e) {
updating.delete(screen.id);
renderScreens();
toast(e.message);
}
}
function renderUpdate(screen) {
const u = screen.update || {};
const row = node("div", undefined, "screen-update");
const running = u.state === "running" || updating.has(screen.id);
if (running) {
row.append(
node("span", undefined, "spin"),
node("small", PHASES[u.phase] || "Update starten…"),
);
} else if (u.state === "queued") {
row.append(node("small", "In de wachtrij voor de update"));
} else if (u.available && screen.online) {
row.append(node("span", `Update ${u.target}`, "badge update"));
const button = node("button", "Bijwerken", "mini");
button.type = "button";
if (u.host && u.profile) {
button.onclick = (e) => {
e.stopPropagation();
startUpdate(screen);
};
} else if (!u.profile) {
button.disabled = true;
button.title = "Geen ESPHome-profiel met deze apparaatnaam gevonden.";
} else {
button.onclick = (e) => {
e.stopPropagation();
const form = node("form", undefined, "screen-host");
const input = node("input");
input.placeholder = "IP-adres, bijv. 192.168.1.50";
input.required = true;
input.pattern = "[A-Za-z0-9][A-Za-z0-9.\\-]*";
const go = node("button", "Start", "mini");
const cancel = node("button", "✕", "quiet");
cancel.type = "button";
cancel.setAttribute("aria-label", "Annuleren");
cancel.onclick = () => {
form.remove();
renderScreens();
};
form.append(input, go, cancel);
form.onsubmit = (ev) => {
ev.preventDefault();
form.remove();
startUpdate(screen, input.value.trim());
};
row.replaceChildren(
node("small", "Eenmalig het IP-adres; nieuwe firmware meldt het zelf."),
form,
);
input.focus();
};
}
row.append(button);
} else if (u.result && Date.now() / 1000 - u.result.time < 86400) {
row.append(
node(
"small",
u.result.message,
u.result.state === "failed" ? "failed" : "",
),
);
} else return null;
return row;
}
function renderScreens() { function renderScreens() {
// Keep an open address form alive across the periodic refresh.
if ($("#screens .screen-host")) {
renderUpdates();
return;
}
$("#screens").replaceChildren(); $("#screens").replaceChildren();
for (const screen of inventory.screens) { for (const screen of inventory.screens) {
const b = node( if (screen.update?.state === "running") updating.delete(screen.id);
"button", const item = node(
"div",
undefined, undefined,
`screen ${screen.id === selected ? "selected" : ""}`, `screen ${screen.id === selected ? "selected" : ""}`,
); );
const b = node("button", undefined, "screen-main");
b.type = "button";
b.append( b.append(
node("strong", screen.name), node("strong", screen.name),
node( node(
@@ -191,7 +284,10 @@ function renderScreens() {
), ),
); );
b.onclick = () => select(screen.id); b.onclick = () => select(screen.id);
$("#screens").append(b); item.append(b);
const update = renderUpdate(screen);
if (update) item.append(update);
$("#screens").append(item);
} }
const screen = inventory.screens.find((s) => s.id === selected); const screen = inventory.screens.find((s) => s.id === selected);
if (screen) { if (screen) {
@@ -200,7 +296,47 @@ function renderScreens() {
: "Offline · wijzigingen worden bewaard"; : "Offline · wijzigingen worden bewaard";
$("#delivery").classList.toggle("online", screen.online); $("#delivery").classList.toggle("online", screen.online);
} }
renderUpdates();
} }
function renderUpdates() {
const u = inventory.updates;
$("#updates").hidden = !u || !inventory.screens.length;
if (!u) return;
const outdated = inventory.screens.filter((s) => s.update?.available).length;
$("#updates-hint").textContent = u.busy
? `Bezig met bijwerken naar firmware ${u.target}…`
: outdated
? `Firmware ${u.target} is beschikbaar voor ${outdated} scherm${outdated === 1 ? "" : "en"}.`
: `Alle schermen hebben firmware ${u.target}.`;
$("#update-all").hidden = !u.pending || !!u.busy || u.pending < 2;
$("#update-all").textContent = `Alle ${u.pending} schermen bijwerken`;
if (document.activeElement !== $("#auto-update"))
$("#auto-update").checked = u.auto;
}
$("#update-all").onclick = async () => {
try {
await api("updates/run", { method: "POST" });
await refresh();
} catch (e) {
toast(e.message);
}
};
$("#auto-update").onchange = async () => {
try {
await api("updates", {
method: "PUT",
body: JSON.stringify({ auto: $("#auto-update").checked }),
});
toast(
$("#auto-update").checked
? "Schermen worden voortaan 's nachts bijgewerkt."
: "Automatisch bijwerken staat uit.",
);
} catch (e) {
$("#auto-update").checked = !$("#auto-update").checked;
toast(e.message);
}
};
function move(from, to) { function move(from, to) {
if (to < 0 || to >= layout.tiles.length) return; if (to < 0 || to >= layout.tiles.length) return;
const [tile] = layout.tiles.splice(from, 1); const [tile] = layout.tiles.splice(from, 1);
@@ -767,7 +903,12 @@ window.addEventListener("beforeunload", (e) => {
} }
}); });
refresh(); refresh();
setInterval(refresh, 10000); (function poll() {
setTimeout(async () => {
await refresh();
poll();
}, inventory.updates?.busy ? 3000 : 10000);
})();
// Shared firmware workspace; always select a concrete profile and upload target. // Shared firmware workspace; always select a concrete profile and upload target.
let firmwarePoll; let firmwarePoll;
+14
View File
@@ -74,6 +74,20 @@
seconden. seconden.
</p> </p>
<button class="quiet" id="refresh">↻ Vernieuwen</button> <button class="quiet" id="refresh">↻ Vernieuwen</button>
<div class="updates" id="updates">
<h2>Firmware-updates</h2>
<p class="hint" id="updates-hint"></p>
<button id="update-all" hidden>Alle schermen bijwerken</button>
<label class="check">
<input type="checkbox" id="auto-update" />
<span
>Elke nacht automatisch bijwerken<small
>Tussen 03:00 en 06:00, één scherm tegelijk. Stopt bij een
fout en meldt dat in Home Assistant.</small
></span
>
</label>
</div>
</aside> </aside>
<section id="empty" class="empty"> <section id="empty" class="empty">
<span class="empty-icon">▦</span> <span class="empty-icon">▦</span>
+84 -5
View File
@@ -165,20 +165,99 @@ aside h2 {
margin-bottom: 18px; margin-bottom: 18px;
} }
.screen { .screen {
text-align: left;
background: transparent;
color: var(--ink);
border: 1px solid transparent; border: 1px solid transparent;
padding: 14px; border-radius: 14px;
} }
.screen.selected { .screen.selected {
background: white; background: white;
border-color: var(--line); border-color: var(--line);
box-shadow: 0 3px 10px #202c3c05; box-shadow: 0 3px 10px #202c3c05;
} }
.screen small { .screen-main {
display: block;
width: 100%;
text-align: left;
background: transparent;
color: var(--ink);
border: 0;
padding: 14px;
}
.screen-main small {
margin-top: 3px; margin-top: 3px;
} }
.screen-update {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 0 14px 12px;
}
.screen-update small.failed {
color: #b23b3b;
}
.badge.update {
background: #fff4e0;
color: #8a5a10;
border-color: #f4e3c2;
padding: 4px 9px;
}
.mini {
padding: 5px 11px;
font-size: 12px;
border-radius: 20px;
}
.screen-host {
display: flex;
gap: 6px;
width: 100%;
}
.screen-host input {
flex: 1;
min-width: 0;
padding: 6px 9px;
font-size: 12px;
}
.spin {
width: 13px;
height: 13px;
flex: none;
border: 2px solid var(--line);
border-top-color: var(--ink);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.updates {
margin-top: 22px;
}
.updates h2 {
margin-bottom: 4px;
}
.updates .hint {
margin: 0 0 10px;
}
.updates #update-all {
width: 100%;
margin-bottom: 12px;
}
.check {
display: flex;
gap: 9px;
align-items: flex-start;
cursor: pointer;
}
.check input {
margin-top: 3px;
}
.check small {
display: block;
margin-top: 2px;
color: var(--muted);
}
.quiet { .quiet {
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
+226
View File
@@ -0,0 +1,226 @@
"""Firmware updates for paired screens: on demand, all at once, or nightly. One screen at a time."""
import asyncio
from datetime import datetime, timezone
import json
import logging
import os
from pathlib import Path
import re
import time
from core import FIRMWARE_VERSION
LOG = logging.getLogger('screen_manager')
NIGHT_HOURS = range(3, 6)
def parse_version(text):
try:
parts = tuple(int(part) for part in str(text).split('.'))
except ValueError:
return None
return parts if len(parts) == 3 else None
TARGET = parse_version(FIRMWARE_VERSION)
class Updater:
verify_timeout = 240
settle_seconds = 60
pause_seconds = 120
poll_seconds = 5
def __init__(self, manager, path):
self.manager, self.path = manager, Path(path)
self.auto, self.hosts, self.results, self.last_round = False, {}, {}, None
self.task, self.current, self.queue, self.phase = None, None, [], None
if self.path.exists():
raw = json.loads(self.path.read_text())
if raw.get('version') != 1:
raise ValueError('Onbekende opslagversie voor updates; gegevens blijven ongewijzigd.')
self.auto = raw.get('auto') is True
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.last_round = raw.get('last_round') if isinstance(raw.get('last_round'), str) else None
def save(self):
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, 'auto': self.auto, 'hosts': self.hosts, 'results': self.results,
'last_round': self.last_round}, handle, ensure_ascii=False)
handle.flush()
os.fsync(handle.fileno())
temp.replace(self.path)
def busy(self):
return bool(self.task and not self.task.done())
def screen(self, inbox):
return next((s for s in self.manager.inventory()[0] if s['id'] == inbox), None)
def resolve(self, screen):
"""Profile file and OTA address for a screen; None when the add-on cannot tell."""
profiles = self.manager.firmware.profile_names()
profile = next((f for f, p in profiles.items() if screen.get('node') and p['node'] == screen['node']), None)
if profile is None and screen.get('device'):
# Screens on firmware before 0.2.17 do not report their node name yet.
matches = [f for f, p in profiles.items() if p['friendly'] == screen['device']]
profile = matches[0] if len(matches) == 1 else None
return profile, screen.get('ip') or self.hosts.get(screen['id'])
def state_for(self, screen):
version = parse_version(screen.get('firmware'))
profile, host = self.resolve(screen)
if screen['id'] == self.current:
state = 'running'
elif screen['id'] in self.queue:
state = 'queued'
else:
state = 'idle'
return {'available': bool(version and TARGET and version < TARGET), 'target': FIRMWARE_VERSION,
'profile': profile, 'host': host, 'state': state, 'phase': self.phase if state == 'running' else None,
'result': self.results.get(screen['id'])}
def summary(self):
return {'auto': self.auto, 'target': FIRMWARE_VERSION, 'busy': self.current,
'pending': len(self.pending()), 'last_round': self.last_round}
def pending(self):
return [s['id'] for s in self.manager.inventory()[0]
if s['online'] and self.state_for(s)['available'] and all(self.resolve(s))]
def set_auto(self, enabled):
if not isinstance(enabled, bool):
raise ValueError('Kies aan of uit voor automatisch bijwerken.')
self.auto = enabled
self.save()
def start(self, inbox, host=None):
if self.busy():
raise ValueError('Er loopt al een update. Wacht tot die klaar is.')
screen = self.screen(inbox)
if not screen:
raise ValueError('Onbekend scherm. Vernieuw het overzicht.')
if not screen['online']:
raise ValueError('Dit scherm is offline; bijwerken kan zodra het terug is.')
if not self.state_for(screen)['available']:
raise ValueError('Dit scherm heeft al de nieuwste firmware.')
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()):
raise ValueError('Vul het IP-adres van dit scherm in.')
self.hosts[inbox] = host.strip()
self.save()
profile, host = self.resolve(screen)
if not profile:
raise ValueError('Geen ESPHome-profiel gevonden voor dit scherm. Controleer de naam in de ESPHome-map.')
if not host:
raise ValueError('IP-adres onbekend. Vul het eenmalig in; nieuwe firmware meldt het daarna zelf.')
self.launch([inbox])
return self.state_for(screen)
def start_all(self):
if self.busy():
raise ValueError('Er loopt al een update. Wacht tot die klaar is.')
pending = self.pending()
if not pending:
raise ValueError('Alle bereikbare schermen zijn al bijgewerkt.')
self.launch(pending)
return pending
def launch(self, inboxes, automatic=False):
# Mark the first screen busy right away so the page shows progress before the task runs.
self.current, self.queue, self.phase = inboxes[0], inboxes[1:], 'install'
self.task = asyncio.create_task(self.run_round(inboxes, automatic))
def record(self, inbox, state, message):
self.results[inbox] = {'time': time.time(), 'state': state, 'message': message, 'version': FIRMWARE_VERSION}
self.save()
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."""
try:
for index, inbox in enumerate(inboxes):
self.current, self.queue = inbox, inboxes[index+1:]
outcome = await self.update_one(inbox)
if outcome == 'failed':
if automatic:
await self.notify(f"Automatisch bijwerken is gestopt bij {self.name(inbox)}: "
f"{self.results[inbox]['message']} De overige schermen zijn niet aangeraakt.")
break
if self.queue and outcome == 'success':
await asyncio.sleep(self.pause_seconds)
finally:
self.current, self.queue, self.phase = None, [], None
def name(self, inbox):
screen = self.screen(inbox)
return screen['name'] if screen else inbox
async def update_one(self, inbox):
screen = self.screen(inbox)
if not screen or not screen['online']:
self.record(inbox, 'skipped', 'Scherm was offline; volgende keer opnieuw.')
return 'skipped'
profile, host = self.resolve(screen)
if not profile or not host:
self.record(inbox, 'skipped', 'Profiel of IP-adres onbekend; werk dit scherm handmatig bij.')
return 'skipped'
self.phase = 'install'
try:
self.manager.firmware.start({'file': profile, 'action': 'install', 'target': host})
await self.manager.firmware.task
except ValueError as error:
self.record(inbox, 'failed', str(error))
return 'failed'
if self.manager.firmware.job.get('state') != 'success':
self.record(inbox, 'failed', 'Bouwen of installeren mislukt; bekijk het log onder Firmware & USB.')
return 'failed'
self.phase = 'verify'
if not await self.wait_for_target(inbox):
self.record(inbox, 'failed', f'Scherm meldde zich niet met firmware {FIRMWARE_VERSION}; controleer het scherm.')
return 'failed'
self.phase = 'settle'
await asyncio.sleep(self.settle_seconds)
current = self.screen(inbox)
if not current or not current['online']:
self.record(inbox, 'failed', 'Scherm viel weg na de update; controleer het scherm.')
return 'failed'
self.record(inbox, 'success', f'Bijgewerkt naar firmware {FIRMWARE_VERSION}.')
return 'success'
async def wait_for_target(self, inbox):
deadline = time.monotonic() + self.verify_timeout
while time.monotonic() < deadline:
screen = self.screen(inbox)
version = parse_version(screen.get('firmware')) if screen else None
if screen and screen['online'] and version and version >= TARGET:
return True
await asyncio.sleep(self.poll_seconds)
return False
async def notify(self, message):
try:
await self.manager.ha.request('call_service', domain='persistent_notification', service='create',
service_data={'notification_id': 'esp_screens_update', 'title': 'ESP Screens', 'message': message})
except Exception as error:
LOG.warning('Melding naar Home Assistant mislukt (%s)', type(error).__name__)
def due(self, now):
return self.auto and now.hour in NIGHT_HOURS and self.last_round != now.date().isoformat()
async def run(self):
while True:
await asyncio.sleep(60)
try:
if self.busy() or not self.manager.ha.online:
continue
now = datetime.now(getattr(self.manager.ha, 'time_zone', None) or timezone.utc)
if not self.due(now):
continue
self.last_round = now.date().isoformat()
self.save()
pending = self.pending()
if pending:
LOG.info('Nachtelijke firmwareronde: %d scherm(en)', len(pending))
self.launch(pending, automatic=True)
except Exception as error:
LOG.warning('Nachtelijke updatecontrole overgeslagen (%s)', type(error).__name__)
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager name: ESP Screen Manager
version: "0.2.15" version: "0.2.16"
slug: esp_screen_manager slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen. description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen url: https://github.com/MaxGramser/homeassistant_espscreen
+210
View File
@@ -0,0 +1,210 @@
"""Firmware update offers, profile matching and the one-at-a-time update round."""
import asyncio
from datetime import datetime, timezone
import importlib.util
import json
from pathlib import Path
import re
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, discover
from firmware import Firmware
import updates
from updates import Updater, parse_version
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
class VersionSourceTests(unittest.TestCase):
def test_app_target_matches_shipped_firmware(self):
for name in ('packages/cyd.yaml', 'packages/guition.yaml', 'home-like-2432s028.yaml', 'guition-4848s040.yaml'):
text = (ROOT / name).read_text()
self.assertIn(f'SCREEN_FIRMWARE_VERSION: "{FIRMWARE_VERSION}"', text, name)
self.assertIn('name: "Apparaatnaam"', 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.assertIsNone(parse_version('onbekend'))
self.assertIsNone(parse_version('1.2'))
def test_discovery_reports_node_and_address(self):
registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd1'},
{'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'},
{'entity_id': 'text.ip', 'platform': 'esphome', 'original_name': 'IP-adres', 'device_id': 'd1'},
{'entity_id': 'text.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'}]
states = {'text.screen': {'state': 'Ready'}, 'text.node': {'state': 'woonkamer'},
'text.ip': {'state': '192.168.1.50'}, 'text.fw': {'state': '0.2.16'}}
screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Woonkamer'}], [])
self.assertEqual(screens[0]['node'], 'woonkamer')
self.assertEqual(screens[0]['ip'], '192.168.1.50')
self.assertEqual(screens[0]['device'], 'Woonkamer')
states['text.ip']['state'] = 'unavailable'
states['text.node']['state'] = '../evil'
screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Woonkamer'}], [])
self.assertIsNone(screens[0]['ip'])
self.assertIsNone(screens[0]['node'])
class ProfileNameTests(unittest.TestCase):
def test_profiles_are_read_without_resolving_secrets(self):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(tmp, tmp)
(Path(tmp) / 'secrets.yaml').write_text('wifi_ssid: example-net\nwifi_password: example-pw\n')
f.create({'board': 'cyd', 'name': 'woonkamer', 'friendly_name': 'Woonkamer'})
(Path(tmp) / 'manual.yaml').write_text('substitutions:\n DEVICE_NAME: "keuken"\n DEVICE_FRIENDLY_NAME: "Keuken"\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')
(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.assertNotIn('broken.yaml', names)
self.assertNotIn('secrets.yaml', names)
class FakeFirmware:
"""Stands in for the ESPHome CLI: records jobs and flips the screen to the target version."""
def __init__(self, ha, outcome='success'):
self.ha, self.outcome, self.calls, self.task, self.job = ha, outcome, [], None, None
self.names = {'woonkamer.yaml': {'node': 'woonkamer', 'friendly': 'Woonkamer'},
'keuken.yaml': {'node': 'keuken', 'friendly': 'Keuken'}}
def profile_names(self): return self.names
def start(self, data):
if self.task and not self.task.done(): raise ValueError('Er loopt al een build of installatie.')
self.calls.append(data)
self.job = {'state': 'running', **data}
self.task = asyncio.create_task(self.run(data))
return self.job
async def run(self, data):
await asyncio.sleep(0)
if self.outcome == 'success':
for entity, node in (('text.fw1', 'woonkamer'), ('text.fw2', 'keuken')):
if data['file'] == node + '.yaml': self.ha.states[entity] = {'state': FIRMWARE_VERSION}
self.job['state'] = self.outcome
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class UpdaterTests(unittest.IsolatedAsyncioTestCase):
def setup_manager(self, path, outcome='success'):
from server import Manager
class HA:
online = True
time_zone = timezone.utc
registry = [{'entity_id': 'text.screen1', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd1'},
{'entity_id': 'text.node1', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'},
{'entity_id': 'text.ip1', 'platform': 'esphome', 'original_name': 'IP-adres', 'device_id': 'd1'},
{'entity_id': 'text.fw1', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'},
{'entity_id': 'text.screen2', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd2'},
{'entity_id': 'text.fw2', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd2'}]
devices = [{'id': 'd1', 'name': 'Woonkamer'}, {'id': 'd2', 'name': 'Keuken'}]
areas = []
states = {'text.screen1': {'state': 'Ready'}, 'text.node1': {'state': 'woonkamer'}, 'text.ip1': {'state': '10.0.0.5'},
'text.fw1': {'state': '0.2.16'}, 'text.screen2': {'state': 'Ready'}, 'text.fw2': {'state': '0.2.16'}}
changed = asyncio.Event()
def __init__(self): self.messages, self.calls = [], []
async def send(self, inbox, message): self.messages.append((inbox, message))
async def request(self, kind, **data): self.calls.append((kind, data))
manager = Manager(HA(), Path(path) / 'screens.json')
manager.firmware = FakeFirmware(manager.ha, outcome)
for attr in ('verify_timeout', 'settle_seconds', 'pause_seconds', 'poll_seconds'):
setattr(manager.updates, attr, 0.01 if attr == 'verify_timeout' else 0)
return manager
async def test_offer_matching_and_manual_address(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp)
first, second = m.inventory()[0]
self.assertEqual(m.updates.state_for(first)['profile'], 'woonkamer.yaml')
self.assertEqual(m.updates.state_for(first)['host'], '10.0.0.5')
self.assertTrue(m.updates.state_for(first)['available'])
# Older firmware: no node name, so the friendly name decides; no address yet.
self.assertEqual(m.updates.state_for(second)['profile'], 'keuken.yaml')
self.assertIsNone(m.updates.state_for(second)['host'])
self.assertEqual(m.updates.pending(), ['text.screen1'])
with self.assertRaisesRegex(ValueError, 'IP-adres'): m.updates.start('text.screen2')
with self.assertRaises(ValueError): m.updates.start('text.screen2', host='bad host!')
m.updates.start('text.screen2', host='10.0.0.6')
await m.updates.task
self.assertEqual(m.firmware.calls, [{'file': 'keuken.yaml', 'action': 'install', 'target': '10.0.0.6'}])
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'})
with self.assertRaisesRegex(ValueError, 'nieuwste'): m.updates.start('text.screen2')
m.ha.states['text.fw1'] = {'state': FIRMWARE_VERSION}
self.assertEqual(m.updates.pending(), [])
async def test_round_stops_after_failure_and_notifies(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp, outcome='failed')
m.updates.hosts['text.screen2'] = '10.0.0.6'
self.assertEqual(m.updates.start_all(), ['text.screen1', 'text.screen2'])
with self.assertRaises(ValueError): m.updates.start_all()
await m.updates.task
self.assertEqual(len(m.firmware.calls), 1, 'a failed screen must end the round')
self.assertEqual(m.updates.results['text.screen1']['state'], 'failed')
self.assertNotIn('text.screen2', m.updates.results)
self.assertEqual(m.ha.calls, [], 'manual rounds do not notify')
await m.updates.run_round(['text.screen1'], automatic=True)
self.assertEqual(m.ha.calls[0][1]['domain'], 'persistent_notification')
self.assertIn('Woonkamer', m.ha.calls[0][1]['service_data']['message'])
async def test_screen_that_never_returns_fails(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp)
m.firmware.outcome = 'success'
original = m.firmware.run
async def silent(data):
await asyncio.sleep(0); m.firmware.job['state'] = 'success'
m.firmware.run = silent
m.updates.start('text.screen1')
await m.updates.task
self.assertEqual(m.updates.results['text.screen1']['state'], 'failed')
self.assertIn(FIRMWARE_VERSION, m.updates.results['text.screen1']['message'])
m.firmware.run = original
async def test_nightly_window_and_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp)
night, day = datetime(2026, 9, 14, 3, 30, tzinfo=timezone.utc), datetime(2026, 9, 14, 14, 0, tzinfo=timezone.utc)
self.assertFalse(m.updates.due(night))
m.updates.set_auto(True)
self.assertTrue(m.updates.due(night))
self.assertFalse(m.updates.due(day))
m.updates.last_round = '2026-09-14'
self.assertFalse(m.updates.due(night))
self.assertTrue(m.updates.due(datetime(2026, 9, 15, 4, 0, tzinfo=timezone.utc)))
m.updates.save()
again = self.setup_manager(tmp)
self.assertTrue(again.updates.auto)
self.assertEqual(again.updates.last_round, '2026-09-14')
(Path(tmp) / 'updates.json').write_text('{"version": 9}')
with self.assertRaises(ValueError): self.setup_manager(tmp)
with self.assertRaises(ValueError): m.updates.set_auto('yes')
async def test_http_endpoints(self):
from server import create_app
from aiohttp.test_utils import TestClient, TestServer
with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp)
async with TestClient(TestServer(create_app(m, True))) as client:
inventory = await (await client.get('/api/inventory')).json()
self.assertEqual(inventory['updates']['target'], FIRMWARE_VERSION)
self.assertTrue(inventory['screens'][0]['update']['available'])
headers = {'X-Screen-CSRF': inventory['csrf']}
self.assertEqual((await client.put('/api/updates', headers=headers, json={'auto': True})).status, 200)
self.assertTrue(m.updates.auto)
response = await client.post('/api/screens/text.screen2/update', headers=headers, json={})
self.assertEqual(response.status, 400)
response = await client.post('/api/screens/text.screen1/update', headers=headers, json={})
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())['state'], 'running')
await m.updates.task
self.assertEqual(m.updates.results['text.screen1']['state'], 'success')
if __name__ == '__main__': unittest.main()