Cache ESPHome profile metadata and stop polling hidden tabs (0.2.19)

/api/inventory re-read every ESPHome profile with pure-Python PyYAML up to
four times per request, which blocked the event loop for seconds on a Pi.
Profiles are now cached per file on (inode, mtime_ns, size), parsed with
libyaml when available, and read once per request. The sync loop computes
the inventory once per iteration. The page pauses polling while hidden.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 11:19:16 +02:00
parent ef0ca8b350
commit b67cd33fb3
7 changed files with 117 additions and 41 deletions
+7
View File
@@ -1,3 +1,10 @@
## 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).
- Het overzicht en de updatestatus lezen de profiellijst en de HA-inventaris nog één keer per verzoek; de synchronisatielus rekent de inventaris één keer per ronde uit in plaats van per scherm.
- De pagina pollt niet meer in een verborgen tabblad en ververst direct zodra het tabblad weer zichtbaar is.
- Alleen de app verandert; firmware 0.2.18 blijft actueel.
## 0.2.18 (firmware 0.2.18)
- **Eigen icoon per tegel**: in het tegelpaneel staat onder de naam het veld **Icoon**. Kies uit 150 iconen in twaalf groepen (verlichting, ruimtes, klimaat, weer, media en muziek, beveiliging, apparaten, energie, zonwering, tuin en huisdieren, mensen en onderweg, overig), met zoeken op Nederlandse naam, Engelse MDI-naam of groep. De mockup en de kop van het paneel tonen het gekozen icoon direct.
+34 -13
View File
@@ -12,10 +12,24 @@ import time
import yaml
from core import installation_yaml
class LenientLoader(yaml.SafeLoader):
# libyaml parses a 300 KB profile roughly ten times faster than the pure-Python loader.
class LenientLoader(yaml.CSafeLoader if getattr(yaml, '__with_libyaml__', False) else yaml.SafeLoader):
"""Reads profile metadata without resolving !secret or !include."""
LenientLoader.add_multi_constructor('!', lambda loader, suffix, node: None)
def profile_meta(text):
"""{'node', 'friendly'} from profile YAML, or None when it has no esphome block."""
data = yaml.load(text, Loader=LenientLoader)
block = data.get('esphome') if isinstance(data, dict) else None
if not isinstance(block, dict):
return None
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
return {'node': resolve(block.get('name')), 'friendly': resolve(block.get('friendly_name'))}
class Firmware:
def __init__(self, root, data):
self.root, self.data = Path(root).resolve(), Path(data)
@@ -23,6 +37,7 @@ class Firmware:
self.task = None
self.logs = deque(maxlen=300)
self.process = None
self._names = {} # file -> (stat signature, meta or None); parsed only when the file changes
def profiles(self):
if not self.root.exists(): return []
@@ -30,22 +45,28 @@ class Firmware:
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 = {}
"""{file: {'node': esphome name, 'friendly': friendly name}} for every readable profile.
Parsing is cached per file on inode, mtime and size, so a request only pays for changed
profiles. The dict is rebuilt from the current directory listing, so deleted files drop out.
"""
found, cache = {}, {}
for entry in self.profiles():
path = self.root / entry['file']
try:
data = yaml.load((self.root / entry['file']).read_text(), Loader=LenientLoader)
st = path.stat()
key = (st.st_ino, st.st_mtime_ns, st.st_size)
cached = self._names.get(entry['file'])
if cached and cached[0] == key:
meta = cached[1]
else:
meta = profile_meta(path.read_text())
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'))}
cache[entry['file']] = (key, meta)
if meta:
found[entry['file']] = meta
self._names = cache
return found
def ports(self):
+19 -14
View File
@@ -182,8 +182,9 @@ class Manager:
def inventory(self):
return discover(self.ha.registry, self.ha.states, self.ha.devices, self.ha.areas)
def firmware_version(self, inbox):
screen=next((s for s in self.inventory()[0] if s['id']==inbox), {})
def firmware_version(self, inbox, screen=None):
if screen is None:
screen=next((s for s in self.inventory()[0] if s['id']==inbox), {})
try:
version=tuple(int(part) for part in screen.get('firmware','').split('.'))
return version if len(version)==3 else None
@@ -194,18 +195,20 @@ class Manager:
version=self.firmware_version(inbox)
return bool(version) and version >= (0,2,7)
def needs_firmware(self, inbox, layout):
def needs_firmware(self, inbox, layout, screen=None):
"""Version string the screen must run first, or None when the layout can be sent."""
needed=min_firmware(layout)
if needed and not ((self.firmware_version(inbox) or (0,0,0)) >= needed):
if needed and not ((self.firmware_version(inbox, screen) or (0,0,0)) >= needed):
return '.'.join(str(part) for part in needed)
return None
def save(self, inbox, data):
if inbox not in {s['id'] for s in self.inventory()[0]}:
screens, entities = self.inventory()
screen=next((s for s in screens if s['id']==inbox), None)
if screen is None:
raise ValueError('Dit is geen gekoppeld ESP-scherm. Vernieuw het overzicht.')
layout = validate_layout(data)
needed = self.needs_firmware(inbox, layout)
needed = self.needs_firmware(inbox, layout, screen)
if needed:
raise ValueError(f"Installeer eerst schermfirmware {needed} of nieuwer voor deze tegels.")
# A still-open older UI may save tiles without the new optional settings.
@@ -215,7 +218,6 @@ class Manager:
layout['settings']['swipe_pages']=self.layouts.get(inbox,{}).get('settings',{}).get('swipe_pages',False)
if 'settings' in layout and 'rotation' not in data.get('settings',{}):
layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0)
screen=next(s for s in self.inventory()[0] if s['id']==inbox)
if layout.get('settings',{}).get('rotation',0) and screen.get('board')!='guition':
raise ValueError('Rotatie vereist een Guition met firmware 0.2.9 of nieuwer.')
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
@@ -226,7 +228,7 @@ class Manager:
tile['options'][key]=old_options[key]
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
tile['options'] = old_tiles[tile['entity']]['options'].copy()
known = {e['id'] for e in self.inventory()[1]} | set(BUILTIN)
known = {e['id'] for e in entities} | set(BUILTIN)
if any(t['entity'] not in known for t in layout['tiles']):
raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.')
updated = {**self.layouts, inbox: layout}
@@ -251,8 +253,10 @@ class Manager:
entry=(time.monotonic(),value);store[key]=entry
return entry[1]
async def sync_one(self, inbox, layout, force=False):
needed = self.needs_firmware(inbox, layout)
async def sync_one(self, inbox, layout, force=False, screen=None):
if screen is None:
screen=next((s for s in self.inventory()[0] if s['id']==inbox), {})
needed = self.needs_firmware(inbox, layout, screen)
if needed:
self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels"
return
@@ -260,7 +264,7 @@ class Manager:
if 'settings' in layout:
messages[0]['settings'] = {k:v for k,v in layout['settings'].items() if k not in ('swipe_pages','rotation')}
messages[0]['swipe_pages'] = layout['settings'].get('swipe_pages',False)
if next((s.get('board') for s in self.inventory()[0] if s['id']==inbox),None)=='guition':
if screen.get('board')=='guition':
messages[0]['rotation'] = layout['settings'].get('rotation',0)
for i,tile in enumerate(layout['tiles']):
forecast=None
@@ -321,7 +325,7 @@ class Manager:
force = time.monotonic() - self.last.get(inbox, 0) >= 25
# last records full keepalive, not unrelated HA state events.
before = self.last.get(inbox, 0)
await self.sync_one(inbox, self.layouts[inbox], force)
await self.sync_one(inbox, self.layouts[inbox], force, screen)
if not force:
self.last[inbox] = before
except Exception as error:
@@ -357,13 +361,14 @@ def create_app(manager, development=False):
return web.FileResponse(static / 'index.html')
async def inventory(request):
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)
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()})
'backgrounds': TILE_BACKGROUNDS, 'icons': tile_icons.editor(), 'builtin': builtin, 'updates': manager.updates.summary(screens, profiles)})
async def save(request):
manager.save(request.match_info['inbox'], await request.json())
return web.json_response({'saved': True})
+13 -4
View File
@@ -1014,12 +1014,21 @@ window.addEventListener("beforeunload", (e) => {
}
});
refresh();
(function poll() {
setTimeout(async () => {
await refresh();
// Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy.
let pollTimer;
function poll() {
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
if (!document.hidden) await refresh();
poll();
}, inventory.updates?.busy ? 3000 : 10000);
})();
}
poll();
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
await refresh();
poll();
});
// Shared firmware workspace; always select a concrete profile and upload target.
let firmwarePoll;
+16 -9
View File
@@ -57,9 +57,10 @@ class Updater:
def screen(self, inbox):
return next((s for s in self.manager.inventory()[0] if s['id'] == inbox), None)
def resolve(self, screen):
def resolve(self, screen, profiles=None):
"""Profile file and OTA address for a screen; None when the add-on cannot tell."""
profiles = self.manager.firmware.profile_names()
if profiles is None:
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.
@@ -67,9 +68,9 @@ class Updater:
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):
def state_for(self, screen, profiles=None):
version = parse_version(screen.get('firmware'))
profile, host = self.resolve(screen)
profile, host = self.resolve(screen, profiles)
if screen['id'] == self.current:
state = 'running'
elif screen['id'] in self.queue:
@@ -80,13 +81,19 @@ class Updater:
'profile': profile, 'host': host, 'state': state, 'phase': self.phase if state == 'running' else None,
'result': self.results.get(screen['id'])}
def summary(self):
def summary(self, screens=None, profiles=None):
return {'auto': self.auto, 'target': FIRMWARE_VERSION, 'busy': self.current,
'pending': len(self.pending()), 'last_round': self.last_round}
'pending': len(self.pending(screens, profiles)), '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 pending(self, screens=None, profiles=None):
# Callers that already hold the inventory and profile list pass them in; the inventory
# handler otherwise re-reads every ESPHome profile several times per request.
if screens is None:
screens = self.manager.inventory()[0]
if profiles is None:
profiles = self.manager.firmware.profile_names()
return [s['id'] for s in screens
if s['online'] and self.state_for(s, profiles)['available'] and all(self.resolve(s, profiles))]
def set_auto(self, enabled):
if not isinstance(enabled, bool):
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.18"
version: "0.2.19"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen