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
+1 -1
View File
@@ -99,6 +99,6 @@ missed taps. A full-screen BMP is about 390 KB; on the LAN that takes one to thr
(`tests/test_camera_view.cpp`).
- `components/smart_display/runtime_tiles.h`: the full-screen view, the alert picture and the
`camera` message.
- `guition-4848s040.yaml`: the two `online_image` components, the alert frame, and the diagnostic
- `packages/boards/guition-4848s040.yaml`: the two `online_image` components, the alert frame, and the diagnostic
action `preview_camera` (an entity opens it, an empty entity closes it).
- `tests/test_camera.py`: the app side and the words both sides share.
+4 -2
View File
@@ -13,8 +13,10 @@ or periodic recovery script is needed. The GT911 uses unmirrored coordinates.
## Files
- `guition-4848s040.yaml`: the board profile (hardware + 480×480 interface).
- `packages/guition.yaml`: what a screen builds from; generated from that profile.
- `packages/boards/guition-4848s040.yaml`: the board file (hardware, the 480×480 sizes, the Guition's own parts); the
interface itself is `packages/core.yaml`, shared with the CYD (docs/PROFILES.md).
- `packages/guition.yaml`: what a screen builds from over GitHub; `guition-4848s040.yaml` in the root is the same
for a build from a checkout.
- `<your-screen>.yaml`: the screen's own profile, written by ESP Screens, outside Git.
- `secrets.yaml`: local Wi-Fi/API/OTA settings, outside Git.
- `tools/verify_gt911.py`: physical pixel check; no resistive ADC calibration.
+66
View File
@@ -0,0 +1,66 @@
# One screen, three files: the shared core and the board files
Since app 0.2.84 a screen's ESPHome configuration is one shared file plus one file per board, put together by
ESPHome's own [packages](https://esphome.io/components/packages/) mechanism. Before, each board was a 5,000-line
profile of which 87 % was the same text as the other board's; every change had to be made twice and the two had
already drifted apart in small ways.
| File | What it holds |
|---|---|
| `packages/core.yaml` | Everything every board shares: the LVGL tree (tiles, cards, overlays, the touch test page), the scripts, the API actions, the entities, the fonts, the globals. It contains no number that depends on the board: every such value is a `${NAME}` the board file defines. |
| `packages/boards/cyd-2432s028.yaml` | The CYD: its hardware (ILI9341 over SPI, XPT2046 resistive touch, backlight, LED), its sizes and font sizes, the code the shared lambdas take from it (the *hooks*, below), and what only a CYD has: the calibration wizard, a page swipe by LVGL's gesture, `ui_self_test`, the two scripts that drive the backlight as a light action, its climate card layout. |
| `packages/boards/guition-4848s040.yaml` | The Guition: its hardware (ST7701S RGB, GT911, PSRAM), its sizes, its hooks, and what only a Guition has: camera images (`online_image`, the alert's picture frame), the Rotation entity, the page swipe from the glass edge, the snapshot diagnostics, the climate card with its fan and swing card, the wider tile labels (`!extend` on each tile). |
| `packages/cyd.yaml`, `packages/guition.yaml` | What a screen installed from ESP Screens builds from over GitHub, unchanged in name and place: ESP Screen Manager writes every screen's YAML with `files: [packages/<board>.yaml]`. Each is a few lines: where the fonts are, the components from GitHub, and `packages: {core: !include core.yaml, board: !include boards/<board>.yaml}`. |
| `home-like-2432s028.yaml`, `guition-4848s040.yaml` | The same two files for a build from a checkout (the bench, the manual route of README_EXTENDED.md): the components of the checkout, the fonts from `fonts/`, and the secrets from `secrets.yaml`. |
ESPHome merges them in this order: the core, then the board file on top of it, then the entry file on top of both.
A mapping merges key by key, a list of components with ids merges by id (`!extend` adds to an existing widget or
script, `!remove` takes one away), any other list is the core's items followed by the board's, and a plain value of
the board file replaces the core's. `esphome config home-like-2432s028.yaml` shows the result.
## The three ways a board differs from the core
1. **A size, a font size, a name.** `${TILE_ICON_SIZE}`, `${FONT_HEADLINE_SIZE}`, `${TOUCH_TEST_TITLE}`: the core
writes the placeholder, the board file's `substitutions:` table gives the value. `tools/check_packages.py` fails
when a board leaves one out, so a new board cannot forget a size.
2. **A stretch of C++ inside a shared lambda.** The Guition sets `runtime_tiles::rotation_supported`, closes its
camera when the cards close, prepares the alert's picture frame; the CYD sets its calibration up and lays the
climate card out for a portrait panel. Each such stretch is a *hook*: a substitution whose value is that board's
exact lines, written where the old profile had them (`${BOOT_ENABLED_HOOK}`, `${CLOSE_CARDS_HOOK}`,
`${APPLY_ROTATION}`, ...). A board that adds nothing there names the hook with an empty value. The hooks sit at
the end of the board file's `substitutions:` table, each with a line that says where it lands.
3. **A whole section or script.** Hardware, `online_image`, the Rotation select, `capture_ui_snapshot`: these are
the board file's own sections, merged next to the core's. `alert_dismiss`, `alert_flash` and `wake_display` are
in both board files because their *steps* differ (a `light.turn_on` action on the CYD, the `set_backlight` script
on the Guition); so are `ui_self_test` and `climate_card_refresh`, whose layouts differ by design.
## Adding a board
1. Copy the board file of the panel that resembles the new one most (a small SPI panel: the CYD; a big RGB panel
with capacitive touch: the Guition) to `packages/boards/<board>.yaml` and change its hardware sections.
2. Fill the sizes table for the new resolution, and go through the hooks: keep, change or empty each one.
3. Add `packages/<board>.yaml` and `<board>.yaml` after the existing entries, `<board>` to `ENTRIES` in
`tools/profiles.py` and to `installation_yaml()` in `screen_manager/app/core.py`, and a `BANDS` entry to
`tools/render_topbar.py`.
4. `python3 tools/check_packages.py`, `esphome config <board>.yaml`, `tools/check.sh --firmware`.
The tiles per page (two columns of three) are still fixed in the firmware, the add-on and the editor
(`SLOTS_PER_PAGE`); a panel that wants more rows is a later round.
## How the split was verified
The split was made by a script that replaced every size in the old CYD profile and asserted the value it replaced
in both old profiles (195 places, 84 names), and took every hook's text from the old profiles by line. Then the
result of `esphome config` and the generated `main.cpp` of both boards, through the checkout entry and through the
GitHub package route, were compared with the same of the old profiles:
- every scalar, every lambda body and every component is the same, apart from whitespace and `#line` directives;
- on the CYD, the calibration setup runs in the same lambda as the effects page's hooks instead of a lambda of
its own (the same statements, in the same order: `${BOOT_CARDS_TAIL}`);
- on the Guition, the alert's picture frame is created after the OK button instead of before it (they never
overlap), and the hidden `camera_image_seed` after the labels;
- the order in which ESPHome declares and registers fonts, entities, scripts, API actions, styles, includes and
build flags follows the merge (the core's first, the board's after), which changes the numbers in ESPHome's
component tables and nothing that runs in sequence.
Every other generated source file of both builds was identical, byte for byte.
+6 -5
View File
@@ -18,14 +18,15 @@
## For every release
1. Update the source profiles and shared components. Run
`python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly.
1. Update `packages/core.yaml` (shared by every board), the board files under `packages/boards/` and the
shared components; docs/PROFILES.md says what goes where. `python3 tools/check_packages.py` checks that the
boards define every name the core uses (tools/check.sh runs it).
The editor is the Vue app in `web/`: after a change under `web/src`, run
`cd web && npm ci && npm test && npm run check && npm run build` and commit
`screen_manager/app/static` with it (that folder is the build output; never edit it by hand).
2. Run `tools/check.sh` (with `PYTHON=.venv-portal/bin/python` on a development machine). It runs all
Python tests with aiohttp, PyYAML, Pillow and fontTools installed, every `tests/*.cpp` with
`clang++ -std=c++17 -Wall -Wextra -Werror -I.`, both generators with `--check`, and the editor's
`clang++ -std=c++17 -Wall -Wextra -Werror -I.`, `tools/check_packages.py`, `tools/generate_icons.py --check`, and the editor's
`npm ci`, `npm test`, `npm run check` and `npm run build`, and fails when that fresh build differs from the
`screen_manager/app/static` in Git (committed or staged). For a firmware change, `tools/check.sh --firmware`
compiles both board profiles with placeholder secrets from a temporary folder (never the real `secrets.yaml`) and
@@ -209,9 +210,9 @@ font and otherwise falls back to the domain icon. Hence no `min_firmware`.
An old editor that omits `icon` keeps the stored choice.
`tile_icons.py` is the single source list. `tools/generate_icons.py` writes the glyphs
as a YAML anchor into the three MDI fonts of both board profiles and builds
as a YAML anchor into the three MDI fonts of `packages/core.yaml` (both boards build from it) and builds
`web/src/assets/tile-icons.woff` for the editor (with left bearing equal to xMin, otherwise
icons sit off-center in the browser); run `generate_packages.py` afterward.
icons sit off-center in the browser).
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
+2 -1
View File
@@ -141,7 +141,8 @@ The screen's preferences hold the value, so the entity reads it in a lambda inst
(`restore_value` cannot be combined with a lambda). A switch publishes its own changes. A number, time or
select takes `update_interval: never` and one line in `apply_screen_settings`, which publishes it when it
differs; that script runs after every change and on every Home Assistant connection (the time sync), so the
entity has a value before Home Assistant reads the states. Then run `python3 tools/generate_packages.py`.
entity has a value before Home Assistant reads the states. The entities live in `packages/core.yaml`; the
Rotation select, which only a Guition has, in its board file (docs/PROFILES.md).
### 4. The add-on
+83
View File
@@ -0,0 +1,83 @@
# Test results app 0.2.84 / firmware 0.2.70 (2026-09-18)
Max asked whether the two board profiles carried much double code ("hebben we veel dubbele code voor de beide
schermen?"), wanted one shared package with only the board differences apart ("zodat we ook makkelijker een scherm
kunnen toevoegen later"), and set the bar for this round: a refactor that can be verified afterwards, one to one, with
the same end result ("Ik wil echt dat je fully 100% zeker bent dat het precies hetzelfde eindresultaat is"). The rows
per page stay as they are (his call: step 1 only).
## What changed
- `packages/core.yaml`: everything the two profiles shared (4,283 lines). No number in it depends on the board.
- `packages/boards/cyd-2432s028.yaml` (738 lines) and `packages/boards/guition-4848s040.yaml` (1,015 lines): the
hardware, the table of 84 sizes and font sizes, the 18 hooks (the lines of C++ a board adds inside a shared lambda,
as substitutions whose values are the old profile's exact text), and the board's own sections and scripts.
- `packages/cyd.yaml`, `packages/guition.yaml`, `home-like-2432s028.yaml`, `guition-4848s040.yaml`: entry files of
15-45 lines that include the core and the board file. The two under `packages/` keep their names, so every screen's
own YAML (`files: [packages/<board>.yaml]`) builds as before.
- `tools/check_packages.py` replaces `tools/generate_packages.py` in `tools/check.sh` and CI; `tools/profiles.py` gives
the tests and tools the three files as one text (`text`), with the substitutions filled in (`resolved`), or without
the substitution blocks (`merged`). Twenty test files read the profiles through it; `tools/generate_icons.py` writes
the glyph lists into the core only; `tools/render_topbar.py` takes the fonts from the core at each board's sizes;
`tools/check.sh --firmware` links `packages/` next to the check copies of the profiles.
- Docs: docs/PROFILES.md (new), pointers in AGENTS.md, RELEASING.md, GUITION.md, THEME.md, SETTINGS.md, CAMERA.md.
## How the split was made
A script (kept out of the repository) took the old CYD profile as the base of the core and replaced every size by its
`${NAME}`, asserting the value it replaced in *both* old profiles: 195 places, 84 names. Every hook's text was cut from
the old profiles by line. The Guition's tile labels (`height`, `width: 130`, `long_mode: DOT` on twenty labels) are
`!extend` blocks in its board file; its alert picture frame, `camera_image_seed` and `climate_settings_card` likewise.
## The one-to-one check
Before the split, `esphome config` and `esphome compile --only-generate` (ESPHome 2026.6.2) were run for both boards
along two routes: the checkout profile, and a screen YAML as `core.installation_yaml()` writes it, with
`packages: display: {url: file://<checkout>, ref: <commit>, files: [packages/<board>.yaml]}`: the GitHub route the
add-on uses, against the old commit. After the split the same four builds were run against the new commit, and
compared:
- `esphome config`, read as data: every scalar, every lambda body and every component is the same, apart from
whitespace inside a lambda where an empty hook leaves a blank line;
- `main.cpp`, with `#line` directives, blank lines, leading whitespace and ESPHome's numbered ids taken out, compared
as a sorted multiset of lines: the Guition's is identical except the lines that carry ESPHome's component-table
indices and the numbers of two LVGL event triggers (the picture frame and the OK button swapped places); the CYD's
differs by those indices and by one `StatelessLambdaAction` fewer (the boot step that set the calibration wizard up
is now the tail of the boot step that sets the cards' hooks up: `${BOOT_CARDS_TAIL}`, the same statements in the
same order, one lambda instead of two consecutive ones);
- every other generated source file (361 for the CYD, 401 for the Guition; `build_info_data.cpp` with its build
timestamp left out): identical, byte for byte;
- `platformio.ini`: identical on the CYD; the Guition's two `build_flags` in the other order.
What the merge changes is the order in which ESPHome declares and registers fonts, entities, scripts, API actions,
styles, includes and build flags (the core's first, the board's after). That order is what the component tables
number; nothing that runs in sequence moved. The two z-order changes on the Guition (the picture frame after the OK
button, the hidden image seed after the labels) concern widgets that never overlap or are never shown.
So the firmware version stays 0.2.70, and no screen is offered an update.
## Automated
- `tools/check.sh`: Python 466 tests (`tests/test_easy_package.py` rewritten for the new layout), C++ 19/19, the package
check, the icon check, Vitest, vue-tsc, build and the bundle: 10 passed, 0 failed.
- `tools/check.sh --firmware` (ESPHome 2026.6.2): CYD 1,676,912 B = 91.4 % of the 1,835,008 B slot (158,096 B free),
Guition 2,054,080 B = 25.3 %. Against the old profiles built the same way on the same computer (CYD 1,675,472 B,
Guition 2,052,544 B; the 0.2.83 release noted 1,675,424 B for the CYD from another build): +1,440 B and +1,536 B.
- Where those bytes are (`xtensa-esp32-elf-size -A`, `nm -S` on both ELFs): every section is the same size except
`.flash.text` (+1,432 B on the CYD, +1,528 B on the Guition; `.dram0.bss` 16 B smaller on the CYD: the lambda object
that went). Of the symbols that changed size, ESPHome's generated `setup()` accounts for the growth (+1,412 B on the
CYD): the components are registered in the merge's order, which changes its literal pool. The lambdas are the same
functions under other numbers (`{lambda()#75}` +1,259 B where `{lambda()#56}` −1,259 B), net +19 B on the CYD and
−18 B on the Guition from the line numbers ESPHome's log macro bakes in; the font arrays and every string are the
same size, renamed.
## Not done
- No hardware was flashed: the check above is the proof that a screen would run the same code; Max's screens keep
0.2.70 as they are.
- Left for a later round, each a change of what the CYD runs and therefore not part of this one: the CYD could drive
its backlight through the Guition's `set_backlight` script (then `alert_dismiss`, `alert_flash` and `wake_display`
become shared); the camera hooks `camera_close()`, `camera_visible()`, `alert_prepare()`, `alert_clear()` and
`camera_tick()` exist as no-ops on a board without images, so the core could call them on every board and five
hooks would go; the portrait layout in `OPEN_VALUE_OVERLAY_TAIL` could move into the CYD's `climate_card_refresh`.
The tiles per page (`SLOTS_PER_PAGE`, two columns of three) stay fixed in the firmware, the add-on and the editor.
+4 -4
View File
@@ -63,10 +63,10 @@ you set), and a palette pass that overwrote a colour the sun path had chosen its
1. **A role**: one line in `enum Role` and one row in `ROLES`, in the same order (the tests read
both). The light value is the design; choose the dark value on a render, not in the table.
2. **In firmware code**: `theme::color(theme::YOUR_ROLE)`. Never `lv_color_hex(0x...)`.
3. **In a board profile**: the widget takes a paint. For a new combination add it to `enum class
Paint` and `PAINTS`, a colourless `- id: paint_<name>` in the `style_definitions` of both profiles,
and `theme::fill(id(paint_<name>), Paint::<name>);` in their `theme::paints` list. Then
`python3 tools/generate_packages.py`.
3. **In the profiles**: the widget takes a paint. For a new combination add it to `enum class
Paint` and `PAINTS`, a colourless `- id: paint_<name>` in the `style_definitions` of `packages/core.yaml`
(or of one board file, for a paint only that board uses), and `theme::fill(id(paint_<name>), Paint::<name>);`
in the core's `theme::paints` list (a board-only paint goes into the board's `BOARD_PAINT_FILL` hook).
4. **Something drawn once and kept that changes colour by state**: set it where the state is drawn,
and make sure the redraw above reaches that code.
5. A local colour always wins over a paint: an object that takes a paint must not also get a local