From 437e3a5046371410245e4ceb71a72fb49f5f4e58 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sun, 13 Sep 2026 10:14:04 +0200 Subject: [PATCH] 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 --- README.md | 10 +- docs/EASY_SETUP.md | 2 +- docs/RELEASING.md | 20 +++ docs/TEST_RESULTS_0217.md | 54 +++++++ guition-4848s040.yaml | 12 +- home-like-2432s028.yaml | 12 +- packages/cyd.yaml | 12 +- packages/guition.yaml | 12 +- screen_manager/CHANGELOG.md | 8 + screen_manager/app/core.py | 14 ++ screen_manager/app/firmware.py | 23 +++ screen_manager/app/server.py | 29 +++- screen_manager/app/static/app.js | 149 +++++++++++++++++- screen_manager/app/static/index.html | 14 ++ screen_manager/app/static/style.css | 89 ++++++++++- screen_manager/app/updates.py | 226 +++++++++++++++++++++++++++ screen_manager/config.yaml | 2 +- tests/test_updates.py | 210 +++++++++++++++++++++++++ 18 files changed, 875 insertions(+), 23 deletions(-) create mode 100644 docs/TEST_RESULTS_0217.md create mode 100644 screen_manager/app/updates.py create mode 100644 tests/test_updates.py diff --git a/README.md b/README.md index f2cfe16..6ed2520 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,15 @@ blijven afzonderlijk zichtbaar. | --- | --- | | 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 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. Tegelindelingen en opties staan in de permanente appdata. CYD-kalibratie en diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index dd88701..9b356b8 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -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 | | 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 haalt ESPHome de nieuwste gepubliceerde pakket- en componentcode op. Je vervangt diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7498de5..e2d243a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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 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 Opslagversie en tegelprotocol blijven 1. Additief: `tiles[].options.size` diff --git a/docs/TEST_RESULTS_0217.md b/docs/TEST_RESULTS_0217.md new file mode 100644 index 0000000..c94fa89 --- /dev/null +++ b/docs/TEST_RESULTS_0217.md @@ -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. diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index f02f0f9..729e198 100644 --- a/guition-4848s040.yaml +++ b/guition-4848s040.yaml @@ -1,7 +1,7 @@ # 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. substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.16" + SCREEN_FIRMWARE_VERSION: "0.2.17" DYNAMIC_TILES: "false" DASHBOARD_INBOX_INTERNAL: "true" DEVICE_NAME: "guition-wallbox" @@ -1464,6 +1464,16 @@ text_sensor: entity_category: diagnostic lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' 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 id: smartdisplay_action name: "SmartDisplay Action" diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 5203e75..311585a 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -58,7 +58,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.16" + SCREEN_FIRMWARE_VERSION: "0.2.17" DYNAMIC_TILES: "false" DASHBOARD_INBOX_INTERNAL: "true" DEVICE_NAME: "cyd-new" @@ -1649,6 +1649,16 @@ text_sensor: entity_category: diagnostic lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' 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 id: smartdisplay_action name: "SmartDisplay Action" diff --git a/packages/cyd.yaml b/packages/cyd.yaml index 7621132..56e347e 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -59,7 +59,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.16" + SCREEN_FIRMWARE_VERSION: "0.2.17" DYNAMIC_TILES: "true" DASHBOARD_INBOX_INTERNAL: "false" DEVICE_NAME: "cyd-new" @@ -1602,6 +1602,16 @@ text_sensor: entity_category: diagnostic lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' 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 id: smartdisplay_action name: "SmartDisplay Action" diff --git a/packages/guition.yaml b/packages/guition.yaml index 16dea22..cf65b99 100644 --- a/packages/guition.yaml +++ b/packages/guition.yaml @@ -2,7 +2,7 @@ # 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. substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.16" + SCREEN_FIRMWARE_VERSION: "0.2.17" DYNAMIC_TILES: "true" DASHBOARD_INBOX_INTERNAL: "false" DEVICE_NAME: "guition-new" @@ -1417,6 +1417,16 @@ text_sensor: entity_category: diagnostic lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' 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 id: smartdisplay_action name: "SmartDisplay Action" diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index da507be..cdb4018 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -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) - **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. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 75f7245..61dc4a2 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -13,6 +13,8 @@ NEW_DOMAINS = frozenset('sun timer person screen'.split()) WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'] REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' 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()) @@ -229,6 +231,16 @@ def discover(registry, states, devices, areas): if item.get('platform') == 'esphome' and item.get('original_name') == 'Schermfirmware'} boards = {item.get("device_id"): "guition" for item in registry 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 = [], [] for item in registry: 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, 'firmware': versions.get(item.get('device_id'), 'onbekend'), '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'), 'status': state.get('state', 'Niet verbonden')}) if not entity_id(eid) or item.get('disabled_by'): diff --git a/screen_manager/app/firmware.py b/screen_manager/app/firmware.py index dc48439..d6bc41e 100644 --- a/screen_manager/app/firmware.py +++ b/screen_manager/app/firmware.py @@ -12,6 +12,10 @@ import time import 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: def __init__(self, root, 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')) 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): return sorted(set(glob.glob('/dev/serial/by-id/*') or glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*'))) diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 7a63d64..897dbc6 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -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 contextlib import json @@ -10,6 +10,7 @@ import time from datetime import datetime, timedelta, timezone import math from firmware import Firmware +from updates import Updater 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 @@ -169,6 +170,7 @@ class Manager: self.histories = {} self.forecasts = {} 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(): raw = json.loads(self.path.read_text()) # Versioned persistent data. Never silently overwrite an unknown schema. @@ -356,11 +358,21 @@ def create_app(manager, development=False): for screen in screens: screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Thuis', 'tiles': []}) 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()] - 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): manager.save(request.match_info['inbox'], await request.json()) 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): data = await request.json() 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_create(request): return web.json_response(manager.firmware.create(await request.json())) async def shutdown(app): - task=manager.firmware.task - if task and not task.done(): - task.cancel() - with contextlib.suppress(asyncio.CancelledError): await task + for task in (manager.updates.task, manager.firmware.task): + if task and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): await task app.on_cleanup.append(shutdown) 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_post('/api/firmware/jobs', firmware_start) app.router.add_post('/api/firmware/profiles', firmware_create) @@ -408,7 +423,7 @@ async def main(): await runner.setup() await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start() try: - await asyncio.gather(ha.run(), manager.run()) + await asyncio.gather(ha.run(), manager.run(), manager.updates.run()) finally: await runner.cleanup() diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js index 7f68525..63fc39f 100644 --- a/screen_manager/app/static/app.js +++ b/screen_manager/app/static/app.js @@ -175,14 +175,107 @@ function renderSettingsSupport() { ? "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."; } +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() { + // Keep an open address form alive across the periodic refresh. + if ($("#screens .screen-host")) { + renderUpdates(); + return; + } $("#screens").replaceChildren(); for (const screen of inventory.screens) { - const b = node( - "button", + if (screen.update?.state === "running") updating.delete(screen.id); + const item = node( + "div", undefined, `screen ${screen.id === selected ? "selected" : ""}`, ); + const b = node("button", undefined, "screen-main"); + b.type = "button"; b.append( node("strong", screen.name), node( @@ -191,7 +284,10 @@ function renderScreens() { ), ); 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); if (screen) { @@ -200,7 +296,47 @@ function renderScreens() { : "Offline · wijzigingen worden bewaard"; $("#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) { if (to < 0 || to >= layout.tiles.length) return; const [tile] = layout.tiles.splice(from, 1); @@ -767,7 +903,12 @@ window.addEventListener("beforeunload", (e) => { } }); 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. let firmwarePoll; diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html index 06dac38..1cae96c 100644 --- a/screen_manager/app/static/index.html +++ b/screen_manager/app/static/index.html @@ -74,6 +74,20 @@ seconden.

+
+

Firmware-updates

+

+ + +
▦ diff --git a/screen_manager/app/static/style.css b/screen_manager/app/static/style.css index fd89b13..c1bb2a4 100644 --- a/screen_manager/app/static/style.css +++ b/screen_manager/app/static/style.css @@ -165,20 +165,99 @@ aside h2 { margin-bottom: 18px; } .screen { - text-align: left; - background: transparent; - color: var(--ink); border: 1px solid transparent; - padding: 14px; + border-radius: 14px; } .screen.selected { background: white; border-color: var(--line); 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; } +.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 { background: transparent; color: var(--muted); diff --git a/screen_manager/app/updates.py b/screen_manager/app/updates.py new file mode 100644 index 0000000..27b207e --- /dev/null +++ b/screen_manager/app/updates.py @@ -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__) diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index e196143..d144914 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.15" +version: "0.2.16" slug: esp_screen_manager description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen. url: https://github.com/MaxGramser/homeassistant_espscreen diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..f4c2af3 --- /dev/null +++ b/tests/test_updates.py @@ -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()