Merge branch 'i18n-addon' into i18n
This commit is contained in:
@@ -11,6 +11,7 @@ import os
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
from i18n import t
|
||||
import tile_icons
|
||||
from core import (ALERT_CAMERA_FIELD, ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
|
||||
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
|
||||
@@ -433,5 +434,5 @@ def install(directory):
|
||||
temp.write_text(text(), encoding='utf8')
|
||||
temp.replace(directory / 'SKILL.md')
|
||||
except OSError as error:
|
||||
raise ValueError(f"Couldn't write the skill to {directory} ({error.strerror or type(error).__name__}).") from error
|
||||
raise ValueError(t('addon.errors.skill_not_written', folder=directory, reason=error.strerror or type(error).__name__)) from error
|
||||
return {**status(directory), 'restart': restart}
|
||||
|
||||
+148
-103
@@ -7,10 +7,12 @@ import math
|
||||
import re
|
||||
import secrets
|
||||
|
||||
from i18n import english, screen_t, t
|
||||
import tile_icons
|
||||
|
||||
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen camera image'.split())
|
||||
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
|
||||
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them. The names in English: a screen gets them in
|
||||
# its language and the editor in its own (builtin_name, app 0.2.90).
|
||||
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings', **{f'screen.page_{n}': f'Go to page {n}' for n in range(1, 9)}}
|
||||
# A navigation tile (firmware 0.2.62+): screen.page_<n> goes to page n. Firmware 0.2.65+ takes the same one on several
|
||||
# pages (a "Back to page 1" on every page); every other entity still appears once on a screen.
|
||||
@@ -20,6 +22,14 @@ PAGE_TILE_REPEAT_MIN_FIRMWARE = (0, 2, 65)
|
||||
def page_target(entity):
|
||||
"""The page a navigation tile opens, counted from one; 0 for any other entity."""
|
||||
return int(entity[len(PAGE_TILE):]) if isinstance(entity, str) and entity in BUILTIN and entity.startswith(PAGE_TILE) else 0
|
||||
|
||||
def builtin_name(entity, text=screen_t):
|
||||
"""A built-in card's name in the screens' language, for the tile on the screen; `text=t` gives the editor's."""
|
||||
page = page_target(entity)
|
||||
if page:
|
||||
return text('addon.screen.builtin.page', page=page)
|
||||
# The settings tile is named like the settings page it opens.
|
||||
return text('screen.settings.title' if entity == 'screen.settings' else 'addon.screen.builtin.clock')
|
||||
NEW_DOMAINS = frozenset('sun timer person screen'.split())
|
||||
# A camera or an image entity opens full screen on a Guition with firmware 0.2.57+ (camera_feed.py).
|
||||
CAMERA_DOMAINS = frozenset(('camera', 'image'))
|
||||
@@ -33,7 +43,6 @@ FULL_PAGE_MIN_FIRMWARE = (0, 2, 62)
|
||||
# Twenty tiles from firmware 0.2.7, ten before.
|
||||
TWENTY_TILES_MIN_FIRMWARE = (0, 2, 7)
|
||||
FIRST_MAX_TILES = 10
|
||||
WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
# Firmware shipped with this app release; screens below it get an update offer.
|
||||
@@ -55,6 +64,7 @@ ATTRS = frozenset('brightness percentage current_position current_tilt_position
|
||||
BOOL_ATTRS = frozenset(['is_volume_muted'])
|
||||
|
||||
|
||||
# The labels in English, as the Claude skill writes them; the editor gets them in its language (backgrounds()).
|
||||
TILE_BACKGROUNDS = {
|
||||
'auto': {'label': 'Default', 'color': None},
|
||||
# No card behind the tile: contents keep their size and place on the screen background.
|
||||
@@ -70,6 +80,10 @@ TILE_BACKGROUNDS = {
|
||||
'gray': {'label': 'Gray', 'color': '#E5E7EB'},
|
||||
}
|
||||
|
||||
def backgrounds():
|
||||
"""TILE_BACKGROUNDS with their labels in the editor's language (app 0.2.90)."""
|
||||
return {name: {**item, 'label': t(f'addon.labels.backgrounds.{name}')} for name, item in TILE_BACKGROUNDS.items()}
|
||||
|
||||
# Display modes per domain; everything else offers standard and watch (large value).
|
||||
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog'), 'sun': ('standard', 'watch', 'sunpath')}
|
||||
# Displays that only work on a double-width card.
|
||||
@@ -128,7 +142,7 @@ def packed_slots(tiles):
|
||||
0.2.78; the save used to fail later with "Invalid tile position")."""
|
||||
slots = pack_slots(tiles)
|
||||
if any(footprint(slot, tile_size(tile))[-1] >= MAX_SLOTS for tile, slot in zip(tiles, slots)):
|
||||
raise ValueError("These tiles don't fit on eight pages; remove a tile or make one smaller.")
|
||||
raise ValueError(t('addon.errors.layout.eight_pages'))
|
||||
return slots
|
||||
|
||||
def has_gaps(tiles):
|
||||
@@ -138,7 +152,8 @@ def has_gaps(tiles):
|
||||
|
||||
# Direct controls on the right half of a double-width card (firmware 0.2.19+), like
|
||||
# Home Assistant's own entity rows. The first choice is what a wide card shows
|
||||
# when the tile has no explicit choice; 'none' keeps the plain card.
|
||||
# when the tile has no explicit choice; 'none' keeps the plain card. The labels in English,
|
||||
# as the Claude skill writes them; the editor gets them in its language (controls_catalogue).
|
||||
CONTROLS = {
|
||||
'climate': (('setpoint', 'Temperature − / +'), ('mode', 'Off, heat, cool')),
|
||||
'switch': (('toggle', 'On/off switch'),),
|
||||
@@ -160,8 +175,9 @@ CONTROLS = {
|
||||
}
|
||||
|
||||
def controls_catalogue():
|
||||
"""Editor choices per domain: the default first, then 'none'."""
|
||||
return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': label} for key, label in choices] + [{'key': 'none', 'label': 'None'}]}
|
||||
"""Editor choices per domain: the default first, then 'none'; labels in the editor's language (app 0.2.90)."""
|
||||
return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': t(f'addon.labels.controls.{domain}.{key}')} for key, _ in choices]
|
||||
+ [{'key': 'none', 'label': t('addon.labels.controls.none')}]}
|
||||
for domain, choices in CONTROLS.items()}
|
||||
|
||||
def resolve_controls(tile):
|
||||
@@ -374,7 +390,7 @@ def setting_action(key, entity, value):
|
||||
|
||||
def validate_settings(data):
|
||||
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
|
||||
raise ValueError('Unknown screen settings; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.settings.unknown'))
|
||||
clean = {}
|
||||
for key, (default, minimum, maximum) in SETTING_RULES.items():
|
||||
value = data.get(key, default)
|
||||
@@ -384,10 +400,10 @@ def validate_settings(data):
|
||||
valid = type(value) is int and minimum <= value <= maximum
|
||||
if key == "rotation": valid = valid and value in (0, 90, 180, 270)
|
||||
if not valid:
|
||||
raise ValueError(f'Invalid value for {key}.')
|
||||
raise ValueError(t('addon.errors.settings.invalid_value', setting=key))
|
||||
clean[key] = value
|
||||
if max(clean['standby_brightness'], clean['night_brightness']) > clean['brightness']:
|
||||
raise ValueError('Standby and night brightness may not be higher than normal.')
|
||||
raise ValueError(t('addon.errors.settings.dim_above_normal'))
|
||||
return clean
|
||||
|
||||
def entity_id(value):
|
||||
@@ -404,8 +420,9 @@ HEADER_MIN_FIRMWARE = (0, 2, 32)
|
||||
TRANSPORT_MIN_FIRMWARE = (0, 2, 33)
|
||||
MESSAGE_ACTION = 'screen_message'
|
||||
HEADER_MAX_ITEMS = 6
|
||||
# Items the screen draws on its own clock, without Home Assistant.
|
||||
HEADER_BUILTIN = {'clock': 'Time', 'analog': 'Analog clock', 'date': 'Date'}
|
||||
# Items the screen draws on its own clock, without Home Assistant; their labels are in the translations (header_bar.catalogue,
|
||||
# app 0.2.90).
|
||||
HEADER_BUILTIN = ('clock', 'analog', 'date')
|
||||
# Only shown, never controlled: the top bar takes these besides every tile domain.
|
||||
HEADER_ONLY_DOMAINS = frozenset('device_tracker zone lock alarm_control_panel counter event input_datetime input_text water_heater humidifier'.split())
|
||||
HEADER_CONTENTS = ('state', 'last_changed')
|
||||
@@ -423,32 +440,32 @@ def header_items(layout):
|
||||
|
||||
def validate_header(data):
|
||||
if not isinstance(data, dict) or set(data) - {'items'} or not isinstance(data.get('items'), list):
|
||||
raise ValueError('Invalid top bar; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.top_bar.invalid'))
|
||||
if len(data['items']) > HEADER_MAX_ITEMS:
|
||||
raise ValueError(f'The top bar has room for at most {HEADER_MAX_ITEMS} items.')
|
||||
raise ValueError(t('addon.errors.top_bar.full', n=HEADER_MAX_ITEMS))
|
||||
items, seen = [], set()
|
||||
for item in data['items']:
|
||||
kind = item.get('type') if isinstance(item, dict) else None
|
||||
if kind in HEADER_BUILTIN:
|
||||
if set(item) != {'type'}:
|
||||
raise ValueError('Invalid setting in the top bar.')
|
||||
raise ValueError(t('addon.errors.top_bar.invalid_setting'))
|
||||
clean = {'type': kind}
|
||||
elif kind == 'entity':
|
||||
if set(item) - {'type', 'entity', 'content', 'icon', 'show'}:
|
||||
raise ValueError('Unknown setting in the top bar; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.top_bar.unknown_setting'))
|
||||
if not header_entity(item.get('entity')):
|
||||
raise ValueError("This entity can't go in the top bar.")
|
||||
raise ValueError(t('addon.errors.top_bar.entity'))
|
||||
clean = {'type': 'entity', 'entity': item['entity'], 'content': item.get('content', 'state'),
|
||||
'icon': item.get('icon', 'auto'), 'show': item.get('show', 'always')}
|
||||
if clean['content'] not in HEADER_CONTENTS or clean['show'] not in HEADER_SHOWS:
|
||||
raise ValueError('Invalid setting in the top bar.')
|
||||
raise ValueError(t('addon.errors.top_bar.invalid_setting'))
|
||||
if not (clean['icon'] in ('auto', 'none') or isinstance(clean['icon'], str) and clean['icon'] in tile_icons.ICONS):
|
||||
raise ValueError('Choose an icon from the list.')
|
||||
raise ValueError(t('addon.errors.choose_icon'))
|
||||
else:
|
||||
raise ValueError('Unknown item in the top bar; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.top_bar.unknown_item'))
|
||||
key = json.dumps(clean, sort_keys=True)
|
||||
if key in seen:
|
||||
raise ValueError('This item is already in the top bar.')
|
||||
raise ValueError(t('addon.errors.top_bar.twice'))
|
||||
seen.add(key)
|
||||
items.append(clean)
|
||||
return {'items': items}
|
||||
@@ -503,20 +520,20 @@ def loose(text):
|
||||
def match_screen(screens, wanted, layouts=None):
|
||||
"""The screen an event means: by its device name, the name Home Assistant shows, or its title.
|
||||
With one screen paired, an event doesn't have to name it."""
|
||||
listing = ', '.join(sorted(screen['name'] for screen in screens)) or 'none yet'
|
||||
listing = ', '.join(sorted(screen['name'] for screen in screens)) or t('addon.errors.events.none_paired')
|
||||
if not loose(wanted):
|
||||
if len(screens) == 1:
|
||||
return screens[0]
|
||||
raise ValueError(f'Name the screen. Paired: {listing}.')
|
||||
raise ValueError(t('addon.errors.events.name_the_screen', screens=listing))
|
||||
key = loose(wanted)
|
||||
def names(screen):
|
||||
title = (layouts or {}).get(screen['id'], {}).get('title', '')
|
||||
return [loose(value) for value in (screen.get('node'), screen.get('name'), screen.get('device'), title, screen.get('area')) if value]
|
||||
found = [s for s in screens if key in names(s)] or [s for s in screens if any(key in name for name in names(s))]
|
||||
if not found:
|
||||
raise ValueError(f'No screen called "{wanted}". Paired: {listing}.')
|
||||
raise ValueError(t('addon.errors.events.no_such_screen', name=wanted, screens=listing))
|
||||
if len(found) > 1:
|
||||
raise ValueError(f'"{wanted}" fits more than one screen: ' + ', '.join(sorted(s['name'] for s in found)) + '.')
|
||||
raise ValueError(t('addon.errors.events.several_screens', name=wanted, screens=', '.join(sorted(s['name'] for s in found))))
|
||||
return found[0]
|
||||
|
||||
def occupied_cells(tiles, skip=None):
|
||||
@@ -553,15 +570,15 @@ def place_tile(tile, tiles, page=None, slot=None):
|
||||
wanted = set(footprint(slot, size))
|
||||
taken = next((t for t in tiles if t is not tile and 'slot' in t and wanted & set(footprint(t['slot'], tile_size(t)))), None)
|
||||
if taken:
|
||||
raise ValueError(f"That spot is taken by {taken['entity']}; give another spot or move that one first.")
|
||||
raise ValueError(t('addon.errors.events.spot_taken', entity=taken['entity']))
|
||||
tile['slot'] = slot
|
||||
return
|
||||
free = free_slot(tiles, size, page, skip=tile)
|
||||
if free is None:
|
||||
if size == 'full':
|
||||
raise ValueError(f'Page {page + 1} is not empty; a full-page tile needs a page of its own.' if page is not None
|
||||
else 'No page is empty; a full-page tile needs a page of its own.')
|
||||
raise ValueError(f'Page {page + 1} is full.' if page is not None else 'This screen has no room left.')
|
||||
raise ValueError(t('addon.errors.events.page_not_empty', page=page + 1) if page is not None
|
||||
else t('addon.errors.events.no_empty_page'))
|
||||
raise ValueError(t('addon.errors.events.page_full', page=page + 1) if page is not None else t('addon.errors.events.no_room'))
|
||||
tile['slot'] = free
|
||||
|
||||
def tile_options(data, current=None):
|
||||
@@ -593,25 +610,25 @@ def event_page(data):
|
||||
if page in (None, ''):
|
||||
return None
|
||||
if not str(page).strip().isdigit() or not 1 <= int(page) <= MAX_PAGES:
|
||||
raise ValueError(f'Choose a page between 1 and {MAX_PAGES}.')
|
||||
raise ValueError(t('addon.errors.events.page_range', last=MAX_PAGES))
|
||||
return int(page) - 1
|
||||
|
||||
def event_slot(data, page):
|
||||
"""An exact spot: `slot` as the editor counts it, or `row` and `column` within a page."""
|
||||
if data.get('slot') not in (None, ''):
|
||||
if not str(data['slot']).strip().isdigit() or not 0 <= int(data['slot']) < MAX_SLOTS:
|
||||
raise ValueError(f'Choose a spot between 0 and {MAX_SLOTS - 1}.')
|
||||
raise ValueError(t('addon.errors.events.spot_range', last=MAX_SLOTS - 1))
|
||||
return int(data['slot'])
|
||||
if data.get('row') in (None, '') and data.get('column') in (None, ''):
|
||||
return None
|
||||
if page is None:
|
||||
raise ValueError('Name the page for that row or column.')
|
||||
raise ValueError(t('addon.errors.events.row_needs_page'))
|
||||
row = str(data.get('row', 1)).strip()
|
||||
if not row.isdigit() or not 1 <= int(row) <= SLOTS_PER_PAGE // 2:
|
||||
raise ValueError(f'Choose a row between 1 and {SLOTS_PER_PAGE // 2}.')
|
||||
raise ValueError(t('addon.errors.events.row_range', last=SLOTS_PER_PAGE // 2))
|
||||
column = loose(data.get('column') or 'left')
|
||||
if column not in ('left', 'right'):
|
||||
raise ValueError('The column is left or right.')
|
||||
raise ValueError(t('addon.errors.events.column'))
|
||||
return page * SLOTS_PER_PAGE + (int(row) - 1) * 2 + (1 if column == 'right' else 0)
|
||||
|
||||
def page_of(tile):
|
||||
@@ -636,9 +653,11 @@ def event_source(data):
|
||||
slot = event_slot({'slot': data.get('from_slot')}, None)
|
||||
return (slot // SLOTS_PER_PAGE if slot is not None else page), slot
|
||||
|
||||
def where(page, slot):
|
||||
"""The place an event named, as its answer says it: "spot 12" or "page 3"."""
|
||||
return f'spot {slot}' if slot is not None else f'page {page + 1}'
|
||||
def not_there(entity, page, slot):
|
||||
"""The answer to an event that named a place where the tile isn't: "... is not on spot 12" or "... on page 3"."""
|
||||
if slot is not None:
|
||||
return t('addon.errors.events.not_on_spot', entity=entity, slot=slot)
|
||||
return t('addon.errors.events.not_on_page', entity=entity, page=page + 1)
|
||||
|
||||
def pack_page(tiles, page):
|
||||
"""Give these tiles the cells of one page, in the order they are in."""
|
||||
@@ -647,11 +666,11 @@ def pack_page(tiles, page):
|
||||
for tile in tiles:
|
||||
size = tile_size(tile)
|
||||
if size == 'full' and (position != page * SLOTS_PER_PAGE or len(tiles) > 1):
|
||||
raise ValueError(f'A full-page tile takes all of page {page + 1}; nothing else fits there.')
|
||||
raise ValueError(t('addon.errors.events.full_page_alone', page=page + 1))
|
||||
if size == 'wide' and position % 2:
|
||||
position += 1
|
||||
if position + cells_of(size) > last:
|
||||
raise ValueError(f'That does not fit on page {page + 1}; a double-width tile takes two spots.')
|
||||
raise ValueError(t('addon.errors.events.does_not_fit', page=page + 1))
|
||||
tile['slot'] = position
|
||||
position += cells_of(size)
|
||||
|
||||
@@ -676,7 +695,7 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
wanted = [part.strip() for part in wanted.split(',')]
|
||||
wanted = [str(item).strip() for item in wanted if str(item).strip()]
|
||||
if not wanted:
|
||||
raise ValueError('Give the entities in the order you want them.')
|
||||
raise ValueError(t('addon.errors.events.order_needed'))
|
||||
picked, taken, missing, elsewhere, extra = [], set(), [], [], []
|
||||
for name in wanted:
|
||||
copies = copies_of(tiles, name)
|
||||
@@ -692,12 +711,13 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
picked.append(free[0])
|
||||
taken.add(id(free[0]))
|
||||
if missing:
|
||||
raise ValueError('Not on this screen: ' + ', '.join(missing) + '.')
|
||||
raise ValueError(t('addon.errors.events.order_not_on_screen', entities=', '.join(missing)))
|
||||
if elsewhere:
|
||||
raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.join(elsewhere)))
|
||||
raise ValueError(t('addon.errors.events.order_not_on_page', page=page + 1, entities=', '.join(elsewhere)))
|
||||
if extra:
|
||||
raise ValueError('Named more often than it is on %s: %s.' % ('this screen' if page is None else f'page {page + 1}',
|
||||
', '.join(dict.fromkeys(extra))))
|
||||
extra = ', '.join(dict.fromkeys(extra))
|
||||
raise ValueError(t('addon.errors.events.named_too_often', entities=extra) if page is None
|
||||
else t('addon.errors.events.named_too_often_page', page=page + 1, entities=extra))
|
||||
if page is None:
|
||||
tiles = picked + [tile for tile in tiles if id(tile) not in taken]
|
||||
for tile, cell in zip(tiles, packed_slots(tiles)):
|
||||
@@ -707,7 +727,7 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
result['tiles'] = tiles
|
||||
return result, None
|
||||
if not entity:
|
||||
raise ValueError('Name the entity.')
|
||||
raise ValueError(t('addon.errors.events.name_the_entity'))
|
||||
copies = copies_of(tiles, entity)
|
||||
found = copies[0] if copies else None
|
||||
if action == 'remove':
|
||||
@@ -715,26 +735,26 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
if source_page is None:
|
||||
source_page, source_slot = page, slot
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on this screen.')
|
||||
raise ValueError(t('addon.errors.events.not_on_screen', entity=entity))
|
||||
found = pick_copy(copies, source_page, source_slot)
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on {where(source_page, source_slot)}.')
|
||||
raise ValueError(not_there(entity, source_page, source_slot))
|
||||
# The copy that was named itself, not the first tile equal to it.
|
||||
tiles = [tile for tile in tiles if tile is not found]
|
||||
elif action == 'move':
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on this screen; add it first.')
|
||||
raise ValueError(t('addon.errors.events.add_it_first', entity=entity))
|
||||
source_page, source_slot = event_source(data)
|
||||
found = pick_copy(copies, source_page, source_slot)
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on {where(source_page, source_slot)}.')
|
||||
raise ValueError(not_there(entity, source_page, source_slot))
|
||||
if page is None and slot is None:
|
||||
raise ValueError('Name the page or the spot to move it to.')
|
||||
raise ValueError(t('addon.errors.events.move_where'))
|
||||
found.pop('slot', None)
|
||||
place_tile(found, tiles, page, slot)
|
||||
else:
|
||||
if not entity_id(entity) and entity not in BUILTIN:
|
||||
raise ValueError(f'{entity} cannot go on a screen.')
|
||||
raise ValueError(t('addon.errors.events.not_for_a_screen', entity=entity))
|
||||
# A navigation tile the firmware takes more than once: the copy on the named spot or page changes, and anywhere
|
||||
# else a new copy goes (app 0.2.78). Every other tile, or one without a place, is the one that is there.
|
||||
chosen = False
|
||||
@@ -752,7 +772,7 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
tile.pop('options', None)
|
||||
if found is None:
|
||||
if len(tiles) >= MAX_TILES:
|
||||
raise ValueError(f'This screen already has {MAX_TILES} tiles; remove one first.')
|
||||
raise ValueError(t('addon.errors.events.screen_full', n=MAX_TILES))
|
||||
tiles.append(tile)
|
||||
size = tile_size(tile)
|
||||
move = found is None or had_slot is None or (not chosen and (page is not None or slot is not None))
|
||||
@@ -824,45 +844,45 @@ def action_for_screen(value):
|
||||
def validate_tap_action(value):
|
||||
"""The stored form of a tap's own action; ValueError with what to change."""
|
||||
if not isinstance(value, dict) or set(value) - {'action', 'data'}:
|
||||
raise ValueError('Choose an action from the list.')
|
||||
raise ValueError(t('addon.errors.tap_action.choose'))
|
||||
name, data = value.get('action'), value.get('data', {})
|
||||
if not isinstance(name, str) or len(name) > 64 or not ACTION_NAME.fullmatch(name):
|
||||
raise ValueError('Choose an action from the list.')
|
||||
raise ValueError(t('addon.errors.tap_action.choose'))
|
||||
if not isinstance(data, dict) or len(data) > ACTION_MAX_FIELDS:
|
||||
raise ValueError(f'An action on a tap sets at most {ACTION_MAX_FIELDS} fields.')
|
||||
raise ValueError(t('addon.errors.tap_action.fields_max', n=ACTION_MAX_FIELDS))
|
||||
for key in data:
|
||||
if not isinstance(key, str) or not ACTION_FIELD.fullmatch(key) or key in TARGET_KEYS:
|
||||
raise ValueError(f'{key} is not a field an action on a tap can set.')
|
||||
raise ValueError(t('addon.errors.tap_action.field_not_allowed', field=key))
|
||||
clean = {'action': name, **({'data': dict(data)} if data else {})}
|
||||
try:
|
||||
act = action_for_screen(clean)
|
||||
size = len(json.dumps(act, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode())
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('A value of the action is something the screen cannot send.') from None
|
||||
raise ValueError(t('addon.errors.tap_action.unsendable')) from None
|
||||
if size > ACTION_MAX_BYTES or any(len(item[1].encode()) > ACTION_MAX_VALUE for item in act.get('d', []) + act.get('t', [])):
|
||||
raise ValueError('The values of the action are too long for the screen.')
|
||||
raise ValueError(t('addon.errors.tap_action.too_long'))
|
||||
return clean
|
||||
|
||||
def validate_layout(data, stored=False):
|
||||
"""A layout as the editor, a tile event or the storage gives it. `stored`: loaded from the app's own data, where a
|
||||
tile setting this version doesn't know (saved by a newer app) stays as it is instead of stopping the app."""
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Invalid layout.')
|
||||
raise ValueError(t('addon.errors.layout.invalid'))
|
||||
title, tiles = data.get('title'), data.get('tiles')
|
||||
if not isinstance(title, str) or not title.strip() or len(title.encode()) > 96:
|
||||
raise ValueError('Give the screen a title of at most 96 bytes.')
|
||||
raise ValueError(t('addon.errors.layout.title'))
|
||||
if not isinstance(tiles, list) or len(tiles) > MAX_TILES:
|
||||
raise ValueError(f'Choose at most {MAX_TILES} tiles.')
|
||||
raise ValueError(t('addon.errors.layout.tiles_max', n=MAX_TILES))
|
||||
clean, seen = [], set()
|
||||
for tile in tiles:
|
||||
if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
|
||||
raise ValueError("This entity isn't supported.")
|
||||
raise ValueError(t('addon.errors.layout.unsupported'))
|
||||
# A navigation tile may go on several pages (min_firmware asks 0.2.65 for that); anything else appears once.
|
||||
if tile['entity'] in seen and not page_target(tile['entity']):
|
||||
raise ValueError('An entity can only appear once on a screen.')
|
||||
raise ValueError(t('addon.errors.layout.once'))
|
||||
name = tile.get('name', '')
|
||||
if not isinstance(name, str) or len(name.encode()) > 80:
|
||||
raise ValueError('A tile name may contain at most 80 bytes.')
|
||||
raise ValueError(t('addon.errors.layout.tile_name'))
|
||||
seen.add(tile['entity'])
|
||||
item = {'entity': tile['entity'], 'name': name.strip()}
|
||||
if stored and 'options' in tile:
|
||||
@@ -876,46 +896,46 @@ def validate_layout(data, stored=False):
|
||||
if 'options' in tile:
|
||||
options = tile['options']
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls', 'action'}:
|
||||
raise ValueError('Unknown tile settings.')
|
||||
raise ValueError(t('addon.errors.layout.unknown_settings'))
|
||||
# A navigation tile (screen.page_<n>, firmware 0.2.62+) has a name, an icon, a colour and a width; never the page.
|
||||
if page_target(tile['entity']):
|
||||
if options.get('size') == 'full':
|
||||
raise ValueError('A navigation tile is single or double width.')
|
||||
raise ValueError(t('addon.errors.layout.navigation_size'))
|
||||
options = {k: v for k, v in options.items() if k not in ('display', 'inline', 'controls', 'history_hours')}
|
||||
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
|
||||
raise ValueError('Choose a pastel background color from the palette.')
|
||||
raise ValueError(t('addon.errors.layout.background'))
|
||||
if 'icon' in options and not (options['icon'] == 'auto' or isinstance(options['icon'], str) and options['icon'] in tile_icons.ICONS):
|
||||
raise ValueError('Choose an icon from the list.')
|
||||
raise ValueError(t('addon.errors.choose_icon'))
|
||||
domain = tile['entity'].split('.')[0]
|
||||
displays = DISPLAYS.get(domain, ('standard', 'watch'))
|
||||
choices = {'tap': ('auto', 'detail', 'toggle', 'none', 'action'), 'display': displays, 'inline': ('none', 'slider'), 'size': TILE_SIZES_ON_SCREEN}
|
||||
for key, allowed in choices.items():
|
||||
if key in options and options[key] not in allowed:
|
||||
raise ValueError('Invalid tile setting: ' + key)
|
||||
raise ValueError(t('addon.errors.layout.invalid_setting', setting=key))
|
||||
# The five-day strip and the sun path only fit a double-width card (or the whole page).
|
||||
if options.get('display') in WIDE_ONLY and options.get('size') != 'full':
|
||||
options = {**options, 'size': 'wide'}
|
||||
# On / off sends <domain>.toggle. Whether Home Assistant offers that for the entity is checked when saving
|
||||
# (Manager.check_supported, app 0.2.67); the built-in cards have nothing to switch.
|
||||
if options.get('tap') == 'toggle' and domain == 'screen':
|
||||
raise ValueError("This entity doesn't support an on/off action.")
|
||||
raise ValueError(t('addon.errors.layout.no_toggle'))
|
||||
# Perform action (app 0.2.67) keeps its action; another tap choice leaves a stale one behind.
|
||||
if options.get('tap') == 'action':
|
||||
if domain == 'screen':
|
||||
raise ValueError("A built-in card can't perform an action.")
|
||||
raise ValueError(t('addon.errors.layout.builtin_action'))
|
||||
options = {**options, 'action': validate_tap_action(options.get('action'))}
|
||||
elif 'action' in options:
|
||||
options = {key: value for key, value in options.items() if key != 'action'}
|
||||
if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}:
|
||||
raise ValueError("This entity doesn't support a mini-slider.")
|
||||
raise ValueError(t('addon.errors.layout.no_mini_slider'))
|
||||
if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)):
|
||||
raise ValueError('History: choose 1, 6, or 24 hours.')
|
||||
raise ValueError(t('addon.errors.layout.history_hours'))
|
||||
if options.get('display') == 'watch' and options.get('inline') == 'slider':
|
||||
raise ValueError('Choose large value or mini-slider.')
|
||||
raise ValueError(t('addon.errors.layout.watch_or_slider'))
|
||||
if 'controls' in options:
|
||||
allowed = ('none',) + tuple(key for key, _ in CONTROLS.get(domain, ()))
|
||||
if not isinstance(options['controls'], str) or options['controls'] not in allowed:
|
||||
raise ValueError("This entity doesn't support that direct control.")
|
||||
raise ValueError(t('addon.errors.layout.no_direct_control'))
|
||||
item['options'] = dict(options)
|
||||
clean.append(item)
|
||||
# Positions: every tile or none (an older editor sends none and keeps its order).
|
||||
@@ -924,15 +944,15 @@ def validate_layout(data, stored=False):
|
||||
occupied = set()
|
||||
for item, slot in zip(clean, given):
|
||||
if type(slot) is not int or not 0 <= slot < MAX_SLOTS:
|
||||
raise ValueError('Invalid tile position; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.layout.position'))
|
||||
size = tile_size(item)
|
||||
if size == 'full' and slot % SLOTS_PER_PAGE:
|
||||
raise ValueError('A full-page tile starts at the top of its page.')
|
||||
raise ValueError(t('addon.errors.layout.full_page_top'))
|
||||
if size == 'wide' and slot % 2:
|
||||
raise ValueError('A double-width tile starts in the left column.')
|
||||
raise ValueError(t('addon.errors.layout.wide_left'))
|
||||
for cell in footprint(slot, size):
|
||||
if cell in occupied:
|
||||
raise ValueError('Two tiles are in the same spot.')
|
||||
raise ValueError(t('addon.errors.layout.same_spot'))
|
||||
occupied.add(cell)
|
||||
item['slot'] = slot
|
||||
clean.sort(key=lambda item: item['slot'])
|
||||
@@ -943,7 +963,7 @@ def validate_layout(data, stored=False):
|
||||
# Pages kept on purpose, empty ones included; the screen shows at least what the tiles need.
|
||||
if 'pages' in data:
|
||||
if type(data['pages']) is not int or not 1 <= data['pages'] <= MAX_PAGES:
|
||||
raise ValueError(f'A screen has 1 to {MAX_PAGES} pages.')
|
||||
raise ValueError(t('addon.errors.layout.pages', n=MAX_PAGES))
|
||||
result['pages'] = data['pages']
|
||||
if 'settings' in data:
|
||||
result['settings'] = validate_settings(data['settings'])
|
||||
@@ -997,15 +1017,21 @@ def epoch(value):
|
||||
# battery comes from the device's battery sensor.
|
||||
VACUUM_MODE_KEYS = ('cleaning_mode', 'work_mode', 'clean_mode')
|
||||
VACUUM_WATER_KEYS = ('mop_intensity', 'water_flow', 'water_amount', 'water_flow_level', 'water_volume', 'mop_water_level')
|
||||
# Short chip labels; anything else shows its own words.
|
||||
# Short chip labels, by the key of their words in the screens' language (app 0.2.90); anything else shows its own words.
|
||||
# The suction speeds the screen names itself, and Off, are the screen's own words.
|
||||
VACUUM_CHIP = 'addon.screen.vacuum.'
|
||||
VACUUM_LABELS = {
|
||||
'vacuum': 'Vacuum', 'sweeping': 'Vacuum', 'vac_and_mop': 'Vac & mop', 'vacuum_and_mop': 'Vac & mop',
|
||||
'sweeping_and_mopping': 'Vac & mop', 'mop_after_vacuum': 'Vac, then mop', 'mopping_after_sweeping': 'Vac, then mop',
|
||||
'mop': 'Mop', 'mopping': 'Mop', 'custom': 'Custom', 'smart_mode': 'Smart',
|
||||
'off': 'Off', 'min': 'Min', 'slight': 'Slight', 'low': 'Low', 'mild': 'Mild', 'medium': 'Medium', 'moderate': 'Moderate',
|
||||
'standard': 'Standard', 'high': 'High', 'intense': 'Intense', 'extreme': 'Extreme', 'ultrahigh': 'Ultra high',
|
||||
'quiet': 'Quiet', 'silent': 'Silent', 'gentle': 'Gentle', 'balanced': 'Normal', 'turbo': 'Turbo', 'strong': 'Strong',
|
||||
'max': 'Max', 'max_plus': 'Max+', 'auto': 'Auto',
|
||||
'vacuum': VACUUM_CHIP + 'vacuum', 'sweeping': VACUUM_CHIP + 'vacuum', 'vac_and_mop': VACUUM_CHIP + 'vac_and_mop',
|
||||
'vacuum_and_mop': VACUUM_CHIP + 'vac_and_mop', 'sweeping_and_mopping': VACUUM_CHIP + 'vac_and_mop',
|
||||
'mop_after_vacuum': VACUUM_CHIP + 'vac_then_mop', 'mopping_after_sweeping': VACUUM_CHIP + 'vac_then_mop',
|
||||
'mop': VACUUM_CHIP + 'mop', 'mopping': VACUUM_CHIP + 'mop', 'custom': VACUUM_CHIP + 'custom', 'smart_mode': VACUUM_CHIP + 'smart',
|
||||
'off': 'screen.ha.off', 'min': VACUUM_CHIP + 'min', 'slight': VACUUM_CHIP + 'slight', 'low': VACUUM_CHIP + 'low',
|
||||
'mild': VACUUM_CHIP + 'mild', 'medium': VACUUM_CHIP + 'medium', 'moderate': VACUUM_CHIP + 'moderate',
|
||||
'standard': VACUUM_CHIP + 'standard', 'high': VACUUM_CHIP + 'high', 'intense': VACUUM_CHIP + 'intense',
|
||||
'extreme': VACUUM_CHIP + 'extreme', 'ultrahigh': VACUUM_CHIP + 'ultra_high', 'quiet': 'screen.vacuum.speed_quiet',
|
||||
'silent': VACUUM_CHIP + 'silent', 'gentle': VACUUM_CHIP + 'gentle', 'balanced': 'screen.vacuum.speed_balanced',
|
||||
'turbo': 'screen.vacuum.speed_turbo', 'strong': VACUUM_CHIP + 'strong', 'max': 'screen.vacuum.speed_max',
|
||||
'max_plus': VACUUM_CHIP + 'max_plus', 'auto': VACUUM_CHIP + 'auto',
|
||||
}
|
||||
# What a cleaning mode does, one letter per option for the firmware: v vacuum only, m mop only, b both,
|
||||
# a automatic (the robot or its app picks suction and water). Unknown modes count as both.
|
||||
@@ -1016,7 +1042,8 @@ MODE_COVERED = frozenset(('off', 'off_raise_main_brush', 'custom', 'custom_water
|
||||
VACUUM_CHOICES = 6
|
||||
|
||||
def vacuum_label(value):
|
||||
return short(VACUUM_LABELS.get(value) or str(value).replace('_', ' ').capitalize(), 24)
|
||||
key = VACUUM_LABELS.get(value)
|
||||
return short(screen_t(key) if key else str(value).replace('_', ' ').capitalize(), 24)
|
||||
|
||||
def vacuum_related(entity, device, states):
|
||||
"""Entities on the vacuum's device that its card reads, by role: the 'mode' and 'water' selects, the
|
||||
@@ -1159,7 +1186,8 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
|
||||
day = forecast_time(entry, tz)
|
||||
if day is None:
|
||||
continue
|
||||
item = {'d': WEEKDAYS[day.weekday()], 'c': short(entry.get('condition') or '', 20)}
|
||||
# The day's short name in the screens' language (app 0.2.90); the list starts on Sunday, Python's week on Monday.
|
||||
item = {'d': screen_t(f'screen.date.weekdays_min.{(day.weekday() + 1) % 7}'), 'c': short(entry.get('condition') or '', 20)}
|
||||
# h/l: high and low; p: chance of rain in %; r: rain in the entity's unit (mm).
|
||||
for key, name in (('h', 'temperature'), ('l', 'templow'), ('p', 'precipitation_probability'), ('r', 'precipitation')):
|
||||
value = forecast_number(entry, name)
|
||||
@@ -1284,7 +1312,7 @@ def attribute_word(entity_id, attribute, value, attributes, entry, words):
|
||||
def state_message(index, tile, states, extra=None, precision=None, entry=None):
|
||||
if tile['entity'] in BUILTIN:
|
||||
message = {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||||
'name': short(tile['name'] or BUILTIN[tile['entity']], 80), 'state': 'ok', 'a': {}}
|
||||
'name': short(tile['name'] or builtin_name(tile['entity']), 80), 'state': 'ok', 'a': {}}
|
||||
# The same wire form as any tile, so a chosen icon travels as its codepoint (app 0.2.74+).
|
||||
options = screen_options(tile, {})
|
||||
if options is not None:
|
||||
@@ -1341,7 +1369,8 @@ def message_action(node):
|
||||
return alert_service(node, MESSAGE_ACTION)
|
||||
|
||||
# ----- Alerts (firmware 0.2.31+): the reference the cheatsheet shows. tests/test_alerts_reference.py
|
||||
# keeps every value here equal to what the board profiles compile. -----
|
||||
# keeps every value here equal to what the board profiles compile. The texts in English, as the Claude skill
|
||||
# writes them; the cheatsheet gets them in the editor's language (alert_reference, app 0.2.90). -----
|
||||
ALERT_MIN_FIRMWARE = '0.2.31'
|
||||
ALERT_EVENT = 'esphome.screen_alert'
|
||||
ALERT_ENDINGS = (('ok', 'The button was pressed'), ('timeout', 'The timeout ran out'),
|
||||
@@ -1357,6 +1386,9 @@ ALERT_FIELDS = (
|
||||
('timeout', 'int', 'Timeout', 'Seconds after which the card disappears on its own. 0 waits for the button, however long that takes. The button always closes it immediately, even with a timeout.', 0),
|
||||
('flash', 'bool', 'Blinking', 'On makes the backlight blink four times when the alert arrives; the screen then just stays on.', True),
|
||||
)
|
||||
# Examples that are words for people, which the cheatsheet shows in the editor's language; an icon, a colour or a number
|
||||
# is a value to type as it is.
|
||||
ALERT_TEXT_EXAMPLES = frozenset(('title', 'subtitle', 'button_text'))
|
||||
# Bytes per field the firmware keeps (the profiles' ALERT_*_MAX); an accented letter takes two.
|
||||
ALERT_LIMITS = {'cyd': {'title': 48, 'subtitle': 160, 'button_text': 12}, 'guition': {'title': 64, 'subtitle': 240, 'button_text': 16}}
|
||||
ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alarm-light', 'lock', 'lock-open-variant', 'door-open',
|
||||
@@ -1377,15 +1409,21 @@ def alert_service(node, action='show_alert'):
|
||||
return f"esphome.{node.replace('-', '_')}_{action}" if isinstance(node, str) and node else None
|
||||
|
||||
def alert_reference():
|
||||
"""Everything the Alerts cheatsheet shows besides the screens themselves."""
|
||||
"""Everything the Alerts cheatsheet shows besides the screens themselves, in the editor's language (app 0.2.90)."""
|
||||
def example(name, value):
|
||||
return t(f'addon.alerts.fields.{name}.example') if name in ALERT_TEXT_EXAMPLES else value
|
||||
camera = ALERT_CAMERA_FIELD[0]
|
||||
return {'min_firmware': ALERT_MIN_FIRMWARE, 'event': ALERT_EVENT,
|
||||
'broadcast': {'show': BROADCAST_SHOW, 'dismiss': BROADCAST_DISMISS},
|
||||
'endings': [{'action': action, 'label': label} for action, label in ALERT_ENDINGS],
|
||||
'endings': [{'action': action, 'label': t(f'addon.alerts.endings.{action}')} for action, _ in ALERT_ENDINGS],
|
||||
'fallback_icon': ALERT_FALLBACK_ICON, 'fallback_cp': tile_icons.GLYPHS[ALERT_FALLBACK_ICON],
|
||||
'fields': [{'name': name, 'type': kind, 'label': label, 'help': help_, 'example': example} for name, kind, label, help_, example in ALERT_FIELDS],
|
||||
'camera': dict(zip(('name', 'label', 'help', 'example'), ALERT_CAMERA_FIELD)),
|
||||
'fields': [{'name': name, 'type': kind, 'label': t(f'addon.alerts.fields.{name}.label'),
|
||||
'help': t(f'addon.alerts.fields.{name}.help'), 'example': example(name, value)}
|
||||
for name, kind, _, _, value in ALERT_FIELDS],
|
||||
'camera': {'name': camera, 'label': t(f'addon.alerts.fields.{camera}.label'), 'help': t(f'addon.alerts.fields.{camera}.help'),
|
||||
'example': ALERT_CAMERA_FIELD[3]},
|
||||
'limits': ALERT_LIMITS,
|
||||
'colors': [{'name': name, 'label': item['label'], 'color': item['color']} for name, item in TILE_BACKGROUNDS.items() if item['color']],
|
||||
'colors': [{'name': name, 'label': item['label'], 'color': item['color']} for name, item in backgrounds().items() if item['color']],
|
||||
'suggested_icons': [{'name': name, 'cp': tile_icons.GLYPHS[name]} for name in ALERT_SUGGESTED_ICONS],
|
||||
'extra_icons': [{'name': name, 'cp': cp} for name, cp in tile_icons.FIXED]}
|
||||
|
||||
@@ -1444,7 +1482,8 @@ def alert_camera(data):
|
||||
return (value.strip(), True) if usable else ('', False)
|
||||
|
||||
def alert_targets(screens):
|
||||
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason.
|
||||
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason, in English for the
|
||||
log (the editor shows it in its own language, app 0.2.90).
|
||||
|
||||
One call per device: a screen that shows up twice (an old inbox next to a renamed one) counts once."""
|
||||
minimum = parse_firmware(ALERT_MIN_FIRMWARE)
|
||||
@@ -1455,11 +1494,15 @@ def alert_targets(screens):
|
||||
if node in nodes:
|
||||
continue
|
||||
if not node:
|
||||
skipped.append((screen, 'device name unknown'))
|
||||
skipped.append((screen, english('addon.errors.alerts.no_device_name')))
|
||||
elif not screen.get('online'):
|
||||
skipped.append((screen, 'offline'))
|
||||
skipped.append((screen, english('addon.errors.alerts.offline')))
|
||||
elif version is None or version < minimum:
|
||||
skipped.append((screen, f"firmware {screen.get('firmware') or 'unknown'}"))
|
||||
# The sensor's own "unknown" reads as ours, which translates.
|
||||
firmware = screen.get('firmware')
|
||||
if firmware in (None, '', 'unknown'):
|
||||
firmware = english('addon.errors.alerts.unknown_version')
|
||||
skipped.append((screen, english('addon.errors.alerts.firmware', version=firmware)))
|
||||
else:
|
||||
ready.append(screen)
|
||||
nodes.add(node)
|
||||
@@ -1509,7 +1552,9 @@ def discover_screens(registry, states, devices, areas):
|
||||
'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')})
|
||||
# What the screen reports (a word of the firmware's protocol), or ours in English, which the editor
|
||||
# shows in its own language (app 0.2.90).
|
||||
'status': state.get('state', english('screen.settings.not_connected'))})
|
||||
return screens
|
||||
|
||||
def discover(registry, states, devices, areas):
|
||||
@@ -1545,9 +1590,9 @@ def discover(registry, states, devices, areas):
|
||||
def installation_yaml(data):
|
||||
board, name, friendly = data.get('board'), data.get('name'), data.get('friendly_name')
|
||||
if board not in REFS or not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,29}', name):
|
||||
raise ValueError('Choose a board and a unique name (lowercase letters, digits, dashes; 30 characters max).')
|
||||
raise ValueError(t('addon.errors.firmware.board_and_name'))
|
||||
if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
|
||||
raise ValueError('Give the screen a recognizable name (60 characters max).')
|
||||
raise ValueError(t('addon.errors.firmware.friendly_name'))
|
||||
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'
|
||||
|
||||
@@ -11,6 +11,7 @@ import signal
|
||||
import time
|
||||
import yaml
|
||||
from core import REPO, installation_yaml
|
||||
from i18n import t
|
||||
|
||||
# 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):
|
||||
@@ -101,10 +102,10 @@ class Firmware:
|
||||
|
||||
def profile(self, name):
|
||||
if not isinstance(name,str) or not re.fullmatch(r'[a-zA-Z0-9_-]+\.yaml', name):
|
||||
raise ValueError('Choose an existing YAML profile.')
|
||||
raise ValueError(t('addon.errors.firmware.choose_profile'))
|
||||
p = self.root / name
|
||||
if p.is_symlink() or p.resolve().parent != self.root or not p.is_file():
|
||||
raise ValueError("Profile doesn't exist in the ESPHome folder.")
|
||||
raise ValueError(t('addon.errors.firmware.profile_missing'))
|
||||
return p
|
||||
|
||||
def wifi_status(self):
|
||||
@@ -136,12 +137,12 @@ class Firmware:
|
||||
if wifi['state'] == 'ready':
|
||||
return
|
||||
if wifi['state'] == 'invalid':
|
||||
raise ValueError("secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten.")
|
||||
raise ValueError(t('addon.errors.firmware.secrets_invalid'))
|
||||
values = {}
|
||||
for key in wifi['missing']:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or (key == 'wifi_ssid' and not value.strip()):
|
||||
raise ValueError("Fill in the Wi-Fi name and password; they'll be saved in ESPHome secrets.yaml.")
|
||||
raise ValueError(t('addon.errors.firmware.wifi_needed'))
|
||||
values[key] = value
|
||||
path = self.root / 'secrets.yaml'
|
||||
if wifi['state'] == 'new':
|
||||
@@ -160,7 +161,7 @@ class Firmware:
|
||||
text = text + ('' if not text or text.endswith('\n') else '\n') + line + '\n'
|
||||
check = yaml.safe_load(text)
|
||||
if not isinstance(check, dict) or any(check.get(key) != value for key, value in values.items()):
|
||||
raise ValueError("Wi-Fi couldn't be set in secrets.yaml. Fill in wifi_ssid and wifi_password there yourself.")
|
||||
raise ValueError(t('addon.errors.firmware.wifi_not_set'))
|
||||
path.write_text(text)
|
||||
|
||||
def create(self, data):
|
||||
@@ -169,21 +170,21 @@ class Firmware:
|
||||
self.root.mkdir(parents=True,exist_ok=True)
|
||||
profile = self.root / (data['name']+'.yaml')
|
||||
if profile.exists() or profile.is_symlink():
|
||||
raise ValueError('This name already exists. Use the existing profile for updates.')
|
||||
raise ValueError(t('addon.errors.firmware.name_exists'))
|
||||
override = self.root / (data['name'] + self.OVERRIDE_SUFFIX)
|
||||
if override.exists() or override.is_symlink():
|
||||
raise ValueError('This name already has a local override. Restore or remove it before creating a new screen with this name.')
|
||||
raise ValueError(t('addon.errors.firmware.override_exists'))
|
||||
self.store_wifi(data)
|
||||
try:
|
||||
with profile.open('x') as f:
|
||||
os.chmod(profile,0o600);f.write(content)
|
||||
except FileExistsError:
|
||||
raise ValueError('This name already exists. Use the existing profile for updates.')
|
||||
raise ValueError(t('addon.errors.firmware.name_exists'))
|
||||
try:
|
||||
with override.open('x') as f:
|
||||
os.chmod(override, 0o600); f.write('{}\n')
|
||||
except FileExistsError:
|
||||
raise ValueError('This name already has a local override. Restore or remove it before creating a new screen with this name.')
|
||||
raise ValueError(t('addon.errors.firmware.override_exists'))
|
||||
key = yaml.load(content, Loader=LenientLoader)['api']['encryption']['key']
|
||||
# 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}
|
||||
@@ -218,7 +219,7 @@ class Firmware:
|
||||
profile = self.profile(name)
|
||||
path = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
|
||||
if path.is_symlink():
|
||||
raise ValueError('The local override is a symbolic link; replace it with a regular file first.')
|
||||
raise ValueError(t('addon.errors.firmware.override_symlink'))
|
||||
return profile, path
|
||||
|
||||
def _atomic_write(self, path, text):
|
||||
@@ -237,17 +238,17 @@ class Firmware:
|
||||
if re.search(rf'(?m)^\s*local_overrides:\s*!include\s+{re.escape(filename)}\s*$', text):
|
||||
return False
|
||||
if re.search(r'(?m)^\s*local_overrides\s*:', text):
|
||||
raise ValueError('This profile already has a different local_overrides include.')
|
||||
raise ValueError(t('addon.errors.firmware.other_include'))
|
||||
try:
|
||||
parsed = yaml.load(text, Loader=LenientLoader)
|
||||
except yaml.YAMLError as error:
|
||||
raise ValueError("The screen profile isn't valid YAML; fix it before adding an override.") from error
|
||||
raise ValueError(t('addon.errors.firmware.profile_invalid')) from error
|
||||
packages = parsed.get('packages') if isinstance(parsed, dict) else None
|
||||
if not isinstance(packages, dict):
|
||||
raise ValueError("This profile has no packages section that ESP Screens can extend.")
|
||||
raise ValueError(t('addon.errors.firmware.no_packages'))
|
||||
match = re.search(r'(?m)^packages:\s*\n', text)
|
||||
if not match:
|
||||
raise ValueError("This profile has no packages section that ESP Screens can extend.")
|
||||
raise ValueError(t('addon.errors.firmware.no_packages'))
|
||||
next_top = re.search(r'(?m)^[^\s#][^\n]*\n', text[match.end():])
|
||||
if not next_top and not text.endswith('\n'):
|
||||
text += '\n'
|
||||
@@ -259,28 +260,30 @@ class Firmware:
|
||||
|
||||
def _validate_override(self, content):
|
||||
if not isinstance(content, str):
|
||||
raise ValueError('Enter YAML text first.')
|
||||
raise ValueError(t('addon.errors.firmware.yaml_needed'))
|
||||
if len(content.encode('utf8')) > self.OVERRIDE_LIMIT:
|
||||
raise ValueError('The override is too large. Keep it below 12 KB.')
|
||||
raise ValueError(t('addon.errors.firmware.override_too_large'))
|
||||
if not content.strip():
|
||||
content = '{}\n'
|
||||
try:
|
||||
parsed = yaml.load(content, Loader=LenientLoader)
|
||||
except yaml.YAMLError as error:
|
||||
problem = getattr(error, 'problem', None) or 'invalid YAML'
|
||||
# PyYAML's own description of the problem stays in English.
|
||||
problem = getattr(error, 'problem', None) or t('addon.errors.firmware.invalid_yaml')
|
||||
mark = getattr(error, 'problem_mark', None)
|
||||
where = f' on line {mark.line + 1}' if mark else ''
|
||||
raise ValueError(f'YAML error{where}: {problem}.') from error
|
||||
if mark:
|
||||
raise ValueError(t('addon.errors.firmware.yaml_error_line', line=mark.line + 1, problem=problem)) from error
|
||||
raise ValueError(t('addon.errors.firmware.yaml_error', problem=problem)) from error
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError('The override must contain a YAML object, for example display: or substitutions:.')
|
||||
raise ValueError(t('addon.errors.firmware.override_object'))
|
||||
protected = sorted(set(parsed) & self.PROTECTED_OVERRIDE_KEYS)
|
||||
if protected:
|
||||
raise ValueError('These sections stay managed by ESP Screens: ' + ', '.join(protected) + '.')
|
||||
raise ValueError(t('addon.errors.firmware.sections_managed', names=', '.join(protected)))
|
||||
substitutions = parsed.get('substitutions')
|
||||
if isinstance(substitutions, dict):
|
||||
protected = sorted(set(substitutions) & self.PROTECTED_SUBSTITUTIONS)
|
||||
if protected:
|
||||
raise ValueError('These substitutions stay managed by ESP Screens: ' + ', '.join(protected) + '.')
|
||||
raise ValueError(t('addon.errors.firmware.substitutions_managed', names=', '.join(protected)))
|
||||
return content if content.endswith('\n') else content + '\n'
|
||||
|
||||
def override(self, name):
|
||||
@@ -308,11 +311,11 @@ class Firmware:
|
||||
target = data.get('target') or ''
|
||||
if target:
|
||||
if target != 'download' and (not isinstance(target, str) or target not in self.ports()):
|
||||
raise ValueError('Choose the connected USB port from the list.')
|
||||
raise ValueError(t('addon.errors.firmware.usb_port'))
|
||||
if self.task and not self.task.done():
|
||||
raise ValueError('A build or installation is already running. Wait for it to finish.')
|
||||
raise ValueError(t('addon.errors.firmware.busy_wait'))
|
||||
if not shutil.which('esphome'):
|
||||
raise ValueError('The ESPHome CLI is missing from this installation.')
|
||||
raise ValueError(t('addon.errors.firmware.no_esphome'))
|
||||
result = self.create(data)
|
||||
if target == 'download':
|
||||
result['job'] = self.start({'file': result['file'], 'action': 'download'})
|
||||
@@ -335,10 +338,10 @@ class Firmware:
|
||||
owner never gets firmware from before a change or a failed build."""
|
||||
profile = self.profile(name)
|
||||
if self.job and self.job.get('file') == profile.name and self.job.get('state') == 'running':
|
||||
raise ValueError('The firmware is still being built. Download it when the build has finished.')
|
||||
raise ValueError(t('addon.errors.firmware.still_building'))
|
||||
path = self.images.get(profile.name)
|
||||
if not path or not path.is_file():
|
||||
raise ValueError('Build the firmware first; the download appears when the build has finished.')
|
||||
raise ValueError(t('addon.errors.firmware.build_first'))
|
||||
self.downloaded.add(profile.name)
|
||||
return path, profile.stem + '.factory.bin'
|
||||
|
||||
@@ -349,17 +352,17 @@ class Firmware:
|
||||
return re.sub(r'(?i)((?:password|encryption.key|token|ssid)\s*[:=]\s*).+',r'\1[redacted]',text)[:1500]
|
||||
|
||||
def start(self, data):
|
||||
if self.task and not self.task.done(): raise ValueError('A build or installation is already running.')
|
||||
if self.task and not self.task.done(): raise ValueError(t('addon.errors.firmware.busy'))
|
||||
profile = self.profile(data.get('file'))
|
||||
action = data.get('action')
|
||||
if action not in ('validate','build','install','download'): raise ValueError('Unknown firmware action.')
|
||||
if not shutil.which('esphome'): raise ValueError('The ESPHome CLI is missing from this installation.')
|
||||
if action not in ('validate','build','install','download'): raise ValueError(t('addon.errors.firmware.unknown_action'))
|
||||
if not shutil.which('esphome'): raise ValueError(t('addon.errors.firmware.no_esphome'))
|
||||
target = data.get('target','')
|
||||
if action == 'install':
|
||||
if target.startswith('/dev/'):
|
||||
if target not in self.ports(): raise ValueError('Choose the connected USB port from the list.')
|
||||
if target not in self.ports(): raise ValueError(t('addon.errors.firmware.usb_port'))
|
||||
elif not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}',target):
|
||||
raise ValueError('Enter the IP address or hostname of the intended screen.')
|
||||
raise ValueError(t('addon.errors.firmware.host'))
|
||||
self._secret_values=set()
|
||||
# Collect scalar literals, including !secret values, without executing YAML tags.
|
||||
def collect(node):
|
||||
|
||||
@@ -13,6 +13,7 @@ import core
|
||||
from core import attribute_word, state_word
|
||||
import header_bar
|
||||
import history_card
|
||||
from i18n import t
|
||||
|
||||
# Screen widgets and the Home Assistant action behind them: (action, field or None). A widget fits an entity when Home
|
||||
# Assistant lists one of its actions for it, and, where a field is named, offers that field for the entity.
|
||||
@@ -253,20 +254,20 @@ def action_problem(entity_id, label, action, state, actions, services):
|
||||
if actions is None:
|
||||
return None
|
||||
if name not in actions:
|
||||
return f"Home Assistant doesn't offer {name} for {label}. Choose an action from the list."
|
||||
return t('addon.errors.home_assistant.action_not_offered', action=name, name=label)
|
||||
action_domain, service = name.split('.', 1)
|
||||
description = ((services or {}).get(action_domain) or {}).get(service)
|
||||
if description is None:
|
||||
return None
|
||||
if answers_only(description):
|
||||
return f"{name} only answers with data, which a screen can't show."
|
||||
return t('addon.errors.home_assistant.action_answers_only', action=name)
|
||||
fields = dict(fields_for(description, (state or {}).get('attributes') or {}))
|
||||
for key in data:
|
||||
if key not in fields:
|
||||
return f"Home Assistant has no field {key} in {name} for {label}."
|
||||
return t('addon.errors.home_assistant.no_such_field', field=key, action=name, name=label)
|
||||
for key, field in fields.items():
|
||||
if field.get('required') and key not in data:
|
||||
return f"{name} needs a value for {key}."
|
||||
return t('addon.errors.home_assistant.field_needed', action=name, field=key)
|
||||
return None
|
||||
|
||||
|
||||
@@ -274,14 +275,14 @@ def refusal(entity_id, name, key, value):
|
||||
"""The sentence a save or a tile event gets for a setting Home Assistant doesn't support for this entity."""
|
||||
label = name or entity_id
|
||||
if key == 'tap':
|
||||
return f"Home Assistant can't turn {label} on and off, so On / off would do nothing. Choose another tap action."
|
||||
return t('addon.errors.home_assistant.no_on_off', name=label)
|
||||
if key == 'inline':
|
||||
return f"{label} has nothing a small slider can change in Home Assistant."
|
||||
return t('addon.errors.home_assistant.no_small_slider', name=label)
|
||||
if key == 'display' and value == 'graph':
|
||||
return f"{label} has no numbers to draw a graph of."
|
||||
return t('addon.errors.home_assistant.no_graph', name=label)
|
||||
if key == 'display' and value == 'forecast':
|
||||
return f"{label} has no daily forecast in Home Assistant."
|
||||
return f"Home Assistant doesn't offer that direct control for {label}."
|
||||
return t('addon.errors.home_assistant.no_forecast', name=label)
|
||||
return t('addon.errors.home_assistant.no_control', name=label)
|
||||
|
||||
|
||||
def unsupported(tile, previous, caps):
|
||||
|
||||
@@ -16,6 +16,7 @@ from datetime import datetime, time
|
||||
|
||||
import tile_icons
|
||||
from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short, state_word
|
||||
from i18n import TRANSLATIONS, screen_number, screen_t, t
|
||||
|
||||
# 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.
|
||||
@@ -23,35 +24,52 @@ from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_F
|
||||
# every letter European languages write with since app 0.2.90.
|
||||
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).
|
||||
MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
|
||||
UNAVAILABLE = '—'
|
||||
|
||||
CONTENT_LABELS = {'state': 'Status', 'last_changed': 'Last changed'}
|
||||
SHOW_LABELS = {'always': 'Always', 'active': 'Only when active'}
|
||||
|
||||
# Home Assistant's own words where they fit a small bar; the screen shows the rest as they come.
|
||||
# Home Assistant's own words where they fit a small bar; the screen shows the rest as they come. The keys of the words,
|
||||
# which go in the screens' language (app 0.2.90): Home Assistant's own (screen.ha; a lock's are those of a lock sensor)
|
||||
# where the bar says the same, else the bar's ("Up" for above the horizon).
|
||||
STATES = {
|
||||
'on': 'On', 'off': 'Off', 'home': 'Home', 'not_home': 'Away', 'open': 'Open', 'closed': 'Closed',
|
||||
'opening': 'Opening', 'closing': 'Closing', 'locked': 'Locked', 'unlocked': 'Unlocked', 'locking': 'Locking',
|
||||
'unlocking': 'Unlocking', 'jammed': 'Jammed', 'playing': 'Playing', 'paused': 'Paused', 'idle': 'Idle',
|
||||
'standby': 'Standby', 'buffering': 'Buffering', 'cleaning': 'Cleaning', 'docked': 'Docked', 'returning': 'Returning',
|
||||
'error': 'Error', 'heat': 'Heat', 'cool': 'Cool', 'heat_cool': 'Auto', 'auto': 'Auto', 'dry': 'Dry',
|
||||
'fan_only': 'Fan only', 'disarmed': 'Disarmed', 'armed_home': 'Armed home', 'armed_away': 'Armed away', 'armed_night': 'Armed night',
|
||||
'armed_vacation': 'Armed vacation', 'armed_custom_bypass': 'Armed', 'arming': 'Arming', 'pending': 'Pending',
|
||||
'triggered': 'Triggered', 'active': 'Active', 'above_horizon': 'Up', 'below_horizon': 'Down',
|
||||
}
|
||||
# device_class: (on, off). Tiles and cards show the same words from the screen's own copy, BINARY_WORDS in
|
||||
# components/smart_display/tile_controls.h (firmware 0.2.53+); tests/test_binary_words.py keeps the two equal.
|
||||
BINARY_STATES = {
|
||||
'battery': ('Low', 'Normal'), 'battery_charging': ('Charging', 'Not charging'), 'carbon_monoxide': ('Danger', 'Safe'),
|
||||
'cold': ('Cold', 'Normal'), 'connectivity': ('Connected', 'Disconnected'), 'door': ('Open', 'Closed'),
|
||||
'garage_door': ('Open', 'Closed'), 'gas': ('Danger', 'Safe'), 'heat': ('Hot', 'Normal'), 'light': ('Light', 'Dark'),
|
||||
'lock': ('Open', 'Locked'), 'moisture': ('Wet', 'Dry'), 'motion': ('Motion', 'No motion'),
|
||||
'moving': ('Moving', 'Still'), 'occupancy': ('Occupied', 'Clear'), 'opening': ('Open', 'Closed'), 'plug': ('Plugged in', 'Unplugged'),
|
||||
'power': ('On', 'Off'), 'presence': ('Home', 'Away'), 'problem': ('Problem', 'OK'), 'running': ('Active', 'Inactive'),
|
||||
'safety': ('Unsafe', 'Safe'), 'smoke': ('Smoke', 'No smoke'), 'sound': ('Sound', 'Silent'), 'tamper': ('Tampering', 'OK'),
|
||||
'update': ('Update', 'Up to date'), 'vibration': ('Vibration', 'Still'), 'window': ('Open', 'Closed'),
|
||||
'on': 'screen.ha.on', 'off': 'screen.ha.off', 'home': 'screen.ha.person.home', 'not_home': 'screen.ha.person.not_home',
|
||||
'open': 'screen.ha.cover.open', 'closed': 'screen.ha.cover.closed', 'opening': 'screen.ha.cover.opening',
|
||||
'closing': 'screen.ha.cover.closing', 'locked': 'screen.ha.binary.lock_off', 'unlocked': 'screen.ha.binary.lock_on',
|
||||
'locking': 'addon.screen.states.locking', 'unlocking': 'addon.screen.states.unlocking', 'jammed': 'addon.screen.states.jammed',
|
||||
'playing': 'screen.ha.media.playing', 'paused': 'screen.ha.media.paused', 'idle': 'screen.ha.media.idle',
|
||||
'standby': 'screen.ha.media.standby', 'buffering': 'addon.screen.states.buffering', 'cleaning': 'screen.ha.vacuum.cleaning',
|
||||
'docked': 'screen.ha.vacuum.docked', 'returning': 'addon.screen.states.returning', 'error': 'addon.screen.states.error',
|
||||
'heat': 'screen.ha.climate.heat', 'cool': 'screen.ha.climate.cool', 'heat_cool': 'screen.ha.climate.auto',
|
||||
'auto': 'screen.ha.climate.auto', 'dry': 'screen.ha.climate.dry', 'fan_only': 'screen.ha.climate.fan_only',
|
||||
'disarmed': 'addon.screen.states.disarmed', 'armed_home': 'addon.screen.states.armed_home',
|
||||
'armed_away': 'addon.screen.states.armed_away', 'armed_night': 'addon.screen.states.armed_night',
|
||||
'armed_vacation': 'addon.screen.states.armed_vacation', 'armed_custom_bypass': 'addon.screen.states.armed',
|
||||
'arming': 'addon.screen.states.arming', 'pending': 'addon.screen.states.pending', 'triggered': 'addon.screen.states.triggered',
|
||||
'active': 'addon.screen.states.active', 'above_horizon': 'addon.screen.states.up', 'below_horizon': 'addon.screen.states.down',
|
||||
}
|
||||
|
||||
def state_text(raw):
|
||||
"""The bar's word for a state, in the screens' language; None for a state it has no word of its own for."""
|
||||
return screen_t(STATES[raw]) if raw in STATES else None
|
||||
|
||||
# The device classes with words of their own. Tiles and cards show the same words from the screen's own table, BINARY_WORDS
|
||||
# in components/smart_display/tile_controls.h (firmware 0.2.53+); tests/test_binary_words.py keeps the two equal. The words
|
||||
# are Home Assistant's, from the translations (screen.ha.binary, app 0.2.90): BINARY_STATES holds them in English, the
|
||||
# language of reference, as device_class: (on, off); binary_words gives them in the screens' language.
|
||||
BINARY_CLASSES = ('battery', 'battery_charging', 'carbon_monoxide', 'cold', 'connectivity', 'door', 'garage_door', 'gas',
|
||||
'heat', 'light', 'lock', 'moisture', 'motion', 'moving', 'occupancy', 'opening', 'plug', 'power',
|
||||
'presence', 'problem', 'running', 'safety', 'smoke', 'sound', 'tamper', 'update', 'vibration', 'window')
|
||||
BINARY_STATES = {name: (TRANSLATIONS.text(f'screen.ha.binary.{name}_on'), TRANSLATIONS.text(f'screen.ha.binary.{name}_off'))
|
||||
for name in BINARY_CLASSES}
|
||||
|
||||
def binary_words(device_class):
|
||||
"""(on, off) for a binary sensor of this class in the screens' language; On and Off for a class without words of its
|
||||
own, as the screen does."""
|
||||
if device_class in BINARY_STATES:
|
||||
return screen_t(f'screen.ha.binary.{device_class}_on'), screen_t(f'screen.ha.binary.{device_class}_off')
|
||||
return screen_t('screen.ha.on'), screen_t('screen.ha.off')
|
||||
|
||||
# Icon names from tile_icons; the fonts of both boards carry every one of them.
|
||||
BINARY_ICONS = { # device_class: (on, off)
|
||||
'motion': ('motion-sensor', 'motion-sensor'), 'occupancy': ('home-account', 'home-account'),
|
||||
@@ -107,7 +125,8 @@ def clean_text(text):
|
||||
return short(''.join(out).strip(), TEXT_BYTES)
|
||||
|
||||
def number_text(value, precision=None):
|
||||
"""A number as Home Assistant writes it: decimal point, commas between thousands."""
|
||||
"""A number as Home Assistant writes it, in the screens' number format (app 0.2.90): "1,234.5", "1.234,5" or
|
||||
"1 234,5"."""
|
||||
if precision is None:
|
||||
# Without a display precision HA keeps integers whole and shows at most three decimals.
|
||||
digits = 0 if float(value).is_integer() else 3
|
||||
@@ -116,12 +135,10 @@ def number_text(value, precision=None):
|
||||
text = text.rstrip('0').rstrip('.')
|
||||
else:
|
||||
text = f'{value:.{precision}f}'
|
||||
negative = text.startswith('-')
|
||||
whole, _, fraction = text.lstrip('-').partition('.')
|
||||
grouped = f'{int(whole):,}'
|
||||
result = grouped + ('.' + fraction if fraction else '')
|
||||
# "-0" and "-0.0" are just zero.
|
||||
return '-' + result if negative and any(c not in '0.,' for c in result) else result
|
||||
if text.startswith('-') and not any(c not in '0.' for c in text[1:]):
|
||||
text = text[1:]
|
||||
return screen_number(text)
|
||||
|
||||
def with_unit(text, unit):
|
||||
"""Home Assistant's spacing: "21.3 °C", "65%", "18°"."""
|
||||
@@ -145,7 +162,8 @@ def numeric(value):
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
def short_date(moment):
|
||||
return f'{moment.day} {MONTHS[moment.month - 1]}'
|
||||
""""15 Sep", in the screens' language and in its order of day and month (app 0.2.90)."""
|
||||
return screen_t('screen.date.day_month', day=moment.day, month=screen_t(f'screen.date.months_short.{moment.month - 1}'))
|
||||
|
||||
def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
"""(text, unix time) an entity shows as its state; exactly one of them is set. `words` are Home Assistant's
|
||||
@@ -159,11 +177,13 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
return (None, epoch(raw)) if epoch(raw) else (UNAVAILABLE, None)
|
||||
if domain == 'script':
|
||||
moment = epoch(attrs.get('last_triggered'))
|
||||
return ('Running', None) if raw == 'on' else (None, moment) if moment else ('Never run', None)
|
||||
if raw == 'on':
|
||||
return screen_t('addon.screen.script_running'), None
|
||||
return (None, moment) if moment else (screen_t('screen.script.never_run'), None)
|
||||
if domain == 'sun':
|
||||
# The next event: sunset while the sun is up, sunrise while it is down.
|
||||
clock = local_clock(attrs.get('next_setting' if raw == 'above_horizon' else 'next_rising'), tz)
|
||||
return (clock or STATES.get(raw, raw)), None
|
||||
return (clock or state_text(raw) or raw), None
|
||||
if domain == 'weather':
|
||||
temperature = numeric(attrs.get('temperature'))
|
||||
if temperature is None:
|
||||
@@ -174,9 +194,9 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
if current is not None:
|
||||
return with_unit(number_text(current, 1), (units or {}).get('temperature', '°C')), None
|
||||
if domain in ('person', 'device_tracker'):
|
||||
return STATES.get(raw, raw), None
|
||||
return state_text(raw) or raw, None
|
||||
if domain == 'binary_sensor':
|
||||
pair = BINARY_STATES.get(attrs.get('device_class'), ('On', 'Off'))
|
||||
pair = binary_words(attrs.get('device_class'))
|
||||
return (pair[0] if raw == 'on' else pair[1] if raw == 'off' else raw), None
|
||||
if domain == 'input_datetime':
|
||||
# "2026-09-15 07:30:00", "2026-09-15" or just "07:30:00".
|
||||
@@ -200,7 +220,7 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
if domain in ('input_text', 'input_select', 'select'):
|
||||
return state_word(entity, raw, attrs, entry, words) or raw, None
|
||||
if raw in STATES:
|
||||
return STATES[raw], None
|
||||
return state_text(raw), None
|
||||
return state_word(entity, raw, attrs, entry, words) or raw[:1].upper() + raw[1:], None
|
||||
|
||||
def active(entity, state):
|
||||
@@ -306,7 +326,7 @@ def preview(header, states, registry=None, units=None, tz=None, words=None):
|
||||
result = []
|
||||
for item in header['items']:
|
||||
if item['type'] in HEADER_BUILTIN:
|
||||
result.append({'k': item['type'], 'name': HEADER_BUILTIN[item['type']], 'shown': True})
|
||||
result.append({'k': item['type'], 'name': t(f"addon.labels.top_bar.builtin.{item['type']}"), 'shown': True})
|
||||
continue
|
||||
wire, shown = entity_item(item, states, registry, units, tz, words)
|
||||
attrs = (states.get(item['entity']) or {}).get('attributes') or {}
|
||||
@@ -334,19 +354,21 @@ def suggestions(screen, entities, states, registry=None, limit=8):
|
||||
def sensor(device_class):
|
||||
return lambda e: e['id'].startswith('sensor.') and attrs(e).get('device_class') == device_class and numeric(states[e['id']]['state']) is not None
|
||||
picks = []
|
||||
pick('Temperature', sensor('temperature'))
|
||||
pick('Humidity', sensor('humidity'))
|
||||
pick('Power usage', sensor('power'))
|
||||
pick('Last motion', lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed')
|
||||
pick('Weather', lambda e: e['id'].startswith('weather.'))
|
||||
pick('People home', lambda e: e['id'] == 'zone.home')
|
||||
pick('Sunrise and sunset', lambda e: e['id'] == 'sun.sun')
|
||||
pick('CO₂', sensor('carbon_dioxide'))
|
||||
# The labels in the editor's language (app 0.2.90).
|
||||
label = lambda key: t(f'addon.labels.top_bar.suggestions.{key}')
|
||||
pick(label('temperature'), sensor('temperature'))
|
||||
pick(label('humidity'), sensor('humidity'))
|
||||
pick(label('power'), sensor('power'))
|
||||
pick(label('motion'), lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed')
|
||||
pick(label('weather'), lambda e: e['id'].startswith('weather.'))
|
||||
pick(label('people_home'), lambda e: e['id'] == 'zone.home')
|
||||
pick(label('sun'), lambda e: e['id'] == 'sun.sun')
|
||||
pick(label('co2'), sensor('carbon_dioxide'))
|
||||
return picks[:limit]
|
||||
|
||||
def catalogue():
|
||||
"""Choices the editor offers; labels in the order the sheet shows them."""
|
||||
return {'builtin': [{'type': key, 'label': label} for key, label in HEADER_BUILTIN.items()],
|
||||
'contents': [{'key': key, 'label': CONTENT_LABELS[key]} for key in HEADER_CONTENTS],
|
||||
'shows': [{'key': key, 'label': SHOW_LABELS[key]} for key in HEADER_SHOWS],
|
||||
"""Choices the editor offers; labels in the order the sheet shows them, in the editor's language (app 0.2.90)."""
|
||||
return {'builtin': [{'type': key, 'label': t(f'addon.labels.top_bar.builtin.{key}')} for key in HEADER_BUILTIN],
|
||||
'contents': [{'key': key, 'label': t(f'addon.labels.top_bar.contents.{key}')} for key in HEADER_CONTENTS],
|
||||
'shows': [{'key': key, 'label': t(f'addon.labels.top_bar.shows.{key}')} for key in HEADER_SHOWS],
|
||||
'max_items': HEADER_MAX_ITEMS, 'min_firmware': '.'.join(map(str, HEADER_MIN_FIRMWARE))}
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
import header_bar
|
||||
from core import state_word
|
||||
from i18n import screen_t
|
||||
|
||||
RANGES = (1, 24, 168)
|
||||
POINTS = 24
|
||||
@@ -213,18 +214,18 @@ def line(entity, hours, changes, start, end, tz, entry=None, unit='', extreme_ch
|
||||
|
||||
|
||||
def state_label(domain, state, attrs, word=None):
|
||||
"""Home Assistant's words for a state, as the screen shows them elsewhere; `word` is Home Assistant's own word, for a
|
||||
state the screen has no word of its own for (app 0.2.67)."""
|
||||
"""Home Assistant's words for a state, as the screen shows them elsewhere, in the screens' language (app 0.2.90); `word`
|
||||
is Home Assistant's own word, for a state the screen has no word of its own for (app 0.2.67)."""
|
||||
if state in (None, 'unavailable'):
|
||||
return 'Unavailable'
|
||||
return screen_t('screen.ha.unavailable')
|
||||
if state == 'unknown':
|
||||
return 'Unknown'
|
||||
return screen_t('addon.screen.states.unknown')
|
||||
if domain == 'binary_sensor':
|
||||
pair = header_bar.BINARY_STATES.get((attrs or {}).get('device_class'), ('On', 'Off'))
|
||||
pair = header_bar.binary_words((attrs or {}).get('device_class'))
|
||||
return pair[0] if state == 'on' else pair[1] if state == 'off' else state
|
||||
if domain == 'person' and state not in header_bar.STATES:
|
||||
return state.replace('_', ' ')[:1].upper() + state.replace('_', ' ')[1:]
|
||||
return header_bar.STATES.get(state) or word or (state[:1].upper() + state[1:]).replace('_', ' ')
|
||||
return header_bar.state_text(state) or word or (state[:1].upper() + state[1:]).replace('_', ' ')
|
||||
|
||||
|
||||
def state_color(domain, state, others):
|
||||
@@ -280,7 +281,8 @@ def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS, en
|
||||
rest = [state for state in ranked if state not in index_of]
|
||||
if rest:
|
||||
index_of.update({state: len(shown) for state in rest})
|
||||
legend.append(['Other', '9E9E9E', int(sum(totals[state] for state in rest))])
|
||||
other = header_bar.clean_text(screen_t('addon.screen.history.other'))[:24]
|
||||
legend.append([other, '9E9E9E', int(sum(totals[state] for state in rest))])
|
||||
# Each slot shows the state that covers most of it, except that the active state always shows: a door that
|
||||
# stood open for five minutes stays visible on a day's timeline, as it does in Home Assistant.
|
||||
span = (end - start) / slots
|
||||
|
||||
@@ -131,9 +131,42 @@ class Translations:
|
||||
TRANSLATIONS = Translations()
|
||||
|
||||
|
||||
class Text(str):
|
||||
"""A text that keeps its key and params, so it can be written again in another language: a message made in one place
|
||||
and shown in another, such as a delivery status the sync loop sets or an update result kept in updates.json, shows in
|
||||
the language of whoever looks at it (app 0.2.90). Everywhere else it is the str it reads as."""
|
||||
|
||||
def __new__(cls, text, key=None, params=None):
|
||||
made = super().__new__(cls, text)
|
||||
made.key, made.params = key, dict(params or {})
|
||||
return made
|
||||
|
||||
def into(self, language):
|
||||
"""This text in `language`; one without a key stays as it is."""
|
||||
return _render(self.key, language, self.params) if self.key else str(self)
|
||||
|
||||
|
||||
def _render(key, language, params):
|
||||
"""`key` in `language` with its params filled in; a param that is a Text itself goes into that language too."""
|
||||
return TRANSLATIONS.text(key, language, **{name: value.into(language) if isinstance(value, Text) else value
|
||||
for name, value in params.items()})
|
||||
|
||||
|
||||
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)
|
||||
"""An `addon` text in the language of the editor that asked (REQUEST_LANGUAGE). It keeps its key, so the error it is
|
||||
raised with can still be written in another language (Updater.record)."""
|
||||
return Text(_render(key, REQUEST_LANGUAGE.get(), params), key, params)
|
||||
|
||||
|
||||
def english(key, **params):
|
||||
"""A text in English that keeps its key, for a message made now and read later: a delivery status, an update result,
|
||||
why a screen can't show an alert. Logs read the English; `shown` writes it in the editor's language."""
|
||||
return Text(_render(key, 'en', params), key, params)
|
||||
|
||||
|
||||
def shown(value):
|
||||
"""A text made earlier in the language of the editor that asks now; anything but a Text as it is."""
|
||||
return value.into(REQUEST_LANGUAGE.get()) if isinstance(value, Text) else value
|
||||
|
||||
|
||||
# The screens' language and number format (Settings -> Language & region), for the words the app sends to the screens
|
||||
@@ -146,8 +179,9 @@ def set_screens(language, numbers):
|
||||
|
||||
|
||||
def screen_t(key, **params):
|
||||
"""A text in the screens' language, for what the app sends to them."""
|
||||
return TRANSLATIONS.text(key, SCREENS['language'], **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)
|
||||
|
||||
|
||||
def screen_number(text):
|
||||
|
||||
@@ -19,13 +19,13 @@ import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, backgrounds, builtin_name, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state, state_word
|
||||
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 i18n import REQUEST_LANGUAGE, TRANSLATIONS, Region, english, screen_t, shown, t
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
@@ -556,8 +556,9 @@ class HomeAssistant:
|
||||
class Manager:
|
||||
def __init__(self, ha, path):
|
||||
self.ha, self.path = ha, Path(path)
|
||||
# sent: per inbox what the screen holds ({'layout', 'header', 'states', 'rev'}); last: the
|
||||
# last full send; pinged: the last keepalive ping.
|
||||
# sent: per inbox what the screen holds ({'layout', 'header', 'states', 'rev'}); status: how the
|
||||
# delivery went, in English with its key (the editor shows it in its own language, app 0.2.90);
|
||||
# last: the last full send; pinged: the last keepalive ping.
|
||||
self.layouts, self.sent, self.status, self.last, self.pinged = {}, {}, {}, {}, {}
|
||||
# (entity, hours) -> (monotonic, 24 samples); filled by history_loop, read by sync_one.
|
||||
self.histories = {}
|
||||
@@ -785,7 +786,7 @@ class Manager:
|
||||
`on_screen`: the screen reported them itself. The layout message it holds is then brought in step
|
||||
without sending it back, which could undo a change it made after reporting this one; its revision
|
||||
stays, so the next ping still matches."""
|
||||
base = self.layouts.get(inbox) or {'title': (screen or {}).get('name') or 'Home', 'tiles': []}
|
||||
base = self.layouts.get(inbox) or {'title': (screen or {}).get('name') or screen_t('screen.status.home'), 'tiles': []}
|
||||
layout = validate_layout({**base, 'settings': settings})
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
self.write_layouts(updated)
|
||||
@@ -825,15 +826,16 @@ class Manager:
|
||||
inbox = self.aliases.get(inbox, inbox)
|
||||
screen = self.screen(inbox)
|
||||
if screen is None:
|
||||
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
|
||||
raise ValueError(t('addon.errors.not_paired'))
|
||||
view = self.settings_view(screen)
|
||||
if not isinstance(changes, dict) or not changes or set(changes) - set(view['keys']):
|
||||
raise ValueError('Unknown screen settings; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.settings.unknown'))
|
||||
if view['owner'] == 'screen' and not screen.get('online'):
|
||||
raise ValueError('This screen is offline. You can change its settings once it is back.')
|
||||
raise ValueError(t('addon.errors.settings.offline'))
|
||||
# The entities' names, as Home Assistant shows them (the firmware names them in English).
|
||||
missing = [SETTING_ENTITIES[key][1] for key in changes if key in view['unavailable']]
|
||||
if missing:
|
||||
raise ValueError(f"{', '.join(missing)} can't be changed now: the entity is off in Home Assistant, or the screen is restarting.")
|
||||
raise ValueError(t('addon.errors.settings.unavailable', names=', '.join(missing)))
|
||||
# A setting Home Assistant cannot read (its entity is off) is checked at its default.
|
||||
wanted = {**{key: value for key, value in view['values'].items() if value is not None}, **changes}
|
||||
if 'brightness' in changes and type(changes['brightness']) is int:
|
||||
@@ -843,7 +845,7 @@ class Manager:
|
||||
wanted[dim] = min(wanted.get(dim, SETTING_RULES[dim][0]), changes['brightness'])
|
||||
merged = validate_settings(wanted)
|
||||
if merged['rotation'] and screen.get('board') != 'guition':
|
||||
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
|
||||
raise ValueError(t('addon.errors.settings.rotation'))
|
||||
if view['owner'] == 'layout':
|
||||
self.store_settings(inbox, merged, screen)
|
||||
self.ha.changed.set()
|
||||
@@ -1043,14 +1045,14 @@ class Manager:
|
||||
inbox = self.aliases.get(inbox, inbox)
|
||||
screen=next((s for s in screens if s['id']==inbox), None)
|
||||
if screen is None:
|
||||
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
|
||||
raise ValueError(t('addon.errors.not_paired'))
|
||||
layout = validate_layout(data)
|
||||
# A CYD has no memory for camera images, whatever its firmware; say so before asking for an update.
|
||||
if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']) and screen.get('board') not in camera_feed.BOXES:
|
||||
raise ValueError('Camera images need a Guition screen.')
|
||||
raise ValueError(t('addon.errors.layout.camera_guition'))
|
||||
needed = self.needs_firmware(inbox, layout, screen)
|
||||
if needed:
|
||||
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
|
||||
raise ValueError(t('addon.errors.layout.firmware_first', version=needed))
|
||||
# Settings change through their own call (change_settings). A page from before app 0.2.57 still sends them
|
||||
# with the tiles: a screen that owns its settings ignores them, so a stale form never undoes a change
|
||||
# made on the screen; other screens keep taking them, as before.
|
||||
@@ -1069,7 +1071,7 @@ class Manager:
|
||||
if 'settings' in layout and 'rotation' not in data.get('settings',{}):
|
||||
layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0)
|
||||
if layout.get('settings',{}).get('rotation',0) and screen.get('board')!='guition':
|
||||
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
|
||||
raise ValueError(t('addon.errors.settings.rotation'))
|
||||
# A page from before an option existed sends its tiles without it. It never sends a navigation tile twice
|
||||
# (firmware 0.2.65+), so a copy keeps exactly what it was sent with.
|
||||
old_list = self.layouts.get(inbox,{}).get('tiles',[])
|
||||
@@ -1090,21 +1092,20 @@ class Manager:
|
||||
layout = validate_layout(layout)
|
||||
known = {e['id'] for e in entities} | set(BUILTIN)
|
||||
if any(t['entity'] not in known for t in layout['tiles']):
|
||||
raise ValueError('A chosen entity no longer exists. Look up the new entity.')
|
||||
raise ValueError(t('addon.errors.layout.entity_gone'))
|
||||
if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)):
|
||||
raise ValueError('An entity in the top bar no longer exists. Choose a different one.')
|
||||
raise ValueError(t('addon.errors.top_bar.entity_gone'))
|
||||
# The screen takes the layout in one message of at most 4096 bytes, which lists every tile's entity id: 48 tiles
|
||||
# with long ids can pass every rule above and still never reach the screen (app 0.2.78).
|
||||
try:
|
||||
encode(self.layout_message(inbox, layout, screen))
|
||||
except ValueError:
|
||||
raise ValueError("These tiles' entity IDs are too long together to fit in one message to the screen; "
|
||||
"remove a few tiles.") from None
|
||||
raise ValueError(t('addon.errors.layout.ids_too_long')) from None
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
self.write_layouts(updated)
|
||||
self.layouts = updated
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Saved; waiting for sync'
|
||||
self.status[inbox] = english('addon.status.saved')
|
||||
self.history_wake.set()
|
||||
self.ha.changed.set()
|
||||
self.notify()
|
||||
@@ -1289,7 +1290,7 @@ class Manager:
|
||||
screen = self.screen(inbox) or {}
|
||||
needed = self.needs_firmware(inbox, layout, screen)
|
||||
if needed:
|
||||
self.status[inbox]=f"Layout saved; firmware {needed}+ needed for these tiles"
|
||||
self.status[inbox]=english('addon.status.firmware_needed', version=needed)
|
||||
return False
|
||||
tiles = layout['tiles']
|
||||
layout_msg = self.layout_message(inbox, layout, screen)
|
||||
@@ -1297,7 +1298,7 @@ class Manager:
|
||||
encode(layout_msg)
|
||||
except ValueError:
|
||||
# Saved by an app before 0.2.78, which didn't check this: nothing to retry until the layout changes.
|
||||
self.status[inbox] = 'Layout saved; too large for one message to the screen, remove a few tiles'
|
||||
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
|
||||
@@ -1342,7 +1343,7 @@ class Manager:
|
||||
self.last[inbox] = time.monotonic()
|
||||
if outgoing:
|
||||
self.pinged[inbox] = time.monotonic()
|
||||
self.status[inbox] = 'Sent to Home Assistant'
|
||||
self.status[inbox] = english('addon.status.sent')
|
||||
# A screen that answers confirms a new layout at once: a ping after the batch says whether it holds
|
||||
# the layout and every tile, or which part is missing (firmware 0.2.49+).
|
||||
if sent_layout and self.answers(inbox, screen):
|
||||
@@ -1426,7 +1427,7 @@ class Manager:
|
||||
before = self.status.get(inbox)
|
||||
if not screen['online']:
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Screen offline; changes saved'
|
||||
self.status[inbox] = english('addon.status.offline')
|
||||
changed = changed or self.status[inbox] != before
|
||||
continue
|
||||
if inbox not in self.layouts:
|
||||
@@ -1448,7 +1449,7 @@ class Manager:
|
||||
await self.ping(inbox, screen)
|
||||
except Exception as error:
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Sending failed; retrying automatically'
|
||||
self.status[inbox] = english('addon.status.retrying')
|
||||
LOG.warning('Retrying screen sync (%s)', type(error).__name__)
|
||||
await asyncio.sleep(1)
|
||||
changed = changed or self.status.get(inbox) != before
|
||||
@@ -1693,13 +1694,14 @@ 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'))
|
||||
# The editor's language for the messages this request answers with (app 0.2.90). An EventSource sends no headers of
|
||||
# its own, so the live updates (/api/events) carry it in the address.
|
||||
REQUEST_LANGUAGE.set(TRANSLATIONS.resolve(request.headers.get('X-ESP-Screens-Language') or request.query.get('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.')
|
||||
raise web.HTTPForbidden(text=t('addon.errors.open_through_ha'))
|
||||
if request.method not in {'GET', 'HEAD'} and request.headers.get('X-Screen-CSRF') != csrf:
|
||||
raise web.HTTPForbidden(text='Refresh this page and try again.')
|
||||
raise web.HTTPForbidden(text=t('addon.errors.refresh_page'))
|
||||
try:
|
||||
response = await handler(request)
|
||||
except ValueError as error:
|
||||
@@ -1707,13 +1709,13 @@ def create_app(manager, development=False):
|
||||
# Home Assistant said no, or can't be reached (Identify, Try it, a setting): a sentence the page shows, never a
|
||||
# bare 500 with a traceback in the log (app 0.2.78). Refused is a ConnectionError, so it comes first.
|
||||
except Refused as error:
|
||||
return web.json_response({'error': f"Home Assistant didn't take it: {error.detail or 'no reason given'}."}, status=400)
|
||||
return web.json_response({'error': t('addon.errors.ha_refused', reason=error.detail or t('addon.errors.no_reason'))}, status=400)
|
||||
except (ConnectionError, TimeoutError):
|
||||
return web.json_response({'error': "Home Assistant isn't reachable right now. Try again in a moment."}, status=503)
|
||||
return web.json_response({'error': t('addon.errors.ha_unreachable')}, status=503)
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
return web.json_response({'error': 'That request is too large.'}, status=413)
|
||||
return web.json_response({'error': t('addon.errors.too_large')}, status=413)
|
||||
except (TypeError, KeyError):
|
||||
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
|
||||
return web.json_response({'error': t('addon.errors.invalid_input')}, status=400)
|
||||
if response.prepared:
|
||||
return response # streamed (SSE) responses set their headers before prepare()
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
@@ -1747,9 +1749,13 @@ def create_app(manager, development=False):
|
||||
screens = manager.screens()
|
||||
profiles = manager.firmware.profile_names()
|
||||
for screen in screens:
|
||||
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Home', 'tiles': []})
|
||||
# A screen without tiles yet: the title the screen itself shows without one, in its language (app 0.2.90).
|
||||
screen['layout'] = manager.layouts.get(screen['id'], {'title': screen_t('screen.status.home'), 'tiles': []})
|
||||
screen['settings'] = manager.settings_view(screen)
|
||||
screen['delivery'] = manager.status.get(screen['id'], 'Choose your first tiles')
|
||||
# The delivery and our own word for a screen that reports nothing, in the editor's language (app 0.2.90).
|
||||
status = manager.status.get(screen['id'])
|
||||
screen['delivery'] = shown(status) if status else t('addon.status.first_tiles')
|
||||
screen['status'] = shown(screen.get('status'))
|
||||
screen['update'] = manager.updates.state_for(screen, profiles)
|
||||
screen['alert_action'] = alert_service(screen.get('node'))
|
||||
screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert')
|
||||
@@ -1769,14 +1775,16 @@ def create_app(manager, development=False):
|
||||
screens, entities = manager.inventory()
|
||||
payload = light_payload(screens)
|
||||
payload['entities'] = entities
|
||||
payload['backgrounds'] = TILE_BACKGROUNDS
|
||||
# Labels and help in the editor's language (app 0.2.90); ids and keys stay as they are.
|
||||
payload['backgrounds'] = backgrounds()
|
||||
payload['controls'] = controls_catalogue()
|
||||
payload['icons'] = tile_icons.editor()
|
||||
payload['alerts'] = alert_reference()
|
||||
# What's new for the Update badge: here only, not in every live update (app 0.2.78).
|
||||
payload['changelog'] = manager.updates.changelog
|
||||
payload['claude_skill'] = claude_skill.status(manager.skill_dir)
|
||||
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Built into the screen', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
|
||||
payload['builtin'] = [{'id': key, 'name': builtin_name(key, t), 'device': t('addon.labels.built_into_screen'), 'area': '', 'state': 'ok'}
|
||||
for key in BUILTIN]
|
||||
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
|
||||
screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}}
|
||||
return web.json_response(payload)
|
||||
@@ -1856,9 +1864,9 @@ def create_app(manager, development=False):
|
||||
try:
|
||||
view = await manager.change_settings(request.match_info['inbox'], data.get('settings') if isinstance(data, dict) else None)
|
||||
except Refused as error:
|
||||
raise ValueError(f"Home Assistant didn't take the change: {error.detail or 'no reason given'}.") from error
|
||||
raise ValueError(t('addon.errors.ha_refused_change', reason=error.detail or t('addon.errors.no_reason'))) from error
|
||||
except (ConnectionError, TimeoutError) as error:
|
||||
raise ValueError("Home Assistant isn't reachable right now. Try again in a moment.") from error
|
||||
raise ValueError(t('addon.errors.ha_unreachable')) from error
|
||||
return web.json_response(view)
|
||||
async def update_screen(request):
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
@@ -1871,10 +1879,10 @@ def create_app(manager, development=False):
|
||||
async def inspector(request):
|
||||
screen=manager.screen(request.match_info['inbox'])
|
||||
inbox=manager.aliases.get(request.match_info['inbox'], request.match_info['inbox'])
|
||||
if screen is None: raise ValueError('Unknown screen.')
|
||||
if screen is None: raise ValueError(t('addon.errors.unknown_screen'))
|
||||
layout=manager.layouts.get(inbox,{'tiles':[]})
|
||||
return web.json_response({'screen':screen,
|
||||
'delivery':manager.status.get(inbox), 'layout':layout,
|
||||
return web.json_response({'screen':{**screen, 'status':shown(screen.get('status'))},
|
||||
'delivery':shown(manager.status.get(inbox)), 'layout':layout,
|
||||
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
|
||||
# Home Assistant's word for the state, as its own pages show it ("Heat/Cool", app 0.2.67).
|
||||
'word':state_word(t['entity'],manager.ha.states.get(t['entity'],{}).get('state'),manager.ha.states.get(t['entity'],{}).get('attributes'),
|
||||
@@ -1899,23 +1907,26 @@ def create_app(manager, development=False):
|
||||
def one_alert_target(inbox):
|
||||
screen = manager.screen(inbox)
|
||||
if screen is None:
|
||||
raise ValueError('Unknown screen.')
|
||||
raise ValueError(t('addon.errors.unknown_screen'))
|
||||
ready, skipped = alert_targets([screen])
|
||||
if not ready:
|
||||
raise ValueError(f"{screen['name']} can't show an alert: {skipped[0][1] if skipped else 'not ready'}.")
|
||||
raise ValueError(t('addon.errors.alerts.cannot_show', name=screen['name'],
|
||||
reason=skipped[0][1] if skipped else t('addon.errors.alerts.not_ready')))
|
||||
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."""
|
||||
"""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)."""
|
||||
screen = one_alert_target(request.match_info['inbox'])
|
||||
data, _ = alert_data({'title': f"This is {screen['name']}", 'subtitle': 'Identify, from ESP Screens', 'icon': 'bell-ring',
|
||||
'color': 'blue', 'button_text': 'OK', 'timeout': 8, 'flash': True})
|
||||
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})
|
||||
await manager.ha.call(alert_service(screen['node']), data)
|
||||
return web.json_response({'ok': True})
|
||||
async def test_alert(request):
|
||||
"""Alerts → Try it (app 0.2.73): one alert to one screen or to every screen, with the fields an automation sends."""
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError('Invalid alert.')
|
||||
raise ValueError(t('addon.errors.alerts.invalid'))
|
||||
data = body.get('data') if isinstance(body.get('data'), dict) else {}
|
||||
if body.get('screen') == 'all':
|
||||
return web.json_response(await manager.broadcast(BROADCAST_SHOW, data))
|
||||
@@ -1937,7 +1948,7 @@ def create_app(manager, development=False):
|
||||
async def firmware_override_save(request):
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Invalid override data.')
|
||||
raise ValueError(t('addon.errors.firmware.override_invalid'))
|
||||
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; the
|
||||
|
||||
@@ -8,7 +8,10 @@ fonts/materialdesignicons-webfont.ttf. Firmware 0.2.18+ carries every glyph here
|
||||
|
||||
import re
|
||||
|
||||
# (group, ((MDI name, codepoint, label), ...)) in picker order.
|
||||
from i18n import t
|
||||
|
||||
# (group, ((MDI name, codepoint, label), ...)) in picker order. The labels in English, as the Claude skill writes them; the
|
||||
# editor's picker gets them in its language (editor(), app 0.2.90).
|
||||
GROUPS = (
|
||||
('Lighting', (
|
||||
('lightbulb', 'F0335', 'Light bulb'),
|
||||
@@ -493,9 +496,15 @@ def ha_icon(attributes):
|
||||
icon = attributes.get('icon') if isinstance(attributes, dict) else None
|
||||
return GLYPHS.get(icon[4:]) if isinstance(icon, str) and icon.startswith('mdi:') else None
|
||||
|
||||
def label_key(name):
|
||||
"""The translation key of an icon's label ('ceiling-light'), or of a group's ('Media and music')."""
|
||||
return re.sub(r'[^a-z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
def editor():
|
||||
"""Picker groups plus what the mockup needs to predict the screen's own icons."""
|
||||
return {'groups': [{'label': group, 'icons': [{'name': name, 'cp': codepoint, 'label': label} for name, codepoint, label in icons]}
|
||||
"""Picker groups plus what the mockup needs to predict the screen's own icons; the labels in the editor's language."""
|
||||
return {'groups': [{'label': t(f'addon.icons.groups.{label_key(group)}'),
|
||||
'icons': [{'name': name, 'cp': codepoint, 'label': t(f'addon.icons.names.{label_key(name)}')}
|
||||
for name, codepoint, _ in icons]}
|
||||
for group, icons in GROUPS],
|
||||
'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK],
|
||||
'builtin': {entity: GLYPHS[name] for entity, name in BUILTIN_TILES.items()},
|
||||
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
import time
|
||||
import changelog
|
||||
from core import FIRMWARE_VERSION, parse_firmware
|
||||
from i18n import Text, english, screen_t, shown, t
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
NIGHT_HOURS = range(3, 6)
|
||||
@@ -18,6 +19,19 @@ parse_version = parse_firmware
|
||||
|
||||
TARGET = parse_version(FIRMWARE_VERSION)
|
||||
|
||||
def result_text(result):
|
||||
"""A kept result's message: a Text from its key where it has one (app 0.2.90+), else the English it was kept in."""
|
||||
key, params = result.get('key'), result.get('params')
|
||||
if isinstance(key, str) and key:
|
||||
return Text(str(result.get('message', '')), key, params if isinstance(params, dict) else {})
|
||||
return result.get('message', '')
|
||||
|
||||
def shown_result(result):
|
||||
"""A kept result as the editor shows it, its message in the editor's language."""
|
||||
if not isinstance(result, dict) or not isinstance(result.get('key'), str):
|
||||
return result
|
||||
return {**result, 'message': shown(result_text(result))}
|
||||
|
||||
class Updater:
|
||||
verify_timeout = 240
|
||||
settle_seconds = 60
|
||||
@@ -106,7 +120,7 @@ class Updater:
|
||||
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'])}
|
||||
'result': shown_result(self.results.get(screen['id']))}
|
||||
|
||||
def summary(self, screens=None, profiles=None):
|
||||
# The changelog goes with the full inventory only (app 0.2.78): this summary is in every live update of the page.
|
||||
@@ -125,40 +139,40 @@ class Updater:
|
||||
|
||||
def set_auto(self, enabled):
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError('Choose on or off for automatic updates.')
|
||||
raise ValueError(t('addon.errors.updates.automatic'))
|
||||
self.auto = enabled
|
||||
self.save()
|
||||
|
||||
def start(self, inbox, host=None):
|
||||
if self.busy():
|
||||
raise ValueError('An update is already running. Wait for it to finish.')
|
||||
raise ValueError(t('addon.errors.updates.busy'))
|
||||
screen = self.screen(inbox)
|
||||
inbox = self.current_id(inbox)
|
||||
if not screen:
|
||||
raise ValueError('Unknown screen. Refresh the overview.')
|
||||
raise ValueError(t('addon.errors.updates.unknown_screen'))
|
||||
if not screen['online']:
|
||||
raise ValueError("This screen is offline; updating is possible once it's back.")
|
||||
raise ValueError(t('addon.errors.updates.offline'))
|
||||
if not self.state_for(screen)['available']:
|
||||
raise ValueError('This screen already has the latest firmware.')
|
||||
raise ValueError(t('addon.errors.updates.latest'))
|
||||
if host is not None:
|
||||
if not isinstance(host, str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', host.strip()):
|
||||
raise ValueError("Enter this screen's IP address.")
|
||||
raise ValueError(t('addon.errors.updates.enter_ip'))
|
||||
self.hosts[inbox] = host.strip()
|
||||
self.save()
|
||||
profile, host = self.resolve(screen)
|
||||
if not profile:
|
||||
raise ValueError('No ESPHome profile found for this screen. Check the name in the ESPHome folder.')
|
||||
raise ValueError(t('addon.errors.updates.no_profile'))
|
||||
if not host:
|
||||
raise ValueError('IP address unknown. Enter it once; new firmware reports it itself after that.')
|
||||
raise ValueError(t('addon.errors.updates.ip_unknown'))
|
||||
self.launch([inbox])
|
||||
return self.state_for(screen)
|
||||
|
||||
def start_all(self):
|
||||
if self.busy():
|
||||
raise ValueError('An update is already running. Wait for it to finish.')
|
||||
raise ValueError(t('addon.errors.updates.busy'))
|
||||
pending = self.pending()
|
||||
if not pending:
|
||||
raise ValueError('All reachable screens are already updated.')
|
||||
raise ValueError(t('addon.errors.updates.all_updated'))
|
||||
self.launch(pending)
|
||||
return pending
|
||||
|
||||
@@ -168,7 +182,12 @@ class Updater:
|
||||
self.task = asyncio.create_task(self.run_round(inboxes, automatic))
|
||||
|
||||
def record(self, inbox, state, message):
|
||||
self.results[self.current_id(inbox)] = {'time': time.time(), 'state': state, 'message': message, 'version': FIRMWARE_VERSION}
|
||||
"""A screen's last result. `message` keeps its key and params (a Text) next to the English, so the editor shows it
|
||||
in its own language and an app from before 0.2.90 still reads the English."""
|
||||
result = {'time': time.time(), 'state': state, 'message': str(message), 'version': FIRMWARE_VERSION}
|
||||
if isinstance(message, Text) and message.key:
|
||||
result.update(message=message.into('en'), key=message.key, params=message.params)
|
||||
self.results[self.current_id(inbox)] = result
|
||||
self.save()
|
||||
|
||||
async def run_round(self, inboxes, automatic=False):
|
||||
@@ -180,8 +199,10 @@ class Updater:
|
||||
outcome = await self.update_one(inbox)
|
||||
if outcome == 'failed':
|
||||
if automatic:
|
||||
await self.notify(f"Automatic update stopped at {self.name(inbox)}: "
|
||||
f"{self.results[self.current_id(inbox)]['message']} The remaining screens were not touched.")
|
||||
# For whoever reads Home Assistant: in the screens' language, Home Assistant's own unless another was
|
||||
# chosen (app 0.2.90).
|
||||
await self.notify(screen_t('addon.notify.update_stopped', name=self.name(inbox),
|
||||
reason=result_text(self.results[self.current_id(inbox)])))
|
||||
break
|
||||
if self.queue and outcome == 'success':
|
||||
await asyncio.sleep(self.pause_seconds)
|
||||
@@ -195,33 +216,34 @@ class Updater:
|
||||
async def update_one(self, inbox):
|
||||
screen = self.screen(inbox)
|
||||
if not screen or not screen['online']:
|
||||
self.record(inbox, 'skipped', 'Screen was offline; will retry next time.')
|
||||
self.record(inbox, 'skipped', english('addon.updates.offline'))
|
||||
return 'skipped'
|
||||
profile, host = self.resolve(screen)
|
||||
if not profile or not host:
|
||||
self.record(inbox, 'skipped', 'Profile or IP address unknown; update this screen manually.')
|
||||
self.record(inbox, 'skipped', english('addon.updates.unknown_target'))
|
||||
return 'skipped'
|
||||
self.phase = 'install'
|
||||
try:
|
||||
self.manager.firmware.start({'file': profile, 'action': 'install', 'target': host})
|
||||
await self.manager.firmware.task
|
||||
except ValueError as error:
|
||||
self.record(inbox, 'failed', str(error))
|
||||
# The sentence firmware.start refused with, kept with its key.
|
||||
self.record(inbox, 'failed', error.args[0] if len(error.args) == 1 else str(error))
|
||||
return 'failed'
|
||||
if self.manager.firmware.job.get('state') != 'success':
|
||||
self.record(inbox, 'failed', 'Build or install failed; see the log under Firmware & USB.')
|
||||
self.record(inbox, 'failed', english('addon.updates.build_failed'))
|
||||
return 'failed'
|
||||
self.phase = 'verify'
|
||||
if not await self.wait_for_target(inbox):
|
||||
self.record(inbox, 'failed', f"Screen didn't report back with firmware {FIRMWARE_VERSION}; check the screen.")
|
||||
self.record(inbox, 'failed', english('addon.updates.no_report', version=FIRMWARE_VERSION))
|
||||
return 'failed'
|
||||
self.phase = 'settle'
|
||||
await asyncio.sleep(self.settle_seconds)
|
||||
current = self.screen(inbox)
|
||||
if not current or not current['online']:
|
||||
self.record(inbox, 'failed', 'Screen dropped off after the update; check the screen.')
|
||||
self.record(inbox, 'failed', english('addon.updates.dropped_off'))
|
||||
return 'failed'
|
||||
self.record(inbox, 'success', f'Updated to firmware {FIRMWARE_VERSION}.')
|
||||
self.record(inbox, 'success', english('addon.updates.updated', version=FIRMWARE_VERSION))
|
||||
return 'success'
|
||||
|
||||
async def wait_for_target(self, inbox):
|
||||
|
||||
@@ -391,7 +391,560 @@
|
||||
"addon": {
|
||||
"errors": {
|
||||
"language_choice": "Choose a language from the list.",
|
||||
"region_choice": "Choose a clock and a number format from the lists."
|
||||
"region_choice": "Choose a clock and a number format from the lists.",
|
||||
"open_through_ha": "Open this page through Home Assistant.",
|
||||
"refresh_page": "Refresh this page and try again.",
|
||||
"ha_refused": "Home Assistant didn't take it: {reason}.",
|
||||
"ha_refused_change": "Home Assistant didn't take the change: {reason}.",
|
||||
"no_reason": "no reason given",
|
||||
"ha_unreachable": "Home Assistant isn't reachable right now. Try again in a moment.",
|
||||
"too_large": "That request is too large.",
|
||||
"invalid_input": "Invalid input. Check the name, board, and chosen tiles.",
|
||||
"unknown_screen": "Unknown screen.",
|
||||
"not_paired": "This isn't a paired ESP screen. Refresh the overview.",
|
||||
"choose_icon": "Choose an icon from the list.",
|
||||
"skill_not_written": "Couldn't write the skill to {folder} ({reason}).",
|
||||
"settings": {
|
||||
"unknown": "Unknown screen settings; refresh the management page.",
|
||||
"invalid_value": "Invalid value for {setting}.",
|
||||
"dim_above_normal": "Standby and night brightness may not be higher than normal.",
|
||||
"offline": "This screen is offline. You can change its settings once it is back.",
|
||||
"unavailable": "{names} can't be changed now: the entity is off in Home Assistant, or the screen is restarting.",
|
||||
"rotation": "Rotation requires a Guition with firmware 0.2.9 or newer."
|
||||
},
|
||||
"layout": {
|
||||
"invalid": "Invalid layout.",
|
||||
"title": "Give the screen a title of at most 96 bytes.",
|
||||
"tiles_max": "Choose at most 1 tile. | Choose at most {n} tiles.",
|
||||
"unsupported": "This entity isn't supported.",
|
||||
"once": "An entity can only appear once on a screen.",
|
||||
"tile_name": "A tile name may contain at most 80 bytes.",
|
||||
"unknown_settings": "Unknown tile settings.",
|
||||
"navigation_size": "A navigation tile is single or double width.",
|
||||
"background": "Choose a pastel background color from the palette.",
|
||||
"invalid_setting": "Invalid tile setting: {setting}",
|
||||
"no_toggle": "This entity doesn't support an on/off action.",
|
||||
"builtin_action": "A built-in card can't perform an action.",
|
||||
"no_mini_slider": "This entity doesn't support a mini-slider.",
|
||||
"history_hours": "History: choose 1, 6, or 24 hours.",
|
||||
"watch_or_slider": "Choose large value or mini-slider.",
|
||||
"no_direct_control": "This entity doesn't support that direct control.",
|
||||
"position": "Invalid tile position; refresh the management page.",
|
||||
"full_page_top": "A full-page tile starts at the top of its page.",
|
||||
"wide_left": "A double-width tile starts in the left column.",
|
||||
"same_spot": "Two tiles are in the same spot.",
|
||||
"pages": "A screen has 1 page. | A screen has 1 to {n} pages.",
|
||||
"eight_pages": "These tiles don't fit on eight pages; remove a tile or make one smaller.",
|
||||
"camera_guition": "Camera images need a Guition screen.",
|
||||
"firmware_first": "Install screen firmware {version} or newer first for these tiles.",
|
||||
"entity_gone": "A chosen entity no longer exists. Look up the new entity.",
|
||||
"ids_too_long": "These tiles' entity IDs are too long together to fit in one message to the screen; remove a few tiles."
|
||||
},
|
||||
"top_bar": {
|
||||
"invalid": "Invalid top bar; refresh the management page.",
|
||||
"full": "The top bar has room for at most 1 item. | The top bar has room for at most {n} items.",
|
||||
"invalid_setting": "Invalid setting in the top bar.",
|
||||
"unknown_setting": "Unknown setting in the top bar; refresh the management page.",
|
||||
"entity": "This entity can't go in the top bar.",
|
||||
"unknown_item": "Unknown item in the top bar; refresh the management page.",
|
||||
"twice": "This item is already in the top bar.",
|
||||
"entity_gone": "An entity in the top bar no longer exists. Choose a different one."
|
||||
},
|
||||
"tap_action": {
|
||||
"choose": "Choose an action from the list.",
|
||||
"fields_max": "An action on a tap sets at most 1 field. | An action on a tap sets at most {n} fields.",
|
||||
"field_not_allowed": "{field} is not a field an action on a tap can set.",
|
||||
"unsendable": "A value of the action is something the screen cannot send.",
|
||||
"too_long": "The values of the action are too long for the screen."
|
||||
},
|
||||
"events": {
|
||||
"none_paired": "none yet",
|
||||
"name_the_screen": "Name the screen. Paired: {screens}.",
|
||||
"no_such_screen": "No screen called \"{name}\". Paired: {screens}.",
|
||||
"several_screens": "\"{name}\" fits more than one screen: {screens}.",
|
||||
"name_the_entity": "Name the entity.",
|
||||
"not_for_a_screen": "{entity} cannot go on a screen.",
|
||||
"not_on_screen": "{entity} is not on this screen.",
|
||||
"add_it_first": "{entity} is not on this screen; add it first.",
|
||||
"not_on_spot": "{entity} is not on spot {slot}.",
|
||||
"not_on_page": "{entity} is not on page {page}.",
|
||||
"move_where": "Name the page or the spot to move it to.",
|
||||
"spot_taken": "That spot is taken by {entity}; give another spot or move that one first.",
|
||||
"page_not_empty": "Page {page} is not empty; a full-page tile needs a page of its own.",
|
||||
"no_empty_page": "No page is empty; a full-page tile needs a page of its own.",
|
||||
"page_full": "Page {page} is full.",
|
||||
"no_room": "This screen has no room left.",
|
||||
"screen_full": "This screen already has 1 tile; remove one first. | This screen already has {n} tiles; remove one first.",
|
||||
"page_range": "Choose a page between 1 and {last}.",
|
||||
"spot_range": "Choose a spot between 0 and {last}.",
|
||||
"row_needs_page": "Name the page for that row or column.",
|
||||
"row_range": "Choose a row between 1 and {last}.",
|
||||
"column": "The column is left or right.",
|
||||
"full_page_alone": "A full-page tile takes all of page {page}; nothing else fits there.",
|
||||
"does_not_fit": "That does not fit on page {page}; a double-width tile takes two spots.",
|
||||
"order_needed": "Give the entities in the order you want them.",
|
||||
"order_not_on_screen": "Not on this screen: {entities}.",
|
||||
"order_not_on_page": "Not on page {page}: {entities}. Move it there first.",
|
||||
"named_too_often": "Named more often than it is on this screen: {entities}.",
|
||||
"named_too_often_page": "Named more often than it is on page {page}: {entities}."
|
||||
},
|
||||
"home_assistant": {
|
||||
"action_not_offered": "Home Assistant doesn't offer {action} for {name}. Choose an action from the list.",
|
||||
"action_answers_only": "{action} only answers with data, which a screen can't show.",
|
||||
"no_such_field": "Home Assistant has no field {field} in {action} for {name}.",
|
||||
"field_needed": "{action} needs a value for {field}.",
|
||||
"no_on_off": "Home Assistant can't turn {name} on and off, so On / off would do nothing. Choose another tap action.",
|
||||
"no_small_slider": "{name} has nothing a small slider can change in Home Assistant.",
|
||||
"no_graph": "{name} has no numbers to draw a graph of.",
|
||||
"no_forecast": "{name} has no daily forecast in Home Assistant.",
|
||||
"no_control": "Home Assistant doesn't offer that direct control for {name}."
|
||||
},
|
||||
"alerts": {
|
||||
"invalid": "Invalid alert.",
|
||||
"cannot_show": "{name} can't show an alert: {reason}.",
|
||||
"no_device_name": "device name unknown",
|
||||
"offline": "offline",
|
||||
"firmware": "firmware {version}",
|
||||
"unknown_version": "unknown",
|
||||
"not_ready": "not ready"
|
||||
},
|
||||
"firmware": {
|
||||
"board_and_name": "Choose a board and a unique name (lowercase letters, digits, dashes; 30 characters max).",
|
||||
"friendly_name": "Give the screen a recognizable name (60 characters max).",
|
||||
"choose_profile": "Choose an existing YAML profile.",
|
||||
"profile_missing": "Profile doesn't exist in the ESPHome folder.",
|
||||
"secrets_invalid": "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten.",
|
||||
"wifi_needed": "Fill in the Wi-Fi name and password; they'll be saved in ESPHome secrets.yaml.",
|
||||
"wifi_not_set": "Wi-Fi couldn't be set in secrets.yaml. Fill in wifi_ssid and wifi_password there yourself.",
|
||||
"name_exists": "This name already exists. Use the existing profile for updates.",
|
||||
"override_exists": "This name already has a local override. Restore or remove it before creating a new screen with this name.",
|
||||
"override_symlink": "The local override is a symbolic link; replace it with a regular file first.",
|
||||
"other_include": "This profile already has a different local_overrides include.",
|
||||
"profile_invalid": "The screen profile isn't valid YAML; fix it before adding an override.",
|
||||
"no_packages": "This profile has no packages section that ESP Screens can extend.",
|
||||
"override_invalid": "Invalid override data.",
|
||||
"yaml_needed": "Enter YAML text first.",
|
||||
"override_too_large": "The override is too large. Keep it below 12 KB.",
|
||||
"yaml_error_line": "YAML error on line {line}: {problem}.",
|
||||
"yaml_error": "YAML error: {problem}.",
|
||||
"invalid_yaml": "invalid YAML",
|
||||
"override_object": "The override must contain a YAML object, for example display: or substitutions:.",
|
||||
"sections_managed": "These sections stay managed by ESP Screens: {names}.",
|
||||
"substitutions_managed": "These substitutions stay managed by ESP Screens: {names}.",
|
||||
"usb_port": "Choose the connected USB port from the list.",
|
||||
"busy_wait": "A build or installation is already running. Wait for it to finish.",
|
||||
"busy": "A build or installation is already running.",
|
||||
"no_esphome": "The ESPHome CLI is missing from this installation.",
|
||||
"still_building": "The firmware is still being built. Download it when the build has finished.",
|
||||
"build_first": "Build the firmware first; the download appears when the build has finished.",
|
||||
"unknown_action": "Unknown firmware action.",
|
||||
"host": "Enter the IP address or hostname of the intended screen."
|
||||
},
|
||||
"updates": {
|
||||
"automatic": "Choose on or off for automatic updates.",
|
||||
"busy": "An update is already running. Wait for it to finish.",
|
||||
"unknown_screen": "Unknown screen. Refresh the overview.",
|
||||
"offline": "This screen is offline; updating is possible once it's back.",
|
||||
"latest": "This screen already has the latest firmware.",
|
||||
"enter_ip": "Enter this screen's IP address.",
|
||||
"no_profile": "No ESPHome profile found for this screen. Check the name in the ESPHome folder.",
|
||||
"ip_unknown": "IP address unknown. Enter it once; new firmware reports it itself after that.",
|
||||
"all_updated": "All reachable screens are already updated."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"first_tiles": "Choose your first tiles",
|
||||
"saved": "Saved; waiting for sync",
|
||||
"firmware_needed": "Layout saved; firmware {version}+ needed for these tiles",
|
||||
"too_large": "Layout saved; too large for one message to the screen, remove a few tiles",
|
||||
"sent": "Sent to Home Assistant",
|
||||
"offline": "Screen offline; changes saved",
|
||||
"retrying": "Sending failed; retrying automatically"
|
||||
},
|
||||
"updates": {
|
||||
"offline": "Screen was offline; will retry next time.",
|
||||
"unknown_target": "Profile or IP address unknown; update this screen manually.",
|
||||
"build_failed": "Build or install failed; see the log under Firmware & USB.",
|
||||
"no_report": "Screen didn't report back with firmware {version}; check the screen.",
|
||||
"dropped_off": "Screen dropped off after the update; check the screen.",
|
||||
"updated": "Updated to firmware {version}."
|
||||
},
|
||||
"notify": {
|
||||
"update_stopped": "Automatic update stopped at {name}: {reason} The remaining screens were not touched."
|
||||
},
|
||||
"labels": {
|
||||
"built_into_screen": "Built into the screen",
|
||||
"backgrounds": {
|
||||
"auto": "Default",
|
||||
"none": "None",
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"green": "Green",
|
||||
"mint": "Mint",
|
||||
"blue": "Blue",
|
||||
"purple": "Purple",
|
||||
"pink": "Pink",
|
||||
"gray": "Gray"
|
||||
},
|
||||
"controls": {
|
||||
"none": "None",
|
||||
"climate": {
|
||||
"setpoint": "Temperature − / +",
|
||||
"mode": "Off, heat, cool"
|
||||
},
|
||||
"switch": {
|
||||
"toggle": "On/off switch"
|
||||
},
|
||||
"input_boolean": {
|
||||
"toggle": "On/off switch"
|
||||
},
|
||||
"light": {
|
||||
"toggle": "On/off switch",
|
||||
"brightness": "Brightness slider"
|
||||
},
|
||||
"fan": {
|
||||
"toggle": "On/off switch",
|
||||
"speed": "Speed slider"
|
||||
},
|
||||
"vacuum": {
|
||||
"buttons": "Start, stop, dock"
|
||||
},
|
||||
"cover": {
|
||||
"buttons": "Open, stop, close",
|
||||
"position": "Position slider"
|
||||
},
|
||||
"media_player": {
|
||||
"volume": "Volume and mute",
|
||||
"playback": "Previous, play/pause, next"
|
||||
},
|
||||
"number": {
|
||||
"stepper": "Value − / +",
|
||||
"slider": "Slider"
|
||||
},
|
||||
"input_number": {
|
||||
"stepper": "Value − / +",
|
||||
"slider": "Slider"
|
||||
},
|
||||
"select": {
|
||||
"stepper": "Previous / next choice"
|
||||
},
|
||||
"input_select": {
|
||||
"stepper": "Previous / next choice"
|
||||
},
|
||||
"timer": {
|
||||
"buttons": "Start/pause and cancel"
|
||||
},
|
||||
"scene": {
|
||||
"run": "Activate button"
|
||||
},
|
||||
"script": {
|
||||
"run": "Run button"
|
||||
},
|
||||
"button": {
|
||||
"run": "Press button"
|
||||
},
|
||||
"input_button": {
|
||||
"run": "Press button"
|
||||
}
|
||||
},
|
||||
"top_bar": {
|
||||
"builtin": {
|
||||
"clock": "Time",
|
||||
"analog": "Analog clock",
|
||||
"date": "Date"
|
||||
},
|
||||
"contents": {
|
||||
"state": "Status",
|
||||
"last_changed": "Last changed"
|
||||
},
|
||||
"shows": {
|
||||
"always": "Always",
|
||||
"active": "Only when active"
|
||||
},
|
||||
"suggestions": {
|
||||
"temperature": "Temperature",
|
||||
"humidity": "Humidity",
|
||||
"power": "Power usage",
|
||||
"motion": "Last motion",
|
||||
"weather": "Weather",
|
||||
"people_home": "People home",
|
||||
"sun": "Sunrise and sunset",
|
||||
"co2": "CO₂"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"fields": {
|
||||
"title": {
|
||||
"label": "Title",
|
||||
"help": "A single line at the top of the card; what doesn't fit gets an ellipsis. Empty becomes \"Notification\".",
|
||||
"example": "Someone is at the door"
|
||||
},
|
||||
"subtitle": {
|
||||
"label": "Subtitle",
|
||||
"help": "Explanation under the title, across multiple lines; a line break is fine. Empty is fine.",
|
||||
"example": "Door 3, back"
|
||||
},
|
||||
"icon": {
|
||||
"label": "Icon",
|
||||
"help": "A name from the list, also as mdi:name or as a hex codepoint (F12E6). Unknown or empty gives the warning triangle."
|
||||
},
|
||||
"color": {
|
||||
"label": "Color",
|
||||
"help": "One of the nine pastel colors of the tiles. Empty gives the white card."
|
||||
},
|
||||
"button_text": {
|
||||
"label": "Button text",
|
||||
"help": "The text on the button. Empty is \"OK\".",
|
||||
"example": "Coming"
|
||||
},
|
||||
"timeout": {
|
||||
"label": "Timeout",
|
||||
"help": "Seconds after which the card disappears on its own. 0 waits for the button, however long that takes. The button always closes it immediately, even with a timeout."
|
||||
},
|
||||
"flash": {
|
||||
"label": "Blinking",
|
||||
"help": "On makes the backlight blink four times when the alert arrives; the screen then just stays on."
|
||||
},
|
||||
"camera": {
|
||||
"label": "Camera",
|
||||
"help": "A camera or image entity. A Guition with firmware 0.2.57+ shows its picture of that moment across the top of the card; a tap on it opens the camera full screen. Other screens show the alert without it. Only through the esp_screens_show_alert event."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
"ok": "The button was pressed",
|
||||
"timeout": "The timeout ran out",
|
||||
"replaced": "A new alert came over it",
|
||||
"remote": "dismiss_alert from Home Assistant"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"groups": {
|
||||
"lighting": "Lighting",
|
||||
"rooms": "Rooms",
|
||||
"climate": "Climate",
|
||||
"weather": "Weather",
|
||||
"media_and_music": "Media and music",
|
||||
"security": "Security",
|
||||
"appliances": "Appliances",
|
||||
"energy": "Energy",
|
||||
"window_coverings": "Window coverings",
|
||||
"garden_and_pets": "Garden and pets",
|
||||
"people_and_travel": "People and travel",
|
||||
"other": "Other"
|
||||
},
|
||||
"names": {
|
||||
"lightbulb": "Light bulb",
|
||||
"lightbulb_group": "Lights",
|
||||
"ceiling_light": "Ceiling light",
|
||||
"chandelier": "Chandelier",
|
||||
"lamp": "Table lamp",
|
||||
"floor_lamp": "Floor lamp",
|
||||
"desk_lamp": "Desk lamp",
|
||||
"wall_sconce_flat": "Wall light",
|
||||
"light_recessed": "Recessed light",
|
||||
"spotlight_beam": "Spotlight",
|
||||
"led_strip_variant": "LED strip",
|
||||
"outdoor_lamp": "Outdoor light",
|
||||
"home": "House",
|
||||
"sofa": "Living room",
|
||||
"bed": "Bedroom",
|
||||
"silverware_fork_knife": "Dining room",
|
||||
"chef_hat": "Kitchen",
|
||||
"shower": "Shower",
|
||||
"bathtub": "Bathroom",
|
||||
"toilet": "Toilet",
|
||||
"desk": "Office",
|
||||
"garage": "Garage",
|
||||
"fireplace": "Fireplace",
|
||||
"thermometer": "Temperature",
|
||||
"thermostat": "Thermostat",
|
||||
"air_conditioner": "AC",
|
||||
"radiator": "Radiator",
|
||||
"heat_pump": "Heat pump",
|
||||
"fire": "Heating",
|
||||
"snowflake": "Cooling",
|
||||
"water_percent": "Humidity",
|
||||
"fan": "Fan",
|
||||
"ceiling_fan": "Ceiling fan",
|
||||
"air_purifier": "Air purifier",
|
||||
"molecule_co2": "CO₂",
|
||||
"weather_sunny": "Sun",
|
||||
"weather_partly_cloudy": "Partly cloudy",
|
||||
"weather_cloudy": "Cloudy",
|
||||
"weather_rainy": "Rain",
|
||||
"weather_snowy": "Snow",
|
||||
"weather_windy": "Wind",
|
||||
"weather_night": "Night",
|
||||
"weather_sunset_up": "Sunrise",
|
||||
"music_note": "Music",
|
||||
"music": "Music notes",
|
||||
"playlist_music": "Playlist",
|
||||
"album": "Album",
|
||||
"record_player": "Record player",
|
||||
"spotify": "Spotify",
|
||||
"radio": "Radio",
|
||||
"podcast": "Podcast",
|
||||
"speaker": "Speaker",
|
||||
"speaker_multiple": "Speakers",
|
||||
"soundbar": "Soundbar",
|
||||
"headphones": "Headphones",
|
||||
"piano": "Piano",
|
||||
"guitar_acoustic": "Guitar",
|
||||
"television": "TV",
|
||||
"projector": "Projector",
|
||||
"movie_open": "Movie",
|
||||
"cast": "Cast",
|
||||
"gamepad_variant": "Game console",
|
||||
"remote_tv": "Remote control",
|
||||
"lock": "Lock",
|
||||
"lock_open_variant": "Lock open",
|
||||
"shield_home": "Alarm",
|
||||
"alarm_light": "Alarm light",
|
||||
"bell": "Bell",
|
||||
"doorbell": "Doorbell",
|
||||
"cctv": "Camera",
|
||||
"motion_sensor": "Motion sensor",
|
||||
"door_closed": "Door",
|
||||
"door_open": "Door open",
|
||||
"window_closed_variant": "Window",
|
||||
"smoke_detector": "Smoke detector",
|
||||
"water_alert": "Water leak",
|
||||
"washing_machine": "Washing machine",
|
||||
"tumble_dryer": "Dryer",
|
||||
"dishwasher": "Dishwasher",
|
||||
"fridge": "Fridge",
|
||||
"stove": "Stove",
|
||||
"microwave": "Microwave",
|
||||
"toaster_oven": "Oven",
|
||||
"coffee_maker": "Coffee",
|
||||
"kettle": "Kettle",
|
||||
"robot_vacuum": "Robot vacuum",
|
||||
"robot_mower": "Robot mower",
|
||||
"printer_3d": "3D printer",
|
||||
"power_plug": "Plug",
|
||||
"power_socket_eu": "Socket",
|
||||
"laptop": "Laptop",
|
||||
"router_wireless": "Router",
|
||||
"flash": "Power",
|
||||
"solar_power": "Solar power",
|
||||
"solar_panel": "Solar panels",
|
||||
"home_battery": "Home battery",
|
||||
"battery_high": "Battery",
|
||||
"ev_station": "Charging station",
|
||||
"car_electric": "Electric car",
|
||||
"meter_electric": "Power meter",
|
||||
"meter_gas": "Gas meter",
|
||||
"water": "Water",
|
||||
"water_boiler": "Boiler",
|
||||
"gauge": "Meter",
|
||||
"window_shutter": "Shutter",
|
||||
"window_shutter_open": "Shutter open",
|
||||
"blinds": "Blinds",
|
||||
"blinds_open": "Blinds open",
|
||||
"roller_shade_closed": "Roller blind",
|
||||
"curtains": "Curtains",
|
||||
"curtains_closed": "Curtains closed",
|
||||
"awning_outline": "Awning",
|
||||
"flower": "Flower",
|
||||
"tree": "Tree",
|
||||
"sprout": "Plant",
|
||||
"grass": "Lawn",
|
||||
"sprinkler": "Sprinkler",
|
||||
"pool": "Pool",
|
||||
"grill": "Barbecue",
|
||||
"mailbox": "Mailbox",
|
||||
"trash_can": "Trash",
|
||||
"recycle": "Recycling",
|
||||
"dog": "Dog",
|
||||
"cat": "Cat",
|
||||
"account": "Person",
|
||||
"account_group": "Family",
|
||||
"account_child": "Child",
|
||||
"home_account": "Home",
|
||||
"sleep": "Sleeping",
|
||||
"run": "Exercising",
|
||||
"briefcase": "Work",
|
||||
"car": "Car",
|
||||
"bike": "Bike",
|
||||
"airplane": "Vacation",
|
||||
"map_marker": "Location",
|
||||
"power": "On/off",
|
||||
"toggle_switch": "Switch",
|
||||
"gesture_tap_button": "Button",
|
||||
"play": "Start",
|
||||
"script_text": "Script",
|
||||
"palette": "Scene",
|
||||
"alarm": "Alarm clock",
|
||||
"clock_outline": "Clock",
|
||||
"timer_outline": "Timer",
|
||||
"calendar": "Calendar",
|
||||
"bell_ring": "Notification",
|
||||
"heart": "Favorite",
|
||||
"alert_outline": "Warning",
|
||||
"cog": "Settings",
|
||||
"broom": "Cleaning"
|
||||
}
|
||||
},
|
||||
"screen": {
|
||||
"builtin": {
|
||||
"clock": "Clock",
|
||||
"page": "Go to page {page}"
|
||||
},
|
||||
"identify": {
|
||||
"title": "This is {name}",
|
||||
"subtitle": "Identify, from ESP Screens"
|
||||
},
|
||||
"script_running": "Running",
|
||||
"states": {
|
||||
"locking": "Locking",
|
||||
"unlocking": "Unlocking",
|
||||
"jammed": "Jammed",
|
||||
"buffering": "Buffering",
|
||||
"returning": "Returning",
|
||||
"error": "Error",
|
||||
"disarmed": "Disarmed",
|
||||
"armed_home": "Armed home",
|
||||
"armed_away": "Armed away",
|
||||
"armed_night": "Armed night",
|
||||
"armed_vacation": "Armed vacation",
|
||||
"armed": "Armed",
|
||||
"arming": "Arming",
|
||||
"pending": "Pending",
|
||||
"triggered": "Triggered",
|
||||
"active": "Active",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"history": {
|
||||
"other": "Other"
|
||||
},
|
||||
"vacuum": {
|
||||
"vacuum": "Vacuum",
|
||||
"vac_and_mop": "Vac & mop",
|
||||
"vac_then_mop": "Vac, then mop",
|
||||
"mop": "Mop",
|
||||
"custom": "Custom",
|
||||
"smart": "Smart",
|
||||
"min": "Min",
|
||||
"slight": "Slight",
|
||||
"low": "Low",
|
||||
"mild": "Mild",
|
||||
"medium": "Medium",
|
||||
"moderate": "Moderate",
|
||||
"standard": "Standard",
|
||||
"high": "High",
|
||||
"intense": "Intense",
|
||||
"extreme": "Extreme",
|
||||
"ultra_high": "Ultra high",
|
||||
"silent": "Silent",
|
||||
"gentle": "Gentle",
|
||||
"strong": "Strong",
|
||||
"max_plus": "Max+",
|
||||
"auto": "Auto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""The app's own texts in the translations (app 0.2.90): its messages, statuses and labels in the editor's language, the
|
||||
words it sends to the screens in theirs.
|
||||
|
||||
Every key the code asks for is in en.json with the placeholders the code fills in, and the English tables the Claude skill
|
||||
writes from (backgrounds, direct controls, the alert reference, the icons) say what en.json says. A made-up language, xx,
|
||||
shows that another language reaches the editor through the request's X-ESP-Screens-Language header and the screens
|
||||
through Settings -> Language & region, while what the app hands Home Assistant as data stays English.
|
||||
"""
|
||||
import ast
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / 'screen_manager/app'
|
||||
sys.path.insert(0, str(APP))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import core # noqa: E402
|
||||
import header_bar # noqa: E402
|
||||
import history_card # noqa: E402
|
||||
import i18n # noqa: E402
|
||||
from i18n import REQUEST_LANGUAGE, TRANSLATIONS, english, shown, t # noqa: E402
|
||||
import tile_icons # noqa: E402
|
||||
import updates # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
ENGLISH = TRANSLATIONS.flat['en']
|
||||
PLACEHOLDER = re.compile(r'\{([a-z_]+)\}')
|
||||
KEY = re.compile(r'(addon|screen)(\.[a-z0-9_]+){2,}')
|
||||
|
||||
|
||||
def lookups():
|
||||
"""(where, key, params) of every t(), english() and screen_t() with a written-out key in the app's code."""
|
||||
for path in sorted(APP.glob('*.py')):
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding='utf-8'))):
|
||||
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ('t', 'english', 'screen_t')
|
||||
and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str)):
|
||||
yield f'{path.name}:{node.lineno}', node.args[0].value, {keyword.arg for keyword in node.keywords}
|
||||
|
||||
|
||||
def written_keys():
|
||||
"""(where, key) of every other translation key the code names in full, such as the tables of words by key."""
|
||||
for path in sorted(APP.glob('*.py')):
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding='utf-8'))):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str) and KEY.fullmatch(node.value):
|
||||
yield f'{path.name}:{node.lineno}', node.value
|
||||
|
||||
|
||||
class Language:
|
||||
"""xx for as long as a test runs: the texts under `prefixes` marked "[xx] ", every other one in English."""
|
||||
|
||||
def __init__(self, *prefixes):
|
||||
self.prefixes = prefixes
|
||||
|
||||
def __enter__(self):
|
||||
TRANSLATIONS.data['xx'] = {'_meta': {'name': 'Xx', 'english': 'Xx', 'plural': 'one_other', 'clock': '24'}}
|
||||
TRANSLATIONS.flat['xx'] = {key: ' | '.join(f'[xx] {form.strip()}' for form in text.split('|'))
|
||||
for key, text in ENGLISH.items() if key.startswith(self.prefixes)}
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
del TRANSLATIONS.data['xx'], TRANSLATIONS.flat['xx']
|
||||
i18n.set_screens('en', 'point')
|
||||
|
||||
|
||||
class Keys(unittest.TestCase):
|
||||
def test_every_key_the_app_asks_for_is_in_english_with_its_placeholders(self):
|
||||
found = list(lookups())
|
||||
self.assertGreater(len(found), 150)
|
||||
for where, key, params in found:
|
||||
self.assertIn(key, ENGLISH, where)
|
||||
# `n` picks a plural form; every other placeholder is filled in by name.
|
||||
self.assertEqual(set(PLACEHOLDER.findall(ENGLISH[key])), params, f'{where}: {key}')
|
||||
for where, key in written_keys():
|
||||
self.assertIn(key, ENGLISH, where)
|
||||
|
||||
def test_the_app_s_texts_are_whole_and_keep_the_editor_s_characters_out(self):
|
||||
for key, text in ENGLISH.items():
|
||||
if key.startswith('addon.'):
|
||||
self.assertTrue(text.strip(), key)
|
||||
self.assertNotIn('@', text, key)
|
||||
self.assertFalse(re.search(r'[{}]', PLACEHOLDER.sub('', text)), key)
|
||||
# A plural has English's two forms, each with its own words.
|
||||
self.assertIn(len(text.split('|')), (1, 2), key)
|
||||
|
||||
def test_the_english_tables_the_claude_skill_writes_from_say_what_english_says(self):
|
||||
self.assertEqual({name: item['label'] for name, item in core.backgrounds().items()},
|
||||
{name: item['label'] for name, item in core.TILE_BACKGROUNDS.items()})
|
||||
catalogue = core.controls_catalogue()
|
||||
for domain, choices in core.CONTROLS.items():
|
||||
self.assertEqual([(c['key'], c['label']) for c in catalogue[domain]['choices']], [*choices, ('none', 'None')], domain)
|
||||
reference = core.alert_reference()
|
||||
self.assertEqual([(f['name'], f['label'], f['help'], f['example']) for f in reference['fields']],
|
||||
[(name, label, help_, example) for name, _, label, help_, example in core.ALERT_FIELDS])
|
||||
self.assertEqual(tuple(reference['camera'].values()), core.ALERT_CAMERA_FIELD)
|
||||
self.assertEqual([(e['action'], e['label']) for e in reference['endings']], list(core.ALERT_ENDINGS))
|
||||
picker = tile_icons.editor()['groups']
|
||||
self.assertEqual([(group['label'], [(i['name'], i['cp'], i['label']) for i in group['icons']]) for group in picker],
|
||||
[(group, list(icons)) for group, icons in tile_icons.GROUPS])
|
||||
for entity, name in core.BUILTIN.items():
|
||||
self.assertEqual(core.builtin_name(entity, english), name)
|
||||
self.assertEqual(core.builtin_name(entity, t), name)
|
||||
|
||||
def test_the_binary_words_are_home_assistant_s_from_the_translations(self):
|
||||
for device_class, (on, off) in header_bar.BINARY_STATES.items():
|
||||
self.assertEqual((on, off), (ENGLISH[f'screen.ha.binary.{device_class}_on'], ENGLISH[f'screen.ha.binary.{device_class}_off']))
|
||||
self.assertEqual(header_bar.binary_words(device_class), (on, off))
|
||||
self.assertEqual(header_bar.binary_words('future_class'), ('On', 'Off'))
|
||||
|
||||
|
||||
class Texts(unittest.TestCase):
|
||||
def test_a_text_keeps_its_key_and_can_be_written_again(self):
|
||||
made = english('addon.errors.alerts.cannot_show', name='Hall', reason=english('addon.errors.alerts.offline'))
|
||||
self.assertEqual(made, "Hall can't show an alert: offline.")
|
||||
self.assertEqual((made.key, set(made.params)), ('addon.errors.alerts.cannot_show', {'name', 'reason'}))
|
||||
self.assertEqual(json.dumps({'error': made}), json.dumps({'error': "Hall can't show an alert: offline."}))
|
||||
with Language('addon.'):
|
||||
self.assertEqual(made.into('xx'), "[xx] Hall can't show an alert: [xx] offline.")
|
||||
token = REQUEST_LANGUAGE.set('xx')
|
||||
try:
|
||||
self.assertEqual(shown(made), "[xx] Hall can't show an alert: [xx] offline.")
|
||||
self.assertEqual(t('addon.errors.unknown_screen'), '[xx] Unknown screen.')
|
||||
self.assertEqual(shown('Synced'), 'Synced', 'a word the screen reported stays as it came')
|
||||
finally:
|
||||
REQUEST_LANGUAGE.reset(token)
|
||||
# Outside a request, as for a tile event's answer to Home Assistant: English.
|
||||
self.assertEqual(t('addon.errors.unknown_screen'), 'Unknown screen.')
|
||||
|
||||
def test_a_count_picks_its_plural_form(self):
|
||||
self.assertEqual(t('addon.errors.layout.tiles_max', n=48), 'Choose at most 48 tiles.')
|
||||
self.assertEqual(t('addon.errors.layout.tiles_max', n=1), 'Choose at most 1 tile.')
|
||||
|
||||
def test_the_reasons_a_screen_can_t_show_an_alert_are_english_for_the_log(self):
|
||||
_, skipped = core.alert_targets([{'name': 'A', 'online': True}, {'name': 'B', 'node': 'b', 'online': False},
|
||||
{'name': 'C', 'node': 'c', 'online': True, 'firmware': 'unknown'}])
|
||||
self.assertEqual([reason for _, reason in skipped], ['device name unknown', 'offline', 'firmware unknown'])
|
||||
with Language('addon.'):
|
||||
token = REQUEST_LANGUAGE.set('xx')
|
||||
try:
|
||||
self.assertEqual([shown(reason) for _, reason in skipped],
|
||||
['[xx] device name unknown', '[xx] offline', '[xx] firmware [xx] unknown'])
|
||||
finally:
|
||||
REQUEST_LANGUAGE.reset(token)
|
||||
|
||||
|
||||
class ScreenWords(unittest.TestCase):
|
||||
"""What the app sends to the screens, in the language of Settings -> Language & region."""
|
||||
|
||||
def test_the_screens_get_the_app_s_words_in_their_language(self):
|
||||
with Language('addon.screen.', 'screen.ha.', 'screen.date.', 'screen.settings.', 'screen.vacuum.', 'screen.script.'):
|
||||
i18n.set_screens('xx', 'comma')
|
||||
self.assertEqual(core.state_message(0, {'entity': 'screen.page_2', 'name': ''}, {})['name'], '[xx] Go to page 2')
|
||||
self.assertEqual(core.state_message(0, {'entity': 'screen.settings', 'name': ''}, {})['name'], '[xx] Settings')
|
||||
self.assertEqual(core.state_message(0, {'entity': 'screen.clock', 'name': 'Kitchen'}, {})['name'], 'Kitchen')
|
||||
self.assertEqual([core.vacuum_label(v) for v in ('vac_and_mop', 'quiet', 'off', 'some_thing')],
|
||||
['[xx] Vac & mop', '[xx] Quiet', '[xx] Off', 'Some thing'])
|
||||
day = {'datetime': '2026-09-14T12:00:00+00:00', 'condition': 'sunny'}
|
||||
self.assertEqual(core.extras({'entity': 'weather.home'}, {}, forecast=[day])['days'][0]['d'], '[xx] Mo')
|
||||
value = lambda entity, raw, **attributes: header_bar.value(entity, {'state': raw, 'attributes': attributes})[0]
|
||||
self.assertEqual(value('sensor.power', '1234.5', unit_of_measurement='W'), '1.234,5 W')
|
||||
self.assertEqual(value('lock.front', 'jammed'), '[xx] Jammed')
|
||||
self.assertEqual(value('climate.hall', 'heat_cool'), '[xx] Auto')
|
||||
self.assertEqual(value('binary_sensor.door', 'on', device_class='door'), '[xx] Open')
|
||||
self.assertEqual(value('binary_sensor.x', 'off'), '[xx] Off')
|
||||
self.assertEqual(value('script.night', 'off'), '[xx] Never run')
|
||||
self.assertEqual(value('input_datetime.alarm', '2026-09-15', has_date=True), '[xx] 15 [xx] Sep')
|
||||
self.assertEqual(history_card.state_label('person', 'not_home', {}), '[xx] Away')
|
||||
self.assertEqual(history_card.state_label('sensor', 'unknown', {}), '[xx] Unknown')
|
||||
self.assertEqual(history_card.tick_text(2500.0, 500, 'W'), '2.500')
|
||||
# Back in English, as before this app version.
|
||||
self.assertEqual(core.state_message(0, {'entity': 'screen.page_2', 'name': ''}, {})['name'], 'Go to page 2')
|
||||
self.assertEqual(header_bar.number_text(1234.5, 1), '1,234.5')
|
||||
self.assertEqual(header_bar.short_date(datetime(2026, 9, 15)), '15 Sep')
|
||||
|
||||
def test_a_timeline_s_other_states_take_the_screens_word(self):
|
||||
start, end = 0, 24 * 3600
|
||||
changes = [(i * 600, f'state_{i % 8}') for i in range(144)]
|
||||
with Language('addon.screen.'):
|
||||
i18n.set_screens('xx', 'point')
|
||||
message = history_card.timeline('sensor.status', 24, changes, start, end, timezone.utc, {})
|
||||
# Folded to the letters the screen draws, like every label of the card: the brackets go.
|
||||
self.assertEqual(message['states'][-1][0], 'xx Other')
|
||||
|
||||
|
||||
class Updates(unittest.IsolatedAsyncioTestCase):
|
||||
def updater(self, tmp, start):
|
||||
class HA:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
async def request(self, kind, **data):
|
||||
self.calls.append(data)
|
||||
class Firmware:
|
||||
def profile_names(self):
|
||||
return {'hall.yaml': {'node': 'hall', 'friendly': 'Hall'}}
|
||||
class Owner:
|
||||
def __init__(self):
|
||||
self.ha, self.firmware = HA(), Firmware()
|
||||
self.firmware.start = start
|
||||
def screen(self, inbox):
|
||||
return {'id': inbox, 'name': 'Hall', 'node': 'hall', 'online': True, 'ip': '10.0.0.5', 'firmware': '0.2.1'}
|
||||
def screens(self):
|
||||
return [self.screen('text.hall')]
|
||||
return updates.Updater(Owner(), Path(tmp) / 'updates.json')
|
||||
|
||||
async def test_a_result_keeps_its_key_and_shows_in_the_editor_s_language(self):
|
||||
def refuse(data):
|
||||
raise ValueError(t('addon.errors.firmware.no_esphome'))
|
||||
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
|
||||
updater = self.updater(tmp, refuse)
|
||||
# A round started from an editor in xx: the refusal is made in xx, and kept in English with its key.
|
||||
token = REQUEST_LANGUAGE.set('xx')
|
||||
try:
|
||||
self.assertEqual(await updater.update_one('text.hall'), 'failed')
|
||||
finally:
|
||||
REQUEST_LANGUAGE.reset(token)
|
||||
kept = json.loads((Path(tmp) / 'updates.json').read_text())['results']['text.hall']
|
||||
self.assertEqual((kept['message'], kept['key'], kept['params']),
|
||||
('The ESPHome CLI is missing from this installation.', 'addon.errors.firmware.no_esphome', {}))
|
||||
self.assertEqual(updater.state_for(updater.screen('text.hall'))['result']['message'],
|
||||
'The ESPHome CLI is missing from this installation.')
|
||||
token = REQUEST_LANGUAGE.set('xx')
|
||||
try:
|
||||
self.assertEqual(updater.state_for(updater.screen('text.hall'))['result']['message'],
|
||||
'[xx] The ESPHome CLI is missing from this installation.')
|
||||
updater.record('text.hall', 'success', english('addon.updates.updated', version='0.2.99'))
|
||||
self.assertEqual(updater.results['text.hall']['message'], 'Updated to firmware 0.2.99.')
|
||||
self.assertEqual(updater.state_for(updater.screen('text.hall'))['result']['message'], '[xx] Updated to firmware 0.2.99.')
|
||||
# A result an app before 0.2.90 kept has no key: it shows as it was kept.
|
||||
updater.results['text.hall'] = {'time': 0, 'state': 'failed', 'message': 'Build or install failed.', 'version': '0.2.70'}
|
||||
self.assertEqual(updater.state_for(updater.screen('text.hall'))['result']['message'], 'Build or install failed.')
|
||||
finally:
|
||||
REQUEST_LANGUAGE.reset(token)
|
||||
|
||||
async def test_the_notification_of_a_stopped_round_speaks_the_screens_language(self):
|
||||
def refuse(data):
|
||||
raise ValueError(t('addon.errors.firmware.no_esphome'))
|
||||
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
|
||||
updater = self.updater(tmp, refuse)
|
||||
i18n.set_screens('xx', 'point')
|
||||
await updater.run_round(['text.hall'], automatic=True)
|
||||
message = updater.manager.ha.calls[0]['service_data']['message']
|
||||
self.assertEqual(message, '[xx] Automatic update stopped at Hall: [xx] The ESPHome CLI is missing from this installation. '
|
||||
'The remaining screens were not touched.')
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Editor(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_editor_gets_the_app_s_texts_in_its_language(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
|
||||
manager = Manager(test_scaling.fake_ha(firmware=core.FIRMWARE_VERSION), Path(tmp) / 'screens.json')
|
||||
xx = {'X-ESP-Screens-Language': 'xx'}
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
full = await (await client.get('/api/inventory', headers=xx)).json()
|
||||
self.assertEqual(full['backgrounds']['red']['label'], '[xx] Red')
|
||||
self.assertEqual(full['controls']['light']['choices'][0]['label'], '[xx] On/off switch')
|
||||
self.assertEqual(full['alerts']['fields'][0]['label'], '[xx] Title')
|
||||
self.assertEqual(full['alerts']['fields'][0]['example'], '[xx] Someone is at the door')
|
||||
self.assertEqual(full['alerts']['fields'][2]['example'], 'doorbell', 'an icon name is typed as it is')
|
||||
self.assertEqual(full['icons']['groups'][0]['label'], '[xx] Lighting')
|
||||
self.assertEqual(full['icons']['groups'][0]['icons'][0]['label'], '[xx] Light bulb')
|
||||
self.assertEqual(full['header']['builtin'][0]['label'], '[xx] Time')
|
||||
self.assertEqual({item['id']: (item['name'], item['device']) for item in full['builtin']}['screen.page_3'],
|
||||
('[xx] Go to page 3', '[xx] Built into the screen'))
|
||||
self.assertEqual(full['screens'][0]['delivery'], '[xx] Choose your first tiles')
|
||||
csrf = full['csrf']
|
||||
# The delivery is kept in English and shown in the language of whoever looks.
|
||||
manager.save('text.screen', {'title': 'Office', 'tiles': [{'entity': 'light.a', 'name': ''}]})
|
||||
self.assertEqual(manager.status['text.screen'], 'Saved; waiting for sync')
|
||||
light = await (await client.get('/api/inventory?light=1', headers=xx)).json()
|
||||
self.assertEqual(light['screens'][0]['delivery'], '[xx] Saved; waiting for sync')
|
||||
light = await (await client.get('/api/inventory?light=1')).json()
|
||||
self.assertEqual(light['screens'][0]['delivery'], 'Saved; waiting for sync')
|
||||
# The live updates: an EventSource sends no headers, so the language comes in the address.
|
||||
async with client.get('/api/events?language=xx') as stream:
|
||||
line = await asyncio.wait_for(stream.content.readline(), 1)
|
||||
self.assertEqual(json.loads(line.decode().removeprefix('data: '))['screens'][0]['delivery'], '[xx] Saved; waiting for sync')
|
||||
refused =await client.put('/api/screens/text.gone', json={'title': 'T', 'tiles': []}, headers={**xx, 'X-Screen-CSRF': csrf})
|
||||
self.assertEqual((refused.status, (await refused.json())['error']),
|
||||
(400, "[xx] This isn't a paired ESP screen. Refresh the overview."))
|
||||
refused = await client.put('/api/screens/text.gone', json={'title': 'T', 'tiles': []}, headers={'X-Screen-CSRF': csrf})
|
||||
self.assertEqual((await refused.json())['error'], "This isn't a paired ESP screen. Refresh the overview.")
|
||||
forbidden = await client.put('/api/screens/text.screen', json={}, headers=xx)
|
||||
self.assertEqual((forbidden.status, await forbidden.text()), (403, '[xx] Refresh this page and try again.'))
|
||||
|
||||
async def test_identify_speaks_the_screens_language(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, Language('addon.screen.', 'screen.alert.'):
|
||||
ha = test_scaling.fake_ha(firmware=core.FIRMWARE_VERSION)
|
||||
ha.calls = []
|
||||
async def call(action, data):
|
||||
ha.calls.append((action, data))
|
||||
ha.call = call
|
||||
manager = Manager(ha, Path(tmp) / 'screens.json')
|
||||
i18n.set_screens('xx', 'point')
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
|
||||
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
|
||||
self.assertEqual(answer.status, 200)
|
||||
action, data = ha.calls[0]
|
||||
self.assertEqual((action, data['title'], data['subtitle'], data['button_text']),
|
||||
('esphome.office_1_show_alert', '[xx] This is Office 1', '[xx] Identify, from ESP Screens', '[xx] OK'))
|
||||
|
||||
async def test_a_tile_event_answers_home_assistant_in_english(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
|
||||
ha = test_scaling.fake_ha(firmware=core.FIRMWARE_VERSION)
|
||||
manager = Manager(ha, Path(tmp) / 'screens.json')
|
||||
i18n.set_screens('xx', 'point')
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
await manager.tile_event('esp_screens_add_tile', {'entity': 'foo.bar'})
|
||||
self.assertEqual(str(caught.exception), 'foo.bar cannot go on a screen.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -90,7 +90,8 @@ class TextTests(unittest.TestCase):
|
||||
('sensor.h', state('unavailable', unit_of_measurement='%'), ('—', None)),
|
||||
('binary_sensor.door', state('on', device_class='door'), ('Open', None)),
|
||||
('binary_sensor.door', state('off', device_class='door'), ('Closed', None)),
|
||||
('binary_sensor.smoke', state('on', device_class='smoke'), ('Smoke', None)),
|
||||
# Home Assistant's own words for the class (screen.ha.binary), as the screen's tiles say them (app 0.2.90).
|
||||
('binary_sensor.smoke', state('on', device_class='smoke'), ('Detected', None)),
|
||||
('binary_sensor.something', state('off'), ('Off', None)),
|
||||
('alarm_control_panel.house', state('armed_away'), ('Armed away', None)),
|
||||
('alarm_control_panel.house', state('disarmed'), ('Disarmed', None)),
|
||||
|
||||
@@ -246,7 +246,7 @@ class Messages(unittest.TestCase):
|
||||
message = hc.timeline('binary_sensor.hallway_motion', 168, changes, 0, end, AMSTERDAM, {'device_class': 'motion'})
|
||||
self.assertLess(time.monotonic() - began, 2.0)
|
||||
self.assertLessEqual(len(message['seg']), 96)
|
||||
self.assertEqual([(label, seconds) for label, _, seconds in message['states']], [('Motion', end // 2), ('No motion', end // 2)])
|
||||
self.assertEqual([(label, seconds) for label, _, seconds in message['states']], [('Detected', end // 2), ('Clear', end // 2)])
|
||||
self.assertEqual(message['began'], end // 60 - 1, 'the motion already on at the start did not begin in the range')
|
||||
self.assertLess(len(core.encode(message).encode()), 1024)
|
||||
|
||||
|
||||
@@ -95,11 +95,12 @@ class WordsBeyondTiles(unittest.TestCase):
|
||||
mode = {'state': 'vac_and_mop', 'attributes': {'options': ['vacuum', 'vac_and_mop']}}
|
||||
self.assertEqual(header_bar.value('select.s8_mode', mode, CLEANING, words=WORDS), ('Vacuum and mop', None))
|
||||
self.assertEqual(header_bar.value('select.s8_mode', mode, CLEANING), ('vac_and_mop', None), 'without words as before')
|
||||
# The bar's own short words stay until Max chooses: Returning, Auto, Motion.
|
||||
# The bar's own short words stay until Max chooses: Returning, Auto. A binary sensor says what its tile says, Home
|
||||
# Assistant's word for its class from the translations (screen.ha.binary, app 0.2.90).
|
||||
self.assertEqual(header_bar.value('vacuum.s8', {'state': 'returning', 'attributes': {}}, None, words=WORDS), ('Returning', None))
|
||||
self.assertEqual(header_bar.value('climate.hall', {'state': 'heat_cool', 'attributes': {}}, None, words=WORDS), ('Auto', None))
|
||||
motion = {'state': 'on', 'attributes': {'device_class': 'motion'}}
|
||||
self.assertEqual(header_bar.value('binary_sensor.hall', motion, None, words=WORDS), ('Motion', None))
|
||||
self.assertEqual(header_bar.value('binary_sensor.hall', motion, None, words=WORDS), ('Detected', None))
|
||||
layout = {'header': {'items': [{'type': 'entity', 'entity': 'select.s8_mode', 'content': 'state', 'icon': 'none', 'show': 'always'}]}}
|
||||
bar = header_bar.message(layout, {'select.s8_mode': mode}, {'select.s8_mode': CLEANING}, words=WORDS)
|
||||
self.assertEqual(bar['items'], [{'k': 'text', 't': 'Vacuum and mop'}])
|
||||
|
||||
Reference in New Issue
Block a user