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
+57 -15
View File
@@ -3,6 +3,7 @@ import asyncio
from collections import deque
import glob
import json
import logging
import os
from pathlib import Path
import re
@@ -13,6 +14,8 @@ import yaml
from core import REPO, installation_yaml
from i18n import t
LOG = logging.getLogger('screen_manager')
# 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."""
@@ -65,6 +68,7 @@ class Firmware:
self.images = {}
self.downloaded = set()
self._names = {} # file -> (stat signature, meta or None); parsed only when the file changes
self.language = None # the language builds speak: the manager's Settings -> Language & region (app 0.2.90)
def profiles(self):
if not self.root.exists(): return []
@@ -192,26 +196,57 @@ class Firmware:
def set_language(self, name, language):
"""Let the screen's next build speak `language` (app 0.2.90): the LANGUAGE line of the profile's substitutions,
changed or added without reformatting the user's YAML. A profile that doesn't build from ESP Screens' packages is
left alone, as is an English one without the line (English is the packages' own default). True when it changed."""
left alone, as is an English one without the line (English is the packages' own default), and one whose
substitutions this can't edit safely (an !include, a {...} mapping). The result is read back, and written only
when LANGUAGE is the one thing that changed. True when it changed."""
profile = self.profile(name)
text = profile.read_text()
if 'homeassistant_espscreen' not in text and 'packages/core.yaml' not in text:
raw = profile.read_bytes().decode('utf-8')
if 'homeassistant_espscreen' not in raw and 'packages/core.yaml' not in raw:
return False
line = f' LANGUAGE: {json.dumps(language)}'
block = re.search(r'(?m)^substitutions:[ \t]*(?:#.*)?\n((?:[ \t]+.*\n|[ \t]*\n)*)', text)
if block and re.search(r'(?m)^[ \t]+LANGUAGE:', block.group(1)):
start = block.start(1)
body = re.sub(r'(?m)^[ \t]+LANGUAGE:.*$', line, block.group(1), count=1)
updated = text[:start] + body + text[block.end(1):]
elif language == 'en':
newline = '\r\n' if '\r\n' in raw else '\n'
text = raw.replace('\r\n', '\n')
if not text.endswith('\n'):
text += '\n'
before = yaml.load(text, Loader=LenientLoader)
if not isinstance(before, dict):
return False
substitutions = before.get('substitutions')
if 'substitutions' in before and not isinstance(substitutions, dict):
LOG.warning('%s: its substitutions are not a plain block, so its language stays as it is', profile.name)
return False
substitutions = substitutions or {}
if substitutions.get('LANGUAGE') == language or (language == 'en' and 'LANGUAGE' not in substitutions):
return False
line = f'LANGUAGE: {json.dumps(language)}'
block = re.search(r'(?m)^substitutions:[ \t]*(?:#.*)?\n', text)
if block:
# The block: indented lines, blank ones and comments, up to the next key at the start of a line.
body = re.compile(r'(?:(?:[ \t]+.*|[ \t]*|#.*)\n)*').match(text, block.end())
indent = re.search(r'(?m)^([ \t]+)\S', body.group(0))
indent = indent.group(1) if indent else ' '
current = re.search(r'(?m)^[ \t]+["\']?LANGUAGE["\']?[ \t]*:.*$', body.group(0))
if current:
lines = body.group(0)[:current.start()] + indent + line + body.group(0)[current.end():]
else:
lines = indent + line + '\n' + body.group(0)
updated = text[:body.start()] + lines + text[body.end():]
elif 'substitutions' in before:
LOG.warning('%s: its substitutions are not a plain block, so its language stays as it is', profile.name)
return False
elif block:
updated = text[:block.start(1)] + line + '\n' + text[block.start(1):]
else:
updated = f'substitutions:\n{line}\n\n' + text
if updated == text:
# After the comments (and a document start) at the top of the file.
head = re.match(r'(?:(?:#.*|[ \t]*|---[ \t]*)\n)*', text)
updated = text[:head.end()] + f'substitutions:\n {line}\n\n' + text[head.end():]
after = yaml.load(updated, Loader=LenientLoader)
def rest(data):
data = dict(data)
rest_substitutions = {k: v for k, v in (data.get('substitutions') or {}).items() if k != 'LANGUAGE'}
data.pop('substitutions', None)
return data, rest_substitutions
if not isinstance(after, dict) or (after.get('substitutions') or {}).get('LANGUAGE') != language or rest(after) != rest(before):
LOG.warning('%s: its language could not be written without changing more, so it stays as it is', profile.name)
return False
self._atomic_write(profile, updated)
self._atomic_write(profile, updated.replace('\n', newline))
self._names.pop(profile.name, None)
return True
@@ -356,6 +391,13 @@ class Firmware:
profile = self.profile(data.get('file'))
action = data.get('action')
if action not in ('validate','build','install','download'): raise ValueError(t('addon.errors.firmware.unknown_action'))
# Every build speaks the language of Settings -> Language & region, also for a profile made or changed since
# that language was set (app 0.2.90).
if action != 'validate' and callable(self.language):
try:
self.set_language(profile.name, self.language())
except (OSError, ValueError, yaml.YAMLError) as error:
LOG.warning('Could not write the language into %s (%s)', profile.name, error)
if not shutil.which('esphome'): raise ValueError(t('addon.errors.firmware.no_esphome'))
target = data.get('target','')
if action == 'install':