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
@@ -64,7 +64,7 @@ jobs:
path: | path: |
~/.platformio ~/.platformio
.esphome/check .esphome/check
key: firmware-${{ runner.os }}-esphome-${{ steps.esphome.outputs.version }}-${{ hashFiles('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'components/**') }} key: firmware-${{ runner.os }}-esphome-${{ steps.esphome.outputs.version }}-${{ hashFiles('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/**', 'components/**') }}
restore-keys: | restore-keys: |
firmware-${{ runner.os }}-esphome-${{ steps.esphome.outputs.version }}- firmware-${{ runner.os }}-esphome-${{ steps.esphome.outputs.version }}-
- name: tools/check.sh --firmware - name: tools/check.sh --firmware
+10 -6
View File
@@ -6,7 +6,11 @@ persistent add-on data; Wi-Fi/API/OTA stay in the device's own ESPHome YAML. Rea
docs/RELEASING.md before publishing updates. Main distributes both boards. docs/RELEASING.md before publishing updates. Main distributes both boards.
Every push to GitHub is a release: always also bump the add-on version in Every push to GitHub is a release: always also bump the add-on version in
screen_manager/config.yaml (with a CHANGELOG line), otherwise HA won't see an update. screen_manager/config.yaml (with a CHANGELOG line), otherwise HA won't see an update.
Generate packages with tools/generate_packages.py; don't edit them by hand. A screen's YAML is packages/core.yaml (shared by every board) plus one file under packages/boards/;
packages/<board>.yaml and the two profiles in the root only include them. Read docs/PROFILES.md before
touching them: a board-dependent number is a `${NAME}` in the board file, board-only code inside a shared
lambda is a hook substitution there, and `tools/check_packages.py` (run by tools/check.sh) keeps the boards
complete.
A screen gets its tiles from the add-on while it runs: every card works in all A screen gets its tiles from the add-on while it runs: every card works in all
48 positions (eight pages of six, firmware 0.2.62+), and no Home Assistant entity 48 positions (eight pages of six, firmware 0.2.62+), and no Home Assistant entity
belongs in a board profile. belongs in a board profile.
@@ -16,9 +20,9 @@ a migration. Production Ingress needs no long-lived token or public port.
## Guition board ## Guition board
The Guition 4848S040 has a separate profile `guition-4848s040.yaml` with The Guition 4848S040 has its own board file `packages/boards/guition-4848s040.yaml` with
480×480, ST7701S RGB, and GT911. Read docs/GUITION.md. Don't carry over the CYD 480×480, ST7701S RGB, and GT911 (`guition-4848s040.yaml` in the root only includes it). Read
layout or the XPT2046 calibration. docs/GUITION.md. Don't carry over the CYD layout or the XPT2046 calibration.
Verify touch with tools/verify_gt911.py and keep the CYD regressions green. Verify touch with tools/verify_gt911.py and keep the CYD regressions green.
Don't configure wallbox relays as part of display support. Don't configure wallbox relays as part of display support.
@@ -53,8 +57,8 @@ and screens for lookups and tests. If that file is missing you are not on his ma
## Code and regressions ## Code and regressions
- Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs - Keep the shared UI in `packages/core.yaml` and a board's hardware and sizes in its file under
in the gitignored local profiles. `packages/boards/`; personal data belongs in the gitignored local profiles.
- The editor is `web/` (Vue 3 + Vite, TypeScript; app 0.2.73+). `screen_manager/app/static` is its build - The editor is `web/` (Vue 3 + Vite, TypeScript; app 0.2.73+). `screen_manager/app/static` is its build
output: change `web/src`, run `cd web && npm test && npm run build`, and commit both. Keep every URL the page asks for output: change `web/src`, run `cd web && npm test && npm run build`, and commit both. Keep every URL the page asks for
relative (`api/...`), so it works behind Home Assistant's ingress path. Tests read the source through relative (`api/...`), so it works behind Home Assistant's ingress path. Tests read the source through
+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`). (`tests/test_camera_view.cpp`).
- `components/smart_display/runtime_tiles.h`: the full-screen view, the alert picture and the - `components/smart_display/runtime_tiles.h`: the full-screen view, the alert picture and the
`camera` message. `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). 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. - `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 ## Files
- `guition-4848s040.yaml`: the board profile (hardware + 480×480 interface). - `packages/boards/guition-4848s040.yaml`: the board file (hardware, the 480×480 sizes, the Guition's own parts); the
- `packages/guition.yaml`: what a screen builds from; generated from that profile. 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. - `<your-screen>.yaml`: the screen's own profile, written by ESP Screens, outside Git.
- `secrets.yaml`: local Wi-Fi/API/OTA settings, outside Git. - `secrets.yaml`: local Wi-Fi/API/OTA settings, outside Git.
- `tools/verify_gt911.py`: physical pixel check; no resistive ADC calibration. - `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 ## For every release
1. Update the source profiles and shared components. Run 1. Update `packages/core.yaml` (shared by every board), the board files under `packages/boards/` and the
`python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly. 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 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 `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). `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 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 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 `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` `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 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. 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 `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 `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 The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys. 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 (`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 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 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 ### 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 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. 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...)`. 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 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 both profiles, Paint` and `PAINTS`, a colourless `- id: paint_<name>` in the `style_definitions` of `packages/core.yaml`
and `theme::fill(id(paint_<name>), Paint::<name>);` in their `theme::paints` list. Then (or of one board file, for a paint only that board uses), and `theme::fill(id(paint_<name>), Paint::<name>);`
`python3 tools/generate_packages.py`. 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, 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. 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 5. A local colour always wins over a paint: an object that takes a paint must not also get a local
+13 -5025
View File
File diff suppressed because it is too large Load Diff
+15 -4853
View File
File diff suppressed because it is too large Load Diff
+737
View File
@@ -0,0 +1,737 @@
############################################################
# ESP Screens - CYD board (ESP32-2432S028, "Cheap Yellow Display")
# Built-in ILI9341 320x240 display and XPT2046 resistive touch.
#
# What is in here: the hardware (display, touch, backlight, LED), the sizes the
# shared tree takes from this board, the code the shared lambdas take from this
# board (hooks), and what only a CYD has: the calibration wizard for its
# resistive glass and a page swipe by LVGL's gesture. Everything else (the LVGL
# tree, the cards, the scripts, the entities) is packages/core.yaml, which every
# board shares; docs/PROFILES.md says how the files fit together.
#
# Two entry files include this: home-like-2432s028.yaml (a build on this computer)
# and packages/cyd.yaml (the package a screen builds from over GitHub).
############################################################
substitutions:
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
TOUCH_MIN_PRESS_MS: "60"
# A slider let go this close to the left or right edge of the glass meant its end when that end lies in
# the band. The resistive panel reports a finger up to the bezel, so only the strip's last pixels and
# the margin beside them count (a capacitive Guition loses a fast finger 30-50 px before its edge).
SLIDER_EDGE_SNAP_PX: "20"
DEVICE_NAME: "cyd-new"
DEVICE_FRIENDLY_NAME: "My CYD"
TOUCH_AFFINE_XX: "1.000000000"
TOUCH_AFFINE_XY: "0.000000000"
TOUCH_AFFINE_XC: "0.000000000"
TOUCH_AFFINE_YX: "0.000000000"
TOUCH_AFFINE_YY: "1.000000000"
TOUCH_AFFINE_YC: "0.000000000"
# ORIENTATION
# The panel is always driven at its native 240x320 (see display:); LVGL turns
# both the picture and the touch coordinates. DISPLAY_W/H are the logical canvas
# the layout uses. Turning a screen a quarter is a setting in ESP Screens, so
# these values stay as they are.
DISPLAY_W: "320"
DISPLAY_H: "240"
LVGL_ROTATION: "90"
TOUCH_SWAP_XY: "false"
# true on this board (resistive XPT2046) — was false on the 032c board
# (capacitive GT911). This is the original upstream value for this panel.
TOUCH_MIRROR_X: "true"
TOUCH_MIRROR_Y: "false"
# Measured on this board: median top-right raw=(523,3390),
# bottom-left raw=(3487,482), at screen (299,20) and (20,219).
# Extrapolated to panel edges; verify with the isolated corner test.
TOUCH_CAL_X_MIN: "280"
TOUCH_CAL_X_MAX: "3860"
TOUCH_CAL_Y_MIN: "340"
TOUCH_CAL_Y_MAX: "3860"
GRID_COL1_X: "9"
GRID_COL2_X: "164"
GRID_ROW1_Y: "0"
GRID_ROW2_Y: "56"
GRID_ROW3_Y: "112"
GRID_ROW4_Y: "168"
GRID_ROW5_Y: "224"
SCROLL_Y: "40"
SCROLL_H: "164"
TILE_W: "147"
TILE_H: "52"
HEADER_Y: "14"
# Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board;
# a finger that travels further than the drift limit of a tap is swiping, not holding.
SETTINGS_HOLD_X: "0"
SETTINGS_HOLD_W: "${DISPLAY_W}"
SETTINGS_HOLD_DRIFT_PX: "24"
OVERLAY_SLIDER_W: "96"
OVERLAY_SLIDER_H: "160"
OVERLAY_ICON_Y: "74"
# Alert overlay (HA action show_alert), landscape 320x240: one card centred on LVGL's top layer.
ALERT_CARD_W: "292"
ALERT_CARD_H: "196"
ALERT_ICON_X: "18"
ALERT_ICON_Y: "16"
ALERT_TEXT_X: "62"
ALERT_TEXT_W: "212"
ALERT_TITLE_Y: "16"
ALERT_TITLE_H: "21" # one line of headline; a DOT label only ends in an ellipsis with a height
ALERT_SUBTITLE_Y: "44"
ALERT_SUBTITLE_H: "88"
ALERT_BUTTON_W: "100"
ALERT_BUTTON_H: "40"
ALERT_BUTTON_INSET: "14"
ALERT_TITLE_MAX: "48" # bytes; the label ends one line with an ellipsis
ALERT_BUTTON_MAX: "12" # bytes on the OK button
ALERT_SUBTITLE_MAX: "160" # bytes; wraps over the rows the card has
TILE_ICON_X: "0"
TILE_ICON_Y: "0"
TILE_TITLE_X: "48"
TILE_TITLE_Y: "4"
TILE_VALUE_X: "48"
TILE_VALUE_Y: "18"
# ----- 90° — Portrait 240×320 (USB on bottom) -----
# ORIENTATION: "90"
# DISPLAY_W: "240"
# DISPLAY_H: "320"
# LVGL_ROTATION: "180"
# TOUCH_SWAP_XY: "false"
# TOUCH_MIRROR_X: "true"
# TOUCH_MIRROR_Y: "false"
# TOUCH_CAL_X_MIN: "280"
# TOUCH_CAL_X_MAX: "3860"
# TOUCH_CAL_Y_MIN: "340"
# TOUCH_CAL_Y_MAX: "3860"
# GRID_COL1_X: "6"
# GRID_COL2_X: "123"
# GRID_ROW1_Y: "8"
# GRID_ROW2_Y: "96"
# GRID_ROW3_Y: "184"
# GRID_ROW4_Y: "272"
# GRID_ROW5_Y: "360"
# SCROLL_Y: "44"
# SCROLL_H: "276"
# TILE_W: "111"
# TILE_H: "80"
# HEADER_Y: "14"
# OVERLAY_SLIDER_W: "96"
# OVERLAY_SLIDER_H: "200"
# OVERLAY_ICON_Y: "94"
# TILE_ICON_X: "0"
# TILE_ICON_Y: "0"
# TILE_TITLE_X: "4"
# TILE_TITLE_Y: "38"
# TILE_VALUE_X: "4"
# TILE_VALUE_Y: "52"
# ----- 180° — Landscape flipped 320×240 (USB on left) -----
# ORIENTATION: "180"
# DISPLAY_W: "320"
# DISPLAY_H: "240"
# LVGL_ROTATION: "270"
# TOUCH_SWAP_XY: "false"
# TOUCH_MIRROR_X: "true"
# TOUCH_MIRROR_Y: "false"
# TOUCH_CAL_X_MIN: "280"
# TOUCH_CAL_X_MAX: "3860"
# TOUCH_CAL_Y_MIN: "340"
# TOUCH_CAL_Y_MAX: "3860"
# GRID_COL1_X: "9"
# GRID_COL2_X: "164"
# GRID_ROW1_Y: "8"
# GRID_ROW2_Y: "68"
# GRID_ROW3_Y: "128"
# GRID_ROW4_Y: "188"
# GRID_ROW5_Y: "248"
# SCROLL_Y: "44"
# SCROLL_H: "196"
# TILE_W: "147"
# TILE_H: "52"
# HEADER_Y: "14"
# OVERLAY_SLIDER_W: "96"
# OVERLAY_SLIDER_H: "160"
# OVERLAY_ICON_Y: "74"
# TILE_ICON_X: "0"
# TILE_ICON_Y: "0"
# TILE_TITLE_X: "48"
# TILE_TITLE_Y: "4"
# TILE_VALUE_X: "48"
# TILE_VALUE_Y: "18"
# ----- 270° — Portrait flipped 240×320 (USB on top) -----
# ORIENTATION: "270"
# DISPLAY_W: "240"
# DISPLAY_H: "320"
# LVGL_ROTATION: "0"
# TOUCH_SWAP_XY: "false"
# TOUCH_MIRROR_X: "true"
# TOUCH_MIRROR_Y: "false"
# TOUCH_CAL_X_MIN: "280"
# TOUCH_CAL_X_MAX: "3860"
# TOUCH_CAL_Y_MIN: "340"
# TOUCH_CAL_Y_MAX: "3860"
# GRID_COL1_X: "6"
# GRID_COL2_X: "123"
# GRID_ROW1_Y: "8"
# GRID_ROW2_Y: "96"
# GRID_ROW3_Y: "184"
# GRID_ROW4_Y: "272"
# GRID_ROW5_Y: "360"
# SCROLL_Y: "44"
# SCROLL_H: "276"
# TILE_W: "111"
# TILE_H: "80"
# HEADER_Y: "14"
# OVERLAY_SLIDER_W: "96"
# OVERLAY_SLIDER_H: "200"
# OVERLAY_ICON_Y: "94"
# TILE_ICON_X: "0"
# TILE_ICON_Y: "0"
# TILE_TITLE_X: "4"
# TILE_TITLE_Y: "38"
# TILE_VALUE_X: "4"
# TILE_VALUE_Y: "52"
# Fonts: one size per role, the same files on every board
FONT_TIME_SIZE: "18" # the clock in the top bar
FONT_HEADLINE_SIZE: "18" # card titles
FONT_WATCH_VALUE_SIZE: "22"
FONT_CLOCK_DIGITS_SIZE: "24"
FONT_SETPOINT_SIZE: "40" # the climate card's setpoint
FONT_LABEL_SIZE: "11" # tile titles
FONT_SUBLABEL_SIZE: "11" # tile values and small text
FONT_SUBLABEL_BIG_SIZE: "14" # the top bar and settings rows
FONT_ICON_SIZE: "28" # tile icons
FONT_ICON_MINI_SIZE: "18" # the top bar and card keys
FONT_ICON_BIG_SIZE: "40" # full-page tiles
FONT_WATCH_ICON_SIZE: "12"
# Tiles and the top bar
TILE_RADIUS: "18" # corner of a tile
TILE_PAD: "8" # inside a tile
TILE_ICON_SIZE: "36" # the icon circle
HEADER_INSET: "11" # the room name and the clock from the glass edge
# The page bar under the tiles
PAGE_KEY_W: "160" # the previous/next keys under the tiles
PAGE_BAR_H: "36" # the page bar
PAGE_NUMBER_W: "104" # the dots in the middle
PAGE_CHEVRON_INSET: "14" # the chevrons from the outer edge
# Cards: the round keys and the brightness card
CARD_KEY_X: "10" # a card's round key from the side
CARD_KEY_Y: "8" # from the top
CARD_KEY_SIZE: "40" # its diameter
OVERLAY_TITLE_Y: "19" # the brightness card's title
OVERLAY_VALUE_Y: "42" # its percentage
OVERLAY_SLIDER_Y: "24" # its slider below the centre
OVERLAY_SLIDER_RADIUS: "28" # the slider's corners
OVERLAY_KNOB_PAD: "10" # the knob
COLOR_KEY_X: "-13" # the colour wheel key from the bottom right corner
COLOR_KEY_Y: "-13"
COLOR_KEY_SIZE: "44" # its diameter
COLOR_ARC_SIZE: "40" # the eight arcs in it
COLOR_ARC_W: "10"
COLOR_TITLE_W: "210" # the colour card's title
COLOR_TITLE_Y: "21"
# The climate card
CLIMATE_TITLE_Y: "18" # the title
CLIMATE_TITLE_W: "210"
CLIMATE_STATUS_Y: "54" # the status line
CLIMATE_CARD_Y: "80" # the setpoint card
CLIMATE_CARD_H: "92"
CLIMATE_CARD_RADIUS: "16"
CLIMATE_KEY_INSET: "10" # the -/+ keys from the card edge
CLIMATE_KEY_SIZE: "72" # their diameter
CLIMATE_KEY_RADIUS: "36"
CLIMATE_NUMBER_Y: "-8" # the setpoint number
CLIMATE_CAPTION_Y: "26" # the word under it
CLIMATE_MODES_Y: "184" # the row of mode keys
CLIMATE_MODES_H: "46"
CLIMATE_MODES_GAP: "6"
CLIMATE_MODE_RADIUS: "23"
# The mode picker
PICKER_TITLE_Y: "21" # the "Mode" title
PICKER_LIST_Y: "44" # the list of choices
PICKER_LIST_H: "178"
PICKER_PAD: "3"
PICKER_ROW_GAP: "3"
PICKER_COL_GAP: "4"
PICKER_MODE_W: "88" # a mode chip
PICKER_MODE_H: "26"
PICKER_MODE_RADIUS: "13"
PICKER_TITLE_H: "11" # the Fan and Swing captions
PICKER_CHIP_W: "62" # a fan or swing chip
PICKER_CHIP_H: "22"
PICKER_CHIP_RADIUS: "11"
PICKER_CHIP_FONT: "sublabel" # the words on them
# The touch test page
TOUCH_TEST_PAGE_PAINT: "paint_calibration" # the page
TOUCH_TEST_TITLE: "Follow the calibration wizard"# the words at the top
TOUCH_TEST_TITLE_FONT: "sublabel"
TOUCH_TEST_TITLE_Y: "65"
TOUCH_TEST_INSET: "10" # the corner crosses from the edge
TOUCH_TEST_FAR_X: "289" # the right crosses: width - inset - size - 1
TOUCH_TEST_FAR_Y: "209" # the bottom crosses
TOUCH_TEST_MID_X: "150" # the middle cross
TOUCH_TEST_MID_Y: "110"
TOUCH_TEST_SIZE: "20" # a cross
TOUCH_TEST_PAINT: "paint_calibration" # the crosses
# What the shared scripts do differently on this board
LVGL_DEFAULT_FONT: "sublabel_big" # LVGL's default font: every label names its own, so this only keeps Montserrat out of the build
LVGL_BUFFER_SIZE: "12%" # LVGL's draw buffer as a share of the screen
PAGE_DOTS_LARGE: "false" # bigger page dots
EFFECTS_ROW_FONT: "sublabel_big" # the effects page's rows
ROTATION_SUPPORTED: "false" # the screen turns a quarter from the settings
STANDBY_DIM_MS: "300" # how long the backlight takes to dim into standby (waking takes 300)
ALERT_FLASH_FADE_MS: "0" # an alert's flash: the fade of each step
ALERT_FLASH_DARK_MS: "120" # how long it stays dark
ALERT_FLASH_LIT_MS: "170" # how long it stays lit
# Code the shared lambdas of packages/core.yaml take from this board, at the place named ${...}: the
# exact lines the old profile had there. Empty when this board adds nothing at that place; every board
# must name every hook, or ESPHome stops at the undefined one.
# the first boot step: the touch guard, and what this board's panel needs
BOOT_TOUCH: |-
// Per-panel affine correction; shared defaults are neutral.
id(ts_touch).set_raw_correction(${TOUCH_AFFINE_XX}f, ${TOUCH_AFFINE_XY}f, ${TOUCH_AFFINE_XC}f, ${TOUCH_AFFINE_YX}f, ${TOUCH_AFFINE_YY}f, ${TOUCH_AFFINE_YC}f);
cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS});
cyd::edge_snap_band = ${SLIDER_EDGE_SNAP_PX};
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
id(display_dimmed) = false;
# right after runtime_tiles::enabled = true, before the settings load
BOOT_ENABLED_HOOK: ""
# the paint style only this board has (theme::paints)
BOARD_PAINT_FILL: "theme::fill(id(paint_calibration), Paint::calibration);"
# in runtime_tiles::dismiss, after the detail card closes
CLOSE_CARDS_HOOK: ""
# what LVGL's gesture over the tiles does on this board
BOOT_PAGE_GESTURE: |-
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *event) {
if(!runtime_tiles::enabled || !runtime_tiles::swipe_pages || id(display_dimmed) || id(calibration_active) ||
!id(active_entity).empty() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root,LV_OBJ_FLAG_HIDDEN)))return;
auto *input=lv_indev_active();if(!input)return;
auto direction=lv_indev_get_gesture_dir(input);
if(direction!=LV_DIR_LEFT && direction!=LV_DIR_RIGHT)return;
// A quick drag on a slider is that slider's, never a page swipe (firmware 0.2.68+).
if(runtime_tiles::captured_slider){ESP_LOGI("touch","page swipe ignored: a slider is being dragged");return;}
cyd::touch_guard.consume();
lv_indev_wait_release(input);
ESP_LOGI("touch","page swipe: page %d -> %d",id(tile_page),id(tile_page)+(direction==LV_DIR_LEFT?1:-1));
id(tile_page)+=direction==LV_DIR_LEFT?1:-1;
id(show_tile_page).execute();
}, LV_EVENT_GESTURE, nullptr);
# after the gesture: the camera images of a board that shows them
BOOT_CAMERA_HOOKS: ""
# after the cards' hooks, before the settings page's: the calibration wizard of a resistive panel, the fan and swing card of a big one
BOOT_CARDS_TAIL: |-
if (!runtime_tiles::enabled) return;
screen_calibration::apply = [](const screen_calibration::Calibration &cal) {
id(ts_touch).set_raw_correction(cal.c[0],cal.c[1],cal.c[2],cal.c[3],cal.c[4],cal.c[5]);
};
screen_calibration::isolation = [](bool active) { id(calibration_active) = active; };
screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(),
${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX});
# what else keeps the settings page closed (inline, in the condition)
MAY_OPEN_EXTRA: ""
# the log line of a boot onto the touch test page
TOUCH_TEST_READY_LOG: "USB calibration ready; no tile actions"
# one more line of the ui_state diagnostics
UI_STATE_HOOK: ""
# every 250 ms after runtime_tiles::tick()
TICK_HOOK: ""
# apply_screen_settings: turning the screen, before the standby logic
APPLY_ROTATION: ""
# apply_screen_settings: how the backlight takes its level
APPLY_BACKLIGHT: "id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();"
# apply_screen_settings: the Rotation entity in Home Assistant follows
APPLY_ROTATION_ENTITY: ""
# back_to_page_1_when_due: what else counts as away from page 1 (inline)
AWAY_EXTRA: ""
# alert_show: after the settings page closed, before the card is made
ALERT_SHOW_HOOK: ""
# set_climate_fan_mode and set_climate_swing_mode: after the choice is stored
CLIMATE_MODE_CHOSEN_HOOK: ""
# open_value_overlay: the climate card of a panel in portrait, before its refresh
OPEN_VALUE_OVERLAY_TAIL: |-
// A portrait panel (240 x 320) keeps the same card, a little lower and with more air.
if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) {
lv_obj_set_y(id(climate_current_label), 60);
lv_obj_set_y(id(climate_setpoint_card), 96);
lv_obj_set_y(id(climate_mode_keys), 222);
for (lv_obj_t *key : {id(climate_minus_button), id(climate_plus_button)}) {
lv_obj_set_size(key, 56, 56);
lv_obj_set_style_radius(key, 28, LV_PART_MAIN);
}
lv_obj_set_x(id(climate_minus_button), 6);
lv_obj_set_x(id(climate_plus_button), -6);
}
# ============================================================
# No need to edit below this line.
# ============================================================
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
# ESPHome recommends this itself in the boot log. Yields ~40KB extra IRAM
# and a smaller binary, and every KB counts on this board.
sram1_as_iram: true
# minimum_chip_revision deliberately left out: that was here for the
# 032c board (rev 3.1 confirmed), but this is a different physical board
# with an unknown chip revision. With an unjustifiably high minimum
# the firmware simply refuses to boot.
version: recommended
variant: esp32
# Two SPI buses used by the CYD:
# - lcd: built-in display
# - my_touchscreen: built-in touch controller
# Two SPI buses on the 2432S028: one for the display, a separate one for the
# resistive XPT2046 touch (no I2C on this board, unlike the 032c).
spi:
- id: lcd
clk_pin: GPIO14
mosi_pin: GPIO13
- id: my_touchscreen
clk_pin: GPIO25
mosi_pin: GPIO32
miso_pin: GPIO39
output:
- platform: ledc
pin: GPIO21
id: gpio_backlight_pwm
# ESPHome's ledc default is 1kHz. You don't see that while still (well
# above what the eye perceives as flicker), but moving the screen reveals
# motion blur turning the on/off pulses into streaks (the same effect as
# a PWM LED that "judders" when you move it). 20kHz sits well above the
# speed at which motion still makes that visible, and still leaves ample
# duty-cycle resolution left over for smooth dimming.
frequency: 20000Hz
- platform: ledc
id: output_red
pin: GPIO4
inverted: true
- platform: ledc
id: output_green
pin: GPIO16
inverted: true
- platform: ledc
id: output_blue
pin: GPIO17
inverted: true
light:
# The firmware owns the backlight: brightness, standby, night and alerts set it, and the minute tick
# restores it. Home Assistant uses the brightness numbers and the Wake and Sleep buttons (0.2.46+).
- platform: monochromatic
output: gpio_backlight_pwm
name: "Power Display Backlight"
id: back_light
internal: true
restore_mode: ALWAYS_ON
- platform: rgb
name: LED
red: output_red
id: led
green: output_green
blue: output_blue
restore_mode: ALWAYS_OFF
# Touchscreen configuration (XPT2046, resistive, via SPI)
# XPT2046 calibration is board-specific. Poll at 50Hz; LVGL rotates touch
# with the display. GPIO36 IRQ is deliberately unused (driver requests a
# pull-up that this input-only pin cannot provide).
touchscreen:
platform: xpt2046
id: ts_touch
spi_id: my_touchscreen
cs_pin: 33
# Polling avoids GPIO36 pull-up errors and noisy PENIRQ edges.
update_interval: 20ms
# Diagnosis caught a false corner contact at pressure 422. Deliberate
# contacts measured 936-2643; reject the low-pressure tail on release.
threshold: 800
calibration:
x_min: ${TOUCH_CAL_X_MIN}
x_max: ${TOUCH_CAL_X_MAX}
y_min: ${TOUCH_CAL_Y_MIN}
y_max: ${TOUCH_CAL_Y_MAX}
transform:
swap_xy: ${TOUCH_SWAP_XY}
mirror_x: ${TOUCH_MIRROR_X}
mirror_y: ${TOUCH_MIRROR_Y}
on_touch:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
id(touch_down) = true;
cyd::touch_guard.begin(millis(), touch.x, touch.y, touch.id);
screen_calibration::press();
ESP_LOGI("touch", "press native=%u,%u raw=%d,%d pressure=%d calibration=%d", touch.x, touch.y, id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y(), touch.z_raw, id(calibration_active));
on_update:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
// Only the contact that started the touch counts; the guard ignores other ids.
for (const auto &point : touches) {
cyd::touch_guard.update(point.x, point.y, point.id);
}
on_release:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
id(touch_down) = false;
screen_calibration::release(id(ts_touch).filtered_raw_x(), id(ts_touch).filtered_raw_y());
# Display configuration (ILI9341)
display:
- platform: mipi_spi
id: my_display
spi_id: lcd
# Standard single-Micro-USB CYD; 40MHz matches ESPHome's board preset.
model: ILI9341
data_rate: 40MHz
cs_pin: 15
dc_pin: 2
invert_colors: false
update_interval: never
auto_clear_enabled: false
# Panel tuning, appended after ESPHome's built-in ILI9341 sequence. With the
# defaults (line inversion, 79 Hz) the cheap CYD panel shows a fine vertical
# stripe pattern that flickers when you move your eyes or the board: the
# two polarities of adjacent columns differ in brightness. Frame inversion
# (0xB4 = 0x07) removes the stripes; the maximum frame rate (0xB1 = 0x10,
# 119 Hz) keeps the resulting whole-frame flicker above what the eye sees.
# VCOM (0xC5) stayed at the default; sweeping it changed contrast, not the
# stripes. Checked on the bench CYD on 2026-09-15.
init_sequence:
- [0xB4, 0x07]
- [0xB1, 0x00, 0x10]
# Native panel dimensions (portrait). Do NOT swap these per orientation and
# do NOT add a display transform — rotation is handled by LVGL below.
# ESPHome 2026.6+ rejects a swapped transform here with "Invalid offsets".
dimensions:
width: 240
height: 320
lvgl:
style_definitions:
# The calibration wizard's page and crosses (theme::paints fills it: BOARD_PAINT_FILL).
- id: paint_calibration
button:
- platform: template
name: "Calibrate touch"
internal: ${DASHBOARD_INBOX_INTERNAL}
entity_category: config
on_press:
- lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();'
script:
# Render every overlay and both pages repeatedly without controlling devices.
- id: ui_self_test
mode: single
then:
- logger.log: "UI_TEST START: page/overlay render stress, no HA actions"
- lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");'
- lambda: 'id(calibration_active) = false;'
- lvgl.page.show: home_page
- script.execute: wake_display
- repeat:
count: 10
then:
- lambda: |-
id(last_touch_ms) = millis();
id(last_use_ms) = millis();
static unsigned test_page=0;
id(tile_page) = runtime_tiles::enabled ? test_page++ % runtime_tiles::page_count() : 1;
- script.execute: show_tile_page
- delay: 300ms
- lambda: |-
lv_obj_update_layout(id(tile_scroll));
const int count = runtime_tiles::model.count;
// Wide cards take two slots, so visibility follows the runtime packing.
const bool paginated = runtime_tiles::enabled ? runtime_tiles::page_count() > 1 : count > 6;
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
bool ok = true;
for (int i = 0; i < 10; ++i) {
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
// The bar shows with more than one page, unless the Page buttons setting is off (firmware 0.2.69+).
const bool bar = paginated && (!runtime_tiles::enabled || settings_screen::page_buttons);
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != bar);
lv_area_t nav, grid;
lv_obj_get_coords(id(page_next), &nav);
lv_obj_get_coords(id(tile_scroll), &grid);
ok = ok && (!bar || grid.y2 < nav.y1);
if(runtime_tiles::enabled)ok = runtime_tiles::check_tile_geometry() && ok;
ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1);
id(tile_page) = 0;
- script.execute: show_tile_page
- delay: 150ms
- lvgl.widget.show: brightness_overlay
- delay: 150ms
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
- delay: 150ms
- lvgl.widget.hide: color_detail_overlay
- lvgl.widget.show: climate_detail_overlay
- delay: 150ms
- lvgl.widget.hide: climate_detail_overlay
- lvgl.widget.show: climate_mode_overlay
- delay: 150ms
- lvgl.widget.hide: climate_mode_overlay
- lvgl.widget.show: alert_overlay
- delay: 150ms
- lvgl.widget.hide: alert_overlay
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
- logger.log: "UI_TEST COMPLETE"
# The two scripts that drive the backlight as a light action; the Guition drives it through its set_backlight script.
- id: alert_dismiss
parameters:
reason: string
then:
- if:
condition:
lambda: 'return id(alert_active);'
then:
- lambda: |-
id(alert_active) = false;
id(last_touch_ms) = millis();
ESP_LOGI("alert", "dismissed: %s", reason.c_str());
- lvgl.widget.hide: alert_overlay
- script.stop: alert_timeout
- script.stop: alert_flash
- light.turn_on:
id: back_light
transition_length: 80ms
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- script.execute:
id: alert_report
action: !lambda 'return reason;'
# Four backlight blinks when the alert arrives; the screen then stays on as usual.
- id: alert_flash
mode: restart
then:
- repeat:
count: 4
then:
- light.turn_off:
id: back_light
transition_length: 0s
- delay: 120ms
- light.turn_on:
id: back_light
transition_length: 0s
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- delay: 170ms
- id: wake_display
mode: restart
then:
- lambda: |-
id(last_touch_ms) = millis();
id(display_dimmed) = false;
id(sleep_requested) = false;
- light.turn_on:
id: back_light
# Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish
# when you touch the screen and are waiting on light.
transition_length: 80ms
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
- lvgl.widget.hide: dim_wake_overlay
- script.execute: close_cards
# The climate card as the small panel lays it out: the Guition has its own copy with a fan and swing card.
- id: climate_card_refresh
then:
- lambda: |-
auto lower = [](std::string v) {
for (char &c : v) { if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); }
return v;
};
std::string mode = lower(id(active_climate_mode));
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
// What the device does (Heating), else its mode (Heat); then the room.
std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
if (status.empty()) status = tile_controls::climate_mode_text(mode);
char part[32];
if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; }
if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; }
lv_label_set_text(id(climate_current_label), status.c_str());
int target = id(active_climate_target_x10);
std::string value = id(active_climate_target_known)
? tile_controls::format_value(target / 10.0f, id(active_climate_step_x10) / 10.0f, "°") : std::string("--");
lv_label_set_text(id(climate_target_label), value.c_str());
lv_obj_set_style_text_color(id(climate_target_label), theme::color(off ? theme::OFF : theme::INK), LV_PART_MAIN);
auto enable = [](lv_obj_t *key, bool on) {
if (on) lv_obj_remove_state(key, LV_STATE_DISABLED); else lv_obj_add_state(key, LV_STATE_DISABLED);
};
enable(id(climate_minus_button), !id(active_climate_target_known) || target > id(active_climate_min_x10));
enable(id(climate_plus_button), !id(active_climate_target_known) || target < id(active_climate_max_x10));
// One key per supported mode, in Home Assistant's order. Fan and swing add "more"; with it
// five mode keys fit, and when a sixth mode is in use it takes the last place.
std::string supported = lower(id(active_climate_supported_modes));
auto has = [&](const char *m) {
return supported.find(std::string("\"") + m + "\"") != std::string::npos ||
supported.find(std::string("'") + m + "'") != std::string::npos;
};
struct Key { lv_obj_t *obj; const char *mode; };
const Key keys[] = {{id(cmkey_auto), "auto"}, {id(cmkey_heat_cool), "heat_cool"}, {id(cmkey_heat), "heat"},
{id(cmkey_cool), "cool"}, {id(cmkey_dry), "dry"}, {id(cmkey_fan_only), "fan_only"}};
bool more = !id(active_climate_fan_modes).empty() || !id(active_climate_swing_modes).empty();
const Key *visible[6];
unsigned count = 0, room = more ? 5 : 6;
for (const auto &k : keys) if (has(k.mode)) visible[count++] = &k;
if (count > room) {
for (unsigned i = room; i < count; ++i) if (mode == visible[i]->mode) visible[room - 1] = visible[i];
count = room;
}
for (const auto &k : keys) {
bool show = false;
for (unsigned i = 0; i < count; ++i) show = show || visible[i] == &k;
if (show) lv_obj_remove_flag(k.obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(k.obj, LV_OBJ_FLAG_HIDDEN);
bool selected = show && !off && mode == k.mode;
uint32_t color = tile_controls::mode_color(k.mode);
lv_obj_set_style_bg_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::CARD), LV_PART_MAIN);
lv_obj_set_style_border_color(k.obj, selected ? lv_color_hex(color) : theme::color(theme::LINE), LV_PART_MAIN);
lv_obj_set_style_text_color(lv_obj_get_child(k.obj, 0), theme::color(selected ? theme::ON_ACCENT : theme::SLATE), LV_PART_MAIN);
}
if (more) lv_obj_remove_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(id(cmkey_more), LV_OBJ_FLAG_HIDDEN);
// One mode is no choice: the power key already turns it on and off. Without keys the
// setpoint card moves to the middle of the room below the status line.
const bool show_keys = count > 1 || more;
if (show_keys) lv_obj_remove_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(climate_mode_keys), LV_OBJ_FLAG_HIDDEN);
const bool landscape = atoi("${DISPLAY_W}") >= atoi("${DISPLAY_H}");
lv_obj_set_y(id(climate_setpoint_card), show_keys ? (landscape ? 80 : 96) : (landscape ? 108 : 140));
id(climate_power_refresh).execute();
File diff suppressed because it is too large Load Diff
+4294
View File
File diff suppressed because it is too large Load Diff
+10 -4861
View File
File diff suppressed because it is too large Load Diff
+11 -5030
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -1,3 +1,12 @@
## 0.2.84 (firmware 0.2.70)
One shared screen for every board: the two board profiles became one core plus a small file per board.
- **`packages/core.yaml` is the screen, every board builds from it.** Before, the CYD and the Guition each had a 5,000-line profile of which 87 % was the same text; every change had to be made twice, and the two had drifted apart in small ways. Now the LVGL tree, the cards, the scripts, the API actions, the entities and the fonts live once. A board file under `packages/boards/` holds what is that board's: its hardware, a table with its 84 sizes and font sizes, the lines of C++ only it adds to a shared lambda (named *hooks*, 18 of them), and its own parts (the CYD's calibration wizard, the Guition's camera images and Rotation). docs/PROFILES.md explains the files and how a new board is added: copy the nearest board file, fill the table, go through the hooks.
- **Nothing changes on a screen.** `packages/cyd.yaml` and `packages/guition.yaml` keep their names and places, so a screen installed from ESP Screens builds as before, and the firmware stays 0.2.70: the C++ ESPHome generates for each board was compared line by line with that of the old profiles, through the checkout profiles and through the GitHub route the add-on uses. Every value, every lambda and every component is the same; what differs is whitespace, line numbers, the order in which ESPHome lists fonts, entities and scripts, one boot lambda on the CYD that now holds the calibration setup together with the effects page's hooks (the same statements in the same order), and the alert's picture frame on a Guition being created after the OK button (they never overlap). docs/TEST_RESULTS_0284.md has the method and the figures.
- `tools/check_packages.py` (run by `tools/check.sh` and CI) fails when a board file leaves out a name the core uses, so a new board cannot forget a size or a hook; `tools/generate_packages.py` is gone, there is nothing to generate any more.
- No firmware update: press nothing on the screens. A screen that is built again anyway gets the same code: the CYD image is 1,676,912 bytes, 91.4 % of the update slot, 1,440 bytes more than the same 0.2.83 built on the same computer (the Guition 1,536 bytes more), all of it in ESPHome's generated `setup()` where the components are now registered in the merge's order; every lambda, every string and every font is the same size as before.
## 0.2.83 (firmware 0.2.70) ## 0.2.83 (firmware 0.2.70)
A lamp's modes on the screen: effects, palettes and presets of a WLED, and the effect of any light that has one. A lamp's modes on the screen: effects, palettes and presets of a WLED, and the effect of any light that has one.
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager name: ESP Screen Manager
version: "0.2.83" version: "0.2.84"
slug: esp_screen_manager slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing. description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen url: https://github.com/MaxGramser/homeassistant_espscreen
+8 -5
View File
@@ -6,6 +6,8 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import tile_icons # noqa: E402 import tile_icons # noqa: E402
@@ -37,7 +39,7 @@ def line_height(font, size):
class AlertTests(unittest.TestCase): class AlertTests(unittest.TestCase):
def sources(self): def sources(self):
for name in list(PROFILES.values()) + list(PACKAGES.values()): for name in list(PROFILES.values()) + list(PACKAGES.values()):
yield name, (ROOT / name).read_text() yield name, profiles.text(name)
def test_action_takes_the_six_fields_and_hands_them_to_one_script(self): def test_action_takes_the_six_fields_and_hands_them_to_one_script(self):
for name, text in self.sources(): for name, text in self.sources():
@@ -75,7 +77,8 @@ class AlertTests(unittest.TestCase):
title = section(top, 'id: alert_title\n', 'id: alert_subtitle') title = section(top, 'id: alert_title\n', 'id: alert_subtitle')
self.assertIn('height: ${ALERT_TITLE_H}\n', title, name) self.assertIn('height: ${ALERT_TITLE_H}\n', title, name)
self.assertIn('text_font: headline\n', title, name) self.assertIn('text_font: headline\n', title, name)
font, size = re.search(r'(?m)^ - file: "(?:[^"\n]*/)?(fonts/[^"\n]+)"\n id: headline\n size: (\d+)$', text).groups() # The core names the font as ${FONT_DIR}/... at the board's ${FONT_HEADLINE_SIZE} (app 0.2.84+).
font, size = re.search(r'(?m)^ - file: "(?:[^"\n]*/)?(fonts/[^"\n]+)"\n id: headline\n size: (\d+)$', profiles.resolved(name)).groups()
v = lambda key: int(re.search(rf'^ {key}: "(-?\d+)"', text, re.M)[1]) v = lambda key: int(re.search(rf'^ {key}: "(-?\d+)"', text, re.M)[1])
self.assertEqual(v('ALERT_TITLE_H'), line_height(ROOT / font, int(size)), name) self.assertEqual(v('ALERT_TITLE_H'), line_height(ROOT / font, int(size)), name)
self.assertLessEqual(v('ALERT_TITLE_Y') + v('ALERT_TITLE_H'), v('ALERT_SUBTITLE_Y'), name) self.assertLessEqual(v('ALERT_TITLE_Y') + v('ALERT_TITLE_H'), v('ALERT_SUBTITLE_Y'), name)
@@ -107,7 +110,7 @@ class AlertTests(unittest.TestCase):
self.assertIn('screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX})', show, name) self.assertIn('screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX})', show, name)
self.assertLess(show.index('script.execute: wake_display'), show.index('lvgl.widget.show: alert_overlay'), name) self.assertLess(show.index('script.execute: wake_display'), show.index('lvgl.widget.show: alert_overlay'), name)
self.assertLess(show.index('lvgl.widget.show: alert_overlay'), show.index('script.execute: alert_flash'), name) self.assertLess(show.index('lvgl.widget.show: alert_overlay'), show.index('script.execute: alert_flash'), name)
self_test = section(text, ' - id: ui_self_test\n', ' - id: show_tile_page') self_test = section(text, ' - id: ui_self_test\n', '\n - id: ')
self.assertIn('lvgl.widget.show: alert_overlay', self_test, name) self.assertIn('lvgl.widget.show: alert_overlay', self_test, name)
self.assertIn('lvgl.widget.hide: alert_overlay', self_test, name) self.assertIn('lvgl.widget.hide: alert_overlay', self_test, name)
for forbidden in ('alert_show', 'alert_dismiss', 'homeassistant.event'): for forbidden in ('alert_show', 'alert_dismiss', 'homeassistant.event'):
@@ -131,8 +134,8 @@ class AlertTests(unittest.TestCase):
def test_helper_header_is_included_locally_and_in_the_remote_package(self): def test_helper_header_is_included_locally_and_in_the_remote_package(self):
header = ' - <esphome/components/smart_display/alert_overlay.h>\n' header = ' - <esphome/components/smart_display/alert_overlay.h>\n'
for board, name in PROFILES.items(): for board, name in PROFILES.items():
self.assertIn(header, (ROOT / name).read_text(), name) self.assertIn(header, profiles.text(name), name)
self.assertIn(header, (ROOT / PACKAGES[board]).read_text(), board) self.assertIn(header, profiles.text(PACKAGES[board]), board)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+3 -1
View File
@@ -8,6 +8,8 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests')) sys.path.insert(0, str(ROOT / 'tests'))
import tile_icons # noqa: E402 import tile_icons # noqa: E402
@@ -22,7 +24,7 @@ class ReferenceTests(unittest.TestCase):
def test_fields_and_limits_match_both_board_profiles(self): def test_fields_and_limits_match_both_board_profiles(self):
reference = alert_reference() reference = alert_reference()
for board, name in PROFILES.items(): for board, name in PROFILES.items():
text = (ROOT / name).read_text() text = profiles.text(name)
block = text.split(' - action: show_alert\n', 1)[1].split(' - action: dismiss_alert\n', 1)[0] block = text.split(' - action: show_alert\n', 1)[1].split(' - action: dismiss_alert\n', 1)[0]
self.assertEqual(re.findall(r'^ (\w+): (\w+)$', block, re.M), [(f['name'], f['type']) for f in reference['fields']], name) self.assertEqual(re.findall(r'^ (\w+): (\w+)$', block, re.M), [(f['name'], f['type']) for f in reference['fields']], name)
for field, key in (('title', 'ALERT_TITLE_MAX'), ('subtitle', 'ALERT_SUBTITLE_MAX'), ('button_text', 'ALERT_BUTTON_MAX')): for field, key in (('title', 'ALERT_TITLE_MAX'), ('subtitle', 'ALERT_SUBTITLE_MAX'), ('button_text', 'ALERT_BUTTON_MAX')):
+3 -1
View File
@@ -10,6 +10,8 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402 import header_bar # noqa: E402
import history_card # noqa: E402 import history_card # noqa: E402
@@ -62,7 +64,7 @@ class BinaryWords(unittest.TestCase):
letters = set(''.join(on + off for on, off in header_bar.BINARY_STATES.values())) letters = set(''.join(on + off for on, off in header_bar.BINARY_STATES.values()))
self.assertLessEqual(letters, set(header_bar.GLYPHS)) self.assertLessEqual(letters, set(header_bar.GLYPHS))
for name in PROFILES: for name in PROFILES:
text = (ROOT / name).read_text() text = profiles.resolved(name)
for font in ('headline', 'watch_value', 'label', 'sublabel', 'sublabel_big'): for font in ('headline', 'watch_value', 'label', 'sublabel', 'sublabel_big'):
block = re.search(r'id: ' + font + r'\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S) block = re.search(r'id: ' + font + r'\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S)
self.assertIsNotNone(block, (name, font)) self.assertIsNotNone(block, (name, font))
+4 -2
View File
@@ -10,6 +10,8 @@ import tempfile
import unittest import unittest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import camera_feed # noqa: E402 import camera_feed # noqa: E402
from core import (ALERT_FIELDS, BROADCAST_SHOW, CAMERA_MIN_FIRMWARE, alert_camera, alert_reference, entity_id, # noqa: E402 from core import (ALERT_FIELDS, BROADCAST_SHOW, CAMERA_MIN_FIRMWARE, alert_camera, alert_reference, entity_id, # noqa: E402
@@ -20,7 +22,7 @@ HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP: if HAS_AIOHTTP:
from server import HomeAssistant, Manager from server import HomeAssistant, Manager
PROFILE = (ROOT / 'guition-4848s040.yaml').read_text() PROFILE = profiles.text('guition-4848s040.yaml')
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
@@ -87,7 +89,7 @@ class Rules(unittest.TestCase):
self.assertEqual(PROFILE.count('if (url != current) {'), 2) self.assertEqual(PROFILE.count('if (url != current) {'), 2)
# A busy camera port leaves the rest of the app running. # A busy camera port leaves the rest of the app running.
self.assertIn("except OSError as error:\n # Everything else still works; only camera images stay away.", (ROOT / 'screen_manager/app/server.py').read_text()) self.assertIn("except OSError as error:\n # Everything else still works; only camera images stay away.", (ROOT / 'screen_manager/app/server.py').read_text())
cyd = (ROOT / 'home-like-2432s028.yaml').read_text() cyd = profiles.text('home-like-2432s028.yaml')
self.assertNotIn('online_image', cyd) self.assertNotIn('online_image', cyd)
self.assertNotIn('camera_full.load', cyd) self.assertNotIn('camera_full.load', cyd)
# The add-on's port is published, and the Docker route passes it on with the host network. # The add-on's port is published, and the Docker route passes it on with the host network.
+3 -1
View File
@@ -15,13 +15,15 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests')) sys.path.insert(0, str(ROOT / 'tests'))
from core import ATTRS, cover_related, discover_screens, extras, state_message # noqa: E402 from core import ATTRS, cover_related, discover_screens, extras, state_message # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
PROFILES = {name: (ROOT / name).read_text() for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml')} PROFILES = {name: profiles.text(name) for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml')}
BLIND = 'cover.venetianblind_0001' BLIND = 'cover.venetianblind_0001'
+53 -19
View File
@@ -1,10 +1,15 @@
"""The packages a screen builds from: generated from the board profiles, runtime tiles only.""" """The packages a screen builds from: one shared core plus a board file, runtime tiles only (app 0.2.84+)."""
import importlib.util import importlib.util
import re import re
import subprocess
import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
BOARDS = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'} BOARDS = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'}
# The old manual profile bound tiles to fixed entities in the YAML. ESP Screens sends the tiles now, so # The old manual profile bound tiles to fixed entities in the YAML. ESP Screens sends the tiles now, so
# none of this may come back: the fixed subscriptions, its tap handlers and scripts, its own vacuum card. # none of this may come back: the fixed subscriptions, its tap handlers and scripts, its own vacuum card.
@@ -14,49 +19,78 @@ GONE = ['ha_state_tile1', 'tile1_brightness', 'tile6_climate_humidity', 'tile6_c
'light_controls::subscribe'] 'light_controls::subscribe']
def load_generator(): def load_checker():
spec = importlib.util.spec_from_file_location('generate_packages', ROOT / 'tools/generate_packages.py') spec = importlib.util.spec_from_file_location('check_packages', ROOT / 'tools/check_packages.py')
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
return module return module
class PackageTests(unittest.TestCase): class PackageTests(unittest.TestCase):
def test_packages_are_current(self): def test_the_packages_fit_together(self):
generator = load_generator() result = subprocess.run([sys.executable, str(ROOT / 'tools/check_packages.py')], capture_output=True, text=True)
for board in BOARDS: self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual((ROOT / 'packages' / f'{board}.yaml').read_text(), generator.generate(board), board)
def test_no_secrets_and_no_local_paths(self): def test_every_entry_is_the_shared_core_plus_its_board(self):
checker = load_checker()
for board, profile in BOARDS.items():
for name in (profile, f'packages/{board}.yaml'):
self.assertEqual(sorted(checker.included(ROOT / name)), sorted(['packages/core.yaml', str(profiles.BOARDS[board].relative_to(ROOT))]), name)
def test_the_published_entry_takes_everything_from_github(self):
for board in BOARDS: for board in BOARDS:
package = (ROOT / 'packages' / f'{board}.yaml').read_text() package = (ROOT / 'packages' / f'{board}.yaml').read_text()
self.assertNotIn('!secret', package) self.assertNotIn('!secret', package)
self.assertNotIn('type: local', package) self.assertNotIn('type: local', package)
self.assertIn('url: https://github.com/MaxGramser/homeassistant_espscreen.git', package) self.assertIn('url: https://github.com/MaxGramser/homeassistant_espscreen.git', package)
self.assertIn('FONT_DIR: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts"', package)
# The fonts of the shared core come from that place; a checkout takes them from fonts/.
self.assertIn('file: "${FONT_DIR}/Roboto-500.ttf"', profiles.CORE.read_text())
self.assertIn('FONT_DIR: "fonts"', (ROOT / BOARDS[board]).read_text())
def test_no_secret_or_local_path_under_packages(self):
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('#'))
self.assertNotIn('!secret', text, path.name)
self.assertNotIn('type: local', text, path.name)
for section in ('sensor', 'binary_sensor', 'text_sensor'):
block = re.search(r'^' + section + r':\n.*?(?=^[a-zA-Z_]+:|\Z)', text, re.M | re.S)
self.assertFalse(block and re.search(r'^ - platform: homeassistant$', block[0], re.M), f'{path.name}: a {section} subscribes to Home Assistant')
def test_the_manual_profile_is_gone_from_profiles_and_packages(self): def test_the_manual_profile_is_gone_from_profiles_and_packages(self):
for board, name in BOARDS.items(): for board, name in BOARDS.items():
for path in (ROOT / name, ROOT / 'packages' / f'{board}.yaml'): for entry in (name, f'packages/{board}.yaml'):
text = '\n'.join(line for line in path.read_text().split('\n') if not line.lstrip().startswith('#')) text = '\n'.join(line for line in profiles.text(entry).split('\n') if not line.lstrip().startswith('#'))
for key in GONE: for key in GONE:
self.assertNotIn(key, text, f'{path.name} still carries {key}') self.assertNotIn(key, text, f'{entry} still carries {key}')
def test_runtime_tiles_keep_what_they_bind_and_open(self): def test_runtime_tiles_keep_what_they_bind_and_open(self):
for board in BOARDS: for board in BOARDS:
package = (ROOT / 'packages' / f'{board}.yaml').read_text() package = profiles.text(f'packages/{board}.yaml')
for key in ['runtime_tiles::bind(9, id(tile10)', 'runtime_tiles::enabled = true;', 'id: open_value_overlay', for key in ['runtime_tiles::bind(9, id(tile10)', 'runtime_tiles::enabled = true;', 'id: open_value_overlay',
'id: ui_refresh', 'runtime_tiles::render(id(lbl_room));', 'id: climate_detail_overlay', 'id: ui_refresh', 'runtime_tiles::render(id(lbl_room));', 'id: climate_detail_overlay',
'id: color_detail_overlay']: 'id: color_detail_overlay']:
self.assertIn(key, package, board) self.assertIn(key, package, board)
# The CYD keeps its resistive calibration on the screen itself; the Guition's GT911 needs none. # The CYD keeps its resistive calibration on the screen itself; the Guition's GT911 needs none.
self.assertIn('screen_calibration::setup(', (ROOT / 'packages/cyd.yaml').read_text()) self.assertIn('screen_calibration::setup(', profiles.resolved('packages/cyd.yaml'))
self.assertNotIn('screen_calibration::setup(', profiles.resolved('packages/guition.yaml'))
def test_generator_refuses_a_fixed_home_assistant_subscription(self): def test_the_core_carries_no_board_number_and_every_board_the_same_names(self):
generator = load_generator() core = profiles.CORE.read_text()
text = (ROOT / BOARDS['cyd']).read_text() for needle in ('esp32:', 'platform: xpt2046', 'platform: gt911', 'platform: st7701s', 'platform: mipi_spi', 'psram:',
stray = ' - platform: homeassistant\n id: stray\n entity_id: sensor.stray\n\nbinary_sensor:\n' 'set_raw_correction', 'camera_image', 'GPIO'):
with self.assertRaises(ValueError): self.assertNotIn(needle, core, f'{needle} is a board\'s, not the core\'s')
generator.generate('cyd', text.replace('binary_sensor:\n', stray, 1)) names = {board: set(profiles.substitutions_of(path)) for board, path in profiles.BOARDS.items()}
shared = set.intersection(*names.values())
self.assertGreater(len(shared), 100)
for board, defined in names.items():
own = defined - shared
self.assertTrue(all(re.match(r'(TOUCH_AFFINE_|TOUCH_CAL_|EDGE_SWIPE_|ALERT_\w*IMAGE|CAMERA_)', n) for n in own), f'{board}: {sorted(own)}')
def test_the_checker_refuses_a_fixed_home_assistant_subscription(self):
source = (ROOT / 'tools/check_packages.py').read_text()
self.assertIn("re.search(r'^ - platform: homeassistant$', match[0], re.M)", source)
self.assertIn("for section in ('sensor', 'binary_sensor', 'text_sensor'):", source)
if __name__ == '__main__': if __name__ == '__main__':
+4 -2
View File
@@ -4,9 +4,11 @@ import re
import sys import sys
import unittest import unittest
ROOT=Path(__file__).resolve().parents[1] ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0,str(ROOT/'tools')) sys.path.insert(0,str(ROOT/'tools'))
import verify_gt911 import verify_gt911
SOURCE=(ROOT/'guition-4848s040.yaml').read_text() SOURCE=profiles.resolved('guition-4848s040.yaml')
VALUES=dict(re.findall(r'^ (\w+): "([^"]*)"',SOURCE,re.M)) VALUES=dict(re.findall(r'^ (\w+): "([^"]*)"',SOURCE,re.M))
class GuitionTests(unittest.TestCase): class GuitionTests(unittest.TestCase):
@@ -54,7 +56,7 @@ class GuitionTests(unittest.TestCase):
def test_all_tiles_clip_long_titles(self): def test_all_tiles_clip_long_titles(self):
for i in range(1,11): for i in range(1,11):
self.assertRegex(SOURCE,rf'id: t{i}_title\n\s+height: 24\n\s+width: 130\n\s+long_mode: DOT') self.assertRegex(SOURCE,rf'id: !extend t{i}_title\n\s+height: 24\n\s+width: 130\n\s+long_mode: DOT')
self.assertEqual(VALUES['AUTO_DIM_TIMEOUT'],'600') self.assertEqual(VALUES['AUTO_DIM_TIMEOUT'],'600')
def test_gt911_verification_accepts_pixels_and_rejects_wrong_orientation(self): def test_gt911_verification_accepts_pixels_and_rejects_wrong_orientation(self):
+6 -4
View File
@@ -10,6 +10,8 @@ from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402 import header_bar # noqa: E402
import tile_icons # noqa: E402 import tile_icons # noqa: E402
@@ -191,7 +193,7 @@ class MessageTests(unittest.TestCase):
class ParityTests(unittest.TestCase): class ParityTests(unittest.TestCase):
def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self): def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self):
for name in PROFILES + ('packages/cyd.yaml', 'packages/guition.yaml'): for name in PROFILES + ('packages/cyd.yaml', 'packages/guition.yaml'):
text = (ROOT / name).read_text() text = profiles.resolved(name)
block = re.search(r'id: sublabel_big\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S) block = re.search(r'id: sublabel_big\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S)
self.assertIsNotNone(block, name) self.assertIsNotNone(block, name)
glyphs = set(re.findall(r"'([^'])'|\"(')\"", block.group(1))) glyphs = set(re.findall(r"'([^'])'|\"(')\"", block.group(1)))
@@ -213,7 +215,7 @@ class ParityTests(unittest.TestCase):
def test_profiles_draw_the_bar_from_the_runtime(self): def test_profiles_draw_the_bar_from_the_runtime(self):
for name in PROFILES: for name in PROFILES:
text = (ROOT / name).read_text() text = profiles.text(name)
self.assertIn('runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();', text, name) self.assertIn('runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();', text, name)
self.assertIn('runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();', text, name) self.assertIn('runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();', text, name)
self.assertIn('runtime_tiles::time_label = id(lbl_time);', text, name) self.assertIn('runtime_tiles::time_label = id(lbl_time);', text, name)
@@ -224,10 +226,10 @@ class ParityTests(unittest.TestCase):
def test_second_hand_runs_only_while_the_screen_is_awake(self): def test_second_hand_runs_only_while_the_screen_is_awake(self):
for name in PROFILES: for name in PROFILES:
text = (ROOT / name).read_text() text = profiles.text(name)
self.assertIn('runtime_tiles::screen_awake = []() { return !id(display_dimmed); };', text, name) self.assertIn('runtime_tiles::screen_awake = []() { return !id(display_dimmed); };', text, name)
for name in ('packages/cyd.yaml', 'packages/guition.yaml'): for name in ('packages/cyd.yaml', 'packages/guition.yaml'):
self.assertIn('runtime_tiles::screen_awake', (ROOT / name).read_text(), name) self.assertIn('runtime_tiles::screen_awake', profiles.text(name), name)
self.assertIn('inline bool awake() { return !screen_awake || screen_awake(); }', TILES) self.assertIn('inline bool awake() { return !screen_awake || screen_awake(); }', TILES)
second_hand = TILES.split('inline void second_hand(', 1)[1].split('\n}', 1)[0] second_hand = TILES.split('inline void second_hand(', 1)[1].split('\n}', 1)[0]
self.assertIn('set_hidden(p,!(awake() && now.is_valid()));', second_hand) self.assertIn('set_hidden(p,!(awake() && now.is_valid()));', second_hand)
+3 -1
View File
@@ -15,6 +15,8 @@ from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests')) sys.path.insert(0, str(ROOT / 'tests'))
import history_card as hc # noqa: E402 import history_card as hc # noqa: E402
@@ -397,7 +399,7 @@ class Firmware(unittest.TestCase):
'lv_obj_add_event_cb(area,history_scrub,LV_EVENT_ALL,nullptr);'): 'lv_obj_add_event_cb(area,history_scrub,LV_EVENT_ALL,nullptr);'):
self.assertIn(flag, touch) self.assertIn(flag, touch)
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'): for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'):
text = (ROOT / name).read_text() text = profiles.text(name)
self.assertEqual(len(re.findall(r'runtime_tiles::small_font = id\(sublabel\)->get_lv_font\(\);', text)), 1, name) self.assertEqual(len(re.findall(r'runtime_tiles::small_font = id\(sublabel\)->get_lv_font\(\);', text)), 1, name)
+5 -2
View File
@@ -5,10 +5,13 @@ a slider fill rounded less than its track cost 15-20 KB per frame there, and whe
it retries inside the frame until the task watchdog resets the board. it retries inside the frame until the task watchdog resets the board.
""" """
import re import re
import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
PROFILES = ['home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'] PROFILES = ['home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml']
HEADERS = sorted((ROOT / 'components/smart_display').glob('*.h')) HEADERS = sorted((ROOT / 'components/smart_display').glob('*.h'))
@@ -23,7 +26,7 @@ C_LAYER_STYLES = re.compile(r'lv_obj_set_style_(opa_layered|transform_rotation|t
class LayerFreeTests(unittest.TestCase): class LayerFreeTests(unittest.TestCase):
def test_board_profiles_use_no_layer_styles(self): def test_board_profiles_use_no_layer_styles(self):
for name in PROFILES: for name in PROFILES:
text = (ROOT / name).read_text() text = profiles.text(name)
self.assertEqual(YAML_LAYER_STYLES.findall(text), [], name) self.assertEqual(YAML_LAYER_STYLES.findall(text), [], name)
self.assertEqual(re.findall(r'^\s*clip_corner: true', text, re.M), [], name) self.assertEqual(re.findall(r'^\s*clip_corner: true', text, re.M), [], name)
@@ -50,7 +53,7 @@ class LayerFreeTests(unittest.TestCase):
def test_yaml_slider_fill_keeps_the_track_radius(self): def test_yaml_slider_fill_keeps_the_track_radius(self):
for name in PROFILES: for name in PROFILES:
text = (ROOT / name).read_text() text = profiles.text(name)
for match in re.finditer(r'^(\s+)- (?:slider|bar):\n((?:\1 .*\n|\s*\n)+)', text, re.M): for match in re.finditer(r'^(\s+)- (?:slider|bar):\n((?:\1 .*\n|\s*\n)+)', text, re.M):
block, indent = match.group(2), match.group(1) + ' ' block, indent = match.group(2), match.group(1) + ' '
track = re.search(r'^' + indent + r'radius: (\S+)', block, re.M) track = re.search(r'^' + indent + r'radius: (\S+)', block, re.M)
+5 -2
View File
@@ -1,10 +1,13 @@
"""Regression checks for the 320x240 page geometry and event guards.""" """Regression checks for the 320x240 page geometry and event guards."""
from pathlib import Path from pathlib import Path
import re import re
import sys
import unittest import unittest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
SOURCE = (ROOT / 'home-like-2432s028.yaml').read_text() sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
SOURCE = profiles.resolved('home-like-2432s028.yaml')
VALUES = dict(re.findall(r'^ (\w+): "([^"]*)"', SOURCE, re.M)) VALUES = dict(re.findall(r'^ (\w+): "([^"]*)"', SOURCE, re.M))
class LayoutTests(unittest.TestCase): class LayoutTests(unittest.TestCase):
@@ -40,7 +43,7 @@ class LayoutTests(unittest.TestCase):
def test_page_keys_are_the_halves_of_the_band_under_the_tiles(self): def test_page_keys_are_the_halves_of_the_band_under_the_tiles(self):
"""Firmware 0.2.69+: a chevron in each half of the band, the dots between them take no touches.""" """Firmware 0.2.69+: a chevron in each half of the band, the dots between them take no touches."""
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml'): for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml'):
source = (ROOT / name).read_text() source = profiles.resolved(name)
values = dict(re.findall(r'^ (\w+): "([^"]*)"', source, re.M)) values = dict(re.findall(r'^ (\w+): "([^"]*)"', source, re.M))
band = int(values['DISPLAY_H']) - int(values['SCROLL_Y']) - int(values['SCROLL_H']) band = int(values['DISPLAY_H']) - int(values['SCROLL_Y']) - int(values['SCROLL_H'])
half = int(values['DISPLAY_W']) // 2 half = int(values['DISPLAY_W']) // 2
+3 -1
View File
@@ -8,6 +8,8 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402 import header_bar # noqa: E402
import tile_icons # noqa: E402 import tile_icons # noqa: E402
@@ -20,7 +22,7 @@ class OffLook(unittest.TestCase):
def test_the_fonts_carry_the_bulb_that_is_off(self): def test_the_fonts_carry_the_bulb_that_is_off(self):
self.assertEqual(tile_icons.GLYPHS['lightbulb-off'], 'F0E4F') self.assertEqual(tile_icons.GLYPHS['lightbulb-off'], 'F0E4F')
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'): for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'):
self.assertIn('"\\U000F0E4F" # lightbulb-off', (ROOT / name).read_text(), name) self.assertIn('"\\U000F0E4F" # lightbulb-off', profiles.text(name), name)
def test_the_screen_crosses_out_its_own_bulb_and_greys_what_is_off(self): def test_the_screen_crosses_out_its_own_bulb_and_greys_what_is_off(self):
self.assertIn('if (d == "light" && tile.state == "off") return "\\U000F0E4F";', TILES) self.assertIn('if (d == "light" && tile.state == "off") return "\\U000F0E4F";', TILES)
+3 -1
View File
@@ -14,6 +14,8 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests')) sys.path.insert(0, str(ROOT / 'tests'))
from core import validate_settings # noqa: E402 from core import validate_settings # noqa: E402
@@ -80,7 +82,7 @@ class SettingEvents(unittest.IsolatedAsyncioTestCase):
class Firmware(unittest.TestCase): class Firmware(unittest.TestCase):
def setUp(self): def setUp(self):
self.profiles = {name: (ROOT / name).read_text() for name in PROFILES} self.profiles = {name: profiles.text(name) for name in PROFILES}
def test_home_assistant_setting_the_same_value_writes_and_reports_nothing(self): def test_home_assistant_setting_the_same_value_writes_and_reports_nothing(self):
# Firmware 0.2.49+: the setting entities change a value through settings_screen::set(), which stores, # Firmware 0.2.49+: the setting entities change a value through settings_screen::set(), which stores,
+7 -6
View File
@@ -9,6 +9,8 @@ import sys
import unittest import unittest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import FIRMWARE_VERSION # noqa: E402 from core import FIRMWARE_VERSION # noqa: E402
@@ -17,7 +19,7 @@ CHANGELOG = ROOT / 'screen_manager/CHANGELOG.md'
# sections are firmware-only releases between app 0.2.11 and 0.2.12 and carry no app version, so they don't count. # sections are firmware-only releases between app 0.2.11 and 0.2.12 and carry no app version, so they don't count.
APP_HEADING = re.compile(r'^## (\d+)\.(\d+)\.(\d+)\b(.*)$', re.M) APP_HEADING = re.compile(r'^## (\d+)\.(\d+)\.(\d+)\b(.*)$', re.M)
FIRMWARE_IN_HEADING = re.compile(r'\(firmware (\d+\.\d+\.\d+)\)') FIRMWARE_IN_HEADING = re.compile(r'\(firmware (\d+\.\d+\.\d+)\)')
# The generator (tools/generate_packages.py) turns fonts/<file> into a raw GitHub URL on main. # The published entries (packages/<board>.yaml) point ${FONT_DIR} at the raw GitHub URL of fonts/ on main.
FONT_URL = re.compile(r'https://raw\.githubusercontent\.com/MaxGramser/homeassistant_espscreen/[^/\s"\']+/(fonts/[^"\'\s]+)') FONT_URL = re.compile(r'https://raw\.githubusercontent\.com/MaxGramser/homeassistant_espscreen/[^/\s"\']+/(fonts/[^"\'\s]+)')
@@ -56,13 +58,12 @@ class ReleaseVersionTests(unittest.TestCase):
class PackageFontTests(unittest.TestCase): class PackageFontTests(unittest.TestCase):
def test_every_font_a_package_fetches_is_in_the_tree(self): def test_every_font_a_package_fetches_is_in_the_tree(self):
packages = sorted((ROOT / 'packages').glob('*.yaml'))
self.assertTrue(packages, 'no packages/*.yaml')
seen = 0 seen = 0
for package in packages: for package in profiles.PACKAGES:
for path in FONT_URL.findall(package.read_text()): # The shared core names its fonts as ${FONT_DIR}/...; the published entry says where that is (app 0.2.84+).
for path in FONT_URL.findall(profiles.merged(package)):
seen += 1 seen += 1
self.assertTrue((ROOT / path).is_file(), f'{package.name} fetches {path}, which is not in the tree') self.assertTrue((ROOT / path).is_file(), f'{package} fetches {path}, which is not in the tree')
# A changed URL form must not turn this into a test of nothing. # A changed URL form must not turn this into a test of nothing.
self.assertGreater(seen, 0, 'no fonts/... URL found in packages/*.yaml') self.assertGreater(seen, 0, 'no fonts/... URL found in packages/*.yaml')
+26 -23
View File
@@ -21,6 +21,8 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests')) sys.path.insert(0, str(ROOT / 'tests'))
from core import (FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_ENTITIES_MIN_FIRMWARE, SETTING_RULES, forecast_kinds, # noqa: E402 from core import (FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_ENTITIES_MIN_FIRMWARE, SETTING_RULES, forecast_kinds, # noqa: E402
@@ -33,8 +35,8 @@ if HAS_AIOHTTP:
from aiohttp.test_utils import TestClient, TestServer from aiohttp.test_utils import TestClient, TestServer
from server import ANSWER_RETRY_SECONDS, HomeAssistant, Manager, Refused, create_app from server import ANSWER_RETRY_SECONDS, HomeAssistant, Manager, Refused, create_app
PROFILES = {'cyd': ROOT / 'home-like-2432s028.yaml', 'guition': ROOT / 'guition-4848s040.yaml'} PROFILES = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'}
PACKAGES = {'cyd': ROOT / 'packages/cyd.yaml', 'guition': ROOT / 'packages/guition.yaml'} PACKAGES = {'cyd': 'packages/cyd.yaml', 'guition': 'packages/guition.yaml'}
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
SCREEN_PAGE = (ROOT / 'components/smart_display/settings_screen.h').read_text() SCREEN_PAGE = (ROOT / 'components/smart_display/settings_screen.h').read_text()
STATIC = ROOT / 'screen_manager/app/static' STATIC = ROOT / 'screen_manager/app/static'
@@ -114,18 +116,18 @@ class CoreSettings(unittest.TestCase):
def test_the_entities_are_named_as_in_both_board_profiles(self): def test_the_entities_are_named_as_in_both_board_profiles(self):
blocks = {'switch': 'switch', 'number': 'number', 'time': 'datetime', 'select': 'select'} blocks = {'switch': 'switch', 'number': 'number', 'time': 'datetime', 'select': 'select'}
for board, path in [*PROFILES.items(), *PACKAGES.items()]: for board, path in [*PROFILES.items(), *PACKAGES.items()]:
text = path.read_text() text = profiles.text(path)
for key, (domain, name) in SETTING_ENTITIES.items(): for key, (domain, name) in SETTING_ENTITIES.items():
block = top_block(text, blocks[domain]) block = top_block(text, blocks[domain])
if key == 'rotation' and board == 'cyd': if key == 'rotation' and board == 'cyd':
self.assertNotIn(f'name: "{name}"', block, f'{path.name}: the CYD cannot turn') self.assertNotIn(f'name: "{name}"', block, f'{path}: the CYD cannot turn')
continue continue
self.assertIn(f'name: "{name}"', block, f'{path.name}: {key}') self.assertIn(f'name: "{name}"', block, f'{path}: {key}')
at = block.index(f'name: "{name}"') at = block.index(f'name: "{name}"')
entry = block[max(0, at - 200):at + 900] entry = block[max(0, at - 200):at + 900]
self.assertIn('entity_category: config', entry, f'{path.name}: {key}') self.assertIn('entity_category: config', entry, f'{path}: {key}')
self.assertRegex(entry, rf'settings_screen::set\("{key}"', f'{path.name}: {key} changes through set()') self.assertRegex(entry, rf'settings_screen::set\("{key}"', f'{path}: {key} changes through set()')
self.assertIn('type: time', top_block(text, 'datetime'), path.name) self.assertIn('type: time', top_block(text, 'datetime'), path)
def test_a_screen_owns_its_settings_only_with_firmware_0_2_49_entities(self): def test_a_screen_owns_its_settings_only_with_firmware_0_2_49_entities(self):
old = [{'entity_id': ENTITY_IDS['brightness'], 'platform': 'esphome', 'original_name': 'Normal brightness'}, old = [{'entity_id': ENTITY_IDS['brightness'], 'platform': 'esphome', 'original_name': 'Normal brightness'},
@@ -590,15 +592,15 @@ class Editor(unittest.TestCase):
class Firmware(unittest.TestCase): class Firmware(unittest.TestCase):
def test_the_message_action_answers_a_caller_that_asks(self): def test_the_message_action_answers_a_caller_that_asks(self):
for path in [*PROFILES.values(), *PACKAGES.values()]: for path in [*PROFILES.values(), *PACKAGES.values()]:
text = path.read_text() text = profiles.text(path)
action = text[text.index('- action: screen_message'):] action = text[text.index('- action: screen_message'):]
# Up to the next key of the api block or the next top-level block (the packages carry no keys). # Up to the next key of the api block or the next top-level block (the packages carry no keys).
action = action[:re.search(r'\n(?: [a-z_]+:|[a-z_]+:)', action).start()] action = action[:re.search(r'\n(?: [a-z_]+:|[a-z_]+:)', action).start()]
self.assertIn('supports_response: optional', action, path.name) self.assertIn('supports_response: optional', action, path)
self.assertIn("lambda: 'return call_id != 0;'", action, f'{path.name}: no answer, no warning, for a caller without a call id') self.assertIn("lambda: 'return call_id != 0;'", action, f'{path}: no answer, no warning, for a caller without a call id')
self.assertIn('api.respond:', action, path.name) self.assertIn('api.respond:', action, path)
self.assertIn('root["status"] = id(dashboard_inbox).state;', action, path.name) self.assertIn('root["status"] = id(dashboard_inbox).state;', action, path)
self.assertIn('root["rev"] = runtime_tiles::layout_rev;', action, path.name) self.assertIn('root["rev"] = runtime_tiles::layout_rev;', action, path)
def test_the_settings_tile_opens_without_home_assistant(self): def test_the_settings_tile_opens_without_home_assistant(self):
event = RUNTIME[RUNTIME.index('inline void event(lv_event_t *event) {'):] event = RUNTIME[RUNTIME.index('inline void event(lv_event_t *event) {'):]
@@ -609,23 +611,24 @@ class Firmware(unittest.TestCase):
def test_home_assistant_sees_every_setting_change(self): def test_home_assistant_sees_every_setting_change(self):
for board, path in PROFILES.items(): for board, path in PROFILES.items():
script = path.read_text() text = profiles.text(path)
script = script[script.index('- id: apply_screen_settings'):] script = text[text.index('- id: apply_screen_settings'):]
script = script[:script.index('\n - id: ', 10)] script = script[:script.index('\n - id: ', 10)]
for entity in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds', for entity in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds',
'setting_auto_home_seconds'): 'setting_auto_home_seconds'):
# One loop publishes every number that lacks a state or changed (firmware 0.2.50, see test_cover_card). # One loop publishes every number that lacks a state or changed (firmware 0.2.50, see test_cover_card).
self.assertIn(f'std::make_pair(id({entity}),', script, f'{path.name}: {entity}') self.assertIn(f'std::make_pair(id({entity}),', script, f'{path}: {entity}')
self.assertIn('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, path.name) self.assertIn('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, path)
self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path.name) self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path)
self.assertIn('settings_screen::refresh();', script, f'{path.name}: an open settings page follows Home Assistant') self.assertIn('settings_screen::refresh();', script, f'{path}: an open settings page follows Home Assistant')
self.assertEqual('id(setting_rotation)->update();' in script, board == 'guition', path.name) # The Guition's board file extends the script with the rotation (docs/PROFILES.md).
self.assertEqual('id(setting_rotation)->update();' in text, board == 'guition', path)
def test_the_page_and_every_entity_change_settings_the_same_way(self): def test_the_page_and_every_entity_change_settings_the_same_way(self):
self.assertRegex(SCREEN_PAGE, r'enum class SetResult : uint8_t \{ unknown, same, changed \};') self.assertRegex(SCREEN_PAGE, r'enum class SetResult : uint8_t \{ unknown, same, changed \};')
for path in PROFILES.values(): for path in PROFILES.values():
text = path.read_text() text = profiles.text(path)
self.assertNotIn('settings_preference.save(&s)', text, f'{path.name}: saving goes through settings_screen::set()') self.assertNotIn('settings_preference.save(&s)', text, f'{path}: saving goes through settings_screen::set()')
self.assertNotIn('update_interval: never\n lambda: return settings_screen::swipe_pages', text, 'a template switch has no update_interval') self.assertNotIn('update_interval: never\n lambda: return settings_screen::swipe_pages', text, 'a template switch has no update_interval')
+3 -1
View File
@@ -13,6 +13,8 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests')) sys.path.insert(0, str(ROOT / 'tests'))
import test_portal import test_portal
@@ -85,7 +87,7 @@ class Firmware(unittest.TestCase):
"""The board profiles have to carry the page, its way in, and the timer that closes it.""" """The board profiles have to carry the page, its way in, and the timer that closes it."""
def setUp(self): def setUp(self):
self.profiles = {name: (ROOT / name).read_text() self.profiles = {name: profiles.text(name)
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')} for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')}
def test_both_boards_wire_the_page_and_its_gesture(self): def test_both_boards_wire_the_page_and_its_gesture(self):
+19 -17
View File
@@ -12,12 +12,14 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import DARK_MODE_MIN_FIRMWARE, FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_RULES, SETTINGS_BESIDE_BLOCK # noqa: E402 from core import DARK_MODE_MIN_FIRMWARE, FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_RULES, SETTINGS_BESIDE_BLOCK # noqa: E402
COMPONENT = ROOT / 'components/smart_display' COMPONENT = ROOT / 'components/smart_display'
THEME = (COMPONENT / 'theme.h').read_text() THEME = (COMPONENT / 'theme.h').read_text()
PROFILES = [ROOT / 'home-like-2432s028.yaml', ROOT / 'guition-4848s040.yaml', ROOT / 'packages/cyd.yaml', ROOT / 'packages/guition.yaml'] PROFILES = list(profiles.NAMES)
HEX = re.compile(r'0x[0-9A-Fa-f]{6}\b') HEX = re.compile(r'0x[0-9A-Fa-f]{6}\b')
# What may still carry a number that looks like a colour: the light's colour picker shows the colours a light can take, # What may still carry a number that looks like a colour: the light's colour picker shows the colours a light can take,
# a mask and a codepoint check are no colours, and the value card's colour key is a wheel of fixed hues. # a mask and a codepoint check are no colours, and the value card's colour key is a wheel of fixed hues.
@@ -46,14 +48,14 @@ class OnePlace(unittest.TestCase):
def test_no_profile_writes_a_colour_of_its_own(self): def test_no_profile_writes_a_colour_of_its_own(self):
for path in PROFILES: for path in PROFILES:
text = path.read_text() text = profiles.text(path)
wheel = re.findall(r'^\s+arc_color: (0x[0-9A-F]{6})$', text, re.M) wheel = re.findall(r'^\s+arc_color: (0x[0-9A-F]{6})$', text, re.M)
self.assertEqual(set(wheel), COLOUR_WHEEL, path.name) self.assertEqual(set(wheel), COLOUR_WHEEL, path)
everything = HEX.findall(text) everything = HEX.findall(text)
self.assertEqual(sorted(everything), sorted(wheel), f'{path.name}: only the colour wheel keeps fixed hues') self.assertEqual(sorted(everything), sorted(wheel), f'{path}: only the colour wheel keeps fixed hues')
self.assertNotIn('BG_TOP_COLOR', text) self.assertNotIn('BG_TOP_COLOR', text)
self.assertNotIn('ACCENT_AUTO', text) self.assertNotIn('ACCENT_AUTO', text)
self.assertNotRegex(text, r'^\s+(bg|text|border|bg_grad|line|outline|shadow)_color:', path.name) self.assertNotRegex(text, r'^\s+(bg|text|border|bg_grad|line|outline|shadow)_color:', path)
def test_every_role_has_a_light_and_a_dark_value(self): def test_every_role_has_a_light_and_a_dark_value(self):
roles = re.search(r'enum Role : uint8_t \{(.*?)ROLE_COUNT', THEME, re.S)[1] roles = re.search(r'enum Role : uint8_t \{(.*?)ROLE_COUNT', THEME, re.S)[1]
@@ -67,7 +69,7 @@ class Paints(unittest.TestCase):
def test_every_paint_is_defined_adopted_and_used_in_every_profile(self): def test_every_paint_is_defined_adopted_and_used_in_every_profile(self):
paints = paint_names() paints = paint_names()
for path in PROFILES: for path in PROFILES:
text = path.read_text() text = profiles.merged(path)
defined = re.findall(r'^ - id: (paint_\w+)$', text, re.M) defined = re.findall(r'^ - id: (paint_\w+)$', text, re.M)
used = set(re.findall(r'styles: (paint_\w+)$', text, re.M)) used = set(re.findall(r'styles: (paint_\w+)$', text, re.M))
filling = text.split('theme::paints = []() {', 1)[1].split('\n };', 1)[0] filling = text.split('theme::paints = []() {', 1)[1].split('\n };', 1)[0]
@@ -75,33 +77,33 @@ class Paints(unittest.TestCase):
listed = re.search(r'for \(auto \*style : \{([^}]*)\}\) theme::fill\(style, Paint::(\w+)\);', filling) listed = re.search(r'for \(auto \*style : \{([^}]*)\}\) theme::fill\(style, Paint::(\w+)\);', filling)
looped = [(name, listed[2]) for name in re.findall(r'id\((\w+)\)', listed[1])] looped = [(name, listed[2]) for name in re.findall(r'id\((\w+)\)', listed[1])]
adopted = dict(explicit + looped) adopted = dict(explicit + looped)
self.assertEqual(len(adopted), len(explicit) + len(looped), f'{path.name}: every style is filled once') self.assertEqual(len(adopted), len(explicit) + len(looped), f'{path}: every style is filled once')
self.assertEqual(text.count('theme::fill('), len(explicit) + 1, f'{path.name}: styles are filled in theme::paints only') self.assertEqual(text.count('theme::fill('), len(explicit) + 1, f'{path}: styles are filled in theme::paints only')
self.assertEqual(set(defined), used, f'{path.name}: every paint style is used, every used one defined') self.assertEqual(set(defined), used, f'{path}: every paint style is used, every used one defined')
for style in defined + ['style_page', 'style_tile', 'style_icon_circle', 'style_room', 'style_time', 'style_title', for style in defined + ['style_page', 'style_tile', 'style_icon_circle', 'style_room', 'style_time', 'style_title',
'style_value', 'style_value_big']: 'style_value', 'style_value_big']:
self.assertIn(style, adopted, f'{path.name}: {style} takes its colours from theme.h') self.assertIn(style, adopted, f'{path}: {style} takes its colours from theme.h')
self.assertIn(adopted[style], paints, f'{path.name}: {style}') self.assertIn(adopted[style], paints, f'{path}: {style}')
for style, paint in adopted.items(): for style, paint in adopted.items():
if style.startswith('paint_'): if style.startswith('paint_'):
self.assertEqual(style, f'paint_{paint}', f'{path.name}: a paint style is named after its paint') self.assertEqual(style, f'paint_{paint}', f'{path}: a paint style is named after its paint')
# The styles that carry colours have none written down. # The styles that carry colours have none written down.
styles = re.search(r'^ style_definitions:\n(.*?)^ # ---------- ALERT', text, re.M | re.S)[1] styles = re.search(r'^ style_definitions:\n(.*?)^ # ---------- ALERT', text, re.M | re.S)[1]
self.assertNotRegex(styles, r'_color:', path.name) self.assertNotRegex(styles, r'_color:', path)
def test_the_look_is_applied_at_boot_after_a_change_and_drawn_again(self): def test_the_look_is_applied_at_boot_after_a_change_and_drawn_again(self):
for path in PROFILES: for path in PROFILES:
text = path.read_text() text = profiles.text(path)
boot = text.split('runtime_tiles::load_settings();', 1)[1].split('runtime_tiles::settings_changed', 1)[0] boot = text.split('runtime_tiles::load_settings();', 1)[1].split('runtime_tiles::settings_changed', 1)[0]
self.assertIn('theme::paints();\n theme::set_dark(settings_screen::dark_mode != 0);', boot, self.assertIn('theme::paints();\n theme::set_dark(settings_screen::dark_mode != 0);', boot,
f'{path.name}: the paints filled, then the saved look, before the first frame') f'{path}: the paints filled, then the saved look, before the first frame')
apply = text.split(' - id: apply_screen_settings\n', 1)[1].split('\n - id: ', 1)[0] apply = text.split(' - id: apply_screen_settings\n', 1)[1].split('\n - id: ', 1)[0]
self.assertIn('theme::set_dark(settings_screen::dark_mode != 0);', apply, f'{path.name}: a change of the setting') self.assertIn('theme::set_dark(settings_screen::dark_mode != 0);', apply, f'{path}: a change of the setting')
redraw = text.split('theme::redraw = []() {', 1)[1].split('};', 1)[0] redraw = text.split('theme::redraw = []() {', 1)[1].split('};', 1)[0]
for needle in ('runtime_tiles::restyle();', 'light_controls::restyle();', 'settings_screen::restyle();', for needle in ('runtime_tiles::restyle();', 'light_controls::restyle();', 'settings_screen::restyle();',
'theme::surface(id(alert_card_color))', 'id(climate_card_refresh).execute();', 'theme::surface(id(alert_card_color))', 'id(climate_card_refresh).execute();',
'id(open_climate_mode_picker).execute();'): 'id(open_climate_mode_picker).execute();'):
self.assertIn(needle, redraw, f'{path.name}: {needle}') self.assertIn(needle, redraw, f'{path}: {needle}')
self.assertIn('id(alert_card_color) = alert.color;', text) self.assertIn('id(alert_card_color) = alert.color;', text)
self.assertIn('lv_color_hex(theme::surface(alert.color))', text) self.assertIn('lv_color_hex(theme::surface(alert.color))', text)
+3 -1
View File
@@ -7,6 +7,8 @@ import tempfile
import unittest import unittest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests')) sys.path.insert(0, str(ROOT / 'tests'))
from core import CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout from core import CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout
@@ -75,7 +77,7 @@ class ControlChoices(unittest.TestCase):
self.assertTrue(set(tile_icons.CONTROL_GLYPHS) <= set(tile_icons.GLYPHS)) self.assertTrue(set(tile_icons.CONTROL_GLYPHS) <= set(tile_icons.GLYPHS))
self.assertEqual(set(tile_icons.editor()['controls']), set(tile_icons.CONTROL_GLYPHS)) self.assertEqual(set(tile_icons.editor()['controls']), set(tile_icons.CONTROL_GLYPHS))
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'): for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'):
text = (ROOT / name).read_text() text = profiles.text(name)
for code in used: for code in used:
self.assertIn(f'"\\U000{code}"', text, (name, code)) self.assertIn(f'"\\U000{code}"', text, (name, code))
self.assertIn('runtime_tiles::control_font = id(sublabel_big)->get_lv_font();', text, name) self.assertIn('runtime_tiles::control_font = id(sublabel_big)->get_lv_font();', text, name)
+4 -2
View File
@@ -5,6 +5,8 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests')) sys.path.insert(0, str(ROOT / 'tests'))
import test_portal import test_portal
@@ -22,7 +24,7 @@ class IconSetTests(unittest.TestCase):
self.assertRegex(code, r'^F[0-9A-F]{4}$') self.assertRegex(code, r'^F[0-9A-F]{4}$')
wanted = [f'\\U000{code}' for code in tile_icons.GLYPHS.values()] wanted = [f'\\U000{code}' for code in tile_icons.GLYPHS.values()]
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'): for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'):
text = (ROOT / name).read_text() text = profiles.resolved(name)
fonts = re.findall(r'materialdesignicons-webfont\.ttf["\']\n id: (\w+)\n size: \d+\n bpp: 4\n glyphs: (.*)\n', text) fonts = re.findall(r'materialdesignicons-webfont\.ttf["\']\n id: (\w+)\n size: \d+\n bpp: 4\n glyphs: (.*)\n', text)
self.assertEqual([font for font, _ in fonts], ['materialdesign_icons', 'materialdesign_icons_mini', 'materialdesign_icons_big', 'watch_icon'], name) self.assertEqual([font for font, _ in fonts], ['materialdesign_icons', 'materialdesign_icons_mini', 'materialdesign_icons_big', 'watch_icon'], name)
self.assertTrue(fonts[0][1].startswith('&tile_icons ') and all(g == '*tile_icons' for font, g in fonts[1:] if font != 'materialdesign_icons_big'), name) self.assertTrue(fonts[0][1].startswith('&tile_icons ') and all(g == '*tile_icons' for font, g in fonts[1:] if font != 'materialdesign_icons_big'), name)
@@ -43,7 +45,7 @@ class IconSetTests(unittest.TestCase):
self.assertEqual(len(entries), len(tile_icons.GLYPHS)) self.assertEqual(len(entries), len(tile_icons.GLYPHS))
def test_every_glyph_the_firmware_draws_is_in_the_set(self): def test_every_glyph_the_firmware_draws_is_in_the_set(self):
sources = [HEADER] + [(ROOT / name).read_text().split('glyphs: &tile_icons', 1)[0] for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')] sources = [HEADER] + [profiles.text(name).split('glyphs: &tile_icons', 1)[0] for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')]
used = {code.upper() for source in sources for code in re.findall(r'\\U000(F[0-9A-Fa-f]{4})', source)} used = {code.upper() for source in sources for code in re.findall(r'\\U000(F[0-9A-Fa-f]{4})', source)}
self.assertLessEqual(used, set(tile_icons.GLYPHS.values())) self.assertLessEqual(used, set(tile_icons.GLYPHS.values()))
+3 -1
View File
@@ -11,6 +11,8 @@ import tempfile
import unittest import unittest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import FIRMWARE_VERSION, discover from core import FIRMWARE_VERSION, discover
import firmware import firmware
@@ -24,7 +26,7 @@ HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
class VersionSourceTests(unittest.TestCase): class VersionSourceTests(unittest.TestCase):
def test_app_target_matches_shipped_firmware(self): def test_app_target_matches_shipped_firmware(self):
for name in ('packages/cyd.yaml', 'packages/guition.yaml', 'home-like-2432s028.yaml', 'guition-4848s040.yaml'): for name in ('packages/cyd.yaml', 'packages/guition.yaml', 'home-like-2432s028.yaml', 'guition-4848s040.yaml'):
text = (ROOT / name).read_text() text = profiles.text(name)
self.assertIn(f'SCREEN_FIRMWARE_VERSION: "{FIRMWARE_VERSION}"', text, name) self.assertIn(f'SCREEN_FIRMWARE_VERSION: "{FIRMWARE_VERSION}"', text, name)
self.assertIn('name: "Device name"', text, name) self.assertIn('name: "Device name"', text, name)
self.assertIn('platform: wifi_info', text, name) self.assertIn('platform: wifi_info', text, name)
+12 -4
View File
@@ -12,6 +12,8 @@ import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app')) sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import FIRMWARE_VERSION, WAKE_SLEEP_MIN_FIRMWARE # noqa: E402 from core import FIRMWARE_VERSION, WAKE_SLEEP_MIN_FIRMWARE # noqa: E402
@@ -20,13 +22,19 @@ PROFILES = ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.ya
def section(text, top): def section(text, top):
"""One top-level YAML section of a profile, up to the next one.""" """One top-level YAML section of a profile, up to the next one."""
match = re.search(rf'^{top}:\n(.*?)(?=^[a-z_]+:|\Z)', text, re.M | re.S) # The core and the board file may both carry the section (app 0.2.84+).
return match[1] if match else '' return '\n'.join(re.findall(rf'^{top}:\n(.*?)(?=^[a-z_]+:|\Z)', text, re.M | re.S))
def item(text, key, value): def item(text, key, value):
"""The list item in a section that carries `key: value`, up to the next item at the same depth.""" """The list item in a section that carries `key: value`, up to the next item at the same depth."""
for chunk in re.split(r'^ - ', text, flags=re.M)[1:]: chunks = re.split(r'^ - ', text, flags=re.M)[1:]
# The item that starts with `key: value` first: a script that names another one (`id: alert_dismiss` in
# alert_timeout) may come before that one's own block now that the board file adds scripts of its own.
for chunk in chunks:
if re.match(rf'{key}: "?{re.escape(value)}"?$', chunk, re.M):
return chunk
for chunk in chunks:
if re.search(rf'^\s*{key}: "?{re.escape(value)}"?$', chunk, re.M): if re.search(rf'^\s*{key}: "?{re.escape(value)}"?$', chunk, re.M):
return chunk return chunk
return '' return ''
@@ -38,7 +46,7 @@ def script(text, name):
class Profiles(unittest.TestCase): class Profiles(unittest.TestCase):
def setUp(self): def setUp(self):
self.profiles = {name: (ROOT / name).read_text() for name in PROFILES} self.profiles = {name: profiles.text(name) for name in PROFILES}
def test_the_firmware_that_carries_the_buttons_is_this_release_or_older(self): def test_the_firmware_that_carries_the_buttons_is_this_release_or_older(self):
version = lambda text: tuple(int(part) for part in text.split('.')) version = lambda text: tuple(int(part) for part in text.split('.'))
+7 -5
View File
@@ -2,7 +2,7 @@
# The release checks of docs/RELEASING.md step 2 in one command, the same on a laptop and in CI # 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. # (.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 # 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 --firmware compiles both board profiles and applies the CYD's flash budget
# tools/check.sh --all both # tools/check.sh --all both
@@ -120,7 +120,7 @@ cpp_tests() {
((total > 0 && good == total)) ((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; } icons_current() { cd "$ROOT" && "$PYTHON" tools/generate_icons.py --check; }
editor_install() { cd "$ROOT/web" && npm ci --no-audit --no-fund; } editor_install() { cd "$ROOT/web" && npm ci --no-audit --no-fund; }
editor_tests() { cd "$ROOT/web" && npm test; } 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 # 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 # 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 # 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() { prepare_profiles() {
local config="$WORK/config" board file 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 cat > "$config/secrets.yaml" <<'EOF' || return 1
# Placeholders for the check builds (tools/check.sh); nothing here is a real key. # Placeholders for the check builds (tools/check.sh); nothing here is a real key.
wifi_ssid: "check-wifi" wifi_ssid: "check-wifi"
@@ -254,7 +256,7 @@ if ((want_fast)); then
run "Python packages" python_packages run "Python packages" python_packages
run "Python tests" python_tests run "Python tests" python_tests
run "C++ tests" cpp_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 "Icons match tile_icons.py" icons_current
run "Editor: npm ci" editor_install run "Editor: npm ci" editor_install
if ((last_ok)); then if ((last_ok)); then
+85
View File
@@ -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()
+3 -3
View File
@@ -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 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 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 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. --check changes nothing and fails when anything is out of date.
""" """
import argparse import argparse
@@ -24,12 +24,12 @@ import header_bar # noqa: E402
import tile_icons # noqa: E402 import tile_icons # noqa: E402
TTF = ROOT / 'fonts/materialdesignicons-webfont.ttf' 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' 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)} 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' 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. # 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(): def verify():
"""Names and codepoints must match the TTF the firmware is built from.""" """Names and codepoints must match the TTF the firmware is built from."""
-56
View File
@@ -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()
+75
View File
@@ -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))
+5 -3
View File
@@ -21,6 +21,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
WORK = ROOT / '.esphome' / 'render-topbar' WORK = ROOT / '.esphome' / 'render-topbar'
PROFILES = {'guition': 'guition-4848s040.yaml', 'cyd': 'home-like-2432s028.yaml'} 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') 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. # Hardware headers the host cannot compile; the top bar needs none of them.
SKIP = {'guition_diagnostics.h', '__pycache__'} SKIP = {'guition_diagnostics.h', '__pycache__'}
@@ -39,8 +41,8 @@ SCENARIOS = (
) )
def font_blocks(board): def font_blocks(board):
"""The profile's font entries, ids suffixed per board and file paths made absolute.""" """The shared core's font entries at this board's sizes, ids suffixed per board and file paths made absolute."""
text = (ROOT / PROFILES[board]).read_text() text = profiles.resolve(profiles.CORE.read_text(), profiles.substitutions(PROFILES[board]))
fonts = text[text.index('\nfont:\n'):] fonts = text[text.index('\nfont:\n'):]
out = '' out = ''
for font_id in FONTS: for font_id in FONTS:
@@ -48,7 +50,7 @@ def font_blocks(board):
if not m: if not m:
raise SystemExit(f'Font {font_id} not found in {PROFILES[board]}') 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 = 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') out += block.replace('&tile_icons', f'&tile_icons_{board}').replace('*tile_icons\n', f'*tile_icons_{board}\n')
return out return out