Files
homeassistant_espscreen/tests/test_easy_package.py
T
Max Gramser 3b15e7f7a3 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>
2026-09-19 12:35:49 +02:00

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()