Files
homeassistant_espscreen/tools/check_packages.py
T
Max Gramser d76461d774 Build every screen from one shared core plus a small file per board, the same firmware as before (0.2.84)
The two board profiles (5,000 lines each, 87 % the same text) become packages/core.yaml and one file per
board under packages/boards/: the hardware, a table of 84 sizes and font sizes, 18 hooks with the lines of C++
only that board adds inside a shared lambda, and the board's own sections. packages/cyd.yaml and
packages/guition.yaml keep their names and places (a screen installed from ESP Screens builds as before);
home-like-2432s028.yaml and guition-4848s040.yaml become the same entries for a checkout.

The firmware stays 0.2.70: the C++ ESPHome generates for each board, through the checkout profile and
through the GitHub package route, was compared line by line with the old profiles' (docs/TEST_RESULTS_0284.md).
tools/check_packages.py replaces tools/generate_packages.py; tools/profiles.py gives the tests and tools the
three files as one; docs/PROFILES.md says how the files fit together and how a board is added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:57:14 +02:00

86 lines
4.6 KiB
Python

"""Check that the packages a screen builds from fit together (app 0.2.84+; tools/check.sh runs this).
A screen is packages/core.yaml plus one board file under packages/boards/, included by an entry file: packages/<board>.yaml
for a screen that builds over GitHub, <profile>.yaml in the repository root for a build from a checkout. This checks what
ESPHome would only tell one build at a time:
- every entry names files that exist, and the published entry and the checkout entry of a board include the same two;
- nothing under packages/ carries a secret, a local component path or a fixed Home Assistant subscription: a screen
gets its values from ESP Screen Manager while it runs, and its keys from its own YAML;
- every ${NAME} the shared core uses is defined, and every board defines the same names: a new board file that
forgets a size or a hook fails here, not in the first build of a user.
"""
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import profiles # noqa: E402
ROOT = profiles.ROOT
PLACEHOLDER = re.compile(r'\$\{([A-Z_][A-Z0-9_]*)\}')
# Defined by the entry files (the fonts' place) or by ESP Screen Manager's screen YAML.
FROM_ENTRY = {'FONT_DIR'}
def included(path):
text = path.read_text()
block = re.search(r'^packages:\n(.*?)(?=^[a-z_]+:|\Z)', text, re.M | re.S)
return [str((path.parent / name).resolve().relative_to(ROOT)) for name in re.findall(r'!include (\S+)', block[1])] if block else []
def fail(message):
raise SystemExit(f'check_packages: {message}')
def main():
for board in profiles.BOARDS:
entries = [name for name, b in profiles.ENTRIES.items() if b == board]
wanted = {'packages/core.yaml', str(profiles.BOARDS[board].relative_to(ROOT))}
for name in entries:
files = included(ROOT / name)
if set(files) != wanted:
fail(f'{name} includes {files}, expected {sorted(wanted)}')
for file in files:
if not (ROOT / file).exists():
fail(f'{name} includes a file that does not exist: {file}')
for path in sorted((ROOT / 'packages').rglob('*.yaml')):
text = '\n'.join(line for line in path.read_text().split('\n') if not line.lstrip().startswith('#'))
for needle, why in (('!secret', 'a secret'), ('type: local', 'a local component path')):
if needle in text:
fail(f'{path.relative_to(ROOT)} carries {why} ({needle})')
# A screen gets its values from ESP Screen Manager while it runs: no fixed subscription (time: is the exception).
for section in ('sensor', 'binary_sensor', 'text_sensor'):
match = re.search(r'^' + section + r':\n.*?(?=^[a-zA-Z_]+:|\Z)', text, re.M | re.S)
if match and re.search(r'^ - platform: homeassistant$', match[0], re.M):
fail(f'{path.relative_to(ROOT)}: a {section} subscribes to Home Assistant; runtime tiles bring their own values')
# The names the core uses, comment lines left out (its header says "${NAME}" in words).
core = '\n'.join(line for line in profiles.CORE.read_text().split('\n') if not line.lstrip().startswith('#'))
core_names = set(profiles.substitutions_of(profiles.CORE))
used = set(PLACEHOLDER.findall(core))
board_names = {}
for board, path in profiles.BOARDS.items():
values = profiles.substitutions_of(path)
board_names[board] = set(values)
# A hook's code may name sizes of its own.
used_here = used | {name for value in values.values() for name in PLACEHOLDER.findall(value)}
missing = used_here - core_names - set(values) - FROM_ENTRY
if missing:
fail(f'{path.relative_to(ROOT)} does not define {sorted(missing)}, which the shared core uses')
boards = list(board_names)
for a in boards:
for b in boards:
only = board_names[a] - board_names[b]
only -= {name for name in only if name.startswith(('TOUCH_AFFINE_', 'TOUCH_CAL_', 'EDGE_SWIPE_', 'ALERT_CARD_H_IMAGE', 'ALERT_IMAGE_', 'ALERT_SUBTITLE_H_IMAGE', 'CAMERA_'))}
if only:
fail(f'{profiles.BOARDS[a].name} defines {sorted(only)}, which {profiles.BOARDS[b].name} lacks: a board-only name belongs in that board\'s own sections, a shared one in both')
unused = core_names - used
for board, path in profiles.BOARDS.items():
board_text = path.read_text()
unused -= set(PLACEHOLDER.findall(board_text))
print(f'packages: {len(used)} names in the shared core, defined by every board' + (f'; unused in the core: {sorted(unused)}' if unused else ''))
if __name__ == '__main__':
main()