Take what a tile can do, its tap actions, words and icons from Home Assistant (0.2.67)
GitHub issue #7 asked for cover.toggle on tap. ESP Screens now asks Home Assistant which actions each entity has instead of keeping lists: the editor offers On / off (covers included), sliders, controls and displays only where Home Assistant has the action, and refuses only new unsupported settings. Perform action runs any action Home Assistant offers for the tile's entity, under its own names and fields; the screen sends it with a call id and says Refused when Home Assistant refuses. Sensors round by display precision, and tiles, the top bar, timelines, vacuum chips and the inspector use Home Assistant's words where they showed raw states. Tiles show Home Assistant's icons, picked as its frontend does (the fonts grow to 309 glyphs). Existing screens keep every setting and tap as before: stored layouts load leniently, legacy taps route the same (1020 combinations), and messages are byte-identical without Home Assistant's words and icons. Firmware 0.2.58. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -103,9 +103,15 @@ class Firmware(unittest.TestCase):
|
||||
def test_a_tap_on_a_cover_opens_the_cover_card(self):
|
||||
event = RUNTIME[RUNTIME.index('inline void event(lv_event_t *event) {'):]
|
||||
event = event[:event.index('\n}\n')]
|
||||
show = re.search(r'if\(d=="sensor" \|\|.*?show_detail\(w\.index\); return; \}', event)
|
||||
self.assertTrue(show and 'd=="cover"' in show[0], 'covers go to show_detail with the other runtime cards')
|
||||
self.assertIn('if (d == "light" || d == "climate" || d == "vacuum" || d == "fan") {', event, 'not to the board\'s value overlay any more')
|
||||
# Since firmware 0.2.58 tile_controls::tap_route decides and event() carries the route out.
|
||||
controls = (ROOT / 'components/smart_display/tile_controls.h').read_text()
|
||||
cards = controls[controls.index('inline bool runtime_card_domain('):]
|
||||
cards = cards[:cards.index('\n}\n')]
|
||||
self.assertIn('d == "cover"', cards, 'covers go to show_detail with the other runtime cards')
|
||||
self.assertIn('if (runtime_card_domain(d)) return {TapRoute::CARD, "", true};', controls)
|
||||
self.assertIn('return d == "light" || d == "climate" || d == "fan" ? Tap{TapRoute::OVERLAY', controls, 'not to the board\'s value overlay any more')
|
||||
self.assertIn('case tile_controls::TapRoute::CARD:', event)
|
||||
self.assertIn('show_detail(w.index);', event)
|
||||
self.assertIn('}else if(d=="cover"){', RUNTIME)
|
||||
self.assertIn('render_cover_detail(t,large,width,height,pad);', RUNTIME)
|
||||
for name, text in PROFILES.items():
|
||||
|
||||
@@ -15,8 +15,10 @@ class ExtendedTests(unittest.TestCase):
|
||||
for domain in ['sensor','number','input_number','select','weather','media_player']:
|
||||
layout=validate_layout({'title':'Test','tiles':[{'entity':domain+'.x','options':{'display':'watch'}}]})
|
||||
self.assertEqual(layout['tiles'][0]['options']['display'],'watch')
|
||||
for options in [{'inline':'slider'},{'tap':'toggle'},{'history_hours':True},{'history_hours':100},{'arbitrary':'run'}]:
|
||||
for options in [{'inline':'slider'},{'history_hours':True},{'history_hours':100},{'arbitrary':'run'}]:
|
||||
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'sensor.x','options':options}]})
|
||||
# On / off: whether Home Assistant offers <domain>.toggle is checked when saving (app 0.2.67); a built-in card never.
|
||||
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'screen.clock','options':{'tap':'toggle'}}]})
|
||||
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'light.x','options':{'display':'watch','inline':'slider'}}]})
|
||||
def test_attributes_and_history_fit_protocol(self):
|
||||
tile={'entity':'sensor.a','name':'Temperature','options':{'history_hours':24,'display':'watch'}}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""What Home Assistant says an entity can do (app 0.2.67): the editor offers On / off, a small slider, direct controls
|
||||
and displays from Home Assistant's own action list, and a save refuses a new setting Home Assistant doesn't support.
|
||||
|
||||
SERVICES are real action descriptions (`get_services` of Home Assistant 2026.9.2, trimmed to what the tests use); the
|
||||
entities carry the supported_features of real devices. On that installation `local_actions` gave the same answer as
|
||||
Home Assistant's `get_services_for_target` for 30 entities out of 30."""
|
||||
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'))
|
||||
import ha_catalogue # noqa: E402
|
||||
from core import validate_layout # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from server import HomeAssistant, Manager, Refused, create_app
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
SERVICES = json.loads('''{"cover":{"toggle":{"target":{"entity":[{"domain":["cover"],"supported_features":[3]}]}},
|
||||
"open_cover":{"target":{"entity":[{"domain":["cover"],"supported_features":[1]}]}},"close_cover":{"target":{"entity":[{"domain":["cover"],"supported_features":[2]}]}},
|
||||
"stop_cover":{"target":{"entity":[{"domain":["cover"],"supported_features":[8]}]}},"set_cover_position":{"target":{"entity":[{"domain":["cover"],"supported_features":[4]}]}}},
|
||||
"media_player":{"toggle":{"target":{"entity":[{"domain":["media_player"],"supported_features":[384]}]}},"turn_on":{"target":{"entity":[{"domain":["media_player"],"supported_features":[128]}]}},
|
||||
"turn_off":{"target":{"entity":[{"domain":["media_player"],"supported_features":[256]}]}},"volume_set":{"target":{"entity":[{"domain":["media_player"],"supported_features":[4]}]}},
|
||||
"volume_mute":{"target":{"entity":[{"domain":["media_player"],"supported_features":[8]}]}},"media_play_pause":{"target":{"entity":[{"domain":["media_player"],"supported_features":[16385]}]}},
|
||||
"media_next_track":{"target":{"entity":[{"domain":["media_player"],"supported_features":[32]}]}},"media_previous_track":{"target":{"entity":[{"domain":["media_player"],"supported_features":[16]}]}}},
|
||||
"light":{"toggle":{"target":{"entity":[{"domain":["light"]}]}},"turn_on":{"target":{"entity":[{"domain":["light"]}]},"fields":{"brightness_pct":{"filter":{"attribute":{"supported_color_modes":["brightness","color_temp","hs","xy","rgb","rgbw","rgbww"]}},"selector":{"number":{"min":0.0,"max":100.0,"unit_of_measurement":"%","step":1.0,"mode":"slider"}}},"additional_fields":{"collapsed":true,"fields":{"white":{"filter":{"attribute":{"supported_color_modes":["white"]}}}}}}},
|
||||
"turn_off":{"target":{"entity":[{"domain":["light"]}]}}},
|
||||
"climate":{"toggle":{"target":{"entity":[{"domain":["climate"],"supported_features":[128,256]}]}},"set_temperature":{"target":{"entity":[{"domain":["climate"],"supported_features":[1,2]}]}},"set_hvac_mode":{"target":{"entity":[{"domain":["climate"]}]}}},
|
||||
"homeassistant":{"toggle":{"target":{}},"restart":{}},"sonos":{"snapshot":{"target":{"entity":[{"integration":"sonos","domain":["media_player"]}]}}},
|
||||
"scene":{"turn_on":{"target":{"entity":[{"domain":["scene"]}]}},"delete":{"target":{"entity":[{"integration":"homeassistant","domain":["scene"]}]}}},
|
||||
"script":{"turn_on":{"target":{"entity":[{"domain":["script"]}]}},"toggle":{"target":{"entity":[{"domain":["script"]}]}}},
|
||||
"fan":{"toggle":{"target":{"entity":[{"domain":["fan"]}]}},"set_percentage":{"target":{"entity":[{"domain":["fan"],"supported_features":[1]}]}}},
|
||||
"weather":{"get_forecasts":{"target":{"entity":[{"domain":["weather"],"supported_features":[1,2,4]}]},"response":{"optional":false}}},
|
||||
"switch":{"toggle":{"target":{"entity":[{"domain":["switch"]}]}}},"notify":{"send_message":{"target":{"entity":[{"domain":["notify"]}]}}}}''')
|
||||
|
||||
# entity: (state, attributes, integration)
|
||||
ENTITIES = {
|
||||
'media_player.sonos': ('idle', {'supported_features': 8321599, 'device_class': 'speaker', 'friendly_name': 'Bathroom'}, 'sonos'),
|
||||
'media_player.tv': ('off', {'supported_features': 24509, 'device_class': 'tv'}, 'webostv'),
|
||||
'cover.curtains': ('open', {'supported_features': 15, 'device_class': 'curtain', 'current_position': 100}, 'zha'),
|
||||
'cover.garage': ('closed', {'supported_features': 3, 'device_class': 'garage'}, 'demo'),
|
||||
'cover.position_only': ('open', {'supported_features': 4}, 'demo'),
|
||||
'light.hood': ('off', {'supported_features': 0, 'supported_color_modes': ['onoff']}, 'switch_as_x'),
|
||||
'light.bathroom': ('on', {'supported_features': 44, 'supported_color_modes': ['color_temp', 'xy']}, 'zha'),
|
||||
'climate.airco': ('cool', {'supported_features': 425, 'hvac_modes': ['off', 'cool']}, 'esphome'),
|
||||
'climate.old_thermostat': ('heat', {'supported_features': 1}, 'demo'),
|
||||
'scene.evening': ('2026-09-17T08:00:00+00:00', {}, 'homeassistant'),
|
||||
'script.wake_up': ('off', {}, 'script'),
|
||||
'fan.ceiling': ('off', {'supported_features': 0}, 'demo'),
|
||||
'sensor.temperature': ('21.456', {'unit_of_measurement': '°C', 'state_class': 'measurement'}, 'demo'),
|
||||
'sensor.washer': ('rinsing', {'device_class': 'enum'}, 'demo'),
|
||||
'weather.buienradar': ('rainy', {'supported_features': 1}, 'buienradar'),
|
||||
'weather.hourly_only': ('rainy', {'supported_features': 2}, 'demo'),
|
||||
}
|
||||
|
||||
|
||||
def state(entity):
|
||||
value, attributes, _ = ENTITIES[entity]
|
||||
return {'entity_id': entity, 'state': value, 'attributes': attributes}
|
||||
|
||||
|
||||
def actions(entity):
|
||||
_, attributes, platform = ENTITIES[entity]
|
||||
return ha_catalogue.local_actions(SERVICES, entity, attributes, platform)
|
||||
|
||||
|
||||
def caps(entity):
|
||||
return ha_catalogue.capabilities(entity, actions(entity), state(entity), SERVICES)
|
||||
|
||||
|
||||
class ActionsForAnEntity(unittest.TestCase):
|
||||
def test_home_assistants_target_filters(self):
|
||||
# Every mask of an action must be supported completely: a Sonos lacks on and off, a TV has both.
|
||||
self.assertNotIn('media_player.toggle', actions('media_player.sonos'))
|
||||
self.assertIn('media_player.toggle', actions('media_player.tv'))
|
||||
self.assertEqual([a for a in actions('cover.curtains') if a.startswith('cover.')],
|
||||
['cover.close_cover', 'cover.open_cover', 'cover.set_cover_position', 'cover.stop_cover', 'cover.toggle'])
|
||||
self.assertIn('cover.toggle', actions('cover.garage'))
|
||||
self.assertNotIn('cover.toggle', actions('cover.position_only'))
|
||||
# Either mask of climate.toggle will do.
|
||||
self.assertIn('climate.toggle', actions('climate.airco'))
|
||||
self.assertNotIn('climate.toggle', actions('climate.old_thermostat'))
|
||||
# An integration filter: only a Sonos gets sonos.snapshot; only scenes of Home Assistant itself can be deleted.
|
||||
self.assertIn('sonos.snapshot', actions('media_player.sonos'))
|
||||
self.assertNotIn('sonos.snapshot', actions('media_player.tv'))
|
||||
self.assertIn('scene.delete', actions('scene.evening'))
|
||||
# A target without an entity filter takes every entity; an action without a target is no entity action.
|
||||
self.assertIn('homeassistant.toggle', actions('sensor.washer'))
|
||||
self.assertNotIn('homeassistant.restart', actions('light.hood'))
|
||||
self.assertNotIn('notify.send_message', actions('light.hood'))
|
||||
|
||||
def test_fields_follow_their_filter_also_inside_a_section(self):
|
||||
turn_on = SERVICES['light']['turn_on']
|
||||
self.assertFalse(ha_catalogue.field_matches(ha_catalogue.find_field(turn_on, 'brightness_pct'), {'supported_color_modes': ['onoff']}))
|
||||
self.assertTrue(ha_catalogue.field_matches(ha_catalogue.find_field(turn_on, 'brightness_pct'), {'supported_color_modes': ['xy']}))
|
||||
white = ha_catalogue.find_field(turn_on, 'white')
|
||||
self.assertIsNotNone(white, 'fields in a section (additional_fields) count too')
|
||||
self.assertTrue(ha_catalogue.field_matches(white, {'supported_color_modes': ['white', 'xy']}))
|
||||
self.assertTrue(ha_catalogue.field_matches({'selector': {}}, {}), 'a field without a filter is always there')
|
||||
|
||||
|
||||
class WhatTheEditorOffers(unittest.TestCase):
|
||||
def test_on_off_follows_home_assistant(self):
|
||||
self.assertFalse(caps('media_player.sonos')['toggle'])
|
||||
self.assertTrue(caps('media_player.tv')['toggle'])
|
||||
self.assertTrue(caps('cover.curtains')['toggle'], 'issue #7: a cover taps open and closed')
|
||||
self.assertTrue(caps('script.wake_up')['toggle'])
|
||||
self.assertFalse(caps('scene.evening')['toggle'])
|
||||
self.assertFalse(caps('sensor.temperature')['toggle'])
|
||||
|
||||
def test_small_slider_and_direct_controls(self):
|
||||
self.assertEqual(caps('light.hood'), {'toggle': True, 'inline': False, 'controls': ['toggle'], 'displays': ['standard', 'watch']})
|
||||
self.assertEqual((caps('light.bathroom')['inline'], caps('light.bathroom')['controls']), (True, ['toggle', 'brightness']))
|
||||
self.assertEqual((caps('cover.curtains')['inline'], caps('cover.curtains')['controls']), (True, ['buttons', 'position']))
|
||||
self.assertEqual((caps('cover.garage')['inline'], caps('cover.garage')['controls']), (False, ['buttons']))
|
||||
self.assertEqual((caps('cover.position_only')['inline'], caps('cover.position_only')['controls']), (True, ['position']))
|
||||
self.assertEqual(caps('media_player.sonos')['controls'], ['volume', 'playback'])
|
||||
self.assertEqual(caps('climate.old_thermostat')['controls'], ['setpoint', 'mode'])
|
||||
self.assertEqual((caps('fan.ceiling')['inline'], caps('fan.ceiling')['controls']), (False, ['toggle']))
|
||||
self.assertEqual(caps('scene.evening')['controls'], ['run'])
|
||||
|
||||
def test_displays(self):
|
||||
self.assertIn('graph', caps('sensor.temperature')['displays'])
|
||||
self.assertNotIn('graph', caps('sensor.washer')['displays'])
|
||||
self.assertIn('forecast', caps('weather.buienradar')['displays'])
|
||||
self.assertNotIn('forecast', caps('weather.hourly_only')['displays'])
|
||||
unavailable = {'entity_id': 'weather.x', 'state': 'unavailable', 'attributes': {}}
|
||||
self.assertIn('forecast', ha_catalogue.capabilities('weather.x', [], unavailable, SERVICES)['displays'])
|
||||
|
||||
|
||||
class NewSettingsOnly(unittest.TestCase):
|
||||
def test_a_new_setting_home_assistant_lacks_is_refused_a_saved_one_stays(self):
|
||||
sonos = {'entity': 'media_player.sonos', 'options': {'tap': 'toggle'}}
|
||||
self.assertEqual(ha_catalogue.unsupported(sonos, None, caps('media_player.sonos')), ('tap', 'toggle'))
|
||||
before = {'entity': 'media_player.sonos', 'options': {'tap': 'auto'}}
|
||||
self.assertEqual(ha_catalogue.unsupported(sonos, before, caps('media_player.sonos')), ('tap', 'toggle'))
|
||||
saved = {'entity': 'media_player.sonos', 'options': {'tap': 'toggle', 'background': 'blue'}}
|
||||
changed = {'entity': 'media_player.sonos', 'options': {'tap': 'toggle', 'background': 'red'}}
|
||||
self.assertIsNone(ha_catalogue.unsupported(changed, saved, caps('media_player.sonos')), 'a setting the tile already had stays')
|
||||
self.assertIsNone(ha_catalogue.unsupported(sonos, None, None), 'nothing is refused while Home Assistant cannot say')
|
||||
curtain = {'entity': 'cover.curtains', 'options': {'tap': 'toggle', 'inline': 'slider'}}
|
||||
self.assertIsNone(ha_catalogue.unsupported(curtain, None, caps('cover.curtains')))
|
||||
hood = {'entity': 'light.hood', 'options': {'inline': 'slider'}}
|
||||
self.assertEqual(ha_catalogue.unsupported(hood, None, caps('light.hood')), ('inline', 'slider'))
|
||||
self.assertEqual(ha_catalogue.unsupported({'entity': 'cover.garage', 'options': {'size': 'wide', 'controls': 'position'}}, None,
|
||||
caps('cover.garage')), ('controls', 'position'))
|
||||
self.assertEqual(ha_catalogue.unsupported({'entity': 'sensor.washer', 'options': {'display': 'graph'}}, None, caps('sensor.washer')),
|
||||
('display', 'graph'))
|
||||
self.assertIn("can't turn Bathroom on and off", ha_catalogue.refusal('media_player.sonos', 'Bathroom', 'tap', 'toggle'))
|
||||
|
||||
def test_a_setting_a_newer_app_saved_never_stops_the_app(self):
|
||||
stored = {'title': 'Hall', 'tiles': [{'entity': 'cover.curtains', 'name': '', 'slot': 0,
|
||||
'options': {'tap': 'swipe', 'swipe': {'action': 'cover.toggle'}}}]}
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout(stored)
|
||||
kept = validate_layout(stored, stored=True)
|
||||
self.assertEqual(kept['tiles'][0]['options'], stored['tiles'][0]['options'])
|
||||
with self.assertRaises(ValueError, msg='a broken entity still stops loading'):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'nonsense', 'options': {}}]}, stored=True)
|
||||
|
||||
|
||||
def fake_ha(target_lookup=True):
|
||||
class HA(HomeAssistant):
|
||||
def __init__(self):
|
||||
super().__init__(None, 'http://supervisor/core/api', 'token')
|
||||
self.registry = [{'entity_id': 'text.d1_tiles', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.d1_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.d1_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
|
||||
self.states = {'text.d1_tiles': {'state': 'Synced'}, 'sensor.d1_node': {'state': 'living-room'}, 'sensor.d1_fw': {'state': '0.2.58'}}
|
||||
for entity, (_, attributes, platform) in ENTITIES.items():
|
||||
self.registry.append({'entity_id': entity, 'platform': platform, 'device_id': 'd9'})
|
||||
self.states[entity] = state(entity)
|
||||
self.devices, self.areas = [{'id': 'd1', 'name': 'Living room screen'}, {'id': 'd9', 'name': 'Demo'}], []
|
||||
self.services, self.services_rev = SERVICES, 1
|
||||
self.asked, self.fired, self.published = [], [], {}
|
||||
|
||||
async def request(self, kind, **data):
|
||||
self.asked.append((kind, data))
|
||||
if kind != 'get_services_for_target':
|
||||
raise AssertionError(kind)
|
||||
if not target_lookup:
|
||||
raise Refused('Unknown command.')
|
||||
entity = data['target']['entity_id'][0]
|
||||
return ha_catalogue.local_actions(SERVICES, entity, self.states[entity]['attributes'], ENTITIES[entity][2])
|
||||
|
||||
async def fire(self, event_type, data):
|
||||
self.fired.append((event_type, data))
|
||||
|
||||
async def set_state(self, entity_id, value, attributes):
|
||||
self.published[entity_id] = (value, attributes)
|
||||
return HA()
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class AskingHomeAssistant(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_home_assistant_answers_and_the_answer_is_kept_per_entity(self):
|
||||
ha = fake_ha()
|
||||
self.assertFalse((await ha.capabilities('media_player.sonos'))['toggle'])
|
||||
self.assertTrue((await ha.capabilities('cover.curtains'))['toggle'])
|
||||
await ha.capabilities('cover.curtains')
|
||||
self.assertEqual([data['target'] for _, data in ha.asked], [{'entity_id': ['media_player.sonos']}, {'entity_id': ['cover.curtains']}])
|
||||
# New features, a new action list or a new registry ask again.
|
||||
ha.states['cover.curtains'] = {**state('cover.curtains'), 'attributes': {'supported_features': 4}}
|
||||
self.assertFalse((await ha.capabilities('cover.curtains'))['toggle'])
|
||||
ha.services_rev += 1
|
||||
await ha.capabilities('cover.curtains')
|
||||
self.assertEqual(len(ha.asked), 4)
|
||||
self.assertIsNone(await ha.capabilities('light.unknown'), 'no state: unknown, so nothing is refused')
|
||||
|
||||
async def test_an_older_home_assistant_gets_the_same_answer_from_the_descriptions(self):
|
||||
ha = fake_ha(target_lookup=False)
|
||||
self.assertFalse((await ha.capabilities('media_player.sonos'))['toggle'])
|
||||
self.assertTrue((await ha.capabilities('cover.curtains'))['toggle'])
|
||||
self.assertEqual(len(ha.asked), 1, 'asked once; after "Unknown command" the descriptions answer')
|
||||
self.assertIs(ha.target_lookup, False)
|
||||
|
||||
async def test_no_action_list_yet_means_unknown(self):
|
||||
ha = fake_ha()
|
||||
ha.services = {}
|
||||
self.assertIsNone(await ha.capabilities('cover.curtains'))
|
||||
self.assertEqual(ha.asked, [])
|
||||
|
||||
async def test_saving_and_tile_events_refuse_only_new_settings(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'screens.json'
|
||||
m = Manager(fake_ha(), path)
|
||||
tiles = [{'entity': 'cover.curtains', 'name': '', 'slot': 0, 'options': {'tap': 'toggle'}},
|
||||
{'entity': 'media_player.sonos', 'name': '', 'slot': 1}]
|
||||
data = {'title': 'Living room', 'tiles': tiles}
|
||||
await m.check_supported('text.d1_tiles', data)
|
||||
m.save('text.d1_tiles', data)
|
||||
refused = {'title': 'Living room', 'tiles': [tiles[0], {**tiles[1], 'options': {'tap': 'toggle'}}]}
|
||||
with self.assertRaisesRegex(ValueError, "can't turn Bathroom on and off"):
|
||||
await m.check_supported('text.d1_tiles', refused)
|
||||
# A layout saved before 0.2.67 with On / off on that speaker keeps saving when something else changes.
|
||||
m.layouts['text.d1_tiles']['tiles'][1]['options'] = {'tap': 'toggle'}
|
||||
await m.check_supported('text.d1_tiles', {'title': 'Renamed', 'tiles': [tiles[0], {**tiles[1], 'options': {'tap': 'toggle'}}]})
|
||||
# Claude in Home Assistant gets the same answer.
|
||||
m.ha.tile_events = asyncio.Queue()
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'light.hood', 'inline': 'slider'}))
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'cover.garage', 'tap': 'toggle'}))
|
||||
worker = asyncio.create_task(m.tile_loop())
|
||||
for _ in range(200):
|
||||
await asyncio.sleep(0.01)
|
||||
if len(m.ha.fired) == 2:
|
||||
break
|
||||
worker.cancel()
|
||||
answers = [answer for _, answer in m.ha.fired]
|
||||
self.assertFalse(answers[0]['ok'])
|
||||
self.assertIn('small slider', answers[0]['error'])
|
||||
self.assertTrue(answers[1]['ok'], answers[1])
|
||||
saved = json.loads(path.read_text())['screens']['text.d1_tiles']
|
||||
self.assertEqual({t['entity']: t.get('options', {}).get('tap') for t in saved['tiles']},
|
||||
{'cover.curtains': 'toggle', 'media_player.sonos': 'toggle', 'cover.garage': 'toggle'})
|
||||
|
||||
async def test_the_editor_asks_for_several_entities_at_once(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
answer = await (await client.get('/api/capabilities?entity=cover.curtains&entity=media_player.sonos&entity=light.unknown')).json()
|
||||
found = answer['capabilities']
|
||||
self.assertTrue(found['cover.curtains']['toggle'])
|
||||
self.assertFalse(found['media_player.sonos']['toggle'])
|
||||
self.assertIsNone(found['light.unknown'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Home Assistant's default icons on tiles and in the top bar (app 0.2.67, firmware 0.2.58). ICONS is a subset of what
|
||||
Home Assistant 2026.9.2 answers frontend/get_icons with; the firmware fonts carry the icons Home Assistant uses for the
|
||||
domains a screen shows, so a closed blind, a playing speaker and a door that is open look as they do in Home Assistant."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
import tile_icons # noqa: E402
|
||||
import header_bar # noqa: E402
|
||||
from core import discover, screen_options # noqa: E402
|
||||
|
||||
ICONS = json.loads('''{"entity_component":{"cover":{"_":{"default":"mdi:window-open","state":{"closed":"mdi:window-closed","closing":"mdi:arrow-down-box","opening":"mdi:arrow-up-box"}},"blind":{"default":"mdi:blinds-horizontal","state":{"closed":"mdi:blinds-horizontal-closed","closing":"mdi:arrow-down-box","opening":"mdi:arrow-up-box"}},"garage":{"default":"mdi:garage-open","state":{"closed":"mdi:garage","closing":"mdi:arrow-down-box","opening":"mdi:arrow-up-box"}}},"light":{"_":{"default":"mdi:lightbulb","state":{"off":"mdi:lightbulb-off"}}},"climate":{"_":{"default":"mdi:thermostat"}},"scene":{"_":{"default":"mdi:palette"}},"script":{"_":{"default":"mdi:script-text","state":{"on":"mdi:script-text-play"}}},"binary_sensor":{"_":{"default":"mdi:radiobox-blank","state":{"on":"mdi:checkbox-marked-circle"}},"door":{"default":"mdi:door-closed","state":{"on":"mdi:door-open"}},"smoke":{"default":"mdi:smoke-detector-variant","state":{"on":"mdi:smoke-detector-variant-alert"}}},"sensor":{"_":{"default":"mdi:eye"},"temperature":{"default":"mdi:thermometer"},"battery":{"default":"mdi:battery-unknown","range":{"0":"mdi:battery-alert","10":"mdi:battery-10","20":"mdi:battery-20","30":"mdi:battery-30","40":"mdi:battery-40","50":"mdi:battery-50","60":"mdi:battery-60","70":"mdi:battery-70","80":"mdi:battery-80","90":"mdi:battery-90","100":"mdi:battery"}},"wind_direction":{"default":"mdi:compass-rose","range":{"0":"mdi:arrow-down","22.5":"mdi:arrow-bottom-left","67.5":"mdi:arrow-left","112.5":"mdi:arrow-top-left","157.5":"mdi:arrow-up","202.5":"mdi:arrow-top-right","247.5":"mdi:arrow-right","292.5":"mdi:arrow-bottom-right","337.5":"mdi:arrow-down"}}},"device_tracker":{"_":{"default":"mdi:account","state":{"not_home":"mdi:account-arrow-right"}}},"update":{"_":{"default":"mdi:package-up","state":{"off":"mdi:package"}}},"media_player":{"_":{"default":"mdi:cast","state":{"off":"mdi:cast-off","paused":"mdi:cast-connected","playing":"mdi:cast-connected"}},"speaker":{"default":"mdi:speaker","state":{"off":"mdi:speaker-off","paused":"mdi:speaker-pause","playing":"mdi:speaker-play"}},"tv":{"default":"mdi:television","state":{"off":"mdi:television-off","paused":"mdi:television-pause","playing":"mdi:television-play"}}},"lock":{"_":{"default":"mdi:lock","state":{"jammed":"mdi:lock-alert","locking":"mdi:lock-clock","open":"mdi:lock-open-variant","opening":"mdi:lock-clock","unlocked":"mdi:lock-open-variant","unlocking":"mdi:lock-clock"}}},"weather":{"_":{"default":"mdi:weather-partly-cloudy","state":{"rainy":"mdi:weather-rainy"}}}},"entity":{"zha":{"binary_sensor":{"hand_open":{"default":"mdi:hand-wave"}}}}}''')
|
||||
|
||||
|
||||
def glyph(name):
|
||||
return tile_icons.GLYPHS[name]
|
||||
|
||||
|
||||
class Resolution(unittest.TestCase):
|
||||
def test_as_home_assistants_frontend_picks_it(self):
|
||||
name = lambda entity, state, attributes=None, entry=None: tile_icons.ha_default_icon(entity, state, attributes or {}, entry, ICONS)
|
||||
self.assertEqual(name('cover.office', 'closed', {'device_class': 'blind'}), 'blinds-horizontal-closed')
|
||||
self.assertEqual(name('cover.office', 'open', {'device_class': 'blind'}), 'blinds-horizontal')
|
||||
self.assertEqual(name('cover.skylight', 'opening'), 'arrow-up-box', 'a class without an entry uses the domain')
|
||||
self.assertEqual(name('light.lamp', 'off'), 'lightbulb-off')
|
||||
self.assertEqual(name('media_player.sonos', 'playing', {'device_class': 'speaker'}), 'speaker-play')
|
||||
self.assertEqual(name('binary_sensor.front', 'on', {'device_class': 'door'}), 'door-open')
|
||||
self.assertEqual(name('sensor.hall', '21.5', {}, {'original_device_class': 'temperature'}), 'eye',
|
||||
"the frontend reads the device class from the state, which carries the registry's")
|
||||
self.assertEqual(name('cover.gate', 'opening', {'device_class': 'gate'}), 'arrow-up-box')
|
||||
self.assertEqual(name('binary_sensor.hand', 'on', {}, {'platform': 'zha', 'translation_key': 'hand_open'}), 'hand-wave',
|
||||
"an integration's own icon comes first")
|
||||
self.assertIsNone(tile_icons.ha_default_icon('cover.x', 'open', {}, {}, {}))
|
||||
self.assertEqual(name('cover.x', None), 'window-open', 'an entity without a state gets the default, as in the entity list')
|
||||
|
||||
def test_a_number_picks_its_step_of_the_range(self):
|
||||
name = lambda entity, state, device_class: tile_icons.ha_default_icon(entity, state, {'device_class': device_class}, None, ICONS)
|
||||
for level, icon in (('45', 'battery-40'), ('40', 'battery-40'), ('9.9', 'battery-alert'), ('5', 'battery-alert'),
|
||||
('100', 'battery'), ('250', 'battery'), (' 71 ', 'battery-70'), ('-1', 'battery-unknown'),
|
||||
('unavailable', 'battery-unknown'), ('unknown', 'battery-unknown')):
|
||||
self.assertEqual(name('sensor.phone_battery', level, 'battery'), icon, level)
|
||||
for degrees, icon in (('0', 'arrow-down'), ('22.5', 'arrow-bottom-left'), ('100', 'arrow-left'), ('350', 'arrow-down'),
|
||||
('2.1e2', 'arrow-top-right'), ('north', 'compass-rose')):
|
||||
self.assertEqual(name('sensor.wind', degrees, 'wind_direction'), icon, degrees)
|
||||
self.assertEqual(tile_icons.js_number(''), 0.0, 'Number("") is 0')
|
||||
self.assertIsNone(tile_icons.js_number('0x1'), 'not a decimal state')
|
||||
|
||||
def test_the_icons_the_frontend_picks_in_code(self):
|
||||
name = lambda entity, state, attributes: tile_icons.ha_default_icon(entity, state, attributes, None, ICONS)
|
||||
self.assertEqual(name('device_tracker.phone', 'home', {'source_type': 'router'}), 'lan-connect')
|
||||
self.assertEqual(name('device_tracker.phone', 'not_home', {'source_type': 'router'}), 'lan-disconnect')
|
||||
self.assertEqual(name('device_tracker.tag', 'home', {'source_type': 'bluetooth_le'}), 'bluetooth-connect')
|
||||
self.assertEqual(name('device_tracker.tag', 'not_home', {'source_type': 'bluetooth'}), 'bluetooth')
|
||||
self.assertEqual(name('device_tracker.car', 'not_home', {'source_type': 'gps'}), 'account-arrow-right')
|
||||
self.assertEqual(name('device_tracker.car', 'Work', {'source_type': 'gps'}), 'account')
|
||||
self.assertEqual(name('input_datetime.alarm', '07:00:00', {'has_date': False, 'has_time': True}), 'clock')
|
||||
self.assertEqual(name('input_datetime.trip', '2026-09-20', {'has_date': True, 'has_time': False}), 'calendar')
|
||||
self.assertIsNone(name('input_datetime.moment', '2026-09-20 07:00:00', {'has_date': True, 'has_time': True}),
|
||||
'Home Assistant has no icons of its own for input_datetime')
|
||||
self.assertEqual(name('update.core', 'off', {'in_progress': True}), 'package-down')
|
||||
self.assertEqual(name('update.core', 'on', {'in_progress': False}), 'package-up')
|
||||
platform = {'entity': {'demo': {'device_tracker': {'phone': {'default': 'mdi:cellphone'}}}}}
|
||||
self.assertEqual(tile_icons.ha_default_icon('device_tracker.phone', 'home', {'source_type': 'router'},
|
||||
{'platform': 'demo', 'translation_key': 'phone'}, platform), 'cellphone',
|
||||
"an integration's own icon comes before the frontend's")
|
||||
|
||||
def test_the_screen_gets_only_what_its_fonts_carry(self):
|
||||
glyph_of = lambda entity, state, attributes=None, entry=None: tile_icons.default_glyph(entity, state, attributes or {}, entry, ICONS)
|
||||
self.assertEqual(glyph_of('cover.office', 'closed', {'device_class': 'blind'}), glyph('blinds-horizontal-closed'))
|
||||
self.assertEqual(glyph_of('climate.airco', 'cool'), glyph('thermostat'))
|
||||
self.assertEqual(glyph_of('scene.evening', '2026-09-17T08:00:00+00:00'), glyph('palette'))
|
||||
self.assertEqual(glyph_of('script.wake', 'on'), glyph('script-text-play'))
|
||||
self.assertEqual(glyph_of('sensor.phone_battery', '64', {'device_class': 'battery'}), glyph('battery-60'))
|
||||
self.assertEqual(glyph_of('device_tracker.phone', 'home', {'source_type': 'router'}), glyph('lan-connect'))
|
||||
self.assertIsNone(glyph_of('update.core', 'off', {'in_progress': True}), 'package-down: no update tiles yet')
|
||||
self.assertIsNone(glyph_of('binary_sensor.hand', 'on', {}, {'platform': 'zha', 'translation_key': 'hand_open'}),
|
||||
'hand-wave is not in the fonts: the screen keeps its own icon')
|
||||
self.assertIsNone(glyph_of('binary_sensor.plain', 'off'), 'radiobox-blank is not in the icon font either')
|
||||
for entity in ('weather.home', 'sun.sun', 'screen.clock'):
|
||||
self.assertIsNone(glyph_of(entity, 'rainy'), 'the screen draws these from the state itself')
|
||||
|
||||
def test_every_default_icon_of_home_assistant_for_these_domains_is_in_the_fonts(self):
|
||||
wanted = set()
|
||||
for spec in ICONS['entity_component'].values():
|
||||
for entry in spec.values():
|
||||
wanted.add(entry['default'][4:])
|
||||
wanted.update(icon[4:] for icon in (entry.get('state') or {}).values())
|
||||
wanted.update(icon[4:] for icon in (entry.get('range') or {}).values())
|
||||
wanted.update(('lan-connect', 'lan-disconnect', 'bluetooth-connect', 'bluetooth', 'account-arrow-right', 'account', 'clock', 'calendar'))
|
||||
self.assertEqual(sorted(wanted - set(tile_icons.GLYPHS)), ['radiobox-blank'], 'the one icon the MDI font of this repo lacks')
|
||||
self.assertFalse(set(dict(tile_icons.HA_DEFAULTS)) & set(tile_icons.ICONS), 'the defaults are not pickable')
|
||||
|
||||
|
||||
class Everywhere(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tile_icons.use_ha_icons(ICONS)
|
||||
|
||||
def tearDown(self):
|
||||
tile_icons.use_ha_icons({})
|
||||
|
||||
def test_tile_top_bar_and_editor_show_the_same_icon(self):
|
||||
state = {'state': 'closed', 'attributes': {'device_class': 'blind', 'friendly_name': 'Office blind'}}
|
||||
auto = {'entity': 'cover.office', 'name': ''}
|
||||
self.assertEqual(screen_options(auto, state['attributes'], 'closed')['icon'], glyph('blinds-horizontal-closed'))
|
||||
chosen = {'entity': 'cover.office', 'name': '', 'options': {'icon': 'lamp'}}
|
||||
self.assertEqual(screen_options(chosen, state['attributes'], 'closed')['icon'], tile_icons.ICONS['lamp'][0], 'a chosen icon wins')
|
||||
own = {**state['attributes'], 'icon': 'mdi:curtains'}
|
||||
self.assertEqual(screen_options(auto, own, 'closed')['icon'], glyph('curtains'), "an entity's own icon wins")
|
||||
self.assertEqual(header_bar.auto_icon('cover.office', state), glyph('blinds-horizontal-closed'))
|
||||
_, entities = discover([{'entity_id': 'cover.office', 'platform': 'demo'}], {'cover.office': {**state, 'entity_id': 'cover.office'}}, [], [])
|
||||
self.assertEqual(entities[0]['icon'], glyph('blinds-horizontal-closed'))
|
||||
self.assertEqual(header_bar.auto_icon('weather.home', {'state': 'rainy', 'attributes': {}}), glyph('weather-rainy'))
|
||||
|
||||
def test_without_home_assistants_icons_everything_is_as_before(self):
|
||||
tile_icons.use_ha_icons({})
|
||||
self.assertNotIn('icon', screen_options({'entity': 'cover.office', 'name': ''}, {'device_class': 'blind'}, 'closed') or {})
|
||||
self.assertEqual(header_bar.auto_icon('climate.airco', {'state': 'cool', 'attributes': {}}), glyph('thermostat'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -38,6 +38,14 @@ static void test_domains_and_packing() {
|
||||
assert(valid_entity("sun.sun") && valid_entity("timer.kitchen") && valid_entity("person.max"));
|
||||
assert(m.set_layout({"screen.clock", "weather.home", "light.a", "sensor.b", "person.max", "timer.egg", "sun.sun"}, "Home", changed));
|
||||
assert(m.tiles[0].available() && !m.tiles[1].available()); // built-in cards need no HA state
|
||||
// A scene or button that never ran is unknown in Home Assistant and can still be pressed; other unknown states can't act.
|
||||
for (const char *entity : {"scene.evening", "button.restart", "input_button.doorbell"}) {
|
||||
runtime_tiles::Tile never; never.entity = entity; never.received = true; never.state = "unknown";
|
||||
assert(never.available());
|
||||
never.state = "unavailable"; assert(!never.available());
|
||||
}
|
||||
{ runtime_tiles::Tile lamp; lamp.entity = "light.lamp"; lamp.received = true; lamp.state = "unknown"; assert(!lamp.available()); }
|
||||
{ runtime_tiles::Tile waiting; waiting.entity = "scene.evening"; waiting.state = "unknown"; assert(!waiting.available()); }
|
||||
m.tiles[4].state = "home"; assert(m.tiles[4].active());
|
||||
m.tiles[5].state = "active"; assert(m.tiles[5].active());
|
||||
m.tiles[5].state = "idle"; assert(!m.tiles[5].active());
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Home Assistant's numbers and words (app 0.2.67, firmware 0.2.58): a sensor's value rounded by its display precision,
|
||||
and Home Assistant's word where a tile, the top bar, a history timeline or a vacuum card's chip showed the raw state
|
||||
("open", "playing", "rinsing", "vac_and_mop"). WORDS are real entries of frontend/get_translations (English) of Home
|
||||
Assistant 2026.9.2."""
|
||||
from datetime import timezone
|
||||
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 ha_catalogue # noqa: E402
|
||||
import header_bar # noqa: E402
|
||||
import history_card # noqa: E402
|
||||
from core import rounded_state, state_message, vacuum_extras # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from server import Manager
|
||||
from test_ha_capabilities import fake_ha
|
||||
|
||||
WORDS = {
|
||||
'component.cover.entity_component._.state.open': 'Open',
|
||||
'component.cover.entity_component._.state.closing': 'Closing',
|
||||
'component.media_player.entity_component._.state.playing': 'Playing',
|
||||
'component.vacuum.entity_component._.state.returning': 'Returning to dock',
|
||||
'component.binary_sensor.entity_component.motion.state.on': 'Detected',
|
||||
'component.sensor.entity_component._.state.on': 'On',
|
||||
'component.roborock.entity.sensor.status.state.charging': 'Charging',
|
||||
'component.roborock.entity.sensor.zeo_state.state.rinsing': 'Rinsing',
|
||||
'component.roborock.entity.select.cleaning_mode.state.vac_and_mop': 'Vacuum and mop',
|
||||
'component.roborock.entity.select.cleaning_mode.state.vacuum': 'Vacuum only',
|
||||
'component.roborock.entity.select.mop_intensity.state.mild': 'Mild',
|
||||
'component.roborock.entity.select.mop_intensity.state.vac_followed_by_mop': 'Vacuum followed by mop',
|
||||
'component.roborock.entity.vacuum.roborock.state_attributes.fan_speed.state.balanced': 'Balanced',
|
||||
'component.roborock.entity.vacuum.roborock.state_attributes.fan_speed.state.off_raise_main_brush': 'Off (raised brush)',
|
||||
'component.climate.entity_component._.state.heat_cool': 'Heat/Cool',
|
||||
}
|
||||
CLEANING = {'platform': 'roborock', 'translation_key': 'cleaning_mode'}
|
||||
|
||||
|
||||
class Rounding(unittest.TestCase):
|
||||
def test_display_precision(self):
|
||||
self.assertEqual(rounded_state('21.456', 1), '21.5')
|
||||
self.assertEqual(rounded_state('21.45', 1), '21.5', 'half up, as the frontend rounds')
|
||||
self.assertEqual(rounded_state('1249.6', 0), '1250', 'no thousands separator: the screen reads the number')
|
||||
self.assertEqual(rounded_state('-0.04', 1), '0.0')
|
||||
self.assertEqual(rounded_state('3', 2), '3.00')
|
||||
for value in ('unavailable', 'on', '', 'nan', 'inf'):
|
||||
self.assertEqual(rounded_state(value, 1), value)
|
||||
self.assertEqual(rounded_state('21.456', None), '21.456', 'without a precision Home Assistant shows the state as it is')
|
||||
self.assertEqual(rounded_state('21.456', True), '21.456')
|
||||
|
||||
def test_only_sensor_states_are_rounded(self):
|
||||
states = {'sensor.t': {'state': '21.456', 'attributes': {'unit_of_measurement': '°C'}},
|
||||
'number.n': {'state': '21.456', 'attributes': {}}}
|
||||
self.assertEqual(state_message(0, {'entity': 'sensor.t', 'name': ''}, states, precision=1)['state'], '21.5')
|
||||
self.assertEqual(state_message(0, {'entity': 'number.n', 'name': ''}, states, precision=1)['state'], '21.456')
|
||||
self.assertEqual(state_message(0, {'entity': 'sensor.t', 'name': ''}, states)['state'], '21.456')
|
||||
|
||||
|
||||
class Words(unittest.TestCase):
|
||||
def test_home_assistants_word_as_its_frontend_picks_it(self):
|
||||
word = lambda entity, state, attributes=None, entry=None: ha_catalogue.state_word(entity, state, attributes, entry, WORDS)
|
||||
self.assertEqual(word('cover.blind', 'open'), 'Open')
|
||||
self.assertEqual(word('binary_sensor.hall', 'on', {'device_class': 'motion'}), 'Detected')
|
||||
self.assertEqual(word('sensor.washer', 'rinsing', {'device_class': 'enum'}, {'platform': 'roborock', 'translation_key': 'zeo_state'}), 'Rinsing')
|
||||
self.assertEqual(word('sensor.robot', 'charging', {}, {'platform': 'roborock', 'translation_key': 'status'}), 'Charging')
|
||||
self.assertIsNone(word('sensor.robot', 'charging', {}, {'platform': 'roborock'}), 'without a translation key there is no entity word')
|
||||
self.assertIsNone(word('cover.blind', 'jammed'))
|
||||
self.assertIsNone(word('binary_sensor.hall', 'on', {}, {'original_device_class': 'motion'}), 'the device class comes from the state, as in the frontend')
|
||||
self.assertEqual(ha_catalogue.attribute_word('vacuum.s8', 'fan_speed', 'balanced', {}, {'platform': 'roborock', 'translation_key': 'roborock'}, WORDS), 'Balanced')
|
||||
self.assertIsNone(ha_catalogue.state_word('cover.blind', 'open', {}, {}, {}))
|
||||
|
||||
def test_tiles_get_a_word_only_where_they_showed_the_raw_state(self):
|
||||
screen = lambda entity, state, attributes=None, entry=None: ha_catalogue.screen_word(entity, state, attributes, entry, WORDS)
|
||||
self.assertEqual(screen('cover.blind', 'open'), 'Open')
|
||||
self.assertEqual(screen('media_player.sonos', 'playing'), 'Playing')
|
||||
self.assertEqual(screen('vacuum.s8', 'returning'), 'Returning to dock')
|
||||
self.assertEqual(screen('select.s8_mode', 'vac_and_mop', {}, {'platform': 'roborock', 'translation_key': 'cleaning_mode'}), 'Vacuum and mop')
|
||||
self.assertIsNone(screen('binary_sensor.hall', 'on', {'device_class': 'motion'}), 'binary sensors keep the words of firmware 0.2.53')
|
||||
self.assertIsNone(screen('sensor.power', '12.5'), 'a number keeps its number')
|
||||
self.assertIsNone(screen('light.lamp', 'on'))
|
||||
|
||||
|
||||
class WordsBeyondTiles(unittest.TestCase):
|
||||
def test_the_top_bar_takes_home_assistants_word_where_it_showed_the_state_as_it_came(self):
|
||||
washer = {'state': 'rinsing', 'attributes': {'device_class': 'enum'}}
|
||||
self.assertEqual(header_bar.value('sensor.washer', washer, {'platform': 'roborock', 'translation_key': 'zeo_state'}, words=WORDS), ('Rinsing', None))
|
||||
mode = {'state': 'vac_and_mop', 'attributes': {'options': ['vacuum', 'vac_and_mop']}}
|
||||
self.assertEqual(header_bar.value('select.s8_mode', mode, CLEANING, words=WORDS), ('Vacuum and mop', None))
|
||||
self.assertEqual(header_bar.value('select.s8_mode', mode, CLEANING), ('vac_and_mop', None), 'without words as before')
|
||||
# The bar's own short words stay until Max chooses: Returning, Auto, Motion.
|
||||
self.assertEqual(header_bar.value('vacuum.s8', {'state': 'returning', 'attributes': {}}, None, words=WORDS), ('Returning', None))
|
||||
self.assertEqual(header_bar.value('climate.hall', {'state': 'heat_cool', 'attributes': {}}, None, words=WORDS), ('Auto', None))
|
||||
motion = {'state': 'on', 'attributes': {'device_class': 'motion'}}
|
||||
self.assertEqual(header_bar.value('binary_sensor.hall', motion, None, words=WORDS), ('Motion', None))
|
||||
layout = {'header': {'items': [{'type': 'entity', 'entity': 'select.s8_mode', 'content': 'state', 'icon': 'none', 'show': 'always'}]}}
|
||||
bar = header_bar.message(layout, {'select.s8_mode': mode}, {'select.s8_mode': CLEANING}, words=WORDS)
|
||||
self.assertEqual(bar['items'], [{'k': 'text', 't': 'Vacuum and mop'}])
|
||||
self.assertEqual(header_bar.preview(layout['header'], {'select.s8_mode': mode}, {'select.s8_mode': CLEANING}, words=WORDS)[0]['t'], 'Vacuum and mop')
|
||||
|
||||
def test_a_history_timeline_speaks_the_same_words(self):
|
||||
changes = [(0, 'vacuum'), (3600, 'vac_and_mop'), (7200, 'custom')]
|
||||
message = history_card.timeline('select.s8_mode', 24, changes, 0, 24 * 3600, timezone.utc, {}, entry=CLEANING, translations=WORDS)
|
||||
self.assertEqual(sorted(label for label, _, _ in message['states']), ['Custom', 'Vacuum and mop', 'Vacuum only'])
|
||||
self.assertEqual(dict(message['words']), {'vacuum': 'Vacuum only', 'vac_and_mop': 'Vacuum and mop', 'custom': 'Custom'})
|
||||
before = history_card.timeline('select.s8_mode', 24, changes, 0, 24 * 3600, timezone.utc, {})
|
||||
self.assertEqual(dict(before['words']), {'vacuum': 'Vacuum', 'vac_and_mop': 'Vac and mop', 'custom': 'Custom'}, 'without words as before')
|
||||
door = history_card.timeline('binary_sensor.front', 24, [(0, 'off'), (60, 'on')], 0, 3600, timezone.utc, {'device_class': 'door'},
|
||||
entry=None, translations={'component.binary_sensor.entity_component.door.state.on': 'Open'})
|
||||
self.assertEqual(dict(door['words'])['on'], 'Open')
|
||||
|
||||
def test_vacuum_chips_take_home_assistants_word_where_the_screen_has_no_label(self):
|
||||
device = [{'entity_id': 'vacuum.s8', 'platform': 'roborock', 'translation_key': 'roborock'},
|
||||
{'entity_id': 'select.s8_mop_intensity', 'platform': 'roborock', 'translation_key': 'mop_intensity'}]
|
||||
states = {'vacuum.s8': {'state': 'docked', 'attributes': {'fan_speed_list': ['quiet', 'balanced', 'off_raise_main_brush']}},
|
||||
'select.s8_mop_intensity': {'state': 'mild', 'attributes': {'options': ['mild', 'vac_followed_by_mop']}}}
|
||||
extra = vacuum_extras({'entity': 'vacuum.s8'}, states, device)
|
||||
self.assertEqual((extra['fan']['l'], extra['water']['l']), (['Quiet', 'Normal', 'Off raise main brush'], ['Mild', 'Vac followed by mop']))
|
||||
ha_catalogue.chip_words(extra, 'vacuum.s8', states, device, WORDS)
|
||||
self.assertEqual(extra['fan']['l'], ['Quiet', 'Normal', 'Off (raised brush)'], "the screen's own Normal stays")
|
||||
self.assertEqual(extra['water']['l'], ['Mild', 'Vacuum followed by mop'])
|
||||
self.assertEqual(extra['fan']['o'], ['quiet', 'balanced', 'off_raise_main_brush'], 'the values the screen sends stay')
|
||||
self.assertIsNone(ha_catalogue.chip_words(None, 'vacuum.s8', states, device, WORDS))
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class TileMessages(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_tile_message_carries_the_word_and_the_rounded_value(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
ha.state_words = WORDS
|
||||
ha.states['sensor.living_temperature'] = {'state': '21.456', 'attributes': {'unit_of_measurement': '°C', 'state_class': 'measurement'}}
|
||||
ha.registry.append({'entity_id': 'sensor.living_temperature', 'platform': 'demo', 'options': {'sensor': {'display_precision': 1}}})
|
||||
ha.states['sensor.washer'] = {'state': 'rinsing', 'attributes': {'device_class': 'enum'}}
|
||||
ha.registry.append({'entity_id': 'sensor.washer', 'platform': 'roborock', 'translation_key': 'zeo_state'})
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
temperature = await m.tile_message(0, {'entity': 'sensor.living_temperature', 'name': ''})
|
||||
self.assertEqual((temperature['state'], temperature.get('x', {}).get('w')), ('21.5', None))
|
||||
washer = await m.tile_message(1, {'entity': 'sensor.washer', 'name': ''})
|
||||
self.assertEqual((washer['state'], washer['x']['w']), ('rinsing', 'Rinsing'))
|
||||
curtains = await m.tile_message(2, {'entity': 'cover.curtains', 'name': ''})
|
||||
self.assertEqual(curtains['x']['w'], 'Open')
|
||||
ha.state_words = {}
|
||||
self.assertNotIn('x', await m.tile_message(2, {'entity': 'cover.curtains', 'name': ''}), 'no words: the raw state, as before')
|
||||
|
||||
async def test_the_top_bar_timeline_and_vacuum_card_get_the_words_from_the_app(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
ha.state_words = WORDS
|
||||
ha.states['select.s8_mode'] = {'state': 'vac_and_mop', 'attributes': {'options': ['vacuum', 'vac_and_mop']}}
|
||||
ha.states['vacuum.s8'] = {'state': 'docked', 'attributes': {'fan_speed_list': ['balanced', 'off_raise_main_brush']}}
|
||||
# The mode select sits on another device here: with one on the robot's device, suction off belongs to Mop.
|
||||
ha.registry += [{'entity_id': 'select.s8_mode', 'device_id': 'hall', **CLEANING},
|
||||
{'entity_id': 'vacuum.s8', 'device_id': 's8', 'platform': 'roborock', 'translation_key': 'roborock'}]
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
layout = {'tiles': [], 'header': {'items': [{'type': 'entity', 'entity': 'select.s8_mode', 'content': 'state', 'icon': 'none', 'show': 'always'}]}}
|
||||
self.assertEqual(m.header_message(layout)['items'], [{'k': 'text', 't': 'Vacuum and mop'}])
|
||||
vacuum = await m.tile_message(0, {'entity': 'vacuum.s8', 'name': ''})
|
||||
self.assertEqual(vacuum['x']['fan']['l'], ['Normal', 'Off (raised brush)'], "the screen's own Normal stays")
|
||||
async def state_changes(entity, hours):
|
||||
return [(0, 'vacuum'), (60, 'vac_and_mop')]
|
||||
ha.state_changes = state_changes
|
||||
timeline = await m.build_card_history('select.s8_mode', 24, 'timeline')
|
||||
self.assertEqual(dict(timeline['words'])['vac_and_mop'], 'Vacuum and mop')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Perform action (app 0.2.67, firmware 0.2.58): a tap runs an action Home Assistant offers for the tile's entity, with
|
||||
data for its fields. The editor lists the actions under Home Assistant's own names, a save accepts only what Home
|
||||
Assistant would take, and the screen gets a compact form it sends as ESPHome action data and templates."""
|
||||
import asyncio
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
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 ha_catalogue # noqa: E402
|
||||
from core import (action_for_screen, apply_tile_event, layout_snapshot, screen_options, state_message, # noqa: E402
|
||||
validate_layout, validate_tap_action)
|
||||
from test_ha_capabilities import ENTITIES, SERVICES, actions, state # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from server import Manager, Refused, create_app
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from test_ha_capabilities import fake_ha
|
||||
|
||||
# Real descriptions of Home Assistant 2026.9.2 with their fields, and its English names for them.
|
||||
DESCRIBED = copy.deepcopy(SERVICES)
|
||||
DESCRIBED['cover']['set_cover_position']['fields'] = {
|
||||
'position': {'required': True, 'selector': {'number': {'min': 0.0, 'max': 100.0, 'unit_of_measurement': '%', 'step': 1.0, 'mode': 'slider'}}},
|
||||
'speed': {'example': 'fast', 'filter': {'supported_features': [256]}, 'selector': {'state': {'attribute': 'speed'}}}}
|
||||
NAMES = {
|
||||
'component.cover.services.set_cover_position.name': 'Set cover position',
|
||||
'component.cover.services.set_cover_position.description': 'Moves a cover to a specific position.',
|
||||
'component.cover.services.set_cover_position.fields.position.name': 'Position',
|
||||
'component.cover.services.set_cover_position.fields.position.description': 'Target position.',
|
||||
'component.cover.services.toggle.name': 'Toggle cover',
|
||||
'component.homeassistant.services.toggle.name': 'Generic toggle',
|
||||
'component.sonos.services.snapshot.name': 'Snapshot',
|
||||
}
|
||||
|
||||
|
||||
def template_value(template):
|
||||
"""What Home Assistant renders from a template the app wrote: `{{ "<json>" | from_json }}`."""
|
||||
inner = re.fullmatch(r'\{\{ (".*") \| from_json \}\}', template)
|
||||
return json.loads(json.loads(inner.group(1)))
|
||||
|
||||
|
||||
class StoredAction(unittest.TestCase):
|
||||
def test_what_a_tap_action_may_hold(self):
|
||||
self.assertEqual(validate_tap_action({'action': 'cover.toggle'}), {'action': 'cover.toggle'})
|
||||
self.assertEqual(validate_tap_action({'action': 'sonos.snapshot', 'data': {'with_group': True}}),
|
||||
{'action': 'sonos.snapshot', 'data': {'with_group': True}})
|
||||
for bad in ({'action': 'cover'}, {'action': 'Cover.toggle'}, {'action': 'cover.toggle', 'target': {}}, 'cover.toggle',
|
||||
{'action': 'cover.toggle', 'data': {'entity_id': 'cover.other'}}, {'action': 'cover.toggle', 'data': {'Bad key': 1}},
|
||||
{'action': 'light.turn_on', 'data': {f'f{i}': i for i in range(9)}},
|
||||
{'action': 'light.turn_on', 'data': {'effect': 'x' * 500}},
|
||||
{'action': 'light.turn_on', 'data': {'brightness': float('nan')}}):
|
||||
with self.assertRaises(ValueError, msg=bad):
|
||||
validate_tap_action(bad)
|
||||
|
||||
def test_the_layout_keeps_the_action_only_with_perform_action(self):
|
||||
layout = validate_layout({'title': 'Hall', 'tiles': [
|
||||
{'entity': 'cover.curtains', 'options': {'tap': 'action', 'action': {'action': 'cover.set_cover_position', 'data': {'position': 50}}}},
|
||||
{'entity': 'light.hood', 'options': {'tap': 'toggle', 'action': {'action': 'light.turn_on'}}}]})
|
||||
self.assertEqual(layout['tiles'][0]['options']['action'], {'action': 'cover.set_cover_position', 'data': {'position': 50}})
|
||||
self.assertNotIn('action', layout['tiles'][1]['options'], 'another tap choice drops a stale action')
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'cover.curtains', 'options': {'tap': 'action'}}]})
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'screen.clock', 'options': {'tap': 'action', 'action': {'action': 'light.turn_on'}}}]})
|
||||
|
||||
def test_the_screen_gets_text_as_data_and_every_other_value_as_a_template(self):
|
||||
value = {'action': 'light.turn_on', 'data': {'effect': 'colorloop', 'brightness_pct': 40, 'rgb_color': [255, 0, 0],
|
||||
'flash': True, 'transition': 1.5, 'name': 'Café "late"'}}
|
||||
act = action_for_screen(validate_tap_action(value))
|
||||
self.assertEqual(act['s'], 'light.turn_on')
|
||||
self.assertEqual(act['d'], [['effect', 'colorloop'], ['name', 'Café "late"']])
|
||||
self.assertEqual({key: template_value(text) for key, text in act['t']},
|
||||
{'brightness_pct': 40, 'rgb_color': [255, 0, 0], 'flash': True, 'transition': 1.5})
|
||||
tile = {'entity': 'cover.curtains', 'name': '', 'options': {'tap': 'action', 'action': {'action': 'cover.toggle'}}}
|
||||
self.assertEqual(screen_options(tile, {}), {'tap': 'action', 'act': {'s': 'cover.toggle'}})
|
||||
message = state_message(0, tile, {'cover.curtains': state('cover.curtains')})
|
||||
self.assertEqual(message['o'], {'tap': 'action', 'act': {'s': 'cover.toggle'}})
|
||||
broken = {'entity': 'cover.curtains', 'name': '', 'options': {'tap': 'action', 'action': {'action': 'nonsense'}}}
|
||||
self.assertEqual(screen_options(broken, {}), {'tap': 'action'}, 'a stored action the screen could not send stays behind')
|
||||
|
||||
def test_tile_events_and_the_layout_sensor(self):
|
||||
layout = apply_tile_event({'title': 'Hall', 'tiles': []}, 'add',
|
||||
{'entity': 'cover.curtains', 'action': 'cover.set_cover_position', 'data': {'position': 30}})
|
||||
options = layout['tiles'][0]['options']
|
||||
self.assertEqual((options['tap'], options['action']), ('action', {'action': 'cover.set_cover_position', 'data': {'position': 30}}))
|
||||
kept = apply_tile_event(layout, 'add', {'entity': 'cover.curtains', 'tap': 'detail'})
|
||||
self.assertEqual(validate_layout(kept)['tiles'][0]['options'].get('tap'), 'detail')
|
||||
self.assertNotIn('action', validate_layout(kept)['tiles'][0]['options'])
|
||||
snapshot = layout_snapshot({'name': 'Hall'}, validate_layout(layout))
|
||||
self.assertEqual((snapshot['tiles'][0]['tap'], snapshot['tiles'][0]['action']['action']), ('action', 'cover.set_cover_position'))
|
||||
|
||||
|
||||
class HomeAssistantsList(unittest.TestCase):
|
||||
def test_actions_under_home_assistants_names_with_the_fields_of_this_entity(self):
|
||||
found = ha_catalogue.action_choices('media_player.sonos', actions('media_player.sonos'), state('media_player.sonos'), DESCRIBED, NAMES, 'sonos')
|
||||
order = [item['action'] for item in found]
|
||||
self.assertLess(order.index('media_player.volume_set'), order.index('sonos.snapshot'))
|
||||
self.assertLess(order.index('sonos.snapshot'), order.index('homeassistant.toggle'))
|
||||
self.assertEqual(next(item for item in found if item['action'] == 'sonos.snapshot')['name'], 'Snapshot')
|
||||
curtains = ha_catalogue.action_choices('cover.curtains', actions('cover.curtains'), state('cover.curtains'), DESCRIBED, NAMES, 'zha')
|
||||
position = next(item for item in curtains if item['action'] == 'cover.set_cover_position')
|
||||
self.assertEqual((position['name'], position['description']), ('Set cover position', 'Moves a cover to a specific position.'))
|
||||
self.assertEqual([(f['key'], f['name'], f['required']) for f in position['fields']], [('position', 'Position', True)],
|
||||
'speed needs the SPEED feature, which these curtains lack')
|
||||
self.assertEqual(next(item for item in curtains if item['action'] == 'cover.close_cover')['name'], 'cover.close_cover',
|
||||
'without a name anywhere the action id stands in')
|
||||
weather = ha_catalogue.action_choices('weather.buienradar', actions('weather.buienradar'), state('weather.buienradar'), DESCRIBED, NAMES)
|
||||
self.assertNotIn('weather.get_forecasts', [item['action'] for item in weather], 'an action that only answers with data is left out')
|
||||
|
||||
def test_a_field_that_asks_for_a_value_of_the_entity_lists_its_values(self):
|
||||
described = copy.deepcopy(DESCRIBED)
|
||||
described['media_player']['select_source'] = {'target': {'entity': [{'domain': ['media_player'], 'supported_features': [2048]}]},
|
||||
'fields': {'source': {'required': True, 'selector': {'state': {'attribute': 'source'}}}}}
|
||||
sonos = {**state('media_player.sonos'), 'attributes': {**state('media_player.sonos')['attributes'], 'source_list': ['TV', 'NPO Radio 2', 'Qmusic']}}
|
||||
found = ha_catalogue.action_choices('media_player.sonos', ['media_player.select_source'], sonos, described, NAMES)
|
||||
self.assertEqual(found[0]['fields'][0]['options'], ['TV', 'NPO Radio 2', 'Qmusic'])
|
||||
self.assertEqual(ha_catalogue.attribute_options({'fan_modes': ['auto', 'low']}, 'fan_mode'), ['auto', 'low'])
|
||||
self.assertEqual(ha_catalogue.attribute_options({'supported_speeds': ['slow', 'fast']}, 'speed'), ['slow', 'fast'])
|
||||
self.assertIsNone(ha_catalogue.attribute_options({'source_list': []}, 'source'))
|
||||
|
||||
def test_what_a_save_refuses(self):
|
||||
curtains = actions('cover.curtains')
|
||||
problem = lambda action, entity='cover.curtains', offered=curtains: ha_catalogue.action_problem(
|
||||
entity, 'Curtains', action, state(entity), offered, DESCRIBED)
|
||||
self.assertIsNone(problem({'action': 'cover.set_cover_position', 'data': {'position': 50}}))
|
||||
self.assertIsNone(problem({'action': 'cover.toggle'}))
|
||||
self.assertIn("doesn't offer media_player.volume_set", problem({'action': 'media_player.volume_set'}))
|
||||
self.assertIn('needs a value for position', problem({'action': 'cover.set_cover_position'}))
|
||||
self.assertIn('no field speed', problem({'action': 'cover.set_cover_position', 'data': {'position': 5, 'speed': 'fast'}}))
|
||||
self.assertIn('only answers with data', problem({'action': 'weather.get_forecasts'}, 'weather.buienradar', actions('weather.buienradar')))
|
||||
self.assertIsNone(problem({'action': 'cover.toggle'}, offered=None), 'nothing is refused while Home Assistant cannot say')
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class SavingAndTheEditor(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_names_come_with_the_action_list_and_a_refused_request_leaves_none(self):
|
||||
ha = fake_ha()
|
||||
answers = {'get_services': DESCRIBED, 'frontend/get_translations': {'resources': NAMES}, 'frontend/get_icons': {'resources': {}}}
|
||||
async def request(kind, **data):
|
||||
if kind in answers:
|
||||
return answers[kind]
|
||||
raise AssertionError(kind)
|
||||
ha.request = request
|
||||
await ha.fetch_services()
|
||||
self.assertEqual(ha.service_names['component.cover.services.toggle.name'], 'Toggle cover')
|
||||
async def refusing(kind, **data):
|
||||
if kind == 'frontend/get_translations':
|
||||
raise Refused('Unknown command.')
|
||||
return DESCRIBED
|
||||
ha.request, ha.service_names = refusing, {}
|
||||
await ha.fetch_services()
|
||||
self.assertEqual(ha.service_names, {})
|
||||
self.assertEqual(ha.services, DESCRIBED)
|
||||
|
||||
async def test_a_save_takes_only_what_home_assistant_would(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
ha.services = DESCRIBED
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
def layout(action):
|
||||
return {'title': 'Living room', 'tiles': [{'entity': 'cover.curtains', 'name': '', 'slot': 0,
|
||||
'options': {'tap': 'action', 'action': action}}]}
|
||||
with self.assertRaisesRegex(ValueError, 'needs a value for position'):
|
||||
await m.check_supported('text.d1_tiles', layout({'action': 'cover.set_cover_position'}))
|
||||
with self.assertRaisesRegex(ValueError, "doesn't offer light.turn_on"):
|
||||
await m.check_supported('text.d1_tiles', layout({'action': 'light.turn_on'}))
|
||||
good = layout({'action': 'cover.set_cover_position', 'data': {'position': 50}})
|
||||
await m.check_supported('text.d1_tiles', good)
|
||||
m.save('text.d1_tiles', good)
|
||||
# Home Assistant drops the action later (an integration removed): the saved tile still saves.
|
||||
ha.services = {key: value for key, value in DESCRIBED.items() if key != 'cover'}
|
||||
ha.services_rev += 1
|
||||
await m.check_supported('text.d1_tiles', {**good, 'title': 'Renamed'})
|
||||
saved = json.loads((Path(tmp) / 'screens.json').read_text())['screens']['text.d1_tiles']
|
||||
self.assertEqual(saved['tiles'][0]['options']['action'], {'action': 'cover.set_cover_position', 'data': {'position': 50}})
|
||||
|
||||
async def test_claude_in_home_assistant_sets_an_action(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'screens.json'
|
||||
ha = fake_ha()
|
||||
ha.services = DESCRIBED
|
||||
m = Manager(ha, path)
|
||||
ha.tile_events = asyncio.Queue()
|
||||
ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'cover.curtains', 'action': 'cover.set_cover_position'}))
|
||||
ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'cover.curtains', 'action': 'cover.set_cover_position', 'data': {'position': 70}}))
|
||||
worker = asyncio.create_task(m.tile_loop())
|
||||
for _ in range(200):
|
||||
await asyncio.sleep(0.01)
|
||||
if len(ha.fired) == 2:
|
||||
break
|
||||
worker.cancel()
|
||||
answers = [answer for _, answer in ha.fired]
|
||||
self.assertFalse(answers[0]['ok'])
|
||||
self.assertIn('needs a value for position', answers[0]['error'])
|
||||
self.assertTrue(answers[1]['ok'], answers[1])
|
||||
tile = json.loads(path.read_text())['screens']['text.d1_tiles']['tiles'][0]
|
||||
self.assertEqual(screen_options(tile, {})['act'], {'s': 'cover.set_cover_position', 't': [['position', '{{ "70" | from_json }}']]})
|
||||
|
||||
async def test_the_editor_asks_for_an_entitys_actions(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
ha.services, ha.service_names = DESCRIBED, NAMES
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
found = (await (await client.get('/api/entity-actions?entity=cover.curtains')).json())['actions']
|
||||
unknown = (await (await client.get('/api/entity-actions?entity=light.unknown')).json())['actions']
|
||||
nonsense = (await (await client.get('/api/entity-actions?entity=../etc')).json())['actions']
|
||||
self.assertEqual(found[0]['action'].split('.')[0], 'cover')
|
||||
self.assertIn({'action': 'cover.toggle', 'name': 'Toggle cover', 'description': '', 'fields': []}, found)
|
||||
self.assertIsNone(unknown)
|
||||
self.assertIsNone(nonsense)
|
||||
|
||||
|
||||
class Editor(unittest.TestCase):
|
||||
SCRIPT = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
|
||||
def test_perform_action_is_a_tap_choice_with_home_assistants_list(self):
|
||||
self.assertIn('taps.push(["action", "Perform action"]);', self.SCRIPT)
|
||||
self.assertIn('api(`entity-actions?entity=${encodeURIComponent(entity)}`)', self.SCRIPT)
|
||||
self.assertIn('if (tap === "action") body.append(actionPicker(tile));', self.SCRIPT)
|
||||
# Fields follow Home Assistant's selectors; an empty field is left out of the data.
|
||||
for marker in ('kind === "boolean"', 'kind === "number" || kind === "color_temp"', 'config.options', 'delete data[field.key]'):
|
||||
self.assertIn(marker, self.SCRIPT)
|
||||
self.assertIn('supportsFirmware(0, 2, 58)', self.SCRIPT)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -179,5 +179,67 @@ int main() {
|
||||
assert(!strcmp(binary_state_text("battery_charging", false), "Not charging") && !strcmp(binary_state_text("battery", true), "Low"));
|
||||
assert(!strcmp(binary_state_text("", true), "On") && !strcmp(binary_state_text("", false), "Off"));
|
||||
assert(!strcmp(binary_state_text("future_class", true), "On") && !strcmp(binary_state_text("Door", true), "On"));
|
||||
|
||||
// Tap routing (firmware 0.2.58). `legacy` is runtime_tiles::event() of firmware 0.2.56, line by line: every tap
|
||||
// choice an existing layout can hold must route exactly as before, so no screen changes behaviour on the update.
|
||||
struct Legacy { TapRoute route; std::string service; bool busy; };
|
||||
auto legacy = [](const Tile &tile, bool hold) -> Legacy {
|
||||
const std::string d = tile.domain();
|
||||
if (tile.tap == "none") return {TapRoute::NONE, "", false};
|
||||
bool open = hold || d == "climate" || d == "vacuum" || d == "cover";
|
||||
if (!hold && tile.tap == "detail") open = true;
|
||||
if (!hold && tile.tap == "toggle") open = false;
|
||||
if (d == "media_player" && tile.tap == "toggle" && !hold) return {TapRoute::ACTION, "media_player.toggle", false};
|
||||
if (d == "climate" && tile.tap == "toggle" && !hold) return {TapRoute::ACTION, "climate.toggle", false};
|
||||
if (d == "timer" && !open) return {TapRoute::ACTION, tile.state == "active" ? "timer.pause" : "timer.start", false};
|
||||
if (d == "sensor" || d == "binary_sensor" || d == "weather" || d == "number" || d == "input_number" || d == "select" ||
|
||||
d == "input_select" || d == "media_player" || d == "vacuum" || d == "cover" || d == "sun" || d == "person" || d == "timer")
|
||||
return {TapRoute::CARD, "", true};
|
||||
if (open) return d == "light" || d == "climate" || d == "vacuum" || d == "fan" ? Legacy{TapRoute::OVERLAY, "", true} : Legacy{TapRoute::CARD, "", false};
|
||||
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") return {TapRoute::ACTION, d + ".toggle", false};
|
||||
if (d == "scene" || d == "script") return {TapRoute::ACTION, d + ".turn_on", false};
|
||||
if (d == "button" || d == "input_button") return {TapRoute::ACTION, d + ".press", false};
|
||||
return {TapRoute::NONE, "", false};
|
||||
};
|
||||
const char *domains[] = {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor",
|
||||
"binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player",
|
||||
"button", "input_button", "sun", "timer", "person"};
|
||||
// What validate_layout accepted before 0.2.67: `toggle` only on these six domains.
|
||||
auto toggled_before = [](const std::string &d) {
|
||||
return d == "light" || d == "switch" || d == "input_boolean" || d == "fan" || d == "media_player" || d == "climate";
|
||||
};
|
||||
int compared = 0;
|
||||
// "action" without an action (an app before 0.2.67) taps like firmware 0.2.56 did with a value it didn't know: automatically.
|
||||
for (const char *domain : domains) for (const char *choice : {"auto", "detail", "toggle", "none", "action"})
|
||||
for (const char *state : {"on", "off", "active", "idle", "open"}) for (bool hold : {false, true}) {
|
||||
Tile tile = make((std::string(domain) + ".x").c_str(), state);
|
||||
tile.tap = choice;
|
||||
Tap now = tap_route(tile, hold);
|
||||
if (std::string(choice) == "toggle" && !toggled_before(domain)) {
|
||||
// New in 0.2.58: a short tap toggles any entity the app allowed it for; holding still opens the card as before.
|
||||
if (!hold) { assert(now.route == TapRoute::ACTION && now.service == std::string(domain) + ".toggle"); continue; }
|
||||
}
|
||||
Legacy before = legacy(tile, hold);
|
||||
assert(now.route == before.route && now.service == before.service);
|
||||
if (now.route != TapRoute::ACTION) assert(now.busy == before.busy);
|
||||
++compared;
|
||||
}
|
||||
assert(compared == 1020); // 22 domains x 5 choices x 5 states x 2 gestures, less the 80 new short toggles
|
||||
// The example of issue #7: a short tap on a cover set to On / off sends cover.toggle; holding opens its card.
|
||||
Tile issue7 = make("cover.curtain", "open", feature::COVER_OPEN | feature::COVER_CLOSE | feature::COVER_STOP);
|
||||
issue7.tap = "toggle";
|
||||
assert(tap_route(issue7, false).route == TapRoute::ACTION && tap_route(issue7, false).service == "cover.toggle");
|
||||
assert(tap_route(issue7, true).route == TapRoute::CARD);
|
||||
issue7.tap = "auto";
|
||||
assert(tap_route(issue7, false).route == TapRoute::CARD && tap_route(issue7, false).busy);
|
||||
// An action of the tile's own choosing (firmware 0.2.58): a short tap performs it, holding still opens the card.
|
||||
issue7.tap = "action";
|
||||
issue7.edit_extra().action = "cover.set_cover_position";
|
||||
issue7.edit_extra().action_data = {{"position", "50"}};
|
||||
assert(tap_route(issue7, false).route == TapRoute::CUSTOM && tap_route(issue7, false).service == "cover.set_cover_position");
|
||||
assert(tap_route(issue7, true).route == TapRoute::CARD);
|
||||
assert(runtime_tiles::valid_action("cover.toggle") && runtime_tiles::valid_action("sonos.snapshot") && runtime_tiles::valid_action("homeassistant.turn_on"));
|
||||
assert(!runtime_tiles::valid_action("cover") && !runtime_tiles::valid_action("Cover.toggle") && !runtime_tiles::valid_action("a.b.c") &&
|
||||
!runtime_tiles::valid_action(".toggle") && !runtime_tiles::valid_action("cover.") && !runtime_tiles::valid_action("cover.to ggle"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user