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:
@@ -5,6 +5,8 @@ ARG BUILD_ARCH
|
||||
LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${BUILD_ARCH}"
|
||||
RUN pip install --no-cache-dir 'aiohttp>=3.12,<4' PyYAML
|
||||
COPY app /app
|
||||
# Every text of the app, the editor and the screens, one file per language (app 0.2.90).
|
||||
COPY translations /app/translations
|
||||
# The Update badge shows what's new from the changelog (app 0.2.73).
|
||||
COPY CHANGELOG.md /app/CHANGELOG.md
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = '—'
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"_meta": {
|
||||
"name": "English (UK)",
|
||||
"english": "English (UK)",
|
||||
"script": "latin",
|
||||
"plural": "one_other",
|
||||
"clock": "24",
|
||||
"checked": true
|
||||
},
|
||||
"screen": {
|
||||
"light": {
|
||||
"color": "Colour",
|
||||
"color_temperature": "Colour temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,395 @@
|
||||
{
|
||||
"_meta": {
|
||||
"name": "English",
|
||||
"english": "English",
|
||||
"name": "English (US)",
|
||||
"english": "English (US)",
|
||||
"script": "latin",
|
||||
"plural": "one_other",
|
||||
"clock": "12",
|
||||
"checked": true
|
||||
},
|
||||
"screen": {},
|
||||
"addon": {},
|
||||
"screen": {
|
||||
"number": {
|
||||
"decimal": ".",
|
||||
"group": ","
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Just now",
|
||||
"minutes_ago": "{n} min ago",
|
||||
"hours_ago": "1 hour ago | {n} hours ago",
|
||||
"yesterday": "Yesterday",
|
||||
"days_ago": "1 day ago | {n} days ago",
|
||||
"weeks_ago": "1 week ago | {n} weeks ago",
|
||||
"months_ago": "1 month ago | {n} months ago",
|
||||
"years_ago": "1 year ago | {n} years ago",
|
||||
"in_minutes": "In {n} min",
|
||||
"in_hours": "In 1 hour | In {n} hours",
|
||||
"tomorrow": "Tomorrow",
|
||||
"in_days": "In 1 day | In {n} days",
|
||||
"am": "AM",
|
||||
"pm": "PM"
|
||||
},
|
||||
"date": {
|
||||
"weekdays": [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday"
|
||||
],
|
||||
"weekdays_short": [
|
||||
"Sun",
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat"
|
||||
],
|
||||
"weekdays_min": [
|
||||
"Su",
|
||||
"Mo",
|
||||
"Tu",
|
||||
"We",
|
||||
"Th",
|
||||
"Fr",
|
||||
"Sa"
|
||||
],
|
||||
"months": [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December"
|
||||
],
|
||||
"months_short": [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec"
|
||||
],
|
||||
"top_bar": "{weekday} {day} {month}",
|
||||
"full": "{weekday} {day} {month}",
|
||||
"day_month": "{day} {month}",
|
||||
"weekday_time": "{weekday} {time}"
|
||||
},
|
||||
"duration": {
|
||||
"seconds": "{n} s",
|
||||
"minutes": "{n} min",
|
||||
"hours": "{n} h",
|
||||
"hours_minutes": "{h} h {m} min",
|
||||
"days": "{n} d",
|
||||
"days_hours": "{d} d {h} h"
|
||||
},
|
||||
"status": {
|
||||
"connecting": "Connecting to Home Assistant",
|
||||
"waiting": "Waiting for ESP Screens",
|
||||
"loading_tiles": "Loading tiles...",
|
||||
"ha_not_connected": "HA not connected",
|
||||
"manager_not_active": "ESP Screens not active",
|
||||
"choose_tiles": "Choose tiles in HA",
|
||||
"home": "Home",
|
||||
"no_network": "No network"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"brightness": "Brightness",
|
||||
"night": "Night",
|
||||
"screen": "Screen",
|
||||
"this_screen": "This screen",
|
||||
"dark_mode": "Dark mode",
|
||||
"auto_standby": "Auto standby",
|
||||
"standby_after": "Standby after",
|
||||
"standby_brightness": "Standby brightness",
|
||||
"night_mode": "Night mode",
|
||||
"starts": "Starts",
|
||||
"ends": "Ends",
|
||||
"night_brightness": "Night brightness",
|
||||
"back_to_page_1": "Back to page 1",
|
||||
"after": "After",
|
||||
"also_on_standby": "Also on standby",
|
||||
"swipe_between_pages": "Swipe between pages",
|
||||
"page_buttons": "Page buttons",
|
||||
"rotation": "Rotation",
|
||||
"address": "Address",
|
||||
"firmware": "Firmware",
|
||||
"home_assistant": "Home Assistant",
|
||||
"restart": "Restart",
|
||||
"tap_again_to_restart": "Tap again to restart",
|
||||
"seconds": "{n} sec",
|
||||
"minutes": "{n} min",
|
||||
"hours": "{n} h",
|
||||
"hours_minutes": "{h} h {m}"
|
||||
},
|
||||
"tile": {
|
||||
"tap_to_open": "Tap to open",
|
||||
"page": "Page {n}",
|
||||
"unavailable": "Unavailable",
|
||||
"refused": "Refused",
|
||||
"command_sent": "Command sent...",
|
||||
"confirmed": "Confirmed by Home Assistant"
|
||||
},
|
||||
"alert": {
|
||||
"notification": "Notification",
|
||||
"ok": "OK"
|
||||
},
|
||||
"light": {
|
||||
"color": "Color",
|
||||
"color_temperature": "Color temperature",
|
||||
"brightness": "Brightness",
|
||||
"waiting": "Waiting..."
|
||||
},
|
||||
"effects": {
|
||||
"effect": "Effect",
|
||||
"nothing": "Nothing to choose",
|
||||
"loading": "Loading...",
|
||||
"not_connected": "Not connected"
|
||||
},
|
||||
"climate": {
|
||||
"target": "Target",
|
||||
"mode": "Mode",
|
||||
"fan": "Fan",
|
||||
"swing": "Swing",
|
||||
"now": "Now {value}"
|
||||
},
|
||||
"vacuum": {
|
||||
"check_robot": "Check the robot in HA",
|
||||
"auto_suction": "Automatic suction power",
|
||||
"robot_chooses": "The robot chooses suction and water",
|
||||
"per_room": "Suction and water as set per room",
|
||||
"speed_quiet": "Quiet",
|
||||
"speed_balanced": "Normal",
|
||||
"speed_turbo": "Turbo",
|
||||
"speed_max": "Max",
|
||||
"pause_cleaning": "Pause cleaning",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"start_cleaning": "Start cleaning",
|
||||
"clean": "Clean",
|
||||
"dock": "Dock"
|
||||
},
|
||||
"cover": {
|
||||
"position": "Position",
|
||||
"tilt": "Tilt",
|
||||
"tilt_value": "Tilt {n}%"
|
||||
},
|
||||
"weather": {
|
||||
"feels_like": "Feels like {n}°",
|
||||
"no_forecast": "No daily forecast from Home Assistant",
|
||||
"coming_days": "Coming days"
|
||||
},
|
||||
"history": {
|
||||
"no_data": "No data",
|
||||
"average": "average",
|
||||
"average_of": "average of {duration}",
|
||||
"now": "Now",
|
||||
"range_hour": "1 hour",
|
||||
"range_day": "24 hours",
|
||||
"range_week": "1 week",
|
||||
"high": "High {value}",
|
||||
"low": "Low {value}",
|
||||
"times": "once | {n} times",
|
||||
"loading": "Loading history...",
|
||||
"empty": "No history in this period",
|
||||
"unavailable": "No history available"
|
||||
},
|
||||
"media": {
|
||||
"muted": "Muted",
|
||||
"not_playing": "Not playing",
|
||||
"loading": "Loading"
|
||||
},
|
||||
"timer": {
|
||||
"running": "Running",
|
||||
"paused": "Paused",
|
||||
"stopped": "Stopped",
|
||||
"pause": "Pause",
|
||||
"start": "Start",
|
||||
"cancel": "Cancel",
|
||||
"paused_left": "Paused {time}"
|
||||
},
|
||||
"sun": {
|
||||
"name": "Sun",
|
||||
"sunrise": "Sunrise {time}",
|
||||
"sunset": "Sunset {time}",
|
||||
"rise": "rise {time}",
|
||||
"set": "set {time}"
|
||||
},
|
||||
"script": {
|
||||
"running": "Running...",
|
||||
"never_run": "Never run",
|
||||
"last_time": "Last {time}",
|
||||
"yesterday_time": "Yesterday {time}",
|
||||
"last_date": "Last {date}"
|
||||
},
|
||||
"camera": {
|
||||
"no_image": "No image from this camera",
|
||||
"loading": "Loading image",
|
||||
"live": "Live",
|
||||
"recording": "Recording",
|
||||
"tap_to_view": "Tap to view",
|
||||
"no_image_yet": "No image yet"
|
||||
},
|
||||
"button": {
|
||||
"activate": "Activate",
|
||||
"run": "Run",
|
||||
"press": "Press"
|
||||
},
|
||||
"calibration": {
|
||||
"title": "Follow the calibration wizard",
|
||||
"touch_check": "GT911 touch check",
|
||||
"hold": "Hold each tap for a moment",
|
||||
"crosshair": "Tap the crosshair\nPoint {point}/5 - tap {tap}/3",
|
||||
"gently": "Tap gently, a bit longer",
|
||||
"same": "Tap the same crosshair 3 times",
|
||||
"rejected": "Measurement rejected; try again",
|
||||
"save_failed": "Save failed; try again"
|
||||
},
|
||||
"ha": {
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"climate": {
|
||||
"off": "Off",
|
||||
"heat": "Heat",
|
||||
"cool": "Cool",
|
||||
"heat_cool": "Heat/Cool",
|
||||
"auto": "Auto",
|
||||
"dry": "Dry",
|
||||
"fan_only": "Fan only"
|
||||
},
|
||||
"hvac_action": {
|
||||
"heating": "Heating",
|
||||
"cooling": "Cooling",
|
||||
"idle": "Idle",
|
||||
"off": "Off",
|
||||
"drying": "Drying",
|
||||
"fan": "Fan",
|
||||
"preheating": "Preheating",
|
||||
"defrosting": "Defrosting"
|
||||
},
|
||||
"cover": {
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"opening": "Opening",
|
||||
"closing": "Closing"
|
||||
},
|
||||
"media": {
|
||||
"playing": "Playing",
|
||||
"paused": "Paused",
|
||||
"idle": "Idle",
|
||||
"standby": "Standby"
|
||||
},
|
||||
"person": {
|
||||
"home": "Home",
|
||||
"not_home": "Away"
|
||||
},
|
||||
"sun": {
|
||||
"above_horizon": "Above the horizon",
|
||||
"below_horizon": "Below the horizon"
|
||||
},
|
||||
"vacuum": {
|
||||
"docked": "Docked",
|
||||
"cleaning": "Cleaning",
|
||||
"paused": "Paused",
|
||||
"returning": "Returning to dock",
|
||||
"idle": "Idle"
|
||||
},
|
||||
"weather": {
|
||||
"sunny": "Sunny",
|
||||
"clear_night": "Clear, night",
|
||||
"cloudy": "Cloudy",
|
||||
"partlycloudy": "Partly cloudy",
|
||||
"rainy": "Rainy",
|
||||
"pouring": "Pouring",
|
||||
"snowy": "Snowy",
|
||||
"snowy_rainy": "Snowy, rainy",
|
||||
"fog": "Fog",
|
||||
"hail": "Hail",
|
||||
"lightning": "Lightning",
|
||||
"lightning_rainy": "Lightning, rainy",
|
||||
"windy": "Windy",
|
||||
"exceptional": "Exceptional"
|
||||
},
|
||||
"binary": {
|
||||
"battery_on": "Low",
|
||||
"battery_off": "Normal",
|
||||
"battery_charging_on": "Charging",
|
||||
"battery_charging_off": "Not charging",
|
||||
"carbon_monoxide_on": "Danger",
|
||||
"carbon_monoxide_off": "Safe",
|
||||
"cold_on": "Cold",
|
||||
"cold_off": "Normal",
|
||||
"connectivity_on": "Connected",
|
||||
"connectivity_off": "Disconnected",
|
||||
"door_on": "Open",
|
||||
"door_off": "Closed",
|
||||
"garage_door_on": "Open",
|
||||
"garage_door_off": "Closed",
|
||||
"gas_on": "Danger",
|
||||
"gas_off": "Safe",
|
||||
"heat_on": "Hot",
|
||||
"heat_off": "Normal",
|
||||
"light_on": "Light",
|
||||
"light_off": "Dark",
|
||||
"lock_on": "Open",
|
||||
"lock_off": "Locked",
|
||||
"moisture_on": "Wet",
|
||||
"moisture_off": "Dry",
|
||||
"motion_on": "Motion",
|
||||
"motion_off": "No motion",
|
||||
"moving_on": "Moving",
|
||||
"moving_off": "Still",
|
||||
"occupancy_on": "Occupied",
|
||||
"occupancy_off": "Clear",
|
||||
"opening_on": "Open",
|
||||
"opening_off": "Closed",
|
||||
"plug_on": "Plugged in",
|
||||
"plug_off": "Unplugged",
|
||||
"power_on": "On",
|
||||
"power_off": "Off",
|
||||
"presence_on": "Home",
|
||||
"presence_off": "Away",
|
||||
"problem_on": "Problem",
|
||||
"problem_off": "OK",
|
||||
"running_on": "Active",
|
||||
"running_off": "Inactive",
|
||||
"safety_on": "Unsafe",
|
||||
"safety_off": "Safe",
|
||||
"smoke_on": "Smoke",
|
||||
"smoke_off": "No smoke",
|
||||
"sound_on": "Sound",
|
||||
"sound_off": "Silent",
|
||||
"tamper_on": "Tampering",
|
||||
"tamper_off": "OK",
|
||||
"update_on": "Update",
|
||||
"update_off": "Up to date",
|
||||
"vibration_on": "Vibration",
|
||||
"vibration_off": "Still",
|
||||
"window_on": "Open",
|
||||
"window_off": "Closed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"addon": {
|
||||
"errors": {
|
||||
"language_choice": "Choose a language from the list.",
|
||||
"region_choice": "Choose a clock and a number format from the lists."
|
||||
}
|
||||
},
|
||||
"editor": {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user