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
+3 -3
View File
@@ -28,7 +28,7 @@ async def main():
p.add_argument('--secrets',type=Path,default=Path(__file__).resolve().parents[1]/'secrets.yaml')
p.add_argument('--output',type=Path,required=True)
args=p.parse_args()
if args.output.exists():raise ValueError('Output bestaat al; kies een andere naam.')
if args.output.exists():raise ValueError('Output already exists; choose a different name.')
client=APIClient(args.host,6053,noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'],expected_name=args.name)
await client.connect(login=True)
try:
@@ -43,9 +43,9 @@ async def main():
client.subscribe_logs(log,log_level=LogLevel.LOG_LEVEL_INFO,dump_config=False)
await client.execute_service(next(s for s in services if s.name=='ui_snapshot'),{})
await asyncio.wait_for(done.wait(),timeout=60)
if errors or set(rows)!=set(range(240)):raise ValueError(f'Onvolledig beeld: {len(rows)}/240 rijen; {errors}')
if errors or set(rows)!=set(range(240)):raise ValueError(f'Incomplete image: {len(rows)}/240 rows; {errors}')
with args.output.open('xb') as f:f.write(png(rows))
print(f'PASS: LVGL-render opgeslagen in {args.output}')
print(f'PASS: LVGL render saved to {args.output}')
finally:await client.disconnect()
if __name__=='__main__':asyncio.run(main())
+1 -1
View File
@@ -9,7 +9,7 @@ async def main():
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['touch_diagnostics', 'end_touch_diagnostics', 'ui_state', 'light_controls_preview'])
parser.add_argument('--host', default='cyd-2432s028.local')
parser.add_argument('--name', default='cyd-2432s028', help='Verwachte DEVICE_NAME')
parser.add_argument('--name', default='cyd-2432s028', help='Expected DEVICE_NAME')
parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
args = parser.parse_args()
secret = yaml.safe_load(args.secrets.read_text())
+1 -1
View File
@@ -9,7 +9,7 @@ from aioesphomeapi import APIClient, LogLevel
async def main():
parser = argparse.ArgumentParser()
parser.add_argument('--host', default='cyd-2432s028.local')
parser.add_argument('--name', default='cyd-2432s028', help='Verwachte DEVICE_NAME')
parser.add_argument('--name', default='cyd-2432s028', help='Expected DEVICE_NAME')
parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
args = parser.parse_args()
secrets = yaml.safe_load(args.secrets.read_text())
+54 -51
View File
@@ -17,71 +17,71 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app
from core import extras, packets, state_message, validate_layout # noqa: E402
def demo_layout():
return validate_layout({'title': 'Demo kaarten', 'tiles': [
return validate_layout({'title': 'Demo cards', 'tiles': [
{'entity': 'screen.clock', 'name': '', 'options': {'display': 'analog', 'background': 'none'}},
{'entity': 'weather.demo', 'name': 'Buiten', 'options': {'display': 'forecast'}},
{'entity': 'sensor.demo_temperatuur', 'name': 'Woonkamer', 'options': {'display': 'graph', 'icon': 'thermometer'}},
{'entity': 'weather.demo', 'name': 'Outside', 'options': {'display': 'forecast'}},
{'entity': 'sensor.demo_temperature', 'name': 'Living room', 'options': {'display': 'graph', 'icon': 'thermometer'}},
{'entity': 'person.demo', 'name': 'Max', 'options': {}},
{'entity': 'timer.demo', 'name': 'Eieren', 'options': {'icon': 'chef-hat'}},
{'entity': 'sun.sun', 'name': 'Zon', 'options': {'display': 'sunpath'}},
{'entity': 'sensor.demo_energie', 'name': 'Verbruik vandaag', 'options': {'display': 'graph', 'size': 'wide'}},
{'entity': 'light.demo', 'name': 'Tafellamp', 'options': {'display': 'watch', 'size': 'wide', 'icon': 'lamp'}},
{'entity': 'weather.demo_standaard', 'name': 'Weer nu', 'options': {}},
{'entity': 'timer.demo', 'name': 'Eggs', 'options': {'icon': 'chef-hat'}},
{'entity': 'sun.sun', 'name': 'Sun', 'options': {'display': 'sunpath'}},
{'entity': 'sensor.demo_energy', 'name': 'Usage today', 'options': {'display': 'graph', 'size': 'wide'}},
{'entity': 'light.demo', 'name': 'Table lamp', 'options': {'display': 'watch', 'size': 'wide', 'icon': 'lamp'}},
{'entity': 'weather.demo_default', 'name': 'Weather now', 'options': {}},
]})
# Direct controls on wide cards (firmware 0.2.19+): one card per control set.
def controls_layout():
return validate_layout({'title': 'Directe bediening', 'tiles': [
{'entity': 'cover.demo_gordijn', 'name': 'Gordijnen Raambekleding', 'options': {'size': 'wide', 'icon': 'curtains'}},
{'entity': 'climate.demo_airco', 'name': 'Airco', 'options': {'size': 'wide'}},
return validate_layout({'title': 'Direct control', 'tiles': [
{'entity': 'cover.demo_curtain', 'name': 'Curtains Window covering', 'options': {'size': 'wide', 'icon': 'curtains'}},
{'entity': 'climate.demo_ac', 'name': 'AC', 'options': {'size': 'wide'}},
{'entity': 'media_player.demo_sonos', 'name': 'Sonos', 'options': {'size': 'wide', 'icon': 'speaker'}},
{'entity': 'climate.demo_cv', 'name': 'Verwarming', 'options': {'size': 'wide', 'controls': 'mode', 'icon': 'radiator'}},
{'entity': 'media_player.demo_radio', 'name': 'Sonos slaapkamer', 'options': {'size': 'wide', 'controls': 'playback', 'icon': 'radio'}},
{'entity': 'climate.demo_heating', 'name': 'Heating', 'options': {'size': 'wide', 'controls': 'mode', 'icon': 'radiator'}},
{'entity': 'media_player.demo_radio', 'name': 'Sonos bedroom', 'options': {'size': 'wide', 'controls': 'playback', 'icon': 'radio'}},
{'entity': 'vacuum.demo_robot', 'name': 'Pippa', 'options': {'size': 'wide'}},
{'entity': 'switch.demo_bureau', 'name': 'Bureau', 'options': {'size': 'wide', 'background': 'blue', 'icon': 'power-socket-eu'}},
{'entity': 'light.demo_tafellamp', 'name': 'Tafellamp', 'options': {'size': 'wide', 'controls': 'brightness', 'icon': 'lamp'}},
{'entity': 'number.demo_doel', 'name': 'Doel luchtvochtigheid', 'options': {'size': 'wide', 'icon': 'water-percent'}},
{'entity': 'select.demo_stand', 'name': 'Verwarmingsstand', 'options': {'size': 'wide', 'icon': 'thermostat'}},
{'entity': 'timer.demo_eieren', 'name': 'Eieren', 'options': {'size': 'wide', 'icon': 'chef-hat'}},
{'entity': 'scene.demo_avond', 'name': 'Avond', 'options': {'size': 'wide', 'icon': 'sofa'}},
{'entity': 'fan.demo_ventilator', 'name': 'Ventilator', 'options': {'size': 'wide'}},
{'entity': 'cover.demo_rolluik', 'name': 'Rolluik', 'options': {'size': 'wide', 'controls': 'position', 'icon': 'window-shutter'}},
{'entity': 'light.demo_plafond', 'name': 'Plafondlamp', 'options': {'size': 'wide', 'icon': 'ceiling-light'}},
{'entity': 'weather.demo_buiten', 'name': 'Buiten', 'options': {'display': 'forecast'}},
{'entity': 'script.demo_tv', 'name': 'TV aanzetten', 'options': {'icon': 'television'}},
{'entity': 'scene.demo_ochtend', 'name': 'Ochtend', 'options': {'icon': 'weather-sunset-up'}},
{'entity': 'switch.demo_desk', 'name': 'Desk', 'options': {'size': 'wide', 'background': 'blue', 'icon': 'power-socket-eu'}},
{'entity': 'light.demo_table_lamp', 'name': 'Table lamp', 'options': {'size': 'wide', 'controls': 'brightness', 'icon': 'lamp'}},
{'entity': 'number.demo_target', 'name': 'Target humidity', 'options': {'size': 'wide', 'icon': 'water-percent'}},
{'entity': 'select.demo_mode', 'name': 'Heating mode', 'options': {'size': 'wide', 'icon': 'thermostat'}},
{'entity': 'timer.demo_eggs', 'name': 'Eggs', 'options': {'size': 'wide', 'icon': 'chef-hat'}},
{'entity': 'scene.demo_evening', 'name': 'Evening', 'options': {'size': 'wide', 'icon': 'sofa'}},
{'entity': 'fan.demo_fan', 'name': 'Fan', 'options': {'size': 'wide'}},
{'entity': 'cover.demo_shutter', 'name': 'Shutter', 'options': {'size': 'wide', 'controls': 'position', 'icon': 'window-shutter'}},
{'entity': 'light.demo_ceiling', 'name': 'Ceiling light', 'options': {'size': 'wide', 'icon': 'ceiling-light'}},
{'entity': 'weather.demo_outside', 'name': 'Outside', 'options': {'display': 'forecast'}},
{'entity': 'script.demo_tv', 'name': 'Turn on TV', 'options': {'icon': 'television'}},
{'entity': 'scene.demo_morning', 'name': 'Morning', 'options': {'icon': 'weather-sunset-up'}},
]})
def controls_states(now):
end = now + timedelta(minutes=4, seconds=32)
return {
'cover.demo_gordijn': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
'climate.demo_airco': {'state': 'cool', 'attributes': {'current_temperature': 21.5, 'temperature': 20, 'min_temp': 16, 'max_temp': 32, 'target_temp_step': 1.0, 'hvac_modes': ['off', 'heat_cool', 'cool', 'heat', 'fan_only', 'dry'], 'fan_modes': ['auto', 'low', 'medium', 'high'], 'fan_mode': 'low', 'swing_modes': ['off', 'both', 'vertical', 'horizontal'], 'swing_mode': 'off', 'supported_features': 425}},
'cover.demo_curtain': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
'climate.demo_ac': {'state': 'cool', 'attributes': {'current_temperature': 21.5, 'temperature': 20, 'min_temp': 16, 'max_temp': 32, 'target_temp_step': 1.0, 'hvac_modes': ['off', 'heat_cool', 'cool', 'heat', 'fan_only', 'dry'], 'fan_modes': ['auto', 'low', 'medium', 'high'], 'fan_mode': 'low', 'swing_modes': ['off', 'both', 'vertical', 'horizontal'], 'swing_mode': 'off', 'supported_features': 425}},
'media_player.demo_sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599}},
'climate.demo_cv': {'state': 'heat', 'attributes': {'current_temperature': 19.5, 'temperature': 21, 'hvac_action': 'heating', 'hvac_modes': ['off', 'heat', 'auto']}},
'climate.demo_heating': {'state': 'heat', 'attributes': {'current_temperature': 19.5, 'temperature': 21, 'hvac_action': 'heating', 'hvac_modes': ['off', 'heat', 'auto']}},
'media_player.demo_radio': {'state': 'playing', 'attributes': {'volume_level': 0.15, 'media_title': 'NPO Radio 2', 'supported_features': 8321599}},
'vacuum.demo_robot': {'state': 'docked', 'attributes': {'battery_level': 100, 'fan_speed': 'max', 'fan_speed_list': ['quiet', 'balanced', 'turbo', 'max'], 'supported_features': 30524}},
'switch.demo_bureau': {'state': 'on', 'attributes': {}},
'light.demo_tafellamp': {'state': 'on', 'attributes': {'brightness': 163}},
'number.demo_doel': {'state': '55', 'attributes': {'min': 30, 'max': 70, 'step': 5, 'unit_of_measurement': '%'}},
'select.demo_stand': {'state': 'Comfort', 'attributes': {'options': ['Eco', 'Comfort', 'Boost']}},
'timer.demo_eieren': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
'scene.demo_avond': {'state': (now - timedelta(hours=3)).isoformat(), 'attributes': {}},
'fan.demo_ventilator': {'state': 'off', 'attributes': {'percentage': 0}},
'cover.demo_rolluik': {'state': 'open', 'attributes': {'current_position': 35, 'supported_features': 15}},
'light.demo_plafond': {'state': 'off', 'attributes': {}},
'weather.demo_buiten': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C', 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h', 'apparent_temperature': 17.1}},
'switch.demo_desk': {'state': 'on', 'attributes': {}},
'light.demo_table_lamp': {'state': 'on', 'attributes': {'brightness': 163}},
'number.demo_target': {'state': '55', 'attributes': {'min': 30, 'max': 70, 'step': 5, 'unit_of_measurement': '%'}},
'select.demo_mode': {'state': 'Comfort', 'attributes': {'options': ['Eco', 'Comfort', 'Boost']}},
'timer.demo_eggs': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
'scene.demo_evening': {'state': (now - timedelta(hours=3)).isoformat(), 'attributes': {}},
'fan.demo_fan': {'state': 'off', 'attributes': {'percentage': 0}},
'cover.demo_shutter': {'state': 'open', 'attributes': {'current_position': 35, 'supported_features': 15}},
'light.demo_ceiling': {'state': 'off', 'attributes': {}},
'weather.demo_outside': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C', 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h', 'apparent_temperature': 17.1}},
'script.demo_tv': {'state': 'off', 'attributes': {'last_triggered': (now - timedelta(hours=2, minutes=8)).isoformat()}},
'scene.demo_ochtend': {'state': (now - timedelta(days=1, hours=5)).isoformat(), 'attributes': {}},
'scene.demo_morning': {'state': (now - timedelta(days=1, hours=5)).isoformat(), 'attributes': {}},
}
def demo_states(now):
end = now + timedelta(minutes=4, seconds=32)
return {
'weather.demo': {'state': 'partlycloudy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C'}},
'weather.demo_standaard': {'state': 'rainy', 'attributes': {'temperature': 12.0, 'temperature_unit': '°C'}},
'sensor.demo_temperatuur': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C'}},
'sensor.demo_energie': {'state': '7.42', 'attributes': {'unit_of_measurement': 'kWh'}},
'weather.demo_default': {'state': 'rainy', 'attributes': {'temperature': 12.0, 'temperature_unit': '°C'}},
'sensor.demo_temperature': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C'}},
'sensor.demo_energy': {'state': '7.42', 'attributes': {'unit_of_measurement': 'kWh'}},
'person.demo': {'state': 'home', 'attributes': {'icon': 'mdi:account-child'}},
'timer.demo': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
'sun.sun': {'state': 'above_horizon', 'attributes': {'next_rising': (now + timedelta(hours=9)).isoformat(), 'next_setting': (now + timedelta(hours=2)).isoformat()}},
@@ -122,19 +122,20 @@ def messages(inbox, rotate=0, digital=False, wide=False, controls=False):
async def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--host', required=True)
p.add_argument('--name', required=True, help='Verwachte DEVICE_NAME')
p.add_argument('--name', required=True, help='Expected DEVICE_NAME')
p.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
p.add_argument('--rotate', type=int, default=0, help='Zet tegel N vooraan, zodat latere pagina\'s op pagina 1 komen')
p.add_argument('--digital', action='store_true', help='Digitale klok op een enkele tegel in plaats van de analoge kalenderkaart zonder achtergrond')
p.add_argument('--wide', action='store_true', help='Analoge klok dubbelbreed (wijzerplaat met digitale tijd en datum)')
p.add_argument('--controls', action='store_true', help='Dubbelbrede kaarten met directe bediening (firmware 0.2.19+), drie per pagina')
p.add_argument('--rotate', type=int, default=0, help='Put tile N first, so later pages land on page 1')
p.add_argument('--digital', action='store_true', help='Digital clock on a single tile instead of the analog calendar card without a background')
p.add_argument('--wide', action='store_true', help='Analog clock double-width (dial with digital time and date)')
p.add_argument('--controls', action='store_true', help='Double-width cards with direct control (firmware 0.2.19+), three per page')
args = p.parse_args()
client = APIClient(args.host, 6053, noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'],
client_info='Demo layout', expected_name=args.name)
await client.connect(login=True)
try:
entities, _ = await client.list_entities_services()
inbox = next(e for e in entities if type(e).__name__ == 'TextInfo' and e.name == 'Tegelinstellingen')
# Firmware built before the English translation still names this entity in Dutch.
inbox = next(e for e in entities if type(e).__name__ == 'TextInfo' and e.name in ('Tile settings', 'Tegelinstellingen'))
# The firmware answers through the inbox entity's state, not the log.
replies = []
def state(update):
@@ -148,9 +149,11 @@ async def main():
client.text_command(inbox.key, packet)
await asyncio.sleep(0.05)
await asyncio.sleep(1.5)
print(f'Demo-indeling verstuurd in {time.monotonic() - started:.1f}s; inbox meldt: {" / ".join(dict.fromkeys(replies)) or "(geen statuswijziging)"}')
if not any(r in ('Indeling ontvangen', 'Gesynchroniseerd', 'Tegels laden') for r in replies):
raise SystemExit('De firmware accepteerde de demo-indeling niet.')
print(f'Demo layout sent in {time.monotonic() - started:.1f}s; inbox reports: {" / ".join(dict.fromkeys(replies)) or "(no status change)"}')
# Firmware built before the English translation still reports the Dutch originals.
accepted = ('Layout received', 'Synced', 'Loading tiles', 'Indeling ontvangen', 'Gesynchroniseerd', 'Tegels laden')
if not any(r in accepted for r in replies):
raise SystemExit('The firmware did not accept the demo layout.')
finally:
await client.disconnect()
+4 -3
View File
@@ -20,7 +20,8 @@ async def main():
await client.connect(login=True)
try:
entities, _ = await client.list_entities_services()
inbox = next(e for e in entities if isinstance(e, TextInfo) and e.name == 'Tegelinstellingen')
# Firmware built before the English translation still names this entity in Dutch.
inbox = next(e for e in entities if isinstance(e, TextInfo) and e.name in ('Tile settings', 'Tegelinstellingen'))
assert inbox.max_length == 255
queue = asyncio.Queue()
client.subscribe_states(lambda s: queue.put_nowait(s.state) if isinstance(s, TextState) and s.key == inbox.key else None)
@@ -35,13 +36,13 @@ async def main():
client.text_command(inbox.key, secrets.token_hex(6) + '|0|1|' + encoded)
while True:
ack = await asyncio.wait_for(queue.get(), 5)
if ack.startswith('Fout:'):
if ack.startswith(('Error:', 'Fout:')):
print('PASS: rejected invalid command — ' + ack)
break
client.text_command(inbox.key, secrets.token_hex(6) + '|2|1|e30=')
while True:
ack = await asyncio.wait_for(queue.get(), 5)
if ack == 'Fout: onvolledig bericht':
if ack in ('Error: incomplete message', 'Fout: onvolledig bericht'):
print('PASS: missing chunks rejected')
break
finally: