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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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()); }
|
||||
|
||||
@@ -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] : "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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: ""
|
||||
@@ -1,2 +0,0 @@
|
||||
#pragma once
|
||||
#include "esphome/components/smart_display/guition_diagnostics.h"
|
||||
+83
-2747
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+65
-2622
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
#include "../cyd_ui.h"
|
||||
#include "../components/smart_display/cyd_ui.h"
|
||||
#include <cassert>
|
||||
#include <limits>
|
||||
int main() {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,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,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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user