Files
homeassistant_espscreen/components/smart_display/screen_text_gen.py
T
2026-09-19 14:59:42 +02:00

174 lines
7.8 KiB
Python

"""The texts a screen shows, in the language it is built for (app 0.2.90).
Every text lives in screen_manager/translations/<code>.json; its `screen` section is what the firmware draws. A build
takes one language: this module turns that section into one C++ table in the build's main.cpp, so a screen carries a
single language and looks a text up by its index (screen_text.h). The keys are the same for every language and come
from en.json in document order; tools/i18n.py writes them to screen_text_keys.h, and a static_assert in the build
checks that header against the JSON it is built from. A key a language lacks takes the English text.
Plain Python on purpose, without ESPHome: __init__.py imports it for the build, tools/i18n.py and the tests use it too.
The ESP Screens app has its own reader (screen_manager/app/i18n.py), as the app's image holds no components folder.
"""
import json
import re
from pathlib import Path
# The repository root: components/smart_display/ sits two folders down, in a checkout and in the clone ESPHome makes
# of the repository for an external component (git clone --depth=1 of the whole repository).
ROOT = Path(__file__).resolve().parents[2]
TRANSLATIONS = ROOT / 'screen_manager' / 'translations'
LANGUAGE_CODE = re.compile(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?')
# How a language picks the form of a plural text ("1 hour ago | {n} hours ago"): the index of the form for n, as C++.
# The editor has the same rules (web/src/i18n.ts); docs/TRANSLATING.md lists which languages use which.
PLURAL_RULES = {
'one_other': 'return n == 1 ? 0 : 1;',
'one_upto_1': 'return (n < 0 ? -n : n) <= 1 ? 0 : 1;',
'slavic_pl': 'if (n == 1) return 0; int t = n % 10, h = n % 100; '
'return t >= 2 && t <= 4 && (h < 12 || h > 14) ? 1 : 2;',
'east_slavic': 'int t = n % 10, h = n % 100; if (t == 1 && h != 11) return 0; '
'return t >= 2 && t <= 4 && (h < 12 || h > 14) ? 1 : 2;',
'none': 'return 0;',
}
def load(code, folder=TRANSLATIONS):
"""A language file as a dict; None when there is none."""
path = Path(folder) / f'{code}.json'
if not LANGUAGE_CODE.fullmatch(code or '') or not path.is_file():
return None
return json.loads(path.read_text(encoding='utf-8'))
def resolve(code, folder=TRANSLATIONS):
"""The language a build takes for `code`: the file itself, else its base language (pt-BR -> pt), else English."""
for candidate in (code, (code or '').split('-')[0]):
if candidate and load(candidate, folder) is not None:
return candidate
return 'en'
def flatten(node, prefix=''):
"""(key, text) pairs in document order; a list's items get .0, .1, ... ."""
if isinstance(node, dict):
items = node.items()
elif isinstance(node, list):
items = ((str(index), value) for index, value in enumerate(node))
else:
return [(prefix, node)]
pairs = []
for key, value in items:
pairs.extend(flatten(value, f'{prefix}.{key}' if prefix else key))
return pairs
def screen_pairs(data):
"""The `screen` section as (key, text) pairs, the key without the section."""
return [(key, value) for key, value in flatten((data or {}).get('screen', {})) if isinstance(value, str)]
def identifier(key):
"""The C++ name of a key: settings.dark_mode -> settings_dark_mode."""
return re.sub(r'[^A-Za-z0-9]+', '_', key).strip('_')
def keys_hash(keys):
"""FNV-1a over the keys, so a build notices a screen_text_keys.h that no longer matches the JSON."""
value = 0x811C9DC5
for byte in '\n'.join(keys).encode('utf-8'):
value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF
return value
def cpp_string(text):
"""A C++ string literal of UTF-8 text; every byte outside printable ASCII as a three-digit octal escape."""
out = []
for byte in text.encode('utf-8'):
char = chr(byte)
if char in '"\\':
out.append('\\' + char)
elif 32 <= byte < 127:
out.append(char)
else:
out.append(f'\\{byte:03o}')
return '"' + ''.join(out) + '"'
def table(code, folder=TRANSLATIONS):
"""(language, keys, texts, plural rule) of a build: every English key, in its language where it has one."""
english = load('en', folder)
language = resolve(code, folder)
own = dict(screen_pairs(load(language, folder))) if language != 'en' else {}
keys, texts = [], []
for key, text in screen_pairs(english):
keys.append(key)
translated = own.get(key)
# Only an empty text is missing: a separator of one space (French thousands) is a text.
texts.append(translated if isinstance(translated, str) and translated != '' else text)
meta = (load(language, folder) or {}).get('_meta', {})
rule = meta.get('plural') if meta.get('plural') in PLURAL_RULES else 'one_other'
return language, keys, texts, rule
def arrays(keys):
"""{list key: (index of its first item, length)} for the lists among the keys (date.months_short.0 ...)."""
found = {}
for index, key in enumerate(keys):
head, _, last = key.rpartition('.')
if head and last.isdigit():
start, length = found.get(head, (index, 0))
found[head] = (start, length + 1)
return found
def keys_header(keys):
"""screen_text_keys.h: the index of every key, and the hash of them all."""
lines = [
'// GENERATED by tools/i18n.py from screen_manager/translations/en.json; run `python3 tools/i18n.py header`',
'// after changing the keys of the `screen` section. The index of every text a screen shows in its language\'s',
'// table (screen_text.h); a list such as the month names is its first index plus a count.',
'#pragma once',
'#include <cstdint>',
'',
'namespace screen_text {',
'namespace txt {',
]
lists = arrays(keys)
listed = {index for start, length in lists.values() for index in range(start, start + length)}
for index, key in enumerate(keys):
if index not in listed:
lines.append(f'constexpr uint16_t {identifier(key)} = {index};')
for key, (start, length) in lists.items():
lines.append(f'constexpr uint16_t {identifier(key)} = {start};')
lines.append(f'constexpr uint16_t {identifier(key)}_count = {length};')
lines += [
'} // namespace txt',
f'constexpr uint16_t KEY_COUNT = {len(keys)};',
f'constexpr uint32_t KEYS_HASH = 0x{keys_hash(keys):08X}u;',
'} // namespace screen_text',
'',
]
return '\n'.join(lines)
def definitions(code, folder=TRANSLATIONS):
"""The C++ a build adds to main.cpp: its language's table and plural rule, and the check against the keys header."""
language, keys, texts, rule = table(code, folder)
rows = ',\n'.join(f' {cpp_string(text)}' for text in texts)
return language, (
f'static_assert(screen_text::KEYS_HASH == 0x{keys_hash(keys):08X}u && screen_text::KEY_COUNT == {len(keys)},\n'
' "screen_text_keys.h does not match screen_manager/translations/en.json: run tools/i18n.py header");\n'
f'const char *const screen_text::TABLE[] = {{\n{rows}\n}};\n'
f'const char *const screen_text::LANGUAGE = {cpp_string(language)};\n'
f'int screen_text::plural_index(int n) {{ {PLURAL_RULES[rule]} }}\n'
)
def english_header(folder=TRANSLATIONS):
"""tests/screen_text_en.h: the English table for the C++ tests, which build without ESPHome."""
_, text = definitions('en', folder)
return ('// GENERATED by tools/i18n.py (`python3 tools/i18n.py header`): the English texts for the C++ tests, which\n'
'// build a header without ESPHome and so without the table a screen\'s build writes into main.cpp.\n'
'#pragma once\n'
'#include "components/smart_display/screen_text.h"\n\n' + text)