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
+2 -1
View File
@@ -3,12 +3,13 @@
#pragma once
#include "components/smart_display/screen_text.h"
static_assert(screen_text::KEYS_HASH == 0xC6B1A3EDu && screen_text::KEY_COUNT == 305,
static_assert(screen_text::KEYS_HASH == 0x5DBB4605u && screen_text::KEY_COUNT == 306,
"screen_text_keys.h does not match screen_manager/translations/en.json: run tools/i18n.py header");
const char *const screen_text::TABLE[] = {
".",
",",
"1",
"%",
"Just now",
"{n} min ago",
"1 hour ago | {n} hours ago",
+11 -3
View File
@@ -304,11 +304,19 @@ class Editor(unittest.IsolatedAsyncioTestCase):
i18n.set_screens('xx', 'point')
async with TestClient(TestServer(create_app(manager, True))) as client:
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
# Firmware from before the languages speaks English, whatever the screens' language is.
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
self.assertEqual(answer.status, 200)
action, data = ha.calls[0]
self.assertEqual((action, data['title'], data['subtitle'], data['button_text']),
('esphome.office_1_show_alert', '[xx] This is Office 1', '[xx] Identify, from ESP Screens', '[xx] OK'))
# Firmware that says it speaks the screens' language gets the card in it.
ha.registry = ha.registry + [{'entity_id': 'sensor.language', 'platform': 'esphome', 'original_name': 'Screen language',
'device_id': 'd1'}]
ha.states['sensor.language'] = {'state': 'xx'}
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
self.assertEqual(answer.status, 200)
texts = [(action, data['title'], data['subtitle'], data['button_text']) for action, data in ha.calls]
self.assertEqual(texts, [('esphome.office_1_show_alert', 'This is Office 1', 'Identify, from ESP Screens', 'OK'),
('esphome.office_1_show_alert', '[xx] This is Office 1', '[xx] Identify, from ESP Screens', '[xx] OK')])
i18n.set_screens('en', 'point')
async def test_a_tile_event_answers_home_assistant_in_english(self):
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
+1 -1
View File
@@ -38,7 +38,7 @@ int main() {
assert(number_text(128) == "128");
assert(number_text(12.5f) == "12.5");
assert(number_text(0) == "0");
assert(percent_text(50) == "50 %");
assert(percent_text(50) == "50%"); // "50 %" where the language writes it so (screen_text::percent_sign)
// The roller's text and the row that is chosen in it.
std::vector<std::string> names = {"Solid", "Akemi", "TV Simulator"};
+263
View File
@@ -0,0 +1,263 @@
"""Settings -> Language & region (app 0.2.90): how the screens write the clock and numbers, the language each screen's
messages go in, the language in the screens' YAML and the 24 hours an app from before it kept."""
import asyncio
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import i18n # noqa: E402
import yaml # noqa: E402
from firmware import Firmware, LenientLoader # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
PROFILE_TAIL = 'packages:\n display: !include /r/packages/core.yaml\n'
def region(tmp, ha='en', **stored):
path = Path(tmp) / 'language.json'
if stored:
path.write_text(json.dumps({'version': 1, **stored}))
return i18n.Region(path, ha_language=lambda: ha)
class NotationTests(unittest.TestCase):
def test_every_home_assistant_language_has_a_notation(self):
tool = importlib.util.spec_from_file_location('i18n_tool', ROOT / 'tools' / 'i18n.py')
module = importlib.util.module_from_spec(tool)
tool.loader.exec_module(module)
self.assertEqual(set(module.HA_LANGUAGES), set(i18n.REGIONS) - {'_about'})
for code, written in i18n.REGIONS.items():
if code == '_about':
continue
self.assertIn(written['clock'], ('12', '24'), code)
self.assertIn(written['numbers'], ('point', 'comma', 'space'), code)
self.assertIn(written['group_min'], (1, 2), code)
def test_automatic_follows_home_assistants_own_language(self):
with tempfile.TemporaryDirectory() as tmp:
# Swedish: English texts (no file yet), Swedish clock and numbers.
sv = region(tmp, 'sv')
self.assertEqual((sv.language(), sv.clock_24h(), sv.number_style(), sv.group_min(), sv.percent_space()),
('en', True, 'space', 1, True))
# Brazilian Portuguese: the Portuguese texts, Brazil's numbers.
br = region(tmp, 'pt-BR')
self.assertEqual((br.language(), br.number_style(), br.group_min()), ('pt', 'comma', 1))
pt = region(tmp, 'pt')
self.assertEqual((pt.number_style(), pt.group_min(), pt.percent_space()), ('space', 2, False))
us, uk = region(tmp, 'en'), region(tmp, 'en-GB')
self.assertEqual((us.clock_24h(), uk.clock_24h(), uk.language()), (False, True, 'en-GB'))
de = region(tmp, 'de')
self.assertEqual((de.number_style(), de.percent_space()), ('comma', True))
def test_a_chosen_language_and_format_win(self):
with tempfile.TemporaryDirectory() as tmp:
chosen = region(tmp, 'nl', language='es', clock='12', numbers='point')
# A chosen format is Home Assistant's for it, which groups from four digits; the space before % stays the
# language's.
self.assertEqual((chosen.language(), chosen.clock_24h(), chosen.number_style(), chosen.group_min()),
('es', False, 'point', 1))
self.assertEqual(region(tmp, 'nl', language='es', clock='auto', numbers='auto').group_min(), 2)
with tempfile.TemporaryDirectory() as tmp:
view = region(tmp, 'de').view()
self.assertEqual({key: view[key] for key in ('group_min', 'percent_space', 'numbers_effective')},
{'group_min': 1, 'percent_space': True, 'numbers_effective': 'comma'})
def test_numbers_and_units_as_home_assistant_writes_them(self):
self.assertEqual(i18n.format_number('1234.5', 'comma', 2), '1234,5')
self.assertEqual(i18n.format_number('12345.5', 'comma', 2), '12.345,5')
self.assertEqual(i18n.format_number('1234', 'space'), '1 234')
self.assertEqual(i18n.format_number('-1234567', 'point'), '-1,234,567')
self.assertEqual(i18n.format_number('on', 'comma'), 'on')
try:
i18n.set_screens('de', 'comma', True, 1, True)
self.assertEqual([i18n.unit_suffix(unit) for unit in ('%', '°', '°C', '', None)], [' %', '°', ' °C', '', ''])
i18n.set_screens('nl', 'comma', True, 1, False)
self.assertEqual(i18n.unit_suffix('%'), '%')
finally:
i18n.set_screens('en', 'point')
def test_plural_rules_match_the_firmware(self):
"""Python's rules against the C++ ones a screen's build writes (screen_text_gen.PLURAL_RULES), n from -30."""
compiler = shutil.which(os.environ.get('CXX', 'clang++')) or shutil.which('g++')
if not compiler:
self.skipTest('no C++ compiler')
spec = importlib.util.spec_from_file_location('gen', ROOT / 'components/smart_display/screen_text_gen.py')
gen = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gen)
numbers = list(range(-30, 131)) + [1001, 1011, 1021, 1112]
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / 'rules.cpp'
functions = '\n'.join(f'int rule_{name}(int n) {{ {body} }}' for name, body in gen.PLURAL_RULES.items())
calls = '\n'.join(f' for (int n : ns) std::printf("{name} %d %d\\n", n, rule_{name}(n));' for name in gen.PLURAL_RULES)
source.write_text('#include <cstdio>\n' + functions + '\nint main() {\n int ns[] = {' + ','.join(map(str, numbers)) +
'};\n' + calls + '\n}\n')
subprocess.run([compiler, '-std=c++17', str(source), '-o', str(Path(tmp) / 'rules')], check=True)
out = subprocess.run([str(Path(tmp) / 'rules')], check=True, capture_output=True, text=True).stdout.split('\n')
for line in filter(None, out):
name, n, index = line.split()
self.assertEqual(i18n.PLURAL[name](int(n)), int(index), f'{name}({n})')
class ScreenContextTests(unittest.TestCase):
def tearDown(self):
i18n.set_screens('en', 'point')
def test_each_screen_gets_the_words_its_firmware_speaks(self):
import header_bar
i18n.set_screens('nl', 'comma', True, 1, False)
legacy = i18n.screen_context({'language': None, 'language_sensor': False})
dutch = i18n.screen_context({'language': 'nl', 'language_sensor': True})
restarting = i18n.screen_context({'language': None, 'language_sensor': True})
self.assertEqual(i18n.screen_t('screen.ha.on'), 'Aan')
for context, word, number, letters in ((legacy, 'On', '1,234.5', 'Waz'), (dutch, 'Aan', '1.234,5', 'Wąż'),
(restarting, 'Aan', '1.234,5', 'Wąż')):
token = i18n.SCREEN.set(context)
try:
self.assertEqual((i18n.screen_t('screen.ha.on'), i18n.screen_number('1234.5')), (word, number))
# Firmware from before the languages lacks most accented letters: they fold to the base letter.
self.assertEqual(header_bar.clean_text('Wąż'), letters)
finally:
i18n.SCREEN.reset(token)
self.assertIsNone(i18n.screen_context(None))
class ProfileLanguageTests(unittest.TestCase):
CASES = {
'plain': ('substitutions:\n DEVICE_NAME: "a"\n\n' + PROFILE_TAIL, True),
'four spaces': ('substitutions:\n DEVICE_NAME: "a"\n FONT: "x"\n' + PROFILE_TAIL, True),
'a comment inside': ('substitutions:\n DEVICE_NAME: "a"\n# a note\n LANGUAGE: "en"\n' + PROFILE_TAIL, True),
'quoted key': ('substitutions:\n "LANGUAGE": "en"\n DEVICE_NAME: a\n' + PROFILE_TAIL, True),
'no final newline': (PROFILE_TAIL + 'substitutions:\n DEVICE_NAME: "a"\n LANGUAGE: "de"', True),
'document start': ('# mine\n---\n' + PROFILE_TAIL, True),
'comments after the key': ('substitutions: # mine\n DEVICE_NAME: "a" # name\n\n\n# esphome\nesphome:\n name: a\n' + PROFILE_TAIL, True),
'windows line ends': (('substitutions:\n DEVICE_NAME: "a"\n' + PROFILE_TAIL).replace('\n', '\r\n'), True),
'include': ('substitutions: !include subs.yaml\n' + PROFILE_TAIL, False),
'flow mapping': ('substitutions: {DEVICE_NAME: a}\n' + PROFILE_TAIL, False),
'not ours': ('substitutions:\n A: b\nesphome:\n name: z\n', False),
}
def test_the_language_line_changes_nothing_else(self):
with tempfile.TemporaryDirectory() as tmp:
firmware = Firmware(tmp, tmp)
for index, (name, (text, writes)) in enumerate(self.CASES.items()):
with self.subTest(name):
path = Path(tmp) / f'p{index}.yaml'
path.write_bytes(text.encode())
before = yaml.load(text.replace('\r\n', '\n'), Loader=LenientLoader)
with self.assertLogs('screen_manager', 'WARNING') if name in ('include', 'flow mapping') else _quiet():
self.assertEqual(firmware.set_language(path.name, 'nl'), writes)
out = path.read_bytes().decode()
self.assertEqual('\r\n' in out, '\r\n' in text, 'line ends stay as they were')
after = yaml.load(out.replace('\r\n', '\n'), Loader=LenientLoader)
if writes:
self.assertEqual(after['substitutions']['LANGUAGE'], 'nl')
strip = lambda data: ({k: v for k, v in data.items() if k != 'substitutions'},
{k: v for k, v in (data.get('substitutions') or {}).items() if k != 'LANGUAGE'})
self.assertEqual(strip(after), strip(before))
self.assertFalse(firmware.set_language(path.name, 'nl'), 'nothing to do the second time')
else:
self.assertEqual(out, text)
english = Path(tmp) / 'english.yaml'
english.write_text(PROFILE_TAIL)
self.assertFalse(firmware.set_language(english.name, 'en'), 'English is the default: no line needed')
def test_every_build_writes_the_language_first(self):
with tempfile.TemporaryDirectory() as tmp:
firmware = Firmware(tmp, tmp)
path = Path(tmp) / 'screen.yaml'
path.write_text('substitutions:\n DEVICE_NAME: "screen"\n' + PROFILE_TAIL)
firmware.language = lambda: 'de'
firmware.run = lambda *args: asyncio.sleep(0)
with unittest.mock.patch('shutil.which', return_value='/usr/bin/esphome'):
async def start():
firmware.start({'file': 'screen.yaml', 'action': 'validate'})
self.assertNotIn('LANGUAGE', path.read_text(), 'validating changes nothing')
await firmware.task
firmware.start({'file': 'screen.yaml', 'action': 'build'})
await firmware.task
asyncio.run(start())
self.assertIn('LANGUAGE: "de"', path.read_text())
class _quiet:
def __enter__(self):
return self
def __exit__(self, *args):
return False
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class ClockPinTests(unittest.IsolatedAsyncioTestCase):
def manager(self, path, switches=None):
import test_scaling
from server import Manager
ha = test_scaling.fake_ha(firmware='0.2.75')
for index, state in enumerate(switches or []):
ha.registry.append({'entity_id': f'switch.clock_{index}', 'platform': 'esphome', 'original_name': '24-hour clock',
'device_id': 'd1'})
if state is not None:
ha.states[f'switch.clock_{index}'] = {'state': state}
ha.ha_language = 'en'
ha.services_changed = asyncio.Event()
return Manager(ha, path)
async def test_a_new_install_follows_the_language_from_the_start(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
first = self.manager(path)
self.assertTrue((Path(tmp) / 'language.json').exists(), 'stored at once')
first.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a'}]})
again = self.manager(path, ['on'])
self.assertEqual((again.region.clock, again.region.pinned, again.region.clock_24h()), ('auto', False, False))
async def test_an_older_app_keeps_its_24_hours_until_the_switches_tell(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': {'title': 'Home', 'tiles': [{'entity': 'light.a'}]}}}))
waiting = self.manager(path, ['off', None])
self.assertEqual((waiting.region.clock, waiting.region.pinned), ('24', True))
self.assertFalse(waiting.resolve_clock_pin(), 'a switch without a state: ask again later')
self.assertTrue(json.loads((Path(tmp) / 'language.json').read_text())['pinned'], 'the question survives a restart')
twelve = self.manager(path, ['off', 'off'])
self.assertTrue(twelve.resolve_clock_pin())
self.assertEqual((twelve.region.clock, twelve.region.pinned), ('12', False))
(Path(tmp) / 'language.json').unlink()
mixed = self.manager(path, ['off', 'on'])
self.assertFalse(mixed.resolve_clock_pin())
self.assertEqual((mixed.region.clock, mixed.region.pinned), ('24', False))
(Path(tmp) / 'language.json').unlink()
chosen = self.manager(path, ['off', None])
chosen.region.change({'clock': 'auto'})
self.assertFalse(chosen.region.pinned, 'the owner chose a clock')
async def test_language_and_region_over_http(self):
from server import create_app
from aiohttp.test_utils import TestClient, TestServer
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(Path(tmp) / 'screens.json')
async with TestClient(TestServer(create_app(m, True))) as client:
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
answer = await client.put('/api/language', json={'setting': 'de', 'numbers': 'point'}, headers={'X-Screen-CSRF': csrf})
view = (await answer.json())['language']
self.assertEqual((view['effective'], view['numbers_effective'], view['group_min'], view['percent_space']),
('de', 'point', 1, True))
refused = await client.put('/api/language', json={'clock': '13'}, headers={'X-Screen-CSRF': csrf, 'X-ESP-Screens-Language': 'nl'})
self.assertEqual(refused.status, 400)
self.assertEqual((await refused.json())['error'], i18n.TRANSLATIONS.text('addon.errors.region_choice', 'nl'))
self.assertEqual(json.loads((Path(tmp) / 'language.json').read_text())['language'], 'de')
import unittest.mock # noqa: E402
if __name__ == '__main__':
unittest.main()
+5 -2
View File
@@ -129,11 +129,14 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}})
self.assertEqual(path.read_bytes(),before)
await fresh.sync_one('text.screen',saved)
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
# The clock is the one of Settings -> Language & region (app 0.2.90), the rest what the screen kept.
self.assertEqual(fresh.ha.messages[0][1]['settings'],{**{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK},
'clock_24h':fresh.region.clock_24h()})
await fresh.sync_one('text.screen',saved)
self.assertEqual(len(fresh.ha.messages),2)
await fresh.sync_one('text.screen',saved,True)
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
self.assertEqual(fresh.ha.messages[2][1]['settings'],{**{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK},
'clock_24h':fresh.region.clock_24h()})
async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp:
+17
View File
@@ -42,6 +42,23 @@ int main() {
number_style_of("space") == 3 && number_style_of("dot") == -1);
number_style = 0;
// Grouping from five digits where the language does (CLDR: Spanish, Polish write 1234 but 12.345), as the app says.
number_group_min = 2;
assert(localize("1234") == "1234" && localize("12345") == "12,345" && localize("-1234.5") == "-1234.5");
number_group_min = 1;
assert(localize("1234") == "1,234");
number_group_min = 0;
// Units spaced as Home Assistant spaces them: none before "%" or "°" in English, one before any other unit; German
// and French put one before "%" too (the app sends it).
assert(with_unit("54", "%") == "54%" && with_unit("18", "\u00B0") == "18\u00B0" && with_unit("21.5", "\u00B0C") == "21.5 \u00B0C");
assert(with_unit("7", "") == "7" && unit_suffix("kWh") == " kWh" && percent(71) == "71%");
number_percent = 2;
assert(with_unit("54", "%") == "54 %" && percent(71) == "71 %");
number_percent = 1;
assert(percent(71) == "71%");
number_percent = 0;
// Times the screen gets as "HH:MM" (the sun, the forecast), written for its clock.
assert(clock_text("07:12", true) == "07:12" && clock_text("19:05", false) == "7:05 PM");
assert(clock_text("00:00", false) == "12:00 AM" && clock_text("14:00", false, true) == "2 PM");