Build the screens with ESPHome 2026.9 and keep its build memory in the app's own storage, give the CYD 94 KB more room, and stop a timer from counting one second too many (0.2.89)

The add-on ships ESPHome 2026.9.0: it builds an ESP32 with ESP-IDF itself
and keeps a compiler cache. firmware.py gives the CLI its own storage in
/data (ESPHOME_DATA_DIR /data/esphome, which the ESPHome Device Builder no
longer wipes on start; ESP-IDF and ccache in /data/idf, at most 1 GB;
compilers limited to the core count as PlatformIO ran them), removes the
unused PlatformIO once before the first ESP-IDF build, and leaves the new
folders out of backups.

Firmware 0.2.75: assertion_level SILENT in every board's esp32 block, the
core logs from INFO, and clock_parts() in runtime_model.h replaces sscanf.
timer_left() caps a running timer at its duration (the screen's clock runs
up to a second behind Home Assistant's). The packages keep min_version
2026.6.2, the top-level online_image and OTA passwords for new screens, so
an add-on or Device Builder that isn't updated yet keeps building every
screen; CI now builds both boards with the add-on's ESPHome and with
min_version. ESP-IDF's error names stay on, and build_flags stay under
platformio_options, because ESPHome 2026.9's LVGL reads only those.

tools/check.sh: 478 Python tests, 19/19 C++, editor; --firmware PASS with
ESPHome 2026.6.2 (CYD 87.7 %), 2026.8.1 (88.3 %) and 2026.9.0 (86.4 %,
was 91.5 %). Both bench screens OTA'd and watched for half an hour;
ESP Screens as a local add-on on the Yellow built both profiles
(CYD 1,037 s first build, 41 s unchanged). Details:
docs/TEST_RESULTS_0289.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 12:35:49 +02:00
parent 51bb2c0ddc
commit 3b15e7f7a3
25 changed files with 542 additions and 41 deletions
+21 -5
View File
@@ -43,25 +43,41 @@ jobs:
CXX: clang++
firmware:
name: firmware
name: firmware (${{ matrix.esphome }})
needs: checks
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# The ESPHome the add-on ships, and the oldest one the packages promise (`min_version` in packages/core.yaml):
# every screen builds from main with whichever its owner has (docs/RELEASING.md, Compatibility 0.2.89).
esphome: [add-on, min_version]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
# The ESPHome the add-on ships and builds every screen's update with.
- name: ESPHome version of the add-on
- name: ESPHome version
id: esphome
run: echo "version=$(sed -n 's|^FROM ghcr.io/esphome/esphome:||p' screen_manager/Dockerfile)" >> "$GITHUB_OUTPUT"
run: |
if [ "${{ matrix.esphome }}" = add-on ]; then
version=$(sed -n 's|^FROM ghcr.io/esphome/esphome:||p' screen_manager/Dockerfile)
else
version=$(sed -n 's/^ min_version: //p' packages/core.yaml)
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: ESPHome
run: python -m pip install "esphome==${{ steps.esphome.outputs.version }}"
# PlatformIO's toolchains and ESP-IDF, and the two check builds (tools/check.sh builds under .esphome/check).
# ESPHome 2026.7+ builds with ESP-IDF itself and reuses what it compiled before when ccache is on the PATH.
- name: ccache
run: ccache --version || (sudo apt-get update && sudo apt-get install -y ccache)
# ESP-IDF, its tools and ccache's cache (ESPHome 2026.7+ keeps them in ~/.cache/esphome), PlatformIO's toolchains
# (ESPHome before 2026.7), and the two check builds (tools/check.sh builds under .esphome/check).
- uses: actions/cache@v4
with:
path: |
~/.cache/esphome
~/.platformio
.esphome/check
key: firmware-${{ runner.os }}-esphome-${{ steps.esphome.outputs.version }}-${{ hashFiles('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/**', 'components/**') }}
+2 -1
View File
@@ -38,7 +38,8 @@ physically tap; an agent cannot replace that with software coordinates.
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.
The add-on builds with ESPHome 2026.9.0 (`screen_manager/Dockerfile`, Python 3.12-3.14); the packages also build with
their `min_version` (2026.6.2). 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
+2 -1
View File
@@ -160,7 +160,8 @@ https://github.com/user-attachments/assets/f9f6a933-d388-4c8b-9c9a-011dfa37617b
Use these exact board variants: similar-looking product names can have different
controllers or connectors. Wallbox relays are not controlled.
The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
ESP Screens builds with its own **ESPHome 2026.9.0**. The firmware also builds in your own ESPHome Device Builder
with ESPHome 2026.6.2 or newer.
### Do I need ESPHome?
+38
View File
@@ -144,6 +144,44 @@ struct Extra {
action_templates.empty() && state_word.empty() && effect.empty() && option_rows.empty() && number_rows.empty();
}
};
// The numbers of a clock text ("0:05:00", "07:45"), at most `max` of them, each after optional white space, up to the
// first character that is not a digit or a colon after one: what sscanf's "%u:%u:%u" reads. Read by hand (firmware
// 0.2.75+): sscanf brought newlib's whole scanf into the firmware, 9.5 KB on the CYD.
inline int clock_parts(const std::string &text, unsigned *out, int max) {
int n = 0;
const char *p = text.c_str();
while (n < max) {
while (*p == ' ' || (*p >= '\t' && *p <= '\r')) ++p;
if (*p < '0' || *p > '9') break;
unsigned value = 0;
while (*p >= '0' && *p <= '9') value = value * 10 + unsigned(*p++ - '0');
out[n++] = value;
if (*p != ':') break;
++p;
}
return n;
}
// A timer's duration or remaining time as Home Assistant writes it ("0:05:00", or "5:00") in seconds; 0 when unusable.
inline uint32_t duration_seconds(const std::string &text) {
unsigned v[3] = {0, 0, 0};
const int n = clock_parts(text, v, 3);
if (n == 3) return v[0] * 3600 + v[1] * 60 + v[2];
if (n == 2) return v[0] * 60 + v[1];
return 0;
}
// Seconds a running timer has left, from Home Assistant's end time and the screen's clock. Both are whole seconds and
// the clock runs up to a second behind Home Assistant's (it syncs in whole seconds), so the difference can be one more
// than the timer holds: never more than its duration (firmware 0.2.75+; a 3 s timer started early in a second read 0:04).
inline uint32_t timer_left(uint32_t end, uint32_t now, const std::string &duration) {
const uint32_t left = end > now && now ? end - now : 0;
const uint32_t full = duration_seconds(duration);
return full && left > full ? full : left;
}
// A sun time from the add-on ("06:45") in minutes after midnight; -1 when unusable.
inline int minutes_of(const std::string &clock) {
unsigned v[2] = {0, 0};
return clock_parts(clock, v, 2) == 2 && v[0] < 24 && v[1] < 60 ? int(v[0] * 60 + v[1]) : -1;
}
// The Extra of a tile on the heap, copied along with the tile like an ordinary member.
struct ExtraBox {
std::unique_ptr<Extra> ptr;
+1 -11
View File
@@ -2144,13 +2144,6 @@ inline const char *icon_for(const Tile &tile) {
if (d == "camera" || d == "image") return "\U000F07AE";
return "\U000F0425";
}
// HA duration strings ("0:05:00") to seconds; 0 when unusable.
inline uint32_t duration_seconds(const std::string &text) {
unsigned h = 0, m = 0, s = 0;
if (sscanf(text.c_str(), "%u:%u:%u", &h, &m, &s) == 3) return h * 3600 + m * 60 + s;
if (sscanf(text.c_str(), "%u:%u", &m, &s) == 2) return m * 60 + s;
return 0;
}
inline std::string countdown(uint32_t seconds) {
char b[16];
if (seconds >= 3600) snprintf(b, sizeof(b), "%u:%02u:%02u", seconds / 3600, seconds / 60 % 60, seconds % 60);
@@ -2171,7 +2164,7 @@ inline std::string last_run_text(uint32_t epoch) {
}
inline std::string timer_text(const Tile &t) {
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 == "active") return countdown(timer_left(x.timer_end, now_epoch(), x.duration));
if (t.state == "paused") return "Paused " + countdown(duration_seconds(x.remaining));
return x.duration.empty() ? "Off" : countdown(duration_seconds(x.duration));
}
@@ -2617,9 +2610,6 @@ inline void render_graph(Widgets &w,const Tile &t,bool large,int x,int y,int wid
}
// Sun path: horizon, an arc from sunrise to sunset and the sun at the current
// position (or below the horizon at night). Wide cards only.
inline int minutes_of(const std::string &clock) {
unsigned h=0,m=0;return sscanf(clock.c_str(),"%u:%u",&h,&m)==2 && h<24 && m<60 ? int(h*60+m) : -1;
}
inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int height) {
begin_extra(w,"sunpath",width,height);
const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
+1 -1
View File
@@ -80,7 +80,7 @@ under **Settings → Devices & services** in Home Assistant, and then choose the
[ESPHome Web](https://web.esphome.io) in Chrome or Edge on the computer the screen is plugged into.
- **Backups:** Home Assistant backups don't include this container. Keep a copy of
`data/screens.json` and `data/updates.json` (layouts and update settings) and of the
ESPHome folder. `data/build` and `data/platformio` are caches.
ESPHome folder. `data/build`, `data/esphome`, `data/idf` and `data/platformio` are caches.
- **Claude:** **Install for Claude Code** is meant for the Claude Code app on Home Assistant
OS. Use **Download for claude.ai** instead.
- **Camera images (Guition, app 0.2.66+):** the screens load camera pictures, and from app 0.2.77 the
+1 -1
View File
@@ -29,7 +29,7 @@ The wallbox relays are not used.
3. Install and start **ESP Screen Manager**. Turn on **Start on boot**
and **Show in sidebar**. Open the **ESP Screens** web interface.
This app includes the tested ESPHome 2026.6.2 CLI and runs within your HA login.
This app includes the tested ESPHome 2026.9.0 CLI and runs within your HA login.
A second ESPHome management page, MQTT, blueprint, or long-lived token is not needed.
Use the GitHub version for updates; a local test add-on is a separate app.
+3 -1
View File
@@ -37,7 +37,9 @@ the board file replaces the core's. `esphome config home-like-2432s028.yaml` sho
## Adding a board
1. Copy the board file of the panel that resembles the new one most (a small SPI panel: the CYD; a big RGB panel
with capacitive touch: the Guition) to `packages/boards/<board>.yaml` and change its hardware sections.
with capacitive touch: the Guition) to `packages/boards/<board>.yaml` and change its hardware sections. Keep
`assertion_level: SILENT` in its `esp32:` block (firmware 0.2.75+): every board builds its firmware the same way,
and `tests/test_easy_package.py` checks it.
2. Fill the sizes table for the new resolution, and go through the hooks: keep, change or empty each one.
3. Add `packages/<board>.yaml` and `<board>.yaml` after the existing entries, `<board>` to `ENTRIES` in
`tools/profiles.py` and to `REFS` in `screen_manager/app/core.py` (the boards `installation_yaml()` writes a
+64 -2
View File
@@ -131,8 +131,8 @@ don't replace these folders. Native number settings report changes via
`esphome.screen_setting`; the manager validates the inbox, key, and value.
The built-in CLI stores re-downloadable caches under `/data/build` and
`/data/platformio`. Only these folders are excluded from app backups via
`backup_exclude`. `/data/screens.json` and the device's own ESPHome configurations remain
`/data/platformio` (0.2.89 adds `/data/esphome` and `/data/idf`, see Compatibility 0.2.89). Only these folders are
excluded from app backups via `backup_exclude`. `/data/screens.json` and the device's own ESPHome configurations remain
backup data; never exclude `/data` as a whole.
### Compatibility 0.2.7
@@ -219,6 +219,68 @@ icons sit off-center in the browser).
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
### Compatibility 0.2.89 / firmware 0.2.75
ESPHome 2026.9.0 in the add-on (`screen_manager/Dockerfile`, `requirements.txt`); the packages' `min_version` stays
2026.6.2. No protocol, storage, preference or key change; the firmware behaves the same apart from its log level and
the timer countdown.
- **Staged on purpose.** Screens build their firmware from the packages on main (`refresh: 0s`), whatever ESPHome
builds them: this add-on, an add-on not yet updated (2026.6.2), or the owner's ESPHome Device Builder (2026.8.1 on
Max's HA that day). A `min_version` of 2026.9.0 would have failed all of those builds, the nightly round included,
until each owner updated. So this release uses nothing the packages' `min_version` lacks: the Guition keeps the
top-level `online_image:` and new screens keep an OTA password. The next step is a release of its own: raise
`min_version` to 2026.9.0, move to `image: - platform: online_image` (ESPHome 2027.1 removes the top-level form) and
write `ota: - platform: esphome` with `encryption: {}` in `installation_yaml()` (ESPHome 2026.9 takes the api key;
about 0.9 KB less on the CYD). CI (`.github/workflows/ci.yml`) builds both boards with the add-on's ESPHome and with
the packages' `min_version`, so a form newer than `min_version` fails there.
- **Toolchain.** ESPHome 2026.7+ builds an ESP32 with ESP-IDF itself (native toolchain) instead of PlatformIO, and uses
ccache when it is on the PATH (the ESPHome image ships it). Its ESP-IDF, tools and ccache go in the user cache folder
unless `ESPHOME_ESP_IDF_PREFIX` says otherwise; inside the add-on's container that folder is gone at every restart.
- **Where the add-on's CLI keeps things** (`Firmware.build_env`): `ESPHOME_BUILD_PATH=/data/build/<profile>` as before,
`ESPHOME_DATA_DIR=/data/esphome` (ESPHome's storage JSON, `packages/` and `external_components/` clones; before, this
was `/homeassistant/esphome/.esphome`, which the ESPHome Device Builder app `rm -rf`s on every start, and a missing
storage JSON makes ESPHome wipe the build folder), `ESPHOME_ESP_IDF_PREFIX=/data/idf` (as the official ESPHome app does
with `/data/cache/idf`), `CCACHE_MAXSIZE=1G` unless the environment sets one, and `PLATFORMIO_CORE_DIR=/data/platformio`
for whatever ESPHome still builds with PlatformIO. `backup_exclude` adds `*/esphome/` and `*/idf/`.
`ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT` is the machine's core count (unless the environment sets one), the
parallelism PlatformIO used: ESP-IDF's ninja defaults to cores + 2, and each compiler takes a few hundred MB next to
Home Assistant on a small Raspberry Pi (the add-on showed 1.2 GB in use while the Yellow built with ninja's default).
- **The first build after the update** has no storage JSON in `/data/esphome` and a new toolchain, so ESPHome wipes
that screen's build folder (`storage_should_clean`, `clean_build(full=True)`) and builds it completely, once per
screen; the old PlatformIO build trees go with it. Before the first build with ESP-IDF (no `/data/idf` yet),
`Firmware.retire_platformio()` removes `/data/platformio`: PlatformIO's ESP32 toolchains and ESP-IDF, unused from now
on. A check of the YAML (validate) leaves it. `tests/test_build_env.py` covers the environment, the backup list and
the one-time removal.
- **Firmware size** (`packages/boards/*.yaml`, `esp32: framework: advanced: assertion_level: SILENT` in every board,
checked by `tests/test_easy_package.py`): ESP-IDF's assert messages are left out (49.0 KB on the CYD, measured alone
on 2026.6.2); an assert still aborts, and the backtrace still decodes to the line. `CONFIG_ESP_ERR_TO_NAME_LOOKUP`
stays on: without it `esp_err_to_name()` returns "UNKNOWN ERROR", and ESPHome's own error lines (30 in its Wi-Fi code)
print only that name, not the number. `logger: level: INFO` in the core (8.9 KB): `ESP_LOGCONFIG` and `ESP_LOGD`
compile away; the UI self-test's `logger.log` lines say `level: INFO`. A screen's Override YAML may still set
`logger: level: DEBUG`.
- **sscanf.** `duration_seconds()` and `minutes_of()` moved from `runtime_tiles.h` to `runtime_model.h` and read their
numbers with `clock_parts()` instead of `sscanf` (9.5 KB of newlib's scanf on the CYD). The same answers as the
`sscanf` forms for any text, white space before a number included (compared on two million random strings);
`tests/test_runtime_model.cpp` holds the cases.
- **Timer countdown.** `timer_left()` (`runtime_model.h`) caps a running timer's countdown at its `duration`: the
screen's clock (Home Assistant's whole `epoch_seconds`, synced every minute) runs up to a second behind, so
`finishes_at - now` could read one more than the timer lasts.
- **Build flags stay under `platformio_options`.** ESPHome 2026.9 warns that `esphome: platformio_options: build_flags`
is deprecated (removed in 2026.12) in favour of `esphome: build_flags`, but its LVGL component reads only the former
when it writes `lv_conf.h` (`generate_lv_conf_h`, since ESPHome PR #16362) and writes `#define LV_USE_SWITCH 0` over
`-DLV_USE_SWITCH=1` given the new way: `lv_switch_create` is then undeclared. Move `LV_USE_SWITCH` (core) and
`LV_USE_SNAPSHOT` (Guition) once ESPHome's LVGL reads `esphome: build_flags`; `tools/check.sh --firmware` fails if
the move is early.
- **OTA.** Profiles keep `password:`. ESPHome 2026.9's CLI tries the api key first and falls back to the password for
firmware that doesn't offer encryption yet (both bench screens: "The device did not offer OTA encryption; continuing
in plaintext"); every 2026.9 build with an api key offers it, and ESPHome 2026.6.2's uploader still gets in with the
password. ESPHome removes the fallback in 2027.3; a password profile then uploads in plaintext with its password.
ESPHome 2026.9 warns about the password at every build of such a profile.
- **Tooling.** Repository builds need Python 3.12-3.14 for ESPHome 2026.9 (`.esphome/venv313` on the owner's Mac);
CI caches `~/.cache/esphome` (ESP-IDF and ccache) instead of `~/.platformio` and installs ccache. Details and
measurements: docs/TEST_RESULTS_0289.md.
### Compatibility 0.2.88 / firmware 0.2.74
Firmware only, one setting: `packages/core.yaml` has `wifi: power_save_mode: none`, the first `wifi:` key in a package.
+171
View File
@@ -0,0 +1,171 @@
# Test results app 0.2.89 / firmware 0.2.75 (2026-09-19)
Max asked whether ESPHome's defaults leave room for faster builds and smaller firmware, and after the report
(flash and build time, 2026-09-19) said: "ga het doen, maak het toekomstbestendig". This round implements it: ESPHome
2026.9.0 in the add-on, the add-on's build memory out of the ESPHome folder, and three settings that make the CYD's
firmware smaller. Everything stays on ESPHome's and ESP-IDF's own options; no lwIP, Wi-Fi or LVGL settings.
## What changed
| | Before (0.2.88) | Now (0.2.89) |
|---|---|---|
| ESPHome in the add-on | 2026.6.2 (PlatformIO) | 2026.9.0 (ESP-IDF itself, ccache) |
| The packages' `min_version` | 2026.6.2 | 2026.6.2, on purpose (below) |
| ESPHome's build memory (storage JSON, fetched packages) | `/homeassistant/esphome/.esphome` | `/data/esphome` |
| ESP-IDF, its tools, the compiler cache | PlatformIO in `/data/platformio` | `/data/idf`, ccache at most 1 GB |
| ESP-IDF assert messages | in the firmware | left out (`assertion_level: SILENT`, every board) |
| Log level of the screen | DEBUG | INFO |
| Timer and sun times | `sscanf` | `clock_parts()` in `runtime_model.h` |
| A running timer's countdown | could read one more than its duration | at most its duration |
Three items of the report were left out or put off on purpose:
- **`min_version` 2026.9.0**, with the Guition's `image: - platform: online_image` and `ota: encryption:` for new
screens. It was in the branch and on both bench screens; before the release Max asked whether everyone can update
smoothly, and this is where they couldn't. Screens build from the packages on main whatever ESPHome builds them: an
add-on not yet updated has 2026.6.2, Max's own ESPHome Device Builder had 2026.8.1. ESPHome 2026.6.2 knows neither
the `image:` form of `online_image` nor `ota: encryption:` (checked in its source), so every such build, the nightly
round included, would have failed until its owner updated. The packages now use nothing their `min_version` lacks;
raising it is a release of its own, before ESPHome 2027.1 drops the top-level `online_image:`. CI builds both
boards with the add-on's ESPHome and with `min_version` from now on.
- **ESP-IDF's error names** (`CONFIG_ESP_ERR_TO_NAME_LOOKUP: n`, 7.2 KB). The report said error codes would then be
logged as a number. Checked in ESP-IDF 5.5.5 (`esp_err_to_name.c`): without the table `esp_err_to_name()` returns
"UNKNOWN ERROR", and ESPHome's own error lines print only that name (30 of them in its Wi-Fi code, none with the
number). A failing Wi-Fi start would then say nothing useful.
- **`esphome: build_flags`** instead of `platformio_options: build_flags`. The move made the CYD fail to compile:
ESPHome 2026.9's LVGL reads only `platformio_options` when it writes `lv_conf.h` and wrote `#define LV_USE_SWITCH 0`
over the flag (`'lv_switch_create' was not declared`). The dev branch of ESPHome still does; no issue or pull request
about it was found. The old form works until ESPHome 2026.12 and prints a deprecation warning.
## A running timer read one second too many
Max, testing the Guition: the idle tile of `timer.ok_nabu_timer` (duration `0:00:03`) read 0:03, but a tap started it
at 0:04, and it never came below 0:02 before Home Assistant said it had finished. Not a change of this round (the
countdown and ESPHome's time sync are the same in 0.2.74 and ESPHome 2026.6.2): the tile subtracts the screen's clock
from Home Assistant's end time (`finishes_at`, whole seconds from the add-on), and the screen sets its clock from Home
Assistant's `epoch_seconds` every minute, so it runs up to a second behind; a timer started early in a second then
reads one more than it lasts, and Home Assistant's finish comes while the tile still shows 0:01 or 0:02.
`timer_left()` (`runtime_model.h`) now caps the countdown at the timer's duration; the end stays up to a second early,
since the screen gets no finer time from Home Assistant. `tests/test_runtime_model.cpp` holds the cases.
## Firmware size
`tools/check.sh --firmware --baseline 1678816` (ESPHome 2026.9.0, the check profiles: fallback hotspot, captive
portal, OTA password):
| | 0.2.88 on 2026.6.2 | 0.2.89 on 2026.9.0 | |
|---|---|---|---|
| CYD image | 1,678,816 B, 91.5 % | 1,585,136 B, 86.4 % | −93,680 B |
| CYD, OTA with the api key (the next step) | | 1,584,208 B, 86.3 % | −912 B more |
| Guition image | 2,055,280 B, 25.3 % | 1,945,152 B, 23.9 % | −110,128 B |
The firmware of this release with each ESPHome a screen may be built with (`tools/check.sh --firmware` per version,
the same check profiles):
| ESPHome | Who builds with it | CYD | Guition |
|---|---|---|---|
| 2026.6.2 (`min_version`) | an ESP Screens not yet updated | 1,609,856 B, 87.7 % (−68,960 B) | 1,982,896 B, 24.4 % |
| 2026.8.1 | an ESPHome Device Builder like Max's | 1,620,640 B, 88.3 % (−58,176 B) | 1,998,432 B, 24.6 % |
| 2026.9.0 | ESP Screens 0.2.89 | 1,585,136 B, 86.4 % (−93,680 B) | 1,945,152 B, 23.9 % |
Of the CYD's 93.7 KB, the report's single measurements attribute 49.0 KB to the silent asserts, 8.9 KB to INFO and
9.5 KB to `sscanf`; ESPHome 2026.9 itself saves about 24 KB (its API encryption and the ESP-IDF parts it leaves out).
The European letter set of the language plan (+65.8 KB) now fits at about 90 %.
## Bench screens (over the air from the Mac)
Copies of the Device Builder YAMLs (`guition-wallbox.yaml`, `cyd-2432s028.yaml`: keys, Wi-Fi and the CYD's
calibration unchanged, compared value by value with the root `secrets.yaml`) with the packages, components and fonts of
this branch, built with ESPHome 2026.9.0:
| | Guition Wallbox .136 | CYD .134 |
|---|---|---|
| Before | 0.2.74, ESPHome 2026.6.2 | 0.2.71, ESPHome 2026.6.2 |
| Build on the Mac | 127 s | 100 s |
| Image | 1,945,088 B | 1,489,216 B (its YAML has no hotspot) |
| OTA | 13.0 s, password (plaintext fallback) | 15.4 s, password (plaintext fallback) |
| `device_info` after | 0.2.75, ESPHome 2026.9.0, 10:29:09 | 0.2.75, ESPHome 2026.9.0, 10:31:51 |
| Home Assistant | `Synced`, online | `Synced`, online |
| Free heap 5 min after boot | 86,940 B (fresh boots on 0.2.50: 85-86 KB) | 114,724 B (was 101,712 B) |
| Largest free block | 45,056 B | 57,344 B (was 49,152 B) |
This first flash had the Guition's `image:` form of `online_image`. The released firmware (the top-level form back,
the timer cap added) went onto both the same way at 12:32 and 12:34, now over an encrypted connection with the api
key, since their 2026.9 firmware offered it: Guition 1,945,152 B, CYD 1,489,232 B, both 0.2.75 on ESPHome 2026.9.0 in
`device_info` and `Synced` in Home Assistant.
Max tested both screens by hand for half an hour on the first flash while their logs (over the API, INFO) and Home
Assistant entities were watched from the Mac:
- 0 errors in about 450 log lines; one restart, Max's own from the settings page ("restart from the screen"), back
in 9 s. No lost connection otherwise; both stayed `Synced`.
- 29 taps confirmed by Home Assistant in 256-486 ms (median 343 ms); the light effects page (select and number
entities) changed them in Home Assistant; a docked robot's return to base had nothing to confirm.
- Sonos covers and 22 camera pictures in a row loaded without a failure; the first camera picture 5 s after the tap
(EZVIZ snapshot included, 4.5 s in 0.2.87).
- Free heap steady: Guition about 80 KB (largest block about 34 KB), CYD 98-115 KB (51-56 KB), as before the update.
- ESPHome's "took a long time" warnings at boot, on opening a card (CYD LVGL 356 ms) and while pictures came in; one
picture of the camera that took 4 s instead of 2 held the loop 0.3-1.2 s in `online_image`, whose read loop is the
same as in 2026.6.2 (a slow download, not this release). A full redraw Max saw on the CYD was Back to page 1 after
120 s without a touch.
OTA paths, all on the Guition with the same 0.2.75 image:
- ESPHome 2026.9 CLI, profile with a password, device on 2026.6.2 firmware: "The device did not offer OTA encryption;
continuing in plaintext", success.
- The same again, now that the device runs 2026.9 firmware: "Encrypted connection established" with the api key,
success (14.5 s; the upload itself takes 14 s encrypted against 8 s in plaintext).
- ESPHome 2026.6.2's uploader (`espota2`) with the password, as the add-on before 0.2.89 and the Device Builder 2026.8
upload: success. A screen on 0.2.75 stays reachable for older tools that know its password.
## On the Yellow (local add-on)
This branch's `screen_manager/` as a local add-on (`local_esp_screen_manager` 0.2.89, SMB share `local_apps` and the
Supervisor), next to the store add-on 0.2.85: its camera port set to none, no layouts, so it sent nothing to the
screens. Its image built in 44 s from `ghcr.io/esphome/esphome:2026.9.0` (aarch64). It built the Device Builder
profiles through ESP Screens' own firmware jobs (build only, no upload), so with the packages on GitHub's main
(firmware 0.2.74); the owner's ESPHome folder stays read-only, and this branch's packages were compiled on the Mac.
Uninstalled afterwards, and the copy on the share removed.
| Build on the Yellow | 2026.6.2 with PlatformIO (before) | 2026.9.0 in this add-on |
|---|---|---|
| Guition, first full build | 920 s (2026-09-12) | 1,379 s, of which the first ESP-IDF install into `/data/idf` |
| CYD, first full build | 1,154 s (2026-09-18 23:35) | 1,037 s, ESP-IDF already there |
| Guition again, nothing changed | not measured | 41 s |
- The build went where `Firmware.build_env` says: `/data/build/guition-wallbox/guition-wallbox/build/`, ESPHome's
packages in `/data/esphome/packages/`, ESP-IDF 5.5.5 in `/data/idf` ("ccache will be used for faster
recompilation"). The Download of the built firmware gave `guition-wallbox.factory.bin`, 2,083,728 B, starting with the
ESP image byte 0xE9.
- A first build is about as long as before: a cold ccache saves nothing, and ESP-IDF builds more files than
PlatformIO did (1,699 steps for the Guition). The gain is in what follows. The build folder and ESPHome's memory of
it now survive a restart of the ESPHome Device Builder, which emptied `/homeassistant/esphome/.esphome` and made the
next update of every screen a full build again (the CYD's 18-minute build of 2026-09-18 was one). A new firmware
version still recompiles ESPHome's own sources and `main.cpp`, because the version is a define in `defines.h`: 59 s
on the Mac against 46 s with PlatformIO (the report's measurement), so about ten minutes a screen on a Yellow,
estimated with the Mac-to-Yellow ratio of these builds (107 s against 1,037 s for the CYD).
- The Supervisor showed the add-on at 94 % CPU and 1.2 GB of memory while it built, with ninja's default of cores + 2
compilers. The released add-on starts as many as the machine has cores (`ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT`), as
PlatformIO did, so a small Raspberry Pi builds with about as much memory as before (not measured on one). The Yellow's disk: 60 GB used of
917 GB.
- ESP-IDF's GCC warns `array subscript 6 is above array bounds of 'Key* [6]'` in the Guition's climate card lambda
(`packages/boards/guition-4848s040.yaml`, `visible[room - 1] = visible[i]`): on the Guition `room` is 6 and there
are six keys, so that loop never runs. Left as it is.
## Automated
- `tools/check.sh` (Python 3.13.14, `.esphome/venv313`): Python 478 tests OK (new: `tests/test_build_env.py`, the
OTA line in `test_portal`, every board's `assertion_level` in `test_easy_package`), C++ 19/19 (new: clock texts in
`test_runtime_model.cpp`), packages, icons, editor tests, types, build and bundle PASS.
- `tools/check.sh --firmware --baseline 1678816` with ESPHome 2026.6.2, 2026.8.1 and 2026.9.0: PASS (table above).
- The Mac's ccache after this round and the report's builds: 27,986 compiler calls (about seventeen full builds of
both boards), 0.6 GB. The add-on caps it at 1 GB (`Firmware.CCACHE_SIZE`); past that ccache drops the oldest entries.
- `clock_parts()`: identical answers to the `sscanf` forms for two million random strings of digits, colons, spaces,
tabs, dots and letters.
## Not tested
- Studio 1: not touched. It gets 0.2.75 with its next **Update** once its ESP Screens has 0.2.89.
- `tools/render_topbar.py` does not build, on ESPHome 2026.6.2 either: its host project lacks the roller the effects
page (firmware 0.2.70) uses. Left for a separate round.
+1 -1
View File
@@ -7,7 +7,7 @@
| Port busy | Stop ESPHome logs, the IDE monitor, and other serial readers. One reader at a time. |
| Upload keeps connecting | Check the correct board/port. Hold BOOT while connecting and release afterward if the board requires it. |
| ESPHome Web shows no port or can't connect (Download) | Chrome or Edge on a computer; Safari, Firefox and phones can't reach USB. A data cable, and the driver for the board's USB chip if the computer asks for one (CH340 on most CYDs). Hold BOOT while choosing the port if it keeps failing to connect. |
| Build fails | Python 3.11–3.14 and ESPHome 2026.6.2; the full unpacked folder, local fonts/components present, internet, and disk space. Save the first real error from the build log. |
| Build fails | ESPHome 2026.6.2 or newer with a Python it supports (the app's 2026.9.0 needs 3.12–3.14); the full unpacked folder, local fonts/components present, internet, and disk space. Save the first real error from the build log. |
| White/black or wrongly rotated image | Check the ILI9341 + XPT2046 variant. Don't blindly copy the pins, rotation, or SPI settings from a different CYD model. |
| No measurement screen | The upload must be complete, not just the build. Flash with `-s CALIBRATION_ON_BOOT true`. Look for `USB calibration ready` in the USB logs; a normal boot shows no crosshairs. |
| No measurements | Five crosshairs visible, other log reader closed, correct port. Press Enter in the wizard first, then tap the requested point three times, releasing in between. |
+11 -2
View File
@@ -386,6 +386,11 @@ esp32:
framework:
type: esp-idf
advanced:
# ESP-IDF's assert texts (file, line and condition of every check) stay out of the firmware (0.2.75+), 49 KB on
# the CYD: a failed assert still stops and restarts the screen, and its backtrace still points at the line.
# Every board sets it (tests/test_easy_package.py). ESP-IDF's error names stay: without them ESPHome's own
# error lines only say "UNKNOWN ERROR".
assertion_level: SILENT
# ESPHome recommends this itself in the boot log. Yields ~40KB extra IRAM
# and a smaller binary, and every KB counts on this board.
sram1_as_iram: true
@@ -554,7 +559,9 @@ script:
- id: ui_self_test
mode: single
then:
- logger.log: "UI_TEST START: page/overlay render stress, no HA actions"
- logger.log:
format: "UI_TEST START: page/overlay render stress, no HA actions"
level: INFO # the core logs from INFO; logger.log is DEBUG by default
- lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");'
- lambda: 'id(calibration_active) = false;'
- lvgl.page.show: home_page
@@ -610,7 +617,9 @@ script:
- delay: 150ms
- lvgl.widget.hide: alert_overlay
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
- logger.log: "UI_TEST COMPLETE"
- logger.log:
format: "UI_TEST COMPLETE"
level: INFO
# The two scripts that drive the backlight as a light action; the Guition drives it through its set_backlight script.
- id: alert_dismiss
+13 -4
View File
@@ -324,7 +324,7 @@ substitutions:
# ============================================================
esphome:
platformio_options:
platformio_options: # not `build_flags:` yet, see packages/core.yaml
build_flags:
- -DLV_USE_SNAPSHOT=1
includes:
@@ -339,6 +339,11 @@ esp32:
type: esp-idf
version: recommended
advanced:
# ESP-IDF's assert texts (file, line and condition of every check) stay out of the firmware (0.2.75+), 49 KB on
# the CYD: a failed assert still stops and restarts the screen, and its backtrace still points at the line.
# Every board sets it (tests/test_easy_package.py). ESP-IDF's error names stay: without them ESPHome's own
# error lines only say "UNKNOWN ERROR".
assertion_level: SILENT
execute_from_psram: true
psram:
@@ -381,7 +386,7 @@ http_request:
verify_ssl: false
# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests
# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7.
# `image: - platform: online_image`, and 2027.1 drops it: move with the min_version raise (docs/RELEASING.md, 0.2.89).
online_image:
- id: camera_image
url: "http://127.0.0.1/camera.bmp" # set when a camera opens
@@ -783,7 +788,9 @@ script:
- id: ui_self_test
mode: single
then:
- logger.log: "UI_TEST START: page/overlay render stress, no HA actions"
- logger.log:
format: "UI_TEST START: page/overlay render stress, no HA actions"
level: INFO # the core logs from INFO; logger.log is DEBUG by default
- lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");'
- lambda: 'id(calibration_active) = false;'
- lvgl.page.show: home_page
@@ -843,7 +850,9 @@ script:
- delay: 150ms
- lvgl.widget.hide: alert_overlay
- lambda: 'if (!id(ui_refresh).is_running()) id(ui_refresh).execute();'
- logger.log: "UI_TEST COMPLETE"
- logger.log:
format: "UI_TEST COMPLETE"
level: INFO
# The two scripts that drive the backlight through set_backlight; the CYD drives it as a light action.
- id: alert_dismiss
+10 -4
View File
@@ -14,7 +14,7 @@
# fonts/materialdesignicons-webfont.ttf (Apache 2.0, github.com/Templarian/MaterialDesign-Webfont).
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.74"
SCREEN_FIRMWARE_VERSION: "0.2.75"
DASHBOARD_INBOX_INTERNAL: "false"
CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page
ROOM_NAME: "" # top left until ESP Screens sends the screen name
@@ -34,10 +34,16 @@ substitutions:
# board file's, the credentials the entry file's (secrets.yaml, see secrets.yaml.example).
# ============================================================
esphome:
# The oldest ESPHome these packages build with, not the one the add-on ships (2026.9.0, screen_manager/Dockerfile): an
# add-on or ESPHome Device Builder that isn't updated yet keeps building every screen (firmware 0.2.75+). Raise it in a
# release of its own, with the camera images' `image:` form (before ESPHome 2027.1) and OTA with the api key for new
# screens; docs/RELEASING.md, Compatibility 0.2.89.
min_version: 2026.6.2
project:
name: maxgramser.esp_screen
version: "${SCREEN_FIRMWARE_VERSION}"
# Still under platformio_options, although ESPHome 2026.9 calls that deprecated and offers `esphome: build_flags`:
# its LVGL only reads these flags when it writes lv_conf.h, and writes `LV_USE_SWITCH 0` over the other form.
platformio_options:
build_flags:
- -DLV_USE_SWITCH=1
@@ -251,9 +257,9 @@ esphome:
logger:
# Set to DEBUG or VERBOSE temporarily for diagnosis. Note: ESPHome logs
# its configuration lines at CONFIG level, which are invisible at INFO.
level: DEBUG
# INFO (firmware 0.2.75+): the lines below INFO, ESPHome's configuration dump and its per-state debug lines, are left
# out of the firmware, 9 KB on the CYD. For a diagnosis, give one screen `logger: level: DEBUG` in its Override YAML.
level: INFO
logs:
sensor: WARN
text_sensor: WARN
+1 -1
View File
@@ -1,2 +1,2 @@
# Tested toolchain; do not silently upgrade during commissioning.
esphome==2026.6.2
esphome==2026.9.0
+11
View File
@@ -1,3 +1,14 @@
## 0.2.89 (firmware 0.2.75)
ESPHome 2026.9 in ESP Screens, updates that no longer start from scratch without reason, and 94 KB more room on a CYD.
- **ESP Screens builds with ESPHome 2026.9.0** (was 2026.6.2). ESPHome now compiles an ESP32 with Espressif's own ESP-IDF instead of PlatformIO, and keeps what it compiled before in a cache. Before the first update of a screen, ESP Screens downloads ESP-IDF once (about 1.7 GB on disk) and removes PlatformIO, which only the old builds used, with the disk space it took. That first update builds each screen completely, once: on a Home Assistant Yellow 17 minutes for a CYD (about as long as before) and 23 minutes for a Guition, the download included. After it, a screen whose firmware didn't change is done in under a minute.
- **An update no longer starts from scratch because of the ESPHome Device Builder.** ESP Screens kept its memory of each build in the ESPHome folder, which the ESPHome Device Builder app empties every time it starts. The next update of every screen then rebuilt everything, 15 to 18 minutes on a Yellow. That memory now lives in ESP Screens' own storage, with ESP-IDF and the compiler's cache. Backups leave all of it out; it is downloaded or built again when needed.
- **94 KB more room on a CYD.** Its firmware is 1,585,136 bytes, 86.4 % of its update slot (was 91.5 %). That makes room for more languages and later ESPHome versions. Three changes: ESP-IDF's texts for failed internal checks are left out (49 KB; a failed check still restarts the screen), the screen logs from INFO instead of DEBUG (9 KB), and a small piece of our code no longer pulls in C's whole text scanner (9.5 KB). ESPHome 2026.9 itself saves the rest. A Guition's firmware is 110 KB smaller too.
- **Fewer log lines from the screen itself.** The screen's own log now starts at INFO: ESPHome's configuration dump and its debug lines for every state are no longer in the firmware. Nothing changes on the screen or in Home Assistant. To look closer at one screen, put `logger:` with `level: DEBUG` in its **Override YAML** and press **Update**.
- **A running timer never shows more than it lasts.** A 3-second timer could start at 0:04 on its tile. The screen counts down from Home Assistant's end time with its own clock, both in whole seconds, and its clock can be up to a second behind. The countdown now stops at the timer's duration.
- **Nothing to do in a particular order.** The firmware still builds with ESPHome 2026.6.2 or newer, so an ESP Screens that isn't updated yet, or your own ESPHome Device Builder, keeps building every screen. Update ESP Screens, then press **Update** on each screen (or let the nightly round do it): firmware 0.2.75. Includes everything from 0.2.88.
## 0.2.88 (firmware 0.2.74)
Wi-Fi that never dozes, on every screen.
+1 -1
View File
@@ -1,5 +1,5 @@
# Official ESPHome distribution: compiler, ESP-IDF, esptool and OTA CLI included.
FROM ghcr.io/esphome/esphome:2026.6.2
FROM ghcr.io/esphome/esphome:2026.9.0
ARG BUILD_VERSION
ARG BUILD_ARCH
LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${BUILD_ARCH}"
+3 -1
View File
@@ -37,7 +37,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.74'
FIRMWARE_VERSION = '0.2.75'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
@@ -1544,6 +1544,8 @@ def installation_yaml(data):
if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
raise ValueError('Give the screen a recognizable name (60 characters max).')
quote = lambda s: json.dumps(s, ensure_ascii=False)
# An OTA password, not yet `ota: encryption:` with the api key: ESPHome before 2026.9 refuses that, and the owner's
# ESPHome Device Builder may still be older (docs/RELEASING.md, Compatibility 0.2.89).
key, ota, ap = base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(12)
return f'''# Keep this file safe: it contains the unique keys for this screen.
# Wi-Fi comes from the secrets.yaml of ESPHome Device Builder.
+37 -1
View File
@@ -47,6 +47,9 @@ class Firmware:
# flashes on a board. A build writes it next to firmware.bin: under .pioenvs/<node>/ with PlatformIO,
# under build/ with ESPHome's native ESP-IDF toolchain.
FACTORY_IMAGES = ('*/.pioenvs/*/firmware.factory.bin', '*/build/firmware.factory.bin')
# The compiler cache's limit (app 0.2.89+); past it ccache drops the oldest entries. Some seventeen full builds of
# both boards took 0.6 GB on a Mac (docs/TEST_RESULTS_0289.md).
CCACHE_SIZE = '1G'
def __init__(self, root, data):
self.root, self.data = Path(root).resolve(), Path(data)
@@ -356,9 +359,42 @@ class Firmware:
self.task=asyncio.create_task(self.run(profile,action,target))
return dict(self.job)
def build_env(self, profile):
"""The ESPHome CLI's environment: everything it downloads or builds goes in the app's own /data, which an app
update or restart keeps and a backup leaves out (config.yaml backup_exclude), none of it in the ESPHome folder.
- build/<profile>: the screen's build folder.
- esphome: ESPHome's memory of each build (storage/) and what it fetched from GitHub (app 0.2.89+). It used to
be the ESPHome folder's .esphome, which the ESPHome Device Builder app deletes whenever it starts, so every
screen's next update was built from scratch.
- idf: ESP-IDF, its tools and the compiler cache, with which ESPHome 2026.7+ builds an ESP32 instead of
PlatformIO (app 0.2.89+); by default they would go in the container's own cache, gone at every restart.
- platformio: PlatformIO, for anything ESPHome still builds with it.
As many compilers at once as the machine has cores, as PlatformIO ran them: ESP-IDF's ninja would start two
more, and each takes a few hundred MB next to Home Assistant on a small Raspberry Pi (ESPHome's own limit, which
the official ESPHome app sets too)."""
return {**os.environ, 'ESPHOME_BUILD_PATH': str(self.data / 'build' / profile.stem),
'ESPHOME_DATA_DIR': str(self.data / 'esphome'), 'ESPHOME_ESP_IDF_PREFIX': str(self.data / 'idf'),
'CCACHE_MAXSIZE': os.environ.get('CCACHE_MAXSIZE', self.CCACHE_SIZE),
'ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT': os.environ.get('ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT',
str(os.cpu_count() or 1)),
'PLATFORMIO_CORE_DIR': str(self.data / 'platformio'), 'NO_COLOR': '1'}
async def retire_platformio(self):
"""PlatformIO's ESP32 toolchains and ESP-IDF, with which ESPHome built the screens before 2026.7 (app 0.2.88 and
older), take gigabytes that no screen uses any more. They go once: before the first build with ESPHome's own
ESP-IDF, which creates /data/idf. Anything that still needs PlatformIO downloads its own part again."""
old = self.data / 'platformio'
if old.is_dir() and not (self.data / 'idf').exists():
self.logs.append('Removing PlatformIO from before ESPHome 2026.7: the screens build with ESP-IDF now.')
await asyncio.to_thread(shutil.rmtree, old, True)
async def run(self, profile, action, target):
env={**os.environ, 'PLATFORMIO_CORE_DIR':str(self.data/'platformio'), 'ESPHOME_BUILD_PATH':str(self.data/'build'/profile.stem), 'NO_COLOR':'1'}
env = self.build_env(profile)
try:
if action != 'validate':
await self.retire_platformio()
stages = ['config'] if action=='validate' else ['compile'] + (['upload'] if action=='install' else [])
for stage in stages:
cmd=['esphome']+(['--quiet'] if stage=='config' else [])+[stage,str(profile)]
+4 -2
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.88"
version: "0.2.89"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
@@ -24,7 +24,9 @@ map:
- homeassistant_config:rw
uart: true
usb: true
# Reproducible compiler/download caches; retain screens.json and user configuration.
# Reproducible compiler/download caches (firmware.py build_env); retain screens.json and user configuration.
backup_exclude:
- '*/build/'
- '*/esphome/'
- '*/idf/'
- '*/platformio/'
+119
View File
@@ -0,0 +1,119 @@
"""Where the add-on's ESPHome CLI keeps what it downloads and builds (app 0.2.89+): all in the app's own /data, apart
from the ESPHome folder the ESPHome Device Builder app cleans, and PlatformIO from before ESPHome 2026.7 removed once."""
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
import yaml
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from firmware import Firmware # noqa: E402
PROFILE = {'board': 'cyd', 'name': 'kitchen', 'friendly_name': 'Kitchen', 'wifi_ssid': 'ssid', 'wifi_password': 'password'}
KEYS = ('ESPHOME_BUILD_PATH', 'ESPHOME_DATA_DIR', 'ESPHOME_ESP_IDF_PREFIX', 'CCACHE_MAXSIZE', 'PLATFORMIO_CORE_DIR',
'ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT')
# Stands in for the ESPHome CLI: it writes the environment it got, and whether PlatformIO's old folder was still there.
FAKE_CLI = '''#!{python}
import json, os, sys
from pathlib import Path
data = Path(os.environ['FAKE_DATA'])
record = {{key: os.environ.get(key) for key in {keys!r}}}
record['platformio_left'] = (data / 'platformio' / 'packages').exists()
with open(data / 'env.jsonl', 'a') as out:
out.write(json.dumps(record) + '\\n')
'''
def fake_cli(folder, data):
bin_dir = Path(folder) / 'bin'
bin_dir.mkdir(exist_ok=True)
cli = bin_dir / 'esphome'
cli.write_text(FAKE_CLI.format(python=sys.executable, keys=KEYS))
cli.chmod(0o755)
return {'PATH': f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", 'FAKE_DATA': str(data)}
def records(data):
return [json.loads(line) for line in (data / 'env.jsonl').read_text().splitlines()]
class BuildEnvironment(unittest.IsolatedAsyncioTestCase):
async def test_everything_esphome_keeps_goes_in_the_apps_own_data(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp) / 'data'
data.mkdir()
with patch.dict(os.environ, fake_cli(tmp, data)):
os.environ.pop('CCACHE_MAXSIZE', None)
os.environ.pop('ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT', None)
f = Firmware(Path(tmp) / 'esphome', data)
f.create(PROFILE)
f.start({'file': 'kitchen.yaml', 'action': 'build'})
await f.task
self.assertEqual(f.job['state'], 'success', list(f.logs))
got = records(data)[0]
self.assertEqual(got['ESPHOME_BUILD_PATH'], str(data / 'build' / 'kitchen'))
# Not the ESPHome folder's .esphome, which the ESPHome Device Builder app deletes whenever it starts.
self.assertEqual(got['ESPHOME_DATA_DIR'], str(data / 'esphome'))
self.assertFalse(got['ESPHOME_DATA_DIR'].startswith(str(Path(tmp) / 'esphome')))
# ESP-IDF and the compiler cache survive a restart of the app, which empties the container's own cache.
self.assertEqual(got['ESPHOME_ESP_IDF_PREFIX'], str(data / 'idf'))
self.assertEqual(got['CCACHE_MAXSIZE'], Firmware.CCACHE_SIZE)
self.assertEqual(got['PLATFORMIO_CORE_DIR'], str(data / 'platformio'))
# As many compilers at once as cores, as PlatformIO ran them; ninja alone would start two more.
self.assertEqual(got['ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT'], str(os.cpu_count() or 1))
async def test_limits_of_the_owner_win(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp) / 'data'
data.mkdir()
with patch.dict(os.environ, {**fake_cli(tmp, data), 'CCACHE_MAXSIZE': '300M', 'ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT': '1'}):
f = Firmware(Path(tmp) / 'esphome', data)
f.create(PROFILE)
f.start({'file': 'kitchen.yaml', 'action': 'build'})
await f.task
self.assertEqual(records(data)[0]['CCACHE_MAXSIZE'], '300M')
self.assertEqual(records(data)[0]['ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT'], '1')
def test_every_cache_stays_out_of_a_backup(self):
config = yaml.safe_load((ROOT / 'screen_manager/config.yaml').read_text())
excluded = set(config['backup_exclude'])
for folder in ('build', 'esphome', 'idf', 'platformio'):
self.assertIn(f'*/{folder}/', excluded)
# Never /data as a whole: screens.json and updates.json are the owner's.
self.assertFalse({'*', '/data', '*/data/'} & excluded)
async def test_platformio_from_before_esphome_2026_7_goes_once(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp) / 'data'
(data / 'platformio' / 'packages' / 'toolchain-xtensa-esp32').mkdir(parents=True)
with patch.dict(os.environ, fake_cli(tmp, data)):
f = Firmware(Path(tmp) / 'esphome', data)
f.create(PROFILE)
# A check of the YAML downloads and builds nothing, so it leaves it.
f.start({'file': 'kitchen.yaml', 'action': 'validate'})
await f.task
self.assertTrue((data / 'platformio' / 'packages').exists())
# The first build removes it before ESPHome runs.
f.start({'file': 'kitchen.yaml', 'action': 'build'})
await f.task
self.assertEqual(f.job['state'], 'success', list(f.logs))
self.assertFalse(records(data)[-1]['platformio_left'])
self.assertIn('Removing PlatformIO from before ESPHome 2026.7: the screens build with ESP-IDF now.', f.logs)
# Once ESPHome has its own ESP-IDF, whatever PlatformIO gets later is left alone.
(data / 'idf').mkdir()
(data / 'platformio' / 'packages').mkdir(parents=True)
f.start({'file': 'kitchen.yaml', 'action': 'build'})
await f.task
self.assertTrue(records(data)[-1]['platformio_left'])
self.assertTrue((data / 'platformio' / 'packages').exists())
self.assertNotIn('Removing PlatformIO from before ESPHome 2026.7: the screens build with ESP-IDF now.', f.logs)
if __name__ == '__main__':
unittest.main()
+3
View File
@@ -95,6 +95,9 @@ class PackageTests(unittest.TestCase):
for needle in ('esp32:', 'platform: xpt2046', 'platform: gt911', 'platform: st7701s', 'platform: mipi_spi', 'psram:',
'set_raw_correction', 'camera_image', 'GPIO'):
self.assertNotIn(needle, core, f'{needle} is a board\'s, not the core\'s')
# How the firmware is built is the same on every board, in the board's own esp32: block (firmware 0.2.75+).
for board, path in profiles.BOARDS.items():
self.assertIn(' assertion_level: SILENT\n', path.read_text(), board)
names = {board: set(profiles.substitutions_of(path)) for board, path in profiles.BOARDS.items()}
shared = set.intersection(*names.values())
self.assertGreater(len(shared), 100)
+4
View File
@@ -63,6 +63,10 @@ class ProtocolTests(unittest.TestCase):
self.assertIn('!secret wifi_password',first)
import re
self.assertEqual(len(base64.b64decode(re.search(r'key: "([^"]+)"',first)[1])),32)
# An OTA password until the packages' min_version reaches ESPHome 2026.9: an older Device Builder refuses
# `ota: encryption:` (app 0.2.89).
self.assertRegex(first, r'ota:\n - platform: esphome\n password: "[^"]{20,}"\nwifi:')
self.assertNotIn('encryption: {}', first)
for name in ['../bad','Bad Name','a\napi:']:
with self.assertRaises(ValueError): installation_yaml({**data,'name':name})
+19
View File
@@ -294,3 +294,22 @@ static void test_tile_bits() {
for (size_t a = 0; a < MAX_TILES; ++a) for (size_t b = a + 1; b < MAX_TILES; ++b) assert(!(tile_bit(a) & tile_bit(b)));
}
struct RunTileBits { RunTileBits() { test_tile_bits(); } } run_tile_bits;
// Clock texts without sscanf (firmware 0.2.75+): the same answers the sscanf versions gave.
static void test_clock_texts() {
using namespace runtime_tiles;
assert(duration_seconds("0:05:00") == 300 && duration_seconds("1:02:03") == 3723 && duration_seconds("12:00:00") == 43200);
assert(duration_seconds("5:30") == 330 && duration_seconds("0:00:05.5") == 5 && duration_seconds("5:00:") == 300);
assert(duration_seconds("1:2:3:4") == 3723);
assert(duration_seconds("") == 0 && duration_seconds("12") == 0 && duration_seconds("1 day, 2:03:04") == 0);
assert(duration_seconds("idle") == 0 && duration_seconds(":05") == 0);
assert(minutes_of("06:45") == 405 && minutes_of("0:00") == 0 && minutes_of("23:59") == 1439 && minutes_of("07:30:00") == 450);
assert(minutes_of("24:00") == -1 && minutes_of("12:60") == -1 && minutes_of("12") == -1 && minutes_of("") == -1);
assert(minutes_of("x6:45") == -1 && minutes_of(" 6: 45") == 405 && duration_seconds("1: 59:") == 119);
// A running timer never shows more than its duration, whatever the clocks' whole seconds make of it.
assert(timer_left(1003, 999, "0:00:03") == 3 && timer_left(1003, 1000, "0:00:03") == 3 && timer_left(1003, 1001, "0:00:03") == 2);
assert(timer_left(1003, 1003, "0:00:03") == 0 && timer_left(1003, 1010, "0:00:03") == 0 && timer_left(1003, 0, "0:00:03") == 0);
assert(timer_left(1300, 1000, "0:05:00") == 300 && timer_left(1300, 1000, "") == 300 && timer_left(1300, 1000, "idle") == 300);
unsigned v[3] = {9, 9, 9};
assert(clock_parts("7:8", v, 3) == 2 && v[0] == 7 && v[1] == 8 && v[2] == 9);
}
struct RunClockTexts { RunClockTexts() { test_clock_texts(); } } run_clock_texts;
+1 -1
View File
@@ -274,7 +274,7 @@ if ((want_firmware)); then
run "ESPHome" esphome_version
if ((last_ok)); then run "Check profiles" prepare_profiles; fi
if ((last_ok)); then
# One after the other: parallel ESP-IDF builds race on PlatformIO's shared cache.
# One after the other: parallel builds race on ESPHome's shared ESP-IDF install (and on PlatformIO's, before 2026.7).
run "Firmware: CYD" compile_board cyd
if ((last_ok)); then run "CYD flash budget" cyd_budget; else skip "CYD flash budget" "no CYD build"; fi
run "Firmware: Guition" compile_board guition