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