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
+5 -11
View File
@@ -19,6 +19,8 @@ import re
import secrets
import time
from core import screen_firmware
LOG = logging.getLogger(__name__)
DOMAINS = ('camera', 'image')
@@ -55,17 +57,9 @@ def supported(entity):
and entity.split('.')[0] in DOMAINS)
def version(text):
try:
parts = tuple(int(part) for part in str(text or '').split('.'))
except ValueError:
return ()
return parts if len(parts) == 3 else ()
def can_show(screen):
"""A paired Guition with firmware that draws camera images."""
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= MIN_FIRMWARE
"""A paired Guition with firmware that draws camera images (the version feature gates go by, core.screen_firmware)."""
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= MIN_FIRMWARE
def cover_supported(entity):
@@ -76,7 +70,7 @@ def cover_supported(entity):
def can_show_cover(screen):
"""A paired Guition with firmware that draws the media card's cover."""
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= COVER_MIN_FIRMWARE
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= COVER_MIN_FIRMWARE
def cover_request(request):
+48 -14
View File
@@ -3,39 +3,73 @@
The Dockerfile copies CHANGELOG.md next to this file; a development checkout has it one folder up.
Each `## <app> (firmware <fw>)` section gives its bullet lines as plain text, newest first.
"""
import logging
import re
from pathlib import Path
LOG = logging.getLogger('screen_manager')
HEADER = re.compile(r'^## (\d+\.\d+\.\d+) \(firmware (\d+\.\d+\.\d+)\)')
# A heading of level one or two ends a section; a `### ` inside one doesn't.
SECTION_END = re.compile(r'^#{1,2}(\s|$)')
BULLET = re.compile(r'^[-*+]\s+(.*)$')
BOLD = re.compile(r'\*\*(.+?)\*\*')
# *Full page*: single asterisks around text. A lone asterisk (5 * 3) or one inside a word stays (app 0.2.78).
ITALIC = re.compile(r'(?<![\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])')
CODE = re.compile(r'`([^`]*)`')
LINK = re.compile(r'\[([^\]]+)\]\([^)]+\)')
def load(path=None, limit=20):
"""[{app, firmware, lines}] for the latest sections; [] without a changelog."""
"""[{app, firmware, lines}] for the latest sections; [] without a changelog, or with one that can't be read (a
folder, no permission, not UTF-8): the Update badge then has no notes, and the app still starts (app 0.2.78)."""
candidates = [Path(path)] if path else [Path(__file__).with_name('CHANGELOG.md'), Path(__file__).parent.parent / 'CHANGELOG.md']
for candidate in candidates:
if candidate.exists():
return parse(candidate.read_text(), limit)
try:
return parse(candidate.read_text(encoding='utf-8'), limit)
except (OSError, UnicodeDecodeError) as error:
LOG.warning("What's new stays empty: %s can't be read (%s)", candidate, type(error).__name__)
return []
return []
def parse(text, limit=20):
sections = []
"""The sections of a changelog. A bullet (`- ` or `* `) takes the lines that continue it, indented or wrapped
right under it; bullets under any other heading of level one or two belong to no section (app 0.2.78)."""
sections, current, item, blank = [], None, None, False
def close():
if item is not None and current is not None:
current['lines'].append(plain(' '.join(item)))
for line in text.splitlines():
match = HEADER.match(line)
if match:
if len(sections) >= limit:
break
sections.append({'app': match.group(1), 'firmware': match.group(2), 'lines': []})
elif sections and line.startswith('- '):
sections[-1]['lines'].append(plain(line[2:]))
stripped = line.strip()
if SECTION_END.match(line):
close()
item, match = None, HEADER.match(line)
if match and len(sections) >= limit:
return sections
current = {'app': match.group(1), 'firmware': match.group(2), 'lines': []} if match else None
if current:
sections.append(current)
elif (bullet := BULLET.match(line)) and current is not None:
close()
item = [bullet.group(1).strip()]
elif stripped and item is not None and (line[:1].isspace() or not blank) and not line.startswith('#'):
# Markdown continues a bullet with an indented line, or with any line right under it.
item.append(stripped)
elif stripped:
close()
item = None
blank = not stripped
close()
return sections
def plain(markdown):
text = BOLD.sub(r'\1', markdown)
text = CODE.sub(r'\1', text)
text = LINK.sub(r'\1', text)
return text.strip()
"""The text of one bullet without Markdown: bold, italics and links outside code, and the code's backticks."""
parts = CODE.split(markdown)
for index in range(0, len(parts), 2):
parts[index] = LINK.sub(r'\1', ITALIC.sub(r'\1', BOLD.sub(r'\1', parts[index])))
return ''.join(parts).strip()
+14 -6
View File
@@ -14,11 +14,13 @@ 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, 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)
CONTROLS, DISPLAYS, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_SLOTS, MAX_TILES, PAGE_TILE_REPEAT_MIN_FIRMWARE,
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)
# The same navigation tile on several pages (app 0.2.78).
PAGE_TILE_REPEAT_VERSION = '.'.join(str(part) for part in PAGE_TILE_REPEAT_MIN_FIRMWARE)
NAME = 'esp-screens'
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
@@ -92,6 +94,8 @@ 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`. |
| `slot` | An exact spot as the screen's sensor counts it, 0 to {MAX_SLOTS - 1}: instead of `page`, `row` and `column`. |
| `from_page`, `from_slot` | Only for `esp_screens_move_tile`: which copy of a navigation tile that is on several pages to move (see below). |
| `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). |
@@ -102,7 +106,11 @@ 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.
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. It is single or double width, never full-page.
On firmware {PAGE_TILE_REPEAT_VERSION} or newer the same navigation tile can be on several pages, such as a `screen.page_1` named "Back" on every other page: add it with the `page` (or spot) it goes on, and a page without a copy gets a new one. Older firmware has one per page it goes to, and adding it again moves that one. Every other entity is on a screen once.
When a navigation tile is on several pages, say which copy you mean: `page` or `slot` for `esp_screens_add_tile` (change that copy) and `esp_screens_remove_tile`, `from_page` or `from_slot` for `esp_screens_move_tile` (its `page` and `slot` say where to), and in `esp_screens_order_tiles` each time you name it takes the next copy. An event that doesn't say acts on the first copy, the one with the lowest `slot` in the sensor.
### What a tile can do, per kind of entity
@@ -118,7 +126,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 (and `to_page` for a `screen.page_<n>` 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`, `slot`, `size`, `controls` and `display` per tile (and `to_page` for a `screen.page_<n>` tile), every copy of a navigation tile on its own. 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:
@@ -135,7 +143,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 {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.
ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity`, the `page` and `slot` of the tile it placed, changed, moved or removed (not for an order) 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
+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 '',
+1 -1
View File
@@ -39,7 +39,7 @@ def profile_meta(text):
class Firmware:
OVERRIDE_SUFFIX = '.local.yaml'
OVERRIDE_LIMIT = 12 * 1024 # bytes; the request body limit is 16 KB and JSON escaping grows the text
OVERRIDE_LIMIT = 12 * 1024 # bytes; checked here, far below the request body limit (128 KB since app 0.2.78)
PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
'external_components', 'captive_portal'})
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'})
+66 -16
View File
@@ -1,5 +1,6 @@
"""HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+), each screen's alert actions when an alert event for every screen fires, and one persistent notification when a nightly update stops."""
import asyncio
from collections import Counter
import contextlib
import json
import logging
@@ -17,8 +18,9 @@ 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, apply_tile_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, pack_slots, 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, 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 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
from zoneinfo import ZoneInfo
@@ -635,13 +637,11 @@ class Manager:
self.notify()
def firmware_version(self, inbox, screen=None):
"""The firmware a screen's features go by: its sensor, or while that has no version (offline, restarting) the
one Home Assistant's device registry kept (app 0.2.78); None when neither says (core.screen_firmware)."""
if screen is None:
screen = self.screen(inbox) or {}
try:
version=tuple(int(part) for part in screen.get('firmware','').split('.'))
return version if len(version)==3 else None
except (ValueError, TypeError):
return None
return screen_firmware(screen)
def supports_twenty(self, inbox):
version=self.firmware_version(inbox)
@@ -963,7 +963,11 @@ class Manager:
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.')
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
# 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',[])
old_counts, new_counts = Counter(t['entity'] for t in old_list), Counter(t['entity'] for t in layout['tiles'])
old_tiles = {t['entity']:t for t in old_list if old_counts[t['entity']] == 1 and new_counts[t['entity']] == 1}
for tile in layout['tiles']:
old_options=old_tiles.get(tile['entity'],{}).get('options',{})
for key in ('background', 'icon', 'controls'):
@@ -974,7 +978,7 @@ class Manager:
# Restored options can widen a tile: an editor without positions packs again with
# the real widths, and explicit positions are checked once more for overlap.
if not any(isinstance(t, dict) and 'slot' in t for t in data.get('tiles', [])):
for tile, slot in zip(layout['tiles'], pack_slots(layout['tiles'])):
for tile, slot in zip(layout['tiles'], packed_slots(layout['tiles'])):
tile['slot'] = slot
layout = validate_layout(layout)
known = {e['id'] for e in entities} | set(BUILTIN)
@@ -982,6 +986,13 @@ class Manager:
raise ValueError('A chosen entity no longer exists. Look up the new entity.')
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.')
# 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
updated = {**self.layouts, inbox: layout}
self.write_layouts(updated)
self.layouts = updated
@@ -1132,6 +1143,12 @@ class Manager:
return False
tiles = layout['tiles']
layout_msg = self.layout_message(inbox, layout, screen)
try:
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'
return False
previous = self.sent.get(inbox)
full = force or not previous or previous['layout'] != layout_msg or dirty is None
# The top bar right after the layout (firmware 0.2.32+; older firmware draws the clock of show_clock).
@@ -1459,13 +1476,16 @@ class Manager:
return {'sent': len(ready) - len(failed), 'skipped': len(skipped), 'failed': len(failed)}
async def tile_event(self, event_type, data):
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does."""
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does. Returns the
screen and the tile the event acted on (None for an order)."""
screen = match_screen(self.screens(), data.get('screen'), self.layouts)
inbox = self.aliases.get(screen['id'], screen['id'])
layout = apply_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data)
# Firmware 0.2.65+ takes a navigation tile on several pages; an older screen keeps one per page it goes to.
repeat = (self.firmware_version(inbox, screen) or (0, 0, 0)) >= PAGE_TILE_REPEAT_MIN_FIRMWARE
layout, tile = run_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data, repeat)
await self.check_supported(inbox, layout)
self.save(inbox, layout)
return screen
return screen, tile
async def tile_loop(self):
"""Tile events (app 0.2.51+) in arrival order, apart from the sync loop, which can be busy for a
@@ -1475,8 +1495,11 @@ class Manager:
event_type, data = await queue.get()
answer = {'event': event_type, 'screen': data.get('screen') or '', 'entity': data.get('entity') or ''}
try:
screen = await self.tile_event(event_type, data)
screen, tile = await self.tile_event(event_type, data)
answer.update(ok=True, screen=screen['name'])
if tile is not None and 'slot' in tile:
# Which tile it was, which matters for a navigation tile that is on several pages (app 0.2.78).
answer.update(page=tile['slot'] // SLOTS_PER_PAGE + 1, slot=tile['slot'])
LOG.info('%s: %s on %s', event_type, answer['entity'] or 'order', screen['name'])
await self.publish_layouts()
except Exception as error:
@@ -1529,8 +1552,14 @@ def create_app(manager, development=False):
response = await handler(request)
except ValueError as error:
return web.json_response({'error': str(error)}, status=400)
# 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)
except (ConnectionError, TimeoutError):
return web.json_response({'error': "Home Assistant isn't reachable right now. Try again in a moment."}, status=503)
except web.HTTPRequestEntityTooLarge:
return web.json_response({'error': 'That is too much text for one request. Keep it below 12 KB.'}, status=413)
return web.json_response({'error': 'That request is too large.'}, status=413)
except (TypeError, KeyError):
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
if response.prepared:
@@ -1540,9 +1569,19 @@ def create_app(manager, development=False):
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'"
return response
app = web.Application(middlewares=[guard], client_max_size=16*1024)
# A layout of 48 tiles with actions on their taps passes 16 KB, the limit from the twenty-tile days; the largest the
# rules allow stays under about 75 KB (app 0.2.78). The YAML override keeps its own 12 KB limit (firmware.py).
app = web.Application(middlewares=[guard], client_max_size=128*1024)
static = Path(__file__).parent / 'static'
async def cache_assets(request, response):
"""The built script, styles and fonts under /assets/ never change under their name (Vite names each file after a
hash of its content), so a browser keeps them instead of fetching about 310 KB on every open (app 0.2.78).
`private`: behind ingress the request carries Home Assistant's session. index.html and api/ stay no-store."""
if request.path.startswith('/assets/') and response.status in (200, 304):
response.headers['Cache-Control'] = 'private, max-age=31536000, immutable'
app.on_response_prepare.append(cache_assets)
# The page is the Vite build of web/ (npm run build writes it here): index.html names its script and styles
# by a hash of their content, so a browser that keeps old copies anyway (Safari did after an update) never
# runs an old script against a new page.
@@ -1562,6 +1601,11 @@ def create_app(manager, development=False):
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')
# What the editor may offer this screen, by the firmware the app's own checks go by (app 0.2.78): an offline
# screen keeps its 48 tiles, and the editor needn't know the version rules.
version = manager.firmware_version(screen['id'], screen)
screen['firmware_known'] = version_text(version)
screen.update(firmware_features(version))
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
'pending': manager.pending_profiles(screens, profiles),
'updates': manager.updates.summary(screens, profiles)}
@@ -1576,6 +1620,8 @@ def create_app(manager, development=False):
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['header'] = {**header_bar.catalogue(), 'suggestions': {
@@ -1781,7 +1827,9 @@ async def main():
async with ClientSession(timeout=ClientTimeout(total=20)) as session:
ha = HomeAssistant(session, os.environ.get('HA_API', 'http://supervisor/core/api'), token)
manager = Manager(ha, Path(os.environ.get('SCREEN_DATA', '/data')) / 'screens.json')
runner = web.AppRunner(create_app(manager, development), access_log=None)
# handle_signals: SIGTERM (the Supervisor stopping the app, `docker stop`) and SIGINT end the app through the
# cleanup below, which also stops a running build, instead of waiting ten seconds for SIGKILL (app 0.2.78).
runner = web.AppRunner(create_app(manager, development), access_log=None, handle_signals=True)
await runner.setup()
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
# Camera images for the screens: their own port on the LAN, not the ingress page (docs/CAMERA.md).
@@ -1801,4 +1849,6 @@ async def main():
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
asyncio.run(main())
# The signal arrives as GracefulExit once everything is cleaned up, as in aiohttp's own run_app: a normal stop.
with contextlib.suppress(web.GracefulExit):
asyncio.run(main())
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-Cj3m_z3Z.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BL5pkID8.css">
<script type="module" crossorigin src="./assets/index-DfYonu9c.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DUJYFAbE.css">
</head>
<body>
<div id="app"></div>
+5 -8
View File
@@ -8,17 +8,13 @@ from pathlib import Path
import re
import time
import changelog
from core import FIRMWARE_VERSION
from core import FIRMWARE_VERSION, parse_firmware
LOG = logging.getLogger('screen_manager')
NIGHT_HOURS = range(3, 6)
def parse_version(text):
try:
parts = tuple(int(part) for part in str(text).split('.'))
except ValueError:
return None
return parts if len(parts) == 3 else None
# Strict X.Y.Z, the one rule the feature gates follow too: an update goes by what the screen's sensor reports now.
parse_version = parse_firmware
TARGET = parse_version(FIRMWARE_VERSION)
@@ -105,8 +101,9 @@ class Updater:
'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.
return {'auto': self.auto, 'target': FIRMWARE_VERSION, 'busy': self.current,
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round, 'changelog': self.changelog}
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round}
def pending(self, screens=None, profiles=None):
# Callers that already hold the inventory and profile list pass them in; the inventory