Files
homeassistant_espscreen/tools/generate_icons.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

158 lines
7.5 KiB
Python

"""Write the tile icon set into both board profiles and build the editor's fonts.
The list lives in screen_manager/app/tile_icons.py. Every Material Design Icons
font of a board profile gets exactly those glyphs (the first font carries the
list as a YAML anchor, the smaller sizes reuse it); the big font of the full-page
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. The fonts live in packages/core.yaml, shared by every board;
--check changes nothing and fails when anything is out of date.
"""
import argparse
import io
import re
import sys
from pathlib import Path
from fontTools import subset
from fontTools.ttLib import TTFont
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402
import tile_icons # noqa: E402
TTF = ROOT / 'fonts/materialdesignicons-webfont.ttf'
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: \"\$\{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."""
codepoints = {}
for code, name in TTFont(TTF).getBestCmap().items():
codepoints.setdefault(name, code)
codepoints[name] = min(codepoints[name], code)
seen = set()
for name, code in tile_icons.GLYPHS.items():
if codepoints.get(name) != int(code, 16):
raise SystemExit(f'{name}: codepoint {code} does not match {TTF.name}')
if code in seen:
raise SystemExit(f'Duplicate glyph {code}')
seen.add(code)
if len(tile_icons.ICONS) != sum(len(icons) for _, icons in tile_icons.GROUPS):
raise SystemExit('An icon appears in more than one group')
def glyph_list(anchor='tile_icons', names=None):
lines = [f' glyphs: &{anchor} # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py']
chosen = tile_icons.GLYPHS if names is None else {name: tile_icons.GLYPHS[name] for name in names}
lines += [f' - "\\U000{code}" # {name}' for name, code in chosen.items()]
return '\n'.join(lines) + '\n'
def profile(text):
blocks = list(FONT_BLOCK.finditer(text))
if len(blocks) != 4:
raise SystemExit(f'Expected four icon fonts, found {len(blocks)}')
out, last = [], 0
for index, block in enumerate(blocks):
big = re.search(r'id: \w+_big\n', block[1])
glyphs = glyph_list('tile_icons_big', tile_icons.BIG_GLYPHS) if big else glyph_list() if index == 0 else ' glyphs: *tile_icons\n'
out += [text[last:block.start()], block[1], glyphs]
last = block.end()
return ''.join(out) + text[last:]
def name_table():
"""Name -> codepoint for the firmware, so a Home Assistant action can say icon: doorbell."""
lines = ['#pragma once',
'// GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py; do not edit.',
'#include <cstdint>', '#include <string>', '', 'namespace tile_icon {',
'struct Named { const char *name; uint32_t codepoint; };',
'// Every glyph the three icon fonts of both boards carry, by Material Design Icons name.',
'inline constexpr Named NAMES[] = {']
lines += [f' {{"{name}", 0x{code}}},' for name, code in tile_icons.GLYPHS.items()]
lines += ['};',
'inline constexpr size_t NAME_COUNT = sizeof(NAMES) / sizeof(NAMES[0]);',
'// Codepoint of a name the fonts carry; 0 for any other name.',
'inline uint32_t named(const std::string &name) {',
' for (const auto &entry : NAMES) if (name == entry.name) return entry.codepoint;',
' return 0;',
'}',
'// True when the fonts carry this codepoint, so a label can draw it.',
'inline bool carried(uint32_t codepoint) {',
' for (const auto &entry : NAMES) if (entry.codepoint == codepoint) return true;',
' return false;',
'}',
'} // namespace tile_icon']
return '\n'.join(lines) + '\n'
def web_font():
font = TTFont(TTF)
options = subset.Options()
options.flavor = 'woff'
options.layout_features = []
options.name_IDs = []
options.notdef_outline = False
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=[int(code, 16) for code in tile_icons.GLYPHS.values()])
subsetter.subset(font)
# The source stores xMin 0 with left bearing 0; saving recalculates xMin, and browsers
# then shift each glyph left by the difference. Keep the bearing equal to the real xMin.
glyf, hmtx = font['glyf'], font['hmtx']
for name in font.getGlyphOrder():
glyph = glyf[name]
glyph.recalcBounds(glyf)
hmtx[name] = (hmtx[name][0], getattr(glyph, 'xMin', 0))
buffer = io.BytesIO()
font.flavor = 'woff'
font.save(buffer)
return buffer.getvalue()
def bar_font(weight):
"""Roboto subset for the editor's top bar mockup: the glyphs and metrics the screens draw."""
font = TTFont(ROOT / f'fonts/Roboto-{weight}.ttf')
options = subset.Options()
options.flavor = 'woff'
options.layout_features = ['kern']
options.name_IDs = [0, 1, 2]
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=[ord(char) for char in header_bar.GLYPHS])
subsetter.subset(font)
buffer = io.BytesIO()
font.flavor = 'woff'
font.save(buffer)
return buffer.getvalue()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
verify()
for path in PROFILES:
expected = profile(path.read_text())
if args.check:
if path.read_text() != expected: raise SystemExit(f'Outdated icon fonts: {path.name}')
else:
path.write_text(expected)
if args.check:
if not NAME_TABLE.exists() or NAME_TABLE.read_text() != name_table(): raise SystemExit(f'Outdated: {NAME_TABLE.name}')
# Compare glyph coverage, not bytes: font output differs between fontTools versions.
wanted = {int(code, 16) for code in tile_icons.GLYPHS.values()}
if not WEB_FONT.exists() or set(TTFont(WEB_FONT).getBestCmap()) != wanted:
raise SystemExit(f'Outdated: {WEB_FONT.name}')
for weight, path in BAR_FONTS.items():
if not path.exists() or set(TTFont(path).getBestCmap()) != {ord(char) for char in header_bar.GLYPHS}:
raise SystemExit(f'Outdated: {path.name}')
else:
NAME_TABLE.write_text(name_table())
WEB_FONT.write_bytes(web_font())
for weight, path in BAR_FONTS.items():
path.write_bytes(bar_font(weight))
print(f'{len(tile_icons.ICONS)} pickable icons, {len(tile_icons.GLYPHS)} glyphs: ' + ('verified' if args.check else 'generated'))
if __name__ == '__main__':
main()