Let every screen choose its top bar: clocks, date and entities (0.2.38)

The name stays on the left; up to six items go on the right: the time, an
analog clock, the date, or a Home Assistant entity with its icon, showing its
state or how long ago it changed, always or only while active. The add-on
formats entity text in Dutch and sends it as op: header right after the layout
to firmware 0.2.32+; older firmware keeps the clock of show_clock, which now
follows the clock item.

The firmware draws every value in one font on the name's baseline, centres
icons and the dial on the digit height and measures gaps between glyph ink, so
the bar looks even; the editor's mockup uses Roboto and the same integer rules.
tools/render_topbar.py renders the real LVGL code on a computer (ESPHome host +
SDL2). The editor's section buttons are gone.

Firmware 0.2.32 for both boards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-14 18:38:49 +02:00
parent ae308e37ab
commit 3f6889f750
25 changed files with 2365 additions and 94 deletions
+27 -4
View File
@@ -1,11 +1,12 @@
"""Write the tile icon set into both board profiles and build the editor's icon font.
"""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), and the editor gets a subset
of the same TTF so its mockup shows the glyphs the screen draws. Run
tools/generate_packages.py afterwards; --check changes nothing and fails when
anything is out of date.
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;
--check changes nothing and fails when anything is out of date.
"""
import argparse
import io
@@ -18,11 +19,13 @@ 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 / 'home-like-2432s028.yaml', ROOT / 'guition-4848s040.yaml']
WEB_FONT = ROOT / 'screen_manager/app/static/tile-icons.woff'
BAR_FONTS = {weight: ROOT / f'screen_manager/app/static/bar-roboto-{weight}.woff' for weight in (400, 500)}
NAME_TABLE = ROOT / 'components/smart_display/tile_icon_names.h'
FONT_BLOCK = re.compile(r"( - file: 'fonts/materialdesignicons-webfont\.ttf'\n id: \w+\n size: \d+\n) glyphs:.*\n(?: .*\n)*")
@@ -103,6 +106,21 @@ def web_font():
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')
@@ -120,9 +138,14 @@ def main():
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__':
+299
View File
@@ -0,0 +1,299 @@
"""Render the real top bar code (runtime_tiles::draw_header) for both boards on this computer.
Builds a small ESPHome host project (SDL2 display, LVGL 9.5 as the screens run it) with the
fonts copied verbatim from both board profiles, draws a set of scenarios through the firmware's
own drawing code and saves LVGL snapshots as PNG plus one contact sheet: Guition at 2x, CYD at
3x, nearest neighbour, so every pixel shows. No screen, Home Assistant or flash is involved.
Needs the ESPHome CLI (2026.6.2) and SDL2 (`sdl2-config` on PATH, e.g. `brew install sdl2`).
Output: .esphome/render-topbar/out/*.png and sheet.png (or --out).
"""
import argparse
import os
import re
import shutil
import struct
import subprocess
import sys
import zlib
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'}
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 = {'backlight_fade.h', 'guition_diagnostics.h', '__pycache__'}
# Header band per board: page width and height above the tiles.
BANDS = {'guition': (480, 72), 'cyd': (320, 40)}
# (slug, title, items) with items as C++ expressions of the render lambda's helpers.
SCENARIOS = (
('1-klok', 'Studio 1', ['builtin(Kind::clock)']),
('2-mockup', 'Woonkamer', ['text(0xF050F, "—")', 'text(0xF050F, "21,3 °C")', 'text(0xF058E, "—")', 'ago(0xF0004, 26 * 3600)']),
('3-deur-alarm', 'Studio 1', ['text(0xF081C, "Open", 0xFFB300)', 'text(0xF068A, "Afwezig", 0x43A047)', 'builtin(Kind::clock)']),
('4-analoog-datum', 'Keuken', ['text(0xF050F, "23,5 °C")', 'builtin(Kind::date)', 'builtin(Kind::analog)']),
('5-thuis-weer-stroom', 'Studio 1', ['text(0xF0849, "4")', 'text(0xF0595, "22 °C")', 'text(0xF0241, "1.249 W")', 'builtin(Kind::clock)']),
('6-te-vol', 'Woonkamer beneden', ['text(0xF050F, "21,3 °C")', 'text(0xF058E, "48%")', 'ago(0xF0D91, 5 * 60)', 'text(0xF0241, "78,0 W")', 'builtin(Kind::analog)', 'builtin(Kind::clock)']),
('7-mensen', 'Studio 1', ['text(0xF0004, "Weg")', 'text(0xF0849, "4")', 'builtin(Kind::analog)', 'builtin(Kind::clock)']),
)
def font_blocks(board):
"""The profile's font entries, ids suffixed per board and file paths made absolute."""
text = (ROOT / PROFILES[board]).read_text()
fonts = text[text.index('\nfont:\n'):]
out = ''
for font_id in FONTS:
m = re.search(r'\n - file: [^\n]+\n id: ' + font_id + r'\n(?: [^\n]*\n| [^\n]*\n| [^\n]*\n)*', fonts)
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)
out += block.replace('&tile_icons', f'&tile_icons_{board}').replace('*tile_icons\n', f'*tile_icons_{board}\n')
return out
def band(board, y):
width, height = BANDS[board]
margin, top = (16, 22) if board == 'guition' else (11, 14)
tile_y, tile_w, tile_h, col2, radius = (72, 218, 108, 246, 22) if board == 'guition' else (40, 147, 52, 164, 18)
return f''' - obj:
id: {board}_page
x: 0
y: {y}
width: {width}
height: {height + tile_h + margin}
bg_color: 0xE7E7E7
bg_opa: COVER
border_width: 0
radius: 0
pad_all: 0
scrollable: false
widgets:
- label:
id: {board}_room
x: {margin}
y: {top}
text_font: headline_{board}
text_color: 0x1B1B1B
- label:
id: {board}_time
align: TOP_RIGHT
x: -{margin}
y: {top}
text: ""
text_font: time_label_{board}
- obj:
x: {margin if board == 'guition' else 9}
y: {tile_y}
width: {tile_w}
height: {tile_h}
radius: {radius}
border_width: 1
border_color: 0xDDDDDD
- obj:
x: {col2}
y: {tile_y}
width: {tile_w}
height: {tile_h}
radius: {radius}
border_width: 1
border_color: 0xDDDDDD
'''
def render_lambda(out):
scenarios = ',\n'.join(f' {{"{slug}", "{title}", {{{", ".join(items)}}}}}' for slug, title, items in SCENARIOS)
return f'''using namespace runtime_tiles;
using header_bar::Kind;
static const std::string OUT = "{out}/";
// A fixed moment, 14 september 2026 18:04 in the Netherlands, so renders compare between runs.
static const int64_t NOW = 1789401840;
auto fixed = esphome::ESPTime::from_epoch_local(NOW);
now_time = [fixed]() {{ return fixed; }};
enabled = true;
screen_settings::current.clock_24h = 1;
auto text = [](uint32_t icon, const char *value, uint32_t color = 0) {{
header_bar::Item item; item.kind = Kind::text; item.icon = icon; item.text = value;
if (color) {{ item.color = color; item.has_color = true; }}
return item;
}};
auto ago = [](uint32_t icon, int64_t seconds) {{ header_bar::Item item; item.kind = Kind::ago; item.icon = icon; item.epoch = NOW - seconds; return item; }};
auto builtin = [](Kind kind) {{ header_bar::Item item; item.kind = kind; return item; }};
struct Scenario {{ const char *slug, *title; std::vector<header_bar::Item> items; }};
std::vector<Scenario> scenarios = {{
{scenarios}
}};
struct Board {{ const char *name; lv_obj_t *page, *room, *time; const lv_font_t *text_font, *icon_font; }};
std::vector<Board> boards = {{
{{"guition", id(guition_page), id(guition_room), id(guition_time), id(sublabel_big_guition)->get_lv_font(), id(materialdesign_icons_mini_guition)->get_lv_font()}},
{{"cyd", id(cyd_page), id(cyd_room), id(cyd_time), id(sublabel_big_cyd)->get_lv_font(), id(materialdesign_icons_mini_cyd)->get_lv_font()}},
}};
for (auto &scenario : scenarios) {{
for (auto &board : boards) {{
// A fresh bar per board: the runtime keeps one set of objects.
if (header_root) {{ lv_obj_delete(header_root); header_root = nullptr; header_ring = nullptr; header_hands = {{}}; header_slots = {{}}; header_dial_key = -1; }}
room_label = board.room; time_label = board.time; header_text_font = board.text_font; header_icon_font = board.icon_font;
lv_label_set_long_mode(board.room, LV_LABEL_LONG_WRAP);
lv_obj_set_size(board.room, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_label_set_text(board.room, scenario.title);
header = header_bar::Bar{{}};
for (auto &item : scenario.items) header.items[header.count++] = item;
header.received = true;
for (int pass = 0; pass < 2; ++pass) {{ lv_obj_update_layout(board.page); draw_header(true); }}
lv_obj_update_layout(board.page);
lv_draw_buf_t *buf = lv_snapshot_take(board.page, LV_COLOR_FORMAT_RGB888);
std::string path = OUT + scenario.slug + "-" + board.name + ".ppm";
FILE *f = fopen(path.c_str(), "wb");
fprintf(f, "P6\\n%d %d\\n255\\n", (int)buf->header.w, (int)buf->header.h);
for (int y = 0; y < (int)buf->header.h; ++y) {{
const uint8_t *row = buf->data + y * buf->header.stride;
for (int x = 0; x < (int)buf->header.w; ++x) {{ uint8_t px[3] = {{row[x * 3 + 2], row[x * 3 + 1], row[x * 3]}}; fwrite(px, 1, 3, f); }}
}}
fclose(f);
lv_draw_buf_destroy(buf);
}}
}}
ESP_LOGI("render", "klaar");
fflush(stdout);
exit(0);
'''
def project(out):
lam = '\n'.join((' ' + line) if line else '' for line in render_lambda(out).splitlines())
return f'''esphome:
name: render-topbar
platformio_options:
build_flags:
- -DLV_USE_SNAPSHOT=1
on_boot:
priority: -100
then:
- lambda: |-
{lam}
host:
external_components:
- source:
type: local
path: {WORK}/components
components: [smart_display]
logger:
level: INFO
# Sets the process time zone the way the screens get it; the renders use a fixed moment.
time:
- platform: host
timezone: Europe/Amsterdam
api:
homeassistant_services: true
reboot_timeout: 0s
text:
- platform: smart_display
name: "Tegelinstellingen"
display:
- platform: sdl
id: sdl_display
dimensions:
width: 480
height: 480
auto_clear_enabled: false
update_interval: never
font:{font_blocks('guition')}{font_blocks('cyd')}
lvgl:
displays: sdl_display
pages:
- id: render_page
pad_all: 0
scrollable: false
widgets:
# Never shown: they make ESPHome compile the widgets the component code creates.
- slider:
hidden: true
- switch:
hidden: true
- spinner:
hidden: true
spin_time: 1s
arc_length: 60deg
- line:
hidden: true
points:
- 0, 0
- 1, 1
{band('guition', 0)}{band('cyd', 220)}
script:
- id: action_seed
then:
- homeassistant.action:
action: light.turn_on
'''
def png(path, width, height, rgb):
raw = b''.join(b'\0' + rgb[y * width * 3:(y + 1) * width * 3] for y in range(height))
chunk = lambda kind, data: struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xFFFFFFFF)
path.write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raw, 9)) + chunk(b'IEND', b''))
def read_ppm(path):
data = path.read_bytes()
magic, width, height, depth, pixels = re.match(rb'(P6)\s+(\d+)\s+(\d+)\s+(\d+)\s(.*)', data, re.S).groups()
return int(width), int(height), pixels
def crop_scale(image, width, height, scale):
w, _, rgb = image
out = bytearray()
for y in range(height * scale):
row = rgb[(y // scale) * w * 3:(y // scale) * w * 3 + width * 3]
out += b''.join(row[x * 3:x * 3 + 3] * scale for x in range(width))
return bytes(out)
def sheet(out):
"""Header bands of every scenario: Guition 2x above CYD 3x, 12 px white between."""
width, gap = 960, 12
rows = []
for slug, _, _ in SCENARIOS:
for board, scale in (('guition', 2), ('cyd', 3)):
image = read_ppm(out / f'{slug}-{board}.ppm')
band_w, band_h = BANDS[board]
png(out / f'{slug}-{board}.png', image[0], image[1], image[2])
rows.append((crop_scale(image, band_w, band_h, scale), band_h * scale))
rows.append((bytes([255]) * width * 3 * gap * 2, gap * 2))
height = sum(h for _, h in rows)
png(out / 'sheet.png', width, height, b''.join(data for data, _ in rows))
return out / 'sheet.png'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--out', type=Path, default=WORK / 'out')
parser.add_argument('--esphome', default=os.environ.get('ESPHOME') or shutil.which('esphome') or str(Path.home() / '.local/pipx/venvs/esphome/bin/esphome'))
args = parser.parse_args()
if not shutil.which('sdl2-config'):
raise SystemExit('SDL2 ontbreekt: installeer het eerst (bijvoorbeeld brew install sdl2).')
out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True)
for old in out.glob('*.p[np][gm]'):
old.unlink()
components = WORK / 'components' / 'smart_display'
shutil.rmtree(components, ignore_errors=True)
components.mkdir(parents=True)
for source in (ROOT / 'components/smart_display').iterdir():
if source.name not in SKIP:
(components / source.name).symlink_to(source)
config = WORK / 'render-topbar.yaml'
config.write_text(project(out))
build = subprocess.run([args.esphome, 'compile', str(config)], capture_output=True, text=True)
if build.returncode:
sys.stdout.write(build.stdout[-4000:] + build.stderr[-2000:])
raise SystemExit('Build mislukt.')
program = next(WORK.glob('.esphome/build/render-topbar/.pioenvs/render-topbar/program'), None)
if not program:
raise SystemExit('Geen host-programma gevonden na de build.')
subprocess.run([str(program)], capture_output=True, timeout=60, check=True)
print(sheet(out))
if __name__ == '__main__':
main()