Merge branch 'i18n-addon' into i18n

This commit is contained in:
Max Gramser
2026-09-19 15:07:29 +02:00
15 changed files with 1311 additions and 282 deletions
+2 -1
View File
@@ -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
View File
@@ -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'
+35 -32
View File
@@ -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):
+10 -9
View File
@@ -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):
+71 -49
View File
@@ -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))}
+9 -7
View File
@@ -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
+38 -4
View File
@@ -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):
+59 -48
View File
@@ -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
+12 -3
View File
@@ -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()},
+43 -21
View File
@@ -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):
+554 -1
View File
@@ -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": {
+324
View File
@@ -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()
+2 -1
View File
@@ -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)),
+1 -1
View File
@@ -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)
+3 -2
View File
@@ -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'}])