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>
116 lines
7.0 KiB
Python
116 lines
7.0 KiB
Python
"""The packages a screen builds from: one shared core plus a board file, runtime tiles only (app 0.2.84+)."""
|
|
import importlib.util
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / 'tools'))
|
|
import profiles # noqa: E402
|
|
|
|
BOARDS = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'}
|
|
# The old manual profile bound tiles to fixed entities in the YAML. ESP Screens sends the tiles now, so
|
|
# none of this may come back: the fixed subscriptions, its tap handlers and scripts, its own vacuum card.
|
|
GONE = ['ha_state_tile1', 'tile1_brightness', 'tile6_climate_humidity', 'tile6_cover_state', 'smartdisplay_action',
|
|
'publish_action', 'do_tile_action', 'open_vacuum_overlay', 'vacuum_action', 'vacuum_fan_speed', 'vacuum_overlay',
|
|
'vacspd_quiet', 'vacuum_actions', 'DYNAMIC_TILES', 'TILE_COUNT', 'DIRECT_ACTIONS', 'TILE1_ENTITY',
|
|
'light_controls::subscribe']
|
|
|
|
|
|
def load_checker():
|
|
spec = importlib.util.spec_from_file_location('check_packages', ROOT / 'tools/check_packages.py')
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class PackageTests(unittest.TestCase):
|
|
def test_the_packages_fit_together(self):
|
|
result = subprocess.run([sys.executable, str(ROOT / 'tools/check_packages.py')], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
def test_every_entry_is_the_shared_core_plus_its_board(self):
|
|
checker = load_checker()
|
|
for board, profile in BOARDS.items():
|
|
for name in (profile, f'packages/{board}.yaml'):
|
|
self.assertEqual(sorted(checker.included(ROOT / name)), sorted(['packages/core.yaml', str(profiles.BOARDS[board].relative_to(ROOT))]), name)
|
|
|
|
def test_the_published_entry_takes_everything_from_github(self):
|
|
for board in BOARDS:
|
|
package = (ROOT / 'packages' / f'{board}.yaml').read_text()
|
|
self.assertNotIn('!secret', package)
|
|
self.assertNotIn('type: local', package)
|
|
self.assertIn('url: https://github.com/MaxGramser/homeassistant_espscreen.git', package)
|
|
self.assertIn('FONT_DIR: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts"', package)
|
|
# The fonts of the shared core come from that place; a checkout takes them from fonts/.
|
|
self.assertIn('file: "${FONT_DIR}/Roboto-500.ttf"', profiles.CORE.read_text())
|
|
self.assertIn('FONT_DIR: "fonts"', (ROOT / BOARDS[board]).read_text())
|
|
|
|
def test_no_secret_or_local_path_under_packages(self):
|
|
for path in sorted((ROOT / 'packages').rglob('*.yaml')):
|
|
text = '\n'.join(line for line in path.read_text().split('\n') if not line.lstrip().startswith('#'))
|
|
self.assertNotIn('!secret', text, path.name)
|
|
self.assertNotIn('type: local', text, path.name)
|
|
for section in ('sensor', 'binary_sensor', 'text_sensor'):
|
|
block = re.search(r'^' + section + r':\n.*?(?=^[a-zA-Z_]+:|\Z)', text, re.M | re.S)
|
|
self.assertFalse(block and re.search(r'^ - platform: homeassistant$', block[0], re.M), f'{path.name}: a {section} subscribes to Home Assistant')
|
|
|
|
def test_the_wifi_never_dozes_but_stays_the_owners(self):
|
|
# Firmware 0.2.74+: the core keeps Wi-Fi awake for every screen, and nothing else of Wi-Fi lives in a package;
|
|
# the network, its password and the fallback hotspot stay in the screen's own YAML, which also wins on the mode.
|
|
for path in sorted((ROOT / 'packages').rglob('*.yaml')):
|
|
text = path.read_text()
|
|
block = re.search(r'^wifi:\n(.*?)(?=^[a-zA-Z_]+:|\Z)', text, re.M | re.S)
|
|
if path == profiles.CORE:
|
|
self.assertIsNotNone(block, 'packages/core.yaml has no wifi: block')
|
|
lines = [line for line in block[1].split('\n') if line.strip() and not line.lstrip().startswith('#')]
|
|
self.assertEqual(lines, [' power_save_mode: none'])
|
|
else:
|
|
self.assertIsNone(block, f'{path.name} has a wifi: block')
|
|
# The YAML ESP Screens writes says so too (since app 0.2.24), so an older package changes nothing for those.
|
|
self.assertIn(' power_save_mode: none\n', (ROOT / 'screen_manager/app/core.py').read_text())
|
|
|
|
def test_the_manual_profile_is_gone_from_profiles_and_packages(self):
|
|
for board, name in BOARDS.items():
|
|
for entry in (name, f'packages/{board}.yaml'):
|
|
text = '\n'.join(line for line in profiles.text(entry).split('\n') if not line.lstrip().startswith('#'))
|
|
for key in GONE:
|
|
self.assertNotIn(key, text, f'{entry} still carries {key}')
|
|
|
|
def test_runtime_tiles_keep_what_they_bind_and_open(self):
|
|
for board in BOARDS:
|
|
package = profiles.text(f'packages/{board}.yaml')
|
|
for key in ['runtime_tiles::bind(9, id(tile10)', 'runtime_tiles::enabled = true;', 'id: open_value_overlay',
|
|
'id: ui_refresh', 'runtime_tiles::render(id(lbl_room));', 'id: climate_detail_overlay',
|
|
'id: color_detail_overlay']:
|
|
self.assertIn(key, package, board)
|
|
# The CYD keeps its resistive calibration on the screen itself; the Guition's GT911 needs none.
|
|
self.assertIn('screen_calibration::setup(', profiles.resolved('packages/cyd.yaml'))
|
|
self.assertNotIn('screen_calibration::setup(', profiles.resolved('packages/guition.yaml'))
|
|
|
|
def test_the_core_carries_no_board_number_and_every_board_the_same_names(self):
|
|
core = profiles.CORE.read_text()
|
|
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)
|
|
for board, defined in names.items():
|
|
own = defined - shared
|
|
self.assertTrue(all(re.match(r'(TOUCH_AFFINE_|TOUCH_CAL_|EDGE_SWIPE_|ALERT_\w*IMAGE|CAMERA_)', n) for n in own), f'{board}: {sorted(own)}')
|
|
|
|
def test_the_checker_refuses_a_fixed_home_assistant_subscription(self):
|
|
source = (ROOT / 'tools/check_packages.py').read_text()
|
|
self.assertIn("re.search(r'^ - platform: homeassistant$', match[0], re.M)", source)
|
|
self.assertIn("for section in ('sensor', 'binary_sensor', 'text_sensor'):", source)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|