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>
This commit is contained in:
Max Gramser
2026-09-18 22:57:14 +02:00
parent e7e173d3b7
commit d76461d774
45 changed files with 6636 additions and 19954 deletions
+8 -5
View File
@@ -6,6 +6,8 @@ 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'))
import tile_icons # noqa: E402
@@ -37,7 +39,7 @@ def line_height(font, size):
class AlertTests(unittest.TestCase):
def sources(self):
for name in list(PROFILES.values()) + list(PACKAGES.values()):
yield name, (ROOT / name).read_text()
yield name, profiles.text(name)
def test_action_takes_the_six_fields_and_hands_them_to_one_script(self):
for name, text in self.sources():
@@ -75,7 +77,8 @@ class AlertTests(unittest.TestCase):
title = section(top, 'id: alert_title\n', 'id: alert_subtitle')
self.assertIn('height: ${ALERT_TITLE_H}\n', title, name)
self.assertIn('text_font: headline\n', title, name)
font, size = re.search(r'(?m)^ - file: "(?:[^"\n]*/)?(fonts/[^"\n]+)"\n id: headline\n size: (\d+)$', text).groups()
# The core names the font as ${FONT_DIR}/... at the board's ${FONT_HEADLINE_SIZE} (app 0.2.84+).
font, size = re.search(r'(?m)^ - file: "(?:[^"\n]*/)?(fonts/[^"\n]+)"\n id: headline\n size: (\d+)$', profiles.resolved(name)).groups()
v = lambda key: int(re.search(rf'^ {key}: "(-?\d+)"', text, re.M)[1])
self.assertEqual(v('ALERT_TITLE_H'), line_height(ROOT / font, int(size)), name)
self.assertLessEqual(v('ALERT_TITLE_Y') + v('ALERT_TITLE_H'), v('ALERT_SUBTITLE_Y'), name)
@@ -107,7 +110,7 @@ class AlertTests(unittest.TestCase):
self.assertIn('screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX})', show, name)
self.assertLess(show.index('script.execute: wake_display'), show.index('lvgl.widget.show: alert_overlay'), name)
self.assertLess(show.index('lvgl.widget.show: alert_overlay'), show.index('script.execute: alert_flash'), name)
self_test = section(text, ' - id: ui_self_test\n', ' - id: show_tile_page')
self_test = section(text, ' - id: ui_self_test\n', '\n - id: ')
self.assertIn('lvgl.widget.show: alert_overlay', self_test, name)
self.assertIn('lvgl.widget.hide: alert_overlay', self_test, name)
for forbidden in ('alert_show', 'alert_dismiss', 'homeassistant.event'):
@@ -131,8 +134,8 @@ class AlertTests(unittest.TestCase):
def test_helper_header_is_included_locally_and_in_the_remote_package(self):
header = ' - <esphome/components/smart_display/alert_overlay.h>\n'
for board, name in PROFILES.items():
self.assertIn(header, (ROOT / name).read_text(), name)
self.assertIn(header, (ROOT / PACKAGES[board]).read_text(), board)
self.assertIn(header, profiles.text(name), name)
self.assertIn(header, profiles.text(PACKAGES[board]), board)
if __name__ == '__main__':
unittest.main()
+3 -1
View File
@@ -8,6 +8,8 @@ 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 tile_icons # noqa: E402
@@ -22,7 +24,7 @@ class ReferenceTests(unittest.TestCase):
def test_fields_and_limits_match_both_board_profiles(self):
reference = alert_reference()
for board, name in PROFILES.items():
text = (ROOT / name).read_text()
text = profiles.text(name)
block = text.split(' - action: show_alert\n', 1)[1].split(' - action: dismiss_alert\n', 1)[0]
self.assertEqual(re.findall(r'^ (\w+): (\w+)$', block, re.M), [(f['name'], f['type']) for f in reference['fields']], name)
for field, key in (('title', 'ALERT_TITLE_MAX'), ('subtitle', 'ALERT_SUBTITLE_MAX'), ('button_text', 'ALERT_BUTTON_MAX')):
+3 -1
View File
@@ -10,6 +10,8 @@ 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'))
import header_bar # noqa: E402
import history_card # noqa: E402
@@ -62,7 +64,7 @@ class BinaryWords(unittest.TestCase):
letters = set(''.join(on + off for on, off in header_bar.BINARY_STATES.values()))
self.assertLessEqual(letters, set(header_bar.GLYPHS))
for name in PROFILES:
text = (ROOT / name).read_text()
text = profiles.resolved(name)
for font in ('headline', 'watch_value', 'label', 'sublabel', 'sublabel_big'):
block = re.search(r'id: ' + font + r'\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S)
self.assertIsNotNone(block, (name, font))
+4 -2
View File
@@ -10,6 +10,8 @@ import tempfile
import unittest
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'))
import camera_feed # noqa: E402
from core import (ALERT_FIELDS, BROADCAST_SHOW, CAMERA_MIN_FIRMWARE, alert_camera, alert_reference, entity_id, # noqa: E402
@@ -20,7 +22,7 @@ HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
from server import HomeAssistant, Manager
PROFILE = (ROOT / 'guition-4848s040.yaml').read_text()
PROFILE = profiles.text('guition-4848s040.yaml')
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
@@ -87,7 +89,7 @@ class Rules(unittest.TestCase):
self.assertEqual(PROFILE.count('if (url != current) {'), 2)
# A busy camera port leaves the rest of the app running.
self.assertIn("except OSError as error:\n # Everything else still works; only camera images stay away.", (ROOT / 'screen_manager/app/server.py').read_text())
cyd = (ROOT / 'home-like-2432s028.yaml').read_text()
cyd = profiles.text('home-like-2432s028.yaml')
self.assertNotIn('online_image', cyd)
self.assertNotIn('camera_full.load', cyd)
# The add-on's port is published, and the Docker route passes it on with the host network.
+3 -1
View File
@@ -15,13 +15,15 @@ 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'))
from core import ATTRS, cover_related, discover_screens, extras, state_message # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
PROFILES = {name: (ROOT / name).read_text() for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml')}
PROFILES = {name: profiles.text(name) for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml')}
BLIND = 'cover.venetianblind_0001'
+53 -19
View File
@@ -1,10 +1,15 @@
"""The packages a screen builds from: generated from the board profiles, runtime tiles only."""
"""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.
@@ -14,49 +19,78 @@ GONE = ['ha_state_tile1', 'tile1_brightness', 'tile6_climate_humidity', 'tile6_c
'light_controls::subscribe']
def load_generator():
spec = importlib.util.spec_from_file_location('generate_packages', ROOT / 'tools/generate_packages.py')
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_packages_are_current(self):
generator = load_generator()
for board in BOARDS:
self.assertEqual((ROOT / 'packages' / f'{board}.yaml').read_text(), generator.generate(board), board)
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_no_secrets_and_no_local_paths(self):
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_manual_profile_is_gone_from_profiles_and_packages(self):
for board, name in BOARDS.items():
for path in (ROOT / name, ROOT / 'packages' / f'{board}.yaml'):
text = '\n'.join(line for line in path.read_text().split('\n') if not line.lstrip().startswith('#'))
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'{path.name} still carries {key}')
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 = (ROOT / 'packages' / f'{board}.yaml').read_text()
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(', (ROOT / 'packages/cyd.yaml').read_text())
self.assertIn('screen_calibration::setup(', profiles.resolved('packages/cyd.yaml'))
self.assertNotIn('screen_calibration::setup(', profiles.resolved('packages/guition.yaml'))
def test_generator_refuses_a_fixed_home_assistant_subscription(self):
generator = load_generator()
text = (ROOT / BOARDS['cyd']).read_text()
stray = ' - platform: homeassistant\n id: stray\n entity_id: sensor.stray\n\nbinary_sensor:\n'
with self.assertRaises(ValueError):
generator.generate('cyd', text.replace('binary_sensor:\n', stray, 1))
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')
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__':
+4 -2
View File
@@ -4,9 +4,11 @@ import re
import sys
import unittest
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0,str(ROOT/'tools'))
import verify_gt911
SOURCE=(ROOT/'guition-4848s040.yaml').read_text()
SOURCE=profiles.resolved('guition-4848s040.yaml')
VALUES=dict(re.findall(r'^ (\w+): "([^"]*)"',SOURCE,re.M))
class GuitionTests(unittest.TestCase):
@@ -54,7 +56,7 @@ class GuitionTests(unittest.TestCase):
def test_all_tiles_clip_long_titles(self):
for i in range(1,11):
self.assertRegex(SOURCE,rf'id: t{i}_title\n\s+height: 24\n\s+width: 130\n\s+long_mode: DOT')
self.assertRegex(SOURCE,rf'id: !extend t{i}_title\n\s+height: 24\n\s+width: 130\n\s+long_mode: DOT')
self.assertEqual(VALUES['AUTO_DIM_TIMEOUT'],'600')
def test_gt911_verification_accepts_pixels_and_rejects_wrong_orientation(self):
+6 -4
View File
@@ -10,6 +10,8 @@ from pathlib import Path
from zoneinfo import ZoneInfo
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'))
import header_bar # noqa: E402
import tile_icons # noqa: E402
@@ -191,7 +193,7 @@ class MessageTests(unittest.TestCase):
class ParityTests(unittest.TestCase):
def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self):
for name in PROFILES + ('packages/cyd.yaml', 'packages/guition.yaml'):
text = (ROOT / name).read_text()
text = profiles.resolved(name)
block = re.search(r'id: sublabel_big\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S)
self.assertIsNotNone(block, name)
glyphs = set(re.findall(r"'([^'])'|\"(')\"", block.group(1)))
@@ -213,7 +215,7 @@ class ParityTests(unittest.TestCase):
def test_profiles_draw_the_bar_from_the_runtime(self):
for name in PROFILES:
text = (ROOT / name).read_text()
text = profiles.text(name)
self.assertIn('runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();', text, name)
self.assertIn('runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();', text, name)
self.assertIn('runtime_tiles::time_label = id(lbl_time);', text, name)
@@ -224,10 +226,10 @@ class ParityTests(unittest.TestCase):
def test_second_hand_runs_only_while_the_screen_is_awake(self):
for name in PROFILES:
text = (ROOT / name).read_text()
text = profiles.text(name)
self.assertIn('runtime_tiles::screen_awake = []() { return !id(display_dimmed); };', text, name)
for name in ('packages/cyd.yaml', 'packages/guition.yaml'):
self.assertIn('runtime_tiles::screen_awake', (ROOT / name).read_text(), name)
self.assertIn('runtime_tiles::screen_awake', profiles.text(name), name)
self.assertIn('inline bool awake() { return !screen_awake || screen_awake(); }', TILES)
second_hand = TILES.split('inline void second_hand(', 1)[1].split('\n}', 1)[0]
self.assertIn('set_hidden(p,!(awake() && now.is_valid()));', second_hand)
+3 -1
View File
@@ -15,6 +15,8 @@ from pathlib import Path
from zoneinfo import ZoneInfo
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 history_card as hc # noqa: E402
@@ -397,7 +399,7 @@ class Firmware(unittest.TestCase):
'lv_obj_add_event_cb(area,history_scrub,LV_EVENT_ALL,nullptr);'):
self.assertIn(flag, touch)
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'):
text = (ROOT / name).read_text()
text = profiles.text(name)
self.assertEqual(len(re.findall(r'runtime_tiles::small_font = id\(sublabel\)->get_lv_font\(\);', text)), 1, name)
+5 -2
View File
@@ -5,10 +5,13 @@ a slider fill rounded less than its track cost 15-20 KB per frame there, and whe
it retries inside the frame until the task watchdog resets the board.
"""
import re
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
PROFILES = ['home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml']
HEADERS = sorted((ROOT / 'components/smart_display').glob('*.h'))
@@ -23,7 +26,7 @@ C_LAYER_STYLES = re.compile(r'lv_obj_set_style_(opa_layered|transform_rotation|t
class LayerFreeTests(unittest.TestCase):
def test_board_profiles_use_no_layer_styles(self):
for name in PROFILES:
text = (ROOT / name).read_text()
text = profiles.text(name)
self.assertEqual(YAML_LAYER_STYLES.findall(text), [], name)
self.assertEqual(re.findall(r'^\s*clip_corner: true', text, re.M), [], name)
@@ -50,7 +53,7 @@ class LayerFreeTests(unittest.TestCase):
def test_yaml_slider_fill_keeps_the_track_radius(self):
for name in PROFILES:
text = (ROOT / name).read_text()
text = profiles.text(name)
for match in re.finditer(r'^(\s+)- (?:slider|bar):\n((?:\1 .*\n|\s*\n)+)', text, re.M):
block, indent = match.group(2), match.group(1) + ' '
track = re.search(r'^' + indent + r'radius: (\S+)', block, re.M)
+5 -2
View File
@@ -1,10 +1,13 @@
"""Regression checks for the 320x240 page geometry and event guards."""
from pathlib import Path
import re
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
SOURCE = (ROOT / 'home-like-2432s028.yaml').read_text()
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
SOURCE = profiles.resolved('home-like-2432s028.yaml')
VALUES = dict(re.findall(r'^ (\w+): "([^"]*)"', SOURCE, re.M))
class LayoutTests(unittest.TestCase):
@@ -40,7 +43,7 @@ class LayoutTests(unittest.TestCase):
def test_page_keys_are_the_halves_of_the_band_under_the_tiles(self):
"""Firmware 0.2.69+: a chevron in each half of the band, the dots between them take no touches."""
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml'):
source = (ROOT / name).read_text()
source = profiles.resolved(name)
values = dict(re.findall(r'^ (\w+): "([^"]*)"', source, re.M))
band = int(values['DISPLAY_H']) - int(values['SCROLL_Y']) - int(values['SCROLL_H'])
half = int(values['DISPLAY_W']) // 2
+3 -1
View File
@@ -8,6 +8,8 @@ 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'))
import header_bar # noqa: E402
import tile_icons # noqa: E402
@@ -20,7 +22,7 @@ class OffLook(unittest.TestCase):
def test_the_fonts_carry_the_bulb_that_is_off(self):
self.assertEqual(tile_icons.GLYPHS['lightbulb-off'], 'F0E4F')
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'):
self.assertIn('"\\U000F0E4F" # lightbulb-off', (ROOT / name).read_text(), name)
self.assertIn('"\\U000F0E4F" # lightbulb-off', profiles.text(name), name)
def test_the_screen_crosses_out_its_own_bulb_and_greys_what_is_off(self):
self.assertIn('if (d == "light" && tile.state == "off") return "\\U000F0E4F";', TILES)
+3 -1
View File
@@ -14,6 +14,8 @@ 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'))
from core import validate_settings # noqa: E402
@@ -80,7 +82,7 @@ class SettingEvents(unittest.IsolatedAsyncioTestCase):
class Firmware(unittest.TestCase):
def setUp(self):
self.profiles = {name: (ROOT / name).read_text() for name in PROFILES}
self.profiles = {name: profiles.text(name) for name in PROFILES}
def test_home_assistant_setting_the_same_value_writes_and_reports_nothing(self):
# Firmware 0.2.49+: the setting entities change a value through settings_screen::set(), which stores,
+7 -6
View File
@@ -9,6 +9,8 @@ import sys
import unittest
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'))
from core import FIRMWARE_VERSION # noqa: E402
@@ -17,7 +19,7 @@ CHANGELOG = ROOT / 'screen_manager/CHANGELOG.md'
# sections are firmware-only releases between app 0.2.11 and 0.2.12 and carry no app version, so they don't count.
APP_HEADING = re.compile(r'^## (\d+)\.(\d+)\.(\d+)\b(.*)$', re.M)
FIRMWARE_IN_HEADING = re.compile(r'\(firmware (\d+\.\d+\.\d+)\)')
# The generator (tools/generate_packages.py) turns fonts/<file> into a raw GitHub URL on main.
# The published entries (packages/<board>.yaml) point ${FONT_DIR} at the raw GitHub URL of fonts/ on main.
FONT_URL = re.compile(r'https://raw\.githubusercontent\.com/MaxGramser/homeassistant_espscreen/[^/\s"\']+/(fonts/[^"\'\s]+)')
@@ -56,13 +58,12 @@ class ReleaseVersionTests(unittest.TestCase):
class PackageFontTests(unittest.TestCase):
def test_every_font_a_package_fetches_is_in_the_tree(self):
packages = sorted((ROOT / 'packages').glob('*.yaml'))
self.assertTrue(packages, 'no packages/*.yaml')
seen = 0
for package in packages:
for path in FONT_URL.findall(package.read_text()):
for package in profiles.PACKAGES:
# The shared core names its fonts as ${FONT_DIR}/...; the published entry says where that is (app 0.2.84+).
for path in FONT_URL.findall(profiles.merged(package)):
seen += 1
self.assertTrue((ROOT / path).is_file(), f'{package.name} fetches {path}, which is not in the tree')
self.assertTrue((ROOT / path).is_file(), f'{package} fetches {path}, which is not in the tree')
# A changed URL form must not turn this into a test of nothing.
self.assertGreater(seen, 0, 'no fonts/... URL found in packages/*.yaml')
+26 -23
View File
@@ -21,6 +21,8 @@ from pathlib import Path
from unittest.mock import patch
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'))
from core import (FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_ENTITIES_MIN_FIRMWARE, SETTING_RULES, forecast_kinds, # noqa: E402
@@ -33,8 +35,8 @@ if HAS_AIOHTTP:
from aiohttp.test_utils import TestClient, TestServer
from server import ANSWER_RETRY_SECONDS, HomeAssistant, Manager, Refused, create_app
PROFILES = {'cyd': ROOT / 'home-like-2432s028.yaml', 'guition': ROOT / 'guition-4848s040.yaml'}
PACKAGES = {'cyd': ROOT / 'packages/cyd.yaml', 'guition': ROOT / 'packages/guition.yaml'}
PROFILES = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'}
PACKAGES = {'cyd': 'packages/cyd.yaml', 'guition': 'packages/guition.yaml'}
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
SCREEN_PAGE = (ROOT / 'components/smart_display/settings_screen.h').read_text()
STATIC = ROOT / 'screen_manager/app/static'
@@ -114,18 +116,18 @@ class CoreSettings(unittest.TestCase):
def test_the_entities_are_named_as_in_both_board_profiles(self):
blocks = {'switch': 'switch', 'number': 'number', 'time': 'datetime', 'select': 'select'}
for board, path in [*PROFILES.items(), *PACKAGES.items()]:
text = path.read_text()
text = profiles.text(path)
for key, (domain, name) in SETTING_ENTITIES.items():
block = top_block(text, blocks[domain])
if key == 'rotation' and board == 'cyd':
self.assertNotIn(f'name: "{name}"', block, f'{path.name}: the CYD cannot turn')
self.assertNotIn(f'name: "{name}"', block, f'{path}: the CYD cannot turn')
continue
self.assertIn(f'name: "{name}"', block, f'{path.name}: {key}')
self.assertIn(f'name: "{name}"', block, f'{path}: {key}')
at = block.index(f'name: "{name}"')
entry = block[max(0, at - 200):at + 900]
self.assertIn('entity_category: config', entry, f'{path.name}: {key}')
self.assertRegex(entry, rf'settings_screen::set\("{key}"', f'{path.name}: {key} changes through set()')
self.assertIn('type: time', top_block(text, 'datetime'), path.name)
self.assertIn('entity_category: config', entry, f'{path}: {key}')
self.assertRegex(entry, rf'settings_screen::set\("{key}"', f'{path}: {key} changes through set()')
self.assertIn('type: time', top_block(text, 'datetime'), path)
def test_a_screen_owns_its_settings_only_with_firmware_0_2_49_entities(self):
old = [{'entity_id': ENTITY_IDS['brightness'], 'platform': 'esphome', 'original_name': 'Normal brightness'},
@@ -590,15 +592,15 @@ class Editor(unittest.TestCase):
class Firmware(unittest.TestCase):
def test_the_message_action_answers_a_caller_that_asks(self):
for path in [*PROFILES.values(), *PACKAGES.values()]:
text = path.read_text()
text = profiles.text(path)
action = text[text.index('- action: screen_message'):]
# Up to the next key of the api block or the next top-level block (the packages carry no keys).
action = action[:re.search(r'\n(?: [a-z_]+:|[a-z_]+:)', action).start()]
self.assertIn('supports_response: optional', action, path.name)
self.assertIn("lambda: 'return call_id != 0;'", action, f'{path.name}: no answer, no warning, for a caller without a call id')
self.assertIn('api.respond:', action, path.name)
self.assertIn('root["status"] = id(dashboard_inbox).state;', action, path.name)
self.assertIn('root["rev"] = runtime_tiles::layout_rev;', action, path.name)
self.assertIn('supports_response: optional', action, path)
self.assertIn("lambda: 'return call_id != 0;'", action, f'{path}: no answer, no warning, for a caller without a call id')
self.assertIn('api.respond:', action, path)
self.assertIn('root["status"] = id(dashboard_inbox).state;', action, path)
self.assertIn('root["rev"] = runtime_tiles::layout_rev;', action, path)
def test_the_settings_tile_opens_without_home_assistant(self):
event = RUNTIME[RUNTIME.index('inline void event(lv_event_t *event) {'):]
@@ -609,23 +611,24 @@ class Firmware(unittest.TestCase):
def test_home_assistant_sees_every_setting_change(self):
for board, path in PROFILES.items():
script = path.read_text()
script = script[script.index('- id: apply_screen_settings'):]
text = profiles.text(path)
script = text[text.index('- id: apply_screen_settings'):]
script = script[:script.index('\n - id: ', 10)]
for entity in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds',
'setting_auto_home_seconds'):
# One loop publishes every number that lacks a state or changed (firmware 0.2.50, see test_cover_card).
self.assertIn(f'std::make_pair(id({entity}),', script, f'{path.name}: {entity}')
self.assertIn('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, path.name)
self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path.name)
self.assertIn('settings_screen::refresh();', script, f'{path.name}: an open settings page follows Home Assistant')
self.assertEqual('id(setting_rotation)->update();' in script, board == 'guition', path.name)
self.assertIn(f'std::make_pair(id({entity}),', script, f'{path}: {entity}')
self.assertIn('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, path)
self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path)
self.assertIn('settings_screen::refresh();', script, f'{path}: an open settings page follows Home Assistant')
# The Guition's board file extends the script with the rotation (docs/PROFILES.md).
self.assertEqual('id(setting_rotation)->update();' in text, board == 'guition', path)
def test_the_page_and_every_entity_change_settings_the_same_way(self):
self.assertRegex(SCREEN_PAGE, r'enum class SetResult : uint8_t \{ unknown, same, changed \};')
for path in PROFILES.values():
text = path.read_text()
self.assertNotIn('settings_preference.save(&s)', text, f'{path.name}: saving goes through settings_screen::set()')
text = profiles.text(path)
self.assertNotIn('settings_preference.save(&s)', text, f'{path}: saving goes through settings_screen::set()')
self.assertNotIn('update_interval: never\n lambda: return settings_screen::swipe_pages', text, 'a template switch has no update_interval')
+3 -1
View File
@@ -13,6 +13,8 @@ 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
@@ -85,7 +87,7 @@ 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: (ROOT / name).read_text()
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):
+19 -17
View File
@@ -12,12 +12,14 @@ 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'))
from core import DARK_MODE_MIN_FIRMWARE, FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_RULES, SETTINGS_BESIDE_BLOCK # noqa: E402
COMPONENT = ROOT / 'components/smart_display'
THEME = (COMPONENT / 'theme.h').read_text()
PROFILES = [ROOT / 'home-like-2432s028.yaml', ROOT / 'guition-4848s040.yaml', ROOT / 'packages/cyd.yaml', ROOT / 'packages/guition.yaml']
PROFILES = list(profiles.NAMES)
HEX = re.compile(r'0x[0-9A-Fa-f]{6}\b')
# What may still carry a number that looks like a colour: the light's colour picker shows the colours a light can take,
# a mask and a codepoint check are no colours, and the value card's colour key is a wheel of fixed hues.
@@ -46,14 +48,14 @@ class OnePlace(unittest.TestCase):
def test_no_profile_writes_a_colour_of_its_own(self):
for path in PROFILES:
text = path.read_text()
text = profiles.text(path)
wheel = re.findall(r'^\s+arc_color: (0x[0-9A-F]{6})$', text, re.M)
self.assertEqual(set(wheel), COLOUR_WHEEL, path.name)
self.assertEqual(set(wheel), COLOUR_WHEEL, path)
everything = HEX.findall(text)
self.assertEqual(sorted(everything), sorted(wheel), f'{path.name}: only the colour wheel keeps fixed hues')
self.assertEqual(sorted(everything), sorted(wheel), f'{path}: only the colour wheel keeps fixed hues')
self.assertNotIn('BG_TOP_COLOR', text)
self.assertNotIn('ACCENT_AUTO', text)
self.assertNotRegex(text, r'^\s+(bg|text|border|bg_grad|line|outline|shadow)_color:', path.name)
self.assertNotRegex(text, r'^\s+(bg|text|border|bg_grad|line|outline|shadow)_color:', path)
def test_every_role_has_a_light_and_a_dark_value(self):
roles = re.search(r'enum Role : uint8_t \{(.*?)ROLE_COUNT', THEME, re.S)[1]
@@ -67,7 +69,7 @@ class Paints(unittest.TestCase):
def test_every_paint_is_defined_adopted_and_used_in_every_profile(self):
paints = paint_names()
for path in PROFILES:
text = path.read_text()
text = profiles.merged(path)
defined = re.findall(r'^ - id: (paint_\w+)$', text, re.M)
used = set(re.findall(r'styles: (paint_\w+)$', text, re.M))
filling = text.split('theme::paints = []() {', 1)[1].split('\n };', 1)[0]
@@ -75,33 +77,33 @@ class Paints(unittest.TestCase):
listed = re.search(r'for \(auto \*style : \{([^}]*)\}\) theme::fill\(style, Paint::(\w+)\);', filling)
looped = [(name, listed[2]) for name in re.findall(r'id\((\w+)\)', listed[1])]
adopted = dict(explicit + looped)
self.assertEqual(len(adopted), len(explicit) + len(looped), f'{path.name}: every style is filled once')
self.assertEqual(text.count('theme::fill('), len(explicit) + 1, f'{path.name}: styles are filled in theme::paints only')
self.assertEqual(set(defined), used, f'{path.name}: every paint style is used, every used one defined')
self.assertEqual(len(adopted), len(explicit) + len(looped), f'{path}: every style is filled once')
self.assertEqual(text.count('theme::fill('), len(explicit) + 1, f'{path}: styles are filled in theme::paints only')
self.assertEqual(set(defined), used, f'{path}: every paint style is used, every used one defined')
for style in defined + ['style_page', 'style_tile', 'style_icon_circle', 'style_room', 'style_time', 'style_title',
'style_value', 'style_value_big']:
self.assertIn(style, adopted, f'{path.name}: {style} takes its colours from theme.h')
self.assertIn(adopted[style], paints, f'{path.name}: {style}')
self.assertIn(style, adopted, f'{path}: {style} takes its colours from theme.h')
self.assertIn(adopted[style], paints, f'{path}: {style}')
for style, paint in adopted.items():
if style.startswith('paint_'):
self.assertEqual(style, f'paint_{paint}', f'{path.name}: a paint style is named after its paint')
self.assertEqual(style, f'paint_{paint}', f'{path}: a paint style is named after its paint')
# The styles that carry colours have none written down.
styles = re.search(r'^ style_definitions:\n(.*?)^ # ---------- ALERT', text, re.M | re.S)[1]
self.assertNotRegex(styles, r'_color:', path.name)
self.assertNotRegex(styles, r'_color:', path)
def test_the_look_is_applied_at_boot_after_a_change_and_drawn_again(self):
for path in PROFILES:
text = path.read_text()
text = profiles.text(path)
boot = text.split('runtime_tiles::load_settings();', 1)[1].split('runtime_tiles::settings_changed', 1)[0]
self.assertIn('theme::paints();\n theme::set_dark(settings_screen::dark_mode != 0);', boot,
f'{path.name}: the paints filled, then the saved look, before the first frame')
f'{path}: the paints filled, then the saved look, before the first frame')
apply = text.split(' - id: apply_screen_settings\n', 1)[1].split('\n - id: ', 1)[0]
self.assertIn('theme::set_dark(settings_screen::dark_mode != 0);', apply, f'{path.name}: a change of the setting')
self.assertIn('theme::set_dark(settings_screen::dark_mode != 0);', apply, f'{path}: a change of the setting')
redraw = text.split('theme::redraw = []() {', 1)[1].split('};', 1)[0]
for needle in ('runtime_tiles::restyle();', 'light_controls::restyle();', 'settings_screen::restyle();',
'theme::surface(id(alert_card_color))', 'id(climate_card_refresh).execute();',
'id(open_climate_mode_picker).execute();'):
self.assertIn(needle, redraw, f'{path.name}: {needle}')
self.assertIn(needle, redraw, f'{path}: {needle}')
self.assertIn('id(alert_card_color) = alert.color;', text)
self.assertIn('lv_color_hex(theme::surface(alert.color))', text)
+3 -1
View File
@@ -7,6 +7,8 @@ import tempfile
import unittest
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'))
from core import CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout
@@ -75,7 +77,7 @@ class ControlChoices(unittest.TestCase):
self.assertTrue(set(tile_icons.CONTROL_GLYPHS) <= set(tile_icons.GLYPHS))
self.assertEqual(set(tile_icons.editor()['controls']), set(tile_icons.CONTROL_GLYPHS))
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'):
text = (ROOT / name).read_text()
text = profiles.text(name)
for code in used:
self.assertIn(f'"\\U000{code}"', text, (name, code))
self.assertIn('runtime_tiles::control_font = id(sublabel_big)->get_lv_font();', text, name)
+4 -2
View File
@@ -5,6 +5,8 @@ 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
@@ -22,7 +24,7 @@ class IconSetTests(unittest.TestCase):
self.assertRegex(code, r'^F[0-9A-F]{4}$')
wanted = [f'\\U000{code}' for code in tile_icons.GLYPHS.values()]
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'):
text = (ROOT / name).read_text()
text = profiles.resolved(name)
fonts = re.findall(r'materialdesignicons-webfont\.ttf["\']\n id: (\w+)\n size: \d+\n bpp: 4\n glyphs: (.*)\n', text)
self.assertEqual([font for font, _ in fonts], ['materialdesign_icons', 'materialdesign_icons_mini', 'materialdesign_icons_big', 'watch_icon'], name)
self.assertTrue(fonts[0][1].startswith('&tile_icons ') and all(g == '*tile_icons' for font, g in fonts[1:] if font != 'materialdesign_icons_big'), name)
@@ -43,7 +45,7 @@ class IconSetTests(unittest.TestCase):
self.assertEqual(len(entries), len(tile_icons.GLYPHS))
def test_every_glyph_the_firmware_draws_is_in_the_set(self):
sources = [HEADER] + [(ROOT / name).read_text().split('glyphs: &tile_icons', 1)[0] for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')]
sources = [HEADER] + [profiles.text(name).split('glyphs: &tile_icons', 1)[0] for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')]
used = {code.upper() for source in sources for code in re.findall(r'\\U000(F[0-9A-Fa-f]{4})', source)}
self.assertLessEqual(used, set(tile_icons.GLYPHS.values()))
+3 -1
View File
@@ -11,6 +11,8 @@ import tempfile
import unittest
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'))
from core import FIRMWARE_VERSION, discover
import firmware
@@ -24,7 +26,7 @@ HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
class VersionSourceTests(unittest.TestCase):
def test_app_target_matches_shipped_firmware(self):
for name in ('packages/cyd.yaml', 'packages/guition.yaml', 'home-like-2432s028.yaml', 'guition-4848s040.yaml'):
text = (ROOT / name).read_text()
text = profiles.text(name)
self.assertIn(f'SCREEN_FIRMWARE_VERSION: "{FIRMWARE_VERSION}"', text, name)
self.assertIn('name: "Device name"', text, name)
self.assertIn('platform: wifi_info', text, name)
+12 -4
View File
@@ -12,6 +12,8 @@ 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'))
from core import FIRMWARE_VERSION, WAKE_SLEEP_MIN_FIRMWARE # noqa: E402
@@ -20,13 +22,19 @@ PROFILES = ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.ya
def section(text, top):
"""One top-level YAML section of a profile, up to the next one."""
match = re.search(rf'^{top}:\n(.*?)(?=^[a-z_]+:|\Z)', text, re.M | re.S)
return match[1] if match else ''
# The core and the board file may both carry the section (app 0.2.84+).
return '\n'.join(re.findall(rf'^{top}:\n(.*?)(?=^[a-z_]+:|\Z)', text, re.M | re.S))
def item(text, key, value):
"""The list item in a section that carries `key: value`, up to the next item at the same depth."""
for chunk in re.split(r'^ - ', text, flags=re.M)[1:]:
chunks = re.split(r'^ - ', text, flags=re.M)[1:]
# The item that starts with `key: value` first: a script that names another one (`id: alert_dismiss` in
# alert_timeout) may come before that one's own block now that the board file adds scripts of its own.
for chunk in chunks:
if re.match(rf'{key}: "?{re.escape(value)}"?$', chunk, re.M):
return chunk
for chunk in chunks:
if re.search(rf'^\s*{key}: "?{re.escape(value)}"?$', chunk, re.M):
return chunk
return ''
@@ -38,7 +46,7 @@ def script(text, name):
class Profiles(unittest.TestCase):
def setUp(self):
self.profiles = {name: (ROOT / name).read_text() for name in PROFILES}
self.profiles = {name: profiles.text(name) for name in PROFILES}
def test_the_firmware_that_carries_the_buttons_is_this_release_or_older(self):
version = lambda text: tuple(int(part) for part in text.split('.'))