Show binary sensors in Home Assistant's words and grey out what is off (0.2.62)

A binary sensor's tile and card now say what Home Assistant says for its
device class, such as Open or Closed for a door and Wet or Dry for a leak
sensor. The screen maps the class it already receives, with the same words
as the top bar and the history card; a test keeps both tables equal. A
history card still waiting for its answer no longer shows the previous
card's word.

Lights and binary sensors that are off turn grey, as in Home Assistant, and
a light without an icon of its own shows a crossed-out bulb while it is off,
on the tile and in the top bar. A chosen icon stays and turns grey.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-17 08:32:22 +02:00
parent 8a587303d7
commit 7311a1cdce
18 changed files with 256 additions and 10 deletions
+74
View File
@@ -0,0 +1,74 @@
"""Binary sensors in Home Assistant's words (app 0.2.62 / firmware 0.2.53): a door tile says Open or Closed.
The screen maps the device class itself (`tile_controls::binary_state_text`), from the `device_class` attribute every
state message has carried since app 0.2.23: nothing new on the wire, so it works with older apps too. These tests keep
its table equal to the add-on's `header_bar.BINARY_STATES`, which the top bar and the history card use.
"""
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402
import history_card # noqa: E402
from core import state_message # noqa: E402
CONTROLS = (ROOT / 'components/smart_display/tile_controls.h').read_text()
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml')
def firmware_words():
block = re.search(r'constexpr BinaryWords BINARY_WORDS\[\] = \{(.*?)\n\};', CONTROLS, re.S)
return {device_class: (on, off) for device_class, on, off in re.findall(r'\{"(\w+)", "([^"]*)", "([^"]*)"\}', block.group(1))}
class BinaryWords(unittest.TestCase):
def test_the_screen_carries_the_add_on_s_words(self):
self.assertEqual(firmware_words(), header_bar.BINARY_STATES)
def test_top_bar_and_history_card_say_the_same(self):
for device_class, (on, off) in header_bar.BINARY_STATES.items():
attrs = {'device_class': device_class}
for raw, words in (('on', on), ('off', off)):
self.assertEqual(header_bar.value('binary_sensor.x', {'state': raw, 'attributes': attrs}), (words, None))
self.assertEqual(history_card.state_label('binary_sensor', raw, attrs), words)
# Without a class, or with one Home Assistant adds later, all of them say On and Off.
for attrs in ({}, {'device_class': 'future_class'}):
self.assertEqual(header_bar.value('binary_sensor.x', {'state': 'on', 'attributes': attrs}), ('On', None))
self.assertEqual(history_card.state_label('binary_sensor', 'off', attrs), 'Off')
self.assertIn('return on ? "On" : "Off";', CONTROLS)
def test_the_state_message_already_carries_the_class(self):
tile = {'entity': 'binary_sensor.front_door', 'name': ''}
states = {'binary_sensor.front_door': {'state': 'on', 'attributes': {'device_class': 'door', 'friendly_name': 'Front door'}}}
message = state_message(0, tile, states)
self.assertEqual((message['state'], message['a']['device_class']), ('on', 'door'))
self.assertNotIn('x', message)
self.assertIn('tile.device_class=string(a["device_class"],24);', TILES)
self.assertLessEqual(max(map(len, header_bar.BINARY_STATES)), 24)
def test_tile_card_and_heading_use_them_for_binary_sensors_only(self):
self.assertIn('else if (d == "binary_sensor" && (value == "on" || value == "off")) '
'value = tile_controls::binary_state_text(t.device_class, value == "on");', TILES)
self.assertIn('if(t.domain()=="binary_sensor"&&(t.state=="on"||t.state=="off"))'
'return tile_controls::binary_state_text(t.device_class,t.state=="on");', TILES)
# A card still waiting for its history must not take the words of the card opened before it.
self.assertIn('if(history.entity==t.entity)for(const auto &pair:history.words)', TILES)
def test_the_fonts_carry_every_word(self):
letters = set(''.join(on + off for on, off in header_bar.BINARY_STATES.values()))
self.assertLessEqual(letters, set(header_bar.GLYPHS))
for name in PROFILES:
text = (ROOT / name).read_text()
for font in ('headline', 'watch_value', 'label', 'sublabel', 'sublabel_big'):
block = re.search(r'id: ' + font + r'\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S)
self.assertIsNotNone(block, (name, font))
glyphs = {a or b for a, b in re.findall(r"'([^'])'|\"(')\"", block.group(1))}
self.assertLessEqual(letters, glyphs, (name, font))
if __name__ == '__main__':
unittest.main()
+38
View File
@@ -0,0 +1,38 @@
"""An off light and an off binary sensor look off, as in Home Assistant (app 0.2.62 / firmware 0.2.53).
Home Assistant's own light icon is a bulb that is crossed out while off (`mdi:lightbulb-off`), and every entity that is
off is grey. An icon of its own, or one chosen in ESP Screens, stays the same and turns grey, as in Home Assistant.
"""
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402
import tile_icons # noqa: E402
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
class OffLook(unittest.TestCase):
def test_the_fonts_carry_the_bulb_that_is_off(self):
self.assertEqual(tile_icons.GLYPHS['lightbulb-off'], 'F0E4F')
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'):
self.assertIn('"\\U000F0E4F" # lightbulb-off', (ROOT / name).read_text(), name)
def test_the_screen_crosses_out_its_own_bulb_and_greys_what_is_off(self):
self.assertIn('if (d == "light" && tile.state == "off") return "\\U000F0E4F";', TILES)
self.assertIn('(t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent', TILES)
def test_the_top_bar_does_the_same(self):
light = lambda state, **attrs: {'state': state, 'attributes': attrs}
self.assertEqual(header_bar.auto_icon('light.hall', light('off')), tile_icons.GLYPHS['lightbulb-off'])
self.assertEqual(header_bar.auto_icon('light.hall', light('on')), tile_icons.GLYPHS['lightbulb'])
self.assertEqual(header_bar.auto_icon('light.hall', light('unavailable')), tile_icons.GLYPHS['lightbulb'])
self.assertEqual(header_bar.auto_icon('light.lamp', light('off', icon='mdi:lamp')), tile_icons.GLYPHS['lamp'])
self.assertIsNone(header_bar.accent('light.hall', light('off')))
if __name__ == '__main__':
unittest.main()
+8
View File
@@ -170,5 +170,13 @@ int main() {
assert(old_robot2.choice('s') && old_robot2.choice('s')->labels[1] == "Normal" && old_robot2.choice('s')->current == "balanced");
rows = vacuum_rows(old_robot2, 0);
assert(rows.suction && !rows.water && !choice_action(old_robot2, 'm', "mop").valid());
// Binary sensors (firmware 0.2.53+) in the words of their device class; no class or an unknown one says On and Off.
assert(!strcmp(binary_state_text("door", true), "Open") && !strcmp(binary_state_text("door", false), "Closed"));
assert(!strcmp(binary_state_text("motion", true), "Motion") && !strcmp(binary_state_text("motion", false), "No motion"));
assert(!strcmp(binary_state_text("moisture", true), "Wet") && !strcmp(binary_state_text("moisture", false), "Dry"));
assert(!strcmp(binary_state_text("battery_charging", false), "Not charging") && !strcmp(binary_state_text("battery", true), "Low"));
assert(!strcmp(binary_state_text("", true), "On") && !strcmp(binary_state_text("", false), "Off"));
assert(!strcmp(binary_state_text("future_class", true), "On") && !strcmp(binary_state_text("Door", true), "On"));
return 0;
}