Forty-eight tiles, a tile over the whole page and tiles that go to a page (0.2.74)
Max asked for many more tiles, a tile that takes the whole page as a light
switch you push without looking, and tiles that go to a page. The limit is
one tile per slot of the eight pages, 48, and the firmware keeps its tile
list on the heap sized to the layout (PSRAM on a Guition). A tile's size can
be `full`: one big button that lights up in the state colour while on, or the
double-width card's head with its small slider, direct controls or graph at
the bottom and the value the double-width card had no room for in between;
the clock, the forecast (with an hours strip) and the sun path fill the page.
Navigation tiles are the built-in entities screen.page_1 to screen.page_8:
an arrow or a chosen icon, the name and "Page n", a tap opens that page.
The app sends the three only to firmware 0.2.62+, the editor (the Vue port of
this round by the editor session, 00c9a4e) offers Normal / Double-width /
Full page and "Goes to page", and Claude in Home Assistant gets size: full and
the page tiles. Built-in tiles now send a chosen icon as its codepoint.
Found and fixed by the new host harness before release: the one-second tick
moved the analog clock's second hand (part 18) on a full forecast's hour label
during a page switch and corrupted it. Firmware 0.2.62; both C++ and Python
suites, host harness 37/37 on both boards, hardware pass on both bench boards
in docs/TEST_RESULTS_0274.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""Full-page tiles, navigation tiles and forty-eight tiles per screen (app 0.2.74, firmware 0.2.62)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
|
||||
from core import (BUILTIN, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_TILES, SLOTS_PER_PAGE, apply_tile_event, # noqa: E402
|
||||
footprint, free_slot, layout_snapshot, min_firmware, pack_slots, page_target, place_tile, resolve_controls,
|
||||
state_message, tile_size, validate_layout)
|
||||
import tile_icons # noqa: E402
|
||||
|
||||
|
||||
def tile(entity, slot=None, **options):
|
||||
item = {'entity': entity, 'name': ''}
|
||||
if slot is not None:
|
||||
item['slot'] = slot
|
||||
if options:
|
||||
item['options'] = options
|
||||
return item
|
||||
|
||||
|
||||
class Sizes(unittest.TestCase):
|
||||
def test_footprints(self):
|
||||
self.assertEqual(tile_size(tile('light.a')), 'single')
|
||||
self.assertEqual(tile_size(tile('light.a', size='wide')), 'wide')
|
||||
self.assertEqual(tile_size(tile('light.a', size='full')), 'full')
|
||||
self.assertEqual(tile_size(tile('light.a', size='huge')), 'single')
|
||||
self.assertEqual(footprint(3, 'single'), (3,))
|
||||
self.assertEqual(footprint(2, 'wide'), (2, 3))
|
||||
self.assertEqual(footprint(2, True), (2, 3))
|
||||
# A full tile takes its page whatever cell it is named by.
|
||||
self.assertEqual(footprint(9, 'full'), (6, 7, 8, 9, 10, 11))
|
||||
|
||||
def test_in_order_packing_gives_a_full_tile_its_own_page(self):
|
||||
tiles = [tile('light.a'), tile('light.b', size='full'), tile('light.c'), tile('light.d', size='wide')]
|
||||
self.assertEqual(pack_slots(tiles), [0, 6, 12, 14])
|
||||
tiles = [tile('light.b', size='full'), tile('light.c')]
|
||||
self.assertEqual(pack_slots(tiles), [0, 6])
|
||||
|
||||
def test_free_slot_for_a_full_tile_is_an_empty_page(self):
|
||||
tiles = [tile('light.a', 0), tile('light.b', 7)]
|
||||
self.assertEqual(free_slot(tiles, 'full'), 12)
|
||||
self.assertIsNone(free_slot(tiles, 'full', page=1))
|
||||
self.assertEqual(free_slot(tiles, 'full', page=2), 12)
|
||||
self.assertEqual(free_slot(tiles, 'wide', page=0), 2)
|
||||
|
||||
def test_place_tile_snaps_a_full_tile_to_its_page_start(self):
|
||||
tiles = [tile('light.a', 0)]
|
||||
full = tile('light.b', size='full')
|
||||
tiles.append(full)
|
||||
place_tile(full, tiles, slot=9)
|
||||
self.assertEqual(full['slot'], 6)
|
||||
with self.assertRaisesRegex(ValueError, 'taken by light.b'):
|
||||
place_tile(tile('light.c'), tiles, slot=10)
|
||||
with self.assertRaisesRegex(ValueError, 'page of its own'):
|
||||
place_tile(tile('light.d', size='full'), tiles, page=0)
|
||||
|
||||
|
||||
class Layouts(unittest.TestCase):
|
||||
def test_forty_eight_tiles_and_the_firmware_they_need(self):
|
||||
forty_eight = validate_layout({'title': 'Home', 'tiles': [tile(f'light.a{i}') for i in range(MAX_TILES)]})
|
||||
self.assertEqual(len(forty_eight['tiles']), 48)
|
||||
self.assertEqual([t['slot'] for t in forty_eight['tiles']][-1], 47)
|
||||
self.assertEqual(min_firmware(forty_eight), FULL_PAGE_MIN_FIRMWARE)
|
||||
twenty = validate_layout({'title': 'Home', 'tiles': [tile(f'light.a{i}') for i in range(20)]})
|
||||
self.assertEqual(min_firmware(twenty), (0, 2, 7))
|
||||
with self.assertRaisesRegex(ValueError, 'at most 48'):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile(f'light.a{i}') for i in range(49)]})
|
||||
|
||||
def test_full_tile_positions(self):
|
||||
layout = validate_layout({'title': 'Home', 'tiles': [tile('light.a', 0), tile('light.b', 6, size='full'), tile('light.c', 12)]})
|
||||
self.assertEqual([t['slot'] for t in layout['tiles']], [0, 6, 12])
|
||||
self.assertEqual(min_firmware(layout), FULL_PAGE_MIN_FIRMWARE)
|
||||
with self.assertRaisesRegex(ValueError, 'top of its page'):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile('light.b', 8, size='full')]})
|
||||
with self.assertRaisesRegex(ValueError, 'same spot'):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile('light.a', 6, size='full'), tile('light.c', 11)]})
|
||||
# A full-page card is one big button unless a control was chosen; a wide card shows its usual one.
|
||||
self.assertIsNone(resolve_controls(tile('light.a', 0, size='full')))
|
||||
self.assertEqual(resolve_controls(tile('light.a', 0, size='full', controls='brightness')), 'brightness')
|
||||
self.assertEqual(resolve_controls(tile('light.a', 0, size='wide')), 'toggle')
|
||||
# A forecast keeps a full tile full; on a single tile it still widens to a row.
|
||||
full = validate_layout({'title': 'Home', 'tiles': [tile('weather.home', 0, display='forecast', size='full')]})
|
||||
self.assertEqual(full['tiles'][0]['options']['size'], 'full')
|
||||
wide = validate_layout({'title': 'Home', 'tiles': [tile('weather.home', 0, display='forecast')]})
|
||||
self.assertEqual(wide['tiles'][0]['options']['size'], 'wide')
|
||||
|
||||
def test_navigation_tile(self):
|
||||
# One built-in entity per page it goes to, so an entity still appears once on a screen.
|
||||
self.assertEqual(BUILTIN['screen.page_3'], 'Go to page 3')
|
||||
self.assertEqual([page_target(e) for e in ('screen.page_1', 'screen.page_8', 'screen.page_9', 'screen.page', 'light.a')], [1, 8, 0, 0, 0])
|
||||
layout = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_3', 0, icon='radiator'), tile('screen.page_5', 1)]})
|
||||
self.assertEqual(layout['tiles'][0]['options'], {'icon': 'radiator'})
|
||||
self.assertEqual(min_firmware(layout), FULL_PAGE_MIN_FIRMWARE)
|
||||
# A display or control makes no sense on it and is dropped; the whole page is not for it.
|
||||
layout = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0, display='digital', size='wide')]})
|
||||
self.assertEqual(layout['tiles'][0]['options'], {'size': 'wide'})
|
||||
with self.assertRaisesRegex(ValueError, 'single or double width'):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0, size='full')]})
|
||||
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)]})
|
||||
with self.assertRaisesRegex(ValueError, 'only appear once'):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0), tile('screen.page_2', 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')]})
|
||||
snapshot = layout_snapshot({'name': 'Hall', 'node': 'hall'}, layout)
|
||||
self.assertEqual(snapshot['tiles'][0]['to_page'], 3)
|
||||
self.assertEqual(snapshot['tiles'][1]['size'], 'full')
|
||||
self.assertNotIn('to_page', snapshot['tiles'][1])
|
||||
self.assertEqual(snapshot['pages'], 2)
|
||||
# A built-in tile's chosen icon travels as its codepoint, like every other tile's, and the page with it.
|
||||
message = state_message(0, layout['tiles'][0], {})
|
||||
self.assertEqual(message['o'], {'icon': tile_icons.ICONS['radiator'][0]})
|
||||
self.assertEqual(message['name'], 'Go to page 3')
|
||||
# Without a chosen icon nothing travels: the screen draws the arrow itself, the editor shows the same one.
|
||||
self.assertNotIn('o', state_message(0, tile('screen.page_2', 0), {}))
|
||||
self.assertEqual(tile_icons.editor()['builtin']['screen.page_2'], tile_icons.GLYPHS['arrow-right'])
|
||||
# The big icon font carries the domain icons and the built-in cards' icons, every name a real glyph.
|
||||
for name in tile_icons.BIG_GLYPHS:
|
||||
self.assertIn(name, tile_icons.GLYPHS)
|
||||
self.assertIn(tile_icons.BUILTIN_TILES['screen.page_1'], tile_icons.BIG_GLYPHS)
|
||||
self.assertIn('lightbulb-off', tile_icons.BIG_GLYPHS)
|
||||
|
||||
|
||||
class Events(unittest.TestCase):
|
||||
def test_add_a_full_tile_and_a_navigation_tile(self):
|
||||
layout = validate_layout({'title': 'Home', 'tiles': [tile('light.a', 0), tile('light.b', 1)]})
|
||||
result = apply_tile_event(layout, 'add', {'entity': 'light.c', 'size': 'full screen'})
|
||||
self.assertEqual(next(t for t in result['tiles'] if t['entity'] == 'light.c'), {'entity': 'light.c', 'name': '', 'options': {'size': 'full'}, 'slot': 6})
|
||||
result = apply_tile_event(result, 'add', {'entity': 'screen.page_2', 'name': 'Heating', 'page': 1})
|
||||
page_tile = next(t for t in result['tiles'] if t['entity'] == 'screen.page_2')
|
||||
self.assertEqual((page_tile['name'], page_tile.get('options'), page_tile['slot']), ('Heating', None, 2))
|
||||
with self.assertRaisesRegex(ValueError, 'cannot go on a screen'):
|
||||
apply_tile_event(result, 'add', {'entity': 'screen.page_9'})
|
||||
# A tile that grows to the whole page keeps its page when nothing else is there, else moves to an empty page.
|
||||
result = apply_tile_event(result, 'add', {'entity': 'light.a', 'size': 'full'})
|
||||
self.assertEqual(next(t for t in result['tiles'] if t['entity'] == 'light.a')['slot'], 12)
|
||||
with self.assertRaisesRegex(ValueError, 'not empty'):
|
||||
apply_tile_event(result, 'move', {'entity': 'light.c', 'page': 1})
|
||||
# Ordering the page of a full tile keeps it at the page start.
|
||||
result = apply_tile_event(result, 'order', {'page': 2, 'entities': ['light.c']})
|
||||
self.assertEqual(next(t for t in result['tiles'] if t['entity'] == 'light.c')['slot'], 6)
|
||||
|
||||
def test_forty_eight_is_the_limit(self):
|
||||
layout = validate_layout({'title': 'Home', 'tiles': [tile(f'light.a{i}', i) for i in range(MAX_TILES)]})
|
||||
with self.assertRaisesRegex(ValueError, f'{MAX_TILES} tiles'):
|
||||
apply_tile_event(layout, 'add', {'entity': 'light.extra'})
|
||||
self.assertEqual(SLOTS_PER_PAGE * MAX_PAGES, MAX_TILES)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -20,7 +20,7 @@ class ProtocolTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError): packets({'huge':'x'*4096})
|
||||
|
||||
def test_layout_rejects_duplicates_unsupported_and_overflow(self):
|
||||
for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(21)],[{'entity':'light.a;restart'}]]:
|
||||
for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(49)],[{'entity':'light.a;restart'}]]:
|
||||
with self.assertRaises(ValueError): validate_layout({'title':'Home','tiles':tiles})
|
||||
self.assertEqual(validate_layout({'title':'Home','tiles':[]})['tiles'],[])
|
||||
|
||||
|
||||
@@ -23,10 +23,14 @@ int main() {
|
||||
assert(!m.accepts(0, "light.a"));
|
||||
assert(m.set_layout({}, "Empty", changed)); assert(changed && m.ready() && m.count == 0);
|
||||
std::vector<std::string> many;
|
||||
for (int i = 0; i < 21; ++i) many.push_back("light.a" + std::to_string(i));
|
||||
// Eight pages of six slots (firmware 0.2.62+): 48 tiles fit, a 49th is refused.
|
||||
for (int i = 0; i < 49; ++i) many.push_back("light.a" + std::to_string(i));
|
||||
assert(!m.set_layout(many, "Too many", changed)); assert(m.count == 0);
|
||||
many.pop_back(); assert(m.set_layout(many,"Twenty",changed));
|
||||
assert(m.count==20 && m.accepts(19,"light.a19"));
|
||||
many.pop_back(); assert(m.set_layout(many,"Forty-eight",changed));
|
||||
assert(m.count==48 && m.tiles.size()==48 && m.accepts(47,"light.a47"));
|
||||
// A screen pays only for the tiles it has: twelve tiles, twelve on the heap.
|
||||
assert(m.set_layout({"light.a0","light.a1","light.a2","light.a3","light.a4","light.a5","light.a6","light.a7","light.a8","light.a9","light.a10","light.a11"},"Twelve",changed));
|
||||
assert(changed && m.count==12 && m.tiles.size()==12);
|
||||
for(size_t i=0;i<m.count;++i){m.tiles[i].received=true;m.tiles[i].state="off";}
|
||||
assert(m.ready());
|
||||
}
|
||||
@@ -63,6 +67,17 @@ static void test_domains_and_packing() {
|
||||
assert(pack(m.tiles, 3, p) == 1 && p[0].slot == 0 && p[1].slot == 2 && p[2].slot == 4);
|
||||
m.tiles[3].wide = true;
|
||||
assert(pack(m.tiles, 4, p) == 2 && p[3].page == 1 && p[3].slot == 0);
|
||||
// A full tile (firmware 0.2.62+) takes a page of its own: it starts one when its page is in use, and the
|
||||
// tiles after it start the next page. Tiles at page starts keep their page.
|
||||
for (auto &t : m.tiles) { t.wide = false; t.full = false; }
|
||||
m.tiles[1].full = true; m.tiles[1].wide = true;
|
||||
assert(m.tiles[1].cells() == 6 && m.tiles[0].cells() == 1);
|
||||
assert(pack(m.tiles, 3, p) == 3 && p[0].page == 0 && p[1].page == 1 && p[1].slot == 0 && p[2].page == 2 && p[2].slot == 0);
|
||||
m.tiles[0].full = true; m.tiles[0].wide = true;
|
||||
assert(pack(m.tiles, 3, p) == 3 && p[0].page == 0 && p[0].slot == 0 && p[1].page == 1 && p[2].page == 2);
|
||||
m.tiles[1].full = m.tiles[1].wide = false;
|
||||
assert(pack(m.tiles, 3, p) == 2 && p[1].page == 1 && p[1].slot == 0 && p[2].page == 1 && p[2].slot == 1);
|
||||
for (auto &t : m.tiles) { t.wide = false; t.full = false; }
|
||||
}
|
||||
struct RunExtra { RunExtra() { test_domains_and_packing(); } } run_extra;
|
||||
// Explicit grid positions (0.2.26+): gaps stay empty, a wide card starts in the left column.
|
||||
@@ -104,6 +119,15 @@ static void test_explicit_slots() {
|
||||
assert(place(m, p) == 1 && p[0].slot == 0 && p[1].slot == 1 && p[2].slot == 2);
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {}, moved));
|
||||
assert(!changed && !moved);
|
||||
// A full tile (firmware 0.2.62+) on any slot of a page snaps to that page's first slot and fills the page.
|
||||
assert(valid_entity("screen.page_1") && valid_entity("screen.page_8") && !valid_entity("screen.page_9") && !valid_entity("screen.page") && !valid_entity("screen.page_"));
|
||||
{ Tile nav; nav.entity = "screen.page_3"; assert(nav.is_page() && nav.page_target() == 3 && nav.builtin() && nav.available()); }
|
||||
m.tiles[1].full = true; m.tiles[1].wide = true;
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 9, 12}, moved));
|
||||
assert(!changed && moved && place(m, p) == 3 && p[1].page == 1 && p[1].slot == 0 && p[2].page == 2 && p[2].slot == 0);
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 47, 1}, moved));
|
||||
assert(place(m, p) == 8 && p[1].page == 7 && p[1].slot == 0);
|
||||
m.tiles[1].full = m.tiles[1].wide = false;
|
||||
// Changing the tiles resets everything, including the positions.
|
||||
assert(m.set_layout({"light.a", "light.z"}, "Home", changed, {1, 6}, moved));
|
||||
assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1);
|
||||
|
||||
@@ -93,8 +93,8 @@ class AddAndUpdate(unittest.TestCase):
|
||||
({'entity': 'light.a', 'page': 1, 'column': 'middle'}, 'column is left or right')):
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
apply_tile_event(layout(), 'add', data)
|
||||
full = layout(*[tile(f'light.a{n}', n) for n in range(20)])
|
||||
with self.assertRaisesRegex(ValueError, 'twenty tiles'):
|
||||
full = layout(*[tile(f'light.a{n}', n) for n in range(48)])
|
||||
with self.assertRaisesRegex(ValueError, '48 tiles'):
|
||||
apply_tile_event(full, 'add', {'entity': 'light.extra'})
|
||||
|
||||
|
||||
|
||||
@@ -23,10 +23,14 @@ class IconSetTests(unittest.TestCase):
|
||||
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'):
|
||||
text = (ROOT / name).read_text()
|
||||
fonts = re.findall(r'materialdesignicons-webfont\.ttf["\']\n id: (\w+)\n size: \d+\n glyphs: (.*)\n', text)
|
||||
self.assertEqual([font for font, _ in fonts], ['materialdesign_icons', 'materialdesign_icons_mini', 'watch_icon'], name)
|
||||
self.assertTrue(fonts[0][1].startswith('&tile_icons') and all(g == '*tile_icons' for _, g in fonts[1:]), name)
|
||||
block = text.split('glyphs: &tile_icons', 1)[1].split('\n\n', 1)[0]
|
||||
self.assertEqual([font for font, _ in fonts], ['materialdesign_icons', 'materialdesign_icons_mini', 'materialdesign_icons_big', 'watch_icon'], name)
|
||||
self.assertTrue(fonts[0][1].startswith('&tile_icons ') and all(g == '*tile_icons' for font, g in fonts[1:] if font != 'materialdesign_icons_big'), name)
|
||||
block = text.split('glyphs: &tile_icons ', 1)[1].split('\n\n', 1)[0]
|
||||
self.assertEqual(re.findall(r'- "(\\U000F[0-9A-F]{4})"', block), wanted, name)
|
||||
# The big font of the full-page card (firmware 0.2.62+) carries the subset the screen draws on its own.
|
||||
self.assertEqual(fonts[2][1].split(' #')[0].strip(), '&tile_icons_big', name)
|
||||
big = text.split('glyphs: &tile_icons_big', 1)[1].split('\n\n', 1)[0]
|
||||
self.assertEqual(re.findall(r'- "(\\U000F[0-9A-F]{4})"', big), [f'\\U000{tile_icons.GLYPHS[n]}' for n in tile_icons.BIG_GLYPHS], name)
|
||||
self.assertNotIn('MDI_GLYPH', text, name)
|
||||
self.assertTrue((ROOT / 'web/src/assets/tile-icons.woff').exists())
|
||||
|
||||
@@ -48,11 +52,11 @@ class IconSetTests(unittest.TestCase):
|
||||
for domains, code in re.findall(r'if \((d == "\w+"(?: \|\| d == "\w+")*)\) return "\\U000(F[0-9A-F]{4})";', body):
|
||||
for domain in re.findall(r'"(\w+)"', domains):
|
||||
native[domain] = code
|
||||
# The built-in cards pick per entity, not per domain: a clock or the settings page.
|
||||
settings, clock = re.search(
|
||||
r'if \(d == "screen"\) return tile\.is_settings\(\) \? "\\U000(F[0-9A-F]{4})" : "\\U000(F[0-9A-F]{4})";', body).groups()
|
||||
# The built-in cards pick per entity, not per domain: a clock, the settings page or a page to go to.
|
||||
settings, page, clock = re.search(
|
||||
r'if \(d == "screen"\) return tile\.is_settings\(\) \? "\\U000(F[0-9A-F]{4})" : tile\.is_page\(\) \? "\\U000(F[0-9A-F]{4})" : "\\U000(F[0-9A-F]{4})";', body).groups()
|
||||
builtin = {entity: tile_icons.GLYPHS[name] for entity, name in tile_icons.BUILTIN_TILES.items()}
|
||||
self.assertEqual({'screen.settings': settings, 'screen.clock': clock}, builtin)
|
||||
self.assertEqual({'screen.settings': settings, 'screen.clock': clock, **{f'screen.page_{n}': page for n in range(1, 9)}}, builtin)
|
||||
self.assertEqual(tile_icons.editor()['builtin'], builtin)
|
||||
self.assertEqual(tile_icons.GLYPHS[tile_icons.DEFAULTS['screen']], clock)
|
||||
expected = {domain: tile_icons.GLYPHS[name] for domain, name in tile_icons.DEFAULTS.items() if domain != 'screen'}
|
||||
|
||||
Reference in New Issue
Block a user