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:
Max Gramser
2026-09-18 08:44:44 +02:00
parent 00c9a4e4f3
commit bb25353089
29 changed files with 948 additions and 128 deletions
+111 -45
View File
@@ -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', {})