Forty-eight tiles, a tile over the whole page and tiles that go to a page (0.2.74)
Max asked for many more tiles, a tile that takes the whole page as a light
switch you push without looking, and tiles that go to a page. The limit is
one tile per slot of the eight pages, 48, and the firmware keeps its tile
list on the heap sized to the layout (PSRAM on a Guition). A tile's size can
be `full`: one big button that lights up in the state colour while on, or the
double-width card's head with its small slider, direct controls or graph at
the bottom and the value the double-width card had no room for in between;
the clock, the forecast (with an hours strip) and the sun path fill the page.
Navigation tiles are the built-in entities screen.page_1 to screen.page_8:
an arrow or a chosen icon, the name and "Page n", a tap opens that page.
The app sends the three only to firmware 0.2.62+, the editor (the Vue port of
this round by the editor session, 00c9a4e) offers Normal / Double-width /
Full page and "Goes to page", and Claude in Home Assistant gets size: full and
the page tiles. Built-in tiles now send a chosen icon as its codepoint.
Found and fixed by the new host harness before release: the one-second tick
moved the analog clock's second hand (part 18) on a full forecast's hour label
during a page switch and corrupted it. Firmware 0.2.62; both C++ and Python
suites, host harness 37/37 on both boards, hardware pass on both bench boards
in docs/TEST_RESULTS_0274.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,11 @@ import zipfile
|
||||
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,
|
||||
CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
|
||||
CONTROLS, DISPLAYS, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_TILES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
|
||||
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE,
|
||||
DARK_MODE_MIN_FIRMWARE)
|
||||
# Full-page tiles, navigation tiles and forty-eight tiles per screen.
|
||||
FULL_PAGE_VERSION = '.'.join(str(part) for part in FULL_PAGE_MIN_FIRMWARE)
|
||||
|
||||
NAME = 'esp-screens'
|
||||
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
|
||||
@@ -65,7 +67,7 @@ An alert is a card over the whole screen with an icon, a title, a subtitle and o
|
||||
|
||||
## Tiles on a screen
|
||||
|
||||
A screen shows tiles: two columns and {SLOTS_PER_PAGE // 2} rows per page, at most {MAX_PAGES} pages and twenty tiles. A tile is single or double-width; a double-width one starts in the left column and takes two spots.
|
||||
A screen shows tiles: two columns and {SLOTS_PER_PAGE // 2} rows per page, at most {MAX_PAGES} pages and {MAX_TILES} tiles (twenty on firmware before {FULL_PAGE_VERSION}). A tile is single, double-width or full-page: a double-width one starts in the left column and takes two spots, a full-page one takes a whole page of its own and is one big button, so someone can switch a light by pushing anywhere on the screen without looking. A `controls` choice, a small slider or a graph sits at the bottom of that page (a full-page tile shows no control unless you choose one).
|
||||
|
||||
Fire one of these events and ESP Screens changes that screen and sends it right away, the same way its own editor does.
|
||||
|
||||
@@ -90,7 +92,7 @@ actions:
|
||||
| `name` | A name of your own on the tile; leave it out to keep Home Assistant's. |
|
||||
| `page` | Page, counted from 1. Without a spot the tile takes the first free one on that page. |
|
||||
| `row`, `column` | An exact spot on that page: row 1 to {SLOTS_PER_PAGE // 2}, column `left` or `right`. |
|
||||
| `size` | `single` or `wide`. |
|
||||
| `size` | `single`, `wide` or `full` (the whole page; firmware {FULL_PAGE_VERSION} or newer). |
|
||||
| `controls` | What you can operate on the tile itself (see below). |
|
||||
| `display` | How the tile draws itself (see below). |
|
||||
| `icon`, `color` | An icon from the list further down, and one of the pastel colors. |
|
||||
@@ -100,6 +102,8 @@ actions:
|
||||
|
||||
A tile with a control, a forecast or a sun path is drawn double-width on its own; you don't have to ask for that.
|
||||
|
||||
A navigation tile goes to another page: add the entity `screen.page_3` (for page 3, `screen.page_1` to `screen.page_{MAX_PAGES}`) with a `name` such as "Heating"; it shows an arrow (or an `icon`), its name and the page number, and a tap opens that page. Handy as a menu on page 2 when page 1 is one full-page light switch. One such tile per page it goes to; it is single or double width, never full-page.
|
||||
|
||||
### What a tile can do, per kind of entity
|
||||
|
||||
| Entity | `controls` |
|
||||
@@ -114,7 +118,7 @@ Everything else shows its name and state, and opens a card of its own on a long
|
||||
|
||||
### Reading a screen first
|
||||
|
||||
Every screen also publishes what it shows, as `sensor.esp_screens_<device name>`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `size`, `controls` and `display` per tile. Read that before moving things around, so you know what is already there and where.
|
||||
Every screen also publishes what it shows, as `sensor.esp_screens_<device name>`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `size`, `controls` and `display` per tile (and `to_page` for a `screen.page_<n>` tile). Read that before moving things around, so you know what is already there and where.
|
||||
|
||||
Ordering a page means naming the tiles that are on it, in the order you want:
|
||||
|
||||
@@ -131,7 +135,7 @@ A tile from another page has to be moved there first (`esp_screens_move_tile` wi
|
||||
|
||||
### What comes back
|
||||
|
||||
ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity` and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has twenty tiles"). The add-on log says the same. Nothing changes on a refused event.
|
||||
ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity` and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has {MAX_TILES} tiles", "No page is empty; a full-page tile needs a page of its own"). The add-on log says the same. Nothing changes on a refused event.
|
||||
|
||||
### How to work
|
||||
|
||||
|
||||
+111
-45
@@ -11,16 +11,26 @@ 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.
|
||||
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings'}
|
||||
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; one per page it goes to, so an entity still appears once.
|
||||
PAGE_TILE = 'screen.page_'
|
||||
|
||||
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
|
||||
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'))
|
||||
CAMERA_MIN_FIRMWARE = (0, 2, 57)
|
||||
# Forty-eight tiles (one per slot), a tile over the whole page and the screen.page tile (firmware 0.2.62+).
|
||||
MAX_TILES = 48
|
||||
LEGACY_MAX_TILES = 20
|
||||
FULL_PAGE_MIN_FIRMWARE = (0, 2, 62)
|
||||
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.
|
||||
FIRMWARE_VERSION = '0.2.61'
|
||||
FIRMWARE_VERSION = '0.2.62'
|
||||
# The Auto standby switch a screen offers Home Assistant automations.
|
||||
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
|
||||
# The settings page the screen opens itself, and the screen.settings tile that opens it.
|
||||
@@ -63,22 +73,44 @@ SLOTS_PER_PAGE = 6
|
||||
MAX_PAGES = 8
|
||||
MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE
|
||||
|
||||
def is_wide(tile):
|
||||
return tile.get('options', {}).get('size') == 'wide'
|
||||
TILE_SIZES_ON_SCREEN = ('single', 'wide', 'full')
|
||||
|
||||
def footprint(slot, wide):
|
||||
return (slot, slot + 1) if wide else (slot,)
|
||||
def tile_size(tile):
|
||||
"""'single', 'wide' (a row) or 'full' (the whole page, firmware 0.2.62+)."""
|
||||
size = tile.get('options', {}).get('size', 'single')
|
||||
return size if size in TILE_SIZES_ON_SCREEN else 'single'
|
||||
|
||||
def is_wide(tile):
|
||||
"""Double width or the whole page: the card spans both columns."""
|
||||
return tile_size(tile) != 'single'
|
||||
|
||||
def is_full(tile):
|
||||
return tile_size(tile) == 'full'
|
||||
|
||||
def cells_of(size):
|
||||
return SLOTS_PER_PAGE if size == 'full' else 2 if size in ('wide', True) else 1
|
||||
|
||||
def page_start(slot):
|
||||
return slot - slot % SLOTS_PER_PAGE
|
||||
|
||||
def footprint(slot, size):
|
||||
"""The cells a tile of `size` takes from `slot` (True still means wide)."""
|
||||
if size == 'full':
|
||||
return tuple(range(page_start(slot), page_start(slot) + SLOTS_PER_PAGE))
|
||||
return (slot, slot + 1) if size in ('wide', True) else (slot,)
|
||||
|
||||
def pack_slots(tiles):
|
||||
"""In-order packing, the rule before explicit positions and what firmware without
|
||||
`slots` still does: fill left to right, a wide card starts a new row."""
|
||||
`slots` still does: fill left to right, a wide card starts a new row, a full one a new page."""
|
||||
position, slots = 0, []
|
||||
for tile in tiles:
|
||||
wide = is_wide(tile)
|
||||
if wide and position % 2:
|
||||
size = tile_size(tile)
|
||||
if size == 'full' and position % SLOTS_PER_PAGE:
|
||||
position += SLOTS_PER_PAGE - position % SLOTS_PER_PAGE
|
||||
elif size == 'wide' and position % 2:
|
||||
position += 1
|
||||
slots.append(position)
|
||||
position += 2 if wide else 1
|
||||
position += cells_of(size)
|
||||
return slots
|
||||
|
||||
def has_gaps(tiles):
|
||||
@@ -118,9 +150,10 @@ def resolve_controls(tile):
|
||||
"""Control set a card shows on the screen, or None: only wide cards in the standard layout have room for one."""
|
||||
options = tile.get('options', {})
|
||||
domain = tile['entity'].split('.')[0]
|
||||
if domain not in CONTROLS or options.get('size') != 'wide' or options.get('display', 'standard') != 'standard' or options.get('inline') == 'slider':
|
||||
if domain not in CONTROLS or options.get('size') not in ('wide', 'full') or options.get('display', 'standard') != 'standard' or options.get('inline') == 'slider':
|
||||
return None
|
||||
choice = options.get('controls', CONTROLS[domain][0][0])
|
||||
# A full-page card is one big button unless a control was chosen for it; a wide card shows its usual one.
|
||||
choice = options.get('controls', 'none' if options.get('size') == 'full' else CONTROLS[domain][0][0])
|
||||
return None if choice == 'none' else choice
|
||||
|
||||
# Diagnostic entities every ESP Screens firmware exposes; the manager watches them for screens.
|
||||
@@ -353,6 +386,8 @@ def validate_header(data):
|
||||
|
||||
def min_firmware(layout):
|
||||
"""Oldest firmware that still accepts this layout; None when any version works."""
|
||||
if len(layout['tiles']) > LEGACY_MAX_TILES or any(is_full(t) or page_target(t['entity']) for t in layout['tiles']):
|
||||
return FULL_PAGE_MIN_FIRMWARE
|
||||
if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']):
|
||||
return CAMERA_MIN_FIRMWARE
|
||||
if any(t['entity'] == 'screen.settings' for t in layout['tiles']):
|
||||
@@ -381,7 +416,8 @@ TILE_RESULT_EVENT = 'esp_screens_tile_result'
|
||||
TILE_EVENT_OPTIONS = {'size': 'size', 'controls': 'controls', 'display': 'display', 'icon': 'icon',
|
||||
'color': 'background', 'background': 'background', 'tap': 'tap', 'inline': 'inline',
|
||||
'history_hours': 'history_hours'}
|
||||
TILE_SIZES = {'wide': 'wide', 'double': 'wide', 'large': 'wide', 'big': 'wide',
|
||||
TILE_SIZES = {'full': 'full', 'fullscreen': 'full', 'full screen': 'full', 'full-screen': 'full', 'page': 'full', 'whole page': 'full',
|
||||
'wide': 'wide', 'double': 'wide', 'large': 'wide', 'big': 'wide',
|
||||
'single': 'single', 'small': 'single', 'normal': 'single'}
|
||||
|
||||
def loose(text):
|
||||
@@ -412,34 +448,43 @@ def occupied_cells(tiles, skip=None):
|
||||
for tile in tiles:
|
||||
if tile is skip or 'slot' not in tile:
|
||||
continue
|
||||
cells.update(footprint(tile['slot'], is_wide(tile)))
|
||||
cells.update(footprint(tile['slot'], tile_size(tile)))
|
||||
return cells
|
||||
|
||||
def free_slot(tiles, wide, page=None, skip=None):
|
||||
"""The first cell a tile of this width fits in, on `page` or anywhere; None when there is no room."""
|
||||
def free_slot(tiles, size, page=None, skip=None):
|
||||
"""The first cell a tile of this size fits in, on `page` or anywhere; None when there is no room."""
|
||||
cells = occupied_cells(tiles, skip)
|
||||
size = 'wide' if size is True else size
|
||||
first = 0 if page is None else page * SLOTS_PER_PAGE
|
||||
last = MAX_SLOTS if page is None else min(MAX_SLOTS, first + SLOTS_PER_PAGE)
|
||||
for slot in range(first, last):
|
||||
if wide and (slot % 2 or slot + 1 >= last):
|
||||
if size == 'full' and slot % SLOTS_PER_PAGE:
|
||||
continue
|
||||
if not set(footprint(slot, wide)) & cells:
|
||||
if size == 'wide' and (slot % 2 or slot + 1 >= last):
|
||||
continue
|
||||
if not set(footprint(slot, size)) & cells:
|
||||
return slot
|
||||
return None
|
||||
|
||||
def place_tile(tile, tiles, page=None, slot=None):
|
||||
"""Give a tile its cell: the one asked for when it is free, else the first free one (on `page`)."""
|
||||
wide = is_wide(tile)
|
||||
size = tile_size(tile)
|
||||
if slot is not None:
|
||||
if wide and slot % 2:
|
||||
if size == 'full':
|
||||
slot = page_start(slot)
|
||||
elif size == 'wide' and slot % 2:
|
||||
slot -= 1
|
||||
taken = next((t for t in tiles if t is not tile and slot in footprint(t.get('slot', -9), is_wide(t))), 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.")
|
||||
tile['slot'] = slot
|
||||
return
|
||||
free = free_slot(tiles, wide, page, skip=tile)
|
||||
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.')
|
||||
tile['slot'] = free
|
||||
|
||||
@@ -462,7 +507,7 @@ def tile_options(data, current=None):
|
||||
options['action'] = {'action': str(data['action']).strip(), **({'data': data['data']} if isinstance(data.get('data'), dict) and data['data'] else {})}
|
||||
if data.get('tap') in (None, ''):
|
||||
options['tap'] = 'action'
|
||||
if options.get('controls', 'none') != 'none' or options.get('display') in WIDE_ONLY:
|
||||
if (options.get('controls', 'none') != 'none' or options.get('display') in WIDE_ONLY) and options.get('size') != 'full':
|
||||
options['size'] = 'wide'
|
||||
return {key: value for key, value in options.items() if value not in (None, '')}
|
||||
|
||||
@@ -501,13 +546,15 @@ def pack_page(tiles, page):
|
||||
position = page * SLOTS_PER_PAGE
|
||||
last = position + SLOTS_PER_PAGE
|
||||
for tile in tiles:
|
||||
wide = is_wide(tile)
|
||||
if wide and position % 2:
|
||||
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.')
|
||||
if size == 'wide' and position % 2:
|
||||
position += 1
|
||||
if position + (2 if wide else 1) > last:
|
||||
if position + cells_of(size) > last:
|
||||
raise ValueError(f'That does not fit on page {page + 1}; a double-width tile takes two spots.')
|
||||
tile['slot'] = position
|
||||
position += 2 if wide else 1
|
||||
position += cells_of(size)
|
||||
|
||||
def apply_tile_event(layout, action, data):
|
||||
"""The layout after one tile event. Raises ValueError with the sentence the log and the answer show."""
|
||||
@@ -559,7 +606,7 @@ def apply_tile_event(layout, action, data):
|
||||
else:
|
||||
if not entity_id(entity) and entity not in BUILTIN:
|
||||
raise ValueError(f'{entity} cannot go on a screen.')
|
||||
was_wide, had_slot = bool(found) and is_wide(found), (found or {}).get('slot')
|
||||
was_size, had_slot = tile_size(found) if found else 'single', (found or {}).get('slot')
|
||||
options = tile_options(data, (found or {}).get('options'))
|
||||
tile = found or {'entity': entity, 'name': ''}
|
||||
if data.get('name') not in (None, ''):
|
||||
@@ -569,17 +616,25 @@ def apply_tile_event(layout, action, data):
|
||||
elif found:
|
||||
tile.pop('options', None)
|
||||
if found is None:
|
||||
if len(tiles) >= 20:
|
||||
raise ValueError('This screen already has twenty tiles; remove one first.')
|
||||
if len(tiles) >= MAX_TILES:
|
||||
raise ValueError(f'This screen already has {MAX_TILES} tiles; remove one first.')
|
||||
tiles.append(tile)
|
||||
wide = is_wide(tile)
|
||||
size = tile_size(tile)
|
||||
move = found is None or had_slot is None or page is not None or slot is not None
|
||||
if not move and wide != was_wide:
|
||||
# A tile that just grew keeps its spot when the cell beside it is free.
|
||||
move = (wide and had_slot % 2) or bool(set(footprint(had_slot, wide)) & occupied_cells([t for t in tiles if t is not tile]))
|
||||
if not move and size != was_size:
|
||||
# A tile that just grew keeps its spot when the cells it needs are free.
|
||||
start = page_start(had_slot) if size == 'full' else had_slot - had_slot % 2 if size == 'wide' else had_slot
|
||||
move = start != had_slot or bool(set(footprint(start, size)) & occupied_cells([t for t in tiles if t is not tile]))
|
||||
if move:
|
||||
tile.pop('slot', None)
|
||||
place_tile(tile, tiles, page, slot)
|
||||
if size == 'full' and had_slot is not None and page is None and slot is None:
|
||||
# A tile that grew to the whole page stays on its page when the page is otherwise empty.
|
||||
try:
|
||||
place_tile(tile, tiles, had_slot // SLOTS_PER_PAGE)
|
||||
except ValueError:
|
||||
place_tile(tile, tiles)
|
||||
else:
|
||||
place_tile(tile, tiles, page, slot)
|
||||
result['tiles'] = tiles
|
||||
return result
|
||||
|
||||
@@ -594,7 +649,8 @@ def layout_snapshot(screen, layout):
|
||||
'column': 'right' if slot % 2 else 'left', 'slot': slot,
|
||||
'size': options.get('size', 'single'), 'controls': options.get('controls', ''),
|
||||
'display': options.get('display', 'standard'), 'tap': options.get('tap', 'auto'),
|
||||
**({'action': options['action']} if options.get('tap') == 'action' and 'action' in options else {})})
|
||||
**({'action': options['action']} if options.get('tap') == 'action' and 'action' in options else {}),
|
||||
**({'to_page': page_target(tile['entity'])} if page_target(tile['entity']) else {})})
|
||||
return {'screen': screen.get('name', ''), 'node': screen.get('node') or '', 'title': layout.get('title', ''),
|
||||
'pages': max([tile['page'] for tile in tiles], default=1), 'tiles': tiles}
|
||||
|
||||
@@ -653,8 +709,8 @@ def validate_layout(data, stored=False):
|
||||
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.')
|
||||
if not isinstance(tiles, list) or len(tiles) > 20:
|
||||
raise ValueError('Choose at most 20 tiles.')
|
||||
if not isinstance(tiles, list) or len(tiles) > MAX_TILES:
|
||||
raise ValueError(f'Choose at most {MAX_TILES} tiles.')
|
||||
clean, seen = [], set()
|
||||
for tile in tiles:
|
||||
if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
|
||||
@@ -678,18 +734,23 @@ def validate_layout(data, stored=False):
|
||||
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.')
|
||||
# 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.')
|
||||
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.')
|
||||
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.')
|
||||
domain = tile['entity'].split('.')[0]
|
||||
displays = DISPLAYS.get(domain, ('standard', 'watch'))
|
||||
choices = {'tap': ('auto', 'detail', 'toggle', 'none', 'action'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
|
||||
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)
|
||||
# The five-day strip and the sun path only fit a double-width card.
|
||||
if options.get('display') in WIDE_ONLY:
|
||||
# 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.
|
||||
@@ -721,9 +782,12 @@ def validate_layout(data, stored=False):
|
||||
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.')
|
||||
if is_wide(item) and slot % 2:
|
||||
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.')
|
||||
if size == 'wide' and slot % 2:
|
||||
raise ValueError('A double-width tile starts in the left column.')
|
||||
for cell in footprint(slot, is_wide(item)):
|
||||
for cell in footprint(slot, size):
|
||||
if cell in occupied:
|
||||
raise ValueError('Two tiles are in the same spot.')
|
||||
occupied.add(cell)
|
||||
@@ -1040,8 +1104,10 @@ 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': {}}
|
||||
if 'options' in tile:
|
||||
message['o'] = tile['options']
|
||||
# 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:
|
||||
message['o'] = options
|
||||
return message
|
||||
state = states.get(tile['entity'], {})
|
||||
attrs = state.get('attributes', {})
|
||||
|
||||
@@ -371,8 +371,16 @@ DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot
|
||||
'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge',
|
||||
'timer': 'timer-outline', 'person': 'account', 'camera': 'cctv', 'image': 'cctv', 'screen': 'clock-outline'}
|
||||
# The cards the screen brings itself: one icon per entity, not per domain.
|
||||
BUILTIN_TILES = {'screen.clock': 'clock-outline', 'screen.settings': 'cog'}
|
||||
BUILTIN_TILES = {'screen.clock': 'clock-outline', 'screen.settings': 'cog', **{f'screen.page_{n}': 'arrow-right' for n in range(1, 9)}}
|
||||
FALLBACK = 'power'
|
||||
# The large icon font of a card that takes the whole page (firmware 0.2.62+): what the screen draws on its own for a
|
||||
# domain, a state or a built-in card, at 64 px on the Guition and 40 px on the CYD. Kept to these so the CYD's flash
|
||||
# stays free; a chosen icon outside this set shows at its usual size in the big circle.
|
||||
BIG_GLYPHS = tuple(dict.fromkeys(list(DEFAULTS.values()) + list(BUILTIN_TILES.values()) + ['lightbulb-off', FALLBACK] +
|
||||
['weather-sunny', 'weather-night', 'weather-cloudy', 'weather-partly-cloudy', 'weather-rainy',
|
||||
'weather-pouring', 'weather-snowy', 'weather-snowy-rainy', 'weather-fog', 'weather-hail',
|
||||
'weather-lightning', 'weather-lightning-rainy', 'weather-windy', 'alert-circle-outline',
|
||||
'weather-sunset-up', 'weather-sunset-down']))
|
||||
CONTROL_GLYPHS = ('play', 'pause', 'stop', 'skip-next', 'skip-previous', 'volume-high', 'volume-off', 'arrow-up', 'arrow-down',
|
||||
'arrow-expand-horizontal', 'arrow-collapse-horizontal', 'home-map-marker', 'plus', 'minus', 'chevron-left',
|
||||
'chevron-right', 'close', 'power', 'fire', 'snowflake')
|
||||
|
||||
Reference in New Issue
Block a user