Wake only for watched entities, registry events, light poll, 2 min keepalive (0.2.20)

The sync loop now wakes only for state changes of entities on a layout or
belonging to a screen. Registries refresh on HA registry events with a
10 minute fallback instead of every 30 s. The page polls a light inventory
(screens + update status) every 10 s and the full catalogues every 5 min or
when the tab becomes visible. The full keepalive interval is 120 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 11:24:17 +02:00
parent b67cd33fb3
commit 8dc5e616f5
6 changed files with 85 additions and 16 deletions
+1 -1
View File
@@ -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
+8
View File
@@ -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).
+50 -9
View File
@@ -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)
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})
+11 -4
View File
@@ -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();
});
+1 -1
View File
@@ -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
+13
View File
@@ -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)