Build each screen in one language: the screen texts from the translations, European letters in the fonts, and Language & region in the app

The firmware takes its texts from screen_manager/translations/<code>.json at build time (components/smart_display:
one table in main.cpp, keys in screen_text_keys.h), and the fonts carry every letter European languages write with.
The app keeps Settings -> Language & region (language, clock, numbers) for every screen, writes LANGUAGE into the
screens' YAML and sends the clock and the number format in the layout message; the per-screen 24-hour clock goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 14:24:19 +02:00
parent 9362a5f1db
commit 378d0b3e61
51 changed files with 2321 additions and 383 deletions
+9 -1
View File
@@ -182,6 +182,8 @@ NAME_SCREEN_FIRMWARE = ('Screen firmware', 'Schermfirmware')
NAME_GUITION_TYPE = ('Guition screen type', 'Guition schermtype')
NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam')
NAME_IP_ADDRESS = ('IP address', 'IP-adres')
# The language a screen's firmware was built in (firmware 0.2.76+, app 0.2.90); older firmware speaks English.
NAME_SCREEN_LANGUAGE = ('Screen language',)
SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS)
def parse_firmware(text):
@@ -304,7 +306,6 @@ SETTING_ENTITIES = {
'night_start': ('time', 'Night starts'),
'night_end': ('time', 'Night ends'),
'night_brightness': ('number', 'Night brightness'),
'clock_24h': ('switch', '24-hour clock'),
'auto_home': ('switch', 'Back to page 1'),
'auto_home_seconds': ('number', 'Back to page 1 after'),
'home_on_standby': ('switch', 'Back to page 1 on standby'),
@@ -315,6 +316,8 @@ SETTING_ENTITIES = {
}
# Entities firmware 0.2.49 added; one of them on a device means the screen owns its settings. The first five
# existed before, so they cannot tell.
# The 12 or 24 hours is Settings -> Language & region's since app 0.2.90, for every screen at once: firmware 0.2.76 has no
# "24-hour clock" entity any more, and the one of older firmware is only a marker.
OWNED_SETTINGS_MARKERS = frozenset(('Night mode', 'Night starts', 'Night ends', '24-hour clock', 'Back to page 1',
'Back to page 1 after', 'Back to page 1 on standby', 'Swipe between pages'))
ROTATION_OPTIONS = ('0°', '90°', '180°', '270°')
@@ -1485,6 +1488,7 @@ def discover_screens(registry, states, devices, areas):
return found
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})?')
screens = []
for item in registry:
eid = item['entity_id']
@@ -1502,6 +1506,7 @@ def discover_screens(registry, states, devices, areas):
'firmware_known': known_firmware(firmware, device.get('sw_version')),
'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')),
'device': device.get('name') or '',
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
'status': state.get('state', 'Not connected')})
@@ -1544,6 +1549,8 @@ def installation_yaml(data):
if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
raise ValueError('Give the screen a recognizable name (60 characters max).')
quote = lambda s: json.dumps(s, ensure_ascii=False)
# The language of the screen's texts (app 0.2.90): Settings -> Language & region, which ESP Screens passes in.
language = data.get('language') if isinstance(data.get('language'), str) and re.fullmatch(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?', data.get('language')) else 'en'
# An OTA password, not yet `ota: encryption:` with the api key: ESPHome before 2026.9 refuses that, and the owner's
# ESPHome Device Builder may still be older (docs/RELEASING.md, Compatibility 0.2.89).
key, ota, ap = base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(12)
@@ -1552,6 +1559,7 @@ def installation_yaml(data):
substitutions:
DEVICE_NAME: {quote(name)}
DEVICE_FRIENDLY_NAME: {quote(friendly.strip())}
LANGUAGE: {quote(language)}
esphome:
name: {quote(name)}
+28 -1
View File
@@ -42,7 +42,8 @@ class Firmware:
OVERRIDE_LIMIT = 12 * 1024 # bytes; checked here, far below the request body limit (128 KB since app 0.2.78)
PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
'external_components', 'captive_portal'})
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'})
# LANGUAGE follows Settings -> Language & region (app 0.2.90), which writes it into the profile itself.
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION', 'LANGUAGE'})
# ESPHome's "Factory format" (bootloader, partition table and app from address 0), the file ESPHome Web
# flashes on a board. A build writes it next to firmware.bin: under .pioenvs/<node>/ with PlatformIO,
# under build/ with ESPHome's native ESP-IDF toolchain.
@@ -187,6 +188,32 @@ class Firmware:
# The key is what Home Assistant asks for when pairing; the page shows it once.
return {'file': profile.name, 'node': data['name'], 'api_key': key}
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."""
profile = self.profile(name)
text = profile.read_text()
if 'homeassistant_espscreen' not in text and 'packages/core.yaml' not in text:
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':
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:
return False
self._atomic_write(profile, updated)
self._names.pop(profile.name, None)
return True
def _override_path(self, name):
profile = self.profile(name)
path = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
+3 -1
View File
@@ -19,7 +19,9 @@ from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_F
# Characters the top bar's text font carries on both boards (`sublabel_big` in the profiles;
# tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes.
GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–")
# 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/…·\'#*=;²³µ–ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿĄąĆćĘꣳŃńŚśŹźŻżČčĎďĚěŇňŘřŠšŤťŮůŽžĹ弾ŔŕŐőŰűĂăȘșȚțĐđĀāĒēĢģĪīĶķĻļŅņŪūĖėĮįŲųĞğİıŞşĊċĠġĦħŴŵŶŷĿŀŒœŸ„“”‘’«»‹›¿¡€£')
TEXT_BYTES = 40
MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
UNAVAILABLE = '—'
+213
View File
@@ -0,0 +1,213 @@
"""The languages of ESP Screens (app 0.2.90).
Every text of the app, the editor and the screens lives in one JSON file per language (screen_manager/translations,
docs/TRANSLATING.md); English is the source, and a text a language lacks shows in English. The editor reads its own
section in the browser, the screens get theirs built into their firmware (components/smart_display/screen_text_gen.py),
and this module gives the app its `addon` texts and the `screen` words it sends to the screens itself.
`Region` is Settings -> Language & region, the one place for every screen: the language (Home Assistant's, or a chosen
one), the clock (12 or 24 hours) and how numbers are written. "auto" does what Home Assistant does for a user who never
changed their profile: the clock and the numbers follow the language.
"""
import contextvars
import json
import logging
import os
import re
from pathlib import Path
LOG = logging.getLogger('screen_manager')
HERE = Path(__file__).resolve().parent
# In the app's image the files sit next to the code (Dockerfile: COPY translations); in a checkout one folder up.
FOLDER = next((path for path in (HERE / 'translations', HERE.parent / 'translations') if path.is_dir()), HERE / 'translations')
CODE = re.compile(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?')
CLOCKS = ('auto', '24', '12')
NUMBERS = ('auto', 'point', 'comma', 'space')
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
if one:
return 0
return 1 if 2 <= t <= 4 and not 12 <= h <= 14 else 2
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),
'none': lambda n: 0,
}
# The language of an editor's request (its X-ESP-Screens-Language header), for the messages the app answers with.
REQUEST_LANGUAGE = contextvars.ContextVar('request_language', default='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
class Translations:
"""Every language file, read once."""
def __init__(self, folder=FOLDER):
self.data, self.flat = {}, {}
for path in sorted(Path(folder).glob('*.json')):
try:
data = json.loads(path.read_text(encoding='utf-8'))
except (OSError, ValueError) as error:
LOG.warning('Translation %s is unreadable (%s); it is left out', path.name, type(error).__name__)
continue
if CODE.fullmatch(path.stem) and isinstance(data, dict):
self.data[path.stem] = data
self.flat[path.stem] = {key: value for key, value in flatten({k: v for k, v in data.items() if k != '_meta'})
if isinstance(value, str)}
self.data.setdefault('en', {'_meta': {'name': 'English', 'english': 'English', 'plural': 'one_other'}})
self.flat.setdefault('en', {})
def resolve(self, code):
"""The language ESP Screens has for `code`: the code itself (in any case), else its base (pt-BR -> pt), else English."""
if not isinstance(code, str):
return 'en'
by_lower = {known.lower(): known for known in self.data}
for candidate in (code, code.split('-')[0]):
if candidate.lower() in by_lower:
return by_lower[candidate.lower()]
return 'en'
def meta(self, code):
return self.data.get(self.resolve(code), {}).get('_meta', {})
def languages(self):
"""Every language for the settings: English first, then by English name."""
found = [{'code': code, 'name': str(self.meta(code).get('name') or code),
'english': str(self.meta(code).get('english') or code), 'checked': self.meta(code).get('checked') is True}
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."""
language = self.resolve(language)
text = self.flat.get(language, {}).get(key) or self.flat['en'].get(key)
if not isinstance(text, str) or not text.strip():
return key
if '|' in text and 'n' in params:
forms = [form.strip() for form in text.split('|')]
rule = PLURAL.get(self.meta(language).get('plural'), PLURAL['one_other'])
try:
text = forms[min(rule(int(params['n'])), len(forms) - 1)]
except (TypeError, ValueError):
text = forms[-1]
return PLACEHOLDER.sub(lambda match: str(params[match.group(1)]) if match.group(1) in params else match.group(0), text)
def clock_24h(self, code):
"""Whether the language writes 24 hours by default (_meta.clock), as Home Assistant's "auto" does."""
return str(self.meta(code).get('clock', '24')) != '12'
def number_style(self, code):
"""How the language writes 1234.5: point (1,234.5), comma (1.234,5) or space (1 234,5)."""
decimal = self.text('screen.number.decimal', code)
group = self.text('screen.number.group', code)
if decimal == '.':
return 'point'
return 'space' if group.strip() == '' else 'comma'
TRANSLATIONS = Translations()
def t(key, **params):
"""An `addon` text in the language of the editor that asked (REQUEST_LANGUAGE)."""
return TRANSLATIONS.text(key, REQUEST_LANGUAGE.get(), **params)
class Region:
"""Settings -> Language & region, kept in /data/language.json: one language, clock and number format for every
screen. A screen's firmware carries its language; the clock and the numbers travel in the layout message."""
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'
# Home Assistant's own language (get_config), once connected.
self.ha_language = ha_language or (lambda: None)
self.stored = self.path.exists()
if self.stored:
try:
raw = json.loads(self.path.read_text(encoding='utf-8'))
except (OSError, ValueError):
raw = {}
if raw.get('version') == 1:
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'
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)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(self.path)
self.stored = True
def change(self, data):
"""Apply {language?, clock?, numbers?} from the editor; ValueError (translated) for anything unknown."""
if not isinstance(data, dict):
raise ValueError(t('addon.errors.language_choice'))
setting = data.get('setting', self.setting)
clock = data.get('clock', self.clock)
numbers = data.get('numbers', self.numbers)
if setting != 'auto' and setting not in self.tr.data:
raise ValueError(t('addon.errors.language_choice'))
if clock not in CLOCKS or numbers not in NUMBERS:
raise ValueError(t('addon.errors.region_choice'))
self.setting, self.clock, self.numbers = setting, clock, numbers
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 clock_24h(self):
return self.tr.clock_24h(self.language()) 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
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()}
def screen_text(self, key, **params):
"""A `screen` or `addon` text in the screens' language, for what the app itself sends to them."""
return self.tr.text(key, self.language(), **params)
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."""
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:
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 '')
+126 -17
View File
@@ -24,6 +24,8 @@ from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entiti
from core import PAGE_TILE_REPEAT_MIN_FIRMWARE, SLOTS_PER_PAGE, firmware_features, packed_slots, run_tile_event, screen_firmware, version_text
import header_bar
import history_card
import i18n
from i18n import REQUEST_LANGUAGE, TRANSLATIONS, Region, t
from zoneinfo import ZoneInfo
@@ -106,6 +108,12 @@ class HomeAssistant:
self.time_zone = None
# Home Assistant's unit system; a climate entity's temperature carries no unit of its own.
self.units = {}
# Home Assistant's own language (get_config), and the one the screens speak (Settings -> Language & region,
# app 0.2.90), in which the words for states are fetched; the manager binds `language_of`.
self.ha_language = None
self.language_of = lambda: 'en'
self.config_stale = False
self.on_config = lambda: None
# (event type, data) of alert events for every screen, for Manager.alert_loop.
self.broadcasts = asyncio.Queue()
# (event type, data) of tile events (app 0.2.51+), for Manager.tile_loop.
@@ -118,11 +126,12 @@ class HomeAssistant:
# 0.2.67); `services_rev` counts the refreshes. `targets` keeps Home Assistant's answer per entity, and
# `target_lookup` stays None until it is known whether Home Assistant answers get_services_for_target (2025.12+).
self.services, self.services_rev, self.targets, self.target_lookup = {}, 0, {}, None
# Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations, English
# like the editor), for Perform action (app 0.2.67). `get_services` has carried no translated names since 2025.10.
self.service_names = {}
# Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`, English), which a
# tile shows where it would show the raw state (app 0.2.67).
# Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations), for Perform
# action (app 0.2.67); `get_services` has carried no translated names since 2025.10. Per language (app 0.2.90): the
# screens' one first, an editor in another language asks for its own (service_names_in).
self.service_names, self.service_names_by_language = {}, {}
# Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`), in the screens'
# language (app 0.2.90), which a tile shows where it would show the raw state (app 0.2.67).
self.state_words = {}
self.esphome_services = False
self._platforms_source, self._platforms = None, {}
@@ -182,6 +191,10 @@ class HomeAssistant:
self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set()
elif event.get('event_type') == 'core_config_updated':
# Settings -> System -> General in Home Assistant, such as its language (app 0.2.90).
self.config_stale = True
self.services_changed.set()
elif event.get('event_type') in SERVICE_EVENTS:
# Every action counts for what the editor offers (app 0.2.67); a screen's own actions also decide
# how the app talks to it.
@@ -216,12 +229,14 @@ class HomeAssistant:
if isinstance(services, dict):
self.services, self.services_rev, self.targets = services, self.services_rev + 1, {}
try:
names = await self.request('frontend/get_translations', language='en', category='services')
language = self.language_of()
names = await self.request('frontend/get_translations', language=language, category='services')
if isinstance((names or {}).get('resources'), dict):
self.service_names = names['resources']
self.service_names_by_language = {language: names['resources']}
words = {}
for category in ('entity_component', 'entity'):
found = await self.request('frontend/get_translations', language='en', category=category)
found = await self.request('frontend/get_translations', language=language, category=category)
if isinstance((found or {}).get('resources'), dict):
words.update(found['resources'])
if words:
@@ -237,6 +252,34 @@ class HomeAssistant:
# The editor then shows the names services.yaml still carries, and tiles keep their raw states.
LOG.info('No action names or state words from Home Assistant (%s)', type(error).__name__)
async def service_names_in(self, language):
"""Home Assistant's names of actions in `language` (the editor's), fetched once per language; the screens' own
when Home Assistant doesn't answer."""
if language == self.language_of() or not self.service_names_by_language:
return self.service_names
if language not in self.service_names_by_language:
try:
names = await self.request('frontend/get_translations', language=language, category='services')
self.service_names_by_language[language] = (names or {}).get('resources') or self.service_names
except (Refused, TimeoutError, ConnectionError):
return self.service_names
return self.service_names_by_language[language]
async def read_config(self):
"""Units, time zone and language from Home Assistant's core config; again when someone changes them there."""
self.config_stale = False
try:
config = await self.request('get_config')
self.units = config.get('unit_system') or {}
self.time_zone = ZoneInfo(config.get('time_zone') or 'UTC')
language = config.get('language')
changed = isinstance(language, str) and language != self.ha_language
self.ha_language = language if isinstance(language, str) else self.ha_language
if changed:
self.on_config()
except (Refused, TimeoutError, ConnectionError, ValueError, TypeError, AttributeError, KeyError):
self.time_zone = self.time_zone or timezone.utc
async def refresh_services(self):
"""fetch_services for the connection loop: a list that cannot be read only means no answers are asked for."""
try:
@@ -310,13 +353,10 @@ class HomeAssistant:
await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries()
# The config first: its language decides the language of the words for states (app 0.2.90).
await self.request('subscribe_events', event_type='core_config_updated')
await self.read_config()
await self.refresh_services()
try:
config = await self.request('get_config')
self.units = config.get('unit_system') or {}
self.time_zone = ZoneInfo(config.get('time_zone') or 'UTC')
except Exception:
self.time_zone = timezone.utc
self.online = True
self.changed.set()
connected = time.monotonic()
@@ -337,6 +377,8 @@ class HomeAssistant:
# A screen that reconnects registers its actions one after the other.
await asyncio.sleep(1)
self.services_changed.clear()
if self.config_stale:
await self.read_config()
await self.refresh_services()
if self.esphome_services:
self.esphome_services = False
@@ -524,6 +566,11 @@ class Manager:
self.listeners = set() # asyncio.Event per open /api/events stream
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
self.updates = Updater(self, self.path.parent / 'updates.json')
# 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.ha.on_config = self.language_changed
self.pin_clock = False
self.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, []
@@ -556,6 +603,12 @@ 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'
self.region.save()
self.pin_clock = True
def inventory(self):
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving."""
@@ -1085,6 +1138,38 @@ class Manager:
for listener in self.listeners:
listener.set()
# ----- Language & region (app 0.2.90) -----
def language_changed(self):
"""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()
self.ha.services_changed.set()
self.sent.clear()
self.write_languages()
self.ha.changed.set()
self.notify()
def write_languages(self):
"""Every paired screen's YAML says the language of its next build (the LANGUAGE substitution)."""
language, profiles = self.region.language(), self.firmware.profile_names()
for screen in self.screens():
profile, _ = self.updates.resolve(screen, profiles)
if not profile:
continue
try:
if self.firmware.set_language(profile, language):
LOG.info('%s builds in %s from its next update', profile, language)
except (OSError, ValueError) as error:
LOG.warning('Could not write the language into %s (%s)', profile, error)
def pending_profiles(self, screens, profiles):
"""ESP Screens profiles without a paired screen: flashed but not yet added in Home Assistant, or not flashed yet.
@@ -1182,6 +1267,12 @@ class Manager:
message['auto_home_seconds'] = layout['settings'].get('auto_home_seconds',120)
if screen.get('board')=='guition':
message['rotation'] = layout['settings'].get('rotation',0)
# The clock and the number format of Settings -> Language & region (app 0.2.90), the same on every screen; firmware
# 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()
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).
message['rev'] = revision(message)
return message
@@ -1600,6 +1691,8 @@ def create_app(manager, development=False):
csrf = secrets.token_urlsafe(32)
@web.middleware
async def guard(request, handler):
# The editor's language for the messages this request answers with (app 0.2.90).
REQUEST_LANGUAGE.set(TRANSLATIONS.resolve(request.headers.get('X-ESP-Screens-Language') or 'en'))
allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'}
if request.remote not in allowed:
raise web.HTTPForbidden(text='Open this page through Home Assistant.')
@@ -1665,7 +1758,8 @@ def create_app(manager, development=False):
screen.update(firmware_features(version))
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
'pending': manager.pending_profiles(screens, profiles),
'updates': manager.updates.summary(screens, profiles)}
'updates': manager.updates.summary(screens, profiles),
'language': manager.region.view()}
async def inventory(request):
if request.query.get('light') == '1':
# The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand.
@@ -1742,8 +1836,18 @@ def create_app(manager, development=False):
actions = await ha.entity_actions(entity) if entity_id(entity) and hasattr(ha, 'entity_actions') else None
if actions is None:
return web.json_response({'actions': None})
# Home Assistant's names in the editor's own language (app 0.2.90).
names = await ha.service_names_in(REQUEST_LANGUAGE.get()) if hasattr(ha, 'service_names_in') else getattr(ha, 'service_names', {})
return web.json_response({'actions': ha_catalogue.action_choices(entity, actions, ha.states.get(entity), ha.services,
getattr(ha, 'service_names', {}), ha.platform_of(entity))})
names, ha.platform_of(entity))})
async def change_language(request):
"""Settings -> Language & region (app 0.2.90): the language, clock and number format of every screen."""
data = await request.json()
before = (manager.region.language(), manager.region.clock_24h(), manager.region.number_style())
manager.region.change({key: data[key] for key in ('setting', 'clock', 'numbers') if isinstance(data, dict) and key in data})
if before != (manager.region.language(), manager.region.clock_24h(), manager.region.number_style()):
manager.language_changed()
return web.json_response({'language': manager.region.view()})
async def change_settings(request):
"""Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57)."""
data = await request.json()
@@ -1834,8 +1938,12 @@ def create_app(manager, development=False):
raise ValueError('Invalid override data.')
return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content')))
async def firmware_create(request):
# Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request.
return web.json_response(manager.firmware.install(await request.json()))
# Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request; the
# screen speaks the language of Settings -> Language & region from its first build (app 0.2.90).
data = await request.json()
if isinstance(data, dict):
data['language'] = manager.region.language()
return web.json_response(manager.firmware.install(data))
async def firmware_download(request):
"""New screen and Firmware & USB → Download: the factory image this app just built, for ESPHome Web on
the owner's own computer. Like the profile it came from, it holds the Wi-Fi password and the screen's keys."""
@@ -1852,6 +1960,7 @@ def create_app(manager, development=False):
app.router.add_post('/api/screens/{inbox}/update', update_screen)
app.router.add_post('/api/updates/run', update_all)
app.router.add_put('/api/updates', update_settings)
app.router.add_put('/api/language', change_language)
app.router.add_get('/api/firmware', firmware_status)
app.router.add_post('/api/firmware/jobs', firmware_start)
app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override)
+10 -2
View File
@@ -87,8 +87,15 @@ class Updater:
profile = matches[0] if len(matches) == 1 else None
return profile, screen.get('ip') or self.hosts.get(screen['id'])
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."""
region = getattr(self.manager, 'region', None)
return bool(region) and (screen.get('language') or 'en') != region.language()
def state_for(self, screen, profiles=None):
version = parse_version(screen.get('firmware'))
language = self.language_due(screen)
profile, host = self.resolve(screen, profiles)
if screen['id'] == self.current:
state = 'running'
@@ -96,7 +103,8 @@ class Updater:
state = 'queued'
else:
state = 'idle'
return {'available': bool(version and TARGET and version < TARGET), 'target': FIRMWARE_VERSION,
return {'available': bool(version and TARGET and version < TARGET) or (bool(version) and language),
'language': bool(version) and language, 'target': FIRMWARE_VERSION,
'profile': profile, 'host': host, 'state': state, 'phase': self.phase if state == 'running' else None,
'result': self.results.get(screen['id'])}
@@ -221,7 +229,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:
if screen and screen['online'] and version and version >= TARGET and not self.language_due(screen):
return True
await asyncio.sleep(self.poll_seconds)
return False