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