45fcbdd807
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>
176 lines
13 KiB
Python
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()
|