From d76461d774761fbb35d17bdbc1f37751d5966edf Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Fri, 18 Sep 2026 22:57:14 +0200 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 2 +- AGENTS.md | 16 +- docs/CAMERA.md | 2 +- docs/GUITION.md | 6 +- docs/PROFILES.md | 66 + docs/RELEASING.md | 11 +- docs/SETTINGS.md | 3 +- docs/TEST_RESULTS_0284.md | 83 + docs/THEME.md | 8 +- guition-4848s040.yaml | 5038 +----------------------- home-like-2432s028.yaml | 4868 +----------------------- packages/boards/cyd-2432s028.yaml | 737 ++++ packages/boards/guition-4848s040.yaml | 1014 +++++ packages/core.yaml | 4294 +++++++++++++++++++++ packages/cyd.yaml | 4871 +----------------------- packages/guition.yaml | 5041 +------------------------ screen_manager/CHANGELOG.md | 9 + screen_manager/config.yaml | 2 +- tests/test_alert.py | 13 +- tests/test_alerts_reference.py | 4 +- tests/test_binary_words.py | 4 +- tests/test_camera.py | 6 +- tests/test_cover_card.py | 4 +- tests/test_easy_package.py | 72 +- tests/test_guition.py | 6 +- tests/test_header_bar.py | 10 +- tests/test_history_card.py | 4 +- tests/test_layer_free.py | 7 +- tests/test_layout.py | 7 +- tests/test_off_look.py | 4 +- tests/test_quiet_resync.py | 4 +- tests/test_release_lint.py | 13 +- tests/test_screen_owned_settings.py | 49 +- tests/test_settings_page.py | 4 +- tests/test_theme.py | 36 +- tests/test_tile_controls.py | 4 +- tests/test_tile_icons.py | 6 +- tests/test_updates.py | 4 +- tests/test_wake_sleep.py | 16 +- tools/check.sh | 12 +- tools/check_packages.py | 85 + tools/generate_icons.py | 6 +- tools/generate_packages.py | 56 - tools/profiles.py | 75 + tools/render_topbar.py | 8 +- 45 files changed, 6636 insertions(+), 19954 deletions(-) create mode 100644 docs/PROFILES.md create mode 100644 docs/TEST_RESULTS_0284.md create mode 100644 packages/boards/cyd-2432s028.yaml create mode 100644 packages/boards/guition-4848s040.yaml create mode 100644 packages/core.yaml create mode 100644 tools/check_packages.py delete mode 100644 tools/generate_packages.py create mode 100644 tools/profiles.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dbf2e8..50ca3c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: path: | ~/.platformio .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: | firmware-${{ runner.os }}-esphome-${{ steps.esphome.outputs.version }}- - name: tools/check.sh --firmware diff --git a/AGENTS.md b/AGENTS.md index 54ccae3..1eca575 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. 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. -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/.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 48 positions (eight pages of six, firmware 0.2.62+), and no Home Assistant entity belongs in a board profile. @@ -16,9 +20,9 @@ a migration. Production Ingress needs no long-lived token or public port. ## Guition board -The Guition 4848S040 has a separate profile `guition-4848s040.yaml` with -480×480, ST7701S RGB, and GT911. Read docs/GUITION.md. Don't carry over the CYD -layout or the XPT2046 calibration. +The Guition 4848S040 has its own board file `packages/boards/guition-4848s040.yaml` with +480×480, ST7701S RGB, and GT911 (`guition-4848s040.yaml` in the root only includes it). Read +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. 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 -- Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs - in the gitignored local profiles. +- Keep the shared UI in `packages/core.yaml` and a board's hardware and sizes in its file under + `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 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 diff --git a/docs/CAMERA.md b/docs/CAMERA.md index 2d2cf6a..07c0b01 100644 --- a/docs/CAMERA.md +++ b/docs/CAMERA.md @@ -99,6 +99,6 @@ missed taps. A full-screen BMP is about 390 KB; on the LAN that takes one to thr (`tests/test_camera_view.cpp`). - `components/smart_display/runtime_tiles.h`: the full-screen view, the alert picture and the `camera` message. -- `guition-4848s040.yaml`: the two `online_image` components, the alert frame, and the diagnostic +- `packages/boards/guition-4848s040.yaml`: the two `online_image` components, the alert frame, and the diagnostic action `preview_camera` (an entity opens it, an empty entity closes it). - `tests/test_camera.py`: the app side and the words both sides share. diff --git a/docs/GUITION.md b/docs/GUITION.md index 92f1e14..fa94f46 100644 --- a/docs/GUITION.md +++ b/docs/GUITION.md @@ -13,8 +13,10 @@ or periodic recovery script is needed. The GT911 uses unmirrored coordinates. ## Files -- `guition-4848s040.yaml`: the board profile (hardware + 480×480 interface). -- `packages/guition.yaml`: what a screen builds from; generated from that profile. +- `packages/boards/guition-4848s040.yaml`: the board file (hardware, the 480×480 sizes, the Guition's own parts); the + interface itself is `packages/core.yaml`, shared with the CYD (docs/PROFILES.md). +- `packages/guition.yaml`: what a screen builds from over GitHub; `guition-4848s040.yaml` in the root is the same + for a build from a checkout. - `.yaml`: the screen's own profile, written by ESP Screens, outside Git. - `secrets.yaml`: local Wi-Fi/API/OTA settings, outside Git. - `tools/verify_gt911.py`: physical pixel check; no resistive ADC calibration. diff --git a/docs/PROFILES.md b/docs/PROFILES.md new file mode 100644 index 0000000..df3e736 --- /dev/null +++ b/docs/PROFILES.md @@ -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/.yaml]`. Each is a few lines: where the fonts are, the components from GitHub, and `packages: {core: !include core.yaml, board: !include boards/.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/.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/.yaml` and `.yaml` after the existing entries, `` 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 .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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 00d2440..5761dee 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -18,14 +18,15 @@ ## For every release -1. Update the source profiles and shared components. Run - `python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly. +1. Update `packages/core.yaml` (shared by every board), the board files under `packages/boards/` and the + shared components; docs/PROFILES.md says what goes where. `python3 tools/check_packages.py` checks that the + boards define every name the core uses (tools/check.sh runs it). The editor is the Vue app in `web/`: after a change under `web/src`, run `cd web && npm ci && npm test && npm run check && npm run build` and commit `screen_manager/app/static` with it (that folder is the build output; never edit it by hand). 2. Run `tools/check.sh` (with `PYTHON=.venv-portal/bin/python` on a development machine). It runs all Python tests with aiohttp, PyYAML, Pillow and fontTools installed, every `tests/*.cpp` with - `clang++ -std=c++17 -Wall -Wextra -Werror -I.`, both generators with `--check`, and the editor's + `clang++ -std=c++17 -Wall -Wextra -Werror -I.`, `tools/check_packages.py`, `tools/generate_icons.py --check`, and the editor's `npm ci`, `npm test`, `npm run check` and `npm run build`, and fails when that fresh build differs from the `screen_manager/app/static` in Git (committed or staged). For a firmware change, `tools/check.sh --firmware` compiles both board profiles with placeholder secrets from a temporary folder (never the real `secrets.yaml`) and @@ -209,9 +210,9 @@ font and otherwise falls back to the domain icon. Hence no `min_firmware`. An old editor that omits `icon` keeps the stored choice. `tile_icons.py` is the single source list. `tools/generate_icons.py` writes the glyphs -as a YAML anchor into the three MDI fonts of both board profiles and builds +as a YAML anchor into the three MDI fonts of `packages/core.yaml` (both boards build from it) and builds `web/src/assets/tile-icons.woff` for the editor (with left bearing equal to xMin, otherwise -icons sit off-center in the browser); run `generate_packages.py` afterward. +icons sit off-center in the browser). The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual profiles must come from the set. No changed preferences or keys. diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 08c8db0..4ed8801 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -141,7 +141,8 @@ The screen's preferences hold the value, so the entity reads it in a lambda inst (`restore_value` cannot be combined with a lambda). A switch publishes its own changes. A number, time or select takes `update_interval: never` and one line in `apply_screen_settings`, which publishes it when it differs; that script runs after every change and on every Home Assistant connection (the time sync), so the -entity has a value before Home Assistant reads the states. Then run `python3 tools/generate_packages.py`. +entity has a value before Home Assistant reads the states. The entities live in `packages/core.yaml`; the +Rotation select, which only a Guition has, in its board file (docs/PROFILES.md). ### 4. The add-on diff --git a/docs/TEST_RESULTS_0284.md b/docs/TEST_RESULTS_0284.md new file mode 100644 index 0000000..7fa5e16 --- /dev/null +++ b/docs/TEST_RESULTS_0284.md @@ -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/.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://, ref: , files: [packages/.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. diff --git a/docs/THEME.md b/docs/THEME.md index 8fb79d3..7ce2c33 100644 --- a/docs/THEME.md +++ b/docs/THEME.md @@ -63,10 +63,10 @@ you set), and a palette pass that overwrote a colour the sun path had chosen its 1. **A role**: one line in `enum Role` and one row in `ROLES`, in the same order (the tests read both). The light value is the design; choose the dark value on a render, not in the table. 2. **In firmware code**: `theme::color(theme::YOUR_ROLE)`. Never `lv_color_hex(0x...)`. -3. **In a board profile**: the widget takes a paint. For a new combination add it to `enum class - Paint` and `PAINTS`, a colourless `- id: paint_` in the `style_definitions` of both profiles, - and `theme::fill(id(paint_), Paint::);` in their `theme::paints` list. Then - `python3 tools/generate_packages.py`. +3. **In the profiles**: the widget takes a paint. For a new combination add it to `enum class + Paint` and `PAINTS`, a colourless `- id: paint_` in the `style_definitions` of `packages/core.yaml` + (or of one board file, for a paint only that board uses), and `theme::fill(id(paint_), Paint::);` + in the core's `theme::paints` list (a board-only paint goes into the board's `BOARD_PAINT_FILL` hook). 4. **Something drawn once and kept that changes colour by state**: set it where the state is drawn, and make sure the redraw above reaches that code. 5. A local colour always wins over a paint: an object that takes a paint must not also get a local diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index 195d4f3..a0569ee 100644 --- a/guition-4848s040.yaml +++ b/guition-4848s040.yaml @@ -1,585 +1,26 @@ -# ESP Screens - Guition ESP32-S3-4848S040 board profile: 4-inch EU wallbox, 480x480 IPS + GT911. -# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md. -# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile. +# ESP Screens - a Guition ESP32-S3-4848S040 (4-inch EU wallbox) built from this checkout: the bench and the +# manual route (README_EXTENDED.md, docs/GUITION.md). +# +# The screen is two packages: packages/core.yaml, which every board shares (the LVGL tree, the cards, the +# scripts, the entities), and packages/boards/guition-4848s040.yaml with this board's hardware and sizes. +# docs/PROFILES.md says how the files fit together. A screen installed from ESP Screens builds the same two +# files over GitHub through packages/guition.yaml; this file only adds what a build from a checkout needs: +# the secrets (api_encryption_key, ota_password, Wi-Fi) from secrets.yaml and the components of this checkout. substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.70" - # 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile - # (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it - # anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot). - # The CYD keeps a limit because its resistive panel jumps. - TOUCH_MOVE_LIMIT_PX: "0" - # Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not. - TOUCH_MIN_PRESS_MS: "20" - # Page swipe starts in this band along the left or right edge (about 5 mm) and needs - # this much travel inward (about 6 mm); a swipe from the middle of the screen does nothing. - EDGE_SWIPE_BAND_PX: "32" - EDGE_SWIPE_TRAVEL_PX: "40" - # A slider let go this close to the left or right edge of the glass (about 1 cm) meant its end when - # that end lies in the band: the panel loses a finger swiped off the glass 30-50 px before the edge. - SLIDER_EDGE_SNAP_PX: "67" - DASHBOARD_INBOX_INTERNAL: "false" - DEVICE_NAME: "guition-new" - DEVICE_FRIENDLY_NAME: "My Guition" - CALIBRATION_ON_BOOT: "false" - ROOM_NAME: "Choose tiles in HA" # shown top left until ESP Screens sends the screen name + FONT_DIR: "fonts" # the fonts next to this file - ## DIM settings - AUTO_DIM: "true" # true = enable auto dimming - AUTO_DIM_TIMEOUT: "600" # seconds after last touch - MAX_BRIGHTNESS: "100" # backlight brightness in percent - DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent - NIGHT_MODE: "true" # true = use separate dim level at night - NIGHT_START_HOUR: "22" # 0-23 - NIGHT_END_HOUR: "7" # 0-23 - NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night +packages: + core: !include packages/core.yaml + board: !include packages/boards/guition-4848s040.yaml - DISPLAY_W: "480" - DISPLAY_H: "480" - LVGL_ROTATION: "0" - TOUCH_SWAP_XY: "false" - TOUCH_MIRROR_X: "false" - TOUCH_MIRROR_Y: "false" - GRID_COL1_X: "16" - GRID_COL2_X: "246" - GRID_ROW1_Y: "0" - GRID_ROW2_Y: "120" - GRID_ROW3_Y: "240" - GRID_ROW4_Y: "360" - GRID_ROW5_Y: "480" - SCROLL_Y: "72" - SCROLL_H: "348" - TILE_W: "218" - TILE_H: "108" - HEADER_Y: "22" - # Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe - # that starts up there still changes the page. - SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}" - SETTINGS_HOLD_W: "416" - SETTINGS_HOLD_DRIFT_PX: "32" - - # Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer. - ALERT_CARD_W: "420" - ALERT_CARD_H: "320" - ALERT_ICON_X: "26" - ALERT_ICON_Y: "22" - ALERT_TEXT_X: "90" - ALERT_TEXT_W: "304" - ALERT_TITLE_Y: "26" - ALERT_TITLE_H: "32" # one line of headline; a DOT label only ends in an ellipsis with a height - ALERT_SUBTITLE_Y: "68" - ALERT_SUBTITLE_H: "150" - ALERT_BUTTON_W: "150" - ALERT_BUTTON_H: "60" - ALERT_BUTTON_INSET: "22" - ALERT_TITLE_MAX: "64" # bytes; the label ends one line with an ellipsis - ALERT_BUTTON_MAX: "16" # bytes on the OK button - ALERT_SUBTITLE_MAX: "240" # bytes; wraps over the rows the card has - # An alert with a camera image (firmware 0.2.57+): the image across the top of a taller card, the words under it. - ALERT_CARD_H_IMAGE: "452" - ALERT_IMAGE_INSET: "14" - ALERT_IMAGE_SHIFT: "236" # the icon, title and subtitle move down by the image and a gap - ALERT_SUBTITLE_H_IMAGE: "62" # two lines between the title and the button - # Camera images (firmware 0.2.57+): the boxes ESP Screen Manager sizes a snapshot for (camera_feed.BOXES). - CAMERA_FULL_W: "480" - CAMERA_FULL_H: "480" - CAMERA_THUMB_W: "392" - CAMERA_THUMB_H: "220" - OVERLAY_SLIDER_W: "144" - OVERLAY_SLIDER_H: "300" - OVERLAY_ICON_Y: "148" - TILE_ICON_X: "0" - TILE_ICON_Y: "12" - TILE_TITLE_X: "64" - TILE_TITLE_Y: "17" - TILE_VALUE_X: "64" - TILE_VALUE_Y: "45" - -# ============================================================ -# No need to edit below this line. -# All configuration is done via the substitutions above. -# Credentials are managed in secrets.yaml (see secrets.yaml.example). -# The Material Design Icons font is included at fonts/materialdesignicons-webfont.ttf (Apache 2.0). -# Source: https://github.com/Templarian/MaterialDesign-Webfont -# ============================================================ +# The components of this checkout; packages/guition.yaml takes them from GitHub. external_components: - source: type: local path: components components: [smart_display] -esphome: - min_version: 2026.6.2 - project: - name: maxgramser.esp_screen - version: "${SCREEN_FIRMWARE_VERSION}" - platformio_options: - build_flags: - - -DLV_USE_SNAPSHOT=1 - - -DLV_USE_SWITCH=1 - includes: - - - - - - - - - - - name: ${DEVICE_NAME} - friendly_name: ${DEVICE_FRIENDLY_NAME} - on_boot: - priority: -100 - then: - - lambda: |- - cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS}); - cyd::edge_swipe.configure(${DISPLAY_W}, ${DISPLAY_H}, ${EDGE_SWIPE_BAND_PX}, ${EDGE_SWIPE_TRAVEL_PX}); - cyd::edge_snap_band = ${SLIDER_EDGE_SNAP_PX}; - // The page and the tile container take no presses: the theme's pressed style would - // otherwise repaint nearly the whole screen twice per touch in the empty edge band, - // starving the touch polling for the whole length of a quick swipe. - lv_obj_remove_flag(id(home_page)->obj, LV_OBJ_FLAG_CLICKABLE); - lv_obj_remove_flag(id(tile_scroll), LV_OBJ_FLAG_CLICKABLE); - // A stray GT911 contact at exactly (0, 0) never reaches LVGL (cyd::GhostTouch): as the last - // sample of a drag it sent a slider to its end on release. LVGL still reads ESPHome's callback. - if (auto *indev = lv_indev_get_next(nullptr)) { - static lv_indev_read_cb_t esphome_read = nullptr; - static const lv_indev_read_cb_t filtered = [](lv_indev_t *input, lv_indev_data_t *data) { - esphome_read(input, data); - int32_t ghost_x = 0, ghost_y = 0; - id(screen_lvgl).rotate_coordinates(ghost_x, ghost_y); - const unsigned dropped = cyd::ghost_touch.dropped(); - const auto read = cyd::ghost_touch.filter({data->state == LV_INDEV_STATE_PRESSED, (int) data->point.x, (int) data->point.y}, ghost_x, ghost_y); - if (cyd::ghost_touch.dropped() != dropped) ESP_LOGI("touch", "GT911 stray contact at (0,0) ignored"); - data->state = read.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; - data->point.x = read.x; - data->point.y = read.y; - }; - if (lv_indev_get_read_cb(indev) != filtered) { - esphome_read = lv_indev_get_read_cb(indev); - lv_indev_set_read_cb(indev, filtered); - } - } - id(last_touch_ms) = millis(); - id(last_use_ms) = millis(); - id(display_dimmed) = false; - - lambda: |- - auto &settings = screen_settings::current; - settings.standby_enabled = std::string("${AUTO_DIM}") == "true"; - settings.standby_seconds = atoi("${AUTO_DIM_TIMEOUT}"); - settings.brightness = atoi("${MAX_BRIGHTNESS}"); - settings.standby_brightness = atoi("${DIM_BRIGHTNESS}"); - settings.night_enabled = std::string("${NIGHT_MODE}") == "true"; - settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; - settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; - settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); - runtime_tiles::watch_font = id(headline)->get_lv_font(); - runtime_tiles::watch_value_font = id(watch_value)->get_lv_font(); - runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font(); - runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); - runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; - runtime_tiles::screen_awake = []() { return !id(display_dimmed); }; - runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); - runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); - runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); - runtime_tiles::small_font = id(sublabel)->get_lv_font(); - // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock - // label lends its margin and drawing order and stays hidden in runtime mode. - runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font(); - runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font(); - runtime_tiles::time_label = id(lbl_time); - runtime_tiles::enabled = true; - runtime_tiles::rotation_supported = true; - runtime_tiles::load_settings(); - // Colours (firmware 0.2.54+): the styles of this profile take their colours from theme.h. The list runs now, - // in the look the screen saved, before anything is drawn, and again after every change of look. - theme::paints = []() { - using theme::Paint; - for (auto *style : {id(style_room), id(style_time), id(style_title), id(style_value_big), id(paint_ink)}) theme::fill(style, Paint::ink); - theme::fill(id(style_page), Paint::page); - theme::fill(id(style_tile), Paint::card); - theme::fill(id(style_icon_circle), Paint::accent_tint); - theme::fill(id(style_value), Paint::muted); - theme::fill(id(paint_page_soft), Paint::page_soft); - theme::fill(id(paint_page_pressed), Paint::page_pressed); - theme::fill(id(paint_card), Paint::card); - theme::fill(id(paint_raised), Paint::raised); - theme::fill(id(paint_key), Paint::key); - theme::fill(id(paint_key_pressed), Paint::key_pressed); - theme::fill(id(paint_track), Paint::track); - theme::fill(id(paint_ink_soft), Paint::ink_soft); - theme::fill(id(paint_muted), Paint::muted); - theme::fill(id(paint_subtle), Paint::subtle); - theme::fill(id(paint_slate), Paint::slate); - theme::fill(id(paint_scrim), Paint::scrim); - theme::fill(id(paint_veil), Paint::veil); - theme::fill(id(paint_accent_tint), Paint::accent_tint); - theme::fill(id(paint_accent_bright), Paint::accent_bright); - theme::fill(id(paint_button_dark), Paint::button_dark); - theme::fill(id(paint_button_dark_pressed), Paint::button_dark_pressed); - theme::fill(id(paint_camera), Paint::camera); - }; - theme::paints(); - theme::set_dark(settings_screen::dark_mode != 0); - runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; - runtime_tiles::dismiss = []() { - effects_page::close(); - runtime_tiles::hide_detail(); - runtime_tiles::camera_close(); - id(active_entity).clear(); - lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); - }; - runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; - // A quick flick or wipe over the tiles is never a tap (firmware 0.2.65+). LVGL reports it as a gesture that - // bubbles up to this page (a tile's slider keeps its own, runtime_tiles::bind): the tap under the finger is - // consumed and LVGL waits for the release. No page flip: pages change with the edge swipe (on_update). LVGL - // wants speed as well as 50 px of travel, so a firm press that drifts slowly is still a tap. Cards and the - // settings page keep their drags; in standby the push still wakes the screen, it only switches nothing. - lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *) { - if (!runtime_tiles::enabled || id(calibration_active) || !id(active_entity).empty() || settings_screen::visible() || - (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN))) return; - // A quick drag on a slider is that slider's: the press stays with it (firmware 0.2.68+). - if (runtime_tiles::captured_slider) return; - cyd::touch_guard.consume(); - ESP_LOGI("touch", "flick: no tap"); - if (id(display_dimmed)) return; - if (auto *input = lv_indev_active()) lv_indev_wait_release(input); - }, LV_EVENT_GESTURE, nullptr); - // Camera images (firmware 0.2.57+): the two online_images and the alert's frame. - // set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304. - runtime_tiles::camera_full.load = [](const std::string &url) { - static std::string current; - if (url != current) { id(camera_image).set_url(url); current = url; } - id(camera_image).update(); - }; - runtime_tiles::camera_full.release = []() { id(camera_image).release(); }; - runtime_tiles::camera_full.source = []() { return id(camera_image).get_lv_image_dsc(); }; - runtime_tiles::camera_thumb.load = [](const std::string &url) { - static std::string current; - if (url != current) { id(alert_image).set_url(url); current = url; } - id(alert_image).update(); - }; - runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); }; - runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); }; - runtime_tiles::alert_frame = id(alert_image_frame); - runtime_tiles::alert_frame_icon = id(alert_image_icon); - runtime_tiles::alert_room = [](bool image) { - const int shift = image ? ${ALERT_IMAGE_SHIFT} : 0; - lv_obj_set_height(id(alert_card), image ? ${ALERT_CARD_H_IMAGE} : ${ALERT_CARD_H}); - lv_obj_set_y(id(alert_icon), ${ALERT_ICON_Y} + shift); - lv_obj_set_y(id(alert_title), ${ALERT_TITLE_Y} + shift); - lv_obj_set_y(id(alert_subtitle), ${ALERT_SUBTITLE_Y} + shift); - lv_obj_set_height(id(alert_subtitle), image ? ${ALERT_SUBTITLE_H_IMAGE} : ${ALERT_SUBTITLE_H}); - }; - runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; - runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); - runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); - runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); - runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); - runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); - runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); - runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); - runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); - runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); - runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); - runtime_tiles::detail = [](runtime_tiles::Tile &t) { - light_controls::fallback = light_controls::State{}; - light_controls::fallback.hue = t.hue; - light_controls::fallback.kelvin = t.kelvin; - light_controls::fallback.minimum = t.min_kelvin; - light_controls::fallback.maximum = t.max_kelvin; - id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; - id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; - id(active_climate_action) = t.extra().hvac_action; - id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, - t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), - "Off", "On", t.modes, - t.current, t.target, t.humidity, t.state, t.extra().hvac_modes, - std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); - }; - runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { - effects_page::updated(t); - if (id(active_entity) != t.entity || id(touch_down)) return; - if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { - if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; - id(climate_edit_pending) = false; - id(active_climate_mode) = t.state; - id(active_climate_action) = t.extra().hvac_action; - id(active_climate_supported_modes) = t.extra().hvac_modes; - id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; - id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; - id(active_climate_current) = t.current; - id(active_climate_humidity) = std::isfinite(t.humidity) ? (int) lround(t.humidity) : -1; - if (std::isfinite(t.target)) { - id(active_climate_target_x10) = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); - id(active_climate_target_known) = true; - } - id(climate_card_refresh).execute(); - } - }; - - lambda: |- - light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font()); - light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; - light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; - light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; - // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and - // the ways out: actions to Home Assistant and the question for a picker's names. - effects_page::title_font = id(headline)->get_lv_font(); - effects_page::row_font = id(sublabel)->get_lv_font(); - effects_page::roller_font = id(sublabel_big)->get_lv_font(); - effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font(); - effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * { - for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i]; - return nullptr; - }; - effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) { - runtime_tiles::action(service, entity, key, value); - }; - effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); }; - effects_page::drift = []() { return cyd::touch_guard.distance(); }; - effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; - effects_page::now = []() { return (uint32_t) millis(); }; - runtime_tiles::options_received = effects_page::received; - climate_card::look.card = id(climate_settings_card); - climate_card::look.font = id(label)->get_lv_font(); - climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font(); - climate_card::choose = [](char kind, int index) { - if (kind == 'f') id(set_climate_fan_mode).execute(index); - else id(set_climate_swing_mode).execute(index); - }; - # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager - # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built - # when it opens and thrown away when it closes, so it costs nothing while it is closed. - - lambda: |- - if (!runtime_tiles::enabled) return; - settings_screen::title_font = id(headline)->get_lv_font(); - settings_screen::row_font = id(sublabel_big)->get_lv_font(); - settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); - settings_screen::store = []() { runtime_tiles::persist_settings(); }; - settings_screen::apply = []() { id(apply_screen_settings).execute(); }; - settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; - settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; - settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; - settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; - settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; - settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; - // A dimmed screen, a calibration or an alert owns the glass; the page waits. - settings_screen::may_open = []() { - return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active) && !runtime_tiles::camera_visible(); - }; - settings_screen::drift = []() { return cyd::touch_guard.distance(); }; - settings_screen::before_open = []() { id(close_cards).execute(); }; - settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; - settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; - // What a change of look draws again, now that everything is bound: the tiles, the top bar and an open card, - // the colour card, the settings page, an alert and the climate cards. - theme::redraw = []() { - runtime_tiles::restyle(); - light_controls::restyle(); - effects_page::restyle(); - settings_screen::restyle(); - if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN); - if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute(); - if (!lv_obj_has_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN)) id(open_climate_mode_picker).execute(); - }; - // Under the cards: brightness_overlay is the first of them on the page, so the back button and - // the action at the top right of a card keep their taps. - settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}, id(brightness_overlay)); - - light.turn_on: - id: back_light - brightness: !lambda "return screen_settings::current.brightness / 100.0f;" - - script.execute: show_tile_page - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - - if: - condition: - lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";' - then: - - lambda: 'id(calibration_active) = true;' - - lvgl.page.show: touch_cal_page - - lambda: |- - lv_obj_invalidate(id(touch_cal_page)->obj); - lv_refr_now(nullptr); - ESP_LOGI("ui_diag", "GT911 touch test ready; no tile actions"); - - -esp32: - board: esp32-s3-devkitc-1 - variant: esp32s3 - flash_size: 16MB - cpu_frequency: 240MHz - framework: - type: esp-idf - version: recommended - advanced: - execute_from_psram: true - -psram: - mode: octal - speed: 80MHz - -logger: - tx_buffer_size: 2048 - hardware_uart: UART0 - # Set to DEBUG or VERBOSE temporarily for diagnosis. Note: ESPHome logs - # its configuration lines at CONFIG level, which are invisible at INFO. - level: DEBUG - logs: - sensor: WARN - text_sensor: WARN - xpt2046: WARN - homeassistant.sensor: WARN - homeassistant.text_sensor: WARN - - -# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values. api: - actions: - - action: ui_snapshot - then: - - script.execute: capture_ui_snapshot - - action: touch_diagnostics - then: - - lambda: 'id(calibration_active) = true;' - - script.execute: wake_display - - lvgl.page.show: touch_cal_page - - lambda: |- - lv_obj_invalidate(id(touch_cal_page)->obj); - lv_refr_now(nullptr); - ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj); - - action: end_touch_diagnostics - then: - - lambda: 'id(calibration_active) = false;' - - lvgl.page.show: home_page - - action: ui_state - then: - - lambda: |- - const auto &cfg = screen_settings::current; - ESP_LOGI("ui_diag", "settings standby=%d timeout=%d brightness=%d dim=%d night=%d start=%d end=%d night_dim=%d clock=%d clock24=%d home=%d idle_ms=%u use_idle_ms=%u dimmed=%d", - cfg.standby_enabled, cfg.standby_seconds, cfg.brightness, cfg.standby_brightness, - cfg.night_enabled, cfg.night_start, cfg.night_end, cfg.night_brightness, - cfg.show_clock, cfg.clock_24h, cfg.home_on_standby, - (unsigned)(millis() - id(last_touch_ms)), (unsigned)(millis() - id(last_use_ms)), id(display_dimmed)); - ESP_LOGI("ui_diag", "navigation page=%d runtime_detail=%d", id(tile_page), - runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); - ESP_LOGI("ui_diag", "rotation=%u actual=%u", (unsigned)runtime_tiles::rotation, (unsigned)id(screen_lvgl).get_rotation()); - lv_area_t nav, grid; - lv_obj_update_layout(id(home_page)->obj); - lv_obj_get_coords(id(page_next), &nav); - lv_obj_get_coords(id(tile_scroll), &grid); - ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", - lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, - grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); - - action: preview_runtime_card - variables: - index: int - then: - - lambda: |- - if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } - if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { - runtime_tiles::active_index = index; - auto &tile = runtime_tiles::model.tiles[index]; - auto domain = tile.domain(); - // Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+) - // open the runtime card, as a tap does. - if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) { - tile.begin(millis(), true); - runtime_tiles::detail(tile); - return; - } - runtime_tiles::show_detail(index); - } - # A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes - # it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens. - - action: preview_camera - variables: - entity: string - then: - - lambda: |- - if (entity.empty()) { runtime_tiles::camera_close(); return; } - id(last_use_ms) = millis(); - id(wake_display).execute(); - std::string name = entity; - for (size_t i = 0; i < runtime_tiles::model.count; ++i) - if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; } - runtime_tiles::camera_open(entity, name); - # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away - # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. - # Handy for support ("look at Screen -> Rotation") and for the render tests. - - action: open_settings - variables: - page: int - then: - - lambda: |- - if (page < 0) { id(go_home).execute(); return; } - // Opened for someone to look at: Back to page 1 counts from now, as after a touch. - id(last_use_ms) = millis(); - id(wake_display).execute(); - settings_screen::open((uint8_t) page); - ESP_LOGI("settings", "page %d open from Home Assistant", page); - - # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. - - action: preview_climate_picker - then: - - script.execute: open_climate_mode_picker - - action: preview_busy - variables: - index: int - then: - - lambda: |- - if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { - runtime_tiles::model.tiles[index].begin(millis()); - if (runtime_tiles::refresh) runtime_tiles::refresh(); - } - - action: light_controls_preview - then: - - script.execute: light_controls_preview - - action: ui_self_test - then: - - script.execute: ui_self_test - # Home Assistant automations: esphome._show_alert puts a card over the whole - # screen (README, "Alert from an automation"); dismiss_alert removes it again. - - action: show_alert - variables: - title: string - subtitle: string - icon: string - color: string - button_text: string - timeout: int - flash: bool - then: - - script.execute: - id: alert_show - title: !lambda 'return title;' - subtitle: !lambda 'return subtitle;' - icon: !lambda 'return icon;' - color: !lambda 'return color;' - button_text: !lambda 'return button_text;' - timeout: !lambda 'return timeout;' - flash: !lambda 'return flash;' - - action: dismiss_alert - then: - - script.execute: - id: alert_dismiss - reason: "remote" - # ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of - # base64 chunks in the text inbox; the inbox keeps showing the result. - # Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same - # status as the inbox, plus the revision of the layout on the screen. A call without a call id - # (older apps) gets no answer, so ESPHome has nothing to warn about. - - action: screen_message - supports_response: optional - variables: - message: string - then: - - lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));' - - if: - condition: - lambda: 'return call_id != 0;' - then: - - api.respond: - data: !lambda |- - root["status"] = id(dashboard_inbox).state; - root["rev"] = runtime_tiles::layout_rev; encryption: key: !secret api_encryption_key @@ -597,4456 +38,3 @@ wifi: password: !secret ap_password captive_portal: - -# Camera images (firmware 0.2.57+): ESP Screen Manager serves each image already sized for this screen, as an uncompressed -# BMP on its own port on the LAN (screen_manager/app/camera_feed.py). ESPHome decodes a BMP piece by piece as it arrives, -# 4 KB per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn. One image full -# screen and one in an alert's frame; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP -# on the LAN, no certificates. -http_request: - timeout: 4s - verify_ssl: false - -# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests -# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7. -online_image: - - id: camera_image - url: "http://127.0.0.1/camera.bmp" # set when a camera opens - format: BMP - type: RGB565 - buffer_size: 4096 - on_download_finished: - - lambda: 'runtime_tiles::camera_loaded(false, cached);' - on_error: - - lambda: 'runtime_tiles::camera_failed(false);' - - id: alert_image - url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image - format: BMP - type: RGB565 - buffer_size: 4096 - on_download_finished: - - lambda: 'runtime_tiles::camera_loaded(true, cached);' - on_error: - - lambda: 'runtime_tiles::camera_failed(true);' - -interval: - - interval: 250ms - then: - - lambda: |- - runtime_tiles::tick(); - runtime_tiles::camera_tick(); - - interval: 1s - then: - - if: - condition: - lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;' - then: - # The edit window is over: show what Home Assistant reports now. A closed card has - # nothing to show, and a flag left set would redraw every tile each second. - - lambda: |- - if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; } - if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; } - // Runtime tiles: a state that arrived during the window was held back. - id(climate_edit_pending) = false; - if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 && - runtime_tiles::active_index < (int) runtime_tiles::model.count) - runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]); - - if: - condition: - lambda: |- - if (!screen_settings::current.standby_enabled) return false; - if (id(display_dimmed) || id(touch_down) || id(calibration_active) || id(alert_active)) return false; - return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); - then: - - script.execute: dim_display - - script.execute: back_to_page_1_when_due - -time: - - platform: homeassistant - id: homeassistant_time - # Refresh immediately on time sync; do not wait for an HA entity update. - update_interval: 60s - on_time_sync: - then: - - script.execute: apply_screen_settings - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - on_time: - - seconds: 0 - minutes: /1 - then: - - script.execute: apply_screen_settings - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - -globals: - # Alert overlay (HA action show_alert): standby waits while one is up. - - id: alert_active - type: bool - initial_value: 'false' - - id: alert_title_text - type: std::string - # The alert's colour as the automation named it (a swatch's light value, 0 for the normal card). - - id: alert_card_color - type: uint32_t - initial_value: '0' - - id: alert_timeout_seconds - type: int - initial_value: '0' - - id: alert_flash_requested - type: bool - initial_value: 'false' - - id: rendered_frames - type: uint32_t - initial_value: '0' - - id: calibration_active - type: bool - initial_value: 'false' - - id: tile_page - type: int - initial_value: '0' - - id: touch_down - type: bool - initial_value: 'false' - - id: climate_edit_ms - type: uint32_t - initial_value: '0' - - id: climate_edit_pending - type: bool - initial_value: 'false' - - id: active_entity - type: std::string - restore_value: no - - - id: active_control_kind - type: std::string - restore_value: no - - - id: active_value_suffix - type: std::string - restore_value: no - - # Two clocks. last_touch_ms is the standby time: a touch, Wake, an alert ending and Auto standby on restart it. - # last_use_ms is Back to page 1 (firmware 0.2.56+): only a touch, or Home Assistant opening the settings page. - - id: last_touch_ms - type: uint32_t - restore_value: no - initial_value: '0' - - - id: last_use_ms - type: uint32_t - restore_value: no - initial_value: '0' - - # Debounce for do_tile_action: protects against repeated events from - # multiple separate press/release cycles for one physical touch (noise in - # the resistive layer), causing the same HA action to fire multiple times - # within a few hundred ms (observed: script.ambient_morning 6x in 8s). - - id: last_action_entity - type: std::string - restore_value: no - - - id: last_action_ms - type: uint32_t - restore_value: no - initial_value: '0' - - - id: display_dimmed - type: bool - restore_value: no - initial_value: 'false' - - # Sleep from Home Assistant (0.2.45+): standby that also holds with Auto standby off, - # until a tap, Wake or an alert wakes the screen. - - id: sleep_requested - type: bool - restore_value: no - initial_value: 'false' - - - id: active_label_off - type: std::string - restore_value: no - - - id: active_label_on - type: std::string - restore_value: no - - - id: active_title - type: std::string - restore_value: no - - - id: active_icon_text - type: std::string - restore_value: no - - - id: active_brightness_pct - type: int - restore_value: no - initial_value: '100' - - - id: active_supports_color - type: bool - restore_value: no - initial_value: 'false' - - - id: active_supports_temperature - type: bool - restore_value: no - initial_value: 'false' - - - id: active_color_detail_tab - type: int - restore_value: no - initial_value: '0' - - - id: active_climate_current_x10 - type: int - restore_value: no - initial_value: '0' - - - id: active_climate_target_x10 - type: int - restore_value: no - initial_value: '200' - - - id: active_climate_humidity - type: int - restore_value: no - initial_value: '-1' - - - id: active_climate_min_x10 - type: int - restore_value: no - initial_value: '50' - - - id: active_climate_max_x10 - type: int - restore_value: no - initial_value: '300' - - - id: active_climate_step_x10 - type: int - restore_value: no - initial_value: '5' - - - id: active_climate_mode - type: std::string - restore_value: no - - - id: active_climate_power_on - type: bool - restore_value: no - initial_value: 'false' - - - id: active_climate_fan_modes - type: std::string - restore_value: no - - - id: active_climate_fan_mode - type: std::string - restore_value: no - - - id: active_climate_swing_modes - type: std::string - restore_value: no - - - id: active_climate_swing_mode - type: std::string - restore_value: no - - - id: active_climate_supported_modes - type: std::string - restore_value: no - - # Room temperature (NAN when unknown), what the device does (hvac_action, runtime tiles only), - # and whether Home Assistant reported a setpoint at all (heat_cool ranges do not). - - id: active_climate_current - type: float - restore_value: no - initial_value: 'NAN' - - - id: active_climate_action - type: std::string - restore_value: no - - - id: active_climate_target_known - type: bool - restore_value: no - initial_value: 'false' - -font: - # The climate card's setpoint, large enough to read across the room. - - file: "fonts/Roboto-400.ttf" - id: setpoint_digits - size: 64 - bpp: 4 - glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9'] - - file: "fonts/Roboto-500.ttf" - id: time_label - size: 27 - bpp: 4 - glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P'] - - - file: "fonts/Roboto-500.ttf" - id: headline - size: 27 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - - file: "fonts/Roboto-500.ttf" - id: watch_value - size: 38 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - # Clock card digits only; the date line reuses the tile title font. - - file: "fonts/Roboto-500.ttf" - id: clock_digits - size: 48 - bpp: 4 - glyphs: [':','-','0','1','2','3','4','5','6','7','8','9'] - - - file: "fonts/Roboto-700.ttf" - id: label - size: 18 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - - file: "fonts/Roboto-400.ttf" - id: sublabel - size: 16 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] - - - file: "fonts/Roboto-400.ttf" - id: sublabel_big - size: 21 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–'] - - # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) - # Source: https://github.com/Templarian/MaterialDesign-Webfont - - file: 'fonts/materialdesignicons-webfont.ttf' - id: materialdesign_icons - size: 42 - bpp: 4 - glyphs: &tile_icons # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py - - "\U000F0335" # lightbulb - - "\U000F1253" # lightbulb-group - - "\U000F0769" # ceiling-light - - "\U000F1793" # chandelier - - "\U000F06B5" # lamp - - "\U000F08DD" # floor-lamp - - "\U000F095F" # desk-lamp - - "\U000F091D" # wall-sconce-flat - - "\U000F179B" # light-recessed - - "\U000F04C9" # spotlight-beam - - "\U000F1051" # led-strip-variant - - "\U000F1054" # outdoor-lamp - - "\U000F02DC" # home - - "\U000F04B9" # sofa - - "\U000F02E3" # bed - - "\U000F0A70" # silverware-fork-knife - - "\U000F0B7C" # chef-hat - - "\U000F09A0" # shower - - "\U000F1818" # bathtub - - "\U000F09AB" # toilet - - "\U000F1239" # desk - - "\U000F06D9" # garage - - "\U000F0E2E" # fireplace - - "\U000F050F" # thermometer - - "\U000F0393" # thermostat - - "\U000F001B" # air-conditioner - - "\U000F0438" # radiator - - "\U000F1A43" # heat-pump - - "\U000F0238" # fire - - "\U000F0717" # snowflake - - "\U000F058E" # water-percent - - "\U000F0210" # fan - - "\U000F1797" # ceiling-fan - - "\U000F0D44" # air-purifier - - "\U000F07E4" # molecule-co2 - - "\U000F0599" # weather-sunny - - "\U000F0595" # weather-partly-cloudy - - "\U000F0590" # weather-cloudy - - "\U000F0597" # weather-rainy - - "\U000F0598" # weather-snowy - - "\U000F059D" # weather-windy - - "\U000F0594" # weather-night - - "\U000F059C" # weather-sunset-up - - "\U000F0387" # music-note - - "\U000F075A" # music - - "\U000F0CB8" # playlist-music - - "\U000F0025" # album - - "\U000F099A" # record-player - - "\U000F04C7" # spotify - - "\U000F0439" # radio - - "\U000F0994" # podcast - - "\U000F04C3" # speaker - - "\U000F0D38" # speaker-multiple - - "\U000F17DB" # soundbar - - "\U000F02CB" # headphones - - "\U000F067D" # piano - - "\U000F0771" # guitar-acoustic - - "\U000F0502" # television - - "\U000F042E" # projector - - "\U000F0FCE" # movie-open - - "\U000F0118" # cast - - "\U000F0297" # gamepad-variant - - "\U000F0EC5" # remote-tv - - "\U000F033E" # lock - - "\U000F0FC6" # lock-open-variant - - "\U000F068A" # shield-home - - "\U000F078F" # alarm-light - - "\U000F009A" # bell - - "\U000F12E6" # doorbell - - "\U000F07AE" # cctv - - "\U000F0D91" # motion-sensor - - "\U000F081B" # door-closed - - "\U000F081C" # door-open - - "\U000F11DB" # window-closed-variant - - "\U000F0392" # smoke-detector - - "\U000F1502" # water-alert - - "\U000F072A" # washing-machine - - "\U000F0917" # tumble-dryer - - "\U000F0AAC" # dishwasher - - "\U000F0290" # fridge - - "\U000F04DE" # stove - - "\U000F0C99" # microwave - - "\U000F0CD3" # toaster-oven - - "\U000F109F" # coffee-maker - - "\U000F05FA" # kettle - - "\U000F070D" # robot-vacuum - - "\U000F11F7" # robot-mower - - "\U000F042B" # printer-3d - - "\U000F06A5" # power-plug - - "\U000F07E7" # power-socket-eu - - "\U000F0322" # laptop - - "\U000F0469" # router-wireless - - "\U000F0241" # flash - - "\U000F0A72" # solar-power - - "\U000F0D9B" # solar-panel - - "\U000F1901" # home-battery - - "\U000F12A3" # battery-high - - "\U000F05F1" # ev-station - - "\U000F0B6C" # car-electric - - "\U000F1A57" # meter-electric - - "\U000F1A59" # meter-gas - - "\U000F058C" # water - - "\U000F0F92" # water-boiler - - "\U000F029A" # gauge - - "\U000F111C" # window-shutter - - "\U000F111E" # window-shutter-open - - "\U000F00AC" # blinds - - "\U000F1011" # blinds-open - - "\U000F1A6C" # roller-shade-closed - - "\U000F1846" # curtains - - "\U000F1847" # curtains-closed - - "\U000F1B88" # awning-outline - - "\U000F024A" # flower - - "\U000F0531" # tree - - "\U000F0E66" # sprout - - "\U000F1510" # grass - - "\U000F105F" # sprinkler - - "\U000F0606" # pool - - "\U000F0E45" # grill - - "\U000F06EE" # mailbox - - "\U000F0A79" # trash-can - - "\U000F044C" # recycle - - "\U000F0A43" # dog - - "\U000F011B" # cat - - "\U000F0004" # account - - "\U000F0849" # account-group - - "\U000F0A89" # account-child - - "\U000F0826" # home-account - - "\U000F04B2" # sleep - - "\U000F070E" # run - - "\U000F00D6" # briefcase - - "\U000F010B" # car - - "\U000F00A3" # bike - - "\U000F001D" # airplane - - "\U000F034E" # map-marker - - "\U000F0425" # power - - "\U000F0521" # toggle-switch - - "\U000F12A8" # gesture-tap-button - - "\U000F040A" # play - - "\U000F0BC2" # script-text - - "\U000F03D8" # palette - - "\U000F0020" # alarm - - "\U000F0150" # clock-outline - - "\U000F051B" # timer-outline - - "\U000F00ED" # calendar - - "\U000F009E" # bell-ring - - "\U000F02D1" # heart - - "\U000F002A" # alert-outline - - "\U000F0493" # cog - - "\U000F00E2" # broom - - "\U000F05D6" # alert-circle-outline - - "\U000F012C" # check - - "\U000F03E4" # pause - - "\U000F04DB" # stop - - "\U000F04AD" # skip-next - - "\U000F04AE" # skip-previous - - "\U000F057E" # volume-high - - "\U000F0581" # volume-off - - "\U000F005D" # arrow-up - - "\U000F0045" # arrow-down - - "\U000F084E" # arrow-expand-horizontal - - "\U000F084C" # arrow-collapse-horizontal - - "\U000F05F8" # home-map-marker - - "\U000F0415" # plus - - "\U000F0374" # minus - - "\U000F0141" # chevron-left - - "\U000F0142" # chevron-right - - "\U000F0156" # close - - "\U000F004D" # arrow-left - - "\U000F1A79" # sun-snowflake-variant - - "\U000F1B17" # thermostat-auto - - "\U000F0591" # weather-fog - - "\U000F0592" # weather-hail - - "\U000F0593" # weather-lightning - - "\U000F067E" # weather-lightning-rainy - - "\U000F0596" # weather-pouring - - "\U000F067F" # weather-snowy-rainy - - "\U000F059B" # weather-sunset-down - - "\U000F01D8" # dots-horizontal - - "\U000F1C91" # arrow-oscillating - - "\U000F0379" # monitor - - "\U000F02FD" # information-outline - - "\U000F0709" # restart - - "\U000F0E4F" # lightbulb-off - - "\U000F0B53" # account-arrow-right - - "\U000F0D43" # air-filter - - "\U000F1099" # air-humidifier - - "\U000F1466" # air-humidifier-off - - "\U000F0028" # alert-circle - - "\U000F0937" # angle-acute - - "\U000F0042" # arrow-bottom-left - - "\U000F0043" # arrow-bottom-right - - "\U000F06C0" # arrow-down-box - - "\U000F0E73" # arrow-left-right - - "\U000F0054" # arrow-right - - "\U000F093C" # arrow-split-vertical - - "\U000F005B" # arrow-top-left - - "\U000F005C" # arrow-top-right - - "\U000F06C3" # arrow-up-box - - "\U000F093D" # audio-video - - "\U000F11B6" # audio-video-off - - "\U000F0079" # battery - - "\U000F007A" # battery-10 - - "\U000F007B" # battery-20 - - "\U000F007C" # battery-30 - - "\U000F007D" # battery-40 - - "\U000F007E" # battery-50 - - "\U000F007F" # battery-60 - - "\U000F0080" # battery-70 - - "\U000F0081" # battery-80 - - "\U000F0082" # battery-90 - - "\U000F0083" # battery-alert - - "\U000F0084" # battery-charging - - "\U000F008E" # battery-outline - - "\U000F0091" # battery-unknown - - "\U000F1A2B" # blinds-horizontal - - "\U000F1A2C" # blinds-horizontal-closed - - "\U000F00AF" # bluetooth - - "\U000F00B1" # bluetooth-connect - - "\U000F00DE" # brightness-5 - - "\U000F00E0" # brightness-7 - - "\U000F1B50" # button-pointer - - "\U000F010C" # car-battery - - "\U000F1019" # car-coolant-level - - "\U000F0114" # cash - - "\U000F0119" # cast-connected - - "\U000F078A" # cast-off - - "\U000F05E0" # check-circle - - "\U000F05E1" # check-circle-outline - - "\U000F0C54" # check-network-outline - - "\U000F0133" # checkbox-marked-circle - - "\U000F0AA5" # circle-slice-8 - - "\U000F0954" # clock - - "\U000F0155" # clock-start - - "\U000F015A" # close-circle-outline - - "\U000F0C5F" # close-network-outline - - "\U000F1382" # compass-rose - - "\U000F01A1" # crop-portrait - - "\U000F1136" # crosshairs-question - - "\U000F1480" # current-ac - - "\U000F01BC" # database - - "\U000F07C5" # ear-hearing - - "\U000F0208" # eye - - "\U000F0D04" # eye-check - - "\U000F081D" # fan-off - - "\U000F0279" # format-list-bulleted - - "\U000F06DA" # garage-open - - "\U000F0299" # gate - - "\U000F116A" # gate-open - - "\U000F06A1" # home-outline - - "\U000F0318" # lan-connect - - "\U000F0319" # lan-disconnect - - "\U000F140B" # lightning-bolt - - "\U000F08EE" # lock-alert - - "\U000F097F" # lock-clock - - "\U000F033F" # lock-open - - "\U000F0BAC" # molecule - - "\U000F12FE" # molecule-co - - "\U000F1435" # motion-sensor-off - - "\U000F038A" # music-note-off - - "\U000F03C3" # octagon - - "\U000F03D3" # package - - "\U000F03D5" # package-up - - "\U000F17C5" # ph - - "\U000F184D" # pipe-valve - - "\U000F06A6" # power-plug-off - - "\U000F0996" # progress-clock - - "\U000F1A23" # projector-off - - "\U000F043C" # radioactive - - "\U000F0445" # ray-vertex - - "\U000F1A6B" # roller-shade - - "\U000F1727" # script-text-play - - "\U000F0483" # security - - "\U000F0498" # shield - - "\U000F06BB" # shield-airplane - - "\U000F099D" # shield-lock - - "\U000F1828" # shield-moon - - "\U000F099E" # shield-off - - "\U000F0499" # shield-outline - - "\U000F095B" # sine-wave - - "\U000F192E" # smoke-detector-alert - - "\U000F180B" # smoke-detector-variant - - "\U000F1930" # smoke-detector-variant-alert - - "\U000F04C4" # speaker-off - - "\U000F1B73" # speaker-pause - - "\U000F1B72" # speaker-play - - "\U000F04C5" # speedometer - - "\U000F0674" # creation - - "\U000F0E0C" # palette-outline - - "\U000F0411" # playlist-play - - "\U000F08FF" # play-speed - - "\U000F00DF" # brightness-6 - - "\U000F1429" # spoon-sugar - - "\U000F0E67" # sprout-outline - - "\U000F0764" # square - - "\U000F0763" # square-outline - - "\U000F1A75" # storage-tank - - "\U000F17FE" # sun-wireless - - "\U000F083B" # television-off - - "\U000F0F89" # television-pause - - "\U000F0ECF" # television-play - - "\U000F0FE6" # texture-box - - "\U000F0510" # thermometer-lines - - "\U000F1A25" # toggle-switch-variant - - "\U000F1A26" # toggle-switch-variant-off - - "\U000F0D3E" # transmission-tower - - "\U000F0566" # vibrate - - "\U000F11B4" # water-boiler-off - - "\U000F058D" # water-off - - "\U000F1855" # water-opacity - - "\U000F05A1" # weight - - "\U000F05A9" # wifi - - "\U000F05AE" # window-closed - - "\U000F05B1" # window-open - - - file: 'fonts/materialdesignicons-webfont.ttf' - id: materialdesign_icons_mini - size: 26 - bpp: 4 - glyphs: *tile_icons - - # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, - # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. - - file: 'fonts/materialdesignicons-webfont.ttf' - id: materialdesign_icons_big - size: 64 - bpp: 4 - glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py - - "\U000F0335" # lightbulb - - "\U000F001B" # air-conditioner - - "\U000F070D" # robot-vacuum - - "\U000F0210" # fan - - "\U000F111C" # window-shutter - - "\U000F04B9" # sofa - - "\U000F029A" # gauge - - "\U000F051B" # timer-outline - - "\U000F0004" # account - - "\U000F07AE" # cctv - - "\U000F0150" # clock-outline - - "\U000F0493" # cog - - "\U000F0054" # arrow-right - - "\U000F0E4F" # lightbulb-off - - "\U000F0425" # power - - "\U000F0599" # weather-sunny - - "\U000F0594" # weather-night - - "\U000F0590" # weather-cloudy - - "\U000F0595" # weather-partly-cloudy - - "\U000F0597" # weather-rainy - - "\U000F0596" # weather-pouring - - "\U000F0598" # weather-snowy - - "\U000F067F" # weather-snowy-rainy - - "\U000F0591" # weather-fog - - "\U000F0592" # weather-hail - - "\U000F0593" # weather-lightning - - "\U000F067E" # weather-lightning-rainy - - "\U000F059D" # weather-windy - - "\U000F05D6" # alert-circle-outline - - "\U000F059C" # weather-sunset-up - - "\U000F059B" # weather-sunset-down - - - file: 'fonts/materialdesignicons-webfont.ttf' - id: watch_icon - size: 18 - bpp: 4 - glyphs: *tile_icons - -# Make memory visible: this board has no PSRAM, so the free heap determines -# how much UI still fits. Also shows up as an entity in Home Assistant. -debug: - # Heap figures every five minutes: enough to spot a leak, few recorder rows. - update_interval: 300s - -sensor: - - platform: debug - free: - name: "Heap Free" - id: heap_free - on_value: - - lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);' - fragmentation: - name: "Heap Fragmentation" - block: - name: "Heap Largest Block" - on_value: - - lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);' - # The lowest free heap since boot: what the busiest moment (boot sync, a swipe) left over. - min_free: - name: "Heap Minimum Free" - on_value: - - lambda: 'ESP_LOGI("health", "min_free=%.0f", x);' - - platform: wifi_signal - name: "Wifi Signal" - update_interval: 600s - - # When the screen booted, as a timestamp: one value per boot instead of a new recorder - # row every 15 s. A new entity on purpose: Home Assistant keeps the old Uptime's unit - # "s" in its registry and would refuse a timestamp under that name. - - platform: uptime - type: timestamp - name: "Last boot" - id: boot_time - - -binary_sensor: - - platform: status - name: "Node Status" - id: system_status - -# ---------------------------------------------------------- -# Text sensors -# -# smartdisplay_action: -# - Main touch event channel exported to Home Assistant -# - On short tap, publishes one of: -# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press -# - The value is reset shortly after publishing -# -# tile*_preset: -# - Mirrors the Home Assistant preset_mode attribute for each tile entity -# - Used by the UI to display fan presets when applicable -# ---------------------------------------------------------- -text_sensor: - - platform: template - name: "Guition screen type" - entity_category: diagnostic - update_interval: never - lambda: 'return {"guition"};' - - platform: template - name: "Screen firmware" - entity_category: diagnostic - lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' - update_interval: 60s - # ESP Screen Manager matches the screen to its YAML profile and OTA address. - - platform: template - name: "Device name" - entity_category: diagnostic - lambda: 'return {"${DEVICE_NAME}"};' - update_interval: 60s - - platform: wifi_info - ip_address: - # `id` so the settings page on the screen can show the same address (0.2.44+). - id: wifi_ip - name: "IP address" - entity_category: diagnostic - -switch: - - platform: restart - name: "Restart" - # The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen - # owns them: the settings page, these entities and ESP Screens all change them through - # settings_screen::set(), which saves, applies and reports a real change only. The saved values keep - # their preference records. A template switch reads its lambda every loop and publishes a change itself; - # the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the - # standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that - # moment. Nothing runs at boot: every switch follows the saved setting. - - platform: template - id: setting_auto_standby - name: "Auto standby" - icon: "mdi:sleep" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.standby_enabled; - turn_on_action: - - lambda: |- - id(last_touch_ms) = millis(); - settings_screen::set("standby_enabled", 1); - turn_off_action: - - lambda: 'settings_screen::set("standby_enabled", 0);' - - platform: template - id: setting_night_mode - name: "Night mode" - icon: "mdi:weather-night" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.night_enabled; - turn_on_action: - - lambda: 'settings_screen::set("night_enabled", 1);' - turn_off_action: - - lambda: 'settings_screen::set("night_enabled", 0);' - # The dark look for a screen beside a bed (firmware 0.2.54+): black page, graphite cards, soft white text. - - platform: template - id: setting_dark_mode - name: "Dark mode" - icon: "mdi:theme-light-dark" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::dark_mode != 0; - turn_on_action: - - lambda: 'settings_screen::set("dark_mode", 1);' - turn_off_action: - - lambda: 'settings_screen::set("dark_mode", 0);' - - platform: template - id: setting_clock_24h - name: "24-hour clock" - icon: "mdi:clock-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.clock_24h; - turn_on_action: - - lambda: 'settings_screen::set("clock_24h", 1);' - turn_off_action: - - lambda: 'settings_screen::set("clock_24h", 0);' - - platform: template - id: setting_auto_home - name: "Back to page 1" - icon: "mdi:home-import-outline" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::auto_home != 0; - turn_on_action: - - lambda: 'settings_screen::set("auto_home", 1);' - turn_off_action: - - lambda: 'settings_screen::set("auto_home", 0);' - - platform: template - id: setting_home_on_standby - name: "Back to page 1 on standby" - icon: "mdi:home-import-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.home_on_standby; - turn_on_action: - - lambda: 'settings_screen::set("home_on_standby", 1);' - turn_off_action: - - lambda: 'settings_screen::set("home_on_standby", 0);' - - platform: template - id: setting_swipe_pages - name: "Swipe between pages" - icon: "mdi:gesture-swipe-horizontal" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::swipe_pages != 0; - turn_on_action: - - lambda: 'settings_screen::set("swipe_pages", 1);' - turn_off_action: - - lambda: 'settings_screen::set("swipe_pages", 0);' - # Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+). - - platform: template - id: setting_page_buttons - name: "Page buttons" - icon: "mdi:page-next-outline" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::page_buttons != 0; - turn_on_action: - - lambda: 'settings_screen::set("page_buttons", 1);' - turn_off_action: - - lambda: 'settings_screen::set("page_buttons", 0);' - -# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight. -datetime: - - platform: template - type: time - id: setting_night_start - name: "Night starts" - icon: "mdi:weather-sunset-down" - entity_category: config - update_interval: never - lambda: |- - ESPTime time{}; - time.hour = screen_settings::current.night_start / 60; - time.minute = screen_settings::current.night_start % 60; - return time; - set_action: - - lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);' - - platform: template - type: time - id: setting_night_end - name: "Night ends" - icon: "mdi:weather-sunset-up" - entity_category: config - update_interval: never - lambda: |- - ESPTime time{}; - time.hour = screen_settings::current.night_end / 60; - time.minute = screen_settings::current.night_end % 60; - return time; - set_action: - - lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);' - -# Rotation as a Home Assistant choice (firmware 0.2.49+). Only the Guition turns; the CYD keeps its orientation. -select: - - platform: template - id: setting_rotation - name: "Rotation" - icon: "mdi:screen-rotation" - entity_category: config - options: ["0°", "90°", "180°", "270°"] - update_interval: never - lambda: |- - static const char *const names[] = {"0°", "90°", "180°", "270°"}; - return std::string(names[std::clamp(settings_screen::rotation, 0, 270) / 90]); - set_action: - - lambda: |- - const std::string option(x.c_str(), x.size()); - settings_screen::set("rotation", atoi(option.c_str())); - -# Two SPI buses used by the CYD: -# - lcd: built-in display -# - my_touchscreen: built-in touch controller -# Pinout from ESPHome's GUITION-4848S040 preset. No relay outputs are configured. -spi: - id: lcd - clk_pin: GPIO48 - mosi_pin: GPIO47 - -i2c: - id: touch_bus - sda: GPIO19 - scl: - number: GPIO45 - ignore_strapping_warning: true - scan: true - -output: - - platform: ledc - pin: GPIO38 - id: gpio_backlight_pwm - frequency: 20000Hz - -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: "Display Backlight" - id: back_light - internal: true - restore_mode: ALWAYS_ON - default_transition_length: 150ms - -touchscreen: - platform: gt911 - id: ts_touch - i2c_id: touch_bus - display: my_display - update_interval: 20ms - 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); - cyd::edge_swipe.begin(touch.x, touch.y, static_cast(id(screen_lvgl).get_rotation())); - ESP_LOGI("touch", "GT911 press x=%u y=%u id=%u test=%d", touch.x, touch.y, (unsigned) touch.id, 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) { - // Trace every sample of an edge touch: the log then shows how often the panel delivers. - if (cyd::edge_swipe.armed()) - ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y); - // The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went. - if (point.x == 0 && point.y == 0) continue; - cyd::touch_guard.update(point.x, point.y, point.id); - if (point.id != cyd::touch_guard.contact()) continue; - // Swiping in from a side edge flips the page ("Swiping between pages"); the tap - // under the finger is consumed and LVGL waits for the release. LVGL 9.5 sends no - // PRESSING to the input device, hence the touchscreen trigger. - const int step = cyd::edge_swipe.update(point.x, point.y); - if (!step) continue; - const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off" - : id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open" - : (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open" - : runtime_tiles::camera_visible() ? "camera open" : nullptr; - if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; } - cyd::touch_guard.consume(); - for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev); - ESP_LOGI("touch", "edge swipe: page %d -> %d", id(tile_page), id(tile_page) + step); - id(tile_page) += step; - id(show_tile_page).execute(); - } - on_release: - - lambda: |- - id(last_touch_ms) = millis(); - id(last_use_ms) = millis(); - id(touch_down) = false; - if (cyd::edge_swipe.armed() && cyd::edge_swipe.inward() > 0) - ESP_LOGI("touch", "edge swipe not fired: %d px inward, %d px vertical", cyd::edge_swipe.inward(), cyd::edge_swipe.sideways()); - cyd::edge_swipe.end(); - - -# Native ESPHome driver with the working manufacturer's LVGL demo timings. -# See docs/GUITION_FACTORY_REFERENCE.md; no custom RGB component is required. -display: - - platform: st7701s - id: my_display - dimensions: {width: 480, height: 480} - spi_id: lcd - spi_mode: MODE0 - data_rate: 2MHz - color_order: RGB - invert_colors: false - cs_pin: 39 - de_pin: 18 - hsync_pin: 16 - vsync_pin: 17 - pclk_pin: 21 - pclk_frequency: 16MHz - pclk_inverted: false - hsync_front_porch: 10 - hsync_pulse_width: 8 - hsync_back_porch: 50 - vsync_front_porch: 10 - vsync_pulse_width: 8 - vsync_back_porch: 20 - transform: - mirror_x: false - mirror_y: false - init_sequence: - - 1 - # Manufacturer's 16-bit bus configuration and normal scan direction. - - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x10] - - [0xCD, 0x00] - - [0xC7, 0x00] - # Return to the standard register bank before pixel format / sleep-out. - - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x00] - - [0x3A, 0x60] - - [0x36, 0x00] - - [0x20] - - [0x11] - - delay 120ms - data_pins: - red: [11, 12, 13, 14, 0] - green: [8, 20, 3, 46, 9, 10] - blue: [4, 5, 6, 7, 15] - update_interval: never - auto_clear_enabled: false - - -lvgl: - id: screen_lvgl - # The theme's font for text without a font of its own. Every label names its font, so this only keeps - # ESPHome from building LVGL's Montserrat 14 for nothing (firmware 0.2.65+). - default_font: sublabel - on_draw_start: - - lambda: 'id(rendered_frames)++;' - displays: - - my_display - touchscreens: - - touchscreen_id: ts_touch - - # S3 octal PSRAM holds RGB framebuffer and LVGL draw buffers. - buffer_size: 25% - - # Direct local feedback on the WHOLE UI, not just the tiles. - # A theme sets the style per widget type, so this covers all - # buttons, tabs, sliders and the thermostat ring in one go. Non-clickable - # widgets never enter the pressed state, so those stay untouched. - theme: - obj: - pressed: - # bg_opa and not opa: opa would also fade the icon and text. Styles that - # make LVGL draw into a buffer of its own (opa_layered, rotation/scale, - # clip_corner, a slider fill rounded less than its track) cost KB's per - # redraw that the CYD's heap can't spare; tests/test_layer_free.py. - bg_opa: 45% - slider: - knob: - pressed: - bg_opa: 65% - arc: - knob: - pressed: - bg_opa: 65% - - # LVGL owns the rotation (display + touch) since ESPHome 2026.4. - rotation: ${LVGL_ROTATION} - - style_definitions: - # The page behind the tiles and the cards: one colour from the theme (theme.h PAGE), no wallpaper and no - # gradient, so a redraw is a plain fill. - - id: style_page - bg_opa: COVER - border_width: 0 - radius: 0 - - - id: style_tile - bg_opa: COVER - radius: 22 - border_width: 1 - border_opa: COVER - pad_left: 12 - pad_right: 12 - pad_top: 12 - pad_bottom: 12 - shadow_width: 0 - shadow_opa: 40% - - - id: style_icon_circle - bg_opa: COVER - radius: 999 - border_width: 0 - - - id: style_room - text_font: headline - text_opa: 100% - - - id: style_time - text_font: time_label - text_opa: 100% - - - id: style_title - text_font: label - text_opa: 100% - - - id: style_value - text_font: sublabel - text_opa: 100% - - - id: style_value_big - text_font: sublabel_big - text_opa: 80% - - # Paints: shared styles without a colour of their own. theme.h fills them (the list in on_boot, theme::paints) - # and fills them again when Dark mode changes, so a widget names a paint where it used to write a colour. - - id: paint_page_soft - - id: paint_page_pressed - - id: paint_card - - id: paint_raised - - id: paint_key - - id: paint_key_pressed - - id: paint_track - - id: paint_ink - - id: paint_ink_soft - - id: paint_muted - - id: paint_subtle - - id: paint_slate - - id: paint_scrim - - id: paint_veil - - id: paint_accent_tint - - id: paint_accent_bright - - id: paint_button_dark - - id: paint_button_dark_pressed - - id: paint_camera - - # ---------- ALERT OVERLAY (HA action show_alert) ---------- - # LVGL's top layer sits above every page, card and the dim overlay; the card - # is filled by the alert_show script. Fixed positions, no layout engine. - top_layer: - widgets: - - obj: - id: alert_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - styles: paint_scrim - bg_opa: 60% - radius: 0 - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - # Same look when pressed: a tap next to the card must not repaint the screen. - pressed: - bg_opa: 60% - widgets: - - obj: - id: alert_card - align: CENTER - width: ${ALERT_CARD_W} - height: ${ALERT_CARD_H} - styles: paint_raised - bg_opa: COVER - radius: 18 - border_width: 1 - border_opa: COVER - shadow_width: 0 - pad_all: 0 - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: alert_icon - x: ${ALERT_ICON_X} - y: ${ALERT_ICON_Y} - text: "\U000F002A" - text_font: materialdesign_icons - clickable: false - - label: - id: alert_title - x: ${ALERT_TEXT_X} - y: ${ALERT_TITLE_Y} - width: ${ALERT_TEXT_W} - height: ${ALERT_TITLE_H} - long_mode: DOT - text: "Notification" - text_font: headline - clickable: false - - label: - id: alert_subtitle - x: ${ALERT_TEXT_X} - y: ${ALERT_SUBTITLE_Y} - width: ${ALERT_TEXT_W} - height: ${ALERT_SUBTITLE_H} - long_mode: WRAP - text: "" - text_font: sublabel_big - styles: paint_ink_soft - clickable: false - # The alert's camera image (firmware 0.2.57+) across the top of the card, shown when the app announced - # one; a tap opens the camera full screen over the alert. runtime_tiles.h puts the image in it. - - obj: - id: alert_image_frame - x: ${ALERT_IMAGE_INSET} - y: ${ALERT_IMAGE_INSET} - width: ${CAMERA_THUMB_W} - height: ${CAMERA_THUMB_H} - hidden: true - styles: paint_camera - bg_opa: COVER - radius: 0 - border_width: 0 - shadow_width: 0 - pad_all: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept(millis(), 14) && !runtime_tiles::alert_camera.empty();' - then: - - lambda: 'runtime_tiles::camera_open(runtime_tiles::alert_camera, id(alert_title_text));' - widgets: - - label: - id: alert_image_icon - align: CENTER - text: "\U000F07AE" - text_font: materialdesign_icons - clickable: false - - obj: - id: alert_ok - align: BOTTOM_RIGHT - x: -${ALERT_BUTTON_INSET} - y: -${ALERT_BUTTON_INSET} - width: ${ALERT_BUTTON_W} - height: ${ALERT_BUTTON_H} - radius: 999 - styles: paint_button_dark - bg_opa: COVER - border_width: 0 - shadow_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_button_dark_pressed - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept(millis(), 13);' - then: - - script.execute: - id: alert_dismiss - reason: "ok" - widgets: - - label: - id: alert_ok_label - align: CENTER - text: "OK" - text_font: headline - clickable: false - - pages: - - id: home_page - styles: style_page - pad_all: 0 - scrollable: false - scrollbar_mode: 'OFF' - widgets: - # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. - - spinner: - id: busy_spinner_seed - hidden: true - width: 2 - height: 2 - spin_time: 1s - arc_length: 60deg - # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from - # (firmware 0.2.70+). - - roller: - id: roller_seed - hidden: true - width: 2 - height: 2 - options: ["-"] - # Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with. - - image: - id: camera_image_seed - hidden: true - src: alert_image - - label: - id: lbl_room - x: 16 - y: ${HEADER_Y} - text: "${ROOM_NAME}" - styles: style_room - - # Enables LVGL's line widget for the runtime clock hands and sparklines. - - line: - id: runtime_line_enable - hidden: true - points: - - 0, 0 - - 1, 1 - - - label: - id: lbl_time - align: TOP_RIGHT - x: -16 - y: ${HEADER_Y} - text: "" - styles: style_time - - # Six tiles on page 1, four on page 2. Fixed pages keep navigation predictable. - - obj: - id: tile_scroll - x: 0 - y: ${SCROLL_Y} - width: ${DISPLAY_W} - height: ${SCROLL_H} - bg_opa: TRANSP - # A tap between two tiles presses this area: without its own pressed style the - # theme's 45 % lights it up as a pale panel behind the tiles. - pressed: - bg_opa: TRANSP - border_width: 0 - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - scrollable: false - scrollbar_mode: 'OFF' - widgets: - # ---------- TILE 1 ---------- - - obj: - id: tile1 - x: ${GRID_COL1_X} - y: ${GRID_ROW1_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile1_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile1_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F001B" - clickable: false - - label: - id: t1_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Lamp" - styles: style_title - clickable: false - - label: - id: t1_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 2 ---------- - - obj: - id: tile2 - x: ${GRID_COL2_X} - y: ${GRID_ROW1_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile2_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile2_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F0387" - clickable: false - - label: - id: t2_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Plug" - styles: style_title - clickable: false - - label: - id: t2_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 3 ---------- - - obj: - id: tile3 - x: ${GRID_COL1_X} - y: ${GRID_ROW2_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile3_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile3_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F02E3" - clickable: false - - label: - id: t3_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Evening" - styles: style_title - clickable: false - - label: - id: t3_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 4 ---------- - - obj: - id: tile4 - x: ${GRID_COL2_X} - y: ${GRID_ROW2_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile4_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile4_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F059C" - clickable: false - - label: - id: t4_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "All off" - styles: style_title - clickable: false - - label: - id: t4_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 5 ---------- - - obj: - id: tile5 - x: ${GRID_COL1_X} - y: ${GRID_ROW3_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile5_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile5_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F0425" - clickable: false - - label: - id: t5_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "AC" - styles: style_title - clickable: false - - label: - id: t5_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 6 ---------- - - obj: - id: tile6 - x: ${GRID_COL2_X} - y: ${GRID_ROW3_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile6_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile6_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F070D" - clickable: false - - label: - id: t6_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Vacuum" - styles: style_title - clickable: false - - label: - id: t6_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 7 — Tafellamp ---------- - # Brightness-only lamp: deliberately no percentage/cover/climate - # sensors were created for this tile, those would only cost - # HA subscriptions and heap. The overlay therefore gets - # NAN / "" for the fields a light never uses. - - obj: - id: tile7 - hidden: true - x: ${GRID_COL1_X} - y: ${GRID_ROW4_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile7_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile7_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F06B5" - clickable: false - - label: - id: t7_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 7" - styles: style_title - clickable: false - - label: - id: t7_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 8 — Brievenbus reset ---------- - # input_select with options Full/Empty. The tile lights up as soon as - # the status is TILE8_ACTIVE_STATE ("Full"); a short tap sends - # input_select.select_option with option: "Empty". - # No slider mode, so this tile only needs ha_state_tile8 — - # no other HA subscription at all. - - obj: - id: tile8 - hidden: true - x: ${GRID_COL2_X} - y: ${GRID_ROW4_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile8_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile8_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F06EE" - clickable: false - - label: - id: t8_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 8" - styles: style_title - clickable: false - - label: - id: t8_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 9 — Living room (moved from tile 2) ---------- - - obj: - id: tile9 - hidden: true - x: ${GRID_COL1_X} - y: ${GRID_ROW5_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile9_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile9_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F04B9" - clickable: false - - label: - id: t9_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 9" - styles: style_title - clickable: false - - label: - id: t9_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 10 — Alarm on/off ---------- - - obj: - id: tile10 - hidden: true - x: ${GRID_COL2_X} - y: ${GRID_ROW5_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile10_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile10_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F002A" - clickable: false - - label: - id: t10_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 10" - styles: style_title - clickable: false - - label: - id: t10_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- VALUE SLIDER OVERLAY ---------- - # ---------- PAGE NAVIGATION: the band under the tiles ---------- - # Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the - # page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge. - - obj: - id: page_prev - align: BOTTOM_LEFT - x: 0 - y: 0 - width: 240 - height: 60 - pad_all: 0 - radius: 0 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pressed: - styles: paint_page_pressed - bg_opa: COVER - # on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing - # (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe. - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);' - then: - - lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);' - - script.execute: show_tile_page - widgets: - - label: - align: LEFT_MID - x: 24 - text: "\U000F0141" # chevron-left - text_font: materialdesign_icons_mini - styles: paint_ink - clickable: false - - obj: - id: page_number - align: BOTTOM_MID - y: 0 - width: 160 - height: 60 - pad_all: 0 - bg_opa: TRANSP - border_width: 0 - clickable: false - scrollable: false - - obj: - id: page_next - align: BOTTOM_RIGHT - x: 0 - y: 0 - width: 240 - height: 60 - pad_all: 0 - radius: 0 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pressed: - styles: paint_page_pressed - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);' - then: - - lambda: 'id(tile_page) += 1;' - - script.execute: show_tile_page - widgets: - - label: - align: RIGHT_MID - x: -24 - text: "\U000F0142" # chevron-right - text_font: materialdesign_icons_mini - styles: paint_ink - clickable: false - - - obj: - id: brightness_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - bg_opa: TRANSP - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: brightness_overlay_bg - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: style_page - scrollable: false - clickable: true - on_click: - - script.execute: close_cards - - - obj: - id: brightness_overlay_dim - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: paint_veil - bg_opa: 18% - border_width: 0 - clickable: false - scrollable: false - - - obj: - id: overlay_back_button - align: TOP_LEFT - x: 16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: overlay_title - align: TOP_MID - y: 30 - text: "Light" - styles: style_room - - - label: - id: overlay_value - align: TOP_MID - y: 70 - text: "100 %" - styles: style_value_big - - - slider: - id: overlay_slider - align: CENTER - y: 36 - width: ${OVERLAY_SLIDER_W} - height: ${OVERLAY_SLIDER_H} - min_value: 0 - max_value: 100 - value: 100 - adv_hittest: false - - styles: paint_accent_tint - bg_opa: 100% - radius: 42 - # The only widget with BOTH a radius AND a child (overlay_icon). - # LVGL then clips children to the rounding, and that's only - # possible via a contiguous 96x160 = 46KB ARGB layer. - # If that allocation fails, LVGL keeps trying until the - # task watchdog restarts the board. The rounding stays, the - # clipping goes. - clip_corner: false - border_width: 0 - - indicator: - styles: paint_accent_bright - bg_opa: 100% - radius: 42 - - knob: - bg_opa: 0% - border_width: 0 - shadow_width: 0 - pad_all: 15 - - on_value: - - script.execute: - id: slider_preview - value: !lambda "return x;" - - on_release: - - script.execute: - id: slider_commit - value: !lambda "return lv_slider_get_value(id(overlay_slider));" - - - # The icon used to be a CHILD in the slider. A widget with a radius - # and children has to clip those children to the rounding, and - # LVGL can only do that via an ARGB layer of tens of KB's. Without - # PSRAM that sometimes fails, after which LVGL keeps retrying until - # the watchdog restarts the board. As a sibling on top, no layer - # is needed and it looks identical. - - label: - id: overlay_icon - align: CENTER - y: ${OVERLAY_ICON_Y} - text_font: materialdesign_icons - styles: paint_ink - text: "\U000F001B" - clickable: false - - - obj: - id: overlay_color_button - align: BOTTOM_RIGHT - x: -20 - y: -26 - width: 66 - height: 66 - radius: 999 - hidden: true - clickable: true - bg_opa: TRANSP - border_width: 0 - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - scrollable: false - scrollbar_mode: 'OFF' - on_click: - - script.execute: open_color_detail - widgets: - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 0 - end_angle: 45 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0xFF3B30 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 45 - end_angle: 90 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0xFF9500 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 90 - end_angle: 135 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0xFFD60A - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 135 - end_angle: 180 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0x30D158 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 180 - end_angle: 225 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0x32D6FF - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 225 - end_angle: 270 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0x0A84FF - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 270 - end_angle: 315 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0x5856D6 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 315 - end_angle: 360 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0xBF5AF2 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - # ---------- LIGHT COLOR DETAIL OVERLAY ---------- - - obj: - id: color_detail_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: paint_page_soft - bg_opa: COVER - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: color_close_button - align: TOP_LEFT - x: 16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette, - # preset, speed), opens its effects page. Hidden for every other light. - - obj: - id: color_effects_button - align: TOP_RIGHT - x: -16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - hidden: true - clickable: true - scrollable: false - on_click: - - lambda: 'effects_page::open(id(active_entity));' - widgets: - - label: - align: CENTER - text: "\U000F0674" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: color_detail_title - width: 310 - long_mode: DOT - text_align: CENTER - align: TOP_MID - y: 35 - text: "Light Color" - styles: style_room - - - # ---------- CLIMATE DETAIL OVERLAY ---------- - # The CYD's climate card at the Guition's size: nothing to drag. A setpoint card with big - # -/+ keys (the number changes at once, one call goes out once the taps stop) and one row - # of mode keys like Home Assistant's climate card; fan and swing stay in the picker behind - # "more". A tap redraws only a label or a key, never a dial. - - obj: - id: climate_detail_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: style_page - bg_opa: COVER - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: climate_close_button - align: TOP_LEFT - x: 16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - obj: - id: climate_power_button - align: TOP_RIGHT - x: -16 - y: 16 - width: 60 - height: 60 - radius: 999 - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: climate_power_toggle - widgets: - - label: - id: climate_power_icon - align: CENTER - text: "\U000F0425" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: climate_detail_title - align: TOP_MID - y: 30 - width: 300 - long_mode: DOT - text_align: CENTER - text: "Climate" - styles: style_room - - # What the device does (or its mode), the room temperature and humidity. - - label: - id: climate_current_label - align: TOP_MID - y: 92 - width: 94% - long_mode: DOT - text_align: CENTER - text: "--" - text_font: sublabel_big - styles: paint_muted - clickable: false - - - obj: - id: climate_setpoint_card - align: TOP_MID - y: 136 - width: 94% - height: 200 - radius: 24 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: false - scrollable: false - widgets: - # Every clean tap counts (a quick triple tap is three steps); holding keeps stepping. - - obj: - id: climate_minus_button - align: LEFT_MID - x: 18 - width: 116 - height: 116 - radius: 58 - styles: paint_track - bg_opa: COVER - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key_pressed - disabled: - opa: 40% - on_short_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 60);' - then: - - script.execute: { id: climate_adjust_target, direction: -1, held: false } - on_long_press_repeat: - - script.execute: { id: climate_adjust_target, direction: -1, held: true } - widgets: - - label: - id: climate_minus_icon - align: CENTER - text: "\U000F0374" - text_font: materialdesign_icons - clickable: false - - obj: - id: climate_plus_button - align: RIGHT_MID - x: -18 - width: 116 - height: 116 - radius: 58 - styles: paint_track - bg_opa: COVER - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key_pressed - disabled: - opa: 40% - on_short_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 61);' - then: - - script.execute: { id: climate_adjust_target, direction: 1, held: false } - on_long_press_repeat: - - script.execute: { id: climate_adjust_target, direction: 1, held: true } - widgets: - - label: - id: climate_plus_icon - align: CENTER - text: "\U000F0415" - text_font: materialdesign_icons - clickable: false - - label: - id: climate_target_label - align: CENTER - y: -14 - text: "--" - text_font: setpoint_digits - clickable: false - - label: - id: climate_set_label - align: CENTER - y: 46 - text: "Target" - text_font: sublabel - styles: paint_subtle - clickable: false - - # One key per mode the device supports (off is the power key); the one in use is - # filled in Home Assistant's colour for it. "More" opens the picker with fan and swing. - - obj: - id: climate_mode_keys - align: TOP_MID - y: 364 - width: 94% - height: 76 - bg_opa: TRANSP - border_width: 0 - pad_all: 0 - clickable: false - scrollable: false - layout: - type: FLEX - flex_flow: ROW - flex_align_main: CENTER - flex_align_cross: CENTER - pad_column: 10 - widgets: - - obj: - id: cmkey_auto - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "auto" } - widgets: - - label: - align: CENTER - text: "\U000F1B17" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_heat_cool - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "heat_cool" } - widgets: - - label: - align: CENTER - text: "\U000F1A79" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_heat - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "heat" } - widgets: - - label: - align: CENTER - text: "\U000F0238" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_cool - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "cool" } - widgets: - - label: - align: CENTER - text: "\U000F0717" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_dry - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "dry" } - widgets: - - label: - align: CENTER - text: "\U000F058E" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_fan_only - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "fan_only" } - widgets: - - label: - align: CENTER - text: "\U000F0210" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_more - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: open_climate_mode_picker - widgets: - - label: - align: CENTER - text: "\U000F01D8" - text_font: materialdesign_icons - styles: paint_slate - clickable: false - # Fan and swing, each a row of choices behind its icon (climate_card.h fills the card). - - obj: - id: climate_settings_card - align: TOP_MID - y: 340 - width: 94% - height: 116 - radius: 24 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - hidden: true - clickable: false - scrollable: false - # ---------- CLIMATE MODE PICKER ---------- - # Replaces cycling through modes. Cycling sends a set_hvac_mode on every - # tap, so to get from 'cool' to 'dry' a heat pump actually passes through - # 'heat' and 'fan_only' along the way. Here you pick directly, and exactly - # one command is sent. - - obj: - id: climate_mode_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - styles: style_page - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - align: TOP_MID - y: 35 - text: "Mode" - text_font: sublabel_big - clickable: false - - # Flex ensures unsupported modes don't leave a gap: - # LVGL skips hidden children when arranging. - - obj: - id: climate_mode_list - align: TOP_MID - y: 92 - width: 98% - height: 372 - bg_opa: TRANSP - border_width: 0 - scrollable: false - clickable: false - pad_all: 4 - layout: - type: FLEX - pad_row: 6 - pad_column: 6 - flex_flow: ROW_WRAP - flex_align_main: CENTER - flex_align_cross: CENTER - flex_align_track: CENTER - widgets: - - obj: - id: cmode_off - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "off" } - widgets: - - label: - align: CENTER - text: "Off" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_heat - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "heat" } - widgets: - - label: - align: CENTER - text: "Heat" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_cool - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "cool" } - widgets: - - label: - align: CENTER - text: "Cool" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_heat_cool - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "heat_cool" } - widgets: - - label: - align: CENTER - text: "Heat/Cool" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_auto - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "auto" } - widgets: - - label: - align: CENTER - text: "Auto" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_dry - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "dry" } - widgets: - - label: - align: CENTER - text: "Dry" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_fan_only - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "fan_only" } - widgets: - - label: - align: CENTER - text: "Fan only" - text_font: sublabel_big - clickable: false - - - label: - id: cfan_title - width: 100% - height: 24 - text: "Fan" - text_font: sublabel - styles: paint_subtle - text_align: CENTER - clickable: false - - obj: - id: cfan_0 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 0 } - widgets: - - label: - id: cfan_0_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cfan_1 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 1 } - widgets: - - label: - id: cfan_1_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cfan_2 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 2 } - widgets: - - label: - id: cfan_2_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cfan_3 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 3 } - widgets: - - label: - id: cfan_3_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - label: - id: cswing_title - width: 100% - height: 24 - text: "Swing" - text_font: sublabel - styles: paint_subtle - text_align: CENTER - clickable: false - - obj: - id: cswing_0 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 0 } - widgets: - - label: - id: cswing_0_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cswing_1 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 1 } - widgets: - - label: - id: cswing_1_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cswing_2 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 2 } - widgets: - - label: - id: cswing_2_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cswing_3 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 3 } - widgets: - - label: - id: cswing_3_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: climate_mode_overlay_x - align: TOP_LEFT - x: 16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - lvgl.widget.hide: climate_mode_overlay - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - # ---------- VACUUM CONTROL ---------- - # A vacuum is not a switch: start, pause, stop and return to - # the dock are separate commands, and suction power is a separate axis. - - - # ---------- DIM / WAKE OVERLAY ---------- - - obj: - id: dim_wake_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - bg_opa: TRANSP - # The tap that wakes a dimmed screen must not light up a pale sheet over it. - pressed: - bg_opa: TRANSP - border_width: 0 - scrollable: false - scrollbar_mode: 'OFF' - clickable: true - on_click: - - script.execute: wake_display - # A page that is one full tile switching something on or off switches with this push as well - # (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes. - - lambda: 'runtime_tiles::wake_tap();' - - - id: touch_cal_page - styles: paint_page_soft - bg_opa: COVER - pad_all: 0 - border_width: 0 - scrollable: false - widgets: - - label: - align: TOP_MID - y: 110 - text: "GT911 touch check" - text_font: headline - - obj: - x: 12 - y: 12 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - - obj: - x: 443 - y: 12 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - - obj: - x: 443 - y: 443 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - - obj: - x: 12 - y: 443 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - - obj: - x: 228 - y: 228 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - -script: - - id: capture_ui_snapshot - mode: single - then: - - lambda: guition::snapshot_begin(); - - repeat: - count: 240 - then: - - lambda: guition::snapshot_next_row(); - - delay: 100ms - # 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 = guition::check_geometry(id(home_page)->obj); - for (int i = 0; i < 10; ++i) { - bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index= 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 - - lambda: guition::check_geometry(id(brightness_overlay)); - - lvgl.widget.hide: brightness_overlay - - lvgl.widget.show: color_detail_overlay - - delay: 150ms - - lambda: guition::check_geometry(id(color_detail_overlay)); - - lvgl.widget.hide: color_detail_overlay - - lvgl.widget.show: climate_detail_overlay - - delay: 150ms - - lambda: guition::check_geometry(id(climate_detail_overlay)); - - lvgl.widget.hide: climate_detail_overlay - - lvgl.widget.show: climate_mode_overlay - - delay: 150ms - - lambda: guition::check_geometry(id(climate_mode_overlay)); - - 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" - - - id: show_tile_page - then: - - lambda: |- - if(runtime_tiles::enabled){ - runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number)); - return; - } - lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), - id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; - const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; - const int count = runtime_tiles::model.count; - const bool paginated = count > 6; - if (!paginated) id(tile_page) = 0; - for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { - if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN); - } - for (int i = 0; i < 10; ++i) { - bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6); - if (visible) { - lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]); - lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); - } - } - settings_screen::page_dots(id(page_number), id(tile_page), 2, true); - if (id(tile_page) == 0) { - lv_obj_add_state(id(page_prev), LV_STATE_DISABLED); - lv_obj_clear_state(id(page_next), LV_STATE_DISABLED); - } else { - lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED); - lv_obj_add_state(id(page_next), LV_STATE_DISABLED); - } - - # Runtime changes do not count as touches and do not reset the idle timer. - - id: apply_screen_settings - mode: restart - then: - - lambda: |- - const auto &settings = screen_settings::current; - // Dark mode (firmware 0.2.54+): a look the screen already has costs nothing. - theme::set_dark(settings_screen::dark_mode != 0); - if(runtime_tiles::enabled && static_cast(id(screen_lvgl).get_rotation())!=runtime_tiles::rotation){ - cyd::touch_guard.consume(); - if(lv_indev_get_act())lv_indev_wait_release(lv_indev_get_act()); - id(screen_lvgl).set_rotation(static_cast(runtime_tiles::rotation)); - } - // Auto standby off wakes the screen, but not out of a Sleep pressed in Home Assistant. - if (id(display_dimmed) && !settings.standby_enabled && !id(sleep_requested)) { - id(display_dimmed) = false; - } - auto now = id(homeassistant_time).now(); - const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; - const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; - // Standby dims slowly (ESPHome's transition); waking is quick. - id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300); - if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); } - else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); - // The setting numbers and times only publish a changed value (HA keeps identical states anyway); the - // switches publish their own changes. An open settings page shows a change from Home Assistant too. - // A number without a state publishes too: a night brightness of 0 equals the number's initial state and - // stayed "unknown" in Home Assistant before firmware 0.2.50. - for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness), - std::make_pair(id(setting_standby_brightness), settings.standby_brightness), - std::make_pair(id(setting_night_brightness), settings.night_brightness), - std::make_pair(id(setting_standby_seconds), settings.standby_seconds), - std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) { - if (!entity->has_state() || entity->state != value) entity->publish_state(value); - } - for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) { - if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update(); - } - const auto shown_rotation = id(setting_rotation)->active_index(); - if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update(); - // The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+). - if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed(); - settings_screen::refresh(); - if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); - - # Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light, - # colour and climate overlays, and active_entity, which the swipe guard reads. - - id: close_cards - then: - - lambda: 'if (runtime_tiles::dismiss) runtime_tiles::dismiss();' - - # Everything back to the overview: the settings page, every card and the first page of tiles. - # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. - - id: go_home - then: - - lambda: 'settings_screen::close();' - - script.execute: close_cards - - lambda: 'id(tile_page) = 0;' - - script.execute: show_tile_page - - # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps - # showing what someone looked at minutes ago; after this long the screen is a screen again. - # Independent of standby, which may be off or much later; paused while the screen is in standby. - # The time runs from the last touch (last_use_ms, firmware 0.2.56+): Wake, an alert or Auto standby - # light the screen and restart the standby time, but nobody used a page. Checked every second and by Wake. - - id: back_to_page_1_when_due - then: - - if: - condition: - lambda: |- - if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; - if (id(display_dimmed)) return false; - bool away = settings_screen::visible() || runtime_tiles::camera_visible() || !id(active_entity).empty() || id(tile_page) != 0 || - (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); - return away && (millis() - id(last_use_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; - then: - - script.execute: go_home - - # ---------- ALERT (HA action show_alert) ---------- - # The screen wakes, keeps its normal brightness and shows the card until OK or the - # timeout (0: until OK); the standby interval waits on alert_active. A new alert - # replaces the current one; every ending reports itself as esphome.screen_alert. - - id: alert_show - mode: restart - parameters: - title: string - subtitle: string - icon: string - color: string - button_text: string - timeout: int - flash: bool - then: - - lambda: |- - if (id(alert_active)) id(alert_report).execute("replaced"); - // An alert is the one thing that outranks the settings page, so that closes first. - settings_screen::close(); - // An open camera closes; the card makes room for an image the app announced for this alert. - runtime_tiles::alert_prepare(); - const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); - id(alert_active) = true; - id(alert_title_text) = alert.title; - id(alert_timeout_seconds) = alert.timeout_seconds; - id(alert_flash_requested) = alert.flash; - lv_label_set_text(id(alert_icon), alert.icon.c_str()); - lv_label_set_text(id(alert_title), alert.title.c_str()); - lv_label_set_text(id(alert_subtitle), alert.subtitle.c_str()); - lv_label_set_text(id(alert_ok_label), alert.button.c_str()); - id(alert_card_color) = alert.color; - lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(alert.color)), LV_PART_MAIN); - ESP_LOGI("alert", "show \"%s\" timeout=%d flash=%d", alert.title.c_str(), alert.timeout_seconds, (int) alert.flash); - - script.execute: wake_display - - lvgl.widget.show: alert_overlay - - script.stop: alert_timeout - - if: - condition: - lambda: 'return id(alert_flash_requested);' - then: - - script.execute: alert_flash - - if: - condition: - lambda: 'return id(alert_timeout_seconds) > 0;' - then: - - script.execute: - id: alert_timeout - seconds: !lambda 'return id(alert_timeout_seconds);' - - - 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(); - runtime_tiles::alert_clear(); - ESP_LOGI("alert", "dismissed: %s", reason.c_str()); - - lvgl.widget.hide: alert_overlay - - script.stop: alert_timeout - - script.stop: alert_flash - - lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 80);' - - 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: - - lambda: 'id(set_backlight).execute(0, 60);' - - delay: 140ms - - lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 60);' - - delay: 180ms - - - id: alert_timeout - mode: restart - parameters: - seconds: int - then: - - delay: !lambda 'return static_cast(seconds) * 1000UL;' - - script.execute: - id: alert_dismiss - reason: "timeout" - - # Home Assistant adds device_id; action is ok, timeout, replaced or remote. - - id: alert_report - parameters: - action: string - then: - - homeassistant.event: - event: esphome.screen_alert - data: - action: !lambda 'return action;' - title: !lambda 'return id(alert_title_text);' - screen: !lambda 'return App.get_name();' - - - id: wake_display - mode: restart - then: - - lambda: |- - id(last_touch_ms) = millis(); - id(display_dimmed) = false; - id(sleep_requested) = false; - // Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish - // when you touch the screen and are waiting on light. - id(set_backlight).execute(screen_settings::current.brightness, 80); - - lvgl.widget.hide: dim_wake_overlay - - script.execute: close_cards - - # One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56 - # stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final; - # a new call interrupts the running transition from where it is, as ESPHome does for any light. - - id: set_backlight - mode: restart - parameters: - level: int - ms: int - then: - - lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform(); - - # Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed. - # The settings page and every card close; with "also on standby" the screen goes back to page 1 too. - - id: dim_display - mode: restart - then: - - lambda: |- - id(display_dimmed) = true; - id(apply_screen_settings).execute(); - - if: - condition: - lambda: 'return screen_settings::current.home_on_standby;' - then: - - script.execute: go_home - else: - - lambda: 'settings_screen::close();' - - script.execute: close_cards - - # A -/+ tap on the climate card: the number follows at once, the call waits for the last tap. - # Holding a key steps three times a second. - - id: climate_adjust_target - parameters: - direction: int - held: bool - then: - - lambda: |- - if (held && millis() - id(climate_edit_ms) < 300) return; - int step_x10 = std::max(5, id(active_climate_step_x10)); - int next = cyd::quantize_temperature(id(active_climate_target_x10) + direction * step_x10, - id(active_climate_min_x10), id(active_climate_max_x10), step_x10); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - if (next == id(active_climate_target_x10) && id(active_climate_target_known)) return; - id(active_climate_target_x10) = next; - id(active_climate_target_known) = true; - id(climate_card_refresh).execute(); - id(climate_target_send).execute(id(active_entity), next / 10.0f); - - # One set_temperature for a series of taps: every tap restarts the wait with the newest value. - # Entity and value travel along, so leaving the card right after a tap still sends it. - - id: climate_target_send - mode: restart - parameters: - entity: string - value: float - then: - - delay: 700ms - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !entity.empty();' - then: - - homeassistant.action: - action: climate.set_temperature - data: - entity_id: !lambda "return entity;" - temperature: !lambda "return value;" - - # Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys. - - 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"}}; - // The Guition shows fan and swing on their own card below, so no "more" key. - bool more = false; - 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. - 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); - - // Fan and swing rows, in the entity's own names and order. - climate_card::Row settings[2]; - int rows = 0; - if (!cyd::list_item(id(active_climate_fan_modes), 0).empty()) - settings[rows++] = {'f', "\U000F0210", id(active_climate_fan_modes), id(active_climate_fan_mode)}; - if (!cyd::list_item(id(active_climate_swing_modes), 0).empty()) - settings[rows++] = {'s', "\U000F1C91", id(active_climate_swing_modes), id(active_climate_swing_mode)}; - // One layout per card: the setpoint gives room to the mode keys and the settings below it, - // and a card with nothing below keeps the setpoint centred. - static int laid_out = -1; - if (laid_out != rows * 2 + show_keys) { - laid_out = rows * 2 + show_keys; - struct Layout { int status, card_y, card_h, key, number_y, caption_y, modes_y, modes_h, settings_y, edge, row_h, gap; }; - static const Layout layouts[6] = { - // no mode keys: 0, 1, 2 rows - {92, 180, 200, 116, -14, 46, 0, 0, 0, 14, 48, 10}, - {92, 140, 200, 116, -14, 46, 0, 0, 364, 14, 48, 10}, - {90, 130, 180, 108, -12, 42, 0, 0, 330, 14, 46, 10}, - // mode keys: 0, 1, 2 rows - {92, 136, 200, 116, -14, 46, 364, 76, 0, 14, 48, 10}, - {90, 126, 170, 108, -12, 42, 312, 68, 394, 12, 44, 8}, - {86, 118, 140, 96, -10, 36, 270, 60, 342, 12, 42, 8}, - }; - const Layout &l = layouts[(show_keys ? 3 : 0) + rows]; - lv_obj_set_y(id(climate_current_label), l.status); - lv_obj_set_y(id(climate_setpoint_card), l.card_y); - lv_obj_set_height(id(climate_setpoint_card), l.card_h); - for (lv_obj_t *button : {id(climate_minus_button), id(climate_plus_button)}) { - lv_obj_set_size(button, l.key, l.key); - lv_obj_set_style_radius(button, l.key / 2, LV_PART_MAIN); - } - lv_obj_set_y(id(climate_target_label), l.number_y); - lv_obj_set_y(id(climate_set_label), l.caption_y); - if (show_keys) { - lv_obj_set_y(id(climate_mode_keys), l.modes_y); - lv_obj_set_height(id(climate_mode_keys), l.modes_h); - for (lv_obj_t *mode_key : {id(cmkey_auto), id(cmkey_heat_cool), id(cmkey_heat), id(cmkey_cool), id(cmkey_dry), id(cmkey_fan_only), id(cmkey_more)}) { - lv_obj_set_height(mode_key, l.modes_h); - lv_obj_set_style_radius(mode_key, l.modes_h / 2, LV_PART_MAIN); - } - } - lv_obj_set_y(id(climate_settings_card), l.settings_y); - climate_card::look.edge = l.edge; - climate_card::look.row_h = l.row_h; - climate_card::look.gap = l.gap; - } - climate_card::show(settings, rows); - id(climate_power_refresh).execute(); - - # Opens the mode picker. Hides modes the entity doesn't support and marks - # the current one, so you see where you stand before you pick something. - # Opens the vacuum controls and remembers which entity it concerns. - - # A standalone command: start, pause, stop or return_to_base. - - # Suction power is a separate axis, apart from start/stop. - - - id: open_climate_mode_picker - mode: restart - 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 supported = lower(id(active_climate_supported_modes)); - std::string current = lower(id(active_climate_mode)); - - auto has = [&](const char* m) { - std::string mode(m); - if (supported.empty() || supported == "unknown" || supported == "unavailable") return false; - return supported.find("'" + mode + "'") != std::string::npos || - supported.find("\"" + mode + "\"") != std::string::npos; - }; - auto apply = [&](lv_obj_t* btn, const char* m) { - if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); - bool selected = (current == std::string(m)); - lv_obj_set_style_bg_color(btn, theme::color(selected ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); - lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), theme::color(selected ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); - }; - apply(id(cmode_off), "off"); - apply(id(cmode_heat), "heat"); - apply(id(cmode_cool), "cool"); - apply(id(cmode_heat_cool), "heat_cool"); - apply(id(cmode_auto), "auto"); - apply(id(cmode_dry), "dry"); - apply(id(cmode_fan_only), "fan_only"); - // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. - auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; - auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { - unsigned shown = 0; - for (unsigned i = 0; i < 4; ++i) { - std::string raw = cyd::list_item(modes, i); - if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } - lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; - lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); - lv_obj_set_style_bg_color(chips[i], theme::color(raw == current_mode ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); - lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), theme::color(raw == current_mode ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); - } - if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); - }; - lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; - lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; - fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); - fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - - lvgl.widget.show: climate_mode_overlay - - # Sets exactly one mode. No intermediate steps, so no unwanted - # commands to the heat pump along the way. - # Fan and swing modes from the picker: one call with the entity's own mode name. - - id: set_climate_fan_mode - mode: restart - parameters: - index: int - then: - - lambda: |- - id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - id(climate_card_refresh).execute(); - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_fan_mode).empty();' - then: - - homeassistant.action: - action: climate.set_fan_mode - data: - entity_id: !lambda "return id(active_entity);" - fan_mode: !lambda "return id(active_climate_fan_mode);" - - id: set_climate_swing_mode - mode: restart - parameters: - index: int - then: - - lambda: |- - id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - id(climate_card_refresh).execute(); - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_swing_mode).empty();' - then: - - homeassistant.action: - action: climate.set_swing_mode - data: - entity_id: !lambda "return id(active_entity);" - swing_mode: !lambda "return id(active_climate_swing_mode);" - - # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. - - id: climate_power_toggle - mode: restart - then: - - lambda: |- - std::string mode = id(active_climate_mode); - for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); - id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - if (!id(active_climate_power_on)) id(active_climate_mode) = "off"; - id(climate_card_refresh).execute(); - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_climate_power_on);' - then: - - homeassistant.action: - action: climate.turn_on - data: - entity_id: !lambda "return id(active_entity);" - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_power_on);' - then: - - homeassistant.action: - action: climate.turn_off - data: - entity_id: !lambda "return id(active_entity);" - - # The power key lights up while the device runs in any mode. - - id: climate_power_refresh - mode: restart - then: - - lambda: |- - std::string mode = id(active_climate_mode); - for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); - bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); - if (id(climate_edit_pending)) on = id(active_climate_power_on); - lv_obj_set_style_bg_color(id(climate_power_button), theme::color(on ? theme::ACCENT_TINT : theme::KEY), LV_PART_MAIN); - lv_obj_set_style_text_color(id(climate_power_icon), theme::color(on ? theme::ACCENT_ICON : theme::ICON_OFF), LV_PART_MAIN); - - - id: set_climate_mode - mode: restart - parameters: - mode: string - then: - - lambda: |- - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - id(active_climate_mode) = mode; - // The action line would still say what the old mode did. - id(active_climate_action).clear(); - id(active_climate_power_on) = mode != "off"; - id(climate_card_refresh).execute(); - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh();' - then: - - homeassistant.action: - action: climate.set_hvac_mode - data: - entity_id: !lambda "return id(active_entity);" - hvac_mode: !lambda "return id(active_climate_mode);" - - # Isolated 40-second preview; every light commit is suppressed in the helper. - - id: light_controls_preview - mode: single - then: - - script.execute: wake_display - - lambda: |- - light_controls::demo = true; - light_controls::fallback.minimum = 2000; - light_controls::fallback.maximum = 6500; - light_controls::fallback.kelvin = 3000; - light_controls::fallback.hue = 200; - light_controls::open("diagnostic", true, true, 75); - lv_label_set_text(id(color_detail_title), "Example lamp"); - - lvgl.widget.hide: color_close_button - - lvgl.widget.show: color_detail_overlay - - delay: 40s - - lvgl.widget.hide: color_detail_overlay - - lvgl.widget.show: color_close_button - - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' - - - id: open_color_detail - mode: restart - then: - - lambda: |- - lv_label_set_text(id(color_detail_title), id(active_title).c_str()); - light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); - if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); - - lvgl.widget.hide: brightness_overlay - - lvgl.widget.show: color_detail_overlay - - - id: color_brightness_commit - parameters: - value: int - then: - - lambda: 'id(active_brightness_pct) = value;' - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_entity).rfind("light.", 0) == 0;' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - brightness_pct: !lambda "return value;" - - - id: set_light_hue - parameters: - value: int - then: - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - data_template: - hs_color: "{{ [hue | int, 100] }}" - variables: - hue: !lambda "return light_controls::clamp(value, 0, 360);" - - - id: set_light_color_temperature - parameters: - kelvin: int - then: - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" - - - id: slider_preview - parameters: - value: int - then: - - lambda: |- - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str()); - lv_label_set_text(id(overlay_value), buf); - int pct = value; - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - std::string value_text; - - if (id(active_control_kind) == "cover_position") { - std::string closed_label = id(active_label_off).c_str(); - std::string open_label = id(active_label_on).c_str(); - - if (pct <= 0) { - value_text = closed_label; - } else if (pct >= 100) { - value_text = open_label; - } else { - char buf[40]; - snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct); - value_text = buf; - } - } else { - if (pct <= 0) { - value_text = id(active_label_off).c_str(); - } else { - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); - value_text = buf; - } - } - - lv_label_set_text(id(overlay_value), value_text.c_str()); - - - - id: slider_commit - parameters: - value: int - then: - - if: - condition: - lambda: |- - return id(active_control_kind) == "light_brightness"; - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - brightness_pct: !lambda "return value;" - else: - - if: - condition: - lambda: |- - return id(active_control_kind) == "fan_percentage"; - then: - - if: - condition: - lambda: "return value <= 0;" - then: - - homeassistant.action: - action: fan.turn_off - data: - entity_id: !lambda "return id(active_entity);" - else: - - homeassistant.action: - action: fan.turn_on - data: - entity_id: !lambda "return id(active_entity);" - - delay: 150ms - - homeassistant.action: - action: fan.set_percentage - data: - entity_id: !lambda "return id(active_entity);" - percentage: !lambda "return value;" - else: - - if: - condition: - lambda: |- - return id(active_control_kind) == "cover_position"; - then: - - homeassistant.action: - action: cover.set_cover_position - data: - entity_id: !lambda "return id(active_entity);" - position: !lambda "return value;" - - # ---------------------------------------------------------- - # do_tile_action - # - # Centralized handler for executing Home Assistant actions - # triggered by tile taps. - # - # Parameters - # - tile_type: light | fan | switch | scene | script | cover - # - tap_action: auto | toggle | activate | fan_toggle_preset - # - service: optional Home Assistant service override - # - entity_id: target entity - # - param_key / param_val: optional parameters for custom services - # - was_on: previous entity state (pass false for longpress actions) - # - # Behavior - # - # toggle - # -> toggles the entity - # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on) - # - # activate - # -> activates entity - # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover - # - # auto - # -> resolves automatically based on tile_type: - # light/switch/fan/cover -> toggle - # scene/script -> activate - # - # fan_toggle_preset - # -> if fan was ON: fan.turn_off - # -> if fan was OFF: - # fan.turn_on - # wait 150ms - # fan.set_preset_mode(param_val) - # - # Custom service - # If "service" is set, it will be called directly. - # param_key / param_val may be used for parameters - # like preset_mode, brightness_pct, percentage, etc. - # ---------------------------------------------------------- - - - id: open_value_overlay - parameters: - title: string - entity: string - tile_type: string - longpress_mode: string - was_on: bool - off_value: int - brightness_value: float - percentage_value: float - position_value: float - icon_text: string - label_off: string - label_on: string - supported_color_modes: string - climate_current_temperature: float - climate_target_temperature: float - climate_humidity: float - climate_mode: string - climate_supported_modes: string - climate_min_temp: string - climate_max_temp: string - climate_step: string - then: - - lambda: |- - std::string mode = longpress_mode; - auto contains_mode = [](const std::string& modes, const std::string& needle) { - return modes.find(needle) != std::string::npos; - }; - if (mode == "auto") { - if (tile_type == "light") mode = "brightness"; - else if (tile_type == "fan") mode = "percentage"; - else if (tile_type == "cover") mode = "cover_position"; - else if (tile_type == "climate") mode = "climate_temperature"; - else mode = "none"; - } - - if (mode == "none") return; - - id(active_entity) = entity; - id(active_value_suffix) = " %"; - id(active_label_off) = label_off; - id(active_label_on) = label_on; - id(active_title) = title; - id(active_icon_text) = icon_text; - id(active_supports_color) = false; - id(active_supports_temperature) = false; - lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); - - if (mode == "climate_temperature") { - auto to_x10 = [](float value, int fallback) -> int { - if (isnan(value)) return fallback; - return (int) lround(value * 10.0f); - }; - auto parse_x10 = [](const std::string& value, int fallback) -> int { - if (value.empty()) return fallback; - return (int) lround(atof(value.c_str()) * 10.0f); - }; - id(climate_edit_pending) = false; - id(active_control_kind) = "climate_temperature"; - id(active_climate_min_x10) = parse_x10(climate_min_temp, 50); - id(active_climate_max_x10) = parse_x10(climate_max_temp, 300); - id(active_climate_step_x10) = parse_x10(climate_step, 5); - if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5; - if (id(active_climate_max_x10) <= id(active_climate_min_x10)) { - id(active_climate_min_x10) = 50; - id(active_climate_max_x10) = 300; - } - - id(active_climate_current_x10) = to_x10(climate_current_temperature, 0); - id(active_climate_current) = climate_current_temperature; - id(active_climate_target_known) = !isnan(climate_target_temperature); - int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10); - id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target); - if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); - if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); - id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10); - if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); - if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); - id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity); - id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode; - id(active_climate_supported_modes) = climate_supported_modes; - - lv_label_set_text(id(climate_detail_title), title.c_str()); - id(climate_card_refresh).execute(); - - lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); - return; - } - - int pct = off_value; - - if (mode == "brightness") { - id(active_control_kind) = "light_brightness"; - if (was_on) { - if (isnan(brightness_value)) pct = 100; - else pct = (int) lround((brightness_value / 255.0f) * 100.0f); - } - } else if (mode == "percentage") { - id(active_control_kind) = "fan_percentage"; - if (was_on) { - if (isnan(percentage_value)) pct = 0; - else pct = (int) lround(percentage_value); - } - } else if (mode == "cover_position") { - id(active_control_kind) = "cover_position"; - if (isnan(position_value)) pct = was_on ? 100 : 0; - else pct = (int) lround(position_value); - } else { - return; - } - - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - id(active_brightness_pct) = pct; - - if (tile_type == "light") { - id(active_supports_color) = - contains_mode(supported_color_modes, "hs") || - contains_mode(supported_color_modes, "rgb") || - contains_mode(supported_color_modes, "rgbw") || - contains_mode(supported_color_modes, "rgbww") || - contains_mode(supported_color_modes, "xy"); - id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp"); - - if (id(active_supports_color) || id(active_supports_temperature)) { - lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); - } - } - - // Track and fill are the pale blue and blue paints of overlay_slider. - lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN); - lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR); - - lv_label_set_text(id(overlay_title), title.c_str()); - lv_label_set_text(id(overlay_icon), icon_text.c_str()); - lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF); - - std::string value_text; - - if (tile_type == "cover") { - if (pct <= 0) { - value_text = label_off; - } else if (pct >= 100) { - value_text = label_on; - } else { - char buf[40]; - snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct); - value_text = buf; - } - } else { - if (pct <= 0) { - value_text = label_off; - } else { - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); - value_text = buf; - } - } - - lv_label_set_text(id(overlay_value), value_text.c_str()); - - lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR); - - - if: - condition: - lambda: 'return id(active_control_kind) != "climate_temperature";' - then: - - if: - condition: - lambda: 'return id(active_supports_color) || id(active_supports_temperature);' - then: - - script.execute: open_color_detail - else: - - lvgl.widget.show: brightness_overlay - - # UI refresh (values + time + styles) + render - - id: ui_refresh - mode: single - then: - - delay: 100ms - - lambda: |- - if (runtime_tiles::enabled) { - // The name and the top bar (clock included) draw together. - runtime_tiles::render(id(lbl_room)); - return; - } - -# HA blueprint transport; never publishes the JSON command as an HA state. -text: - - platform: smart_display - id: dashboard_inbox - name: "Tile settings" - internal: ${DASHBOARD_INBOX_INTERNAL} - -button: - # Wake and Sleep for Home Assistant automations (0.2.45+). Wake is light, not a tap (0.2.56+): it - # lights a dimmed screen and counts the standby time from now, while Back to page 1 keeps counting - # from the last touch, so Wake on every motion no longer holds a card or page 2 up. Sleep is the - # standby time running out, right away and also with Auto standby off; it holds until a tap, Wake - # or an alert, and closes an alert that is up. Neither saves anything, so an automation may press - # them on every motion. No entity_category: they are controls, not settings. - - platform: template - id: wake_button - name: "Wake" - icon: "mdi:gesture-tap" - on_press: - - lambda: 'ESP_LOGI("standby", "Wake from Home Assistant, screen %s", id(display_dimmed) ? "in standby" : "on");' - - if: - condition: - lambda: 'return id(display_dimmed);' - then: - - script.execute: wake_display - # A page whose time ran out during standby: light up on page 1, not on the old page first. - - script.execute: back_to_page_1_when_due - else: - - lambda: 'id(last_touch_ms) = millis();' - - platform: template - id: sleep_button - name: "Sleep" - icon: "mdi:power-sleep" - on_press: - - if: - condition: - lambda: 'return id(calibration_active);' - then: - - lambda: 'ESP_LOGI("standby", "Sleep from Home Assistant ignored: touch calibration is running");' - else: - - lambda: |- - ESP_LOGI("standby", "Sleep from Home Assistant"); - id(sleep_requested) = true; - - script.execute: - id: alert_dismiss - reason: "remote" - - script.execute: dim_display - -number: - # Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the - # screen already has writes and reports nothing (an automation may set it on every light change). - - platform: template - id: setting_brightness - name: "Normal brightness" - entity_category: config - min_value: 5 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.brightness; - set_action: - - lambda: 'settings_screen::set("brightness", (int32_t) lround(x));' - - platform: template - id: setting_standby_brightness - name: "Standby brightness" - entity_category: config - min_value: 0 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.standby_brightness; - set_action: - - lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));' - - platform: template - id: setting_night_brightness - name: "Night brightness" - entity_category: config - min_value: 0 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.night_brightness; - set_action: - - lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));' - - platform: template - id: setting_standby_seconds - name: "Standby after" - entity_category: config - min_value: 60 - max_value: 86400 - step: 60 - unit_of_measurement: "s" - update_interval: never - lambda: return screen_settings::current.standby_seconds; - set_action: - - lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));' - - platform: template - id: setting_auto_home_seconds - name: "Back to page 1 after" - icon: "mdi:timer-outline" - entity_category: config - min_value: 30 - max_value: 3600 - step: 30 - mode: box - unit_of_measurement: "s" - update_interval: never - lambda: return settings_screen::auto_home_seconds; - set_action: - - lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));' diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 75de110..a770717 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -1,633 +1,32 @@ ############################################################ -# ESP Screens - CYD board profile (ESP32-2432S028, "Cheap Yellow Display") -# Built-in ILI9341 320x240 display and XPT2046 touch, drawn with LVGL. +# ESP Screens - a CYD (ESP32-2432S028, "Cheap Yellow Display") built from this checkout: +# the bench and the manual route (README_EXTENDED.md). # -# This is the source profile. tools/generate_packages.py turns it into -# packages/cyd.yaml, the package a screen builds from over GitHub; never edit -# that file by hand. A screen gets its tiles from the ESP Screen Manager add-on -# while it runs, so no Home Assistant entities live in here. +# The screen is two packages: packages/core.yaml, which every board shares (the LVGL +# tree, the cards, the scripts, the entities), and packages/boards/cyd-2432s028.yaml +# with this board's hardware and sizes. docs/PROFILES.md says how the files fit together. +# A screen installed from ESP Screens builds the same two files over GitHub through +# packages/cyd.yaml; this file only adds what a build from a checkout needs. # -# What is in here: the hardware (display, touch, backlight, LED), the LVGL tree -# the runtime fills (tiles, cards, overlays), the screen settings, and the -# diagnostics a bench session uses. -# -# What to adapt before flashing: the secrets (api_encryption_key, ota_password, -# Wi-Fi) and, for another CYD variant, the pins and the touch calibration. -# Start at README.md; docs/ covers installing, calibrating and releasing. +# What to adapt before flashing: the secrets (api_encryption_key, ota_password, Wi-Fi) +# in secrets.yaml and, for another CYD variant, the pins and the touch calibration in +# the board file. Start at README.md; docs/ covers installing, calibrating and releasing. ############################################################ - substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.70" - # 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" - DASHBOARD_INBOX_INTERNAL: "false" - DEVICE_NAME: "cyd-new" - DEVICE_FRIENDLY_NAME: "My CYD" - CALIBRATION_ON_BOOT: "false" - 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" - ROOM_NAME: "Choose tiles in HA" # shown top left until ESP Screens sends the screen name + FONT_DIR: "fonts" # the fonts next to this file - ## DIM settings - AUTO_DIM: "true" # true = enable auto dimming - AUTO_DIM_TIMEOUT: "600" # seconds after last touch - MAX_BRIGHTNESS: "100" # backlight brightness in percent - DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent - NIGHT_MODE: "true" # true = use separate dim level at night - NIGHT_START_HOUR: "22" # 0-23 - NIGHT_END_HOUR: "7" # 0-23 - NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night +packages: + core: !include packages/core.yaml + board: !include packages/boards/cyd-2432s028.yaml - - # 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" - -# ============================================================ -# No need to edit below this line. -# All configuration is done via the substitutions above. -# Credentials are managed in secrets.yaml (see secrets.yaml.example). -# The Material Design Icons font is included at fonts/materialdesignicons-webfont.ttf (Apache 2.0). -# Source: https://github.com/Templarian/MaterialDesign-Webfont -# ============================================================ +# The components of this checkout; packages/cyd.yaml takes them from GitHub. external_components: - source: type: local path: components components: [xpt2046, smart_display] -esphome: - min_version: 2026.6.2 - project: - name: maxgramser.esp_screen - version: "${SCREEN_FIRMWARE_VERSION}" - platformio_options: - build_flags: - - -DLV_USE_SWITCH=1 - includes: - - - - - - - - - name: ${DEVICE_NAME} - friendly_name: ${DEVICE_FRIENDLY_NAME} - on_boot: - priority: -100 - then: - - lambda: |- - // 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; - - lambda: |- - auto &settings = screen_settings::current; - settings.standby_enabled = std::string("${AUTO_DIM}") == "true"; - settings.standby_seconds = atoi("${AUTO_DIM_TIMEOUT}"); - settings.brightness = atoi("${MAX_BRIGHTNESS}"); - settings.standby_brightness = atoi("${DIM_BRIGHTNESS}"); - settings.night_enabled = std::string("${NIGHT_MODE}") == "true"; - settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; - settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; - settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); - runtime_tiles::watch_font = id(headline)->get_lv_font(); - runtime_tiles::watch_value_font = id(watch_value)->get_lv_font(); - runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font(); - runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); - runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; - runtime_tiles::screen_awake = []() { return !id(display_dimmed); }; - runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); - runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); - runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); - runtime_tiles::small_font = id(sublabel)->get_lv_font(); - // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock - // label lends its margin and drawing order and stays hidden in runtime mode. - runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font(); - runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font(); - runtime_tiles::time_label = id(lbl_time); - runtime_tiles::enabled = true; - runtime_tiles::load_settings(); - // Colours (firmware 0.2.54+): the styles of this profile take their colours from theme.h. The list runs now, - // in the look the screen saved, before anything is drawn, and again after every change of look. - theme::paints = []() { - using theme::Paint; - for (auto *style : {id(style_room), id(style_time), id(style_title), id(style_value_big), id(paint_ink)}) theme::fill(style, Paint::ink); - theme::fill(id(style_page), Paint::page); - theme::fill(id(style_tile), Paint::card); - theme::fill(id(style_icon_circle), Paint::accent_tint); - theme::fill(id(style_value), Paint::muted); - theme::fill(id(paint_page_soft), Paint::page_soft); - theme::fill(id(paint_page_pressed), Paint::page_pressed); - theme::fill(id(paint_card), Paint::card); - theme::fill(id(paint_raised), Paint::raised); - theme::fill(id(paint_key), Paint::key); - theme::fill(id(paint_key_pressed), Paint::key_pressed); - theme::fill(id(paint_track), Paint::track); - theme::fill(id(paint_ink_soft), Paint::ink_soft); - theme::fill(id(paint_muted), Paint::muted); - theme::fill(id(paint_subtle), Paint::subtle); - theme::fill(id(paint_slate), Paint::slate); - theme::fill(id(paint_scrim), Paint::scrim); - theme::fill(id(paint_veil), Paint::veil); - theme::fill(id(paint_accent_tint), Paint::accent_tint); - theme::fill(id(paint_accent_bright), Paint::accent_bright); - theme::fill(id(paint_button_dark), Paint::button_dark); - theme::fill(id(paint_button_dark_pressed), Paint::button_dark_pressed); - theme::fill(id(paint_calibration), Paint::calibration); - }; - theme::paints(); - theme::set_dark(settings_screen::dark_mode != 0); - runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; - runtime_tiles::dismiss = []() { - effects_page::close(); - runtime_tiles::hide_detail(); - id(active_entity).clear(); - lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); - }; - runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; - 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); - runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; - runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); - runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); - runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); - runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); - runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); - runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); - runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); - runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); - runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); - runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); - runtime_tiles::detail = [](runtime_tiles::Tile &t) { - light_controls::fallback = light_controls::State{}; - light_controls::fallback.hue = t.hue; - light_controls::fallback.kelvin = t.kelvin; - light_controls::fallback.minimum = t.min_kelvin; - light_controls::fallback.maximum = t.max_kelvin; - id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; - id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; - id(active_climate_action) = t.extra().hvac_action; - id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, - t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), - "Off", "On", t.modes, - t.current, t.target, t.humidity, t.state, t.extra().hvac_modes, - std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); - }; - runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { - effects_page::updated(t); - if (id(active_entity) != t.entity || id(touch_down)) return; - if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { - if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; - id(climate_edit_pending) = false; - id(active_climate_mode) = t.state; - id(active_climate_action) = t.extra().hvac_action; - id(active_climate_supported_modes) = t.extra().hvac_modes; - id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; - id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; - id(active_climate_current) = t.current; - id(active_climate_humidity) = std::isfinite(t.humidity) ? (int) lround(t.humidity) : -1; - if (std::isfinite(t.target)) { - id(active_climate_target_x10) = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); - id(active_climate_target_known) = true; - } - id(climate_card_refresh).execute(); - } - }; - - lambda: |- - light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font()); - light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; - light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; - light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; - // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and - // the ways out: actions to Home Assistant and the question for a picker's names. - effects_page::title_font = id(headline)->get_lv_font(); - effects_page::row_font = id(sublabel_big)->get_lv_font(); - effects_page::roller_font = id(sublabel_big)->get_lv_font(); - effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font(); - effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * { - for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i]; - return nullptr; - }; - effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) { - runtime_tiles::action(service, entity, key, value); - }; - effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); }; - effects_page::drift = []() { return cyd::touch_guard.distance(); }; - effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; - effects_page::now = []() { return (uint32_t) millis(); }; - runtime_tiles::options_received = effects_page::received; - - lambda: |- - 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}); - # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager - # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built - # when it opens and thrown away when it closes, so it costs nothing while it is closed. - - lambda: |- - if (!runtime_tiles::enabled) return; - settings_screen::title_font = id(headline)->get_lv_font(); - settings_screen::row_font = id(sublabel_big)->get_lv_font(); - settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); - settings_screen::store = []() { runtime_tiles::persist_settings(); }; - settings_screen::apply = []() { id(apply_screen_settings).execute(); }; - settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; - settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; - settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; - settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; - settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; - settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; - // A dimmed screen, a calibration or an alert owns the glass; the page waits. - settings_screen::may_open = []() { - return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active); - }; - settings_screen::drift = []() { return cyd::touch_guard.distance(); }; - settings_screen::before_open = []() { id(close_cards).execute(); }; - settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; - settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; - // What a change of look draws again, now that everything is bound: the tiles, the top bar and an open card, - // the colour card, the settings page, an alert and the climate cards. - theme::redraw = []() { - runtime_tiles::restyle(); - light_controls::restyle(); - effects_page::restyle(); - settings_screen::restyle(); - if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN); - if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute(); - if (!lv_obj_has_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN)) id(open_climate_mode_picker).execute(); - }; - // Under the cards: brightness_overlay is the first of them on the page, so the back button and - // the action at the top right of a card keep their taps. - settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}, id(brightness_overlay)); - - light.turn_on: - id: back_light - brightness: !lambda "return screen_settings::current.brightness / 100.0f;" - - script.execute: show_tile_page - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - - if: - condition: - lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";' - then: - - lambda: 'id(calibration_active) = true;' - - lvgl.page.show: touch_cal_page - - lambda: |- - lv_obj_invalidate(id(touch_cal_page)->obj); - lv_refr_now(nullptr); - ESP_LOGI("ui_diag", "USB calibration ready; no tile actions"); - - -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 - -logger: - # Set to DEBUG or VERBOSE temporarily for diagnosis. Note: ESPHome logs - # its configuration lines at CONFIG level, which are invisible at INFO. - level: DEBUG - logs: - sensor: WARN - text_sensor: WARN - xpt2046: WARN - homeassistant.sensor: WARN - homeassistant.text_sensor: WARN - - -# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values. api: - actions: - - action: touch_diagnostics - then: - - lambda: 'id(calibration_active) = true;' - - script.execute: wake_display - - lvgl.page.show: touch_cal_page - - lambda: |- - lv_obj_invalidate(id(touch_cal_page)->obj); - lv_refr_now(nullptr); - ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj); - - action: end_touch_diagnostics - then: - - lambda: 'id(calibration_active) = false;' - - lvgl.page.show: home_page - - action: ui_state - then: - - lambda: |- - const auto &cfg = screen_settings::current; - ESP_LOGI("ui_diag", "settings standby=%d timeout=%d brightness=%d dim=%d night=%d start=%d end=%d night_dim=%d clock=%d clock24=%d home=%d idle_ms=%u use_idle_ms=%u dimmed=%d", - cfg.standby_enabled, cfg.standby_seconds, cfg.brightness, cfg.standby_brightness, - cfg.night_enabled, cfg.night_start, cfg.night_end, cfg.night_brightness, - cfg.show_clock, cfg.clock_24h, cfg.home_on_standby, - (unsigned)(millis() - id(last_touch_ms)), (unsigned)(millis() - id(last_use_ms)), id(display_dimmed)); - ESP_LOGI("ui_diag", "navigation page=%d runtime_detail=%d", id(tile_page), - runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); - lv_area_t nav, grid; - lv_obj_update_layout(id(home_page)->obj); - lv_obj_get_coords(id(page_next), &nav); - lv_obj_get_coords(id(tile_scroll), &grid); - ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", - lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, - grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); - - action: preview_runtime_card - variables: - index: int - then: - - lambda: |- - if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } - if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { - runtime_tiles::active_index = index; - auto &tile = runtime_tiles::model.tiles[index]; - auto domain = tile.domain(); - // Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+) - // open the runtime card, as a tap does. - if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) { - tile.begin(millis(), true); - runtime_tiles::detail(tile); - return; - } - runtime_tiles::show_detail(index); - } - # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away - # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. - # Handy for support ("look at Screen -> Rotation") and for the render tests. - - action: open_settings - variables: - page: int - then: - - lambda: |- - if (page < 0) { id(go_home).execute(); return; } - // Opened for someone to look at: Back to page 1 counts from now, as after a touch. - id(last_use_ms) = millis(); - id(wake_display).execute(); - settings_screen::open((uint8_t) page); - ESP_LOGI("settings", "page %d open from Home Assistant", page); - - # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. - - action: preview_climate_picker - then: - - script.execute: open_climate_mode_picker - - action: preview_busy - variables: - index: int - then: - - lambda: |- - if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { - runtime_tiles::model.tiles[index].begin(millis()); - if (runtime_tiles::refresh) runtime_tiles::refresh(); - } - - action: light_controls_preview - then: - - script.execute: light_controls_preview - - action: ui_self_test - then: - - script.execute: ui_self_test - # Home Assistant automations: esphome._show_alert puts a card over the whole - # screen (README, "Alert from an automation"); dismiss_alert removes it again. - - action: show_alert - variables: - title: string - subtitle: string - icon: string - color: string - button_text: string - timeout: int - flash: bool - then: - - script.execute: - id: alert_show - title: !lambda 'return title;' - subtitle: !lambda 'return subtitle;' - icon: !lambda 'return icon;' - color: !lambda 'return color;' - button_text: !lambda 'return button_text;' - timeout: !lambda 'return timeout;' - flash: !lambda 'return flash;' - - action: dismiss_alert - then: - - script.execute: - id: alert_dismiss - reason: "remote" - # ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of - # base64 chunks in the text inbox; the inbox keeps showing the result. - # Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same - # status as the inbox, plus the revision of the layout on the screen. A call without a call id - # (older apps) gets no answer, so ESPHome has nothing to warn about. - - action: screen_message - supports_response: optional - variables: - message: string - then: - - lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));' - - if: - condition: - lambda: 'return call_id != 0;' - then: - - api.respond: - data: !lambda |- - root["status"] = id(dashboard_inbox).state; - root["rev"] = runtime_tiles::layout_rev; encryption: key: !secret api_encryption_key @@ -645,4240 +44,3 @@ wifi: password: !secret ap_password captive_portal: - -interval: - - interval: 250ms - then: - - lambda: runtime_tiles::tick(); - - interval: 1s - then: - - if: - condition: - lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;' - then: - # The edit window is over: show what Home Assistant reports now. A closed card has - # nothing to show, and a flag left set would redraw every tile each second. - - lambda: |- - if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; } - if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; } - // Runtime tiles: a state that arrived during the window was held back. - id(climate_edit_pending) = false; - if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 && - runtime_tiles::active_index < (int) runtime_tiles::model.count) - runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]); - - if: - condition: - lambda: |- - if (!screen_settings::current.standby_enabled) return false; - if (id(display_dimmed) || id(touch_down) || id(calibration_active) || id(alert_active)) return false; - return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); - then: - - script.execute: dim_display - - script.execute: back_to_page_1_when_due - -time: - - platform: homeassistant - id: homeassistant_time - # Refresh immediately on time sync; do not wait for an HA entity update. - update_interval: 60s - on_time_sync: - then: - - script.execute: apply_screen_settings - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - on_time: - - seconds: 0 - minutes: /1 - then: - - script.execute: apply_screen_settings - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - -globals: - # Alert overlay (HA action show_alert): standby waits while one is up. - - id: alert_active - type: bool - initial_value: 'false' - - id: alert_title_text - type: std::string - # The alert's colour as the automation named it (a swatch's light value, 0 for the normal card). - - id: alert_card_color - type: uint32_t - initial_value: '0' - - id: alert_timeout_seconds - type: int - initial_value: '0' - - id: alert_flash_requested - type: bool - initial_value: 'false' - - id: rendered_frames - type: uint32_t - initial_value: '0' - - id: calibration_active - type: bool - initial_value: 'false' - - id: tile_page - type: int - initial_value: '0' - - id: touch_down - type: bool - initial_value: 'false' - - id: climate_edit_ms - type: uint32_t - initial_value: '0' - - id: climate_edit_pending - type: bool - initial_value: 'false' - - id: active_entity - type: std::string - restore_value: no - - - id: active_control_kind - type: std::string - restore_value: no - - - id: active_value_suffix - type: std::string - restore_value: no - - # Two clocks. last_touch_ms is the standby time: a touch, Wake, an alert ending and Auto standby on restart it. - # last_use_ms is Back to page 1 (firmware 0.2.56+): only a touch, or Home Assistant opening the settings page. - - id: last_touch_ms - type: uint32_t - restore_value: no - initial_value: '0' - - - id: last_use_ms - type: uint32_t - restore_value: no - initial_value: '0' - - # Debounce for do_tile_action: this resistive XPT2046 panel sometimes gives - # multiple separate press/release cycles for one physical touch (noise in - # the resistive layer), causing the same HA action to fire multiple times - # within a few hundred ms (observed: script.ambient_morning 6x in 8s). - - id: last_action_entity - type: std::string - restore_value: no - - - id: last_action_ms - type: uint32_t - restore_value: no - initial_value: '0' - - - id: display_dimmed - type: bool - restore_value: no - initial_value: 'false' - - # Sleep from Home Assistant (0.2.45+): standby that also holds with Auto standby off, - # until a tap, Wake or an alert wakes the screen. - - id: sleep_requested - type: bool - restore_value: no - initial_value: 'false' - - - id: active_label_off - type: std::string - restore_value: no - - - id: active_label_on - type: std::string - restore_value: no - - - id: active_title - type: std::string - restore_value: no - - - id: active_icon_text - type: std::string - restore_value: no - - - id: active_brightness_pct - type: int - restore_value: no - initial_value: '100' - - - id: active_supports_color - type: bool - restore_value: no - initial_value: 'false' - - - id: active_supports_temperature - type: bool - restore_value: no - initial_value: 'false' - - - id: active_color_detail_tab - type: int - restore_value: no - initial_value: '0' - - - id: active_climate_current_x10 - type: int - restore_value: no - initial_value: '0' - - - id: active_climate_target_x10 - type: int - restore_value: no - initial_value: '200' - - - id: active_climate_humidity - type: int - restore_value: no - initial_value: '-1' - - - id: active_climate_min_x10 - type: int - restore_value: no - initial_value: '50' - - - id: active_climate_max_x10 - type: int - restore_value: no - initial_value: '300' - - - id: active_climate_step_x10 - type: int - restore_value: no - initial_value: '5' - - - id: active_climate_mode - type: std::string - restore_value: no - - - id: active_climate_power_on - type: bool - restore_value: no - initial_value: 'false' - - - id: active_climate_fan_modes - type: std::string - restore_value: no - - - id: active_climate_fan_mode - type: std::string - restore_value: no - - - id: active_climate_swing_modes - type: std::string - restore_value: no - - - id: active_climate_swing_mode - type: std::string - restore_value: no - - - id: active_climate_supported_modes - type: std::string - restore_value: no - - # Room temperature (NAN when unknown), what the device does (hvac_action, runtime tiles only), - # and whether Home Assistant reported a setpoint at all (heat_cool ranges do not). - - id: active_climate_current - type: float - restore_value: no - initial_value: 'NAN' - - - id: active_climate_action - type: std::string - restore_value: no - - - id: active_climate_target_known - type: bool - restore_value: no - initial_value: 'false' - -font: - - file: "fonts/Roboto-500.ttf" - id: time_label - size: 18 - bpp: 4 - glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P'] - - - file: "fonts/Roboto-500.ttf" - id: headline - size: 18 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - - file: "fonts/Roboto-500.ttf" - id: watch_value - size: 22 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - # Clock card digits only; the date line reuses the tile title font. - - file: "fonts/Roboto-500.ttf" - id: clock_digits - size: 24 - bpp: 4 - glyphs: [':','-','0','1','2','3','4','5','6','7','8','9'] - - # The climate card's setpoint, large enough to read across the room. - - file: "fonts/Roboto-400.ttf" - id: setpoint_digits - size: 40 - bpp: 4 - glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9'] - - - file: "fonts/Roboto-700.ttf" - id: label - size: 11 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - - file: "fonts/Roboto-400.ttf" - id: sublabel - size: 11 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] - - - file: "fonts/Roboto-400.ttf" - id: sublabel_big - size: 14 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–'] - - # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) - # Source: https://github.com/Templarian/MaterialDesign-Webfont - - file: 'fonts/materialdesignicons-webfont.ttf' - id: materialdesign_icons - size: 28 - bpp: 4 - glyphs: &tile_icons # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py - - "\U000F0335" # lightbulb - - "\U000F1253" # lightbulb-group - - "\U000F0769" # ceiling-light - - "\U000F1793" # chandelier - - "\U000F06B5" # lamp - - "\U000F08DD" # floor-lamp - - "\U000F095F" # desk-lamp - - "\U000F091D" # wall-sconce-flat - - "\U000F179B" # light-recessed - - "\U000F04C9" # spotlight-beam - - "\U000F1051" # led-strip-variant - - "\U000F1054" # outdoor-lamp - - "\U000F02DC" # home - - "\U000F04B9" # sofa - - "\U000F02E3" # bed - - "\U000F0A70" # silverware-fork-knife - - "\U000F0B7C" # chef-hat - - "\U000F09A0" # shower - - "\U000F1818" # bathtub - - "\U000F09AB" # toilet - - "\U000F1239" # desk - - "\U000F06D9" # garage - - "\U000F0E2E" # fireplace - - "\U000F050F" # thermometer - - "\U000F0393" # thermostat - - "\U000F001B" # air-conditioner - - "\U000F0438" # radiator - - "\U000F1A43" # heat-pump - - "\U000F0238" # fire - - "\U000F0717" # snowflake - - "\U000F058E" # water-percent - - "\U000F0210" # fan - - "\U000F1797" # ceiling-fan - - "\U000F0D44" # air-purifier - - "\U000F07E4" # molecule-co2 - - "\U000F0599" # weather-sunny - - "\U000F0595" # weather-partly-cloudy - - "\U000F0590" # weather-cloudy - - "\U000F0597" # weather-rainy - - "\U000F0598" # weather-snowy - - "\U000F059D" # weather-windy - - "\U000F0594" # weather-night - - "\U000F059C" # weather-sunset-up - - "\U000F0387" # music-note - - "\U000F075A" # music - - "\U000F0CB8" # playlist-music - - "\U000F0025" # album - - "\U000F099A" # record-player - - "\U000F04C7" # spotify - - "\U000F0439" # radio - - "\U000F0994" # podcast - - "\U000F04C3" # speaker - - "\U000F0D38" # speaker-multiple - - "\U000F17DB" # soundbar - - "\U000F02CB" # headphones - - "\U000F067D" # piano - - "\U000F0771" # guitar-acoustic - - "\U000F0502" # television - - "\U000F042E" # projector - - "\U000F0FCE" # movie-open - - "\U000F0118" # cast - - "\U000F0297" # gamepad-variant - - "\U000F0EC5" # remote-tv - - "\U000F033E" # lock - - "\U000F0FC6" # lock-open-variant - - "\U000F068A" # shield-home - - "\U000F078F" # alarm-light - - "\U000F009A" # bell - - "\U000F12E6" # doorbell - - "\U000F07AE" # cctv - - "\U000F0D91" # motion-sensor - - "\U000F081B" # door-closed - - "\U000F081C" # door-open - - "\U000F11DB" # window-closed-variant - - "\U000F0392" # smoke-detector - - "\U000F1502" # water-alert - - "\U000F072A" # washing-machine - - "\U000F0917" # tumble-dryer - - "\U000F0AAC" # dishwasher - - "\U000F0290" # fridge - - "\U000F04DE" # stove - - "\U000F0C99" # microwave - - "\U000F0CD3" # toaster-oven - - "\U000F109F" # coffee-maker - - "\U000F05FA" # kettle - - "\U000F070D" # robot-vacuum - - "\U000F11F7" # robot-mower - - "\U000F042B" # printer-3d - - "\U000F06A5" # power-plug - - "\U000F07E7" # power-socket-eu - - "\U000F0322" # laptop - - "\U000F0469" # router-wireless - - "\U000F0241" # flash - - "\U000F0A72" # solar-power - - "\U000F0D9B" # solar-panel - - "\U000F1901" # home-battery - - "\U000F12A3" # battery-high - - "\U000F05F1" # ev-station - - "\U000F0B6C" # car-electric - - "\U000F1A57" # meter-electric - - "\U000F1A59" # meter-gas - - "\U000F058C" # water - - "\U000F0F92" # water-boiler - - "\U000F029A" # gauge - - "\U000F111C" # window-shutter - - "\U000F111E" # window-shutter-open - - "\U000F00AC" # blinds - - "\U000F1011" # blinds-open - - "\U000F1A6C" # roller-shade-closed - - "\U000F1846" # curtains - - "\U000F1847" # curtains-closed - - "\U000F1B88" # awning-outline - - "\U000F024A" # flower - - "\U000F0531" # tree - - "\U000F0E66" # sprout - - "\U000F1510" # grass - - "\U000F105F" # sprinkler - - "\U000F0606" # pool - - "\U000F0E45" # grill - - "\U000F06EE" # mailbox - - "\U000F0A79" # trash-can - - "\U000F044C" # recycle - - "\U000F0A43" # dog - - "\U000F011B" # cat - - "\U000F0004" # account - - "\U000F0849" # account-group - - "\U000F0A89" # account-child - - "\U000F0826" # home-account - - "\U000F04B2" # sleep - - "\U000F070E" # run - - "\U000F00D6" # briefcase - - "\U000F010B" # car - - "\U000F00A3" # bike - - "\U000F001D" # airplane - - "\U000F034E" # map-marker - - "\U000F0425" # power - - "\U000F0521" # toggle-switch - - "\U000F12A8" # gesture-tap-button - - "\U000F040A" # play - - "\U000F0BC2" # script-text - - "\U000F03D8" # palette - - "\U000F0020" # alarm - - "\U000F0150" # clock-outline - - "\U000F051B" # timer-outline - - "\U000F00ED" # calendar - - "\U000F009E" # bell-ring - - "\U000F02D1" # heart - - "\U000F002A" # alert-outline - - "\U000F0493" # cog - - "\U000F00E2" # broom - - "\U000F05D6" # alert-circle-outline - - "\U000F012C" # check - - "\U000F03E4" # pause - - "\U000F04DB" # stop - - "\U000F04AD" # skip-next - - "\U000F04AE" # skip-previous - - "\U000F057E" # volume-high - - "\U000F0581" # volume-off - - "\U000F005D" # arrow-up - - "\U000F0045" # arrow-down - - "\U000F084E" # arrow-expand-horizontal - - "\U000F084C" # arrow-collapse-horizontal - - "\U000F05F8" # home-map-marker - - "\U000F0415" # plus - - "\U000F0374" # minus - - "\U000F0141" # chevron-left - - "\U000F0142" # chevron-right - - "\U000F0156" # close - - "\U000F004D" # arrow-left - - "\U000F1A79" # sun-snowflake-variant - - "\U000F1B17" # thermostat-auto - - "\U000F0591" # weather-fog - - "\U000F0592" # weather-hail - - "\U000F0593" # weather-lightning - - "\U000F067E" # weather-lightning-rainy - - "\U000F0596" # weather-pouring - - "\U000F067F" # weather-snowy-rainy - - "\U000F059B" # weather-sunset-down - - "\U000F01D8" # dots-horizontal - - "\U000F1C91" # arrow-oscillating - - "\U000F0379" # monitor - - "\U000F02FD" # information-outline - - "\U000F0709" # restart - - "\U000F0E4F" # lightbulb-off - - "\U000F0B53" # account-arrow-right - - "\U000F0D43" # air-filter - - "\U000F1099" # air-humidifier - - "\U000F1466" # air-humidifier-off - - "\U000F0028" # alert-circle - - "\U000F0937" # angle-acute - - "\U000F0042" # arrow-bottom-left - - "\U000F0043" # arrow-bottom-right - - "\U000F06C0" # arrow-down-box - - "\U000F0E73" # arrow-left-right - - "\U000F0054" # arrow-right - - "\U000F093C" # arrow-split-vertical - - "\U000F005B" # arrow-top-left - - "\U000F005C" # arrow-top-right - - "\U000F06C3" # arrow-up-box - - "\U000F093D" # audio-video - - "\U000F11B6" # audio-video-off - - "\U000F0079" # battery - - "\U000F007A" # battery-10 - - "\U000F007B" # battery-20 - - "\U000F007C" # battery-30 - - "\U000F007D" # battery-40 - - "\U000F007E" # battery-50 - - "\U000F007F" # battery-60 - - "\U000F0080" # battery-70 - - "\U000F0081" # battery-80 - - "\U000F0082" # battery-90 - - "\U000F0083" # battery-alert - - "\U000F0084" # battery-charging - - "\U000F008E" # battery-outline - - "\U000F0091" # battery-unknown - - "\U000F1A2B" # blinds-horizontal - - "\U000F1A2C" # blinds-horizontal-closed - - "\U000F00AF" # bluetooth - - "\U000F00B1" # bluetooth-connect - - "\U000F00DE" # brightness-5 - - "\U000F00E0" # brightness-7 - - "\U000F1B50" # button-pointer - - "\U000F010C" # car-battery - - "\U000F1019" # car-coolant-level - - "\U000F0114" # cash - - "\U000F0119" # cast-connected - - "\U000F078A" # cast-off - - "\U000F05E0" # check-circle - - "\U000F05E1" # check-circle-outline - - "\U000F0C54" # check-network-outline - - "\U000F0133" # checkbox-marked-circle - - "\U000F0AA5" # circle-slice-8 - - "\U000F0954" # clock - - "\U000F0155" # clock-start - - "\U000F015A" # close-circle-outline - - "\U000F0C5F" # close-network-outline - - "\U000F1382" # compass-rose - - "\U000F01A1" # crop-portrait - - "\U000F1136" # crosshairs-question - - "\U000F1480" # current-ac - - "\U000F01BC" # database - - "\U000F07C5" # ear-hearing - - "\U000F0208" # eye - - "\U000F0D04" # eye-check - - "\U000F081D" # fan-off - - "\U000F0279" # format-list-bulleted - - "\U000F06DA" # garage-open - - "\U000F0299" # gate - - "\U000F116A" # gate-open - - "\U000F06A1" # home-outline - - "\U000F0318" # lan-connect - - "\U000F0319" # lan-disconnect - - "\U000F140B" # lightning-bolt - - "\U000F08EE" # lock-alert - - "\U000F097F" # lock-clock - - "\U000F033F" # lock-open - - "\U000F0BAC" # molecule - - "\U000F12FE" # molecule-co - - "\U000F1435" # motion-sensor-off - - "\U000F038A" # music-note-off - - "\U000F03C3" # octagon - - "\U000F03D3" # package - - "\U000F03D5" # package-up - - "\U000F17C5" # ph - - "\U000F184D" # pipe-valve - - "\U000F06A6" # power-plug-off - - "\U000F0996" # progress-clock - - "\U000F1A23" # projector-off - - "\U000F043C" # radioactive - - "\U000F0445" # ray-vertex - - "\U000F1A6B" # roller-shade - - "\U000F1727" # script-text-play - - "\U000F0483" # security - - "\U000F0498" # shield - - "\U000F06BB" # shield-airplane - - "\U000F099D" # shield-lock - - "\U000F1828" # shield-moon - - "\U000F099E" # shield-off - - "\U000F0499" # shield-outline - - "\U000F095B" # sine-wave - - "\U000F192E" # smoke-detector-alert - - "\U000F180B" # smoke-detector-variant - - "\U000F1930" # smoke-detector-variant-alert - - "\U000F04C4" # speaker-off - - "\U000F1B73" # speaker-pause - - "\U000F1B72" # speaker-play - - "\U000F04C5" # speedometer - - "\U000F0674" # creation - - "\U000F0E0C" # palette-outline - - "\U000F0411" # playlist-play - - "\U000F08FF" # play-speed - - "\U000F00DF" # brightness-6 - - "\U000F1429" # spoon-sugar - - "\U000F0E67" # sprout-outline - - "\U000F0764" # square - - "\U000F0763" # square-outline - - "\U000F1A75" # storage-tank - - "\U000F17FE" # sun-wireless - - "\U000F083B" # television-off - - "\U000F0F89" # television-pause - - "\U000F0ECF" # television-play - - "\U000F0FE6" # texture-box - - "\U000F0510" # thermometer-lines - - "\U000F1A25" # toggle-switch-variant - - "\U000F1A26" # toggle-switch-variant-off - - "\U000F0D3E" # transmission-tower - - "\U000F0566" # vibrate - - "\U000F11B4" # water-boiler-off - - "\U000F058D" # water-off - - "\U000F1855" # water-opacity - - "\U000F05A1" # weight - - "\U000F05A9" # wifi - - "\U000F05AE" # window-closed - - "\U000F05B1" # window-open - - - file: 'fonts/materialdesignicons-webfont.ttf' - id: materialdesign_icons_mini - size: 18 - bpp: 4 - glyphs: *tile_icons - - # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, - # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. - - file: 'fonts/materialdesignicons-webfont.ttf' - id: materialdesign_icons_big - size: 40 - bpp: 4 - glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py - - "\U000F0335" # lightbulb - - "\U000F001B" # air-conditioner - - "\U000F070D" # robot-vacuum - - "\U000F0210" # fan - - "\U000F111C" # window-shutter - - "\U000F04B9" # sofa - - "\U000F029A" # gauge - - "\U000F051B" # timer-outline - - "\U000F0004" # account - - "\U000F07AE" # cctv - - "\U000F0150" # clock-outline - - "\U000F0493" # cog - - "\U000F0054" # arrow-right - - "\U000F0E4F" # lightbulb-off - - "\U000F0425" # power - - "\U000F0599" # weather-sunny - - "\U000F0594" # weather-night - - "\U000F0590" # weather-cloudy - - "\U000F0595" # weather-partly-cloudy - - "\U000F0597" # weather-rainy - - "\U000F0596" # weather-pouring - - "\U000F0598" # weather-snowy - - "\U000F067F" # weather-snowy-rainy - - "\U000F0591" # weather-fog - - "\U000F0592" # weather-hail - - "\U000F0593" # weather-lightning - - "\U000F067E" # weather-lightning-rainy - - "\U000F059D" # weather-windy - - "\U000F05D6" # alert-circle-outline - - "\U000F059C" # weather-sunset-up - - "\U000F059B" # weather-sunset-down - - - file: 'fonts/materialdesignicons-webfont.ttf' - id: watch_icon - size: 12 - bpp: 4 - glyphs: *tile_icons - -# Make memory visible: this board has no PSRAM, so the free heap determines -# how much UI still fits. Also shows up as an entity in Home Assistant. -debug: - # Heap figures every five minutes: enough to spot a leak, few recorder rows. - update_interval: 300s - -sensor: - - platform: debug - free: - name: "Heap Free" - id: heap_free - on_value: - - lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);' - fragmentation: - name: "Heap Fragmentation" - block: - name: "Heap Largest Block" - on_value: - - lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);' - # The lowest free heap since boot: what the busiest moment (boot sync, a swipe) left over. - min_free: - name: "Heap Minimum Free" - on_value: - - lambda: 'ESP_LOGI("health", "min_free=%.0f", x);' - - platform: wifi_signal - name: "Wifi Signal" - update_interval: 600s - - # When the screen booted, as a timestamp: one value per boot instead of a new recorder - # row every 15 s. A new entity on purpose: Home Assistant keeps the old Uptime's unit - # "s" in its registry and would refuse a timestamp under that name. - - platform: uptime - type: timestamp - name: "Last boot" - id: boot_time - - -binary_sensor: - - platform: status - name: "Node Status" - id: system_status - -# ---------------------------------------------------------- -# Text sensors -# -# smartdisplay_action: -# - Main touch event channel exported to Home Assistant -# - On short tap, publishes one of: -# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press -# - The value is reset shortly after publishing -# -# tile*_preset: -# - Mirrors the Home Assistant preset_mode attribute for each tile entity -# - Used by the UI to display fan presets when applicable -# ---------------------------------------------------------- -text_sensor: - - platform: template - name: "Screen firmware" - entity_category: diagnostic - lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' - update_interval: 60s - # ESP Screen Manager matches the screen to its YAML profile and OTA address. - - platform: template - name: "Device name" - entity_category: diagnostic - lambda: 'return {"${DEVICE_NAME}"};' - update_interval: 60s - - platform: wifi_info - ip_address: - # `id` so the settings page on the screen can show the same address (0.2.44+). - id: wifi_ip - name: "IP address" - entity_category: diagnostic - -switch: - - platform: restart - name: "Restart" - # The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen - # owns them: the settings page, these entities and ESP Screens all change them through - # settings_screen::set(), which saves, applies and reports a real change only. The saved values keep - # their preference records. A template switch reads its lambda every loop and publishes a change itself; - # the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the - # standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that - # moment. Nothing runs at boot: every switch follows the saved setting. - - platform: template - id: setting_auto_standby - name: "Auto standby" - icon: "mdi:sleep" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.standby_enabled; - turn_on_action: - - lambda: |- - id(last_touch_ms) = millis(); - settings_screen::set("standby_enabled", 1); - turn_off_action: - - lambda: 'settings_screen::set("standby_enabled", 0);' - - platform: template - id: setting_night_mode - name: "Night mode" - icon: "mdi:weather-night" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.night_enabled; - turn_on_action: - - lambda: 'settings_screen::set("night_enabled", 1);' - turn_off_action: - - lambda: 'settings_screen::set("night_enabled", 0);' - # The dark look for a screen beside a bed (firmware 0.2.54+): black page, graphite cards, soft white text. - - platform: template - id: setting_dark_mode - name: "Dark mode" - icon: "mdi:theme-light-dark" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::dark_mode != 0; - turn_on_action: - - lambda: 'settings_screen::set("dark_mode", 1);' - turn_off_action: - - lambda: 'settings_screen::set("dark_mode", 0);' - - platform: template - id: setting_clock_24h - name: "24-hour clock" - icon: "mdi:clock-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.clock_24h; - turn_on_action: - - lambda: 'settings_screen::set("clock_24h", 1);' - turn_off_action: - - lambda: 'settings_screen::set("clock_24h", 0);' - - platform: template - id: setting_auto_home - name: "Back to page 1" - icon: "mdi:home-import-outline" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::auto_home != 0; - turn_on_action: - - lambda: 'settings_screen::set("auto_home", 1);' - turn_off_action: - - lambda: 'settings_screen::set("auto_home", 0);' - - platform: template - id: setting_home_on_standby - name: "Back to page 1 on standby" - icon: "mdi:home-import-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.home_on_standby; - turn_on_action: - - lambda: 'settings_screen::set("home_on_standby", 1);' - turn_off_action: - - lambda: 'settings_screen::set("home_on_standby", 0);' - - platform: template - id: setting_swipe_pages - name: "Swipe between pages" - icon: "mdi:gesture-swipe-horizontal" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::swipe_pages != 0; - turn_on_action: - - lambda: 'settings_screen::set("swipe_pages", 1);' - turn_off_action: - - lambda: 'settings_screen::set("swipe_pages", 0);' - # Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+). - - platform: template - id: setting_page_buttons - name: "Page buttons" - icon: "mdi:page-next-outline" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::page_buttons != 0; - turn_on_action: - - lambda: 'settings_screen::set("page_buttons", 1);' - turn_off_action: - - lambda: 'settings_screen::set("page_buttons", 0);' - -# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight. -datetime: - - platform: template - type: time - id: setting_night_start - name: "Night starts" - icon: "mdi:weather-sunset-down" - entity_category: config - update_interval: never - lambda: |- - ESPTime time{}; - time.hour = screen_settings::current.night_start / 60; - time.minute = screen_settings::current.night_start % 60; - return time; - set_action: - - lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);' - - platform: template - type: time - id: setting_night_end - name: "Night ends" - icon: "mdi:weather-sunset-up" - entity_category: config - update_interval: never - lambda: |- - ESPTime time{}; - time.hour = screen_settings::current.night_end / 60; - time.minute = screen_settings::current.night_end % 60; - return time; - set_action: - - lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);' - -# 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: - # The theme's font for text without a font of its own. Every label names its font, so this only keeps - # ESPHome from building LVGL's Montserrat 14 (about 14 KB of flash) for nothing (firmware 0.2.65+). - default_font: sublabel_big - on_draw_start: - - lambda: 'id(rendered_frames)++;' - displays: - - my_display - touchscreens: - - touchscreen_id: ts_touch - - # The ESP32-WROOM-32 has no PSRAM; a full framebuffer (320x240x2 - # = 153600 bytes) doesn't fit in DRAM. 25% is the usual value. - # 12% instead of 25%: frees up ~20KB of extra contiguous memory, - # and LVGL scales the strips of an ARGB layer along with the available - # drawing space, so the requested allocations get smaller too. - buffer_size: 12% - - # Direct local feedback on the WHOLE UI, not just the tiles. - # A theme sets the style per widget type, so this covers all - # buttons, tabs, sliders and the thermostat ring in one go. Non-clickable - # widgets never enter the pressed state, so those stay untouched. - theme: - obj: - pressed: - # bg_opa and not opa: opa would also fade the icon and text. Styles that - # make LVGL draw into a buffer of its own (opa_layered, rotation/scale, - # clip_corner, a slider fill rounded less than its track) cost KB's per - # redraw that the CYD's heap can't spare; tests/test_layer_free.py. - bg_opa: 45% - slider: - knob: - pressed: - bg_opa: 65% - arc: - knob: - pressed: - bg_opa: 65% - - # LVGL owns the rotation (display + touch) since ESPHome 2026.4. - rotation: ${LVGL_ROTATION} - - style_definitions: - # The page behind the tiles and the cards: one colour from the theme (theme.h PAGE), no wallpaper and no - # gradient, so a redraw is a plain fill. - - id: style_page - bg_opa: COVER - border_width: 0 - radius: 0 - - - id: style_tile - bg_opa: COVER - radius: 18 - border_width: 1 - border_opa: COVER - pad_left: 8 - pad_right: 8 - pad_top: 8 - pad_bottom: 8 - shadow_width: 0 - shadow_opa: 40% - - - id: style_icon_circle - bg_opa: COVER - radius: 999 - border_width: 0 - - - id: style_room - text_font: headline - text_opa: 100% - - - id: style_time - text_font: time_label - text_opa: 100% - - - id: style_title - text_font: label - text_opa: 100% - - - id: style_value - text_font: sublabel - text_opa: 100% - - - id: style_value_big - text_font: sublabel_big - text_opa: 80% - - # Paints: shared styles without a colour of their own. theme.h fills them (the list in on_boot, theme::paints) - # and fills them again when Dark mode changes, so a widget names a paint where it used to write a colour. - - id: paint_page_soft - - id: paint_page_pressed - - id: paint_card - - id: paint_raised - - id: paint_key - - id: paint_key_pressed - - id: paint_track - - id: paint_ink - - id: paint_ink_soft - - id: paint_muted - - id: paint_subtle - - id: paint_slate - - id: paint_scrim - - id: paint_veil - - id: paint_accent_tint - - id: paint_accent_bright - - id: paint_button_dark - - id: paint_button_dark_pressed - - id: paint_calibration - - # ---------- ALERT OVERLAY (HA action show_alert) ---------- - # LVGL's top layer sits above every page, card and the dim overlay; the card - # is filled by the alert_show script. Fixed positions, no layout engine. - top_layer: - widgets: - - obj: - id: alert_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - styles: paint_scrim - bg_opa: 60% - radius: 0 - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - # Same look when pressed: a tap next to the card must not repaint the screen. - pressed: - bg_opa: 60% - widgets: - - obj: - id: alert_card - align: CENTER - width: ${ALERT_CARD_W} - height: ${ALERT_CARD_H} - styles: paint_raised - bg_opa: COVER - radius: 18 - border_width: 1 - border_opa: COVER - shadow_width: 0 - pad_all: 0 - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: alert_icon - x: ${ALERT_ICON_X} - y: ${ALERT_ICON_Y} - text: "\U000F002A" - text_font: materialdesign_icons - clickable: false - - label: - id: alert_title - x: ${ALERT_TEXT_X} - y: ${ALERT_TITLE_Y} - width: ${ALERT_TEXT_W} - height: ${ALERT_TITLE_H} - long_mode: DOT - text: "Notification" - text_font: headline - clickable: false - - label: - id: alert_subtitle - x: ${ALERT_TEXT_X} - y: ${ALERT_SUBTITLE_Y} - width: ${ALERT_TEXT_W} - height: ${ALERT_SUBTITLE_H} - long_mode: WRAP - text: "" - text_font: sublabel_big - styles: paint_ink_soft - clickable: false - - obj: - id: alert_ok - align: BOTTOM_RIGHT - x: -${ALERT_BUTTON_INSET} - y: -${ALERT_BUTTON_INSET} - width: ${ALERT_BUTTON_W} - height: ${ALERT_BUTTON_H} - radius: 999 - styles: paint_button_dark - bg_opa: COVER - border_width: 0 - shadow_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_button_dark_pressed - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept(millis(), 13);' - then: - - script.execute: - id: alert_dismiss - reason: "ok" - widgets: - - label: - id: alert_ok_label - align: CENTER - text: "OK" - text_font: headline - clickable: false - - pages: - - id: home_page - styles: style_page - pad_all: 0 - scrollable: false - scrollbar_mode: 'OFF' - widgets: - # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. - - spinner: - id: busy_spinner_seed - hidden: true - width: 2 - height: 2 - spin_time: 1s - arc_length: 60deg - # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from - # (firmware 0.2.70+). - - roller: - id: roller_seed - hidden: true - width: 2 - height: 2 - options: ["-"] - - label: - id: lbl_room - x: 11 - y: ${HEADER_Y} - text: "${ROOM_NAME}" - styles: style_room - - # Enables LVGL's line widget for the runtime clock hands and sparklines. - - line: - id: runtime_line_enable - hidden: true - points: - - 0, 0 - - 1, 1 - - - label: - id: lbl_time - align: TOP_RIGHT - x: -11 - y: ${HEADER_Y} - text: "" - styles: style_time - - # Six tiles on page 1, four on page 2. No dragging on resistive glass. - - obj: - id: tile_scroll - x: 0 - y: ${SCROLL_Y} - width: ${DISPLAY_W} - height: ${SCROLL_H} - bg_opa: TRANSP - # A tap between two tiles presses this area: without its own pressed style the - # theme's 45 % lights it up as a pale panel behind the tiles. - pressed: - bg_opa: TRANSP - border_width: 0 - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - scrollable: false - scrollbar_mode: 'OFF' - widgets: - # ---------- TILE 1 ---------- - - obj: - id: tile1 - x: ${GRID_COL1_X} - y: ${GRID_ROW1_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile1_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile1_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F001B" - clickable: false - - label: - id: t1_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Lamp" - styles: style_title - clickable: false - - label: - id: t1_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 2 ---------- - - obj: - id: tile2 - x: ${GRID_COL2_X} - y: ${GRID_ROW1_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile2_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile2_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F0387" - clickable: false - - label: - id: t2_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Plug" - styles: style_title - clickable: false - - label: - id: t2_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 3 ---------- - - obj: - id: tile3 - x: ${GRID_COL1_X} - y: ${GRID_ROW2_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile3_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile3_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F02E3" - clickable: false - - label: - id: t3_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Evening" - styles: style_title - clickable: false - - label: - id: t3_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 4 ---------- - - obj: - id: tile4 - x: ${GRID_COL2_X} - y: ${GRID_ROW2_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile4_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile4_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F059C" - clickable: false - - label: - id: t4_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "All off" - styles: style_title - clickable: false - - label: - id: t4_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 5 ---------- - - obj: - id: tile5 - x: ${GRID_COL1_X} - y: ${GRID_ROW3_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile5_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile5_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F0425" - clickable: false - - label: - id: t5_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "AC" - styles: style_title - clickable: false - - label: - id: t5_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 6 ---------- - - obj: - id: tile6 - x: ${GRID_COL2_X} - y: ${GRID_ROW3_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile6_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile6_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F070D" - clickable: false - - label: - id: t6_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Vacuum" - styles: style_title - clickable: false - - label: - id: t6_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 7 — Table lamp ---------- - # Brightness-only lamp: deliberately no percentage/cover/climate - # sensors were created for this tile, those would only cost - # HA subscriptions and heap. The overlay therefore gets - # NAN / "" for the fields a light never uses. - - obj: - id: tile7 - hidden: true - x: ${GRID_COL1_X} - y: ${GRID_ROW4_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile7_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile7_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F06B5" - clickable: false - - label: - id: t7_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 7" - styles: style_title - clickable: false - - label: - id: t7_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 8 — Mailbox reset ---------- - # input_select with options Full/Empty. The tile lights up as soon as - # the status is TILE8_ACTIVE_STATE ("Full"); a short tap sends - # input_select.select_option with option: "Empty". - # No slider mode, so this tile only needs ha_state_tile8 — - # no other HA subscription at all. - - obj: - id: tile8 - hidden: true - x: ${GRID_COL2_X} - y: ${GRID_ROW4_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile8_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile8_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F06EE" - clickable: false - - label: - id: t8_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 8" - styles: style_title - clickable: false - - label: - id: t8_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 9 — Living room (moved from tile 2) ---------- - - obj: - id: tile9 - hidden: true - x: ${GRID_COL1_X} - y: ${GRID_ROW5_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile9_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile9_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F04B9" - clickable: false - - label: - id: t9_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 9" - styles: style_title - clickable: false - - label: - id: t9_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 10 — Alarm on/off ---------- - - obj: - id: tile10 - hidden: true - x: ${GRID_COL2_X} - y: ${GRID_ROW5_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile10_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile10_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F002A" - clickable: false - - label: - id: t10_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 10" - styles: style_title - clickable: false - - label: - id: t10_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- VALUE SLIDER OVERLAY ---------- - # ---------- PAGE NAVIGATION: the band under the tiles ---------- - # Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the - # page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge. - - obj: - id: page_prev - align: BOTTOM_LEFT - x: 0 - y: 0 - width: 160 - height: 36 - pad_all: 0 - radius: 0 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pressed: - styles: paint_page_pressed - bg_opa: COVER - # on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing - # (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe. - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);' - then: - - lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);' - - script.execute: show_tile_page - widgets: - - label: - align: LEFT_MID - x: 14 - text: "\U000F0141" # chevron-left - text_font: materialdesign_icons_mini - styles: paint_ink - clickable: false - - obj: - id: page_number - align: BOTTOM_MID - y: 0 - width: 104 - height: 36 - pad_all: 0 - bg_opa: TRANSP - border_width: 0 - clickable: false - scrollable: false - - obj: - id: page_next - align: BOTTOM_RIGHT - x: 0 - y: 0 - width: 160 - height: 36 - pad_all: 0 - radius: 0 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pressed: - styles: paint_page_pressed - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);' - then: - - lambda: 'id(tile_page) += 1;' - - script.execute: show_tile_page - widgets: - - label: - align: RIGHT_MID - x: -14 - text: "\U000F0142" # chevron-right - text_font: materialdesign_icons_mini - styles: paint_ink - clickable: false - - - obj: - id: brightness_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - bg_opa: TRANSP - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: brightness_overlay_bg - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: style_page - scrollable: false - clickable: true - on_click: - - script.execute: close_cards - - - obj: - id: brightness_overlay_dim - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: paint_veil - bg_opa: 18% - border_width: 0 - clickable: false - scrollable: false - - - obj: - id: overlay_back_button - align: TOP_LEFT - x: 10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: overlay_title - align: TOP_MID - y: 19 - text: "Light" - styles: style_room - - - label: - id: overlay_value - align: TOP_MID - y: 42 - text: "100 %" - styles: style_value_big - - - slider: - id: overlay_slider - align: CENTER - y: 24 - width: ${OVERLAY_SLIDER_W} - height: ${OVERLAY_SLIDER_H} - min_value: 0 - max_value: 100 - value: 100 - adv_hittest: false - - styles: paint_accent_tint - bg_opa: 100% - radius: 28 - # The only widget with BOTH a radius AND a child (overlay_icon). - # LVGL then clips children to the rounding, and that's only - # possible via a contiguous 96x160 = 46KB ARGB layer. - # If that allocation fails, LVGL keeps trying until the - # task watchdog restarts the board. The rounding stays, the - # clipping goes. - clip_corner: false - border_width: 0 - - indicator: - styles: paint_accent_bright - bg_opa: 100% - radius: 28 - - knob: - bg_opa: 0% - border_width: 0 - shadow_width: 0 - pad_all: 10 - - on_value: - - script.execute: - id: slider_preview - value: !lambda "return x;" - - on_release: - - script.execute: - id: slider_commit - value: !lambda "return lv_slider_get_value(id(overlay_slider));" - - - # The icon used to be a CHILD in the slider. A widget with a radius - # and children has to clip those children to the rounding, and - # LVGL can only do that via an ARGB layer of tens of KB's. Without - # PSRAM that sometimes fails, after which LVGL keeps retrying until - # the watchdog restarts the board. As a sibling on top, no layer - # is needed and it looks identical. - - label: - id: overlay_icon - align: CENTER - y: ${OVERLAY_ICON_Y} - text_font: materialdesign_icons - styles: paint_ink - text: "\U000F001B" - clickable: false - - - obj: - id: overlay_color_button - align: BOTTOM_RIGHT - x: -13 - y: -13 - width: 44 - height: 44 - radius: 999 - hidden: true - clickable: true - bg_opa: TRANSP - border_width: 0 - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - scrollable: false - scrollbar_mode: 'OFF' - on_click: - - script.execute: open_color_detail - widgets: - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 0 - end_angle: 45 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0xFF3B30 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 45 - end_angle: 90 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0xFF9500 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 90 - end_angle: 135 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0xFFD60A - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 135 - end_angle: 180 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0x30D158 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 180 - end_angle: 225 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0x32D6FF - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 225 - end_angle: 270 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0x0A84FF - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 270 - end_angle: 315 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0x5856D6 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 315 - end_angle: 360 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0xBF5AF2 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - # ---------- LIGHT COLOR DETAIL OVERLAY ---------- - - obj: - id: color_detail_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: paint_page_soft - bg_opa: COVER - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: color_close_button - align: TOP_LEFT - x: 10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette, - # preset, speed), opens its effects page. Hidden for every other light. - - obj: - id: color_effects_button - align: TOP_RIGHT - x: -10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - hidden: true - clickable: true - scrollable: false - on_click: - - lambda: 'effects_page::open(id(active_entity));' - widgets: - - label: - align: CENTER - text: "\U000F0674" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: color_detail_title - width: 210 - long_mode: DOT - text_align: CENTER - align: TOP_MID - y: 21 - text: "Light Color" - styles: style_room - - - # ---------- CLIMATE DETAIL OVERLAY ---------- - # Made for the small resistive panel: nothing to drag. A setpoint card with big -/+ keys - # (the number changes at once, one call goes out once the taps stop) and one row of mode - # keys like Home Assistant's climate card; fan and swing stay in the picker behind "more". - # A tap redraws only a label or a key, never a dial, so the SPI panel keeps up. - - obj: - id: climate_detail_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: style_page - bg_opa: COVER - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: climate_close_button - align: TOP_LEFT - x: 10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - obj: - id: climate_power_button - align: TOP_RIGHT - x: -10 - y: 8 - width: 40 - height: 40 - radius: 999 - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: climate_power_toggle - widgets: - - label: - id: climate_power_icon - align: CENTER - text: "\U000F0425" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: climate_detail_title - align: TOP_MID - y: 18 - width: 210 - long_mode: DOT - text_align: CENTER - text: "Climate" - styles: style_room - - # What the device does (or its mode), the room temperature and humidity. - - label: - id: climate_current_label - align: TOP_MID - y: 54 - width: 94% - long_mode: DOT - text_align: CENTER - text: "--" - text_font: sublabel_big - styles: paint_muted - clickable: false - - - obj: - id: climate_setpoint_card - align: TOP_MID - y: 80 - width: 94% - height: 92 - radius: 16 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: false - scrollable: false - widgets: - # Every clean tap counts (a quick triple tap is three steps); holding keeps stepping. - - obj: - id: climate_minus_button - align: LEFT_MID - x: 10 - width: 72 - height: 72 - radius: 36 - styles: paint_track - bg_opa: COVER - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key_pressed - disabled: - opa: 40% - on_short_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 60);' - then: - - script.execute: { id: climate_adjust_target, direction: -1, held: false } - on_long_press_repeat: - - script.execute: { id: climate_adjust_target, direction: -1, held: true } - widgets: - - label: - id: climate_minus_icon - align: CENTER - text: "\U000F0374" - text_font: materialdesign_icons - clickable: false - - obj: - id: climate_plus_button - align: RIGHT_MID - x: -10 - width: 72 - height: 72 - radius: 36 - styles: paint_track - bg_opa: COVER - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key_pressed - disabled: - opa: 40% - on_short_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 61);' - then: - - script.execute: { id: climate_adjust_target, direction: 1, held: false } - on_long_press_repeat: - - script.execute: { id: climate_adjust_target, direction: 1, held: true } - widgets: - - label: - id: climate_plus_icon - align: CENTER - text: "\U000F0415" - text_font: materialdesign_icons - clickable: false - - label: - id: climate_target_label - align: CENTER - y: -8 - text: "--" - text_font: setpoint_digits - clickable: false - - label: - id: climate_set_label - align: CENTER - y: 26 - text: "Target" - text_font: sublabel - styles: paint_subtle - clickable: false - - # One key per mode the device supports (off is the power key); the one in use is - # filled in Home Assistant's colour for it. "More" opens the picker with fan and swing. - - obj: - id: climate_mode_keys - align: TOP_MID - y: 184 - width: 94% - height: 46 - bg_opa: TRANSP - border_width: 0 - pad_all: 0 - clickable: false - scrollable: false - layout: - type: FLEX - flex_flow: ROW - flex_align_main: CENTER - flex_align_cross: CENTER - pad_column: 6 - widgets: - - obj: - id: cmkey_auto - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "auto" } - widgets: - - label: - align: CENTER - text: "\U000F1B17" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_heat_cool - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "heat_cool" } - widgets: - - label: - align: CENTER - text: "\U000F1A79" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_heat - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "heat" } - widgets: - - label: - align: CENTER - text: "\U000F0238" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_cool - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "cool" } - widgets: - - label: - align: CENTER - text: "\U000F0717" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_dry - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "dry" } - widgets: - - label: - align: CENTER - text: "\U000F058E" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_fan_only - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "fan_only" } - widgets: - - label: - align: CENTER - text: "\U000F0210" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_more - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: open_climate_mode_picker - widgets: - - label: - align: CENTER - text: "\U000F01D8" - text_font: materialdesign_icons - styles: paint_slate - clickable: false - # ---------- CLIMATE MODE PICKER ---------- - # Replaces cycling through modes. Cycling sends a set_hvac_mode on every - # tap, so to get from 'cool' to 'dry' a heat pump actually passes through - # 'heat' and 'fan_only' along the way. Here you pick directly, and exactly - # one command is sent. - - obj: - id: climate_mode_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - styles: style_page - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - align: TOP_MID - y: 21 - text: "Mode" - text_font: sublabel_big - clickable: false - - # Flex ensures unsupported modes don't leave a gap: - # LVGL skips hidden children when arranging. - - obj: - id: climate_mode_list - align: TOP_MID - y: 44 - width: 98% - height: 178 - bg_opa: TRANSP - border_width: 0 - scrollable: false - clickable: false - pad_all: 3 - layout: - type: FLEX - pad_row: 3 - pad_column: 4 - flex_flow: ROW_WRAP - flex_align_main: CENTER - flex_align_cross: CENTER - flex_align_track: CENTER - widgets: - - obj: - id: cmode_off - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "off" } - widgets: - - label: - align: CENTER - text: "Off" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_heat - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "heat" } - widgets: - - label: - align: CENTER - text: "Heat" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_cool - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "cool" } - widgets: - - label: - align: CENTER - text: "Cool" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_heat_cool - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "heat_cool" } - widgets: - - label: - align: CENTER - text: "Heat/Cool" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_auto - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "auto" } - widgets: - - label: - align: CENTER - text: "Auto" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_dry - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "dry" } - widgets: - - label: - align: CENTER - text: "Dry" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_fan_only - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "fan_only" } - widgets: - - label: - align: CENTER - text: "Fan only" - text_font: sublabel_big - clickable: false - - - label: - id: cfan_title - width: 100% - height: 11 - text: "Fan" - text_font: sublabel - styles: paint_subtle - text_align: CENTER - clickable: false - - obj: - id: cfan_0 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 0 } - widgets: - - label: - id: cfan_0_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cfan_1 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 1 } - widgets: - - label: - id: cfan_1_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cfan_2 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 2 } - widgets: - - label: - id: cfan_2_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cfan_3 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 3 } - widgets: - - label: - id: cfan_3_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - label: - id: cswing_title - width: 100% - height: 11 - text: "Swing" - text_font: sublabel - styles: paint_subtle - text_align: CENTER - clickable: false - - obj: - id: cswing_0 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 0 } - widgets: - - label: - id: cswing_0_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cswing_1 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 1 } - widgets: - - label: - id: cswing_1_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cswing_2 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 2 } - widgets: - - label: - id: cswing_2_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cswing_3 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 3 } - widgets: - - label: - id: cswing_3_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: climate_mode_overlay_x - align: TOP_LEFT - x: 10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - lvgl.widget.hide: climate_mode_overlay - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - # ---------- VACUUM CONTROL ---------- - # A vacuum is not a switch: start, pause, stop and return to - # the dock are separate commands, and suction power is a separate axis. - - - # ---------- DIM / WAKE OVERLAY ---------- - - obj: - id: dim_wake_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - bg_opa: TRANSP - # The tap that wakes a dimmed screen must not light up a pale sheet over it. - pressed: - bg_opa: TRANSP - border_width: 0 - scrollable: false - scrollbar_mode: 'OFF' - clickable: true - on_click: - - script.execute: wake_display - # A page that is one full tile switching something on or off switches with this push as well - # (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes. - - lambda: 'runtime_tiles::wake_tap();' - - - id: touch_cal_page - styles: paint_calibration - bg_opa: COVER - pad_all: 0 - border_width: 0 - scrollable: false - widgets: - - label: - align: TOP_MID - y: 65 - text: "Follow the calibration wizard" - text_font: sublabel - - obj: - x: 10 - y: 10 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - - obj: - x: 289 - y: 10 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - - obj: - x: 289 - y: 209 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - - obj: - x: 10 - y: 209 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - - obj: - x: 150 - y: 110 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - -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= 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" - - - id: show_tile_page - then: - - lambda: |- - if(runtime_tiles::enabled){ - runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number)); - return; - } - lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), - id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; - const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; - const int count = runtime_tiles::model.count; - const bool paginated = count > 6; - if (!paginated) id(tile_page) = 0; - for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { - if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN); - } - for (int i = 0; i < 10; ++i) { - bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6); - if (visible) { - lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]); - lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); - } - } - settings_screen::page_dots(id(page_number), id(tile_page), 2, false); - if (id(tile_page) == 0) { - lv_obj_add_state(id(page_prev), LV_STATE_DISABLED); - lv_obj_clear_state(id(page_next), LV_STATE_DISABLED); - } else { - lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED); - lv_obj_add_state(id(page_next), LV_STATE_DISABLED); - } - - # Runtime changes do not count as touches and do not reset the idle timer. - - id: apply_screen_settings - mode: restart - then: - - lambda: |- - const auto &settings = screen_settings::current; - // Dark mode (firmware 0.2.54+): a look the screen already has costs nothing. - theme::set_dark(settings_screen::dark_mode != 0); - // Auto standby off wakes the screen, but not out of a Sleep pressed in Home Assistant. - if (id(display_dimmed) && !settings.standby_enabled && !id(sleep_requested)) { - id(display_dimmed) = false; - } - auto now = id(homeassistant_time).now(); - const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; - const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; - id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform(); - if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); } - else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); - // The setting numbers and times only publish a changed value (HA keeps identical states anyway); the - // switches publish their own changes. An open settings page shows a change from Home Assistant too. - // A number without a state publishes too: a night brightness of 0 equals the number's initial state and - // stayed "unknown" in Home Assistant before firmware 0.2.50. - for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness), - std::make_pair(id(setting_standby_brightness), settings.standby_brightness), - std::make_pair(id(setting_night_brightness), settings.night_brightness), - std::make_pair(id(setting_standby_seconds), settings.standby_seconds), - std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) { - if (!entity->has_state() || entity->state != value) entity->publish_state(value); - } - for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) { - if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update(); - } - // The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+). - if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed(); - settings_screen::refresh(); - if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); - - # Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light, - # colour and climate overlays, and active_entity, which the swipe guard reads. - - id: close_cards - then: - - lambda: 'if (runtime_tiles::dismiss) runtime_tiles::dismiss();' - - # Everything back to the overview: the settings page, every card and the first page of tiles. - # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. - - id: go_home - then: - - lambda: 'settings_screen::close();' - - script.execute: close_cards - - lambda: 'id(tile_page) = 0;' - - script.execute: show_tile_page - - # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps - # showing what someone looked at minutes ago; after this long the screen is a screen again. - # Independent of standby, which may be off or much later; paused while the screen is in standby. - # The time runs from the last touch (last_use_ms, firmware 0.2.56+): Wake, an alert or Auto standby - # light the screen and restart the standby time, but nobody used a page. Checked every second and by Wake. - - id: back_to_page_1_when_due - then: - - if: - condition: - lambda: |- - if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; - if (id(display_dimmed)) return false; - bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 || - (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); - return away && (millis() - id(last_use_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; - then: - - script.execute: go_home - - # ---------- ALERT (HA action show_alert) ---------- - # The screen wakes, keeps its normal brightness and shows the card until OK or the - # timeout (0: until OK); the standby interval waits on alert_active. A new alert - # replaces the current one; every ending reports itself as esphome.screen_alert. - - id: alert_show - mode: restart - parameters: - title: string - subtitle: string - icon: string - color: string - button_text: string - timeout: int - flash: bool - then: - - lambda: |- - if (id(alert_active)) id(alert_report).execute("replaced"); - // An alert is the one thing that outranks the settings page, so that closes first. - settings_screen::close(); - const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); - id(alert_active) = true; - id(alert_title_text) = alert.title; - id(alert_timeout_seconds) = alert.timeout_seconds; - id(alert_flash_requested) = alert.flash; - lv_label_set_text(id(alert_icon), alert.icon.c_str()); - lv_label_set_text(id(alert_title), alert.title.c_str()); - lv_label_set_text(id(alert_subtitle), alert.subtitle.c_str()); - lv_label_set_text(id(alert_ok_label), alert.button.c_str()); - id(alert_card_color) = alert.color; - lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(alert.color)), LV_PART_MAIN); - ESP_LOGI("alert", "show \"%s\" timeout=%d flash=%d", alert.title.c_str(), alert.timeout_seconds, (int) alert.flash); - - script.execute: wake_display - - lvgl.widget.show: alert_overlay - - script.stop: alert_timeout - - if: - condition: - lambda: 'return id(alert_flash_requested);' - then: - - script.execute: alert_flash - - if: - condition: - lambda: 'return id(alert_timeout_seconds) > 0;' - then: - - script.execute: - id: alert_timeout - seconds: !lambda 'return id(alert_timeout_seconds);' - - - 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: alert_timeout - mode: restart - parameters: - seconds: int - then: - - delay: !lambda 'return static_cast(seconds) * 1000UL;' - - script.execute: - id: alert_dismiss - reason: "timeout" - - # Home Assistant adds device_id; action is ok, timeout, replaced or remote. - - id: alert_report - parameters: - action: string - then: - - homeassistant.event: - event: esphome.screen_alert - data: - action: !lambda 'return action;' - title: !lambda 'return id(alert_title_text);' - screen: !lambda 'return App.get_name();' - - - 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 - - # Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed. - # The settings page and every card close; with "also on standby" the screen goes back to page 1 too. - - id: dim_display - mode: restart - then: - - lambda: |- - id(display_dimmed) = true; - id(apply_screen_settings).execute(); - - if: - condition: - lambda: 'return screen_settings::current.home_on_standby;' - then: - - script.execute: go_home - else: - - lambda: 'settings_screen::close();' - - script.execute: close_cards - - # A -/+ tap on the climate card: the number follows at once, the call waits for the last tap. - # Holding a key steps three times a second. - - id: climate_adjust_target - parameters: - direction: int - held: bool - then: - - lambda: |- - if (held && millis() - id(climate_edit_ms) < 300) return; - int step_x10 = std::max(5, id(active_climate_step_x10)); - int next = cyd::quantize_temperature(id(active_climate_target_x10) + direction * step_x10, - id(active_climate_min_x10), id(active_climate_max_x10), step_x10); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - if (next == id(active_climate_target_x10) && id(active_climate_target_known)) return; - id(active_climate_target_x10) = next; - id(active_climate_target_known) = true; - id(climate_card_refresh).execute(); - id(climate_target_send).execute(id(active_entity), next / 10.0f); - - # One set_temperature for a series of taps: every tap restarts the wait with the newest value. - # Entity and value travel along, so leaving the card right after a tap still sends it. - - id: climate_target_send - mode: restart - parameters: - entity: string - value: float - then: - - delay: 700ms - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !entity.empty();' - then: - - homeassistant.action: - action: climate.set_temperature - data: - entity_id: !lambda "return entity;" - temperature: !lambda "return value;" - - # Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys. - - 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(); - - # Opens the mode picker. Hides modes the entity doesn't support and marks - # the current one, so you see where you stand before you pick something. - # Opens the vacuum controls and remembers which entity it concerns. - - # A standalone command: start, pause, stop or return_to_base. - - # Suction power is a separate axis, apart from start/stop. - - - id: open_climate_mode_picker - mode: restart - 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 supported = lower(id(active_climate_supported_modes)); - std::string current = lower(id(active_climate_mode)); - - auto has = [&](const char* m) { - std::string mode(m); - if (supported.empty() || supported == "unknown" || supported == "unavailable") return false; - return supported.find("'" + mode + "'") != std::string::npos || - supported.find("\"" + mode + "\"") != std::string::npos; - }; - auto apply = [&](lv_obj_t* btn, const char* m) { - if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); - bool selected = (current == std::string(m)); - lv_obj_set_style_bg_color(btn, theme::color(selected ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); - lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), theme::color(selected ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); - }; - apply(id(cmode_off), "off"); - apply(id(cmode_heat), "heat"); - apply(id(cmode_cool), "cool"); - apply(id(cmode_heat_cool), "heat_cool"); - apply(id(cmode_auto), "auto"); - apply(id(cmode_dry), "dry"); - apply(id(cmode_fan_only), "fan_only"); - // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. - auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; - auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { - unsigned shown = 0; - for (unsigned i = 0; i < 4; ++i) { - std::string raw = cyd::list_item(modes, i); - if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } - lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; - lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); - lv_obj_set_style_bg_color(chips[i], theme::color(raw == current_mode ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); - lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), theme::color(raw == current_mode ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); - } - if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); - }; - lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; - lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; - fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); - fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - - lvgl.widget.show: climate_mode_overlay - - # Sets exactly one mode. No intermediate steps, so no unwanted - # commands to the heat pump along the way. - # Fan and swing modes from the picker: one call with the entity's own mode name. - - id: set_climate_fan_mode - mode: restart - parameters: - index: int - then: - - lambda: |- - id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_fan_mode).empty();' - then: - - homeassistant.action: - action: climate.set_fan_mode - data: - entity_id: !lambda "return id(active_entity);" - fan_mode: !lambda "return id(active_climate_fan_mode);" - - id: set_climate_swing_mode - mode: restart - parameters: - index: int - then: - - lambda: |- - id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_swing_mode).empty();' - then: - - homeassistant.action: - action: climate.set_swing_mode - data: - entity_id: !lambda "return id(active_entity);" - swing_mode: !lambda "return id(active_climate_swing_mode);" - - # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. - - id: climate_power_toggle - mode: restart - then: - - lambda: |- - std::string mode = id(active_climate_mode); - for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); - id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - if (!id(active_climate_power_on)) id(active_climate_mode) = "off"; - id(climate_card_refresh).execute(); - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_climate_power_on);' - then: - - homeassistant.action: - action: climate.turn_on - data: - entity_id: !lambda "return id(active_entity);" - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_power_on);' - then: - - homeassistant.action: - action: climate.turn_off - data: - entity_id: !lambda "return id(active_entity);" - - # The power key lights up while the device runs in any mode. - - id: climate_power_refresh - mode: restart - then: - - lambda: |- - std::string mode = id(active_climate_mode); - for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); - bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); - if (id(climate_edit_pending)) on = id(active_climate_power_on); - lv_obj_set_style_bg_color(id(climate_power_button), theme::color(on ? theme::ACCENT_TINT : theme::KEY), LV_PART_MAIN); - lv_obj_set_style_text_color(id(climate_power_icon), theme::color(on ? theme::ACCENT_ICON : theme::ICON_OFF), LV_PART_MAIN); - - - id: set_climate_mode - mode: restart - parameters: - mode: string - then: - - lambda: |- - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - id(active_climate_mode) = mode; - // The action line would still say what the old mode did. - id(active_climate_action).clear(); - id(active_climate_power_on) = mode != "off"; - id(climate_card_refresh).execute(); - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh();' - then: - - homeassistant.action: - action: climate.set_hvac_mode - data: - entity_id: !lambda "return id(active_entity);" - hvac_mode: !lambda "return id(active_climate_mode);" - - # Isolated 40-second preview; every light commit is suppressed in the helper. - - id: light_controls_preview - mode: single - then: - - script.execute: wake_display - - lambda: |- - light_controls::demo = true; - light_controls::fallback.minimum = 2000; - light_controls::fallback.maximum = 6500; - light_controls::fallback.kelvin = 3000; - light_controls::fallback.hue = 200; - light_controls::open("diagnostic", true, true, 75); - lv_label_set_text(id(color_detail_title), "Example lamp"); - - lvgl.widget.hide: color_close_button - - lvgl.widget.show: color_detail_overlay - - delay: 40s - - lvgl.widget.hide: color_detail_overlay - - lvgl.widget.show: color_close_button - - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' - - - id: open_color_detail - mode: restart - then: - - lambda: |- - lv_label_set_text(id(color_detail_title), id(active_title).c_str()); - light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); - if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); - - lvgl.widget.hide: brightness_overlay - - lvgl.widget.show: color_detail_overlay - - - id: color_brightness_commit - parameters: - value: int - then: - - lambda: 'id(active_brightness_pct) = value;' - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_entity).rfind("light.", 0) == 0;' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - brightness_pct: !lambda "return value;" - - - id: set_light_hue - parameters: - value: int - then: - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - data_template: - hs_color: "{{ [hue | int, 100] }}" - variables: - hue: !lambda "return light_controls::clamp(value, 0, 360);" - - - id: set_light_color_temperature - parameters: - kelvin: int - then: - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" - - - id: slider_preview - parameters: - value: int - then: - - lambda: |- - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str()); - lv_label_set_text(id(overlay_value), buf); - int pct = value; - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - std::string value_text; - - if (id(active_control_kind) == "cover_position") { - std::string closed_label = id(active_label_off).c_str(); - std::string open_label = id(active_label_on).c_str(); - - if (pct <= 0) { - value_text = closed_label; - } else if (pct >= 100) { - value_text = open_label; - } else { - char buf[40]; - snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct); - value_text = buf; - } - } else { - if (pct <= 0) { - value_text = id(active_label_off).c_str(); - } else { - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); - value_text = buf; - } - } - - lv_label_set_text(id(overlay_value), value_text.c_str()); - - - - id: slider_commit - parameters: - value: int - then: - - if: - condition: - lambda: |- - return id(active_control_kind) == "light_brightness"; - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - brightness_pct: !lambda "return value;" - else: - - if: - condition: - lambda: |- - return id(active_control_kind) == "fan_percentage"; - then: - - if: - condition: - lambda: "return value <= 0;" - then: - - homeassistant.action: - action: fan.turn_off - data: - entity_id: !lambda "return id(active_entity);" - else: - - homeassistant.action: - action: fan.turn_on - data: - entity_id: !lambda "return id(active_entity);" - - delay: 150ms - - homeassistant.action: - action: fan.set_percentage - data: - entity_id: !lambda "return id(active_entity);" - percentage: !lambda "return value;" - else: - - if: - condition: - lambda: |- - return id(active_control_kind) == "cover_position"; - then: - - homeassistant.action: - action: cover.set_cover_position - data: - entity_id: !lambda "return id(active_entity);" - position: !lambda "return value;" - - # ---------------------------------------------------------- - # do_tile_action - # - # Centralized handler for executing Home Assistant actions - # triggered by tile taps. - # - # Parameters - # - tile_type: light | fan | switch | scene | script | cover - # - tap_action: auto | toggle | activate | fan_toggle_preset - # - service: optional Home Assistant service override - # - entity_id: target entity - # - param_key / param_val: optional parameters for custom services - # - was_on: previous entity state (pass false for longpress actions) - # - # Behavior - # - # toggle - # -> toggles the entity - # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on) - # - # activate - # -> activates entity - # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover - # - # auto - # -> resolves automatically based on tile_type: - # light/switch/fan/cover -> toggle - # scene/script -> activate - # - # fan_toggle_preset - # -> if fan was ON: fan.turn_off - # -> if fan was OFF: - # fan.turn_on - # wait 150ms - # fan.set_preset_mode(param_val) - # - # Custom service - # If "service" is set, it will be called directly. - # param_key / param_val may be used for parameters - # like preset_mode, brightness_pct, percentage, etc. - # ---------------------------------------------------------- - - - id: open_value_overlay - parameters: - title: string - entity: string - tile_type: string - longpress_mode: string - was_on: bool - off_value: int - brightness_value: float - percentage_value: float - position_value: float - icon_text: string - label_off: string - label_on: string - supported_color_modes: string - climate_current_temperature: float - climate_target_temperature: float - climate_humidity: float - climate_mode: string - climate_supported_modes: string - climate_min_temp: string - climate_max_temp: string - climate_step: string - then: - - lambda: |- - std::string mode = longpress_mode; - auto contains_mode = [](const std::string& modes, const std::string& needle) { - return modes.find(needle) != std::string::npos; - }; - if (mode == "auto") { - if (tile_type == "light") mode = "brightness"; - else if (tile_type == "fan") mode = "percentage"; - else if (tile_type == "cover") mode = "cover_position"; - else if (tile_type == "climate") mode = "climate_temperature"; - else mode = "none"; - } - - if (mode == "none") return; - - id(active_entity) = entity; - id(active_value_suffix) = " %"; - id(active_label_off) = label_off; - id(active_label_on) = label_on; - id(active_title) = title; - id(active_icon_text) = icon_text; - id(active_supports_color) = false; - id(active_supports_temperature) = false; - lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); - - if (mode == "climate_temperature") { - auto to_x10 = [](float value, int fallback) -> int { - if (isnan(value)) return fallback; - return (int) lround(value * 10.0f); - }; - auto parse_x10 = [](const std::string& value, int fallback) -> int { - if (value.empty()) return fallback; - return (int) lround(atof(value.c_str()) * 10.0f); - }; - id(climate_edit_pending) = false; - id(active_control_kind) = "climate_temperature"; - id(active_climate_min_x10) = parse_x10(climate_min_temp, 50); - id(active_climate_max_x10) = parse_x10(climate_max_temp, 300); - id(active_climate_step_x10) = parse_x10(climate_step, 5); - if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5; - if (id(active_climate_max_x10) <= id(active_climate_min_x10)) { - id(active_climate_min_x10) = 50; - id(active_climate_max_x10) = 300; - } - - id(active_climate_current_x10) = to_x10(climate_current_temperature, 0); - id(active_climate_current) = climate_current_temperature; - id(active_climate_target_known) = !isnan(climate_target_temperature); - int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10); - id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target); - if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); - if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); - id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10); - if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); - if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); - id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity); - id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode; - id(active_climate_supported_modes) = climate_supported_modes; - - lv_label_set_text(id(climate_detail_title), title.c_str()); - // 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); - } - id(climate_card_refresh).execute(); - - lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); - return; - } - - int pct = off_value; - - if (mode == "brightness") { - id(active_control_kind) = "light_brightness"; - if (was_on) { - if (isnan(brightness_value)) pct = 100; - else pct = (int) lround((brightness_value / 255.0f) * 100.0f); - } - } else if (mode == "percentage") { - id(active_control_kind) = "fan_percentage"; - if (was_on) { - if (isnan(percentage_value)) pct = 0; - else pct = (int) lround(percentage_value); - } - } else if (mode == "cover_position") { - id(active_control_kind) = "cover_position"; - if (isnan(position_value)) pct = was_on ? 100 : 0; - else pct = (int) lround(position_value); - } else { - return; - } - - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - id(active_brightness_pct) = pct; - - if (tile_type == "light") { - id(active_supports_color) = - contains_mode(supported_color_modes, "hs") || - contains_mode(supported_color_modes, "rgb") || - contains_mode(supported_color_modes, "rgbw") || - contains_mode(supported_color_modes, "rgbww") || - contains_mode(supported_color_modes, "xy"); - id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp"); - - if (id(active_supports_color) || id(active_supports_temperature)) { - lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); - } - } - - // Track and fill are the pale blue and blue paints of overlay_slider. - lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN); - lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR); - - lv_label_set_text(id(overlay_title), title.c_str()); - lv_label_set_text(id(overlay_icon), icon_text.c_str()); - lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF); - - std::string value_text; - - if (tile_type == "cover") { - if (pct <= 0) { - value_text = label_off; - } else if (pct >= 100) { - value_text = label_on; - } else { - char buf[40]; - snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct); - value_text = buf; - } - } else { - if (pct <= 0) { - value_text = label_off; - } else { - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); - value_text = buf; - } - } - - lv_label_set_text(id(overlay_value), value_text.c_str()); - - lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR); - - - if: - condition: - lambda: 'return id(active_control_kind) != "climate_temperature";' - then: - - if: - condition: - lambda: 'return id(active_supports_color) || id(active_supports_temperature);' - then: - - script.execute: open_color_detail - else: - - lvgl.widget.show: brightness_overlay - - # UI refresh (values + time + styles) + render - - id: ui_refresh - mode: single - then: - - delay: 100ms - - lambda: |- - if (runtime_tiles::enabled) { - // The name and the top bar (clock included) draw together. - runtime_tiles::render(id(lbl_room)); - return; - } - - -# HA blueprint transport; never publishes the JSON command as an HA state. -text: - - platform: smart_display - id: dashboard_inbox - name: "Tile settings" - internal: ${DASHBOARD_INBOX_INTERNAL} - -button: - - platform: template - name: "Calibrate touch" - internal: ${DASHBOARD_INBOX_INTERNAL} - entity_category: config - on_press: - - lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();' - # Wake and Sleep for Home Assistant automations (0.2.45+). Wake is light, not a tap (0.2.56+): it - # lights a dimmed screen and counts the standby time from now, while Back to page 1 keeps counting - # from the last touch, so Wake on every motion no longer holds a card or page 2 up. Sleep is the - # standby time running out, right away and also with Auto standby off; it holds until a tap, Wake - # or an alert, and closes an alert that is up. Neither saves anything, so an automation may press - # them on every motion. No entity_category: they are controls, not settings. - - platform: template - id: wake_button - name: "Wake" - icon: "mdi:gesture-tap" - on_press: - - lambda: 'ESP_LOGI("standby", "Wake from Home Assistant, screen %s", id(display_dimmed) ? "in standby" : "on");' - - if: - condition: - lambda: 'return id(display_dimmed);' - then: - - script.execute: wake_display - # A page whose time ran out during standby: light up on page 1, not on the old page first. - - script.execute: back_to_page_1_when_due - else: - - lambda: 'id(last_touch_ms) = millis();' - - platform: template - id: sleep_button - name: "Sleep" - icon: "mdi:power-sleep" - on_press: - - if: - condition: - lambda: 'return id(calibration_active);' - then: - - lambda: 'ESP_LOGI("standby", "Sleep from Home Assistant ignored: touch calibration is running");' - else: - - lambda: |- - ESP_LOGI("standby", "Sleep from Home Assistant"); - id(sleep_requested) = true; - - script.execute: - id: alert_dismiss - reason: "remote" - - script.execute: dim_display - -number: - # Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the - # screen already has writes and reports nothing (an automation may set it on every light change). - - platform: template - id: setting_brightness - name: "Normal brightness" - entity_category: config - min_value: 5 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.brightness; - set_action: - - lambda: 'settings_screen::set("brightness", (int32_t) lround(x));' - - platform: template - id: setting_standby_brightness - name: "Standby brightness" - entity_category: config - min_value: 0 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.standby_brightness; - set_action: - - lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));' - - platform: template - id: setting_night_brightness - name: "Night brightness" - entity_category: config - min_value: 0 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.night_brightness; - set_action: - - lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));' - - platform: template - id: setting_standby_seconds - name: "Standby after" - entity_category: config - min_value: 60 - max_value: 86400 - step: 60 - unit_of_measurement: "s" - update_interval: never - lambda: return screen_settings::current.standby_seconds; - set_action: - - lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));' - - platform: template - id: setting_auto_home_seconds - name: "Back to page 1 after" - icon: "mdi:timer-outline" - entity_category: config - min_value: 30 - max_value: 3600 - step: 30 - mode: box - unit_of_measurement: "s" - update_interval: never - lambda: return settings_screen::auto_home_seconds; - set_action: - - lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));' diff --git a/packages/boards/cyd-2432s028.yaml b/packages/boards/cyd-2432s028.yaml new file mode 100644 index 0000000..35a84a8 --- /dev/null +++ b/packages/boards/cyd-2432s028.yaml @@ -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= 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(); diff --git a/packages/boards/guition-4848s040.yaml b/packages/boards/guition-4848s040.yaml new file mode 100644 index 0000000..2f29d72 --- /dev/null +++ b/packages/boards/guition-4848s040.yaml @@ -0,0 +1,1014 @@ +# ESP Screens - Guition ESP32-S3-4848S040 board: 4-inch EU wallbox, 480x480 IPS + GT911 capacitive touch. +# +# What is in here: the hardware (display, touch, backlight, PSRAM), the sizes the shared tree takes from +# this board, the code the shared lambdas take from this board (hooks), and what only a Guition has: camera +# images, a screen that turns a quarter, a page swipe from the glass edge, a climate card with a fan and +# swing card, and the snapshot diagnostics. 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, docs/GUITION.md what this board needs. +# +# Two entry files include this: guition-4848s040.yaml (a build on this computer) and packages/guition.yaml +# (the package a screen builds from over GitHub). +substitutions: + + # 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile + # (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it + # anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot). + # The CYD keeps a limit because its resistive panel jumps. + TOUCH_MOVE_LIMIT_PX: "0" + # Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not. + TOUCH_MIN_PRESS_MS: "20" + # Page swipe starts in this band along the left or right edge (about 5 mm) and needs + # this much travel inward (about 6 mm); a swipe from the middle of the screen does nothing. + EDGE_SWIPE_BAND_PX: "32" + EDGE_SWIPE_TRAVEL_PX: "40" + # A slider let go this close to the left or right edge of the glass (about 1 cm) meant its end when + # that end lies in the band: the panel loses a finger swiped off the glass 30-50 px before the edge. + SLIDER_EDGE_SNAP_PX: "67" + DEVICE_NAME: "guition-new" + DEVICE_FRIENDLY_NAME: "My Guition" + + DISPLAY_W: "480" + DISPLAY_H: "480" + LVGL_ROTATION: "0" + TOUCH_SWAP_XY: "false" + TOUCH_MIRROR_X: "false" + TOUCH_MIRROR_Y: "false" + GRID_COL1_X: "16" + GRID_COL2_X: "246" + GRID_ROW1_Y: "0" + GRID_ROW2_Y: "120" + GRID_ROW3_Y: "240" + GRID_ROW4_Y: "360" + GRID_ROW5_Y: "480" + SCROLL_Y: "72" + SCROLL_H: "348" + TILE_W: "218" + TILE_H: "108" + HEADER_Y: "22" + # Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe + # that starts up there still changes the page. + SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}" + SETTINGS_HOLD_W: "416" + SETTINGS_HOLD_DRIFT_PX: "32" + + # Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer. + ALERT_CARD_W: "420" + ALERT_CARD_H: "320" + ALERT_ICON_X: "26" + ALERT_ICON_Y: "22" + ALERT_TEXT_X: "90" + ALERT_TEXT_W: "304" + ALERT_TITLE_Y: "26" + ALERT_TITLE_H: "32" # one line of headline; a DOT label only ends in an ellipsis with a height + ALERT_SUBTITLE_Y: "68" + ALERT_SUBTITLE_H: "150" + ALERT_BUTTON_W: "150" + ALERT_BUTTON_H: "60" + ALERT_BUTTON_INSET: "22" + ALERT_TITLE_MAX: "64" # bytes; the label ends one line with an ellipsis + ALERT_BUTTON_MAX: "16" # bytes on the OK button + ALERT_SUBTITLE_MAX: "240" # bytes; wraps over the rows the card has + # An alert with a camera image (firmware 0.2.57+): the image across the top of a taller card, the words under it. + ALERT_CARD_H_IMAGE: "452" + ALERT_IMAGE_INSET: "14" + ALERT_IMAGE_SHIFT: "236" # the icon, title and subtitle move down by the image and a gap + ALERT_SUBTITLE_H_IMAGE: "62" # two lines between the title and the button + # Camera images (firmware 0.2.57+): the boxes ESP Screen Manager sizes a snapshot for (camera_feed.BOXES). + CAMERA_FULL_W: "480" + CAMERA_FULL_H: "480" + CAMERA_THUMB_W: "392" + CAMERA_THUMB_H: "220" + OVERLAY_SLIDER_W: "144" + OVERLAY_SLIDER_H: "300" + OVERLAY_ICON_Y: "148" + TILE_ICON_X: "0" + TILE_ICON_Y: "12" + TILE_TITLE_X: "64" + TILE_TITLE_Y: "17" + TILE_VALUE_X: "64" + TILE_VALUE_Y: "45" + + # Fonts: one size per role, the same files on every board + FONT_TIME_SIZE: "27" # the clock in the top bar + FONT_HEADLINE_SIZE: "27" # card titles + FONT_WATCH_VALUE_SIZE: "38" + FONT_CLOCK_DIGITS_SIZE: "48" + FONT_SETPOINT_SIZE: "64" # the climate card's setpoint + FONT_LABEL_SIZE: "18" # tile titles + FONT_SUBLABEL_SIZE: "16" # tile values and small text + FONT_SUBLABEL_BIG_SIZE: "21" # the top bar and settings rows + FONT_ICON_SIZE: "42" # tile icons + FONT_ICON_MINI_SIZE: "26" # the top bar and card keys + FONT_ICON_BIG_SIZE: "64" # full-page tiles + FONT_WATCH_ICON_SIZE: "18" + + # Tiles and the top bar + TILE_RADIUS: "22" # corner of a tile + TILE_PAD: "12" # inside a tile + TILE_ICON_SIZE: "54" # the icon circle + HEADER_INSET: "16" # the room name and the clock from the glass edge + + # The page bar under the tiles + PAGE_KEY_W: "240" # the previous/next keys under the tiles + PAGE_BAR_H: "60" # the page bar + PAGE_NUMBER_W: "160" # the dots in the middle + PAGE_CHEVRON_INSET: "24" # the chevrons from the outer edge + + # Cards: the round keys and the brightness card + CARD_KEY_X: "16" # a card's round key from the side + CARD_KEY_Y: "16" # from the top + CARD_KEY_SIZE: "60" # its diameter + OVERLAY_TITLE_Y: "30" # the brightness card's title + OVERLAY_VALUE_Y: "70" # its percentage + OVERLAY_SLIDER_Y: "36" # its slider below the centre + OVERLAY_SLIDER_RADIUS: "42" # the slider's corners + OVERLAY_KNOB_PAD: "15" # the knob + COLOR_KEY_X: "-20" # the colour wheel key from the bottom right corner + COLOR_KEY_Y: "-26" + COLOR_KEY_SIZE: "66" # its diameter + COLOR_ARC_SIZE: "60" # the eight arcs in it + COLOR_ARC_W: "15" + COLOR_TITLE_W: "310" # the colour card's title + COLOR_TITLE_Y: "35" + + # The climate card + CLIMATE_TITLE_Y: "30" # the title + CLIMATE_TITLE_W: "300" + CLIMATE_STATUS_Y: "92" # the status line + CLIMATE_CARD_Y: "136" # the setpoint card + CLIMATE_CARD_H: "200" + CLIMATE_CARD_RADIUS: "24" + CLIMATE_KEY_INSET: "18" # the -/+ keys from the card edge + CLIMATE_KEY_SIZE: "116" # their diameter + CLIMATE_KEY_RADIUS: "58" + CLIMATE_NUMBER_Y: "-14" # the setpoint number + CLIMATE_CAPTION_Y: "46" # the word under it + CLIMATE_MODES_Y: "364" # the row of mode keys + CLIMATE_MODES_H: "76" + CLIMATE_MODES_GAP: "10" + CLIMATE_MODE_RADIUS: "38" + + # The mode picker + PICKER_TITLE_Y: "35" # the "Mode" title + PICKER_LIST_Y: "92" # the list of choices + PICKER_LIST_H: "372" + PICKER_PAD: "4" + PICKER_ROW_GAP: "6" + PICKER_COL_GAP: "6" + PICKER_MODE_W: "136" # a mode chip + PICKER_MODE_H: "58" + PICKER_MODE_RADIUS: "22" + PICKER_TITLE_H: "24" # the Fan and Swing captions + PICKER_CHIP_W: "98" # a fan or swing chip + PICKER_CHIP_H: "48" + PICKER_CHIP_RADIUS: "18" + PICKER_CHIP_FONT: "sublabel_big" # the words on them + + # The touch test page + TOUCH_TEST_PAGE_PAINT: "paint_page_soft" # the page + TOUCH_TEST_TITLE: "GT911 touch check" # the words at the top + TOUCH_TEST_TITLE_FONT: "headline" + TOUCH_TEST_TITLE_Y: "110" + TOUCH_TEST_INSET: "12" # the corner crosses from the edge + TOUCH_TEST_FAR_X: "443" # the right crosses: width - inset - size - 1 + TOUCH_TEST_FAR_Y: "443" # the bottom crosses + TOUCH_TEST_MID_X: "228" # the middle cross + TOUCH_TEST_MID_Y: "228" + TOUCH_TEST_SIZE: "24" # a cross + TOUCH_TEST_PAINT: "paint_ink" # the crosses + + # What the shared scripts do differently on this board + LVGL_DEFAULT_FONT: "sublabel" # LVGL's default font: every label names its own, so this only keeps Montserrat out of the build + LVGL_BUFFER_SIZE: "25%" # LVGL's draw buffer as a share of the screen + PAGE_DOTS_LARGE: "true" # bigger page dots + EFFECTS_ROW_FONT: "sublabel" # the effects page's rows + ROTATION_SUPPORTED: "true" # the screen turns a quarter from the settings + STANDBY_DIM_MS: "1500" # how long the backlight takes to dim into standby (waking takes 300) + ALERT_FLASH_FADE_MS: "60" # an alert's flash: the fade of each step + ALERT_FLASH_DARK_MS: "140" # how long it stays dark + ALERT_FLASH_LIT_MS: "180" # 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: |- + cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS}); + cyd::edge_swipe.configure(${DISPLAY_W}, ${DISPLAY_H}, ${EDGE_SWIPE_BAND_PX}, ${EDGE_SWIPE_TRAVEL_PX}); + cyd::edge_snap_band = ${SLIDER_EDGE_SNAP_PX}; + // The page and the tile container take no presses: the theme's pressed style would + // otherwise repaint nearly the whole screen twice per touch in the empty edge band, + // starving the touch polling for the whole length of a quick swipe. + lv_obj_remove_flag(id(home_page)->obj, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(id(tile_scroll), LV_OBJ_FLAG_CLICKABLE); + // A stray GT911 contact at exactly (0, 0) never reaches LVGL (cyd::GhostTouch): as the last + // sample of a drag it sent a slider to its end on release. LVGL still reads ESPHome's callback. + if (auto *indev = lv_indev_get_next(nullptr)) { + static lv_indev_read_cb_t esphome_read = nullptr; + static const lv_indev_read_cb_t filtered = [](lv_indev_t *input, lv_indev_data_t *data) { + esphome_read(input, data); + int32_t ghost_x = 0, ghost_y = 0; + id(screen_lvgl).rotate_coordinates(ghost_x, ghost_y); + const unsigned dropped = cyd::ghost_touch.dropped(); + const auto read = cyd::ghost_touch.filter({data->state == LV_INDEV_STATE_PRESSED, (int) data->point.x, (int) data->point.y}, ghost_x, ghost_y); + if (cyd::ghost_touch.dropped() != dropped) ESP_LOGI("touch", "GT911 stray contact at (0,0) ignored"); + data->state = read.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; + data->point.x = read.x; + data->point.y = read.y; + }; + if (lv_indev_get_read_cb(indev) != filtered) { + esphome_read = lv_indev_get_read_cb(indev); + lv_indev_set_read_cb(indev, filtered); + } + } + 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: "runtime_tiles::rotation_supported = true;" + # the paint style only this board has (theme::paints) + BOARD_PAINT_FILL: "theme::fill(id(paint_camera), Paint::camera);" + # in runtime_tiles::dismiss, after the detail card closes + CLOSE_CARDS_HOOK: "runtime_tiles::camera_close();" + # what LVGL's gesture over the tiles does on this board + BOOT_PAGE_GESTURE: |- + // A quick flick or wipe over the tiles is never a tap (firmware 0.2.65+). LVGL reports it as a gesture that + // bubbles up to this page (a tile's slider keeps its own, runtime_tiles::bind): the tap under the finger is + // consumed and LVGL waits for the release. No page flip: pages change with the edge swipe (on_update). LVGL + // wants speed as well as 50 px of travel, so a firm press that drifts slowly is still a tap. Cards and the + // settings page keep their drags; in standby the push still wakes the screen, it only switches nothing. + lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *) { + if (!runtime_tiles::enabled || id(calibration_active) || !id(active_entity).empty() || settings_screen::visible() || + (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN))) return; + // A quick drag on a slider is that slider's: the press stays with it (firmware 0.2.68+). + if (runtime_tiles::captured_slider) return; + cyd::touch_guard.consume(); + ESP_LOGI("touch", "flick: no tap"); + if (id(display_dimmed)) return; + if (auto *input = lv_indev_active()) lv_indev_wait_release(input); + }, LV_EVENT_GESTURE, nullptr); + # after the gesture: the camera images of a board that shows them + BOOT_CAMERA_HOOKS: |- + // Camera images (firmware 0.2.57+): the two online_images and the alert's frame. + // set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304. + runtime_tiles::camera_full.load = [](const std::string &url) { + static std::string current; + if (url != current) { id(camera_image).set_url(url); current = url; } + id(camera_image).update(); + }; + runtime_tiles::camera_full.release = []() { id(camera_image).release(); }; + runtime_tiles::camera_full.source = []() { return id(camera_image).get_lv_image_dsc(); }; + runtime_tiles::camera_thumb.load = [](const std::string &url) { + static std::string current; + if (url != current) { id(alert_image).set_url(url); current = url; } + id(alert_image).update(); + }; + runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); }; + runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); }; + runtime_tiles::alert_frame = id(alert_image_frame); + runtime_tiles::alert_frame_icon = id(alert_image_icon); + runtime_tiles::alert_room = [](bool image) { + const int shift = image ? ${ALERT_IMAGE_SHIFT} : 0; + lv_obj_set_height(id(alert_card), image ? ${ALERT_CARD_H_IMAGE} : ${ALERT_CARD_H}); + lv_obj_set_y(id(alert_icon), ${ALERT_ICON_Y} + shift); + lv_obj_set_y(id(alert_title), ${ALERT_TITLE_Y} + shift); + lv_obj_set_y(id(alert_subtitle), ${ALERT_SUBTITLE_Y} + shift); + lv_obj_set_height(id(alert_subtitle), image ? ${ALERT_SUBTITLE_H_IMAGE} : ${ALERT_SUBTITLE_H}); + }; + # 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: |- + climate_card::look.card = id(climate_settings_card); + climate_card::look.font = id(label)->get_lv_font(); + climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font(); + climate_card::choose = [](char kind, int index) { + if (kind == 'f') id(set_climate_fan_mode).execute(index); + else id(set_climate_swing_mode).execute(index); + }; + # what else keeps the settings page closed (inline, in the condition) + MAY_OPEN_EXTRA: " && !runtime_tiles::camera_visible()" + # the log line of a boot onto the touch test page + TOUCH_TEST_READY_LOG: "GT911 touch test ready; no tile actions" + # one more line of the ui_state diagnostics + UI_STATE_HOOK: "ESP_LOGI(\"ui_diag\", \"rotation=%u actual=%u\", (unsigned)runtime_tiles::rotation, (unsigned)id(screen_lvgl).get_rotation());" + # every 250 ms after runtime_tiles::tick() + TICK_HOOK: "runtime_tiles::camera_tick();" + # apply_screen_settings: turning the screen, before the standby logic + APPLY_ROTATION: |- + if(runtime_tiles::enabled && static_cast(id(screen_lvgl).get_rotation())!=runtime_tiles::rotation){ + cyd::touch_guard.consume(); + if(lv_indev_get_act())lv_indev_wait_release(lv_indev_get_act()); + id(screen_lvgl).set_rotation(static_cast(runtime_tiles::rotation)); + } + # apply_screen_settings: how the backlight takes its level + APPLY_BACKLIGHT: |- + // Standby dims slowly (ESPHome's transition); waking is quick. + id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300); + # apply_screen_settings: the Rotation entity in Home Assistant follows + APPLY_ROTATION_ENTITY: |- + const auto shown_rotation = id(setting_rotation)->active_index(); + if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update(); + # back_to_page_1_when_due: what else counts as away from page 1 (inline) + AWAY_EXTRA: " runtime_tiles::camera_visible() ||" + # alert_show: after the settings page closed, before the card is made + ALERT_SHOW_HOOK: |- + // An open camera closes; the card makes room for an image the app announced for this alert. + runtime_tiles::alert_prepare(); + # set_climate_fan_mode and set_climate_swing_mode: after the choice is stored + CLIMATE_MODE_CHOSEN_HOOK: "id(climate_card_refresh).execute();" + # open_value_overlay: the climate card of a panel in portrait, before its refresh + OPEN_VALUE_OVERLAY_TAIL: "" + +# ============================================================ +# No need to edit below this line. +# ============================================================ + +esphome: + platformio_options: + build_flags: + - -DLV_USE_SNAPSHOT=1 + includes: + - + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + cpu_frequency: 240MHz + framework: + type: esp-idf + version: recommended + advanced: + execute_from_psram: true + +psram: + mode: octal + speed: 80MHz + +logger: + tx_buffer_size: 2048 + hardware_uart: UART0 + +api: + actions: + - action: ui_snapshot + then: + - script.execute: capture_ui_snapshot + # A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes + # it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens. + - action: preview_camera + variables: + entity: string + then: + - lambda: |- + if (entity.empty()) { runtime_tiles::camera_close(); return; } + id(last_use_ms) = millis(); + id(wake_display).execute(); + std::string name = entity; + for (size_t i = 0; i < runtime_tiles::model.count; ++i) + if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; } + runtime_tiles::camera_open(entity, name); + +# Camera images (firmware 0.2.57+): ESP Screen Manager serves each image already sized for this screen, as an uncompressed +# BMP on its own port on the LAN (screen_manager/app/camera_feed.py). ESPHome decodes a BMP piece by piece as it arrives, +# 4 KB per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn. One image full +# screen and one in an alert's frame; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP +# on the LAN, no certificates. +http_request: + timeout: 4s + verify_ssl: false + +# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests +# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7. +online_image: + - id: camera_image + url: "http://127.0.0.1/camera.bmp" # set when a camera opens + format: BMP + type: RGB565 + buffer_size: 4096 + on_download_finished: + - lambda: 'runtime_tiles::camera_loaded(false, cached);' + on_error: + - lambda: 'runtime_tiles::camera_failed(false);' + - id: alert_image + url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image + format: BMP + type: RGB565 + buffer_size: 4096 + on_download_finished: + - lambda: 'runtime_tiles::camera_loaded(true, cached);' + on_error: + - lambda: 'runtime_tiles::camera_failed(true);' + +text_sensor: + - platform: template + name: "Guition screen type" + entity_category: diagnostic + update_interval: never + lambda: 'return {"guition"};' + +# Rotation as a Home Assistant choice (firmware 0.2.49+). Only the Guition turns; the CYD keeps its orientation. +select: + - platform: template + id: setting_rotation + name: "Rotation" + icon: "mdi:screen-rotation" + entity_category: config + options: ["0°", "90°", "180°", "270°"] + update_interval: never + lambda: |- + static const char *const names[] = {"0°", "90°", "180°", "270°"}; + return std::string(names[std::clamp(settings_screen::rotation, 0, 270) / 90]); + set_action: + - lambda: |- + const std::string option(x.c_str(), x.size()); + settings_screen::set("rotation", atoi(option.c_str())); + +# Pinout from ESPHome's GUITION-4848S040 preset. No relay outputs are configured. +spi: + id: lcd + clk_pin: GPIO48 + mosi_pin: GPIO47 + +i2c: + id: touch_bus + sda: GPIO19 + scl: + number: GPIO45 + ignore_strapping_warning: true + scan: true + +output: + - platform: ledc + pin: GPIO38 + id: gpio_backlight_pwm + frequency: 20000Hz + +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: "Display Backlight" + id: back_light + internal: true + restore_mode: ALWAYS_ON + default_transition_length: 150ms + +touchscreen: + platform: gt911 + id: ts_touch + i2c_id: touch_bus + display: my_display + update_interval: 20ms + 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); + cyd::edge_swipe.begin(touch.x, touch.y, static_cast(id(screen_lvgl).get_rotation())); + ESP_LOGI("touch", "GT911 press x=%u y=%u id=%u test=%d", touch.x, touch.y, (unsigned) touch.id, 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) { + // Trace every sample of an edge touch: the log then shows how often the panel delivers. + if (cyd::edge_swipe.armed()) + ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y); + // The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went. + if (point.x == 0 && point.y == 0) continue; + cyd::touch_guard.update(point.x, point.y, point.id); + if (point.id != cyd::touch_guard.contact()) continue; + // Swiping in from a side edge flips the page ("Swiping between pages"); the tap + // under the finger is consumed and LVGL waits for the release. LVGL 9.5 sends no + // PRESSING to the input device, hence the touchscreen trigger. + const int step = cyd::edge_swipe.update(point.x, point.y); + if (!step) continue; + const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off" + : id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open" + : (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open" + : runtime_tiles::camera_visible() ? "camera open" : nullptr; + if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; } + cyd::touch_guard.consume(); + for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev); + ESP_LOGI("touch", "edge swipe: page %d -> %d", id(tile_page), id(tile_page) + step); + id(tile_page) += step; + id(show_tile_page).execute(); + } + on_release: + - lambda: |- + id(last_touch_ms) = millis(); + id(last_use_ms) = millis(); + id(touch_down) = false; + if (cyd::edge_swipe.armed() && cyd::edge_swipe.inward() > 0) + ESP_LOGI("touch", "edge swipe not fired: %d px inward, %d px vertical", cyd::edge_swipe.inward(), cyd::edge_swipe.sideways()); + cyd::edge_swipe.end(); + + +# Native ESPHome driver with the working manufacturer's LVGL demo timings. +# See docs/GUITION_FACTORY_REFERENCE.md; no custom RGB component is required. +display: + - platform: st7701s + id: my_display + dimensions: {width: 480, height: 480} + spi_id: lcd + spi_mode: MODE0 + data_rate: 2MHz + color_order: RGB + invert_colors: false + cs_pin: 39 + de_pin: 18 + hsync_pin: 16 + vsync_pin: 17 + pclk_pin: 21 + pclk_frequency: 16MHz + pclk_inverted: false + hsync_front_porch: 10 + hsync_pulse_width: 8 + hsync_back_porch: 50 + vsync_front_porch: 10 + vsync_pulse_width: 8 + vsync_back_porch: 20 + transform: + mirror_x: false + mirror_y: false + init_sequence: + - 1 + # Manufacturer's 16-bit bus configuration and normal scan direction. + - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x10] + - [0xCD, 0x00] + - [0xC7, 0x00] + # Return to the standard register bank before pixel format / sleep-out. + - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x00] + - [0x3A, 0x60] + - [0x36, 0x00] + - [0x20] + - [0x11] + - delay 120ms + data_pins: + red: [11, 12, 13, 14, 0] + green: [8, 20, 3, 46, 9, 10] + blue: [4, 5, 6, 7, 15] + update_interval: never + auto_clear_enabled: false + +lvgl: + id: screen_lvgl + style_definitions: + # The alert's camera frame (theme::paints fills it: BOARD_PAINT_FILL). + - id: paint_camera + top_layer: + widgets: + - obj: + id: !extend alert_overlay + widgets: + - obj: + id: !extend alert_card + widgets: + # The alert's camera image (firmware 0.2.57+) across the top of the card, shown when the app announced + # one; a tap opens the camera full screen over the alert. runtime_tiles.h puts the image in it. + - obj: + id: alert_image_frame + x: ${ALERT_IMAGE_INSET} + y: ${ALERT_IMAGE_INSET} + width: ${CAMERA_THUMB_W} + height: ${CAMERA_THUMB_H} + hidden: true + styles: paint_camera + bg_opa: COVER + radius: 0 + border_width: 0 + shadow_width: 0 + pad_all: 0 + clickable: true + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: COVER + on_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept(millis(), 14) && !runtime_tiles::alert_camera.empty();' + then: + - lambda: 'runtime_tiles::camera_open(runtime_tiles::alert_camera, id(alert_title_text));' + widgets: + - label: + id: alert_image_icon + align: CENTER + text: "\U000F07AE" + text_font: materialdesign_icons + clickable: false + pages: + - id: !extend home_page + widgets: + # Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with. + - image: + id: camera_image_seed + hidden: true + src: alert_image + # The tile labels get a width and an ellipsis on this board (the small panel keeps LVGL's content width). + - obj: + id: !extend tile_scroll + widgets: + - obj: + id: !extend tile1 + widgets: + - label: + id: !extend t1_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t1_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile2 + widgets: + - label: + id: !extend t2_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t2_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile3 + widgets: + - label: + id: !extend t3_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t3_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile4 + widgets: + - label: + id: !extend t4_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t4_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile5 + widgets: + - label: + id: !extend t5_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t5_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile6 + widgets: + - label: + id: !extend t6_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t6_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile7 + widgets: + - label: + id: !extend t7_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t7_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile8 + widgets: + - label: + id: !extend t8_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t8_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile9 + widgets: + - label: + id: !extend t9_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t9_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend tile10 + widgets: + - label: + id: !extend t10_title + height: 24 + width: 130 + long_mode: DOT + - label: + id: !extend t10_value + height: 22 + width: 130 + long_mode: DOT + - obj: + id: !extend climate_detail_overlay + widgets: + # Fan and swing, each a row of choices behind its icon (climate_card.h fills the card). + - obj: + id: climate_settings_card + align: TOP_MID + y: 340 + width: 94% + height: 116 + radius: 24 + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + hidden: true + clickable: false + scrollable: false + +script: + - id: capture_ui_snapshot + mode: single + then: + - lambda: guition::snapshot_begin(); + - repeat: + count: 240 + then: + - lambda: guition::snapshot_next_row(); + - delay: 100ms + # 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 = guition::check_geometry(id(home_page)->obj); + for (int i = 0; i < 10; ++i) { + bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index= 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 + - lambda: guition::check_geometry(id(brightness_overlay)); + - lvgl.widget.hide: brightness_overlay + - lvgl.widget.show: color_detail_overlay + - delay: 150ms + - lambda: guition::check_geometry(id(color_detail_overlay)); + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.show: climate_detail_overlay + - delay: 150ms + - lambda: guition::check_geometry(id(climate_detail_overlay)); + - lvgl.widget.hide: climate_detail_overlay + - lvgl.widget.show: climate_mode_overlay + - delay: 150ms + - lambda: guition::check_geometry(id(climate_mode_overlay)); + - 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 through set_backlight; the CYD drives it as a light action. + - 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(); + runtime_tiles::alert_clear(); + ESP_LOGI("alert", "dismissed: %s", reason.c_str()); + - lvgl.widget.hide: alert_overlay + - script.stop: alert_timeout + - script.stop: alert_flash + - lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 80);' + - 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: + - lambda: 'id(set_backlight).execute(0, 60);' + - delay: 140ms + - lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 60);' + - delay: 180ms + + - id: wake_display + mode: restart + then: + - lambda: |- + id(last_touch_ms) = millis(); + id(display_dimmed) = false; + id(sleep_requested) = false; + // Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish + // when you touch the screen and are waiting on light. + id(set_backlight).execute(screen_settings::current.brightness, 80); + - lvgl.widget.hide: dim_wake_overlay + - script.execute: close_cards + + # One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56 + # stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final; + # a new call interrupts the running transition from where it is, as ESPHome does for any light. + - id: set_backlight + mode: restart + parameters: + level: int + ms: int + then: + - lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform(); + + # The climate card at this size, with the fan and swing card (the CYD has its own copy). + - 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"}}; + // The Guition shows fan and swing on their own card below, so no "more" key. + bool more = false; + 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. + 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); + + // Fan and swing rows, in the entity's own names and order. + climate_card::Row settings[2]; + int rows = 0; + if (!cyd::list_item(id(active_climate_fan_modes), 0).empty()) + settings[rows++] = {'f', "\U000F0210", id(active_climate_fan_modes), id(active_climate_fan_mode)}; + if (!cyd::list_item(id(active_climate_swing_modes), 0).empty()) + settings[rows++] = {'s', "\U000F1C91", id(active_climate_swing_modes), id(active_climate_swing_mode)}; + // One layout per card: the setpoint gives room to the mode keys and the settings below it, + // and a card with nothing below keeps the setpoint centred. + static int laid_out = -1; + if (laid_out != rows * 2 + show_keys) { + laid_out = rows * 2 + show_keys; + struct Layout { int status, card_y, card_h, key, number_y, caption_y, modes_y, modes_h, settings_y, edge, row_h, gap; }; + static const Layout layouts[6] = { + // no mode keys: 0, 1, 2 rows + {92, 180, 200, 116, -14, 46, 0, 0, 0, 14, 48, 10}, + {92, 140, 200, 116, -14, 46, 0, 0, 364, 14, 48, 10}, + {90, 130, 180, 108, -12, 42, 0, 0, 330, 14, 46, 10}, + // mode keys: 0, 1, 2 rows + {92, 136, 200, 116, -14, 46, 364, 76, 0, 14, 48, 10}, + {90, 126, 170, 108, -12, 42, 312, 68, 394, 12, 44, 8}, + {86, 118, 140, 96, -10, 36, 270, 60, 342, 12, 42, 8}, + }; + const Layout &l = layouts[(show_keys ? 3 : 0) + rows]; + lv_obj_set_y(id(climate_current_label), l.status); + lv_obj_set_y(id(climate_setpoint_card), l.card_y); + lv_obj_set_height(id(climate_setpoint_card), l.card_h); + for (lv_obj_t *button : {id(climate_minus_button), id(climate_plus_button)}) { + lv_obj_set_size(button, l.key, l.key); + lv_obj_set_style_radius(button, l.key / 2, LV_PART_MAIN); + } + lv_obj_set_y(id(climate_target_label), l.number_y); + lv_obj_set_y(id(climate_set_label), l.caption_y); + if (show_keys) { + lv_obj_set_y(id(climate_mode_keys), l.modes_y); + lv_obj_set_height(id(climate_mode_keys), l.modes_h); + for (lv_obj_t *mode_key : {id(cmkey_auto), id(cmkey_heat_cool), id(cmkey_heat), id(cmkey_cool), id(cmkey_dry), id(cmkey_fan_only), id(cmkey_more)}) { + lv_obj_set_height(mode_key, l.modes_h); + lv_obj_set_style_radius(mode_key, l.modes_h / 2, LV_PART_MAIN); + } + } + lv_obj_set_y(id(climate_settings_card), l.settings_y); + climate_card::look.edge = l.edge; + climate_card::look.row_h = l.row_h; + climate_card::look.gap = l.gap; + } + climate_card::show(settings, rows); + id(climate_power_refresh).execute(); diff --git a/packages/core.yaml b/packages/core.yaml new file mode 100644 index 0000000..430dc99 --- /dev/null +++ b/packages/core.yaml @@ -0,0 +1,4294 @@ +############################################################ +# ESP Screens - the shared core: what every board's screen is made of. +# +# The LVGL tree the runtime fills (tiles, cards, overlays, the touch test page), +# the scripts, the API actions, the entities, the fonts and the globals. It carries +# no number that depends on the board: every size, font size and board-only line of +# C++ is a ${NAME} that the board file under packages/boards/ defines, next to the +# board's hardware. An entry file includes both: packages/.yaml for a screen +# that builds over GitHub, .yaml in the repository root for a checkout. +# docs/PROFILES.md says how the three fit together and how a board is added. +# +# A screen gets its tiles from the ESP Screen Manager add-on while it runs, so no +# Home Assistant entity lives in here. The Material Design Icons font comes from +# fonts/materialdesignicons-webfont.ttf (Apache 2.0, github.com/Templarian/MaterialDesign-Webfont). +############################################################ +substitutions: + SCREEN_FIRMWARE_VERSION: "0.2.70" + DASHBOARD_INBOX_INTERNAL: "false" + CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page + ROOM_NAME: "Choose tiles in HA" # shown top left until ESP Screens sends the screen name + + ## DIM settings + AUTO_DIM: "true" # true = enable auto dimming + AUTO_DIM_TIMEOUT: "600" # seconds after last touch + MAX_BRIGHTNESS: "100" # backlight brightness in percent + DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent + NIGHT_MODE: "true" # true = use separate dim level at night + NIGHT_START_HOUR: "22" # 0-23 + NIGHT_END_HOUR: "7" # 0-23 + NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night + +# ============================================================ +# No need to edit below this line for a screen of your own: the sizes are the +# board file's, the credentials the entry file's (secrets.yaml, see secrets.yaml.example). +# ============================================================ +esphome: + min_version: 2026.6.2 + project: + name: maxgramser.esp_screen + version: "${SCREEN_FIRMWARE_VERSION}" + platformio_options: + build_flags: + - -DLV_USE_SWITCH=1 + includes: + - + - + - + - + name: ${DEVICE_NAME} + friendly_name: ${DEVICE_FRIENDLY_NAME} + on_boot: + priority: -100 + then: + - lambda: |- + ${BOOT_TOUCH} + - lambda: |- + auto &settings = screen_settings::current; + settings.standby_enabled = std::string("${AUTO_DIM}") == "true"; + settings.standby_seconds = atoi("${AUTO_DIM_TIMEOUT}"); + settings.brightness = atoi("${MAX_BRIGHTNESS}"); + settings.standby_brightness = atoi("${DIM_BRIGHTNESS}"); + settings.night_enabled = std::string("${NIGHT_MODE}") == "true"; + settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; + settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; + settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); + runtime_tiles::watch_font = id(headline)->get_lv_font(); + runtime_tiles::watch_value_font = id(watch_value)->get_lv_font(); + runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font(); + runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); + runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; + runtime_tiles::screen_awake = []() { return !id(display_dimmed); }; + runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); + runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); + runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); + runtime_tiles::small_font = id(sublabel)->get_lv_font(); + // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock + // label lends its margin and drawing order and stays hidden in runtime mode. + runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font(); + runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font(); + runtime_tiles::time_label = id(lbl_time); + runtime_tiles::enabled = true; + ${BOOT_ENABLED_HOOK} + runtime_tiles::load_settings(); + // Colours (firmware 0.2.54+): the styles of this profile take their colours from theme.h. The list runs now, + // in the look the screen saved, before anything is drawn, and again after every change of look. + theme::paints = []() { + using theme::Paint; + for (auto *style : {id(style_room), id(style_time), id(style_title), id(style_value_big), id(paint_ink)}) theme::fill(style, Paint::ink); + theme::fill(id(style_page), Paint::page); + theme::fill(id(style_tile), Paint::card); + theme::fill(id(style_icon_circle), Paint::accent_tint); + theme::fill(id(style_value), Paint::muted); + theme::fill(id(paint_page_soft), Paint::page_soft); + theme::fill(id(paint_page_pressed), Paint::page_pressed); + theme::fill(id(paint_card), Paint::card); + theme::fill(id(paint_raised), Paint::raised); + theme::fill(id(paint_key), Paint::key); + theme::fill(id(paint_key_pressed), Paint::key_pressed); + theme::fill(id(paint_track), Paint::track); + theme::fill(id(paint_ink_soft), Paint::ink_soft); + theme::fill(id(paint_muted), Paint::muted); + theme::fill(id(paint_subtle), Paint::subtle); + theme::fill(id(paint_slate), Paint::slate); + theme::fill(id(paint_scrim), Paint::scrim); + theme::fill(id(paint_veil), Paint::veil); + theme::fill(id(paint_accent_tint), Paint::accent_tint); + theme::fill(id(paint_accent_bright), Paint::accent_bright); + theme::fill(id(paint_button_dark), Paint::button_dark); + theme::fill(id(paint_button_dark_pressed), Paint::button_dark_pressed); + ${BOARD_PAINT_FILL} + }; + theme::paints(); + theme::set_dark(settings_screen::dark_mode != 0); + runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; + runtime_tiles::dismiss = []() { + effects_page::close(); + runtime_tiles::hide_detail(); + ${CLOSE_CARDS_HOOK} + id(active_entity).clear(); + lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); + }; + runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; + ${BOOT_PAGE_GESTURE} + ${BOOT_CAMERA_HOOKS} + runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; + runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); + runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); + runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); + runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); + runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); + runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); + runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); + runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); + runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); + runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); + runtime_tiles::detail = [](runtime_tiles::Tile &t) { + light_controls::fallback = light_controls::State{}; + light_controls::fallback.hue = t.hue; + light_controls::fallback.kelvin = t.kelvin; + light_controls::fallback.minimum = t.min_kelvin; + light_controls::fallback.maximum = t.max_kelvin; + id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; + id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; + id(active_climate_action) = t.extra().hvac_action; + id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, + t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), + "Off", "On", t.modes, + t.current, t.target, t.humidity, t.state, t.extra().hvac_modes, + std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); + }; + runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { + effects_page::updated(t); + if (id(active_entity) != t.entity || id(touch_down)) return; + if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { + if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; + id(climate_edit_pending) = false; + id(active_climate_mode) = t.state; + id(active_climate_action) = t.extra().hvac_action; + id(active_climate_supported_modes) = t.extra().hvac_modes; + id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; + id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; + id(active_climate_current) = t.current; + id(active_climate_humidity) = std::isfinite(t.humidity) ? (int) lround(t.humidity) : -1; + if (std::isfinite(t.target)) { + id(active_climate_target_x10) = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); + id(active_climate_target_known) = true; + } + id(climate_card_refresh).execute(); + } + }; + - lambda: |- + light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font()); + light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; + light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; + light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; + // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and + // the ways out: actions to Home Assistant and the question for a picker's names. + effects_page::title_font = id(headline)->get_lv_font(); + effects_page::row_font = id(${EFFECTS_ROW_FONT})->get_lv_font(); + effects_page::roller_font = id(sublabel_big)->get_lv_font(); + effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font(); + effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * { + for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i]; + return nullptr; + }; + effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) { + runtime_tiles::action(service, entity, key, value); + }; + effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); }; + effects_page::drift = []() { return cyd::touch_guard.distance(); }; + effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; + effects_page::now = []() { return (uint32_t) millis(); }; + runtime_tiles::options_received = effects_page::received; + ${BOOT_CARDS_TAIL} + # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager + # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built + # when it opens and thrown away when it closes, so it costs nothing while it is closed. + - lambda: |- + if (!runtime_tiles::enabled) return; + settings_screen::title_font = id(headline)->get_lv_font(); + settings_screen::row_font = id(sublabel_big)->get_lv_font(); + settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); + settings_screen::store = []() { runtime_tiles::persist_settings(); }; + settings_screen::apply = []() { id(apply_screen_settings).execute(); }; + settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; + settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; + settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; + settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; + settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; + settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; + // A dimmed screen, a calibration or an alert owns the glass; the page waits. + settings_screen::may_open = []() { + return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active)${MAY_OPEN_EXTRA}; + }; + settings_screen::drift = []() { return cyd::touch_guard.distance(); }; + settings_screen::before_open = []() { id(close_cards).execute(); }; + settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; + settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; + // What a change of look draws again, now that everything is bound: the tiles, the top bar and an open card, + // the colour card, the settings page, an alert and the climate cards. + theme::redraw = []() { + runtime_tiles::restyle(); + light_controls::restyle(); + effects_page::restyle(); + settings_screen::restyle(); + if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN); + if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute(); + if (!lv_obj_has_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN)) id(open_climate_mode_picker).execute(); + }; + // Under the cards: brightness_overlay is the first of them on the page, so the back button and + // the action at the top right of a card keep their taps. + settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}, id(brightness_overlay)); + - light.turn_on: + id: back_light + brightness: !lambda "return screen_settings::current.brightness / 100.0f;" + - script.execute: show_tile_page + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + - if: + condition: + lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";' + then: + - lambda: 'id(calibration_active) = true;' + - lvgl.page.show: touch_cal_page + - lambda: |- + lv_obj_invalidate(id(touch_cal_page)->obj); + lv_refr_now(nullptr); + ESP_LOGI("ui_diag", "${TOUCH_TEST_READY_LOG}"); + + +logger: + # Set to DEBUG or VERBOSE temporarily for diagnosis. Note: ESPHome logs + # its configuration lines at CONFIG level, which are invisible at INFO. + level: DEBUG + logs: + sensor: WARN + text_sensor: WARN + xpt2046: WARN + homeassistant.sensor: WARN + homeassistant.text_sensor: WARN + + +# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values. +api: + actions: + - action: touch_diagnostics + then: + - lambda: 'id(calibration_active) = true;' + - script.execute: wake_display + - lvgl.page.show: touch_cal_page + - lambda: |- + lv_obj_invalidate(id(touch_cal_page)->obj); + lv_refr_now(nullptr); + ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj); + - action: end_touch_diagnostics + then: + - lambda: 'id(calibration_active) = false;' + - lvgl.page.show: home_page + - action: ui_state + then: + - lambda: |- + const auto &cfg = screen_settings::current; + ESP_LOGI("ui_diag", "settings standby=%d timeout=%d brightness=%d dim=%d night=%d start=%d end=%d night_dim=%d clock=%d clock24=%d home=%d idle_ms=%u use_idle_ms=%u dimmed=%d", + cfg.standby_enabled, cfg.standby_seconds, cfg.brightness, cfg.standby_brightness, + cfg.night_enabled, cfg.night_start, cfg.night_end, cfg.night_brightness, + cfg.show_clock, cfg.clock_24h, cfg.home_on_standby, + (unsigned)(millis() - id(last_touch_ms)), (unsigned)(millis() - id(last_use_ms)), id(display_dimmed)); + ESP_LOGI("ui_diag", "navigation page=%d runtime_detail=%d", id(tile_page), + runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); + ${UI_STATE_HOOK} + lv_area_t nav, grid; + lv_obj_update_layout(id(home_page)->obj); + lv_obj_get_coords(id(page_next), &nav); + lv_obj_get_coords(id(tile_scroll), &grid); + ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", + lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, + grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); + - action: preview_runtime_card + variables: + index: int + then: + - lambda: |- + if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } + if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { + runtime_tiles::active_index = index; + auto &tile = runtime_tiles::model.tiles[index]; + auto domain = tile.domain(); + // Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+) + // open the runtime card, as a tap does. + if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) { + tile.begin(millis(), true); + runtime_tiles::detail(tile); + return; + } + runtime_tiles::show_detail(index); + } + # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away + # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. + # Handy for support ("look at Screen -> Rotation") and for the render tests. + - action: open_settings + variables: + page: int + then: + - lambda: |- + if (page < 0) { id(go_home).execute(); return; } + // Opened for someone to look at: Back to page 1 counts from now, as after a touch. + id(last_use_ms) = millis(); + id(wake_display).execute(); + settings_screen::open((uint8_t) page); + ESP_LOGI("settings", "page %d open from Home Assistant", page); + + # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. + - action: preview_climate_picker + then: + - script.execute: open_climate_mode_picker + - action: preview_busy + variables: + index: int + then: + - lambda: |- + if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { + runtime_tiles::model.tiles[index].begin(millis()); + if (runtime_tiles::refresh) runtime_tiles::refresh(); + } + - action: light_controls_preview + then: + - script.execute: light_controls_preview + - action: ui_self_test + then: + - script.execute: ui_self_test + # Home Assistant automations: esphome._show_alert puts a card over the whole + # screen (README, "Alert from an automation"); dismiss_alert removes it again. + - action: show_alert + variables: + title: string + subtitle: string + icon: string + color: string + button_text: string + timeout: int + flash: bool + then: + - script.execute: + id: alert_show + title: !lambda 'return title;' + subtitle: !lambda 'return subtitle;' + icon: !lambda 'return icon;' + color: !lambda 'return color;' + button_text: !lambda 'return button_text;' + timeout: !lambda 'return timeout;' + flash: !lambda 'return flash;' + - action: dismiss_alert + then: + - script.execute: + id: alert_dismiss + reason: "remote" + # ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of + # base64 chunks in the text inbox; the inbox keeps showing the result. + # Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same + # status as the inbox, plus the revision of the layout on the screen. A call without a call id + # (older apps) gets no answer, so ESPHome has nothing to warn about. + - action: screen_message + supports_response: optional + variables: + message: string + then: + - lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));' + - if: + condition: + lambda: 'return call_id != 0;' + then: + - api.respond: + data: !lambda |- + root["status"] = id(dashboard_inbox).state; + root["rev"] = runtime_tiles::layout_rev; + +interval: + - interval: 250ms + then: + - lambda: |- + runtime_tiles::tick(); + ${TICK_HOOK} + - interval: 1s + then: + - if: + condition: + lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;' + then: + # The edit window is over: show what Home Assistant reports now. A closed card has + # nothing to show, and a flag left set would redraw every tile each second. + - lambda: |- + if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; } + if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; } + // Runtime tiles: a state that arrived during the window was held back. + id(climate_edit_pending) = false; + if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 && + runtime_tiles::active_index < (int) runtime_tiles::model.count) + runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]); + - if: + condition: + lambda: |- + if (!screen_settings::current.standby_enabled) return false; + if (id(display_dimmed) || id(touch_down) || id(calibration_active) || id(alert_active)) return false; + return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); + then: + - script.execute: dim_display + - script.execute: back_to_page_1_when_due + +time: + - platform: homeassistant + id: homeassistant_time + # Refresh immediately on time sync; do not wait for an HA entity update. + update_interval: 60s + on_time_sync: + then: + - script.execute: apply_screen_settings + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + on_time: + - seconds: 0 + minutes: /1 + then: + - script.execute: apply_screen_settings + - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' + +globals: + # Alert overlay (HA action show_alert): standby waits while one is up. + - id: alert_active + type: bool + initial_value: 'false' + - id: alert_title_text + type: std::string + # The alert's colour as the automation named it (a swatch's light value, 0 for the normal card). + - id: alert_card_color + type: uint32_t + initial_value: '0' + - id: alert_timeout_seconds + type: int + initial_value: '0' + - id: alert_flash_requested + type: bool + initial_value: 'false' + - id: rendered_frames + type: uint32_t + initial_value: '0' + - id: calibration_active + type: bool + initial_value: 'false' + - id: tile_page + type: int + initial_value: '0' + - id: touch_down + type: bool + initial_value: 'false' + - id: climate_edit_ms + type: uint32_t + initial_value: '0' + - id: climate_edit_pending + type: bool + initial_value: 'false' + - id: active_entity + type: std::string + restore_value: no + + - id: active_control_kind + type: std::string + restore_value: no + + - id: active_value_suffix + type: std::string + restore_value: no + + # Two clocks. last_touch_ms is the standby time: a touch, Wake, an alert ending and Auto standby on restart it. + # last_use_ms is Back to page 1 (firmware 0.2.56+): only a touch, or Home Assistant opening the settings page. + - id: last_touch_ms + type: uint32_t + restore_value: no + initial_value: '0' + + - id: last_use_ms + type: uint32_t + restore_value: no + initial_value: '0' + + # Debounce for do_tile_action: this resistive XPT2046 panel sometimes gives + # multiple separate press/release cycles for one physical touch (noise in + # the resistive layer), causing the same HA action to fire multiple times + # within a few hundred ms (observed: script.ambient_morning 6x in 8s). + - id: last_action_entity + type: std::string + restore_value: no + + - id: last_action_ms + type: uint32_t + restore_value: no + initial_value: '0' + + - id: display_dimmed + type: bool + restore_value: no + initial_value: 'false' + + # Sleep from Home Assistant (0.2.45+): standby that also holds with Auto standby off, + # until a tap, Wake or an alert wakes the screen. + - id: sleep_requested + type: bool + restore_value: no + initial_value: 'false' + + - id: active_label_off + type: std::string + restore_value: no + + - id: active_label_on + type: std::string + restore_value: no + + - id: active_title + type: std::string + restore_value: no + + - id: active_icon_text + type: std::string + restore_value: no + + - id: active_brightness_pct + type: int + restore_value: no + initial_value: '100' + + - id: active_supports_color + type: bool + restore_value: no + initial_value: 'false' + + - id: active_supports_temperature + type: bool + restore_value: no + initial_value: 'false' + + - id: active_color_detail_tab + type: int + restore_value: no + initial_value: '0' + + - id: active_climate_current_x10 + type: int + restore_value: no + initial_value: '0' + + - id: active_climate_target_x10 + type: int + restore_value: no + initial_value: '200' + + - id: active_climate_humidity + type: int + restore_value: no + initial_value: '-1' + + - id: active_climate_min_x10 + type: int + restore_value: no + initial_value: '50' + + - id: active_climate_max_x10 + type: int + restore_value: no + initial_value: '300' + + - id: active_climate_step_x10 + type: int + restore_value: no + initial_value: '5' + + - id: active_climate_mode + type: std::string + restore_value: no + + - id: active_climate_power_on + type: bool + restore_value: no + initial_value: 'false' + + - id: active_climate_fan_modes + type: std::string + restore_value: no + + - id: active_climate_fan_mode + type: std::string + restore_value: no + + - id: active_climate_swing_modes + type: std::string + restore_value: no + + - id: active_climate_swing_mode + type: std::string + restore_value: no + + - id: active_climate_supported_modes + type: std::string + restore_value: no + + # Room temperature (NAN when unknown), what the device does (hvac_action, runtime tiles only), + # and whether Home Assistant reported a setpoint at all (heat_cool ranges do not). + - id: active_climate_current + type: float + restore_value: no + initial_value: 'NAN' + + - id: active_climate_action + type: std::string + restore_value: no + + - id: active_climate_target_known + type: bool + restore_value: no + initial_value: 'false' + +font: + - file: "${FONT_DIR}/Roboto-500.ttf" + id: time_label + size: ${FONT_TIME_SIZE} + bpp: 4 + glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P'] + + - file: "${FONT_DIR}/Roboto-500.ttf" + id: headline + size: ${FONT_HEADLINE_SIZE} + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] + + - file: "${FONT_DIR}/Roboto-500.ttf" + id: watch_value + size: ${FONT_WATCH_VALUE_SIZE} + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] + + # Clock card digits only; the date line reuses the tile title font. + - file: "${FONT_DIR}/Roboto-500.ttf" + id: clock_digits + size: ${FONT_CLOCK_DIGITS_SIZE} + bpp: 4 + glyphs: [':','-','0','1','2','3','4','5','6','7','8','9'] + + # The climate card's setpoint, large enough to read across the room. + - file: "${FONT_DIR}/Roboto-400.ttf" + id: setpoint_digits + size: ${FONT_SETPOINT_SIZE} + bpp: 4 + glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9'] + + - file: "${FONT_DIR}/Roboto-700.ttf" + id: label + size: ${FONT_LABEL_SIZE} + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] + + - file: "${FONT_DIR}/Roboto-400.ttf" + id: sublabel + size: ${FONT_SUBLABEL_SIZE} + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] + + - file: "${FONT_DIR}/Roboto-400.ttf" + id: sublabel_big + size: ${FONT_SUBLABEL_BIG_SIZE} + bpp: 4 + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', + '0','1','2','3','4','5','6','7','8','9', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–'] + + # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) + # Source: https://github.com/Templarian/MaterialDesign-Webfont + - file: "${FONT_DIR}/materialdesignicons-webfont.ttf" + id: materialdesign_icons + size: ${FONT_ICON_SIZE} + bpp: 4 + glyphs: &tile_icons # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py + - "\U000F0335" # lightbulb + - "\U000F1253" # lightbulb-group + - "\U000F0769" # ceiling-light + - "\U000F1793" # chandelier + - "\U000F06B5" # lamp + - "\U000F08DD" # floor-lamp + - "\U000F095F" # desk-lamp + - "\U000F091D" # wall-sconce-flat + - "\U000F179B" # light-recessed + - "\U000F04C9" # spotlight-beam + - "\U000F1051" # led-strip-variant + - "\U000F1054" # outdoor-lamp + - "\U000F02DC" # home + - "\U000F04B9" # sofa + - "\U000F02E3" # bed + - "\U000F0A70" # silverware-fork-knife + - "\U000F0B7C" # chef-hat + - "\U000F09A0" # shower + - "\U000F1818" # bathtub + - "\U000F09AB" # toilet + - "\U000F1239" # desk + - "\U000F06D9" # garage + - "\U000F0E2E" # fireplace + - "\U000F050F" # thermometer + - "\U000F0393" # thermostat + - "\U000F001B" # air-conditioner + - "\U000F0438" # radiator + - "\U000F1A43" # heat-pump + - "\U000F0238" # fire + - "\U000F0717" # snowflake + - "\U000F058E" # water-percent + - "\U000F0210" # fan + - "\U000F1797" # ceiling-fan + - "\U000F0D44" # air-purifier + - "\U000F07E4" # molecule-co2 + - "\U000F0599" # weather-sunny + - "\U000F0595" # weather-partly-cloudy + - "\U000F0590" # weather-cloudy + - "\U000F0597" # weather-rainy + - "\U000F0598" # weather-snowy + - "\U000F059D" # weather-windy + - "\U000F0594" # weather-night + - "\U000F059C" # weather-sunset-up + - "\U000F0387" # music-note + - "\U000F075A" # music + - "\U000F0CB8" # playlist-music + - "\U000F0025" # album + - "\U000F099A" # record-player + - "\U000F04C7" # spotify + - "\U000F0439" # radio + - "\U000F0994" # podcast + - "\U000F04C3" # speaker + - "\U000F0D38" # speaker-multiple + - "\U000F17DB" # soundbar + - "\U000F02CB" # headphones + - "\U000F067D" # piano + - "\U000F0771" # guitar-acoustic + - "\U000F0502" # television + - "\U000F042E" # projector + - "\U000F0FCE" # movie-open + - "\U000F0118" # cast + - "\U000F0297" # gamepad-variant + - "\U000F0EC5" # remote-tv + - "\U000F033E" # lock + - "\U000F0FC6" # lock-open-variant + - "\U000F068A" # shield-home + - "\U000F078F" # alarm-light + - "\U000F009A" # bell + - "\U000F12E6" # doorbell + - "\U000F07AE" # cctv + - "\U000F0D91" # motion-sensor + - "\U000F081B" # door-closed + - "\U000F081C" # door-open + - "\U000F11DB" # window-closed-variant + - "\U000F0392" # smoke-detector + - "\U000F1502" # water-alert + - "\U000F072A" # washing-machine + - "\U000F0917" # tumble-dryer + - "\U000F0AAC" # dishwasher + - "\U000F0290" # fridge + - "\U000F04DE" # stove + - "\U000F0C99" # microwave + - "\U000F0CD3" # toaster-oven + - "\U000F109F" # coffee-maker + - "\U000F05FA" # kettle + - "\U000F070D" # robot-vacuum + - "\U000F11F7" # robot-mower + - "\U000F042B" # printer-3d + - "\U000F06A5" # power-plug + - "\U000F07E7" # power-socket-eu + - "\U000F0322" # laptop + - "\U000F0469" # router-wireless + - "\U000F0241" # flash + - "\U000F0A72" # solar-power + - "\U000F0D9B" # solar-panel + - "\U000F1901" # home-battery + - "\U000F12A3" # battery-high + - "\U000F05F1" # ev-station + - "\U000F0B6C" # car-electric + - "\U000F1A57" # meter-electric + - "\U000F1A59" # meter-gas + - "\U000F058C" # water + - "\U000F0F92" # water-boiler + - "\U000F029A" # gauge + - "\U000F111C" # window-shutter + - "\U000F111E" # window-shutter-open + - "\U000F00AC" # blinds + - "\U000F1011" # blinds-open + - "\U000F1A6C" # roller-shade-closed + - "\U000F1846" # curtains + - "\U000F1847" # curtains-closed + - "\U000F1B88" # awning-outline + - "\U000F024A" # flower + - "\U000F0531" # tree + - "\U000F0E66" # sprout + - "\U000F1510" # grass + - "\U000F105F" # sprinkler + - "\U000F0606" # pool + - "\U000F0E45" # grill + - "\U000F06EE" # mailbox + - "\U000F0A79" # trash-can + - "\U000F044C" # recycle + - "\U000F0A43" # dog + - "\U000F011B" # cat + - "\U000F0004" # account + - "\U000F0849" # account-group + - "\U000F0A89" # account-child + - "\U000F0826" # home-account + - "\U000F04B2" # sleep + - "\U000F070E" # run + - "\U000F00D6" # briefcase + - "\U000F010B" # car + - "\U000F00A3" # bike + - "\U000F001D" # airplane + - "\U000F034E" # map-marker + - "\U000F0425" # power + - "\U000F0521" # toggle-switch + - "\U000F12A8" # gesture-tap-button + - "\U000F040A" # play + - "\U000F0BC2" # script-text + - "\U000F03D8" # palette + - "\U000F0020" # alarm + - "\U000F0150" # clock-outline + - "\U000F051B" # timer-outline + - "\U000F00ED" # calendar + - "\U000F009E" # bell-ring + - "\U000F02D1" # heart + - "\U000F002A" # alert-outline + - "\U000F0493" # cog + - "\U000F00E2" # broom + - "\U000F05D6" # alert-circle-outline + - "\U000F012C" # check + - "\U000F03E4" # pause + - "\U000F04DB" # stop + - "\U000F04AD" # skip-next + - "\U000F04AE" # skip-previous + - "\U000F057E" # volume-high + - "\U000F0581" # volume-off + - "\U000F005D" # arrow-up + - "\U000F0045" # arrow-down + - "\U000F084E" # arrow-expand-horizontal + - "\U000F084C" # arrow-collapse-horizontal + - "\U000F05F8" # home-map-marker + - "\U000F0415" # plus + - "\U000F0374" # minus + - "\U000F0141" # chevron-left + - "\U000F0142" # chevron-right + - "\U000F0156" # close + - "\U000F004D" # arrow-left + - "\U000F1A79" # sun-snowflake-variant + - "\U000F1B17" # thermostat-auto + - "\U000F0591" # weather-fog + - "\U000F0592" # weather-hail + - "\U000F0593" # weather-lightning + - "\U000F067E" # weather-lightning-rainy + - "\U000F0596" # weather-pouring + - "\U000F067F" # weather-snowy-rainy + - "\U000F059B" # weather-sunset-down + - "\U000F01D8" # dots-horizontal + - "\U000F1C91" # arrow-oscillating + - "\U000F0379" # monitor + - "\U000F02FD" # information-outline + - "\U000F0709" # restart + - "\U000F0E4F" # lightbulb-off + - "\U000F0B53" # account-arrow-right + - "\U000F0D43" # air-filter + - "\U000F1099" # air-humidifier + - "\U000F1466" # air-humidifier-off + - "\U000F0028" # alert-circle + - "\U000F0937" # angle-acute + - "\U000F0042" # arrow-bottom-left + - "\U000F0043" # arrow-bottom-right + - "\U000F06C0" # arrow-down-box + - "\U000F0E73" # arrow-left-right + - "\U000F0054" # arrow-right + - "\U000F093C" # arrow-split-vertical + - "\U000F005B" # arrow-top-left + - "\U000F005C" # arrow-top-right + - "\U000F06C3" # arrow-up-box + - "\U000F093D" # audio-video + - "\U000F11B6" # audio-video-off + - "\U000F0079" # battery + - "\U000F007A" # battery-10 + - "\U000F007B" # battery-20 + - "\U000F007C" # battery-30 + - "\U000F007D" # battery-40 + - "\U000F007E" # battery-50 + - "\U000F007F" # battery-60 + - "\U000F0080" # battery-70 + - "\U000F0081" # battery-80 + - "\U000F0082" # battery-90 + - "\U000F0083" # battery-alert + - "\U000F0084" # battery-charging + - "\U000F008E" # battery-outline + - "\U000F0091" # battery-unknown + - "\U000F1A2B" # blinds-horizontal + - "\U000F1A2C" # blinds-horizontal-closed + - "\U000F00AF" # bluetooth + - "\U000F00B1" # bluetooth-connect + - "\U000F00DE" # brightness-5 + - "\U000F00E0" # brightness-7 + - "\U000F1B50" # button-pointer + - "\U000F010C" # car-battery + - "\U000F1019" # car-coolant-level + - "\U000F0114" # cash + - "\U000F0119" # cast-connected + - "\U000F078A" # cast-off + - "\U000F05E0" # check-circle + - "\U000F05E1" # check-circle-outline + - "\U000F0C54" # check-network-outline + - "\U000F0133" # checkbox-marked-circle + - "\U000F0AA5" # circle-slice-8 + - "\U000F0954" # clock + - "\U000F0155" # clock-start + - "\U000F015A" # close-circle-outline + - "\U000F0C5F" # close-network-outline + - "\U000F1382" # compass-rose + - "\U000F01A1" # crop-portrait + - "\U000F1136" # crosshairs-question + - "\U000F1480" # current-ac + - "\U000F01BC" # database + - "\U000F07C5" # ear-hearing + - "\U000F0208" # eye + - "\U000F0D04" # eye-check + - "\U000F081D" # fan-off + - "\U000F0279" # format-list-bulleted + - "\U000F06DA" # garage-open + - "\U000F0299" # gate + - "\U000F116A" # gate-open + - "\U000F06A1" # home-outline + - "\U000F0318" # lan-connect + - "\U000F0319" # lan-disconnect + - "\U000F140B" # lightning-bolt + - "\U000F08EE" # lock-alert + - "\U000F097F" # lock-clock + - "\U000F033F" # lock-open + - "\U000F0BAC" # molecule + - "\U000F12FE" # molecule-co + - "\U000F1435" # motion-sensor-off + - "\U000F038A" # music-note-off + - "\U000F03C3" # octagon + - "\U000F03D3" # package + - "\U000F03D5" # package-up + - "\U000F17C5" # ph + - "\U000F184D" # pipe-valve + - "\U000F06A6" # power-plug-off + - "\U000F0996" # progress-clock + - "\U000F1A23" # projector-off + - "\U000F043C" # radioactive + - "\U000F0445" # ray-vertex + - "\U000F1A6B" # roller-shade + - "\U000F1727" # script-text-play + - "\U000F0483" # security + - "\U000F0498" # shield + - "\U000F06BB" # shield-airplane + - "\U000F099D" # shield-lock + - "\U000F1828" # shield-moon + - "\U000F099E" # shield-off + - "\U000F0499" # shield-outline + - "\U000F095B" # sine-wave + - "\U000F192E" # smoke-detector-alert + - "\U000F180B" # smoke-detector-variant + - "\U000F1930" # smoke-detector-variant-alert + - "\U000F04C4" # speaker-off + - "\U000F1B73" # speaker-pause + - "\U000F1B72" # speaker-play + - "\U000F04C5" # speedometer + - "\U000F0674" # creation + - "\U000F0E0C" # palette-outline + - "\U000F0411" # playlist-play + - "\U000F08FF" # play-speed + - "\U000F00DF" # brightness-6 + - "\U000F1429" # spoon-sugar + - "\U000F0E67" # sprout-outline + - "\U000F0764" # square + - "\U000F0763" # square-outline + - "\U000F1A75" # storage-tank + - "\U000F17FE" # sun-wireless + - "\U000F083B" # television-off + - "\U000F0F89" # television-pause + - "\U000F0ECF" # television-play + - "\U000F0FE6" # texture-box + - "\U000F0510" # thermometer-lines + - "\U000F1A25" # toggle-switch-variant + - "\U000F1A26" # toggle-switch-variant-off + - "\U000F0D3E" # transmission-tower + - "\U000F0566" # vibrate + - "\U000F11B4" # water-boiler-off + - "\U000F058D" # water-off + - "\U000F1855" # water-opacity + - "\U000F05A1" # weight + - "\U000F05A9" # wifi + - "\U000F05AE" # window-closed + - "\U000F05B1" # window-open + + - file: "${FONT_DIR}/materialdesignicons-webfont.ttf" + id: materialdesign_icons_mini + size: ${FONT_ICON_MINI_SIZE} + bpp: 4 + glyphs: *tile_icons + + # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, + # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. + - file: "${FONT_DIR}/materialdesignicons-webfont.ttf" + id: materialdesign_icons_big + size: ${FONT_ICON_BIG_SIZE} + bpp: 4 + glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py + - "\U000F0335" # lightbulb + - "\U000F001B" # air-conditioner + - "\U000F070D" # robot-vacuum + - "\U000F0210" # fan + - "\U000F111C" # window-shutter + - "\U000F04B9" # sofa + - "\U000F029A" # gauge + - "\U000F051B" # timer-outline + - "\U000F0004" # account + - "\U000F07AE" # cctv + - "\U000F0150" # clock-outline + - "\U000F0493" # cog + - "\U000F0054" # arrow-right + - "\U000F0E4F" # lightbulb-off + - "\U000F0425" # power + - "\U000F0599" # weather-sunny + - "\U000F0594" # weather-night + - "\U000F0590" # weather-cloudy + - "\U000F0595" # weather-partly-cloudy + - "\U000F0597" # weather-rainy + - "\U000F0596" # weather-pouring + - "\U000F0598" # weather-snowy + - "\U000F067F" # weather-snowy-rainy + - "\U000F0591" # weather-fog + - "\U000F0592" # weather-hail + - "\U000F0593" # weather-lightning + - "\U000F067E" # weather-lightning-rainy + - "\U000F059D" # weather-windy + - "\U000F05D6" # alert-circle-outline + - "\U000F059C" # weather-sunset-up + - "\U000F059B" # weather-sunset-down + + - file: "${FONT_DIR}/materialdesignicons-webfont.ttf" + id: watch_icon + size: ${FONT_WATCH_ICON_SIZE} + bpp: 4 + glyphs: *tile_icons + +# Make memory visible: this board has no PSRAM, so the free heap determines +# how much UI still fits. Also shows up as an entity in Home Assistant. +debug: + # Heap figures every five minutes: enough to spot a leak, few recorder rows. + update_interval: 300s + +sensor: + - platform: debug + free: + name: "Heap Free" + id: heap_free + on_value: + - lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);' + fragmentation: + name: "Heap Fragmentation" + block: + name: "Heap Largest Block" + on_value: + - lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);' + # The lowest free heap since boot: what the busiest moment (boot sync, a swipe) left over. + min_free: + name: "Heap Minimum Free" + on_value: + - lambda: 'ESP_LOGI("health", "min_free=%.0f", x);' + - platform: wifi_signal + name: "Wifi Signal" + update_interval: 600s + + # When the screen booted, as a timestamp: one value per boot instead of a new recorder + # row every 15 s. A new entity on purpose: Home Assistant keeps the old Uptime's unit + # "s" in its registry and would refuse a timestamp under that name. + - platform: uptime + type: timestamp + name: "Last boot" + id: boot_time + + +binary_sensor: + - platform: status + name: "Node Status" + id: system_status + +# ---------------------------------------------------------- +# Text sensors +# +# smartdisplay_action: +# - Main touch event channel exported to Home Assistant +# - On short tap, publishes one of: +# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press +# - The value is reset shortly after publishing +# +# tile*_preset: +# - Mirrors the Home Assistant preset_mode attribute for each tile entity +# - Used by the UI to display fan presets when applicable +# ---------------------------------------------------------- +text_sensor: + - platform: template + name: "Screen firmware" + entity_category: diagnostic + lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' + update_interval: 60s + # ESP Screen Manager matches the screen to its YAML profile and OTA address. + - platform: template + name: "Device name" + entity_category: diagnostic + lambda: 'return {"${DEVICE_NAME}"};' + update_interval: 60s + - platform: wifi_info + ip_address: + # `id` so the settings page on the screen can show the same address (0.2.44+). + id: wifi_ip + name: "IP address" + entity_category: diagnostic + +switch: + - platform: restart + name: "Restart" + # The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen + # owns them: the settings page, these entities and ESP Screens all change them through + # settings_screen::set(), which saves, applies and reports a real change only. The saved values keep + # their preference records. A template switch reads its lambda every loop and publishes a change itself; + # the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the + # standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that + # moment. Nothing runs at boot: every switch follows the saved setting. + - platform: template + id: setting_auto_standby + name: "Auto standby" + icon: "mdi:sleep" + entity_category: config + restore_mode: DISABLED + lambda: return screen_settings::current.standby_enabled; + turn_on_action: + - lambda: |- + id(last_touch_ms) = millis(); + settings_screen::set("standby_enabled", 1); + turn_off_action: + - lambda: 'settings_screen::set("standby_enabled", 0);' + - platform: template + id: setting_night_mode + name: "Night mode" + icon: "mdi:weather-night" + entity_category: config + restore_mode: DISABLED + lambda: return screen_settings::current.night_enabled; + turn_on_action: + - lambda: 'settings_screen::set("night_enabled", 1);' + turn_off_action: + - lambda: 'settings_screen::set("night_enabled", 0);' + # The dark look for a screen beside a bed (firmware 0.2.54+): black page, graphite cards, soft white text. + - platform: template + id: setting_dark_mode + name: "Dark mode" + icon: "mdi:theme-light-dark" + entity_category: config + restore_mode: DISABLED + lambda: return settings_screen::dark_mode != 0; + turn_on_action: + - lambda: 'settings_screen::set("dark_mode", 1);' + turn_off_action: + - lambda: 'settings_screen::set("dark_mode", 0);' + - platform: template + id: setting_clock_24h + name: "24-hour clock" + icon: "mdi:clock-outline" + entity_category: config + restore_mode: DISABLED + lambda: return screen_settings::current.clock_24h; + turn_on_action: + - lambda: 'settings_screen::set("clock_24h", 1);' + turn_off_action: + - lambda: 'settings_screen::set("clock_24h", 0);' + - platform: template + id: setting_auto_home + name: "Back to page 1" + icon: "mdi:home-import-outline" + entity_category: config + restore_mode: DISABLED + lambda: return settings_screen::auto_home != 0; + turn_on_action: + - lambda: 'settings_screen::set("auto_home", 1);' + turn_off_action: + - lambda: 'settings_screen::set("auto_home", 0);' + - platform: template + id: setting_home_on_standby + name: "Back to page 1 on standby" + icon: "mdi:home-import-outline" + entity_category: config + restore_mode: DISABLED + lambda: return screen_settings::current.home_on_standby; + turn_on_action: + - lambda: 'settings_screen::set("home_on_standby", 1);' + turn_off_action: + - lambda: 'settings_screen::set("home_on_standby", 0);' + - platform: template + id: setting_swipe_pages + name: "Swipe between pages" + icon: "mdi:gesture-swipe-horizontal" + entity_category: config + restore_mode: DISABLED + lambda: return settings_screen::swipe_pages != 0; + turn_on_action: + - lambda: 'settings_screen::set("swipe_pages", 1);' + turn_off_action: + - lambda: 'settings_screen::set("swipe_pages", 0);' + # Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+). + - platform: template + id: setting_page_buttons + name: "Page buttons" + icon: "mdi:page-next-outline" + entity_category: config + restore_mode: DISABLED + lambda: return settings_screen::page_buttons != 0; + turn_on_action: + - lambda: 'settings_screen::set("page_buttons", 1);' + turn_off_action: + - lambda: 'settings_screen::set("page_buttons", 0);' + +# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight. +datetime: + - platform: template + type: time + id: setting_night_start + name: "Night starts" + icon: "mdi:weather-sunset-down" + entity_category: config + update_interval: never + lambda: |- + ESPTime time{}; + time.hour = screen_settings::current.night_start / 60; + time.minute = screen_settings::current.night_start % 60; + return time; + set_action: + - lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);' + - platform: template + type: time + id: setting_night_end + name: "Night ends" + icon: "mdi:weather-sunset-up" + entity_category: config + update_interval: never + lambda: |- + ESPTime time{}; + time.hour = screen_settings::current.night_end / 60; + time.minute = screen_settings::current.night_end % 60; + return time; + set_action: + - lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);' + +lvgl: + # The theme's font for text without a font of its own. Every label names its font, so this only keeps + # ESPHome from building LVGL's Montserrat 14 for nothing (firmware 0.2.65+). + default_font: ${LVGL_DEFAULT_FONT} + on_draw_start: + - lambda: 'id(rendered_frames)++;' + displays: + - my_display + touchscreens: + - touchscreen_id: ts_touch + + buffer_size: ${LVGL_BUFFER_SIZE} + + # Direct local feedback on the WHOLE UI, not just the tiles. + # A theme sets the style per widget type, so this covers all + # buttons, tabs, sliders and the thermostat ring in one go. Non-clickable + # widgets never enter the pressed state, so those stay untouched. + theme: + obj: + pressed: + # bg_opa and not opa: opa would also fade the icon and text. Styles that + # make LVGL draw into a buffer of its own (opa_layered, rotation/scale, + # clip_corner, a slider fill rounded less than its track) cost KB's per + # redraw that the CYD's heap can't spare; tests/test_layer_free.py. + bg_opa: 45% + slider: + knob: + pressed: + bg_opa: 65% + arc: + knob: + pressed: + bg_opa: 65% + + # LVGL owns the rotation (display + touch) since ESPHome 2026.4. + rotation: ${LVGL_ROTATION} + + style_definitions: + # The page behind the tiles and the cards: one colour from the theme (theme.h PAGE), no wallpaper and no + # gradient, so a redraw is a plain fill. + - id: style_page + bg_opa: COVER + border_width: 0 + radius: 0 + + - id: style_tile + bg_opa: COVER + radius: ${TILE_RADIUS} + border_width: 1 + border_opa: COVER + pad_left: ${TILE_PAD} + pad_right: ${TILE_PAD} + pad_top: ${TILE_PAD} + pad_bottom: ${TILE_PAD} + shadow_width: 0 + shadow_opa: 40% + + - id: style_icon_circle + bg_opa: COVER + radius: 999 + border_width: 0 + + - id: style_room + text_font: headline + text_opa: 100% + + - id: style_time + text_font: time_label + text_opa: 100% + + - id: style_title + text_font: label + text_opa: 100% + + - id: style_value + text_font: sublabel + text_opa: 100% + + - id: style_value_big + text_font: sublabel_big + text_opa: 80% + + # Paints: shared styles without a colour of their own. theme.h fills them (the list in on_boot, theme::paints) + # and fills them again when Dark mode changes, so a widget names a paint where it used to write a colour. + - id: paint_page_soft + - id: paint_page_pressed + - id: paint_card + - id: paint_raised + - id: paint_key + - id: paint_key_pressed + - id: paint_track + - id: paint_ink + - id: paint_ink_soft + - id: paint_muted + - id: paint_subtle + - id: paint_slate + - id: paint_scrim + - id: paint_veil + - id: paint_accent_tint + - id: paint_accent_bright + - id: paint_button_dark + - id: paint_button_dark_pressed + + # ---------- ALERT OVERLAY (HA action show_alert) ---------- + # LVGL's top layer sits above every page, card and the dim overlay; the card + # is filled by the alert_show script. Fixed positions, no layout engine. + top_layer: + widgets: + - obj: + id: alert_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + styles: paint_scrim + bg_opa: 60% + radius: 0 + border_width: 0 + pad_all: 0 + clickable: true + scrollable: false + scrollbar_mode: 'OFF' + # Same look when pressed: a tap next to the card must not repaint the screen. + pressed: + bg_opa: 60% + widgets: + - obj: + id: alert_card + align: CENTER + width: ${ALERT_CARD_W} + height: ${ALERT_CARD_H} + styles: paint_raised + bg_opa: COVER + radius: 18 + border_width: 1 + border_opa: COVER + shadow_width: 0 + pad_all: 0 + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: alert_icon + x: ${ALERT_ICON_X} + y: ${ALERT_ICON_Y} + text: "\U000F002A" + text_font: materialdesign_icons + clickable: false + - label: + id: alert_title + x: ${ALERT_TEXT_X} + y: ${ALERT_TITLE_Y} + width: ${ALERT_TEXT_W} + height: ${ALERT_TITLE_H} + long_mode: DOT + text: "Notification" + text_font: headline + clickable: false + - label: + id: alert_subtitle + x: ${ALERT_TEXT_X} + y: ${ALERT_SUBTITLE_Y} + width: ${ALERT_TEXT_W} + height: ${ALERT_SUBTITLE_H} + long_mode: WRAP + text: "" + text_font: sublabel_big + styles: paint_ink_soft + clickable: false + - obj: + id: alert_ok + align: BOTTOM_RIGHT + x: -${ALERT_BUTTON_INSET} + y: -${ALERT_BUTTON_INSET} + width: ${ALERT_BUTTON_W} + height: ${ALERT_BUTTON_H} + radius: 999 + styles: paint_button_dark + bg_opa: COVER + border_width: 0 + shadow_width: 0 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_button_dark_pressed + bg_opa: COVER + on_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept(millis(), 13);' + then: + - script.execute: + id: alert_dismiss + reason: "ok" + widgets: + - label: + id: alert_ok_label + align: CENTER + text: "OK" + text_font: headline + clickable: false + + pages: + - id: home_page + styles: style_page + pad_all: 0 + scrollable: false + scrollbar_mode: 'OFF' + widgets: + # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. + - spinner: + id: busy_spinner_seed + hidden: true + width: 2 + height: 2 + spin_time: 1s + arc_length: 60deg + # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from + # (firmware 0.2.70+). + - roller: + id: roller_seed + hidden: true + width: 2 + height: 2 + options: ["-"] + - label: + id: lbl_room + x: ${HEADER_INSET} + y: ${HEADER_Y} + text: "${ROOM_NAME}" + styles: style_room + + # Enables LVGL's line widget for the runtime clock hands and sparklines. + - line: + id: runtime_line_enable + hidden: true + points: + - 0, 0 + - 1, 1 + + - label: + id: lbl_time + align: TOP_RIGHT + x: -${HEADER_INSET} + y: ${HEADER_Y} + text: "" + styles: style_time + + # Six tiles on page 1, four on page 2. No dragging on resistive glass. + - obj: + id: tile_scroll + x: 0 + y: ${SCROLL_Y} + width: ${DISPLAY_W} + height: ${SCROLL_H} + bg_opa: TRANSP + # A tap between two tiles presses this area: without its own pressed style the + # theme's 45 % lights it up as a pale panel behind the tiles. + pressed: + bg_opa: TRANSP + border_width: 0 + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + scrollable: false + scrollbar_mode: 'OFF' + widgets: + # ---------- TILE 1 ---------- + - obj: + id: tile1 + x: ${GRID_COL1_X} + y: ${GRID_ROW1_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Direct local feedback: LVGL applies this as soon as the finger + # touches the glass, in the same frame. Independent of Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile1_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile1_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F001B" + clickable: false + - label: + id: t1_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "Lamp" + styles: style_title + clickable: false + - label: + id: t1_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 2 ---------- + - obj: + id: tile2 + x: ${GRID_COL2_X} + y: ${GRID_ROW1_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Direct local feedback: LVGL applies this as soon as the finger + # touches the glass, in the same frame. Independent of Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile2_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile2_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F0387" + clickable: false + - label: + id: t2_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "Plug" + styles: style_title + clickable: false + - label: + id: t2_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 3 ---------- + - obj: + id: tile3 + x: ${GRID_COL1_X} + y: ${GRID_ROW2_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Direct local feedback: LVGL applies this as soon as the finger + # touches the glass, in the same frame. Independent of Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile3_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile3_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F02E3" + clickable: false + - label: + id: t3_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "Evening" + styles: style_title + clickable: false + - label: + id: t3_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 4 ---------- + - obj: + id: tile4 + x: ${GRID_COL2_X} + y: ${GRID_ROW2_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Direct local feedback: LVGL applies this as soon as the finger + # touches the glass, in the same frame. Independent of Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile4_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile4_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F059C" + clickable: false + - label: + id: t4_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "All off" + styles: style_title + clickable: false + - label: + id: t4_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 5 ---------- + - obj: + id: tile5 + x: ${GRID_COL1_X} + y: ${GRID_ROW3_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Direct local feedback: LVGL applies this as soon as the finger + # touches the glass, in the same frame. Independent of Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile5_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile5_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F0425" + clickable: false + - label: + id: t5_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "AC" + styles: style_title + clickable: false + - label: + id: t5_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 6 ---------- + - obj: + id: tile6 + x: ${GRID_COL2_X} + y: ${GRID_ROW3_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + # Direct local feedback: LVGL applies this as soon as the finger + # touches the glass, in the same frame. Independent of Home Assistant. + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile6_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile6_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F070D" + clickable: false + - label: + id: t6_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "Vacuum" + styles: style_title + clickable: false + - label: + id: t6_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 7 — Table lamp ---------- + # Brightness-only lamp: deliberately no percentage/cover/climate + # sensors were created for this tile, those would only cost + # HA subscriptions and heap. The overlay therefore gets + # NAN / "" for the fields a light never uses. + - obj: + id: tile7 + hidden: true + x: ${GRID_COL1_X} + y: ${GRID_ROW4_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile7_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile7_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F06B5" + clickable: false + - label: + id: t7_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "Tile 7" + styles: style_title + clickable: false + - label: + id: t7_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 8 — Mailbox reset ---------- + # input_select with options Full/Empty. The tile lights up as soon as + # the status is TILE8_ACTIVE_STATE ("Full"); a short tap sends + # input_select.select_option with option: "Empty". + # No slider mode, so this tile only needs ha_state_tile8 — + # no other HA subscription at all. + - obj: + id: tile8 + hidden: true + x: ${GRID_COL2_X} + y: ${GRID_ROW4_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile8_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile8_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F06EE" + clickable: false + - label: + id: t8_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "Tile 8" + styles: style_title + clickable: false + - label: + id: t8_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 9 — Living room (moved from tile 2) ---------- + - obj: + id: tile9 + hidden: true + x: ${GRID_COL1_X} + y: ${GRID_ROW5_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile9_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile9_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F04B9" + clickable: false + - label: + id: t9_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "Tile 9" + styles: style_title + clickable: false + - label: + id: t9_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- TILE 10 — Alarm on/off ---------- + - obj: + id: tile10 + hidden: true + x: ${GRID_COL2_X} + y: ${GRID_ROW5_Y} + width: ${TILE_W} + height: ${TILE_H} + styles: style_tile + scrollable: false + scrollbar_mode: 'OFF' + pressed: + bg_opa: 45% + widgets: + - obj: + id: tile10_icon_circle + x: ${TILE_ICON_X} + y: ${TILE_ICON_Y} + width: ${TILE_ICON_SIZE} + height: ${TILE_ICON_SIZE} + styles: style_icon_circle + clickable: false + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + id: tile10_icon_lbl + align: CENTER + text_font: materialdesign_icons + text: "\U000F002A" + clickable: false + - label: + id: t10_title + x: ${TILE_TITLE_X} + y: ${TILE_TITLE_Y} + text: "Tile 10" + styles: style_title + clickable: false + - label: + id: t10_value + x: ${TILE_VALUE_X} + y: ${TILE_VALUE_Y} + text: "—" + styles: style_value + clickable: false + + # ---------- VALUE SLIDER OVERLAY ---------- + # ---------- PAGE NAVIGATION: the band under the tiles ---------- + # Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the + # page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge. + - obj: + id: page_prev + align: BOTTOM_LEFT + x: 0 + y: 0 + width: ${PAGE_KEY_W} + height: ${PAGE_BAR_H} + pad_all: 0 + radius: 0 + bg_opa: TRANSP + border_width: 0 + scrollable: false + pressed: + styles: paint_page_pressed + bg_opa: COVER + # on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing + # (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe. + on_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);' + then: + - lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);' + - script.execute: show_tile_page + widgets: + - label: + align: LEFT_MID + x: ${PAGE_CHEVRON_INSET} + text: "\U000F0141" # chevron-left + text_font: materialdesign_icons_mini + styles: paint_ink + clickable: false + - obj: + id: page_number + align: BOTTOM_MID + y: 0 + width: ${PAGE_NUMBER_W} + height: ${PAGE_BAR_H} + pad_all: 0 + bg_opa: TRANSP + border_width: 0 + clickable: false + scrollable: false + - obj: + id: page_next + align: BOTTOM_RIGHT + x: 0 + y: 0 + width: ${PAGE_KEY_W} + height: ${PAGE_BAR_H} + pad_all: 0 + radius: 0 + bg_opa: TRANSP + border_width: 0 + scrollable: false + pressed: + styles: paint_page_pressed + bg_opa: COVER + on_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);' + then: + - lambda: 'id(tile_page) += 1;' + - script.execute: show_tile_page + widgets: + - label: + align: RIGHT_MID + x: -${PAGE_CHEVRON_INSET} + text: "\U000F0142" # chevron-right + text_font: materialdesign_icons_mini + styles: paint_ink + clickable: false + + - obj: + id: brightness_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + bg_opa: TRANSP + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: brightness_overlay_bg + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: style_page + scrollable: false + clickable: true + on_click: + - script.execute: close_cards + + - obj: + id: brightness_overlay_dim + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: paint_veil + bg_opa: 18% + border_width: 0 + clickable: false + scrollable: false + + - obj: + id: overlay_back_button + align: TOP_LEFT + x: ${CARD_KEY_X} + y: ${CARD_KEY_Y} + width: ${CARD_KEY_SIZE} + height: ${CARD_KEY_SIZE} + radius: 999 + styles: paint_key + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - script.execute: close_cards + widgets: + - label: + align: CENTER + text: "\U000F004D" + text_font: materialdesign_icons_mini + clickable: false + + - label: + id: overlay_title + align: TOP_MID + y: ${OVERLAY_TITLE_Y} + text: "Light" + styles: style_room + + - label: + id: overlay_value + align: TOP_MID + y: ${OVERLAY_VALUE_Y} + text: "100 %" + styles: style_value_big + + - slider: + id: overlay_slider + align: CENTER + y: ${OVERLAY_SLIDER_Y} + width: ${OVERLAY_SLIDER_W} + height: ${OVERLAY_SLIDER_H} + min_value: 0 + max_value: 100 + value: 100 + adv_hittest: false + + styles: paint_accent_tint + bg_opa: 100% + radius: ${OVERLAY_SLIDER_RADIUS} + # The only widget with BOTH a radius AND a child (overlay_icon). + # LVGL then clips children to the rounding, and that's only + # possible via a contiguous 96x160 = 46KB ARGB layer. + # If that allocation fails, LVGL keeps trying until the + # task watchdog restarts the board. The rounding stays, the + # clipping goes. + clip_corner: false + border_width: 0 + + indicator: + styles: paint_accent_bright + bg_opa: 100% + radius: ${OVERLAY_SLIDER_RADIUS} + + knob: + bg_opa: 0% + border_width: 0 + shadow_width: 0 + pad_all: ${OVERLAY_KNOB_PAD} + + on_value: + - script.execute: + id: slider_preview + value: !lambda "return x;" + + on_release: + - script.execute: + id: slider_commit + value: !lambda "return lv_slider_get_value(id(overlay_slider));" + + + # The icon used to be a CHILD in the slider. A widget with a radius + # and children has to clip those children to the rounding, and + # LVGL can only do that via an ARGB layer of tens of KB's. Without + # PSRAM that sometimes fails, after which LVGL keeps retrying until + # the watchdog restarts the board. As a sibling on top, no layer + # is needed and it looks identical. + - label: + id: overlay_icon + align: CENTER + y: ${OVERLAY_ICON_Y} + text_font: materialdesign_icons + styles: paint_ink + text: "\U000F001B" + clickable: false + + - obj: + id: overlay_color_button + align: BOTTOM_RIGHT + x: ${COLOR_KEY_X} + y: ${COLOR_KEY_Y} + width: ${COLOR_KEY_SIZE} + height: ${COLOR_KEY_SIZE} + radius: 999 + hidden: true + clickable: true + bg_opa: TRANSP + border_width: 0 + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + scrollable: false + scrollbar_mode: 'OFF' + on_click: + - script.execute: open_color_detail + widgets: + - arc: + align: CENTER + width: ${COLOR_ARC_SIZE} + height: ${COLOR_ARC_SIZE} + start_angle: 0 + end_angle: 45 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: ${COLOR_ARC_W} + arc_rounded: false + arc_color: 0xFF3B30 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: ${COLOR_ARC_SIZE} + height: ${COLOR_ARC_SIZE} + start_angle: 45 + end_angle: 90 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: ${COLOR_ARC_W} + arc_rounded: false + arc_color: 0xFF9500 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: ${COLOR_ARC_SIZE} + height: ${COLOR_ARC_SIZE} + start_angle: 90 + end_angle: 135 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: ${COLOR_ARC_W} + arc_rounded: false + arc_color: 0xFFD60A + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: ${COLOR_ARC_SIZE} + height: ${COLOR_ARC_SIZE} + start_angle: 135 + end_angle: 180 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: ${COLOR_ARC_W} + arc_rounded: false + arc_color: 0x30D158 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: ${COLOR_ARC_SIZE} + height: ${COLOR_ARC_SIZE} + start_angle: 180 + end_angle: 225 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: ${COLOR_ARC_W} + arc_rounded: false + arc_color: 0x32D6FF + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: ${COLOR_ARC_SIZE} + height: ${COLOR_ARC_SIZE} + start_angle: 225 + end_angle: 270 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: ${COLOR_ARC_W} + arc_rounded: false + arc_color: 0x0A84FF + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: ${COLOR_ARC_SIZE} + height: ${COLOR_ARC_SIZE} + start_angle: 270 + end_angle: 315 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: ${COLOR_ARC_W} + arc_rounded: false + arc_color: 0x5856D6 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + - arc: + align: CENTER + width: ${COLOR_ARC_SIZE} + height: ${COLOR_ARC_SIZE} + start_angle: 315 + end_angle: 360 + min_value: 0 + max_value: 100 + value: 0 + adjustable: false + arc_width: ${COLOR_ARC_W} + arc_rounded: false + arc_color: 0xBF5AF2 + arc_opa: COVER + bg_opa: TRANSP + border_width: 0 + clickable: false + indicator: + arc_opa: TRANSP + knob: + bg_opa: TRANSP + border_width: 0 + + # ---------- LIGHT COLOR DETAIL OVERLAY ---------- + - obj: + id: color_detail_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: paint_page_soft + bg_opa: COVER + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: color_close_button + align: TOP_LEFT + x: ${CARD_KEY_X} + y: ${CARD_KEY_Y} + width: ${CARD_KEY_SIZE} + height: ${CARD_KEY_SIZE} + radius: 999 + styles: paint_key + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - script.execute: close_cards + widgets: + - label: + align: CENTER + text: "\U000F004D" + text_font: materialdesign_icons_mini + clickable: false + + # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette, + # preset, speed), opens its effects page. Hidden for every other light. + - obj: + id: color_effects_button + align: TOP_RIGHT + x: -${CARD_KEY_X} + y: ${CARD_KEY_Y} + width: ${CARD_KEY_SIZE} + height: ${CARD_KEY_SIZE} + radius: 999 + styles: paint_key + bg_opa: COVER + border_width: 0 + hidden: true + clickable: true + scrollable: false + on_click: + - lambda: 'effects_page::open(id(active_entity));' + widgets: + - label: + align: CENTER + text: "\U000F0674" + text_font: materialdesign_icons_mini + clickable: false + + - label: + id: color_detail_title + width: ${COLOR_TITLE_W} + long_mode: DOT + text_align: CENTER + align: TOP_MID + y: ${COLOR_TITLE_Y} + text: "Light Color" + styles: style_room + + + # ---------- CLIMATE DETAIL OVERLAY ---------- + # Made for the small resistive panel: nothing to drag. A setpoint card with big -/+ keys + # (the number changes at once, one call goes out once the taps stop) and one row of mode + # keys like Home Assistant's climate card; fan and swing stay in the picker behind "more". + # A tap redraws only a label or a key, never a dial, so the SPI panel keeps up. + - obj: + id: climate_detail_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + styles: style_page + bg_opa: COVER + hidden: true + scrollable: false + scrollbar_mode: 'OFF' + pad_left: 0 + pad_right: 0 + pad_top: 0 + pad_bottom: 0 + border_width: 0 + widgets: + - obj: + id: climate_close_button + align: TOP_LEFT + x: ${CARD_KEY_X} + y: ${CARD_KEY_Y} + width: ${CARD_KEY_SIZE} + height: ${CARD_KEY_SIZE} + radius: 999 + styles: paint_key + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - script.execute: close_cards + widgets: + - label: + align: CENTER + text: "\U000F004D" + text_font: materialdesign_icons_mini + clickable: false + + - obj: + id: climate_power_button + align: TOP_RIGHT + x: -${CARD_KEY_X} + y: ${CARD_KEY_Y} + width: ${CARD_KEY_SIZE} + height: ${CARD_KEY_SIZE} + radius: 999 + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - script.execute: climate_power_toggle + widgets: + - label: + id: climate_power_icon + align: CENTER + text: "\U000F0425" + text_font: materialdesign_icons_mini + clickable: false + + - label: + id: climate_detail_title + align: TOP_MID + y: ${CLIMATE_TITLE_Y} + width: ${CLIMATE_TITLE_W} + long_mode: DOT + text_align: CENTER + text: "Climate" + styles: style_room + + # What the device does (or its mode), the room temperature and humidity. + - label: + id: climate_current_label + align: TOP_MID + y: ${CLIMATE_STATUS_Y} + width: 94% + long_mode: DOT + text_align: CENTER + text: "--" + text_font: sublabel_big + styles: paint_muted + clickable: false + + - obj: + id: climate_setpoint_card + align: TOP_MID + y: ${CLIMATE_CARD_Y} + width: 94% + height: ${CLIMATE_CARD_H} + radius: ${CLIMATE_CARD_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + clickable: false + scrollable: false + widgets: + # Every clean tap counts (a quick triple tap is three steps); holding keeps stepping. + - obj: + id: climate_minus_button + align: LEFT_MID + x: ${CLIMATE_KEY_INSET} + width: ${CLIMATE_KEY_SIZE} + height: ${CLIMATE_KEY_SIZE} + radius: ${CLIMATE_KEY_RADIUS} + styles: paint_track + bg_opa: COVER + border_width: 0 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key_pressed + disabled: + opa: 40% + on_short_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept_repeat(millis(), 60);' + then: + - script.execute: { id: climate_adjust_target, direction: -1, held: false } + on_long_press_repeat: + - script.execute: { id: climate_adjust_target, direction: -1, held: true } + widgets: + - label: + id: climate_minus_icon + align: CENTER + text: "\U000F0374" + text_font: materialdesign_icons + clickable: false + - obj: + id: climate_plus_button + align: RIGHT_MID + x: -${CLIMATE_KEY_INSET} + width: ${CLIMATE_KEY_SIZE} + height: ${CLIMATE_KEY_SIZE} + radius: ${CLIMATE_KEY_RADIUS} + styles: paint_track + bg_opa: COVER + border_width: 0 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key_pressed + disabled: + opa: 40% + on_short_click: + - if: + condition: + lambda: 'return cyd::touch_guard.accept_repeat(millis(), 61);' + then: + - script.execute: { id: climate_adjust_target, direction: 1, held: false } + on_long_press_repeat: + - script.execute: { id: climate_adjust_target, direction: 1, held: true } + widgets: + - label: + id: climate_plus_icon + align: CENTER + text: "\U000F0415" + text_font: materialdesign_icons + clickable: false + - label: + id: climate_target_label + align: CENTER + y: ${CLIMATE_NUMBER_Y} + text: "--" + text_font: setpoint_digits + clickable: false + - label: + id: climate_set_label + align: CENTER + y: ${CLIMATE_CAPTION_Y} + text: "Target" + text_font: sublabel + styles: paint_subtle + clickable: false + + # One key per mode the device supports (off is the power key); the one in use is + # filled in Home Assistant's colour for it. "More" opens the picker with fan and swing. + - obj: + id: climate_mode_keys + align: TOP_MID + y: ${CLIMATE_MODES_Y} + width: 94% + height: ${CLIMATE_MODES_H} + bg_opa: TRANSP + border_width: 0 + pad_all: 0 + clickable: false + scrollable: false + layout: + type: FLEX + flex_flow: ROW + flex_align_main: CENTER + flex_align_cross: CENTER + pad_column: ${CLIMATE_MODES_GAP} + widgets: + - obj: + id: cmkey_auto + flex_grow: 1 + height: ${CLIMATE_MODES_H} + radius: ${CLIMATE_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key + on_click: + - script.execute: { id: set_climate_mode, mode: "auto" } + widgets: + - label: + align: CENTER + text: "\U000F1B17" + text_font: materialdesign_icons + clickable: false + - obj: + id: cmkey_heat_cool + flex_grow: 1 + height: ${CLIMATE_MODES_H} + radius: ${CLIMATE_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key + on_click: + - script.execute: { id: set_climate_mode, mode: "heat_cool" } + widgets: + - label: + align: CENTER + text: "\U000F1A79" + text_font: materialdesign_icons + clickable: false + - obj: + id: cmkey_heat + flex_grow: 1 + height: ${CLIMATE_MODES_H} + radius: ${CLIMATE_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key + on_click: + - script.execute: { id: set_climate_mode, mode: "heat" } + widgets: + - label: + align: CENTER + text: "\U000F0238" + text_font: materialdesign_icons + clickable: false + - obj: + id: cmkey_cool + flex_grow: 1 + height: ${CLIMATE_MODES_H} + radius: ${CLIMATE_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key + on_click: + - script.execute: { id: set_climate_mode, mode: "cool" } + widgets: + - label: + align: CENTER + text: "\U000F0717" + text_font: materialdesign_icons + clickable: false + - obj: + id: cmkey_dry + flex_grow: 1 + height: ${CLIMATE_MODES_H} + radius: ${CLIMATE_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key + on_click: + - script.execute: { id: set_climate_mode, mode: "dry" } + widgets: + - label: + align: CENTER + text: "\U000F058E" + text_font: materialdesign_icons + clickable: false + - obj: + id: cmkey_fan_only + flex_grow: 1 + height: ${CLIMATE_MODES_H} + radius: ${CLIMATE_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key + on_click: + - script.execute: { id: set_climate_mode, mode: "fan_only" } + widgets: + - label: + align: CENTER + text: "\U000F0210" + text_font: materialdesign_icons + clickable: false + - obj: + id: cmkey_more + flex_grow: 1 + height: ${CLIMATE_MODES_H} + radius: ${CLIMATE_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + pad_all: 0 + clickable: true + scrollable: false + pressed: + styles: paint_key + on_click: + - script.execute: open_climate_mode_picker + widgets: + - label: + align: CENTER + text: "\U000F01D8" + text_font: materialdesign_icons + styles: paint_slate + clickable: false + # ---------- CLIMATE MODE PICKER ---------- + # Replaces cycling through modes. Cycling sends a set_hvac_mode on every + # tap, so to get from 'cool' to 'dry' a heat pump actually passes through + # 'heat' and 'fan_only' along the way. Here you pick directly, and exactly + # one command is sent. + - obj: + id: climate_mode_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + styles: style_page + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + scrollbar_mode: 'OFF' + widgets: + - label: + align: TOP_MID + y: ${PICKER_TITLE_Y} + text: "Mode" + text_font: sublabel_big + clickable: false + + # Flex ensures unsupported modes don't leave a gap: + # LVGL skips hidden children when arranging. + - obj: + id: climate_mode_list + align: TOP_MID + y: ${PICKER_LIST_Y} + width: 98% + height: ${PICKER_LIST_H} + bg_opa: TRANSP + border_width: 0 + scrollable: false + clickable: false + pad_all: ${PICKER_PAD} + layout: + type: FLEX + pad_row: ${PICKER_ROW_GAP} + pad_column: ${PICKER_COL_GAP} + flex_flow: ROW_WRAP + flex_align_main: CENTER + flex_align_cross: CENTER + flex_align_track: CENTER + widgets: + - obj: + id: cmode_off + width: ${PICKER_MODE_W} + height: ${PICKER_MODE_H} + radius: ${PICKER_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "off" } + widgets: + - label: + align: CENTER + text: "Off" + text_font: sublabel_big + clickable: false + - obj: + id: cmode_heat + width: ${PICKER_MODE_W} + height: ${PICKER_MODE_H} + radius: ${PICKER_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "heat" } + widgets: + - label: + align: CENTER + text: "Heat" + text_font: sublabel_big + clickable: false + - obj: + id: cmode_cool + width: ${PICKER_MODE_W} + height: ${PICKER_MODE_H} + radius: ${PICKER_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "cool" } + widgets: + - label: + align: CENTER + text: "Cool" + text_font: sublabel_big + clickable: false + - obj: + id: cmode_heat_cool + width: ${PICKER_MODE_W} + height: ${PICKER_MODE_H} + radius: ${PICKER_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "heat_cool" } + widgets: + - label: + align: CENTER + text: "Heat/Cool" + text_font: sublabel_big + clickable: false + - obj: + id: cmode_auto + width: ${PICKER_MODE_W} + height: ${PICKER_MODE_H} + radius: ${PICKER_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "auto" } + widgets: + - label: + align: CENTER + text: "Auto" + text_font: sublabel_big + clickable: false + - obj: + id: cmode_dry + width: ${PICKER_MODE_W} + height: ${PICKER_MODE_H} + radius: ${PICKER_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "dry" } + widgets: + - label: + align: CENTER + text: "Dry" + text_font: sublabel_big + clickable: false + - obj: + id: cmode_fan_only + width: ${PICKER_MODE_W} + height: ${PICKER_MODE_H} + radius: ${PICKER_MODE_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_mode, mode: "fan_only" } + widgets: + - label: + align: CENTER + text: "Fan only" + text_font: sublabel_big + clickable: false + + - label: + id: cfan_title + width: 100% + height: ${PICKER_TITLE_H} + text: "Fan" + text_font: sublabel + styles: paint_subtle + text_align: CENTER + clickable: false + - obj: + id: cfan_0 + width: ${PICKER_CHIP_W} + height: ${PICKER_CHIP_H} + radius: ${PICKER_CHIP_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 0 } + widgets: + - label: + id: cfan_0_label + align: CENTER + text: "-" + text_font: ${PICKER_CHIP_FONT} + clickable: false + - obj: + id: cfan_1 + width: ${PICKER_CHIP_W} + height: ${PICKER_CHIP_H} + radius: ${PICKER_CHIP_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 1 } + widgets: + - label: + id: cfan_1_label + align: CENTER + text: "-" + text_font: ${PICKER_CHIP_FONT} + clickable: false + - obj: + id: cfan_2 + width: ${PICKER_CHIP_W} + height: ${PICKER_CHIP_H} + radius: ${PICKER_CHIP_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 2 } + widgets: + - label: + id: cfan_2_label + align: CENTER + text: "-" + text_font: ${PICKER_CHIP_FONT} + clickable: false + - obj: + id: cfan_3 + width: ${PICKER_CHIP_W} + height: ${PICKER_CHIP_H} + radius: ${PICKER_CHIP_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_fan_mode, index: 3 } + widgets: + - label: + id: cfan_3_label + align: CENTER + text: "-" + text_font: ${PICKER_CHIP_FONT} + clickable: false + - label: + id: cswing_title + width: 100% + height: ${PICKER_TITLE_H} + text: "Swing" + text_font: sublabel + styles: paint_subtle + text_align: CENTER + clickable: false + - obj: + id: cswing_0 + width: ${PICKER_CHIP_W} + height: ${PICKER_CHIP_H} + radius: ${PICKER_CHIP_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 0 } + widgets: + - label: + id: cswing_0_label + align: CENTER + text: "-" + text_font: ${PICKER_CHIP_FONT} + clickable: false + - obj: + id: cswing_1 + width: ${PICKER_CHIP_W} + height: ${PICKER_CHIP_H} + radius: ${PICKER_CHIP_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 1 } + widgets: + - label: + id: cswing_1_label + align: CENTER + text: "-" + text_font: ${PICKER_CHIP_FONT} + clickable: false + - obj: + id: cswing_2 + width: ${PICKER_CHIP_W} + height: ${PICKER_CHIP_H} + radius: ${PICKER_CHIP_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 2 } + widgets: + - label: + id: cswing_2_label + align: CENTER + text: "-" + text_font: ${PICKER_CHIP_FONT} + clickable: false + - obj: + id: cswing_3 + width: ${PICKER_CHIP_W} + height: ${PICKER_CHIP_H} + radius: ${PICKER_CHIP_RADIUS} + styles: paint_card + bg_opa: COVER + border_width: 1 + clickable: true + scrollable: false + pad_all: 0 + on_click: + - script.execute: { id: set_climate_swing_mode, index: 3 } + widgets: + - label: + id: cswing_3_label + align: CENTER + text: "-" + text_font: ${PICKER_CHIP_FONT} + clickable: false + - obj: + id: climate_mode_overlay_x + align: TOP_LEFT + x: ${CARD_KEY_X} + y: ${CARD_KEY_Y} + width: ${CARD_KEY_SIZE} + height: ${CARD_KEY_SIZE} + radius: 999 + styles: paint_key + bg_opa: COVER + border_width: 0 + clickable: true + scrollable: false + on_click: + - lvgl.widget.hide: climate_mode_overlay + widgets: + - label: + align: CENTER + text: "\U000F004D" + text_font: materialdesign_icons_mini + clickable: false + + + # ---------- VACUUM CONTROL ---------- + # A vacuum is not a switch: start, pause, stop and return to + # the dock are separate commands, and suction power is a separate axis. + + + # ---------- DIM / WAKE OVERLAY ---------- + - obj: + id: dim_wake_overlay + x: 0 + y: 0 + width: ${DISPLAY_W} + height: ${DISPLAY_H} + hidden: true + bg_opa: TRANSP + # The tap that wakes a dimmed screen must not light up a pale sheet over it. + pressed: + bg_opa: TRANSP + border_width: 0 + scrollable: false + scrollbar_mode: 'OFF' + clickable: true + on_click: + - script.execute: wake_display + # A page that is one full tile switching something on or off switches with this push as well + # (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes. + - lambda: 'runtime_tiles::wake_tap();' + + - id: touch_cal_page + styles: ${TOUCH_TEST_PAGE_PAINT} + bg_opa: COVER + pad_all: 0 + border_width: 0 + scrollable: false + widgets: + - label: + align: TOP_MID + y: ${TOUCH_TEST_TITLE_Y} + text: ${TOUCH_TEST_TITLE} + text_font: ${TOUCH_TEST_TITLE_FONT} + - obj: + x: ${TOUCH_TEST_INSET} + y: ${TOUCH_TEST_INSET} + width: ${TOUCH_TEST_SIZE} + height: ${TOUCH_TEST_SIZE} + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + styles: ${TOUCH_TEST_PAINT} + - obj: + x: ${TOUCH_TEST_FAR_X} + y: ${TOUCH_TEST_INSET} + width: ${TOUCH_TEST_SIZE} + height: ${TOUCH_TEST_SIZE} + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + styles: ${TOUCH_TEST_PAINT} + - obj: + x: ${TOUCH_TEST_FAR_X} + y: ${TOUCH_TEST_FAR_Y} + width: ${TOUCH_TEST_SIZE} + height: ${TOUCH_TEST_SIZE} + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + styles: ${TOUCH_TEST_PAINT} + - obj: + x: ${TOUCH_TEST_INSET} + y: ${TOUCH_TEST_FAR_Y} + width: ${TOUCH_TEST_SIZE} + height: ${TOUCH_TEST_SIZE} + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + styles: ${TOUCH_TEST_PAINT} + - obj: + x: ${TOUCH_TEST_MID_X} + y: ${TOUCH_TEST_MID_Y} + width: ${TOUCH_TEST_SIZE} + height: ${TOUCH_TEST_SIZE} + pad_all: 0 + border_width: 0 + bg_opa: TRANSP + scrollable: false + widgets: + - label: + align: CENTER + text: "+" + text_font: headline + styles: ${TOUCH_TEST_PAINT} + +script: + - id: show_tile_page + then: + - lambda: |- + if(runtime_tiles::enabled){ + runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number)); + return; + } + lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), + id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; + const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; + const int count = runtime_tiles::model.count; + const bool paginated = count > 6; + if (!paginated) id(tile_page) = 0; + for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { + if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN); + } + for (int i = 0; i < 10; ++i) { + bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6); + if (visible) { + lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]); + lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); + } + } + settings_screen::page_dots(id(page_number), id(tile_page), 2, ${PAGE_DOTS_LARGE}); + if (id(tile_page) == 0) { + lv_obj_add_state(id(page_prev), LV_STATE_DISABLED); + lv_obj_clear_state(id(page_next), LV_STATE_DISABLED); + } else { + lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED); + lv_obj_add_state(id(page_next), LV_STATE_DISABLED); + } + + # Runtime changes do not count as touches and do not reset the idle timer. + - id: apply_screen_settings + mode: restart + then: + - lambda: |- + const auto &settings = screen_settings::current; + // Dark mode (firmware 0.2.54+): a look the screen already has costs nothing. + theme::set_dark(settings_screen::dark_mode != 0); + ${APPLY_ROTATION} + // Auto standby off wakes the screen, but not out of a Sleep pressed in Home Assistant. + if (id(display_dimmed) && !settings.standby_enabled && !id(sleep_requested)) { + id(display_dimmed) = false; + } + auto now = id(homeassistant_time).now(); + const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; + const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; + ${APPLY_BACKLIGHT} + if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); } + else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); + // The setting numbers and times only publish a changed value (HA keeps identical states anyway); the + // switches publish their own changes. An open settings page shows a change from Home Assistant too. + // A number without a state publishes too: a night brightness of 0 equals the number's initial state and + // stayed "unknown" in Home Assistant before firmware 0.2.50. + for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness), + std::make_pair(id(setting_standby_brightness), settings.standby_brightness), + std::make_pair(id(setting_night_brightness), settings.night_brightness), + std::make_pair(id(setting_standby_seconds), settings.standby_seconds), + std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) { + if (!entity->has_state() || entity->state != value) entity->publish_state(value); + } + for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) { + if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update(); + } + ${APPLY_ROTATION_ENTITY} + // The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+). + if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed(); + settings_screen::refresh(); + if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); + + # Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light, + # colour and climate overlays, and active_entity, which the swipe guard reads. + - id: close_cards + then: + - lambda: 'if (runtime_tiles::dismiss) runtime_tiles::dismiss();' + + # Everything back to the overview: the settings page, every card and the first page of tiles. + # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. + - id: go_home + then: + - lambda: 'settings_screen::close();' + - script.execute: close_cards + - lambda: 'id(tile_page) = 0;' + - script.execute: show_tile_page + + # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps + # showing what someone looked at minutes ago; after this long the screen is a screen again. + # Independent of standby, which may be off or much later; paused while the screen is in standby. + # The time runs from the last touch (last_use_ms, firmware 0.2.56+): Wake, an alert or Auto standby + # light the screen and restart the standby time, but nobody used a page. Checked every second and by Wake. + - id: back_to_page_1_when_due + then: + - if: + condition: + lambda: |- + if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; + if (id(display_dimmed)) return false; + bool away = settings_screen::visible() ||${AWAY_EXTRA} !id(active_entity).empty() || id(tile_page) != 0 || + (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); + return away && (millis() - id(last_use_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; + then: + - script.execute: go_home + + # ---------- ALERT (HA action show_alert) ---------- + # The screen wakes, keeps its normal brightness and shows the card until OK or the + # timeout (0: until OK); the standby interval waits on alert_active. A new alert + # replaces the current one; every ending reports itself as esphome.screen_alert. + - id: alert_show + mode: restart + parameters: + title: string + subtitle: string + icon: string + color: string + button_text: string + timeout: int + flash: bool + then: + - lambda: |- + if (id(alert_active)) id(alert_report).execute("replaced"); + // An alert is the one thing that outranks the settings page, so that closes first. + settings_screen::close(); + ${ALERT_SHOW_HOOK} + const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); + id(alert_active) = true; + id(alert_title_text) = alert.title; + id(alert_timeout_seconds) = alert.timeout_seconds; + id(alert_flash_requested) = alert.flash; + lv_label_set_text(id(alert_icon), alert.icon.c_str()); + lv_label_set_text(id(alert_title), alert.title.c_str()); + lv_label_set_text(id(alert_subtitle), alert.subtitle.c_str()); + lv_label_set_text(id(alert_ok_label), alert.button.c_str()); + id(alert_card_color) = alert.color; + lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(alert.color)), LV_PART_MAIN); + ESP_LOGI("alert", "show \"%s\" timeout=%d flash=%d", alert.title.c_str(), alert.timeout_seconds, (int) alert.flash); + - script.execute: wake_display + - lvgl.widget.show: alert_overlay + - script.stop: alert_timeout + - if: + condition: + lambda: 'return id(alert_flash_requested);' + then: + - script.execute: alert_flash + - if: + condition: + lambda: 'return id(alert_timeout_seconds) > 0;' + then: + - script.execute: + id: alert_timeout + seconds: !lambda 'return id(alert_timeout_seconds);' + + - id: alert_timeout + mode: restart + parameters: + seconds: int + then: + - delay: !lambda 'return static_cast(seconds) * 1000UL;' + - script.execute: + id: alert_dismiss + reason: "timeout" + + # Home Assistant adds device_id; action is ok, timeout, replaced or remote. + - id: alert_report + parameters: + action: string + then: + - homeassistant.event: + event: esphome.screen_alert + data: + action: !lambda 'return action;' + title: !lambda 'return id(alert_title_text);' + screen: !lambda 'return App.get_name();' + + # Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed. + # The settings page and every card close; with "also on standby" the screen goes back to page 1 too. + - id: dim_display + mode: restart + then: + - lambda: |- + id(display_dimmed) = true; + id(apply_screen_settings).execute(); + - if: + condition: + lambda: 'return screen_settings::current.home_on_standby;' + then: + - script.execute: go_home + else: + - lambda: 'settings_screen::close();' + - script.execute: close_cards + + # A -/+ tap on the climate card: the number follows at once, the call waits for the last tap. + # Holding a key steps three times a second. + - id: climate_adjust_target + parameters: + direction: int + held: bool + then: + - lambda: |- + if (held && millis() - id(climate_edit_ms) < 300) return; + int step_x10 = std::max(5, id(active_climate_step_x10)); + int next = cyd::quantize_temperature(id(active_climate_target_x10) + direction * step_x10, + id(active_climate_min_x10), id(active_climate_max_x10), step_x10); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + if (next == id(active_climate_target_x10) && id(active_climate_target_known)) return; + id(active_climate_target_x10) = next; + id(active_climate_target_known) = true; + id(climate_card_refresh).execute(); + id(climate_target_send).execute(id(active_entity), next / 10.0f); + + # One set_temperature for a series of taps: every tap restarts the wait with the newest value. + # Entity and value travel along, so leaving the card right after a tap still sends it. + - id: climate_target_send + mode: restart + parameters: + entity: string + value: float + then: + - delay: 700ms + - if: + condition: + lambda: 'return runtime_tiles::fresh() && !entity.empty();' + then: + - homeassistant.action: + action: climate.set_temperature + data: + entity_id: !lambda "return entity;" + temperature: !lambda "return value;" + + # Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys. + # Opens the mode picker. Hides modes the entity doesn't support and marks + # the current one, so you see where you stand before you pick something. + # Opens the vacuum controls and remembers which entity it concerns. + + # A standalone command: start, pause, stop or return_to_base. + + # Suction power is a separate axis, apart from start/stop. + + - id: open_climate_mode_picker + mode: restart + 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 supported = lower(id(active_climate_supported_modes)); + std::string current = lower(id(active_climate_mode)); + + auto has = [&](const char* m) { + std::string mode(m); + if (supported.empty() || supported == "unknown" || supported == "unavailable") return false; + return supported.find("'" + mode + "'") != std::string::npos || + supported.find("\"" + mode + "\"") != std::string::npos; + }; + auto apply = [&](lv_obj_t* btn, const char* m) { + if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); + bool selected = (current == std::string(m)); + lv_obj_set_style_bg_color(btn, theme::color(selected ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), theme::color(selected ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); + }; + apply(id(cmode_off), "off"); + apply(id(cmode_heat), "heat"); + apply(id(cmode_cool), "cool"); + apply(id(cmode_heat_cool), "heat_cool"); + apply(id(cmode_auto), "auto"); + apply(id(cmode_dry), "dry"); + apply(id(cmode_fan_only), "fan_only"); + // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. + auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; + auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { + unsigned shown = 0; + for (unsigned i = 0; i < 4; ++i) { + std::string raw = cyd::list_item(modes, i); + if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } + lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; + lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); + lv_obj_set_style_bg_color(chips[i], theme::color(raw == current_mode ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); + lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), theme::color(raw == current_mode ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); + } + if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); + }; + lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; + lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; + fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); + fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); + - lvgl.widget.show: climate_mode_overlay + + # Sets exactly one mode. No intermediate steps, so no unwanted + # commands to the heat pump along the way. + # Fan and swing modes from the picker: one call with the entity's own mode name. + - id: set_climate_fan_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + ${CLIMATE_MODE_CHOSEN_HOOK} + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return runtime_tiles::fresh() && !id(active_climate_fan_mode).empty();' + then: + - homeassistant.action: + action: climate.set_fan_mode + data: + entity_id: !lambda "return id(active_entity);" + fan_mode: !lambda "return id(active_climate_fan_mode);" + - id: set_climate_swing_mode + mode: restart + parameters: + index: int + then: + - lambda: |- + id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + ${CLIMATE_MODE_CHOSEN_HOOK} + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return runtime_tiles::fresh() && !id(active_climate_swing_mode).empty();' + then: + - homeassistant.action: + action: climate.set_swing_mode + data: + entity_id: !lambda "return id(active_entity);" + swing_mode: !lambda "return id(active_climate_swing_mode);" + + # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. + - id: climate_power_toggle + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + if (!id(active_climate_power_on)) id(active_climate_mode) = "off"; + id(climate_card_refresh).execute(); + - if: + condition: + lambda: 'return runtime_tiles::fresh() && id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_on + data: + entity_id: !lambda "return id(active_entity);" + - if: + condition: + lambda: 'return runtime_tiles::fresh() && !id(active_climate_power_on);' + then: + - homeassistant.action: + action: climate.turn_off + data: + entity_id: !lambda "return id(active_entity);" + + # The power key lights up while the device runs in any mode. + - id: climate_power_refresh + mode: restart + then: + - lambda: |- + std::string mode = id(active_climate_mode); + for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); + if (id(climate_edit_pending)) on = id(active_climate_power_on); + lv_obj_set_style_bg_color(id(climate_power_button), theme::color(on ? theme::ACCENT_TINT : theme::KEY), LV_PART_MAIN); + lv_obj_set_style_text_color(id(climate_power_icon), theme::color(on ? theme::ACCENT_ICON : theme::ICON_OFF), LV_PART_MAIN); + + - id: set_climate_mode + mode: restart + parameters: + mode: string + then: + - lambda: |- + id(climate_edit_ms) = millis(); + id(climate_edit_pending) = true; + id(active_climate_mode) = mode; + // The action line would still say what the old mode did. + id(active_climate_action).clear(); + id(active_climate_power_on) = mode != "off"; + id(climate_card_refresh).execute(); + - lvgl.widget.hide: climate_mode_overlay + - if: + condition: + lambda: 'return runtime_tiles::fresh();' + then: + - homeassistant.action: + action: climate.set_hvac_mode + data: + entity_id: !lambda "return id(active_entity);" + hvac_mode: !lambda "return id(active_climate_mode);" + + # Isolated 40-second preview; every light commit is suppressed in the helper. + - id: light_controls_preview + mode: single + then: + - script.execute: wake_display + - lambda: |- + light_controls::demo = true; + light_controls::fallback.minimum = 2000; + light_controls::fallback.maximum = 6500; + light_controls::fallback.kelvin = 3000; + light_controls::fallback.hue = 200; + light_controls::open("diagnostic", true, true, 75); + lv_label_set_text(id(color_detail_title), "Example lamp"); + - lvgl.widget.hide: color_close_button + - lvgl.widget.show: color_detail_overlay + - delay: 40s + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.show: color_close_button + - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' + + - id: open_color_detail + mode: restart + then: + - lambda: |- + lv_label_set_text(id(color_detail_title), id(active_title).c_str()); + light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); + if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); + - lvgl.widget.hide: brightness_overlay + - lvgl.widget.show: color_detail_overlay + + - id: color_brightness_commit + parameters: + value: int + then: + - lambda: 'id(active_brightness_pct) = value;' + - if: + condition: + lambda: 'return runtime_tiles::fresh() && id(active_entity).rfind("light.", 0) == 0;' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + brightness_pct: !lambda "return value;" + + - id: set_light_hue + parameters: + value: int + then: + - if: + condition: + lambda: 'return runtime_tiles::fresh() && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + data_template: + hs_color: "{{ [hue | int, 100] }}" + variables: + hue: !lambda "return light_controls::clamp(value, 0, 360);" + + - id: set_light_color_temperature + parameters: + kelvin: int + then: + - if: + condition: + lambda: 'return runtime_tiles::fresh() && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" + + - id: slider_preview + parameters: + value: int + then: + - lambda: |- + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str()); + lv_label_set_text(id(overlay_value), buf); + int pct = value; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + std::string value_text; + + if (id(active_control_kind) == "cover_position") { + std::string closed_label = id(active_label_off).c_str(); + std::string open_label = id(active_label_on).c_str(); + + if (pct <= 0) { + value_text = closed_label; + } else if (pct >= 100) { + value_text = open_label; + } else { + char buf[40]; + snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct); + value_text = buf; + } + } else { + if (pct <= 0) { + value_text = id(active_label_off).c_str(); + } else { + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); + value_text = buf; + } + } + + lv_label_set_text(id(overlay_value), value_text.c_str()); + + + - id: slider_commit + parameters: + value: int + then: + - if: + condition: + lambda: |- + return id(active_control_kind) == "light_brightness"; + then: + - homeassistant.action: + action: light.turn_on + data: + entity_id: !lambda "return id(active_entity);" + brightness_pct: !lambda "return value;" + else: + - if: + condition: + lambda: |- + return id(active_control_kind) == "fan_percentage"; + then: + - if: + condition: + lambda: "return value <= 0;" + then: + - homeassistant.action: + action: fan.turn_off + data: + entity_id: !lambda "return id(active_entity);" + else: + - homeassistant.action: + action: fan.turn_on + data: + entity_id: !lambda "return id(active_entity);" + - delay: 150ms + - homeassistant.action: + action: fan.set_percentage + data: + entity_id: !lambda "return id(active_entity);" + percentage: !lambda "return value;" + else: + - if: + condition: + lambda: |- + return id(active_control_kind) == "cover_position"; + then: + - homeassistant.action: + action: cover.set_cover_position + data: + entity_id: !lambda "return id(active_entity);" + position: !lambda "return value;" + + # ---------------------------------------------------------- + # do_tile_action + # + # Centralized handler for executing Home Assistant actions + # triggered by tile taps. + # + # Parameters + # - tile_type: light | fan | switch | scene | script | cover + # - tap_action: auto | toggle | activate | fan_toggle_preset + # - service: optional Home Assistant service override + # - entity_id: target entity + # - param_key / param_val: optional parameters for custom services + # - was_on: previous entity state (pass false for longpress actions) + # + # Behavior + # + # toggle + # -> toggles the entity + # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on) + # + # activate + # -> activates entity + # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover + # + # auto + # -> resolves automatically based on tile_type: + # light/switch/fan/cover -> toggle + # scene/script -> activate + # + # fan_toggle_preset + # -> if fan was ON: fan.turn_off + # -> if fan was OFF: + # fan.turn_on + # wait 150ms + # fan.set_preset_mode(param_val) + # + # Custom service + # If "service" is set, it will be called directly. + # param_key / param_val may be used for parameters + # like preset_mode, brightness_pct, percentage, etc. + # ---------------------------------------------------------- + + - id: open_value_overlay + parameters: + title: string + entity: string + tile_type: string + longpress_mode: string + was_on: bool + off_value: int + brightness_value: float + percentage_value: float + position_value: float + icon_text: string + label_off: string + label_on: string + supported_color_modes: string + climate_current_temperature: float + climate_target_temperature: float + climate_humidity: float + climate_mode: string + climate_supported_modes: string + climate_min_temp: string + climate_max_temp: string + climate_step: string + then: + - lambda: |- + std::string mode = longpress_mode; + auto contains_mode = [](const std::string& modes, const std::string& needle) { + return modes.find(needle) != std::string::npos; + }; + if (mode == "auto") { + if (tile_type == "light") mode = "brightness"; + else if (tile_type == "fan") mode = "percentage"; + else if (tile_type == "cover") mode = "cover_position"; + else if (tile_type == "climate") mode = "climate_temperature"; + else mode = "none"; + } + + if (mode == "none") return; + + id(active_entity) = entity; + id(active_value_suffix) = " %"; + id(active_label_off) = label_off; + id(active_label_on) = label_on; + id(active_title) = title; + id(active_icon_text) = icon_text; + id(active_supports_color) = false; + id(active_supports_temperature) = false; + lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); + + if (mode == "climate_temperature") { + auto to_x10 = [](float value, int fallback) -> int { + if (isnan(value)) return fallback; + return (int) lround(value * 10.0f); + }; + auto parse_x10 = [](const std::string& value, int fallback) -> int { + if (value.empty()) return fallback; + return (int) lround(atof(value.c_str()) * 10.0f); + }; + id(climate_edit_pending) = false; + id(active_control_kind) = "climate_temperature"; + id(active_climate_min_x10) = parse_x10(climate_min_temp, 50); + id(active_climate_max_x10) = parse_x10(climate_max_temp, 300); + id(active_climate_step_x10) = parse_x10(climate_step, 5); + if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5; + if (id(active_climate_max_x10) <= id(active_climate_min_x10)) { + id(active_climate_min_x10) = 50; + id(active_climate_max_x10) = 300; + } + + id(active_climate_current_x10) = to_x10(climate_current_temperature, 0); + id(active_climate_current) = climate_current_temperature; + id(active_climate_target_known) = !isnan(climate_target_temperature); + int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10); + id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target); + if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); + if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); + id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10); + if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); + if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); + id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity); + id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode; + id(active_climate_supported_modes) = climate_supported_modes; + + lv_label_set_text(id(climate_detail_title), title.c_str()); + ${OPEN_VALUE_OVERLAY_TAIL} + id(climate_card_refresh).execute(); + + lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); + return; + } + + int pct = off_value; + + if (mode == "brightness") { + id(active_control_kind) = "light_brightness"; + if (was_on) { + if (isnan(brightness_value)) pct = 100; + else pct = (int) lround((brightness_value / 255.0f) * 100.0f); + } + } else if (mode == "percentage") { + id(active_control_kind) = "fan_percentage"; + if (was_on) { + if (isnan(percentage_value)) pct = 0; + else pct = (int) lround(percentage_value); + } + } else if (mode == "cover_position") { + id(active_control_kind) = "cover_position"; + if (isnan(position_value)) pct = was_on ? 100 : 0; + else pct = (int) lround(position_value); + } else { + return; + } + + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + id(active_brightness_pct) = pct; + + if (tile_type == "light") { + id(active_supports_color) = + contains_mode(supported_color_modes, "hs") || + contains_mode(supported_color_modes, "rgb") || + contains_mode(supported_color_modes, "rgbw") || + contains_mode(supported_color_modes, "rgbww") || + contains_mode(supported_color_modes, "xy"); + id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp"); + + if (id(active_supports_color) || id(active_supports_temperature)) { + lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); + } + } + + // Track and fill are the pale blue and blue paints of overlay_slider. + lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN); + lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR); + + lv_label_set_text(id(overlay_title), title.c_str()); + lv_label_set_text(id(overlay_icon), icon_text.c_str()); + lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF); + + std::string value_text; + + if (tile_type == "cover") { + if (pct <= 0) { + value_text = label_off; + } else if (pct >= 100) { + value_text = label_on; + } else { + char buf[40]; + snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct); + value_text = buf; + } + } else { + if (pct <= 0) { + value_text = label_off; + } else { + char buf[20]; + snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); + value_text = buf; + } + } + + lv_label_set_text(id(overlay_value), value_text.c_str()); + + lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR); + + - if: + condition: + lambda: 'return id(active_control_kind) != "climate_temperature";' + then: + - if: + condition: + lambda: 'return id(active_supports_color) || id(active_supports_temperature);' + then: + - script.execute: open_color_detail + else: + - lvgl.widget.show: brightness_overlay + + # UI refresh (values + time + styles) + render + - id: ui_refresh + mode: single + then: + - delay: 100ms + - lambda: |- + if (runtime_tiles::enabled) { + // The name and the top bar (clock included) draw together. + runtime_tiles::render(id(lbl_room)); + return; + } + + +# HA blueprint transport; never publishes the JSON command as an HA state. +text: + - platform: smart_display + id: dashboard_inbox + name: "Tile settings" + internal: ${DASHBOARD_INBOX_INTERNAL} + +button: + # Wake and Sleep for Home Assistant automations (0.2.45+). Wake is light, not a tap (0.2.56+): it + # lights a dimmed screen and counts the standby time from now, while Back to page 1 keeps counting + # from the last touch, so Wake on every motion no longer holds a card or page 2 up. Sleep is the + # standby time running out, right away and also with Auto standby off; it holds until a tap, Wake + # or an alert, and closes an alert that is up. Neither saves anything, so an automation may press + # them on every motion. No entity_category: they are controls, not settings. + - platform: template + id: wake_button + name: "Wake" + icon: "mdi:gesture-tap" + on_press: + - lambda: 'ESP_LOGI("standby", "Wake from Home Assistant, screen %s", id(display_dimmed) ? "in standby" : "on");' + - if: + condition: + lambda: 'return id(display_dimmed);' + then: + - script.execute: wake_display + # A page whose time ran out during standby: light up on page 1, not on the old page first. + - script.execute: back_to_page_1_when_due + else: + - lambda: 'id(last_touch_ms) = millis();' + - platform: template + id: sleep_button + name: "Sleep" + icon: "mdi:power-sleep" + on_press: + - if: + condition: + lambda: 'return id(calibration_active);' + then: + - lambda: 'ESP_LOGI("standby", "Sleep from Home Assistant ignored: touch calibration is running");' + else: + - lambda: |- + ESP_LOGI("standby", "Sleep from Home Assistant"); + id(sleep_requested) = true; + - script.execute: + id: alert_dismiss + reason: "remote" + - script.execute: dim_display + +number: + # Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the + # screen already has writes and reports nothing (an automation may set it on every light change). + - platform: template + id: setting_brightness + name: "Normal brightness" + entity_category: config + min_value: 5 + max_value: 100 + step: 1 + unit_of_measurement: "%" + update_interval: never + lambda: return screen_settings::current.brightness; + set_action: + - lambda: 'settings_screen::set("brightness", (int32_t) lround(x));' + - platform: template + id: setting_standby_brightness + name: "Standby brightness" + entity_category: config + min_value: 0 + max_value: 100 + step: 1 + unit_of_measurement: "%" + update_interval: never + lambda: return screen_settings::current.standby_brightness; + set_action: + - lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));' + - platform: template + id: setting_night_brightness + name: "Night brightness" + entity_category: config + min_value: 0 + max_value: 100 + step: 1 + unit_of_measurement: "%" + update_interval: never + lambda: return screen_settings::current.night_brightness; + set_action: + - lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));' + - platform: template + id: setting_standby_seconds + name: "Standby after" + entity_category: config + min_value: 60 + max_value: 86400 + step: 60 + unit_of_measurement: "s" + update_interval: never + lambda: return screen_settings::current.standby_seconds; + set_action: + - lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));' + - platform: template + id: setting_auto_home_seconds + name: "Back to page 1 after" + icon: "mdi:timer-outline" + entity_category: config + min_value: 30 + max_value: 3600 + step: 30 + mode: box + unit_of_measurement: "s" + update_interval: never + lambda: return settings_screen::auto_home_seconds; + set_action: + - lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));' diff --git a/packages/cyd.yaml b/packages/cyd.yaml index dfb5e02..0a7c758 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -1,229 +1,17 @@ -# GENERATED by tools/generate_packages.py; edit the source board profile. -############################################################ -# ESP Screens - CYD board profile (ESP32-2432S028, "Cheap Yellow Display") -# Built-in ILI9341 320x240 display and XPT2046 touch, drawn with LVGL. +# ESP Screens - the package a CYD (ESP32-2432S028) builds from over GitHub. ESP Screen Manager writes every +# screen's own YAML with `packages: display: {url: , files: [packages/cyd.yaml]}` and adds the +# name, the Wi-Fi reference and the unique keys (docs/EASY_SETUP.md); nothing of that lives here. # -# This is the source profile. tools/generate_packages.py turns it into -# packages/cyd.yaml, the package a screen builds from over GitHub; never edit -# that file by hand. A screen gets its tiles from the ESP Screen Manager add-on -# while it runs, so no Home Assistant entities live in here. -# -# What is in here: the hardware (display, touch, backlight, LED), the LVGL tree -# the runtime fills (tiles, cards, overlays), the screen settings, and the -# diagnostics a bench session uses. -# -# What to adapt before flashing: the secrets (api_encryption_key, ota_password, -# Wi-Fi) and, for another CYD variant, the pins and the touch calibration. -# Start at README.md; docs/ covers installing, calibrating and releasing. -############################################################ - +# The screen is two packages: core.yaml, which every board shares (the LVGL tree, the cards, the scripts, the +# entities), and boards/cyd-2432s028.yaml with this board's hardware and sizes. docs/PROFILES.md says how the +# files fit together. home-like-2432s028.yaml builds the same two files from a checkout. substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.70" - # 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" - DASHBOARD_INBOX_INTERNAL: "false" - DEVICE_NAME: "cyd-new" - DEVICE_FRIENDLY_NAME: "My CYD" - CALIBRATION_ON_BOOT: "false" - 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" - ROOM_NAME: "Choose tiles in HA" # shown top left until ESP Screens sends the screen name + FONT_DIR: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts" - ## DIM settings - AUTO_DIM: "true" # true = enable auto dimming - AUTO_DIM_TIMEOUT: "600" # seconds after last touch - MAX_BRIGHTNESS: "100" # backlight brightness in percent - DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent - NIGHT_MODE: "true" # true = use separate dim level at night - NIGHT_START_HOUR: "22" # 0-23 - NIGHT_END_HOUR: "7" # 0-23 - NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night +packages: + core: !include core.yaml + board: !include boards/cyd-2432s028.yaml - - # 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" - -# ============================================================ -# No need to edit below this line. -# All configuration is done via the substitutions above. -# Credentials are managed in secrets.yaml (see secrets.yaml.example). -# The Material Design Icons font is included at fonts/materialdesignicons-webfont.ttf (Apache 2.0). -# Source: https://github.com/Templarian/MaterialDesign-Webfont -# ============================================================ external_components: - source: type: git @@ -232,4642 +20,3 @@ external_components: path: components refresh: 0s components: [xpt2046, smart_display] - -esphome: - min_version: 2026.6.2 - project: - name: maxgramser.esp_screen - version: "${SCREEN_FIRMWARE_VERSION}" - platformio_options: - build_flags: - - -DLV_USE_SWITCH=1 - includes: - - - - - - - - - name: ${DEVICE_NAME} - friendly_name: ${DEVICE_FRIENDLY_NAME} - on_boot: - priority: -100 - then: - - lambda: |- - // 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; - - lambda: |- - auto &settings = screen_settings::current; - settings.standby_enabled = std::string("${AUTO_DIM}") == "true"; - settings.standby_seconds = atoi("${AUTO_DIM_TIMEOUT}"); - settings.brightness = atoi("${MAX_BRIGHTNESS}"); - settings.standby_brightness = atoi("${DIM_BRIGHTNESS}"); - settings.night_enabled = std::string("${NIGHT_MODE}") == "true"; - settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; - settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; - settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); - runtime_tiles::watch_font = id(headline)->get_lv_font(); - runtime_tiles::watch_value_font = id(watch_value)->get_lv_font(); - runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font(); - runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); - runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; - runtime_tiles::screen_awake = []() { return !id(display_dimmed); }; - runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); - runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); - runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); - runtime_tiles::small_font = id(sublabel)->get_lv_font(); - // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock - // label lends its margin and drawing order and stays hidden in runtime mode. - runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font(); - runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font(); - runtime_tiles::time_label = id(lbl_time); - runtime_tiles::enabled = true; - runtime_tiles::load_settings(); - // Colours (firmware 0.2.54+): the styles of this profile take their colours from theme.h. The list runs now, - // in the look the screen saved, before anything is drawn, and again after every change of look. - theme::paints = []() { - using theme::Paint; - for (auto *style : {id(style_room), id(style_time), id(style_title), id(style_value_big), id(paint_ink)}) theme::fill(style, Paint::ink); - theme::fill(id(style_page), Paint::page); - theme::fill(id(style_tile), Paint::card); - theme::fill(id(style_icon_circle), Paint::accent_tint); - theme::fill(id(style_value), Paint::muted); - theme::fill(id(paint_page_soft), Paint::page_soft); - theme::fill(id(paint_page_pressed), Paint::page_pressed); - theme::fill(id(paint_card), Paint::card); - theme::fill(id(paint_raised), Paint::raised); - theme::fill(id(paint_key), Paint::key); - theme::fill(id(paint_key_pressed), Paint::key_pressed); - theme::fill(id(paint_track), Paint::track); - theme::fill(id(paint_ink_soft), Paint::ink_soft); - theme::fill(id(paint_muted), Paint::muted); - theme::fill(id(paint_subtle), Paint::subtle); - theme::fill(id(paint_slate), Paint::slate); - theme::fill(id(paint_scrim), Paint::scrim); - theme::fill(id(paint_veil), Paint::veil); - theme::fill(id(paint_accent_tint), Paint::accent_tint); - theme::fill(id(paint_accent_bright), Paint::accent_bright); - theme::fill(id(paint_button_dark), Paint::button_dark); - theme::fill(id(paint_button_dark_pressed), Paint::button_dark_pressed); - theme::fill(id(paint_calibration), Paint::calibration); - }; - theme::paints(); - theme::set_dark(settings_screen::dark_mode != 0); - runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; - runtime_tiles::dismiss = []() { - effects_page::close(); - runtime_tiles::hide_detail(); - id(active_entity).clear(); - lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); - }; - runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; - 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); - runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; - runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); - runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); - runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); - runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); - runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); - runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); - runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); - runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); - runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); - runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); - runtime_tiles::detail = [](runtime_tiles::Tile &t) { - light_controls::fallback = light_controls::State{}; - light_controls::fallback.hue = t.hue; - light_controls::fallback.kelvin = t.kelvin; - light_controls::fallback.minimum = t.min_kelvin; - light_controls::fallback.maximum = t.max_kelvin; - id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; - id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; - id(active_climate_action) = t.extra().hvac_action; - id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, - t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), - "Off", "On", t.modes, - t.current, t.target, t.humidity, t.state, t.extra().hvac_modes, - std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); - }; - runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { - effects_page::updated(t); - if (id(active_entity) != t.entity || id(touch_down)) return; - if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { - if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; - id(climate_edit_pending) = false; - id(active_climate_mode) = t.state; - id(active_climate_action) = t.extra().hvac_action; - id(active_climate_supported_modes) = t.extra().hvac_modes; - id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; - id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; - id(active_climate_current) = t.current; - id(active_climate_humidity) = std::isfinite(t.humidity) ? (int) lround(t.humidity) : -1; - if (std::isfinite(t.target)) { - id(active_climate_target_x10) = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); - id(active_climate_target_known) = true; - } - id(climate_card_refresh).execute(); - } - }; - - lambda: |- - light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font()); - light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; - light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; - light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; - // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and - // the ways out: actions to Home Assistant and the question for a picker's names. - effects_page::title_font = id(headline)->get_lv_font(); - effects_page::row_font = id(sublabel_big)->get_lv_font(); - effects_page::roller_font = id(sublabel_big)->get_lv_font(); - effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font(); - effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * { - for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i]; - return nullptr; - }; - effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) { - runtime_tiles::action(service, entity, key, value); - }; - effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); }; - effects_page::drift = []() { return cyd::touch_guard.distance(); }; - effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; - effects_page::now = []() { return (uint32_t) millis(); }; - runtime_tiles::options_received = effects_page::received; - - lambda: |- - 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}); - # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager - # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built - # when it opens and thrown away when it closes, so it costs nothing while it is closed. - - lambda: |- - if (!runtime_tiles::enabled) return; - settings_screen::title_font = id(headline)->get_lv_font(); - settings_screen::row_font = id(sublabel_big)->get_lv_font(); - settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); - settings_screen::store = []() { runtime_tiles::persist_settings(); }; - settings_screen::apply = []() { id(apply_screen_settings).execute(); }; - settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; - settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; - settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; - settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; - settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; - settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; - // A dimmed screen, a calibration or an alert owns the glass; the page waits. - settings_screen::may_open = []() { - return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active); - }; - settings_screen::drift = []() { return cyd::touch_guard.distance(); }; - settings_screen::before_open = []() { id(close_cards).execute(); }; - settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; - settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; - // What a change of look draws again, now that everything is bound: the tiles, the top bar and an open card, - // the colour card, the settings page, an alert and the climate cards. - theme::redraw = []() { - runtime_tiles::restyle(); - light_controls::restyle(); - effects_page::restyle(); - settings_screen::restyle(); - if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN); - if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute(); - if (!lv_obj_has_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN)) id(open_climate_mode_picker).execute(); - }; - // Under the cards: brightness_overlay is the first of them on the page, so the back button and - // the action at the top right of a card keep their taps. - settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}, id(brightness_overlay)); - - light.turn_on: - id: back_light - brightness: !lambda "return screen_settings::current.brightness / 100.0f;" - - script.execute: show_tile_page - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - - if: - condition: - lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";' - then: - - lambda: 'id(calibration_active) = true;' - - lvgl.page.show: touch_cal_page - - lambda: |- - lv_obj_invalidate(id(touch_cal_page)->obj); - lv_refr_now(nullptr); - ESP_LOGI("ui_diag", "USB calibration ready; no tile actions"); - - -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 - -logger: - # Set to DEBUG or VERBOSE temporarily for diagnosis. Note: ESPHome logs - # its configuration lines at CONFIG level, which are invisible at INFO. - level: DEBUG - logs: - sensor: WARN - text_sensor: WARN - xpt2046: WARN - homeassistant.sensor: WARN - homeassistant.text_sensor: WARN - - -# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values. -api: - actions: - - action: touch_diagnostics - then: - - lambda: 'id(calibration_active) = true;' - - script.execute: wake_display - - lvgl.page.show: touch_cal_page - - lambda: |- - lv_obj_invalidate(id(touch_cal_page)->obj); - lv_refr_now(nullptr); - ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj); - - action: end_touch_diagnostics - then: - - lambda: 'id(calibration_active) = false;' - - lvgl.page.show: home_page - - action: ui_state - then: - - lambda: |- - const auto &cfg = screen_settings::current; - ESP_LOGI("ui_diag", "settings standby=%d timeout=%d brightness=%d dim=%d night=%d start=%d end=%d night_dim=%d clock=%d clock24=%d home=%d idle_ms=%u use_idle_ms=%u dimmed=%d", - cfg.standby_enabled, cfg.standby_seconds, cfg.brightness, cfg.standby_brightness, - cfg.night_enabled, cfg.night_start, cfg.night_end, cfg.night_brightness, - cfg.show_clock, cfg.clock_24h, cfg.home_on_standby, - (unsigned)(millis() - id(last_touch_ms)), (unsigned)(millis() - id(last_use_ms)), id(display_dimmed)); - ESP_LOGI("ui_diag", "navigation page=%d runtime_detail=%d", id(tile_page), - runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); - lv_area_t nav, grid; - lv_obj_update_layout(id(home_page)->obj); - lv_obj_get_coords(id(page_next), &nav); - lv_obj_get_coords(id(tile_scroll), &grid); - ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", - lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, - grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); - - action: preview_runtime_card - variables: - index: int - then: - - lambda: |- - if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } - if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { - runtime_tiles::active_index = index; - auto &tile = runtime_tiles::model.tiles[index]; - auto domain = tile.domain(); - // Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+) - // open the runtime card, as a tap does. - if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) { - tile.begin(millis(), true); - runtime_tiles::detail(tile); - return; - } - runtime_tiles::show_detail(index); - } - # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away - # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. - # Handy for support ("look at Screen -> Rotation") and for the render tests. - - action: open_settings - variables: - page: int - then: - - lambda: |- - if (page < 0) { id(go_home).execute(); return; } - // Opened for someone to look at: Back to page 1 counts from now, as after a touch. - id(last_use_ms) = millis(); - id(wake_display).execute(); - settings_screen::open((uint8_t) page); - ESP_LOGI("settings", "page %d open from Home Assistant", page); - - # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. - - action: preview_climate_picker - then: - - script.execute: open_climate_mode_picker - - action: preview_busy - variables: - index: int - then: - - lambda: |- - if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { - runtime_tiles::model.tiles[index].begin(millis()); - if (runtime_tiles::refresh) runtime_tiles::refresh(); - } - - action: light_controls_preview - then: - - script.execute: light_controls_preview - - action: ui_self_test - then: - - script.execute: ui_self_test - # Home Assistant automations: esphome._show_alert puts a card over the whole - # screen (README, "Alert from an automation"); dismiss_alert removes it again. - - action: show_alert - variables: - title: string - subtitle: string - icon: string - color: string - button_text: string - timeout: int - flash: bool - then: - - script.execute: - id: alert_show - title: !lambda 'return title;' - subtitle: !lambda 'return subtitle;' - icon: !lambda 'return icon;' - color: !lambda 'return color;' - button_text: !lambda 'return button_text;' - timeout: !lambda 'return timeout;' - flash: !lambda 'return flash;' - - action: dismiss_alert - then: - - script.execute: - id: alert_dismiss - reason: "remote" - # ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of - # base64 chunks in the text inbox; the inbox keeps showing the result. - # Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same - # status as the inbox, plus the revision of the layout on the screen. A call without a call id - # (older apps) gets no answer, so ESPHome has nothing to warn about. - - action: screen_message - supports_response: optional - variables: - message: string - then: - - lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));' - - if: - condition: - lambda: 'return call_id != 0;' - then: - - api.respond: - data: !lambda |- - root["status"] = id(dashboard_inbox).state; - root["rev"] = runtime_tiles::layout_rev; - -captive_portal: - -interval: - - interval: 250ms - then: - - lambda: runtime_tiles::tick(); - - interval: 1s - then: - - if: - condition: - lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;' - then: - # The edit window is over: show what Home Assistant reports now. A closed card has - # nothing to show, and a flag left set would redraw every tile each second. - - lambda: |- - if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; } - if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; } - // Runtime tiles: a state that arrived during the window was held back. - id(climate_edit_pending) = false; - if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 && - runtime_tiles::active_index < (int) runtime_tiles::model.count) - runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]); - - if: - condition: - lambda: |- - if (!screen_settings::current.standby_enabled) return false; - if (id(display_dimmed) || id(touch_down) || id(calibration_active) || id(alert_active)) return false; - return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); - then: - - script.execute: dim_display - - script.execute: back_to_page_1_when_due - -time: - - platform: homeassistant - id: homeassistant_time - # Refresh immediately on time sync; do not wait for an HA entity update. - update_interval: 60s - on_time_sync: - then: - - script.execute: apply_screen_settings - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - on_time: - - seconds: 0 - minutes: /1 - then: - - script.execute: apply_screen_settings - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - -globals: - # Alert overlay (HA action show_alert): standby waits while one is up. - - id: alert_active - type: bool - initial_value: 'false' - - id: alert_title_text - type: std::string - # The alert's colour as the automation named it (a swatch's light value, 0 for the normal card). - - id: alert_card_color - type: uint32_t - initial_value: '0' - - id: alert_timeout_seconds - type: int - initial_value: '0' - - id: alert_flash_requested - type: bool - initial_value: 'false' - - id: rendered_frames - type: uint32_t - initial_value: '0' - - id: calibration_active - type: bool - initial_value: 'false' - - id: tile_page - type: int - initial_value: '0' - - id: touch_down - type: bool - initial_value: 'false' - - id: climate_edit_ms - type: uint32_t - initial_value: '0' - - id: climate_edit_pending - type: bool - initial_value: 'false' - - id: active_entity - type: std::string - restore_value: no - - - id: active_control_kind - type: std::string - restore_value: no - - - id: active_value_suffix - type: std::string - restore_value: no - - # Two clocks. last_touch_ms is the standby time: a touch, Wake, an alert ending and Auto standby on restart it. - # last_use_ms is Back to page 1 (firmware 0.2.56+): only a touch, or Home Assistant opening the settings page. - - id: last_touch_ms - type: uint32_t - restore_value: no - initial_value: '0' - - - id: last_use_ms - type: uint32_t - restore_value: no - initial_value: '0' - - # Debounce for do_tile_action: this resistive XPT2046 panel sometimes gives - # multiple separate press/release cycles for one physical touch (noise in - # the resistive layer), causing the same HA action to fire multiple times - # within a few hundred ms (observed: script.ambient_morning 6x in 8s). - - id: last_action_entity - type: std::string - restore_value: no - - - id: last_action_ms - type: uint32_t - restore_value: no - initial_value: '0' - - - id: display_dimmed - type: bool - restore_value: no - initial_value: 'false' - - # Sleep from Home Assistant (0.2.45+): standby that also holds with Auto standby off, - # until a tap, Wake or an alert wakes the screen. - - id: sleep_requested - type: bool - restore_value: no - initial_value: 'false' - - - id: active_label_off - type: std::string - restore_value: no - - - id: active_label_on - type: std::string - restore_value: no - - - id: active_title - type: std::string - restore_value: no - - - id: active_icon_text - type: std::string - restore_value: no - - - id: active_brightness_pct - type: int - restore_value: no - initial_value: '100' - - - id: active_supports_color - type: bool - restore_value: no - initial_value: 'false' - - - id: active_supports_temperature - type: bool - restore_value: no - initial_value: 'false' - - - id: active_color_detail_tab - type: int - restore_value: no - initial_value: '0' - - - id: active_climate_current_x10 - type: int - restore_value: no - initial_value: '0' - - - id: active_climate_target_x10 - type: int - restore_value: no - initial_value: '200' - - - id: active_climate_humidity - type: int - restore_value: no - initial_value: '-1' - - - id: active_climate_min_x10 - type: int - restore_value: no - initial_value: '50' - - - id: active_climate_max_x10 - type: int - restore_value: no - initial_value: '300' - - - id: active_climate_step_x10 - type: int - restore_value: no - initial_value: '5' - - - id: active_climate_mode - type: std::string - restore_value: no - - - id: active_climate_power_on - type: bool - restore_value: no - initial_value: 'false' - - - id: active_climate_fan_modes - type: std::string - restore_value: no - - - id: active_climate_fan_mode - type: std::string - restore_value: no - - - id: active_climate_swing_modes - type: std::string - restore_value: no - - - id: active_climate_swing_mode - type: std::string - restore_value: no - - - id: active_climate_supported_modes - type: std::string - restore_value: no - - # Room temperature (NAN when unknown), what the device does (hvac_action, runtime tiles only), - # and whether Home Assistant reported a setpoint at all (heat_cool ranges do not). - - id: active_climate_current - type: float - restore_value: no - initial_value: 'NAN' - - - id: active_climate_action - type: std::string - restore_value: no - - - id: active_climate_target_known - type: bool - restore_value: no - initial_value: 'false' - -font: - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" - id: time_label - size: 18 - bpp: 4 - glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" - id: headline - size: 18 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" - id: watch_value - size: 22 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - # Clock card digits only; the date line reuses the tile title font. - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" - id: clock_digits - size: 24 - bpp: 4 - glyphs: [':','-','0','1','2','3','4','5','6','7','8','9'] - - # The climate card's setpoint, large enough to read across the room. - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" - id: setpoint_digits - size: 40 - bpp: 4 - glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-700.ttf" - id: label - size: 11 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" - id: sublabel - size: 11 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" - id: sublabel_big - size: 14 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–'] - - # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) - # Source: https://github.com/Templarian/MaterialDesign-Webfont - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" - id: materialdesign_icons - size: 28 - bpp: 4 - glyphs: &tile_icons # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py - - "\U000F0335" # lightbulb - - "\U000F1253" # lightbulb-group - - "\U000F0769" # ceiling-light - - "\U000F1793" # chandelier - - "\U000F06B5" # lamp - - "\U000F08DD" # floor-lamp - - "\U000F095F" # desk-lamp - - "\U000F091D" # wall-sconce-flat - - "\U000F179B" # light-recessed - - "\U000F04C9" # spotlight-beam - - "\U000F1051" # led-strip-variant - - "\U000F1054" # outdoor-lamp - - "\U000F02DC" # home - - "\U000F04B9" # sofa - - "\U000F02E3" # bed - - "\U000F0A70" # silverware-fork-knife - - "\U000F0B7C" # chef-hat - - "\U000F09A0" # shower - - "\U000F1818" # bathtub - - "\U000F09AB" # toilet - - "\U000F1239" # desk - - "\U000F06D9" # garage - - "\U000F0E2E" # fireplace - - "\U000F050F" # thermometer - - "\U000F0393" # thermostat - - "\U000F001B" # air-conditioner - - "\U000F0438" # radiator - - "\U000F1A43" # heat-pump - - "\U000F0238" # fire - - "\U000F0717" # snowflake - - "\U000F058E" # water-percent - - "\U000F0210" # fan - - "\U000F1797" # ceiling-fan - - "\U000F0D44" # air-purifier - - "\U000F07E4" # molecule-co2 - - "\U000F0599" # weather-sunny - - "\U000F0595" # weather-partly-cloudy - - "\U000F0590" # weather-cloudy - - "\U000F0597" # weather-rainy - - "\U000F0598" # weather-snowy - - "\U000F059D" # weather-windy - - "\U000F0594" # weather-night - - "\U000F059C" # weather-sunset-up - - "\U000F0387" # music-note - - "\U000F075A" # music - - "\U000F0CB8" # playlist-music - - "\U000F0025" # album - - "\U000F099A" # record-player - - "\U000F04C7" # spotify - - "\U000F0439" # radio - - "\U000F0994" # podcast - - "\U000F04C3" # speaker - - "\U000F0D38" # speaker-multiple - - "\U000F17DB" # soundbar - - "\U000F02CB" # headphones - - "\U000F067D" # piano - - "\U000F0771" # guitar-acoustic - - "\U000F0502" # television - - "\U000F042E" # projector - - "\U000F0FCE" # movie-open - - "\U000F0118" # cast - - "\U000F0297" # gamepad-variant - - "\U000F0EC5" # remote-tv - - "\U000F033E" # lock - - "\U000F0FC6" # lock-open-variant - - "\U000F068A" # shield-home - - "\U000F078F" # alarm-light - - "\U000F009A" # bell - - "\U000F12E6" # doorbell - - "\U000F07AE" # cctv - - "\U000F0D91" # motion-sensor - - "\U000F081B" # door-closed - - "\U000F081C" # door-open - - "\U000F11DB" # window-closed-variant - - "\U000F0392" # smoke-detector - - "\U000F1502" # water-alert - - "\U000F072A" # washing-machine - - "\U000F0917" # tumble-dryer - - "\U000F0AAC" # dishwasher - - "\U000F0290" # fridge - - "\U000F04DE" # stove - - "\U000F0C99" # microwave - - "\U000F0CD3" # toaster-oven - - "\U000F109F" # coffee-maker - - "\U000F05FA" # kettle - - "\U000F070D" # robot-vacuum - - "\U000F11F7" # robot-mower - - "\U000F042B" # printer-3d - - "\U000F06A5" # power-plug - - "\U000F07E7" # power-socket-eu - - "\U000F0322" # laptop - - "\U000F0469" # router-wireless - - "\U000F0241" # flash - - "\U000F0A72" # solar-power - - "\U000F0D9B" # solar-panel - - "\U000F1901" # home-battery - - "\U000F12A3" # battery-high - - "\U000F05F1" # ev-station - - "\U000F0B6C" # car-electric - - "\U000F1A57" # meter-electric - - "\U000F1A59" # meter-gas - - "\U000F058C" # water - - "\U000F0F92" # water-boiler - - "\U000F029A" # gauge - - "\U000F111C" # window-shutter - - "\U000F111E" # window-shutter-open - - "\U000F00AC" # blinds - - "\U000F1011" # blinds-open - - "\U000F1A6C" # roller-shade-closed - - "\U000F1846" # curtains - - "\U000F1847" # curtains-closed - - "\U000F1B88" # awning-outline - - "\U000F024A" # flower - - "\U000F0531" # tree - - "\U000F0E66" # sprout - - "\U000F1510" # grass - - "\U000F105F" # sprinkler - - "\U000F0606" # pool - - "\U000F0E45" # grill - - "\U000F06EE" # mailbox - - "\U000F0A79" # trash-can - - "\U000F044C" # recycle - - "\U000F0A43" # dog - - "\U000F011B" # cat - - "\U000F0004" # account - - "\U000F0849" # account-group - - "\U000F0A89" # account-child - - "\U000F0826" # home-account - - "\U000F04B2" # sleep - - "\U000F070E" # run - - "\U000F00D6" # briefcase - - "\U000F010B" # car - - "\U000F00A3" # bike - - "\U000F001D" # airplane - - "\U000F034E" # map-marker - - "\U000F0425" # power - - "\U000F0521" # toggle-switch - - "\U000F12A8" # gesture-tap-button - - "\U000F040A" # play - - "\U000F0BC2" # script-text - - "\U000F03D8" # palette - - "\U000F0020" # alarm - - "\U000F0150" # clock-outline - - "\U000F051B" # timer-outline - - "\U000F00ED" # calendar - - "\U000F009E" # bell-ring - - "\U000F02D1" # heart - - "\U000F002A" # alert-outline - - "\U000F0493" # cog - - "\U000F00E2" # broom - - "\U000F05D6" # alert-circle-outline - - "\U000F012C" # check - - "\U000F03E4" # pause - - "\U000F04DB" # stop - - "\U000F04AD" # skip-next - - "\U000F04AE" # skip-previous - - "\U000F057E" # volume-high - - "\U000F0581" # volume-off - - "\U000F005D" # arrow-up - - "\U000F0045" # arrow-down - - "\U000F084E" # arrow-expand-horizontal - - "\U000F084C" # arrow-collapse-horizontal - - "\U000F05F8" # home-map-marker - - "\U000F0415" # plus - - "\U000F0374" # minus - - "\U000F0141" # chevron-left - - "\U000F0142" # chevron-right - - "\U000F0156" # close - - "\U000F004D" # arrow-left - - "\U000F1A79" # sun-snowflake-variant - - "\U000F1B17" # thermostat-auto - - "\U000F0591" # weather-fog - - "\U000F0592" # weather-hail - - "\U000F0593" # weather-lightning - - "\U000F067E" # weather-lightning-rainy - - "\U000F0596" # weather-pouring - - "\U000F067F" # weather-snowy-rainy - - "\U000F059B" # weather-sunset-down - - "\U000F01D8" # dots-horizontal - - "\U000F1C91" # arrow-oscillating - - "\U000F0379" # monitor - - "\U000F02FD" # information-outline - - "\U000F0709" # restart - - "\U000F0E4F" # lightbulb-off - - "\U000F0B53" # account-arrow-right - - "\U000F0D43" # air-filter - - "\U000F1099" # air-humidifier - - "\U000F1466" # air-humidifier-off - - "\U000F0028" # alert-circle - - "\U000F0937" # angle-acute - - "\U000F0042" # arrow-bottom-left - - "\U000F0043" # arrow-bottom-right - - "\U000F06C0" # arrow-down-box - - "\U000F0E73" # arrow-left-right - - "\U000F0054" # arrow-right - - "\U000F093C" # arrow-split-vertical - - "\U000F005B" # arrow-top-left - - "\U000F005C" # arrow-top-right - - "\U000F06C3" # arrow-up-box - - "\U000F093D" # audio-video - - "\U000F11B6" # audio-video-off - - "\U000F0079" # battery - - "\U000F007A" # battery-10 - - "\U000F007B" # battery-20 - - "\U000F007C" # battery-30 - - "\U000F007D" # battery-40 - - "\U000F007E" # battery-50 - - "\U000F007F" # battery-60 - - "\U000F0080" # battery-70 - - "\U000F0081" # battery-80 - - "\U000F0082" # battery-90 - - "\U000F0083" # battery-alert - - "\U000F0084" # battery-charging - - "\U000F008E" # battery-outline - - "\U000F0091" # battery-unknown - - "\U000F1A2B" # blinds-horizontal - - "\U000F1A2C" # blinds-horizontal-closed - - "\U000F00AF" # bluetooth - - "\U000F00B1" # bluetooth-connect - - "\U000F00DE" # brightness-5 - - "\U000F00E0" # brightness-7 - - "\U000F1B50" # button-pointer - - "\U000F010C" # car-battery - - "\U000F1019" # car-coolant-level - - "\U000F0114" # cash - - "\U000F0119" # cast-connected - - "\U000F078A" # cast-off - - "\U000F05E0" # check-circle - - "\U000F05E1" # check-circle-outline - - "\U000F0C54" # check-network-outline - - "\U000F0133" # checkbox-marked-circle - - "\U000F0AA5" # circle-slice-8 - - "\U000F0954" # clock - - "\U000F0155" # clock-start - - "\U000F015A" # close-circle-outline - - "\U000F0C5F" # close-network-outline - - "\U000F1382" # compass-rose - - "\U000F01A1" # crop-portrait - - "\U000F1136" # crosshairs-question - - "\U000F1480" # current-ac - - "\U000F01BC" # database - - "\U000F07C5" # ear-hearing - - "\U000F0208" # eye - - "\U000F0D04" # eye-check - - "\U000F081D" # fan-off - - "\U000F0279" # format-list-bulleted - - "\U000F06DA" # garage-open - - "\U000F0299" # gate - - "\U000F116A" # gate-open - - "\U000F06A1" # home-outline - - "\U000F0318" # lan-connect - - "\U000F0319" # lan-disconnect - - "\U000F140B" # lightning-bolt - - "\U000F08EE" # lock-alert - - "\U000F097F" # lock-clock - - "\U000F033F" # lock-open - - "\U000F0BAC" # molecule - - "\U000F12FE" # molecule-co - - "\U000F1435" # motion-sensor-off - - "\U000F038A" # music-note-off - - "\U000F03C3" # octagon - - "\U000F03D3" # package - - "\U000F03D5" # package-up - - "\U000F17C5" # ph - - "\U000F184D" # pipe-valve - - "\U000F06A6" # power-plug-off - - "\U000F0996" # progress-clock - - "\U000F1A23" # projector-off - - "\U000F043C" # radioactive - - "\U000F0445" # ray-vertex - - "\U000F1A6B" # roller-shade - - "\U000F1727" # script-text-play - - "\U000F0483" # security - - "\U000F0498" # shield - - "\U000F06BB" # shield-airplane - - "\U000F099D" # shield-lock - - "\U000F1828" # shield-moon - - "\U000F099E" # shield-off - - "\U000F0499" # shield-outline - - "\U000F095B" # sine-wave - - "\U000F192E" # smoke-detector-alert - - "\U000F180B" # smoke-detector-variant - - "\U000F1930" # smoke-detector-variant-alert - - "\U000F04C4" # speaker-off - - "\U000F1B73" # speaker-pause - - "\U000F1B72" # speaker-play - - "\U000F04C5" # speedometer - - "\U000F0674" # creation - - "\U000F0E0C" # palette-outline - - "\U000F0411" # playlist-play - - "\U000F08FF" # play-speed - - "\U000F00DF" # brightness-6 - - "\U000F1429" # spoon-sugar - - "\U000F0E67" # sprout-outline - - "\U000F0764" # square - - "\U000F0763" # square-outline - - "\U000F1A75" # storage-tank - - "\U000F17FE" # sun-wireless - - "\U000F083B" # television-off - - "\U000F0F89" # television-pause - - "\U000F0ECF" # television-play - - "\U000F0FE6" # texture-box - - "\U000F0510" # thermometer-lines - - "\U000F1A25" # toggle-switch-variant - - "\U000F1A26" # toggle-switch-variant-off - - "\U000F0D3E" # transmission-tower - - "\U000F0566" # vibrate - - "\U000F11B4" # water-boiler-off - - "\U000F058D" # water-off - - "\U000F1855" # water-opacity - - "\U000F05A1" # weight - - "\U000F05A9" # wifi - - "\U000F05AE" # window-closed - - "\U000F05B1" # window-open - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" - id: materialdesign_icons_mini - size: 18 - bpp: 4 - glyphs: *tile_icons - - # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, - # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" - id: materialdesign_icons_big - size: 40 - bpp: 4 - glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py - - "\U000F0335" # lightbulb - - "\U000F001B" # air-conditioner - - "\U000F070D" # robot-vacuum - - "\U000F0210" # fan - - "\U000F111C" # window-shutter - - "\U000F04B9" # sofa - - "\U000F029A" # gauge - - "\U000F051B" # timer-outline - - "\U000F0004" # account - - "\U000F07AE" # cctv - - "\U000F0150" # clock-outline - - "\U000F0493" # cog - - "\U000F0054" # arrow-right - - "\U000F0E4F" # lightbulb-off - - "\U000F0425" # power - - "\U000F0599" # weather-sunny - - "\U000F0594" # weather-night - - "\U000F0590" # weather-cloudy - - "\U000F0595" # weather-partly-cloudy - - "\U000F0597" # weather-rainy - - "\U000F0596" # weather-pouring - - "\U000F0598" # weather-snowy - - "\U000F067F" # weather-snowy-rainy - - "\U000F0591" # weather-fog - - "\U000F0592" # weather-hail - - "\U000F0593" # weather-lightning - - "\U000F067E" # weather-lightning-rainy - - "\U000F059D" # weather-windy - - "\U000F05D6" # alert-circle-outline - - "\U000F059C" # weather-sunset-up - - "\U000F059B" # weather-sunset-down - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" - id: watch_icon - size: 12 - bpp: 4 - glyphs: *tile_icons - -# Make memory visible: this board has no PSRAM, so the free heap determines -# how much UI still fits. Also shows up as an entity in Home Assistant. -debug: - # Heap figures every five minutes: enough to spot a leak, few recorder rows. - update_interval: 300s - -sensor: - - platform: debug - free: - name: "Heap Free" - id: heap_free - on_value: - - lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);' - fragmentation: - name: "Heap Fragmentation" - block: - name: "Heap Largest Block" - on_value: - - lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);' - # The lowest free heap since boot: what the busiest moment (boot sync, a swipe) left over. - min_free: - name: "Heap Minimum Free" - on_value: - - lambda: 'ESP_LOGI("health", "min_free=%.0f", x);' - - platform: wifi_signal - name: "Wifi Signal" - update_interval: 600s - - # When the screen booted, as a timestamp: one value per boot instead of a new recorder - # row every 15 s. A new entity on purpose: Home Assistant keeps the old Uptime's unit - # "s" in its registry and would refuse a timestamp under that name. - - platform: uptime - type: timestamp - name: "Last boot" - id: boot_time - - -binary_sensor: - - platform: status - name: "Node Status" - id: system_status - -# ---------------------------------------------------------- -# Text sensors -# -# smartdisplay_action: -# - Main touch event channel exported to Home Assistant -# - On short tap, publishes one of: -# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press -# - The value is reset shortly after publishing -# -# tile*_preset: -# - Mirrors the Home Assistant preset_mode attribute for each tile entity -# - Used by the UI to display fan presets when applicable -# ---------------------------------------------------------- -text_sensor: - - platform: template - name: "Screen firmware" - entity_category: diagnostic - lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' - update_interval: 60s - # ESP Screen Manager matches the screen to its YAML profile and OTA address. - - platform: template - name: "Device name" - entity_category: diagnostic - lambda: 'return {"${DEVICE_NAME}"};' - update_interval: 60s - - platform: wifi_info - ip_address: - # `id` so the settings page on the screen can show the same address (0.2.44+). - id: wifi_ip - name: "IP address" - entity_category: diagnostic - -switch: - - platform: restart - name: "Restart" - # The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen - # owns them: the settings page, these entities and ESP Screens all change them through - # settings_screen::set(), which saves, applies and reports a real change only. The saved values keep - # their preference records. A template switch reads its lambda every loop and publishes a change itself; - # the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the - # standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that - # moment. Nothing runs at boot: every switch follows the saved setting. - - platform: template - id: setting_auto_standby - name: "Auto standby" - icon: "mdi:sleep" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.standby_enabled; - turn_on_action: - - lambda: |- - id(last_touch_ms) = millis(); - settings_screen::set("standby_enabled", 1); - turn_off_action: - - lambda: 'settings_screen::set("standby_enabled", 0);' - - platform: template - id: setting_night_mode - name: "Night mode" - icon: "mdi:weather-night" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.night_enabled; - turn_on_action: - - lambda: 'settings_screen::set("night_enabled", 1);' - turn_off_action: - - lambda: 'settings_screen::set("night_enabled", 0);' - # The dark look for a screen beside a bed (firmware 0.2.54+): black page, graphite cards, soft white text. - - platform: template - id: setting_dark_mode - name: "Dark mode" - icon: "mdi:theme-light-dark" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::dark_mode != 0; - turn_on_action: - - lambda: 'settings_screen::set("dark_mode", 1);' - turn_off_action: - - lambda: 'settings_screen::set("dark_mode", 0);' - - platform: template - id: setting_clock_24h - name: "24-hour clock" - icon: "mdi:clock-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.clock_24h; - turn_on_action: - - lambda: 'settings_screen::set("clock_24h", 1);' - turn_off_action: - - lambda: 'settings_screen::set("clock_24h", 0);' - - platform: template - id: setting_auto_home - name: "Back to page 1" - icon: "mdi:home-import-outline" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::auto_home != 0; - turn_on_action: - - lambda: 'settings_screen::set("auto_home", 1);' - turn_off_action: - - lambda: 'settings_screen::set("auto_home", 0);' - - platform: template - id: setting_home_on_standby - name: "Back to page 1 on standby" - icon: "mdi:home-import-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.home_on_standby; - turn_on_action: - - lambda: 'settings_screen::set("home_on_standby", 1);' - turn_off_action: - - lambda: 'settings_screen::set("home_on_standby", 0);' - - platform: template - id: setting_swipe_pages - name: "Swipe between pages" - icon: "mdi:gesture-swipe-horizontal" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::swipe_pages != 0; - turn_on_action: - - lambda: 'settings_screen::set("swipe_pages", 1);' - turn_off_action: - - lambda: 'settings_screen::set("swipe_pages", 0);' - # Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+). - - platform: template - id: setting_page_buttons - name: "Page buttons" - icon: "mdi:page-next-outline" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::page_buttons != 0; - turn_on_action: - - lambda: 'settings_screen::set("page_buttons", 1);' - turn_off_action: - - lambda: 'settings_screen::set("page_buttons", 0);' - -# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight. -datetime: - - platform: template - type: time - id: setting_night_start - name: "Night starts" - icon: "mdi:weather-sunset-down" - entity_category: config - update_interval: never - lambda: |- - ESPTime time{}; - time.hour = screen_settings::current.night_start / 60; - time.minute = screen_settings::current.night_start % 60; - return time; - set_action: - - lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);' - - platform: template - type: time - id: setting_night_end - name: "Night ends" - icon: "mdi:weather-sunset-up" - entity_category: config - update_interval: never - lambda: |- - ESPTime time{}; - time.hour = screen_settings::current.night_end / 60; - time.minute = screen_settings::current.night_end % 60; - return time; - set_action: - - lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);' - -# 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: - # The theme's font for text without a font of its own. Every label names its font, so this only keeps - # ESPHome from building LVGL's Montserrat 14 (about 14 KB of flash) for nothing (firmware 0.2.65+). - default_font: sublabel_big - on_draw_start: - - lambda: 'id(rendered_frames)++;' - displays: - - my_display - touchscreens: - - touchscreen_id: ts_touch - - # The ESP32-WROOM-32 has no PSRAM; a full framebuffer (320x240x2 - # = 153600 bytes) doesn't fit in DRAM. 25% is the usual value. - # 12% instead of 25%: frees up ~20KB of extra contiguous memory, - # and LVGL scales the strips of an ARGB layer along with the available - # drawing space, so the requested allocations get smaller too. - buffer_size: 12% - - # Direct local feedback on the WHOLE UI, not just the tiles. - # A theme sets the style per widget type, so this covers all - # buttons, tabs, sliders and the thermostat ring in one go. Non-clickable - # widgets never enter the pressed state, so those stay untouched. - theme: - obj: - pressed: - # bg_opa and not opa: opa would also fade the icon and text. Styles that - # make LVGL draw into a buffer of its own (opa_layered, rotation/scale, - # clip_corner, a slider fill rounded less than its track) cost KB's per - # redraw that the CYD's heap can't spare; tests/test_layer_free.py. - bg_opa: 45% - slider: - knob: - pressed: - bg_opa: 65% - arc: - knob: - pressed: - bg_opa: 65% - - # LVGL owns the rotation (display + touch) since ESPHome 2026.4. - rotation: ${LVGL_ROTATION} - - style_definitions: - # The page behind the tiles and the cards: one colour from the theme (theme.h PAGE), no wallpaper and no - # gradient, so a redraw is a plain fill. - - id: style_page - bg_opa: COVER - border_width: 0 - radius: 0 - - - id: style_tile - bg_opa: COVER - radius: 18 - border_width: 1 - border_opa: COVER - pad_left: 8 - pad_right: 8 - pad_top: 8 - pad_bottom: 8 - shadow_width: 0 - shadow_opa: 40% - - - id: style_icon_circle - bg_opa: COVER - radius: 999 - border_width: 0 - - - id: style_room - text_font: headline - text_opa: 100% - - - id: style_time - text_font: time_label - text_opa: 100% - - - id: style_title - text_font: label - text_opa: 100% - - - id: style_value - text_font: sublabel - text_opa: 100% - - - id: style_value_big - text_font: sublabel_big - text_opa: 80% - - # Paints: shared styles without a colour of their own. theme.h fills them (the list in on_boot, theme::paints) - # and fills them again when Dark mode changes, so a widget names a paint where it used to write a colour. - - id: paint_page_soft - - id: paint_page_pressed - - id: paint_card - - id: paint_raised - - id: paint_key - - id: paint_key_pressed - - id: paint_track - - id: paint_ink - - id: paint_ink_soft - - id: paint_muted - - id: paint_subtle - - id: paint_slate - - id: paint_scrim - - id: paint_veil - - id: paint_accent_tint - - id: paint_accent_bright - - id: paint_button_dark - - id: paint_button_dark_pressed - - id: paint_calibration - - # ---------- ALERT OVERLAY (HA action show_alert) ---------- - # LVGL's top layer sits above every page, card and the dim overlay; the card - # is filled by the alert_show script. Fixed positions, no layout engine. - top_layer: - widgets: - - obj: - id: alert_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - styles: paint_scrim - bg_opa: 60% - radius: 0 - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - # Same look when pressed: a tap next to the card must not repaint the screen. - pressed: - bg_opa: 60% - widgets: - - obj: - id: alert_card - align: CENTER - width: ${ALERT_CARD_W} - height: ${ALERT_CARD_H} - styles: paint_raised - bg_opa: COVER - radius: 18 - border_width: 1 - border_opa: COVER - shadow_width: 0 - pad_all: 0 - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: alert_icon - x: ${ALERT_ICON_X} - y: ${ALERT_ICON_Y} - text: "\U000F002A" - text_font: materialdesign_icons - clickable: false - - label: - id: alert_title - x: ${ALERT_TEXT_X} - y: ${ALERT_TITLE_Y} - width: ${ALERT_TEXT_W} - height: ${ALERT_TITLE_H} - long_mode: DOT - text: "Notification" - text_font: headline - clickable: false - - label: - id: alert_subtitle - x: ${ALERT_TEXT_X} - y: ${ALERT_SUBTITLE_Y} - width: ${ALERT_TEXT_W} - height: ${ALERT_SUBTITLE_H} - long_mode: WRAP - text: "" - text_font: sublabel_big - styles: paint_ink_soft - clickable: false - - obj: - id: alert_ok - align: BOTTOM_RIGHT - x: -${ALERT_BUTTON_INSET} - y: -${ALERT_BUTTON_INSET} - width: ${ALERT_BUTTON_W} - height: ${ALERT_BUTTON_H} - radius: 999 - styles: paint_button_dark - bg_opa: COVER - border_width: 0 - shadow_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_button_dark_pressed - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept(millis(), 13);' - then: - - script.execute: - id: alert_dismiss - reason: "ok" - widgets: - - label: - id: alert_ok_label - align: CENTER - text: "OK" - text_font: headline - clickable: false - - pages: - - id: home_page - styles: style_page - pad_all: 0 - scrollable: false - scrollbar_mode: 'OFF' - widgets: - # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. - - spinner: - id: busy_spinner_seed - hidden: true - width: 2 - height: 2 - spin_time: 1s - arc_length: 60deg - # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from - # (firmware 0.2.70+). - - roller: - id: roller_seed - hidden: true - width: 2 - height: 2 - options: ["-"] - - label: - id: lbl_room - x: 11 - y: ${HEADER_Y} - text: "${ROOM_NAME}" - styles: style_room - - # Enables LVGL's line widget for the runtime clock hands and sparklines. - - line: - id: runtime_line_enable - hidden: true - points: - - 0, 0 - - 1, 1 - - - label: - id: lbl_time - align: TOP_RIGHT - x: -11 - y: ${HEADER_Y} - text: "" - styles: style_time - - # Six tiles on page 1, four on page 2. No dragging on resistive glass. - - obj: - id: tile_scroll - x: 0 - y: ${SCROLL_Y} - width: ${DISPLAY_W} - height: ${SCROLL_H} - bg_opa: TRANSP - # A tap between two tiles presses this area: without its own pressed style the - # theme's 45 % lights it up as a pale panel behind the tiles. - pressed: - bg_opa: TRANSP - border_width: 0 - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - scrollable: false - scrollbar_mode: 'OFF' - widgets: - # ---------- TILE 1 ---------- - - obj: - id: tile1 - x: ${GRID_COL1_X} - y: ${GRID_ROW1_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile1_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile1_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F001B" - clickable: false - - label: - id: t1_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Lamp" - styles: style_title - clickable: false - - label: - id: t1_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 2 ---------- - - obj: - id: tile2 - x: ${GRID_COL2_X} - y: ${GRID_ROW1_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile2_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile2_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F0387" - clickable: false - - label: - id: t2_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Plug" - styles: style_title - clickable: false - - label: - id: t2_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 3 ---------- - - obj: - id: tile3 - x: ${GRID_COL1_X} - y: ${GRID_ROW2_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile3_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile3_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F02E3" - clickable: false - - label: - id: t3_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Evening" - styles: style_title - clickable: false - - label: - id: t3_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 4 ---------- - - obj: - id: tile4 - x: ${GRID_COL2_X} - y: ${GRID_ROW2_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile4_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile4_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F059C" - clickable: false - - label: - id: t4_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "All off" - styles: style_title - clickable: false - - label: - id: t4_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 5 ---------- - - obj: - id: tile5 - x: ${GRID_COL1_X} - y: ${GRID_ROW3_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile5_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile5_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F0425" - clickable: false - - label: - id: t5_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "AC" - styles: style_title - clickable: false - - label: - id: t5_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 6 ---------- - - obj: - id: tile6 - x: ${GRID_COL2_X} - y: ${GRID_ROW3_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile6_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile6_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F070D" - clickable: false - - label: - id: t6_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Vacuum" - styles: style_title - clickable: false - - label: - id: t6_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 7 — Table lamp ---------- - # Brightness-only lamp: deliberately no percentage/cover/climate - # sensors were created for this tile, those would only cost - # HA subscriptions and heap. The overlay therefore gets - # NAN / "" for the fields a light never uses. - - obj: - id: tile7 - hidden: true - x: ${GRID_COL1_X} - y: ${GRID_ROW4_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile7_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile7_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F06B5" - clickable: false - - label: - id: t7_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 7" - styles: style_title - clickable: false - - label: - id: t7_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 8 — Mailbox reset ---------- - # input_select with options Full/Empty. The tile lights up as soon as - # the status is TILE8_ACTIVE_STATE ("Full"); a short tap sends - # input_select.select_option with option: "Empty". - # No slider mode, so this tile only needs ha_state_tile8 — - # no other HA subscription at all. - - obj: - id: tile8 - hidden: true - x: ${GRID_COL2_X} - y: ${GRID_ROW4_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile8_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile8_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F06EE" - clickable: false - - label: - id: t8_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 8" - styles: style_title - clickable: false - - label: - id: t8_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 9 — Living room (moved from tile 2) ---------- - - obj: - id: tile9 - hidden: true - x: ${GRID_COL1_X} - y: ${GRID_ROW5_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile9_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile9_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F04B9" - clickable: false - - label: - id: t9_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 9" - styles: style_title - clickable: false - - label: - id: t9_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 10 — Alarm on/off ---------- - - obj: - id: tile10 - hidden: true - x: ${GRID_COL2_X} - y: ${GRID_ROW5_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile10_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 36 - height: 36 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile10_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F002A" - clickable: false - - label: - id: t10_title - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 10" - styles: style_title - clickable: false - - label: - id: t10_value - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- VALUE SLIDER OVERLAY ---------- - # ---------- PAGE NAVIGATION: the band under the tiles ---------- - # Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the - # page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge. - - obj: - id: page_prev - align: BOTTOM_LEFT - x: 0 - y: 0 - width: 160 - height: 36 - pad_all: 0 - radius: 0 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pressed: - styles: paint_page_pressed - bg_opa: COVER - # on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing - # (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe. - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);' - then: - - lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);' - - script.execute: show_tile_page - widgets: - - label: - align: LEFT_MID - x: 14 - text: "\U000F0141" # chevron-left - text_font: materialdesign_icons_mini - styles: paint_ink - clickable: false - - obj: - id: page_number - align: BOTTOM_MID - y: 0 - width: 104 - height: 36 - pad_all: 0 - bg_opa: TRANSP - border_width: 0 - clickable: false - scrollable: false - - obj: - id: page_next - align: BOTTOM_RIGHT - x: 0 - y: 0 - width: 160 - height: 36 - pad_all: 0 - radius: 0 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pressed: - styles: paint_page_pressed - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);' - then: - - lambda: 'id(tile_page) += 1;' - - script.execute: show_tile_page - widgets: - - label: - align: RIGHT_MID - x: -14 - text: "\U000F0142" # chevron-right - text_font: materialdesign_icons_mini - styles: paint_ink - clickable: false - - - obj: - id: brightness_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - bg_opa: TRANSP - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: brightness_overlay_bg - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: style_page - scrollable: false - clickable: true - on_click: - - script.execute: close_cards - - - obj: - id: brightness_overlay_dim - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: paint_veil - bg_opa: 18% - border_width: 0 - clickable: false - scrollable: false - - - obj: - id: overlay_back_button - align: TOP_LEFT - x: 10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: overlay_title - align: TOP_MID - y: 19 - text: "Light" - styles: style_room - - - label: - id: overlay_value - align: TOP_MID - y: 42 - text: "100 %" - styles: style_value_big - - - slider: - id: overlay_slider - align: CENTER - y: 24 - width: ${OVERLAY_SLIDER_W} - height: ${OVERLAY_SLIDER_H} - min_value: 0 - max_value: 100 - value: 100 - adv_hittest: false - - styles: paint_accent_tint - bg_opa: 100% - radius: 28 - # The only widget with BOTH a radius AND a child (overlay_icon). - # LVGL then clips children to the rounding, and that's only - # possible via a contiguous 96x160 = 46KB ARGB layer. - # If that allocation fails, LVGL keeps trying until the - # task watchdog restarts the board. The rounding stays, the - # clipping goes. - clip_corner: false - border_width: 0 - - indicator: - styles: paint_accent_bright - bg_opa: 100% - radius: 28 - - knob: - bg_opa: 0% - border_width: 0 - shadow_width: 0 - pad_all: 10 - - on_value: - - script.execute: - id: slider_preview - value: !lambda "return x;" - - on_release: - - script.execute: - id: slider_commit - value: !lambda "return lv_slider_get_value(id(overlay_slider));" - - - # The icon used to be a CHILD in the slider. A widget with a radius - # and children has to clip those children to the rounding, and - # LVGL can only do that via an ARGB layer of tens of KB's. Without - # PSRAM that sometimes fails, after which LVGL keeps retrying until - # the watchdog restarts the board. As a sibling on top, no layer - # is needed and it looks identical. - - label: - id: overlay_icon - align: CENTER - y: ${OVERLAY_ICON_Y} - text_font: materialdesign_icons - styles: paint_ink - text: "\U000F001B" - clickable: false - - - obj: - id: overlay_color_button - align: BOTTOM_RIGHT - x: -13 - y: -13 - width: 44 - height: 44 - radius: 999 - hidden: true - clickable: true - bg_opa: TRANSP - border_width: 0 - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - scrollable: false - scrollbar_mode: 'OFF' - on_click: - - script.execute: open_color_detail - widgets: - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 0 - end_angle: 45 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0xFF3B30 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 45 - end_angle: 90 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0xFF9500 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 90 - end_angle: 135 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0xFFD60A - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 135 - end_angle: 180 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0x30D158 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 180 - end_angle: 225 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0x32D6FF - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 225 - end_angle: 270 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0x0A84FF - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 270 - end_angle: 315 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0x5856D6 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 40 - height: 40 - start_angle: 315 - end_angle: 360 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 10 - arc_rounded: false - arc_color: 0xBF5AF2 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - # ---------- LIGHT COLOR DETAIL OVERLAY ---------- - - obj: - id: color_detail_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: paint_page_soft - bg_opa: COVER - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: color_close_button - align: TOP_LEFT - x: 10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette, - # preset, speed), opens its effects page. Hidden for every other light. - - obj: - id: color_effects_button - align: TOP_RIGHT - x: -10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - hidden: true - clickable: true - scrollable: false - on_click: - - lambda: 'effects_page::open(id(active_entity));' - widgets: - - label: - align: CENTER - text: "\U000F0674" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: color_detail_title - width: 210 - long_mode: DOT - text_align: CENTER - align: TOP_MID - y: 21 - text: "Light Color" - styles: style_room - - - # ---------- CLIMATE DETAIL OVERLAY ---------- - # Made for the small resistive panel: nothing to drag. A setpoint card with big -/+ keys - # (the number changes at once, one call goes out once the taps stop) and one row of mode - # keys like Home Assistant's climate card; fan and swing stay in the picker behind "more". - # A tap redraws only a label or a key, never a dial, so the SPI panel keeps up. - - obj: - id: climate_detail_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: style_page - bg_opa: COVER - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: climate_close_button - align: TOP_LEFT - x: 10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - obj: - id: climate_power_button - align: TOP_RIGHT - x: -10 - y: 8 - width: 40 - height: 40 - radius: 999 - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: climate_power_toggle - widgets: - - label: - id: climate_power_icon - align: CENTER - text: "\U000F0425" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: climate_detail_title - align: TOP_MID - y: 18 - width: 210 - long_mode: DOT - text_align: CENTER - text: "Climate" - styles: style_room - - # What the device does (or its mode), the room temperature and humidity. - - label: - id: climate_current_label - align: TOP_MID - y: 54 - width: 94% - long_mode: DOT - text_align: CENTER - text: "--" - text_font: sublabel_big - styles: paint_muted - clickable: false - - - obj: - id: climate_setpoint_card - align: TOP_MID - y: 80 - width: 94% - height: 92 - radius: 16 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: false - scrollable: false - widgets: - # Every clean tap counts (a quick triple tap is three steps); holding keeps stepping. - - obj: - id: climate_minus_button - align: LEFT_MID - x: 10 - width: 72 - height: 72 - radius: 36 - styles: paint_track - bg_opa: COVER - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key_pressed - disabled: - opa: 40% - on_short_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 60);' - then: - - script.execute: { id: climate_adjust_target, direction: -1, held: false } - on_long_press_repeat: - - script.execute: { id: climate_adjust_target, direction: -1, held: true } - widgets: - - label: - id: climate_minus_icon - align: CENTER - text: "\U000F0374" - text_font: materialdesign_icons - clickable: false - - obj: - id: climate_plus_button - align: RIGHT_MID - x: -10 - width: 72 - height: 72 - radius: 36 - styles: paint_track - bg_opa: COVER - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key_pressed - disabled: - opa: 40% - on_short_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 61);' - then: - - script.execute: { id: climate_adjust_target, direction: 1, held: false } - on_long_press_repeat: - - script.execute: { id: climate_adjust_target, direction: 1, held: true } - widgets: - - label: - id: climate_plus_icon - align: CENTER - text: "\U000F0415" - text_font: materialdesign_icons - clickable: false - - label: - id: climate_target_label - align: CENTER - y: -8 - text: "--" - text_font: setpoint_digits - clickable: false - - label: - id: climate_set_label - align: CENTER - y: 26 - text: "Target" - text_font: sublabel - styles: paint_subtle - clickable: false - - # One key per mode the device supports (off is the power key); the one in use is - # filled in Home Assistant's colour for it. "More" opens the picker with fan and swing. - - obj: - id: climate_mode_keys - align: TOP_MID - y: 184 - width: 94% - height: 46 - bg_opa: TRANSP - border_width: 0 - pad_all: 0 - clickable: false - scrollable: false - layout: - type: FLEX - flex_flow: ROW - flex_align_main: CENTER - flex_align_cross: CENTER - pad_column: 6 - widgets: - - obj: - id: cmkey_auto - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "auto" } - widgets: - - label: - align: CENTER - text: "\U000F1B17" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_heat_cool - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "heat_cool" } - widgets: - - label: - align: CENTER - text: "\U000F1A79" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_heat - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "heat" } - widgets: - - label: - align: CENTER - text: "\U000F0238" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_cool - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "cool" } - widgets: - - label: - align: CENTER - text: "\U000F0717" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_dry - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "dry" } - widgets: - - label: - align: CENTER - text: "\U000F058E" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_fan_only - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "fan_only" } - widgets: - - label: - align: CENTER - text: "\U000F0210" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_more - flex_grow: 1 - height: 46 - radius: 23 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: open_climate_mode_picker - widgets: - - label: - align: CENTER - text: "\U000F01D8" - text_font: materialdesign_icons - styles: paint_slate - clickable: false - # ---------- CLIMATE MODE PICKER ---------- - # Replaces cycling through modes. Cycling sends a set_hvac_mode on every - # tap, so to get from 'cool' to 'dry' a heat pump actually passes through - # 'heat' and 'fan_only' along the way. Here you pick directly, and exactly - # one command is sent. - - obj: - id: climate_mode_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - styles: style_page - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - align: TOP_MID - y: 21 - text: "Mode" - text_font: sublabel_big - clickable: false - - # Flex ensures unsupported modes don't leave a gap: - # LVGL skips hidden children when arranging. - - obj: - id: climate_mode_list - align: TOP_MID - y: 44 - width: 98% - height: 178 - bg_opa: TRANSP - border_width: 0 - scrollable: false - clickable: false - pad_all: 3 - layout: - type: FLEX - pad_row: 3 - pad_column: 4 - flex_flow: ROW_WRAP - flex_align_main: CENTER - flex_align_cross: CENTER - flex_align_track: CENTER - widgets: - - obj: - id: cmode_off - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "off" } - widgets: - - label: - align: CENTER - text: "Off" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_heat - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "heat" } - widgets: - - label: - align: CENTER - text: "Heat" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_cool - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "cool" } - widgets: - - label: - align: CENTER - text: "Cool" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_heat_cool - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "heat_cool" } - widgets: - - label: - align: CENTER - text: "Heat/Cool" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_auto - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "auto" } - widgets: - - label: - align: CENTER - text: "Auto" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_dry - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "dry" } - widgets: - - label: - align: CENTER - text: "Dry" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_fan_only - width: 88 - height: 26 - radius: 13 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "fan_only" } - widgets: - - label: - align: CENTER - text: "Fan only" - text_font: sublabel_big - clickable: false - - - label: - id: cfan_title - width: 100% - height: 11 - text: "Fan" - text_font: sublabel - styles: paint_subtle - text_align: CENTER - clickable: false - - obj: - id: cfan_0 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 0 } - widgets: - - label: - id: cfan_0_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cfan_1 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 1 } - widgets: - - label: - id: cfan_1_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cfan_2 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 2 } - widgets: - - label: - id: cfan_2_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cfan_3 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 3 } - widgets: - - label: - id: cfan_3_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - label: - id: cswing_title - width: 100% - height: 11 - text: "Swing" - text_font: sublabel - styles: paint_subtle - text_align: CENTER - clickable: false - - obj: - id: cswing_0 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 0 } - widgets: - - label: - id: cswing_0_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cswing_1 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 1 } - widgets: - - label: - id: cswing_1_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cswing_2 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 2 } - widgets: - - label: - id: cswing_2_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: cswing_3 - width: 62 - height: 22 - radius: 11 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 3 } - widgets: - - label: - id: cswing_3_label - align: CENTER - text: "-" - text_font: sublabel - clickable: false - - obj: - id: climate_mode_overlay_x - align: TOP_LEFT - x: 10 - y: 8 - width: 40 - height: 40 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - lvgl.widget.hide: climate_mode_overlay - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - # ---------- VACUUM CONTROL ---------- - # A vacuum is not a switch: start, pause, stop and return to - # the dock are separate commands, and suction power is a separate axis. - - - # ---------- DIM / WAKE OVERLAY ---------- - - obj: - id: dim_wake_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - bg_opa: TRANSP - # The tap that wakes a dimmed screen must not light up a pale sheet over it. - pressed: - bg_opa: TRANSP - border_width: 0 - scrollable: false - scrollbar_mode: 'OFF' - clickable: true - on_click: - - script.execute: wake_display - # A page that is one full tile switching something on or off switches with this push as well - # (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes. - - lambda: 'runtime_tiles::wake_tap();' - - - id: touch_cal_page - styles: paint_calibration - bg_opa: COVER - pad_all: 0 - border_width: 0 - scrollable: false - widgets: - - label: - align: TOP_MID - y: 65 - text: "Follow the calibration wizard" - text_font: sublabel - - obj: - x: 10 - y: 10 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - - obj: - x: 289 - y: 10 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - - obj: - x: 289 - y: 209 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - - obj: - x: 10 - y: 209 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - - obj: - x: 150 - y: 110 - width: 20 - height: 20 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_calibration - -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= 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" - - - id: show_tile_page - then: - - lambda: |- - if(runtime_tiles::enabled){ - runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number)); - return; - } - lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), - id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; - const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; - const int count = runtime_tiles::model.count; - const bool paginated = count > 6; - if (!paginated) id(tile_page) = 0; - for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { - if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN); - } - for (int i = 0; i < 10; ++i) { - bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6); - if (visible) { - lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]); - lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); - } - } - settings_screen::page_dots(id(page_number), id(tile_page), 2, false); - if (id(tile_page) == 0) { - lv_obj_add_state(id(page_prev), LV_STATE_DISABLED); - lv_obj_clear_state(id(page_next), LV_STATE_DISABLED); - } else { - lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED); - lv_obj_add_state(id(page_next), LV_STATE_DISABLED); - } - - # Runtime changes do not count as touches and do not reset the idle timer. - - id: apply_screen_settings - mode: restart - then: - - lambda: |- - const auto &settings = screen_settings::current; - // Dark mode (firmware 0.2.54+): a look the screen already has costs nothing. - theme::set_dark(settings_screen::dark_mode != 0); - // Auto standby off wakes the screen, but not out of a Sleep pressed in Home Assistant. - if (id(display_dimmed) && !settings.standby_enabled && !id(sleep_requested)) { - id(display_dimmed) = false; - } - auto now = id(homeassistant_time).now(); - const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; - const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; - id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform(); - if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); } - else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); - // The setting numbers and times only publish a changed value (HA keeps identical states anyway); the - // switches publish their own changes. An open settings page shows a change from Home Assistant too. - // A number without a state publishes too: a night brightness of 0 equals the number's initial state and - // stayed "unknown" in Home Assistant before firmware 0.2.50. - for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness), - std::make_pair(id(setting_standby_brightness), settings.standby_brightness), - std::make_pair(id(setting_night_brightness), settings.night_brightness), - std::make_pair(id(setting_standby_seconds), settings.standby_seconds), - std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) { - if (!entity->has_state() || entity->state != value) entity->publish_state(value); - } - for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) { - if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update(); - } - // The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+). - if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed(); - settings_screen::refresh(); - if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); - - # Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light, - # colour and climate overlays, and active_entity, which the swipe guard reads. - - id: close_cards - then: - - lambda: 'if (runtime_tiles::dismiss) runtime_tiles::dismiss();' - - # Everything back to the overview: the settings page, every card and the first page of tiles. - # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. - - id: go_home - then: - - lambda: 'settings_screen::close();' - - script.execute: close_cards - - lambda: 'id(tile_page) = 0;' - - script.execute: show_tile_page - - # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps - # showing what someone looked at minutes ago; after this long the screen is a screen again. - # Independent of standby, which may be off or much later; paused while the screen is in standby. - # The time runs from the last touch (last_use_ms, firmware 0.2.56+): Wake, an alert or Auto standby - # light the screen and restart the standby time, but nobody used a page. Checked every second and by Wake. - - id: back_to_page_1_when_due - then: - - if: - condition: - lambda: |- - if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; - if (id(display_dimmed)) return false; - bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 || - (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); - return away && (millis() - id(last_use_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; - then: - - script.execute: go_home - - # ---------- ALERT (HA action show_alert) ---------- - # The screen wakes, keeps its normal brightness and shows the card until OK or the - # timeout (0: until OK); the standby interval waits on alert_active. A new alert - # replaces the current one; every ending reports itself as esphome.screen_alert. - - id: alert_show - mode: restart - parameters: - title: string - subtitle: string - icon: string - color: string - button_text: string - timeout: int - flash: bool - then: - - lambda: |- - if (id(alert_active)) id(alert_report).execute("replaced"); - // An alert is the one thing that outranks the settings page, so that closes first. - settings_screen::close(); - const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); - id(alert_active) = true; - id(alert_title_text) = alert.title; - id(alert_timeout_seconds) = alert.timeout_seconds; - id(alert_flash_requested) = alert.flash; - lv_label_set_text(id(alert_icon), alert.icon.c_str()); - lv_label_set_text(id(alert_title), alert.title.c_str()); - lv_label_set_text(id(alert_subtitle), alert.subtitle.c_str()); - lv_label_set_text(id(alert_ok_label), alert.button.c_str()); - id(alert_card_color) = alert.color; - lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(alert.color)), LV_PART_MAIN); - ESP_LOGI("alert", "show \"%s\" timeout=%d flash=%d", alert.title.c_str(), alert.timeout_seconds, (int) alert.flash); - - script.execute: wake_display - - lvgl.widget.show: alert_overlay - - script.stop: alert_timeout - - if: - condition: - lambda: 'return id(alert_flash_requested);' - then: - - script.execute: alert_flash - - if: - condition: - lambda: 'return id(alert_timeout_seconds) > 0;' - then: - - script.execute: - id: alert_timeout - seconds: !lambda 'return id(alert_timeout_seconds);' - - - 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: alert_timeout - mode: restart - parameters: - seconds: int - then: - - delay: !lambda 'return static_cast(seconds) * 1000UL;' - - script.execute: - id: alert_dismiss - reason: "timeout" - - # Home Assistant adds device_id; action is ok, timeout, replaced or remote. - - id: alert_report - parameters: - action: string - then: - - homeassistant.event: - event: esphome.screen_alert - data: - action: !lambda 'return action;' - title: !lambda 'return id(alert_title_text);' - screen: !lambda 'return App.get_name();' - - - 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 - - # Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed. - # The settings page and every card close; with "also on standby" the screen goes back to page 1 too. - - id: dim_display - mode: restart - then: - - lambda: |- - id(display_dimmed) = true; - id(apply_screen_settings).execute(); - - if: - condition: - lambda: 'return screen_settings::current.home_on_standby;' - then: - - script.execute: go_home - else: - - lambda: 'settings_screen::close();' - - script.execute: close_cards - - # A -/+ tap on the climate card: the number follows at once, the call waits for the last tap. - # Holding a key steps three times a second. - - id: climate_adjust_target - parameters: - direction: int - held: bool - then: - - lambda: |- - if (held && millis() - id(climate_edit_ms) < 300) return; - int step_x10 = std::max(5, id(active_climate_step_x10)); - int next = cyd::quantize_temperature(id(active_climate_target_x10) + direction * step_x10, - id(active_climate_min_x10), id(active_climate_max_x10), step_x10); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - if (next == id(active_climate_target_x10) && id(active_climate_target_known)) return; - id(active_climate_target_x10) = next; - id(active_climate_target_known) = true; - id(climate_card_refresh).execute(); - id(climate_target_send).execute(id(active_entity), next / 10.0f); - - # One set_temperature for a series of taps: every tap restarts the wait with the newest value. - # Entity and value travel along, so leaving the card right after a tap still sends it. - - id: climate_target_send - mode: restart - parameters: - entity: string - value: float - then: - - delay: 700ms - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !entity.empty();' - then: - - homeassistant.action: - action: climate.set_temperature - data: - entity_id: !lambda "return entity;" - temperature: !lambda "return value;" - - # Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys. - - 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(); - - # Opens the mode picker. Hides modes the entity doesn't support and marks - # the current one, so you see where you stand before you pick something. - # Opens the vacuum controls and remembers which entity it concerns. - - # A standalone command: start, pause, stop or return_to_base. - - # Suction power is a separate axis, apart from start/stop. - - - id: open_climate_mode_picker - mode: restart - 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 supported = lower(id(active_climate_supported_modes)); - std::string current = lower(id(active_climate_mode)); - - auto has = [&](const char* m) { - std::string mode(m); - if (supported.empty() || supported == "unknown" || supported == "unavailable") return false; - return supported.find("'" + mode + "'") != std::string::npos || - supported.find("\"" + mode + "\"") != std::string::npos; - }; - auto apply = [&](lv_obj_t* btn, const char* m) { - if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); - bool selected = (current == std::string(m)); - lv_obj_set_style_bg_color(btn, theme::color(selected ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); - lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), theme::color(selected ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); - }; - apply(id(cmode_off), "off"); - apply(id(cmode_heat), "heat"); - apply(id(cmode_cool), "cool"); - apply(id(cmode_heat_cool), "heat_cool"); - apply(id(cmode_auto), "auto"); - apply(id(cmode_dry), "dry"); - apply(id(cmode_fan_only), "fan_only"); - // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. - auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; - auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { - unsigned shown = 0; - for (unsigned i = 0; i < 4; ++i) { - std::string raw = cyd::list_item(modes, i); - if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } - lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; - lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); - lv_obj_set_style_bg_color(chips[i], theme::color(raw == current_mode ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); - lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), theme::color(raw == current_mode ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); - } - if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); - }; - lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; - lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; - fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); - fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - - lvgl.widget.show: climate_mode_overlay - - # Sets exactly one mode. No intermediate steps, so no unwanted - # commands to the heat pump along the way. - # Fan and swing modes from the picker: one call with the entity's own mode name. - - id: set_climate_fan_mode - mode: restart - parameters: - index: int - then: - - lambda: |- - id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_fan_mode).empty();' - then: - - homeassistant.action: - action: climate.set_fan_mode - data: - entity_id: !lambda "return id(active_entity);" - fan_mode: !lambda "return id(active_climate_fan_mode);" - - id: set_climate_swing_mode - mode: restart - parameters: - index: int - then: - - lambda: |- - id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_swing_mode).empty();' - then: - - homeassistant.action: - action: climate.set_swing_mode - data: - entity_id: !lambda "return id(active_entity);" - swing_mode: !lambda "return id(active_climate_swing_mode);" - - # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. - - id: climate_power_toggle - mode: restart - then: - - lambda: |- - std::string mode = id(active_climate_mode); - for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); - id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - if (!id(active_climate_power_on)) id(active_climate_mode) = "off"; - id(climate_card_refresh).execute(); - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_climate_power_on);' - then: - - homeassistant.action: - action: climate.turn_on - data: - entity_id: !lambda "return id(active_entity);" - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_power_on);' - then: - - homeassistant.action: - action: climate.turn_off - data: - entity_id: !lambda "return id(active_entity);" - - # The power key lights up while the device runs in any mode. - - id: climate_power_refresh - mode: restart - then: - - lambda: |- - std::string mode = id(active_climate_mode); - for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); - bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); - if (id(climate_edit_pending)) on = id(active_climate_power_on); - lv_obj_set_style_bg_color(id(climate_power_button), theme::color(on ? theme::ACCENT_TINT : theme::KEY), LV_PART_MAIN); - lv_obj_set_style_text_color(id(climate_power_icon), theme::color(on ? theme::ACCENT_ICON : theme::ICON_OFF), LV_PART_MAIN); - - - id: set_climate_mode - mode: restart - parameters: - mode: string - then: - - lambda: |- - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - id(active_climate_mode) = mode; - // The action line would still say what the old mode did. - id(active_climate_action).clear(); - id(active_climate_power_on) = mode != "off"; - id(climate_card_refresh).execute(); - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh();' - then: - - homeassistant.action: - action: climate.set_hvac_mode - data: - entity_id: !lambda "return id(active_entity);" - hvac_mode: !lambda "return id(active_climate_mode);" - - # Isolated 40-second preview; every light commit is suppressed in the helper. - - id: light_controls_preview - mode: single - then: - - script.execute: wake_display - - lambda: |- - light_controls::demo = true; - light_controls::fallback.minimum = 2000; - light_controls::fallback.maximum = 6500; - light_controls::fallback.kelvin = 3000; - light_controls::fallback.hue = 200; - light_controls::open("diagnostic", true, true, 75); - lv_label_set_text(id(color_detail_title), "Example lamp"); - - lvgl.widget.hide: color_close_button - - lvgl.widget.show: color_detail_overlay - - delay: 40s - - lvgl.widget.hide: color_detail_overlay - - lvgl.widget.show: color_close_button - - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' - - - id: open_color_detail - mode: restart - then: - - lambda: |- - lv_label_set_text(id(color_detail_title), id(active_title).c_str()); - light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); - if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); - - lvgl.widget.hide: brightness_overlay - - lvgl.widget.show: color_detail_overlay - - - id: color_brightness_commit - parameters: - value: int - then: - - lambda: 'id(active_brightness_pct) = value;' - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_entity).rfind("light.", 0) == 0;' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - brightness_pct: !lambda "return value;" - - - id: set_light_hue - parameters: - value: int - then: - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - data_template: - hs_color: "{{ [hue | int, 100] }}" - variables: - hue: !lambda "return light_controls::clamp(value, 0, 360);" - - - id: set_light_color_temperature - parameters: - kelvin: int - then: - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" - - - id: slider_preview - parameters: - value: int - then: - - lambda: |- - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str()); - lv_label_set_text(id(overlay_value), buf); - int pct = value; - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - std::string value_text; - - if (id(active_control_kind) == "cover_position") { - std::string closed_label = id(active_label_off).c_str(); - std::string open_label = id(active_label_on).c_str(); - - if (pct <= 0) { - value_text = closed_label; - } else if (pct >= 100) { - value_text = open_label; - } else { - char buf[40]; - snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct); - value_text = buf; - } - } else { - if (pct <= 0) { - value_text = id(active_label_off).c_str(); - } else { - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); - value_text = buf; - } - } - - lv_label_set_text(id(overlay_value), value_text.c_str()); - - - - id: slider_commit - parameters: - value: int - then: - - if: - condition: - lambda: |- - return id(active_control_kind) == "light_brightness"; - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - brightness_pct: !lambda "return value;" - else: - - if: - condition: - lambda: |- - return id(active_control_kind) == "fan_percentage"; - then: - - if: - condition: - lambda: "return value <= 0;" - then: - - homeassistant.action: - action: fan.turn_off - data: - entity_id: !lambda "return id(active_entity);" - else: - - homeassistant.action: - action: fan.turn_on - data: - entity_id: !lambda "return id(active_entity);" - - delay: 150ms - - homeassistant.action: - action: fan.set_percentage - data: - entity_id: !lambda "return id(active_entity);" - percentage: !lambda "return value;" - else: - - if: - condition: - lambda: |- - return id(active_control_kind) == "cover_position"; - then: - - homeassistant.action: - action: cover.set_cover_position - data: - entity_id: !lambda "return id(active_entity);" - position: !lambda "return value;" - - # ---------------------------------------------------------- - # do_tile_action - # - # Centralized handler for executing Home Assistant actions - # triggered by tile taps. - # - # Parameters - # - tile_type: light | fan | switch | scene | script | cover - # - tap_action: auto | toggle | activate | fan_toggle_preset - # - service: optional Home Assistant service override - # - entity_id: target entity - # - param_key / param_val: optional parameters for custom services - # - was_on: previous entity state (pass false for longpress actions) - # - # Behavior - # - # toggle - # -> toggles the entity - # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on) - # - # activate - # -> activates entity - # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover - # - # auto - # -> resolves automatically based on tile_type: - # light/switch/fan/cover -> toggle - # scene/script -> activate - # - # fan_toggle_preset - # -> if fan was ON: fan.turn_off - # -> if fan was OFF: - # fan.turn_on - # wait 150ms - # fan.set_preset_mode(param_val) - # - # Custom service - # If "service" is set, it will be called directly. - # param_key / param_val may be used for parameters - # like preset_mode, brightness_pct, percentage, etc. - # ---------------------------------------------------------- - - - id: open_value_overlay - parameters: - title: string - entity: string - tile_type: string - longpress_mode: string - was_on: bool - off_value: int - brightness_value: float - percentage_value: float - position_value: float - icon_text: string - label_off: string - label_on: string - supported_color_modes: string - climate_current_temperature: float - climate_target_temperature: float - climate_humidity: float - climate_mode: string - climate_supported_modes: string - climate_min_temp: string - climate_max_temp: string - climate_step: string - then: - - lambda: |- - std::string mode = longpress_mode; - auto contains_mode = [](const std::string& modes, const std::string& needle) { - return modes.find(needle) != std::string::npos; - }; - if (mode == "auto") { - if (tile_type == "light") mode = "brightness"; - else if (tile_type == "fan") mode = "percentage"; - else if (tile_type == "cover") mode = "cover_position"; - else if (tile_type == "climate") mode = "climate_temperature"; - else mode = "none"; - } - - if (mode == "none") return; - - id(active_entity) = entity; - id(active_value_suffix) = " %"; - id(active_label_off) = label_off; - id(active_label_on) = label_on; - id(active_title) = title; - id(active_icon_text) = icon_text; - id(active_supports_color) = false; - id(active_supports_temperature) = false; - lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); - - if (mode == "climate_temperature") { - auto to_x10 = [](float value, int fallback) -> int { - if (isnan(value)) return fallback; - return (int) lround(value * 10.0f); - }; - auto parse_x10 = [](const std::string& value, int fallback) -> int { - if (value.empty()) return fallback; - return (int) lround(atof(value.c_str()) * 10.0f); - }; - id(climate_edit_pending) = false; - id(active_control_kind) = "climate_temperature"; - id(active_climate_min_x10) = parse_x10(climate_min_temp, 50); - id(active_climate_max_x10) = parse_x10(climate_max_temp, 300); - id(active_climate_step_x10) = parse_x10(climate_step, 5); - if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5; - if (id(active_climate_max_x10) <= id(active_climate_min_x10)) { - id(active_climate_min_x10) = 50; - id(active_climate_max_x10) = 300; - } - - id(active_climate_current_x10) = to_x10(climate_current_temperature, 0); - id(active_climate_current) = climate_current_temperature; - id(active_climate_target_known) = !isnan(climate_target_temperature); - int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10); - id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target); - if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); - if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); - id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10); - if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); - if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); - id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity); - id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode; - id(active_climate_supported_modes) = climate_supported_modes; - - lv_label_set_text(id(climate_detail_title), title.c_str()); - // 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); - } - id(climate_card_refresh).execute(); - - lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); - return; - } - - int pct = off_value; - - if (mode == "brightness") { - id(active_control_kind) = "light_brightness"; - if (was_on) { - if (isnan(brightness_value)) pct = 100; - else pct = (int) lround((brightness_value / 255.0f) * 100.0f); - } - } else if (mode == "percentage") { - id(active_control_kind) = "fan_percentage"; - if (was_on) { - if (isnan(percentage_value)) pct = 0; - else pct = (int) lround(percentage_value); - } - } else if (mode == "cover_position") { - id(active_control_kind) = "cover_position"; - if (isnan(position_value)) pct = was_on ? 100 : 0; - else pct = (int) lround(position_value); - } else { - return; - } - - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - id(active_brightness_pct) = pct; - - if (tile_type == "light") { - id(active_supports_color) = - contains_mode(supported_color_modes, "hs") || - contains_mode(supported_color_modes, "rgb") || - contains_mode(supported_color_modes, "rgbw") || - contains_mode(supported_color_modes, "rgbww") || - contains_mode(supported_color_modes, "xy"); - id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp"); - - if (id(active_supports_color) || id(active_supports_temperature)) { - lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); - } - } - - // Track and fill are the pale blue and blue paints of overlay_slider. - lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN); - lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR); - - lv_label_set_text(id(overlay_title), title.c_str()); - lv_label_set_text(id(overlay_icon), icon_text.c_str()); - lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF); - - std::string value_text; - - if (tile_type == "cover") { - if (pct <= 0) { - value_text = label_off; - } else if (pct >= 100) { - value_text = label_on; - } else { - char buf[40]; - snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct); - value_text = buf; - } - } else { - if (pct <= 0) { - value_text = label_off; - } else { - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); - value_text = buf; - } - } - - lv_label_set_text(id(overlay_value), value_text.c_str()); - - lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR); - - - if: - condition: - lambda: 'return id(active_control_kind) != "climate_temperature";' - then: - - if: - condition: - lambda: 'return id(active_supports_color) || id(active_supports_temperature);' - then: - - script.execute: open_color_detail - else: - - lvgl.widget.show: brightness_overlay - - # UI refresh (values + time + styles) + render - - id: ui_refresh - mode: single - then: - - delay: 100ms - - lambda: |- - if (runtime_tiles::enabled) { - // The name and the top bar (clock included) draw together. - runtime_tiles::render(id(lbl_room)); - return; - } - - -# HA blueprint transport; never publishes the JSON command as an HA state. -text: - - platform: smart_display - id: dashboard_inbox - name: "Tile settings" - internal: ${DASHBOARD_INBOX_INTERNAL} - -button: - - platform: template - name: "Calibrate touch" - internal: ${DASHBOARD_INBOX_INTERNAL} - entity_category: config - on_press: - - lambda: 'if (runtime_tiles::enabled) screen_calibration::begin();' - # Wake and Sleep for Home Assistant automations (0.2.45+). Wake is light, not a tap (0.2.56+): it - # lights a dimmed screen and counts the standby time from now, while Back to page 1 keeps counting - # from the last touch, so Wake on every motion no longer holds a card or page 2 up. Sleep is the - # standby time running out, right away and also with Auto standby off; it holds until a tap, Wake - # or an alert, and closes an alert that is up. Neither saves anything, so an automation may press - # them on every motion. No entity_category: they are controls, not settings. - - platform: template - id: wake_button - name: "Wake" - icon: "mdi:gesture-tap" - on_press: - - lambda: 'ESP_LOGI("standby", "Wake from Home Assistant, screen %s", id(display_dimmed) ? "in standby" : "on");' - - if: - condition: - lambda: 'return id(display_dimmed);' - then: - - script.execute: wake_display - # A page whose time ran out during standby: light up on page 1, not on the old page first. - - script.execute: back_to_page_1_when_due - else: - - lambda: 'id(last_touch_ms) = millis();' - - platform: template - id: sleep_button - name: "Sleep" - icon: "mdi:power-sleep" - on_press: - - if: - condition: - lambda: 'return id(calibration_active);' - then: - - lambda: 'ESP_LOGI("standby", "Sleep from Home Assistant ignored: touch calibration is running");' - else: - - lambda: |- - ESP_LOGI("standby", "Sleep from Home Assistant"); - id(sleep_requested) = true; - - script.execute: - id: alert_dismiss - reason: "remote" - - script.execute: dim_display - -number: - # Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the - # screen already has writes and reports nothing (an automation may set it on every light change). - - platform: template - id: setting_brightness - name: "Normal brightness" - entity_category: config - min_value: 5 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.brightness; - set_action: - - lambda: 'settings_screen::set("brightness", (int32_t) lround(x));' - - platform: template - id: setting_standby_brightness - name: "Standby brightness" - entity_category: config - min_value: 0 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.standby_brightness; - set_action: - - lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));' - - platform: template - id: setting_night_brightness - name: "Night brightness" - entity_category: config - min_value: 0 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.night_brightness; - set_action: - - lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));' - - platform: template - id: setting_standby_seconds - name: "Standby after" - entity_category: config - min_value: 60 - max_value: 86400 - step: 60 - unit_of_measurement: "s" - update_interval: never - lambda: return screen_settings::current.standby_seconds; - set_action: - - lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));' - - platform: template - id: setting_auto_home_seconds - name: "Back to page 1 after" - icon: "mdi:timer-outline" - entity_category: config - min_value: 30 - max_value: 3600 - step: 30 - mode: box - unit_of_measurement: "s" - update_interval: never - lambda: return settings_screen::auto_home_seconds; - set_action: - - lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));' diff --git a/packages/guition.yaml b/packages/guition.yaml index ab58ace..b71c9ad 100644 --- a/packages/guition.yaml +++ b/packages/guition.yaml @@ -1,107 +1,17 @@ -# GENERATED by tools/generate_packages.py; edit the source board profile. -# ESP Screens - Guition ESP32-S3-4848S040 board profile: 4-inch EU wallbox, 480x480 IPS + GT911. -# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md. -# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile. +# ESP Screens - the package a Guition ESP32-S3-4848S040 builds from over GitHub. ESP Screen Manager writes every +# screen's own YAML with `packages: display: {url: , files: [packages/guition.yaml]}` and adds +# the name, the Wi-Fi reference and the unique keys (docs/EASY_SETUP.md); nothing of that lives here. +# +# The screen is two packages: core.yaml, which every board shares (the LVGL tree, the cards, the scripts, the +# entities), and boards/guition-4848s040.yaml with this board's hardware and sizes. docs/PROFILES.md says how +# the files fit together. guition-4848s040.yaml builds the same two files from a checkout. substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.70" - # 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile - # (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it - # anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot). - # The CYD keeps a limit because its resistive panel jumps. - TOUCH_MOVE_LIMIT_PX: "0" - # Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not. - TOUCH_MIN_PRESS_MS: "20" - # Page swipe starts in this band along the left or right edge (about 5 mm) and needs - # this much travel inward (about 6 mm); a swipe from the middle of the screen does nothing. - EDGE_SWIPE_BAND_PX: "32" - EDGE_SWIPE_TRAVEL_PX: "40" - # A slider let go this close to the left or right edge of the glass (about 1 cm) meant its end when - # that end lies in the band: the panel loses a finger swiped off the glass 30-50 px before the edge. - SLIDER_EDGE_SNAP_PX: "67" - DASHBOARD_INBOX_INTERNAL: "false" - DEVICE_NAME: "guition-new" - DEVICE_FRIENDLY_NAME: "My Guition" - CALIBRATION_ON_BOOT: "false" - ROOM_NAME: "Choose tiles in HA" # shown top left until ESP Screens sends the screen name + FONT_DIR: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts" - ## DIM settings - AUTO_DIM: "true" # true = enable auto dimming - AUTO_DIM_TIMEOUT: "600" # seconds after last touch - MAX_BRIGHTNESS: "100" # backlight brightness in percent - DIM_BRIGHTNESS: "20" # dimmed backlight brightness in percent - NIGHT_MODE: "true" # true = use separate dim level at night - NIGHT_START_HOUR: "22" # 0-23 - NIGHT_END_HOUR: "7" # 0-23 - NIGHT_DIM_BRIGHTNESS: "10" # backlight brightness in percent at night +packages: + core: !include core.yaml + board: !include boards/guition-4848s040.yaml - DISPLAY_W: "480" - DISPLAY_H: "480" - LVGL_ROTATION: "0" - TOUCH_SWAP_XY: "false" - TOUCH_MIRROR_X: "false" - TOUCH_MIRROR_Y: "false" - GRID_COL1_X: "16" - GRID_COL2_X: "246" - GRID_ROW1_Y: "0" - GRID_ROW2_Y: "120" - GRID_ROW3_Y: "240" - GRID_ROW4_Y: "360" - GRID_ROW5_Y: "480" - SCROLL_Y: "72" - SCROLL_H: "348" - TILE_W: "218" - TILE_H: "108" - HEADER_Y: "22" - # Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe - # that starts up there still changes the page. - SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}" - SETTINGS_HOLD_W: "416" - SETTINGS_HOLD_DRIFT_PX: "32" - - # Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer. - ALERT_CARD_W: "420" - ALERT_CARD_H: "320" - ALERT_ICON_X: "26" - ALERT_ICON_Y: "22" - ALERT_TEXT_X: "90" - ALERT_TEXT_W: "304" - ALERT_TITLE_Y: "26" - ALERT_TITLE_H: "32" # one line of headline; a DOT label only ends in an ellipsis with a height - ALERT_SUBTITLE_Y: "68" - ALERT_SUBTITLE_H: "150" - ALERT_BUTTON_W: "150" - ALERT_BUTTON_H: "60" - ALERT_BUTTON_INSET: "22" - ALERT_TITLE_MAX: "64" # bytes; the label ends one line with an ellipsis - ALERT_BUTTON_MAX: "16" # bytes on the OK button - ALERT_SUBTITLE_MAX: "240" # bytes; wraps over the rows the card has - # An alert with a camera image (firmware 0.2.57+): the image across the top of a taller card, the words under it. - ALERT_CARD_H_IMAGE: "452" - ALERT_IMAGE_INSET: "14" - ALERT_IMAGE_SHIFT: "236" # the icon, title and subtitle move down by the image and a gap - ALERT_SUBTITLE_H_IMAGE: "62" # two lines between the title and the button - # Camera images (firmware 0.2.57+): the boxes ESP Screen Manager sizes a snapshot for (camera_feed.BOXES). - CAMERA_FULL_W: "480" - CAMERA_FULL_H: "480" - CAMERA_THUMB_W: "392" - CAMERA_THUMB_H: "220" - OVERLAY_SLIDER_W: "144" - OVERLAY_SLIDER_H: "300" - OVERLAY_ICON_Y: "148" - TILE_ICON_X: "0" - TILE_ICON_Y: "12" - TILE_TITLE_X: "64" - TILE_TITLE_Y: "17" - TILE_VALUE_X: "64" - TILE_VALUE_Y: "45" - -# ============================================================ -# No need to edit below this line. -# All configuration is done via the substitutions above. -# Credentials are managed in secrets.yaml (see secrets.yaml.example). -# The Material Design Icons font is included at fonts/materialdesignicons-webfont.ttf (Apache 2.0). -# Source: https://github.com/Templarian/MaterialDesign-Webfont -# ============================================================ external_components: - source: type: git @@ -110,4932 +20,3 @@ external_components: path: components refresh: 0s components: [smart_display] - -esphome: - min_version: 2026.6.2 - project: - name: maxgramser.esp_screen - version: "${SCREEN_FIRMWARE_VERSION}" - platformio_options: - build_flags: - - -DLV_USE_SNAPSHOT=1 - - -DLV_USE_SWITCH=1 - includes: - - - - - - - - - - - name: ${DEVICE_NAME} - friendly_name: ${DEVICE_FRIENDLY_NAME} - on_boot: - priority: -100 - then: - - lambda: |- - cyd::touch_guard.configure(${TOUCH_MOVE_LIMIT_PX}, ${TOUCH_MIN_PRESS_MS}); - cyd::edge_swipe.configure(${DISPLAY_W}, ${DISPLAY_H}, ${EDGE_SWIPE_BAND_PX}, ${EDGE_SWIPE_TRAVEL_PX}); - cyd::edge_snap_band = ${SLIDER_EDGE_SNAP_PX}; - // The page and the tile container take no presses: the theme's pressed style would - // otherwise repaint nearly the whole screen twice per touch in the empty edge band, - // starving the touch polling for the whole length of a quick swipe. - lv_obj_remove_flag(id(home_page)->obj, LV_OBJ_FLAG_CLICKABLE); - lv_obj_remove_flag(id(tile_scroll), LV_OBJ_FLAG_CLICKABLE); - // A stray GT911 contact at exactly (0, 0) never reaches LVGL (cyd::GhostTouch): as the last - // sample of a drag it sent a slider to its end on release. LVGL still reads ESPHome's callback. - if (auto *indev = lv_indev_get_next(nullptr)) { - static lv_indev_read_cb_t esphome_read = nullptr; - static const lv_indev_read_cb_t filtered = [](lv_indev_t *input, lv_indev_data_t *data) { - esphome_read(input, data); - int32_t ghost_x = 0, ghost_y = 0; - id(screen_lvgl).rotate_coordinates(ghost_x, ghost_y); - const unsigned dropped = cyd::ghost_touch.dropped(); - const auto read = cyd::ghost_touch.filter({data->state == LV_INDEV_STATE_PRESSED, (int) data->point.x, (int) data->point.y}, ghost_x, ghost_y); - if (cyd::ghost_touch.dropped() != dropped) ESP_LOGI("touch", "GT911 stray contact at (0,0) ignored"); - data->state = read.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; - data->point.x = read.x; - data->point.y = read.y; - }; - if (lv_indev_get_read_cb(indev) != filtered) { - esphome_read = lv_indev_get_read_cb(indev); - lv_indev_set_read_cb(indev, filtered); - } - } - id(last_touch_ms) = millis(); - id(last_use_ms) = millis(); - id(display_dimmed) = false; - - lambda: |- - auto &settings = screen_settings::current; - settings.standby_enabled = std::string("${AUTO_DIM}") == "true"; - settings.standby_seconds = atoi("${AUTO_DIM_TIMEOUT}"); - settings.brightness = atoi("${MAX_BRIGHTNESS}"); - settings.standby_brightness = atoi("${DIM_BRIGHTNESS}"); - settings.night_enabled = std::string("${NIGHT_MODE}") == "true"; - settings.night_start = atoi("${NIGHT_START_HOUR}") * 60; - settings.night_end = atoi("${NIGHT_END_HOUR}") * 60; - settings.night_brightness = atoi("${NIGHT_DIM_BRIGHTNESS}"); - runtime_tiles::watch_font = id(headline)->get_lv_font(); - runtime_tiles::watch_value_font = id(watch_value)->get_lv_font(); - runtime_tiles::watch_icon_font = id(watch_icon)->get_lv_font(); - runtime_tiles::clock_font = id(clock_digits)->get_lv_font(); - runtime_tiles::now_time = []() { return id(homeassistant_time).now(); }; - runtime_tiles::screen_awake = []() { return !id(display_dimmed); }; - runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font(); - runtime_tiles::big_icon_font = id(materialdesign_icons_big)->get_lv_font(); - runtime_tiles::control_font = id(sublabel_big)->get_lv_font(); - runtime_tiles::small_font = id(sublabel)->get_lv_font(); - // Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock - // label lends its margin and drawing order and stays hidden in runtime mode. - runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font(); - runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font(); - runtime_tiles::time_label = id(lbl_time); - runtime_tiles::enabled = true; - runtime_tiles::rotation_supported = true; - runtime_tiles::load_settings(); - // Colours (firmware 0.2.54+): the styles of this profile take their colours from theme.h. The list runs now, - // in the look the screen saved, before anything is drawn, and again after every change of look. - theme::paints = []() { - using theme::Paint; - for (auto *style : {id(style_room), id(style_time), id(style_title), id(style_value_big), id(paint_ink)}) theme::fill(style, Paint::ink); - theme::fill(id(style_page), Paint::page); - theme::fill(id(style_tile), Paint::card); - theme::fill(id(style_icon_circle), Paint::accent_tint); - theme::fill(id(style_value), Paint::muted); - theme::fill(id(paint_page_soft), Paint::page_soft); - theme::fill(id(paint_page_pressed), Paint::page_pressed); - theme::fill(id(paint_card), Paint::card); - theme::fill(id(paint_raised), Paint::raised); - theme::fill(id(paint_key), Paint::key); - theme::fill(id(paint_key_pressed), Paint::key_pressed); - theme::fill(id(paint_track), Paint::track); - theme::fill(id(paint_ink_soft), Paint::ink_soft); - theme::fill(id(paint_muted), Paint::muted); - theme::fill(id(paint_subtle), Paint::subtle); - theme::fill(id(paint_slate), Paint::slate); - theme::fill(id(paint_scrim), Paint::scrim); - theme::fill(id(paint_veil), Paint::veil); - theme::fill(id(paint_accent_tint), Paint::accent_tint); - theme::fill(id(paint_accent_bright), Paint::accent_bright); - theme::fill(id(paint_button_dark), Paint::button_dark); - theme::fill(id(paint_button_dark_pressed), Paint::button_dark_pressed); - theme::fill(id(paint_camera), Paint::camera); - }; - theme::paints(); - theme::set_dark(settings_screen::dark_mode != 0); - runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); }; - runtime_tiles::dismiss = []() { - effects_page::close(); - runtime_tiles::hide_detail(); - runtime_tiles::camera_close(); - id(active_entity).clear(); - lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN); - }; - runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); }; - // A quick flick or wipe over the tiles is never a tap (firmware 0.2.65+). LVGL reports it as a gesture that - // bubbles up to this page (a tile's slider keeps its own, runtime_tiles::bind): the tap under the finger is - // consumed and LVGL waits for the release. No page flip: pages change with the edge swipe (on_update). LVGL - // wants speed as well as 50 px of travel, so a firm press that drifts slowly is still a tap. Cards and the - // settings page keep their drags; in standby the push still wakes the screen, it only switches nothing. - lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *) { - if (!runtime_tiles::enabled || id(calibration_active) || !id(active_entity).empty() || settings_screen::visible() || - (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN))) return; - // A quick drag on a slider is that slider's: the press stays with it (firmware 0.2.68+). - if (runtime_tiles::captured_slider) return; - cyd::touch_guard.consume(); - ESP_LOGI("touch", "flick: no tap"); - if (id(display_dimmed)) return; - if (auto *input = lv_indev_active()) lv_indev_wait_release(input); - }, LV_EVENT_GESTURE, nullptr); - // Camera images (firmware 0.2.57+): the two online_images and the alert's frame. - // set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304. - runtime_tiles::camera_full.load = [](const std::string &url) { - static std::string current; - if (url != current) { id(camera_image).set_url(url); current = url; } - id(camera_image).update(); - }; - runtime_tiles::camera_full.release = []() { id(camera_image).release(); }; - runtime_tiles::camera_full.source = []() { return id(camera_image).get_lv_image_dsc(); }; - runtime_tiles::camera_thumb.load = [](const std::string &url) { - static std::string current; - if (url != current) { id(alert_image).set_url(url); current = url; } - id(alert_image).update(); - }; - runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); }; - runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); }; - runtime_tiles::alert_frame = id(alert_image_frame); - runtime_tiles::alert_frame_icon = id(alert_image_icon); - runtime_tiles::alert_room = [](bool image) { - const int shift = image ? ${ALERT_IMAGE_SHIFT} : 0; - lv_obj_set_height(id(alert_card), image ? ${ALERT_CARD_H_IMAGE} : ${ALERT_CARD_H}); - lv_obj_set_y(id(alert_icon), ${ALERT_ICON_Y} + shift); - lv_obj_set_y(id(alert_title), ${ALERT_TITLE_Y} + shift); - lv_obj_set_y(id(alert_subtitle), ${ALERT_SUBTITLE_Y} + shift); - lv_obj_set_height(id(alert_subtitle), image ? ${ALERT_SUBTITLE_H_IMAGE} : ${ALERT_SUBTITLE_H}); - }; - runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); }; - runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl)); - runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl)); - runtime_tiles::bind(2, id(tile3), id(t3_title), id(t3_value), id(tile3_icon_circle), id(tile3_icon_lbl)); - runtime_tiles::bind(3, id(tile4), id(t4_title), id(t4_value), id(tile4_icon_circle), id(tile4_icon_lbl)); - runtime_tiles::bind(4, id(tile5), id(t5_title), id(t5_value), id(tile5_icon_circle), id(tile5_icon_lbl)); - runtime_tiles::bind(5, id(tile6), id(t6_title), id(t6_value), id(tile6_icon_circle), id(tile6_icon_lbl)); - runtime_tiles::bind(6, id(tile7), id(t7_title), id(t7_value), id(tile7_icon_circle), id(tile7_icon_lbl)); - runtime_tiles::bind(7, id(tile8), id(t8_title), id(t8_value), id(tile8_icon_circle), id(tile8_icon_lbl)); - runtime_tiles::bind(8, id(tile9), id(t9_title), id(t9_value), id(tile9_icon_circle), id(tile9_icon_lbl)); - runtime_tiles::bind(9, id(tile10), id(t10_title), id(t10_value), id(tile10_icon_circle), id(tile10_icon_lbl)); - runtime_tiles::detail = [](runtime_tiles::Tile &t) { - light_controls::fallback = light_controls::State{}; - light_controls::fallback.hue = t.hue; - light_controls::fallback.kelvin = t.kelvin; - light_controls::fallback.minimum = t.min_kelvin; - light_controls::fallback.maximum = t.max_kelvin; - id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; - id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; - id(active_climate_action) = t.extra().hvac_action; - id(open_value_overlay).execute(t.name, t.entity, t.domain(), "auto", t.active(), 0, - t.brightness, t.percentage, t.position, runtime_tiles::icon_for(t), - "Off", "On", t.modes, - t.current, t.target, t.humidity, t.state, t.extra().hvac_modes, - std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step)); - }; - runtime_tiles::detail_update = [](runtime_tiles::Tile &t) { - effects_page::updated(t); - if (id(active_entity) != t.entity || id(touch_down)) return; - if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { - if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return; - id(climate_edit_pending) = false; - id(active_climate_mode) = t.state; - id(active_climate_action) = t.extra().hvac_action; - id(active_climate_supported_modes) = t.extra().hvac_modes; - id(active_climate_fan_modes) = t.extra().fan_modes; id(active_climate_fan_mode) = t.extra().fan_mode; - id(active_climate_swing_modes) = t.extra().swing_modes; id(active_climate_swing_mode) = t.extra().swing_mode; - id(active_climate_current) = t.current; - id(active_climate_humidity) = std::isfinite(t.humidity) ? (int) lround(t.humidity) : -1; - if (std::isfinite(t.target)) { - id(active_climate_target_x10) = cyd::quantize_temperature(lround(t.target * 10), id(active_climate_min_x10), id(active_climate_max_x10), id(active_climate_step_x10)); - id(active_climate_target_known) = true; - } - id(climate_card_refresh).execute(); - } - }; - - lambda: |- - light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font()); - light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; - light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; - light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; - // The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and - // the ways out: actions to Home Assistant and the question for a picker's names. - effects_page::title_font = id(headline)->get_lv_font(); - effects_page::row_font = id(sublabel)->get_lv_font(); - effects_page::roller_font = id(sublabel_big)->get_lv_font(); - effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font(); - effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * { - for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i]; - return nullptr; - }; - effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) { - runtime_tiles::action(service, entity, key, value); - }; - effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); }; - effects_page::drift = []() { return cyd::touch_guard.distance(); }; - effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; - effects_page::now = []() { return (uint32_t) millis(); }; - runtime_tiles::options_received = effects_page::received; - climate_card::look.card = id(climate_settings_card); - climate_card::look.font = id(label)->get_lv_font(); - climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font(); - climate_card::choose = [](char kind, int index) { - if (kind == 'f') id(set_climate_fan_mode).execute(index); - else id(set_climate_swing_mode).execute(index); - }; - # The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager - # writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built - # when it opens and thrown away when it closes, so it costs nothing while it is closed. - - lambda: |- - if (!runtime_tiles::enabled) return; - settings_screen::title_font = id(headline)->get_lv_font(); - settings_screen::row_font = id(sublabel_big)->get_lv_font(); - settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font(); - settings_screen::store = []() { runtime_tiles::persist_settings(); }; - settings_screen::apply = []() { id(apply_screen_settings).execute(); }; - settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; - settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; - settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; - settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; - settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; - settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; - // A dimmed screen, a calibration or an alert owns the glass; the page waits. - settings_screen::may_open = []() { - return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active) && !runtime_tiles::camera_visible(); - }; - settings_screen::drift = []() { return cyd::touch_guard.distance(); }; - settings_screen::before_open = []() { id(close_cards).execute(); }; - settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX}; - settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX}; - // What a change of look draws again, now that everything is bound: the tiles, the top bar and an open card, - // the colour card, the settings page, an alert and the climate cards. - theme::redraw = []() { - runtime_tiles::restyle(); - light_controls::restyle(); - effects_page::restyle(); - settings_screen::restyle(); - if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN); - if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute(); - if (!lv_obj_has_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN)) id(open_climate_mode_picker).execute(); - }; - // Under the cards: brightness_overlay is the first of them on the page, so the back button and - // the action at the top right of a card keep their taps. - settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y}, id(brightness_overlay)); - - light.turn_on: - id: back_light - brightness: !lambda "return screen_settings::current.brightness / 100.0f;" - - script.execute: show_tile_page - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - - if: - condition: - lambda: 'return std::string("${CALIBRATION_ON_BOOT}") == "true";' - then: - - lambda: 'id(calibration_active) = true;' - - lvgl.page.show: touch_cal_page - - lambda: |- - lv_obj_invalidate(id(touch_cal_page)->obj); - lv_refr_now(nullptr); - ESP_LOGI("ui_diag", "GT911 touch test ready; no tile actions"); - - -esp32: - board: esp32-s3-devkitc-1 - variant: esp32s3 - flash_size: 16MB - cpu_frequency: 240MHz - framework: - type: esp-idf - version: recommended - advanced: - execute_from_psram: true - -psram: - mode: octal - speed: 80MHz - -logger: - tx_buffer_size: 2048 - hardware_uart: UART0 - # Set to DEBUG or VERBOSE temporarily for diagnosis. Note: ESPHome logs - # its configuration lines at CONFIG level, which are invisible at INFO. - level: DEBUG - logs: - sensor: WARN - text_sensor: WARN - xpt2046: WARN - homeassistant.sensor: WARN - homeassistant.text_sensor: WARN - - -# Credentials are loaded from secrets.yaml — copy secrets.yaml.example to secrets.yaml and fill in your values. -api: - actions: - - action: ui_snapshot - then: - - script.execute: capture_ui_snapshot - - action: touch_diagnostics - then: - - lambda: 'id(calibration_active) = true;' - - script.execute: wake_display - - lvgl.page.show: touch_cal_page - - lambda: |- - lv_obj_invalidate(id(touch_cal_page)->obj); - lv_refr_now(nullptr); - ESP_LOGI("ui_diag", "calibration page active=%d", lv_screen_active() == id(touch_cal_page)->obj); - - action: end_touch_diagnostics - then: - - lambda: 'id(calibration_active) = false;' - - lvgl.page.show: home_page - - action: ui_state - then: - - lambda: |- - const auto &cfg = screen_settings::current; - ESP_LOGI("ui_diag", "settings standby=%d timeout=%d brightness=%d dim=%d night=%d start=%d end=%d night_dim=%d clock=%d clock24=%d home=%d idle_ms=%u use_idle_ms=%u dimmed=%d", - cfg.standby_enabled, cfg.standby_seconds, cfg.brightness, cfg.standby_brightness, - cfg.night_enabled, cfg.night_start, cfg.night_end, cfg.night_brightness, - cfg.show_clock, cfg.clock_24h, cfg.home_on_standby, - (unsigned)(millis() - id(last_touch_ms)), (unsigned)(millis() - id(last_use_ms)), id(display_dimmed)); - ESP_LOGI("ui_diag", "navigation page=%d runtime_detail=%d", id(tile_page), - runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); - ESP_LOGI("ui_diag", "rotation=%u actual=%u", (unsigned)runtime_tiles::rotation, (unsigned)id(screen_lvgl).get_rotation()); - lv_area_t nav, grid; - lv_obj_update_layout(id(home_page)->obj); - lv_obj_get_coords(id(page_next), &nav); - lv_obj_get_coords(id(tile_scroll), &grid); - ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", - lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, - grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); - - action: preview_runtime_card - variables: - index: int - then: - - lambda: |- - if (index < 0) { runtime_tiles::hide_detail(); if (runtime_tiles::dismiss) runtime_tiles::dismiss(); return; } - if (runtime_tiles::enabled && index < (int)runtime_tiles::model.count) { - runtime_tiles::active_index = index; - auto &tile = runtime_tiles::model.tiles[index]; - auto domain = tile.domain(); - // Lights, fans and climate open the board's own card, like a long press does; covers (firmware 0.2.50+) - // open the runtime card, as a tap does. - if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) { - tile.begin(millis(), true); - runtime_tiles::detail(tile); - return; - } - runtime_tiles::show_detail(index); - } - # A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes - # it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens. - - action: preview_camera - variables: - entity: string - then: - - lambda: |- - if (entity.empty()) { runtime_tiles::camera_close(); return; } - id(last_use_ms) = millis(); - id(wake_display).execute(); - std::string name = entity; - for (size_t i = 0; i < runtime_tiles::model.count; ++i) - if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; } - runtime_tiles::camera_open(entity, name); - # The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away - # (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1. - # Handy for support ("look at Screen -> Rotation") and for the render tests. - - action: open_settings - variables: - page: int - then: - - lambda: |- - if (page < 0) { id(go_home).execute(); return; } - // Opened for someone to look at: Back to page 1 counts from now, as after a touch. - id(last_use_ms) = millis(); - id(wake_display).execute(); - settings_screen::open((uint8_t) page); - ESP_LOGI("settings", "page %d open from Home Assistant", page); - - # Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA. - - action: preview_climate_picker - then: - - script.execute: open_climate_mode_picker - - action: preview_busy - variables: - index: int - then: - - lambda: |- - if (runtime_tiles::enabled && index >= 0 && index < (int)runtime_tiles::model.count) { - runtime_tiles::model.tiles[index].begin(millis()); - if (runtime_tiles::refresh) runtime_tiles::refresh(); - } - - action: light_controls_preview - then: - - script.execute: light_controls_preview - - action: ui_self_test - then: - - script.execute: ui_self_test - # Home Assistant automations: esphome._show_alert puts a card over the whole - # screen (README, "Alert from an automation"); dismiss_alert removes it again. - - action: show_alert - variables: - title: string - subtitle: string - icon: string - color: string - button_text: string - timeout: int - flash: bool - then: - - script.execute: - id: alert_show - title: !lambda 'return title;' - subtitle: !lambda 'return subtitle;' - icon: !lambda 'return icon;' - color: !lambda 'return color;' - button_text: !lambda 'return button_text;' - timeout: !lambda 'return timeout;' - flash: !lambda 'return flash;' - - action: dismiss_alert - then: - - script.execute: - id: alert_dismiss - reason: "remote" - # ESP Screen Manager 0.2.39+ hands a whole tile message over in one call instead of - # base64 chunks in the text inbox; the inbox keeps showing the result. - # Firmware 0.2.49+ also answers a caller that asks for it (app 0.2.57+ does for its pings): the same - # status as the inbox, plus the revision of the layout on the screen. A call without a call id - # (older apps) gets no answer, so ESPHome has nothing to warn about. - - action: screen_message - supports_response: optional - variables: - message: string - then: - - lambda: 'id(dashboard_inbox).publish_state(runtime_tiles::receive(message));' - - if: - condition: - lambda: 'return call_id != 0;' - then: - - api.respond: - data: !lambda |- - root["status"] = id(dashboard_inbox).state; - root["rev"] = runtime_tiles::layout_rev; - -captive_portal: - -# Camera images (firmware 0.2.57+): ESP Screen Manager serves each image already sized for this screen, as an uncompressed -# BMP on its own port on the LAN (screen_manager/app/camera_feed.py). ESPHome decodes a BMP piece by piece as it arrives, -# 4 KB per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn. One image full -# screen and one in an alert's frame; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP -# on the LAN, no certificates. -http_request: - timeout: 4s - verify_ssl: false - -# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests -# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7. -online_image: - - id: camera_image - url: "http://127.0.0.1/camera.bmp" # set when a camera opens - format: BMP - type: RGB565 - buffer_size: 4096 - on_download_finished: - - lambda: 'runtime_tiles::camera_loaded(false, cached);' - on_error: - - lambda: 'runtime_tiles::camera_failed(false);' - - id: alert_image - url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image - format: BMP - type: RGB565 - buffer_size: 4096 - on_download_finished: - - lambda: 'runtime_tiles::camera_loaded(true, cached);' - on_error: - - lambda: 'runtime_tiles::camera_failed(true);' - -interval: - - interval: 250ms - then: - - lambda: |- - runtime_tiles::tick(); - runtime_tiles::camera_tick(); - - interval: 1s - then: - - if: - condition: - lambda: 'return id(climate_edit_pending) && !id(touch_down) && millis() - id(climate_edit_ms) >= 3000;' - then: - # The edit window is over: show what Home Assistant reports now. A closed card has - # nothing to show, and a flag left set would redraw every tile each second. - - lambda: |- - if (lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) { id(climate_edit_pending) = false; return; } - if (!runtime_tiles::enabled) { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); return; } - // Runtime tiles: a state that arrived during the window was held back. - id(climate_edit_pending) = false; - if (runtime_tiles::detail_update && runtime_tiles::active_index >= 0 && - runtime_tiles::active_index < (int) runtime_tiles::model.count) - runtime_tiles::detail_update(runtime_tiles::model.tiles[runtime_tiles::active_index]); - - if: - condition: - lambda: |- - if (!screen_settings::current.standby_enabled) return false; - if (id(display_dimmed) || id(touch_down) || id(calibration_active) || id(alert_active)) return false; - return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL); - then: - - script.execute: dim_display - - script.execute: back_to_page_1_when_due - -time: - - platform: homeassistant - id: homeassistant_time - # Refresh immediately on time sync; do not wait for an HA entity update. - update_interval: 60s - on_time_sync: - then: - - script.execute: apply_screen_settings - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - on_time: - - seconds: 0 - minutes: /1 - then: - - script.execute: apply_screen_settings - - lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();' - -globals: - # Alert overlay (HA action show_alert): standby waits while one is up. - - id: alert_active - type: bool - initial_value: 'false' - - id: alert_title_text - type: std::string - # The alert's colour as the automation named it (a swatch's light value, 0 for the normal card). - - id: alert_card_color - type: uint32_t - initial_value: '0' - - id: alert_timeout_seconds - type: int - initial_value: '0' - - id: alert_flash_requested - type: bool - initial_value: 'false' - - id: rendered_frames - type: uint32_t - initial_value: '0' - - id: calibration_active - type: bool - initial_value: 'false' - - id: tile_page - type: int - initial_value: '0' - - id: touch_down - type: bool - initial_value: 'false' - - id: climate_edit_ms - type: uint32_t - initial_value: '0' - - id: climate_edit_pending - type: bool - initial_value: 'false' - - id: active_entity - type: std::string - restore_value: no - - - id: active_control_kind - type: std::string - restore_value: no - - - id: active_value_suffix - type: std::string - restore_value: no - - # Two clocks. last_touch_ms is the standby time: a touch, Wake, an alert ending and Auto standby on restart it. - # last_use_ms is Back to page 1 (firmware 0.2.56+): only a touch, or Home Assistant opening the settings page. - - id: last_touch_ms - type: uint32_t - restore_value: no - initial_value: '0' - - - id: last_use_ms - type: uint32_t - restore_value: no - initial_value: '0' - - # Debounce for do_tile_action: protects against repeated events from - # multiple separate press/release cycles for one physical touch (noise in - # the resistive layer), causing the same HA action to fire multiple times - # within a few hundred ms (observed: script.ambient_morning 6x in 8s). - - id: last_action_entity - type: std::string - restore_value: no - - - id: last_action_ms - type: uint32_t - restore_value: no - initial_value: '0' - - - id: display_dimmed - type: bool - restore_value: no - initial_value: 'false' - - # Sleep from Home Assistant (0.2.45+): standby that also holds with Auto standby off, - # until a tap, Wake or an alert wakes the screen. - - id: sleep_requested - type: bool - restore_value: no - initial_value: 'false' - - - id: active_label_off - type: std::string - restore_value: no - - - id: active_label_on - type: std::string - restore_value: no - - - id: active_title - type: std::string - restore_value: no - - - id: active_icon_text - type: std::string - restore_value: no - - - id: active_brightness_pct - type: int - restore_value: no - initial_value: '100' - - - id: active_supports_color - type: bool - restore_value: no - initial_value: 'false' - - - id: active_supports_temperature - type: bool - restore_value: no - initial_value: 'false' - - - id: active_color_detail_tab - type: int - restore_value: no - initial_value: '0' - - - id: active_climate_current_x10 - type: int - restore_value: no - initial_value: '0' - - - id: active_climate_target_x10 - type: int - restore_value: no - initial_value: '200' - - - id: active_climate_humidity - type: int - restore_value: no - initial_value: '-1' - - - id: active_climate_min_x10 - type: int - restore_value: no - initial_value: '50' - - - id: active_climate_max_x10 - type: int - restore_value: no - initial_value: '300' - - - id: active_climate_step_x10 - type: int - restore_value: no - initial_value: '5' - - - id: active_climate_mode - type: std::string - restore_value: no - - - id: active_climate_power_on - type: bool - restore_value: no - initial_value: 'false' - - - id: active_climate_fan_modes - type: std::string - restore_value: no - - - id: active_climate_fan_mode - type: std::string - restore_value: no - - - id: active_climate_swing_modes - type: std::string - restore_value: no - - - id: active_climate_swing_mode - type: std::string - restore_value: no - - - id: active_climate_supported_modes - type: std::string - restore_value: no - - # Room temperature (NAN when unknown), what the device does (hvac_action, runtime tiles only), - # and whether Home Assistant reported a setpoint at all (heat_cool ranges do not). - - id: active_climate_current - type: float - restore_value: no - initial_value: 'NAN' - - - id: active_climate_action - type: std::string - restore_value: no - - - id: active_climate_target_known - type: bool - restore_value: no - initial_value: 'false' - -font: - # The climate card's setpoint, large enough to read across the room. - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" - id: setpoint_digits - size: 64 - bpp: 4 - glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9'] - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" - id: time_label - size: 27 - bpp: 4 - glyphs: ['0','1','2','3','4','5','6','7','8','9',':',' ', 'A', 'M', 'P'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" - id: headline - size: 27 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" - id: watch_value - size: 38 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - # Clock card digits only; the date line reuses the tile title font. - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-500.ttf" - id: clock_digits - size: 48 - bpp: 4 - glyphs: [':','-','0','1','2','3','4','5','6','7','8','9'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-700.ttf" - id: label - size: 18 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" - id: sublabel - size: 16 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/Roboto-400.ttf" - id: sublabel_big - size: 21 - bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–'] - - # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) - # Source: https://github.com/Templarian/MaterialDesign-Webfont - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" - id: materialdesign_icons - size: 42 - bpp: 4 - glyphs: &tile_icons # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py - - "\U000F0335" # lightbulb - - "\U000F1253" # lightbulb-group - - "\U000F0769" # ceiling-light - - "\U000F1793" # chandelier - - "\U000F06B5" # lamp - - "\U000F08DD" # floor-lamp - - "\U000F095F" # desk-lamp - - "\U000F091D" # wall-sconce-flat - - "\U000F179B" # light-recessed - - "\U000F04C9" # spotlight-beam - - "\U000F1051" # led-strip-variant - - "\U000F1054" # outdoor-lamp - - "\U000F02DC" # home - - "\U000F04B9" # sofa - - "\U000F02E3" # bed - - "\U000F0A70" # silverware-fork-knife - - "\U000F0B7C" # chef-hat - - "\U000F09A0" # shower - - "\U000F1818" # bathtub - - "\U000F09AB" # toilet - - "\U000F1239" # desk - - "\U000F06D9" # garage - - "\U000F0E2E" # fireplace - - "\U000F050F" # thermometer - - "\U000F0393" # thermostat - - "\U000F001B" # air-conditioner - - "\U000F0438" # radiator - - "\U000F1A43" # heat-pump - - "\U000F0238" # fire - - "\U000F0717" # snowflake - - "\U000F058E" # water-percent - - "\U000F0210" # fan - - "\U000F1797" # ceiling-fan - - "\U000F0D44" # air-purifier - - "\U000F07E4" # molecule-co2 - - "\U000F0599" # weather-sunny - - "\U000F0595" # weather-partly-cloudy - - "\U000F0590" # weather-cloudy - - "\U000F0597" # weather-rainy - - "\U000F0598" # weather-snowy - - "\U000F059D" # weather-windy - - "\U000F0594" # weather-night - - "\U000F059C" # weather-sunset-up - - "\U000F0387" # music-note - - "\U000F075A" # music - - "\U000F0CB8" # playlist-music - - "\U000F0025" # album - - "\U000F099A" # record-player - - "\U000F04C7" # spotify - - "\U000F0439" # radio - - "\U000F0994" # podcast - - "\U000F04C3" # speaker - - "\U000F0D38" # speaker-multiple - - "\U000F17DB" # soundbar - - "\U000F02CB" # headphones - - "\U000F067D" # piano - - "\U000F0771" # guitar-acoustic - - "\U000F0502" # television - - "\U000F042E" # projector - - "\U000F0FCE" # movie-open - - "\U000F0118" # cast - - "\U000F0297" # gamepad-variant - - "\U000F0EC5" # remote-tv - - "\U000F033E" # lock - - "\U000F0FC6" # lock-open-variant - - "\U000F068A" # shield-home - - "\U000F078F" # alarm-light - - "\U000F009A" # bell - - "\U000F12E6" # doorbell - - "\U000F07AE" # cctv - - "\U000F0D91" # motion-sensor - - "\U000F081B" # door-closed - - "\U000F081C" # door-open - - "\U000F11DB" # window-closed-variant - - "\U000F0392" # smoke-detector - - "\U000F1502" # water-alert - - "\U000F072A" # washing-machine - - "\U000F0917" # tumble-dryer - - "\U000F0AAC" # dishwasher - - "\U000F0290" # fridge - - "\U000F04DE" # stove - - "\U000F0C99" # microwave - - "\U000F0CD3" # toaster-oven - - "\U000F109F" # coffee-maker - - "\U000F05FA" # kettle - - "\U000F070D" # robot-vacuum - - "\U000F11F7" # robot-mower - - "\U000F042B" # printer-3d - - "\U000F06A5" # power-plug - - "\U000F07E7" # power-socket-eu - - "\U000F0322" # laptop - - "\U000F0469" # router-wireless - - "\U000F0241" # flash - - "\U000F0A72" # solar-power - - "\U000F0D9B" # solar-panel - - "\U000F1901" # home-battery - - "\U000F12A3" # battery-high - - "\U000F05F1" # ev-station - - "\U000F0B6C" # car-electric - - "\U000F1A57" # meter-electric - - "\U000F1A59" # meter-gas - - "\U000F058C" # water - - "\U000F0F92" # water-boiler - - "\U000F029A" # gauge - - "\U000F111C" # window-shutter - - "\U000F111E" # window-shutter-open - - "\U000F00AC" # blinds - - "\U000F1011" # blinds-open - - "\U000F1A6C" # roller-shade-closed - - "\U000F1846" # curtains - - "\U000F1847" # curtains-closed - - "\U000F1B88" # awning-outline - - "\U000F024A" # flower - - "\U000F0531" # tree - - "\U000F0E66" # sprout - - "\U000F1510" # grass - - "\U000F105F" # sprinkler - - "\U000F0606" # pool - - "\U000F0E45" # grill - - "\U000F06EE" # mailbox - - "\U000F0A79" # trash-can - - "\U000F044C" # recycle - - "\U000F0A43" # dog - - "\U000F011B" # cat - - "\U000F0004" # account - - "\U000F0849" # account-group - - "\U000F0A89" # account-child - - "\U000F0826" # home-account - - "\U000F04B2" # sleep - - "\U000F070E" # run - - "\U000F00D6" # briefcase - - "\U000F010B" # car - - "\U000F00A3" # bike - - "\U000F001D" # airplane - - "\U000F034E" # map-marker - - "\U000F0425" # power - - "\U000F0521" # toggle-switch - - "\U000F12A8" # gesture-tap-button - - "\U000F040A" # play - - "\U000F0BC2" # script-text - - "\U000F03D8" # palette - - "\U000F0020" # alarm - - "\U000F0150" # clock-outline - - "\U000F051B" # timer-outline - - "\U000F00ED" # calendar - - "\U000F009E" # bell-ring - - "\U000F02D1" # heart - - "\U000F002A" # alert-outline - - "\U000F0493" # cog - - "\U000F00E2" # broom - - "\U000F05D6" # alert-circle-outline - - "\U000F012C" # check - - "\U000F03E4" # pause - - "\U000F04DB" # stop - - "\U000F04AD" # skip-next - - "\U000F04AE" # skip-previous - - "\U000F057E" # volume-high - - "\U000F0581" # volume-off - - "\U000F005D" # arrow-up - - "\U000F0045" # arrow-down - - "\U000F084E" # arrow-expand-horizontal - - "\U000F084C" # arrow-collapse-horizontal - - "\U000F05F8" # home-map-marker - - "\U000F0415" # plus - - "\U000F0374" # minus - - "\U000F0141" # chevron-left - - "\U000F0142" # chevron-right - - "\U000F0156" # close - - "\U000F004D" # arrow-left - - "\U000F1A79" # sun-snowflake-variant - - "\U000F1B17" # thermostat-auto - - "\U000F0591" # weather-fog - - "\U000F0592" # weather-hail - - "\U000F0593" # weather-lightning - - "\U000F067E" # weather-lightning-rainy - - "\U000F0596" # weather-pouring - - "\U000F067F" # weather-snowy-rainy - - "\U000F059B" # weather-sunset-down - - "\U000F01D8" # dots-horizontal - - "\U000F1C91" # arrow-oscillating - - "\U000F0379" # monitor - - "\U000F02FD" # information-outline - - "\U000F0709" # restart - - "\U000F0E4F" # lightbulb-off - - "\U000F0B53" # account-arrow-right - - "\U000F0D43" # air-filter - - "\U000F1099" # air-humidifier - - "\U000F1466" # air-humidifier-off - - "\U000F0028" # alert-circle - - "\U000F0937" # angle-acute - - "\U000F0042" # arrow-bottom-left - - "\U000F0043" # arrow-bottom-right - - "\U000F06C0" # arrow-down-box - - "\U000F0E73" # arrow-left-right - - "\U000F0054" # arrow-right - - "\U000F093C" # arrow-split-vertical - - "\U000F005B" # arrow-top-left - - "\U000F005C" # arrow-top-right - - "\U000F06C3" # arrow-up-box - - "\U000F093D" # audio-video - - "\U000F11B6" # audio-video-off - - "\U000F0079" # battery - - "\U000F007A" # battery-10 - - "\U000F007B" # battery-20 - - "\U000F007C" # battery-30 - - "\U000F007D" # battery-40 - - "\U000F007E" # battery-50 - - "\U000F007F" # battery-60 - - "\U000F0080" # battery-70 - - "\U000F0081" # battery-80 - - "\U000F0082" # battery-90 - - "\U000F0083" # battery-alert - - "\U000F0084" # battery-charging - - "\U000F008E" # battery-outline - - "\U000F0091" # battery-unknown - - "\U000F1A2B" # blinds-horizontal - - "\U000F1A2C" # blinds-horizontal-closed - - "\U000F00AF" # bluetooth - - "\U000F00B1" # bluetooth-connect - - "\U000F00DE" # brightness-5 - - "\U000F00E0" # brightness-7 - - "\U000F1B50" # button-pointer - - "\U000F010C" # car-battery - - "\U000F1019" # car-coolant-level - - "\U000F0114" # cash - - "\U000F0119" # cast-connected - - "\U000F078A" # cast-off - - "\U000F05E0" # check-circle - - "\U000F05E1" # check-circle-outline - - "\U000F0C54" # check-network-outline - - "\U000F0133" # checkbox-marked-circle - - "\U000F0AA5" # circle-slice-8 - - "\U000F0954" # clock - - "\U000F0155" # clock-start - - "\U000F015A" # close-circle-outline - - "\U000F0C5F" # close-network-outline - - "\U000F1382" # compass-rose - - "\U000F01A1" # crop-portrait - - "\U000F1136" # crosshairs-question - - "\U000F1480" # current-ac - - "\U000F01BC" # database - - "\U000F07C5" # ear-hearing - - "\U000F0208" # eye - - "\U000F0D04" # eye-check - - "\U000F081D" # fan-off - - "\U000F0279" # format-list-bulleted - - "\U000F06DA" # garage-open - - "\U000F0299" # gate - - "\U000F116A" # gate-open - - "\U000F06A1" # home-outline - - "\U000F0318" # lan-connect - - "\U000F0319" # lan-disconnect - - "\U000F140B" # lightning-bolt - - "\U000F08EE" # lock-alert - - "\U000F097F" # lock-clock - - "\U000F033F" # lock-open - - "\U000F0BAC" # molecule - - "\U000F12FE" # molecule-co - - "\U000F1435" # motion-sensor-off - - "\U000F038A" # music-note-off - - "\U000F03C3" # octagon - - "\U000F03D3" # package - - "\U000F03D5" # package-up - - "\U000F17C5" # ph - - "\U000F184D" # pipe-valve - - "\U000F06A6" # power-plug-off - - "\U000F0996" # progress-clock - - "\U000F1A23" # projector-off - - "\U000F043C" # radioactive - - "\U000F0445" # ray-vertex - - "\U000F1A6B" # roller-shade - - "\U000F1727" # script-text-play - - "\U000F0483" # security - - "\U000F0498" # shield - - "\U000F06BB" # shield-airplane - - "\U000F099D" # shield-lock - - "\U000F1828" # shield-moon - - "\U000F099E" # shield-off - - "\U000F0499" # shield-outline - - "\U000F095B" # sine-wave - - "\U000F192E" # smoke-detector-alert - - "\U000F180B" # smoke-detector-variant - - "\U000F1930" # smoke-detector-variant-alert - - "\U000F04C4" # speaker-off - - "\U000F1B73" # speaker-pause - - "\U000F1B72" # speaker-play - - "\U000F04C5" # speedometer - - "\U000F0674" # creation - - "\U000F0E0C" # palette-outline - - "\U000F0411" # playlist-play - - "\U000F08FF" # play-speed - - "\U000F00DF" # brightness-6 - - "\U000F1429" # spoon-sugar - - "\U000F0E67" # sprout-outline - - "\U000F0764" # square - - "\U000F0763" # square-outline - - "\U000F1A75" # storage-tank - - "\U000F17FE" # sun-wireless - - "\U000F083B" # television-off - - "\U000F0F89" # television-pause - - "\U000F0ECF" # television-play - - "\U000F0FE6" # texture-box - - "\U000F0510" # thermometer-lines - - "\U000F1A25" # toggle-switch-variant - - "\U000F1A26" # toggle-switch-variant-off - - "\U000F0D3E" # transmission-tower - - "\U000F0566" # vibrate - - "\U000F11B4" # water-boiler-off - - "\U000F058D" # water-off - - "\U000F1855" # water-opacity - - "\U000F05A1" # weight - - "\U000F05A9" # wifi - - "\U000F05AE" # window-closed - - "\U000F05B1" # window-open - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" - id: materialdesign_icons_mini - size: 26 - bpp: 4 - glyphs: *tile_icons - - # The large icon of a card that takes the whole page (firmware 0.2.62+): the domain icons only, - # so the flash stays free for other things; a chosen icon outside this set keeps its usual size. - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" - id: materialdesign_icons_big - size: 64 - bpp: 4 - glyphs: &tile_icons_big # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py - - "\U000F0335" # lightbulb - - "\U000F001B" # air-conditioner - - "\U000F070D" # robot-vacuum - - "\U000F0210" # fan - - "\U000F111C" # window-shutter - - "\U000F04B9" # sofa - - "\U000F029A" # gauge - - "\U000F051B" # timer-outline - - "\U000F0004" # account - - "\U000F07AE" # cctv - - "\U000F0150" # clock-outline - - "\U000F0493" # cog - - "\U000F0054" # arrow-right - - "\U000F0E4F" # lightbulb-off - - "\U000F0425" # power - - "\U000F0599" # weather-sunny - - "\U000F0594" # weather-night - - "\U000F0590" # weather-cloudy - - "\U000F0595" # weather-partly-cloudy - - "\U000F0597" # weather-rainy - - "\U000F0596" # weather-pouring - - "\U000F0598" # weather-snowy - - "\U000F067F" # weather-snowy-rainy - - "\U000F0591" # weather-fog - - "\U000F0592" # weather-hail - - "\U000F0593" # weather-lightning - - "\U000F067E" # weather-lightning-rainy - - "\U000F059D" # weather-windy - - "\U000F05D6" # alert-circle-outline - - "\U000F059C" # weather-sunset-up - - "\U000F059B" # weather-sunset-down - - - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" - id: watch_icon - size: 18 - bpp: 4 - glyphs: *tile_icons - -# Make memory visible: this board has no PSRAM, so the free heap determines -# how much UI still fits. Also shows up as an entity in Home Assistant. -debug: - # Heap figures every five minutes: enough to spot a leak, few recorder rows. - update_interval: 300s - -sensor: - - platform: debug - free: - name: "Heap Free" - id: heap_free - on_value: - - lambda: 'ESP_LOGI("health", "free_heap=%.0f", x);' - fragmentation: - name: "Heap Fragmentation" - block: - name: "Heap Largest Block" - on_value: - - lambda: 'ESP_LOGI("health", "largest_block=%.0f", x);' - # The lowest free heap since boot: what the busiest moment (boot sync, a swipe) left over. - min_free: - name: "Heap Minimum Free" - on_value: - - lambda: 'ESP_LOGI("health", "min_free=%.0f", x);' - - platform: wifi_signal - name: "Wifi Signal" - update_interval: 600s - - # When the screen booted, as a timestamp: one value per boot instead of a new recorder - # row every 15 s. A new entity on purpose: Home Assistant keeps the old Uptime's unit - # "s" in its registry and would refuse a timestamp under that name. - - platform: uptime - type: timestamp - name: "Last boot" - id: boot_time - - -binary_sensor: - - platform: status - name: "Node Status" - id: system_status - -# ---------------------------------------------------------- -# Text sensors -# -# smartdisplay_action: -# - Main touch event channel exported to Home Assistant -# - On short tap, publishes one of: -# tile1_press / tile2_press / tile3_press / tile4_press / tile5_press / tile6_press -# - The value is reset shortly after publishing -# -# tile*_preset: -# - Mirrors the Home Assistant preset_mode attribute for each tile entity -# - Used by the UI to display fan presets when applicable -# ---------------------------------------------------------- -text_sensor: - - platform: template - name: "Guition screen type" - entity_category: diagnostic - update_interval: never - lambda: 'return {"guition"};' - - platform: template - name: "Screen firmware" - entity_category: diagnostic - lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' - update_interval: 60s - # ESP Screen Manager matches the screen to its YAML profile and OTA address. - - platform: template - name: "Device name" - entity_category: diagnostic - lambda: 'return {"${DEVICE_NAME}"};' - update_interval: 60s - - platform: wifi_info - ip_address: - # `id` so the settings page on the screen can show the same address (0.2.44+). - id: wifi_ip - name: "IP address" - entity_category: diagnostic - -switch: - - platform: restart - name: "Restart" - # The screen settings as Home Assistant entities (firmware 0.2.49+, Auto standby since 0.2.41). The screen - # owns them: the settings page, these entities and ESP Screens all change them through - # settings_screen::set(), which saves, applies and reports a real change only. The saved values keep - # their preference records. A template switch reads its lambda every loop and publishes a change itself; - # the numbers and times are published by apply_screen_settings. Auto standby off wakes a screen the - # standby time dimmed (a Sleep holds until a tap, Wake or an alert); on counts the standby time from that - # moment. Nothing runs at boot: every switch follows the saved setting. - - platform: template - id: setting_auto_standby - name: "Auto standby" - icon: "mdi:sleep" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.standby_enabled; - turn_on_action: - - lambda: |- - id(last_touch_ms) = millis(); - settings_screen::set("standby_enabled", 1); - turn_off_action: - - lambda: 'settings_screen::set("standby_enabled", 0);' - - platform: template - id: setting_night_mode - name: "Night mode" - icon: "mdi:weather-night" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.night_enabled; - turn_on_action: - - lambda: 'settings_screen::set("night_enabled", 1);' - turn_off_action: - - lambda: 'settings_screen::set("night_enabled", 0);' - # The dark look for a screen beside a bed (firmware 0.2.54+): black page, graphite cards, soft white text. - - platform: template - id: setting_dark_mode - name: "Dark mode" - icon: "mdi:theme-light-dark" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::dark_mode != 0; - turn_on_action: - - lambda: 'settings_screen::set("dark_mode", 1);' - turn_off_action: - - lambda: 'settings_screen::set("dark_mode", 0);' - - platform: template - id: setting_clock_24h - name: "24-hour clock" - icon: "mdi:clock-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.clock_24h; - turn_on_action: - - lambda: 'settings_screen::set("clock_24h", 1);' - turn_off_action: - - lambda: 'settings_screen::set("clock_24h", 0);' - - platform: template - id: setting_auto_home - name: "Back to page 1" - icon: "mdi:home-import-outline" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::auto_home != 0; - turn_on_action: - - lambda: 'settings_screen::set("auto_home", 1);' - turn_off_action: - - lambda: 'settings_screen::set("auto_home", 0);' - - platform: template - id: setting_home_on_standby - name: "Back to page 1 on standby" - icon: "mdi:home-import-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.home_on_standby; - turn_on_action: - - lambda: 'settings_screen::set("home_on_standby", 1);' - turn_off_action: - - lambda: 'settings_screen::set("home_on_standby", 0);' - - platform: template - id: setting_swipe_pages - name: "Swipe between pages" - icon: "mdi:gesture-swipe-horizontal" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::swipe_pages != 0; - turn_on_action: - - lambda: 'settings_screen::set("swipe_pages", 1);' - turn_off_action: - - lambda: 'settings_screen::set("swipe_pages", 0);' - # Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+). - - platform: template - id: setting_page_buttons - name: "Page buttons" - icon: "mdi:page-next-outline" - entity_category: config - restore_mode: DISABLED - lambda: return settings_screen::page_buttons != 0; - turn_on_action: - - lambda: 'settings_screen::set("page_buttons", 1);' - turn_off_action: - - lambda: 'settings_screen::set("page_buttons", 0);' - -# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight. -datetime: - - platform: template - type: time - id: setting_night_start - name: "Night starts" - icon: "mdi:weather-sunset-down" - entity_category: config - update_interval: never - lambda: |- - ESPTime time{}; - time.hour = screen_settings::current.night_start / 60; - time.minute = screen_settings::current.night_start % 60; - return time; - set_action: - - lambda: 'settings_screen::set("night_start", x.hour * 60 + x.minute);' - - platform: template - type: time - id: setting_night_end - name: "Night ends" - icon: "mdi:weather-sunset-up" - entity_category: config - update_interval: never - lambda: |- - ESPTime time{}; - time.hour = screen_settings::current.night_end / 60; - time.minute = screen_settings::current.night_end % 60; - return time; - set_action: - - lambda: 'settings_screen::set("night_end", x.hour * 60 + x.minute);' - -# Rotation as a Home Assistant choice (firmware 0.2.49+). Only the Guition turns; the CYD keeps its orientation. -select: - - platform: template - id: setting_rotation - name: "Rotation" - icon: "mdi:screen-rotation" - entity_category: config - options: ["0°", "90°", "180°", "270°"] - update_interval: never - lambda: |- - static const char *const names[] = {"0°", "90°", "180°", "270°"}; - return std::string(names[std::clamp(settings_screen::rotation, 0, 270) / 90]); - set_action: - - lambda: |- - const std::string option(x.c_str(), x.size()); - settings_screen::set("rotation", atoi(option.c_str())); - -# Two SPI buses used by the CYD: -# - lcd: built-in display -# - my_touchscreen: built-in touch controller -# Pinout from ESPHome's GUITION-4848S040 preset. No relay outputs are configured. -spi: - id: lcd - clk_pin: GPIO48 - mosi_pin: GPIO47 - -i2c: - id: touch_bus - sda: GPIO19 - scl: - number: GPIO45 - ignore_strapping_warning: true - scan: true - -output: - - platform: ledc - pin: GPIO38 - id: gpio_backlight_pwm - frequency: 20000Hz - -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: "Display Backlight" - id: back_light - internal: true - restore_mode: ALWAYS_ON - default_transition_length: 150ms - -touchscreen: - platform: gt911 - id: ts_touch - i2c_id: touch_bus - display: my_display - update_interval: 20ms - 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); - cyd::edge_swipe.begin(touch.x, touch.y, static_cast(id(screen_lvgl).get_rotation())); - ESP_LOGI("touch", "GT911 press x=%u y=%u id=%u test=%d", touch.x, touch.y, (unsigned) touch.id, 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) { - // Trace every sample of an edge touch: the log then shows how often the panel delivers. - if (cyd::edge_swipe.armed()) - ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y); - // The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went. - if (point.x == 0 && point.y == 0) continue; - cyd::touch_guard.update(point.x, point.y, point.id); - if (point.id != cyd::touch_guard.contact()) continue; - // Swiping in from a side edge flips the page ("Swiping between pages"); the tap - // under the finger is consumed and LVGL waits for the release. LVGL 9.5 sends no - // PRESSING to the input device, hence the touchscreen trigger. - const int step = cyd::edge_swipe.update(point.x, point.y); - if (!step) continue; - const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off" - : id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open" - : (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open" - : runtime_tiles::camera_visible() ? "camera open" : nullptr; - if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; } - cyd::touch_guard.consume(); - for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev); - ESP_LOGI("touch", "edge swipe: page %d -> %d", id(tile_page), id(tile_page) + step); - id(tile_page) += step; - id(show_tile_page).execute(); - } - on_release: - - lambda: |- - id(last_touch_ms) = millis(); - id(last_use_ms) = millis(); - id(touch_down) = false; - if (cyd::edge_swipe.armed() && cyd::edge_swipe.inward() > 0) - ESP_LOGI("touch", "edge swipe not fired: %d px inward, %d px vertical", cyd::edge_swipe.inward(), cyd::edge_swipe.sideways()); - cyd::edge_swipe.end(); - - -# Native ESPHome driver with the working manufacturer's LVGL demo timings. -# See docs/GUITION_FACTORY_REFERENCE.md; no custom RGB component is required. -display: - - platform: st7701s - id: my_display - dimensions: {width: 480, height: 480} - spi_id: lcd - spi_mode: MODE0 - data_rate: 2MHz - color_order: RGB - invert_colors: false - cs_pin: 39 - de_pin: 18 - hsync_pin: 16 - vsync_pin: 17 - pclk_pin: 21 - pclk_frequency: 16MHz - pclk_inverted: false - hsync_front_porch: 10 - hsync_pulse_width: 8 - hsync_back_porch: 50 - vsync_front_porch: 10 - vsync_pulse_width: 8 - vsync_back_porch: 20 - transform: - mirror_x: false - mirror_y: false - init_sequence: - - 1 - # Manufacturer's 16-bit bus configuration and normal scan direction. - - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x10] - - [0xCD, 0x00] - - [0xC7, 0x00] - # Return to the standard register bank before pixel format / sleep-out. - - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x00] - - [0x3A, 0x60] - - [0x36, 0x00] - - [0x20] - - [0x11] - - delay 120ms - data_pins: - red: [11, 12, 13, 14, 0] - green: [8, 20, 3, 46, 9, 10] - blue: [4, 5, 6, 7, 15] - update_interval: never - auto_clear_enabled: false - - -lvgl: - id: screen_lvgl - # The theme's font for text without a font of its own. Every label names its font, so this only keeps - # ESPHome from building LVGL's Montserrat 14 for nothing (firmware 0.2.65+). - default_font: sublabel - on_draw_start: - - lambda: 'id(rendered_frames)++;' - displays: - - my_display - touchscreens: - - touchscreen_id: ts_touch - - # S3 octal PSRAM holds RGB framebuffer and LVGL draw buffers. - buffer_size: 25% - - # Direct local feedback on the WHOLE UI, not just the tiles. - # A theme sets the style per widget type, so this covers all - # buttons, tabs, sliders and the thermostat ring in one go. Non-clickable - # widgets never enter the pressed state, so those stay untouched. - theme: - obj: - pressed: - # bg_opa and not opa: opa would also fade the icon and text. Styles that - # make LVGL draw into a buffer of its own (opa_layered, rotation/scale, - # clip_corner, a slider fill rounded less than its track) cost KB's per - # redraw that the CYD's heap can't spare; tests/test_layer_free.py. - bg_opa: 45% - slider: - knob: - pressed: - bg_opa: 65% - arc: - knob: - pressed: - bg_opa: 65% - - # LVGL owns the rotation (display + touch) since ESPHome 2026.4. - rotation: ${LVGL_ROTATION} - - style_definitions: - # The page behind the tiles and the cards: one colour from the theme (theme.h PAGE), no wallpaper and no - # gradient, so a redraw is a plain fill. - - id: style_page - bg_opa: COVER - border_width: 0 - radius: 0 - - - id: style_tile - bg_opa: COVER - radius: 22 - border_width: 1 - border_opa: COVER - pad_left: 12 - pad_right: 12 - pad_top: 12 - pad_bottom: 12 - shadow_width: 0 - shadow_opa: 40% - - - id: style_icon_circle - bg_opa: COVER - radius: 999 - border_width: 0 - - - id: style_room - text_font: headline - text_opa: 100% - - - id: style_time - text_font: time_label - text_opa: 100% - - - id: style_title - text_font: label - text_opa: 100% - - - id: style_value - text_font: sublabel - text_opa: 100% - - - id: style_value_big - text_font: sublabel_big - text_opa: 80% - - # Paints: shared styles without a colour of their own. theme.h fills them (the list in on_boot, theme::paints) - # and fills them again when Dark mode changes, so a widget names a paint where it used to write a colour. - - id: paint_page_soft - - id: paint_page_pressed - - id: paint_card - - id: paint_raised - - id: paint_key - - id: paint_key_pressed - - id: paint_track - - id: paint_ink - - id: paint_ink_soft - - id: paint_muted - - id: paint_subtle - - id: paint_slate - - id: paint_scrim - - id: paint_veil - - id: paint_accent_tint - - id: paint_accent_bright - - id: paint_button_dark - - id: paint_button_dark_pressed - - id: paint_camera - - # ---------- ALERT OVERLAY (HA action show_alert) ---------- - # LVGL's top layer sits above every page, card and the dim overlay; the card - # is filled by the alert_show script. Fixed positions, no layout engine. - top_layer: - widgets: - - obj: - id: alert_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - styles: paint_scrim - bg_opa: 60% - radius: 0 - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - # Same look when pressed: a tap next to the card must not repaint the screen. - pressed: - bg_opa: 60% - widgets: - - obj: - id: alert_card - align: CENTER - width: ${ALERT_CARD_W} - height: ${ALERT_CARD_H} - styles: paint_raised - bg_opa: COVER - radius: 18 - border_width: 1 - border_opa: COVER - shadow_width: 0 - pad_all: 0 - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: alert_icon - x: ${ALERT_ICON_X} - y: ${ALERT_ICON_Y} - text: "\U000F002A" - text_font: materialdesign_icons - clickable: false - - label: - id: alert_title - x: ${ALERT_TEXT_X} - y: ${ALERT_TITLE_Y} - width: ${ALERT_TEXT_W} - height: ${ALERT_TITLE_H} - long_mode: DOT - text: "Notification" - text_font: headline - clickable: false - - label: - id: alert_subtitle - x: ${ALERT_TEXT_X} - y: ${ALERT_SUBTITLE_Y} - width: ${ALERT_TEXT_W} - height: ${ALERT_SUBTITLE_H} - long_mode: WRAP - text: "" - text_font: sublabel_big - styles: paint_ink_soft - clickable: false - # The alert's camera image (firmware 0.2.57+) across the top of the card, shown when the app announced - # one; a tap opens the camera full screen over the alert. runtime_tiles.h puts the image in it. - - obj: - id: alert_image_frame - x: ${ALERT_IMAGE_INSET} - y: ${ALERT_IMAGE_INSET} - width: ${CAMERA_THUMB_W} - height: ${CAMERA_THUMB_H} - hidden: true - styles: paint_camera - bg_opa: COVER - radius: 0 - border_width: 0 - shadow_width: 0 - pad_all: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept(millis(), 14) && !runtime_tiles::alert_camera.empty();' - then: - - lambda: 'runtime_tiles::camera_open(runtime_tiles::alert_camera, id(alert_title_text));' - widgets: - - label: - id: alert_image_icon - align: CENTER - text: "\U000F07AE" - text_font: materialdesign_icons - clickable: false - - obj: - id: alert_ok - align: BOTTOM_RIGHT - x: -${ALERT_BUTTON_INSET} - y: -${ALERT_BUTTON_INSET} - width: ${ALERT_BUTTON_W} - height: ${ALERT_BUTTON_H} - radius: 999 - styles: paint_button_dark - bg_opa: COVER - border_width: 0 - shadow_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_button_dark_pressed - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept(millis(), 13);' - then: - - script.execute: - id: alert_dismiss - reason: "ok" - widgets: - - label: - id: alert_ok_label - align: CENTER - text: "OK" - text_font: headline - clickable: false - - pages: - - id: home_page - styles: style_page - pad_all: 0 - scrollable: false - scrollbar_mode: 'OFF' - widgets: - # Never shown: it only makes ESPHome compile LVGL's spinner, which the busy sheet on a tile draws. - - spinner: - id: busy_spinner_seed - hidden: true - width: 2 - height: 2 - spin_time: 1s - arc_length: 60deg - # Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from - # (firmware 0.2.70+). - - roller: - id: roller_seed - hidden: true - width: 2 - height: 2 - options: ["-"] - # Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with. - - image: - id: camera_image_seed - hidden: true - src: alert_image - - label: - id: lbl_room - x: 16 - y: ${HEADER_Y} - text: "${ROOM_NAME}" - styles: style_room - - # Enables LVGL's line widget for the runtime clock hands and sparklines. - - line: - id: runtime_line_enable - hidden: true - points: - - 0, 0 - - 1, 1 - - - label: - id: lbl_time - align: TOP_RIGHT - x: -16 - y: ${HEADER_Y} - text: "" - styles: style_time - - # Six tiles on page 1, four on page 2. Fixed pages keep navigation predictable. - - obj: - id: tile_scroll - x: 0 - y: ${SCROLL_Y} - width: ${DISPLAY_W} - height: ${SCROLL_H} - bg_opa: TRANSP - # A tap between two tiles presses this area: without its own pressed style the - # theme's 45 % lights it up as a pale panel behind the tiles. - pressed: - bg_opa: TRANSP - border_width: 0 - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - scrollable: false - scrollbar_mode: 'OFF' - widgets: - # ---------- TILE 1 ---------- - - obj: - id: tile1 - x: ${GRID_COL1_X} - y: ${GRID_ROW1_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile1_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile1_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F001B" - clickable: false - - label: - id: t1_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Lamp" - styles: style_title - clickable: false - - label: - id: t1_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 2 ---------- - - obj: - id: tile2 - x: ${GRID_COL2_X} - y: ${GRID_ROW1_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile2_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile2_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F0387" - clickable: false - - label: - id: t2_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Plug" - styles: style_title - clickable: false - - label: - id: t2_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 3 ---------- - - obj: - id: tile3 - x: ${GRID_COL1_X} - y: ${GRID_ROW2_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile3_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile3_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F02E3" - clickable: false - - label: - id: t3_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Evening" - styles: style_title - clickable: false - - label: - id: t3_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 4 ---------- - - obj: - id: tile4 - x: ${GRID_COL2_X} - y: ${GRID_ROW2_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile4_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile4_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F059C" - clickable: false - - label: - id: t4_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "All off" - styles: style_title - clickable: false - - label: - id: t4_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 5 ---------- - - obj: - id: tile5 - x: ${GRID_COL1_X} - y: ${GRID_ROW3_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile5_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile5_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F0425" - clickable: false - - label: - id: t5_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "AC" - styles: style_title - clickable: false - - label: - id: t5_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 6 ---------- - - obj: - id: tile6 - x: ${GRID_COL2_X} - y: ${GRID_ROW3_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - # Direct local feedback: LVGL applies this as soon as the finger - # touches the glass, in the same frame. Independent of Home Assistant. - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile6_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile6_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F070D" - clickable: false - - label: - id: t6_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Vacuum" - styles: style_title - clickable: false - - label: - id: t6_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 7 — Tafellamp ---------- - # Brightness-only lamp: deliberately no percentage/cover/climate - # sensors were created for this tile, those would only cost - # HA subscriptions and heap. The overlay therefore gets - # NAN / "" for the fields a light never uses. - - obj: - id: tile7 - hidden: true - x: ${GRID_COL1_X} - y: ${GRID_ROW4_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile7_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile7_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F06B5" - clickable: false - - label: - id: t7_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 7" - styles: style_title - clickable: false - - label: - id: t7_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 8 — Brievenbus reset ---------- - # input_select with options Full/Empty. The tile lights up as soon as - # the status is TILE8_ACTIVE_STATE ("Full"); a short tap sends - # input_select.select_option with option: "Empty". - # No slider mode, so this tile only needs ha_state_tile8 — - # no other HA subscription at all. - - obj: - id: tile8 - hidden: true - x: ${GRID_COL2_X} - y: ${GRID_ROW4_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile8_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile8_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F06EE" - clickable: false - - label: - id: t8_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 8" - styles: style_title - clickable: false - - label: - id: t8_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 9 — Living room (moved from tile 2) ---------- - - obj: - id: tile9 - hidden: true - x: ${GRID_COL1_X} - y: ${GRID_ROW5_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile9_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile9_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F04B9" - clickable: false - - label: - id: t9_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 9" - styles: style_title - clickable: false - - label: - id: t9_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- TILE 10 — Alarm on/off ---------- - - obj: - id: tile10 - hidden: true - x: ${GRID_COL2_X} - y: ${GRID_ROW5_Y} - width: ${TILE_W} - height: ${TILE_H} - styles: style_tile - scrollable: false - scrollbar_mode: 'OFF' - pressed: - bg_opa: 45% - widgets: - - obj: - id: tile10_icon_circle - x: ${TILE_ICON_X} - y: ${TILE_ICON_Y} - width: 54 - height: 54 - styles: style_icon_circle - clickable: false - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - id: tile10_icon_lbl - align: CENTER - text_font: materialdesign_icons - text: "\U000F002A" - clickable: false - - label: - id: t10_title - height: 24 - width: 130 - long_mode: DOT - x: ${TILE_TITLE_X} - y: ${TILE_TITLE_Y} - text: "Tile 10" - styles: style_title - clickable: false - - label: - id: t10_value - height: 22 - width: 130 - long_mode: DOT - x: ${TILE_VALUE_X} - y: ${TILE_VALUE_Y} - text: "—" - styles: style_value - clickable: false - - # ---------- VALUE SLIDER OVERLAY ---------- - # ---------- PAGE NAVIGATION: the band under the tiles ---------- - # Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the - # page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge. - - obj: - id: page_prev - align: BOTTOM_LEFT - x: 0 - y: 0 - width: 240 - height: 60 - pad_all: 0 - radius: 0 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pressed: - styles: paint_page_pressed - bg_opa: COVER - # on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing - # (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe. - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);' - then: - - lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);' - - script.execute: show_tile_page - widgets: - - label: - align: LEFT_MID - x: 24 - text: "\U000F0141" # chevron-left - text_font: materialdesign_icons_mini - styles: paint_ink - clickable: false - - obj: - id: page_number - align: BOTTOM_MID - y: 0 - width: 160 - height: 60 - pad_all: 0 - bg_opa: TRANSP - border_width: 0 - clickable: false - scrollable: false - - obj: - id: page_next - align: BOTTOM_RIGHT - x: 0 - y: 0 - width: 240 - height: 60 - pad_all: 0 - radius: 0 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pressed: - styles: paint_page_pressed - bg_opa: COVER - on_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);' - then: - - lambda: 'id(tile_page) += 1;' - - script.execute: show_tile_page - widgets: - - label: - align: RIGHT_MID - x: -24 - text: "\U000F0142" # chevron-right - text_font: materialdesign_icons_mini - styles: paint_ink - clickable: false - - - obj: - id: brightness_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - bg_opa: TRANSP - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: brightness_overlay_bg - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: style_page - scrollable: false - clickable: true - on_click: - - script.execute: close_cards - - - obj: - id: brightness_overlay_dim - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: paint_veil - bg_opa: 18% - border_width: 0 - clickable: false - scrollable: false - - - obj: - id: overlay_back_button - align: TOP_LEFT - x: 16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: overlay_title - align: TOP_MID - y: 30 - text: "Light" - styles: style_room - - - label: - id: overlay_value - align: TOP_MID - y: 70 - text: "100 %" - styles: style_value_big - - - slider: - id: overlay_slider - align: CENTER - y: 36 - width: ${OVERLAY_SLIDER_W} - height: ${OVERLAY_SLIDER_H} - min_value: 0 - max_value: 100 - value: 100 - adv_hittest: false - - styles: paint_accent_tint - bg_opa: 100% - radius: 42 - # The only widget with BOTH a radius AND a child (overlay_icon). - # LVGL then clips children to the rounding, and that's only - # possible via a contiguous 96x160 = 46KB ARGB layer. - # If that allocation fails, LVGL keeps trying until the - # task watchdog restarts the board. The rounding stays, the - # clipping goes. - clip_corner: false - border_width: 0 - - indicator: - styles: paint_accent_bright - bg_opa: 100% - radius: 42 - - knob: - bg_opa: 0% - border_width: 0 - shadow_width: 0 - pad_all: 15 - - on_value: - - script.execute: - id: slider_preview - value: !lambda "return x;" - - on_release: - - script.execute: - id: slider_commit - value: !lambda "return lv_slider_get_value(id(overlay_slider));" - - - # The icon used to be a CHILD in the slider. A widget with a radius - # and children has to clip those children to the rounding, and - # LVGL can only do that via an ARGB layer of tens of KB's. Without - # PSRAM that sometimes fails, after which LVGL keeps retrying until - # the watchdog restarts the board. As a sibling on top, no layer - # is needed and it looks identical. - - label: - id: overlay_icon - align: CENTER - y: ${OVERLAY_ICON_Y} - text_font: materialdesign_icons - styles: paint_ink - text: "\U000F001B" - clickable: false - - - obj: - id: overlay_color_button - align: BOTTOM_RIGHT - x: -20 - y: -26 - width: 66 - height: 66 - radius: 999 - hidden: true - clickable: true - bg_opa: TRANSP - border_width: 0 - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - scrollable: false - scrollbar_mode: 'OFF' - on_click: - - script.execute: open_color_detail - widgets: - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 0 - end_angle: 45 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0xFF3B30 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 45 - end_angle: 90 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0xFF9500 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 90 - end_angle: 135 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0xFFD60A - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 135 - end_angle: 180 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0x30D158 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 180 - end_angle: 225 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0x32D6FF - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 225 - end_angle: 270 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0x0A84FF - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 270 - end_angle: 315 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0x5856D6 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - arc: - align: CENTER - width: 60 - height: 60 - start_angle: 315 - end_angle: 360 - min_value: 0 - max_value: 100 - value: 0 - adjustable: false - arc_width: 15 - arc_rounded: false - arc_color: 0xBF5AF2 - arc_opa: COVER - bg_opa: TRANSP - border_width: 0 - clickable: false - indicator: - arc_opa: TRANSP - knob: - bg_opa: TRANSP - border_width: 0 - - # ---------- LIGHT COLOR DETAIL OVERLAY ---------- - - obj: - id: color_detail_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: paint_page_soft - bg_opa: COVER - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: color_close_button - align: TOP_LEFT - x: 16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - # The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette, - # preset, speed), opens its effects page. Hidden for every other light. - - obj: - id: color_effects_button - align: TOP_RIGHT - x: -16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - hidden: true - clickable: true - scrollable: false - on_click: - - lambda: 'effects_page::open(id(active_entity));' - widgets: - - label: - align: CENTER - text: "\U000F0674" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: color_detail_title - width: 310 - long_mode: DOT - text_align: CENTER - align: TOP_MID - y: 35 - text: "Light Color" - styles: style_room - - - # ---------- CLIMATE DETAIL OVERLAY ---------- - # The CYD's climate card at the Guition's size: nothing to drag. A setpoint card with big - # -/+ keys (the number changes at once, one call goes out once the taps stop) and one row - # of mode keys like Home Assistant's climate card; fan and swing stay in the picker behind - # "more". A tap redraws only a label or a key, never a dial. - - obj: - id: climate_detail_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - styles: style_page - bg_opa: COVER - hidden: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - border_width: 0 - widgets: - - obj: - id: climate_close_button - align: TOP_LEFT - x: 16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: close_cards - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - obj: - id: climate_power_button - align: TOP_RIGHT - x: -16 - y: 16 - width: 60 - height: 60 - radius: 999 - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - script.execute: climate_power_toggle - widgets: - - label: - id: climate_power_icon - align: CENTER - text: "\U000F0425" - text_font: materialdesign_icons_mini - clickable: false - - - label: - id: climate_detail_title - align: TOP_MID - y: 30 - width: 300 - long_mode: DOT - text_align: CENTER - text: "Climate" - styles: style_room - - # What the device does (or its mode), the room temperature and humidity. - - label: - id: climate_current_label - align: TOP_MID - y: 92 - width: 94% - long_mode: DOT - text_align: CENTER - text: "--" - text_font: sublabel_big - styles: paint_muted - clickable: false - - - obj: - id: climate_setpoint_card - align: TOP_MID - y: 136 - width: 94% - height: 200 - radius: 24 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: false - scrollable: false - widgets: - # Every clean tap counts (a quick triple tap is three steps); holding keeps stepping. - - obj: - id: climate_minus_button - align: LEFT_MID - x: 18 - width: 116 - height: 116 - radius: 58 - styles: paint_track - bg_opa: COVER - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key_pressed - disabled: - opa: 40% - on_short_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 60);' - then: - - script.execute: { id: climate_adjust_target, direction: -1, held: false } - on_long_press_repeat: - - script.execute: { id: climate_adjust_target, direction: -1, held: true } - widgets: - - label: - id: climate_minus_icon - align: CENTER - text: "\U000F0374" - text_font: materialdesign_icons - clickable: false - - obj: - id: climate_plus_button - align: RIGHT_MID - x: -18 - width: 116 - height: 116 - radius: 58 - styles: paint_track - bg_opa: COVER - border_width: 0 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key_pressed - disabled: - opa: 40% - on_short_click: - - if: - condition: - lambda: 'return cyd::touch_guard.accept_repeat(millis(), 61);' - then: - - script.execute: { id: climate_adjust_target, direction: 1, held: false } - on_long_press_repeat: - - script.execute: { id: climate_adjust_target, direction: 1, held: true } - widgets: - - label: - id: climate_plus_icon - align: CENTER - text: "\U000F0415" - text_font: materialdesign_icons - clickable: false - - label: - id: climate_target_label - align: CENTER - y: -14 - text: "--" - text_font: setpoint_digits - clickable: false - - label: - id: climate_set_label - align: CENTER - y: 46 - text: "Target" - text_font: sublabel - styles: paint_subtle - clickable: false - - # One key per mode the device supports (off is the power key); the one in use is - # filled in Home Assistant's colour for it. "More" opens the picker with fan and swing. - - obj: - id: climate_mode_keys - align: TOP_MID - y: 364 - width: 94% - height: 76 - bg_opa: TRANSP - border_width: 0 - pad_all: 0 - clickable: false - scrollable: false - layout: - type: FLEX - flex_flow: ROW - flex_align_main: CENTER - flex_align_cross: CENTER - pad_column: 10 - widgets: - - obj: - id: cmkey_auto - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "auto" } - widgets: - - label: - align: CENTER - text: "\U000F1B17" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_heat_cool - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "heat_cool" } - widgets: - - label: - align: CENTER - text: "\U000F1A79" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_heat - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "heat" } - widgets: - - label: - align: CENTER - text: "\U000F0238" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_cool - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "cool" } - widgets: - - label: - align: CENTER - text: "\U000F0717" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_dry - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "dry" } - widgets: - - label: - align: CENTER - text: "\U000F058E" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_fan_only - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: { id: set_climate_mode, mode: "fan_only" } - widgets: - - label: - align: CENTER - text: "\U000F0210" - text_font: materialdesign_icons - clickable: false - - obj: - id: cmkey_more - flex_grow: 1 - height: 76 - radius: 38 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - clickable: true - scrollable: false - pressed: - styles: paint_key - on_click: - - script.execute: open_climate_mode_picker - widgets: - - label: - align: CENTER - text: "\U000F01D8" - text_font: materialdesign_icons - styles: paint_slate - clickable: false - # Fan and swing, each a row of choices behind its icon (climate_card.h fills the card). - - obj: - id: climate_settings_card - align: TOP_MID - y: 340 - width: 94% - height: 116 - radius: 24 - styles: paint_card - bg_opa: COVER - border_width: 1 - pad_all: 0 - hidden: true - clickable: false - scrollable: false - # ---------- CLIMATE MODE PICKER ---------- - # Replaces cycling through modes. Cycling sends a set_hvac_mode on every - # tap, so to get from 'cool' to 'dry' a heat pump actually passes through - # 'heat' and 'fan_only' along the way. Here you pick directly, and exactly - # one command is sent. - - obj: - id: climate_mode_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - styles: style_page - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - widgets: - - label: - align: TOP_MID - y: 35 - text: "Mode" - text_font: sublabel_big - clickable: false - - # Flex ensures unsupported modes don't leave a gap: - # LVGL skips hidden children when arranging. - - obj: - id: climate_mode_list - align: TOP_MID - y: 92 - width: 98% - height: 372 - bg_opa: TRANSP - border_width: 0 - scrollable: false - clickable: false - pad_all: 4 - layout: - type: FLEX - pad_row: 6 - pad_column: 6 - flex_flow: ROW_WRAP - flex_align_main: CENTER - flex_align_cross: CENTER - flex_align_track: CENTER - widgets: - - obj: - id: cmode_off - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "off" } - widgets: - - label: - align: CENTER - text: "Off" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_heat - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "heat" } - widgets: - - label: - align: CENTER - text: "Heat" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_cool - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "cool" } - widgets: - - label: - align: CENTER - text: "Cool" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_heat_cool - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "heat_cool" } - widgets: - - label: - align: CENTER - text: "Heat/Cool" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_auto - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "auto" } - widgets: - - label: - align: CENTER - text: "Auto" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_dry - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "dry" } - widgets: - - label: - align: CENTER - text: "Dry" - text_font: sublabel_big - clickable: false - - obj: - id: cmode_fan_only - width: 136 - height: 58 - radius: 22 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_mode, mode: "fan_only" } - widgets: - - label: - align: CENTER - text: "Fan only" - text_font: sublabel_big - clickable: false - - - label: - id: cfan_title - width: 100% - height: 24 - text: "Fan" - text_font: sublabel - styles: paint_subtle - text_align: CENTER - clickable: false - - obj: - id: cfan_0 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 0 } - widgets: - - label: - id: cfan_0_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cfan_1 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 1 } - widgets: - - label: - id: cfan_1_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cfan_2 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 2 } - widgets: - - label: - id: cfan_2_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cfan_3 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_fan_mode, index: 3 } - widgets: - - label: - id: cfan_3_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - label: - id: cswing_title - width: 100% - height: 24 - text: "Swing" - text_font: sublabel - styles: paint_subtle - text_align: CENTER - clickable: false - - obj: - id: cswing_0 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 0 } - widgets: - - label: - id: cswing_0_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cswing_1 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 1 } - widgets: - - label: - id: cswing_1_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cswing_2 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 2 } - widgets: - - label: - id: cswing_2_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: cswing_3 - width: 98 - height: 48 - radius: 18 - styles: paint_card - bg_opa: COVER - border_width: 1 - clickable: true - scrollable: false - pad_all: 0 - on_click: - - script.execute: { id: set_climate_swing_mode, index: 3 } - widgets: - - label: - id: cswing_3_label - align: CENTER - text: "-" - text_font: sublabel_big - clickable: false - - obj: - id: climate_mode_overlay_x - align: TOP_LEFT - x: 16 - y: 16 - width: 60 - height: 60 - radius: 999 - styles: paint_key - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - on_click: - - lvgl.widget.hide: climate_mode_overlay - widgets: - - label: - align: CENTER - text: "\U000F004D" - text_font: materialdesign_icons_mini - clickable: false - - - # ---------- VACUUM CONTROL ---------- - # A vacuum is not a switch: start, pause, stop and return to - # the dock are separate commands, and suction power is a separate axis. - - - # ---------- DIM / WAKE OVERLAY ---------- - - obj: - id: dim_wake_overlay - x: 0 - y: 0 - width: ${DISPLAY_W} - height: ${DISPLAY_H} - hidden: true - bg_opa: TRANSP - # The tap that wakes a dimmed screen must not light up a pale sheet over it. - pressed: - bg_opa: TRANSP - border_width: 0 - scrollable: false - scrollbar_mode: 'OFF' - clickable: true - on_click: - - script.execute: wake_display - # A page that is one full tile switching something on or off switches with this push as well - # (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes. - - lambda: 'runtime_tiles::wake_tap();' - - - id: touch_cal_page - styles: paint_page_soft - bg_opa: COVER - pad_all: 0 - border_width: 0 - scrollable: false - widgets: - - label: - align: TOP_MID - y: 110 - text: "GT911 touch check" - text_font: headline - - obj: - x: 12 - y: 12 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - - obj: - x: 443 - y: 12 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - - obj: - x: 443 - y: 443 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - - obj: - x: 12 - y: 443 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - - obj: - x: 228 - y: 228 - width: 24 - height: 24 - pad_all: 0 - border_width: 0 - bg_opa: TRANSP - scrollable: false - widgets: - - label: - align: CENTER - text: "+" - text_font: headline - styles: paint_ink - -script: - - id: capture_ui_snapshot - mode: single - then: - - lambda: guition::snapshot_begin(); - - repeat: - count: 240 - then: - - lambda: guition::snapshot_next_row(); - - delay: 100ms - # 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 = guition::check_geometry(id(home_page)->obj); - for (int i = 0; i < 10; ++i) { - bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index= 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 - - lambda: guition::check_geometry(id(brightness_overlay)); - - lvgl.widget.hide: brightness_overlay - - lvgl.widget.show: color_detail_overlay - - delay: 150ms - - lambda: guition::check_geometry(id(color_detail_overlay)); - - lvgl.widget.hide: color_detail_overlay - - lvgl.widget.show: climate_detail_overlay - - delay: 150ms - - lambda: guition::check_geometry(id(climate_detail_overlay)); - - lvgl.widget.hide: climate_detail_overlay - - lvgl.widget.show: climate_mode_overlay - - delay: 150ms - - lambda: guition::check_geometry(id(climate_mode_overlay)); - - 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" - - - id: show_tile_page - then: - - lambda: |- - if(runtime_tiles::enabled){ - runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number)); - return; - } - lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5), - id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)}; - const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}}; - const int count = runtime_tiles::model.count; - const bool paginated = count > 6; - if (!paginated) id(tile_page) = 0; - for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)}) { - if (paginated) lv_obj_clear_flag(control, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN); - } - for (int i = 0; i < 10; ++i) { - bool visible = i < count && (id(tile_page) == 0 ? i < 6 : i >= 6); - if (visible) { - lv_obj_set_y(tiles[i], rows[(i < 6 ? i : i - 6) / 2]); - lv_obj_clear_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN); - } - } - settings_screen::page_dots(id(page_number), id(tile_page), 2, true); - if (id(tile_page) == 0) { - lv_obj_add_state(id(page_prev), LV_STATE_DISABLED); - lv_obj_clear_state(id(page_next), LV_STATE_DISABLED); - } else { - lv_obj_clear_state(id(page_prev), LV_STATE_DISABLED); - lv_obj_add_state(id(page_next), LV_STATE_DISABLED); - } - - # Runtime changes do not count as touches and do not reset the idle timer. - - id: apply_screen_settings - mode: restart - then: - - lambda: |- - const auto &settings = screen_settings::current; - // Dark mode (firmware 0.2.54+): a look the screen already has costs nothing. - theme::set_dark(settings_screen::dark_mode != 0); - if(runtime_tiles::enabled && static_cast(id(screen_lvgl).get_rotation())!=runtime_tiles::rotation){ - cyd::touch_guard.consume(); - if(lv_indev_get_act())lv_indev_wait_release(lv_indev_get_act()); - id(screen_lvgl).set_rotation(static_cast(runtime_tiles::rotation)); - } - // Auto standby off wakes the screen, but not out of a Sleep pressed in Home Assistant. - if (id(display_dimmed) && !settings.standby_enabled && !id(sleep_requested)) { - id(display_dimmed) = false; - } - auto now = id(homeassistant_time).now(); - const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1; - const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness; - // Standby dims slowly (ESPHome's transition); waking is quick. - id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300); - if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); } - else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); - // The setting numbers and times only publish a changed value (HA keeps identical states anyway); the - // switches publish their own changes. An open settings page shows a change from Home Assistant too. - // A number without a state publishes too: a night brightness of 0 equals the number's initial state and - // stayed "unknown" in Home Assistant before firmware 0.2.50. - for (auto [entity, value] : {std::make_pair(id(setting_brightness), settings.brightness), - std::make_pair(id(setting_standby_brightness), settings.standby_brightness), - std::make_pair(id(setting_night_brightness), settings.night_brightness), - std::make_pair(id(setting_standby_seconds), settings.standby_seconds), - std::make_pair(id(setting_auto_home_seconds), settings_screen::auto_home_seconds)}) { - if (!entity->has_state() || entity->state != value) entity->publish_state(value); - } - for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) { - if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update(); - } - const auto shown_rotation = id(setting_rotation)->active_index(); - if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update(); - // The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+). - if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed(); - settings_screen::refresh(); - if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); - - # Every card closes through runtime_tiles::dismiss (set in on_boot): the runtime detail card, the light, - # colour and climate overlays, and active_entity, which the swipe guard reads. - - id: close_cards - then: - - lambda: 'if (runtime_tiles::dismiss) runtime_tiles::dismiss();' - - # Everything back to the overview: the settings page, every card and the first page of tiles. - # Used by standby, by the "Back to page 1" timeout and by the Home Assistant action. - - id: go_home - then: - - lambda: 'settings_screen::close();' - - script.execute: close_cards - - lambda: 'id(tile_page) = 0;' - - script.execute: show_tile_page - - # Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps - # showing what someone looked at minutes ago; after this long the screen is a screen again. - # Independent of standby, which may be off or much later; paused while the screen is in standby. - # The time runs from the last touch (last_use_ms, firmware 0.2.56+): Wake, an alert or Auto standby - # light the screen and restart the standby time, but nobody used a page. Checked every second and by Wake. - - id: back_to_page_1_when_due - then: - - if: - condition: - lambda: |- - if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false; - if (id(display_dimmed)) return false; - bool away = settings_screen::visible() || runtime_tiles::camera_visible() || !id(active_entity).empty() || id(tile_page) != 0 || - (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)); - return away && (millis() - id(last_use_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL; - then: - - script.execute: go_home - - # ---------- ALERT (HA action show_alert) ---------- - # The screen wakes, keeps its normal brightness and shows the card until OK or the - # timeout (0: until OK); the standby interval waits on alert_active. A new alert - # replaces the current one; every ending reports itself as esphome.screen_alert. - - id: alert_show - mode: restart - parameters: - title: string - subtitle: string - icon: string - color: string - button_text: string - timeout: int - flash: bool - then: - - lambda: |- - if (id(alert_active)) id(alert_report).execute("replaced"); - // An alert is the one thing that outranks the settings page, so that closes first. - settings_screen::close(); - // An open camera closes; the card makes room for an image the app announced for this alert. - runtime_tiles::alert_prepare(); - const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX}); - id(alert_active) = true; - id(alert_title_text) = alert.title; - id(alert_timeout_seconds) = alert.timeout_seconds; - id(alert_flash_requested) = alert.flash; - lv_label_set_text(id(alert_icon), alert.icon.c_str()); - lv_label_set_text(id(alert_title), alert.title.c_str()); - lv_label_set_text(id(alert_subtitle), alert.subtitle.c_str()); - lv_label_set_text(id(alert_ok_label), alert.button.c_str()); - id(alert_card_color) = alert.color; - lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(alert.color)), LV_PART_MAIN); - ESP_LOGI("alert", "show \"%s\" timeout=%d flash=%d", alert.title.c_str(), alert.timeout_seconds, (int) alert.flash); - - script.execute: wake_display - - lvgl.widget.show: alert_overlay - - script.stop: alert_timeout - - if: - condition: - lambda: 'return id(alert_flash_requested);' - then: - - script.execute: alert_flash - - if: - condition: - lambda: 'return id(alert_timeout_seconds) > 0;' - then: - - script.execute: - id: alert_timeout - seconds: !lambda 'return id(alert_timeout_seconds);' - - - 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(); - runtime_tiles::alert_clear(); - ESP_LOGI("alert", "dismissed: %s", reason.c_str()); - - lvgl.widget.hide: alert_overlay - - script.stop: alert_timeout - - script.stop: alert_flash - - lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 80);' - - 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: - - lambda: 'id(set_backlight).execute(0, 60);' - - delay: 140ms - - lambda: 'id(set_backlight).execute(screen_settings::current.brightness, 60);' - - delay: 180ms - - - id: alert_timeout - mode: restart - parameters: - seconds: int - then: - - delay: !lambda 'return static_cast(seconds) * 1000UL;' - - script.execute: - id: alert_dismiss - reason: "timeout" - - # Home Assistant adds device_id; action is ok, timeout, replaced or remote. - - id: alert_report - parameters: - action: string - then: - - homeassistant.event: - event: esphome.screen_alert - data: - action: !lambda 'return action;' - title: !lambda 'return id(alert_title_text);' - screen: !lambda 'return App.get_name();' - - - id: wake_display - mode: restart - then: - - lambda: |- - id(last_touch_ms) = millis(); - id(display_dimmed) = false; - id(sleep_requested) = false; - // Almost instant brightness-up. ESPHome's default is 1s, and that feels sluggish - // when you touch the screen and are waiting on light. - id(set_backlight).execute(screen_settings::current.brightness, 80); - - lvgl.widget.hide: dim_wake_overlay - - script.execute: close_cards - - # One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56 - # stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final; - # a new call interrupts the running transition from where it is, as ESPHome does for any light. - - id: set_backlight - mode: restart - parameters: - level: int - ms: int - then: - - lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform(); - - # Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed. - # The settings page and every card close; with "also on standby" the screen goes back to page 1 too. - - id: dim_display - mode: restart - then: - - lambda: |- - id(display_dimmed) = true; - id(apply_screen_settings).execute(); - - if: - condition: - lambda: 'return screen_settings::current.home_on_standby;' - then: - - script.execute: go_home - else: - - lambda: 'settings_screen::close();' - - script.execute: close_cards - - # A -/+ tap on the climate card: the number follows at once, the call waits for the last tap. - # Holding a key steps three times a second. - - id: climate_adjust_target - parameters: - direction: int - held: bool - then: - - lambda: |- - if (held && millis() - id(climate_edit_ms) < 300) return; - int step_x10 = std::max(5, id(active_climate_step_x10)); - int next = cyd::quantize_temperature(id(active_climate_target_x10) + direction * step_x10, - id(active_climate_min_x10), id(active_climate_max_x10), step_x10); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - if (next == id(active_climate_target_x10) && id(active_climate_target_known)) return; - id(active_climate_target_x10) = next; - id(active_climate_target_known) = true; - id(climate_card_refresh).execute(); - id(climate_target_send).execute(id(active_entity), next / 10.0f); - - # One set_temperature for a series of taps: every tap restarts the wait with the newest value. - # Entity and value travel along, so leaving the card right after a tap still sends it. - - id: climate_target_send - mode: restart - parameters: - entity: string - value: float - then: - - delay: 700ms - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !entity.empty();' - then: - - homeassistant.action: - action: climate.set_temperature - data: - entity_id: !lambda "return entity;" - temperature: !lambda "return value;" - - # Draws the climate card from the active_climate_* globals: status line, setpoint, -/+ and mode keys. - - 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"}}; - // The Guition shows fan and swing on their own card below, so no "more" key. - bool more = false; - 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. - 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); - - // Fan and swing rows, in the entity's own names and order. - climate_card::Row settings[2]; - int rows = 0; - if (!cyd::list_item(id(active_climate_fan_modes), 0).empty()) - settings[rows++] = {'f', "\U000F0210", id(active_climate_fan_modes), id(active_climate_fan_mode)}; - if (!cyd::list_item(id(active_climate_swing_modes), 0).empty()) - settings[rows++] = {'s', "\U000F1C91", id(active_climate_swing_modes), id(active_climate_swing_mode)}; - // One layout per card: the setpoint gives room to the mode keys and the settings below it, - // and a card with nothing below keeps the setpoint centred. - static int laid_out = -1; - if (laid_out != rows * 2 + show_keys) { - laid_out = rows * 2 + show_keys; - struct Layout { int status, card_y, card_h, key, number_y, caption_y, modes_y, modes_h, settings_y, edge, row_h, gap; }; - static const Layout layouts[6] = { - // no mode keys: 0, 1, 2 rows - {92, 180, 200, 116, -14, 46, 0, 0, 0, 14, 48, 10}, - {92, 140, 200, 116, -14, 46, 0, 0, 364, 14, 48, 10}, - {90, 130, 180, 108, -12, 42, 0, 0, 330, 14, 46, 10}, - // mode keys: 0, 1, 2 rows - {92, 136, 200, 116, -14, 46, 364, 76, 0, 14, 48, 10}, - {90, 126, 170, 108, -12, 42, 312, 68, 394, 12, 44, 8}, - {86, 118, 140, 96, -10, 36, 270, 60, 342, 12, 42, 8}, - }; - const Layout &l = layouts[(show_keys ? 3 : 0) + rows]; - lv_obj_set_y(id(climate_current_label), l.status); - lv_obj_set_y(id(climate_setpoint_card), l.card_y); - lv_obj_set_height(id(climate_setpoint_card), l.card_h); - for (lv_obj_t *button : {id(climate_minus_button), id(climate_plus_button)}) { - lv_obj_set_size(button, l.key, l.key); - lv_obj_set_style_radius(button, l.key / 2, LV_PART_MAIN); - } - lv_obj_set_y(id(climate_target_label), l.number_y); - lv_obj_set_y(id(climate_set_label), l.caption_y); - if (show_keys) { - lv_obj_set_y(id(climate_mode_keys), l.modes_y); - lv_obj_set_height(id(climate_mode_keys), l.modes_h); - for (lv_obj_t *mode_key : {id(cmkey_auto), id(cmkey_heat_cool), id(cmkey_heat), id(cmkey_cool), id(cmkey_dry), id(cmkey_fan_only), id(cmkey_more)}) { - lv_obj_set_height(mode_key, l.modes_h); - lv_obj_set_style_radius(mode_key, l.modes_h / 2, LV_PART_MAIN); - } - } - lv_obj_set_y(id(climate_settings_card), l.settings_y); - climate_card::look.edge = l.edge; - climate_card::look.row_h = l.row_h; - climate_card::look.gap = l.gap; - } - climate_card::show(settings, rows); - id(climate_power_refresh).execute(); - - # Opens the mode picker. Hides modes the entity doesn't support and marks - # the current one, so you see where you stand before you pick something. - # Opens the vacuum controls and remembers which entity it concerns. - - # A standalone command: start, pause, stop or return_to_base. - - # Suction power is a separate axis, apart from start/stop. - - - id: open_climate_mode_picker - mode: restart - 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 supported = lower(id(active_climate_supported_modes)); - std::string current = lower(id(active_climate_mode)); - - auto has = [&](const char* m) { - std::string mode(m); - if (supported.empty() || supported == "unknown" || supported == "unavailable") return false; - return supported.find("'" + mode + "'") != std::string::npos || - supported.find("\"" + mode + "\"") != std::string::npos; - }; - auto apply = [&](lv_obj_t* btn, const char* m) { - if (has(m)) lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); - bool selected = (current == std::string(m)); - lv_obj_set_style_bg_color(btn, theme::color(selected ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); - lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), theme::color(selected ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); - }; - apply(id(cmode_off), "off"); - apply(id(cmode_heat), "heat"); - apply(id(cmode_cool), "cool"); - apply(id(cmode_heat_cool), "heat_cool"); - apply(id(cmode_auto), "auto"); - apply(id(cmode_dry), "dry"); - apply(id(cmode_fan_only), "fan_only"); - // Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them. - auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; }; - auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string ¤t_mode) { - unsigned shown = 0; - for (unsigned i = 0; i < 4; ++i) { - std::string raw = cyd::list_item(modes, i); - if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; } - lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown; - lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str()); - lv_obj_set_style_bg_color(chips[i], theme::color(raw == current_mode ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN); - lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), theme::color(raw == current_mode ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN); - } - if (shown) lv_obj_clear_flag(title, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); - }; - lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)}; - lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)}; - fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode)); - fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode)); - - lvgl.widget.show: climate_mode_overlay - - # Sets exactly one mode. No intermediate steps, so no unwanted - # commands to the heat pump along the way. - # Fan and swing modes from the picker: one call with the entity's own mode name. - - id: set_climate_fan_mode - mode: restart - parameters: - index: int - then: - - lambda: |- - id(active_climate_fan_mode) = cyd::list_item(id(active_climate_fan_modes), index); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - id(climate_card_refresh).execute(); - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_fan_mode).empty();' - then: - - homeassistant.action: - action: climate.set_fan_mode - data: - entity_id: !lambda "return id(active_entity);" - fan_mode: !lambda "return id(active_climate_fan_mode);" - - id: set_climate_swing_mode - mode: restart - parameters: - index: int - then: - - lambda: |- - id(active_climate_swing_mode) = cyd::list_item(id(active_climate_swing_modes), index); - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - id(climate_card_refresh).execute(); - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_swing_mode).empty();' - then: - - homeassistant.action: - action: climate.set_swing_mode - data: - entity_id: !lambda "return id(active_entity);" - swing_mode: !lambda "return id(active_climate_swing_mode);" - - # Quick on/off in the climate card; climate.turn_on restores the last mode in Home Assistant. - - id: climate_power_toggle - mode: restart - then: - - lambda: |- - std::string mode = id(active_climate_mode); - for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); - id(active_climate_power_on) = mode == "off" || mode.empty() || mode == "unknown"; - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - if (!id(active_climate_power_on)) id(active_climate_mode) = "off"; - id(climate_card_refresh).execute(); - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_climate_power_on);' - then: - - homeassistant.action: - action: climate.turn_on - data: - entity_id: !lambda "return id(active_entity);" - - if: - condition: - lambda: 'return runtime_tiles::fresh() && !id(active_climate_power_on);' - then: - - homeassistant.action: - action: climate.turn_off - data: - entity_id: !lambda "return id(active_entity);" - - # The power key lights up while the device runs in any mode. - - id: climate_power_refresh - mode: restart - then: - - lambda: |- - std::string mode = id(active_climate_mode); - for (char &c : mode) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); - bool on = !(mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"); - if (id(climate_edit_pending)) on = id(active_climate_power_on); - lv_obj_set_style_bg_color(id(climate_power_button), theme::color(on ? theme::ACCENT_TINT : theme::KEY), LV_PART_MAIN); - lv_obj_set_style_text_color(id(climate_power_icon), theme::color(on ? theme::ACCENT_ICON : theme::ICON_OFF), LV_PART_MAIN); - - - id: set_climate_mode - mode: restart - parameters: - mode: string - then: - - lambda: |- - id(climate_edit_ms) = millis(); - id(climate_edit_pending) = true; - id(active_climate_mode) = mode; - // The action line would still say what the old mode did. - id(active_climate_action).clear(); - id(active_climate_power_on) = mode != "off"; - id(climate_card_refresh).execute(); - - lvgl.widget.hide: climate_mode_overlay - - if: - condition: - lambda: 'return runtime_tiles::fresh();' - then: - - homeassistant.action: - action: climate.set_hvac_mode - data: - entity_id: !lambda "return id(active_entity);" - hvac_mode: !lambda "return id(active_climate_mode);" - - # Isolated 40-second preview; every light commit is suppressed in the helper. - - id: light_controls_preview - mode: single - then: - - script.execute: wake_display - - lambda: |- - light_controls::demo = true; - light_controls::fallback.minimum = 2000; - light_controls::fallback.maximum = 6500; - light_controls::fallback.kelvin = 3000; - light_controls::fallback.hue = 200; - light_controls::open("diagnostic", true, true, 75); - lv_label_set_text(id(color_detail_title), "Example lamp"); - - lvgl.widget.hide: color_close_button - - lvgl.widget.show: color_detail_overlay - - delay: 40s - - lvgl.widget.hide: color_detail_overlay - - lvgl.widget.show: color_close_button - - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' - - - id: open_color_detail - mode: restart - then: - - lambda: |- - lv_label_set_text(id(color_detail_title), id(active_title).c_str()); - light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); - if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN); - - lvgl.widget.hide: brightness_overlay - - lvgl.widget.show: color_detail_overlay - - - id: color_brightness_commit - parameters: - value: int - then: - - lambda: 'id(active_brightness_pct) = value;' - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_entity).rfind("light.", 0) == 0;' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - brightness_pct: !lambda "return value;" - - - id: set_light_hue - parameters: - value: int - then: - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - data_template: - hs_color: "{{ [hue | int, 100] }}" - variables: - hue: !lambda "return light_controls::clamp(value, 0, 360);" - - - id: set_light_color_temperature - parameters: - kelvin: int - then: - - if: - condition: - lambda: 'return runtime_tiles::fresh() && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" - - - id: slider_preview - parameters: - value: int - then: - - lambda: |- - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", value, id(active_value_suffix).c_str()); - lv_label_set_text(id(overlay_value), buf); - int pct = value; - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - std::string value_text; - - if (id(active_control_kind) == "cover_position") { - std::string closed_label = id(active_label_off).c_str(); - std::string open_label = id(active_label_on).c_str(); - - if (pct <= 0) { - value_text = closed_label; - } else if (pct >= 100) { - value_text = open_label; - } else { - char buf[40]; - snprintf(buf, sizeof(buf), "%s - %d%%", open_label.c_str(), pct); - value_text = buf; - } - } else { - if (pct <= 0) { - value_text = id(active_label_off).c_str(); - } else { - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); - value_text = buf; - } - } - - lv_label_set_text(id(overlay_value), value_text.c_str()); - - - - id: slider_commit - parameters: - value: int - then: - - if: - condition: - lambda: |- - return id(active_control_kind) == "light_brightness"; - then: - - homeassistant.action: - action: light.turn_on - data: - entity_id: !lambda "return id(active_entity);" - brightness_pct: !lambda "return value;" - else: - - if: - condition: - lambda: |- - return id(active_control_kind) == "fan_percentage"; - then: - - if: - condition: - lambda: "return value <= 0;" - then: - - homeassistant.action: - action: fan.turn_off - data: - entity_id: !lambda "return id(active_entity);" - else: - - homeassistant.action: - action: fan.turn_on - data: - entity_id: !lambda "return id(active_entity);" - - delay: 150ms - - homeassistant.action: - action: fan.set_percentage - data: - entity_id: !lambda "return id(active_entity);" - percentage: !lambda "return value;" - else: - - if: - condition: - lambda: |- - return id(active_control_kind) == "cover_position"; - then: - - homeassistant.action: - action: cover.set_cover_position - data: - entity_id: !lambda "return id(active_entity);" - position: !lambda "return value;" - - # ---------------------------------------------------------- - # do_tile_action - # - # Centralized handler for executing Home Assistant actions - # triggered by tile taps. - # - # Parameters - # - tile_type: light | fan | switch | scene | script | cover - # - tap_action: auto | toggle | activate | fan_toggle_preset - # - service: optional Home Assistant service override - # - entity_id: target entity - # - param_key / param_val: optional parameters for custom services - # - was_on: previous entity state (pass false for longpress actions) - # - # Behavior - # - # toggle - # -> toggles the entity - # light.toggle / fan.toggle / switch.toggle / cover.open_cover|close_cover (based on was_on) - # - # activate - # -> activates entity - # scene.turn_on / script.turn_on / light.turn_on / fan.turn_on / cover.open_cover - # - # auto - # -> resolves automatically based on tile_type: - # light/switch/fan/cover -> toggle - # scene/script -> activate - # - # fan_toggle_preset - # -> if fan was ON: fan.turn_off - # -> if fan was OFF: - # fan.turn_on - # wait 150ms - # fan.set_preset_mode(param_val) - # - # Custom service - # If "service" is set, it will be called directly. - # param_key / param_val may be used for parameters - # like preset_mode, brightness_pct, percentage, etc. - # ---------------------------------------------------------- - - - id: open_value_overlay - parameters: - title: string - entity: string - tile_type: string - longpress_mode: string - was_on: bool - off_value: int - brightness_value: float - percentage_value: float - position_value: float - icon_text: string - label_off: string - label_on: string - supported_color_modes: string - climate_current_temperature: float - climate_target_temperature: float - climate_humidity: float - climate_mode: string - climate_supported_modes: string - climate_min_temp: string - climate_max_temp: string - climate_step: string - then: - - lambda: |- - std::string mode = longpress_mode; - auto contains_mode = [](const std::string& modes, const std::string& needle) { - return modes.find(needle) != std::string::npos; - }; - if (mode == "auto") { - if (tile_type == "light") mode = "brightness"; - else if (tile_type == "fan") mode = "percentage"; - else if (tile_type == "cover") mode = "cover_position"; - else if (tile_type == "climate") mode = "climate_temperature"; - else mode = "none"; - } - - if (mode == "none") return; - - id(active_entity) = entity; - id(active_value_suffix) = " %"; - id(active_label_off) = label_off; - id(active_label_on) = label_on; - id(active_title) = title; - id(active_icon_text) = icon_text; - id(active_supports_color) = false; - id(active_supports_temperature) = false; - lv_obj_add_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); - - if (mode == "climate_temperature") { - auto to_x10 = [](float value, int fallback) -> int { - if (isnan(value)) return fallback; - return (int) lround(value * 10.0f); - }; - auto parse_x10 = [](const std::string& value, int fallback) -> int { - if (value.empty()) return fallback; - return (int) lround(atof(value.c_str()) * 10.0f); - }; - id(climate_edit_pending) = false; - id(active_control_kind) = "climate_temperature"; - id(active_climate_min_x10) = parse_x10(climate_min_temp, 50); - id(active_climate_max_x10) = parse_x10(climate_max_temp, 300); - id(active_climate_step_x10) = parse_x10(climate_step, 5); - if (id(active_climate_step_x10) < 5) id(active_climate_step_x10) = 5; - if (id(active_climate_max_x10) <= id(active_climate_min_x10)) { - id(active_climate_min_x10) = 50; - id(active_climate_max_x10) = 300; - } - - id(active_climate_current_x10) = to_x10(climate_current_temperature, 0); - id(active_climate_current) = climate_current_temperature; - id(active_climate_target_known) = !isnan(climate_target_temperature); - int fallback_target = isnan(climate_current_temperature) ? 200 : id(active_climate_current_x10); - id(active_climate_target_x10) = to_x10(climate_target_temperature, fallback_target); - if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); - if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); - id(active_climate_target_x10) = id(active_climate_min_x10) + (int) lround((id(active_climate_target_x10) - id(active_climate_min_x10)) / (float) id(active_climate_step_x10)) * id(active_climate_step_x10); - if (id(active_climate_target_x10) < id(active_climate_min_x10)) id(active_climate_target_x10) = id(active_climate_min_x10); - if (id(active_climate_target_x10) > id(active_climate_max_x10)) id(active_climate_target_x10) = id(active_climate_max_x10); - id(active_climate_humidity) = isnan(climate_humidity) ? -1 : (int) lround(climate_humidity); - id(active_climate_mode) = climate_mode.empty() ? std::string("Auto") : climate_mode; - id(active_climate_supported_modes) = climate_supported_modes; - - lv_label_set_text(id(climate_detail_title), title.c_str()); - id(climate_card_refresh).execute(); - - lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN); - return; - } - - int pct = off_value; - - if (mode == "brightness") { - id(active_control_kind) = "light_brightness"; - if (was_on) { - if (isnan(brightness_value)) pct = 100; - else pct = (int) lround((brightness_value / 255.0f) * 100.0f); - } - } else if (mode == "percentage") { - id(active_control_kind) = "fan_percentage"; - if (was_on) { - if (isnan(percentage_value)) pct = 0; - else pct = (int) lround(percentage_value); - } - } else if (mode == "cover_position") { - id(active_control_kind) = "cover_position"; - if (isnan(position_value)) pct = was_on ? 100 : 0; - else pct = (int) lround(position_value); - } else { - return; - } - - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - id(active_brightness_pct) = pct; - - if (tile_type == "light") { - id(active_supports_color) = - contains_mode(supported_color_modes, "hs") || - contains_mode(supported_color_modes, "rgb") || - contains_mode(supported_color_modes, "rgbw") || - contains_mode(supported_color_modes, "rgbww") || - contains_mode(supported_color_modes, "xy"); - id(active_supports_temperature) = contains_mode(supported_color_modes, "color_temp"); - - if (id(active_supports_color) || id(active_supports_temperature)) { - lv_obj_clear_flag(id(overlay_color_button), LV_OBJ_FLAG_HIDDEN); - } - } - - // Track and fill are the pale blue and blue paints of overlay_slider. - lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_radius(id(overlay_slider), 28, LV_PART_MAIN); - lv_obj_set_style_radius(id(overlay_slider), 0, LV_PART_INDICATOR); - - lv_label_set_text(id(overlay_title), title.c_str()); - lv_label_set_text(id(overlay_icon), icon_text.c_str()); - lv_slider_set_value(id(overlay_slider), pct, LV_ANIM_OFF); - - std::string value_text; - - if (tile_type == "cover") { - if (pct <= 0) { - value_text = label_off; - } else if (pct >= 100) { - value_text = label_on; - } else { - char buf[40]; - snprintf(buf, sizeof(buf), "%s - %d%%", label_on.c_str(), pct); - value_text = buf; - } - } else { - if (pct <= 0) { - value_text = label_off; - } else { - char buf[20]; - snprintf(buf, sizeof(buf), "%d%s", pct, id(active_value_suffix).c_str()); - value_text = buf; - } - } - - lv_label_set_text(id(overlay_value), value_text.c_str()); - - lv_obj_set_style_bg_opa(id(overlay_slider), LV_OPA_COVER, LV_PART_INDICATOR); - - - if: - condition: - lambda: 'return id(active_control_kind) != "climate_temperature";' - then: - - if: - condition: - lambda: 'return id(active_supports_color) || id(active_supports_temperature);' - then: - - script.execute: open_color_detail - else: - - lvgl.widget.show: brightness_overlay - - # UI refresh (values + time + styles) + render - - id: ui_refresh - mode: single - then: - - delay: 100ms - - lambda: |- - if (runtime_tiles::enabled) { - // The name and the top bar (clock included) draw together. - runtime_tiles::render(id(lbl_room)); - return; - } - -# HA blueprint transport; never publishes the JSON command as an HA state. -text: - - platform: smart_display - id: dashboard_inbox - name: "Tile settings" - internal: ${DASHBOARD_INBOX_INTERNAL} - -button: - # Wake and Sleep for Home Assistant automations (0.2.45+). Wake is light, not a tap (0.2.56+): it - # lights a dimmed screen and counts the standby time from now, while Back to page 1 keeps counting - # from the last touch, so Wake on every motion no longer holds a card or page 2 up. Sleep is the - # standby time running out, right away and also with Auto standby off; it holds until a tap, Wake - # or an alert, and closes an alert that is up. Neither saves anything, so an automation may press - # them on every motion. No entity_category: they are controls, not settings. - - platform: template - id: wake_button - name: "Wake" - icon: "mdi:gesture-tap" - on_press: - - lambda: 'ESP_LOGI("standby", "Wake from Home Assistant, screen %s", id(display_dimmed) ? "in standby" : "on");' - - if: - condition: - lambda: 'return id(display_dimmed);' - then: - - script.execute: wake_display - # A page whose time ran out during standby: light up on page 1, not on the old page first. - - script.execute: back_to_page_1_when_due - else: - - lambda: 'id(last_touch_ms) = millis();' - - platform: template - id: sleep_button - name: "Sleep" - icon: "mdi:power-sleep" - on_press: - - if: - condition: - lambda: 'return id(calibration_active);' - then: - - lambda: 'ESP_LOGI("standby", "Sleep from Home Assistant ignored: touch calibration is running");' - else: - - lambda: |- - ESP_LOGI("standby", "Sleep from Home Assistant"); - id(sleep_requested) = true; - - script.execute: - id: alert_dismiss - reason: "remote" - - script.execute: dim_display - -number: - # Firmware 0.2.49+: every number goes through settings_screen::set(), like the settings page, so a value the - # screen already has writes and reports nothing (an automation may set it on every light change). - - platform: template - id: setting_brightness - name: "Normal brightness" - entity_category: config - min_value: 5 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.brightness; - set_action: - - lambda: 'settings_screen::set("brightness", (int32_t) lround(x));' - - platform: template - id: setting_standby_brightness - name: "Standby brightness" - entity_category: config - min_value: 0 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.standby_brightness; - set_action: - - lambda: 'settings_screen::set("standby_brightness", (int32_t) lround(x));' - - platform: template - id: setting_night_brightness - name: "Night brightness" - entity_category: config - min_value: 0 - max_value: 100 - step: 1 - unit_of_measurement: "%" - update_interval: never - lambda: return screen_settings::current.night_brightness; - set_action: - - lambda: 'settings_screen::set("night_brightness", (int32_t) lround(x));' - - platform: template - id: setting_standby_seconds - name: "Standby after" - entity_category: config - min_value: 60 - max_value: 86400 - step: 60 - unit_of_measurement: "s" - update_interval: never - lambda: return screen_settings::current.standby_seconds; - set_action: - - lambda: 'settings_screen::set("standby_seconds", (int32_t) lround(x));' - - platform: template - id: setting_auto_home_seconds - name: "Back to page 1 after" - icon: "mdi:timer-outline" - entity_category: config - min_value: 30 - max_value: 3600 - step: 30 - mode: box - unit_of_measurement: "s" - update_interval: never - lambda: return settings_screen::auto_home_seconds; - set_action: - - lambda: 'settings_screen::set("auto_home_seconds", (int32_t) lround(x));' diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index d9d05c6..1c8d600 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -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) A lamp's modes on the screen: effects, palettes and presets of a WLED, and the effect of any light that has one. diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 14fa741..bf96b61 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.83" +version: "0.2.84" slug: esp_screen_manager description: Choose tiles for your CYD and Guition touchscreen, without reflashing. url: https://github.com/MaxGramser/homeassistant_espscreen diff --git a/tests/test_alert.py b/tests/test_alert.py index 5ede3f3..62c4af9 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -6,6 +6,8 @@ import unittest from pathlib import Path 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')) import tile_icons # noqa: E402 @@ -37,7 +39,7 @@ def line_height(font, size): class AlertTests(unittest.TestCase): def sources(self): 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): for name, text in self.sources(): @@ -75,7 +77,8 @@ class AlertTests(unittest.TestCase): title = section(top, 'id: alert_title\n', 'id: alert_subtitle') self.assertIn('height: ${ALERT_TITLE_H}\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]) 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) @@ -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.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_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.hide: alert_overlay', self_test, name) 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): header = ' - \n' for board, name in PROFILES.items(): - self.assertIn(header, (ROOT / name).read_text(), name) - self.assertIn(header, (ROOT / PACKAGES[board]).read_text(), board) + self.assertIn(header, profiles.text(name), name) + self.assertIn(header, profiles.text(PACKAGES[board]), board) if __name__ == '__main__': unittest.main() diff --git a/tests/test_alerts_reference.py b/tests/test_alerts_reference.py index 97d6c69..7c0bac2 100644 --- a/tests/test_alerts_reference.py +++ b/tests/test_alerts_reference.py @@ -8,6 +8,8 @@ import unittest from pathlib import Path 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 / 'tests')) import tile_icons # noqa: E402 @@ -22,7 +24,7 @@ class ReferenceTests(unittest.TestCase): def test_fields_and_limits_match_both_board_profiles(self): reference = alert_reference() 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] 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')): diff --git a/tests/test_binary_words.py b/tests/test_binary_words.py index 4b96ed5..6e4794f 100644 --- a/tests/test_binary_words.py +++ b/tests/test_binary_words.py @@ -10,6 +10,8 @@ import unittest from pathlib import Path 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')) import header_bar # 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())) self.assertLessEqual(letters, set(header_bar.GLYPHS)) for name in PROFILES: - text = (ROOT / name).read_text() + text = profiles.resolved(name) 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) self.assertIsNotNone(block, (name, font)) diff --git a/tests/test_camera.py b/tests/test_camera.py index 10115a7..eb2d432 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -10,6 +10,8 @@ import tempfile import unittest 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')) import camera_feed # 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: 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() @@ -87,7 +89,7 @@ class Rules(unittest.TestCase): self.assertEqual(PROFILE.count('if (url != current) {'), 2) # 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()) - cyd = (ROOT / 'home-like-2432s028.yaml').read_text() + cyd = profiles.text('home-like-2432s028.yaml') self.assertNotIn('online_image', 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. diff --git a/tests/test_cover_card.py b/tests/test_cover_card.py index cf5186b..b1fa46e 100644 --- a/tests/test_cover_card.py +++ b/tests/test_cover_card.py @@ -15,13 +15,15 @@ import unittest from pathlib import Path 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 / 'tests')) from core import ATTRS, cover_related, discover_screens, extras, state_message # noqa: E402 HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None 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' diff --git a/tests/test_easy_package.py b/tests/test_easy_package.py index 7d789f2..ec35bfc 100644 --- a/tests/test_easy_package.py +++ b/tests/test_easy_package.py @@ -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 re +import subprocess +import sys import unittest from pathlib import Path 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'} # 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. @@ -14,49 +19,78 @@ GONE = ['ha_state_tile1', 'tile1_brightness', 'tile6_climate_humidity', 'tile6_c 'light_controls::subscribe'] -def load_generator(): - spec = importlib.util.spec_from_file_location('generate_packages', ROOT / 'tools/generate_packages.py') +def load_checker(): + spec = importlib.util.spec_from_file_location('check_packages', ROOT / 'tools/check_packages.py') module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module class PackageTests(unittest.TestCase): - def test_packages_are_current(self): - generator = load_generator() - for board in BOARDS: - self.assertEqual((ROOT / 'packages' / f'{board}.yaml').read_text(), generator.generate(board), board) + def test_the_packages_fit_together(self): + result = subprocess.run([sys.executable, str(ROOT / 'tools/check_packages.py')], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - 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: package = (ROOT / 'packages' / f'{board}.yaml').read_text() self.assertNotIn('!secret', package) self.assertNotIn('type: local', 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): for board, name in BOARDS.items(): - for path in (ROOT / name, ROOT / 'packages' / f'{board}.yaml'): - text = '\n'.join(line for line in path.read_text().split('\n') if not line.lstrip().startswith('#')) + for entry in (name, f'packages/{board}.yaml'): + text = '\n'.join(line for line in profiles.text(entry).split('\n') if not line.lstrip().startswith('#')) 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): 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', 'id: ui_refresh', 'runtime_tiles::render(id(lbl_room));', 'id: climate_detail_overlay', 'id: color_detail_overlay']: self.assertIn(key, package, board) # 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): - generator = load_generator() - text = (ROOT / BOARDS['cyd']).read_text() - stray = ' - platform: homeassistant\n id: stray\n entity_id: sensor.stray\n\nbinary_sensor:\n' - with self.assertRaises(ValueError): - generator.generate('cyd', text.replace('binary_sensor:\n', stray, 1)) + def test_the_core_carries_no_board_number_and_every_board_the_same_names(self): + core = profiles.CORE.read_text() + for needle in ('esp32:', 'platform: xpt2046', 'platform: gt911', 'platform: st7701s', 'platform: mipi_spi', 'psram:', + 'set_raw_correction', 'camera_image', 'GPIO'): + self.assertNotIn(needle, core, f'{needle} is a board\'s, not the core\'s') + 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__': diff --git a/tests/test_guition.py b/tests/test_guition.py index 8f84224..d1d720d 100644 --- a/tests/test_guition.py +++ b/tests/test_guition.py @@ -4,9 +4,11 @@ import re import sys import unittest ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import profiles # noqa: E402 sys.path.insert(0,str(ROOT/'tools')) 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)) class GuitionTests(unittest.TestCase): @@ -54,7 +56,7 @@ class GuitionTests(unittest.TestCase): def test_all_tiles_clip_long_titles(self): 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') def test_gt911_verification_accepts_pixels_and_rejects_wrong_orientation(self): diff --git a/tests/test_header_bar.py b/tests/test_header_bar.py index 35bb6f1..3a2aa5b 100644 --- a/tests/test_header_bar.py +++ b/tests/test_header_bar.py @@ -10,6 +10,8 @@ from pathlib import Path from zoneinfo import ZoneInfo 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')) import header_bar # noqa: E402 import tile_icons # noqa: E402 @@ -191,7 +193,7 @@ class MessageTests(unittest.TestCase): class ParityTests(unittest.TestCase): def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self): 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) self.assertIsNotNone(block, name) 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): 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_icon_font = id(materialdesign_icons_mini)->get_lv_font();', 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): 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) 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) 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) diff --git a/tests/test_history_card.py b/tests/test_history_card.py index b6772e0..79899c7 100644 --- a/tests/test_history_card.py +++ b/tests/test_history_card.py @@ -15,6 +15,8 @@ from pathlib import Path from zoneinfo import ZoneInfo 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 / 'tests')) 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);'): self.assertIn(flag, touch) 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) diff --git a/tests/test_layer_free.py b/tests/test_layer_free.py index f7629af..4b8907a 100644 --- a/tests/test_layer_free.py +++ b/tests/test_layer_free.py @@ -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. """ import re +import sys import unittest from pathlib import Path 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'] 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): def test_board_profiles_use_no_layer_styles(self): for name in PROFILES: - text = (ROOT / name).read_text() + text = profiles.text(name) self.assertEqual(YAML_LAYER_STYLES.findall(text), [], 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): 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): block, indent = match.group(2), match.group(1) + ' ' track = re.search(r'^' + indent + r'radius: (\S+)', block, re.M) diff --git a/tests/test_layout.py b/tests/test_layout.py index 58653eb..c99e721 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -1,10 +1,13 @@ """Regression checks for the 320x240 page geometry and event guards.""" from pathlib import Path import re +import sys import unittest 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)) 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): """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'): - source = (ROOT / name).read_text() + source = profiles.resolved(name) values = dict(re.findall(r'^ (\w+): "([^"]*)"', source, re.M)) band = int(values['DISPLAY_H']) - int(values['SCROLL_Y']) - int(values['SCROLL_H']) half = int(values['DISPLAY_W']) // 2 diff --git a/tests/test_off_look.py b/tests/test_off_look.py index a3854da..2d85d87 100644 --- a/tests/test_off_look.py +++ b/tests/test_off_look.py @@ -8,6 +8,8 @@ import unittest from pathlib import Path 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')) import header_bar # 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): self.assertEqual(tile_icons.GLYPHS['lightbulb-off'], 'F0E4F') 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): self.assertIn('if (d == "light" && tile.state == "off") return "\\U000F0E4F";', TILES) diff --git a/tests/test_quiet_resync.py b/tests/test_quiet_resync.py index 29a3d95..24c8f1b 100644 --- a/tests/test_quiet_resync.py +++ b/tests/test_quiet_resync.py @@ -14,6 +14,8 @@ import unittest from pathlib import Path 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 / 'tests')) from core import validate_settings # noqa: E402 @@ -80,7 +82,7 @@ class SettingEvents(unittest.IsolatedAsyncioTestCase): class Firmware(unittest.TestCase): 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): # Firmware 0.2.49+: the setting entities change a value through settings_screen::set(), which stores, diff --git a/tests/test_release_lint.py b/tests/test_release_lint.py index e44ee76..2f86b60 100644 --- a/tests/test_release_lint.py +++ b/tests/test_release_lint.py @@ -9,6 +9,8 @@ import sys import unittest 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')) 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. APP_HEADING = re.compile(r'^## (\d+)\.(\d+)\.(\d+)\b(.*)$', re.M) FIRMWARE_IN_HEADING = re.compile(r'\(firmware (\d+\.\d+\.\d+)\)') -# The generator (tools/generate_packages.py) turns fonts/ into a raw GitHub URL on main. +# The published entries (packages/.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]+)') @@ -56,13 +58,12 @@ class ReleaseVersionTests(unittest.TestCase): class PackageFontTests(unittest.TestCase): 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 - for package in packages: - for path in FONT_URL.findall(package.read_text()): + for package in profiles.PACKAGES: + # 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 - 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. self.assertGreater(seen, 0, 'no fonts/... URL found in packages/*.yaml') diff --git a/tests/test_screen_owned_settings.py b/tests/test_screen_owned_settings.py index 28cf90d..39fdac3 100644 --- a/tests/test_screen_owned_settings.py +++ b/tests/test_screen_owned_settings.py @@ -21,6 +21,8 @@ from pathlib import Path from unittest.mock import patch 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 / 'tests')) 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 server import ANSWER_RETRY_SECONDS, HomeAssistant, Manager, Refused, create_app -PROFILES = {'cyd': ROOT / 'home-like-2432s028.yaml', 'guition': ROOT / 'guition-4848s040.yaml'} -PACKAGES = {'cyd': ROOT / 'packages/cyd.yaml', 'guition': ROOT / 'packages/guition.yaml'} +PROFILES = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'} +PACKAGES = {'cyd': 'packages/cyd.yaml', 'guition': 'packages/guition.yaml'} RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() SCREEN_PAGE = (ROOT / 'components/smart_display/settings_screen.h').read_text() 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): blocks = {'switch': 'switch', 'number': 'number', 'time': 'datetime', 'select': 'select'} for board, path in [*PROFILES.items(), *PACKAGES.items()]: - text = path.read_text() + text = profiles.text(path) for key, (domain, name) in SETTING_ENTITIES.items(): block = top_block(text, blocks[domain]) 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 - 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}"') entry = block[max(0, at - 200):at + 900] - self.assertIn('entity_category: config', entry, f'{path.name}: {key}') - self.assertRegex(entry, rf'settings_screen::set\("{key}"', f'{path.name}: {key} changes through set()') - self.assertIn('type: time', top_block(text, 'datetime'), path.name) + self.assertIn('entity_category: config', entry, f'{path}: {key}') + self.assertRegex(entry, rf'settings_screen::set\("{key}"', f'{path}: {key} changes through set()') + self.assertIn('type: time', top_block(text, 'datetime'), path) 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'}, @@ -590,15 +592,15 @@ class Editor(unittest.TestCase): class Firmware(unittest.TestCase): def test_the_message_action_answers_a_caller_that_asks(self): for path in [*PROFILES.values(), *PACKAGES.values()]: - text = path.read_text() + text = profiles.text(path) 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). action = action[:re.search(r'\n(?: [a-z_]+:|[a-z_]+:)', action).start()] - self.assertIn('supports_response: optional', action, path.name) - self.assertIn("lambda: 'return call_id != 0;'", action, f'{path.name}: no answer, no warning, for a caller without a call id') - self.assertIn('api.respond:', action, path.name) - self.assertIn('root["status"] = id(dashboard_inbox).state;', action, path.name) - self.assertIn('root["rev"] = runtime_tiles::layout_rev;', action, path.name) + self.assertIn('supports_response: optional', action, path) + 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) + self.assertIn('root["status"] = id(dashboard_inbox).state;', action, path) + self.assertIn('root["rev"] = runtime_tiles::layout_rev;', action, path) def test_the_settings_tile_opens_without_home_assistant(self): 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): for board, path in PROFILES.items(): - script = path.read_text() - script = script[script.index('- id: apply_screen_settings'):] + text = profiles.text(path) + script = text[text.index('- id: apply_screen_settings'):] script = script[:script.index('\n - id: ', 10)] for entity in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds', 'setting_auto_home_seconds'): # 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('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, path.name) - self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path.name) - self.assertIn('settings_screen::refresh();', script, f'{path.name}: an open settings page follows Home Assistant') - self.assertEqual('id(setting_rotation)->update();' in script, board == 'guition', path.name) + 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) + self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path) + self.assertIn('settings_screen::refresh();', script, f'{path}: an open settings page follows Home Assistant') + # 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): self.assertRegex(SCREEN_PAGE, r'enum class SetResult : uint8_t \{ unknown, same, changed \};') for path in PROFILES.values(): - text = path.read_text() - self.assertNotIn('settings_preference.save(&s)', text, f'{path.name}: saving goes through settings_screen::set()') + text = profiles.text(path) + 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') diff --git a/tests/test_settings_page.py b/tests/test_settings_page.py index 34f31cf..801ee00 100644 --- a/tests/test_settings_page.py +++ b/tests/test_settings_page.py @@ -13,6 +13,8 @@ import unittest from pathlib import Path 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 / 'tests')) 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.""" 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')} def test_both_boards_wire_the_page_and_its_gesture(self): diff --git a/tests/test_theme.py b/tests/test_theme.py index d0e5d7d..16286eb 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -12,12 +12,14 @@ import unittest from pathlib import Path 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')) from core import DARK_MODE_MIN_FIRMWARE, FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_RULES, SETTINGS_BESIDE_BLOCK # noqa: E402 COMPONENT = ROOT / 'components/smart_display' 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') # 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. @@ -46,14 +48,14 @@ class OnePlace(unittest.TestCase): def test_no_profile_writes_a_colour_of_its_own(self): 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) - self.assertEqual(set(wheel), COLOUR_WHEEL, path.name) + self.assertEqual(set(wheel), COLOUR_WHEEL, path) 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('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): 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): paints = paint_names() for path in PROFILES: - text = path.read_text() + text = profiles.merged(path) defined = re.findall(r'^ - id: (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] @@ -75,33 +77,33 @@ class Paints(unittest.TestCase): 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])] adopted = dict(explicit + looped) - self.assertEqual(len(adopted), len(explicit) + len(looped), f'{path.name}: 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(set(defined), used, f'{path.name}: every paint style is used, every used one defined') + 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}: styles are filled in theme::paints only') + 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', 'style_value', 'style_value_big']: - self.assertIn(style, adopted, f'{path.name}: {style} takes its colours from theme.h') - self.assertIn(adopted[style], paints, f'{path.name}: {style}') + self.assertIn(style, adopted, f'{path}: {style} takes its colours from theme.h') + self.assertIn(adopted[style], paints, f'{path}: {style}') for style, paint in adopted.items(): 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. 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): 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] 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] - 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] for needle in ('runtime_tiles::restyle();', 'light_controls::restyle();', 'settings_screen::restyle();', 'theme::surface(id(alert_card_color))', 'id(climate_card_refresh).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('lv_color_hex(theme::surface(alert.color))', text) diff --git a/tests/test_tile_controls.py b/tests/test_tile_controls.py index d596fd5..42897c9 100644 --- a/tests/test_tile_controls.py +++ b/tests/test_tile_controls.py @@ -7,6 +7,8 @@ import tempfile import unittest 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 / 'tests')) 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.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'): - text = (ROOT / name).read_text() + text = profiles.text(name) for code in used: self.assertIn(f'"\\U000{code}"', text, (name, code)) self.assertIn('runtime_tiles::control_font = id(sublabel_big)->get_lv_font();', text, name) diff --git a/tests/test_tile_icons.py b/tests/test_tile_icons.py index 465a75d..4d1c810 100644 --- a/tests/test_tile_icons.py +++ b/tests/test_tile_icons.py @@ -5,6 +5,8 @@ import unittest from pathlib import Path 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 / 'tests')) import test_portal @@ -22,7 +24,7 @@ class IconSetTests(unittest.TestCase): self.assertRegex(code, r'^F[0-9A-F]{4}$') 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'): - 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) 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) @@ -43,7 +45,7 @@ class IconSetTests(unittest.TestCase): self.assertEqual(len(entries), len(tile_icons.GLYPHS)) 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)} self.assertLessEqual(used, set(tile_icons.GLYPHS.values())) diff --git a/tests/test_updates.py b/tests/test_updates.py index cd8f149..0b77413 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -11,6 +11,8 @@ import tempfile import unittest 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')) from core import FIRMWARE_VERSION, discover import firmware @@ -24,7 +26,7 @@ HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None class VersionSourceTests(unittest.TestCase): def test_app_target_matches_shipped_firmware(self): 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('name: "Device name"', text, name) self.assertIn('platform: wifi_info', text, name) diff --git a/tests/test_wake_sleep.py b/tests/test_wake_sleep.py index b8ae188..7129e16 100644 --- a/tests/test_wake_sleep.py +++ b/tests/test_wake_sleep.py @@ -12,6 +12,8 @@ import unittest from pathlib import Path 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')) 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): """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) - return match[1] if match else '' + # The core and the board file may both carry the section (app 0.2.84+). + return '\n'.join(re.findall(rf'^{top}:\n(.*?)(?=^[a-z_]+:|\Z)', text, re.M | re.S)) def item(text, key, value): """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): return chunk return '' @@ -38,7 +46,7 @@ def script(text, name): class Profiles(unittest.TestCase): 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): version = lambda text: tuple(int(part) for part in text.split('.')) diff --git a/tools/check.sh b/tools/check.sh index acbe231..c1b9f9d 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -2,7 +2,7 @@ # 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. # -# 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 # tools/check.sh --firmware compiles both board profiles and applies the CYD's flash budget # tools/check.sh --all both @@ -120,7 +120,7 @@ cpp_tests() { ((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; } editor_install() { cd "$ROOT/web" && npm ci --no-audit --no-fund; } 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 # 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 -# 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() { 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 # Placeholders for the check builds (tools/check.sh); nothing here is a real key. wifi_ssid: "check-wifi" @@ -254,7 +256,7 @@ if ((want_fast)); then run "Python packages" python_packages run "Python tests" python_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 "Editor: npm ci" editor_install if ((last_ok)); then diff --git a/tools/check_packages.py b/tools/check_packages.py new file mode 100644 index 0000000..fa7f6c0 --- /dev/null +++ b/tools/check_packages.py @@ -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/.yaml +for a screen that builds over GitHub, .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() diff --git a/tools/generate_icons.py b/tools/generate_icons.py index 001f213..a085735 100644 --- a/tools/generate_icons.py +++ b/tools/generate_icons.py @@ -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 of the same TTF so its mockup shows the glyphs the screen draws. The top bar mockup also gets Roboto 400/500 with the glyphs of header_bar.GLYPHS, so text -widths in the editor match the screen. Run tools/generate_packages.py afterwards; +widths in the editor match the screen. The fonts live in packages/core.yaml, shared by every board; --check changes nothing and fails when anything is out of date. """ import argparse @@ -24,12 +24,12 @@ import header_bar # noqa: E402 import tile_icons # noqa: E402 TTF = ROOT / 'fonts/materialdesignicons-webfont.ttf' -PROFILES = [ROOT / 'home-like-2432s028.yaml', ROOT / 'guition-4848s040.yaml'] +PROFILES = [ROOT / 'packages/core.yaml'] # the fonts every board shares (app 0.2.84+); the sizes are the boards' substitutions WEB_FONT = ROOT / 'web/src/assets/tile-icons.woff' BAR_FONTS = {weight: ROOT / f'web/src/assets/bar-roboto-{weight}.woff' for weight in (400, 500)} NAME_TABLE = ROOT / 'components/smart_display/tile_icon_names.h' # Four bits per pixel: the default of one draws a thin ring like mdi:power as a lumpy circle on the CYD. -FONT_BLOCK = re.compile(r"( - file: '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(): """Names and codepoints must match the TTF the firmware is built from.""" diff --git a/tools/generate_packages.py b/tools/generate_packages.py deleted file mode 100644 index edb8702..0000000 --- a/tools/generate_packages.py +++ /dev/null @@ -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() diff --git a/tools/profiles.py b/tools/profiles.py new file mode 100644 index 0000000..ac350a9 --- /dev/null +++ b/tools/profiles.py @@ -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)) diff --git a/tools/render_topbar.py b/tools/render_topbar.py index fea0a52..93be521 100644 --- a/tools/render_topbar.py +++ b/tools/render_topbar.py @@ -21,6 +21,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] WORK = ROOT / '.esphome' / 'render-topbar' PROFILES = {'guition': 'guition-4848s040.yaml', 'cyd': 'home-like-2432s028.yaml'} +sys.path.insert(0, str(ROOT / 'tools')) +import profiles # noqa: E402 FONTS = ('headline', 'time_label', 'sublabel_big', 'label', 'materialdesign_icons', 'materialdesign_icons_mini') # Hardware headers the host cannot compile; the top bar needs none of them. SKIP = {'guition_diagnostics.h', '__pycache__'} @@ -39,8 +41,8 @@ SCENARIOS = ( ) def font_blocks(board): - """The profile's font entries, ids suffixed per board and file paths made absolute.""" - text = (ROOT / PROFILES[board]).read_text() + """The shared core's font entries at this board's sizes, ids suffixed per board and file paths made absolute.""" + text = profiles.resolve(profiles.CORE.read_text(), profiles.substitutions(PROFILES[board])) fonts = text[text.index('\nfont:\n'):] out = '' for font_id in FONTS: @@ -48,7 +50,7 @@ def font_blocks(board): if not m: raise SystemExit(f'Font {font_id} not found in {PROFILES[board]}') block = m.group(0).replace(f'id: {font_id}\n', f'id: {font_id}_{board}\n') - block = re.sub(r'file: ["\']fonts/([^"\']+)["\']', lambda f: f'file: "{ROOT}/fonts/{f[1]}"', block) + block = re.sub(r'file: "fonts/([^"]+)"', lambda f: f'file: "{ROOT}/fonts/{f[1]}"', block) out += block.replace('&tile_icons', f'&tile_icons_{board}').replace('*tile_icons\n', f'*tile_icons_{board}\n') return out