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:
+7
-5
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user