3b15e7f7a3
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>
120 lines
6.1 KiB
Python
120 lines
6.1 KiB
Python
"""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()
|