b58f0cde25
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>
120 lines
7.0 KiB
Python
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()
|