Files
homeassistant_espscreen/tests/test_state_words.py
T
Max Gramser 45fcbdd807 Keep a screen's updates going when a sensor value has too many digits to round (0.2.69)
rounded_state raised decimal.InvalidOperation for a value beyond Decimal's
28-digit context, such as 1e30; the exception escaped state_message and the
whole screen's sync stalled. Such a value now passes through unrounded.
App only, firmware stays 0.2.58. Found in a review of 0.2.67.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:12:45 +02:00

176 lines
13 KiB
Python

"""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('1e30', 1), '1e30', 'too many digits to round: the value stays and the sync goes on')
self.assertEqual(rounded_state('123456789012345678901234567890.5', 1), '123456789012345678901234567890.5')
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()