Files
homeassistant_espscreen/tests/test_quiet_resync.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

120 lines
7.0 KiB
Python

"""A colour slider on Studio 1 turned red on release (app 0.2.55, firmware 0.2.47).
An automation set a screen setting to the value it already had on every light change. The screen reported
it, ESP Screen Manager saved the layout and resent the whole screen, and the screen drew its whole page
again while a finger was on a slider. Touch input stalled, and the GT911's stray (0, 0) contact became
the finger's last position, so LVGL moved the slider to its end on release. Each link is checked here.
"""
import asyncio
import importlib.util
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'))
sys.path.insert(0, str(ROOT / 'tests'))
from core import validate_settings # noqa: E402
PROFILES = ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml')
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
LIGHT_CONTROLS = (ROOT / 'components/smart_display/light_controls.h').read_text()
@unittest.skipUnless(importlib.util.find_spec('aiohttp'), 'Run using .venv-portal/bin/python for server tests')
class SettingEvents(unittest.IsolatedAsyncioTestCase):
async def run_loop_for(self, manager, seconds):
task = asyncio.create_task(manager.run())
await asyncio.sleep(seconds)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def test_a_setting_the_screen_already_has_is_not_saved_or_resent(self):
import test_scaling
from server import Manager
with tempfile.TemporaryDirectory() as tmp:
ha = test_scaling.fake_ha(firmware='0.2.47')
ha.setting_events = []
m = Manager(ha, Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Studio', 'tiles': [{'entity': 'light.a', 'name': ''}],
'settings': validate_settings({'standby_seconds': 86400})})
ha.changed.set()
await self.run_loop_for(m, 0.6)
self.assertEqual([msg['op'] for _, msg, _ in ha.messages], ['layout', 'header', 'state'], 'first pass sends everything')
ha.messages.clear()
stored = m.layouts['text.screen']
# An automation sets "Standby after" to 86400 on every light change; the screen reports it.
ha.setting_events.append({'inbox': 'text.screen', 'key': 'standby_seconds', 'value': '86400'})
ha.changed.set()
await self.run_loop_for(m, 0.6)
self.assertEqual(ha.messages, [], 'nothing changed: no resend of the whole screen')
self.assertIs(m.layouts['text.screen'], stored, 'and no save')
self.assertEqual(ha.setting_events, [], 'the event is handled all the same')
# A real change is kept (app 0.2.57), and not sent back: the screen has it already, and an echo that
# arrives after the screen stepped further would undo that step (a held + on its settings page).
rev = m.sent['text.screen']['rev']
ha.setting_events.append({'inbox': 'text.screen', 'key': 'standby_seconds', 'value': '600'})
ha.changed.set()
await self.run_loop_for(m, 0.6)
self.assertEqual(m.layouts['text.screen']['settings']['standby_seconds'], 600)
self.assertEqual(ha.messages, [], 'nothing goes back to the screen')
self.assertEqual(m.sent['text.screen']['layout']['settings']['standby_seconds'], 600, 'what the screen holds follows')
self.assertEqual(m.sent['text.screen']['rev'], rev, 'the ping keeps the revision the screen has')
# So is a brightness that pulls the standby brightness down with it.
ha.setting_events.append({'inbox': 'text.screen', 'key': 'brightness', 'value': '10'})
ha.changed.set()
await self.run_loop_for(m, 0.6)
settings = m.layouts['text.screen']['settings']
self.assertEqual((settings['brightness'], settings['standby_brightness']), (10, 10))
self.assertEqual(ha.messages, [])
# The hourly repeat carries the kept values, so the screen and the stored layout agree.
await m.sync_one('text.screen', m.layouts['text.screen'], force=True, screen=m.screen('text.screen'), dirty=set())
layout = next(msg for _, msg, _ in ha.messages if msg['op'] == 'layout')
self.assertEqual((layout['settings']['brightness'], layout['settings']['standby_seconds']), (10, 600))
class Firmware(unittest.TestCase):
def setUp(self):
self.profiles = {name: (ROOT / name).read_text() 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,
# applies and reports only a real change (tests/test_settings_screen.cpp checks that).
screen = (ROOT / 'components/smart_display/settings_screen.h').read_text()
self.assertRegex(screen, r'return SetResult::same;\n changed\(key\.c_str\(\), reported\);')
for name, text in self.profiles.items():
for key in ('brightness', 'standby_brightness', 'night_brightness', 'standby_seconds'):
self.assertIn(f"set_action:\n - lambda: 'settings_screen::set(\"{key}\", (int32_t) lround(x));'", text, f'{name}: {key}')
self.assertNotIn('runtime_tiles::setting_event("', text, f'{name}: settings are reported through set() only')
def test_a_repeated_layout_does_not_redraw_the_page(self):
block = RUNTIME[RUNTIME.index('if (op == "layout")'):RUNTIME.index('if (op == "ping")')]
guard = re.search(r'if \((.*)\) \{\s+if \(layout_changed\) layout_changed\(\);\s+refresh_all\(\);\s+\}', block)
self.assertTrue(guard, 'layout_changed/refresh_all must only run for a layout that differs')
for reason in ('changed', 'moved', '!was_configured', 'rotation_changed', 'model.pages != previous_pages',
'model.title != previous_title'):
self.assertIn(reason, guard[1])
self.assertEqual(block.count('layout_changed()'), 1)
def test_the_guition_hides_the_stray_gt911_contact_from_lvgl_and_the_edge_swipe(self):
for name in ('guition-4848s040.yaml', 'packages/guition.yaml'):
text = self.profiles[name]
self.assertIn('cyd::ghost_touch.filter(', text, name)
self.assertIn('lv_indev_set_read_cb(indev, filtered);', text, name)
self.assertRegex(text, r'if \(point\.x == 0 && point\.y == 0\) continue;\s+cyd::touch_guard\.update', name)
for name in ('home-like-2432s028.yaml', 'packages/cyd.yaml'):
self.assertNotIn('ghost_touch', self.profiles[name], 'the CYD has its own XPT2046 filter')
def test_a_slider_that_jumps_on_release_is_logged(self):
self.assertIn('cyd::release_jump(row.held, value,', LIGHT_CONTROLS)
self.assertIn('cyd::release_jump(slider_held,raw,', RUNTIME)
if __name__ == '__main__':
unittest.main()