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')
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user