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
@@ -283,13 +283,14 @@ Every screen has these entities in Home Assistant, on its ESPHome device. `<scre
| `switch.<screen>_dark_mode` | On: the dark look, a black page with graphite cards and soft white text, for a screen beside a bed or in a dark room. Off: the light look. Firmware {DARK_MODE_MIN_FIRMWARE} or newer. |
| `switch.<screen>_night_mode` | Night mode: the night brightness between the two times below. Firmware {SETTING_ENTITIES_MIN_FIRMWARE} or newer, like every row below it. |
| `time.<screen>_night_starts`, `time.<screen>_night_ends` | The night hours; set them with `time.set_value`. |
| `switch.<screen>_24_hour_clock` | On: 24-hour clock. Off: 12-hour clock. |
| `switch.<screen>_back_to_page_1` | On: after `number.<screen>_back_to_page_1_after` seconds without a touch (30 to 3600) the screen closes a card and goes back to page 1. Pressing Wake, an alert or Auto standby don't count as a touch. |
| `switch.<screen>_back_to_page_1_on_standby` | On: going into standby also goes back to page 1. |
| `switch.<screen>_swipe_between_pages` | On: swipe between pages. |
| `switch.<screen>_page_buttons` | On: the Previous and Next bar under the tiles on a screen with more than one page. Off: no bar, the tiles take its room, and only swiping or Go to page tiles change the page. Firmware {PAGE_BUTTONS_MIN_FIRMWARE} or newer. |
| `select.<screen>_rotation` | Guition only: `0°`, `90°`, `180°` or `270°`. |
The 12 or 24-hour clock, the language and how numbers are written are one choice for every screen, in ESP Screens under Settings → Language & region; no entity changes them (firmware 0.2.76 or newer; older firmware still has `switch.<screen>_24_hour_clock`).
Wake and Sleep are buttons: press them with the `button.press` action. They save nothing, so an automation may press them as often as it likes, on every motion too. To reach several screens at once, list their buttons under `entity_id`. Don't target an area or a device with `button.press`: that presses every other button there as well, the Wake and Sleep of the same screen included.
```yaml
+4
View File
@@ -1533,6 +1533,9 @@ def discover_screens(registry, states, devices, areas):
nodes = diagnostic(NAME_DEVICE_NAME, r'[a-z0-9][a-z0-9-]{0,30}')
addresses = diagnostic(NAME_IP_ADDRESS, r'\d{1,3}(\.\d{1,3}){3}')
languages = diagnostic(NAME_SCREEN_LANGUAGE, r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?')
# Firmware from before the languages (0.2.75 and older) has no such sensor: it speaks English, with fewer letters.
speaks = {item.get('device_id') for item in registry
if item.get('platform') == 'esphome' and item.get('original_name') in NAME_SCREEN_LANGUAGE}
screens = []
for item in registry:
eid = item['entity_id']
@@ -1551,6 +1554,7 @@ def discover_screens(registry, states, devices, areas):
'board': boards.get(item.get('device_id'), 'unknown'),
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
'language': languages.get(item.get('device_id')),
'language_sensor': item.get('device_id') in speaks,
'device': device.get('name') or '',
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
# What the screen reports (a word of the firmware's protocol), or ours in English, which the editor
+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':
+7 -6
View File
@@ -24,6 +24,8 @@ from i18n import TRANSLATIONS, screen_number, screen_t, t
# The characters the screen's text fonts carry (the sublabel_big glyph list; tests/test_header_bar.py keeps them equal):
# every letter European languages write with since app 0.2.90.
GLYPHS = frozenset('<>—&@!,.?"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/…·\'#*=;²³µ–ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿĄąĆćĘꣳŃńŚśŹźŻżČčĎďĚěŇňŘřŠšŤťŮůŽžĹ弾ŔŕŐőŰűĂăȘșȚțĐđĀāĒēĢģĪīĶķĻļŅņŪūĖėĮįŲųĞğİıŞşĊċĠġĦħŴŵŶŷĿŀŒœŸ„“”‘’«»‹›¿¡€£')
# The letters of firmware from before the languages (0.2.75 and older), which gets what it can draw.
LEGACY_GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–")
TEXT_BYTES = 40
# The months in English, the language of reference (tests/test_header_bar.py); a screen gets them in its own language
# (short_date, app 0.2.90).
@@ -115,13 +117,14 @@ ALARM_CLASSES = frozenset(['battery', 'carbon_monoxide', 'gas', 'heat', 'lock',
def clean_text(text):
"""Text the screen can draw: known glyphs, accents folded to the base letter, at most TEXT_BYTES."""
glyphs = LEGACY_GLYPHS if i18n.legacy_screen() else GLYPHS
out = []
for char in unicodedata.normalize('NFC', str(text)):
if char in GLYPHS:
if char in glyphs:
out.append(char)
continue
base = unicodedata.normalize('NFKD', char)[0]
if base in GLYPHS:
if base in glyphs:
out.append(base)
return short(''.join(out).strip(), TEXT_BYTES)
@@ -142,10 +145,8 @@ def number_text(value, precision=None):
return screen_number(text)
def with_unit(text, unit):
"""Home Assistant's spacing: "21.3 °C", "65%", "18°"."""
if not unit:
return text
return text + unit if unit in ('%', '°') else f'{text} {unit}'
"""Home Assistant's spacing: "21.3 °C", "65%" ("65 %" in German and French), "18°"."""
return text + i18n.unit_suffix(unit)
def precision_of(entry):
options = ((entry or {}).get('options') or {}).get('sensor') or {}
+110 -22
View File
@@ -11,6 +11,7 @@ changed their profile: the clock and the numbers follow the language.
"""
import contextvars
import json
import math
import logging
import os
import re
@@ -28,7 +29,8 @@ PLACEHOLDER = re.compile(r'\{([a-z_]+)\}')
# Which form of a plural text ("1 hour ago | {n} hours ago") fits n; the firmware (screen_text_gen.py) and the editor
# (web/src/i18n.ts) have the same rules, docs/TRANSLATING.md says which languages use which.
def _slavic(n, one):
t, h = n % 10, n % 100
# C++'s remainder (the sign of n), as the firmware and the editor count.
t, h = int(math.fmod(n, 10)), int(math.fmod(n, 100))
if one:
return 0
return 1 if 2 <= t <= 4 and not 12 <= h <= 14 else 2
@@ -37,7 +39,7 @@ PLURAL = {
'one_other': lambda n: 0 if n == 1 else 1,
'one_upto_1': lambda n: 0 if abs(n) <= 1 else 1,
'slavic_pl': lambda n: _slavic(n, n == 1),
'east_slavic': lambda n: _slavic(n, n % 10 == 1 and n % 100 != 11),
'east_slavic': lambda n: _slavic(n, math.fmod(n, 10) == 1 and math.fmod(n, 100) != 11),
'none': lambda n: 0,
}
@@ -88,7 +90,12 @@ class Translations:
return 'en'
def meta(self, code):
return self.data.get(self.resolve(code), {}).get('_meta', {})
"""A language's `_meta`, its base language's for what a small variant file (en-GB) leaves out."""
language = self.resolve(code)
merged = {}
for name in reversed(list(dict.fromkeys([language, self.resolve(language.split('-')[0])]))):
merged.update(self.data.get(name, {}).get('_meta', {}))
return merged
def languages(self):
"""Every language for the settings: English first, then by English name."""
@@ -97,14 +104,20 @@ class Translations:
for code in self.data]
return sorted(found, key=lambda item: (not item['code'].startswith('en'), item['code'] != 'en', item['english']))
def text(self, key, language='en', **params):
"""`key` in `language`, English where it has none, with {placeholders} filled; `n` picks a plural form."""
def chain(self, language):
"""The files a language's texts come from, most specific first: pt-BR, then pt, then English."""
language = self.resolve(language)
base = self.resolve(language.split('-')[0])
return list(dict.fromkeys([language, base, 'en']))
def text(self, key, language='en', **params):
"""`key` in `language`, else in its base language (pt-BR -> pt), else in English, with {placeholders} filled;
`n` picks a plural form."""
language = self.resolve(language)
text = self.flat.get(language, {}).get(key)
# Only an empty text is missing: a separator of one space (French thousands) is a text.
if not isinstance(text, str) or text == '':
text = self.flat['en'].get(key)
if not isinstance(text, str) or text == '':
text = next((found for found in (self.flat.get(code, {}).get(key) for code in self.chain(language))
if isinstance(found, str) and found != ''), None)
if text is None:
return key
if '|' in text and 'n' in params:
forms = [form.strip() for form in text.split('|')]
@@ -169,32 +182,76 @@ def shown(value):
return value.into(REQUEST_LANGUAGE.get()) if isinstance(value, Text) else value
# How every Home Assistant language writes the clock and numbers (tools/i18n.py cldr writes it from the Unicode CLDR):
# Automatic in Settings -> Language & region follows it, also for a language ESP Screens has no texts for yet.
try:
REGIONS = {code: value for code, value in json.loads((HERE / 'regions.json').read_text(encoding='utf-8')).items()
if isinstance(value, dict)}
except (OSError, ValueError):
REGIONS = {}
# The screens' language and number format (Settings -> Language & region), for the words the app sends to the screens
# itself: tile names, the top bar, the history card. The manager keeps it current (Manager.language_changed).
SCREENS = {'language': 'en', 'numbers': 'point', 'clock_24h': True}
SCREENS = {'language': 'en', 'numbers': 'point', 'clock_24h': True, 'group_min': 1, 'percent_space': False}
# The screen whose messages the app is writing now (Manager.sync_one), for the words that go to that screen alone: the
# language its firmware speaks, which can still be the one before an update, and whether that firmware predates the
# languages (English, fewer letters, numbers of its own).
SCREEN = contextvars.ContextVar('screen', default=None)
LEGACY = {'language': 'en', 'legacy': True, 'numbers': 'point', 'group_min': 1, 'percent_space': False}
def set_screens(language, numbers, clock_24h=True):
SCREENS.update(language=language, numbers=numbers, clock_24h=clock_24h)
def set_screens(language, numbers, clock_24h=True, group_min=1, percent_space=False):
SCREENS.update(language=language, numbers=numbers, clock_24h=clock_24h, group_min=group_min, percent_space=percent_space)
def screen_context(screen):
"""What SCREEN holds for a screen: firmware without the "Screen language" sensor predates the languages; one whose
sensor has no state yet (restarting) counts as speaking the screens' language."""
if not screen:
return None
if not screen.get('language_sensor'):
return LEGACY
return {'language': TRANSLATIONS.resolve(screen.get('language') or SCREENS['language']), 'legacy': False}
def _screen(name):
"""A property of the screen being written for, else of all screens."""
screen = SCREEN.get()
return screen[name] if screen and name in screen else SCREENS[name]
def legacy_screen():
screen = SCREEN.get()
return bool(screen and screen.get('legacy'))
def screen_t(key, **params):
"""A text in the screens' language, for what the app sends to them (and the notification it leaves in Home
Assistant); a param that is a Text goes into that language too."""
return _render(key, SCREENS['language'], params)
return _render(key, _screen('language'), params)
def screen_clock(hour, minute):
"""A time of day as the screens write it: "07:30" on 24 hours, "7:30 AM" on 12, in the language's day periods."""
if SCREENS.get('clock_24h', True):
return f'{hour:02d}:{minute:02d}'
period = TRANSLATIONS.text('screen.time.am' if hour < 12 else 'screen.time.pm', SCREENS['language'])
period = TRANSLATIONS.text('screen.time.am' if hour < 12 else 'screen.time.pm', _screen('language'))
return f'{hour % 12 or 12}:{minute:02d} {period}'
def screen_number(text):
"""A number as Home Assistant sends it, written as the screens write numbers ("1.234,5" in Dutch)."""
return format_number(text, SCREENS['numbers'])
return format_number(text, _screen('numbers'), _screen('group_min'))
def unit_suffix(unit):
"""What follows a number for its unit, spaced as Home Assistant spaces them (blankBeforeUnit): nothing before "°",
" %" or "%" by the language, one space before any other unit."""
if not unit or unit == '°':
return unit or ''
if unit == '%':
return ' %' if _screen('percent_space') else '%'
return ' ' + unit
class Region:
@@ -204,6 +261,8 @@ class Region:
def __init__(self, path, translations=TRANSLATIONS, ha_language=None):
self.path, self.tr = Path(path), translations
self.setting, self.clock, self.numbers = 'auto', 'auto', 'auto'
# The 24 hours an app from before Language & region pinned, until Home Assistant tells what the screens showed.
self.pinned = False
# Home Assistant's own language (get_config), once connected.
self.ha_language = ha_language or (lambda: None)
self.stored = self.path.exists()
@@ -216,13 +275,15 @@ class Region:
self.setting = raw.get('language') if raw.get('language') == 'auto' or raw.get('language') in self.tr.data else 'auto'
self.clock = raw.get('clock') if raw.get('clock') in CLOCKS else 'auto'
self.numbers = raw.get('numbers') if raw.get('numbers') in NUMBERS else 'auto'
self.pinned = raw.get('pinned') is True
def save(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix('.tmp')
with open(temporary, 'w', encoding='utf-8') as handle:
os.chmod(temporary, 0o600)
json.dump({'version': 1, 'language': self.setting, 'clock': self.clock, 'numbers': self.numbers}, handle)
json.dump({'version': 1, 'language': self.setting, 'clock': self.clock, 'numbers': self.numbers,
**({'pinned': True} if self.pinned else {})}, handle)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(self.path)
@@ -239,36 +300,63 @@ class Region:
raise ValueError(t('addon.errors.language_choice'))
if clock not in CLOCKS or numbers not in NUMBERS:
raise ValueError(t('addon.errors.region_choice'))
# The owner choosing a clock ends the pin.
self.setting, self.clock, self.numbers = setting, clock, numbers
self.pinned = self.pinned and 'clock' not in data
self.save()
def language(self):
"""The language of every screen: the chosen one, else Home Assistant's, else English."""
return self.setting if self.setting != 'auto' else self.tr.resolve(self.ha_language() or 'en')
def written(self):
"""How the language writes the clock and numbers: Home Assistant's own language (sv, pt-BR) even when ESP Screens
shows it English or Portuguese texts, the chosen language when there is one. From regions.json, else from the
language's file."""
code = self.setting if self.setting != 'auto' else (self.ha_language() or 'en')
for candidate in (code, str(code).split('-')[0]):
found = REGIONS.get(candidate)
if found:
return found
language = self.tr.resolve(code)
return {'clock': '24' if self.tr.clock_24h(language) else '12', 'numbers': self.tr.number_style(language),
'group_min': 2 if self.tr.text('screen.number.group_min', language) == '2' else 1,
'percent_space': self.tr.text('screen.number.percent', language).startswith(' ')}
def clock_24h(self):
return self.tr.clock_24h(self.language()) if self.clock == 'auto' else self.clock == '24'
return self.written().get('clock') != '12' if self.clock == 'auto' else self.clock == '24'
def number_style(self):
return self.tr.number_style(self.language()) if self.numbers == 'auto' else self.numbers
return self.written().get('numbers', 'point') if self.numbers == 'auto' else self.numbers
def group_min(self):
"""CLDR's minimum grouping digits of the numbers the screens write: 2 is 1234 but 12.345. A chosen format is
Home Assistant's for it (en, de or fr), which groups from 1,234."""
return int(self.written().get('group_min', 1)) if self.numbers == 'auto' else 1
def percent_space(self):
"""Whether the language puts a space before "%" (Home Assistant's rule, whatever the number format)."""
return bool(self.written().get('percent_space'))
def view(self):
"""What the editor's Language & region card shows."""
return {'setting': self.setting, 'effective': self.language(),
'ha': self.ha_language(), 'languages': self.tr.languages(),
'clock': self.clock, 'clock_effective': '24' if self.clock_24h() else '12',
'numbers': self.numbers, 'numbers_effective': self.number_style()}
'numbers': self.numbers, 'numbers_effective': self.number_style(),
'group_min': self.group_min(), 'percent_space': self.percent_space()}
def format_number(text, style):
"""A number as Home Assistant sends it ("1234.5") written in `style` ("1.234,5" for comma); anything else unchanged."""
def format_number(text, style, group_min=1):
"""A number as Home Assistant sends it ("1234.5") written in `style` ("1.234,5" for comma); anything else unchanged.
`group_min` 2 leaves four digits whole (1234, but 12.345), as the language itself does."""
if not isinstance(text, str) or not re.fullmatch(r'-?\d+(\.\d+)?', text):
return text
sign, body = ('-', text[1:]) if text.startswith('-') else ('', text)
whole, _, rest = body.partition('.')
group, decimal = {'point': (',', '.'), 'comma': ('.', ','), 'space': (' ', ',')}.get(style, (',', '.'))
if len(whole) > 3:
if len(whole) > (4 if group_min == 2 else 3):
head = len(whole) % 3 or 3
whole = whole[:head] + ''.join(group + whole[index:index + 3] for index in range(head, len(whole), 3))
return sign + whole + (decimal + rest if rest else '')
+381
View File
@@ -0,0 +1,381 @@
{
"_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.",
"af": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"ar": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"bg": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"bn": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"bs": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ca": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"cs": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"cy": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"da": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"de": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": true
},
"el": {
"clock": "12",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"en": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"en-GB": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"eo": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"es": {
"clock": "24",
"numbers": "comma",
"group_min": 2,
"percent_space": false
},
"et": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"eu": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"fa": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"fi": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"fy": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"fr": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"ga": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"gl": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"gsw": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"he": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"hi": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"hr": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"hu": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"hy": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"id": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"it": {
"clock": "24",
"numbers": "comma",
"group_min": 2,
"percent_space": false
},
"is": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ja": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"ka": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"ko": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"lb": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"lt": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"lv": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"mk": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ml": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"nl": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"nb": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"nn": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"pl": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"pt": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"pt-BR": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ro": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ru": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"sk": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"sl": {
"clock": "24",
"numbers": "comma",
"group_min": 2,
"percent_space": false
},
"sr": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"sr-Latn": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"sv": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"sq": {
"clock": "12",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"ta": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"te": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"th": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"tr": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"uk": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"ur": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"vi": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"zh-Hans": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"zh-Hant": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
}
}
+68 -25
View File
@@ -570,9 +570,9 @@ class Manager:
# Settings -> Language & region (app 0.2.90): the language, clock and numbers of every screen.
self.region = Region(self.path.parent / 'language.json', ha_language=lambda: getattr(self.ha, 'ha_language', None))
self.ha.language_of = self.region.language
self.firmware.language = self.region.language
self.ha.on_config = self.language_changed
self.pin_clock = False
i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.set_screens()
self.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, []
@@ -605,12 +605,36 @@ class Manager:
raise ValueError('Unknown storage version; data stays unchanged.')
# Loaded leniently: a tile setting this version doesn't know (saved by a newer one) never stops the app.
self.layouts = {key: validate_layout(value, stored=True) for key, value in raw['screens'].items()}
# An app from before Language & region (app 0.2.90): its screens keep the 24 hours they show; language_changed
# makes it 12 once Home Assistant says every screen's own clock setting was 12 hours.
if not self.region.stored and self.layouts:
self.region.clock = '24'
# An app from before Language & region (app 0.2.90): its screens keep the 24 hours they show, until Home Assistant
# says every screen's own clock setting was 12 hours (resolve_clock_pin). A new install starts on Automatic, and
# is stored right away so a restart never takes it for an old one.
if not self.region.stored:
if self.layouts:
self.region.clock, self.region.pinned = '24', True
self.region.save()
self.pin_clock = True
def resolve_clock_pin(self):
"""The pinned 24 hours of an app from before Language & region become 12 when every screen's own "24-hour clock"
switch (firmware before 0.2.76) was off; they stay 24 when one was on or none is left. Waits while a switch has no
state yet (a screen restarts). True when the clock changed."""
if not self.region.pinned or not self.ha.online or not getattr(self.ha, 'registry', None):
return False
switches = [item['entity_id'] for item in self.ha.registry if item.get('platform') == 'esphome'
and item.get('original_name') == '24-hour clock' and not item.get('disabled_by')]
states = [self.ha.states.get(entity, {}).get('state') for entity in switches]
if any(state not in ('on', 'off') for state in states):
return False
self.region.pinned = False
twelve = bool(states) and all(state == 'off' for state in states)
if twelve:
self.region.clock = '12'
self.region.save()
return twelve
def set_screens(self):
"""What the app writes for the screens follows Settings -> Language & region (i18n.SCREENS)."""
region = self.region
i18n.set_screens(region.language(), region.number_style(), region.clock_24h(), region.group_min(), region.percent_space())
def inventory(self):
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving."""
@@ -953,11 +977,17 @@ class Manager:
action = self.transport(inbox, screen)
if what is None or not action:
return
await self.ha.send(inbox, await self.card_history(entity, hours, what), action)
# Its words in the language of this screen's firmware (app 0.2.90).
token = i18n.SCREEN.set(i18n.screen_context(screen))
try:
await self.ha.send(inbox, await self.card_history(entity, hours, what), action)
finally:
i18n.SCREEN.reset(token)
async def card_history(self, entity, hours, what):
"""The history message, from a short cache; screens asking at the same time share one fetch."""
key = (entity, hours)
"""The history message, from a short cache; screens asking at the same time share one fetch, per language."""
context = i18n.SCREEN.get() or {}
key = (entity, hours, context.get('language'), bool(context.get('legacy')))
cached = self.card_histories.get(key)
if cached and time.monotonic() - cached[0] < history_card.CACHE_SECONDS[hours]:
return cached[1]
@@ -1145,15 +1175,8 @@ class Manager:
"""The screens' language may have changed (Settings -> Language & region, or Home Assistant's own): the words for
states come again in it, every screen gets all its messages anew, and each screen's YAML gets the language its
next firmware update builds in, which makes that update show."""
if self.pin_clock:
self.pin_clock = False
clocks = [self.ha.states.get(item['entity_id'], {}).get('state') for item in getattr(self.ha, 'registry', [])
if item.get('platform') == 'esphome' and item.get('original_name') == '24-hour clock']
known = [state for state in clocks if state in ('on', 'off')]
if known and all(state == 'off' for state in known):
self.region.clock = '12'
self.region.save()
i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.resolve_clock_pin()
self.set_screens()
self.ha.services_changed.set()
self.sent.clear()
self.write_languages()
@@ -1274,6 +1297,9 @@ class Manager:
# before 0.2.76 ignores both and keeps a clock setting of its own.
message['clock_24h'] = self.region.clock_24h()
message['numbers'] = self.region.number_style()
# From 1234 or 12.345 on, and the space before "%" of the language (firmware 0.2.76+, app 0.2.90).
message['group_min'] = self.region.group_min()
message['percent_space'] = self.region.percent_space()
if 'settings' in message:
message['settings']['clock_24h'] = message['clock_24h']
# The revision the screen echoes on every ping (firmware 0.2.33+; older firmware ignores it).
@@ -1288,6 +1314,15 @@ class Manager:
sent. None rebuilds every message and sends the differences; `force` sends everything."""
if screen is None:
screen = self.screen(inbox) or {}
# The words go in the language this screen's firmware speaks, which is the old one until its update (app 0.2.90).
context = i18n.screen_context(screen)
token = i18n.SCREEN.set(context)
try:
return await self._sync_one(inbox, layout, force, screen, dirty, context)
finally:
i18n.SCREEN.reset(token)
async def _sync_one(self, inbox, layout, force, screen, dirty, context):
needed = self.needs_firmware(inbox, layout, screen)
if needed:
self.status[inbox]=english('addon.status.firmware_needed', version=needed)
@@ -1301,7 +1336,9 @@ class Manager:
self.status[inbox] = english('addon.status.too_large')
return False
previous = self.sent.get(inbox)
full = force or not previous or previous['layout'] != layout_msg or dirty is None
# A screen that now speaks another language (an update, or its sensor reporting after a restart) gets every
# message written anew.
full = force or not previous or previous['layout'] != layout_msg or dirty is None or previous.get('context') != context
# The top bar right after the layout (firmware 0.2.32+; older firmware draws the clock of show_clock).
header_msg = None
if self.supports_header(inbox, screen):
@@ -1335,7 +1372,7 @@ class Manager:
# sent (settings the screen reported itself, see store_settings) keeps the one it had.
sent_layout = any(message is layout_msg for message in outgoing)
rev = layout_msg['rev'] if sent_layout or not previous else previous.get('rev', layout_msg['rev'])
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev}
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev, 'context': context}
# The hourly timer follows a real full transmission; any message refreshes the screen's
# feed window, so the ping timer follows whatever went out (a rebuild without differences,
# after a registry refresh, must not postpone the ping).
@@ -1414,6 +1451,8 @@ class Manager:
event.get('inbox') if isinstance(event, dict) else '?', error)
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
dirty = self.take_dirty()
if self.region.pinned and self.resolve_clock_pin():
self.language_changed()
screens_key = self._screens_key
screens = self.screens()
changed = self._screens_key != screens_key
@@ -1915,11 +1954,15 @@ def create_app(manager, development=False):
return ready[0]
async def identify(request):
"""Identify (app 0.2.73): the screen shows a short card and blinks its backlight, so you know which one it is. The
card speaks the screens' language (app 0.2.90)."""
card speaks the language of the screen's firmware (app 0.2.90)."""
screen = one_alert_target(request.match_info['inbox'])
data, _ = alert_data({'title': screen_t('addon.screen.identify.title', name=screen['name']),
'subtitle': screen_t('addon.screen.identify.subtitle'), 'icon': 'bell-ring',
'color': 'blue', 'button_text': screen_t('screen.alert.ok'), 'timeout': 8, 'flash': True})
token = i18n.SCREEN.set(i18n.screen_context(screen))
try:
data, _ = alert_data({'title': screen_t('addon.screen.identify.title', name=screen['name']),
'subtitle': screen_t('addon.screen.identify.subtitle'), 'icon': 'bell-ring',
'color': 'blue', 'button_text': screen_t('screen.alert.ok'), 'timeout': 8, 'flash': True})
finally:
i18n.SCREEN.reset(token)
await manager.ha.call(alert_service(screen['node']), data)
return web.json_response({'ok': True})
async def test_alert(request):
+13 -3
View File
@@ -103,9 +103,19 @@ class Updater:
def language_due(self, screen):
"""True when a screen speaks another language than Settings -> Language & region says (app 0.2.90): its
"Screen language" sensor, or English for firmware from before that sensor."""
"Screen language" sensor, or English for firmware from before that sensor. A sensor without a state yet (the
screen restarts) says nothing."""
region = getattr(self.manager, 'region', None)
return bool(region) and (screen.get('language') or 'en') != region.language()
if not region or (screen.get('language_sensor') and not screen.get('language')):
return False
return (screen.get('language') or 'en') != region.language()
def speaks_region(self, screen):
"""Whether the screen says it speaks the language of Settings -> Language & region: what an update waits for."""
region = getattr(self.manager, 'region', None)
if not region:
return True
return (screen.get('language') if screen.get('language_sensor') else 'en') == region.language()
def state_for(self, screen, profiles=None):
version = parse_version(screen.get('firmware'))
@@ -251,7 +261,7 @@ class Updater:
while time.monotonic() < deadline:
screen = self.screen(inbox)
version = parse_version(screen.get('firmware')) if screen else None
if screen and screen['online'] and version and version >= TARGET and not self.language_due(screen):
if screen and screen['online'] and version and version >= TARGET and self.speaks_region(screen):
return True
await asyncio.sleep(self.poll_seconds)
return False
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": ".",
"group_min": "1"
"group_min": "1",
"percent": " %"
},
"time": {
"just_now": "Gerade eben",
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ".",
"group": ",",
"group_min": "1"
"group_min": "1",
"percent": "%"
},
"time": {
"just_now": "Just now",
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": ".",
"group_min": "2"
"group_min": "2",
"percent": "%"
},
"time": {
"just_now": "Justo ahora",
+9 -8
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": " ",
"group_min": "1"
"group_min": "1",
"percent": " %"
},
"time": {
"just_now": "À l'instant",
@@ -49,13 +50,13 @@
"sam."
],
"weekdays_min": [
"dim.",
"lun.",
"mar.",
"mer.",
"jeu.",
"ven.",
"sam."
"dim",
"lun",
"mar",
"mer",
"jeu",
"ven",
"sam"
],
"months": [
"janvier",
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": ".",
"group_min": "2"
"group_min": "2",
"percent": "%"
},
"time": {
"just_now": "Proprio ora",
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": ".",
"group_min": "1"
"group_min": "1",
"percent": "%"
},
"time": {
"just_now": "Zojuist",
+9 -8
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": " ",
"group_min": "2"
"group_min": "2",
"percent": "%"
},
"time": {
"just_now": "Przed chwilą",
@@ -49,13 +50,13 @@
"sob."
],
"weekdays_min": [
"niedz.",
"pon.",
"wt.",
"śr.",
"czw.",
"pt.",
"sob."
"nie",
"pon",
"wt",
"śr",
"czw",
"pt",
"sob"
],
"months": [
"stycznia",
+9 -8
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": " ",
"group_min": "2"
"group_min": "2",
"percent": "%"
},
"time": {
"just_now": "Agora mesmo",
@@ -49,13 +50,13 @@
"sábado"
],
"weekdays_min": [
"domingo",
"segunda",
"terça",
"quarta",
"quinta",
"sexta",
"sábado"
"dom",
"seg",
"ter",
"qua",
"qui",
"sex",
"sáb"
],
"months": [
"janeiro",