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>
This commit is contained in:
Max Gramser
2026-09-18 17:25:23 +02:00
parent c09e4e333f
commit 1a6308aba7
80 changed files with 2623 additions and 320 deletions
+1
View File
@@ -10,6 +10,7 @@ import unittest
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 (ALERT_FIELDS, ALERT_MAX_TIMEOUT, BROADCAST_DISMISS, BROADCAST_EVENTS, BROADCAST_SHOW, # noqa: E402
alert_data, alert_reference, alert_targets)
+1
View File
@@ -9,6 +9,7 @@ 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'))
import tile_icons # noqa: E402
from core import (ALERT_LIMITS, ALERT_MIN_FIRMWARE, FIRMWARE_VERSION, TILE_BACKGROUNDS, alert_reference, # noqa: E402
alert_service)
+20 -4
View File
@@ -129,18 +129,23 @@ class Clock:
@unittest.skipUnless(HAS_PIL, 'Pillow comes with ESPHome in the add-on image')
class Feed(unittest.IsolatedAsyncioTestCase):
def feed(self, answers):
self.fetched = []
self.fetched, self.gate = [], None
clock = Clock()
async def fetch(entity):
self.fetched.append(entity)
answer = answers[min(len(self.fetched), len(answers)) - 1]
if self.gate is not None:
# A camera that takes its time: the fetch stays on its way until the test opens the gate.
await self.gate.wait()
if isinstance(answer, Exception):
raise answer
return answer
return camera_feed.CameraFeed(fetch, clock=clock), clock
async def settle(self):
"""Let the fetch a load started run before counting fetches: whether it already ran when serve() returns
depends on whether serve() had to wait for anything itself."""
for _ in range(5):
await asyncio.sleep(0)
@@ -163,18 +168,23 @@ class Feed(unittest.IsolatedAsyncioTestCase):
feed, clock = self.feed([first, second, third])
token = feed.link('camera.max', (480, 480))
_, one, tag_one = await feed.serve(token)
self.assertEqual(self.fetched, ['camera.max'] * 2, 'the first picture, and the next one started at once')
await self.settle()
self.assertEqual(self.fetched, ['camera.max'] * 2, 'the first picture, and the next one started at once')
await asyncio.sleep(0.05)
self.assertEqual(len(self.fetched), 2, 'nobody loads, nothing is fetched')
clock.now += 4
_, two, tag_two = await feed.serve(token, tag_one)
await self.settle()
self.assertNotEqual(tag_two, tag_one, 'the picture fetched after the last load')
self.assertEqual(len(self.fetched), 3)
# Two screens loading at once share one fetch.
# Two screens loading while the next picture is on its way share that one fetch.
self.gate = asyncio.Event()
await feed.serve(token, tag_two)
await feed.serve(token, tag_two)
self.assertLessEqual(len(self.fetched), 4)
await self.settle()
self.assertEqual(len(self.fetched), 4)
self.gate.set()
await self.settle()
async def test_a_camera_that_fails_is_asked_again_after_a_pause(self):
feed, clock = self.feed([ConnectionError('500'), ConnectionError('500'), picture('JPEG', (640, 360))])
@@ -182,22 +192,27 @@ class Feed(unittest.IsolatedAsyncioTestCase):
with self.assertLogs(camera_feed.LOG, 'INFO'):
self.assertEqual((await feed.serve(token))[0], 503)
self.assertEqual((await feed.serve(token))[0], 503)
await self.settle()
self.assertEqual(len(self.fetched), 1, 'no second try within the pause')
clock.now += 5
self.assertEqual((await feed.serve(token))[0], 503)
await self.settle()
self.assertEqual(len(self.fetched), 2)
clock.now += 10
self.assertEqual((await feed.serve(token))[0], 200)
await self.settle()
self.assertEqual(len(self.fetched), 4, 'the good picture, then the next one')
async def test_an_alert_gets_a_snapshot_of_its_own_moment(self):
first, second = picture('JPEG', (640, 360)), picture('PNG', (640, 360))
feed, clock = self.feed([first, second])
tag_first, _ = await feed.frame('camera.front_door', (392, 220), fresh=False)
await self.settle()
self.assertEqual(len(self.fetched), 1)
clock.now += 20
# The bell rings 20 s later: the kept snapshot is not the moment, a new fetch is.
tag_alert, _ = await feed.frame('camera.front_door', (392, 220), fresh=False, now=True)
await self.settle()
self.assertEqual(len(self.fetched), 2)
self.assertNotEqual(tag_alert, tag_first)
@@ -236,6 +251,7 @@ class Feed(unittest.IsolatedAsyncioTestCase):
self.assertIn(tokens[-1], feed.links)
self.assertTrue(all(re.fullmatch(r'[A-Za-z0-9_-]{24}', token) for token in tokens))
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
async def test_the_http_port(self):
from aiohttp.test_utils import TestClient, TestServer
feed, _ = self.feed([picture('JPEG', (1920, 1080))])
+8 -3
View File
@@ -27,8 +27,9 @@ int main() {
pager.begin(2000); assert(pager.accept_repeat(2080, 12));
pager.begin(2250); assert(pager.accept_repeat(2330, 12)); // 250 ms after the last
pager.begin(2500); assert(pager.accept_repeat(2580, 12)); // and again
pager.begin(2600); assert(!pager.accept(2680, 12)); // the old rule: bounce window
pager.begin(2700); assert(pager.accept_repeat(2780, 11)); // Previous right after Next
pager.begin(2800); assert(!pager.accept(2880, 12)); // 300 ms after the last Next: the old
assert(pager.accept_repeat(2880, 12)); // rule's 600 ms window drops it, this takes it
pager.begin(2950); assert(pager.accept_repeat(3030, 11)); // Previous right after Next
cyd::TouchGuard g;
g.begin(100);
assert(!g.accept(120, 1)); // resistive noise pulse
@@ -84,7 +85,8 @@ int main() {
wide.begin(3200, 200, 200, 3);
assert(!wide.accept(3260, 2)); // same tile within 600 ms
assert(wide.reason() == "same button within the debounce window");
// No movement limit (0): a capacitive board leaves the drift decision to LVGL's press-lost.
// No movement limit (0, the Guition): drift never drops a tap. The tile checks that the finger let go on it
// (runtime_tiles::event, firmware 0.2.65+), and a quick flick is LVGL's gesture, which consumes the contact.
cyd::TouchGuard free;
free.configure(0, 20);
free.begin(100, 200, 200, 3);
@@ -96,6 +98,9 @@ int main() {
free.update(205, 200, 3); free.update(205, 200, 3); free.update(205, 200, 3);
free.update(400, 200, 3);
assert(free.accept_repeat(1060, 7)); // -/+ keys follow the same rule
free.begin(2000, 200, 200, 3);
free.consume(); // a flick (the Guition's gesture handler)
assert(!free.accept(2100, 1) && !free.accept_repeat(2100, 7) && !free.accept_slider(2100, 201));
cyd::TouchGuard resistive;
resistive.configure(56, 60);
resistive.begin(100, 50, 50);
+79 -4
View File
@@ -1,9 +1,10 @@
"""Live values on the mockup, Identify, the test alert and the changelog for the Update badge (app 0.2.73).
The editor asks /api/states for the tiles it shows and draws Home Assistant's values on the mockup; Identify and
Try it call a screen's own show_alert action, with the same field rules as an alert event; the update summary
Try it call a screen's own show_alert action, with the same field rules as an alert event; the full inventory
carries the CHANGELOG sections so the badge can say what a screen gets.
"""
import contextlib
import importlib.util
import sys
import tempfile
@@ -37,6 +38,58 @@ class Changelog(unittest.TestCase):
self.assertEqual(sections[0]['lines'], ['One workspace. Sidebar, pages, library with code.', 'Second line.'])
self.assertEqual(changelog.parse(SAMPLE, limit=1)[0]['app'], '0.2.74')
def test_italics_go_and_lone_asterisks_and_code_stay(self):
# What's new showed "*Full page*" (app 0.2.78).
self.assertEqual(changelog.plain('A size can be *Full page*: (*26.0 °C*, *Off*).'), 'A size can be Full page: (26.0 °C, Off).')
self.assertEqual(changelog.plain('5 * 3 * 2, a*b*c, snake_case_name, camera.* or image.*'), '5 * 3 * 2, a*b*c, snake_case_name, camera.* or image.*')
self.assertEqual(changelog.plain('**Bold** with `code *x*` and [a *link*](docs/X.md)'), 'Bold with code *x* and a link')
def test_other_headings_star_bullets_and_wrapped_lines(self):
text = '''# Changelog
## 0.2.80 (firmware 0.2.66)
Intro, not a bullet.
- **First.** It goes on
on an indented line
and on a line right under it.
* A star bullet.
- Third.
A second paragraph of the third one.
A paragraph after the list, not a bullet.
### Details
- Under a smaller heading, still this release.
## Unreleased
- Not in any release.
## 0.2.79 (firmware 0.2.65)
- Older.
'''
sections = changelog.parse(text)
self.assertEqual([s['app'] for s in sections], ['0.2.80', '0.2.79'])
self.assertEqual(sections[0]['lines'], ['First. It goes on on an indented line and on a line right under it.', 'A star bullet.',
'Third. A second paragraph of the third one.', 'Under a smaller heading, still this release.'])
self.assertEqual(sections[1]['lines'], ['Older.'], 'a bullet under an unknown heading belongs to no release')
def test_a_changelog_that_cant_be_read_leaves_the_notes_empty(self):
# Updater() reads it when the app starts; a broken file must never stop the app (app 0.2.78).
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp) / 'CHANGELOG.md'
folder.mkdir()
latin = Path(tmp) / 'latin.md'
latin.write_bytes('## 0.2.80 (firmware 0.2.66)\n\n- caf\xe9\n'.encode('latin-1'))
for path in (folder, latin, Path(tmp) / 'missing.md'):
with self.assertLogs('screen_manager', 'WARNING') if path.exists() else contextlib.nullcontext():
self.assertEqual(changelog.load(path), [], path)
def test_the_real_changelog_is_found_and_shipped(self):
sections = changelog.load()
self.assertTrue(sections and sections[0]['lines'], 'CHANGELOG.md one folder up from the app')
@@ -100,11 +153,33 @@ class Endpoints(unittest.IsolatedAsyncioTestCase):
bad = await self.client.post('/api/alerts/test', headers=self.headers, json={'screen': 'text.nope'})
self.assertEqual(bad.status, 400)
async def test_the_update_summary_carries_the_changelog(self):
inventory = await (await self.client.get('/api/inventory?light=1')).json()
sections = inventory['updates']['changelog']
async def test_home_assistant_saying_no_or_not_answering_is_a_sentence_not_a_500(self):
# Identify and Try it answered a bare 500 with a traceback in the log (app 0.2.78).
from server import Refused
cases = ((Refused('Action esphome.office_1_show_alert not found'), 400,
"Home Assistant didn't take it: Action esphome.office_1_show_alert not found."),
(Refused(''), 400, "Home Assistant didn't take it: no reason given."),
(ConnectionError("Home Assistant isn't connected."), 503, "Home Assistant isn't reachable right now. Try again in a moment."),
(TimeoutError(), 503, "Home Assistant isn't reachable right now. Try again in a moment."))
for error, status, sentence in cases:
async def call(action, data, error=error):
raise error
self.ha.call = call
for response in (await self.client.post(f"/api/screens/{self.screen['id']}/identify", headers=self.headers),
await self.client.post('/api/alerts/test', headers=self.headers,
json={'screen': self.screen['id'], 'data': {'title': 'Door'}})):
self.assertEqual((response.status, await response.json()), (status, {'error': sentence}), repr(error))
async def test_the_full_inventory_carries_the_changelog(self):
# Only the full inventory (app 0.2.78): the live payload goes out every few seconds and needs no notes.
inventory = await (await self.client.get('/api/inventory')).json()
sections = inventory['changelog']
self.assertTrue(sections)
self.assertEqual(set(sections[0]), {'app', 'firmware', 'lines'})
self.assertNotIn('changelog', inventory['updates'])
light = await (await self.client.get('/api/inventory?light=1')).json()
self.assertNotIn('changelog', light)
self.assertNotIn('changelog', light['updates'])
class Editor(unittest.TestCase):
+5 -3
View File
@@ -5,10 +5,12 @@ import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'screen_manager/app'))
sys.path.insert(0,str(Path(__file__).resolve().parent))
from server import KEEPALIVE_SECONDS
from core import validate_layout, state_message, packets
from firmware import Firmware
from test_portal import ManagerTests
# The module, not the class: a TestCase imported by name runs its tests a second time in this module.
import test_portal
class ExtendedTests(unittest.TestCase):
def test_options_validation_and_new_domains(self):
@@ -53,7 +55,7 @@ class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
except asyncio.CancelledError:pass
async def test_old_browser_preserves_new_tile_options(self):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]})
m.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','name':'Lamp'}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})
@@ -92,7 +94,7 @@ class SpecialTiles(unittest.TestCase):
class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
async def test_new_domains_need_firmware_and_send_extras(self):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Home','tiles':[{'entity':'screen.clock','name':''},{'entity':'sun.sun','name':''}]}
m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Sun','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}}
with self.assertRaises(ValueError):m.save('text.screen',layout)
+1
View File
@@ -8,6 +8,7 @@ import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from firmware import Firmware # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
+4 -1
View File
@@ -100,8 +100,11 @@ class Layouts(unittest.TestCase):
for entity in ('screen.page_9', 'screen.page', 'screen.page_0'):
with self.assertRaisesRegex(ValueError, "isn't supported"):
validate_layout({'title': 'Home', 'tiles': [tile(entity, 0)]})
# The same navigation tile on several pages is firmware 0.2.65's (app 0.2.78); any other entity stays once.
twice = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0), tile('screen.page_2', 7)]})
self.assertEqual(min_firmware(twice), (0, 2, 65))
with self.assertRaisesRegex(ValueError, 'only appear once'):
validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0), tile('screen.page_2', 1)]})
validate_layout({'title': 'Home', 'tiles': [tile('light.a', 0), tile('light.a', 1)]})
def test_snapshot_and_wire(self):
layout = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_3', 0, icon='radiator'), tile('light.a', 6, size='full')]})
+133
View File
@@ -0,0 +1,133 @@
"""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()
+5 -1
View File
@@ -8,6 +8,7 @@ import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from firmware import Firmware # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
@@ -149,9 +150,12 @@ class OverrideRoutes(unittest.IsolatedAsyncioTestCase):
response = await client.put(url, json={'content': 'api:\n reboot_timeout: 0s\n'}, headers=headers)
self.assertEqual(response.status, 400)
self.assertIn('api', (await response.json())['error'])
# The override keeps its own 12 KB limit; the request limit is far above it (app 0.2.78).
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 4000}, headers=headers)
self.assertEqual(response.status, 413)
self.assertEqual(response.status, 400)
self.assertIn('12 KB', (await response.json())['error'])
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 40000}, headers=headers)
self.assertEqual((response.status, (await response.json())['error']), (413, 'That request is too large.'))
self.assertEqual((await (await client.get(url)).json())['content'], 'display:\n - id: !extend my_display\n model: ST7789V\n')
self.assertEqual((await (await client.get('/api/firmware')).json())['profiles'], [{'file': 'kitchen.yaml'}])
+46 -6
View File
@@ -6,6 +6,7 @@ new version is always fetched and an old one is never combined with a new page.
"""
import importlib.util
import re
import sys
import tempfile
import unittest
from pathlib import Path
@@ -13,6 +14,12 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / 'screen_manager/app/static'
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import test_scaling
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
class Build(unittest.TestCase):
@@ -30,12 +37,6 @@ class Build(unittest.TestCase):
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Assets(unittest.IsolatedAsyncioTestCase):
async def test_the_server_serves_the_page_and_its_hashed_assets(self):
import sys
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import test_scaling
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
page = (STATIC / 'index.html').read_text()
script = re.search(r'src="\./(assets/index-[\w-]+\.js)"', page)[1]
with tempfile.TemporaryDirectory() as tmp:
@@ -50,6 +51,45 @@ class Assets(unittest.IsolatedAsyncioTestCase):
self.assertEqual((asset.status, await asset.read()), (200, (STATIC / script).read_bytes()))
self.assertEqual((await client.get('/static/app.js')).status, 404)
async def test_hashed_assets_are_kept_and_the_page_and_api_never(self):
# A hashed file never changes under its name: the browser keeps it instead of fetching ~310 KB on every open
# (app 0.2.78). `private`, as the request behind ingress carries Home Assistant's session.
page = (STATIC / 'index.html').read_text()
names = re.findall(r'(?:src|href)="\./(assets/[^"]+)"', page)
self.assertTrue(any(name.endswith('.js') for name in names) and any(name.endswith('.css') for name in names), names)
with tempfile.TemporaryDirectory() as tmp:
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
async with TestClient(TestServer(create_app(manager, True))) as client:
for name in names:
asset = await client.get(f'/{name}')
self.assertEqual((asset.status, asset.headers['Cache-Control']), (200, 'private, max-age=31536000, immutable'), name)
again = await client.get(f'/{name}', headers={'If-None-Match': asset.headers['ETag']})
self.assertEqual((again.status, again.headers['Cache-Control']), (304, 'private, max-age=31536000, immutable'), name)
missing = await client.get('/assets/index-nothere.js')
self.assertEqual(missing.status, 404)
self.assertNotIn('immutable', missing.headers.get('Cache-Control', ''))
for path in ('/', '/api/inventory', '/api/inventory?light=1'):
self.assertEqual((await client.get(path)).headers['Cache-Control'], 'no-store', path)
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Guard(unittest.IsolatedAsyncioTestCase):
async def test_only_ingress_reaches_the_app_and_a_change_needs_the_page_token(self):
with tempfile.TemporaryDirectory() as tmp:
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
# Production only answers Home Assistant's ingress proxy (172.30.32.2), not a request from anywhere else.
async with TestClient(TestServer(create_app(manager))) as client:
for path in ('/', '/api/inventory', '/assets/x.js'):
response = await client.get(path)
self.assertEqual((response.status, await response.text()), (403, 'Open this page through Home Assistant.'), path)
# The development server answers localhost, and a change still needs the token the page got.
async with TestClient(TestServer(create_app(manager, True))) as client:
self.assertEqual((await client.get('/api/inventory')).status, 200)
for headers in ({}, {'X-Screen-CSRF': 'guessed'}):
response = await client.put('/api/updates', json={'auto': True}, headers=headers)
self.assertEqual((response.status, await response.text()), (403, 'Refresh this page and try again.'))
self.assertFalse(manager.updates.auto)
if __name__ == '__main__':
unittest.main()
+195
View File
@@ -0,0 +1,195 @@
"""The same navigation tile on several pages (app 0.2.78, firmware 0.2.65): a "Back to page 1" on every page.
Every other entity is on a screen once. A layout with copies needs firmware 0.2.65, so an older screen gets the usual
"Install screen firmware" refusal. A tile event from Claude in Home Assistant names the copy it means by its spot or its
page (`from_slot`/`from_page` for a move) and acts on the first copy, in spot order, when it doesn't; the answer says
which tile it was, and the layout sensor lists every copy.
"""
import asyncio
import importlib.util
import json
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'))
from core import (PAGE_TILE_REPEAT_MIN_FIRMWARE, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, min_firmware, # noqa: E402
run_tile_event, validate_layout)
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
import test_tile_events
from server import Manager
BACK = 'screen.page_1'
def tile(entity, slot, **options):
item = {'entity': entity, 'name': '', 'slot': slot}
if options:
item['options'] = options
return item
def layout(*tiles):
return {'title': 'Hall', 'tiles': [dict(t) for t in tiles]}
def places(result):
return [(t['entity'], t['slot']) for t in sorted(result['tiles'], key=lambda t: t['slot'])]
# A light on page 1, and the way back on pages 2 and 3.
MENU = layout(tile('light.a', 0), tile(BACK, 6, icon='home'), tile('light.b', 7), tile(BACK, 12))
class Layouts(unittest.TestCase):
def test_copies_of_a_navigation_tile_need_firmware_0_2_65(self):
result = validate_layout(MENU)
self.assertEqual(places(result), [('light.a', 0), (BACK, 6), ('light.b', 7), (BACK, 12)])
self.assertEqual(PAGE_TILE_REPEAT_MIN_FIRMWARE, (0, 2, 65))
self.assertEqual(min_firmware(result), (0, 2, 65))
self.assertEqual(min_firmware(validate_layout(layout(tile('light.a', 0), tile(BACK, 6)))), (0, 2, 62), 'one copy: as before')
# Two on one page is odd but harmless, and what the editor allows too.
validate_layout(layout(tile(BACK, 0), tile(BACK, 1)))
for entity in ('light.a', 'screen.clock', 'screen.settings'):
with self.assertRaisesRegex(ValueError, 'only appear once'):
validate_layout(layout(tile(entity, 0), tile(entity, 6)))
def test_the_sensor_lists_every_copy(self):
snapshot = layout_snapshot({'name': 'Hall', 'node': 'hall'}, validate_layout(MENU))
self.assertEqual([(t['entity'], t['page'], t['slot'], t.get('to_page')) for t in snapshot['tiles']],
[('light.a', 1, 0, None), (BACK, 2, 6, 1), ('light.b', 2, 7, None), (BACK, 3, 12, 1)])
class Events(unittest.TestCase):
def test_add_puts_a_copy_on_a_page_without_one_and_changes_the_one_that_is_there(self):
result, placed = run_tile_event(MENU, 'add', {'entity': BACK, 'page': 4, 'name': 'Back'}, repeat_pages=True)
self.assertEqual(places(result)[-1], (BACK, 18))
self.assertEqual((placed['slot'], placed['name']), (18, 'Back'))
# Named by the page it is on: that copy changes and stays where it is.
result, changed = run_tile_event(MENU, 'add', {'entity': BACK, 'page': 3, 'color': 'blue'}, repeat_pages=True)
self.assertEqual(places(result), places(MENU))
self.assertEqual((changed['slot'], changed['options']), (12, {'background': 'blue'}))
self.assertEqual(next(t for t in result['tiles'] if t['slot'] == 6)['options'], {'icon': 'home'}, 'the other copy is untouched')
# Named by nothing: the first copy.
result, changed = run_tile_event(MENU, 'add', {'entity': BACK, 'name': 'Home'}, repeat_pages=True)
self.assertEqual((changed['slot'], len(result['tiles'])), (6, 4))
def test_older_firmware_keeps_one_and_adding_it_again_moves_it(self):
start = layout(tile('light.a', 0), tile(BACK, 6))
result, moved = run_tile_event(start, 'add', {'entity': BACK, 'page': 3})
self.assertEqual(places(result), [('light.a', 0), (BACK, 12)])
self.assertEqual(moved['slot'], 12)
self.assertEqual(places(apply_tile_event(start, 'add', {'entity': BACK, 'page': 3})), places(result), 'the old call is the same')
def test_remove_takes_the_copy_named_or_the_first(self):
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK})), [('light.a', 0), ('light.b', 7), (BACK, 12)])
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK, 'page': 3})), [('light.a', 0), (BACK, 6), ('light.b', 7)])
result, removed = run_tile_event(MENU, 'remove', {'entity': BACK, 'slot': 12})
self.assertEqual((removed['slot'], len(result['tiles'])), (12, 3))
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK, 'from_slot': 12})), places(result))
with self.assertRaisesRegex(ValueError, 'screen.page_1 is not on page 4'):
apply_tile_event(MENU, 'remove', {'entity': BACK, 'page': 4})
with self.assertRaisesRegex(ValueError, 'screen.page_1 is not on spot 7'):
apply_tile_event(MENU, 'remove', {'entity': BACK, 'slot': 7})
def test_move_takes_the_copy_named_by_from_page_or_from_slot_or_the_first(self):
self.assertEqual(places(apply_tile_event(MENU, 'move', {'entity': BACK, 'page': 5})),
[('light.a', 0), ('light.b', 7), (BACK, 12), (BACK, 24)])
self.assertEqual(places(apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 3, 'page': 5})),
[('light.a', 0), (BACK, 6), ('light.b', 7), (BACK, 24)])
result, moved = run_tile_event(MENU, 'move', {'entity': BACK, 'from_slot': 12, 'slot': 13})
self.assertEqual((moved['slot'], places(result)[-1]), (13, (BACK, 13)))
with self.assertRaisesRegex(ValueError, 'is not on page 1'):
apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 1, 'page': 5})
with self.assertRaisesRegex(ValueError, 'page between 1 and 8'):
apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 9, 'page': 5})
def test_order_takes_the_next_copy_each_time_it_is_named(self):
# The whole screen: named once, the first copy leads and the other keeps its place in the order.
self.assertEqual(places(apply_tile_event(MENU, 'order', {'entities': [BACK]})),
[(BACK, 0), ('light.a', 1), ('light.b', 2), (BACK, 3)])
self.assertEqual(places(apply_tile_event(MENU, 'order', {'entities': [BACK, 'light.b', BACK]})),
[(BACK, 0), ('light.b', 1), (BACK, 2), ('light.a', 3)])
with self.assertRaisesRegex(ValueError, 'Named more often than it is on this screen: screen.page_1'):
apply_tile_event(MENU, 'order', {'entities': [BACK, BACK, BACK]})
# One page: only the copy on that page.
self.assertEqual(places(apply_tile_event(MENU, 'order', {'page': 2, 'entities': ['light.b', BACK]})),
[('light.a', 0), ('light.b', 6), (BACK, 7), (BACK, 12)])
with self.assertRaisesRegex(ValueError, 'Named more often than it is on page 2'):
apply_tile_event(MENU, 'order', {'page': 2, 'entities': [BACK, BACK]})
self.assertIsNone(run_tile_event(MENU, 'order', {'entities': [BACK]})[1], 'an order acts on no single tile')
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Through(unittest.IsolatedAsyncioTestCase):
def manager(self, tmp, firmware):
ha = test_tile_events.fake_ha()
ha.states['sensor.d1_fw']['state'] = firmware
ha.registry.append({'entity_id': 'light.a', 'platform': 'demo', 'original_name': 'A', 'device_id': 'd9'})
ha.states['light.a'] = {'state': 'on', 'attributes': {'friendly_name': 'A'}}
return Manager(ha, Path(tmp) / 'screens.json')
async def events(self, m, *events):
for data in events:
m.ha.tile_events.put_nowait(('esp_screens_add_tile', data))
worker = asyncio.create_task(m.tile_loop())
for _ in range(300):
await asyncio.sleep(0.01)
if len(m.ha.fired) == len(events):
break
worker.cancel()
return [answer for _, answer in m.ha.fired]
async def test_firmware_0_2_65_takes_a_copy_on_every_page_and_the_answer_says_where(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, '0.2.65')
m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [tile('light.a', 0), tile('light.reading', 6)]})
answers = await self.events(m, {'entity': BACK, 'page': 2, 'name': 'Back'}, {'entity': BACK, 'page': 3, 'name': 'Back'})
self.assertEqual([(a['ok'], a['page'], a['slot']) for a in answers], [(True, 2, 7), (True, 3, 12)], answers)
self.assertEqual(answers[0]['event'], 'esp_screens_add_tile')
saved = json.loads(m.path.read_text())['screens']['text.d1_tiles']['tiles']
self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.a', 0), ('light.reading', 6), (BACK, 7), (BACK, 12)])
_, attributes = m.ha.published['sensor.esp_screens_living_room']
self.assertEqual([t['slot'] for t in attributes['tiles'] if t['entity'] == BACK], [7, 12])
self.assertTrue(all(fired == TILE_RESULT_EVENT for fired, _ in m.ha.fired))
async def test_older_firmware_moves_the_one_it_has_and_refuses_copies_from_the_editor(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, '0.2.63')
m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [tile('light.a', 0), tile(BACK, 6)]})
answers = await self.events(m, {'entity': BACK, 'page': 3})
self.assertEqual((answers[0]['ok'], answers[0]['page'], answers[0]['slot']), (True, 3, 12), answers)
self.assertEqual([t['slot'] for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [12])
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.65 or newer first'):
m.save('text.d1_tiles', MENU)
async def test_each_copy_keeps_its_own_settings_when_saved(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, '0.2.65')
m.ha.registry.append({'entity_id': 'light.b', 'platform': 'demo', 'original_name': 'B', 'device_id': 'd9'})
m.ha.states['light.b'] = {'state': 'on', 'attributes': {'friendly_name': 'B'}}
m.save('text.d1_tiles', layout(tile('light.a', 0), tile(BACK, 6), tile('light.b', 7), tile(BACK, 12, icon='home')))
# Both copies sent without options keep none: a page from before an option existed gets its old options back,
# but such a page never sends copies, and one copy must never take another's icon.
m.save('text.d1_tiles', layout(tile('light.a', 0), tile(BACK, 6), tile('light.b', 7), tile(BACK, 12)))
self.assertEqual([t.get('options') for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [None, None])
m.save('text.d1_tiles', MENU)
self.assertEqual([t.get('options') for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [{'icon': 'home'}, None])
m.ha.messages = []
async def send(inbox, message, action=None, respond=False):
m.ha.messages.append(message)
m.ha.send = send
await m.sync_one('text.d1_tiles', m.layouts['text.d1_tiles'])
self.assertEqual(m.ha.messages[0]['entities'], ['light.a', BACK, 'light.b', BACK])
self.assertEqual(m.ha.messages[0]['slots'], [0, 6, 7, 12])
self.assertEqual([msg['i'] for msg in m.ha.messages if msg['op'] == 'state' and msg['entity'] == BACK], [1, 3])
if __name__ == '__main__':
unittest.main()
+2
View File
@@ -3,6 +3,8 @@ import json
import tempfile
import unittest
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_portal
from core import MAX_SLOTS, has_gaps, pack_slots, validate_layout
+71
View File
@@ -0,0 +1,71 @@
"""Release lint (app 0.2.78): the version numbers of a release agree, and the packages fetch only fonts that exist.
Every push to main is a release (docs/RELEASING.md), so these run with the rest of the suite in tools/check.sh and CI.
Standard library only.
"""
from pathlib import Path
import re
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import FIRMWARE_VERSION # noqa: E402
CHANGELOG = ROOT / 'screen_manager/CHANGELOG.md'
# An app release: "## 0.2.76 (firmware 0.2.63)"; the oldest ones name no firmware. The two "## Firmware 0.2.1x"
# sections are firmware-only releases between app 0.2.11 and 0.2.12 and carry no app version, so they don't count.
APP_HEADING = re.compile(r'^## (\d+)\.(\d+)\.(\d+)\b(.*)$', re.M)
FIRMWARE_IN_HEADING = re.compile(r'\(firmware (\d+\.\d+\.\d+)\)')
# The generator (tools/generate_packages.py) turns fonts/<file> into a raw GitHub URL on main.
FONT_URL = re.compile(r'https://raw\.githubusercontent\.com/MaxGramser/homeassistant_espscreen/[^/\s"\']+/(fonts/[^"\'\s]+)')
def app_headings():
"""[(version tuple, text after the version)] of the CHANGELOG, top to bottom."""
return [((int(a), int(b), int(c)), rest) for a, b, c, rest in APP_HEADING.findall(CHANGELOG.read_text())]
def dotted(version):
return '.'.join(map(str, version))
class ReleaseVersionTests(unittest.TestCase):
def test_config_version_is_the_newest_changelog_entry(self):
match = re.search(r'^version:\s*["\']?([^"\'\s]+)["\']?\s*$', (ROOT / 'screen_manager/config.yaml').read_text(), re.M)
self.assertIsNotNone(match, 'screen_manager/config.yaml has no version line')
headings = app_headings()
self.assertTrue(headings, 'screen_manager/CHANGELOG.md has no "## x.y.z" heading')
self.assertEqual(match.group(1), dotted(headings[0][0]),
'Home Assistant offers the update by config.yaml: bump it together with a new CHANGELOG entry')
def test_newest_entry_names_the_shipped_firmware(self):
version, rest = app_headings()[0]
firmware = FIRMWARE_IN_HEADING.search(rest)
if firmware:
self.assertEqual(firmware.group(1), FIRMWARE_VERSION,
f'CHANGELOG {dotted(version)} names firmware {firmware.group(1)}, core.FIRMWARE_VERSION is {FIRMWARE_VERSION}')
def test_changelog_versions_are_unique_and_newest_first(self):
versions = [version for version, _ in app_headings()]
duplicates = sorted({dotted(v) for v in versions if versions.count(v) > 1})
self.assertEqual(duplicates, [], 'a CHANGELOG version appears twice')
for newer, older in zip(versions, versions[1:]):
self.assertGreater(newer, older, f'CHANGELOG {dotted(newer)} stands above {dotted(older)}')
class PackageFontTests(unittest.TestCase):
def test_every_font_a_package_fetches_is_in_the_tree(self):
packages = sorted((ROOT / 'packages').glob('*.yaml'))
self.assertTrue(packages, 'no packages/*.yaml')
seen = 0
for package in packages:
for path in FONT_URL.findall(package.read_text()):
seen += 1
self.assertTrue((ROOT / path).is_file(), f'{package.name} fetches {path}, which is not in the tree')
# A changed URL form must not turn this into a test of nothing.
self.assertGreater(seen, 0, 'no fonts/... URL found in packages/*.yaml')
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
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 extras, packets, state_message
import test_portal
+2
View File
@@ -1,6 +1,8 @@
import tempfile
import unittest
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_portal
from core import validate_settings
+65
View File
@@ -186,3 +186,68 @@ static void test_slider_colours() {
setpoint.state = "55.0"; assert(setpoint.slider_active());
}
struct RunSliderColours { RunSliderColours() { test_slider_colours(); } } run_slider_colours;
// A navigation tile may sit on several pages of one screen (firmware 0.2.65+); a Home Assistant entity still appears once.
static void test_repeated_page_tiles() {
using namespace runtime_tiles;
Model m; bool changed = false, moved = false;
std::array<Placement, MAX_TILES> p;
assert(m.set_layout({"light.x", "screen.page_1", "screen.page_1", "sensor.y"}, "Home", changed, {0, 6, 12, 13}, moved));
assert(changed && m.count == 4 && place(m, p) == 3 && p[1].page == 1 && p[2].page == 2);
assert(m.tiles[1].is_page() && m.tiles[2].is_page() && m.tiles[1].page_target() == 1 && m.tiles[2].page_target() == 1);
// Each copy is its own tile: a state message reaches it by its index, so both get their options.
assert(m.accepts(1, "screen.page_1") && m.accepts(2, "screen.page_1") && !m.accepts(3, "screen.page_1"));
m.tiles[1].icon = "a"; m.tiles[2].icon = "b";
assert(m.tiles[1].icon != m.tiles[2].icon);
for (size_t i = 0; i < m.count; ++i) m.tiles[i].received = true;
assert(m.ready());
assert(m.set_layout({"light.x", "screen.page_1", "screen.page_1", "sensor.y"}, "Home", changed, {0, 6, 12, 13}, moved));
assert(!changed && !moved && m.ready()); // the repeat of the same layout keeps everything
assert(m.set_layout({"screen.page_2", "screen.page_2", "screen.page_2"}, "Menu", changed));
// Every Home Assistant entity and the other built-in cards stay unique.
assert(!m.set_layout({"light.x", "screen.page_1", "light.x"}, "Home", changed, {0, 6, 12}, moved));
assert(!m.set_layout({"screen.clock", "screen.clock"}, "Home", changed));
assert(!m.set_layout({"screen.settings", "screen.page_1", "screen.settings"}, "Home", changed));
assert(!m.set_layout({"screen.page_9", "screen.page_9"}, "Home", changed));
assert(m.count == 3 && m.title == "Menu"); // a refused layout leaves the one on screen alone
}
struct RunRepeatedPages { RunRepeatedPages() { test_repeated_page_tiles(); } } run_repeated_pages;
// A longer list needs one block for every tile (firmware 0.2.65+): without it the layout is refused before anything
// changes, with a reason for the manager, instead of std::vector writing tiles through a null pointer on the ESP32.
static size_t test_room = 0;
static void test_tile_room() {
using namespace runtime_tiles;
assert(!tile_room); // the host has room and asks nothing
Model m; bool changed = false;
std::vector<std::string> twelve, forty_eight;
for (int i = 0; i < 48; ++i) (i < 12 ? twelve : forty_eight).push_back("light.r" + std::to_string(i));
for (int i = 0; i < 12; ++i) forty_eight.push_back("light.r" + std::to_string(i));
assert(m.set_layout(twelve, "Twelve", changed) && m.count == 12 && m.refusal.empty());
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "on"; }
tile_room = [] { return test_room; };
test_room = 48 * sizeof(Tile) - 1;
assert(!m.set_layout(forty_eight, "Forty-eight", changed));
assert(m.refusal == "Error: no memory for 48 tiles");
assert(m.configured && m.count == 12 && m.title == "Twelve" && m.ready() && m.tiles[0].state == "on");
// Fewer tiles than the list already holds need no new block, and a malformed layout is no memory question.
assert(m.set_layout({"light.r0", "light.r1"}, "Two", changed) && changed && m.count == 2 && m.refusal.empty());
assert(!m.set_layout({"light.r0", "light.r0"}, "Twice", changed) && m.refusal.empty());
test_room = 48 * sizeof(Tile);
assert(m.set_layout(forty_eight, "Forty-eight", changed) && m.count == 48 && m.refusal.empty());
// An empty screen asks for its first tiles the same way.
Model fresh;
test_room = 0;
assert(!fresh.set_layout({"light.a"}, "One", changed) && !fresh.configured && fresh.refusal == "Error: no memory for 1 tiles");
assert(fresh.set_layout({}, "None", changed) && fresh.configured);
tile_room = nullptr;
}
struct RunTileRoom { RunTileRoom() { test_tile_room(); } } run_tile_room;
// One redraw bit for each of the 48 tiles (firmware 0.2.65+); 32 bits sent tiles 33-48 through a full redraw.
static void test_tile_bits() {
using namespace runtime_tiles;
assert(tile_bit(0) == 1 && tile_bit(31) == (uint64_t{1} << 31) && tile_bit(47) == (uint64_t{1} << 47));
assert(tile_bit(MAX_TILES - 1) && !tile_bit(64) && !tile_bit(MAX_TILES + 100));
uint64_t dirty = tile_bit(33) | tile_bit(47);
assert((dirty & tile_bit(33)) && (dirty & tile_bit(47)) && !(dirty & tile_bit(1)) && !(dirty & tile_bit(32)));
for (size_t a = 0; a < MAX_TILES; ++a) for (size_t b = a + 1; b < MAX_TILES; ++b) assert(!(tile_bit(a) & tile_bit(b)));
}
struct RunTileBits { RunTileBits() { test_tile_bits(); } } run_tile_bits;
+150
View File
@@ -0,0 +1,150 @@
"""What a layout of forty-eight tiles runs into, refused when it is saved instead of later (app 0.2.78).
- The screen takes the layout in one message of at most 4096 bytes that lists every entity id: 48 tiles with long ids
passed Save and then never reached the screen.
- Tiles without positions are packed in order: 48 with one double-width, or 43 with a full-page one that isn't first,
ran past the eighth page and failed later with "Invalid tile position".
- The request limit was 16 KB from the twenty-tile days, and a valid 48-tile layout with tap actions is larger.
- The layout sensor keeps under the 16 KB Home Assistant's recorder stores of a state's attributes.
"""
import importlib.util
import json
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'))
from core import (ACTION_MAX_BYTES, MAX_TILES, apply_tile_event, encode, layout_snapshot, pack_slots, packed_slots, # noqa: E402
validate_layout, validate_tap_action, action_for_screen)
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
TOO_LONG = "These tiles' entity IDs are too long together to fit in one message to the screen; remove a few tiles."
NO_ROOM = "These tiles don't fit on eight pages; remove a tile or make one smaller."
# Home Assistant's recorder keeps a state's attributes up to this many bytes of JSON.
RECORDER_ATTRIBUTES = 16384
def entity(index, length, domain='light'):
stem = f'{domain}.tile_{index:02d}_'
return stem + 'x' * (length - len(stem))
def tiles(count, length=12, **options):
return [{'entity': entity(i, length), 'name': '', **({'options': dict(options)} if options else {})} for i in range(count)]
class Packing(unittest.TestCase):
def test_tiles_that_run_past_the_last_page_are_refused_at_once(self):
wide = tiles(MAX_TILES)
wide[5]['options'] = {'size': 'wide'}
self.assertGreaterEqual(max(pack_slots(wide)), 48, 'what used to be stored')
with self.assertRaisesRegex(ValueError, NO_ROOM):
validate_layout({'title': 'Hall', 'tiles': wide})
full = tiles(43)
full[1]['options'] = {'size': 'full'}
with self.assertRaisesRegex(ValueError, NO_ROOM):
packed_slots(full)
with self.assertRaisesRegex(ValueError, NO_ROOM):
validate_layout({'title': 'Hall', 'tiles': full})
# The same tiles fit with the full page first, and 48 single tiles fill every spot.
first = [full[1]] + full[:1] + full[2:]
self.assertEqual(validate_layout({'title': 'Hall', 'tiles': first})['tiles'][-1]['slot'], 47)
self.assertEqual(packed_slots(tiles(MAX_TILES)), list(range(48)))
def test_ordering_the_whole_screen_says_so_too(self):
placed = [{**tile, 'slot': slot} for tile, slot in zip(tiles(43), [0] + list(range(6, 48)))]
placed[0]['options'] = {'size': 'full'}
layout = validate_layout({'title': 'Hall', 'tiles': placed})
with self.assertRaisesRegex(ValueError, NO_ROOM):
apply_tile_event(layout, 'order', {'entities': [entity(1, 12)]})
class Snapshot(unittest.TestCase):
def test_the_layout_sensor_stays_within_what_the_recorder_keeps(self):
action = {'action': 'light.turn_on', 'data': {'brightness_pct': 40, 'transition': 2, 'color_name': 'warm' + 'x' * 40}}
layout = validate_layout({'title': 'T' * 96, 'tiles': [
{'entity': entity(i, 60), 'name': 'N' * 30, 'slot': i,
'options': {'size': 'single', 'controls': 'none', 'display': 'standard', 'tap': 'action' if i % 12 == 0 else 'detail',
**({'action': action} if i % 12 == 0 else {})}} for i in range(MAX_TILES)]})
snapshot = layout_snapshot({'name': 'Living room screen', 'node': 'living-room-screen'}, layout)
# What publish_layouts hands Home Assistant.
attributes = {'friendly_name': 'Living room screen tiles', 'icon': 'mdi:view-dashboard-outline', **snapshot}
size = len(json.dumps(attributes, ensure_ascii=False, separators=(',', ':')).encode())
self.assertLess(size, RECORDER_ATTRIBUTES, size)
self.assertEqual(set(snapshot), {'screen', 'node', 'title', 'pages', 'tiles'})
documented = {'entity', 'name', 'page', 'row', 'column', 'slot', 'size', 'controls', 'display', 'tap', 'action', 'to_page'}
for item in snapshot['tiles']:
self.assertLessEqual(set(item), documented)
self.assertEqual(sum('action' in item for item in snapshot['tiles']), 4)
def largest_tap_action():
"""A tap action as large as validate_tap_action allows: eight fields, ACTION_MAX_BYTES on the wire."""
data = {f'field_{n}': '' for n in range(8)}
while True:
for key in data:
trial = {**data, key: data[key] + 'v'}
act = action_for_screen({'action': 'light.turn_on', 'data': trial})
if len(json.dumps(act, separators=(',', ':')).encode()) > ACTION_MAX_BYTES:
return validate_tap_action({'action': 'light.turn_on', 'data': data})
data = trial
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Saving(unittest.IsolatedAsyncioTestCase):
def manager(self, tmp, layout_tiles):
ha = test_scaling.fake_ha(firmware='0.2.63')
for tile in layout_tiles:
ha.registry.append({'entity_id': tile['entity'], 'platform': 'hue'})
ha.states[tile['entity']] = {'state': 'on', 'attributes': {}}
return Manager(ha, Path(tmp) / 'screens.json')
async def test_long_entity_ids_are_refused_when_saved(self):
long_ids, short_ids = tiles(MAX_TILES, 80), tiles(MAX_TILES)
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, long_ids + short_ids)
with self.assertRaisesRegex(ValueError, TOO_LONG):
m.save('text.screen', {'title': 'Hall', 'tiles': long_ids})
self.assertNotIn('text.screen', m.layouts)
m.save('text.screen', {'title': 'Hall', 'tiles': short_ids})
self.assertEqual(len(m.layouts['text.screen']['tiles']), 48)
self.assertTrue(await m.sync_one('text.screen', m.layouts['text.screen']))
# A layout an older app stored anyway gets a status, not a retry every pass.
stored = validate_layout({'title': 'Hall', 'tiles': long_ids})
m.layouts = {'text.screen': stored}
m.sent.clear()
self.assertFalse(await m.sync_one('text.screen', stored))
self.assertIn('too large for one message', m.status['text.screen'])
async def test_the_largest_valid_layout_is_accepted_and_a_huge_request_is_refused_plainly(self):
# 70-character ids are about the longest 48 tiles can have in one message.
action = largest_tap_action()
layout_tiles = [{'entity': entity(i, 70), 'name': 'é' * 40, 'slot': i,
'options': {'tap': 'action', 'action': action, 'background': 'purple', 'icon': 'lightbulb', 'size': 'single',
'inline': 'none', 'display': 'standard'}} for i in range(MAX_TILES)]
body = {'title': 'T' * 96, 'tiles': layout_tiles}
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, layout_tiles)
async with TestClient(TestServer(create_app(m, True))) as client:
headers = {'X-Screen-CSRF': (await (await client.get('/api/inventory?light=1')).json())['csrf']}
raw = json.dumps(body).encode()
self.assertGreater(len(raw), 48 * 1024, 'far past the old 16 KB')
response = await client.put('/api/screens/text.screen', data=raw, headers={**headers, 'Content-Type': 'application/json'})
self.assertEqual((response.status, await response.json()), (200, {'saved': True}))
self.assertEqual(len(m.layouts['text.screen']['tiles']), 48)
encode(m.layout_message('text.screen', m.layouts['text.screen'], m.screen('text.screen')))
huge = json.dumps({'title': 'Hall', 'tiles': [], 'filler': 'x' * (128 * 1024)}).encode()
response = await client.put('/api/screens/text.screen', data=huge, headers={**headers, 'Content-Type': 'application/json'})
self.assertEqual((response.status, await response.json()), (413, {'error': 'That request is too large.'}))
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -22,6 +22,7 @@ from unittest.mock import patch
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 (FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_ENTITIES_MIN_FIRMWARE, SETTING_RULES, forecast_kinds, # noqa: E402
setting_action, setting_entities, setting_from_state, validate_settings)
+1
View File
@@ -14,6 +14,7 @@ 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'))
import test_portal
from core import (BUILTIN, SETTING_RULES, SETTINGS_BESIDE_BLOCK, entity_id, min_firmware, validate_layout,
validate_settings)
+75
View File
@@ -0,0 +1,75 @@
"""Stopping the app (app 0.2.78).
`init: false` came from the first add-on, whose image was Home Assistant's s6 base with its own init. The ESPHome base
image has none, so python3 ran as process 1: it ignored SIGTERM (every stop waited ten seconds for SIGKILL) and never
reaped a killed build. The app now gets Home Assistant's default init (tini), Docker Compose `init: true`, and the
server ends on SIGTERM through its own cleanup, which also stops a running build.
"""
import importlib.util
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / 'screen_manager/app'
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
# The real server.py as `python3 server.py` runs it, with every port the OS's choice instead of 8099 and 8098.
LAUNCH = '''
import runpy, sys
from aiohttp import web
class Site(web.TCPSite):
def __init__(self, runner, host=None, port=None, **kwargs):
super().__init__(runner, '127.0.0.1', 0, **kwargs)
web.TCPSite = Site
sys.path.insert(0, sys.argv[1])
runpy.run_path(sys.argv[1] + '/server.py', run_name='__main__')
'''
class Configuration(unittest.TestCase):
def test_the_app_and_compose_run_an_init(self):
config = (ROOT / 'screen_manager/config.yaml').read_text()
self.assertNotRegex(config, r'(?m)^init:', "Home Assistant's default (init: true) gives the app tini as process 1")
self.assertRegex((ROOT / 'docker/compose.yaml').read_text(), r'(?m)^ init: true$')
self.assertIn('handle_signals=True', (APP / 'server.py').read_text())
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Signal(unittest.TestCase):
def test_sigterm_ends_the_app_at_once_and_cleanly(self):
with tempfile.TemporaryDirectory() as tmp:
token = Path(tmp) / 'token'
token.write_text('not-a-real-token\n')
env = {**os.environ, 'SCREEN_DEV': '1', 'HA_TOKEN_FILE': str(token), 'HA_API': 'http://127.0.0.1:9/api',
'SCREEN_DATA': tmp, 'ESPHOME_CONFIG': str(Path(tmp) / 'esphome'), 'HA_CONFIG': tmp, 'PYTHONUNBUFFERED': '1'}
process = subprocess.Popen([sys.executable, '-c', LAUNCH, str(APP)], env=env, cwd=tmp,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
try:
# Up once it tried Home Assistant (nothing listens on port 9 here).
deadline, lines = time.monotonic() + 20, []
while time.monotonic() < deadline:
line = process.stderr.readline()
lines.append(line)
if 'Home Assistant temporarily unavailable' in line or not line:
break
self.assertIn('Home Assistant temporarily unavailable', ''.join(lines), ''.join(lines))
started = time.monotonic()
process.send_signal(signal.SIGTERM)
code = process.wait(timeout=10)
self.assertEqual(code, 0, 'a normal stop, not killed by the signal')
self.assertLess(time.monotonic() - started, 5)
finally:
if process.poll() is None:
process.kill()
process.wait()
process.stderr.close()
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -8,6 +8,7 @@ import unittest
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 CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout
import tile_icons
import test_portal
+1
View File
@@ -6,6 +6,7 @@ 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'))
import test_portal
import tile_icons
from core import discover, packets, state_message, validate_layout
+2
View File
@@ -2,6 +2,8 @@ import re
import tempfile
import unittest
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_portal
from core import TILE_BACKGROUNDS, validate_layout
+2
View File
@@ -1,7 +1,9 @@
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_portal
from core import validate_layout, validate_settings, packets