Files
Max Gramser 0e856a18fc Speak the app's own texts from the translations: its messages and labels in the editor's language, the words it sends the screens in theirs
Every error, delivery status, update result and label the editor shows is an `addon` text of en.json now, in the language
of the editor's request: its X-ESP-Screens-Language header, or ?language= for the live updates, which an EventSource sends
without headers. A text made in the background and read later (a delivery status, an update result in updates.json, why a
screen can't show an alert) is kept in English with its key (i18n.Text, i18n.english) and written in the editor's language
when it is shown (i18n.shown); updates.json keeps the English message next to the key, for an older app.

The words the app sends to the screens come in the screens' language (Settings -> Language & region): the names of the
built-in tiles, the top bar's words, dates and numbers, the history card's words, the vacuum chips, the forecast's day
names and the Identify card, and so does the notification of a stopped update round. The top bar and the history card
take a binary sensor's words from screen.ha.binary, as the tiles do, which makes test_binary_words pass again; three tests
follow Home Assistant's new words there (Detected, Clear).

What stays English: what Home Assistant gets as data (a tile event's answer, the layout sensors, entity names), the logs
and the firmware jobs' logs, the screens' protocol words, and the Claude skill, which is built from the English tables in
core.py and tile_icons.py; en.json repeats those and tests/test_addon_translations.py keeps both equal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:06:12 +02:00

177 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. A binary sensor says what its tile says, Home
# Assistant's word for its class from the translations (screen.ha.binary, app 0.2.90).
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), ('Detected', 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()