Files
Max Gramser 1a6308aba7 Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host
harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and
Vue) found these, fixed here with Max's choices for the product questions:

- Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the
  waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap
  it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has
  no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built.
- App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its
  firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the
  live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes.
- Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone
  dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build.
- tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:25:23 +02:00

134 lines
8.0 KiB
Python

"""The firmware a screen's features go by, and what the editor may offer it (app 0.2.78).
A screen whose "Screen firmware" sensor had no version (offline, restarting) counted as firmware 0.0.0: a save of more
than ten tiles or of a newer kind of tile was refused, and the editor capped the screen at ten tiles. Home Assistant's
device registry keeps the version ESPHome reported, "<project version> (ESPHome <version>)" for our firmware, and the app
now falls back to that. Only a plain X.Y.Z counts, as before: "0.2.65-dev" is no release.
Each screen in the inventory says what that means for the editor: `firmware_known`, `tile_limit`, `full_page` and
`page_tiles_repeat` (the contract with web/src/store.ts).
"""
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import camera_feed # noqa: E402
from core import (alert_targets, discover_screens, firmware_features, known_firmware, parse_firmware, registry_firmware, # noqa: E402
screen_firmware, tile_limit)
import updates # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
import test_scaling
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
REGISTRY = '0.2.63 (ESPHome 2026.6.2)'
class Versions(unittest.TestCase):
def test_only_a_plain_version_counts(self):
self.assertEqual(parse_firmware('0.2.63'), (0, 2, 63))
self.assertEqual(parse_firmware('1.10.0'), (1, 10, 0))
for text in ('0.2.65-dev', '0.2', '0.2.63.1', ' 0.2.63', '0.2.+63', REGISTRY, 'unknown', 'unavailable', '', None, 63):
self.assertIsNone(parse_firmware(text), text)
self.assertIs(updates.parse_version, parse_firmware, 'update offers follow the same strict rule')
def test_the_registry_version_of_our_firmware(self):
self.assertEqual(registry_firmware(REGISTRY), '0.2.63')
# Firmware without `esphome: project:` has ESPHome's own version there, which is no screen firmware.
for text in ('2026.6.2 (Sep 17 2026, 10:00:00)', '2026.6.2', '0.2.65-dev (ESPHome 2026.6.2)', 'v0.2.63 (ESPHome 2026.6.2)', '', None):
self.assertIsNone(registry_firmware(text), text)
self.assertEqual(known_firmware('0.2.64', REGISTRY), '0.2.64', 'the sensor comes first')
for sensor in ('unavailable', 'unknown', '0.2.65-dev', None):
self.assertEqual(known_firmware(sensor, REGISTRY), '0.2.63', sensor)
self.assertIsNone(known_firmware('unavailable', None))
def test_what_a_firmware_may_be_offered(self):
self.assertEqual([tile_limit(v) for v in (None, (0, 2, 6), (0, 2, 7), (0, 2, 61), (0, 2, 62), (0, 3, 0))], [10, 10, 20, 20, 48, 48])
self.assertEqual(firmware_features((0, 2, 64)), {'tile_limit': 48, 'full_page': True, 'page_tiles_repeat': False})
self.assertEqual(firmware_features((0, 2, 65)), {'tile_limit': 48, 'full_page': True, 'page_tiles_repeat': True})
self.assertEqual(firmware_features(None), {'tile_limit': 10, 'full_page': False, 'page_tiles_repeat': False})
def test_discovery_falls_back_to_the_registry(self):
registry = [{'entity_id': 'text.hall_tile_settings', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'sensor.hall_screen_firmware', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
devices = [{'id': 'd1', 'name': 'Hall', 'sw_version': REGISTRY}]
for sensor, known in (('0.2.64', '0.2.64'), ('unavailable', '0.2.63'), ('0.2.65-dev', '0.2.63')):
screen, = discover_screens(registry, {'sensor.hall_screen_firmware': {'state': sensor}}, devices, [])
self.assertEqual((screen['firmware'], screen['firmware_known']), (sensor, known))
self.assertEqual(screen_firmware(screen), parse_firmware(known))
screen, = discover_screens(registry, {}, [{'id': 'd1', 'name': 'Hall'}], [])
self.assertEqual((screen['firmware'], screen['firmware_known'], screen_firmware(screen)), ('unknown', None, None))
def test_alerts_and_camera_images_go_by_the_same_version(self):
restarting = {'id': 'text.hall', 'name': 'Hall', 'node': 'hall', 'online': True, 'board': 'guition',
'firmware': 'unknown', 'firmware_known': '0.2.63'}
self.assertEqual(alert_targets([restarting]), ([restarting], []))
self.assertTrue(camera_feed.can_show(restarting))
unknown = {**restarting, 'firmware_known': None}
self.assertEqual(alert_targets([unknown])[1], [(unknown, 'firmware unknown')])
self.assertFalse(camera_feed.can_show(unknown))
def tiles(count, prefix='light.l'):
return [{'entity': f'{prefix}{i}', 'name': ''} for i in range(count)]
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Saving(unittest.IsolatedAsyncioTestCase):
def manager(self, tmp, sensor, sw_version=None, online=True):
ha = test_scaling.fake_ha(firmware=sensor)
for tile in tiles(30):
ha.registry.append({'entity_id': tile['entity'], 'platform': 'hue'})
ha.states[tile['entity']] = {'state': 'on', 'attributes': {}}
if sw_version:
ha.devices[0]['sw_version'] = sw_version
if not online:
ha.states['text.screen']['state'] = 'unavailable'
return Manager(ha, Path(tmp) / 'screens.json')
async def test_an_offline_screen_keeps_its_features(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, 'unavailable', REGISTRY, online=False)
m.save('text.screen', {'title': 'Hall', 'tiles': tiles(30)})
self.assertEqual(len(m.layouts['text.screen']['tiles']), 30)
self.assertEqual(m.status['text.screen'], 'Saved; waiting for sync')
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, 'unavailable', online=False)
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.62 or newer first'):
m.save('text.screen', {'title': 'Hall', 'tiles': tiles(30)})
async def test_a_development_version_is_no_release(self):
back = [{'entity': 'screen.page_1', 'name': '', 'slot': 6}, {'entity': 'screen.page_1', 'name': '', 'slot': 12}]
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, '0.2.65-dev')
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.65 or newer first'):
m.save('text.screen', {'title': 'Hall', 'tiles': back})
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, 'unknown', '0.2.65 (ESPHome 2026.6.2)')
m.save('text.screen', {'title': 'Hall', 'tiles': back})
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [6, 12])
async def test_every_screen_in_the_inventory_says_what_the_editor_may_offer(self):
cases = (('0.2.6', None, '0.2.6', 10, False, False), ('0.2.7', None, '0.2.7', 20, False, False),
('0.2.62', None, '0.2.62', 48, True, False), ('0.2.65', None, '0.2.65', 48, True, True),
('unavailable', REGISTRY, '0.2.63', 48, True, False), ('0.2.65-dev', None, None, 10, False, False))
for sensor, sw_version, known, limit, full, repeat in cases:
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, sensor, sw_version)
async with TestClient(TestServer(create_app(m, True))) as client:
for path in ('/api/inventory', '/api/inventory?light=1'):
screen, = (await (await client.get(path)).json())['screens']
self.assertEqual((screen['firmware'], screen['firmware_known'], screen['tile_limit'], screen['full_page'],
screen['page_tiles_repeat']), (sensor, known, limit, full, repeat), (path, sensor))
if __name__ == '__main__':
unittest.main()