Free memory on the CYD and drop the old manual profile (0.2.50)

The CYD restarted under load: a slider fill rounded less than its track made
LVGL draw the fill into a buffer of its own size on every redraw (15-20 KB), and
when that allocation failed the refresh retried until the task watchdog fired.
Track and fill now share one radius, and tests/test_layer_free.py keeps every
layer-making style out of the profiles, the packages and the firmware headers.

A tile keeps only the fields every card uses; climate modes, select options,
weather, sun and timer times, the media title and the vacuum rows moved to an
Extra block that exists only while a state needs one, and the state copy became
a fingerprint. runtime_tiles::model went from 24,296 to 8,216 bytes.

The manual profile that bound tiles to fixed entities in the YAML is gone:
331 TILE* substitutions, DYNAMIC_TILES/TILE_COUNT/DIRECT_ACTIONS, the 80 Home
Assistant subscriptions, its tap handlers and scripts, its own vacuum card, its
guides and helper scripts, and the five stale header copies in the repository
root that two C++ tests were compiling instead of the components. esphome config
of both Easy profiles is unchanged by that cleanup.

tile_scroll and the wake overlay no longer light up on a tap, and the debug
sensor min_free reports the lowest free heap since boot. On the bench CYD static
RAM went from 32.0% to 22.8% and free heap from 64-77 KB to about 101 KB, with
78 KB as the lowest point over thirteen hours without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 08:06:41 +02:00
parent 79a84a4c77
commit 08197c4ae1
45 changed files with 854 additions and 12896 deletions
+22 -24
View File
@@ -7,8 +7,8 @@ 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.
Runtime mode supports all cards in all twenty positions. The position constraints
below apply only to the old manual profiles.
A screen gets its tiles from the add-on while it runs: every card works in all
twenty positions, and no Home Assistant entity belongs in a board profile.
Preserve the data schema, protocol compatibility, unique keys, and CYD preferences.
Test updates against existing data. Don't publish an unknown storage version without
a migration. Production Ingress needs no long-lived token or public port.
@@ -16,8 +16,8 @@ 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. Use its own local
`guition-device.yaml`; don't carry over the CYD layout or XPT2046 calibration.
480×480, ST7701S RGB, and GT911. 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.
@@ -29,23 +29,21 @@ physically tap; an agent cannot replace that with software coordinates.
## Installing a new screen
1. Identify the USB port and board variant. Use ESPHome 2026.6.2 with Python
3.11–3.14. First check whether local configuration already exists.
2. In a fresh copy, use `tools/new_device.py` to create your own `device.yaml`,
`calibration.yaml`, `secrets.yaml`. Never overwrite a working profile.
Let the owner fill in Wi-Fi locally. Don't show keys in logs/chat.
3. Flash `device.yaml` with the CLI substitution `CALIBRATION_ON_BOOT=true`.
The isolated screen shows five crosshairs and works without HA.
4. Follow docs/CALIBRATING.md: guided USB capture, fit, flash, new capture,
independent verification. Ask for physical taps per target. Wait for confirmation
that the measurement screen is actually visible; a successful build is not a flash.
5. Flash without that override. Pair with the owner's own HA via the ESPHome integration.
1. Install it from ESP Screens (docs/EASY_SETUP.md): the add-on writes the screen's
own ESPHome YAML with its name, Wi-Fi reference and unique keys, and flashes it
over USB. Identify the USB port and board variant first, and check whether a
profile for this screen already exists; never overwrite a working one.
Use ESPHome 2026.6.2 with Python 3.11-3.14. Don't show keys in logs or chat.
2. CYD: the screen shows its calibration on first boot; the owner taps the
crosshairs. `tools/calibrate.py` with docs/CALIBRATING.md is the USB route for a
panel that needs measuring; ask for physical taps per target, and wait for
confirmation that the measurement screen is visible. A successful build is not a flash.
Guition: GT911 reports pixels; verify with tools/verify_gt911.py, no ADC calibration.
3. Pair with the owner's own Home Assistant through the ESPHome integration.
Read real entity IDs and supported attributes; don't make up entities.
6. Configure every tile you use according to docs/TILES.md. The vacuum card
sits at position 6. Positions 8/10 don't have a full slider/climate binding.
Don't test real device actions without the owner's permission.
7. Go through docs/ACCEPTANCE.md and report the tests actually carried out,
limitations, and the observed stability duration.
4. Choose the tiles in ESP Screens. Don't test real device actions without the
owner's permission, and report which checks were actually carried out,
the limitations, and how long the screen stayed up.
## Code and regressions
@@ -67,12 +65,12 @@ physically tap; an agent cannot replace that with software coordinates.
layout within ~25 s. Guition: `capture_ui.py` saves the LVGL render as a PNG.
Without a screen: `tools/render_topbar.py` renders the real top-bar code for both
boards via the ESPHome host + SDL2 to `.esphome/render-topbar/out/sheet.png`.
- Share via `tools/export_bundle.py` or Git. Don't stage secrets, measurements,
binaries, logs, build caches, or local device profiles.
- Share through Git. Don't stage secrets, measurements, binaries, logs, build
caches, or local device profiles.
- No automatic firmware upload to an arbitrary connected port.
With multiple boards, first determine the intended port.
- Profiles with the same `DEVICE_NAME` (Easy Setup and manual) share
`.esphome/build/<name>`. Never compile or upload them concurrently; check
- Profiles with the same `DEVICE_NAME` share `.esphome/build/<name>`.
Never compile or upload them concurrently; check
the `firmware.bin` path in the upload log, and then the compile time via
`device_info`. A wrong profile knocks the screen out of ESP Screen Manager.
-124
View File
@@ -1,124 +0,0 @@
> Historical investigation on a single panel. For a new screen, see [README.md](README.md)
> and [the five-point calibration](docs/CALIBRATING.md); don't reuse these measurements.
# ESP32-2432S028: stable operation
Use **home-like-2432s028.yaml** for the connected 2.8-inch CYD with
ILI9341 and XPT2046. The Wi-Fi, API, and OTA secrets stay in `secrets.yaml`.
## Operation
- Set `TILE_COUNT` to the number of tiles in use (1–10). With six tiles or
fewer, both page buttons and the page indicator disappear; higher
tile numbers stay hidden.
- Page 1 holds tiles 1–6; page 2 holds 7–10. Use the **Previous**
and **Next** buttons at the bottom. Swiping is disabled.
- A short tap runs the tile's existing action. A long press opens the
existing light, climate, or vacuum card, or the configured long-press action.
- Standby starts after ten minutes without touch (`AUTO_DIM_TIMEOUT: "600"`).
- The first tap on the dimmed screen wakes the screen.
- Contacts shorter than `TOUCH_MIN_PRESS_MS` (60 ms on the CYD, 20 ms on the
Guition) are ignored. One touch can produce at most one tile action;
a movement greater than `TOUCH_MOVE_LIMIT_PX` (56 px, about 1 cm, on the CYD;
0 on the Guition: no limit, LVGL decides and releasing inside the tile counts)
relative to the settled first contact point cancels the tap; only the first
contact counts; the same tile is protected against contact bounce for 600 ms.
A rejected tap is logged with its reason (tag `touch`).
- The thermostat sends the rounded, clamped temperature it displays.
A local temperature or mode choice gets three seconds to sync with HA.
An active control press prevents automatic dimming.
- Pippa uses the real `vacuum.s8` status, so docking, cleaning,
pausing, and returning are distinguished. Missing HA states
are shown as `Unavailable`.
## Technical choices
ESPHome 2026.6.2 with ESP-IDF; no Arduino components are needed.
The display bus runs at 40 MHz, like ESPHome's standard CYD profile.
XPT2046 is polled every 20 ms without the GPIO36 interrupt. The pressure
threshold is 800, after a suspect corner reading at pressure 422. A local XPT2046
driver requires three consistent ADC samples for a new touch, ignores isolated
outliers, and waits for two pressure-free samples before release. This adds about
40–60 ms on touch and prevents an unstable first reading from being immediately
interpreted as a different button. The driver lives under `components/`. The stock
interrupt driver tries to enable an unavailable internal pull-up on that
input-only pin; the old firmware logged a GPIO error for this.
LVGL gets a 12% draw buffer. No scroll animations and no extra flash pulse after
each tap. Tile backgrounds are opaque. Updates within 100 ms are
coalesced; unchanged tile labels and colors are not redrawn.
UI initialization only runs after LVGL has started. The clock refreshes immediately
on time sync. Roboto is bundled locally in `fonts/`, including the OFL license.
The active layout is 320×240. The old, commented-out portrait presets
are not adapted page layouts yet: with a different orientation, also reserve
34 pixels at the bottom for navigation and check the tile positions.
## Building and flashing
```sh
esphome compile home-like-2432s028.yaml
esphome upload home-like-2432s028.yaml --device /dev/cu.usbserial-130
esphome logs home-like-2432s028.yaml --device /dev/cu.usbserial-130
```
Close any running serial log reader before flashing. The previous configuration
is stored locally in `diagnostics/before-2432s028.yaml.bak`; the corresponding previous
factory binary is in `diagnostics/before-2432s028.factory.bin`. These local backups
are not included in Git. You can roll back by restoring the backup as the configuration
and rebuilding/flashing; restore the old Arduino toolchain
if you build the old configuration.
## Tests
```sh
clang++ -std=c++17 -Wall -Wextra -Werror tests/test_cyd_ui.cpp -o /tmp/test_cyd_ui
/tmp/test_cyd_ui
python3 tests/test_layout.py
clang++ -std=c++17 -Wall -Wextra -Werror tests/test_touch_filter.cpp -o /tmp/test_touch_filter
/tmp/test_touch_filter
```
The C++ test checks contact bounce, short noise pulses, consecutive tiles,
repetition within a single touch, `millis()` overflow, and temperature rounding.
The layout test checks overlap, screen bounds, space for navigation,
and the filter ahead of both handlers for all ten tiles.
With ESPHome's Python environment (with aioesphomeapi, PyYAML, and pyserial):
```sh
python diagnostics/capture_serial.py --reset --seconds 120 > diagnostics/runtime.log
python diagnostics/run_ui_test.py --host cyd-2432s028.local
```
The API test reads the encryption key internally from `secrets.yaml` and doesn't
print it. It switches pages ten times and draws the five overlays on each
round. It doesn't call any Home Assistant actions. Don't touch the screen during
these roughly 11 seconds. This is a rendering/memory test; it doesn't replace
a physical check of the touch calibration or a test of HA service effects.
Diagnostics logs free heap and the largest free memory block every 30 seconds.
`DEBUG` is useful temporarily for touch and connection diagnostics. For everyday
use, `logger.level` can go back to `INFO` once physical operation is confirmed good.
## Touch diagnostics
`python diagnostics/control_ui.py touch_diagnostics` opens a separate screen
with four crosshairs. During this measurement, touches can't trigger a tile
action. Tap accurately, in order: top-left, top-right,
bottom-right, and bottom-left. The raw and normalized coordinates appear in
the serial logs with tag `touch`. Exit with
`python diagnostics/control_ui.py end_touch_diagnostics`. The crosshairs sit
at (20,20), (299,20), (299,219), and (20,219) for 320×240 landscape.
The current calibration (X 225–3800, Y 274–3609) is derived from the three
repeated measurements at top-right and bottom-left on this physical board. The earlier
top-left measurements were unstable and were deliberately not used, to avoid
shifting the entire screen to the wrong position.
On top of that, `set_raw_correction(...)` in `on_boot` corrects the measured
skew. The fit uses the filtered corner medians (830,556), (544,3461),
(3410,3391), and (3492,372). After correction, the four medians land within five
pixels of their measurement target in the regression test. These are board-specific
coefficients; don't carry them over blindly to another panel. The `raw=` logs
still show the physical, filtered ADC readings.
-232
View File
@@ -355,7 +355,6 @@ screen also gets a new `SCREEN_FIRMWARE_VERSION` in both board profiles.
- [Complete installation from ESP Screens](docs/EASY_SETUP.md)
- [Guition hardware, mounting, and rotation](docs/GUITION.md)
- [CYD calibration and USB diagnostics](docs/CALIBRATING.md)
- [Physical acceptance test](docs/ACCEPTANCE.md)
- [Instructions for developers and LLMs](AGENTS.md)
Give a developer or LLM a clean copy of this repository and, for example:
@@ -368,234 +367,3 @@ Give a developer or LLM a clean copy of this repository and, for example:
A successful build doesn't prove the physical touch or panel image is correct. The owner
must check the display and perform the requested taps.
<details>
<summary>Older manual CYD installation via a computer</summary>
The route below uses the older manual profile with up to ten
tiles. Its position constraints don't apply to ESP Screen Manager's twenty
runtime tiles. For new installations, prefer the route
above; the manual instructions remain available for maintenance.
# CYD Home Assistant control screen
From a fresh **ESP32-2432S028 with ILI9341 + XPT2046** to a calibrated
Home Assistant screen, over USB. With light control, scenes/scripts, climate,
vacuum, up to ten tiles, and fixed pages. With six tiles or fewer,
pagination disappears. Standby starts after ten minutes by default.
**Manual installation via a computer:** you don't need to create a Home Assistant token
or install an ESPHome add-on to flash from a computer.
## Working with an LLM or developer
Give it the unpacked folder and this prompt:
> First read AGENTS.md and README.md. Guide me through a new installation
> on an ESP32-2432S028 connected via USB, with my own Home
> Assistant. Check the board, the serial port, and the Python environment.
> Create a local device.yaml profile; don't use any existing personal
> entities or panel calibration. Walk through USB calibration, HA pairing,
> tile configuration, and the acceptance test. Ask me for physical taps when
> needed. Keep keys and passwords in local files, and report
> which checks were actually carried out.
The assistant can read the code and logs, but can't see the physical screen
on its own. You check the display and perform the requested taps.
## 1. Check what you need
- The intended board: ESP32-2432S028, 320×240, **ILI9341 display and resistive
XPT2046 touch**. Boards exist with almost the same name and a different
display controller. This guide doesn't cover those variants.
- A USB **data cable** and a computer running macOS, Linux, or Windows.
- Python **3.11–3.14**, internet access for the first build, and a few GB of free space.
The version limit comes from the tested ESPHome package; don't use Python 3.15.
- A 2.4GHz Wi-Fi network; Home Assistant must be able to reach the board over
the network. Keep the Wi-Fi password available locally.
- Access to Home Assistant to choose entities and add ESPHome.
Preferably keep only one ESP board connected at a time. Note the variant and
the MAC address from the boot/upload logs.
## 2. Set up the work environment
Clone [the repository](https://github.com/MaxGramser/homeassistant_espscreen)
or unpack the starter ZIP. Open a terminal **in the code folder**. All commands below
are run from that folder.
macOS/Linux:
```sh
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
```
Windows PowerShell:
```powershell
py -3.13 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
```
If activating PowerShell is blocked, use
`.\.venv\Scripts\python.exe` directly instead of `python`; no system change is
needed. Then check:
```sh
python --version
python -m esphome version
python -m serial.tools.list_ports
```
Expect **ESPHome 2026.6.2**. This repo includes a custom touch driver that's
tested against this version. Don't silently upgrade during an installation.
See also the [official ESPHome installation guide](https://esphome.io/install/).
Note the port: for example `/dev/cu.usbserial-130` on macOS,
`/dev/ttyUSB0` on Linux, or `COM3` on Windows. Further down, `<USB_PORT>`
appears; always replace it with the actual port, without angle brackets.
## 3. Create your own device profile
Choose a unique name, for example `display-kitchen`:
```sh
python tools/new_device.py --name display-kitchen --friendly-name "Display kitchen"
```
This creates three local files and overwrites nothing:
| File | What you edit in it |
|---|---|
| `device.yaml` | Device name, room, tiles, and behavior |
| `calibration.yaml` | Calibration for this one physical panel; the wizard writes this |
| `secrets.yaml` | Wi-Fi, API encryption key, OTA and fallback password |
The keys are generated uniquely. In `secrets.yaml`, only fill in your
`wifi_ssid` and `wifi_password`. Leave the generated keys as they are and
keep this file with your own configuration. Don't include it in the ZIP.
You'll need the API encryption key later for HA pairing; copy it
locally from the file, not through a public chat.
Do these files already exist? Work in a new copy of the folder for a new
screen. Don't erase the keys/calibration of a working screen.
`device.yaml` imports the base configuration and your calibration. It also overwrites
**all hidden tile positions**, so they don't keep tracking someone else's
entities. Start with the six example tiles. `DIRECT_ACTIONS: "false"`
prevents direct HA control during the initial installation. At this
stage, don't yet create HA automations that react to the action sensor.
## 4. First USB flash and calibration
First check the configuration (ESPHome masks secrets by default):
```sh
python -m esphome config device.yaml
```
Flash calibration mode, which works without an HA connection:
```sh
python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device <USB_PORT>
```
The first build can take several minutes. After uploading, five
crosshairs appear on a dark screen. `run` keeps showing logs: stop **only the
log reader with Ctrl+C** before starting the calibration wizard. The board stays on.
Now follow [docs/CALIBRATING.md](docs/CALIBRATING.md): three taps are
collected per target, the four corners determine the correction, and the center is an
independent check. Then you flash the result and measure again.
**Never copy the measurements from another panel.**
## 5. Pair with Home Assistant
After a successful calibration, flash the normal mode:
```sh
python -m esphome run device.yaml --device <USB_PORT>
```
Check the logs for the Wi-Fi IP, a stable boot, and the device name. Ctrl+C closes
the log reader. The screen should now show the normal tile page.
1. In HA, open **Settings → Devices & services**.
2. Choose the discovered ESPHome device, or **Add integration → ESPHome**.
3. Use `display-kitchen.local` or the IP address from the logs; API port **6053**.
4. Enter the `api_encryption_key` from your local `secrets.yaml` if asked.
5. Check that the device name is correct and the status becomes online.
6. Open this ESPHome integration's options/configuration and enable
**Allow the device to perform Home Assistant actions**.
The exact wording/location of this option varies by HA version. See the
[official integration guide](https://www.home-assistant.io/integrations/esphome).
The HA integration connects to the board; the ESPHome Device Builder/add-on is
optional and is a separate feature.
## 6. Configure your own tiles
Follow [docs/TILES.md](docs/TILES.md). Collect the real entity IDs in HA,
check supported attributes/actions, and edit `device.yaml`. Use
short titles. Once you've checked everything:
```yaml
substitutions:
DIRECT_ACTIONS: "true"
TILE_COUNT: "6"
```
These are changes in the **existing** substitutions map, not a second map
at the bottom of the file. Build and flash again with `run device.yaml`. The tiles
will then follow the real HA state. An active API connection alone
doesn't prove that HA has been granted permission for actions.
## 7. Handover and testing
Run through [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md). That covers an independent
touch measurement, both pages if present, long press, sliders, climate,
vacuum, real HA state, and the correct action effects. The built-in
render test doesn't control any HA devices itself:
```sh
python diagnostics/run_ui_test.py --host display-kitchen.local --name display-kitchen
```
Keep with this person: `device.yaml`, `calibration.yaml`, `secrets.yaml`, the
measurement files, the ESPHome version used, and a short handover report.
For later updates you can keep using USB or use OTA:
```sh
python -m esphome run device.yaml --device display-kitchen.local
```
Don't change the name/API key without also updating the HA pairing.
For troubleshooting: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
## Handing this code to the next person
Use the exporter instead of copying your entire working folder:
```sh
python tools/export_bundle.py --output dist/cyd-starter.zip
```
The ZIP contains the code, local fonts, licenses, guides, and neutral
examples. The base defaults are neutralized for the recipient.
No `secrets.yaml`, personal `device.yaml`, measurements, logs, `.git`, `.esphome`,
Python environment, or firmware backups. The exporter refuses to overwrite an
existing ZIP; choose a different filename for a new version.
The original base is from
[akuehlewind/ESPHome-touch-display-mount](https://github.com/akuehlewind/ESPHome-touch-display-mount).
Project, ESPHome driver, and font licenses are in `LICENSE`,
`components/xpt2046/LICENSE`, and `fonts/` respectively. Historical board diagnostics are in
[CYD_STABILITY.md](CYD_STABILITY.md); for a **new** installation, follow the
guides above.
</details>
-68
View File
@@ -1,68 +0,0 @@
# Test results — ESP32-2432S028 — September 12, 2026
Tested on the USB-connected `cyd-2432s028`, ESP32 rev3.1, ESPHome
2026.6.2 / ESP-IDF. The firmware reports compile time
`2026-09-12 09:54:02 +0200` and was written and verified over USB.
## Passed
- C++ regression tests for noise, contact bounce, jumping touches,
real drag movements, temperature rounding, and `millis()` overflow.
- Five layout/configuration tests for screen bounds, overlap, modal
layers, conditional pagination, and guards ahead of all tile handlers.
- ESPHome validation with `TILE_COUNT: 6`; navigation is tied in the code to
`count > 6`, and tiles above the configured count stay hidden.
- Full ESP-IDF build and USB upload with verification of the written data.
- Connection to the existing Home Assistant installation and receipt of
entity states.
- **10 page checks and 50 overlay cycles on the real device.**
The render-start counter rose from 38 to 101 between the first and
last page check: the test also verifies actual draw activity.
- Runtime geometry: tile area `(0,40)–(319,203)`, Next button
`(211,206)–(310,237)`. The areas don't overlap.
- Free heap during the ten test measurements: 86,416–139,696 bytes
per `esp_get_free_heap_size()`. No growing memory loss or reset
during this short render test.
## Physical touch diagnostics
The user repeatedly tapped the four screen corners. The raw
measurement at top-left sometimes jumped more than a hundred pixels off. After the driver filter,
the samples stayed within recognizable corner clusters. The remaining skew was
corrected with an affine correction on the corner medians; the regression test
places those medians within five pixels of the target. The source values and
coefficients are in the configuration, tests, and CYD_STABILITY.md.
After the final flash, the user confirmed that the correct buttons respond
and the controls now work well.
## Limitations
This is not a long-duration endurance test. Home Assistant actions were not automatically
performed during the screen test. The user confirmed the physical controls after the final calibration correction
with: "Yes, this works well now". The ten-minute
standby is configured; nobody waited the full ten minutes to let the
complete idle timeout run out.
Local raw logs are in `diagnostics/acceptance-ui-test.log` and
`diagnostics/acceptance-runtime.log` and are not included in Git.
## Portable installation — additional software check
On September 12, 2026, the new onboarding flow was tested:
- 17 Python tests pass: layout, calibration fit, independent verification,
faulty/noisy measurements, unique secrets, not overwriting, and export.
- Both C++17 regression programs pass (touch filter and UI guard).
- Starter ZIP unpacked into a fresh temporary folder; a new profile was created with its own
generated keys. A full ESPHome 2026.6.2 / ESP-IDF build
with calibration boot mode succeeded.
- Generated C++ checked for identity correction on the new board.
The separate local profile retains the six previously measured correction coefficients.
- The Git index and existing history were checked for the local credentials;
no matches. Local profiles and secrets are excluded.
The five-point wizard was tested with synthetic measurement data. In this
round it was not physically run again or flashed to the existing board;
physical acceptance remains mandatory for every new panel. The hardware test
above concerns the previously installed firmware.
-576
View File
@@ -1,576 +0,0 @@
> New ESP32-2432S028 installation: start at [README.md](README.md) and
> [the current tile guide](docs/TILES.md). Legacy behavior is also described below.
# Tile Configuration Reference
For the ESP32-2432S028 profile, use `home-like-2432s028.yaml` and
[CYD_STABILITY.md](CYD_STABILITY.md). That profile uses fixed pages instead of
scrolling, hides pagination when `TILE_COUNT <= 6`, and dims after ten minutes.
The scrolling section below describes `home-like.yaml`, an early personal
profile that has since been removed from the repository; it's kept here only
as a historical record.
This document explains every substitution used across the tile profiles and provides copy-paste examples for all supported entity types.
---
## How substitutions work
ESPHome substitutions are compile-time text replacements. There is no runtime logic in the substitution block — every `${TILE1_...}` reference in the YAML is replaced with the value you set before the firmware is compiled and flashed. This means:
- You cannot use `if/else` in substitutions
- Changes require a reflash
- The firmware is fully self-contained after flashing
---
## The home screen scrolls
The wallpaper, the room name and the clock sit on the page itself and stay put.
All tiles live inside one transparent container (`tile_scroll`) that scrolls
vertically, so the grid can be taller than the screen.
- The grid is **2 columns x 5 rows = 10 tiles**. Rows 1-3 are visible at rest;
dragging up reveals rows 4 and 5.
- `GRID_ROW*_Y` are **relative to the scroll container**, not to the page.
`SCROLL_Y` / `SCROLL_H` define where the container sits and how tall the
viewport is. Both are set per orientation preset.
- LVGL derives the scroll length from the tile positions, so there is no
content height to maintain by hand.
- Tapping and scrolling do not conflict: once a drag passes LVGL's scroll
limit (10 px) the short click *and* the long press are cancelled.
- `scroll_snap_y: START` makes the container click into place per row. Set it
to `NONE` on the `tile_scroll` object for free scrolling.
## Background
The background is a two-stop vertical gradient (`BG_TOP_COLOR` ->
`BG_BOTTOM_COLOR`), not an image. That saves ~150 KB of flash and removes an
image blit from every redraw, which is what makes scrolling usable on this
board. The same gradient is reused as the backdrop of the slider and color
overlays through the `style_bg_grad` style; the climate card uses the plain
light background of the other cards.
## Accent colors are automatic
A tile's accent color is derived from the **Home Assistant domain** — the part
of the entity id before the dot:
| Entity | Domain | Accent |
|--------|--------|--------|
| `script.good_morning` | `script` | `ACCENT_ACTION` (violet) |
| `scene.evening_lights` | `scene` | `ACCENT_ACTION` (violet) |
| `light.living_room_2` | `light` | `ACCENT_LIGHT` (amber) |
| `vacuum.s8` | `vacuum` | `ACCENT_DEVICE` (cyan) |
| `climate.ac` | `climate` | `ACCENT_DEVICE` (cyan) |
The distinction that matters: a script or scene is something you *fire*, a
device is something you switch *on and off*. Those should not look the same.
Two of the ten per-tile color keys participate: `TILE*_CIRCLE_ACTIVE_COLOR`
and `TILE*_ICON_DISABLED_COLOR`. Set them to `${ACCENT_AUTO}` to let the domain
decide — that is the default for every tile — or to a real hex value to
override that one tile. Tile 8 does exactly that: the mailbox turns red when
there is post, regardless of its domain.
Because the color follows the entity, dropping a different entity into a tile
gives it the right color without touching any color key.
> The domain lookup lives in a small `accent_for` lambda that appears **twice**
> in each profile: once in `ui_refresh` (for the tiles) and once in
> `open_value_overlay` (for the slider fill). Keep the two copies in sync.
---
## Global settings
| Key | Default | Description |
|-----|---------|-------------|
| `DIRECT_ACTIONS` | `"true"` | `true` = device calls HA services directly on tap. `false` = only publishes the action event, you handle it in HA automations. |
| `ROOM_NAME` | `"Office"` | Text shown in the top-left header. |
| `TIME_24H` | `"true"` | `true` = 24h clock, `false` = 12h with AM/PM. |
| `AUTO_DIM` | `"true"` | Enable backlight auto-dimming after inactivity. |
| `AUTO_DIM_TIMEOUT` | `"10"` | Seconds of no touch before dimming. |
| `MAX_BRIGHTNESS` | `"100"` | Backlight brightness (%) when active. |
| `DIM_BRIGHTNESS` | `"40"` | Backlight brightness (%) when dimmed. |
| `NIGHT_MODE` | `"true"` | Use a lower brightness level during night hours. |
| `NIGHT_START_HOUR` | `"22"` | Hour (0–23) when night mode starts. |
| `NIGHT_END_HOUR` | `"7"` | Hour (0–23) when night mode ends. |
| `NIGHT_DIM_BRIGHTNESS` | `"18"` | Backlight brightness (%) during night hours. |
| `BG_TOP_COLOR` | `"0xAEC4D9"` | Top color of the background gradient. |
| `BG_BOTTOM_COLOR` | `"0xD6BB9E"` | Bottom color of the background gradient. |
| `ACCENT_AUTO` | `"0x1000000"` | Sentinel meaning "derive this color from the entity domain". Deliberately outside the 24-bit color range. |
| `ACCENT_ACTION` | `"0x8B5CF6"` | Accent for `script`, `scene`, `button`, `input_button`, `automation`. |
| `ACCENT_LIGHT` | `"0xFEC600"` | Accent for `light`. |
| `ACCENT_DEVICE` | `"0x00C5EC"` | Accent for everything else (switch, fan, cover, climate, vacuum, media_player, …). |
---
## Per-tile substitutions
Each tile has the same set of keys, just with a different number
(TILE1 … TILE10). All ten slots are in use — see [Adding a tile](#adding-a-tile)
if you want to extend the grid further.
### Basic settings
| Key | Description |
|-----|-------------|
| `TILE*_ENTITY` | The Home Assistant entity this tile controls (e.g. `light.living_room`). |
| `TILE*_STATE_ENTITY` | Entity used to determine active/inactive visual state. Usually the same as `TILE*_ENTITY`. Set to a different entity if you want the tile to reflect a group or a related sensor. |
| `TILE*_TITLE` | Text label shown on the tile. |
| `TILE*_ICON` | Material Design Icons Unicode glyph. Find icons at [pictogrammers.com/library/mdi](https://pictogrammers.com/library/mdi/) — click an icon and copy the Unicode value (e.g. `U+F0769` → `"\U000F0769"`). |
| `TILE*_TYPE` | Entity type: `light` \| `fan` \| `switch` \| `scene` \| `script` \| `cover` \| `climate`. Controls tap behavior, value display, and slider type in auto mode. |
| `TILE*_TAP_ACTION` | What happens on short tap — see [Tap actions](#tap-actions) below. |
| `TILE*_LONGPRESS` | What happens on long press — see [Long press modes](#long-press-modes) below. |
| `TILE*_VALUE_MODE` | How the value line below the title is rendered — see [Value modes](#value-modes) below. |
| `TILE*_LABEL_OFF` | Text shown in the value line when the entity is off / inactive. |
| `TILE*_LABEL_ON` | Text shown in the value line when `VALUE_MODE` is `text` and entity is on. |
| `TILE*_ACTIVE_STATE` | Optional. Exact Home Assistant state that counts as "active". Leave empty for the default behavior (`on`, or always-on for scenes/scripts). Used by tile 8, where the state is `Vol` / `Leeg` rather than `on` / `off`. |
### Colors
All colors are 24-bit hex in the format `"0xRRGGBB"`.
| Key | Description |
|-----|-------------|
| `TILE*_CIRCLE_ACTIVE_COLOR` | Icon circle background when active. `${ACCENT_AUTO}` = derive from the entity domain. |
| `TILE*_CIRCLE_DISABLED_COLOR` | Icon circle background when inactive. |
| `TILE*_ICON_ACTIVE_COLOR` | Icon glyph color when active. |
| `TILE*_ICON_DISABLED_COLOR` | Icon glyph color when inactive. `${ACCENT_AUTO}` = derive from the entity domain. |
| `TILE*_BG_ACTIVE_COLOR` | Tile background when active. |
| `TILE*_BG_DISABLED_COLOR` | Tile background when inactive. |
| `TILE*_TITLE_ACTIVE_COLOR` | Title text color when active. |
| `TILE*_TITLE_DISABLED_COLOR` | Title text color when inactive. |
| `TILE*_VALUE_ACTIVE_COLOR` | Value text color when active. |
| `TILE*_VALUE_DISABLED_COLOR` | Value text color when inactive. |
### Advanced settings (short tap)
| Key | Default | Description |
|-----|---------|-------------|
| `TILE*_TAP_SERVICE` | `""` | Override the HA service called on tap (e.g. `"light.toggle"`). Leave empty to use the default for the tile type. |
| `TILE*_TAP_PARAM_KEY` | `""` | Extra parameter key for the service call. Supported keys: `preset_mode`, `brightness_pct`, `percentage`, `option`. Any other value is ignored and the service is called with `entity_id` only. |
| `TILE*_TAP_PARAM_VAL` | `""` | Extra parameter value (e.g. `"Auto"`). |
### Advanced settings (long press — slider mode)
Only relevant when `TILE*_LONGPRESS` is set to `"slider"`.
| Key | Default | Description |
|-----|---------|-------------|
| `TILE*_LONGPRESS_SLIDER` | `"auto"` | Slider type: `auto` \| `brightness` \| `percentage` \| `cover_position` \| `climate_temperature`. `auto` picks the right type based on `TILE*_TYPE`. |
| `TILE*_LONGPRESS_OFF_VALUE` | `"0"` | Initial slider position (0–100) shown when the entity is currently off. |
### Advanced settings (climate tiles)
Only relevant when `TILE*_TYPE` is set to `"climate"`.
| Key | Default | Description |
|-----|---------|-------------|
| `TILE*_CLIMATE_MIN_TEMP` | `"5.0"` | Minimum target temperature on the climate card. |
| `TILE*_CLIMATE_MAX_TEMP` | `"30.0"` | Maximum target temperature on the climate card. |
| `TILE*_CLIMATE_STEP` | `"0.5"` | Step size of the − / + keys. Values below `0.5` are rounded up to `0.5`, so the UI always adjusts in whole or half-degree steps. |
| `TILE*_CLIMATE_CURRENT_TEMP_ATTRIBUTE` | `"current_temperature"` | Home Assistant attribute used for the current room temperature. |
| `TILE*_CLIMATE_TARGET_TEMP_ATTRIBUTE` | `"temperature"` | Home Assistant attribute used for the target temperature. |
| `TILE*_CLIMATE_HUMIDITY_ATTRIBUTE` | `"current_humidity"` | Home Assistant attribute used for humidity. Leave the default if the entity does not provide humidity. |
### Advanced settings (long press — action mode)
Only relevant when `TILE*_LONGPRESS` is set to `"action"`.
| Key | Default | Description |
|-----|---------|-------------|
| `TILE*_LONGPRESS_ACTION` | `""` | HA entity to call on long press. Can be completely different from `TILE*_ENTITY`. |
| `TILE*_LONGPRESS_ACTION_TYPE` | `""` | Entity type of the long press target: `light` \| `fan` \| `switch` \| `scene` \| `script` \| `cover`. Leave empty to inherit from `TILE*_TYPE`. |
| `TILE*_LONGPRESS_ACTION_SERVICE` | `""` | Service to call (default is `activate`/`turn_on`). Set e.g. `"light.toggle"` to toggle instead. |
---
## Tap actions
| Value | Behavior |
|-------|----------|
| `auto` | Picks the best action for the tile type: `toggle` for light/fan/switch/cover, `activate` for scene/script. Climate tiles do not have a default tap action; use `TILE*_TAP_SERVICE` if you want short tap behavior. |
| `toggle` | Toggles the entity (light.toggle / fan.toggle / switch.toggle / cover open↔close). |
| `activate` | Turns on / activates the entity (scene.turn_on / script.turn_on / light.turn_on / cover.open_cover). |
| `fan_toggle_preset` | If fan is ON: turns it off. If fan is OFF: turns it on and sets a preset mode (configure with `TAP_PARAM_KEY` / `TAP_PARAM_VAL`). |
| `custom` | Use `TAP_SERVICE` + `TAP_PARAM_KEY` / `TAP_PARAM_VAL` for a fully custom service call. |
---
## Long press modes
| Value | Behavior |
|-------|----------|
| `slider` | Opens a value overlay with a slider. Configure with `TILE*_LONGPRESS_SLIDER` and `TILE*_LONGPRESS_OFF_VALUE`. |
| `action` | Fires a different HA entity than the short tap. Configure with `TILE*_LONGPRESS_ACTION*`. |
| `none` | No direct action — only publishes `tileN_long_press` to HA so you can react in automations. |
In all modes, the event `tileN_long_press` is always published to `sensor.smartdisplay_action` in Home Assistant.
For light tiles in `slider` mode, the brightness overlay automatically shows a color-ring button when `DIRECT_ACTIONS` is `"true"` and Home Assistant reports a supported color mode (`hs`, `rgb`, `rgbw`, `rgbww`, `xy`) or color temperature mode (`color_temp`). The color detail view can send `light.turn_on` with `color_name`, `color_temp_kelvin`, and the current brightness percentage. If the light only supports color or only supports color temperature, the Color/Temp tab switch is hidden and only the supported controls are shown. No extra tile substitutions are required.
For climate tiles in `slider` mode, the climate card shows the target temperature between big − / + keys: the number follows every tap at once, holding a key keeps stepping, and one `climate.set_temperature` goes out once the taps stop (0.7 s). The min/max range, step size, and Home Assistant attribute names are configured through the `TILE*_CLIMATE_*` substitutions. Below it, one key per supported `hvac_mode` (in Home Assistant's order, off is the power key) sends `climate.set_hvac_mode` when `DIRECT_ACTIONS` is `"true"`; the ··· key opens the picker with fan and swing modes.
---
## Value modes
| Value | Behavior |
|-------|----------|
| `auto` | Picks automatically: `brightness` for lights, `percentage` or `preset` for fans, `climate` for climate entities, `text` for everything else. |
| `brightness` | Shows brightness as a percentage (e.g. `80 %`). When off, shows `LABEL_OFF`. |
| `percentage` | Shows fan speed as a percentage. When off, shows `LABEL_OFF`. |
| `preset` | Shows the fan preset_mode attribute. When off, shows `LABEL_OFF`. |
| `climate` | Shows the target temperature, falling back to current temperature if no target is available. When off, shows `LABEL_OFF`. |
| `text` | Shows `LABEL_ON` when on, `LABEL_OFF` when off. For scenes/scripts, always shows `LABEL_ON`. |
---
## Home Assistant events
Every tap publishes a short-lived state to `sensor.smartdisplay_action`:
| Event | Trigger |
|-------|---------|
| `tile1_press` … `tile10_press` | Short tap on tile 1–10 |
| `tile1_long_press` … `tile10_long_press` | Long press on tile 1–10 |
The state resets to `""` after 500ms. In HA automations, trigger on `state` → `to: "tile1_press"` etc.
---
## Complete examples
### Light — toggle + brightness slider
```yaml
TILE1_ENTITY: "light.living_room_ceiling"
TILE1_STATE_ENTITY: "light.living_room_ceiling"
TILE1_TITLE: "Ceiling"
TILE1_ICON: "\U000F0769" # mdi:ceiling-light
TILE1_TYPE: "light"
TILE1_TAP_ACTION: "auto" # toggles the light
TILE1_LONGPRESS: "slider" # long press opens brightness slider
TILE1_VALUE_MODE: "auto" # shows brightness % when on, "Off" when off
TILE1_LABEL_OFF: "Off"
TILE1_LABEL_ON: "On"
# colors
TILE1_CIRCLE_ACTIVE_COLOR: "0xFEC600"
TILE1_CIRCLE_DISABLED_COLOR: "0x7B7B6F"
TILE1_ICON_ACTIVE_COLOR: "0xFFFFFF"
TILE1_ICON_DISABLED_COLOR: "0xFEC600"
TILE1_BG_ACTIVE_COLOR: "0xFFFFFF"
TILE1_BG_DISABLED_COLOR: "0x939391"
TILE1_TITLE_ACTIVE_COLOR: "0x000000"
TILE1_TITLE_DISABLED_COLOR: "0xFFFFFF"
TILE1_VALUE_ACTIVE_COLOR: "0x7A7A7C"
TILE1_VALUE_DISABLED_COLOR: "0xD9D9D9"
# advanced
TILE1_TAP_SERVICE: ""
TILE1_TAP_PARAM_KEY: ""
TILE1_TAP_PARAM_VAL: ""
TILE1_LONGPRESS_SLIDER: "auto" # auto = brightness for lights
TILE1_LONGPRESS_OFF_VALUE: "0"
TILE1_LONGPRESS_ACTION: ""
TILE1_LONGPRESS_ACTION_TYPE: ""
TILE1_LONGPRESS_ACTION_SERVICE: ""
```
---
### Switch — simple toggle, no slider
```yaml
TILE2_ENTITY: "switch.office_power_strip"
TILE2_STATE_ENTITY: "switch.office_power_strip"
TILE2_TITLE: "Power Strip"
TILE2_ICON: "\U000F06FF" # mdi:power-socket-eu
TILE2_TYPE: "switch"
TILE2_TAP_ACTION: "auto" # toggles the switch
TILE2_LONGPRESS: "none" # long press only sends event, no slider
TILE2_VALUE_MODE: "text" # shows "On" / "Off"
TILE2_LABEL_OFF: "Off"
TILE2_LABEL_ON: "On"
# colors
TILE2_CIRCLE_ACTIVE_COLOR: "0xFEC600"
TILE2_CIRCLE_DISABLED_COLOR: "0x7B7B6F"
TILE2_ICON_ACTIVE_COLOR: "0xFFFFFF"
TILE2_ICON_DISABLED_COLOR: "0xFEC600"
TILE2_BG_ACTIVE_COLOR: "0xFFFFFF"
TILE2_BG_DISABLED_COLOR: "0x939391"
TILE2_TITLE_ACTIVE_COLOR: "0x000000"
TILE2_TITLE_DISABLED_COLOR: "0xFFFFFF"
TILE2_VALUE_ACTIVE_COLOR: "0x7A7A7C"
TILE2_VALUE_DISABLED_COLOR: "0xD9D9D9"
# advanced
TILE2_TAP_SERVICE: ""
TILE2_TAP_PARAM_KEY: ""
TILE2_TAP_PARAM_VAL: ""
TILE2_LONGPRESS_SLIDER: "auto"
TILE2_LONGPRESS_OFF_VALUE: "0"
TILE2_LONGPRESS_ACTION: ""
TILE2_LONGPRESS_ACTION_TYPE: ""
TILE2_LONGPRESS_ACTION_SERVICE: ""
```
---
### Cover — open/close toggle + position slider
```yaml
TILE3_ENTITY: "cover.living_room_blinds"
TILE3_STATE_ENTITY: "cover.living_room_blinds"
TILE3_TITLE: "Blinds"
TILE3_ICON: "\U000F111C" # mdi:blinds
TILE3_TYPE: "cover"
TILE3_TAP_ACTION: "auto" # opens if closed, closes if open
TILE3_LONGPRESS: "slider" # long press opens position slider
TILE3_VALUE_MODE: "auto" # shows "Open" / "Closed" (text mode for covers)
TILE3_LABEL_OFF: "Closed"
TILE3_LABEL_ON: "Open"
# colors — blue theme for covers
TILE3_CIRCLE_ACTIVE_COLOR: "0x00C5EC"
TILE3_CIRCLE_DISABLED_COLOR: "0x7B7B6F"
TILE3_ICON_ACTIVE_COLOR: "0xFFFFFF"
TILE3_ICON_DISABLED_COLOR: "0x00C5EC"
TILE3_BG_ACTIVE_COLOR: "0xFFFFFF"
TILE3_BG_DISABLED_COLOR: "0x939391"
TILE3_TITLE_ACTIVE_COLOR: "0x000000"
TILE3_TITLE_DISABLED_COLOR: "0xFFFFFF"
TILE3_VALUE_ACTIVE_COLOR: "0x7A7A7C"
TILE3_VALUE_DISABLED_COLOR: "0xD9D9D9"
# advanced
TILE3_TAP_SERVICE: ""
TILE3_TAP_PARAM_KEY: ""
TILE3_TAP_PARAM_VAL: ""
TILE3_LONGPRESS_SLIDER: "auto" # auto = cover_position for covers
TILE3_LONGPRESS_OFF_VALUE: "0"
TILE3_LONGPRESS_ACTION: ""
TILE3_LONGPRESS_ACTION_TYPE: ""
TILE3_LONGPRESS_ACTION_SERVICE: ""
```
---
### Fan — toggle preset on tap + speed slider on long press
```yaml
TILE4_ENTITY: "fan.air_purifier"
TILE4_STATE_ENTITY: "fan.air_purifier"
TILE4_TITLE: "Purifier"
TILE4_ICON: "\U000F0D43" # mdi:air-purifier
TILE4_TYPE: "fan"
TILE4_TAP_ACTION: "fan_toggle_preset" # off→on with preset, on→off
TILE4_LONGPRESS: "slider" # long press opens speed slider
TILE4_VALUE_MODE: "auto" # shows preset name when on, "Off" when off
TILE4_LABEL_OFF: "Off"
TILE4_LABEL_ON: "On"
# colors — blue theme for fan
TILE4_CIRCLE_ACTIVE_COLOR: "0x00C5EC"
TILE4_CIRCLE_DISABLED_COLOR: "0x7B7B6F"
TILE4_ICON_ACTIVE_COLOR: "0xFFFFFF"
TILE4_ICON_DISABLED_COLOR: "0x00C5EC"
TILE4_BG_ACTIVE_COLOR: "0xFFFFFF"
TILE4_BG_DISABLED_COLOR: "0x939391"
TILE4_TITLE_ACTIVE_COLOR: "0x000000"
TILE4_TITLE_DISABLED_COLOR: "0xFFFFFF"
TILE4_VALUE_ACTIVE_COLOR: "0x7A7A7C"
TILE4_VALUE_DISABLED_COLOR: "0xD9D9D9"
# advanced — fan_toggle_preset requires SERVICE + PARAM
TILE4_TAP_SERVICE: "fan.toggle"
TILE4_TAP_PARAM_KEY: "preset_mode"
TILE4_TAP_PARAM_VAL: "Auto" # preset to activate when turning on
TILE4_LONGPRESS_SLIDER: "auto" # auto = percentage for fans
TILE4_LONGPRESS_OFF_VALUE: "0"
TILE4_LONGPRESS_ACTION: ""
TILE4_LONGPRESS_ACTION_TYPE: ""
TILE4_LONGPRESS_ACTION_SERVICE: ""
```
---
### Climate — climate card on long press
```yaml
TILE5_ENTITY: "climate.living_room"
TILE5_STATE_ENTITY: "climate.living_room"
TILE5_TITLE: "Living"
TILE5_ICON: "\U000F0438" # mdi:radiator
TILE5_TYPE: "climate"
TILE5_TAP_ACTION: "auto" # no default tap action; use TAP_SERVICE if desired
TILE5_LONGPRESS: "slider" # long press opens the climate card
TILE5_VALUE_MODE: "auto" # shows target temperature, or current temperature as fallback
TILE5_LABEL_OFF: "Off"
TILE5_LABEL_ON: "Heat"
# colors
TILE5_CIRCLE_ACTIVE_COLOR: "0xFF9F0A"
TILE5_CIRCLE_DISABLED_COLOR: "0x7B7B6F"
TILE5_ICON_ACTIVE_COLOR: "0xFFFFFF"
TILE5_ICON_DISABLED_COLOR: "0xFF9F0A"
TILE5_BG_ACTIVE_COLOR: "0xFFFFFF"
TILE5_BG_DISABLED_COLOR: "0x939391"
TILE5_TITLE_ACTIVE_COLOR: "0x000000"
TILE5_TITLE_DISABLED_COLOR: "0xFFFFFF"
TILE5_VALUE_ACTIVE_COLOR: "0x7A7A7C"
TILE5_VALUE_DISABLED_COLOR: "0xD9D9D9"
# advanced
TILE5_TAP_SERVICE: ""
TILE5_TAP_PARAM_KEY: ""
TILE5_TAP_PARAM_VAL: ""
TILE5_LONGPRESS_SLIDER: "auto" # auto = climate_temperature for climate tiles
TILE5_LONGPRESS_OFF_VALUE: "0"
TILE5_LONGPRESS_ACTION: ""
TILE5_LONGPRESS_ACTION_TYPE: ""
TILE5_LONGPRESS_ACTION_SERVICE: ""
TILE5_CLIMATE_MIN_TEMP: "5.0"
TILE5_CLIMATE_MAX_TEMP: "30.0"
TILE5_CLIMATE_STEP: "0.5"
TILE5_CLIMATE_CURRENT_TEMP_ATTRIBUTE: "current_temperature"
TILE5_CLIMATE_TARGET_TEMP_ATTRIBUTE: "temperature"
TILE5_CLIMATE_HUMIDITY_ATTRIBUTE: "current_humidity"
```
---
### Scene — activate on tap
```yaml
TILE6_ENTITY: "scene.movie_night"
TILE6_STATE_ENTITY: "scene.movie_night" # scenes are always "on" so the tile stays active-styled
TILE6_TITLE: "Movie"
TILE6_ICON: "\U000F0E8B" # mdi:movie-open
TILE6_TYPE: "scene"
TILE6_TAP_ACTION: "auto" # activates the scene
TILE6_LONGPRESS: "none"
TILE6_VALUE_MODE: "text" # always shows LABEL_ON for scenes
TILE6_LABEL_OFF: "Scene"
TILE6_LABEL_ON: "Scene"
# colors — green theme
TILE6_CIRCLE_ACTIVE_COLOR: "0x4CAF50"
TILE6_CIRCLE_DISABLED_COLOR: "0x7B7B6F"
TILE6_ICON_ACTIVE_COLOR: "0xFFFFFF"
TILE6_ICON_DISABLED_COLOR: "0x4CAF50"
TILE6_BG_ACTIVE_COLOR: "0xFFFFFF"
TILE6_BG_DISABLED_COLOR: "0x939391"
TILE6_TITLE_ACTIVE_COLOR: "0x000000"
TILE6_TITLE_DISABLED_COLOR: "0xFFFFFF"
TILE6_VALUE_ACTIVE_COLOR: "0x7A7A7C"
TILE6_VALUE_DISABLED_COLOR: "0xD9D9D9"
# advanced
TILE6_TAP_SERVICE: ""
TILE6_TAP_PARAM_KEY: ""
TILE6_TAP_PARAM_VAL: ""
TILE6_LONGPRESS_SLIDER: "auto"
TILE6_LONGPRESS_OFF_VALUE: "0"
TILE6_LONGPRESS_ACTION: ""
TILE6_LONGPRESS_ACTION_TYPE: ""
TILE6_LONGPRESS_ACTION_SERVICE: ""
```
---
### Script — run on tap
```yaml
TILE6_ENTITY: "script.good_morning"
TILE6_STATE_ENTITY: "script.good_morning"
TILE6_TITLE: "Morning"
TILE6_ICON: "\U000F0A72" # mdi:weather-sunny
TILE6_TYPE: "script"
TILE6_TAP_ACTION: "auto" # runs the script
TILE6_LONGPRESS: "none"
TILE6_VALUE_MODE: "text"
TILE6_LABEL_OFF: "Run"
TILE6_LABEL_ON: "Run"
# colors — orange theme
TILE6_CIRCLE_ACTIVE_COLOR: "0xFF6F00"
TILE6_CIRCLE_DISABLED_COLOR: "0x7B7B6F"
TILE6_ICON_ACTIVE_COLOR: "0xFFFFFF"
TILE6_ICON_DISABLED_COLOR: "0xFF6F00"
TILE6_BG_ACTIVE_COLOR: "0xFFFFFF"
TILE6_BG_DISABLED_COLOR: "0x939391"
TILE6_TITLE_ACTIVE_COLOR: "0x000000"
TILE6_TITLE_DISABLED_COLOR: "0xFFFFFF"
TILE6_VALUE_ACTIVE_COLOR: "0x7A7A7C"
TILE6_VALUE_DISABLED_COLOR: "0xD9D9D9"
# advanced
TILE6_TAP_SERVICE: ""
TILE6_TAP_PARAM_KEY: ""
TILE6_TAP_PARAM_VAL: ""
TILE6_LONGPRESS_SLIDER: "auto"
TILE6_LONGPRESS_OFF_VALUE: "0"
TILE6_LONGPRESS_ACTION: ""
TILE6_LONGPRESS_ACTION_TYPE: ""
TILE6_LONGPRESS_ACTION_SERVICE: ""
```
---
### Long press — call a different entity
Short tap toggles a light; long press activates a scene.
```yaml
TILE1_ENTITY: "light.living_room"
TILE1_TYPE: "light"
TILE1_TAP_ACTION: "auto" # short tap: toggle the light
TILE1_LONGPRESS: "action" # long press: fire a different entity
# ...
TILE1_LONGPRESS_ACTION: "scene.movie_night"
TILE1_LONGPRESS_ACTION_TYPE: "scene"
TILE1_LONGPRESS_ACTION_SERVICE: "" # empty = default activate for scene
```
Short tap toggles a light; long press also toggles a different light.
```yaml
TILE1_LONGPRESS: "action"
TILE1_LONGPRESS_ACTION: "light.bedroom"
TILE1_LONGPRESS_ACTION_TYPE: "" # empty = inherits "light" from TILE1_TYPE
TILE1_LONGPRESS_ACTION_SERVICE: "light.toggle" # must set toggle explicitly, default is turn_on
```
---
## Adding a tile
All ten slots are filled. To grow the grid, add a `GRID_ROW6_Y` to every
orientation preset first (this applied to the now-removed `home-like.yaml`;
the maintained fixed-page profiles cap at ten tiles by design — see
[docs/TILES.md](docs/TILES.md)). A tile is not a single block — it is six
places. Copy tile 9 (a light with a slider) or tile 10 (a service
call) and change the number:
1. **Substitutions** — a `TILE9_*` block. Copy an existing one wholesale.
2. **Icon** — pick `TILE9_ICON` from `screen_manager/app/tile_icons.py`; the
icon fonts contain exactly that set. A glyph outside it renders as blank
space; add it there and run `tools/generate_icons.py`.
3. **Sensors** — under `sensor:` / `text_sensor:`. `ha_state_tile9` is the only
required one. Add the rest only if the tile actually uses them:
`tile9_brightness` (light), `tile9_percentage` + `tile9_preset` (fan),
`tile9_cover_position` + `tile9_cover_state` (cover),
`tile9_climate_*` (climate), `tile9_supported_color_modes` (color light).
Every subscription costs heap, so skip what the tile does not read.
4. **Widget** — an `obj` under `tile_scroll`'s `widgets:`, at
`x: ${GRID_COL1_X}` / `y: ${GRID_ROW5_Y}`. Pass `NAN` (floats) or `""`
(strings) to `open_value_overlay` for any sensor you skipped in step 3.
5. **Ack script** — a `tile9_ack` copy under `script:`.
6. **Refresh lambda** — three lines in `ui_refresh`: a `t9_on` line next to the
others, a `set_value_auto(id(t9_value), …)` call, and an `apply_tile(t9_on, …)`
call. Climate tiles also need a `sync_open_climate` line.
Watch `sensor.*_heap_free` in Home Assistant after flashing. This board has no
PSRAM, so free heap is the real ceiling on how many tiles fit.
---
## Finding Material Design Icons
1. Go to [pictogrammers.com/library/mdi](https://pictogrammers.com/library/mdi/)
2. Search for an icon name
3. Click the icon → copy the Unicode codepoint (e.g. `F0769`)
4. Format it as `"\U000F0769"` (always 8 hex digits, padded with leading zeros)
The icon fonts contain the tile icon set from `screen_manager/app/tile_icons.py` (150 icons plus the weather glyphs). An icon outside that set shows as a blank space: add it to that file and run `python3 tools/generate_icons.py` and `python3 tools/generate_packages.py`.
-6
View File
@@ -1,6 +0,0 @@
#pragma once
#if __has_include("esphome/components/smart_display/alert_overlay.h")
#include "esphome/components/smart_display/alert_overlay.h"
#else
#include "components/smart_display/alert_overlay.h"
#endif
-6
View File
@@ -1,6 +0,0 @@
#pragma once
#if __has_include("esphome/components/smart_display/backlight_fade.h")
#include "esphome/components/smart_display/backlight_fade.h"
#else
#include "components/smart_display/backlight_fade.h"
#endif
+6 -3
View File
@@ -192,10 +192,13 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
lv_obj_set_style_outline_opa(row.slider, LV_OPA_20, LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : 2000, i == 0 ? 360 : 6500);
} else {
// Track and fill corners, handle and shortest fill as in Home Assistant's 42 px control slider.
// Corners, handle and shortest fill as in Home Assistant's 42 px control slider. The fill keeps
// the track's radius: a smaller one makes LVGL draw the fill into its own full-size buffer on
// every redraw (20 KB on the CYD) and clip it to the track.
lv_obj_set_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h);
lv_obj_set_style_radius(row.slider, track_h * 12 / 42, LV_PART_MAIN);
lv_obj_set_style_radius(row.slider, track_h * 8 / 42, LV_PART_INDICATOR);
int corner = track_h * 12 / 42;
lv_obj_set_style_radius(row.slider, corner, LV_PART_MAIN);
lv_obj_set_style_radius(row.slider, corner, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFDEFC3), LV_PART_MAIN);
+77 -27
View File
@@ -1,6 +1,7 @@
#pragma once
#include <algorithm>
#include <array>
#include <memory>
#include <string>
#include <vector>
#include <cmath>
@@ -26,8 +27,20 @@ inline bool valid_entity(const std::string &entity) {
if (domain == allowed) return true;
return false;
}
inline std::string state_revision(const std::string &state, const std::string &attributes) {
return state + "\n" + attributes;
// A tile keeps a fingerprint (FNV-1a) of its last state message instead of a copy of it. It is also an
// ArduinoJson writer: the firmware hashes the attributes while serializing them, without a string.
struct Fingerprint {
uint32_t value = 2166136261u;
size_t write(uint8_t c) { value = (value ^ c) * 16777619u; return 1; }
size_t write(const uint8_t *data, size_t size) { for (size_t i = 0; i < size; ++i) write(data[i]); return size; }
void add(const std::string &text) { write(reinterpret_cast<const uint8_t *>(text.data()), text.size()); }
};
inline uint32_t state_revision(const std::string &state, const std::string &attributes) {
Fingerprint f;
f.add(state);
f.write('\n');
f.add(attributes);
return f.value;
}
struct Forecast { std::string day, condition; float high = NAN, low = NAN, rain = NAN, mm = NAN; };
struct Hour { std::string time, condition; float temp = NAN, rain = NAN, mm = NAN; };
@@ -41,59 +54,96 @@ struct Choice {
std::string entity, current, roles, sent;
std::vector<std::string> values, labels;
};
struct Tile {
std::string entity, name, state, unit, modes, hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode;
std::array<std::string, 4> fan_speeds;
unsigned fan_speed_count = 0;
// What only some tiles carry: climate modes, a select's options, weather, sun and timer times, a media
// title, the vacuum rows. A light or a sensor has none of it, so a tile holds this block only while its
// state needs one: twenty tiles with these fields inline took 24 KB of the CYD's RAM, mostly empty.
struct Extra {
// Climate: the modes as JSON lists, the current fan and swing mode, and what it is doing now.
std::string hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode, hvac_action;
// A select's options, at most eight.
std::vector<std::string> options;
// Weather: up to five days and eight hours.
std::vector<Forecast> forecast;
std::vector<Hour> hours;
float wind = NAN, feels = NAN;
std::string wind_unit;
std::string sunrise, sunset, duration, remaining;
uint32_t timer_end = 0;
std::string media_title;
// Vacuum: its own speeds (at most four) and speed, the mode, water and suction rows (see Choice), and
// from sensors of its device the room it is in and whether it charges.
std::vector<std::string> fan_speeds;
std::string fan_speed;
std::vector<Choice> choices;
std::string room;
bool charging = false;
Choice *choice(char kind) { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
bool empty() const {
return hvac_modes.empty() && fan_modes.empty() && swing_modes.empty() && fan_mode.empty() && swing_mode.empty() &&
hvac_action.empty() && options.empty() && forecast.empty() && hours.empty() && std::isnan(wind) &&
std::isnan(feels) && wind_unit.empty() && sunrise.empty() && sunset.empty() && duration.empty() &&
remaining.empty() && !timer_end && media_title.empty() && fan_speeds.empty() && fan_speed.empty() &&
choices.empty() && room.empty() && !charging;
}
};
// The Extra of a tile on the heap, copied along with the tile like an ordinary member.
struct ExtraBox {
std::unique_ptr<Extra> ptr;
ExtraBox() = default;
ExtraBox(const ExtraBox &other) : ptr(other.ptr ? new Extra(*other.ptr) : nullptr) {}
ExtraBox &operator=(const ExtraBox &other) { if (this != &other) ptr.reset(other.ptr ? new Extra(*other.ptr) : nullptr); return *this; }
ExtraBox(ExtraBox &&) noexcept = default;
ExtraBox &operator=(ExtraBox &&) noexcept = default;
};
struct Tile {
std::string entity, name, state, unit, modes;
float brightness = NAN, percentage = NAN, position = NAN;
float current = NAN, target = NAN, humidity = NAN, minimum = 7, maximum = 35, step = 0.5f;
int hue = 0, kelvin = 3000, min_kelvin = 0, max_kelvin = 0;
bool received = false;
bool has_hs_color = false;
int saturation = 0;
std::string tap = "auto", display = "standard", inline_control = "none", media_title;
std::string tap = "auto", display = "standard", inline_control = "none";
bool wide = false;
// Direct control set on a wide card (firmware 0.2.19+); empty keeps the plain card.
std::string controls, device_class, hvac_action;
std::string controls, device_class;
bool muted = false;
// A -/+ edit shows at once and is sent as one call after a short pause; the
// value stays until Home Assistant reports it (or a timeout clears it).
float edit_value = NAN; uint32_t edit_since = 0; bool edit_sent = false;
// Knob position a toggle shows while its command is under way.
bool optimistic_on = false;
std::array<std::string, 8> options;
unsigned option_count = 0, history_hours = 24;
std::array<float,24> history{};
// A sensor's graph: 24 samples over `history_hours`, empty without one.
unsigned history_hours = 24;
std::vector<float> history;
bool has_history = false;
// Weather only: up to five days and eight hours; empty vectors cost nothing on the other tiles.
std::vector<Forecast> forecast;
std::vector<Hour> hours;
float wind = NAN, feels = NAN;
std::string wind_unit;
// When a scene, script or button last ran (unix time), pre-computed by the manager.
uint32_t last_run = 0;
std::string sunrise, sunset, duration, remaining;
uint32_t timer_end = 0;
float battery = NAN, volume = NAN;
// Vacuum only: mode, water and suction rows (see Choice); an empty vector costs nothing elsewhere.
// `room` is where the robot is and `charging` whether it charges, both from sensors of its device.
std::vector<Choice> choices;
std::string room;
bool charging = false;
Choice *choice(char kind) { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
const Choice *choice(char kind) const { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
uint32_t supported = 0, background = 0;
bool transparent = false; // "Background: none": card fill and border hidden, contents unchanged.
std::string icon; // UTF-8 glyph of a chosen icon the icon fonts contain; empty keeps the domain icon.
std::string revision, pending_revision;
uint32_t revision = 0, pending_revision = 0; // state_revision() fingerprints
uint32_t pending_since = 0;
bool pending = false, confirmed = false, local_feedback = false;
ExtraBox extra_box;
const Extra &extra() const { static const Extra none; return extra_box.ptr ? *extra_box.ptr : none; }
Extra *extra_ptr() { return extra_box.ptr.get(); }
Extra &edit_extra() { if (!extra_box.ptr) extra_box.ptr.reset(new Extra()); return *extra_box.ptr; }
// A state message's extras replace the block: it stays allocated while the tile needs one and is freed
// when a state brings none.
void set_extra(Extra &&next) {
if (next.empty()) extra_box.ptr.reset();
else if (extra_box.ptr) *extra_box.ptr = std::move(next);
else extra_box.ptr.reset(new Extra(std::move(next)));
}
Choice *choice(char kind) { return extra_box.ptr ? extra_box.ptr->choice(kind) : nullptr; }
const Choice *choice(char kind) const { for (auto &c : extra().choices) if (c.kind == kind) return &c; return nullptr; }
bool is_switch() const { return domain()=="switch" || domain()=="input_boolean"; }
bool loading(uint32_t now) const { return pending && (now-pending_since < (is_switch()?150u:1000u) || (!confirmed && !local_feedback && now-pending_since < 6000)); }
bool awaiting_action(uint32_t now) const { return loading(now) && !local_feedback; }
void begin(uint32_t now, bool local=false) { pending=true; pending_since=now; confirmed=false; local_feedback=local; pending_revision=revision; }
void observe(const std::string &next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; }
void observe(uint32_t next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; }
std::string domain() const { return entity.substr(0, entity.find('.')); }
bool builtin() const { return domain() == "screen"; }
bool available() const { return builtin() || (received && state != "unknown" && state != "unavailable" && !state.empty()); }
+78 -71
View File
@@ -276,13 +276,15 @@ inline std::string receive(const std::string &payload) {
if (!model.accepts(index, entity)) { result = "Error: outdated tile"; return false; }
Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>();
// ArduinoJson clears its destination string: serialize attributes first, then add state.
std::string attributes; serializeJson(a, attributes);
// The extras count too: a vacuum's mode or water select changes only there.
if (!root["x"].isNull()) { std::string more; serializeJson(root["x"], more); attributes += more; }
std::string revision = state_revision(string(root["state"]), attributes);
// The fingerprint of state, attributes and extras (a vacuum's mode or water select changes only
// there), hashed while serializing so no copy of the message stays behind.
Fingerprint revision;
revision.add(string(root["state"]));
revision.write('\n');
serializeJson(a, revision);
if (!root["x"].isNull()) serializeJson(root["x"], revision);
bool was_confirmed=tile.confirmed;
tile.observe(revision);
tile.observe(revision.value);
if(tile.pending && !tile.local_feedback && !was_confirmed && tile.confirmed)
ESP_LOGI("runtime_action","HA state received entity=%s elapsed=%u ms",entity.c_str(),(unsigned)(esphome::millis()-tile.pending_since));
auto options = root["o"];
@@ -301,39 +303,38 @@ inline std::string receive(const std::string &payload) {
bool was_wide = tile.wide;
tile.wide = string(options["size"]) == "wide";
bool repack = was_wide != tile.wide;
// Pre-computed extras: the manager converts time zones and fetches forecasts.
// Pre-computed extras: the manager converts time zones and fetches forecasts. What only some tiles
// carry is collected in `next` and replaces the tile's Extra at the end (see Tile::set_extra).
auto extra = root["x"];
tile.forecast.clear();
Extra next;
if (extra["days"].is<JsonArray>()) for (JsonVariant day : extra["days"].as<JsonArray>()) {
if (tile.forecast.size() == 5) break;
tile.forecast.emplace_back(); auto &f = tile.forecast.back();
if (next.forecast.size() == 5) break;
next.forecast.emplace_back(); auto &f = next.forecast.back();
f.day = string(day["d"], 3); f.condition = string(day["c"], 20); f.high = number(day["h"]); f.low = number(day["l"]);
f.rain = number(day["p"]); f.mm = number(day["r"]);
}
tile.hours.clear();
if (extra["hours"].is<JsonArray>()) for (JsonVariant hour : extra["hours"].as<JsonArray>()) {
if (tile.hours.size() == 8) break;
tile.hours.emplace_back(); auto &h = tile.hours.back();
if (next.hours.size() == 8) break;
next.hours.emplace_back(); auto &h = next.hours.back();
h.time = string(hour["t"], 5); h.condition = string(hour["c"], 20); h.temp = number(hour["h"]); h.rain = number(hour["p"]); h.mm = number(hour["r"]);
}
tile.last_run = extra["last"].is<unsigned>() ? extra["last"].as<uint32_t>() : 0;
tile.sunrise = string(extra["rise"], 5); tile.sunset = string(extra["set"], 5);
tile.timer_end = extra["end"].is<unsigned>() ? extra["end"].as<uint32_t>() : 0;
tile.duration = string(extra["dur"], 16); tile.remaining = string(extra["rem"], 16);
next.sunrise = string(extra["rise"], 5); next.sunset = string(extra["set"], 5);
next.timer_end = extra["end"].is<unsigned>() ? extra["end"].as<uint32_t>() : 0;
next.duration = string(extra["dur"], 16); next.remaining = string(extra["rem"], 16);
tile.has_history=false;
if (root["history"]["values"].is<JsonArray>()) {
tile.history.fill(NAN);unsigned j=0;
tile.history.assign(24,NAN);unsigned j=0;
for (JsonVariant value:root["history"]["values"].as<JsonArray>()) {
if(j==24) break; tile.history[j++]=number(value); }
tile.has_history=j>0;tile.history_hours=std::clamp(root["history"]["hours"].as<unsigned>(),1u,24u);
}
tile.option_count=0;
} else if (!tile.history.empty()) { tile.history.clear(); tile.history.shrink_to_fit(); }
if (a["options"].is<JsonArray>()) for(JsonVariant option:a["options"].as<JsonArray>()) {
if(tile.option_count==8)break;tile.options[tile.option_count++]=string(option,48); }
if(next.options.size()==8)break;next.options.push_back(string(option,48)); }
tile.battery=number(a["battery_level"]);tile.volume=number(a["volume_level"]);
tile.muted=a["is_volume_muted"].is<bool>() && a["is_volume_muted"].as<bool>();
tile.device_class=string(a["device_class"],24);tile.hvac_action=string(a["hvac_action"],24);
tile.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as<uint32_t>();
tile.device_class=string(a["device_class"],24);next.hvac_action=string(a["hvac_action"],24);
next.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as<uint32_t>();
tile.name = string(root["name"], 80);
tile.state = string(root["state"], 160);
tile.unit = string(a["unit_of_measurement"], 20);
@@ -350,12 +351,12 @@ inline std::string receive(const std::string &payload) {
tile.minimum=number(a["min"],0);tile.maximum=number(a["max"],100);tile.step=number(a["step"],1); }
if(tile.domain()=="weather") {
tile.current=number(a["temperature"]);tile.unit=string(a["temperature_unit"],12);
tile.humidity=number(a["humidity"]);tile.wind=number(a["wind_speed"]);tile.wind_unit=string(a["wind_speed_unit"],8);tile.feels=number(a["apparent_temperature"]);
tile.humidity=number(a["humidity"]);next.wind=number(a["wind_speed"]);next.wind_unit=string(a["wind_speed_unit"],8);next.feels=number(a["apparent_temperature"]);
}
tile.modes = list(a["supported_color_modes"]);
tile.hvac_modes = list(a["hvac_modes"]);
tile.fan_modes = list(a["fan_modes"]); tile.swing_modes = list(a["swing_modes"]);
tile.fan_mode = string(a["fan_mode"], 48); tile.swing_mode = string(a["swing_mode"], 48);
next.hvac_modes = list(a["hvac_modes"]);
next.fan_modes = list(a["fan_modes"]); next.swing_modes = list(a["swing_modes"]);
next.fan_mode = string(a["fan_mode"], 48); next.swing_mode = string(a["swing_mode"], 48);
float hue = number(a["hs_color"][0]);
float saturation = number(a["hs_color"][1]);
tile.has_hs_color = std::isfinite(hue) && std::isfinite(saturation);
@@ -365,19 +366,17 @@ inline std::string receive(const std::string &payload) {
if (std::isfinite(kelvin)) tile.kelvin = std::lround(std::clamp(kelvin, 1000.0f, 15000.0f));
tile.min_kelvin = std::clamp(number(a["min_color_temp_kelvin"], 0), 0.0f, 15000.0f);
tile.max_kelvin = std::clamp(number(a["max_color_temp_kelvin"], 0), 0.0f, 15000.0f);
tile.fan_speed = string(a["fan_speed"], 48);
tile.fan_speed_count = 0;
next.fan_speed = string(a["fan_speed"], 48);
if (a["fan_speed_list"].is<JsonArray>()) for (JsonVariant speed : a["fan_speed_list"].as<JsonArray>()) {
if (tile.fan_speed_count == 4) break;
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48);
if (next.fan_speeds.size() == 4) break;
next.fan_speeds.push_back(string(speed, 48));
}
// Vacuum rows (app 0.2.46+): the cleaning mode and water selects of the robot's device and the
// suction speeds to offer, each at most six; the battery sensor when the vacuum has no attribute.
// A chip just tapped keeps its choice while Home Assistant is still busy with it, so another update
// of the robot (its battery, say) does not flip the row back for a moment.
std::vector<std::pair<char, std::string>> tapped;
if (tile.loading(esphome::millis())) for (auto &c : tile.choices) if (!c.sent.empty()) tapped.emplace_back(c.kind, c.sent);
tile.choices.clear();
if (tile.loading(esphome::millis())) for (auto &c : tile.extra().choices) if (!c.sent.empty()) tapped.emplace_back(c.kind, c.sent);
auto choice = [&](const char *key, char kind) {
auto c = extra[key];
if (!c["o"].is<JsonArray>()) return;
@@ -388,13 +387,14 @@ inline std::string receive(const std::string &payload) {
if (c["l"].is<JsonArray>()) for (JsonVariant label : c["l"].as<JsonArray>()) {
if (row.labels.size() == row.values.size()) break; row.labels.push_back(string(label, 24)); }
while (row.labels.size() < row.values.size()) row.labels.push_back(row.values[row.labels.size()]);
if (!row.values.empty()) tile.choices.push_back(std::move(row));
if (!row.values.empty()) next.choices.push_back(std::move(row));
};
if (tile.domain() == "vacuum") { choice("mode", 'm'); choice("water", 'w'); choice("fan", 's'); tile_controls::settle_suction(tile); }
for (auto &[kind, value] : tapped) if (auto *c = tile.choice(kind)) if (c->current != value) c->sent = value;
if (tile.domain() == "vacuum") { choice("mode", 'm'); choice("water", 'w'); choice("fan", 's'); tile_controls::settle_suction(next); }
for (auto &[kind, value] : tapped) if (auto *c = next.choice(kind)) if (c->current != value) c->sent = value;
if (!std::isfinite(tile.battery)) tile.battery = number(extra["bat"]);
tile.charging = extra["chg"].is<int>() && extra["chg"].as<int>() == 1;
tile.room = string(extra["room"], 32);
next.charging = extra["chg"].is<int>() && extra["chg"].as<int>() == 1;
next.room = string(extra["room"], 32);
tile.set_extra(std::move(next));
// Home Assistant reports the edited value: the -/+ pill follows its state again.
if(std::isfinite(tile.edit_value) && tile.edit_sent && std::fabs(tile_controls::edit_target(tile)-tile.edit_value)<0.051f)tile.edit_value=NAN;
tile.received = true;
@@ -530,7 +530,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
if(cmd==20)action("media_player.media_play_pause",t.entity);
if(cmd==21)action("media_player.media_previous_track",t.entity);
if(cmd==22)action("media_player.media_next_track",t.entity);
if(cmd>=30 && cmd<38 && cmd-30<(int)t.option_count)action(t.domain()+".select_option",t.entity,"option",t.options[cmd-30]);
if(cmd>=30 && cmd<38 && cmd-30<(int)t.extra().options.size())action(t.domain()+".select_option",t.entity,"option",t.extra().options[cmd-30]);
if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity);
if(cmd==41)action("timer.cancel",t.entity);
},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
@@ -577,6 +577,7 @@ inline lv_obj_t *detail_card(int x,int y,int w,int h){
// Two cards: "now" with the next hours, and the coming days. Bold highs, muted lows,
// rain in blue with a drop, so the eye finds temperature first and rain second.
inline void render_weather_detail(const Tile &t,bool large,int width,int height,int pad){
const Extra &weather=t.extra();
if(detail_status){lv_obj_add_flag(detail_status,LV_OBJ_FLAG_HIDDEN);detail_status=nullptr;}
const lv_font_t *big=watch_value_font?watch_value_font:detail_font;
const lv_font_t *icon_font=widgets[0].icon_font?widgets[0].icon_font:detail_font;
@@ -587,7 +588,7 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
int text_h=lv_font_get_line_height(detail_font),small_h=lv_font_get_line_height(small),mini_h=lv_font_get_line_height(mini),tiny_h=lv_font_get_line_height(tiny);
int icon_h=lv_font_get_line_height(icon_font),big_h=lv_font_get_line_height(big),hero=std::max(icon_h,big_h);
int card_pad=large?14:7,inner=width-2*pad-2*card_pad;
unsigned columns=std::min<unsigned>(t.hours.size(),6);
unsigned columns=std::min<unsigned>(weather.hours.size(),6);
int hours_h=columns?small_h+mini_h+text_h+small_h+(large?16:6):0;
int card_a_h=card_pad+hero+(columns?(large?14:8)+hours_h:0)+card_pad;
int y=large?62:38;
@@ -601,15 +602,15 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
int lines_h=text_h+small_h+(large?2:0);
detail_text(now,t.available()?weather_text(t.state):"Unavailable",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
std::string details;
if(std::isfinite(t.feels)){snprintf(b,sizeof(b),"Feels like %.0f°",t.feels);details=b;}
if(std::isfinite(weather.feels)){snprintf(b,sizeof(b),"Feels like %.0f°",weather.feels);details=b;}
if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);}
if(std::isfinite(t.wind)){snprintf(b,sizeof(b),"%.0f %s",t.wind,t.wind_unit.empty()?"km/h":t.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);}
if(std::isfinite(weather.wind)){snprintf(b,sizeof(b),"%.0f %s",weather.wind,weather.wind_unit.empty()?"km/h":weather.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);}
detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted);
// Next hours inside the same card: time, icon, temperature, rain per column.
if(columns){
int hy=cy+hero+(large?14:8),col=inner/(int)columns;
for(unsigned i=0;i<columns;++i){
const auto &h=t.hours[i];int x=card_pad+i*col;
const auto &h=weather.hours[i];int x=card_pad+i*col;
detail_text(now,h.time,x,hy,col,small,LV_TEXT_ALIGN_CENTER,muted);
detail_text(now,weather_icon(h.condition),x,hy+small_h+(large?4:1),col,mini,LV_TEXT_ALIGN_CENTER,weather_accent(h.condition));
detail_text(now,std::isfinite(h.temp)?degrees(h.temp):"",x,hy+small_h+mini_h+(large?8:2),col,detail_font,LV_TEXT_ALIGN_CENTER,ink);
@@ -618,16 +619,16 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
}
y+=card_a_h+(large?12:6);
// Coming days: a heading and a card with one row per day.
if(!t.forecast.size()){detail_text(detail_root,"No daily forecast from Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(!weather.forecast.size()){detail_text(detail_root,"No daily forecast from Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(large){detail_text(detail_root,"Coming days",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
int card_b_h=height-y-(large?10:4);
auto *days=detail_card(pad,y,width-2*pad,card_b_h);
int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)t.forecast.size();
int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)weather.forecast.size();
int day_w=large?46:26,icon_x=card_pad+day_w,cond_x=icon_x+mini_h+(large?12:5);
int high_w=large?52:30,low_w=large?46:28,rain_w=large?120:60,drop_w=tiny_h+(large?4:2);
int temps_x=width-2*pad-card_pad-high_w-low_w,rain_x=temps_x-(large?14:6)-rain_w;
for(unsigned i=0;i<t.forecast.size();++i){
const auto &f=t.forecast[i];int ry=row_pad+i*row,tcy=ry+(row-text_h)/2,scy=ry+(row-small_h)/2;
for(unsigned i=0;i<weather.forecast.size();++i){
const auto &f=weather.forecast[i];int ry=row_pad+i*row,tcy=ry+(row-text_h)/2,scy=ry+(row-small_h)/2;
detail_text(days,f.day,card_pad,tcy,day_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
detail_text(days,weather_icon(f.condition),icon_x,ry+(row-mini_h)/2,mini_h+6,mini,LV_TEXT_ALIGN_LEFT,weather_accent(f.condition));
detail_text(days,weather_text(f.condition),cond_x,scy,std::max(1,rain_x-cond_x-4),small,LV_TEXT_ALIGN_LEFT,muted);
@@ -689,7 +690,7 @@ inline int vacuum_power(lv_obj_t *parent,const Tile &t,int x,int y,const lv_font
const lv_font_t *bolt_font=large && watch_icon_font?watch_icon_font:mini_icon_font;
std::string percent=std::to_string((int)std::lround(t.battery))+"%";
int h=lv_font_get_line_height(font),meter_w=large?30:22,meter_h=large?15:11,gap=large?10:6,words=text_width(percent,font);
int bolt_w=t.charging && bolt_font?text_width("\U000F0241",bolt_font):0;
int bolt_w=t.extra().charging && bolt_font?text_width("\U000F0241",bolt_font):0;
int width=meter_w+3+gap+words+(bolt_w?gap/2+bolt_w:0);
if(!parent)return width;
vacuum_battery(parent,x,y+(h-meter_h)/2,meter_w,meter_h,t.battery);
@@ -773,11 +774,11 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
bool locate=large && (!t.supported || (t.supported & 512));
int key=48,text_x=edge+robot+(large?18:12),text_w=inner-text_x-(locate?key+2*edge:edge);
int line=lv_font_get_line_height(big),text_h=lv_font_get_line_height(text),space=large?6:3;
bool room=on_the_way && !t.room.empty();
bool room=on_the_way && !t.extra().room.empty();
int block=line+(room?space+text_h:0)+(battery?space+text_h:0),ty=(hero_h-block)/2;
detail_badge_status=detail_text(hero,state,text_x,ty,text_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
ty+=line;
if(room){ty+=space;detail_text(hero,t.room,text_x,ty,text_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);ty+=text_h;}
if(room){ty+=space;detail_text(hero,t.extra().room,text_x,ty,text_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);ty+=text_h;}
if(battery){ty+=space;vacuum_power(hero,t,text_x,ty,text,large);}
if(locate){
auto *find=detail_button("",pad+inner-edge-key-6,y+(hero_h-key)/2,key,key,3);
@@ -793,8 +794,8 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
detail_shape(detail_root,pad+2,y+(line-dot)/2,dot,dot,look.accent,dot/2);
int words=inner-dot-10-power_w-12,state_w=std::min(words,text_width(state,text)+2);
detail_badge_status=detail_text(detail_root,state,pad+dot+10,y,state_w,text,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
if(on_the_way && !t.room.empty() && words-state_w>24)
detail_text(detail_root," · "+t.room,pad+dot+10+state_w,y,words-state_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);
if(on_the_way && !t.extra().room.empty() && words-state_w>24)
detail_text(detail_root," · "+t.extra().room,pad+dot+10+state_w,y,words-state_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);
if(battery)vacuum_power(detail_root,t,pad+inner-power_w,y,text,false);
y+=line+(gap+4);
}
@@ -876,15 +877,16 @@ inline void show_detail(unsigned index){
if(!std::isfinite(minimum)){detail_label(detail_root,"No numeric HA history",pad,top,width-2*pad);return;}
char text[100];snprintf(text,sizeof(text),"%u hours / %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad);
int chart_y=top+(large?46:28),chart_h=height-chart_y-30,bar_w=(width-pad*2)/24;
for(unsigned i=0;i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2;
for(unsigned i=0;i<t.history.size() && i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2;
auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,pad+i*bar_w,chart_y+chart_h-h);lv_obj_set_size(bar,std::max(2,bar_w-2),h);lv_obj_set_style_bg_color(bar,lv_color_hex(0x16A5E6),0);lv_obj_set_style_bg_opa(bar,LV_OPA_COVER,0);}
detail_label(detail_root,std::to_string(t.history_hours)+" hours ago",pad,height-24,(width-2*pad)/2);
auto *now=detail_label(detail_root,"Now",width/2,height-24,width/2-pad);lv_obj_set_style_text_align(now,LV_TEXT_ALIGN_RIGHT,0);
}else if(d=="select"||d=="input_select"){
for(unsigned i=0;i<t.option_count;++i)detail_button(t.options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
const auto &options=t.extra().options;
for(unsigned i=0;i<options.size();++i)detail_button(options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
}else if(d=="number"||d=="input_number"||d=="media_player"){
if(d=="media_player"){
detail_label(detail_root,t.media_title,pad,top,width-2*pad);top+=large?45:25;
detail_label(detail_root,t.extra().media_title,pad,top,width-2*pad);top+=large?45:25;
int w=(width-pad*2-2*gap)/3;
detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
}
@@ -898,8 +900,8 @@ inline void show_detail(unsigned index){
detail_button(t.state=="active"?"Pause":"Start",pad,top+(large?50:30),cw,bh,40);
detail_button("Cancel",pad+cw+gap,top+(large?50:30),cw,bh,41);
}else if(d=="sun"){
detail_label(detail_root,"Sunrise "+t.sunrise,pad,top,width-2*pad);
detail_label(detail_root,"Sunset "+t.sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
detail_label(detail_root,"Sunrise "+t.extra().sunrise,pad,top,width-2*pad);
detail_label(detail_root,"Sunset "+t.extra().sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
}
}
}
@@ -988,9 +990,10 @@ inline std::string last_run_text(uint32_t epoch) {
return "Last " + std::to_string(when.day_of_month) + " " + month_short(when);
}
inline std::string timer_text(const Tile &t) {
if (t.state == "active") { uint32_t now = now_epoch(); return countdown(t.timer_end > now && now ? t.timer_end - now : 0); }
if (t.state == "paused") return "Paused " + countdown(duration_seconds(t.remaining));
return t.duration.empty() ? "Off" : countdown(duration_seconds(t.duration));
const Extra &x = t.extra();
if (t.state == "active") { uint32_t now = now_epoch(); return countdown(x.timer_end > now && now ? x.timer_end - now : 0); }
if (t.state == "paused") return "Paused " + countdown(duration_seconds(x.remaining));
return x.duration.empty() ? "Off" : countdown(duration_seconds(x.duration));
}
inline std::string weekday_text(const esphome::ESPTime &now) {
static const char *days[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
@@ -1060,8 +1063,11 @@ inline int slider_handle_width(int height) { return height > 20 ? 4 : 2; }
inline int slider_stub(int height) { return std::max(height / 3, 2 * std::max(1, height / 8) + slider_handle_width(height)); }
inline void slider_handle(lv_obj_t *slider, int width, int height) {
int handle = slider_handle_width(height), inset = std::max(1, height / 8) + handle / 2, half = height >> 1;
set_number(slider, LV_STYLE_RADIUS, height > 20 ? height * 12 / 42 : LV_RADIUS_CIRCLE, LV_PART_MAIN);
set_number(slider, LV_STYLE_RADIUS, height > 20 ? height * 8 / 42 : LV_RADIUS_CIRCLE, LV_PART_INDICATOR);
// Track and fill share one radius: a fill rounded less than its track makes LVGL draw the fill into
// a buffer of its own size on every redraw (15 KB on a CYD card), which the CYD's heap can't spare.
int corner = height > 20 ? height * 12 / 42 : LV_RADIUS_CIRCLE;
set_number(slider, LV_STYLE_RADIUS, corner, LV_PART_MAIN);
set_number(slider, LV_STYLE_RADIUS, corner, LV_PART_INDICATOR);
set_number(slider, LV_STYLE_PAD_LEFT, inset + handle / 2 - half, LV_PART_KNOB);
set_number(slider, LV_STYLE_PAD_RIGHT, handle / 2 - inset - (height - half), LV_PART_KNOB);
set_number(slider, LV_STYLE_PAD_TOP, -(height / 4), LV_PART_KNOB);
@@ -1324,7 +1330,7 @@ inline void render_forecast(Widgets &w,const Tile &t,bool large,int width,int he
part_label(w,2,w.value_font,0,y+std::max(icon_h,temp_h)+2,left-4,LV_TEXT_ALIGN_LEFT,weather_text(t.state));
int column=(width-left)/5,day_h=lv_font_get_line_height(title_font),icon_col=lv_font_get_line_height(day_icon);
for(unsigned k=0;k<5;++k){
int x=left+k*column;bool has=k<t.forecast.size();const auto &f=t.forecast[k];
static const Forecast no_day;int x=left+k*column;bool has=k<t.extra().forecast.size();const auto &f=has?t.extra().forecast[k]:no_day;
char temps[24];if(has && std::isfinite(f.high))snprintf(temps,sizeof(temps),std::isfinite(f.low)?"%.0f/%.0f":"%.0f",f.high,f.low);else temps[0]=0;
if(large){
int rows=day_h+icon_col+text_h,top=std::max(0,(height-rows)/2);
@@ -1345,7 +1351,7 @@ inline void render_graph(Widgets &w,const Tile &t,bool large,int x,int y,int wid
begin_extra(w,"graph",x+width,y+height);
float minimum=INFINITY,maximum=-INFINITY;for(float v:t.history)if(std::isfinite(v)){minimum=std::min(minimum,v);maximum=std::max(maximum,v);}
lv_point_precise_t raw[24];unsigned n=0;int stroke=large?3:2,top=stroke;
for(unsigned i=0;i<24;++i){
for(unsigned i=0;i<t.history.size() && i<24;++i){
if(!std::isfinite(t.history[i]))continue;
float level=maximum>minimum?(t.history[i]-minimum)/(maximum-minimum):0.5f;
raw[n++]={(lv_value_precise_t)(stroke/2+i*(width-stroke-1)/23),(lv_value_precise_t)(height-stroke/2-1-level*(height-stroke-1-top))};
@@ -1366,10 +1372,10 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei
int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font);
int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0;
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Sun":t.name);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"rise "+t.sunrise);
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.sunset);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"rise "+t.extra().sunrise);
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.extra().sunset);
auto now=now_time?now_time():esphome::ESPTime{};
int rise=minutes_of(t.sunrise),set=minutes_of(t.sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
int rise=minutes_of(t.extra().sunrise),set=minutes_of(t.extra().sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
bool day=t.state=="above_horizon";float fraction=0.5f;
if(rise>=0 && set>=0 && minute>=0){
if(day){int span=(set-rise+1440)%1440;if(!span)span=1;fraction=std::clamp(float((minute-rise+1440)%1440)/span,0.0f,1.0f);}
@@ -1651,7 +1657,7 @@ inline void render_slot(size_t slot) {
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
else if (d == "sun") value = !t.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
else if (value == "on") value = "On";
@@ -1670,8 +1676,8 @@ inline void render_slot(size_t slot) {
bool large_tile=lv_obj_get_height(w.tile)>80;
lap(swipe_profile::TEXT);
// Cards that replace the name/status layout entirely.
bool clock=t.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.forecast.size()>0 && fresh() && t.available();
bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.sunrise.empty() && !t.sunset.empty() && fresh() && t.available();
bool clock=t.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.extra().forecast.size()>0 && fresh() && t.available();
bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.extra().sunrise.empty() && !t.extra().sunset.empty() && fresh() && t.available();
bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock;
bool custom=clock||forecast||sunpath;
if(!large_tile)pad_vertical(w.tile,watch||custom||graph?2:4);
@@ -2276,7 +2282,7 @@ inline void tick() {
auto &t=model.tiles[i];if(!t.pending || t.loading(esphome::millis()))continue;
t.pending=false;card(i);
// A vacuum chip Home Assistant never confirmed goes back to what the robot reports.
bool sent=false;for(auto &c:t.choices){sent=sent||!c.sent.empty();c.sent.clear();}
bool sent=false;if(auto *x=t.extra_ptr())for(auto &c:x->choices){sent=sent||!c.sent.empty();c.sent.clear();}
if(sent)refresh_detail(i);
}
// A -/+ edit goes out as one call once the finger rests; a value HA never reports is dropped after a while.
@@ -2310,6 +2316,7 @@ inline void tick() {
inline std::string vacuum_option(unsigned index) {
if (active_index < 0 || static_cast<size_t>(active_index) >= model.count) return {};
auto &tile = model.tiles[active_index];
return index < tile.fan_speed_count ? tile.fan_speeds[index] : "";
const auto &speeds = tile.extra().fan_speeds;
return index < speeds.size() ? speeds[index] : "";
}
}
+18 -17
View File
@@ -136,7 +136,7 @@ inline const char *media_state_text(const std::string &state) {
inline std::string status_text(const Tile &t) {
auto d = t.domain(); char b[48];
if (d == "climate") {
std::string text = climate_action_text(t.hvac_action);
std::string text = climate_action_text(t.extra().hvac_action);
if (text.empty()) text = climate_mode_text(t.state);
if (std::isfinite(t.current)) { snprintf(b, sizeof(b), " · %.1f°", t.current); text += b; }
return text;
@@ -147,7 +147,8 @@ inline std::string status_text(const Tile &t) {
return text;
}
if (d == "media_player") {
std::string text = (t.state == "playing" || t.state == "paused") && !t.media_title.empty() ? t.media_title : media_state_text(t.state);
const std::string &title = t.extra().media_title;
std::string text = (t.state == "playing" || t.state == "paused") && !title.empty() ? title : media_state_text(t.state);
if (std::isfinite(t.volume) && (t.supported & feature::MEDIA_VOLUME_SET)) { snprintf(b, sizeof(b), " · %d%%", (int) std::lround(t.volume * 100)); text += b; }
return text;
}
@@ -184,19 +185,19 @@ inline unsigned keys_for(const Tile &t, std::array<Key, 3> &out) {
} else if (c == "mode") {
// Three keys fit beside the name; Home Assistant lists modes in device order.
for (const char *mode : {"off", "heat", "cool", "heat_cool", "auto", "dry", "fan_only"})
if (n < 3 && has_mode(t.hvac_modes, mode)) add(mode_icon(mode), HVAC_MODE, false, t.state == mode, mode);
if (n < 3 && has_mode(t.extra().hvac_modes, mode)) add(mode_icon(mode), HVAC_MODE, false, t.state == mode, mode);
} else if (c == "chevrons") {
add(glyph::LEFT, SELECT_PREVIOUS, t.option_count < 2);
add(glyph::RIGHT, SELECT_NEXT, t.option_count < 2);
add(glyph::LEFT, SELECT_PREVIOUS, t.extra().options.size() < 2);
add(glyph::RIGHT, SELECT_NEXT, t.extra().options.size() < 2);
}
return n;
}
inline std::string neighbour_option(const Tile &t, int direction) {
if (!t.option_count) return {};
int current = 0;
for (unsigned i = 0; i < t.option_count; ++i) if (t.options[i] == t.state) current = i;
int next = (current + direction + (int) t.option_count) % (int) t.option_count;
return t.options[next];
const auto &options = t.extra().options;
if (options.empty()) return {};
int current = 0, count = (int) options.size();
for (int i = 0; i < count; ++i) if (options[i] == t.state) current = i;
return options[(current + direction + count) % count];
}
inline const char *run_label(const std::string &domain) {
if (domain == "scene") return "Activate";
@@ -271,15 +272,15 @@ inline std::string speed_label(const std::string &speed) {
}
// The suction row a vacuum card shows: the manager's (filtered, labelled) speeds, else the first four
// of the vacuum's own list. Its value is always the vacuum's fan_speed.
inline void settle_suction(Tile &t) {
auto *row = t.choice('s');
if (!row && t.fan_speed_count) {
inline void settle_suction(runtime_tiles::Extra &x) {
auto *row = x.choice('s');
if (!row && !x.fan_speeds.empty()) {
runtime_tiles::Choice legacy; legacy.kind = 's';
for (unsigned i = 0; i < t.fan_speed_count; ++i) { legacy.values.push_back(t.fan_speeds[i]); legacy.labels.push_back(speed_label(t.fan_speeds[i])); }
t.choices.push_back(std::move(legacy));
row = &t.choices.back();
for (const auto &speed : x.fan_speeds) { legacy.values.push_back(speed); legacy.labels.push_back(speed_label(speed)); }
x.choices.push_back(std::move(legacy));
row = &x.choices.back();
}
if (row) row->current = t.fan_speed;
if (row) row->current = x.fan_speed;
}
// The service call behind a chip: a select option on the device, or the vacuum's own fan speed.
inline Action choice_action(const Tile &t, char kind, const std::string &value) {
-6
View File
@@ -1,6 +0,0 @@
#pragma once
#if __has_include("esphome/components/smart_display/cyd_ui.h")
#include "esphome/components/smart_display/cyd_ui.h"
#else
#include "components/smart_display/cyd_ui.h"
#endif
-215
View File
@@ -1,215 +0,0 @@
# Copy via: python tools/new_device.py --name display-kitchen
# Only edit this local profile and calibration.yaml for your screen.
packages:
display: !include home-like-2432s028.yaml
calibration: !include calibration.yaml
substitutions:
DEVICE_NAME: "cyd-new"
DEVICE_FRIENDLY_NAME: "My CYD"
ROOM_NAME: "Living room"
TILE_COUNT: "6"
DIRECT_ACTIONS: "false" # Set to true only after pairing/testing.
AUTO_DIM_TIMEOUT: "600"
CALIBRATION_ON_BOOT: "false"
# Tile 1
TILE1_ENTITY: "light.example_lamp"
TILE1_STATE_ENTITY: "light.example_lamp"
TILE1_TITLE: "Lamp"
TILE1_TYPE: "light"
TILE1_TAP_ACTION: "auto"
TILE1_TAP_SERVICE: ""
TILE1_TAP_PARAM_KEY: ""
TILE1_TAP_PARAM_VAL: ""
TILE1_LONGPRESS: "slider"
TILE1_LONGPRESS_SLIDER: "auto"
TILE1_LONGPRESS_OFF_VALUE: "0"
TILE1_LONGPRESS_ACTION: ""
TILE1_LONGPRESS_ACTION_TYPE: ""
TILE1_LONGPRESS_ACTION_SERVICE: ""
TILE1_VALUE_MODE: "auto"
TILE1_LABEL_ON: "On"
TILE1_LABEL_OFF: "Off"
TILE1_ACTIVE_STATE: ""
# Tile 2
TILE2_ENTITY: "switch.example_plug"
TILE2_STATE_ENTITY: "switch.example_plug"
TILE2_TITLE: "Plug"
TILE2_TYPE: "switch"
TILE2_TAP_ACTION: "auto"
TILE2_TAP_SERVICE: ""
TILE2_TAP_PARAM_KEY: ""
TILE2_TAP_PARAM_VAL: ""
TILE2_LONGPRESS: "none"
TILE2_LONGPRESS_SLIDER: "auto"
TILE2_LONGPRESS_OFF_VALUE: "0"
TILE2_LONGPRESS_ACTION: ""
TILE2_LONGPRESS_ACTION_TYPE: ""
TILE2_LONGPRESS_ACTION_SERVICE: ""
TILE2_VALUE_MODE: "auto"
TILE2_LABEL_ON: "On"
TILE2_LABEL_OFF: "Off"
TILE2_ACTIVE_STATE: ""
# Tile 3
TILE3_ENTITY: "scene.example_evening"
TILE3_STATE_ENTITY: "scene.example_evening"
TILE3_TITLE: "Evening"
TILE3_TYPE: "scene"
TILE3_TAP_ACTION: "auto"
TILE3_TAP_SERVICE: ""
TILE3_TAP_PARAM_KEY: ""
TILE3_TAP_PARAM_VAL: ""
TILE3_LONGPRESS: "none"
TILE3_LONGPRESS_SLIDER: "auto"
TILE3_LONGPRESS_OFF_VALUE: "0"
TILE3_LONGPRESS_ACTION: ""
TILE3_LONGPRESS_ACTION_TYPE: ""
TILE3_LONGPRESS_ACTION_SERVICE: ""
TILE3_VALUE_MODE: "auto"
TILE3_LABEL_ON: "On"
TILE3_LABEL_OFF: "Off"
TILE3_ACTIVE_STATE: ""
# Tile 4
TILE4_ENTITY: "script.example_all_off"
TILE4_STATE_ENTITY: "script.example_all_off"
TILE4_TITLE: "All off"
TILE4_TYPE: "script"
TILE4_TAP_ACTION: "auto"
TILE4_TAP_SERVICE: ""
TILE4_TAP_PARAM_KEY: ""
TILE4_TAP_PARAM_VAL: ""
TILE4_LONGPRESS: "none"
TILE4_LONGPRESS_SLIDER: "auto"
TILE4_LONGPRESS_OFF_VALUE: "0"
TILE4_LONGPRESS_ACTION: ""
TILE4_LONGPRESS_ACTION_TYPE: ""
TILE4_LONGPRESS_ACTION_SERVICE: ""
TILE4_VALUE_MODE: "auto"
TILE4_LABEL_ON: "On"
TILE4_LABEL_OFF: "Off"
TILE4_ACTIVE_STATE: ""
# Tile 5
TILE5_ENTITY: "climate.example_ac"
TILE5_STATE_ENTITY: "climate.example_ac"
TILE5_TITLE: "AC"
TILE5_TYPE: "climate"
TILE5_TAP_ACTION: "toggle"
TILE5_TAP_SERVICE: "climate.toggle"
TILE5_TAP_PARAM_KEY: ""
TILE5_TAP_PARAM_VAL: ""
TILE5_LONGPRESS: "slider"
TILE5_LONGPRESS_SLIDER: "auto"
TILE5_LONGPRESS_OFF_VALUE: "0"
TILE5_LONGPRESS_ACTION: ""
TILE5_LONGPRESS_ACTION_TYPE: ""
TILE5_LONGPRESS_ACTION_SERVICE: ""
TILE5_VALUE_MODE: "auto"
TILE5_LABEL_ON: "On"
TILE5_LABEL_OFF: "Off"
TILE5_ACTIVE_STATE: ""
# Tile 6
TILE6_ENTITY: "vacuum.example_robot"
TILE6_STATE_ENTITY: "vacuum.example_robot"
TILE6_TITLE: "Vacuum"
TILE6_TYPE: "vacuum"
TILE6_TAP_ACTION: "custom"
TILE6_TAP_SERVICE: "vacuum.start"
TILE6_TAP_PARAM_KEY: ""
TILE6_TAP_PARAM_VAL: ""
TILE6_LONGPRESS: "vacuum"
TILE6_LONGPRESS_SLIDER: "auto"
TILE6_LONGPRESS_OFF_VALUE: "0"
TILE6_LONGPRESS_ACTION: ""
TILE6_LONGPRESS_ACTION_TYPE: ""
TILE6_LONGPRESS_ACTION_SERVICE: ""
TILE6_VALUE_MODE: "auto"
TILE6_LABEL_ON: "On"
TILE6_LABEL_OFF: "Off"
TILE6_ACTIVE_STATE: ""
# Tile 7 (hidden while TILE_COUNT is lower)
TILE7_ENTITY: "light.unused_7"
TILE7_STATE_ENTITY: "light.unused_7"
TILE7_TITLE: "Tile 7"
TILE7_TYPE: "light"
TILE7_TAP_ACTION: "auto"
TILE7_TAP_SERVICE: ""
TILE7_TAP_PARAM_KEY: ""
TILE7_TAP_PARAM_VAL: ""
TILE7_LONGPRESS: "none"
TILE7_LONGPRESS_SLIDER: "auto"
TILE7_LONGPRESS_OFF_VALUE: "0"
TILE7_LONGPRESS_ACTION: ""
TILE7_LONGPRESS_ACTION_TYPE: ""
TILE7_LONGPRESS_ACTION_SERVICE: ""
TILE7_VALUE_MODE: "auto"
TILE7_LABEL_ON: "On"
TILE7_LABEL_OFF: "Off"
TILE7_ACTIVE_STATE: ""
# Tile 8 (hidden while TILE_COUNT is lower)
TILE8_ENTITY: "light.unused_8"
TILE8_STATE_ENTITY: "light.unused_8"
TILE8_TITLE: "Tile 8"
TILE8_TYPE: "light"
TILE8_TAP_ACTION: "auto"
TILE8_TAP_SERVICE: ""
TILE8_TAP_PARAM_KEY: ""
TILE8_TAP_PARAM_VAL: ""
TILE8_LONGPRESS: "none"
TILE8_LONGPRESS_SLIDER: "auto"
TILE8_LONGPRESS_OFF_VALUE: "0"
TILE8_LONGPRESS_ACTION: ""
TILE8_LONGPRESS_ACTION_TYPE: ""
TILE8_LONGPRESS_ACTION_SERVICE: ""
TILE8_VALUE_MODE: "auto"
TILE8_LABEL_ON: "On"
TILE8_LABEL_OFF: "Off"
TILE8_ACTIVE_STATE: ""
# Tile 9 (hidden while TILE_COUNT is lower)
TILE9_ENTITY: "light.unused_9"
TILE9_STATE_ENTITY: "light.unused_9"
TILE9_TITLE: "Tile 9"
TILE9_TYPE: "light"
TILE9_TAP_ACTION: "auto"
TILE9_TAP_SERVICE: ""
TILE9_TAP_PARAM_KEY: ""
TILE9_TAP_PARAM_VAL: ""
TILE9_LONGPRESS: "none"
TILE9_LONGPRESS_SLIDER: "auto"
TILE9_LONGPRESS_OFF_VALUE: "0"
TILE9_LONGPRESS_ACTION: ""
TILE9_LONGPRESS_ACTION_TYPE: ""
TILE9_LONGPRESS_ACTION_SERVICE: ""
TILE9_VALUE_MODE: "auto"
TILE9_LABEL_ON: "On"
TILE9_LABEL_OFF: "Off"
TILE9_ACTIVE_STATE: ""
# Tile 10 (hidden while TILE_COUNT is lower)
TILE10_ENTITY: "light.unused_10"
TILE10_STATE_ENTITY: "light.unused_10"
TILE10_TITLE: "Tile 10"
TILE10_TYPE: "light"
TILE10_TAP_ACTION: "auto"
TILE10_TAP_SERVICE: ""
TILE10_TAP_PARAM_KEY: ""
TILE10_TAP_PARAM_VAL: ""
TILE10_LONGPRESS: "none"
TILE10_LONGPRESS_SLIDER: "auto"
TILE10_LONGPRESS_OFF_VALUE: "0"
TILE10_LONGPRESS_ACTION: ""
TILE10_LONGPRESS_ACTION_TYPE: ""
TILE10_LONGPRESS_ACTION_SERVICE: ""
TILE10_VALUE_MODE: "auto"
TILE10_LABEL_ON: "On"
TILE10_LABEL_OFF: "Off"
TILE10_ACTIVE_STATE: ""
-98
View File
@@ -1,98 +0,0 @@
# Acceptance test and handover
Note the date, board variant/MAC, Git commit, ESPHome version, device name, and the
names of the two measurement files. Don't include passwords in the report.
## Without real HA actions
1. Calibrate using [CALIBRATING.md](CALIBRATING.md); flash the result and run a
new, independent measurement with `verify`. A successful fit alone
doesn't prove the firmware coordinates are correct.
2. Flash normal mode. Check that the measurement screen disappears.
3. Check every visible tile and its edges: no other tile should respond.
Tap calmly and rapidly in succession, but expect no repeat within the
deliberate debounce period. A swipe should not trigger a tile action afterward.
4. With 1–6 tiles: no page button. With 7–10: repeat at least twenty times:
Next → tile → Previous. The bottom tile should not respond to the
page button. Open/close overlays; a tap inside one should not leak through.
5. Run the built-in render test via the encrypted API:
```sh
python diagnostics/run_ui_test.py --host display-kitchen.local --name display-kitchen
```
Requires ten successful page checks, fifty overlay cycles, and progress
from real draw callbacks. This doesn't test physical touch or HA actions.
6. Leave the board on for at least 15 minutes. Check that it doesn't restart,
that standby only starts after ten minutes without touch, and that the first
tap wakes the screen without toggling a device.
## With Home Assistant
Only now enable direct actions, reflash, and choose safe test devices.
Check every configured tile with its real HA entity:
- A tap performs exactly one intended action; the feedback is correct.
- Changes from HA also appear on the screen.
- Light slider/color, climate setpoint, and supported modes work where used.
- Vacuum actions and speeds work where supported. Note any missing functionality.
- After a power interruption, Wi-Fi and HA come back; name, calibration, and tiles persist.
- After a brief Wi-Fi/HA interruption, the connection recovers without a USB flash.
Keep logs local only. Check for resets, watchdog messages, repeated
connection errors, and noticeably slow components. A short render test doesn't
prove stability over days; note the duration actually observed.
## Automated development checks
From the repo, with the Python environment active:
```sh
python -m unittest discover -s tests -p 'test_*.py'
c++ -std=c++17 -Wall -Wextra -pedantic tests/test_cyd_ui.cpp -o /tmp/test_cyd_ui
/tmp/test_cyd_ui
c++ -std=c++17 -Wall -Wextra -pedantic tests/test_touch_filter.cpp -o /tmp/test_touch_filter
c++ -std=c++17 -Wall -Wextra -pedantic tests/test_light_controls.cpp -o /tmp/test_light_controls
/tmp/test_light_controls
/tmp/test_touch_filter
c++ -std=c++17 -Wall -Wextra -pedantic tests/test_light_controls.cpp -o /tmp/test_light_controls
/tmp/test_light_controls
python -m esphome config device.yaml
python -m esphome compile device.yaml
```
The C++ commands above are for macOS/Linux with a compiler. On Windows,
the developer can use whatever C++17 toolchain is available. Only flash the
checked profile to the identified board.
## Handover report
```text
Board / MAC:
Git commit / ESPHome version:
Device name / HA entities:
Calibration measurement + independent verification:
Render test:
Physical pagination/touch/standby:
Real HA actions:
Observed stable duration:
Limitations / not yet tested:
Local profile files safely stored:
```
Give the owner the three local YAML files and the measurement files through a
suitable private channel. For a different owner, use the clean repository
or starter ZIP, so they get their own keys and their own panel calibration.
## Light card
- RGB+white light: three sliders; RGB-only: color and brightness; white-only:
white temperature and brightness. A plain dimmer keeps the brightness card.
- Drag slowly across the rainbow, release, and check the physical light.
Repeat for white temperature and brightness. Brightness keeps the color mode.
- Reopen the card and check the received values. Test two lights with
different temperature ranges; no value should leak from the other light.
- The firmware render diagnostic includes `Light sliders: PASS`: twenty preview events
per slider, exactly one commit on release, no duplicate commit, no commit
after an aborted touch, and four capability combinations. This runs with
swapped-in callbacks and doesn't control any real lights.
+20 -11
View File
@@ -11,14 +11,20 @@ panel, a loose connection, unstable power, or random outliers.
## Preparing
1. Use the `device.yaml` from `tools/new_device.py`, with your own
`calibration.yaml` that starts as identity. Don't carry over the personal
correction from the historical base profile.
1. Use the screen's own ESPHome YAML, the one ESP Screens wrote when it was
installed, and add your own `calibration.yaml` to its `packages:` (it starts as
identity). Don't carry over another panel's correction.
```yaml
packages:
display: ... # what ESP Screens put there
calibration: !include calibration.yaml
```
2. Check the data cable and the USB port with `python -m serial.tools.list_ports`.
3. Flash from the repo root:
```sh
python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device <USB_PORT>
python -m esphome -s CALIBRATION_ON_BOOT true run <your-screen>.yaml --device <USB_PORT>
```
4. Physically check for the **dark screen with five white + marks**.
@@ -75,8 +81,8 @@ The script:
placing a new, complete version.
The output contains only the four base bounds and six affine coefficients in
an ESPHome substitutions map. `device.yaml` imports this as the calibration
package. Don't change the coefficients by feel, and don't add a second correction
an ESPHome substitutions map, which the screen's YAML imports as the
calibration package. Don't change the coefficients by feel, and don't add a second correction
in the driver. The `raw=` logs contain the filtered **physical ADC values**,
even once a correction is already set; recalibrating therefore doesn't stack twice.
@@ -87,7 +93,7 @@ A successful fit on existing data is not yet a successful physical test.
Flash the correction, still with the measurement screen:
```sh
python -m esphome -s CALIBRATION_ON_BOOT true run device.yaml --device <USB_PORT>
python -m esphome -s CALIBRATION_ON_BOOT true run <your-screen>.yaml --device <USB_PORT>
```
Close the log reader with Ctrl+C and collect **new taps**:
@@ -109,12 +115,15 @@ test to pass. Report any remaining hardware deviation honestly.
## Back to normal operation
```sh
python -m esphome run device.yaml --device <USB_PORT>
python -m esphome run <your-screen>.yaml --device <USB_PORT>
```
No `-s CALIBRATION_ON_BOOT true` is given here. In `device.yaml`, the
setting stays `false`. Check the actual tiles and navigation per
[ACCEPTANCE.md](ACCEPTANCE.md).
No `-s CALIBRATION_ON_BOOT true` is given here; the profile keeps it `false`.
Then check the real thing: the tiles, the navigation and a few taps in the corners.
Most screens never need this. A CYD shows its calibration on the screen itself when
it first starts, and ESP Screens has **Calibrate touch** for a repeat. This USB route
is for a panel that stays off after that, or for a measurement report.
If calibration mode was opened only via the API, you can close it:
+2 -2
View File
@@ -74,8 +74,8 @@ three times, hold each tap briefly, and follow each next crosshair in turn.
There are five positions. The center checks accuracy. On a failed
measurement, the screen asks you to start over. The correction is stored locally
and survives OTA updates. Use the HA device button **Calibrate touch** to
measure again later. For diagnostics and the older manual installation, see:
[CALIBRATING.md](CALIBRATING.md).
measure again later. For a panel that stays off, or for a measurement report over
USB, see [CALIBRATING.md](CALIBRATING.md).
**Guition:** the GT911 touch mapping is baked into the board profile; there's no ADC calibration.
+13 -24
View File
@@ -13,9 +13,9 @@ or periodic recovery script is needed. The GT911 uses unmirrored coordinates.
## Files
- `guition-4848s040.yaml`: neutral hardware + 480×480 interface.
- `guition-device.example.yaml`: personal device/tile settings.
- `guition-device.yaml`: your local profile, outside Git.
- `guition-4848s040.yaml`: the board profile (hardware + 480×480 interface).
- `packages/guition.yaml`: what a screen builds from; generated from that profile.
- `<your-screen>.yaml`: the screen's own profile, written by ESP Screens, outside Git.
- `secrets.yaml`: local Wi-Fi/API/OTA settings, outside Git.
- `tools/verify_gt911.py`: physical pixel check; no resistive ADC calibration.
@@ -50,30 +50,20 @@ interrupts the transition. ESPHome's light state is synced after the fade,
so the entity in HA and later transitions stay correct.
The existing tile actions, climate control, and vacuum card are preserved.
All card types are available at all twenty runtime positions. Only in
the old manual profile does the vacuum card stay on tile 6 and do positions
8/10 have limitations; see [TILES.md](TILES.md).
Every card type works in all twenty positions.
## New installation
Follow the Python/USB setup in [README.md](../README.md), in a fresh
copy. Create this profile:
```sh
python tools/new_device.py --board guition --name wallbox-kitchen --friendly-name "Wallbox kitchen"
```
This creates `guition-device.yaml` and new secrets; existing files are
not overwritten. Fill in Wi-Fi and change the tile entities. With
multiple boards, use a separate folder per device. The owner's current local
Guition profile may use their existing secrets.
Install the screen from ESP Screens ([EASY_SETUP.md](EASY_SETUP.md)): it writes the
screen's own profile with its name, Wi-Fi reference and unique keys, and flashes it
over USB. Choose the tiles afterwards in the app, not in the YAML.
Check the serial port and chip before uploading. On the tested board,
higher serial speeds proved unreliable when reading. Use 115200 baud:
```sh
python -m esphome config guition-device.yaml
python -m esphome compile guition-device.yaml
python -m esphome config <your-screen>.yaml
python -m esphome compile <your-screen>.yaml
```
Flash via `esphome run` if the USB connection is reliable. For explicit
@@ -119,17 +109,16 @@ desired LVGL rotation and give the wizard the same value, for example
## HA and acceptance
Add the new device via the ESPHome integration (name/IP, port 6053,
API key from secrets). Grant permission for HA actions and use
`DIRECT_ACTIONS: "true"` once the correct entities have been checked.
API key from secrets), then choose its tiles in ESP Screens.
```sh
python diagnostics/run_ui_test.py --host wallbox-kitchen.local --name wallbox-kitchen
python -m unittest discover -s tests -p 'test_*.py'
```
Also go through the physical checks in [ACCEPTANCE.md](ACCEPTANCE.md), using the
GT911 wizard instead of the XPT2046 calibration. Check the display, correct
touches, pagination, long press, climate/vacuum, real HA feedback,
Also go through the physical checks, using the GT911 wizard instead of the XPT2046
calibration. Check the display, correct touches, pagination, long press,
climate and vacuum cards, real HA feedback,
standby, and recovery after a restart. A compile/render test doesn't replace
that physical check. The original CYD board's test outcome
says nothing about this new board.
+50 -4
View File
@@ -21,10 +21,10 @@
1. Update the source profiles and shared components. Run
`python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly.
2. Run all Python tests with aiohttp installed, all `tests/*.cpp`, the
generator with `--check`, and compile both Easy Setup profiles plus the
existing manual profiles. Do that sequentially: profiles with the same
`DEVICE_NAME` share one build folder, and a parallel build can make an upload
pick the wrong `firmware.bin`. Check that no secrets are in Git.
generator with `--check`, and compile both Easy Setup profiles. Do that
sequentially: profiles with the same `DEVICE_NAME` share one build folder, and a
parallel build can make an upload pick the wrong `firmware.bin`. Check that no
secrets are in Git.
3. Test app start, saving, restarting/updating with existing layouts,
reconnecting to HA, and an ESP restart. Test a new card on real
hardware. A good build doesn't replace physical touch acceptance.
@@ -176,6 +176,52 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
### Compatibility 0.2.50 / firmware 0.2.43
Firmware, board profiles and the package generator; the app only raises `FIRMWARE_VERSION`. Storage,
protocol, preferences and keys are unchanged.
- Slider fills: `runtime_tiles::slider_handle` and the light card rows in `light_controls.h` give
`LV_PART_INDICATOR` the track's radius (`height * 12 / 42`, round at 20 px or less). A smaller fill
radius makes LVGL 9.5's `lv_bar` draw the fill into an ARGB8888 layer of its own size on every redraw
and clip it to the track (a 128×30 fill: 15 KB). When that allocation failed, the refresh retried until
the task watchdog reset the CYD (log: `lv_draw_buf_create_ex: No memory: 115x30, cf: 16`).
`tests/test_layer_free.py` rejects layer styles (`opa_layered`, transform rotation/scale/skew, blend
mode, bitmap masks, `clip_corner`, drop shadow, blur) and a fill radius that differs from its track in
both profiles, both packages and the firmware headers. `opa` itself makes no layer in LVGL 9.5.
- `runtime_model.h`: `Tile` keeps the shared fields inline. `Extra` holds climate modes and action, select
options, forecast/hours/wind/feels, sunrise/sunset/timer, media title, vacuum speeds, choices, room and
charging. It lives on the heap only while a state message fills something in (`Tile::set_extra`,
`extra()`, `edit_extra()`, `extra_ptr()`). `options` and `fan_speeds` are vectors (no
`option_count`/`fan_speed_count`), and `history` holds 24 samples or none. `revision`/`pending_revision`
are FNV-1a fingerprints (`Fingerprint`, also an ArduinoJson writer) instead of copies of state,
attributes and extras. On the ESP32 `runtime_tiles::model` shrinks from 24,296 to 8,216 bytes.
- The old manual profile is gone from both board profiles: the 80 `homeassistant` sensor and text
sensors of the fixed tiles, `smartdisplay_action`, the tile tap and long-press handlers,
`publish_action`, `do_tile_action`, the manual refresh in `ui_refresh`, the YAML vacuum card with
`open_vacuum_overlay`/`vacuum_action`/`vacuum_fan_speed`, `light_controls::subscribe`, the 331
`TILE*` substitutions, and `DYNAMIC_TILES`/`TILE_COUNT`/`DIRECT_ACTIONS`/`TIME_24H`/`ORIENTATION`.
`runtime_tiles::enabled` is set true at boot and the `DIRECT_ACTIONS` guards became
`runtime_tiles::fresh()`. The runtime detail hook lost its unreachable vacuum branch. Deleted:
`device.example.yaml`, `guition-device.example.yaml`, `tools/new_device.py`, `tools/export_bundle.py`,
`TILE_CONFIGURATION.md`, `docs/TILES.md`, `docs/ACCEPTANCE.md`, `CYD_STABILITY.md`, `TEST_RESULTS.md`,
and the five stale header copies in the repository root (`cyd_ui.h`, `light_controls.h`,
`alert_overlay.h`, `backlight_fade.h`, `guition_diagnostics.h`) that had drifted from
`components/smart_display/`; `tests/test_cyd_ui.cpp` and `tests/test_light_controls.cpp` were
compiling those copies and now include the component headers, as both profiles do.
`tools/generate_packages.py` no longer strips or substitutes anything for the manual profile: the
board profile's own defaults are the package's, and a `homeassistant` sensor in a profile is refused.
The entity "SmartDisplay Action" disappears from every screen. The USB touch calibration
(`tools/calibrate.py`, `docs/CALIBRATING.md`, `calibration.example.yaml`, `CALIBRATION_ON_BOOT`,
the measurement page) and the on-screen calibration are unchanged.
- `esphome config` of both Easy profiles is byte-identical before and after this cleanup, apart from
two reordered lines: the firmware does the same thing, with the same calibration.
- `tile_scroll` and `dim_wake_overlay` (and the manual vacuum rows) get `pressed: bg_opa: TRANSP`. The
theme's obj pressed style (`bg_opa` 45%) lit up the transparent area on a tap between tiles and on the
wake tap.
- Both profiles add the debug sensor `min_free` ("Heap Minimum Free":
`heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL)`, every 300 s).
### Compatibility 0.2.49 / firmware 0.2.42
Firmware and the CYD board profile; the app only raises `FIRMWARE_VERSION`. Storage, protocol,
+50
View File
@@ -0,0 +1,50 @@
# Test results app 0.2.50 / firmware 0.2.43 (2026-09-15/16)
Memory for the CYD: layer-free sliders, a leaner tile model, and the old manual profile out of both
board profiles. See CHANGELOG 0.2.50 and RELEASING "Compatibility 0.2.50".
## Automated
- Python (`.venv-portal`): 175 tests OK. New: `tests/test_layer_free.py` (no style that makes LVGL draw
into a buffer of its own, and every slider fill keeps its track's radius, in both profiles, both
packages and the firmware headers) and `tests/test_easy_package.py` (packages current, no secrets or
local paths, the manual profile gone from profiles and packages, runtime parts present, and the
generator refuses a fixed Home Assistant subscription).
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I . -I components/smart_display`): 13/13 PASS, with
`test_runtime_model.cpp` and `test_tile_controls.cpp` also clean under
`-fsanitize=address,undefined`. `test_cyd_ui.cpp` and `test_light_controls.cpp` were compiling the
stale header copies in the repository root; they include the component headers now and still pass.
- `generate_packages.py --check` green.
- `esphome config` of both Easy profiles is identical before and after the manual-profile cleanup,
apart from two reordered lines: the firmware that was tested on the bench is the firmware released.
## Build sizes (ESPHome 2026.6.2)
| Profile | RAM 0.2.49 | RAM 0.2.50 | Flash 0.2.49 | Flash 0.2.50 |
|---|---|---|---|---|
| easy-cyd-device.yaml | 32.0% | 22.8% (74,668 B) | 78.6% | 75.5% |
| easy-guition-device.yaml | 33.0% | 23.8% (77,892 B) | 19.0% | 18.3% |
`runtime_tiles::model` (twenty tile slots) shrank from 24,296 to 8,216 bytes.
## Bench boards
Both boards were flashed over the air with firmware 0.2.43 and ran from 19:08 to 08:00 the next
morning without a restart: `last_boot` unchanged, `Synced`, no memory warning in the log.
| | CYD before (0.2.42) | CYD after | Guition before | Guition after |
|---|---|---|---|---|
| Free heap after the sync | 64–77 KB | 101–103 KB | 51–65 KB | 84–97 KB |
| Lowest free heap since boot | not measured | 78.1 KB over 13 hours | not measured | 62.8 KB |
| Largest free block | 48 KB | 48 KB | 31.7 KB | 36.9–57.3 KB |
The owner used both screens for about ten minutes right after the update: the brightness slider of a
light tile and of the light card, the vacuum card with its cleaning settings, cards and page swipes.
Home Assistant confirmed every action within about 0.3 s. No `lv_draw_buf_create_ex: No memory`, no
watchdog reset, no rollback. On the Guition, LVGL reported 216–276 ms for single operations while a
card was being rebuilt, as it did before this release.
## Not tested / for the owner
- A screen that still ran the old manual profile. There are none left; the route is gone.
- The USB touch calibration was not measured again; its code and documentation are unchanged.
-184
View File
@@ -1,184 +0,0 @@
# Configuring tiles for your Home Assistant
Edit `device.yaml`, within the existing `substitutions:`. You don't need to
touch the base code for the examples below. Every change requires a rebuild
and reflash. Go through the [installation](../README.md) first.
## Collecting entity IDs
In HA, open **Settings → Tools → States** (**Developer tools → States** on older
HA versions). Find the entity you want and
copy the full ID, for example `light.kitchen`. Check its state and
attributes. The tile's title is free to choose; the entity ID must be exact.
Under **Settings → Tools → Actions**, test the intended action with that entity.
An action can control the real device: choose a suitable test.
See [HA actions](https://www.home-assistant.io/docs/scripts/perform-actions/).
First make a list of position, title, entity ID, tap action, and long-press action.
Use existing HA scripts/scenes for multiple devices or complex logic.
## Positions and limits
| Positions | Intended use in this firmware |
|---|---|
| 1–6 | Main tiles; light, switch, scene, script, fan, cover, and climate |
| 6 | Also the vacuum card; the card is specifically tied to tile 6 |
| 7 and 9 | Extra light tiles with brightness/color control |
| 8 and 10 | Extra status/action tiles; no full number/climate card |
`TILE_COUNT: "1"` through `"10"` determines how many **consecutive** positions are
visible. Up to and including six, Previous/Next stay hidden; from seven on, 1–6 sit
on page one and the rest on page two. Free scrolling has been replaced by fixed
pages. Leave unused positions on the neutral example entities.
Don't change grid dimensions to add tiles.
## Key settings
| Setting (`N` = tile number) | Meaning |
|---|---|
| `TILEN_ENTITY` | Entity the action is performed on |
| `TILEN_STATE_ENTITY` | Entity for status/attributes; usually the same one |
| `TILEN_TITLE` | Short title, ideally about 12 characters max |
| `TILEN_TYPE` | Domain, for example `light`, `scene`, `climate` |
| `TILEN_TAP_ACTION` | `auto`, `toggle`, or `custom` for the examples below |
| `TILEN_TAP_SERVICE` | Explicit HA action when using custom, for example `script.turn_on` |
| `TILEN_LONGPRESS` | `none`, `slider`, or (tile 6 only) `vacuum` |
| `TILEN_LONGPRESS_SLIDER` | `auto`; picks a suitable control based on type |
| `TILEN_LABEL_ON/OFF` | Text for a simple on/off status |
The example config also fills in the remaining fields. Leave those as they are unless
you deliberately use them. For a scene/script, `STATE_ENTITY` can follow a real light or
switch; a scene itself has no reliable on/off status.
## Color, white temperature, and brightness
An RGB/color light with `LONGPRESS: "slider"` opens the light card directly.
It contains up to three horizontal sliders:
- **Color:** a continuous rainbow (0–360° hue, 100% saturation).
- **White temperature:** warm to cool, within the minimum/maximum Kelvin values
the light reports in Home Assistant.
- **Brightness:** 1–100%; on/off stays available via the tile.
The color and white sliders only appear if `supported_color_modes` supports
them. A dimmable-only light keeps the regular brightness card.
No extra tile settings are needed. For a light, use the same
`ENTITY` and `STATE_ENTITY`. The color card follows `hs_color`, `color_temp_kelvin`,
`min_color_temp_kelvin`, and `max_color_temp_kelvin` of the controlled entity.
If the temperature range is missing, the white slider waits for HA data.
When opened, the card takes over the last received light values. Dragging shows
the chosen value locally; only releasing sends a single command. Color selects the
color mode, white temperature selects the white mode. Brightness doesn't change the
color mode. The rainbow controls hue; pastel/saturation isn't a separate control.
The hue list is sent to HA via ESPHome `data_template`; see the
[official API documentation](https://esphome.io/components/api/#homeassistantaction-action).
The shared implementation lives in `light_controls.h`. Both screen profiles
use it; the Guition has larger controls, the CYD a compact card.
`diagnostics/run_ui_test.py` also checks the slider events with swapped-in
callbacks: no real HA actions. For a safe 40-second preview card:
```sh
python diagnostics/control_ui.py light_controls_preview --host guition-wallbox.local --name guition-wallbox
```
For the CYD, use its own host and name. During this preview card,
light commands are suppressed and the card disappears on its own. Then test the
actual light: long press, choose a color, choose white, adjust brightness,
close, and reopen. Check the physical light and the values in HA.
## Examples
Change these keys in the existing map; don't paste `substitutions:` a second time.
Dimmable light at position 1 (tap toggles, long press opens the control):
```yaml
TILE1_ENTITY: "light.kitchen"
TILE1_STATE_ENTITY: "light.kitchen"
TILE1_TITLE: "Kitchen"
TILE1_TYPE: "light"
TILE1_TAP_ACTION: "auto"
TILE1_LONGPRESS: "slider"
```
A non-dimmable light/switch gets `LONGPRESS: "none"`. Color control
requires that the light supports the relevant color mode.
Scene at position 3:
```yaml
TILE3_ENTITY: "scene.evening"
TILE3_STATE_ENTITY: "light.kitchen"
TILE3_TITLE: "Evening"
TILE3_TYPE: "scene"
TILE3_TAP_ACTION: "auto"
TILE3_LONGPRESS: "none"
```
Script at position 4:
```yaml
TILE4_ENTITY: "script.all_off"
TILE4_STATE_ENTITY: "script.all_off"
TILE4_TITLE: "All off"
TILE4_TYPE: "script"
TILE4_TAP_ACTION: "auto"
TILE4_LONGPRESS: "none"
```
AC at position 5 (long press opens climate):
```yaml
TILE5_ENTITY: "climate.living_room"
TILE5_STATE_ENTITY: "climate.living_room"
TILE5_TITLE: "AC"
TILE5_TYPE: "climate"
TILE5_TAP_ACTION: "toggle"
TILE5_TAP_SERVICE: "climate.toggle"
TILE5_LONGPRESS: "slider"
```
Check `min_temp`, `max_temp`, `target_temp_step`, `hvac_modes`, and the
supported fan/swing modes in HA. This card is primarily for a **single
setpoint**, not a thermostat that only uses a temperature range.
Test your integration's modes individually; a different AC unit
may have different capabilities.
Robot vacuum at position 6 (tap starts; long press opens the card):
```yaml
TILE6_ENTITY: "vacuum.robot"
TILE6_STATE_ENTITY: "vacuum.robot"
TILE6_TITLE: "Vacuum"
TILE6_TYPE: "vacuum"
TILE6_TAP_ACTION: "custom"
TILE6_TAP_SERVICE: "vacuum.start"
TILE6_LONGPRESS: "vacuum"
```
The vacuum card uses start/pause/stop/return/locate and the
speed names `quiet`, `balanced`, `turbo`, `max`. Compare these with the
supported actions and `fan_speed_list` in HA. The speed buttons need to be
adjusted in the base code if your integration expects different names.
This is not a universal card for every robot model.
## Enabling and checking actions
After calibration and HA pairing, set `DIRECT_ACTIONS: "true"`. In the
ESPHome integration, grant permission for Home Assistant actions. Don't use an
HA automation that also runs the same screen action again separately; that can
cause a double toggle. With `DIRECT_ACTIONS: "false"`, the action sensor can
still publish events for your own automations.
Flash `device.yaml` and test one tile at a time: correct status, correct physical
action, feedback from HA, and long press. Also check that external
changes in HA appear on the screen. Then run the
[acceptance test](ACCEPTANCE.md).
The icon fonts contain the tile icon set from `screen_manager/app/tile_icons.py`;
pick `TILEn_ICON` from there. To add a different icon, add it there and run
`tools/generate_icons.py`. The bundle includes the fonts locally. The older, more extensive
[TILE_CONFIGURATION.md](../TILE_CONFIGURATION.md) also describes the removed
scroll profile as a historical record; don't use it as hardware/layout instructions for this board.
+2 -2
View File
@@ -15,8 +15,8 @@
| Duplicate HA action | Check both the touch logs and HA automations. Don't use direct actions together with an automation on the action sensor for the same operation. |
| Wi-Fi doesn't connect | 2.4GHz, correct local secrets, network range. USB calibration doesn't need HA. |
| HA doesn't see the board | Manually use the IP from the logs; port 6053 reachable, correct encryption key, no guest network isolation. |
| Status works but action doesn't | Check the HA option for allowed device actions, `DIRECT_ACTIONS: "true"`, the real entity ID, and a supported action. |
| Vacuum/climate partly usable | Supported modes/attributes differ per integration; see TILES.md and test the same action in HA first. |
| Status works but action doesn't | Check the HA option for allowed device actions, the real entity ID, and a supported action. |
| Vacuum/climate partly usable | Supported modes/attributes differ per integration; test the same action in Home Assistant first. |
| Wrong pagination | `TILE_COUNT` is 1–10; hidden buttons up to six, two pages from seven on. Reflash after changing it. |
| Freeze/reset | Keep the USB log, check the reset reason/power, and run the render test. Note the action and time. |
+73 -2688
View File
File diff suppressed because it is too large Load Diff
-213
View File
@@ -1,213 +0,0 @@
# Copy via: python tools/new_device.py --board guition --name display-kitchen
# Only edit this local profile for your screen.
packages:
display: !include guition-4848s040.yaml
substitutions:
DEVICE_NAME: "guition-new"
DEVICE_FRIENDLY_NAME: "My Guition"
ROOM_NAME: "Living room"
TILE_COUNT: "6"
DIRECT_ACTIONS: "false" # Set to true only after pairing/testing.
AUTO_DIM_TIMEOUT: "600"
CALIBRATION_ON_BOOT: "false"
# Tile 1
TILE1_ENTITY: "light.example_lamp"
TILE1_STATE_ENTITY: "light.example_lamp"
TILE1_TITLE: "Lamp"
TILE1_TYPE: "light"
TILE1_TAP_ACTION: "auto"
TILE1_TAP_SERVICE: ""
TILE1_TAP_PARAM_KEY: ""
TILE1_TAP_PARAM_VAL: ""
TILE1_LONGPRESS: "slider"
TILE1_LONGPRESS_SLIDER: "auto"
TILE1_LONGPRESS_OFF_VALUE: "0"
TILE1_LONGPRESS_ACTION: ""
TILE1_LONGPRESS_ACTION_TYPE: ""
TILE1_LONGPRESS_ACTION_SERVICE: ""
TILE1_VALUE_MODE: "auto"
TILE1_LABEL_ON: "On"
TILE1_LABEL_OFF: "Off"
TILE1_ACTIVE_STATE: ""
# Tile 2
TILE2_ENTITY: "switch.example_plug"
TILE2_STATE_ENTITY: "switch.example_plug"
TILE2_TITLE: "Plug"
TILE2_TYPE: "switch"
TILE2_TAP_ACTION: "auto"
TILE2_TAP_SERVICE: ""
TILE2_TAP_PARAM_KEY: ""
TILE2_TAP_PARAM_VAL: ""
TILE2_LONGPRESS: "none"
TILE2_LONGPRESS_SLIDER: "auto"
TILE2_LONGPRESS_OFF_VALUE: "0"
TILE2_LONGPRESS_ACTION: ""
TILE2_LONGPRESS_ACTION_TYPE: ""
TILE2_LONGPRESS_ACTION_SERVICE: ""
TILE2_VALUE_MODE: "auto"
TILE2_LABEL_ON: "On"
TILE2_LABEL_OFF: "Off"
TILE2_ACTIVE_STATE: ""
# Tile 3
TILE3_ENTITY: "scene.example_evening"
TILE3_STATE_ENTITY: "scene.example_evening"
TILE3_TITLE: "Evening"
TILE3_TYPE: "scene"
TILE3_TAP_ACTION: "auto"
TILE3_TAP_SERVICE: ""
TILE3_TAP_PARAM_KEY: ""
TILE3_TAP_PARAM_VAL: ""
TILE3_LONGPRESS: "none"
TILE3_LONGPRESS_SLIDER: "auto"
TILE3_LONGPRESS_OFF_VALUE: "0"
TILE3_LONGPRESS_ACTION: ""
TILE3_LONGPRESS_ACTION_TYPE: ""
TILE3_LONGPRESS_ACTION_SERVICE: ""
TILE3_VALUE_MODE: "auto"
TILE3_LABEL_ON: "On"
TILE3_LABEL_OFF: "Off"
TILE3_ACTIVE_STATE: ""
# Tile 4
TILE4_ENTITY: "script.example_all_off"
TILE4_STATE_ENTITY: "script.example_all_off"
TILE4_TITLE: "All off"
TILE4_TYPE: "script"
TILE4_TAP_ACTION: "auto"
TILE4_TAP_SERVICE: ""
TILE4_TAP_PARAM_KEY: ""
TILE4_TAP_PARAM_VAL: ""
TILE4_LONGPRESS: "none"
TILE4_LONGPRESS_SLIDER: "auto"
TILE4_LONGPRESS_OFF_VALUE: "0"
TILE4_LONGPRESS_ACTION: ""
TILE4_LONGPRESS_ACTION_TYPE: ""
TILE4_LONGPRESS_ACTION_SERVICE: ""
TILE4_VALUE_MODE: "auto"
TILE4_LABEL_ON: "On"
TILE4_LABEL_OFF: "Off"
TILE4_ACTIVE_STATE: ""
# Tile 5
TILE5_ENTITY: "climate.example_ac"
TILE5_STATE_ENTITY: "climate.example_ac"
TILE5_TITLE: "AC"
TILE5_TYPE: "climate"
TILE5_TAP_ACTION: "toggle"
TILE5_TAP_SERVICE: "climate.toggle"
TILE5_TAP_PARAM_KEY: ""
TILE5_TAP_PARAM_VAL: ""
TILE5_LONGPRESS: "slider"
TILE5_LONGPRESS_SLIDER: "auto"
TILE5_LONGPRESS_OFF_VALUE: "0"
TILE5_LONGPRESS_ACTION: ""
TILE5_LONGPRESS_ACTION_TYPE: ""
TILE5_LONGPRESS_ACTION_SERVICE: ""
TILE5_VALUE_MODE: "auto"
TILE5_LABEL_ON: "On"
TILE5_LABEL_OFF: "Off"
TILE5_ACTIVE_STATE: ""
# Tile 6
TILE6_ENTITY: "vacuum.example_robot"
TILE6_STATE_ENTITY: "vacuum.example_robot"
TILE6_TITLE: "Vacuum"
TILE6_TYPE: "vacuum"
TILE6_TAP_ACTION: "custom"
TILE6_TAP_SERVICE: "vacuum.start"
TILE6_TAP_PARAM_KEY: ""
TILE6_TAP_PARAM_VAL: ""
TILE6_LONGPRESS: "vacuum"
TILE6_LONGPRESS_SLIDER: "auto"
TILE6_LONGPRESS_OFF_VALUE: "0"
TILE6_LONGPRESS_ACTION: ""
TILE6_LONGPRESS_ACTION_TYPE: ""
TILE6_LONGPRESS_ACTION_SERVICE: ""
TILE6_VALUE_MODE: "auto"
TILE6_LABEL_ON: "On"
TILE6_LABEL_OFF: "Off"
TILE6_ACTIVE_STATE: ""
# Tile 7 (hidden while TILE_COUNT is lower)
TILE7_ENTITY: "light.unused_7"
TILE7_STATE_ENTITY: "light.unused_7"
TILE7_TITLE: "Tile 7"
TILE7_TYPE: "light"
TILE7_TAP_ACTION: "auto"
TILE7_TAP_SERVICE: ""
TILE7_TAP_PARAM_KEY: ""
TILE7_TAP_PARAM_VAL: ""
TILE7_LONGPRESS: "none"
TILE7_LONGPRESS_SLIDER: "auto"
TILE7_LONGPRESS_OFF_VALUE: "0"
TILE7_LONGPRESS_ACTION: ""
TILE7_LONGPRESS_ACTION_TYPE: ""
TILE7_LONGPRESS_ACTION_SERVICE: ""
TILE7_VALUE_MODE: "auto"
TILE7_LABEL_ON: "On"
TILE7_LABEL_OFF: "Off"
TILE7_ACTIVE_STATE: ""
# Tile 8 (hidden while TILE_COUNT is lower)
TILE8_ENTITY: "light.unused_8"
TILE8_STATE_ENTITY: "light.unused_8"
TILE8_TITLE: "Tile 8"
TILE8_TYPE: "light"
TILE8_TAP_ACTION: "auto"
TILE8_TAP_SERVICE: ""
TILE8_TAP_PARAM_KEY: ""
TILE8_TAP_PARAM_VAL: ""
TILE8_LONGPRESS: "none"
TILE8_LONGPRESS_SLIDER: "auto"
TILE8_LONGPRESS_OFF_VALUE: "0"
TILE8_LONGPRESS_ACTION: ""
TILE8_LONGPRESS_ACTION_TYPE: ""
TILE8_LONGPRESS_ACTION_SERVICE: ""
TILE8_VALUE_MODE: "auto"
TILE8_LABEL_ON: "On"
TILE8_LABEL_OFF: "Off"
TILE8_ACTIVE_STATE: ""
# Tile 9 (hidden while TILE_COUNT is lower)
TILE9_ENTITY: "light.unused_9"
TILE9_STATE_ENTITY: "light.unused_9"
TILE9_TITLE: "Tile 9"
TILE9_TYPE: "light"
TILE9_TAP_ACTION: "auto"
TILE9_TAP_SERVICE: ""
TILE9_TAP_PARAM_KEY: ""
TILE9_TAP_PARAM_VAL: ""
TILE9_LONGPRESS: "none"
TILE9_LONGPRESS_SLIDER: "auto"
TILE9_LONGPRESS_OFF_VALUE: "0"
TILE9_LONGPRESS_ACTION: ""
TILE9_LONGPRESS_ACTION_TYPE: ""
TILE9_LONGPRESS_ACTION_SERVICE: ""
TILE9_VALUE_MODE: "auto"
TILE9_LABEL_ON: "On"
TILE9_LABEL_OFF: "Off"
TILE9_ACTIVE_STATE: ""
# Tile 10 (hidden while TILE_COUNT is lower)
TILE10_ENTITY: "light.unused_10"
TILE10_STATE_ENTITY: "light.unused_10"
TILE10_TITLE: "Tile 10"
TILE10_TYPE: "light"
TILE10_TAP_ACTION: "auto"
TILE10_TAP_SERVICE: ""
TILE10_TAP_PARAM_KEY: ""
TILE10_TAP_PARAM_VAL: ""
TILE10_LONGPRESS: "none"
TILE10_LONGPRESS_SLIDER: "auto"
TILE10_LONGPRESS_OFF_VALUE: "0"
TILE10_LONGPRESS_ACTION: ""
TILE10_LONGPRESS_ACTION_TYPE: ""
TILE10_LONGPRESS_ACTION_SERVICE: ""
TILE10_VALUE_MODE: "auto"
TILE10_LABEL_ON: "On"
TILE10_LABEL_OFF: "Off"
TILE10_ACTIVE_STATE: ""
-2
View File
@@ -1,2 +0,0 @@
#pragma once
#include "esphome/components/smart_display/guition_diagnostics.h"
+83 -2747
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
#pragma once
#if __has_include("esphome/components/smart_display/light_controls.h")
#include "esphome/components/smart_display/light_controls.h"
#else
#include "components/smart_display/light_controls.h"
#endif
+79 -2685
View File
File diff suppressed because it is too large Load Diff
+65 -2622
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,3 +1,14 @@
## 0.2.50 (firmware 0.2.43)
More memory for the CYD, so a busy moment no longer restarts it.
- **Sliders without extra buffers.** The fill of the tile and light card sliders now has the same rounded corners as its track. With the tighter corners it had since 0.2.43, the screen drew every fill into a separate buffer of up to 20 KB on each redraw, and when the CYD ran short of memory it kept retrying until its watchdog restarted it. The end of the fill is now slightly rounder behind the white handle.
- **Leaner tiles.** Climate modes, a select's options, weather, sun and timer times, the media title and the vacuum rows now only take memory on tiles that use them, and a tile keeps a short fingerprint of its last state instead of a full copy. That frees about 16 KB of RAM on the CYD.
- **The old manual setup is gone.** Before ESP Screens existed, tiles were written by hand in the screen's YAML. Nothing used that any more, and it cost a screen memory: 80 Home Assistant subscriptions, its own tap handlers and an old vacuum card it never showed. All of it is out, together with its guides and helper scripts. Choosing tiles, the top bar, the settings and the calibration work exactly as before. The unused sensor **SmartDisplay Action** disappears: Home Assistant shows it as unavailable and you can delete it.
- **No pale flash.** A tap between two tiles, or the tap that wakes a dimmed screen, lit up a pale panel. It stays transparent now, on both boards.
- **Heap Minimum Free.** A new diagnostic sensor shows the lowest free memory since the screen started.
- Firmware 0.2.43 for both boards; the app offers the update.
## 0.2.49 (firmware 0.2.42)
No more flickering stripes on the CYD.
+1 -1
View File
@@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.42'
FIRMWARE_VERSION = '0.2.43'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.49"
version: "0.2.50"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+16 -5
View File
@@ -8,13 +8,15 @@ int main() {
assert(!guard.accept(100,1)); assert(!guard.accept_slider(100,1));
guard.begin(200); assert(guard.accept(300,1));
// A tile keeps the fingerprint of its last state message, not the message itself.
auto rev=[](const char *state){ return runtime_tiles::state_revision(state,""); };
runtime_tiles::Tile t;
t.revision="off"; t.begin(100);
t.revision=rev("off"); t.begin(100);
assert(t.loading(200)); assert(t.awaiting_action(200));
t.observe("off"); assert(!t.confirmed);
t.observe("on"); assert(t.confirmed);
t.observe(rev("off")); assert(!t.confirmed);
t.observe(rev("on")); assert(t.confirmed);
assert(t.loading(999)); assert(!t.loading(1100));
t.begin(2000); t.observe("on");
t.begin(2000); t.observe(rev("on"));
assert(t.loading(7999)); assert(!t.loading(8000)); assert(!t.awaiting_action(8000));
t.begin(0xFFFFFFF0); assert(t.loading(20));
t.begin(100,true);
@@ -31,6 +33,15 @@ int main() {
sw.begin(300); sw.observe(runtime_tiles::state_revision("on","{}"));
assert(sw.loading(1000)); // Repeated identical data is not confirmation.
assert(!sw.loading(6300));
sw.entity="input_boolean.test"; sw.begin(0xFFFFFFF0); sw.observe("off");
sw.entity="input_boolean.test"; sw.begin(0xFFFFFFF0); sw.observe(rev("off"));
assert(sw.loading(20)); assert(!sw.loading(200));
// The fingerprint follows state and attributes, and the streaming writer (what receive() hands to
// ArduinoJson) gives the same value as hashing the joined text.
assert(runtime_tiles::state_revision("on","{\"brightness\":255}")!=runtime_tiles::state_revision("on","{\"brightness\":254}"));
assert(runtime_tiles::state_revision("on","{}")!=runtime_tiles::state_revision("off","{}"));
assert(runtime_tiles::state_revision("o","n{}")!=runtime_tiles::state_revision("on","{}"));
runtime_tiles::Fingerprint f; f.add("on"); f.write('\n');
const char json[]="{\"a\":1}"; f.write(reinterpret_cast<const uint8_t*>(json),sizeof(json)-1);
assert(f.value==runtime_tiles::state_revision("on","{\"a\":1}"));
}
+3 -3
View File
@@ -101,10 +101,10 @@ class AlertTests(unittest.TestCase):
self.assertGreater(v('ALERT_SUBTITLE_MAX'), v('ALERT_TITLE_MAX'), name)
def test_helper_header_is_included_locally_and_in_the_remote_package(self):
header = ' - <esphome/components/smart_display/alert_overlay.h>\n'
for board, name in PROFILES.items():
self.assertIn(' - alert_overlay.h\n', (ROOT / name).read_text(), name)
self.assertIn(' - <esphome/components/smart_display/alert_overlay.h>\n', (ROOT / PACKAGES[board]).read_text(), board)
self.assertIn('components/smart_display/alert_overlay.h', (ROOT / 'alert_overlay.h').read_text())
self.assertIn(header, (ROOT / name).read_text(), name)
self.assertIn(header, (ROOT / PACKAGES[board]).read_text(), board)
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -1,4 +1,4 @@
#include "../cyd_ui.h"
#include "../components/smart_display/cyd_ui.h"
#include <cassert>
#include <limits>
int main() {
+63
View File
@@ -0,0 +1,63 @@
"""The packages a screen builds from: generated from the board profiles, runtime tiles only."""
import importlib.util
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
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.
GONE = ['ha_state_tile1', 'tile1_brightness', 'tile6_climate_humidity', 'tile6_cover_state', 'smartdisplay_action',
'publish_action', 'do_tile_action', 'open_vacuum_overlay', 'vacuum_action', 'vacuum_fan_speed', 'vacuum_overlay',
'vacspd_quiet', 'vacuum_actions', 'DYNAMIC_TILES', 'TILE_COUNT', 'DIRECT_ACTIONS', 'TILE1_ENTITY',
'light_controls::subscribe']
def load_generator():
spec = importlib.util.spec_from_file_location('generate_packages', ROOT / 'tools/generate_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_no_secrets_and_no_local_paths(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)
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 key in GONE:
self.assertNotIn(key, text, f'{path.name} 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()
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())
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))
if __name__ == '__main__':
unittest.main()
+1 -13
View File
@@ -2,12 +2,10 @@
from pathlib import Path
import re
import sys
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
import verify_gt911
import new_device
SOURCE=(ROOT/'guition-4848s040.yaml').read_text()
VALUES=dict(re.findall(r'^ (\w+): "([^"]*)"',SOURCE,re.M))
@@ -49,9 +47,8 @@ class GuitionTests(unittest.TestCase):
self.assertEqual(len(rectangles),6)
self.assertIn('height: 44',SOURCE.split('id: page_next',1)[1][:200])
def test_all_tiles_guard_events_and_clip_long_titles(self):
def test_all_tiles_clip_long_titles(self):
for i in range(1,11):
self.assertEqual(SOURCE.count(f'cyd::touch_guard.accept(millis(), {i})'),2)
self.assertRegex(SOURCE,rf'id: t{i}_title\n\s+height: 24\n\s+width: 130\n\s+long_mode: DOT')
self.assertEqual(VALUES['AUTO_DIM_TIMEOUT'],'600')
@@ -65,13 +62,4 @@ class GuitionTests(unittest.TestCase):
self.assertEqual(verify_gt911.screen_point(20,30,90),(30,459))
self.assertEqual(verify_gt911.screen_point(20,30,270),(449,20))
def test_initializer_does_not_copy_resistive_calibration(self):
with tempfile.TemporaryDirectory() as tmp:
new_device.create(tmp,'wallbox-test','Wallbox Test','guition')
profile=(Path(tmp)/'guition-device.yaml').read_text()
self.assertIn('!include guition-4848s040.yaml',profile)
self.assertIn('DEVICE_NAME: "wallbox-test"',profile)
self.assertFalse((Path(tmp)/'calibration.yaml').exists())
self.assertTrue((Path(tmp)/'secrets.yaml').exists())
if __name__=='__main__':unittest.main()
+63
View File
@@ -0,0 +1,63 @@
"""Drawing that needs no buffers of its own: styles that make LVGL 9.5 render a widget into a separate layer.
Such a layer is a full-size buffer (width x height x 4 bytes) allocated on every redraw. The CYD has no PSRAM:
a slider fill rounded less than its track cost 15-20 KB per frame there, and when LVGL cannot get that memory
it retries inside the frame until the task watchdog resets the board.
"""
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PROFILES = ['home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml']
HEADERS = sorted((ROOT / 'components/smart_display').glob('*.h'))
# Style properties that give a widget a layer (lv_obj_style.c calculate_layer_type, lv_refr.c clip corners).
YAML_LAYER_STYLES = re.compile(r'^\s*(opa_layered|transform_rotation|transform_scale(?:_x|_y)?|transform_zoom|transform_skew(?:_x|_y)?'
r'|blend_mode|bitmap_mask_src|drop_shadow_\w+|blur_\w+):', re.M)
C_LAYER_STYLES = re.compile(r'lv_obj_set_style_(opa_layered|transform_rotation|transform_scale(?:_x|_y)?|transform_skew(?:_x|_y)?'
r'|blend_mode|bitmap_mask_src|drop_shadow_\w+|blur_\w+)\(|LV_STYLE_(OPA_LAYERED|TRANSFORM_ROTATION'
r'|TRANSFORM_SCALE\w*|TRANSFORM_SKEW\w*|BLEND_MODE|BITMAP_MASK_SRC|CLIP_CORNER)\b')
class LayerFreeTests(unittest.TestCase):
def test_board_profiles_use_no_layer_styles(self):
for name in PROFILES:
text = (ROOT / name).read_text()
self.assertEqual(YAML_LAYER_STYLES.findall(text), [], name)
self.assertEqual(re.findall(r'^\s*clip_corner: true', text, re.M), [], name)
def test_firmware_sets_no_layer_styles(self):
for path in HEADERS:
text = path.read_text()
self.assertEqual(C_LAYER_STYLES.findall(text), [], path.name)
self.assertNotRegex(text, r'lv_obj_set_style_clip_corner\([^;]*\btrue\b', path.name)
def test_slider_fill_keeps_the_track_radius_in_code(self):
"""Every fill radius in the firmware is the same expression as a track radius set beside it."""
statement = re.compile(r'(?:lv_obj_set_style_radius\((\w+(?:\.\w+)?),\s*|set_number\((\w+),\s*LV_STYLE_RADIUS,\s*)'
r'([^;]*?),\s*(LV_PART_MAIN|LV_PART_INDICATOR)\s*\)')
checked = 0
for path in HEADERS:
text = path.read_text()
found = [(m.group(1) or m.group(2), m.group(3).strip(), m.group(4)) for m in statement.finditer(text)]
for widget, value, part in found:
if part != 'LV_PART_INDICATOR':
continue
checked += 1
self.assertIn((widget, value, 'LV_PART_MAIN'), found, f'{path.name}: fill radius {value} of {widget} differs from its track')
self.assertGreaterEqual(checked, 2)
def test_yaml_slider_fill_keeps_the_track_radius(self):
for name in PROFILES:
text = (ROOT / name).read_text()
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)
fill = re.search(r'^' + indent + r'indicator:\n(?:' + indent + r' .*\n)*?' + indent + r' radius: (\S+)', block, re.M)
if fill:
self.assertTrue(track and track.group(1) == fill.group(1), f'{name}: slider fill radius {fill.group(1)} differs from its track')
if __name__ == '__main__':
unittest.main()
+5 -9
View File
@@ -23,16 +23,12 @@ class LayoutTests(unittest.TestCase):
self.assertFalse(x < xx + ww and x + w > xx and y < yy + hh and y + h > yy)
rectangles.append((x, y, w, h))
def test_each_tile_filters_both_events_before_side_effects(self):
def test_runtime_binds_every_tile_and_guards_a_tap(self):
"""The tiles are bound by the runtime; it filters a tap before anything happens."""
for n in range(1, 11):
start = SOURCE.index(f' id: tile{n}\n')
next_obj = re.search(r'^ - obj:', SOURCE[start:], re.M)
end = start + next_obj.start() if next_obj else len(SOURCE)
block = SOURCE[start:end]
for event in ('short_click', 'long_press'):
match = re.search(r'on_' + event + r':\s+- if:\s+condition:\s+lambda: (.*?)\n', block)
self.assertIsNotNone(match, (n, event))
self.assertIn(f'cyd::touch_guard.accept(millis(), {n})', match[1])
self.assertIn(f'runtime_tiles::bind({n - 1}, id(tile{n})', SOURCE)
runtime = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
self.assertIn('if (!allowed(esphome::millis(), 100 + w.index, model.tiles[w.index].entity)) return;', runtime)
def test_navigation_is_above_grid_but_below_modal_overlays(self):
grid = SOURCE.index(' id: tile_scroll')
+1 -1
View File
@@ -1,5 +1,5 @@
#define LIGHT_CONTROLS_TEST
#include "../light_controls.h"
#include "../components/smart_display/light_controls.h"
#include <cassert>
int main() {
using namespace light_controls;
+1 -41
View File
@@ -1,17 +1,14 @@
"""Offline regression tests for installation, calibration and safe export."""
"""Offline regression tests for the guided USB touch calibration."""
import copy
from pathlib import Path
import re
import sys
import tempfile
import unittest
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import calibrate
import new_device
import export_bundle
def measurements():
@@ -86,41 +83,4 @@ class CalibrationTests(unittest.TestCase):
self.assertEqual(next(Path(tmp).glob('*.bak-*')).read_text(), 'old')
class InstallationTests(unittest.TestCase):
def test_unique_secrets_and_no_overwrite(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
new_device.create(a, 'display-a', 'Display A')
new_device.create(b, 'display-b', 'Display B')
original = (Path(a)/'secrets.yaml').read_text()
self.assertNotEqual(original, (Path(b)/'secrets.yaml').read_text())
self.assertNotIn('WILL_BE_UNIQUELY_GENERATED', original)
with self.assertRaises(ValueError): new_device.create(a, 'new', 'New')
self.assertEqual(original, (Path(a)/'secrets.yaml').read_text())
def test_invalid_device_name(self):
with tempfile.TemporaryDirectory() as tmp:
for name in ('../oops', 'UPPER', 'has space', 'trailing-'):
with self.assertRaises(ValueError): new_device.create(tmp, name, 'Test')
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_export_is_neutral_and_has_required_sources(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp)/'starter.zip'
export_bundle.export(ROOT, output)
with zipfile.ZipFile(output) as archive:
names = archive.namelist()
for forbidden in ('secrets.yaml', 'device.yaml', 'calibration.yaml', '.git/', '.esphome/', '.log', '.bin', '.json'):
self.assertFalse(any(('/'+forbidden in n if forbidden.endswith('/') else n.endswith(forbidden) if forbidden.startswith('.') else n.endswith('/'+forbidden)) for n in names), forbidden)
self.assertIn('cyd-display/.gitignore', names)
base = archive.read('cyd-display/home-like-2432s028.yaml').decode()
values = dict(re.findall(r'^ (\w+): "([^"]*)"', base, re.M))
self.assertEqual(values['TOUCH_AFFINE_XX'], '1.000000000')
self.assertEqual(values['DIRECT_ACTIONS'], 'false')
for i in range(1,11):
self.assertRegex(values[f'TILE{i}_ENTITY'], r'\.(example_|unused_)')
archive.extractall(tmp)
new_device.create(Path(tmp)/'cyd-display', 'fresh-board', 'Fresh board')
with self.assertRaises(FileExistsError): export_bundle.export(ROOT, output)
if __name__ == '__main__': unittest.main()
+31
View File
@@ -101,3 +101,34 @@ static void test_explicit_slots() {
assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1);
}
struct RunSlots { RunSlots() { test_explicit_slots(); } } run_slots;
// What only some tiles carry lives in an Extra that exists only while a state needs it.
static void test_extra() {
using namespace runtime_tiles;
Tile lamp;
assert(!lamp.extra_ptr() && lamp.extra().options.empty() && !lamp.choice('m'));
lamp.set_extra(Extra{});
assert(!lamp.extra_ptr()); // an empty block is never allocated
Extra climate; climate.hvac_modes = "[\"off\",\"heat\"]"; climate.hvac_action = "heating";
lamp.set_extra(std::move(climate));
Extra *kept = lamp.extra_ptr();
assert(kept && lamp.extra().hvac_action == "heating");
Extra again; again.hvac_action = "idle";
lamp.set_extra(std::move(again));
assert(lamp.extra_ptr() == kept && lamp.extra().hvac_action == "idle" && lamp.extra().hvac_modes.empty());
Tile copy = lamp; // a copy owns its own block
copy.edit_extra().hvac_action = "cooling";
assert(lamp.extra().hvac_action == "idle" && copy.extra().hvac_action == "cooling");
lamp.set_extra(Extra{});
assert(!lamp.extra_ptr() && lamp.extra().hvac_action.empty());
Extra robot; Choice mode; mode.kind = 'm'; mode.values = {"vacuum", "mop"}; robot.choices.push_back(mode); robot.room = "Kitchen";
Tile vacuum; vacuum.set_extra(std::move(robot));
assert(vacuum.choice('m') && vacuum.choice('m')->values.size() == 2 && !vacuum.choice('w') && vacuum.extra().room == "Kitchen");
vacuum.choice('m')->sent = "mop";
assert(static_cast<const Tile &>(vacuum).choice('m')->sent == "mop");
// A new layout resets the slots, blocks included.
Model m; bool changed = false;
assert(m.set_layout({"vacuum.robot"}, "Home", changed));
m.tiles[0] = vacuum;
assert(m.set_layout({"light.a"}, "Home", changed) && changed && !m.tiles[0].extra_ptr());
}
struct RunTileExtra { RunTileExtra() { test_extra(); } } run_tile_extra;
+11 -11
View File
@@ -49,7 +49,7 @@ int main() {
assert(keys_for(old_robot, keys) == 2 && key_action(old_robot, VACUUM_START).service == "vacuum.turn_on");
// Media: playback keys follow supported_features; mute flips is_volume_muted.
Tile sonos = make("media_player.sonos", "playing", 8321599); sonos.volume = 0.17f; sonos.media_title = "TV"; sonos.controls = "playback";
Tile sonos = make("media_player.sonos", "playing", 8321599); sonos.volume = 0.17f; sonos.edit_extra().media_title = "TV"; sonos.controls = "playback";
assert(keys_for(sonos, keys) == 3 && !strcmp(keys[1].icon, glyph::PAUSE) && keys[1].command == MEDIA_PLAY_PAUSE);
assert(status_text(sonos) == "TV · 17%");
Action mute = key_action(sonos, MEDIA_MUTE);
@@ -60,13 +60,13 @@ int main() {
assert(status_text(radio) == "Idle");
// Climate: mode keys in priority order, at most three, the active one checked.
Tile ac = make("climate.ac", "cool"); ac.hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1;
Tile ac = make("climate.ac", "cool"); ac.edit_extra().hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1;
assert(keys_for(ac, keys) == 3);
assert(keys[0].arg == "off" && keys[1].arg == "heat" && keys[2].arg == "cool" && keys[2].checked && !keys[1].checked);
Action mode = key_action(ac, HVAC_MODE, keys[1].arg);
assert(mode.service == "climate.set_hvac_mode" && mode.key == "hvac_mode" && mode.value == "heat");
assert(status_text(ac) == "Cool · 21.5°");
ac.hvac_action = "cooling"; assert(status_text(ac) == "Cooling · 21.5°");
ac.edit_extra().hvac_action = "cooling"; assert(status_text(ac) == "Cooling · 21.5°");
assert(edit_target(ac) == 20 && edit_step(ac) == 1);
Action set = edit_action(ac, step_value(edit_target(ac), edit_step(ac), ac.minimum, ac.maximum, 1));
assert(set.service == "climate.set_temperature" && set.key == "temperature" && set.value == "21");
@@ -76,12 +76,12 @@ int main() {
Tile number = make("number.target", "55"); number.minimum = 0; number.maximum = 100; number.step = 5;
assert(edit_target(number) == 55 && edit_action(number, step_value(55, 5, 0, 100, -1)).value == "50");
assert(edit_action(number, 50).service == "number.set_value");
Tile select = make("select.stand", "Comfort"); select.options = {"Eco", "Comfort", "Boost"}; select.option_count = 3; select.controls = "stepper";
Tile select = make("select.stand", "Comfort"); select.edit_extra().options = {"Eco", "Comfort", "Boost"}; select.controls = "stepper";
assert(panel_kind(select) == "chevrons");
assert(keys_for(select, keys) == 2 && !keys[0].disabled);
assert(key_action(select, SELECT_NEXT).value == "Boost" && key_action(select, SELECT_PREVIOUS).value == "Eco");
select.state = "Boost"; assert(key_action(select, SELECT_NEXT).value == "Eco");
select.option_count = 1; assert(keys_for(select, keys) == 2 && keys[0].disabled);
select.edit_extra().options.resize(1); assert(keys_for(select, keys) == 2 && keys[0].disabled);
// Timer, run buttons and toggles.
Tile timer = make("timer.eggs", "active"); timer.controls = "buttons";
@@ -109,12 +109,12 @@ int main() {
auto row = [](char kind, const char *entity, const char *current, std::vector<std::string> values, const char *roles = "") {
runtime_tiles::Choice c; c.kind = kind; c.entity = entity; c.current = current; c.values = values; c.labels = values; c.roles = roles; return c;
};
pippa.choices = {row('m', "select.woonkamer_s8_schoonmaakmodus", "vac_and_mop", {"vacuum", "vac_and_mop", "mop", "custom"}, "vbma"),
pippa.edit_extra().choices = {row('m', "select.woonkamer_s8_schoonmaakmodus", "vac_and_mop", {"vacuum", "vac_and_mop", "mop", "custom"}, "vbma"),
row('w', "select.s8_intensiteit_van_dweilen", "intense", {"mild", "standard", "intense"}),
row('s', "", "", {"quiet", "balanced", "turbo", "max", "max_plus"})};
pippa.fan_speed = "max";
settle_suction(pippa);
assert(pippa.choice('s')->current == "max" && pippa.choices.size() == 3);
pippa.edit_extra().fan_speed = "max";
settle_suction(pippa.edit_extra());
assert(pippa.choice('s')->current == "max" && pippa.extra().choices.size() == 3);
auto rows = vacuum_rows(pippa, 0);
assert(rows.suction && rows.water && vacuum_role(pippa, 0) == 'b');
pippa.choice('m')->current = "mop"; rows = vacuum_rows(pippa, 0);
@@ -133,8 +133,8 @@ int main() {
assert(choice_action(pippa, 's', "turbo").service == "vacuum.set_fan_speed" && choice_action(pippa, 's', "turbo").value == "turbo");
// An older manager sends no suction row: the vacuum's own four speeds, with the card's names.
Tile old_robot2 = make("vacuum.old", "docked", 30524);
old_robot2.fan_speed_count = 2; old_robot2.fan_speeds[0] = "quiet"; old_robot2.fan_speeds[1] = "balanced"; old_robot2.fan_speed = "balanced";
settle_suction(old_robot2);
old_robot2.edit_extra().fan_speeds = {"quiet", "balanced"}; old_robot2.edit_extra().fan_speed = "balanced";
settle_suction(old_robot2.edit_extra());
assert(old_robot2.choice('s') && old_robot2.choice('s')->labels[1] == "Normal" && old_robot2.choice('s')->current == "balanced");
rows = vacuum_rows(old_robot2, 0);
assert(rows.suction && !rows.water && !choice_action(old_robot2, 'm', "mop").valid());
-63
View File
@@ -1,63 +0,0 @@
"""Export an allowlisted, neutral starter ZIP (never copies local secrets/logs)."""
import argparse
from pathlib import Path
import re
import zipfile
ROOT = Path(__file__).resolve().parents[1]
FILES = ['.gitignore', 'README.md', 'AGENTS.md', 'requirements.txt', 'device.example.yaml',
'calibration.example.yaml', 'secrets.yaml.example', 'cyd_ui.h', 'light_controls.h', 'LICENSE',
'guition_diagnostics.h', 'guition-4848s040.yaml', 'guition-device.example.yaml', 'TILE_CONFIGURATION.md', 'CYD_STABILITY.md', 'TEST_RESULTS.md', 'repository.yaml']
FOLDERS = ['docs', 'tools', 'tests', 'components', 'diagnostics', 'fonts']
SUFFIXES = {'.py', '.cpp', '.h', '.md', '.ttf', '.txt'}
def neutral_base(root):
defaults = '\n'.join((root / name).read_text() for name in ['device.example.yaml', 'calibration.example.yaml'])
replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M))
base = (root / 'home-like-2432s028.yaml').read_text()
def replace(match):
key = match[1]
return f' {key}: {replacements.get(key, match[2])}'
return re.sub(r'^ (\w+): ("[^"\n]*")', replace, base, flags=re.M)
def export(root, output):
root, output = Path(root).resolve(), Path(output)
paths = [root / name for name in FILES]
for folder in FOLDERS:
for path in (root / folder).rglob('*'):
if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES or path.name == 'LICENSE'):
paths.append(path)
# Dedicated distribution directories only; never recursively include owner YAML.
for folder in ['screen_manager', 'packages', 'installers']:
for path in (root / folder).rglob('*'):
if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES | {'.yaml', '.html', '.js', '.css'} or path.name == 'Dockerfile'):
paths.append(path)
for path in paths:
if path.is_symlink() or not path.resolve().is_relative_to(root):
raise ValueError(f'No files outside the source folder or symlinks allowed: {path}')
if not path.is_file():
raise ValueError(f'Missing bundle file: {path}')
output.parent.mkdir(parents=True, exist_ok=True)
# Exclusive mode refuses to replace a previously delivered bundle.
with zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('cyd-display/home-like-2432s028.yaml', neutral_base(root))
for path in sorted(set(paths)):
archive.write(path, 'cyd-display/' + path.relative_to(root).as_posix())
return output
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=ROOT / 'dist' / 'cyd-starter.zip')
args = parser.parse_args()
try:
print(f'Share this ZIP: {export(ROOT, args.output)}')
print('No local secrets, device.yaml, calibration measurements, logs or firmware backups included.')
except (OSError, ValueError) as exc:
parser.exit(1, f'Error: {exc}\n')
if __name__ == '__main__':
main()
+7 -19
View File
@@ -7,21 +7,14 @@ ROOT = Path(__file__).resolve().parents[1]
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
def generate(board):
def generate(board, text=None):
source = ROOT / ('home-like-2432s028.yaml' if board == 'cyd' else 'guition-4848s040.yaml')
s = source.read_text()
defaults = (ROOT / ('device.example.yaml' if board == 'cyd' else 'guition-device.example.yaml')).read_text()
defaults += '\n' + (ROOT / 'calibration.example.yaml').read_text()
replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M))
replacements.update(DYNAMIC_TILES='"true"', DASHBOARD_INBOX_INTERNAL='"false"', DIRECT_ACTIONS='"true"',
ROOM_NAME='"Choose tiles in HA"', TILE_COUNT='"1"')
s = re.sub(r'^ (\w+): ("[^"\n]*")', lambda m: f' {m[1]}: {replacements.get(m[1],m[2])}', s, flags=re.M)
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)
# Referenced files are fetched as an external component or over HTTPS.
s = re.sub(r'^ - (cyd_ui.h|light_controls.h|guition_diagnostics.h|backlight_fade.h|alert_overlay.h)$', r' - <esphome/components/smart_display/\1>', s, flags=re.M)
# 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:
@@ -35,17 +28,12 @@ def generate(board):
'''
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)
# Keep the established card bindings, but remove all fixed HA subscriptions.
# Runtime values arrive through ESP Screen Manager, including after changing entities.
# 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 not match: continue
block = match[0]
def sensor(m):
body = re.sub(r'^ (entity_id|attribute):.*\n', '', m[1], flags=re.M)
return ' - platform: template\n update_interval: never\n' + body
block = re.sub(r'^ - platform: homeassistant\n(.*?)(?=^ - platform:|\Z)', sensor, block, flags=re.M | re.S)
s = s[:match.start()] + block + s[match.end():]
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
-65
View File
@@ -1,65 +0,0 @@
"""Create local device, calibration and secrets files without overwriting any."""
import argparse
import base64
import os
from pathlib import Path
import re
import secrets
ROOT = Path(__file__).resolve().parents[1]
def create(directory, name, friendly_name, board="cyd"):
if not re.fullmatch(r'[a-z][a-z0-9-]{0,30}', name) or name.endswith('-'):
raise ValueError('Use a unique name of 1-31 characters: lowercase letters, digits, and dashes.')
if board not in ("cyd", "guition"):
raise ValueError("Unknown board profile")
import json
device = (ROOT / ('guition-device.example.yaml' if board == 'guition' else 'device.example.yaml')).read_text().replace('"guition-new"' if board == 'guition' else '"cyd-new"', json.dumps(name), 1)
device = device.replace('"My Guition"' if board == 'guition' else '"My CYD"', json.dumps(friendly_name, ensure_ascii=False), 1)
secret_text = (ROOT / 'secrets.yaml.example').read_text()
replacements = [base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(18)]
for value in replacements:
secret_text = secret_text.replace('WILL_BE_UNIQUELY_GENERATED', value, 1)
files = {'guition-device.yaml' if board == 'guition' else 'device.yaml': device, 'secrets.yaml': secret_text}
if board == 'cyd': files['calibration.yaml'] = (ROOT / 'calibration.example.yaml').read_text()
directory = Path(directory)
if not directory.is_dir():
raise ValueError('The target folder must already exist and contain the unpacked code.')
for name in files:
if (directory / name).exists():
raise ValueError(f'{directory / name} already exists; nothing overwritten. Use a fresh copy of the folder.')
created = []
try:
for name, contents in files.items():
path = directory / name
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
created.append(path)
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
handle.write(contents)
except Exception:
for path in created:
path.unlink(missing_ok=True)
raise
return created
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--board', choices=['cyd', 'guition'], default='cyd')
parser.add_argument('--name', required=True, help='Unique ESPHome name, for example display-kitchen')
parser.add_argument('--friendly-name')
parser.add_argument('--directory', type=Path, default=ROOT)
args = parser.parse_args()
args.friendly_name = args.friendly_name or ('My Guition' if args.board == 'guition' else 'My CYD')
try:
for path in create(args.directory, args.name, args.friendly_name, args.board):
print(f'Created: {path}')
print('Fill in Wi-Fi locally in secrets.yaml. Keys are uniquely generated and are not shown.')
print('Follow docs/GUITION.md; flash guition-device.yaml.' if args.board == 'guition' else 'Follow README.md; flash device.yaml.')
except (ValueError, OSError) as exc:
parser.exit(1, f'Error: {exc}\n')
if __name__ == '__main__':
main()