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
+19 -19
View File
@@ -12,7 +12,7 @@ from core import discover, installation_yaml, packets, state_message, validate_l
class ProtocolTests(unittest.TestCase):
def test_unicode_chunks_and_limits(self):
message = {'v':1,'op':'layout','title':'Woonkamer 🌈', 'entities':['light.lamp']*10}
message = {'v':1,'op':'layout','title':'Living room 🌈', 'entities':['light.lamp']*10}
parts = packets(message)
self.assertTrue(all(len(part)<=255 for part in parts))
self.assertEqual(json.loads(base64.b64decode(''.join(p.split('|')[3] for p in parts))),message)
@@ -21,11 +21,11 @@ class ProtocolTests(unittest.TestCase):
def test_layout_rejects_duplicates_unsupported_and_overflow(self):
for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(21)],[{'entity':'light.a;restart'}]]:
with self.assertRaises(ValueError): validate_layout({'title':'Thuis','tiles':tiles})
self.assertEqual(validate_layout({'title':'Thuis','tiles':[]})['tiles'],[])
with self.assertRaises(ValueError): validate_layout({'title':'Home','tiles':tiles})
self.assertEqual(validate_layout({'title':'Home','tiles':[]})['tiles'],[])
def test_settings_defaults_ranges_and_old_layout(self):
self.assertNotIn('settings', validate_layout({'title':'Thuis','tiles':[]}))
self.assertNotIn('settings', validate_layout({'title':'Home','tiles':[]}))
defaults = validate_settings({})
self.assertEqual(defaults['standby_seconds'], 600)
self.assertEqual(defaults['night_start'], 22*60)
@@ -37,7 +37,7 @@ class ProtocolTests(unittest.TestCase):
with self.assertRaises(ValueError): validate_settings(bad)
settings = validate_settings({'brightness':40,'standby_brightness':0,'night_brightness':0})
self.assertEqual(settings['brightness'],40)
message={'v':1,'op':'layout','title':'Thuis','entities':[], 'settings':settings}
message={'v':1,'op':'layout','title':'Home','entities':[], 'settings':settings}
self.assertTrue(all(len(p)<=255 for p in packets(message)))
def test_message_has_only_bounded_display_attributes(self):
@@ -48,17 +48,17 @@ class ProtocolTests(unittest.TestCase):
packets(msg)
def test_discovery_only_enabled_esphome_inboxes(self):
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'},
{'entity_id':'text.wrong','platform':'template','original_name':'Tegelinstellingen'},
{'entity_id':'text.disabled','platform':'esphome','original_name':'Tegelinstellingen','disabled_by':'user'}]
screens,_=discover(registry,{'text.screen':{'state':'Gesynchroniseerd'}},[],[])
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tile settings'},
{'entity_id':'text.wrong','platform':'template','original_name':'Tile settings'},
{'entity_id':'text.disabled','platform':'esphome','original_name':'Tile settings','disabled_by':'user'}]
screens,_=discover(registry,{'text.screen':{'state':'Synced'}},[],[])
self.assertEqual([s['id'] for s in screens],['text.screen'])
def test_unique_credentials_and_remote_profiles(self):
data={'board':'cyd','name':'woonkamer','friendly_name':'Woonkamer'}
data={'board':'cyd','name':'living-room','friendly_name':'Living room'}
first,second=installation_yaml(data),installation_yaml(data)
self.assertNotEqual(first,second)
self.assertIn('DEVICE_NAME: "woonkamer"',first)
self.assertIn('DEVICE_NAME: "living-room"',first)
self.assertIn('packages/cyd.yaml',first)
self.assertIn('!secret wifi_password',first)
import re
@@ -76,7 +76,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
def setup_manager(self,path):
class HA:
online=True
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'}]
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tile settings'}]
devices=[];areas=[]
states={'text.screen':{'state':'Ready'},'light.a':{'state':'on','attributes':{'friendly_name':'Lamp'}}}
changed=asyncio.Event()
@@ -87,7 +87,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_update_restart_preserves_layout(self):
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'screens.json';m=self.setup_manager(path)
layout={'title':'Mijn huis','tiles':[{'entity':'light.a','name':'Eigen naam'}]}
layout={'title':'My house','tiles':[{'entity':'light.a','name':'Custom name'}]}
m.save('text.screen',layout)
fresh=self.setup_manager(path)
# Stored layouts always carry a grid position; a save without one packs in order.
@@ -108,7 +108,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_settings_survive_update_and_old_client_save(self):
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'screens.json'; m=self.setup_manager(path)
legacy={'title':'Thuis','tiles':[{'entity':'light.a','name':'Mijn lamp'}]}
legacy={'title':'Home','tiles':[{'entity':'light.a','name':'My lamp'}]}
path.write_text(json.dumps({'version':1,'screens':{'text.screen':legacy}}))
m=self.setup_manager(path)
stored={**legacy,'tiles':[{**legacy['tiles'][0],'slot':0}]}
@@ -117,7 +117,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
m.save('text.screen',{**legacy,'settings':settings})
fresh=self.setup_manager(path)
self.assertEqual(fresh.layouts['text.screen']['settings'],settings)
fresh.save('text.screen',{**legacy,'title':'Andere titel'})
fresh.save('text.screen',{**legacy,'title':'Different title'})
saved=fresh.layouts['text.screen']
self.assertEqual(saved['settings'],settings)
self.assertEqual(saved['tiles'],stored['tiles'])
@@ -133,12 +133,12 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp:
m=self.setup_manager(Path(temp)/'screens.json');layout={'title':'Thuis','tiles':[{'entity':'light.a','name':''}]}
m=self.setup_manager(Path(temp)/'screens.json');layout={'title':'Home','tiles':[{'entity':'light.a','name':''}]}
m.save('text.screen',layout)
original=m.ha.send
async def interrupt(inbox,message,action=None):
await original(inbox,message)
m.save(inbox,{'title':'Leeg','tiles':[]})
m.save(inbox,{'title':'Empty','tiles':[]})
m.ha.send=interrupt
await m.sync_one('text.screen',m.layouts['text.screen'])
self.assertEqual(len(m.ha.messages),1)
@@ -159,7 +159,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status,200)
from firmware import Firmware
m.firmware=Firmware(Path(temp)/'esphome',Path(temp))
profile={'board':'guition','name':'screen-new','friendly_name':'Nieuw','wifi_ssid':'net','wifi_password':'pw'}
profile={'board':'guition','name':'screen-new','friendly_name':'New','wifi_ssid':'net','wifi_password':'pw'}
# A refused USB target leaves no profile behind.
response=await client.post('/api/firmware/profiles',headers=headers,json={**profile,'target':'/dev/ttyUSB9'})
self.assertEqual(response.status,400)
@@ -175,6 +175,6 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn('pw',json.dumps((await (await client.get('/api/firmware')).json())['wifi']))
# Until Home Assistant lists the screen, the page shows the profile as "not yet in HA" with its key.
pending=(await (await client.get('/api/inventory?light=1')).json())['pending']
self.assertEqual([(p['file'],p['friendly'],p['installed'],p['api_key']==result['api_key']) for p in pending],[('screen-new.yaml','Nieuw',False,True)])
self.assertEqual([(p['file'],p['friendly'],p['installed'],p['api_key']==result['api_key']) for p in pending],[('screen-new.yaml','New',False,True)])
if __name__=='__main__':unittest.main()