Add Dutch, German, French, Italian, Spanish, Portuguese and Polish with their calendars and Home Assistant's own words

tools/i18n.py gets cldr (day and month names, date orders, numbers and the clock from Node's Intl), ha-words (the
words for states from Home Assistant's own translations, English included) and lint (English left in the firmware's
code). The screens write numbers from Home Assistant in the chosen format, and every text font carries the apostrophe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 14:35:37 +02:00
parent 520826821f
commit 951dceebf9
19 changed files with 2263 additions and 334 deletions
+213 -4
View File
@@ -3,10 +3,15 @@
python3 tools/i18n.py check every language against English: keys, placeholders, plural forms, letters
the screens can't draw, screen texts much longer than the English
python3 tools/i18n.py lint English words left in the firmware's code instead of the translations
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
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
a new language file, such as `new sv Svenska Swedish one_other`
"""
import argparse
import importlib.util
@@ -105,10 +110,13 @@ def check():
if plural and forms and len(text.split('|')) not in (1, forms):
problems.append(f'{code}: {key} needs {forms} forms separated by " | "')
if key.startswith('screen.') and drawable:
unknown = sorted(set(text) - drawable - {'\n'})
shown = PLACEHOLDER.sub('', text).replace('|', '')
unknown = sorted(set(shown) - drawable - {'\n'})
if unknown:
problems.append(f'{code}: {key} uses letters the screens can\'t draw: {"".join(unknown)}')
if code != 'en' and len(text) > max(8, len(source) * 1.6) and not plural:
# Home Assistant's words and the calendar's are what they are; our own texts should stay about as short.
ours = not key.startswith(('screen.ha.', 'screen.date.', 'screen.number.'))
if ours and code != 'en' and len(text) > max(8, len(source) * 1.6) and not plural:
notes.append(f'{code}: {key} is much longer than the English ({len(text)} vs {len(source)})')
for line in notes:
print('note:', line)
@@ -118,26 +126,227 @@ def check():
return 1 if problems else 0
# English that stays in the firmware on purpose, with why. Everything else a screen shows comes from the translations,
# and `lint` fails on a new English text in the code, so it never slips back in.
LINT_KEEP = {
# Statuses the app reads and acts on (screen_manager/app/server.py RESEND_STATES, the "Error" prefix): protocol.
'Synced', 'Loading tiles', 'Layout received', 'Resend needed', 'Ready for tile configuration',
'Use the Easy Setup profile', 'Swipe test started', 'no answer', 'Error: message too large',
'Error: invalid message', 'Error: protocol version', 'Error: screen settings', 'Error: outdated tile',
'Error: no memory for ', 'Error: incomplete message', 'Error: invalid encoding',
# Only a log line or the rate limiter's reason shows these.
'history range', 'card button ', 'media key ', 'let go', 'too short (', 'already handled in this contact',
'same button within the debounce window', 'no runtime tiles', 'setting off', 'screen dimmed', 'card open',
'detail card open', 'camera open', 'USB calibration ready; no tile actions', 'GT911 touch test ready; no tile actions',
'UI_TEST START: page/overlay render stress, no HA actions', 'Color', 'Color temperature', 'Brightness',
# Home Assistant's own values and units the code compares with, not words it shows.
'None', 'Auto', 'Wh',
# Placeholders of the YAML tree that the runtime tiles replace before a screen shows them, and profile defaults.
'Lamp', 'Plug', 'Evening', 'All off', 'AC', 'Vacuum', 'Tile 7', 'Tile 8', 'Tile 9', 'Tile 10', 'Light', 'Light Color',
'Climate', 'Example lamp', 'My CYD', 'My Guition',
}
LINT_FILES = ('components/smart_display/*.h', 'packages/core.yaml', 'packages/boards/*.yaml')
def lint():
"""English words in the firmware's code that should be a key of the translations' `screen` section."""
literal = re.compile(r'"((?:[^"\\]|\\.)*)"')
found = []
for pattern in LINT_FILES:
for path in sorted(ROOT.glob(pattern)):
for number, line in enumerate(path.read_text(encoding='utf-8').splitlines(), 1):
stripped = line.strip()
# Comments, logs, and Home Assistant's entity names (renaming one gives it a new entity id).
if stripped.startswith(('//', '#', '*')) or 'ESP_LOG' in line or 'static_assert' in line or re.match(r'\s*name: "', line):
continue
code = line.split('//')[0]
if path.suffix == '.yaml':
code = re.split(r'\s#', code)[0]
for match in literal.finditer(code):
text = match.group(1)
if text in LINT_KEEP or re.search(r'[();]|::|->|\$\{', text):
continue
if re.match(r'^[A-Z][a-z]', text) or re.search(r'[A-Za-z]{2,} [a-z]{2,}', text):
found.append(f'{path.relative_to(ROOT)}:{number}: "{text}"')
for line in found:
print('English in the firmware (a key of screen_manager/translations/en.json, or tools/i18n.py LINT_KEEP):', line)
return 1 if found else 0
# Where screen.ha's words come from: Home Assistant's own translations (frontend/get_translations, `entity_component`),
# so a screen says what Home Assistant says in the same language.
def ha_sources():
sources = {'on': 'component.switch.entity_component._.state.on', 'off': 'component.switch.entity_component._.state.off'}
for mode in ('off', 'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only'):
sources[f'climate.{mode}'] = f'component.climate.entity_component._.state.{mode}'
for action in ('heating', 'cooling', 'idle', 'off', 'drying', 'fan', 'preheating', 'defrosting'):
sources[f'hvac_action.{action}'] = f'component.climate.entity_component._.state_attributes.hvac_action.state.{action}'
for state in ('open', 'closed', 'opening', 'closing'):
sources[f'cover.{state}'] = f'component.cover.entity_component._.state.{state}'
for state in ('playing', 'paused', 'idle', 'standby'):
sources[f'media.{state}'] = f'component.media_player.entity_component._.state.{state}'
for state in ('home', 'not_home'):
sources[f'person.{state}'] = f'component.person.entity_component._.state.{state}'
for state in ('above_horizon', 'below_horizon'):
sources[f'sun.{state}'] = f'component.sun.entity_component._.state.{state}'
for state in ('docked', 'cleaning', 'paused', 'returning', 'idle'):
sources[f'vacuum.{state}'] = f'component.vacuum.entity_component._.state.{state}'
english = json.loads((FOLDER / 'en.json').read_text(encoding='utf-8'))['screen']['ha']
for key in english['weather']:
sources[f'weather.{key}'] = f'component.weather.entity_component._.state.{key.replace("_", "-") if key != "partlycloudy" else key}'
for key in english['binary']:
device_class, state = key.rsplit('_', 1)
sources[f'binary.{key}'] = f'component.binary_sensor.entity_component.{device_class}.state.{state}'
return sources
def ha_words(write):
"""screen.ha of every language from a Home Assistant (HA_URL and HA_TOKEN, or .esphome/ha_url and ha_token): its own
words for states, English included, so a screen says what Home Assistant says (Max, app 0.2.90)."""
import asyncio
import os
try:
import aiohttp
except ImportError:
raise SystemExit('ha-words needs aiohttp (.venv-portal/bin/python has it)')
def setting(name, file):
value = os.environ.get(name)
for folder in (ROOT, ROOT.parent / 'esphome-cyd-display'):
if not value and (folder / '.esphome' / file).exists():
value = (folder / '.esphome' / file).read_text().strip()
if not value:
raise SystemExit(f'Set {name} or .esphome/{file}')
return value
url, token = setting('HA_URL', 'ha_url').rstrip('/'), setting('HA_TOKEN', 'ha_token')
sources = ha_sources()
async def fetch(codes):
found = {}
async with aiohttp.ClientSession() as session:
async with session.ws_connect(url.replace('http', 'ws', 1) + '/api/websocket') as ws:
await ws.receive_json()
await ws.send_json({'type': 'auth', 'access_token': token})
if (await ws.receive_json()).get('type') != 'auth_ok':
raise SystemExit('Home Assistant refused the token')
for number, code in enumerate(codes, 1):
await ws.send_json({'id': number, 'type': 'frontend/get_translations', 'language': code, 'category': 'entity_component'})
answer = await ws.receive_json()
found[code] = (answer.get('result') or {}).get('resources') or {}
return found
langs = languages()
found = asyncio.run(fetch(list(langs)))
status = 0
for code, data in langs.items():
words = found.get(code) or {}
own = {}
for key, source in sources.items():
if isinstance(words.get(source), str) and words[source]:
own[key] = words[source]
else:
print(f'{code}: Home Assistant has no {source}')
reference = dict(generator().flatten(data.get('screen', {}).get('ha', {})))
if code == 'en':
for key, word in own.items():
if reference.get(key) != word:
print(f'en: screen.ha.{key} "{reference.get(key)}" becomes Home Assistant\'s "{word}"')
tree = {}
for key, word in own.items():
node = tree
parts = key.split('.')
for part in parts[:-1]:
node = node.setdefault(part, {})
node[parts[-1]] = word
before = json.dumps(data, ensure_ascii=False)
merge(data.setdefault('screen', {}).setdefault('ha', {}), tree)
if json.dumps(data, ensure_ascii=False) != before:
print(f'{code}: {"wrote" if write else "would change"} {len(own)} words from Home Assistant')
if write:
write_language(code, data)
return status
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest='command', required=True)
sub.add_parser('check')
sub.add_parser('lint')
head = sub.add_parser('header')
head.add_argument('--check', action='store_true')
cldr = sub.add_parser('cldr')
cldr.add_argument('--write', action='store_true')
words = sub.add_parser('ha-words')
words.add_argument('--write', action='store_true')
fresh = sub.add_parser('new')
fresh.add_argument('code')
fresh.add_argument('name')
fresh.add_argument('english')
fresh.add_argument('plural')
args = parser.parse_args()
if args.command == 'new':
return new_language(args.code, args.name, args.english, args.plural)
if args.command == 'check':
return check()
if args.command == 'lint':
return lint()
if args.command == 'header':
return header(args.check)
if args.command == 'cldr':
return cldr_dates(args.write)
if args.command == 'ha-words':
return ha_words(args.write)
return 2
def cldr_dates(write):
raise SystemExit('not yet')
# The CLDR locale of a file where it differs: Portuguese is Home Assistant's pt, the one of Portugal.
CLDR_LOCALE = {'pt': 'pt-PT'}
def write_language(code, data):
(FOLDER / f'{code}.json').write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def merge(target, source):
"""`source` into `target` in place, keeping the order of what `target` already has."""
for key, value in source.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
merge(target[key], value)
else:
target[key] = value
def cldr_dates(write, codes=None):
"""Day and month names, date orders, AM/PM, the number marks and the clock of every language but English (whose
texts are the source) from Node's Intl, into screen.date, screen.time, screen.number and _meta.clock."""
codes = codes or [code for code in languages() if not code.startswith('en')]
if not codes:
return 0
locales = [CLDR_LOCALE.get(code, code) for code in codes]
found = json.loads(subprocess.run(['node', str(ROOT / 'tools' / 'i18n_cldr.mjs'), *locales], check=True,
capture_output=True, text=True).stdout)
for code, locale in zip(codes, locales):
path = FOLDER / f'{code}.json'
data = json.loads(path.read_text(encoding='utf-8'))
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']})
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)
return 0
def new_language(code, name, english, plural):
"""A new language file: its _meta and the CLDR texts, every other text still English until someone translates it."""
if not re.fullmatch(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?', code) or (FOLDER / f'{code}.json').exists():
raise SystemExit(f'{code}: not a new language code')
if plural not in FORMS:
raise SystemExit(f'plural must be one of {", ".join(FORMS)}')
write_language(code, {'_meta': {'name': name, 'english': english, 'script': 'latin', 'plural': plural, 'clock': '24',
'checked': False}, 'screen': {}, 'addon': {}, 'editor': {}})
return cldr_dates(True, [code])
if __name__ == '__main__':
+52
View File
@@ -0,0 +1,52 @@
// Day and month names, date orders, the decimal mark and the clock of languages, from the Unicode CLDR that Node's Intl
// carries (app 0.2.90); tools/i18n.py cldr writes them into the translations. Prints JSON: {code: {...}}.
const codes = process.argv.slice(2);
const space = (text) => text.replace(/[   ]/g, ' ');
// 2026-03-01 is a Sunday: day i of that week is Sunday + i, as the screens count (ESPHome's day_of_week 1 is Sunday).
const day = (i) => new Date(Date.UTC(2026, 2, 1 + i, 12));
const month = (m) => new Date(Date.UTC(2026, m, 14, 12));
const pattern = (code, options, names) => {
const parts = new Intl.DateTimeFormat(code, { ...options, timeZone: 'UTC' }).formatToParts(new Date(Date.UTC(2026, 8, 14, 14, 5)));
let out = '';
for (const part of parts) {
if (names[part.type]) out += `{${names[part.type]}}`;
else if (part.type === 'literal') out += space(part.value);
}
return out.replace(/\{hour\}:\{minute\}/, '{time}').replace(/\{hour\}\.\{minute\}/, '{time}');
};
const result = {};
for (const code of codes) {
const short = new Intl.DateTimeFormat(code, { weekday: 'short', timeZone: 'UTC' });
const long = new Intl.DateTimeFormat(code, { weekday: 'long', timeZone: 'UTC' });
const monthLong = (m) => new Intl.DateTimeFormat(code, { day: 'numeric', month: 'long', timeZone: 'UTC' }).formatToParts(month(m)).find((p) => p.type === 'month').value;
const monthShort = (m) => new Intl.DateTimeFormat(code, { day: 'numeric', month: 'short', timeZone: 'UTC' }).formatToParts(month(m)).find((p) => p.type === 'month').value;
const numbers = new Intl.NumberFormat(code);
const parts = numbers.formatToParts(12345.5);
const decimal = parts.find((p) => p.type === 'decimal').value;
const group = space(parts.find((p) => p.type === 'group').value);
const period = (hour) => (new Intl.DateTimeFormat(code, { hour: 'numeric', hour12: true, timeZone: 'UTC' })
.formatToParts(new Date(Date.UTC(2026, 0, 1, hour))).find((p) => p.type === 'dayPeriod') || { value: hour < 12 ? 'AM' : 'PM' }).value;
const cycle = new Intl.DateTimeFormat(code, { hour: 'numeric', timeZone: 'UTC' }).resolvedOptions().hourCycle;
// A locale that writes a short date with the month as a number (Portugal: "14/09") gets the standalone short names
// and "day month" instead: the screens' calendar shows a month's name beside the big day.
const numericMonth = /^\d+$/.test(monthShort(8));
const standalone = (m) => space(new Intl.DateTimeFormat(code, { month: 'short', timeZone: 'UTC' }).format(month(m)));
const withNames = (text) => (numericMonth ? text.replace(/\{day\}\W*\{month\}/, '{day} {month}') : text);
result[code] = {
clock: cycle === 'h12' || cycle === 'h11' ? '12' : '24',
number: { decimal, group, group_min: numbers.format(1234).includes(parts.find((p) => p.type === 'group').value) ? '1' : '2' },
time: { am: space(period(9)), pm: space(period(21)) },
date: {
weekdays: [0, 1, 2, 3, 4, 5, 6].map((i) => long.format(day(i))),
weekdays_short: [0, 1, 2, 3, 4, 5, 6].map((i) => short.format(day(i))),
weekdays_min: [0, 1, 2, 3, 4, 5, 6].map((i) => short.format(day(i))),
months: [...Array(12).keys()].map(monthLong),
months_short: [...Array(12).keys()].map(numericMonth ? standalone : monthShort),
top_bar: withNames(pattern(code, { weekday: 'short', day: 'numeric', month: 'short' }, { weekday: 'weekday', day: 'day', month: 'month' })),
full: pattern(code, { weekday: 'long', day: 'numeric', month: 'long' }, { weekday: 'weekday', day: 'day', month: 'month' }),
day_month: withNames(pattern(code, { day: 'numeric', month: 'short' }, { day: 'day', month: 'month' })),
weekday_time: pattern(code, { weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }, { weekday: 'weekday', hour: 'hour', minute: 'minute' }),
},
};
}
console.log(JSON.stringify(result, null, 1));