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:
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user