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>
This commit is contained in:
Max Gramser
2026-09-18 22:57:14 +02:00
parent e7e173d3b7
commit d76461d774
45 changed files with 6636 additions and 19954 deletions
+7 -5
View File
@@ -2,7 +2,7 @@
# The release checks of docs/RELEASING.md step 2 in one command, the same on a laptop and in CI
# (.github/workflows/ci.yml, app 0.2.78). Runs from any folder: every path is taken from this repository.
#
# tools/check.sh the Python tests, every tests/*.cpp, both generators with --check, the editor's
# tools/check.sh the Python tests, every tests/*.cpp, the package check, the icon generator's --check, the editor's
# tests, types and build, and whether the editor bundle in Git equals that build
# tools/check.sh --firmware compiles both board profiles and applies the CYD's flash budget
# tools/check.sh --all both
@@ -120,7 +120,7 @@ cpp_tests() {
((total > 0 && good == total))
}
packages_current() { cd "$ROOT" && "$PYTHON" tools/generate_packages.py --check; }
packages_current() { cd "$ROOT" && "$PYTHON" tools/check_packages.py; }
icons_current() { cd "$ROOT" && "$PYTHON" tools/generate_icons.py --check; }
editor_install() { cd "$ROOT/web" && npm ci --no-audit --no-fund; }
editor_tests() { cd "$ROOT/web" && npm test; }
@@ -158,10 +158,12 @@ esphome_version() {
# The builds users get come from the YAML core.installation_yaml() writes: the board's package plus the device's own
# keys, the Wi-Fi fallback access point and captive_portal. The board profiles carry all of that (with !secret), so a
# copy of each profile compiles in a temporary folder with placeholder secrets of the same length as real ones, next
# to links to this tree's components and fonts: this commit's code, never GitHub's main, never the real secrets.yaml.
# to links to this tree's components, fonts and packages (the shared core and the board files the profile includes):
# this commit's code, never GitHub's main, never the real secrets.yaml.
prepare_profiles() {
local config="$WORK/config" board file
mkdir -p "$config" && ln -s "$ROOT/components" "$config/components" && ln -s "$ROOT/fonts" "$config/fonts" || return 1
mkdir -p "$config" && ln -s "$ROOT/components" "$config/components" && ln -s "$ROOT/fonts" "$config/fonts" \
&& ln -s "$ROOT/packages" "$config/packages" || return 1
cat > "$config/secrets.yaml" <<'EOF' || return 1
# Placeholders for the check builds (tools/check.sh); nothing here is a real key.
wifi_ssid: "check-wifi"
@@ -254,7 +256,7 @@ if ((want_fast)); then
run "Python packages" python_packages
run "Python tests" python_tests
run "C++ tests" cpp_tests
run "Packages match the profiles" packages_current
run "Packages fit together" packages_current
run "Icons match tile_icons.py" icons_current
run "Editor: npm ci" editor_install
if ((last_ok)); then
+85
View File
@@ -0,0 +1,85 @@
"""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()
+3 -3
View File
@@ -6,7 +6,7 @@ list as a YAML anchor, the smaller sizes reuse it); the big font of the full-pag
card (id ending in _big) gets tile_icons.BIG_GLYPHS as its own anchor, and the editor gets a subset
of the same TTF so its mockup shows the glyphs the screen draws. The top bar
mockup also gets Roboto 400/500 with the glyphs of header_bar.GLYPHS, so text
widths in the editor match the screen. Run tools/generate_packages.py afterwards;
widths in the editor match the screen. The fonts live in packages/core.yaml, shared by every board;
--check changes nothing and fails when anything is out of date.
"""
import argparse
@@ -24,12 +24,12 @@ import header_bar # noqa: E402
import tile_icons # noqa: E402
TTF = ROOT / 'fonts/materialdesignicons-webfont.ttf'
PROFILES = [ROOT / 'home-like-2432s028.yaml', ROOT / 'guition-4848s040.yaml']
PROFILES = [ROOT / 'packages/core.yaml'] # the fonts every board shares (app 0.2.84+); the sizes are the boards' substitutions
WEB_FONT = ROOT / 'web/src/assets/tile-icons.woff'
BAR_FONTS = {weight: ROOT / f'web/src/assets/bar-roboto-{weight}.woff' for weight in (400, 500)}
NAME_TABLE = ROOT / 'components/smart_display/tile_icon_names.h'
# Four bits per pixel: the default of one draws a thin ring like mdi:power as a lumpy circle on the CYD.
FONT_BLOCK = re.compile(r"( - file: 'fonts/materialdesignicons-webfont\.ttf'\n id: \w+\n size: \d+\n bpp: 4\n) glyphs:.*\n(?: .*\n)*")
FONT_BLOCK = re.compile(r"( - file: \"\$\{FONT_DIR\}/materialdesignicons-webfont\.ttf\"\n id: \w+\n size: [^\n]+\n bpp: 4\n) glyphs:.*\n(?: .*\n)*")
def verify():
"""Names and codepoints must match the TTF the firmware is built from."""
-56
View File
@@ -1,56 +0,0 @@
"""Generate self-contained remote packages from the maintained board profiles."""
import argparse
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
def generate(board, text=None):
source = ROOT / ('home-like-2432s028.yaml' if board == 'cyd' else 'guition-4848s040.yaml')
s = source.read_text() if text is None else text
# The owner's wizard YAML supplies wifi/API/OTA credentials. No shared keys.
s = re.sub(r'^ encryption:\n key: !secret api_encryption_key\n', '', s, flags=re.M)
s = re.sub(r'^ota:\n.*?(?=^\w)', '', s, flags=re.M | re.S)
s = re.sub(r'^wifi:\n.*?(?=^\w)', '', s, flags=re.M | re.S)
# Fonts are fetched over HTTPS; the headers come with the external component.
components = '[xpt2046, smart_display]' if board == 'cyd' else '[smart_display]'
external = f'''external_components:
- source:
type: git
url: {REPO}.git
ref: {REFS[board]}
path: components
refresh: 0s
components: {components}
'''
s = re.sub(r'^external_components:\n.*?(?=^\w)', lambda m: external, s, flags=re.M | re.S)
s = re.sub(r'file: ["\']fonts/([^"\']+)["\']', lambda m: 'file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/' + m[1] + '"', s)
# A screen gets its values from ESP Screen Manager while it runs, entity changes included:
# a fixed Home Assistant subscription in a board profile is a leftover.
for section in ['sensor', 'text_sensor']:
match = re.search(r'^' + section + r':\n.*?(?=^[a-zA-Z_]+:|\Z)', s, re.M | re.S)
if match and re.search(r'^ - platform: homeassistant$', match[0], re.M):
raise ValueError(f'{source.name}: a {section} subscribes to Home Assistant; runtime tiles bring their own values')
if '!secret' in s: raise ValueError('Remote package still depends on local secrets')
return '# GENERATED by tools/generate_packages.py; edit the source board profile.\n' + s
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
for board in REFS:
source = ROOT / ('home-like-2432s028.yaml' if board == 'cyd' else 'guition-4848s040.yaml')
if not source.exists(): continue
path = ROOT / 'packages' / f'{board}.yaml'
expected = generate(board)
if args.check:
if not path.exists() or path.read_text() != expected: raise SystemExit(f'Outdated: {path}')
else:
path.parent.mkdir(exist_ok=True)
path.write_text(expected)
print(f'{board}: package ' + ('verified' if args.check else 'generated'))
if __name__ == '__main__': main()
+75
View File
@@ -0,0 +1,75 @@
"""The board profiles as the tests and tools read them (app 0.2.84+).
A screen is three files: an entry (`home-like-2432s028.yaml` or `packages/cyd.yaml` for a CYD, `guition-4848s040.yaml`
or `packages/guition.yaml` for a Guition), `packages/core.yaml` that every board shares, and the board's own file under
`packages/boards/`. `text(name)` is the three of them one after the other, so a check that looks for a line finds it
wherever it lives; `resolved(name)` also fills in the `${NAME}` substitutions from the three files (the sizes, the fonts
and the hooks with the board's code), for a check that needs the numbers or the whole lambda. Neither is what ESPHome
builds: that is the merge ESPHome's packages component makes (docs/PROFILES.md); the firmware check in tools/check.sh
compiles the real thing.
"""
import re
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
CORE = ROOT / 'packages/core.yaml'
BOARDS = {'cyd': ROOT / 'packages/boards/cyd-2432s028.yaml', 'guition': ROOT / 'packages/boards/guition-4848s040.yaml'}
ENTRIES = {'home-like-2432s028.yaml': 'cyd', 'guition-4848s040.yaml': 'guition',
'packages/cyd.yaml': 'cyd', 'packages/guition.yaml': 'guition'}
# The names the four entry files are known by, in the order the older tests listed them.
PROFILES = ('home-like-2432s028.yaml', 'guition-4848s040.yaml')
PACKAGES = ('packages/cyd.yaml', 'packages/guition.yaml')
NAMES = PROFILES + PACKAGES
def board_of(name):
return ENTRIES[str(name).replace(str(ROOT) + '/', '')]
def files(name):
"""The entry, the core and the board file of a profile name, in that order."""
return [ROOT / name, CORE, BOARDS[board_of(name)]]
def text(name):
return '\n'.join(path.read_text() for path in files(name))
def substitutions_of(path):
"""The `substitutions:` block of one file, as ESPHome reads it (block scalars included)."""
block = re.search(r'^substitutions:\n(.*?)(?=^[a-z_]+:|\Z)', path.read_text(), re.M | re.S)
if not block:
return {}
values = yaml.safe_load('substitutions:\n' + block[1])['substitutions'] or {}
return {key: '' if value is None else str(value) for key, value in values.items()}
def substitutions(name):
"""Every substitution the three files define, the entry's winning over the board's over the core's."""
values = {}
for path in reversed(files(name)):
values.update(substitutions_of(path))
return values
def resolve(source, values):
"""`${NAME}` filled in, until nothing is left to fill (a hook's code names sizes of its own)."""
for _ in range(4):
before = source
source = re.sub(r'\$\{([A-Z_][A-Z0-9_]*)\}', lambda m: values.get(m[1], m[0]), source)
if source == before:
break
return source
def resolved(name):
return resolve(text(name), substitutions(name))
def merged(name):
"""Closer to what ESPHome builds: the three files without their substitutions blocks, every ${NAME} filled in.
For a check that counts things, so a hook's definition in the board file is not counted next to its use."""
without = [re.sub(r'^substitutions:\n(.*?)(?=^[a-z_]+:|\Z)', '', path.read_text(), count=1, flags=re.M | re.S) for path in files(name)]
return resolve('\n'.join(without), substitutions(name))
+5 -3
View File
@@ -21,6 +21,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WORK = ROOT / '.esphome' / 'render-topbar'
PROFILES = {'guition': 'guition-4848s040.yaml', 'cyd': 'home-like-2432s028.yaml'}
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
FONTS = ('headline', 'time_label', 'sublabel_big', 'label', 'materialdesign_icons', 'materialdesign_icons_mini')
# Hardware headers the host cannot compile; the top bar needs none of them.
SKIP = {'guition_diagnostics.h', '__pycache__'}
@@ -39,8 +41,8 @@ SCENARIOS = (
)
def font_blocks(board):
"""The profile's font entries, ids suffixed per board and file paths made absolute."""
text = (ROOT / PROFILES[board]).read_text()
"""The shared core's font entries at this board's sizes, ids suffixed per board and file paths made absolute."""
text = profiles.resolve(profiles.CORE.read_text(), profiles.substitutions(PROFILES[board]))
fonts = text[text.index('\nfont:\n'):]
out = ''
for font_id in FONTS:
@@ -48,7 +50,7 @@ def font_blocks(board):
if not m:
raise SystemExit(f'Font {font_id} not found in {PROFILES[board]}')
block = m.group(0).replace(f'id: {font_id}\n', f'id: {font_id}_{board}\n')
block = re.sub(r'file: ["\']fonts/([^"\']+)["\']', lambda f: f'file: "{ROOT}/fonts/{f[1]}"', block)
block = re.sub(r'file: "fonts/([^"]+)"', lambda f: f'file: "{ROOT}/fonts/{f[1]}"', block)
out += block.replace('&tile_icons', f'&tile_icons_{board}').replace('*tile_icons\n', f'*tile_icons_{board}\n')
return out