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:
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user