From 359c289feb6d5a94df20e3c010043d423427d554 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sun, 13 Sep 2026 11:30:46 +0200 Subject: [PATCH] Push inventory over server-sent events and log why the HA websocket closes (0.2.21) /api/events streams the light inventory whenever the sync loop or a save changes it (3 s check during firmware updates). The page stops polling while the stream is open and falls back to the 10 s poll otherwise. The websocket close is logged with duration, close code and exception. Co-Authored-By: Claude Fable 5.1 --- screen_manager/CHANGELOG.md | 6 ++++ screen_manager/app/server.py | 61 +++++++++++++++++++++++++++++--- screen_manager/app/static/app.js | 25 +++++++++++-- screen_manager/config.yaml | 2 +- tests/test_updates.py | 10 ++++++ 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 645026b..fe0e245 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.2.21 (firmware 0.2.18 blijft actueel) + +- **Live updates zonder pollen**: de pagina luistert op `/api/events` (server-sent events). De add-on stuurt schermstatus en updatestatus zodra de synchronisatielus of een opslag iets verandert, en controleert elke 3 s op wijzigingen tijdens een firmware-update. Zolang de stream open is pollt de pagina niet meer; valt de stream weg, dan neemt de poll van 10 s het over. De volledige lijst met entiteiten, achtergronden en iconen wordt nog elke 5 minuten ververst. +- Het log meldt bij het wegvallen van de Home Assistant-verbinding hoe lang die stond, de websocket close-code en de fout, ook wanneer de verbinding zonder foutmelding sluit. Het add-on-log op de Yellow toonde herhaalde "Home Assistant verbonden"-regels zonder waarschuwing ervoor; met deze regels is de oorzaak bij de volgende keer uit het log te halen. +- Alleen de app verandert; firmware 0.2.18 blijft actueel. + ## 0.2.20 (firmware 0.2.18 blijft actueel) - **Minder verbruik in rust**: de synchronisatielus wordt alleen nog wakker voor entiteiten die op een indeling staan of bij een scherm horen (Tegelinstellingen, Schermfirmware, Apparaatnaam, IP-adres, Guition schermtype), niet meer bij elke `state_changed` in Home Assistant. diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index a3d8e45..158722c 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -84,6 +84,15 @@ class HomeAssistant: self.registry_changed.set() raise ConnectionError('Home Assistant-verbinding verbroken.') + def describe_close(self, connected): + """Why and after how long the websocket ended; helps explain unexpected reconnects in the log.""" + if connected is None: + return '' + ws = self.ws + reason = ws.exception() if ws is not None else None + return ' na %d s, close-code %s%s' % (time.monotonic() - connected, ws.close_code if ws is not None else None, + f', {type(reason).__name__}' if reason else '') + async def registries(self): self.registry, self.devices, self.areas = await asyncio.gather( self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list')) @@ -92,7 +101,7 @@ class HomeAssistant: url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api' else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket') while True: - reader = None + reader, connected = None, None try: async with self.session.ws_connect(url, heartbeat=30, max_msg_size=16*1024*1024) as ws: self.ws = ws @@ -113,6 +122,7 @@ class HomeAssistant: self.time_zone = timezone.utc self.online = True self.changed.set() + connected = time.monotonic() LOG.info('Home Assistant verbonden') # Refresh the registry when HA reports a change (debounced), with a slow # fallback; a full fetch is about 1 MB of JSON and used to run every 30 s. @@ -134,10 +144,12 @@ class HomeAssistant: fetched = time.monotonic() self.changed.set() except (ConnectionError, TimeoutError, OSError, ValueError) as error: - LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)', type(error).__name__) + LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)%s', type(error).__name__, self.describe_close(connected)) except Exception as error: - LOG.warning('Verbinding opnieuw starten (%s)', type(error).__name__) + LOG.warning('Verbinding opnieuw starten (%s)%s', type(error).__name__, self.describe_close(connected)) finally: + if self.online: + LOG.info('Home Assistant-verbinding gesloten%s', self.describe_close(connected)) self.online = False self.ws = None if reader: @@ -196,6 +208,7 @@ class Manager: self.layouts, self.sent, self.status, self.last = {}, {}, {}, {} self.histories = {} self.forecasts = {} + self.listeners = set() # asyncio.Event per open /api/events stream 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(): @@ -270,6 +283,11 @@ class Manager: self.sent.pop(inbox, None) self.status[inbox] = 'Opgeslagen; wacht op synchronisatie' self.ha.changed.set() + self.notify() + + def notify(self): + for listener in self.listeners: + listener.set() def watched_entities(self): """Entities whose state changes matter: tiles on any layout plus the screens' own diagnostics.""" @@ -333,6 +351,7 @@ class Manager: await asyncio.sleep(0.25) if not self.ha.online: self.sent.clear() + self.notify() continue for event in getattr(self.ha,'setting_events',[])[:]: try: @@ -369,6 +388,7 @@ class Manager: self.status[inbox] = 'Verzenden mislukt; automatisch opnieuw proberen' LOG.warning('Schermsynchronisatie opnieuw proberen (%s)', type(error).__name__) await asyncio.sleep(1) + self.notify() def create_app(manager, development=False): csrf = secrets.token_urlsafe(32) @@ -385,6 +405,8 @@ def create_app(manager, development=False): return web.json_response({'error': str(error)}, status=400) except (TypeError, KeyError): return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400) + if response.prepared: + return response # streamed (SSE) responses set their headers before prepare() response.headers['Cache-Control'] = 'no-store' response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'" @@ -395,14 +417,18 @@ def create_app(manager, development=False): async def index(request): return web.FileResponse(static / 'index.html') - async def inventory(request): + def light_payload(): + """Screens and update status: everything that changes while the page is open.""" screens, entities = manager.inventory() profiles = manager.firmware.profile_names() 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, profiles) - payload = {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'updates': manager.updates.summary(screens, profiles)} + return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, + 'updates': manager.updates.summary(screens, profiles)}, entities + async def inventory(request): + payload, entities = light_payload() if request.query.get('light') != '1': # The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand. payload['entities'] = entities @@ -410,6 +436,30 @@ def create_app(manager, development=False): payload['icons'] = tile_icons.editor() payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()] return web.json_response(payload) + async def events(request): + """Server-sent events: pushes the light inventory whenever it changes, so the page need not poll.""" + response = web.StreamResponse(headers={'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', + 'X-Accel-Buffering': 'no', 'X-Content-Type-Options': 'nosniff'}) + await response.prepare(request) + wake, sent = asyncio.Event(), None + manager.listeners.add(wake) + try: + while True: + # Sync results are pushed at once; updater phases are picked up by the 3 s check. + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(wake.wait(), 3) + wake.clear() + body = json.dumps(light_payload()[0], ensure_ascii=False) + if body != sent: + await response.write(f'data: {body}\n\n'.encode()) + sent = body + else: + await response.write(b': keepalive\n\n') + except (ConnectionResetError, asyncio.CancelledError): + pass + finally: + manager.listeners.discard(wake) + return response async def save(request): manager.save(request.match_info['inbox'], await request.json()) return web.json_response({'saved': True}) @@ -452,6 +502,7 @@ def create_app(manager, development=False): app.router.add_post('/api/firmware/profiles', firmware_create) app.router.add_get('/', index) app.router.add_get('/api/inventory', inventory) + app.router.add_get('/api/events', events) app.router.add_put('/api/screens/{inbox}', save) app.router.add_post('/api/install', download) app.router.add_static('/static/', static) diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js index c403872..ece665c 100644 --- a/screen_manager/app/static/app.js +++ b/screen_manager/app/static/app.js @@ -1017,18 +1017,37 @@ window.addEventListener("beforeunload", (e) => { }); refresh(); // Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy. -let pollTimer, lastFull = Date.now(); +// Live updates arrive over server-sent events; polling is the fallback while the stream is down, +// plus a full catalogue refresh every 5 minutes. +let pollTimer, lastFull = Date.now(), live = false, stream; +function applyLive(data) { + inventory = { ...inventory, ...data }; + $("#connection").textContent = inventory.connected ? "● Home Assistant verbonden" : "Verbinding met Home Assistant herstellen…"; + $("#connection").classList.toggle("online", inventory.connected); + renderScreens(); + if (selected) renderSettingsSupport(); + if (!selected && inventory.screens.length) select(inventory.screens[0].id); +} +function listen() { + if (stream || typeof EventSource === "undefined") return; + stream = new EventSource("api/events"); + stream.onopen = () => { live = true; poll(); }; + stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); }; + stream.onerror = () => { live = false; poll(); }; +} function poll() { clearTimeout(pollTimer); + const wait = live ? 60000 : inventory.updates?.busy ? 3000 : 10000; pollTimer = setTimeout(async () => { if (!document.hidden) { const full = Date.now() - lastFull >= 300000; if (full) lastFull = Date.now(); - await refresh(full); + if (full || !live) await refresh(full); } poll(); - }, inventory.updates?.busy ? 3000 : 10000); + }, wait); } +listen(); poll(); document.addEventListener("visibilitychange", async () => { if (document.hidden) return; diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index e714f74..c57b826 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.20" +version: "0.2.21" 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 index 7ec234d..b994c58 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -231,6 +231,16 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(inventory['screens'][0]['update']['available']) self.assertIn('entities', inventory) light = await (await client.get('/api/inventory?light=1')).json() + async with client.get('/api/events') as stream: + self.assertEqual(stream.headers['Content-Type'], 'text/event-stream') + line = await asyncio.wait_for(stream.content.readline(), 5) + pushed = json.loads(line.decode().removeprefix('data: ')) + self.assertEqual([x['id'] for x in pushed['screens']], [x['id'] for x in light['screens']]) + self.assertEqual(len(m.listeners), 1) + for _ in range(50): # the stream notices the closed client at its next write + if not m.listeners: break + await asyncio.sleep(0.1) + self.assertEqual(len(m.listeners), 0) self.assertNotIn('entities', light) self.assertNotIn('icons', light) self.assertEqual([x['id'] for x in light['screens']], [x['id'] for x in inventory['screens']])