Put ESP Screens in English and keep layouts when entity IDs change (0.2.40)

Everything a user sees or reads is now English: both boards' labels,
status replies and logs, the ESP Screens editor and Alerts cheatsheet,
and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md
and TROUBLESHOOTING.md replace the Dutch file names). Labels follow
Home Assistant's own English names (Heat/Cool modes, Heating/Cooling
actions, Away, Returning to dock, weather conditions), dates use English
abbreviations and the top bar writes numbers the English way. Docs use
HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools).
Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed.

Firmware 0.2.34 names its entities in English. Home Assistant registers
a renamed ESPHome entity under a new entity ID, so the inbox
text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and
the layout stored under the old ID would be lost. The manager now
recognises the screen by its device, during a run or after a restart,
and moves its layout and update history along; the Update button and
the nightly round follow the screen through the rename. Screens that
still run 0.2.33 or older keep working: both languages are matched.

Main is the only release branch from now on.

Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards:
see docs/TEST_RESULTS_0240.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 02:08:43 +02:00
parent f60eed3ff6
commit 3e959b3df9
87 changed files with 5049 additions and 11167 deletions
+114 -46
View File
@@ -14,7 +14,7 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BUILTIN, HEADER_MIN_FIRMWARE, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_reference, alert_service, controls_catalogue, discover, discover_screens, encode, extras, header_items, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import BUILTIN, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_reference, alert_service, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
import header_bar
from zoneinfo import ZoneInfo
@@ -31,7 +31,12 @@ FULL_REPEAT_SECONDS = 3600
HISTORY_SECONDS = 300
# Inbox states after which a screen needs the whole layout again: a restart, a ping that
# did not match, or a tile state that never arrived. Guarded so a slow batch cannot loop.
RESEND_STATES = frozenset({'Klaar voor tegelconfiguratie', 'Indeling opnieuw nodig', 'Tegels laden'})
# Firmware built before the English translation still reports the Dutch originals, so both
# forms are recognised until every board has been reflashed.
RESEND_STATES = frozenset({
'Ready for tile configuration', 'Resend needed', 'Loading tiles',
'Klaar voor tegelconfiguratie', 'Indeling opnieuw nodig', 'Tegels laden',
})
RESEND_GUARD_SECONDS = 120
FORECAST_SECONDS = 1800
@@ -78,7 +83,7 @@ class HomeAssistant:
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
raise ConnectionError('Home Assistant is niet verbonden.')
raise ConnectionError("Home Assistant isn't connected.")
self.next_id += 1
key = self.next_id
future = asyncio.get_running_loop().create_future()
@@ -100,7 +105,7 @@ class HomeAssistant:
if data.get('success'):
future.set_result(data.get('result'))
else:
future.set_exception(ConnectionError('Home Assistant heeft de opdracht geweigerd.'))
future.set_exception(ConnectionError('Home Assistant refused the command.'))
elif data.get('type') == 'event':
event = data.get('event', {})
body = event.get('data', {})
@@ -118,7 +123,7 @@ class HomeAssistant:
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 connection lost.')
def describe_close(self, connected):
"""Why and after how long the websocket ended; helps explain unexpected reconnects in the log."""
@@ -126,7 +131,7 @@ class HomeAssistant:
return ''
ws = self.ws
reason = ws.exception() if ws is not None else None
return ' na %d s, close-code %s%s' % (time.monotonic() - connected, ws.close_code if ws is not None else None,
return ' after %d s, close code %s%s' % (time.monotonic() - connected, ws.close_code if ws is not None else None,
f', {type(reason).__name__}' if reason else '')
async def registries(self):
@@ -144,7 +149,7 @@ class HomeAssistant:
await ws.receive_json(timeout=15)
await ws.send_json({'type': 'auth', 'access_token': self.token})
if (await ws.receive_json(timeout=15)).get('type') != 'auth_ok':
raise ConnectionError('Home Assistant-authenticatie mislukt.')
raise ConnectionError('Home Assistant authentication failed.')
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')
@@ -161,7 +166,7 @@ class HomeAssistant:
self.online = True
self.changed.set()
connected = time.monotonic()
LOG.info('Home Assistant verbonden')
LOG.info('Home Assistant connected')
# 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()
@@ -182,12 +187,12 @@ class HomeAssistant:
fetched = time.monotonic()
self.changed.set()
except (ConnectionError, TimeoutError, OSError, ValueError) as error:
LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)%s', type(error).__name__, self.describe_close(connected))
LOG.warning('Home Assistant temporarily unavailable (%s)%s', type(error).__name__, self.describe_close(connected))
except Exception as error:
LOG.warning('Verbinding opnieuw starten (%s)%s', type(error).__name__, self.describe_close(connected))
LOG.warning('Restarting connection (%s)%s', type(error).__name__, self.describe_close(connected))
finally:
if self.online:
LOG.info('Home Assistant-verbinding gesloten%s', self.describe_close(connected))
LOG.info('Home Assistant connection closed%s', self.describe_close(connected))
self.online = False
self.ws = None
if reader:
@@ -196,7 +201,7 @@ class HomeAssistant:
await reader
for future in self.pending.values():
if not future.done():
future.set_exception(ConnectionError('Verbinding verbroken.'))
future.set_exception(ConnectionError('Connection lost.'))
await asyncio.sleep(5)
async def send(self, inbox, message, action=None):
@@ -249,7 +254,7 @@ class HomeAssistant:
raw = bytearray()
async for chunk in response.content.iter_chunked(65536):
raw.extend(chunk)
if len(raw)>2*1024*1024: raise ValueError('Geschiedenis te groot.')
if len(raw)>2*1024*1024: raise ValueError('History too large.')
rows=json.loads(raw)
events=[]
begin=datetime.fromisoformat(start).timestamp(); span=hours*3600
@@ -278,11 +283,15 @@ class Manager:
self._screens_key, self._screens = None, []
self._watched_key, self._watched = None, set()
self._seen_registry = None
# Inbox entity ids seen this run with their Home Assistant device, and old inbox ids that a screen
# now reports under a new id (see follow_renamed_inboxes); the updater follows a screen through them.
self._inbox_devices, self.aliases = {}, {}
self._prefix_source, self._prefixes = None, {}
if self.path.exists():
raw = json.loads(self.path.read_text())
# Versioned persistent data. Never silently overwrite an unknown schema.
if raw.get('version') != 1 or not isinstance(raw.get('screens'), dict):
raise ValueError('Onbekende opslagversie; gegevens blijven ongewijzigd.')
raise ValueError('Unknown storage version; data stays unchanged.')
self.layouts = {key: validate_layout(value) for key, value in raw['screens'].items()}
def inventory(self):
@@ -306,10 +315,74 @@ class Manager:
key = (id(ha.registry), id(ha.devices), id(ha.areas), tuple(ha.states.get(item['entity_id'], {}).get('state') for item in items))
if key != self._screens_key:
self._screens_key, self._screens = key, discover_screens(items, ha.states, ha.devices, ha.areas)
self.follow_renamed_inboxes(items)
return [dict(screen) for screen in self._screens]
def screen(self, inbox):
return next((s for s in self.screens() if s['id'] == inbox), None)
screens = self.screens()
inbox = self.aliases.get(inbox, inbox)
return next((s for s in screens if s['id'] == inbox), None)
def device_prefixes(self, device):
registry = getattr(self.ha, 'registry', [])
if self._prefix_source is not registry:
self._prefix_source, self._prefixes = registry, {}
if device not in self._prefixes:
self._prefixes[device] = device_prefixes(registry, device)
return self._prefixes[device]
def follow_renamed_inboxes(self, items):
"""Keep a screen's layout and update history when its inbox entity gets a new entity id.
Firmware 0.2.34 renamed the inbox from "Tegelinstellingen" to "Tile settings". Home Assistant then
removes the old entity and registers the renamed one under a new id, which would orphan the layout
stored under the old id. The screen is recognised by its device: an inbox this run saw on the same
device, or (after a restart) a stored inbox id that carries one of the device's entity id prefixes."""
current = {item['entity_id']: item.get('device_id') for item in items
if item.get('platform') == 'esphome' and item['entity_id'].startswith('text.') and item.get('device_id')
and item.get('original_name') in NAME_TILE_SETTINGS and not item.get('disabled_by')}
stored = (set(self.layouts) | set(self.updates.hosts) | set(self.updates.results)) - set(current) - set(self.aliases)
for new, device in current.items():
olds = {old for old, seen in self._inbox_devices.items() if seen == device and old not in current}
candidates = [old for old in stored if inbox_prefix(old) is not None]
if candidates:
prefixes = self.device_prefixes(device)
olds |= {old for old in candidates if inbox_prefix(old) in prefixes}
for old in sorted(olds - set(self.aliases)):
self.rename_inbox(old, new)
self._inbox_devices.update(current)
def write_layouts(self, layouts):
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix('.tmp')
with open(temp, 'w', encoding='utf8') as handle:
os.chmod(temp, 0o600)
json.dump({'version': 1, 'screens': layouts}, handle, ensure_ascii=False)
handle.flush()
os.fsync(handle.fileno())
temp.replace(self.path)
def rename_inbox(self, old, new):
"""Move everything kept under an old inbox id to the id the same screen reports under now."""
moved = []
if old in self.layouts and new not in self.layouts:
layouts = {(new if key == old else key): value for key, value in self.layouts.items()}
self.write_layouts(layouts)
self.layouts = layouts
moved.append('layout')
elif old in self.layouts:
LOG.warning('Screen %s already has a layout; the one stored under %s stays untouched', new, old)
if self.updates.renamed(old, new):
moved.append('update history')
for store in (self.sent, self.status, self.last, self.pinged):
store.pop(old, None)
self.aliases = {**{key: (new if value == old else value) for key, value in self.aliases.items()}, old: new}
self._inbox_devices.pop(old, None)
LOG.info('Screen %s now reports as %s%s', old, new, f"; moved its {' and '.join(moved)}" if moved else '')
if moved:
self.history_wake.set()
self.ha.changed.set()
self.notify()
def firmware_version(self, inbox, screen=None):
if screen is None:
@@ -356,13 +429,15 @@ class Manager:
def save(self, inbox, data):
screens, entities = self.inventory()
# A page opened before a screen's inbox got a new id still saves to the right screen.
inbox = self.aliases.get(inbox, inbox)
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.')
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
layout = validate_layout(data)
needed = self.needs_firmware(inbox, layout, screen)
if needed:
raise ValueError(f"Installeer eerst schermfirmware {needed} of nieuwer voor deze tegels.")
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
# A still-open older UI may save tiles without the new optional settings or top bar.
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy()
@@ -376,7 +451,7 @@ class Manager:
if 'settings' in layout and 'rotation' not in data.get('settings',{}):
layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0)
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.')
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
for tile in layout['tiles']:
old_options=old_tiles.get(tile['entity'],{}).get('options',{})
@@ -393,21 +468,14 @@ class Manager:
layout = validate_layout(layout)
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.')
raise ValueError('A chosen entity no longer exists. Look up the new entity.')
if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)):
raise ValueError('Een entiteit in de bovenbalk bestaat niet meer. Kies een andere.')
raise ValueError('An entity in the top bar no longer exists. Choose a different one.')
updated = {**self.layouts, inbox: layout}
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix('.tmp')
with open(temp, 'w', encoding='utf8') as handle:
os.chmod(temp, 0o600)
json.dump({'version': 1, 'screens': updated}, handle, ensure_ascii=False)
handle.flush()
os.fsync(handle.fileno())
temp.replace(self.path)
self.write_layouts(updated)
self.layouts = updated
self.sent.pop(inbox, None)
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
self.status[inbox] = 'Saved; waiting for sync'
self.history_wake.set()
self.ha.changed.set()
self.notify()
@@ -497,7 +565,7 @@ class Manager:
screen = self.screen(inbox) or {}
needed = self.needs_firmware(inbox, layout, screen)
if needed:
self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels"
self.status[inbox]=f"Layout saved; firmware {needed}+ needed for these tiles"
return False
tiles = layout['tiles']
layout_msg = self.layout_message(inbox, layout, screen)
@@ -540,7 +608,7 @@ class Manager:
self.last[inbox] = time.monotonic()
if outgoing:
self.pinged[inbox] = time.monotonic()
self.status[inbox] = 'Verzonden naar Home Assistant'
self.status[inbox] = 'Sent to Home Assistant'
return bool(outgoing)
async def ping(self, inbox, screen):
@@ -599,7 +667,7 @@ class Manager:
before = self.status.get(inbox)
if not screen['online']:
self.sent.pop(inbox, None)
self.status[inbox] = 'Scherm offline; wijzigingen bewaard'
self.status[inbox] = 'Screen offline; changes saved'
changed = changed or self.status[inbox] != before
continue
if inbox not in self.layouts:
@@ -617,8 +685,8 @@ class Manager:
await self.ping(inbox, screen)
except Exception as error:
self.sent.pop(inbox, None)
self.status[inbox] = 'Verzenden mislukt; automatisch opnieuw proberen'
LOG.warning('Schermsynchronisatie opnieuw proberen (%s)', type(error).__name__)
self.status[inbox] = 'Sending failed; retrying automatically'
LOG.warning('Retrying screen sync (%s)', type(error).__name__)
await asyncio.sleep(1)
changed = changed or self.status.get(inbox) != before
if changed:
@@ -647,7 +715,7 @@ class Manager:
try:
found.update({(entity, hours): values for entity, values in (await self.ha.statistics(entities, hours)).items()})
except Exception as error:
LOG.warning('Statistieken ophalen mislukt (%s); geschiedenis per sensor', type(error).__name__)
LOG.warning('Fetching statistics failed (%s); falling back to per-sensor history', type(error).__name__)
changed = set()
for key in due:
values = found.get(key)
@@ -677,7 +745,7 @@ class Manager:
try:
await self.refresh_histories()
except Exception as error:
LOG.warning('Geschiedenis vernieuwen mislukt (%s)', type(error).__name__)
LOG.warning('Refreshing history failed (%s)', type(error).__name__)
def create_app(manager, development=False):
csrf = secrets.token_urlsafe(32)
@@ -685,15 +753,15 @@ def create_app(manager, development=False):
async def guard(request, handler):
allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'}
if request.remote not in allowed:
raise web.HTTPForbidden(text='Open deze pagina via Home Assistant.')
raise web.HTTPForbidden(text='Open this page through Home Assistant.')
if request.method not in {'GET', 'HEAD'} and request.headers.get('X-Screen-CSRF') != csrf:
raise web.HTTPForbidden(text='Vernieuw deze pagina en probeer opnieuw.')
raise web.HTTPForbidden(text='Refresh this page and try again.')
try:
response = await handler(request)
except ValueError as error:
return web.json_response({'error': str(error)}, status=400)
except (TypeError, KeyError):
return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400)
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
if response.prepared:
return response # streamed (SSE) responses set their headers before prepare()
response.headers['Cache-Control'] = 'no-store'
@@ -712,8 +780,8 @@ def create_app(manager, development=False):
screens = manager.screens()
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['layout'] = manager.layouts.get(screen['id'], {'title': 'Home', 'tiles': []})
screen['delivery'] = manager.status.get(screen['id'], 'Choose your first tiles')
screen['update'] = manager.updates.state_for(screen, profiles)
screen['alert_action'] = alert_service(screen.get('node'))
screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert')
@@ -731,7 +799,7 @@ def create_app(manager, development=False):
payload['controls'] = controls_catalogue()
payload['icons'] = tile_icons.editor()
payload['alerts'] = alert_reference()
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Built into the screen', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}}
return web.json_response(payload)
@@ -778,9 +846,9 @@ def create_app(manager, development=False):
manager.updates.set_auto((await request.json()).get('auto'))
return web.json_response(manager.updates.summary())
async def inspector(request):
inbox=request.match_info['inbox']
screen=manager.screen(inbox)
if screen is None: raise ValueError('Onbekend scherm.')
screen=manager.screen(request.match_info['inbox'])
inbox=manager.aliases.get(request.match_info['inbox'], request.match_info['inbox'])
if screen is None: raise ValueError('Unknown screen.')
layout=manager.layouts.get(inbox,{'tiles':[]})
return web.json_response({'screen':screen,
'delivery':manager.status.get(inbox), 'layout':layout,
@@ -819,7 +887,7 @@ async def main():
if development and os.environ.get('HA_TOKEN_FILE'):
token = Path(os.environ['HA_TOKEN_FILE']).read_text().strip()
if not token:
raise SystemExit('Geen Home Assistant-toegang. Start de app via Supervisor.')
raise SystemExit('No Home Assistant access. Start the app via Supervisor.')
async with ClientSession(timeout=ClientTimeout(total=20)) as session:
ha = HomeAssistant(session, os.environ.get('HA_API', 'http://supervisor/core/api'), token)
manager = Manager(ha, Path(os.environ.get('SCREEN_DATA', '/data')) / 'screens.json')