From 3b15e7f7a3adf88c46ea41586c393915ef774db4 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sat, 19 Sep 2026 12:35:49 +0200 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 26 +++- AGENTS.md | 3 +- README.md | 3 +- components/smart_display/runtime_model.h | 38 +++++ components/smart_display/runtime_tiles.h | 12 +- docs/DOCKER.md | 2 +- docs/EASY_SETUP.md | 2 +- docs/PROFILES.md | 4 +- docs/RELEASING.md | 66 ++++++++- docs/TEST_RESULTS_0289.md | 171 +++++++++++++++++++++++ docs/TROUBLESHOOTING.md | 2 +- packages/boards/cyd-2432s028.yaml | 13 +- packages/boards/guition-4848s040.yaml | 17 ++- packages/core.yaml | 14 +- requirements.txt | 2 +- screen_manager/CHANGELOG.md | 11 ++ screen_manager/Dockerfile | 2 +- screen_manager/app/core.py | 4 +- screen_manager/app/firmware.py | 38 ++++- screen_manager/config.yaml | 6 +- tests/test_build_env.py | 119 ++++++++++++++++ tests/test_easy_package.py | 3 + tests/test_portal.py | 4 + tests/test_runtime_model.cpp | 19 +++ tools/check.sh | 2 +- 25 files changed, 542 insertions(+), 41 deletions(-) create mode 100644 docs/TEST_RESULTS_0289.md create mode 100644 tests/test_build_env.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50ca3c2..0a9e2d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/**') }} diff --git a/AGENTS.md b/AGENTS.md index d6c8d91..ae4246c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 9babd59..ef2928c 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index ebabc8d..cd1332a 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -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 ptr; diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index da5bb54..478e1b3 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -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); diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 22b5e86..bdfa4e8 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -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 diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index 3375505..eecebe6 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -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. diff --git a/docs/PROFILES.md b/docs/PROFILES.md index 22e049b..9af521b 100644 --- a/docs/PROFILES.md +++ b/docs/PROFILES.md @@ -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/.yaml` and change its hardware sections. + with capacitive touch: the Guition) to `packages/boards/.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/.yaml` and `.yaml` after the existing entries, `` to `ENTRIES` in `tools/profiles.py` and to `REFS` in `screen_manager/app/core.py` (the boards `installation_yaml()` writes a diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5b6b2bd..14a85ba 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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/` 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. diff --git a/docs/TEST_RESULTS_0289.md b/docs/TEST_RESULTS_0289.md new file mode 100644 index 0000000..bce49b5 --- /dev/null +++ b/docs/TEST_RESULTS_0289.md @@ -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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index efb0544..612d4e0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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. | diff --git a/packages/boards/cyd-2432s028.yaml b/packages/boards/cyd-2432s028.yaml index 35a84a8..3269bdd 100644 --- a/packages/boards/cyd-2432s028.yaml +++ b/packages/boards/cyd-2432s028.yaml @@ -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 diff --git a/packages/boards/guition-4848s040.yaml b/packages/boards/guition-4848s040.yaml index 896dd7b..bd6d02e 100644 --- a/packages/boards/guition-4848s040.yaml +++ b/packages/boards/guition-4848s040.yaml @@ -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 diff --git a/packages/core.yaml b/packages/core.yaml index 603f8da..a1e53f1 100644 --- a/packages/core.yaml +++ b/packages/core.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt index 0bb7941..f5d8c58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ # Tested toolchain; do not silently upgrade during commissioning. -esphome==2026.6.2 +esphome==2026.9.0 diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index f51cc24..770387e 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -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. diff --git a/screen_manager/Dockerfile b/screen_manager/Dockerfile index dc0619c..54ea63d 100644 --- a/screen_manager/Dockerfile +++ b/screen_manager/Dockerfile @@ -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}" diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 80ec427..f4c2645 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -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. diff --git a/screen_manager/app/firmware.py b/screen_manager/app/firmware.py index 9febfc6..c01a59b 100644 --- a/screen_manager/app/firmware.py +++ b/screen_manager/app/firmware.py @@ -47,6 +47,9 @@ class Firmware: # flashes on a board. A build writes it next to firmware.bin: under .pioenvs// 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/: 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)] diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 87aba62..d717815 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -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/' diff --git a/tests/test_build_env.py b/tests/test_build_env.py new file mode 100644 index 0000000..e63de21 --- /dev/null +++ b/tests/test_build_env.py @@ -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() diff --git a/tests/test_easy_package.py b/tests/test_easy_package.py index aec66af..9bcc682 100644 --- a/tests/test_easy_package.py +++ b/tests/test_easy_package.py @@ -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) diff --git a/tests/test_portal.py b/tests/test_portal.py index a2a3a71..6872f28 100644 --- a/tests/test_portal.py +++ b/tests/test_portal.py @@ -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}) diff --git a/tests/test_runtime_model.cpp b/tests/test_runtime_model.cpp index 64c5aad..65268a6 100644 --- a/tests/test_runtime_model.cpp +++ b/tests/test_runtime_model.cpp @@ -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; diff --git a/tools/check.sh b/tools/check.sh index c1b9f9d..ecce5d3 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -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