diff --git a/diagnostics/send_layout.py b/diagnostics/send_layout.py index 85598a0..5b51318 100644 --- a/diagnostics/send_layout.py +++ b/diagnostics/send_layout.py @@ -2,7 +2,7 @@ Uses the same message builder as ESP Screen Manager, but with synthetic states, so rendering can be checked without touching Home Assistant. The running -manager restores the real layout on its next keepalive (about 25 seconds). +manager restores the real layout on its next keepalive (about two minutes). """ import argparse import asyncio diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 1a1bcd0..645026b 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,11 @@ +## 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. +- De entity-, device- en area-registry (circa 1 MB JSON) wordt niet meer elke 30 s opgehaald, maar bij een `*_registry_updated`-event van Home Assistant (1 s gedebounced) en als vangnet elke 10 minuten. +- De volledige keepalive naar de schermen loopt elke 2 minuten in plaats van elke 25 s. Een scherm dat offline en weer online komt krijgt de volledige indeling direct, zoals voorheen. +- **Lichte poll**: de pagina haalt elke 10 s alleen nog schermen en updatestatus op (`/api/inventory?light=1`, enkele KB) en de volledige lijst met entiteiten, achtergronden en iconen bij openen, bij terugkeer naar het tabblad en elke 5 minuten. +- Alleen de app verandert; firmware 0.2.18 blijft actueel. + ## 0.2.19 (firmware 0.2.18 blijft actueel) - **Sneller overzicht**: `/api/inventory` las bij elke aanroep alle ESPHome-profielen opnieuw in (tot vier keer per verzoek), waardoor de add-on op een Pi seconden per verzoek blokkeerde. Profielen worden nu per bestand gecachet op inode, wijzigingstijd en grootte; alleen een gewijzigd profiel wordt opnieuw gelezen en verwijderde profielen verdwijnen direct. Het parsen zelf gebruikt libyaml wanneer die beschikbaar is (circa tien keer sneller). diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 3cbc8e0..a3d8e45 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -19,7 +19,13 @@ from zoneinfo import ZoneInfo LOG = logging.getLogger('screen_manager') +REGISTRY_EVENTS = ('entity_registry_updated', 'device_registry_updated', 'area_registry_updated') +SCREEN_ENTITY_NAMES = {'Tegelinstellingen', 'Schermfirmware', 'Guition schermtype', 'Apparaatnaam', 'IP-adres'} +KEEPALIVE_SECONDS = 120 + class HomeAssistant: + registry_interval = 600 + def __init__(self, session, base, token): self.session, self.base, self.token = session, base.rstrip('/'), token self.ws = None @@ -29,6 +35,9 @@ class HomeAssistant: self.registry, self.devices, self.areas = [], [], [] self.online = False self.changed = asyncio.Event() + # Entity ids the manager cares about; None wakes it for every state change. + self.relevant = None + self.registry_changed = asyncio.Event() self.setting_events = [] self.time_zone = None @@ -63,13 +72,16 @@ class HomeAssistant: if event.get('event_type') == 'esphome.screen_setting': self.setting_events.append(body) self.changed.set() - if event.get('event_type') == 'state_changed': + elif event.get('event_type') == 'state_changed': eid = body.get('entity_id') if body.get('new_state'): self.states[eid] = body['new_state'] else: self.states.pop(eid, None) - self.changed.set() + if self.relevant is None or eid in self.relevant: + self.changed.set() + elif event.get('event_type') in REGISTRY_EVENTS: + self.registry_changed.set() raise ConnectionError('Home Assistant-verbinding verbroken.') async def registries(self): @@ -91,6 +103,8 @@ class HomeAssistant: reader = asyncio.create_task(self.read()) await self.request('subscribe_events', event_type='state_changed') await self.request('subscribe_events', event_type='esphome.screen_setting') + for event_type in REGISTRY_EVENTS: + await self.request('subscribe_events', event_type=event_type) self.states = {s['entity_id']: s for s in await self.request('get_states')} await self.registries() try: @@ -100,12 +114,24 @@ class HomeAssistant: self.online = True self.changed.set() LOG.info('Home Assistant verbonden') - # Refresh registry for newly paired screens and renamed entities. + # 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. + fetched = time.monotonic() while True: - done, _ = await asyncio.wait([reader], timeout=30) - if done: + waiter = asyncio.ensure_future(self.registry_changed.wait()) + try: + done, _ = await asyncio.wait([reader, waiter], timeout=30, return_when=asyncio.FIRST_COMPLETED) + finally: + waiter.cancel() + if reader in done: await reader + if self.registry_changed.is_set(): + await asyncio.sleep(1) + self.registry_changed.clear() + elif time.monotonic() - fetched < self.registry_interval: + continue await self.registries() + fetched = time.monotonic() self.changed.set() except (ConnectionError, TimeoutError, OSError, ValueError) as error: LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)', type(error).__name__) @@ -245,6 +271,14 @@ class Manager: self.status[inbox] = 'Opgeslagen; wacht op synchronisatie' self.ha.changed.set() + def watched_entities(self): + """Entities whose state changes matter: tiles on any layout plus the screens' own diagnostics.""" + watched = {tile['entity'] for layout in self.layouts.values() for tile in layout['tiles']} + for item in getattr(self.ha, 'registry', []): + if item.get('platform') == 'esphome' and item.get('original_name') in SCREEN_ENTITY_NAMES: + watched.add(item['entity_id']) + return watched + async def cached(self, store, key, ttl, fetch): entry=store.get(key) if not entry or time.monotonic()-entry[0]>ttl: @@ -313,7 +347,9 @@ class Manager: layout['settings']=validate_settings(settings);self.save(inbox,layout) except (ValueError,TypeError): pass if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear() - for screen in self.inventory()[0]: + screens = self.inventory()[0] + self.ha.relevant = self.watched_entities() + for screen in screens: inbox = screen['id'] if not screen['online']: self.sent.pop(inbox, None) @@ -322,7 +358,7 @@ class Manager: if inbox not in self.layouts: continue try: - force = time.monotonic() - self.last.get(inbox, 0) >= 25 + force = time.monotonic() - self.last.get(inbox, 0) >= KEEPALIVE_SECONDS # last records full keepalive, not unrelated HA state events. before = self.last.get(inbox, 0) await self.sync_one(inbox, self.layouts[inbox], force, screen) @@ -366,9 +402,14 @@ def create_app(manager, development=False): 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) - 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, 'icons': tile_icons.editor(), 'builtin': builtin, 'updates': manager.updates.summary(screens, profiles)}) + payload = {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'updates': manager.updates.summary(screens, profiles)} + if request.query.get('light') != '1': + # The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand. + payload['entities'] = entities + payload['backgrounds'] = TILE_BACKGROUNDS + 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 save(request): manager.save(request.match_info['inbox'], await request.json()) return web.json_response({'saved': True}) diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js index b78c3e2..c403872 100644 --- a/screen_manager/app/static/app.js +++ b/screen_manager/app/static/app.js @@ -851,9 +851,11 @@ function renderResults() { ), ); } -async function refresh() { +async function refresh(full = true) { try { - inventory = await (await api("inventory")).json(); + const data = await (await api(full ? "inventory" : "inventory?light=1")).json(); + // A light poll carries only screens and update status; keep the catalogues we have. + inventory = full ? data : { ...inventory, ...data }; $("#connection").textContent = inventory.connected ? "● Home Assistant verbonden" : "Verbinding met Home Assistant herstellen…"; @@ -1015,17 +1017,22 @@ window.addEventListener("beforeunload", (e) => { }); refresh(); // Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy. -let pollTimer; +let pollTimer, lastFull = Date.now(); function poll() { clearTimeout(pollTimer); pollTimer = setTimeout(async () => { - if (!document.hidden) await refresh(); + if (!document.hidden) { + const full = Date.now() - lastFull >= 300000; + if (full) lastFull = Date.now(); + await refresh(full); + } poll(); }, inventory.updates?.busy ? 3000 : 10000); } poll(); document.addEventListener("visibilitychange", async () => { if (document.hidden) return; + lastFull = Date.now(); await refresh(); poll(); }); diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 0c46880..e714f74 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.19" +version: "0.2.20" 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 74fd0e0..7ec234d 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -213,6 +213,13 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase): with self.assertRaises(ValueError): self.setup_manager(tmp) with self.assertRaises(ValueError): m.updates.set_auto('yes') + async def test_manager_watches_only_layout_and_screen_entities(self): + with tempfile.TemporaryDirectory() as tmp: + m = self.setup_manager(tmp) + m.layouts['text.screen1'] = {'title': 'Thuis', 'tiles': [{'entity': 'light.lamp'}]} + watched = m.watched_entities() + self.assertEqual(watched, {'light.lamp', 'text.screen1', 'text.node1', 'text.ip1', 'text.fw1', 'text.screen2', 'text.fw2'}) + async def test_http_endpoints(self): from server import create_app from aiohttp.test_utils import TestClient, TestServer @@ -222,6 +229,12 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase): inventory = await (await client.get('/api/inventory')).json() self.assertEqual(inventory['updates']['target'], FIRMWARE_VERSION) self.assertTrue(inventory['screens'][0]['update']['available']) + self.assertIn('entities', inventory) + light = await (await client.get('/api/inventory?light=1')).json() + self.assertNotIn('entities', light) + self.assertNotIn('icons', light) + self.assertEqual([x['id'] for x in light['screens']], [x['id'] for x in inventory['screens']]) + self.assertEqual(light['updates'], inventory['updates']) 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)