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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
@@ -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 '')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"group_min": "1"
|
||||
"group_min": "1",
|
||||
"percent": " %"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Gerade eben",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ".",
|
||||
"group": ",",
|
||||
"group_min": "1"
|
||||
"group_min": "1",
|
||||
"percent": "%"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Just now",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"group_min": "2"
|
||||
"group_min": "2",
|
||||
"percent": "%"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Justo ahora",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"group_min": "2"
|
||||
"group_min": "2",
|
||||
"percent": "%"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Proprio ora",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"group_min": "1"
|
||||
"group_min": "1",
|
||||
"percent": "%"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Zojuist",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user