d76461d774
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>
165 lines
10 KiB
Python
165 lines
10 KiB
Python
"""Colours live in one place, and Dark mode reaches every screen (app 0.2.63 / firmware 0.2.54).
|
|
|
|
components/smart_display/theme.h holds every colour the firmware chooses: roles with a light and a dark value, the named
|
|
card colours and Home Assistant's state colours. The board profiles name paints (shared LVGL styles theme.h fills) and
|
|
the firmware asks theme.h for roles, so a new look is a refill of a few styles plus one redraw. These tests keep it that
|
|
way: no colour written anywhere else, every paint defined and filled, and the look applied at boot and after
|
|
every settings change. tests/test_theme.cpp checks the values themselves.
|
|
"""
|
|
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
|
|
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 = 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.
|
|
ALLOWED_IN_FIRMWARE = {
|
|
'light_controls.h': ('constexpr uint32_t WARM = 0xFF9C32, COOL = 0xC6E6FF;',
|
|
'inline constexpr uint32_t RAINBOW[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};'),
|
|
'tile_icon.h': ('if (cp < 0x10000 || cp > 0x10FFFF) return {};',),
|
|
'runtime_tiles.h': ('state_color=lv_color_to_u32(lv_color_hsv_to_rgb(t.hue%360,t.saturation,100))&0xFFFFFF;',),
|
|
}
|
|
COLOUR_WHEEL = {'0xFF3B30', '0xFF9500', '0xFFD60A', '0x30D158', '0x32D6FF', '0x0A84FF', '0x5856D6', '0xBF5AF2'}
|
|
|
|
|
|
def paint_names():
|
|
block = re.search(r'enum class Paint : uint8_t \{(.*?)\};', THEME, re.S)[1]
|
|
return [name.strip() for name in block.replace('\n', ' ').split(',') if name.strip() and name.strip() != 'COUNT']
|
|
|
|
|
|
class OnePlace(unittest.TestCase):
|
|
def test_no_header_writes_a_colour_of_its_own(self):
|
|
for path in sorted(COMPONENT.glob('*.h')):
|
|
if path.name == 'theme.h':
|
|
continue
|
|
for number, line in enumerate(path.read_text().splitlines(), 1):
|
|
if HEX.search(line):
|
|
self.assertIn(line.strip(), ALLOWED_IN_FIRMWARE.get(path.name, ()), f'{path.name}:{number}: a colour outside theme.h')
|
|
|
|
def test_no_profile_writes_a_colour_of_its_own(self):
|
|
for path in PROFILES:
|
|
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)
|
|
everything = HEX.findall(text)
|
|
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)
|
|
|
|
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]
|
|
names = re.findall(r'^\s+([A-Z_]+),', roles, re.M)
|
|
values = re.findall(r'/\* (\w+) \*/\s+\{0x([0-9A-F]{6}), 0x([0-9A-F]{6})\}', THEME)
|
|
self.assertEqual(names, [name for name, _, _ in values], 'the table follows the enum, one row per role')
|
|
self.assertGreater(len(names), 40)
|
|
|
|
|
|
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 = 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]
|
|
explicit = re.findall(r'theme::fill\(id\((\w+)\), Paint::(\w+)\);', filling)
|
|
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}: 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}: {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}: 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)
|
|
|
|
def test_the_look_is_applied_at_boot_after_a_change_and_drawn_again(self):
|
|
for path in PROFILES:
|
|
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}: 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}: 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}: {needle}')
|
|
self.assertIn('id(alert_card_color) = alert.color;', text)
|
|
self.assertIn('lv_color_hex(theme::surface(alert.color))', text)
|
|
|
|
def test_the_firmware_draws_long_lived_parts_with_paints(self):
|
|
tiles = (COMPONENT / 'runtime_tiles.h').read_text()
|
|
for needle in ('lv_obj_add_style(w.slider,theme::style(theme::Paint::knob),LV_PART_KNOB);',
|
|
'lv_obj_add_style(w.busy,theme::style(theme::Paint::veil),0);',
|
|
'lv_obj_add_style(w.spinner,theme::style(theme::Paint::spinner),LV_PART_MAIN);',
|
|
'lv_obj_add_style(part, theme::style(theme::Paint::slate), 0);',
|
|
'inline void restyle() {'):
|
|
self.assertIn(needle, tiles)
|
|
# Dark mode is kept in a preference of its own, next to the others outside the frozen settings block.
|
|
self.assertIn('make_preference<uint32_t>(0x44524B31)', tiles)
|
|
self.assertIn('inline void restyle() {', (COMPONENT / 'settings_screen.h').read_text())
|
|
self.assertIn('inline void restyle() {', (COMPONENT / 'light_controls.h').read_text())
|
|
|
|
|
|
class RedrawnTheSame(unittest.TestCase):
|
|
"""A change of look draws again what was drawn, and the renders compare a screen that switched with one that
|
|
booted in that look. Two differences they found were older than Dark mode: what a page showed depended on the
|
|
order it was drawn in."""
|
|
|
|
def test_a_switch_knob_follows_the_size_it_was_given(self):
|
|
page = (COMPONENT / 'settings_screen.h').read_text()
|
|
move = page.split('inline void move_knob(', 1)[1].split('\n}\n', 1)[0]
|
|
# draw() ends in refresh() before LVGL has laid the new rows out, when coordinates still read 0.
|
|
self.assertNotIn('lv_obj_get_width', move)
|
|
self.assertNotIn('lv_obj_get_height', move)
|
|
self.assertIn('knob_x(lv_obj_get_style_width(track, LV_PART_MAIN), lv_obj_get_style_height(track, LV_PART_MAIN), on)', move)
|
|
pill = page.split('inline lv_obj_t *pill(', 1)[1].split('\n}\n', 1)[0]
|
|
self.assertIn('plain(track, knob_x(w, h, on), inset, size, size)', pill)
|
|
|
|
def test_the_sun_path_keeps_its_own_fill_after_a_palette_change(self):
|
|
tiles = (COMPONENT / 'runtime_tiles.h').read_text()
|
|
self.assertIn('w.fill_color=lv_color_hex(theme::ha::SUNNY);', tiles)
|
|
self.assertIn('if(w.extra_mode!="sunpath")w.fill_color=color;', tiles)
|
|
self.assertEqual(len(re.findall(r'\bw\.fill_color=', tiles)), 2, 'the sun path and the palette are the only two')
|
|
|
|
|
|
class Setting(unittest.TestCase):
|
|
def test_dark_mode_is_a_setting_the_screen_owns(self):
|
|
self.assertEqual(SETTING_RULES['dark_mode'], (False, None, None))
|
|
self.assertIn('dark_mode', SETTINGS_BESIDE_BLOCK, 'never inside the eleven-key block older firmware insists on')
|
|
self.assertEqual(SETTING_ENTITIES['dark_mode'], ('switch', 'Dark mode'))
|
|
self.assertEqual(DARK_MODE_MIN_FIRMWARE, '0.2.54')
|
|
self.assertLessEqual(tuple(map(int, DARK_MODE_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.'))))
|
|
page = (COMPONENT / 'settings_screen.h').read_text()
|
|
self.assertIn('toggle("Dark mode", []() -> int32_t { return dark_mode; },', page)
|
|
self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page)
|
|
editor = (ROOT / 'web/src/store.ts').read_text()
|
|
self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor)
|
|
brightness = editor.split('{ title: "Brightness"', 1)[1].split('] },', 1)[0]
|
|
self.assertLess(brightness.index('"brightness"'), brightness.index('"dark_mode"'), 'right under Brightness, as on the screen')
|
|
self.assertLess(brightness.index('"dark_mode"'), brightness.index('"standby_enabled"'))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|