Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)

A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host
harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and
Vue) found these, fixed here with Max's choices for the product questions:

- Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the
  waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap
  it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has
  no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built.
- App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its
  firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the
  live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes.
- Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone
  dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build.
- tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 17:25:23 +02:00
parent c09e4e333f
commit 1a6308aba7
80 changed files with 2623 additions and 320 deletions
+165 -32
View File
@@ -12,8 +12,10 @@ 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', **{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.
# 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.
PAGE_TILE = 'screen.page_'
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."""
@@ -28,11 +30,14 @@ COVER_MIN_FIRMWARE = (0, 2, 64)
MAX_TILES = 48
LEGACY_MAX_TILES = 20
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.
FIRMWARE_VERSION = '0.2.64'
FIRMWARE_VERSION = '0.2.65'
# 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.
@@ -115,6 +120,15 @@ def pack_slots(tiles):
position += cells_of(size)
return slots
def packed_slots(tiles):
"""pack_slots for tiles that must fit on the screen: ValueError when the packing runs past the last page (48
tiles with one of them double-width, or 43 with one full-page tile), before a save or an event stores it (app
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.")
return slots
def has_gaps(tiles):
"""True when the stored positions differ from the in-order packing, so firmware
before 0.2.26 (which ignores `slots`) would show another arrangement."""
@@ -168,6 +182,50 @@ NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam')
NAME_IP_ADDRESS = ('IP address', 'IP-adres')
SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS)
def parse_firmware(text):
"""(major, minor, patch) of a screen firmware version such as "0.2.63"; None for anything else. Strict on purpose:
the firmware reports exactly its project version, and anything with a suffix is not a release."""
match = re.fullmatch(r'([0-9]+)\.([0-9]+)\.([0-9]+)', text) if isinstance(text, str) else None
return tuple(int(part) for part in match.groups()) if match else None
# Home Assistant's device registry keeps an ESPHome device's version while the device is offline or restarting. With
# `esphome: project:` (both board profiles) the ESPHome integration writes "<project version> (ESPHome <version>)".
REGISTRY_FIRMWARE = re.compile(r'^([0-9]+\.[0-9]+\.[0-9]+) \(ESPHome ')
def registry_firmware(sw_version):
"""The screen firmware in a device registry sw_version, or None. Firmware built without a project has only
ESPHome's own version there, which must never read as a screen firmware."""
match = REGISTRY_FIRMWARE.match(sw_version) if isinstance(sw_version, str) else None
return match.group(1) if match else None
def known_firmware(sensor, sw_version):
"""The version text discover_screens gives a screen as `firmware_known`: the "Screen firmware" sensor when it holds
one, else the device registry's; None when neither does."""
return sensor if parse_firmware(sensor) else registry_firmware(sw_version)
def screen_firmware(screen):
"""The firmware a screen's feature gates go by (app 0.2.78): its "Screen firmware" sensor, or while that has no
version (the screen is offline or restarting) the one Home Assistant's device registry kept; None when neither
says. Before, an offline screen counted as firmware 0.0.0 and a save of more than ten tiles was refused."""
screen = screen or {}
return parse_firmware(screen.get('firmware_known')) or parse_firmware(screen.get('firmware'))
def version_text(version):
""""0.2.65" for (0, 2, 65); None for None."""
return '.'.join(str(part) for part in version) if version else None
def tile_limit(version):
"""How many tiles firmware `version` (a tuple, or None when unknown) takes."""
version = version or (0, 0, 0)
return MAX_TILES if version >= FULL_PAGE_MIN_FIRMWARE else LEGACY_MAX_TILES if version >= TWENTY_TILES_MIN_FIRMWARE else FIRST_MAX_TILES
def firmware_features(version):
"""What the editor may offer a screen with firmware `version` (a tuple, or None): the tile limit, full-page and
navigation tiles, and the same navigation tile on several pages."""
version = version or (0, 0, 0)
return {'tile_limit': tile_limit(version), 'full_page': version >= FULL_PAGE_MIN_FIRMWARE,
'page_tiles_repeat': version >= PAGE_TILE_REPEAT_MIN_FIRMWARE}
def entity_slug(name):
"""The end of an entity id Home Assistant derives from an entity name (ASCII names)."""
return re.sub(r'[^a-z0-9]+', '_', str(name).lower()).strip('_')
@@ -386,8 +444,15 @@ def validate_header(data):
items.append(clean)
return {'items': items}
def repeated_page_tiles(tiles):
"""True when a navigation tile to the same page is on the screen more than once (firmware 0.2.65+)."""
pages = [tile['entity'] for tile in tiles if page_target(tile['entity'])]
return len(pages) != len(set(pages))
def min_firmware(layout):
"""Oldest firmware that still accepts this layout; None when any version works."""
if repeated_page_tiles(layout['tiles']):
return PAGE_TILE_REPEAT_MIN_FIRMWARE
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']):
@@ -398,8 +463,8 @@ def min_firmware(layout):
return (0, 2, 16)
if any(t['entity'].split('.')[0] in NEW_DOMAINS for t in layout['tiles']):
return (0, 2, 14)
if len(layout['tiles']) > 10:
return (0, 2, 7)
if len(layout['tiles']) > FIRST_MAX_TILES:
return TWENTY_TILES_MIN_FIRMWARE
return None
def short(value, limit):
@@ -543,6 +608,29 @@ def event_slot(data, page):
def page_of(tile):
return tile.get('slot', 0) // SLOTS_PER_PAGE
def copies_of(tiles, entity):
"""The tiles of one entity in slot order: one, or several copies of a navigation tile (firmware 0.2.65+)."""
return sorted((tile for tile in tiles if tile['entity'] == entity), key=lambda tile: tile.get('slot', 0))
def pick_copy(found, page=None, slot=None):
"""The copy an event means: the one covering `slot`, else the first on `page`, else the first; None when none is
there. Only a navigation tile has several; an event that doesn't say which acts on the first (app 0.2.78)."""
if slot is not None:
return next((tile for tile in found if 'slot' in tile and slot in footprint(tile['slot'], tile_size(tile))), None)
if page is not None:
return next((tile for tile in found if page_of(tile) == page), None)
return found[0] if found else None
def event_source(data):
"""(page, slot) of the copy a move event means: `from_page` counted from one, or `from_slot`; None when not named."""
page = event_page({'page': data.get('from_page')})
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 pack_page(tiles, page):
"""Give these tiles the cells of one page, in the order they are in."""
position = page * SLOTS_PER_PAGE
@@ -558,12 +646,18 @@ def pack_page(tiles, page):
tile['slot'] = position
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."""
def run_tile_event(layout, action, data, repeat_pages=False):
"""(layout, tile): the layout after one tile event and the tile it placed, changed, moved or removed (None for an
order). Raises ValueError with the sentence the log and the answer show.
Only a navigation tile can be on a screen more than once, and only when its firmware takes that (`repeat_pages`,
0.2.65+). An event then names the copy it means by a spot it covers or by its page: `slot` or `page` for add and
remove, `from_slot` or `from_page` for a move (whose `slot` and `page` say where to), and each mention in an order
takes the next copy. Without that it acts on the first copy in spot order (app 0.2.78)."""
result = {key: value for key, value in layout.items() if key != 'tiles'}
tiles = [dict(tile) for tile in layout.get('tiles', [])]
entity = str(data.get('entity') or '').strip()
page, slot = event_page(data), None
page = event_page(data)
slot = event_slot(data, page)
if slot is not None and page is None:
page = slot // SLOTS_PER_PAGE
@@ -574,33 +668,57 @@ def apply_tile_event(layout, action, data):
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.')
known = {tile['entity']: tile for tile in tiles}
missing = [name for name in wanted if name not in known]
picked, taken, missing, elsewhere, extra = [], set(), [], [], []
for name in wanted:
copies = copies_of(tiles, name)
here = [tile for tile in copies if page is None or page_of(tile) == page]
free = [tile for tile in here if id(tile) not in taken]
if not copies:
missing.append(name)
elif not here:
elsewhere.append(name)
elif not free:
extra.append(name)
else:
picked.append(free[0])
taken.add(id(free[0]))
if missing:
raise ValueError('Not on this screen: ' + ', '.join(missing) + '.')
if elsewhere:
raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.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))))
if page is None:
rest = [tile for tile in tiles if tile['entity'] not in wanted]
tiles = [known[name] for name in wanted] + rest
for tile, cell in zip(tiles, pack_slots(tiles)):
tiles = picked + [tile for tile in tiles if id(tile) not in taken]
for tile, cell in zip(tiles, packed_slots(tiles)):
tile['slot'] = cell
else:
elsewhere = [name for name in wanted if page_of(known[name]) != page]
if elsewhere:
raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.join(elsewhere)))
on_page = [known[name] for name in wanted] + [t for t in tiles if page_of(t) == page and t['entity'] not in wanted]
pack_page(on_page, page)
pack_page(picked + [tile for tile in tiles if page_of(tile) == page and id(tile) not in taken], page)
result['tiles'] = tiles
return result
return result, None
if not entity:
raise ValueError('Name the entity.')
found = next((tile for tile in tiles if tile['entity'] == entity), None)
copies = copies_of(tiles, entity)
found = copies[0] if copies else None
if action == 'remove':
source_page, source_slot = event_source(data)
if source_page is None:
source_page, source_slot = page, slot
if not found:
raise ValueError(f'{entity} is not on this screen.')
tiles.remove(found)
found = pick_copy(copies, source_page, source_slot)
if not found:
raise ValueError(f'{entity} is not on {where(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.')
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)}.')
if page is None and slot is None:
raise ValueError('Name the page or the spot to move it to.')
found.pop('slot', None)
@@ -608,6 +726,12 @@ 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.')
# 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
if copies and repeat_pages and page_target(entity) and (page is not None or slot is not None):
found = pick_copy(copies, page, slot)
chosen = found is not None
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': ''}
@@ -622,23 +746,30 @@ def apply_tile_event(layout, action, data):
raise ValueError(f'This screen already has {MAX_TILES} tiles; remove one first.')
tiles.append(tile)
size = tile_size(tile)
move = found is None or had_slot is None or page is not None or slot is not None
move = found is None or had_slot is None or (not chosen and (page is not None or slot is not None))
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)
if size == 'full' and had_slot is not None and page is None and slot is None:
if size == 'full' and had_slot is not None and ((page is None and slot is None) or chosen):
# 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)
elif chosen:
place_tile(tile, tiles, page_of({'slot': had_slot}))
else:
place_tile(tile, tiles, page, slot)
found = tile
result['tiles'] = tiles
return result
return result, found
def apply_tile_event(layout, action, data, repeat_pages=False):
"""The layout after one tile event (run_tile_event without the tile it acted on)."""
return run_tile_event(layout, action, data, repeat_pages)[0]
def layout_snapshot(screen, layout):
"""What a screen shows, for the sensor the app publishes in Home Assistant: one entry per tile with
@@ -717,7 +848,8 @@ def validate_layout(data, stored=False):
for tile in tiles:
if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
raise ValueError("This entity isn't supported.")
if tile['entity'] in seen:
# 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.')
name = tile.get('name', '')
if not isinstance(name, str) or len(name.encode()) > 80:
@@ -796,7 +928,7 @@ def validate_layout(data, stored=False):
item['slot'] = slot
clean.sort(key=lambda item: item['slot'])
else:
for item, slot in zip(clean, pack_slots(clean)):
for item, slot in zip(clean, packed_slots(clean)):
item['slot'] = slot
result = {'title': title.strip(), 'tiles': clean}
# Pages kept on purpose, empty ones included; the screen shows at least what the tiles need.
@@ -1300,13 +1432,10 @@ def alert_targets(screens):
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason.
One call per device: a screen that shows up twice (an old inbox next to a renamed one) counts once."""
minimum = tuple(int(part) for part in ALERT_MIN_FIRMWARE.split('.'))
minimum = parse_firmware(ALERT_MIN_FIRMWARE)
ready, skipped, nodes = [], [], set()
for screen in screens:
try:
version = tuple(int(part) for part in str(screen.get('firmware') or '').split('.'))
except ValueError:
version = ()
version = screen_firmware(screen)
node = screen.get('node')
if node in nodes:
continue
@@ -1314,7 +1443,7 @@ def alert_targets(screens):
skipped.append((screen, 'device name unknown'))
elif not screen.get('online'):
skipped.append((screen, 'offline'))
elif len(version) != 3 or version < minimum:
elif version is None or version < minimum:
skipped.append((screen, f"firmware {screen.get('firmware') or 'unknown'}"))
else:
ready.append(screen)
@@ -1352,9 +1481,13 @@ def discover_screens(registry, states, devices, areas):
device = device_map.get(item.get('device_id'), {})
state = states.get(eid, {})
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
firmware = versions.get(item.get('device_id'), 'unknown')
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
'device_id': item.get('device_id'),
'firmware': versions.get(item.get('device_id'), 'unknown'),
'firmware': firmware,
# What feature gates go by: the sensor, or Home Assistant's device registry while the screen is
# offline or restarting (app 0.2.78).
'firmware_known': known_firmware(firmware, device.get('sw_version')),
'board': boards.get(item.get('device_id'), 'unknown'),
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
'device': device.get('name') or '',