Files
Max Gramser d76461d774 Build every screen from one shared core plus a small file per board, the same firmware as before (0.2.84)
The two board profiles (5,000 lines each, 87 % the same text) become packages/core.yaml and one file per
board under packages/boards/: the hardware, a table of 84 sizes and font sizes, 18 hooks with the lines of C++
only that board adds inside a shared lambda, and the board's own sections. packages/cyd.yaml and
packages/guition.yaml keep their names and places (a screen installed from ESP Screens builds as before);
home-like-2432s028.yaml and guition-4848s040.yaml become the same entries for a checkout.

The firmware stays 0.2.70: the C++ ESPHome generates for each board, through the checkout profile and
through the GitHub package route, was compared line by line with the old profiles' (docs/TEST_RESULTS_0284.md).
tools/check_packages.py replaces tools/generate_packages.py; tools/profiles.py gives the tests and tools the
three files as one; docs/PROFILES.md says how the files fit together and how a board is added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:57:14 +02:00

137 lines
7.9 KiB
Python

"""The settings page on the screen itself (app 0.2.52, firmware 0.2.44).
Two halves have to agree without ever seeing each other: the row table in settings_screen.h writes keys
that only mean something if ESP Screens knows them, and the eleven-key `settings` block older firmware
insists on may never grow. Both are checked here, against the real files.
"""
import importlib.util
import json
import re
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import test_portal
from core import (BUILTIN, SETTING_RULES, SETTINGS_BESIDE_BLOCK, entity_id, min_firmware, validate_layout,
validate_settings)
SCREEN = (ROOT / 'components/smart_display/settings_screen.h').read_text()
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
# The keys the firmware's own block still carries; everything newer travels as its own key.
FROZEN = {'standby_enabled', 'standby_seconds', 'brightness', 'standby_brightness', 'night_enabled',
'night_start', 'night_end', 'night_brightness', 'show_clock', 'clock_24h', 'home_on_standby'}
assert FROZEN == set(SETTING_RULES) - set(SETTINGS_BESIDE_BLOCK)
class SettingsPage(unittest.TestCase):
def test_every_key_the_screen_reports_is_a_setting_the_app_knows(self):
# Rows write through set(key, value) (firmware 0.2.49+), which reports the key it was given.
rows = set(re.findall(r'\bset\("(\w+)"', SCREEN))
self.assertTrue(rows, 'no rows found in settings_screen.h')
self.assertLessEqual(rows, set(SETTING_RULES), 'the screen writes a key ESP Screens would drop')
handled = set(re.findall(r'key == "(\w+)"', SCREEN))
self.assertLessEqual(rows, handled, 'a row writes a key set() does not handle')
self.assertEqual(handled, set(SETTING_RULES) - {'show_clock'}, 'set() handles every setting the app knows')
def test_the_frozen_settings_block_keeps_exactly_eleven_keys(self):
# Firmware before 0.2.44 refuses a `settings` object of any other size; that is why the newer
# settings travel as their own keys.
self.assertEqual(re.search(r'if \(obj\.size\(\) != (\d+)\) return false;', RUNTIME).group(1), '11')
self.assertEqual(FROZEN, set(SETTING_RULES) & FROZEN)
def test_new_settings_default_and_validate(self):
clean = validate_settings({})
self.assertEqual(clean['auto_home'], True)
self.assertEqual(clean['auto_home_seconds'], 120)
self.assertEqual(validate_settings({'auto_home_seconds': 30})['auto_home_seconds'], 30)
for bad in ({'auto_home_seconds': 29}, {'auto_home_seconds': 3601}, {'auto_home': 1},
{'auto_home_seconds': True}):
with self.assertRaises(ValueError):
validate_settings(bad)
def test_the_settings_tile_is_a_builtin_that_needs_new_firmware(self):
self.assertIn('screen.settings', BUILTIN)
self.assertTrue(entity_id('screen.settings'))
self.assertEqual(min_firmware({'tiles': [{'entity': 'screen.settings'}]}), (0, 2, 44))
self.assertLess(min_firmware({'tiles': [{'entity': 'screen.clock'}]}), (0, 2, 44))
layout = validate_layout({'title': 'Home', 'tiles': [{'entity': 'screen.settings', 'name': 'Settings'}]})
self.assertEqual(layout['tiles'][0]['entity'], 'screen.settings')
@unittest.skipUnless(importlib.util.find_spec('aiohttp'), 'Run using .venv-portal/bin/python for server tests')
class LayoutMessage(unittest.IsolatedAsyncioTestCase):
async def test_the_new_keys_ride_beside_the_frozen_block(self):
with tempfile.TemporaryDirectory() as temp:
manager = test_portal.ManagerTests().setup_manager(Path(temp) / 'screens.json')
layout = {'title': 'Home', 'tiles': [{'entity': 'light.a'}],
'settings': validate_settings({'auto_home': False, 'auto_home_seconds': 600})}
manager.save('text.screen', layout)
message = manager.layout_message('text.screen', manager.layouts['text.screen'], {})
self.assertEqual(set(message['settings']), FROZEN, 'older firmware refuses any other size')
self.assertEqual(message['auto_home'], False)
self.assertEqual(message['auto_home_seconds'], 600)
# A screen that never stored settings still gets the defaults, not a missing key.
plain = manager.layout_message('text.screen', {**manager.layouts['text.screen'],
'settings': validate_settings({})}, {})
self.assertEqual((plain['auto_home'], plain['auto_home_seconds']), (True, 120))
self.assertLess(len(json.dumps(message)), 4096, 'the screen refuses a message over 4 KB')
class Firmware(unittest.TestCase):
"""The board profiles have to carry the page, its way in, and the timer that closes it."""
def setUp(self):
self.profiles = {name: profiles.text(name)
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')}
def test_both_boards_wire_the_page_and_its_gesture(self):
for name, text in self.profiles.items():
self.assertIn('settings_screen::attach_hold', text, name)
self.assertIn('settings_screen::report', text, name)
self.assertIn('settings_screen::may_open', text, name)
self.assertIn('- action: open_settings', text, name)
self.assertIn('- id: go_home', text, name)
self.assertIn('runtime_tiles::auto_home', text, name)
version = re.search(r'SCREEN_FIRMWARE_VERSION: "([0-9.]+)"', text)[1]
self.assertGreaterEqual(tuple(map(int, version.split('.'))), (0, 2, 44), name)
def test_the_hold_strip_covers_the_top_bar_and_no_more(self):
for name, text in self.profiles.items():
values = dict(re.findall(r'^ (\w+): "([^"]*)"', text, re.M))
width, scroll = int(values['DISPLAY_W']), int(values['SCROLL_Y'])
x = int(values['SETTINGS_HOLD_X'].replace('${EDGE_SWIPE_BAND_PX}', values.get('EDGE_SWIPE_BAND_PX', '0')))
hold = values['SETTINGS_HOLD_W'].replace('${DISPLAY_W}', values['DISPLAY_W'])
self.assertLessEqual(x + int(hold), width, name)
self.assertGreater(scroll, 20, name)
if 'EDGE_SWIPE_BAND_PX' in values:
# The page swipe owns both edge bands; the hold may not start there.
band = int(values['EDGE_SWIPE_BAND_PX'])
self.assertGreaterEqual(x, band, name)
self.assertLessEqual(x + int(hold), width - band, name)
def test_the_hold_strip_lies_under_every_card(self):
# Firmware 0.2.44-0.2.47 created the strip after the cards, so it lay on top of them: a card's
# back button and its action at the top right lost their taps to it. (The wake overlay of a dimmed
# screen was not affected: apply_screen_settings moves it to the foreground.)
self.assertIn('lv_obj_move_to_index(hold_area, lv_obj_get_index(below));', SCREEN)
cards = ('brightness_overlay', 'color_detail_overlay', 'climate_detail_overlay', 'climate_mode_overlay',
'dim_wake_overlay')
for name, text in self.profiles.items():
call = re.search(r'settings_screen::attach_hold\(id\(home_page\)->obj, [^;]*, id\((\w+)\)\);', text)
self.assertTrue(call, f'{name}: attach_hold gets no card to stay under')
self.assertEqual(call[1], cards[0], name)
page = text[text.index('- id: home_page'):]
positions = [page.index(f'id: {card}\n') for card in cards]
self.assertEqual(positions, sorted(positions), f'{name}: {cards[0]} must be the first card on the page')
self.assertLess(page.index('id: page_next\n'), positions[0], f'{name}: the page controls stay under the strip')
if __name__ == '__main__':
unittest.main()