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:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Uses the same message builder as ESP Screen Manager, but with synthetic states,
|
Uses the same message builder as ESP Screen Manager, but with synthetic states,
|
||||||
so rendering can be checked without touching Home Assistant. The running
|
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 argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -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)
|
## 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).
|
- **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).
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
LOG = logging.getLogger('screen_manager')
|
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:
|
class HomeAssistant:
|
||||||
|
registry_interval = 600
|
||||||
|
|
||||||
def __init__(self, session, base, token):
|
def __init__(self, session, base, token):
|
||||||
self.session, self.base, self.token = session, base.rstrip('/'), token
|
self.session, self.base, self.token = session, base.rstrip('/'), token
|
||||||
self.ws = None
|
self.ws = None
|
||||||
@@ -29,6 +35,9 @@ class HomeAssistant:
|
|||||||
self.registry, self.devices, self.areas = [], [], []
|
self.registry, self.devices, self.areas = [], [], []
|
||||||
self.online = False
|
self.online = False
|
||||||
self.changed = asyncio.Event()
|
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.setting_events = []
|
||||||
self.time_zone = None
|
self.time_zone = None
|
||||||
|
|
||||||
@@ -63,13 +72,16 @@ class HomeAssistant:
|
|||||||
if event.get('event_type') == 'esphome.screen_setting':
|
if event.get('event_type') == 'esphome.screen_setting':
|
||||||
self.setting_events.append(body)
|
self.setting_events.append(body)
|
||||||
self.changed.set()
|
self.changed.set()
|
||||||
if event.get('event_type') == 'state_changed':
|
elif event.get('event_type') == 'state_changed':
|
||||||
eid = body.get('entity_id')
|
eid = body.get('entity_id')
|
||||||
if body.get('new_state'):
|
if body.get('new_state'):
|
||||||
self.states[eid] = body['new_state']
|
self.states[eid] = body['new_state']
|
||||||
else:
|
else:
|
||||||
self.states.pop(eid, None)
|
self.states.pop(eid, None)
|
||||||
|
if self.relevant is None or eid in self.relevant:
|
||||||
self.changed.set()
|
self.changed.set()
|
||||||
|
elif event.get('event_type') in REGISTRY_EVENTS:
|
||||||
|
self.registry_changed.set()
|
||||||
raise ConnectionError('Home Assistant-verbinding verbroken.')
|
raise ConnectionError('Home Assistant-verbinding verbroken.')
|
||||||
|
|
||||||
async def registries(self):
|
async def registries(self):
|
||||||
@@ -91,6 +103,8 @@ class HomeAssistant:
|
|||||||
reader = asyncio.create_task(self.read())
|
reader = asyncio.create_task(self.read())
|
||||||
await self.request('subscribe_events', event_type='state_changed')
|
await self.request('subscribe_events', event_type='state_changed')
|
||||||
await self.request('subscribe_events', event_type='esphome.screen_setting')
|
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')}
|
self.states = {s['entity_id']: s for s in await self.request('get_states')}
|
||||||
await self.registries()
|
await self.registries()
|
||||||
try:
|
try:
|
||||||
@@ -100,12 +114,24 @@ class HomeAssistant:
|
|||||||
self.online = True
|
self.online = True
|
||||||
self.changed.set()
|
self.changed.set()
|
||||||
LOG.info('Home Assistant verbonden')
|
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:
|
while True:
|
||||||
done, _ = await asyncio.wait([reader], timeout=30)
|
waiter = asyncio.ensure_future(self.registry_changed.wait())
|
||||||
if done:
|
try:
|
||||||
|
done, _ = await asyncio.wait([reader, waiter], timeout=30, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
waiter.cancel()
|
||||||
|
if reader in done:
|
||||||
await reader
|
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()
|
await self.registries()
|
||||||
|
fetched = time.monotonic()
|
||||||
self.changed.set()
|
self.changed.set()
|
||||||
except (ConnectionError, TimeoutError, OSError, ValueError) as error:
|
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)', type(error).__name__)
|
||||||
@@ -245,6 +271,14 @@ class Manager:
|
|||||||
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
|
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
|
||||||
self.ha.changed.set()
|
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):
|
async def cached(self, store, key, ttl, fetch):
|
||||||
entry=store.get(key)
|
entry=store.get(key)
|
||||||
if not entry or time.monotonic()-entry[0]>ttl:
|
if not entry or time.monotonic()-entry[0]>ttl:
|
||||||
@@ -313,7 +347,9 @@ class Manager:
|
|||||||
layout['settings']=validate_settings(settings);self.save(inbox,layout)
|
layout['settings']=validate_settings(settings);self.save(inbox,layout)
|
||||||
except (ValueError,TypeError): pass
|
except (ValueError,TypeError): pass
|
||||||
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
|
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']
|
inbox = screen['id']
|
||||||
if not screen['online']:
|
if not screen['online']:
|
||||||
self.sent.pop(inbox, None)
|
self.sent.pop(inbox, None)
|
||||||
@@ -322,7 +358,7 @@ class Manager:
|
|||||||
if inbox not in self.layouts:
|
if inbox not in self.layouts:
|
||||||
continue
|
continue
|
||||||
try:
|
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.
|
# last records full keepalive, not unrelated HA state events.
|
||||||
before = self.last.get(inbox, 0)
|
before = self.last.get(inbox, 0)
|
||||||
await self.sync_one(inbox, self.layouts[inbox], force, screen)
|
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['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, profiles)
|
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()]
|
payload = {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'updates': manager.updates.summary(screens, profiles)}
|
||||||
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities,
|
if request.query.get('light') != '1':
|
||||||
'backgrounds': TILE_BACKGROUNDS, 'icons': tile_icons.editor(), 'builtin': builtin, 'updates': manager.updates.summary(screens, profiles)})
|
# 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):
|
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})
|
||||||
|
|||||||
@@ -851,9 +851,11 @@ function renderResults() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
async function refresh() {
|
async function refresh(full = true) {
|
||||||
try {
|
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
|
$("#connection").textContent = inventory.connected
|
||||||
? "● Home Assistant verbonden"
|
? "● Home Assistant verbonden"
|
||||||
: "Verbinding met Home Assistant herstellen…";
|
: "Verbinding met Home Assistant herstellen…";
|
||||||
@@ -1015,17 +1017,22 @@ window.addEventListener("beforeunload", (e) => {
|
|||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
// Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy.
|
// 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() {
|
function poll() {
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
pollTimer = setTimeout(async () => {
|
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();
|
poll();
|
||||||
}, inventory.updates?.busy ? 3000 : 10000);
|
}, inventory.updates?.busy ? 3000 : 10000);
|
||||||
}
|
}
|
||||||
poll();
|
poll();
|
||||||
document.addEventListener("visibilitychange", async () => {
|
document.addEventListener("visibilitychange", async () => {
|
||||||
if (document.hidden) return;
|
if (document.hidden) return;
|
||||||
|
lastFull = Date.now();
|
||||||
await refresh();
|
await refresh();
|
||||||
poll();
|
poll();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: ESP Screen Manager
|
name: ESP Screen Manager
|
||||||
version: "0.2.19"
|
version: "0.2.20"
|
||||||
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
|
||||||
|
|||||||
@@ -213,6 +213,13 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
with self.assertRaises(ValueError): self.setup_manager(tmp)
|
with self.assertRaises(ValueError): self.setup_manager(tmp)
|
||||||
with self.assertRaises(ValueError): m.updates.set_auto('yes')
|
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):
|
async def test_http_endpoints(self):
|
||||||
from server import create_app
|
from server import create_app
|
||||||
from aiohttp.test_utils import TestClient, TestServer
|
from aiohttp.test_utils import TestClient, TestServer
|
||||||
@@ -222,6 +229,12 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
inventory = await (await client.get('/api/inventory')).json()
|
inventory = await (await client.get('/api/inventory')).json()
|
||||||
self.assertEqual(inventory['updates']['target'], FIRMWARE_VERSION)
|
self.assertEqual(inventory['updates']['target'], FIRMWARE_VERSION)
|
||||||
self.assertTrue(inventory['screens'][0]['update']['available'])
|
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']}
|
headers = {'X-Screen-CSRF': inventory['csrf']}
|
||||||
self.assertEqual((await client.put('/api/updates', headers=headers, json={'auto': True})).status, 200)
|
self.assertEqual((await client.put('/api/updates', headers=headers, json={'auto': True})).status, 200)
|
||||||
self.assertTrue(m.updates.auto)
|
self.assertTrue(m.updates.auto)
|
||||||
|
|||||||
Reference in New Issue
Block a user