Notation like Home Assistant, each screen in its own language, and the language in every build

From the end-to-end test on the bench boards and an independent review:
- Units spaced as Home Assistant does (54% / 54 % in German and French, 18°, 21.5 °C), in the firmware and the app.
- Grouping from five digits where the language does (1234 but 12.345 in Spanish, Polish, Portuguese): the layout
  message carries group_min and percent_space; an unknown number style no longer refuses a layout.
- Automatic follows Home Assistant's own language for the clock and numbers, also without a translation (Swedish:
  24 hours, 1 234,5; pt-BR: 1.234,5): regions.json from the CLDR for all 63 languages Home Assistant offers.
- 12-hour clock: the top bar says 3:48 PM, the clock card 3:48 (not 03:48), Last runs follow the clock.
- The climate setpoint font has the decimal comma; the value fonts have ², ³ and µ (m³ was a box).
- Weather days: at most three letters without the dot (mié, sáb, czw); the screen took three bytes.
- Texts fall back per key through the base language (pt-BR -> pt -> en) in the firmware and the app.
- Each screen gets the app's words in the language its firmware speaks; firmware from before the languages gets
  English and its own letters, not boxes.
- set_language re-reads what it writes (indentation, comments, quoted keys, CRLF, !include) and every build writes
  the language first; the 24-hour pin no longer hits a fresh install and waits for the old clock switches.
- Tests: region and notation, plural parity with the C++ rules, set_language edge cases, the pin, PUT /api/language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 16:52:34 +02:00
parent ad8a8ccf56
commit 125346d94e
37 changed files with 1419 additions and 448 deletions
+68 -25
View File
@@ -570,9 +570,9 @@ class Manager:
# Settings -> Language & region (app 0.2.90): the language, clock and numbers of every screen.
self.region = Region(self.path.parent / 'language.json', ha_language=lambda: getattr(self.ha, 'ha_language', None))
self.ha.language_of = self.region.language
self.firmware.language = self.region.language
self.ha.on_config = self.language_changed
self.pin_clock = False
i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.set_screens()
self.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, []
@@ -605,12 +605,36 @@ class Manager:
raise ValueError('Unknown storage version; data stays unchanged.')
# Loaded leniently: a tile setting this version doesn't know (saved by a newer one) never stops the app.
self.layouts = {key: validate_layout(value, stored=True) for key, value in raw['screens'].items()}
# An app from before Language & region (app 0.2.90): its screens keep the 24 hours they show; language_changed
# makes it 12 once Home Assistant says every screen's own clock setting was 12 hours.
if not self.region.stored and self.layouts:
self.region.clock = '24'
# An app from before Language & region (app 0.2.90): its screens keep the 24 hours they show, until Home Assistant
# says every screen's own clock setting was 12 hours (resolve_clock_pin). A new install starts on Automatic, and
# is stored right away so a restart never takes it for an old one.
if not self.region.stored:
if self.layouts:
self.region.clock, self.region.pinned = '24', True
self.region.save()
self.pin_clock = True
def resolve_clock_pin(self):
"""The pinned 24 hours of an app from before Language & region become 12 when every screen's own "24-hour clock"
switch (firmware before 0.2.76) was off; they stay 24 when one was on or none is left. Waits while a switch has no
state yet (a screen restarts). True when the clock changed."""
if not self.region.pinned or not self.ha.online or not getattr(self.ha, 'registry', None):
return False
switches = [item['entity_id'] for item in self.ha.registry if item.get('platform') == 'esphome'
and item.get('original_name') == '24-hour clock' and not item.get('disabled_by')]
states = [self.ha.states.get(entity, {}).get('state') for entity in switches]
if any(state not in ('on', 'off') for state in states):
return False
self.region.pinned = False
twelve = bool(states) and all(state == 'off' for state in states)
if twelve:
self.region.clock = '12'
self.region.save()
return twelve
def set_screens(self):
"""What the app writes for the screens follows Settings -> Language & region (i18n.SCREENS)."""
region = self.region
i18n.set_screens(region.language(), region.number_style(), region.clock_24h(), region.group_min(), region.percent_space())
def inventory(self):
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving."""
@@ -953,11 +977,17 @@ class Manager:
action = self.transport(inbox, screen)
if what is None or not action:
return
await self.ha.send(inbox, await self.card_history(entity, hours, what), action)
# Its words in the language of this screen's firmware (app 0.2.90).
token = i18n.SCREEN.set(i18n.screen_context(screen))
try:
await self.ha.send(inbox, await self.card_history(entity, hours, what), action)
finally:
i18n.SCREEN.reset(token)
async def card_history(self, entity, hours, what):
"""The history message, from a short cache; screens asking at the same time share one fetch."""
key = (entity, hours)
"""The history message, from a short cache; screens asking at the same time share one fetch, per language."""
context = i18n.SCREEN.get() or {}
key = (entity, hours, context.get('language'), bool(context.get('legacy')))
cached = self.card_histories.get(key)
if cached and time.monotonic() - cached[0] < history_card.CACHE_SECONDS[hours]:
return cached[1]
@@ -1145,15 +1175,8 @@ class Manager:
"""The screens' language may have changed (Settings -> Language & region, or Home Assistant's own): the words for
states come again in it, every screen gets all its messages anew, and each screen's YAML gets the language its
next firmware update builds in, which makes that update show."""
if self.pin_clock:
self.pin_clock = False
clocks = [self.ha.states.get(item['entity_id'], {}).get('state') for item in getattr(self.ha, 'registry', [])
if item.get('platform') == 'esphome' and item.get('original_name') == '24-hour clock']
known = [state for state in clocks if state in ('on', 'off')]
if known and all(state == 'off' for state in known):
self.region.clock = '12'
self.region.save()
i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.resolve_clock_pin()
self.set_screens()
self.ha.services_changed.set()
self.sent.clear()
self.write_languages()
@@ -1274,6 +1297,9 @@ class Manager:
# before 0.2.76 ignores both and keeps a clock setting of its own.
message['clock_24h'] = self.region.clock_24h()
message['numbers'] = self.region.number_style()
# From 1234 or 12.345 on, and the space before "%" of the language (firmware 0.2.76+, app 0.2.90).
message['group_min'] = self.region.group_min()
message['percent_space'] = self.region.percent_space()
if 'settings' in message:
message['settings']['clock_24h'] = message['clock_24h']
# The revision the screen echoes on every ping (firmware 0.2.33+; older firmware ignores it).
@@ -1288,6 +1314,15 @@ class Manager:
sent. None rebuilds every message and sends the differences; `force` sends everything."""
if screen is None:
screen = self.screen(inbox) or {}
# The words go in the language this screen's firmware speaks, which is the old one until its update (app 0.2.90).
context = i18n.screen_context(screen)
token = i18n.SCREEN.set(context)
try:
return await self._sync_one(inbox, layout, force, screen, dirty, context)
finally:
i18n.SCREEN.reset(token)
async def _sync_one(self, inbox, layout, force, screen, dirty, context):
needed = self.needs_firmware(inbox, layout, screen)
if needed:
self.status[inbox]=english('addon.status.firmware_needed', version=needed)
@@ -1301,7 +1336,9 @@ class Manager:
self.status[inbox] = english('addon.status.too_large')
return False
previous = self.sent.get(inbox)
full = force or not previous or previous['layout'] != layout_msg or dirty is None
# A screen that now speaks another language (an update, or its sensor reporting after a restart) gets every
# message written anew.
full = force or not previous or previous['layout'] != layout_msg or dirty is None or previous.get('context') != context
# The top bar right after the layout (firmware 0.2.32+; older firmware draws the clock of show_clock).
header_msg = None
if self.supports_header(inbox, screen):
@@ -1335,7 +1372,7 @@ class Manager:
# sent (settings the screen reported itself, see store_settings) keeps the one it had.
sent_layout = any(message is layout_msg for message in outgoing)
rev = layout_msg['rev'] if sent_layout or not previous else previous.get('rev', layout_msg['rev'])
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev}
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev, 'context': context}
# The hourly timer follows a real full transmission; any message refreshes the screen's
# feed window, so the ping timer follows whatever went out (a rebuild without differences,
# after a registry refresh, must not postpone the ping).
@@ -1414,6 +1451,8 @@ class Manager:
event.get('inbox') if isinstance(event, dict) else '?', error)
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
dirty = self.take_dirty()
if self.region.pinned and self.resolve_clock_pin():
self.language_changed()
screens_key = self._screens_key
screens = self.screens()
changed = self._screens_key != screens_key
@@ -1915,11 +1954,15 @@ def create_app(manager, development=False):
return ready[0]
async def identify(request):
"""Identify (app 0.2.73): the screen shows a short card and blinks its backlight, so you know which one it is. The
card speaks the screens' language (app 0.2.90)."""
card speaks the language of the screen's firmware (app 0.2.90)."""
screen = one_alert_target(request.match_info['inbox'])
data, _ = alert_data({'title': screen_t('addon.screen.identify.title', name=screen['name']),
'subtitle': screen_t('addon.screen.identify.subtitle'), 'icon': 'bell-ring',
'color': 'blue', 'button_text': screen_t('screen.alert.ok'), 'timeout': 8, 'flash': True})
token = i18n.SCREEN.set(i18n.screen_context(screen))
try:
data, _ = alert_data({'title': screen_t('addon.screen.identify.title', name=screen['name']),
'subtitle': screen_t('addon.screen.identify.subtitle'), 'icon': 'bell-ring',
'color': 'blue', 'button_text': screen_t('screen.alert.ok'), 'timeout': 8, 'flash': True})
finally:
i18n.SCREEN.reset(token)
await manager.ha.call(alert_service(screen['node']), data)
return web.json_response({'ok': True})
async def test_alert(request):