Files
homeassistant_espscreen/tests/test_settings_page.py
T
Max Gramser b58f0cde25 Let the screen own its settings, answer its pings, and show them as cards (0.2.57)
A code check found that a setting changed on a screen could jump back.
ESP Screen Manager kept its own copy and sent it with every full update:
after a restart of the app or Home Assistant, every hour and after every
save. A change it missed while it or Home Assistant restarted came back
to the old value, a reported setting was lost when save() refused a
layout with a removed entity, and every step of a held - or + on the
screen resent the whole screen.

Firmware 0.2.49 offers every setting as an entity over the existing
preference records (no migration): Night mode, Night starts/ends,
24-hour clock, Back to page 1 and after how long, Back to page 1 on
standby, Swipe between pages and, on the Guition, Rotation, next to the
four numbers and Auto standby. The settings page and every entity write
through settings_screen::set(), which clamps like the page and stores,
applies and reports only a real change. screen_message answers a caller
that asks with its status and layout revision (api.respond). The
settings tile opens without Home Assistant.

The app reads and changes the settings of such a screen through its
entities and leaves them out of the layout message. Older firmware keeps
them with the layout; a setting it reports is stored without the tile
check and not sent back. Pings to screens that can answer learn at once
whether a tile is missing and resend after 30 s instead of 120 s. Layout
sensors come back after a Home Assistant restart, and weather tiles only
ask for the forecasts the entity supports. The editor's settings form is
now three cards with the rows of the screen's own page; a change applies
at once, and an offline screen shows its values as unknown.

Checked with 245 Python and 14 C++ tests, generate_packages --check,
ESP32 builds of both profiles, host-build API tests on both boards, and
a live run against Home Assistant 2026.9.1 with a temporary host device.
Firmware 0.2.49.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:50:41 +02:00

134 lines
7.8 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 / 'screen_manager/app'))
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: (ROOT / name).read_text()
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()