Merge branch 'i18n-addon' into i18n
This commit is contained in:
@@ -11,6 +11,7 @@ import os
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
from i18n import t
|
||||
import tile_icons
|
||||
from core import (ALERT_CAMERA_FIELD, ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
|
||||
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
|
||||
@@ -433,5 +434,5 @@ def install(directory):
|
||||
temp.write_text(text(), encoding='utf8')
|
||||
temp.replace(directory / 'SKILL.md')
|
||||
except OSError as error:
|
||||
raise ValueError(f"Couldn't write the skill to {directory} ({error.strerror or type(error).__name__}).") from error
|
||||
raise ValueError(t('addon.errors.skill_not_written', folder=directory, reason=error.strerror or type(error).__name__)) from error
|
||||
return {**status(directory), 'restart': restart}
|
||||
|
||||
+148
-103
@@ -7,10 +7,12 @@ import math
|
||||
import re
|
||||
import secrets
|
||||
|
||||
from i18n import english, screen_t, t
|
||||
import tile_icons
|
||||
|
||||
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen camera image'.split())
|
||||
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
|
||||
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them. The names in English: a screen gets them in
|
||||
# its language and the editor in its own (builtin_name, app 0.2.90).
|
||||
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings', **{f'screen.page_{n}': f'Go to page {n}' for n in range(1, 9)}}
|
||||
# A navigation tile (firmware 0.2.62+): screen.page_<n> goes to page n. Firmware 0.2.65+ takes the same one on several
|
||||
# pages (a "Back to page 1" on every page); every other entity still appears once on a screen.
|
||||
@@ -20,6 +22,14 @@ PAGE_TILE_REPEAT_MIN_FIRMWARE = (0, 2, 65)
|
||||
def page_target(entity):
|
||||
"""The page a navigation tile opens, counted from one; 0 for any other entity."""
|
||||
return int(entity[len(PAGE_TILE):]) if isinstance(entity, str) and entity in BUILTIN and entity.startswith(PAGE_TILE) else 0
|
||||
|
||||
def builtin_name(entity, text=screen_t):
|
||||
"""A built-in card's name in the screens' language, for the tile on the screen; `text=t` gives the editor's."""
|
||||
page = page_target(entity)
|
||||
if page:
|
||||
return text('addon.screen.builtin.page', page=page)
|
||||
# The settings tile is named like the settings page it opens.
|
||||
return text('screen.settings.title' if entity == 'screen.settings' else 'addon.screen.builtin.clock')
|
||||
NEW_DOMAINS = frozenset('sun timer person screen'.split())
|
||||
# A camera or an image entity opens full screen on a Guition with firmware 0.2.57+ (camera_feed.py).
|
||||
CAMERA_DOMAINS = frozenset(('camera', 'image'))
|
||||
@@ -33,7 +43,6 @@ FULL_PAGE_MIN_FIRMWARE = (0, 2, 62)
|
||||
# Twenty tiles from firmware 0.2.7, ten before.
|
||||
TWENTY_TILES_MIN_FIRMWARE = (0, 2, 7)
|
||||
FIRST_MAX_TILES = 10
|
||||
WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
# Firmware shipped with this app release; screens below it get an update offer.
|
||||
@@ -55,6 +64,7 @@ ATTRS = frozenset('brightness percentage current_position current_tilt_position
|
||||
BOOL_ATTRS = frozenset(['is_volume_muted'])
|
||||
|
||||
|
||||
# The labels in English, as the Claude skill writes them; the editor gets them in its language (backgrounds()).
|
||||
TILE_BACKGROUNDS = {
|
||||
'auto': {'label': 'Default', 'color': None},
|
||||
# No card behind the tile: contents keep their size and place on the screen background.
|
||||
@@ -70,6 +80,10 @@ TILE_BACKGROUNDS = {
|
||||
'gray': {'label': 'Gray', 'color': '#E5E7EB'},
|
||||
}
|
||||
|
||||
def backgrounds():
|
||||
"""TILE_BACKGROUNDS with their labels in the editor's language (app 0.2.90)."""
|
||||
return {name: {**item, 'label': t(f'addon.labels.backgrounds.{name}')} for name, item in TILE_BACKGROUNDS.items()}
|
||||
|
||||
# Display modes per domain; everything else offers standard and watch (large value).
|
||||
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog'), 'sun': ('standard', 'watch', 'sunpath')}
|
||||
# Displays that only work on a double-width card.
|
||||
@@ -128,7 +142,7 @@ def packed_slots(tiles):
|
||||
0.2.78; the save used to fail later with "Invalid tile position")."""
|
||||
slots = pack_slots(tiles)
|
||||
if any(footprint(slot, tile_size(tile))[-1] >= MAX_SLOTS for tile, slot in zip(tiles, slots)):
|
||||
raise ValueError("These tiles don't fit on eight pages; remove a tile or make one smaller.")
|
||||
raise ValueError(t('addon.errors.layout.eight_pages'))
|
||||
return slots
|
||||
|
||||
def has_gaps(tiles):
|
||||
@@ -138,7 +152,8 @@ def has_gaps(tiles):
|
||||
|
||||
# Direct controls on the right half of a double-width card (firmware 0.2.19+), like
|
||||
# Home Assistant's own entity rows. The first choice is what a wide card shows
|
||||
# when the tile has no explicit choice; 'none' keeps the plain card.
|
||||
# when the tile has no explicit choice; 'none' keeps the plain card. The labels in English,
|
||||
# as the Claude skill writes them; the editor gets them in its language (controls_catalogue).
|
||||
CONTROLS = {
|
||||
'climate': (('setpoint', 'Temperature − / +'), ('mode', 'Off, heat, cool')),
|
||||
'switch': (('toggle', 'On/off switch'),),
|
||||
@@ -160,8 +175,9 @@ CONTROLS = {
|
||||
}
|
||||
|
||||
def controls_catalogue():
|
||||
"""Editor choices per domain: the default first, then 'none'."""
|
||||
return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': label} for key, label in choices] + [{'key': 'none', 'label': 'None'}]}
|
||||
"""Editor choices per domain: the default first, then 'none'; labels in the editor's language (app 0.2.90)."""
|
||||
return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': t(f'addon.labels.controls.{domain}.{key}')} for key, _ in choices]
|
||||
+ [{'key': 'none', 'label': t('addon.labels.controls.none')}]}
|
||||
for domain, choices in CONTROLS.items()}
|
||||
|
||||
def resolve_controls(tile):
|
||||
@@ -374,7 +390,7 @@ def setting_action(key, entity, value):
|
||||
|
||||
def validate_settings(data):
|
||||
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
|
||||
raise ValueError('Unknown screen settings; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.settings.unknown'))
|
||||
clean = {}
|
||||
for key, (default, minimum, maximum) in SETTING_RULES.items():
|
||||
value = data.get(key, default)
|
||||
@@ -384,10 +400,10 @@ def validate_settings(data):
|
||||
valid = type(value) is int and minimum <= value <= maximum
|
||||
if key == "rotation": valid = valid and value in (0, 90, 180, 270)
|
||||
if not valid:
|
||||
raise ValueError(f'Invalid value for {key}.')
|
||||
raise ValueError(t('addon.errors.settings.invalid_value', setting=key))
|
||||
clean[key] = value
|
||||
if max(clean['standby_brightness'], clean['night_brightness']) > clean['brightness']:
|
||||
raise ValueError('Standby and night brightness may not be higher than normal.')
|
||||
raise ValueError(t('addon.errors.settings.dim_above_normal'))
|
||||
return clean
|
||||
|
||||
def entity_id(value):
|
||||
@@ -404,8 +420,9 @@ HEADER_MIN_FIRMWARE = (0, 2, 32)
|
||||
TRANSPORT_MIN_FIRMWARE = (0, 2, 33)
|
||||
MESSAGE_ACTION = 'screen_message'
|
||||
HEADER_MAX_ITEMS = 6
|
||||
# Items the screen draws on its own clock, without Home Assistant.
|
||||
HEADER_BUILTIN = {'clock': 'Time', 'analog': 'Analog clock', 'date': 'Date'}
|
||||
# Items the screen draws on its own clock, without Home Assistant; their labels are in the translations (header_bar.catalogue,
|
||||
# app 0.2.90).
|
||||
HEADER_BUILTIN = ('clock', 'analog', 'date')
|
||||
# Only shown, never controlled: the top bar takes these besides every tile domain.
|
||||
HEADER_ONLY_DOMAINS = frozenset('device_tracker zone lock alarm_control_panel counter event input_datetime input_text water_heater humidifier'.split())
|
||||
HEADER_CONTENTS = ('state', 'last_changed')
|
||||
@@ -423,32 +440,32 @@ def header_items(layout):
|
||||
|
||||
def validate_header(data):
|
||||
if not isinstance(data, dict) or set(data) - {'items'} or not isinstance(data.get('items'), list):
|
||||
raise ValueError('Invalid top bar; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.top_bar.invalid'))
|
||||
if len(data['items']) > HEADER_MAX_ITEMS:
|
||||
raise ValueError(f'The top bar has room for at most {HEADER_MAX_ITEMS} items.')
|
||||
raise ValueError(t('addon.errors.top_bar.full', n=HEADER_MAX_ITEMS))
|
||||
items, seen = [], set()
|
||||
for item in data['items']:
|
||||
kind = item.get('type') if isinstance(item, dict) else None
|
||||
if kind in HEADER_BUILTIN:
|
||||
if set(item) != {'type'}:
|
||||
raise ValueError('Invalid setting in the top bar.')
|
||||
raise ValueError(t('addon.errors.top_bar.invalid_setting'))
|
||||
clean = {'type': kind}
|
||||
elif kind == 'entity':
|
||||
if set(item) - {'type', 'entity', 'content', 'icon', 'show'}:
|
||||
raise ValueError('Unknown setting in the top bar; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.top_bar.unknown_setting'))
|
||||
if not header_entity(item.get('entity')):
|
||||
raise ValueError("This entity can't go in the top bar.")
|
||||
raise ValueError(t('addon.errors.top_bar.entity'))
|
||||
clean = {'type': 'entity', 'entity': item['entity'], 'content': item.get('content', 'state'),
|
||||
'icon': item.get('icon', 'auto'), 'show': item.get('show', 'always')}
|
||||
if clean['content'] not in HEADER_CONTENTS or clean['show'] not in HEADER_SHOWS:
|
||||
raise ValueError('Invalid setting in the top bar.')
|
||||
raise ValueError(t('addon.errors.top_bar.invalid_setting'))
|
||||
if not (clean['icon'] in ('auto', 'none') or isinstance(clean['icon'], str) and clean['icon'] in tile_icons.ICONS):
|
||||
raise ValueError('Choose an icon from the list.')
|
||||
raise ValueError(t('addon.errors.choose_icon'))
|
||||
else:
|
||||
raise ValueError('Unknown item in the top bar; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.top_bar.unknown_item'))
|
||||
key = json.dumps(clean, sort_keys=True)
|
||||
if key in seen:
|
||||
raise ValueError('This item is already in the top bar.')
|
||||
raise ValueError(t('addon.errors.top_bar.twice'))
|
||||
seen.add(key)
|
||||
items.append(clean)
|
||||
return {'items': items}
|
||||
@@ -503,20 +520,20 @@ def loose(text):
|
||||
def match_screen(screens, wanted, layouts=None):
|
||||
"""The screen an event means: by its device name, the name Home Assistant shows, or its title.
|
||||
With one screen paired, an event doesn't have to name it."""
|
||||
listing = ', '.join(sorted(screen['name'] for screen in screens)) or 'none yet'
|
||||
listing = ', '.join(sorted(screen['name'] for screen in screens)) or t('addon.errors.events.none_paired')
|
||||
if not loose(wanted):
|
||||
if len(screens) == 1:
|
||||
return screens[0]
|
||||
raise ValueError(f'Name the screen. Paired: {listing}.')
|
||||
raise ValueError(t('addon.errors.events.name_the_screen', screens=listing))
|
||||
key = loose(wanted)
|
||||
def names(screen):
|
||||
title = (layouts or {}).get(screen['id'], {}).get('title', '')
|
||||
return [loose(value) for value in (screen.get('node'), screen.get('name'), screen.get('device'), title, screen.get('area')) if value]
|
||||
found = [s for s in screens if key in names(s)] or [s for s in screens if any(key in name for name in names(s))]
|
||||
if not found:
|
||||
raise ValueError(f'No screen called "{wanted}". Paired: {listing}.')
|
||||
raise ValueError(t('addon.errors.events.no_such_screen', name=wanted, screens=listing))
|
||||
if len(found) > 1:
|
||||
raise ValueError(f'"{wanted}" fits more than one screen: ' + ', '.join(sorted(s['name'] for s in found)) + '.')
|
||||
raise ValueError(t('addon.errors.events.several_screens', name=wanted, screens=', '.join(sorted(s['name'] for s in found))))
|
||||
return found[0]
|
||||
|
||||
def occupied_cells(tiles, skip=None):
|
||||
@@ -553,15 +570,15 @@ def place_tile(tile, tiles, page=None, slot=None):
|
||||
wanted = set(footprint(slot, size))
|
||||
taken = next((t for t in tiles if t is not tile and 'slot' in t and wanted & set(footprint(t['slot'], tile_size(t)))), None)
|
||||
if taken:
|
||||
raise ValueError(f"That spot is taken by {taken['entity']}; give another spot or move that one first.")
|
||||
raise ValueError(t('addon.errors.events.spot_taken', entity=taken['entity']))
|
||||
tile['slot'] = slot
|
||||
return
|
||||
free = free_slot(tiles, size, page, skip=tile)
|
||||
if free is None:
|
||||
if size == 'full':
|
||||
raise ValueError(f'Page {page + 1} is not empty; a full-page tile needs a page of its own.' if page is not None
|
||||
else 'No page is empty; a full-page tile needs a page of its own.')
|
||||
raise ValueError(f'Page {page + 1} is full.' if page is not None else 'This screen has no room left.')
|
||||
raise ValueError(t('addon.errors.events.page_not_empty', page=page + 1) if page is not None
|
||||
else t('addon.errors.events.no_empty_page'))
|
||||
raise ValueError(t('addon.errors.events.page_full', page=page + 1) if page is not None else t('addon.errors.events.no_room'))
|
||||
tile['slot'] = free
|
||||
|
||||
def tile_options(data, current=None):
|
||||
@@ -593,25 +610,25 @@ def event_page(data):
|
||||
if page in (None, ''):
|
||||
return None
|
||||
if not str(page).strip().isdigit() or not 1 <= int(page) <= MAX_PAGES:
|
||||
raise ValueError(f'Choose a page between 1 and {MAX_PAGES}.')
|
||||
raise ValueError(t('addon.errors.events.page_range', last=MAX_PAGES))
|
||||
return int(page) - 1
|
||||
|
||||
def event_slot(data, page):
|
||||
"""An exact spot: `slot` as the editor counts it, or `row` and `column` within a page."""
|
||||
if data.get('slot') not in (None, ''):
|
||||
if not str(data['slot']).strip().isdigit() or not 0 <= int(data['slot']) < MAX_SLOTS:
|
||||
raise ValueError(f'Choose a spot between 0 and {MAX_SLOTS - 1}.')
|
||||
raise ValueError(t('addon.errors.events.spot_range', last=MAX_SLOTS - 1))
|
||||
return int(data['slot'])
|
||||
if data.get('row') in (None, '') and data.get('column') in (None, ''):
|
||||
return None
|
||||
if page is None:
|
||||
raise ValueError('Name the page for that row or column.')
|
||||
raise ValueError(t('addon.errors.events.row_needs_page'))
|
||||
row = str(data.get('row', 1)).strip()
|
||||
if not row.isdigit() or not 1 <= int(row) <= SLOTS_PER_PAGE // 2:
|
||||
raise ValueError(f'Choose a row between 1 and {SLOTS_PER_PAGE // 2}.')
|
||||
raise ValueError(t('addon.errors.events.row_range', last=SLOTS_PER_PAGE // 2))
|
||||
column = loose(data.get('column') or 'left')
|
||||
if column not in ('left', 'right'):
|
||||
raise ValueError('The column is left or right.')
|
||||
raise ValueError(t('addon.errors.events.column'))
|
||||
return page * SLOTS_PER_PAGE + (int(row) - 1) * 2 + (1 if column == 'right' else 0)
|
||||
|
||||
def page_of(tile):
|
||||
@@ -636,9 +653,11 @@ def event_source(data):
|
||||
slot = event_slot({'slot': data.get('from_slot')}, None)
|
||||
return (slot // SLOTS_PER_PAGE if slot is not None else page), slot
|
||||
|
||||
def where(page, slot):
|
||||
"""The place an event named, as its answer says it: "spot 12" or "page 3"."""
|
||||
return f'spot {slot}' if slot is not None else f'page {page + 1}'
|
||||
def not_there(entity, page, slot):
|
||||
"""The answer to an event that named a place where the tile isn't: "... is not on spot 12" or "... on page 3"."""
|
||||
if slot is not None:
|
||||
return t('addon.errors.events.not_on_spot', entity=entity, slot=slot)
|
||||
return t('addon.errors.events.not_on_page', entity=entity, page=page + 1)
|
||||
|
||||
def pack_page(tiles, page):
|
||||
"""Give these tiles the cells of one page, in the order they are in."""
|
||||
@@ -647,11 +666,11 @@ def pack_page(tiles, page):
|
||||
for tile in tiles:
|
||||
size = tile_size(tile)
|
||||
if size == 'full' and (position != page * SLOTS_PER_PAGE or len(tiles) > 1):
|
||||
raise ValueError(f'A full-page tile takes all of page {page + 1}; nothing else fits there.')
|
||||
raise ValueError(t('addon.errors.events.full_page_alone', page=page + 1))
|
||||
if size == 'wide' and position % 2:
|
||||
position += 1
|
||||
if position + cells_of(size) > last:
|
||||
raise ValueError(f'That does not fit on page {page + 1}; a double-width tile takes two spots.')
|
||||
raise ValueError(t('addon.errors.events.does_not_fit', page=page + 1))
|
||||
tile['slot'] = position
|
||||
position += cells_of(size)
|
||||
|
||||
@@ -676,7 +695,7 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
wanted = [part.strip() for part in wanted.split(',')]
|
||||
wanted = [str(item).strip() for item in wanted if str(item).strip()]
|
||||
if not wanted:
|
||||
raise ValueError('Give the entities in the order you want them.')
|
||||
raise ValueError(t('addon.errors.events.order_needed'))
|
||||
picked, taken, missing, elsewhere, extra = [], set(), [], [], []
|
||||
for name in wanted:
|
||||
copies = copies_of(tiles, name)
|
||||
@@ -692,12 +711,13 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
picked.append(free[0])
|
||||
taken.add(id(free[0]))
|
||||
if missing:
|
||||
raise ValueError('Not on this screen: ' + ', '.join(missing) + '.')
|
||||
raise ValueError(t('addon.errors.events.order_not_on_screen', entities=', '.join(missing)))
|
||||
if elsewhere:
|
||||
raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.join(elsewhere)))
|
||||
raise ValueError(t('addon.errors.events.order_not_on_page', page=page + 1, entities=', '.join(elsewhere)))
|
||||
if extra:
|
||||
raise ValueError('Named more often than it is on %s: %s.' % ('this screen' if page is None else f'page {page + 1}',
|
||||
', '.join(dict.fromkeys(extra))))
|
||||
extra = ', '.join(dict.fromkeys(extra))
|
||||
raise ValueError(t('addon.errors.events.named_too_often', entities=extra) if page is None
|
||||
else t('addon.errors.events.named_too_often_page', page=page + 1, entities=extra))
|
||||
if page is None:
|
||||
tiles = picked + [tile for tile in tiles if id(tile) not in taken]
|
||||
for tile, cell in zip(tiles, packed_slots(tiles)):
|
||||
@@ -707,7 +727,7 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
result['tiles'] = tiles
|
||||
return result, None
|
||||
if not entity:
|
||||
raise ValueError('Name the entity.')
|
||||
raise ValueError(t('addon.errors.events.name_the_entity'))
|
||||
copies = copies_of(tiles, entity)
|
||||
found = copies[0] if copies else None
|
||||
if action == 'remove':
|
||||
@@ -715,26 +735,26 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
if source_page is None:
|
||||
source_page, source_slot = page, slot
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on this screen.')
|
||||
raise ValueError(t('addon.errors.events.not_on_screen', entity=entity))
|
||||
found = pick_copy(copies, source_page, source_slot)
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on {where(source_page, source_slot)}.')
|
||||
raise ValueError(not_there(entity, source_page, source_slot))
|
||||
# The copy that was named itself, not the first tile equal to it.
|
||||
tiles = [tile for tile in tiles if tile is not found]
|
||||
elif action == 'move':
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on this screen; add it first.')
|
||||
raise ValueError(t('addon.errors.events.add_it_first', entity=entity))
|
||||
source_page, source_slot = event_source(data)
|
||||
found = pick_copy(copies, source_page, source_slot)
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on {where(source_page, source_slot)}.')
|
||||
raise ValueError(not_there(entity, source_page, source_slot))
|
||||
if page is None and slot is None:
|
||||
raise ValueError('Name the page or the spot to move it to.')
|
||||
raise ValueError(t('addon.errors.events.move_where'))
|
||||
found.pop('slot', None)
|
||||
place_tile(found, tiles, page, slot)
|
||||
else:
|
||||
if not entity_id(entity) and entity not in BUILTIN:
|
||||
raise ValueError(f'{entity} cannot go on a screen.')
|
||||
raise ValueError(t('addon.errors.events.not_for_a_screen', entity=entity))
|
||||
# A navigation tile the firmware takes more than once: the copy on the named spot or page changes, and anywhere
|
||||
# else a new copy goes (app 0.2.78). Every other tile, or one without a place, is the one that is there.
|
||||
chosen = False
|
||||
@@ -752,7 +772,7 @@ def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
tile.pop('options', None)
|
||||
if found is None:
|
||||
if len(tiles) >= MAX_TILES:
|
||||
raise ValueError(f'This screen already has {MAX_TILES} tiles; remove one first.')
|
||||
raise ValueError(t('addon.errors.events.screen_full', n=MAX_TILES))
|
||||
tiles.append(tile)
|
||||
size = tile_size(tile)
|
||||
move = found is None or had_slot is None or (not chosen and (page is not None or slot is not None))
|
||||
@@ -824,45 +844,45 @@ def action_for_screen(value):
|
||||
def validate_tap_action(value):
|
||||
"""The stored form of a tap's own action; ValueError with what to change."""
|
||||
if not isinstance(value, dict) or set(value) - {'action', 'data'}:
|
||||
raise ValueError('Choose an action from the list.')
|
||||
raise ValueError(t('addon.errors.tap_action.choose'))
|
||||
name, data = value.get('action'), value.get('data', {})
|
||||
if not isinstance(name, str) or len(name) > 64 or not ACTION_NAME.fullmatch(name):
|
||||
raise ValueError('Choose an action from the list.')
|
||||
raise ValueError(t('addon.errors.tap_action.choose'))
|
||||
if not isinstance(data, dict) or len(data) > ACTION_MAX_FIELDS:
|
||||
raise ValueError(f'An action on a tap sets at most {ACTION_MAX_FIELDS} fields.')
|
||||
raise ValueError(t('addon.errors.tap_action.fields_max', n=ACTION_MAX_FIELDS))
|
||||
for key in data:
|
||||
if not isinstance(key, str) or not ACTION_FIELD.fullmatch(key) or key in TARGET_KEYS:
|
||||
raise ValueError(f'{key} is not a field an action on a tap can set.')
|
||||
raise ValueError(t('addon.errors.tap_action.field_not_allowed', field=key))
|
||||
clean = {'action': name, **({'data': dict(data)} if data else {})}
|
||||
try:
|
||||
act = action_for_screen(clean)
|
||||
size = len(json.dumps(act, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode())
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('A value of the action is something the screen cannot send.') from None
|
||||
raise ValueError(t('addon.errors.tap_action.unsendable')) from None
|
||||
if size > ACTION_MAX_BYTES or any(len(item[1].encode()) > ACTION_MAX_VALUE for item in act.get('d', []) + act.get('t', [])):
|
||||
raise ValueError('The values of the action are too long for the screen.')
|
||||
raise ValueError(t('addon.errors.tap_action.too_long'))
|
||||
return clean
|
||||
|
||||
def validate_layout(data, stored=False):
|
||||
"""A layout as the editor, a tile event or the storage gives it. `stored`: loaded from the app's own data, where a
|
||||
tile setting this version doesn't know (saved by a newer app) stays as it is instead of stopping the app."""
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Invalid layout.')
|
||||
raise ValueError(t('addon.errors.layout.invalid'))
|
||||
title, tiles = data.get('title'), data.get('tiles')
|
||||
if not isinstance(title, str) or not title.strip() or len(title.encode()) > 96:
|
||||
raise ValueError('Give the screen a title of at most 96 bytes.')
|
||||
raise ValueError(t('addon.errors.layout.title'))
|
||||
if not isinstance(tiles, list) or len(tiles) > MAX_TILES:
|
||||
raise ValueError(f'Choose at most {MAX_TILES} tiles.')
|
||||
raise ValueError(t('addon.errors.layout.tiles_max', n=MAX_TILES))
|
||||
clean, seen = [], set()
|
||||
for tile in tiles:
|
||||
if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
|
||||
raise ValueError("This entity isn't supported.")
|
||||
raise ValueError(t('addon.errors.layout.unsupported'))
|
||||
# A navigation tile may go on several pages (min_firmware asks 0.2.65 for that); anything else appears once.
|
||||
if tile['entity'] in seen and not page_target(tile['entity']):
|
||||
raise ValueError('An entity can only appear once on a screen.')
|
||||
raise ValueError(t('addon.errors.layout.once'))
|
||||
name = tile.get('name', '')
|
||||
if not isinstance(name, str) or len(name.encode()) > 80:
|
||||
raise ValueError('A tile name may contain at most 80 bytes.')
|
||||
raise ValueError(t('addon.errors.layout.tile_name'))
|
||||
seen.add(tile['entity'])
|
||||
item = {'entity': tile['entity'], 'name': name.strip()}
|
||||
if stored and 'options' in tile:
|
||||
@@ -876,46 +896,46 @@ def validate_layout(data, stored=False):
|
||||
if 'options' in tile:
|
||||
options = tile['options']
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls', 'action'}:
|
||||
raise ValueError('Unknown tile settings.')
|
||||
raise ValueError(t('addon.errors.layout.unknown_settings'))
|
||||
# A navigation tile (screen.page_<n>, firmware 0.2.62+) has a name, an icon, a colour and a width; never the page.
|
||||
if page_target(tile['entity']):
|
||||
if options.get('size') == 'full':
|
||||
raise ValueError('A navigation tile is single or double width.')
|
||||
raise ValueError(t('addon.errors.layout.navigation_size'))
|
||||
options = {k: v for k, v in options.items() if k not in ('display', 'inline', 'controls', 'history_hours')}
|
||||
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
|
||||
raise ValueError('Choose a pastel background color from the palette.')
|
||||
raise ValueError(t('addon.errors.layout.background'))
|
||||
if 'icon' in options and not (options['icon'] == 'auto' or isinstance(options['icon'], str) and options['icon'] in tile_icons.ICONS):
|
||||
raise ValueError('Choose an icon from the list.')
|
||||
raise ValueError(t('addon.errors.choose_icon'))
|
||||
domain = tile['entity'].split('.')[0]
|
||||
displays = DISPLAYS.get(domain, ('standard', 'watch'))
|
||||
choices = {'tap': ('auto', 'detail', 'toggle', 'none', 'action'), 'display': displays, 'inline': ('none', 'slider'), 'size': TILE_SIZES_ON_SCREEN}
|
||||
for key, allowed in choices.items():
|
||||
if key in options and options[key] not in allowed:
|
||||
raise ValueError('Invalid tile setting: ' + key)
|
||||
raise ValueError(t('addon.errors.layout.invalid_setting', setting=key))
|
||||
# The five-day strip and the sun path only fit a double-width card (or the whole page).
|
||||
if options.get('display') in WIDE_ONLY and options.get('size') != 'full':
|
||||
options = {**options, 'size': 'wide'}
|
||||
# On / off sends <domain>.toggle. Whether Home Assistant offers that for the entity is checked when saving
|
||||
# (Manager.check_supported, app 0.2.67); the built-in cards have nothing to switch.
|
||||
if options.get('tap') == 'toggle' and domain == 'screen':
|
||||
raise ValueError("This entity doesn't support an on/off action.")
|
||||
raise ValueError(t('addon.errors.layout.no_toggle'))
|
||||
# Perform action (app 0.2.67) keeps its action; another tap choice leaves a stale one behind.
|
||||
if options.get('tap') == 'action':
|
||||
if domain == 'screen':
|
||||
raise ValueError("A built-in card can't perform an action.")
|
||||
raise ValueError(t('addon.errors.layout.builtin_action'))
|
||||
options = {**options, 'action': validate_tap_action(options.get('action'))}
|
||||
elif 'action' in options:
|
||||
options = {key: value for key, value in options.items() if key != 'action'}
|
||||
if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}:
|
||||
raise ValueError("This entity doesn't support a mini-slider.")
|
||||
raise ValueError(t('addon.errors.layout.no_mini_slider'))
|
||||
if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)):
|
||||
raise ValueError('History: choose 1, 6, or 24 hours.')
|
||||
raise ValueError(t('addon.errors.layout.history_hours'))
|
||||
if options.get('display') == 'watch' and options.get('inline') == 'slider':
|
||||
raise ValueError('Choose large value or mini-slider.')
|
||||
raise ValueError(t('addon.errors.layout.watch_or_slider'))
|
||||
if 'controls' in options:
|
||||
allowed = ('none',) + tuple(key for key, _ in CONTROLS.get(domain, ()))
|
||||
if not isinstance(options['controls'], str) or options['controls'] not in allowed:
|
||||
raise ValueError("This entity doesn't support that direct control.")
|
||||
raise ValueError(t('addon.errors.layout.no_direct_control'))
|
||||
item['options'] = dict(options)
|
||||
clean.append(item)
|
||||
# Positions: every tile or none (an older editor sends none and keeps its order).
|
||||
@@ -924,15 +944,15 @@ def validate_layout(data, stored=False):
|
||||
occupied = set()
|
||||
for item, slot in zip(clean, given):
|
||||
if type(slot) is not int or not 0 <= slot < MAX_SLOTS:
|
||||
raise ValueError('Invalid tile position; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.layout.position'))
|
||||
size = tile_size(item)
|
||||
if size == 'full' and slot % SLOTS_PER_PAGE:
|
||||
raise ValueError('A full-page tile starts at the top of its page.')
|
||||
raise ValueError(t('addon.errors.layout.full_page_top'))
|
||||
if size == 'wide' and slot % 2:
|
||||
raise ValueError('A double-width tile starts in the left column.')
|
||||
raise ValueError(t('addon.errors.layout.wide_left'))
|
||||
for cell in footprint(slot, size):
|
||||
if cell in occupied:
|
||||
raise ValueError('Two tiles are in the same spot.')
|
||||
raise ValueError(t('addon.errors.layout.same_spot'))
|
||||
occupied.add(cell)
|
||||
item['slot'] = slot
|
||||
clean.sort(key=lambda item: item['slot'])
|
||||
@@ -943,7 +963,7 @@ def validate_layout(data, stored=False):
|
||||
# Pages kept on purpose, empty ones included; the screen shows at least what the tiles need.
|
||||
if 'pages' in data:
|
||||
if type(data['pages']) is not int or not 1 <= data['pages'] <= MAX_PAGES:
|
||||
raise ValueError(f'A screen has 1 to {MAX_PAGES} pages.')
|
||||
raise ValueError(t('addon.errors.layout.pages', n=MAX_PAGES))
|
||||
result['pages'] = data['pages']
|
||||
if 'settings' in data:
|
||||
result['settings'] = validate_settings(data['settings'])
|
||||
@@ -997,15 +1017,21 @@ def epoch(value):
|
||||
# battery comes from the device's battery sensor.
|
||||
VACUUM_MODE_KEYS = ('cleaning_mode', 'work_mode', 'clean_mode')
|
||||
VACUUM_WATER_KEYS = ('mop_intensity', 'water_flow', 'water_amount', 'water_flow_level', 'water_volume', 'mop_water_level')
|
||||
# Short chip labels; anything else shows its own words.
|
||||
# Short chip labels, by the key of their words in the screens' language (app 0.2.90); anything else shows its own words.
|
||||
# The suction speeds the screen names itself, and Off, are the screen's own words.
|
||||
VACUUM_CHIP = 'addon.screen.vacuum.'
|
||||
VACUUM_LABELS = {
|
||||
'vacuum': 'Vacuum', 'sweeping': 'Vacuum', 'vac_and_mop': 'Vac & mop', 'vacuum_and_mop': 'Vac & mop',
|
||||
'sweeping_and_mopping': 'Vac & mop', 'mop_after_vacuum': 'Vac, then mop', 'mopping_after_sweeping': 'Vac, then mop',
|
||||
'mop': 'Mop', 'mopping': 'Mop', 'custom': 'Custom', 'smart_mode': 'Smart',
|
||||
'off': 'Off', 'min': 'Min', 'slight': 'Slight', 'low': 'Low', 'mild': 'Mild', 'medium': 'Medium', 'moderate': 'Moderate',
|
||||
'standard': 'Standard', 'high': 'High', 'intense': 'Intense', 'extreme': 'Extreme', 'ultrahigh': 'Ultra high',
|
||||
'quiet': 'Quiet', 'silent': 'Silent', 'gentle': 'Gentle', 'balanced': 'Normal', 'turbo': 'Turbo', 'strong': 'Strong',
|
||||
'max': 'Max', 'max_plus': 'Max+', 'auto': 'Auto',
|
||||
'vacuum': VACUUM_CHIP + 'vacuum', 'sweeping': VACUUM_CHIP + 'vacuum', 'vac_and_mop': VACUUM_CHIP + 'vac_and_mop',
|
||||
'vacuum_and_mop': VACUUM_CHIP + 'vac_and_mop', 'sweeping_and_mopping': VACUUM_CHIP + 'vac_and_mop',
|
||||
'mop_after_vacuum': VACUUM_CHIP + 'vac_then_mop', 'mopping_after_sweeping': VACUUM_CHIP + 'vac_then_mop',
|
||||
'mop': VACUUM_CHIP + 'mop', 'mopping': VACUUM_CHIP + 'mop', 'custom': VACUUM_CHIP + 'custom', 'smart_mode': VACUUM_CHIP + 'smart',
|
||||
'off': 'screen.ha.off', 'min': VACUUM_CHIP + 'min', 'slight': VACUUM_CHIP + 'slight', 'low': VACUUM_CHIP + 'low',
|
||||
'mild': VACUUM_CHIP + 'mild', 'medium': VACUUM_CHIP + 'medium', 'moderate': VACUUM_CHIP + 'moderate',
|
||||
'standard': VACUUM_CHIP + 'standard', 'high': VACUUM_CHIP + 'high', 'intense': VACUUM_CHIP + 'intense',
|
||||
'extreme': VACUUM_CHIP + 'extreme', 'ultrahigh': VACUUM_CHIP + 'ultra_high', 'quiet': 'screen.vacuum.speed_quiet',
|
||||
'silent': VACUUM_CHIP + 'silent', 'gentle': VACUUM_CHIP + 'gentle', 'balanced': 'screen.vacuum.speed_balanced',
|
||||
'turbo': 'screen.vacuum.speed_turbo', 'strong': VACUUM_CHIP + 'strong', 'max': 'screen.vacuum.speed_max',
|
||||
'max_plus': VACUUM_CHIP + 'max_plus', 'auto': VACUUM_CHIP + 'auto',
|
||||
}
|
||||
# What a cleaning mode does, one letter per option for the firmware: v vacuum only, m mop only, b both,
|
||||
# a automatic (the robot or its app picks suction and water). Unknown modes count as both.
|
||||
@@ -1016,7 +1042,8 @@ MODE_COVERED = frozenset(('off', 'off_raise_main_brush', 'custom', 'custom_water
|
||||
VACUUM_CHOICES = 6
|
||||
|
||||
def vacuum_label(value):
|
||||
return short(VACUUM_LABELS.get(value) or str(value).replace('_', ' ').capitalize(), 24)
|
||||
key = VACUUM_LABELS.get(value)
|
||||
return short(screen_t(key) if key else str(value).replace('_', ' ').capitalize(), 24)
|
||||
|
||||
def vacuum_related(entity, device, states):
|
||||
"""Entities on the vacuum's device that its card reads, by role: the 'mode' and 'water' selects, the
|
||||
@@ -1159,7 +1186,8 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
|
||||
day = forecast_time(entry, tz)
|
||||
if day is None:
|
||||
continue
|
||||
item = {'d': WEEKDAYS[day.weekday()], 'c': short(entry.get('condition') or '', 20)}
|
||||
# The day's short name in the screens' language (app 0.2.90); the list starts on Sunday, Python's week on Monday.
|
||||
item = {'d': screen_t(f'screen.date.weekdays_min.{(day.weekday() + 1) % 7}'), 'c': short(entry.get('condition') or '', 20)}
|
||||
# h/l: high and low; p: chance of rain in %; r: rain in the entity's unit (mm).
|
||||
for key, name in (('h', 'temperature'), ('l', 'templow'), ('p', 'precipitation_probability'), ('r', 'precipitation')):
|
||||
value = forecast_number(entry, name)
|
||||
@@ -1284,7 +1312,7 @@ def attribute_word(entity_id, attribute, value, attributes, entry, words):
|
||||
def state_message(index, tile, states, extra=None, precision=None, entry=None):
|
||||
if tile['entity'] in BUILTIN:
|
||||
message = {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||||
'name': short(tile['name'] or BUILTIN[tile['entity']], 80), 'state': 'ok', 'a': {}}
|
||||
'name': short(tile['name'] or builtin_name(tile['entity']), 80), 'state': 'ok', 'a': {}}
|
||||
# The same wire form as any tile, so a chosen icon travels as its codepoint (app 0.2.74+).
|
||||
options = screen_options(tile, {})
|
||||
if options is not None:
|
||||
@@ -1341,7 +1369,8 @@ def message_action(node):
|
||||
return alert_service(node, MESSAGE_ACTION)
|
||||
|
||||
# ----- Alerts (firmware 0.2.31+): the reference the cheatsheet shows. tests/test_alerts_reference.py
|
||||
# keeps every value here equal to what the board profiles compile. -----
|
||||
# keeps every value here equal to what the board profiles compile. The texts in English, as the Claude skill
|
||||
# writes them; the cheatsheet gets them in the editor's language (alert_reference, app 0.2.90). -----
|
||||
ALERT_MIN_FIRMWARE = '0.2.31'
|
||||
ALERT_EVENT = 'esphome.screen_alert'
|
||||
ALERT_ENDINGS = (('ok', 'The button was pressed'), ('timeout', 'The timeout ran out'),
|
||||
@@ -1357,6 +1386,9 @@ ALERT_FIELDS = (
|
||||
('timeout', 'int', 'Timeout', 'Seconds after which the card disappears on its own. 0 waits for the button, however long that takes. The button always closes it immediately, even with a timeout.', 0),
|
||||
('flash', 'bool', 'Blinking', 'On makes the backlight blink four times when the alert arrives; the screen then just stays on.', True),
|
||||
)
|
||||
# Examples that are words for people, which the cheatsheet shows in the editor's language; an icon, a colour or a number
|
||||
# is a value to type as it is.
|
||||
ALERT_TEXT_EXAMPLES = frozenset(('title', 'subtitle', 'button_text'))
|
||||
# Bytes per field the firmware keeps (the profiles' ALERT_*_MAX); an accented letter takes two.
|
||||
ALERT_LIMITS = {'cyd': {'title': 48, 'subtitle': 160, 'button_text': 12}, 'guition': {'title': 64, 'subtitle': 240, 'button_text': 16}}
|
||||
ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alarm-light', 'lock', 'lock-open-variant', 'door-open',
|
||||
@@ -1377,15 +1409,21 @@ def alert_service(node, action='show_alert'):
|
||||
return f"esphome.{node.replace('-', '_')}_{action}" if isinstance(node, str) and node else None
|
||||
|
||||
def alert_reference():
|
||||
"""Everything the Alerts cheatsheet shows besides the screens themselves."""
|
||||
"""Everything the Alerts cheatsheet shows besides the screens themselves, in the editor's language (app 0.2.90)."""
|
||||
def example(name, value):
|
||||
return t(f'addon.alerts.fields.{name}.example') if name in ALERT_TEXT_EXAMPLES else value
|
||||
camera = ALERT_CAMERA_FIELD[0]
|
||||
return {'min_firmware': ALERT_MIN_FIRMWARE, 'event': ALERT_EVENT,
|
||||
'broadcast': {'show': BROADCAST_SHOW, 'dismiss': BROADCAST_DISMISS},
|
||||
'endings': [{'action': action, 'label': label} for action, label in ALERT_ENDINGS],
|
||||
'endings': [{'action': action, 'label': t(f'addon.alerts.endings.{action}')} for action, _ in ALERT_ENDINGS],
|
||||
'fallback_icon': ALERT_FALLBACK_ICON, 'fallback_cp': tile_icons.GLYPHS[ALERT_FALLBACK_ICON],
|
||||
'fields': [{'name': name, 'type': kind, 'label': label, 'help': help_, 'example': example} for name, kind, label, help_, example in ALERT_FIELDS],
|
||||
'camera': dict(zip(('name', 'label', 'help', 'example'), ALERT_CAMERA_FIELD)),
|
||||
'fields': [{'name': name, 'type': kind, 'label': t(f'addon.alerts.fields.{name}.label'),
|
||||
'help': t(f'addon.alerts.fields.{name}.help'), 'example': example(name, value)}
|
||||
for name, kind, _, _, value in ALERT_FIELDS],
|
||||
'camera': {'name': camera, 'label': t(f'addon.alerts.fields.{camera}.label'), 'help': t(f'addon.alerts.fields.{camera}.help'),
|
||||
'example': ALERT_CAMERA_FIELD[3]},
|
||||
'limits': ALERT_LIMITS,
|
||||
'colors': [{'name': name, 'label': item['label'], 'color': item['color']} for name, item in TILE_BACKGROUNDS.items() if item['color']],
|
||||
'colors': [{'name': name, 'label': item['label'], 'color': item['color']} for name, item in backgrounds().items() if item['color']],
|
||||
'suggested_icons': [{'name': name, 'cp': tile_icons.GLYPHS[name]} for name in ALERT_SUGGESTED_ICONS],
|
||||
'extra_icons': [{'name': name, 'cp': cp} for name, cp in tile_icons.FIXED]}
|
||||
|
||||
@@ -1444,7 +1482,8 @@ def alert_camera(data):
|
||||
return (value.strip(), True) if usable else ('', False)
|
||||
|
||||
def alert_targets(screens):
|
||||
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason.
|
||||
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason, in English for the
|
||||
log (the editor shows it in its own language, app 0.2.90).
|
||||
|
||||
One call per device: a screen that shows up twice (an old inbox next to a renamed one) counts once."""
|
||||
minimum = parse_firmware(ALERT_MIN_FIRMWARE)
|
||||
@@ -1455,11 +1494,15 @@ def alert_targets(screens):
|
||||
if node in nodes:
|
||||
continue
|
||||
if not node:
|
||||
skipped.append((screen, 'device name unknown'))
|
||||
skipped.append((screen, english('addon.errors.alerts.no_device_name')))
|
||||
elif not screen.get('online'):
|
||||
skipped.append((screen, 'offline'))
|
||||
skipped.append((screen, english('addon.errors.alerts.offline')))
|
||||
elif version is None or version < minimum:
|
||||
skipped.append((screen, f"firmware {screen.get('firmware') or 'unknown'}"))
|
||||
# The sensor's own "unknown" reads as ours, which translates.
|
||||
firmware = screen.get('firmware')
|
||||
if firmware in (None, '', 'unknown'):
|
||||
firmware = english('addon.errors.alerts.unknown_version')
|
||||
skipped.append((screen, english('addon.errors.alerts.firmware', version=firmware)))
|
||||
else:
|
||||
ready.append(screen)
|
||||
nodes.add(node)
|
||||
@@ -1509,7 +1552,9 @@ def discover_screens(registry, states, devices, areas):
|
||||
'language': languages.get(item.get('device_id')),
|
||||
'device': device.get('name') or '',
|
||||
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
|
||||
'status': state.get('state', 'Not connected')})
|
||||
# What the screen reports (a word of the firmware's protocol), or ours in English, which the editor
|
||||
# shows in its own language (app 0.2.90).
|
||||
'status': state.get('state', english('screen.settings.not_connected'))})
|
||||
return screens
|
||||
|
||||
def discover(registry, states, devices, areas):
|
||||
@@ -1545,9 +1590,9 @@ def discover(registry, states, devices, areas):
|
||||
def installation_yaml(data):
|
||||
board, name, friendly = data.get('board'), data.get('name'), data.get('friendly_name')
|
||||
if board not in REFS or not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,29}', name):
|
||||
raise ValueError('Choose a board and a unique name (lowercase letters, digits, dashes; 30 characters max).')
|
||||
raise ValueError(t('addon.errors.firmware.board_and_name'))
|
||||
if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
|
||||
raise ValueError('Give the screen a recognizable name (60 characters max).')
|
||||
raise ValueError(t('addon.errors.firmware.friendly_name'))
|
||||
quote = lambda s: json.dumps(s, ensure_ascii=False)
|
||||
# The language of the screen's texts (app 0.2.90): Settings -> Language & region, which ESP Screens passes in.
|
||||
language = data.get('language') if isinstance(data.get('language'), str) and re.fullmatch(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?', data.get('language')) else 'en'
|
||||
|
||||
@@ -11,6 +11,7 @@ import signal
|
||||
import time
|
||||
import yaml
|
||||
from core import REPO, installation_yaml
|
||||
from i18n import t
|
||||
|
||||
# libyaml parses a 300 KB profile roughly ten times faster than the pure-Python loader.
|
||||
class LenientLoader(yaml.CSafeLoader if getattr(yaml, '__with_libyaml__', False) else yaml.SafeLoader):
|
||||
@@ -101,10 +102,10 @@ class Firmware:
|
||||
|
||||
def profile(self, name):
|
||||
if not isinstance(name,str) or not re.fullmatch(r'[a-zA-Z0-9_-]+\.yaml', name):
|
||||
raise ValueError('Choose an existing YAML profile.')
|
||||
raise ValueError(t('addon.errors.firmware.choose_profile'))
|
||||
p = self.root / name
|
||||
if p.is_symlink() or p.resolve().parent != self.root or not p.is_file():
|
||||
raise ValueError("Profile doesn't exist in the ESPHome folder.")
|
||||
raise ValueError(t('addon.errors.firmware.profile_missing'))
|
||||
return p
|
||||
|
||||
def wifi_status(self):
|
||||
@@ -136,12 +137,12 @@ class Firmware:
|
||||
if wifi['state'] == 'ready':
|
||||
return
|
||||
if wifi['state'] == 'invalid':
|
||||
raise ValueError("secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten.")
|
||||
raise ValueError(t('addon.errors.firmware.secrets_invalid'))
|
||||
values = {}
|
||||
for key in wifi['missing']:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or (key == 'wifi_ssid' and not value.strip()):
|
||||
raise ValueError("Fill in the Wi-Fi name and password; they'll be saved in ESPHome secrets.yaml.")
|
||||
raise ValueError(t('addon.errors.firmware.wifi_needed'))
|
||||
values[key] = value
|
||||
path = self.root / 'secrets.yaml'
|
||||
if wifi['state'] == 'new':
|
||||
@@ -160,7 +161,7 @@ class Firmware:
|
||||
text = text + ('' if not text or text.endswith('\n') else '\n') + line + '\n'
|
||||
check = yaml.safe_load(text)
|
||||
if not isinstance(check, dict) or any(check.get(key) != value for key, value in values.items()):
|
||||
raise ValueError("Wi-Fi couldn't be set in secrets.yaml. Fill in wifi_ssid and wifi_password there yourself.")
|
||||
raise ValueError(t('addon.errors.firmware.wifi_not_set'))
|
||||
path.write_text(text)
|
||||
|
||||
def create(self, data):
|
||||
@@ -169,21 +170,21 @@ class Firmware:
|
||||
self.root.mkdir(parents=True,exist_ok=True)
|
||||
profile = self.root / (data['name']+'.yaml')
|
||||
if profile.exists() or profile.is_symlink():
|
||||
raise ValueError('This name already exists. Use the existing profile for updates.')
|
||||
raise ValueError(t('addon.errors.firmware.name_exists'))
|
||||
override = self.root / (data['name'] + self.OVERRIDE_SUFFIX)
|
||||
if override.exists() or override.is_symlink():
|
||||
raise ValueError('This name already has a local override. Restore or remove it before creating a new screen with this name.')
|
||||
raise ValueError(t('addon.errors.firmware.override_exists'))
|
||||
self.store_wifi(data)
|
||||
try:
|
||||
with profile.open('x') as f:
|
||||
os.chmod(profile,0o600);f.write(content)
|
||||
except FileExistsError:
|
||||
raise ValueError('This name already exists. Use the existing profile for updates.')
|
||||
raise ValueError(t('addon.errors.firmware.name_exists'))
|
||||
try:
|
||||
with override.open('x') as f:
|
||||
os.chmod(override, 0o600); f.write('{}\n')
|
||||
except FileExistsError:
|
||||
raise ValueError('This name already has a local override. Restore or remove it before creating a new screen with this name.')
|
||||
raise ValueError(t('addon.errors.firmware.override_exists'))
|
||||
key = yaml.load(content, Loader=LenientLoader)['api']['encryption']['key']
|
||||
# The key is what Home Assistant asks for when pairing; the page shows it once.
|
||||
return {'file': profile.name, 'node': data['name'], 'api_key': key}
|
||||
@@ -218,7 +219,7 @@ class Firmware:
|
||||
profile = self.profile(name)
|
||||
path = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
|
||||
if path.is_symlink():
|
||||
raise ValueError('The local override is a symbolic link; replace it with a regular file first.')
|
||||
raise ValueError(t('addon.errors.firmware.override_symlink'))
|
||||
return profile, path
|
||||
|
||||
def _atomic_write(self, path, text):
|
||||
@@ -237,17 +238,17 @@ class Firmware:
|
||||
if re.search(rf'(?m)^\s*local_overrides:\s*!include\s+{re.escape(filename)}\s*$', text):
|
||||
return False
|
||||
if re.search(r'(?m)^\s*local_overrides\s*:', text):
|
||||
raise ValueError('This profile already has a different local_overrides include.')
|
||||
raise ValueError(t('addon.errors.firmware.other_include'))
|
||||
try:
|
||||
parsed = yaml.load(text, Loader=LenientLoader)
|
||||
except yaml.YAMLError as error:
|
||||
raise ValueError("The screen profile isn't valid YAML; fix it before adding an override.") from error
|
||||
raise ValueError(t('addon.errors.firmware.profile_invalid')) from error
|
||||
packages = parsed.get('packages') if isinstance(parsed, dict) else None
|
||||
if not isinstance(packages, dict):
|
||||
raise ValueError("This profile has no packages section that ESP Screens can extend.")
|
||||
raise ValueError(t('addon.errors.firmware.no_packages'))
|
||||
match = re.search(r'(?m)^packages:\s*\n', text)
|
||||
if not match:
|
||||
raise ValueError("This profile has no packages section that ESP Screens can extend.")
|
||||
raise ValueError(t('addon.errors.firmware.no_packages'))
|
||||
next_top = re.search(r'(?m)^[^\s#][^\n]*\n', text[match.end():])
|
||||
if not next_top and not text.endswith('\n'):
|
||||
text += '\n'
|
||||
@@ -259,28 +260,30 @@ class Firmware:
|
||||
|
||||
def _validate_override(self, content):
|
||||
if not isinstance(content, str):
|
||||
raise ValueError('Enter YAML text first.')
|
||||
raise ValueError(t('addon.errors.firmware.yaml_needed'))
|
||||
if len(content.encode('utf8')) > self.OVERRIDE_LIMIT:
|
||||
raise ValueError('The override is too large. Keep it below 12 KB.')
|
||||
raise ValueError(t('addon.errors.firmware.override_too_large'))
|
||||
if not content.strip():
|
||||
content = '{}\n'
|
||||
try:
|
||||
parsed = yaml.load(content, Loader=LenientLoader)
|
||||
except yaml.YAMLError as error:
|
||||
problem = getattr(error, 'problem', None) or 'invalid YAML'
|
||||
# PyYAML's own description of the problem stays in English.
|
||||
problem = getattr(error, 'problem', None) or t('addon.errors.firmware.invalid_yaml')
|
||||
mark = getattr(error, 'problem_mark', None)
|
||||
where = f' on line {mark.line + 1}' if mark else ''
|
||||
raise ValueError(f'YAML error{where}: {problem}.') from error
|
||||
if mark:
|
||||
raise ValueError(t('addon.errors.firmware.yaml_error_line', line=mark.line + 1, problem=problem)) from error
|
||||
raise ValueError(t('addon.errors.firmware.yaml_error', problem=problem)) from error
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError('The override must contain a YAML object, for example display: or substitutions:.')
|
||||
raise ValueError(t('addon.errors.firmware.override_object'))
|
||||
protected = sorted(set(parsed) & self.PROTECTED_OVERRIDE_KEYS)
|
||||
if protected:
|
||||
raise ValueError('These sections stay managed by ESP Screens: ' + ', '.join(protected) + '.')
|
||||
raise ValueError(t('addon.errors.firmware.sections_managed', names=', '.join(protected)))
|
||||
substitutions = parsed.get('substitutions')
|
||||
if isinstance(substitutions, dict):
|
||||
protected = sorted(set(substitutions) & self.PROTECTED_SUBSTITUTIONS)
|
||||
if protected:
|
||||
raise ValueError('These substitutions stay managed by ESP Screens: ' + ', '.join(protected) + '.')
|
||||
raise ValueError(t('addon.errors.firmware.substitutions_managed', names=', '.join(protected)))
|
||||
return content if content.endswith('\n') else content + '\n'
|
||||
|
||||
def override(self, name):
|
||||
@@ -308,11 +311,11 @@ class Firmware:
|
||||
target = data.get('target') or ''
|
||||
if target:
|
||||
if target != 'download' and (not isinstance(target, str) or target not in self.ports()):
|
||||
raise ValueError('Choose the connected USB port from the list.')
|
||||
raise ValueError(t('addon.errors.firmware.usb_port'))
|
||||
if self.task and not self.task.done():
|
||||
raise ValueError('A build or installation is already running. Wait for it to finish.')
|
||||
raise ValueError(t('addon.errors.firmware.busy_wait'))
|
||||
if not shutil.which('esphome'):
|
||||
raise ValueError('The ESPHome CLI is missing from this installation.')
|
||||
raise ValueError(t('addon.errors.firmware.no_esphome'))
|
||||
result = self.create(data)
|
||||
if target == 'download':
|
||||
result['job'] = self.start({'file': result['file'], 'action': 'download'})
|
||||
@@ -335,10 +338,10 @@ class Firmware:
|
||||
owner never gets firmware from before a change or a failed build."""
|
||||
profile = self.profile(name)
|
||||
if self.job and self.job.get('file') == profile.name and self.job.get('state') == 'running':
|
||||
raise ValueError('The firmware is still being built. Download it when the build has finished.')
|
||||
raise ValueError(t('addon.errors.firmware.still_building'))
|
||||
path = self.images.get(profile.name)
|
||||
if not path or not path.is_file():
|
||||
raise ValueError('Build the firmware first; the download appears when the build has finished.')
|
||||
raise ValueError(t('addon.errors.firmware.build_first'))
|
||||
self.downloaded.add(profile.name)
|
||||
return path, profile.stem + '.factory.bin'
|
||||
|
||||
@@ -349,17 +352,17 @@ class Firmware:
|
||||
return re.sub(r'(?i)((?:password|encryption.key|token|ssid)\s*[:=]\s*).+',r'\1[redacted]',text)[:1500]
|
||||
|
||||
def start(self, data):
|
||||
if self.task and not self.task.done(): raise ValueError('A build or installation is already running.')
|
||||
if self.task and not self.task.done(): raise ValueError(t('addon.errors.firmware.busy'))
|
||||
profile = self.profile(data.get('file'))
|
||||
action = data.get('action')
|
||||
if action not in ('validate','build','install','download'): raise ValueError('Unknown firmware action.')
|
||||
if not shutil.which('esphome'): raise ValueError('The ESPHome CLI is missing from this installation.')
|
||||
if action not in ('validate','build','install','download'): raise ValueError(t('addon.errors.firmware.unknown_action'))
|
||||
if not shutil.which('esphome'): raise ValueError(t('addon.errors.firmware.no_esphome'))
|
||||
target = data.get('target','')
|
||||
if action == 'install':
|
||||
if target.startswith('/dev/'):
|
||||
if target not in self.ports(): raise ValueError('Choose the connected USB port from the list.')
|
||||
if target not in self.ports(): raise ValueError(t('addon.errors.firmware.usb_port'))
|
||||
elif not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}',target):
|
||||
raise ValueError('Enter the IP address or hostname of the intended screen.')
|
||||
raise ValueError(t('addon.errors.firmware.host'))
|
||||
self._secret_values=set()
|
||||
# Collect scalar literals, including !secret values, without executing YAML tags.
|
||||
def collect(node):
|
||||
|
||||
@@ -13,6 +13,7 @@ import core
|
||||
from core import attribute_word, state_word
|
||||
import header_bar
|
||||
import history_card
|
||||
from i18n import t
|
||||
|
||||
# Screen widgets and the Home Assistant action behind them: (action, field or None). A widget fits an entity when Home
|
||||
# Assistant lists one of its actions for it, and, where a field is named, offers that field for the entity.
|
||||
@@ -253,20 +254,20 @@ def action_problem(entity_id, label, action, state, actions, services):
|
||||
if actions is None:
|
||||
return None
|
||||
if name not in actions:
|
||||
return f"Home Assistant doesn't offer {name} for {label}. Choose an action from the list."
|
||||
return t('addon.errors.home_assistant.action_not_offered', action=name, name=label)
|
||||
action_domain, service = name.split('.', 1)
|
||||
description = ((services or {}).get(action_domain) or {}).get(service)
|
||||
if description is None:
|
||||
return None
|
||||
if answers_only(description):
|
||||
return f"{name} only answers with data, which a screen can't show."
|
||||
return t('addon.errors.home_assistant.action_answers_only', action=name)
|
||||
fields = dict(fields_for(description, (state or {}).get('attributes') or {}))
|
||||
for key in data:
|
||||
if key not in fields:
|
||||
return f"Home Assistant has no field {key} in {name} for {label}."
|
||||
return t('addon.errors.home_assistant.no_such_field', field=key, action=name, name=label)
|
||||
for key, field in fields.items():
|
||||
if field.get('required') and key not in data:
|
||||
return f"{name} needs a value for {key}."
|
||||
return t('addon.errors.home_assistant.field_needed', action=name, field=key)
|
||||
return None
|
||||
|
||||
|
||||
@@ -274,14 +275,14 @@ def refusal(entity_id, name, key, value):
|
||||
"""The sentence a save or a tile event gets for a setting Home Assistant doesn't support for this entity."""
|
||||
label = name or entity_id
|
||||
if key == 'tap':
|
||||
return f"Home Assistant can't turn {label} on and off, so On / off would do nothing. Choose another tap action."
|
||||
return t('addon.errors.home_assistant.no_on_off', name=label)
|
||||
if key == 'inline':
|
||||
return f"{label} has nothing a small slider can change in Home Assistant."
|
||||
return t('addon.errors.home_assistant.no_small_slider', name=label)
|
||||
if key == 'display' and value == 'graph':
|
||||
return f"{label} has no numbers to draw a graph of."
|
||||
return t('addon.errors.home_assistant.no_graph', name=label)
|
||||
if key == 'display' and value == 'forecast':
|
||||
return f"{label} has no daily forecast in Home Assistant."
|
||||
return f"Home Assistant doesn't offer that direct control for {label}."
|
||||
return t('addon.errors.home_assistant.no_forecast', name=label)
|
||||
return t('addon.errors.home_assistant.no_control', name=label)
|
||||
|
||||
|
||||
def unsupported(tile, previous, caps):
|
||||
|
||||
@@ -16,6 +16,7 @@ from datetime import datetime, time
|
||||
|
||||
import tile_icons
|
||||
from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short, state_word
|
||||
from i18n import TRANSLATIONS, screen_number, screen_t, t
|
||||
|
||||
# Characters the top bar's text font carries on both boards (`sublabel_big` in the profiles;
|
||||
# tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes.
|
||||
@@ -23,35 +24,52 @@ from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_F
|
||||
# every letter European languages write with since app 0.2.90.
|
||||
GLYPHS = frozenset('<>—&@!,.?"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/…·\'#*=;²³µ–ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿĄąĆćĘꣳŃńŚśŹźŻżČčĎďĚěŇňŘřŠšŤťŮůŽžĹ弾ŔŕŐőŰűĂăȘșȚțĐđĀāĒēĢģĪīĶķĻļŅņŪūĖėĮįŲųĞğİıŞşĊċĠġĦħŴŵŶŷĿŀŒœŸ„“”‘’«»‹›¿¡€£')
|
||||
TEXT_BYTES = 40
|
||||
# The months in English, the language of reference (tests/test_header_bar.py); a screen gets them in its own language
|
||||
# (short_date, app 0.2.90).
|
||||
MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
|
||||
UNAVAILABLE = '—'
|
||||
|
||||
CONTENT_LABELS = {'state': 'Status', 'last_changed': 'Last changed'}
|
||||
SHOW_LABELS = {'always': 'Always', 'active': 'Only when active'}
|
||||
|
||||
# Home Assistant's own words where they fit a small bar; the screen shows the rest as they come.
|
||||
# Home Assistant's own words where they fit a small bar; the screen shows the rest as they come. The keys of the words,
|
||||
# which go in the screens' language (app 0.2.90): Home Assistant's own (screen.ha; a lock's are those of a lock sensor)
|
||||
# where the bar says the same, else the bar's ("Up" for above the horizon).
|
||||
STATES = {
|
||||
'on': 'On', 'off': 'Off', 'home': 'Home', 'not_home': 'Away', 'open': 'Open', 'closed': 'Closed',
|
||||
'opening': 'Opening', 'closing': 'Closing', 'locked': 'Locked', 'unlocked': 'Unlocked', 'locking': 'Locking',
|
||||
'unlocking': 'Unlocking', 'jammed': 'Jammed', 'playing': 'Playing', 'paused': 'Paused', 'idle': 'Idle',
|
||||
'standby': 'Standby', 'buffering': 'Buffering', 'cleaning': 'Cleaning', 'docked': 'Docked', 'returning': 'Returning',
|
||||
'error': 'Error', 'heat': 'Heat', 'cool': 'Cool', 'heat_cool': 'Auto', 'auto': 'Auto', 'dry': 'Dry',
|
||||
'fan_only': 'Fan only', 'disarmed': 'Disarmed', 'armed_home': 'Armed home', 'armed_away': 'Armed away', 'armed_night': 'Armed night',
|
||||
'armed_vacation': 'Armed vacation', 'armed_custom_bypass': 'Armed', 'arming': 'Arming', 'pending': 'Pending',
|
||||
'triggered': 'Triggered', 'active': 'Active', 'above_horizon': 'Up', 'below_horizon': 'Down',
|
||||
}
|
||||
# device_class: (on, off). Tiles and cards show the same words from the screen's own copy, BINARY_WORDS in
|
||||
# components/smart_display/tile_controls.h (firmware 0.2.53+); tests/test_binary_words.py keeps the two equal.
|
||||
BINARY_STATES = {
|
||||
'battery': ('Low', 'Normal'), 'battery_charging': ('Charging', 'Not charging'), 'carbon_monoxide': ('Danger', 'Safe'),
|
||||
'cold': ('Cold', 'Normal'), 'connectivity': ('Connected', 'Disconnected'), 'door': ('Open', 'Closed'),
|
||||
'garage_door': ('Open', 'Closed'), 'gas': ('Danger', 'Safe'), 'heat': ('Hot', 'Normal'), 'light': ('Light', 'Dark'),
|
||||
'lock': ('Open', 'Locked'), 'moisture': ('Wet', 'Dry'), 'motion': ('Motion', 'No motion'),
|
||||
'moving': ('Moving', 'Still'), 'occupancy': ('Occupied', 'Clear'), 'opening': ('Open', 'Closed'), 'plug': ('Plugged in', 'Unplugged'),
|
||||
'power': ('On', 'Off'), 'presence': ('Home', 'Away'), 'problem': ('Problem', 'OK'), 'running': ('Active', 'Inactive'),
|
||||
'safety': ('Unsafe', 'Safe'), 'smoke': ('Smoke', 'No smoke'), 'sound': ('Sound', 'Silent'), 'tamper': ('Tampering', 'OK'),
|
||||
'update': ('Update', 'Up to date'), 'vibration': ('Vibration', 'Still'), 'window': ('Open', 'Closed'),
|
||||
'on': 'screen.ha.on', 'off': 'screen.ha.off', 'home': 'screen.ha.person.home', 'not_home': 'screen.ha.person.not_home',
|
||||
'open': 'screen.ha.cover.open', 'closed': 'screen.ha.cover.closed', 'opening': 'screen.ha.cover.opening',
|
||||
'closing': 'screen.ha.cover.closing', 'locked': 'screen.ha.binary.lock_off', 'unlocked': 'screen.ha.binary.lock_on',
|
||||
'locking': 'addon.screen.states.locking', 'unlocking': 'addon.screen.states.unlocking', 'jammed': 'addon.screen.states.jammed',
|
||||
'playing': 'screen.ha.media.playing', 'paused': 'screen.ha.media.paused', 'idle': 'screen.ha.media.idle',
|
||||
'standby': 'screen.ha.media.standby', 'buffering': 'addon.screen.states.buffering', 'cleaning': 'screen.ha.vacuum.cleaning',
|
||||
'docked': 'screen.ha.vacuum.docked', 'returning': 'addon.screen.states.returning', 'error': 'addon.screen.states.error',
|
||||
'heat': 'screen.ha.climate.heat', 'cool': 'screen.ha.climate.cool', 'heat_cool': 'screen.ha.climate.auto',
|
||||
'auto': 'screen.ha.climate.auto', 'dry': 'screen.ha.climate.dry', 'fan_only': 'screen.ha.climate.fan_only',
|
||||
'disarmed': 'addon.screen.states.disarmed', 'armed_home': 'addon.screen.states.armed_home',
|
||||
'armed_away': 'addon.screen.states.armed_away', 'armed_night': 'addon.screen.states.armed_night',
|
||||
'armed_vacation': 'addon.screen.states.armed_vacation', 'armed_custom_bypass': 'addon.screen.states.armed',
|
||||
'arming': 'addon.screen.states.arming', 'pending': 'addon.screen.states.pending', 'triggered': 'addon.screen.states.triggered',
|
||||
'active': 'addon.screen.states.active', 'above_horizon': 'addon.screen.states.up', 'below_horizon': 'addon.screen.states.down',
|
||||
}
|
||||
|
||||
def state_text(raw):
|
||||
"""The bar's word for a state, in the screens' language; None for a state it has no word of its own for."""
|
||||
return screen_t(STATES[raw]) if raw in STATES else None
|
||||
|
||||
# The device classes with words of their own. Tiles and cards show the same words from the screen's own table, BINARY_WORDS
|
||||
# in components/smart_display/tile_controls.h (firmware 0.2.53+); tests/test_binary_words.py keeps the two equal. The words
|
||||
# are Home Assistant's, from the translations (screen.ha.binary, app 0.2.90): BINARY_STATES holds them in English, the
|
||||
# language of reference, as device_class: (on, off); binary_words gives them in the screens' language.
|
||||
BINARY_CLASSES = ('battery', 'battery_charging', 'carbon_monoxide', 'cold', 'connectivity', 'door', 'garage_door', 'gas',
|
||||
'heat', 'light', 'lock', 'moisture', 'motion', 'moving', 'occupancy', 'opening', 'plug', 'power',
|
||||
'presence', 'problem', 'running', 'safety', 'smoke', 'sound', 'tamper', 'update', 'vibration', 'window')
|
||||
BINARY_STATES = {name: (TRANSLATIONS.text(f'screen.ha.binary.{name}_on'), TRANSLATIONS.text(f'screen.ha.binary.{name}_off'))
|
||||
for name in BINARY_CLASSES}
|
||||
|
||||
def binary_words(device_class):
|
||||
"""(on, off) for a binary sensor of this class in the screens' language; On and Off for a class without words of its
|
||||
own, as the screen does."""
|
||||
if device_class in BINARY_STATES:
|
||||
return screen_t(f'screen.ha.binary.{device_class}_on'), screen_t(f'screen.ha.binary.{device_class}_off')
|
||||
return screen_t('screen.ha.on'), screen_t('screen.ha.off')
|
||||
|
||||
# Icon names from tile_icons; the fonts of both boards carry every one of them.
|
||||
BINARY_ICONS = { # device_class: (on, off)
|
||||
'motion': ('motion-sensor', 'motion-sensor'), 'occupancy': ('home-account', 'home-account'),
|
||||
@@ -107,7 +125,8 @@ def clean_text(text):
|
||||
return short(''.join(out).strip(), TEXT_BYTES)
|
||||
|
||||
def number_text(value, precision=None):
|
||||
"""A number as Home Assistant writes it: decimal point, commas between thousands."""
|
||||
"""A number as Home Assistant writes it, in the screens' number format (app 0.2.90): "1,234.5", "1.234,5" or
|
||||
"1 234,5"."""
|
||||
if precision is None:
|
||||
# Without a display precision HA keeps integers whole and shows at most three decimals.
|
||||
digits = 0 if float(value).is_integer() else 3
|
||||
@@ -116,12 +135,10 @@ def number_text(value, precision=None):
|
||||
text = text.rstrip('0').rstrip('.')
|
||||
else:
|
||||
text = f'{value:.{precision}f}'
|
||||
negative = text.startswith('-')
|
||||
whole, _, fraction = text.lstrip('-').partition('.')
|
||||
grouped = f'{int(whole):,}'
|
||||
result = grouped + ('.' + fraction if fraction else '')
|
||||
# "-0" and "-0.0" are just zero.
|
||||
return '-' + result if negative and any(c not in '0.,' for c in result) else result
|
||||
if text.startswith('-') and not any(c not in '0.' for c in text[1:]):
|
||||
text = text[1:]
|
||||
return screen_number(text)
|
||||
|
||||
def with_unit(text, unit):
|
||||
"""Home Assistant's spacing: "21.3 °C", "65%", "18°"."""
|
||||
@@ -145,7 +162,8 @@ def numeric(value):
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
def short_date(moment):
|
||||
return f'{moment.day} {MONTHS[moment.month - 1]}'
|
||||
""""15 Sep", in the screens' language and in its order of day and month (app 0.2.90)."""
|
||||
return screen_t('screen.date.day_month', day=moment.day, month=screen_t(f'screen.date.months_short.{moment.month - 1}'))
|
||||
|
||||
def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
"""(text, unix time) an entity shows as its state; exactly one of them is set. `words` are Home Assistant's
|
||||
@@ -159,11 +177,13 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
return (None, epoch(raw)) if epoch(raw) else (UNAVAILABLE, None)
|
||||
if domain == 'script':
|
||||
moment = epoch(attrs.get('last_triggered'))
|
||||
return ('Running', None) if raw == 'on' else (None, moment) if moment else ('Never run', None)
|
||||
if raw == 'on':
|
||||
return screen_t('addon.screen.script_running'), None
|
||||
return (None, moment) if moment else (screen_t('screen.script.never_run'), None)
|
||||
if domain == 'sun':
|
||||
# The next event: sunset while the sun is up, sunrise while it is down.
|
||||
clock = local_clock(attrs.get('next_setting' if raw == 'above_horizon' else 'next_rising'), tz)
|
||||
return (clock or STATES.get(raw, raw)), None
|
||||
return (clock or state_text(raw) or raw), None
|
||||
if domain == 'weather':
|
||||
temperature = numeric(attrs.get('temperature'))
|
||||
if temperature is None:
|
||||
@@ -174,9 +194,9 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
if current is not None:
|
||||
return with_unit(number_text(current, 1), (units or {}).get('temperature', '°C')), None
|
||||
if domain in ('person', 'device_tracker'):
|
||||
return STATES.get(raw, raw), None
|
||||
return state_text(raw) or raw, None
|
||||
if domain == 'binary_sensor':
|
||||
pair = BINARY_STATES.get(attrs.get('device_class'), ('On', 'Off'))
|
||||
pair = binary_words(attrs.get('device_class'))
|
||||
return (pair[0] if raw == 'on' else pair[1] if raw == 'off' else raw), None
|
||||
if domain == 'input_datetime':
|
||||
# "2026-09-15 07:30:00", "2026-09-15" or just "07:30:00".
|
||||
@@ -200,7 +220,7 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
if domain in ('input_text', 'input_select', 'select'):
|
||||
return state_word(entity, raw, attrs, entry, words) or raw, None
|
||||
if raw in STATES:
|
||||
return STATES[raw], None
|
||||
return state_text(raw), None
|
||||
return state_word(entity, raw, attrs, entry, words) or raw[:1].upper() + raw[1:], None
|
||||
|
||||
def active(entity, state):
|
||||
@@ -306,7 +326,7 @@ def preview(header, states, registry=None, units=None, tz=None, words=None):
|
||||
result = []
|
||||
for item in header['items']:
|
||||
if item['type'] in HEADER_BUILTIN:
|
||||
result.append({'k': item['type'], 'name': HEADER_BUILTIN[item['type']], 'shown': True})
|
||||
result.append({'k': item['type'], 'name': t(f"addon.labels.top_bar.builtin.{item['type']}"), 'shown': True})
|
||||
continue
|
||||
wire, shown = entity_item(item, states, registry, units, tz, words)
|
||||
attrs = (states.get(item['entity']) or {}).get('attributes') or {}
|
||||
@@ -334,19 +354,21 @@ def suggestions(screen, entities, states, registry=None, limit=8):
|
||||
def sensor(device_class):
|
||||
return lambda e: e['id'].startswith('sensor.') and attrs(e).get('device_class') == device_class and numeric(states[e['id']]['state']) is not None
|
||||
picks = []
|
||||
pick('Temperature', sensor('temperature'))
|
||||
pick('Humidity', sensor('humidity'))
|
||||
pick('Power usage', sensor('power'))
|
||||
pick('Last motion', lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed')
|
||||
pick('Weather', lambda e: e['id'].startswith('weather.'))
|
||||
pick('People home', lambda e: e['id'] == 'zone.home')
|
||||
pick('Sunrise and sunset', lambda e: e['id'] == 'sun.sun')
|
||||
pick('CO₂', sensor('carbon_dioxide'))
|
||||
# The labels in the editor's language (app 0.2.90).
|
||||
label = lambda key: t(f'addon.labels.top_bar.suggestions.{key}')
|
||||
pick(label('temperature'), sensor('temperature'))
|
||||
pick(label('humidity'), sensor('humidity'))
|
||||
pick(label('power'), sensor('power'))
|
||||
pick(label('motion'), lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed')
|
||||
pick(label('weather'), lambda e: e['id'].startswith('weather.'))
|
||||
pick(label('people_home'), lambda e: e['id'] == 'zone.home')
|
||||
pick(label('sun'), lambda e: e['id'] == 'sun.sun')
|
||||
pick(label('co2'), sensor('carbon_dioxide'))
|
||||
return picks[:limit]
|
||||
|
||||
def catalogue():
|
||||
"""Choices the editor offers; labels in the order the sheet shows them."""
|
||||
return {'builtin': [{'type': key, 'label': label} for key, label in HEADER_BUILTIN.items()],
|
||||
'contents': [{'key': key, 'label': CONTENT_LABELS[key]} for key in HEADER_CONTENTS],
|
||||
'shows': [{'key': key, 'label': SHOW_LABELS[key]} for key in HEADER_SHOWS],
|
||||
"""Choices the editor offers; labels in the order the sheet shows them, in the editor's language (app 0.2.90)."""
|
||||
return {'builtin': [{'type': key, 'label': t(f'addon.labels.top_bar.builtin.{key}')} for key in HEADER_BUILTIN],
|
||||
'contents': [{'key': key, 'label': t(f'addon.labels.top_bar.contents.{key}')} for key in HEADER_CONTENTS],
|
||||
'shows': [{'key': key, 'label': t(f'addon.labels.top_bar.shows.{key}')} for key in HEADER_SHOWS],
|
||||
'max_items': HEADER_MAX_ITEMS, 'min_firmware': '.'.join(map(str, HEADER_MIN_FIRMWARE))}
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
import header_bar
|
||||
from core import state_word
|
||||
from i18n import screen_t
|
||||
|
||||
RANGES = (1, 24, 168)
|
||||
POINTS = 24
|
||||
@@ -213,18 +214,18 @@ def line(entity, hours, changes, start, end, tz, entry=None, unit='', extreme_ch
|
||||
|
||||
|
||||
def state_label(domain, state, attrs, word=None):
|
||||
"""Home Assistant's words for a state, as the screen shows them elsewhere; `word` is Home Assistant's own word, for a
|
||||
state the screen has no word of its own for (app 0.2.67)."""
|
||||
"""Home Assistant's words for a state, as the screen shows them elsewhere, in the screens' language (app 0.2.90); `word`
|
||||
is Home Assistant's own word, for a state the screen has no word of its own for (app 0.2.67)."""
|
||||
if state in (None, 'unavailable'):
|
||||
return 'Unavailable'
|
||||
return screen_t('screen.ha.unavailable')
|
||||
if state == 'unknown':
|
||||
return 'Unknown'
|
||||
return screen_t('addon.screen.states.unknown')
|
||||
if domain == 'binary_sensor':
|
||||
pair = header_bar.BINARY_STATES.get((attrs or {}).get('device_class'), ('On', 'Off'))
|
||||
pair = header_bar.binary_words((attrs or {}).get('device_class'))
|
||||
return pair[0] if state == 'on' else pair[1] if state == 'off' else state
|
||||
if domain == 'person' and state not in header_bar.STATES:
|
||||
return state.replace('_', ' ')[:1].upper() + state.replace('_', ' ')[1:]
|
||||
return header_bar.STATES.get(state) or word or (state[:1].upper() + state[1:]).replace('_', ' ')
|
||||
return header_bar.state_text(state) or word or (state[:1].upper() + state[1:]).replace('_', ' ')
|
||||
|
||||
|
||||
def state_color(domain, state, others):
|
||||
@@ -280,7 +281,8 @@ def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS, en
|
||||
rest = [state for state in ranked if state not in index_of]
|
||||
if rest:
|
||||
index_of.update({state: len(shown) for state in rest})
|
||||
legend.append(['Other', '9E9E9E', int(sum(totals[state] for state in rest))])
|
||||
other = header_bar.clean_text(screen_t('addon.screen.history.other'))[:24]
|
||||
legend.append([other, '9E9E9E', int(sum(totals[state] for state in rest))])
|
||||
# Each slot shows the state that covers most of it, except that the active state always shows: a door that
|
||||
# stood open for five minutes stays visible on a day's timeline, as it does in Home Assistant.
|
||||
span = (end - start) / slots
|
||||
|
||||
@@ -131,9 +131,42 @@ class Translations:
|
||||
TRANSLATIONS = Translations()
|
||||
|
||||
|
||||
class Text(str):
|
||||
"""A text that keeps its key and params, so it can be written again in another language: a message made in one place
|
||||
and shown in another, such as a delivery status the sync loop sets or an update result kept in updates.json, shows in
|
||||
the language of whoever looks at it (app 0.2.90). Everywhere else it is the str it reads as."""
|
||||
|
||||
def __new__(cls, text, key=None, params=None):
|
||||
made = super().__new__(cls, text)
|
||||
made.key, made.params = key, dict(params or {})
|
||||
return made
|
||||
|
||||
def into(self, language):
|
||||
"""This text in `language`; one without a key stays as it is."""
|
||||
return _render(self.key, language, self.params) if self.key else str(self)
|
||||
|
||||
|
||||
def _render(key, language, params):
|
||||
"""`key` in `language` with its params filled in; a param that is a Text itself goes into that language too."""
|
||||
return TRANSLATIONS.text(key, language, **{name: value.into(language) if isinstance(value, Text) else value
|
||||
for name, value in params.items()})
|
||||
|
||||
|
||||
def t(key, **params):
|
||||
"""An `addon` text in the language of the editor that asked (REQUEST_LANGUAGE)."""
|
||||
return TRANSLATIONS.text(key, REQUEST_LANGUAGE.get(), **params)
|
||||
"""An `addon` text in the language of the editor that asked (REQUEST_LANGUAGE). It keeps its key, so the error it is
|
||||
raised with can still be written in another language (Updater.record)."""
|
||||
return Text(_render(key, REQUEST_LANGUAGE.get(), params), key, params)
|
||||
|
||||
|
||||
def english(key, **params):
|
||||
"""A text in English that keeps its key, for a message made now and read later: a delivery status, an update result,
|
||||
why a screen can't show an alert. Logs read the English; `shown` writes it in the editor's language."""
|
||||
return Text(_render(key, 'en', params), key, params)
|
||||
|
||||
|
||||
def shown(value):
|
||||
"""A text made earlier in the language of the editor that asks now; anything but a Text as it is."""
|
||||
return value.into(REQUEST_LANGUAGE.get()) if isinstance(value, Text) else value
|
||||
|
||||
|
||||
# The screens' language and number format (Settings -> Language & region), for the words the app sends to the screens
|
||||
@@ -146,8 +179,9 @@ def set_screens(language, numbers):
|
||||
|
||||
|
||||
def screen_t(key, **params):
|
||||
"""A text in the screens' language, for what the app sends to them."""
|
||||
return TRANSLATIONS.text(key, SCREENS['language'], **params)
|
||||
"""A text in the screens' language, for what the app sends to them (and the notification it leaves in Home
|
||||
Assistant); a param that is a Text goes into that language too."""
|
||||
return _render(key, SCREENS['language'], params)
|
||||
|
||||
|
||||
def screen_number(text):
|
||||
|
||||
@@ -19,13 +19,13 @@ import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, backgrounds, builtin_name, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state, state_word
|
||||
from core import PAGE_TILE_REPEAT_MIN_FIRMWARE, SLOTS_PER_PAGE, firmware_features, packed_slots, run_tile_event, screen_firmware, version_text
|
||||
import header_bar
|
||||
import history_card
|
||||
import i18n
|
||||
from i18n import REQUEST_LANGUAGE, TRANSLATIONS, Region, t
|
||||
from i18n import REQUEST_LANGUAGE, TRANSLATIONS, Region, english, screen_t, shown, t
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
@@ -556,8 +556,9 @@ class HomeAssistant:
|
||||
class Manager:
|
||||
def __init__(self, ha, path):
|
||||
self.ha, self.path = ha, Path(path)
|
||||
# sent: per inbox what the screen holds ({'layout', 'header', 'states', 'rev'}); last: the
|
||||
# last full send; pinged: the last keepalive ping.
|
||||
# sent: per inbox what the screen holds ({'layout', 'header', 'states', 'rev'}); status: how the
|
||||
# delivery went, in English with its key (the editor shows it in its own language, app 0.2.90);
|
||||
# last: the last full send; pinged: the last keepalive ping.
|
||||
self.layouts, self.sent, self.status, self.last, self.pinged = {}, {}, {}, {}, {}
|
||||
# (entity, hours) -> (monotonic, 24 samples); filled by history_loop, read by sync_one.
|
||||
self.histories = {}
|
||||
@@ -785,7 +786,7 @@ class Manager:
|
||||
`on_screen`: the screen reported them itself. The layout message it holds is then brought in step
|
||||
without sending it back, which could undo a change it made after reporting this one; its revision
|
||||
stays, so the next ping still matches."""
|
||||
base = self.layouts.get(inbox) or {'title': (screen or {}).get('name') or 'Home', 'tiles': []}
|
||||
base = self.layouts.get(inbox) or {'title': (screen or {}).get('name') or screen_t('screen.status.home'), 'tiles': []}
|
||||
layout = validate_layout({**base, 'settings': settings})
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
self.write_layouts(updated)
|
||||
@@ -825,15 +826,16 @@ class Manager:
|
||||
inbox = self.aliases.get(inbox, inbox)
|
||||
screen = self.screen(inbox)
|
||||
if screen is None:
|
||||
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
|
||||
raise ValueError(t('addon.errors.not_paired'))
|
||||
view = self.settings_view(screen)
|
||||
if not isinstance(changes, dict) or not changes or set(changes) - set(view['keys']):
|
||||
raise ValueError('Unknown screen settings; refresh the management page.')
|
||||
raise ValueError(t('addon.errors.settings.unknown'))
|
||||
if view['owner'] == 'screen' and not screen.get('online'):
|
||||
raise ValueError('This screen is offline. You can change its settings once it is back.')
|
||||
raise ValueError(t('addon.errors.settings.offline'))
|
||||
# The entities' names, as Home Assistant shows them (the firmware names them in English).
|
||||
missing = [SETTING_ENTITIES[key][1] for key in changes if key in view['unavailable']]
|
||||
if missing:
|
||||
raise ValueError(f"{', '.join(missing)} can't be changed now: the entity is off in Home Assistant, or the screen is restarting.")
|
||||
raise ValueError(t('addon.errors.settings.unavailable', names=', '.join(missing)))
|
||||
# A setting Home Assistant cannot read (its entity is off) is checked at its default.
|
||||
wanted = {**{key: value for key, value in view['values'].items() if value is not None}, **changes}
|
||||
if 'brightness' in changes and type(changes['brightness']) is int:
|
||||
@@ -843,7 +845,7 @@ class Manager:
|
||||
wanted[dim] = min(wanted.get(dim, SETTING_RULES[dim][0]), changes['brightness'])
|
||||
merged = validate_settings(wanted)
|
||||
if merged['rotation'] and screen.get('board') != 'guition':
|
||||
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
|
||||
raise ValueError(t('addon.errors.settings.rotation'))
|
||||
if view['owner'] == 'layout':
|
||||
self.store_settings(inbox, merged, screen)
|
||||
self.ha.changed.set()
|
||||
@@ -1043,14 +1045,14 @@ class Manager:
|
||||
inbox = self.aliases.get(inbox, inbox)
|
||||
screen=next((s for s in screens if s['id']==inbox), None)
|
||||
if screen is None:
|
||||
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
|
||||
raise ValueError(t('addon.errors.not_paired'))
|
||||
layout = validate_layout(data)
|
||||
# A CYD has no memory for camera images, whatever its firmware; say so before asking for an update.
|
||||
if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']) and screen.get('board') not in camera_feed.BOXES:
|
||||
raise ValueError('Camera images need a Guition screen.')
|
||||
raise ValueError(t('addon.errors.layout.camera_guition'))
|
||||
needed = self.needs_firmware(inbox, layout, screen)
|
||||
if needed:
|
||||
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
|
||||
raise ValueError(t('addon.errors.layout.firmware_first', version=needed))
|
||||
# Settings change through their own call (change_settings). A page from before app 0.2.57 still sends them
|
||||
# with the tiles: a screen that owns its settings ignores them, so a stale form never undoes a change
|
||||
# made on the screen; other screens keep taking them, as before.
|
||||
@@ -1069,7 +1071,7 @@ class Manager:
|
||||
if 'settings' in layout and 'rotation' not in data.get('settings',{}):
|
||||
layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0)
|
||||
if layout.get('settings',{}).get('rotation',0) and screen.get('board')!='guition':
|
||||
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
|
||||
raise ValueError(t('addon.errors.settings.rotation'))
|
||||
# A page from before an option existed sends its tiles without it. It never sends a navigation tile twice
|
||||
# (firmware 0.2.65+), so a copy keeps exactly what it was sent with.
|
||||
old_list = self.layouts.get(inbox,{}).get('tiles',[])
|
||||
@@ -1090,21 +1092,20 @@ class Manager:
|
||||
layout = validate_layout(layout)
|
||||
known = {e['id'] for e in entities} | set(BUILTIN)
|
||||
if any(t['entity'] not in known for t in layout['tiles']):
|
||||
raise ValueError('A chosen entity no longer exists. Look up the new entity.')
|
||||
raise ValueError(t('addon.errors.layout.entity_gone'))
|
||||
if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)):
|
||||
raise ValueError('An entity in the top bar no longer exists. Choose a different one.')
|
||||
raise ValueError(t('addon.errors.top_bar.entity_gone'))
|
||||
# The screen takes the layout in one message of at most 4096 bytes, which lists every tile's entity id: 48 tiles
|
||||
# with long ids can pass every rule above and still never reach the screen (app 0.2.78).
|
||||
try:
|
||||
encode(self.layout_message(inbox, layout, screen))
|
||||
except ValueError:
|
||||
raise ValueError("These tiles' entity IDs are too long together to fit in one message to the screen; "
|
||||
"remove a few tiles.") from None
|
||||
raise ValueError(t('addon.errors.layout.ids_too_long')) from None
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
self.write_layouts(updated)
|
||||
self.layouts = updated
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Saved; waiting for sync'
|
||||
self.status[inbox] = english('addon.status.saved')
|
||||
self.history_wake.set()
|
||||
self.ha.changed.set()
|
||||
self.notify()
|
||||
@@ -1289,7 +1290,7 @@ class Manager:
|
||||
screen = self.screen(inbox) or {}
|
||||
needed = self.needs_firmware(inbox, layout, screen)
|
||||
if needed:
|
||||
self.status[inbox]=f"Layout saved; firmware {needed}+ needed for these tiles"
|
||||
self.status[inbox]=english('addon.status.firmware_needed', version=needed)
|
||||
return False
|
||||
tiles = layout['tiles']
|
||||
layout_msg = self.layout_message(inbox, layout, screen)
|
||||
@@ -1297,7 +1298,7 @@ class Manager:
|
||||
encode(layout_msg)
|
||||
except ValueError:
|
||||
# Saved by an app before 0.2.78, which didn't check this: nothing to retry until the layout changes.
|
||||
self.status[inbox] = 'Layout saved; too large for one message to the screen, remove a few tiles'
|
||||
self.status[inbox] = english('addon.status.too_large')
|
||||
return False
|
||||
previous = self.sent.get(inbox)
|
||||
full = force or not previous or previous['layout'] != layout_msg or dirty is None
|
||||
@@ -1342,7 +1343,7 @@ class Manager:
|
||||
self.last[inbox] = time.monotonic()
|
||||
if outgoing:
|
||||
self.pinged[inbox] = time.monotonic()
|
||||
self.status[inbox] = 'Sent to Home Assistant'
|
||||
self.status[inbox] = english('addon.status.sent')
|
||||
# A screen that answers confirms a new layout at once: a ping after the batch says whether it holds
|
||||
# the layout and every tile, or which part is missing (firmware 0.2.49+).
|
||||
if sent_layout and self.answers(inbox, screen):
|
||||
@@ -1426,7 +1427,7 @@ class Manager:
|
||||
before = self.status.get(inbox)
|
||||
if not screen['online']:
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Screen offline; changes saved'
|
||||
self.status[inbox] = english('addon.status.offline')
|
||||
changed = changed or self.status[inbox] != before
|
||||
continue
|
||||
if inbox not in self.layouts:
|
||||
@@ -1448,7 +1449,7 @@ class Manager:
|
||||
await self.ping(inbox, screen)
|
||||
except Exception as error:
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Sending failed; retrying automatically'
|
||||
self.status[inbox] = english('addon.status.retrying')
|
||||
LOG.warning('Retrying screen sync (%s)', type(error).__name__)
|
||||
await asyncio.sleep(1)
|
||||
changed = changed or self.status.get(inbox) != before
|
||||
@@ -1693,13 +1694,14 @@ def create_app(manager, development=False):
|
||||
csrf = secrets.token_urlsafe(32)
|
||||
@web.middleware
|
||||
async def guard(request, handler):
|
||||
# The editor's language for the messages this request answers with (app 0.2.90).
|
||||
REQUEST_LANGUAGE.set(TRANSLATIONS.resolve(request.headers.get('X-ESP-Screens-Language') or 'en'))
|
||||
# The editor's language for the messages this request answers with (app 0.2.90). An EventSource sends no headers of
|
||||
# its own, so the live updates (/api/events) carry it in the address.
|
||||
REQUEST_LANGUAGE.set(TRANSLATIONS.resolve(request.headers.get('X-ESP-Screens-Language') or request.query.get('language') or 'en'))
|
||||
allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'}
|
||||
if request.remote not in allowed:
|
||||
raise web.HTTPForbidden(text='Open this page through Home Assistant.')
|
||||
raise web.HTTPForbidden(text=t('addon.errors.open_through_ha'))
|
||||
if request.method not in {'GET', 'HEAD'} and request.headers.get('X-Screen-CSRF') != csrf:
|
||||
raise web.HTTPForbidden(text='Refresh this page and try again.')
|
||||
raise web.HTTPForbidden(text=t('addon.errors.refresh_page'))
|
||||
try:
|
||||
response = await handler(request)
|
||||
except ValueError as error:
|
||||
@@ -1707,13 +1709,13 @@ def create_app(manager, development=False):
|
||||
# Home Assistant said no, or can't be reached (Identify, Try it, a setting): a sentence the page shows, never a
|
||||
# bare 500 with a traceback in the log (app 0.2.78). Refused is a ConnectionError, so it comes first.
|
||||
except Refused as error:
|
||||
return web.json_response({'error': f"Home Assistant didn't take it: {error.detail or 'no reason given'}."}, status=400)
|
||||
return web.json_response({'error': t('addon.errors.ha_refused', reason=error.detail or t('addon.errors.no_reason'))}, status=400)
|
||||
except (ConnectionError, TimeoutError):
|
||||
return web.json_response({'error': "Home Assistant isn't reachable right now. Try again in a moment."}, status=503)
|
||||
return web.json_response({'error': t('addon.errors.ha_unreachable')}, status=503)
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
return web.json_response({'error': 'That request is too large.'}, status=413)
|
||||
return web.json_response({'error': t('addon.errors.too_large')}, status=413)
|
||||
except (TypeError, KeyError):
|
||||
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
|
||||
return web.json_response({'error': t('addon.errors.invalid_input')}, status=400)
|
||||
if response.prepared:
|
||||
return response # streamed (SSE) responses set their headers before prepare()
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
@@ -1747,9 +1749,13 @@ def create_app(manager, development=False):
|
||||
screens = manager.screens()
|
||||
profiles = manager.firmware.profile_names()
|
||||
for screen in screens:
|
||||
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Home', 'tiles': []})
|
||||
# A screen without tiles yet: the title the screen itself shows without one, in its language (app 0.2.90).
|
||||
screen['layout'] = manager.layouts.get(screen['id'], {'title': screen_t('screen.status.home'), 'tiles': []})
|
||||
screen['settings'] = manager.settings_view(screen)
|
||||
screen['delivery'] = manager.status.get(screen['id'], 'Choose your first tiles')
|
||||
# The delivery and our own word for a screen that reports nothing, in the editor's language (app 0.2.90).
|
||||
status = manager.status.get(screen['id'])
|
||||
screen['delivery'] = shown(status) if status else t('addon.status.first_tiles')
|
||||
screen['status'] = shown(screen.get('status'))
|
||||
screen['update'] = manager.updates.state_for(screen, profiles)
|
||||
screen['alert_action'] = alert_service(screen.get('node'))
|
||||
screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert')
|
||||
@@ -1769,14 +1775,16 @@ def create_app(manager, development=False):
|
||||
screens, entities = manager.inventory()
|
||||
payload = light_payload(screens)
|
||||
payload['entities'] = entities
|
||||
payload['backgrounds'] = TILE_BACKGROUNDS
|
||||
# Labels and help in the editor's language (app 0.2.90); ids and keys stay as they are.
|
||||
payload['backgrounds'] = backgrounds()
|
||||
payload['controls'] = controls_catalogue()
|
||||
payload['icons'] = tile_icons.editor()
|
||||
payload['alerts'] = alert_reference()
|
||||
# What's new for the Update badge: here only, not in every live update (app 0.2.78).
|
||||
payload['changelog'] = manager.updates.changelog
|
||||
payload['claude_skill'] = claude_skill.status(manager.skill_dir)
|
||||
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Built into the screen', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
|
||||
payload['builtin'] = [{'id': key, 'name': builtin_name(key, t), 'device': t('addon.labels.built_into_screen'), 'area': '', 'state': 'ok'}
|
||||
for key in BUILTIN]
|
||||
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
|
||||
screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}}
|
||||
return web.json_response(payload)
|
||||
@@ -1856,9 +1864,9 @@ def create_app(manager, development=False):
|
||||
try:
|
||||
view = await manager.change_settings(request.match_info['inbox'], data.get('settings') if isinstance(data, dict) else None)
|
||||
except Refused as error:
|
||||
raise ValueError(f"Home Assistant didn't take the change: {error.detail or 'no reason given'}.") from error
|
||||
raise ValueError(t('addon.errors.ha_refused_change', reason=error.detail or t('addon.errors.no_reason'))) from error
|
||||
except (ConnectionError, TimeoutError) as error:
|
||||
raise ValueError("Home Assistant isn't reachable right now. Try again in a moment.") from error
|
||||
raise ValueError(t('addon.errors.ha_unreachable')) from error
|
||||
return web.json_response(view)
|
||||
async def update_screen(request):
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
@@ -1871,10 +1879,10 @@ def create_app(manager, development=False):
|
||||
async def inspector(request):
|
||||
screen=manager.screen(request.match_info['inbox'])
|
||||
inbox=manager.aliases.get(request.match_info['inbox'], request.match_info['inbox'])
|
||||
if screen is None: raise ValueError('Unknown screen.')
|
||||
if screen is None: raise ValueError(t('addon.errors.unknown_screen'))
|
||||
layout=manager.layouts.get(inbox,{'tiles':[]})
|
||||
return web.json_response({'screen':screen,
|
||||
'delivery':manager.status.get(inbox), 'layout':layout,
|
||||
return web.json_response({'screen':{**screen, 'status':shown(screen.get('status'))},
|
||||
'delivery':shown(manager.status.get(inbox)), 'layout':layout,
|
||||
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
|
||||
# Home Assistant's word for the state, as its own pages show it ("Heat/Cool", app 0.2.67).
|
||||
'word':state_word(t['entity'],manager.ha.states.get(t['entity'],{}).get('state'),manager.ha.states.get(t['entity'],{}).get('attributes'),
|
||||
@@ -1899,23 +1907,26 @@ def create_app(manager, development=False):
|
||||
def one_alert_target(inbox):
|
||||
screen = manager.screen(inbox)
|
||||
if screen is None:
|
||||
raise ValueError('Unknown screen.')
|
||||
raise ValueError(t('addon.errors.unknown_screen'))
|
||||
ready, skipped = alert_targets([screen])
|
||||
if not ready:
|
||||
raise ValueError(f"{screen['name']} can't show an alert: {skipped[0][1] if skipped else 'not ready'}.")
|
||||
raise ValueError(t('addon.errors.alerts.cannot_show', name=screen['name'],
|
||||
reason=skipped[0][1] if skipped else t('addon.errors.alerts.not_ready')))
|
||||
return ready[0]
|
||||
async def identify(request):
|
||||
"""Identify (app 0.2.73): the screen shows a short card and blinks its backlight, so you know which one it is."""
|
||||
"""Identify (app 0.2.73): the screen shows a short card and blinks its backlight, so you know which one it is. The
|
||||
card speaks the screens' language (app 0.2.90)."""
|
||||
screen = one_alert_target(request.match_info['inbox'])
|
||||
data, _ = alert_data({'title': f"This is {screen['name']}", 'subtitle': 'Identify, from ESP Screens', 'icon': 'bell-ring',
|
||||
'color': 'blue', 'button_text': 'OK', 'timeout': 8, 'flash': True})
|
||||
data, _ = alert_data({'title': screen_t('addon.screen.identify.title', name=screen['name']),
|
||||
'subtitle': screen_t('addon.screen.identify.subtitle'), 'icon': 'bell-ring',
|
||||
'color': 'blue', 'button_text': screen_t('screen.alert.ok'), 'timeout': 8, 'flash': True})
|
||||
await manager.ha.call(alert_service(screen['node']), data)
|
||||
return web.json_response({'ok': True})
|
||||
async def test_alert(request):
|
||||
"""Alerts → Try it (app 0.2.73): one alert to one screen or to every screen, with the fields an automation sends."""
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError('Invalid alert.')
|
||||
raise ValueError(t('addon.errors.alerts.invalid'))
|
||||
data = body.get('data') if isinstance(body.get('data'), dict) else {}
|
||||
if body.get('screen') == 'all':
|
||||
return web.json_response(await manager.broadcast(BROADCAST_SHOW, data))
|
||||
@@ -1937,7 +1948,7 @@ def create_app(manager, development=False):
|
||||
async def firmware_override_save(request):
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Invalid override data.')
|
||||
raise ValueError(t('addon.errors.firmware.override_invalid'))
|
||||
return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content')))
|
||||
async def firmware_create(request):
|
||||
# Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request; the
|
||||
|
||||
@@ -8,7 +8,10 @@ fonts/materialdesignicons-webfont.ttf. Firmware 0.2.18+ carries every glyph here
|
||||
|
||||
import re
|
||||
|
||||
# (group, ((MDI name, codepoint, label), ...)) in picker order.
|
||||
from i18n import t
|
||||
|
||||
# (group, ((MDI name, codepoint, label), ...)) in picker order. The labels in English, as the Claude skill writes them; the
|
||||
# editor's picker gets them in its language (editor(), app 0.2.90).
|
||||
GROUPS = (
|
||||
('Lighting', (
|
||||
('lightbulb', 'F0335', 'Light bulb'),
|
||||
@@ -493,9 +496,15 @@ def ha_icon(attributes):
|
||||
icon = attributes.get('icon') if isinstance(attributes, dict) else None
|
||||
return GLYPHS.get(icon[4:]) if isinstance(icon, str) and icon.startswith('mdi:') else None
|
||||
|
||||
def label_key(name):
|
||||
"""The translation key of an icon's label ('ceiling-light'), or of a group's ('Media and music')."""
|
||||
return re.sub(r'[^a-z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
def editor():
|
||||
"""Picker groups plus what the mockup needs to predict the screen's own icons."""
|
||||
return {'groups': [{'label': group, 'icons': [{'name': name, 'cp': codepoint, 'label': label} for name, codepoint, label in icons]}
|
||||
"""Picker groups plus what the mockup needs to predict the screen's own icons; the labels in the editor's language."""
|
||||
return {'groups': [{'label': t(f'addon.icons.groups.{label_key(group)}'),
|
||||
'icons': [{'name': name, 'cp': codepoint, 'label': t(f'addon.icons.names.{label_key(name)}')}
|
||||
for name, codepoint, _ in icons]}
|
||||
for group, icons in GROUPS],
|
||||
'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK],
|
||||
'builtin': {entity: GLYPHS[name] for entity, name in BUILTIN_TILES.items()},
|
||||
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
import time
|
||||
import changelog
|
||||
from core import FIRMWARE_VERSION, parse_firmware
|
||||
from i18n import Text, english, screen_t, shown, t
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
NIGHT_HOURS = range(3, 6)
|
||||
@@ -18,6 +19,19 @@ parse_version = parse_firmware
|
||||
|
||||
TARGET = parse_version(FIRMWARE_VERSION)
|
||||
|
||||
def result_text(result):
|
||||
"""A kept result's message: a Text from its key where it has one (app 0.2.90+), else the English it was kept in."""
|
||||
key, params = result.get('key'), result.get('params')
|
||||
if isinstance(key, str) and key:
|
||||
return Text(str(result.get('message', '')), key, params if isinstance(params, dict) else {})
|
||||
return result.get('message', '')
|
||||
|
||||
def shown_result(result):
|
||||
"""A kept result as the editor shows it, its message in the editor's language."""
|
||||
if not isinstance(result, dict) or not isinstance(result.get('key'), str):
|
||||
return result
|
||||
return {**result, 'message': shown(result_text(result))}
|
||||
|
||||
class Updater:
|
||||
verify_timeout = 240
|
||||
settle_seconds = 60
|
||||
@@ -106,7 +120,7 @@ class Updater:
|
||||
return {'available': bool(version and TARGET and version < TARGET) or (bool(version) and language),
|
||||
'language': bool(version) and language, 'target': FIRMWARE_VERSION,
|
||||
'profile': profile, 'host': host, 'state': state, 'phase': self.phase if state == 'running' else None,
|
||||
'result': self.results.get(screen['id'])}
|
||||
'result': shown_result(self.results.get(screen['id']))}
|
||||
|
||||
def summary(self, screens=None, profiles=None):
|
||||
# The changelog goes with the full inventory only (app 0.2.78): this summary is in every live update of the page.
|
||||
@@ -125,40 +139,40 @@ class Updater:
|
||||
|
||||
def set_auto(self, enabled):
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError('Choose on or off for automatic updates.')
|
||||
raise ValueError(t('addon.errors.updates.automatic'))
|
||||
self.auto = enabled
|
||||
self.save()
|
||||
|
||||
def start(self, inbox, host=None):
|
||||
if self.busy():
|
||||
raise ValueError('An update is already running. Wait for it to finish.')
|
||||
raise ValueError(t('addon.errors.updates.busy'))
|
||||
screen = self.screen(inbox)
|
||||
inbox = self.current_id(inbox)
|
||||
if not screen:
|
||||
raise ValueError('Unknown screen. Refresh the overview.')
|
||||
raise ValueError(t('addon.errors.updates.unknown_screen'))
|
||||
if not screen['online']:
|
||||
raise ValueError("This screen is offline; updating is possible once it's back.")
|
||||
raise ValueError(t('addon.errors.updates.offline'))
|
||||
if not self.state_for(screen)['available']:
|
||||
raise ValueError('This screen already has the latest firmware.')
|
||||
raise ValueError(t('addon.errors.updates.latest'))
|
||||
if host is not None:
|
||||
if not isinstance(host, str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', host.strip()):
|
||||
raise ValueError("Enter this screen's IP address.")
|
||||
raise ValueError(t('addon.errors.updates.enter_ip'))
|
||||
self.hosts[inbox] = host.strip()
|
||||
self.save()
|
||||
profile, host = self.resolve(screen)
|
||||
if not profile:
|
||||
raise ValueError('No ESPHome profile found for this screen. Check the name in the ESPHome folder.')
|
||||
raise ValueError(t('addon.errors.updates.no_profile'))
|
||||
if not host:
|
||||
raise ValueError('IP address unknown. Enter it once; new firmware reports it itself after that.')
|
||||
raise ValueError(t('addon.errors.updates.ip_unknown'))
|
||||
self.launch([inbox])
|
||||
return self.state_for(screen)
|
||||
|
||||
def start_all(self):
|
||||
if self.busy():
|
||||
raise ValueError('An update is already running. Wait for it to finish.')
|
||||
raise ValueError(t('addon.errors.updates.busy'))
|
||||
pending = self.pending()
|
||||
if not pending:
|
||||
raise ValueError('All reachable screens are already updated.')
|
||||
raise ValueError(t('addon.errors.updates.all_updated'))
|
||||
self.launch(pending)
|
||||
return pending
|
||||
|
||||
@@ -168,7 +182,12 @@ class Updater:
|
||||
self.task = asyncio.create_task(self.run_round(inboxes, automatic))
|
||||
|
||||
def record(self, inbox, state, message):
|
||||
self.results[self.current_id(inbox)] = {'time': time.time(), 'state': state, 'message': message, 'version': FIRMWARE_VERSION}
|
||||
"""A screen's last result. `message` keeps its key and params (a Text) next to the English, so the editor shows it
|
||||
in its own language and an app from before 0.2.90 still reads the English."""
|
||||
result = {'time': time.time(), 'state': state, 'message': str(message), 'version': FIRMWARE_VERSION}
|
||||
if isinstance(message, Text) and message.key:
|
||||
result.update(message=message.into('en'), key=message.key, params=message.params)
|
||||
self.results[self.current_id(inbox)] = result
|
||||
self.save()
|
||||
|
||||
async def run_round(self, inboxes, automatic=False):
|
||||
@@ -180,8 +199,10 @@ class Updater:
|
||||
outcome = await self.update_one(inbox)
|
||||
if outcome == 'failed':
|
||||
if automatic:
|
||||
await self.notify(f"Automatic update stopped at {self.name(inbox)}: "
|
||||
f"{self.results[self.current_id(inbox)]['message']} The remaining screens were not touched.")
|
||||
# For whoever reads Home Assistant: in the screens' language, Home Assistant's own unless another was
|
||||
# chosen (app 0.2.90).
|
||||
await self.notify(screen_t('addon.notify.update_stopped', name=self.name(inbox),
|
||||
reason=result_text(self.results[self.current_id(inbox)])))
|
||||
break
|
||||
if self.queue and outcome == 'success':
|
||||
await asyncio.sleep(self.pause_seconds)
|
||||
@@ -195,33 +216,34 @@ class Updater:
|
||||
async def update_one(self, inbox):
|
||||
screen = self.screen(inbox)
|
||||
if not screen or not screen['online']:
|
||||
self.record(inbox, 'skipped', 'Screen was offline; will retry next time.')
|
||||
self.record(inbox, 'skipped', english('addon.updates.offline'))
|
||||
return 'skipped'
|
||||
profile, host = self.resolve(screen)
|
||||
if not profile or not host:
|
||||
self.record(inbox, 'skipped', 'Profile or IP address unknown; update this screen manually.')
|
||||
self.record(inbox, 'skipped', english('addon.updates.unknown_target'))
|
||||
return 'skipped'
|
||||
self.phase = 'install'
|
||||
try:
|
||||
self.manager.firmware.start({'file': profile, 'action': 'install', 'target': host})
|
||||
await self.manager.firmware.task
|
||||
except ValueError as error:
|
||||
self.record(inbox, 'failed', str(error))
|
||||
# The sentence firmware.start refused with, kept with its key.
|
||||
self.record(inbox, 'failed', error.args[0] if len(error.args) == 1 else str(error))
|
||||
return 'failed'
|
||||
if self.manager.firmware.job.get('state') != 'success':
|
||||
self.record(inbox, 'failed', 'Build or install failed; see the log under Firmware & USB.')
|
||||
self.record(inbox, 'failed', english('addon.updates.build_failed'))
|
||||
return 'failed'
|
||||
self.phase = 'verify'
|
||||
if not await self.wait_for_target(inbox):
|
||||
self.record(inbox, 'failed', f"Screen didn't report back with firmware {FIRMWARE_VERSION}; check the screen.")
|
||||
self.record(inbox, 'failed', english('addon.updates.no_report', version=FIRMWARE_VERSION))
|
||||
return 'failed'
|
||||
self.phase = 'settle'
|
||||
await asyncio.sleep(self.settle_seconds)
|
||||
current = self.screen(inbox)
|
||||
if not current or not current['online']:
|
||||
self.record(inbox, 'failed', 'Screen dropped off after the update; check the screen.')
|
||||
self.record(inbox, 'failed', english('addon.updates.dropped_off'))
|
||||
return 'failed'
|
||||
self.record(inbox, 'success', f'Updated to firmware {FIRMWARE_VERSION}.')
|
||||
self.record(inbox, 'success', english('addon.updates.updated', version=FIRMWARE_VERSION))
|
||||
return 'success'
|
||||
|
||||
async def wait_for_target(self, inbox):
|
||||
|
||||
Reference in New Issue
Block a user