diff --git a/screen_manager/app/claude_skill.py b/screen_manager/app/claude_skill.py index 9372597..9ada14c 100644 --- a/screen_manager/app/claude_skill.py +++ b/screen_manager/app/claude_skill.py @@ -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} diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 04dda4d..b67b5b8 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -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_ 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_, 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 .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' diff --git a/screen_manager/app/firmware.py b/screen_manager/app/firmware.py index e396bff..ca2765c 100644 --- a/screen_manager/app/firmware.py +++ b/screen_manager/app/firmware.py @@ -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): diff --git a/screen_manager/app/ha_catalogue.py b/screen_manager/app/ha_catalogue.py index 6024712..686294d 100644 --- a/screen_manager/app/ha_catalogue.py +++ b/screen_manager/app/ha_catalogue.py @@ -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): diff --git a/screen_manager/app/header_bar.py b/screen_manager/app/header_bar.py index 46f0ea3..e2ccd27 100644 --- a/screen_manager/app/header_bar.py +++ b/screen_manager/app/header_bar.py @@ -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))} diff --git a/screen_manager/app/history_card.py b/screen_manager/app/history_card.py index d268572..33333da 100644 --- a/screen_manager/app/history_card.py +++ b/screen_manager/app/history_card.py @@ -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 diff --git a/screen_manager/app/i18n.py b/screen_manager/app/i18n.py index 0f368d9..bcdbb3b 100644 --- a/screen_manager/app/i18n.py +++ b/screen_manager/app/i18n.py @@ -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): diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 0fcb10d..a3fa2ca 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -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 diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py index d6e12c1..71ecac0 100644 --- a/screen_manager/app/tile_icons.py +++ b/screen_manager/app/tile_icons.py @@ -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()}, diff --git a/screen_manager/app/updates.py b/screen_manager/app/updates.py index e89bc09..86d0d39 100644 --- a/screen_manager/app/updates.py +++ b/screen_manager/app/updates.py @@ -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): diff --git a/screen_manager/translations/en.json b/screen_manager/translations/en.json index ce22627..a829ff0 100644 --- a/screen_manager/translations/en.json +++ b/screen_manager/translations/en.json @@ -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": { diff --git a/tests/test_addon_translations.py b/tests/test_addon_translations.py new file mode 100644 index 0000000..153601c --- /dev/null +++ b/tests/test_addon_translations.py @@ -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() diff --git a/tests/test_header_bar.py b/tests/test_header_bar.py index 1347c38..79418bf 100644 --- a/tests/test_header_bar.py +++ b/tests/test_header_bar.py @@ -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)), diff --git a/tests/test_history_card.py b/tests/test_history_card.py index 79899c7..21e810b 100644 --- a/tests/test_history_card.py +++ b/tests/test_history_card.py @@ -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) diff --git a/tests/test_state_words.py b/tests/test_state_words.py index d758191..25e8de4 100644 --- a/tests/test_state_words.py +++ b/tests/test_state_words.py @@ -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'}])