Show a live picture on a camera tile, run an action from the alert button, roll long media titles, and keep full-page tiles in their own colour (0.2.91)
Live picture (issue #4): a camera or image tile on a Guition takes Display -> Live picture with a pace of 15 or 30 s. The camera tiles of a page share one download: the screen asks ESP Screens for them together (esphome.screen_camera with tiles, size and bg) and the app serves one BMP, a strip of squares with the corners rounded over each tile's own colour; every tile draws its square out of it with LVGL's image offset (LV_IMAGE_ALIGN_TOP_LEFT: the default centres a taller source). A third online_image on the Guition holds the strip; it loads after the alert's picture, a cover and the camera full screen, never under a card, in standby or under a finger. The strip is served whole every time: ESPHome's http_request logs a 304 as a failed request. Firmware 0.2.77. Alert button: esp_screens_show_alert takes action and data; the app performs the action once when the button's report (esphome.screen_alert, ok) comes from any screen. show_alert keeps its seven fields, so every firmware from 0.2.31 has it. Media card and full-page media tile: a title or artist line that is too wide rolls by (LV_LABEL_LONG_SCROLL_CIRCULAR) instead of ending in dots. Full-page tiles are white or their own pastel like every other tile; the tint in the state colour of firmware 0.2.62 to 0.2.76 is gone. Host harness (27 checks) and bench Guition + local add-on on the Yellow: docs/TEST_RESULTS_0291.md. CYD firmware 1,633,728 B, 89.0 %. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,8 @@ 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)
|
||||
from core import (ALERT_EVENT, ALERT_FIELDS, ALERT_MAX_TIMEOUT, BROADCAST_DISMISS, BROADCAST_EVENTS, BROADCAST_SHOW, # noqa: E402
|
||||
alert_action, alert_data, alert_reference, alert_targets)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
@@ -57,6 +57,21 @@ class AlertData(unittest.TestCase):
|
||||
self.assertEqual(alert_reference()['broadcast'], {'show': BROADCAST_SHOW, 'dismiss': BROADCAST_DISMISS})
|
||||
|
||||
|
||||
class AlertAction(unittest.TestCase):
|
||||
"""An action behind the button (app 0.2.91): the event names it, the app performs it on the press."""
|
||||
|
||||
def test_the_action_and_its_data(self):
|
||||
self.assertEqual(alert_action({'title': 'Gate', 'action': 'script.open_gate'}), (('script.open_gate', {}), True))
|
||||
self.assertEqual(alert_action({'action': ' light.turn_off ', 'data': {'entity_id': 'light.hall', 'transition': 2}}),
|
||||
(('light.turn_off', {'entity_id': 'light.hall', 'transition': 2}), True))
|
||||
for data in ({}, {'action': ''}, {'action': None}, None, 'text'):
|
||||
self.assertEqual(alert_action(data), (None, True), data)
|
||||
for data in ({'action': 'open the gate'}, {'action': 42}, {'action': 'script.x', 'data': 'no'}, {'action': 'script.x', 'data': ['a']},
|
||||
{'action': 'script.x', 'data': {'big': 'x' * 5000}}, {'action': 'script.x', 'data': {'nan': float('nan')}}):
|
||||
self.assertEqual(alert_action(data), (None, False), data)
|
||||
self.assertEqual(alert_reference()['action']['name'], 'action')
|
||||
|
||||
|
||||
class AlertTargets(unittest.TestCase):
|
||||
def test_only_screens_that_can_show_it_now(self):
|
||||
screens = [{'name': 'Kitchen', 'node': 'kitchen', 'online': True, 'firmware': '0.2.31'},
|
||||
@@ -91,6 +106,12 @@ def fake_ha():
|
||||
self.changed = asyncio.Event()
|
||||
self.broadcasts = asyncio.Queue()
|
||||
self.calls, self.failing = [], set()
|
||||
self.subscriptions = []
|
||||
|
||||
async def request(self, kind, **data):
|
||||
if kind == 'subscribe_events':
|
||||
self.subscriptions.append(data.get('event_type'))
|
||||
return {}
|
||||
|
||||
async def call(self, action, data):
|
||||
self.calls.append((action, data))
|
||||
@@ -139,6 +160,59 @@ class Broadcast(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(any('unusable timeout left empty' in line for line in logs.output), logs.output)
|
||||
self.assertIn('1 of 3 screens (not: Attic firmware 0.2.30; Hall ConnectionError)', logs.output[-1])
|
||||
|
||||
async def test_the_button_performs_the_alert_action_once(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
with self.assertLogs('screen_manager', 'INFO'):
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'button_text': 'Open', 'action': 'script.open_gate', 'data': {'seconds': 5}})
|
||||
self.assertEqual(sorted(m.alert_actions), ['hall', 'kitchen-screen'])
|
||||
self.assertNotIn('action', m.ha.calls[0][1], 'show_alert keeps its seven fields')
|
||||
m.ha.calls.clear()
|
||||
# Hall presses the button: the action runs; Kitchen pressing it later runs nothing again.
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
await m.alert_ended({'action': 'ok', 'title': 'Gate', 'screen': 'hall'})
|
||||
self.assertEqual(m.ha.calls, [('script.open_gate', {'seconds': 5})])
|
||||
self.assertIn('script.open_gate performed', logs.output[-1])
|
||||
self.assertEqual(m.alert_actions, {})
|
||||
await m.alert_ended({'action': 'ok', 'title': 'Gate', 'screen': 'kitchen-screen'})
|
||||
await m.alert_ended({'action': 'ok', 'title': 'Other', 'screen': 'unknown'})
|
||||
self.assertEqual(len(m.ha.calls), 1)
|
||||
# A timeout, a new alert or a dismissal leaves it unperformed.
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'action': 'script.open_gate'})
|
||||
await m.alert_ended({'action': 'timeout', 'title': 'Gate', 'screen': 'hall'})
|
||||
self.assertNotIn('hall', m.alert_actions)
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Plain'})
|
||||
self.assertEqual(m.alert_actions, {})
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'action': 'script.open_gate'})
|
||||
await m.broadcast(BROADCAST_DISMISS, {})
|
||||
self.assertEqual(m.alert_actions, {})
|
||||
m.ha.calls.clear()
|
||||
# An action Home Assistant refuses is logged, not fatal; an unusable action is named and left out.
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'action': 'script.open_gate'})
|
||||
m.ha.failing.add('script.open_gate')
|
||||
with self.assertLogs('screen_manager', 'WARNING') as logs:
|
||||
await m.alert_ended({'action': 'ok', 'title': 'Gate', 'screen': 'hall'})
|
||||
self.assertIn('script.open_gate failed (ConnectionError)', logs.output[-1])
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'action': 'open the gate'})
|
||||
self.assertTrue(any('unusable action left empty' in line for line in logs.output), logs.output)
|
||||
self.assertEqual(m.alert_actions, {})
|
||||
|
||||
async def test_the_reports_of_the_screens_go_through_the_alert_loop(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
task = asyncio.create_task(m.alert_loop())
|
||||
m.ha.broadcasts.put_nowait((BROADCAST_SHOW, {'title': 'Gate', 'action': 'script.open_gate'}))
|
||||
m.ha.broadcasts.put_nowait((ALERT_EVENT, {'action': 'ok', 'title': 'Gate', 'screen': 'hall'}))
|
||||
with self.assertLogs('screen_manager', 'INFO'):
|
||||
for _ in range(50):
|
||||
if ('script.open_gate', {}) in m.ha.calls:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
task.cancel()
|
||||
self.assertIn(('script.open_gate', {}), m.ha.calls)
|
||||
self.assertIn("ALERT_EVENT, *TILE_EVENTS", (ROOT / 'screen_manager/app/server.py').read_text(), 'subscribed on connect')
|
||||
|
||||
async def test_the_loop_keeps_the_order_and_survives_errors(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
@@ -173,11 +247,12 @@ class Broadcast(unittest.IsolatedAsyncioTestCase):
|
||||
ha = HomeAssistant(None, 'http://ha/api', 'token')
|
||||
ha.ws = Socket([{'type': 'event', 'event': {'event_type': BROADCAST_SHOW, 'data': {'title': 'Mail!'}}},
|
||||
{'type': 'event', 'event': {'event_type': 'call_service', 'data': {}}},
|
||||
{'type': 'event', 'event': {'event_type': ALERT_EVENT, 'data': {'action': 'ok', 'title': 'Mail!', 'screen': 'hall'}}},
|
||||
{'type': 'event', 'event': {'event_type': BROADCAST_DISMISS, 'data': {}}}])
|
||||
with self.assertRaises(ConnectionError):
|
||||
await ha.read()
|
||||
self.assertEqual([ha.broadcasts.get_nowait() for _ in range(ha.broadcasts.qsize())],
|
||||
[(BROADCAST_SHOW, {'title': 'Mail!'}), (BROADCAST_DISMISS, {})])
|
||||
[(BROADCAST_SHOW, {'title': 'Mail!'}), (ALERT_EVENT, {'action': 'ok', 'title': 'Mail!', 'screen': 'hall'}), (BROADCAST_DISMISS, {})])
|
||||
self.assertIn('*BROADCAST_EVENTS', (ROOT / 'screen_manager/app/server.py').read_text(), 'subscribed on connect')
|
||||
|
||||
|
||||
|
||||
+132
-1
@@ -1,6 +1,7 @@
|
||||
"""Camera images on a Guition (app 0.2.66, firmware 0.2.57): the app fetches, sizes and serves the image on its own
|
||||
port; the screen asks with esphome.screen_camera and loads the link with ESPHome's online_image."""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
@@ -86,7 +87,10 @@ class Rules(unittest.TestCase):
|
||||
'runtime_tiles::alert_clear();', 'runtime_tiles::camera_close();', 'id: alert_image_frame'):
|
||||
self.assertIn(needle, PROFILE, needle)
|
||||
# A link only replaces the URL when it is new: set_url() forgets the ETag that makes an unchanged picture a 304.
|
||||
self.assertEqual(PROFILE.count('if (url != current) {'), 2)
|
||||
# Three images: the camera full screen (and the cover), the alert's picture, the live tiles' strip (app 0.2.91).
|
||||
self.assertEqual(PROFILE.count('if (url != current) {'), 3)
|
||||
for needle in (' - id: tile_image', 'runtime_tiles::live_loaded(cached);', 'runtime_tiles::live_failed();', 'runtime_tiles::camera_live.load'):
|
||||
self.assertIn(needle, PROFILE, needle)
|
||||
# A busy camera port leaves the rest of the app running.
|
||||
self.assertIn("except OSError as error:\n # Everything else still works; only camera images stay away.", (ROOT / 'screen_manager/app/server.py').read_text())
|
||||
cyd = profiles.text('home-like-2432s028.yaml')
|
||||
@@ -428,5 +432,132 @@ class App(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(ha.camera_requests.get_nowait(), {'inbox': 'text.d1_tiles', 'entity': 'camera.max'})
|
||||
|
||||
|
||||
class LiveTiles(unittest.TestCase):
|
||||
"""A live picture on a camera tile (app 0.2.91, firmware 0.2.77): the page's tiles as one strip."""
|
||||
|
||||
def test_the_live_display_and_its_pace_are_tile_settings(self):
|
||||
from core import LIVE_MIN_FIRMWARE, LIVE_REFRESH
|
||||
self.assertEqual((camera_feed.LIVE_MIN_FIRMWARE, camera_feed.LIVE_REFRESH), (LIVE_MIN_FIRMWARE, LIVE_REFRESH))
|
||||
layout = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': '', 'options': {'display': 'live', 'refresh': 30}},
|
||||
{'entity': 'image.doorbell', 'name': '', 'options': {'display': 'live'}}]})
|
||||
self.assertEqual(min_firmware(layout), LIVE_MIN_FIRMWARE)
|
||||
self.assertEqual(layout['tiles'][0]['options'], {'display': 'live', 'refresh': 30})
|
||||
# The icon again: the pace goes with the live display.
|
||||
plain = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': '', 'options': {'display': 'standard', 'refresh': 30}}]})
|
||||
self.assertEqual(plain['tiles'][0]['options'], {'display': 'standard'})
|
||||
self.assertEqual(min_firmware(plain), CAMERA_MIN_FIRMWARE)
|
||||
for options in ({'display': 'live', 'refresh': 20}, {'display': 'live', 'refresh': '15'}, {'display': 'live', 'refresh': True}):
|
||||
with self.assertRaises(ValueError, msg=options):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': '', 'options': options}]})
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'light.hall', 'name': '', 'options': {'display': 'live'}}]})
|
||||
# A tile event may ask for it too.
|
||||
from core import tile_options
|
||||
self.assertEqual(tile_options({'display': 'live', 'refresh': '30'}), {'display': 'live', 'refresh': 30})
|
||||
# The editor learns the choice from the capabilities.
|
||||
import ha_catalogue
|
||||
self.assertEqual(ha_catalogue.capabilities('camera.front_door', [], {}, {})['displays'], ['standard', 'watch', 'live'])
|
||||
self.assertNotIn('live', ha_catalogue.capabilities('light.hall', [], {}, {})['displays'])
|
||||
|
||||
def test_which_screens_draw_live_pictures(self):
|
||||
self.assertTrue(camera_feed.can_show_live({'board': 'guition', 'firmware': '0.2.77'}))
|
||||
for screen in ({'board': 'guition', 'firmware': '0.2.76'}, {'board': 'cyd', 'firmware': '0.2.77'}, None):
|
||||
self.assertFalse(camera_feed.can_show_live(screen), screen)
|
||||
|
||||
def test_the_request_names_the_tiles_their_size_and_their_grounds(self):
|
||||
self.assertEqual(camera_feed.live_request({'tiles': 'camera.front_door,image.doorbell', 'size': '54', 'bg': 'FFFFFF,fadadd'}),
|
||||
(['camera.front_door', 'image.doorbell'], 54, [0xFFFFFF, 0xFADADD]))
|
||||
for bad in ({'tiles': 'camera.a', 'size': '54'}, {'tiles': 'camera.a,light.b', 'size': '54', 'bg': 'FFFFFF,FFFFFF'},
|
||||
{'tiles': 'camera.a,camera.a', 'size': '54', 'bg': 'FFFFFF,FFFFFF'}, {'tiles': 'camera.a', 'size': '54', 'bg': 'FFFFFF,FFFFFF'},
|
||||
{'tiles': 'camera.a', 'size': '8', 'bg': 'FFFFFF'}, {'tiles': 'camera.a', 'size': 'x', 'bg': 'FFFFFF'},
|
||||
{'tiles': ','.join(f'camera.c{i}' for i in range(7)), 'size': '54', 'bg': ','.join(['FFFFFF'] * 7)}, {'tiles': '', 'size': '54', 'bg': ''}):
|
||||
self.assertIsNone(camera_feed.live_request(bad), bad)
|
||||
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow')
|
||||
def test_the_strip_is_one_square_per_tile_with_rounded_corners_over_its_own_ground(self):
|
||||
from PIL import Image
|
||||
out = camera_feed.encode_live([picture('JPEG', (1920, 1080)), None, picture('PNG', (100, 100), 'RGBA')], 54, [0xFFFFFF, 0x1A1A1A, 0x00FF00])
|
||||
with Image.open(io.BytesIO(out)) as image:
|
||||
self.assertEqual((image.format, image.size, image.mode), ('BMP', (54, 162), 'RGB'))
|
||||
self.assertEqual(image.getpixel((0, 0)), (255, 255, 255)) # the first square's corner: its ground
|
||||
self.assertEqual(image.getpixel((27, 27))[::2], (200, 90)) # its middle: the picture (JPEG, a shade off)
|
||||
self.assertEqual(image.getpixel((27, 54 + 27)), (26, 26, 26)) # no picture: a plain square of its ground
|
||||
self.assertEqual(image.getpixel((0, 108)), (0, 255, 0)) # the third square's corner: its ground
|
||||
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow')
|
||||
def test_each_load_fetches_the_cameras_whose_pace_has_passed(self):
|
||||
clock = [1000.0]
|
||||
fetched = []
|
||||
|
||||
async def fetch(entity):
|
||||
fetched.append((entity, clock[0]))
|
||||
return picture('JPEG', (640, 360))
|
||||
|
||||
async def run():
|
||||
feed = camera_feed.CameraFeed(fetch, clock=lambda: clock[0])
|
||||
tiles, grounds, paces = ['camera.a', 'camera.b'], [0xFFFFFF, 0xFFFFFF], [15, 30]
|
||||
token = feed.link(','.join(tiles), (54, 54), live=(tiles, 54, grounds, paces))
|
||||
status, body, etag = await feed.serve(token)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(sorted(e for e, _ in fetched), ['camera.a', 'camera.b'], 'the first load waits for both')
|
||||
# 15 s later the page loads again: only the 15 s camera is fetched again. The strip comes whole each
|
||||
# time, never a 304, whatever ETag the screen offers: ESPHome's http_request logs a 304 as an error (seen
|
||||
# on the bench Guition with a radar camera).
|
||||
clock[0] += 15
|
||||
again = await feed.serve(token, etag)
|
||||
self.assertEqual((again[0], again[2] == etag), (200, True))
|
||||
self.assertEqual([e for e, at in fetched if at == 1015.0], ['camera.a'])
|
||||
clock[0] += 15
|
||||
await feed.serve(token, etag)
|
||||
self.assertEqual(sorted(e for e, at in fetched if at == 1030.0), ['camera.a', 'camera.b'])
|
||||
# Nobody loading means nothing fetched.
|
||||
clock[0] += 60
|
||||
self.assertEqual(len([e for e, at in fetched if at > 1030.0]), 0)
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP and HAS_PIL, 'Run using .venv-portal/bin/python for server tests')
|
||||
class LiveApp(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
camera_feed.base_url.__defaults__[0].clear()
|
||||
|
||||
async def test_a_screen_asks_for_the_live_tiles_of_its_page(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha(picture('JPEG', (1920, 1080)))
|
||||
ha.states['sensor.d1_fw']['state'] = '0.2.77'
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
m.layouts['text.d1_tiles'] = validate_layout({'title': 'Hall', 'tiles': [
|
||||
{'entity': 'camera.max', 'name': '', 'options': {'display': 'live'}},
|
||||
{'entity': 'camera.garden', 'name': '', 'options': {'display': 'live', 'refresh': 30}},
|
||||
{'entity': 'camera.shed', 'name': ''}]})
|
||||
with self.assertLogs('screen_manager', 'INFO'):
|
||||
await m.answer_camera({'inbox': 'text.d1_tiles', 'tiles': 'camera.max,camera.garden', 'size': '54', 'bg': 'FFFFFF,FADADD'})
|
||||
(_, inbox, message), = [entry for entry in ha.log if entry[0] == 'send']
|
||||
self.assertEqual((inbox, message['op'], message['t'], message['e']), ('text.d1_tiles', 'camera', 'live', 'camera.max,camera.garden'))
|
||||
token = message['u'].rsplit('/', 1)[1][:-4]
|
||||
self.assertEqual(m.camera.links[token].live, (['camera.max', 'camera.garden'], 54, [0xFFFFFF, 0xFADADD], [15, 30]))
|
||||
ha.log.clear()
|
||||
# A tile that shows its icon, a camera off the layout, a screen on older firmware, a CYD: no strip.
|
||||
for request in ({'inbox': 'text.d1_tiles', 'tiles': 'camera.max,camera.shed', 'size': '54', 'bg': 'FFFFFF,FFFFFF'},
|
||||
{'inbox': 'text.d1_tiles', 'tiles': 'camera.other', 'size': '54', 'bg': 'FFFFFF'},
|
||||
{'inbox': 'text.d3_tiles', 'tiles': 'camera.max', 'size': '54', 'bg': 'FFFFFF'},
|
||||
{'inbox': 'text.d2_tiles', 'tiles': 'camera.max', 'size': '54', 'bg': 'FFFFFF'}):
|
||||
with self.assertLogs('screen_manager', 'INFO') if request['inbox'] == 'text.d1_tiles' and 'shed' in request['tiles'] else contextlib.nullcontext():
|
||||
await m.answer_camera(request)
|
||||
self.assertEqual([entry for entry in ha.log if entry[0] == 'send'], [])
|
||||
|
||||
async def test_a_camera_without_a_picture_keeps_its_icon(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha(None)
|
||||
ha.states['sensor.d1_fw']['state'] = '0.2.77'
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
m.layouts['text.d1_tiles'] = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.max', 'name': '', 'options': {'display': 'live'}}]})
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
await m.answer_camera({'inbox': 'text.d1_tiles', 'tiles': 'camera.max', 'size': '54', 'bg': 'FFFFFF'})
|
||||
(_, _, message), = [entry for entry in ha.log if entry[0] == 'send']
|
||||
self.assertEqual((message['t'], message['e'], message['u']), ('live', 'camera.max', ''))
|
||||
self.assertIn('no image', logs.output[-1])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -63,6 +63,21 @@ int main() {
|
||||
feed.finish(10, true);
|
||||
assert(!feed.should_load(REFRESH_MS - 1) && feed.should_load(REFRESH_MS + 1));
|
||||
|
||||
// The live pictures of a page's camera tiles (firmware 0.2.77+) load at the tiles' own pace instead of REFRESH_MS.
|
||||
Feed tiles;
|
||||
tiles.open("camera.front_door,camera.garden", false, 15000);
|
||||
tiles.ask(0);
|
||||
tiles.link("http://h/camera/tiles.bmp");
|
||||
tiles.start(100);
|
||||
tiles.finish(400, true);
|
||||
assert(!tiles.should_load(100 + 15000 - 1) && tiles.should_load(100 + 15000));
|
||||
assert(tiles.every == 15000 && feed.every == REFRESH_MS);
|
||||
// Without a picture (an empty link) they ask again at that pace, not every ASK_AGAIN_MS.
|
||||
tiles.open("camera.front_door", false, 30000);
|
||||
tiles.ask(0);
|
||||
tiles.link("");
|
||||
assert(tiles.empty && !tiles.should_ask(ASK_AGAIN_MS) && !tiles.should_ask(30000 - 1) && tiles.should_ask(30000));
|
||||
|
||||
// An album cover (firmware 0.2.64+) loads once per link: it stays on screen, never refreshed on a clock.
|
||||
Feed cover;
|
||||
cover.open("media_player.office", true);
|
||||
|
||||
@@ -192,7 +192,7 @@ class Editor(unittest.TestCase):
|
||||
def test_the_mockup_polls_live_values_and_draws_them(self):
|
||||
self.assertIn('getJson(`states?${query}`)', self.store)
|
||||
self.assertIn('if (!document.hidden && state.layout && state.tab === "layout" && route.value === "") loadStates();', self.store)
|
||||
for marker in ('liveOf(props.tile.entity)', ':class="{ lit: isOn }"', ':style="sliderStyle"', "class=\"tog\" :class=\"{ off: !on }\""):
|
||||
for marker in ('liveOf(props.tile.entity)', 'lit: isOn', ':style="sliderStyle"', "class=\"tog\" :class=\"{ off: !on }\""):
|
||||
self.assertIn(marker, self.page, marker)
|
||||
|
||||
def test_identify_and_the_test_alert_have_their_buttons(self):
|
||||
|
||||
@@ -194,6 +194,10 @@ class Covers(unittest.TestCase):
|
||||
class Answer(unittest.TestCase):
|
||||
"""The app's answer to a screen's request, through Manager.answer_camera with a Home Assistant stand-in."""
|
||||
|
||||
def setUp(self):
|
||||
# base_url keeps the last address for ten minutes; another test's Home Assistant may have left one behind.
|
||||
camera_feed.base_url.__defaults__[0].clear()
|
||||
|
||||
class HA:
|
||||
online = True
|
||||
|
||||
|
||||
@@ -188,8 +188,8 @@ static void test_slider_colours() {
|
||||
setpoint.state = "55.0"; assert(setpoint.slider_active());
|
||||
}
|
||||
struct RunSliderColours { RunSliderColours() { test_slider_colours(); } } run_slider_colours;
|
||||
// Home Assistant's stateActive() (firmware 0.2.71+): what it calls inactive is grey on the screen, and only a tile
|
||||
// that can be off lights up over a whole page, while it is on.
|
||||
// Home Assistant's stateActive() (firmware 0.2.71+): what it calls inactive is grey on the screen. (`lights` was the
|
||||
// full-page tint of firmware 0.2.62 to 0.2.76, gone since 0.2.77: the cases stay as a record of the rule.)
|
||||
static void test_state_active() {
|
||||
using namespace runtime_tiles;
|
||||
struct Case { const char *entity, *state; bool active, lights; };
|
||||
@@ -219,12 +219,12 @@ static void test_state_active() {
|
||||
{"scene.a", "unavailable", false, false}}) {
|
||||
Tile t; t.entity = c.entity; t.state = c.state; t.received = true;
|
||||
assert(t.active() == c.active);
|
||||
assert(t.lights_up() == c.lights);
|
||||
(void) c.lights;
|
||||
}
|
||||
// Nothing received yet is inactive; a built-in card has no state, is always active and never lit.
|
||||
{ Tile t; t.entity = "light.a"; t.state = "on"; assert(!t.active() && !t.lights_up()); }
|
||||
// Nothing received yet is inactive; a built-in card has no state and is always active.
|
||||
{ Tile t; t.entity = "light.a"; t.state = "on"; assert(!t.active()); }
|
||||
for (const char *entity : {"screen.clock", "screen.settings", "screen.page_2"}) {
|
||||
Tile t; t.entity = entity; assert(t.active() && !t.lights_up());
|
||||
Tile t; t.entity = entity; assert(t.active());
|
||||
}
|
||||
// A closed blind: grey card, coloured slider.
|
||||
{ Tile t; t.entity = "cover.a"; t.state = "closed"; t.received = true; assert(!t.active() && t.slider_active()); }
|
||||
|
||||
@@ -59,7 +59,10 @@ class StateColours(unittest.TestCase):
|
||||
|
||||
def test_the_tile_greys_and_lights_up_by_home_assistants_rule(self):
|
||||
self.assertIn('uint32_t accent=tile_controls::accent(t);', TILES)
|
||||
self.assertIn('bool lit=w.full && on && t.lights_up() && available && !t.transparent;', TILES)
|
||||
# A full-page card is white or its own pastel like every other card (firmware 0.2.77+; 0.2.62-0.2.76 tinted it).
|
||||
self.assertNotIn('lights_up()', TILES)
|
||||
self.assertNotIn('lights_up', MODEL)
|
||||
self.assertIn('set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(theme::surface(t.background)));', TILES)
|
||||
self.assertIn('const uint32_t fill=slider_on?accent:theme::STATE_OFF;', TILES)
|
||||
self.assertNotIn('domain_accent', TILES)
|
||||
self.assertIn('frontend src/common/entity/state_active.ts', MODEL)
|
||||
|
||||
Reference in New Issue
Block a user