#!/usr/bin/env python3 """ Generate the icon font the UI uses: main/ui/icon_font.c + main/ui/ui_icons.h. The icons are a ~30-glyph subset of Material Design Icons (the same icon set Home Assistant uses, so the panel and the HA dashboard agree on what "partly cloudy" looks like). The subset TTF is embedded as a C array and rasterised at runtime by LVGL's tiny_ttf, which means: - icons are just label text in a special font -- colourable, sizeable, no per-icon bitmaps to regenerate when a size changes; - the whole set costs a few KB of flash, not a 1.3MB font. Inputs (cached copies are fine; only regeneration needs them): curl -sfL -o /tmp/mdi.css https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css curl -sfL -o /tmp/mdi.ttf https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.ttf python3 tools/make_icon_font.py /tmp/mdi.css /tmp/mdi.ttf Codepoints are resolved from the CSS at generation time, never hardcoded -- MDI has re-assigned codepoints between major versions, and a stale codepoint renders as the wrong icon with no error anywhere. The generated files are committed, so a normal build needs neither network nor fonttools. """ import io import re import sys from pathlib import Path try: from fontTools import subset except ImportError: sys.exit("fonttools is required: python3 -m venv /tmp/fontenv && " "/tmp/fontenv/bin/pip install fonttools (then run with that python)") REPO = Path(__file__).resolve().parent.parent OUT_C = REPO / "main" / "ui" / "icon_font.c" OUT_H = REPO / "main" / "ui" / "ui_icons.h" # macro name -> candidate MDI icon names, first match wins. Candidates exist # because MDI occasionally renames icons between versions. ICONS = { # met.no condition slugs (see ha_condition_* in ha_data.c) "ICON_W_SUNNY": ["weather-sunny"], "ICON_W_NIGHT": ["weather-night"], "ICON_W_PARTLY": ["weather-partly-cloudy"], "ICON_W_CLOUDY": ["weather-cloudy"], "ICON_W_RAINY": ["weather-rainy"], "ICON_W_POURING": ["weather-pouring"], "ICON_W_LIGHTNING": ["weather-lightning"], "ICON_W_LIGHTNING_RAIN":["weather-lightning-rainy"], "ICON_W_SNOWY": ["weather-snowy"], "ICON_W_SNOWY_RAIN": ["weather-snowy-rainy"], "ICON_W_FOG": ["weather-fog"], "ICON_W_WINDY": ["weather-windy"], "ICON_W_HAIL": ["weather-hail"], "ICON_W_ALERT": ["alert-circle-outline"], # grid cells "ICON_THERMOMETER": ["thermometer"], "ICON_HOME_THERMO": ["home-thermometer-outline", "home-thermometer"], "ICON_WATER": ["water-outline", "water"], "ICON_SUNRISE": ["weather-sunset-up"], "ICON_SUNSET": ["weather-sunset-down"], "ICON_GAUGE": ["gauge"], # light tiles (order-independent; ha_config.h references these by name) "ICON_SOFA": ["sofa-outline", "sofa"], "ICON_DINING": ["silverware-fork-knife", "silverware"], "ICON_FLOOR_LAMP": ["floor-lamp"], "ICON_DESK_LAMP": ["desk-lamp"], "ICON_CHANDELIER": ["chandelier", "ceiling-light"], "ICON_STRING_LIGHTS": ["string-lights", "outdoor-lamp"], } def resolve_codepoints(css: str) -> dict[str, int]: """macro -> unicode codepoint, via the .mdi-::before rules.""" cp_by_name: dict[str, int] = {} for m in re.finditer(r'\.mdi-([a-z0-9-]+)::?before\{content:"\\([0-9A-Fa-f]+)"\}', css): cp_by_name[m.group(1)] = int(m.group(2), 16) if not cp_by_name: sys.exit("No codepoint rules found in the CSS -- wrong file?") resolved: dict[str, int] = {} for macro, candidates in ICONS.items(): for name in candidates: if name in cp_by_name: resolved[macro] = cp_by_name[name] print(f" {macro:<24} mdi-{name:<24} U+{cp_by_name[name]:05X}") break else: sys.exit(f"None of {candidates} exist in this MDI release " f"(wanted for {macro}) -- pick another candidate") return resolved def subset_font(ttf_path: Path, codepoints: list[int]) -> bytes: opts = subset.Options() opts.flavor = None # plain TTF out opts.desubroutinize = True font = subset.load_font(str(ttf_path), opts) ss = subset.Subsetter(opts) ss.populate(unicodes=codepoints) ss.subset(font) buf = io.BytesIO() font.save(buf) return buf.getvalue() def utf8_escape(cp: int) -> str: return "".join(f"\\x{b:02x}" for b in chr(cp).encode("utf-8")) def main() -> int: if len(sys.argv) != 3: sys.exit(__doc__) css_path, ttf_path = Path(sys.argv[1]), Path(sys.argv[2]) print("resolving codepoints from CSS:") resolved = resolve_codepoints(css_path.read_text()) data = subset_font(ttf_path, sorted(resolved.values())) print(f"\nsubset TTF: {len(data)} bytes ({len(resolved)} glyphs)") guard_note = ( "/*\n" " * GENERATED by tools/make_icon_font.py -- do not edit by hand.\n" " * Subset of Material Design Icons (Apache-2.0), rasterised at runtime\n" " * by LVGL tiny_ttf. Regeneration instructions are in the script.\n" " */\n" ) lines = [guard_note, "#include \n"] lines.append(f"const size_t icon_font_data_size = {len(data)};\n") lines.append("const unsigned char icon_font_data[] = {") for i in range(0, len(data), 16): chunk = ", ".join(f"0x{b:02x}" for b in data[i:i + 16]) lines.append(f" {chunk},") lines.append("};\n") OUT_C.write_text("\n".join(lines)) h = [guard_note] h.append("#ifndef UI_ICONS_H") h.append("#define UI_ICONS_H\n") h.append("#include \n") h.append("/* The subset TTF; feed to lv_tiny_ttf_create_data(). */") h.append("extern const unsigned char icon_font_data[];") h.append("extern const size_t icon_font_data_size;\n") h.append("/* Each icon is a single glyph as a UTF-8 string -- usable as label text") h.append(" * in any font created from icon_font_data. */") for macro, cp in resolved.items(): h.append(f'#define {macro:<24} "{utf8_escape(cp)}" /* U+{cp:05X} */') h.append("\n#endif") OUT_H.write_text("\n".join(h) + "\n") print(f"wrote {OUT_C.relative_to(REPO)}") print(f"wrote {OUT_H.relative_to(REPO)}") return 0 if __name__ == "__main__": sys.exit(main())