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
+49 -3
View File
@@ -7,7 +7,8 @@
python3 tools/i18n.py header [--check] write (or compare) the key header and the tests' English table that the
firmware's `screen` texts need, after changing the keys of en.json
python3 tools/i18n.py cldr [--write] day and month names, date orders and the decimal mark from the Unicode CLDR
(Node's Intl), into every language's screen.date and screen.number
(Node's Intl), into every language's screen.date and screen.number, and the
clock and numbers of every Home Assistant language (screen_manager/app/regions.json)
python3 tools/i18n.py ha-words [--write] Home Assistant's own words for states (screen.ha) in every language, from a
Home Assistant (HA_URL and HA_TOKEN); needs aiohttp
python3 tools/i18n.py new CODE NAME ENGLISH PLURAL
@@ -38,7 +39,8 @@ def generator():
def languages():
"""{code: data} of every language file, English first."""
found = {path.stem: json.loads(path.read_text(encoding='utf-8')) for path in sorted(FOLDER.glob('*.json'))}
found = {path.stem: json.loads(path.read_text(encoding='utf-8')) for path in sorted(FOLDER.glob('*.json'))
if re.fullmatch(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?', path.stem)}
return dict(sorted(found.items(), key=lambda item: (item[0] != 'en', item[0])))
@@ -330,6 +332,40 @@ def main():
# The CLDR locale of a file where it differs: Portuguese is Home Assistant's pt, the one of Portugal.
CLDR_LOCALE = {'pt': 'pt-PT'}
# Every language Home Assistant's frontend offers (2026.9: the languages of its translationMetadata). A language ESP
# Screens has no file for still writes the clock and numbers as Home Assistant does (regions.json); its texts fall back
# to English.
HA_LANGUAGES = ('af ar bg bn bs ca cs cy da de el en en-GB eo es et eu fa fi fy fr ga gl gsw he hi hr hu hy id it is ja '
'ka ko lb lt lv mk ml nl nb nn pl pt pt-BR ro ru sk sl sr sr-Latn sv sq ta te th tr uk ur vi zh-Hans '
'zh-Hant').split()
# The languages that put a space between a number and "%" ("54 %"), as Home Assistant's frontend does
# (src/common/translations/blank_before_unit.ts, blankBeforePercent); every other one writes "54%".
HA_PERCENT_BLANK = ('cs', 'de', 'fi', 'fr', 'sk', 'sv')
REGIONS = ROOT / 'screen_manager' / 'app' / 'regions.json'
def percent_space(code):
return code.split('-')[0] in HA_PERCENT_BLANK
def number_style(number):
"""point (1,234.5), comma (1.234,5) or space (1 234,5): the choices of Settings -> Language & region."""
if number['decimal'] != ',':
return 'point'
return 'space' if number['group'].strip() == '' else 'comma'
def regions():
"""{code: {clock, numbers, group_min, percent_space}} for every Home Assistant language, from Node's Intl."""
locales = [CLDR_LOCALE.get(code, code) for code in HA_LANGUAGES]
found = json.loads(subprocess.run(['node', str(ROOT / 'tools' / 'i18n_cldr.mjs'), *locales], check=True,
capture_output=True, text=True).stdout)
table = {}
for code, locale in zip(HA_LANGUAGES, locales):
cldr = found[locale]
table[code] = {'clock': cldr['clock'], 'numbers': number_style(cldr['number']),
'group_min': int(cldr['number']['group_min']), 'percent_space': percent_space(code)}
return table
def write_language(code, data):
@@ -360,11 +396,21 @@ def cldr_dates(write, codes=None):
cldr = found[locale]
before = json.dumps(data, ensure_ascii=False)
data.setdefault('_meta', {})['clock'] = cldr['clock']
merge(data.setdefault('screen', {}), {'number': cldr['number'], 'time': cldr['time'], 'date': cldr['date']})
number = {**cldr['number'], 'percent': ' %' if percent_space(code) else '%'}
merge(data.setdefault('screen', {}), {'number': number, 'time': cldr['time'], 'date': cldr['date']})
if json.dumps(data, ensure_ascii=False) != before:
print(f'{code}: {"wrote" if write else "would change"} the CLDR texts')
if write:
write_language(code, data)
table = regions()
text = json.dumps({'_about': 'GENERATED by tools/i18n.py cldr: how every Home Assistant language writes the clock and '
'numbers (Unicode CLDR, Home Assistant\'s own rule for the space before %). The app takes '
'it for Settings -> Language & region set to Automatic.', **table},
ensure_ascii=False, indent=1) + '\n'
if not REGIONS.exists() or REGIONS.read_text(encoding='utf-8') != text:
print(f'regions.json: {"wrote" if write else "would change"} the clock and numbers of {len(table)} languages')
if write:
REGIONS.write_text(text, encoding='utf-8')
return 0