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:
Max Gramser
2026-09-17 15:23:03 +02:00
parent d60a0f122b
commit 6809d3b941
32 changed files with 3210 additions and 105 deletions
+173
View File
@@ -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()