Files
Max Gramser e7e173d3b7 Give a lamp's modes a page: the effects, palettes, presets, speed and intensity of a WLED behind its colour card, with a drum picker and the effect's name on the tile (0.2.83)
A light that offers effects gets a sparkles key at the top right of its colour
card. It opens a page with a row per thing the lamp offers, all from Home
Assistant: the light's effect and the select entities of its device (a WLED's
colour palette, preset and playlist) with what they are set to, and a slider per
number entity of the device (speed, intensity). Names are Home Assistant's own
English entity translations, icons its frontend's; nothing about a brand is
hardcoded beyond a preferred order of translation keys.

A row opens LVGL's roller with every name Home Assistant lists at that moment,
asked for when the picker opens (esphome.screen_options, answered with
op: options, one page of names per message), the effects alphabetically with
Solid on top. The check key at the top right sends the choice as one action;
the back key sends nothing. A slider sends number.set_value when let go. While
an effect runs, the tile names it instead of the brightness.

Firmware 0.2.70: effects_page.h (built when it opens, gone when it closes),
Extra.effect/option_rows/number_rows, options_request, a hidden roller widget
so ESPHome compiles LVGL's roller, five firmware-only glyphs. App 0.2.83:
light_effects.py, the effect attribute, the device rows in the state message,
card_options_loop. Checked on the host with a virtual finger (35/35 on both
boards) and compiled for both ESP32 boards; CYD 1,675,424 B (91.3 %).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 21:38:20 +02:00

161 lines
12 KiB
Python

"""A light's effects page (app 0.2.83 / firmware 0.2.70): effects, palettes, presets, speed and intensity of a WLED.
- The tile's state message carries the light's `effect` and the rows of its device: the selects and numbers Home
Assistant lists for it, named in English as its frontend names them, with its icons, in a fixed preference order.
- A picker asks for its names when it opens (`esphome.screen_options`); the app answers one page per message, under
the screen's 4096-byte limit, the effects alphabetically with "Solid" first, a select's options as Home Assistant
lists them, and only for a light on the screen's layout or a select on such a light's device.
The values below are what Home Assistant 2026.9 reports for a WLED 16.0.1 in a Dutch installation.
"""
import json
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import light_effects # noqa: E402
import tile_icons # noqa: E402
from core import ATTRS, encode, extras, state_message # noqa: E402
LIGHT = 'light.vuelta'
EFFECTS = ['Solid', 'Blink', 'Breathe', 'Wipe', 'Wipe Random', 'Random Colors', 'Sweep', 'Dynamic', 'Colorloop', 'Rainbow',
'TV Simulator', 'Dynamic Smooth', 'PS Volcano', 'Color Clouds', 'Slow Transition', 'Akemi', 'Sparkle+', 'Noise 1']
PALETTES = ['* Color 1', '* Color Gradient', 'Analogous', 'April Night', 'Aurora', 'Default', 'Rainbow']
def wled():
"""(states, device registry entries, registry index) of a WLED as Home Assistant reports it in Dutch."""
states = {
LIGHT: {'state': 'on', 'attributes': {'friendly_name': 'VUELTA', 'supported_color_modes': ['rgb'], 'effect': 'TV Simulator',
'effect_list': EFFECTS, 'brightness': 255, 'supported_features': 36, 'icon': 'mdi:lamp'}},
'select.vuelta_kleurenpalet': {'state': 'Aurora', 'attributes': {'friendly_name': 'VUELTA Kleurenpalet', 'options': PALETTES}},
'select.vuelta_voorinstelling': {'state': 'unknown', 'attributes': {'friendly_name': 'VUELTA Voorinstelling', 'options': ['Lava lamp', 'Moving blue', 'Simple White', 'nice1']}},
'select.vuelta_afspeellijst': {'state': 'unknown', 'attributes': {'friendly_name': 'VUELTA Afspeellijst', 'options': ['test']}},
'select.vuelta_live_override': {'state': '0', 'attributes': {'friendly_name': 'VUELTA Live override', 'options': ['0', '1', '2']}},
'number.vuelta_snelheid': {'state': '128', 'attributes': {'friendly_name': 'VUELTA Snelheid', 'min': 0, 'max': 255, 'step': 1}},
'number.vuelta_intensiteit': {'state': '64', 'attributes': {'friendly_name': 'VUELTA Intensiteit', 'min': 0, 'max': 255, 'step': 1}},
'switch.vuelta_nachtlampje': {'state': 'off', 'attributes': {'friendly_name': 'VUELTA Nachtlampje'}},
'sensor.vuelta_ip': {'state': '192.168.146.106', 'attributes': {}},
}
keys = {LIGHT: ('segment', None), 'select.vuelta_kleurenpalet': ('color_palette', 'config'), 'select.vuelta_voorinstelling': ('preset', None),
'select.vuelta_afspeellijst': ('playlist', None), 'select.vuelta_live_override': ('live_override', 'config'),
'number.vuelta_snelheid': ('speed', 'config'), 'number.vuelta_intensiteit': ('intensity', 'config'),
'switch.vuelta_nachtlampje': ('nightlight', 'config'), 'sensor.vuelta_ip': ('ip', 'diagnostic')}
device = [{'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': key, 'entity_category': category,
'original_name': states[eid]['attributes'].get('friendly_name', '').replace('VUELTA ', '') or None}
for eid, (key, category) in keys.items()]
return states, device, {item['entity_id']: item for item in device}
WORDS = {'component.wled.entity.select.color_palette.name': 'Color palette', 'component.wled.entity.select.preset.name': 'Preset',
'component.wled.entity.select.playlist.name': 'Playlist', 'component.wled.entity.number.speed.name': 'Speed',
'component.wled.entity.number.intensity.name': 'Intensity', 'component.wled.entity.light.segment.name': 'Segment {segment}'}
ICONS = {'entity': {'wled': {'number': {'speed': {'default': 'mdi:speedometer'}},
'select': {'color_palette': {'default': 'mdi:palette-outline'}, 'playlist': {'default': 'mdi:play-speed'},
'preset': {'default': 'mdi:playlist-play'}}}}}
def icon_of(eid, state, attrs, entry):
return tile_icons.ha_icon(attrs) or tile_icons.default_glyph(eid, state, attrs, entry, ICONS)
class RowsOfTheDevice(unittest.TestCase):
def test_the_light_gets_its_effect_and_the_rows_of_its_device(self):
states, device, index = wled()
self.assertIn('effect', ATTRS)
extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS, icon_of=icon_of, device_name='VUELTA')
self.assertEqual([r['n'] for r in extra['rows']], ['Color palette', 'Preset', 'Playlist'])
self.assertEqual([r['e'] for r in extra['rows']], ['select.vuelta_kleurenpalet', 'select.vuelta_voorinstelling', 'select.vuelta_afspeellijst'])
self.assertEqual([(r['s'], r['c']) for r in extra['rows']], [('Aurora', 7), ('unknown', 4), ('unknown', 1)])
self.assertEqual([r['i'] for r in extra['rows']], ['F0E0C', 'F0411', 'F08FF'])
self.assertEqual(extra['nums'], [{'e': 'number.vuelta_snelheid', 'n': 'Speed', 'lo': 0, 'hi': 255, 'st': 1, 'v': 128, 'i': 'F04C5'},
{'e': 'number.vuelta_intensiteit', 'n': 'Intensity', 'lo': 0, 'hi': 255, 'st': 1, 'v': 64}])
message = state_message(0, {'entity': LIGHT, 'name': ''}, states, extra)
self.assertEqual(message['a']['effect'], 'TV Simulator')
self.assertNotIn('effect_list', message['a'])
self.assertLess(len(encode(message).encode()), 1200)
# The live override select (options 0/1/2), the switches and the diagnostic sensor stay off the page.
self.assertEqual(light_effects.related(LIGHT, device, states), ('select.vuelta_kleurenpalet', 'select.vuelta_voorinstelling',
'select.vuelta_afspeellijst', 'number.vuelta_snelheid', 'number.vuelta_intensiteit'))
def test_names_fall_back_to_home_assistants_own_without_the_device_name(self):
states, device, index = wled()
extra = extras({'entity': LIGHT}, states, device=device, entries=index, words={}, device_name='VUELTA')
self.assertEqual([r['n'] for r in extra['rows']], ['Kleurenpalet', 'Voorinstelling', 'Afspeellijst'])
self.assertEqual([r['n'] for r in extra['nums']], ['Snelheid', 'Intensiteit'])
self.assertNotIn('i', extra['rows'][0])
# A name with a placeholder is no name ("Segment {segment}").
self.assertEqual(light_effects.entity_name(LIGHT, index[LIGHT], states, WORDS), 'VUELTA')
def test_hidden_disabled_and_empty_entities_are_left_out(self):
states, device, index = wled()
index['select.vuelta_kleurenpalet']['hidden_by'] = 'user'
index['number.vuelta_snelheid']['disabled_by'] = 'integration'
states['select.vuelta_afspeellijst']['attributes']['options'] = []
extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS)
self.assertEqual([r['e'] for r in extra['rows']], ['select.vuelta_voorinstelling'])
self.assertEqual([r['e'] for r in extra['nums']], ['number.vuelta_intensiteit'])
# A light without such a device has no rows and no extra block.
self.assertIsNone(extras({'entity': 'light.kitchen'}, {'light.kitchen': {'state': 'on', 'attributes': {}}}, device=[]))
def test_at_most_three_selects_and_two_numbers_in_preference_order(self):
states, device, index = wled()
for n in range(4):
eid = f'select.vuelta_extra_{n}'
states[eid] = {'state': 'a', 'attributes': {'friendly_name': f'VUELTA Zzz {n}', 'options': ['a', 'b']}}
device.append({'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': f'extra_{n}', 'original_name': f'Zzz {n}'})
eid = f'number.vuelta_more_{n}'
states[eid] = {'state': '1', 'attributes': {'friendly_name': f'VUELTA More {n}', 'min': 0, 'max': 10}}
device.append({'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': f'more_{n}', 'original_name': f'More {n}'})
extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS)
self.assertEqual([r['n'] for r in extra['rows']], ['Color palette', 'Preset', 'Playlist'])
self.assertEqual([r['n'] for r in extra['nums']], ['Speed', 'Intensity'])
class ThePickersNames(unittest.TestCase):
def test_effects_are_alphabetical_with_solid_first(self):
states, _, _ = wled()
names = light_effects.effect_options(states[LIGHT]['attributes'])
self.assertEqual(names[:4], ['Solid', 'Akemi', 'Blink', 'Breathe'])
self.assertEqual(names[-3:], ['TV Simulator', 'Wipe', 'Wipe Random'])
self.assertEqual(len(names), len(EFFECTS))
self.assertEqual(light_effects.effect_options({'effect_list': ['candle', 'off']}), ['candle', 'off'])
self.assertEqual(light_effects.effect_options({}), [])
def test_only_a_light_on_the_layout_or_a_select_of_its_device_is_answered(self):
states, device, _ = wled()
device_of = lambda light: device if light == LIGHT else [] # noqa: E731
self.assertEqual(light_effects.options_for(LIGHT, states, [LIGHT], device_of)[0], 'Solid')
self.assertIsNone(light_effects.options_for(LIGHT, states, ['light.other'], device_of))
self.assertEqual(light_effects.options_for('select.vuelta_kleurenpalet', states, [LIGHT], device_of), PALETTES)
self.assertIsNone(light_effects.options_for('select.vuelta_live_override', states, [LIGHT], device_of))
self.assertIsNone(light_effects.options_for('select.vuelta_kleurenpalet', states, ['light.other'], device_of))
self.assertIsNone(light_effects.options_for('switch.vuelta_nachtlampje', states, [LIGHT], device_of))
# A light without the EFFECT feature answers nothing, whatever its attributes say.
states[LIGHT]['attributes']['supported_features'] = 32
self.assertIsNone(light_effects.options_for(LIGHT, states, [LIGHT], device_of))
def test_pages_stay_under_the_message_limit(self):
names = [f'Effect number {n} with a longer name' for n in range(400)]
pages = light_effects.pages(names)
self.assertGreater(len(pages), 1)
self.assertEqual([n for page in pages for n in page], names)
for i, page in enumerate(pages):
self.assertLess(len(encode(light_effects.message(LIGHT, i, pages)).encode()), 4096)
message = light_effects.message(LIGHT, 99, pages)
self.assertEqual((message['op'], message['i'], message['n']), ('options', len(pages) - 1, len(pages)))
# WLED's 216 effects fit in one page.
wled_names = [f'Effect {n}' for n in range(216)]
self.assertEqual(len(light_effects.pages(wled_names)), 1)
self.assertEqual(light_effects.pages([]), [[]])
self.assertEqual(light_effects.message(LIGHT, 0, light_effects.pages([]))['o'], [])
def test_the_answer_is_what_the_firmware_parses(self):
message = light_effects.message('select.x', 0, light_effects.pages(['A', 'B']))
self.assertEqual(json.loads(encode(message)), {'v': 1, 'op': 'options', 'e': 'select.x', 'i': 0, 'n': 1, 'o': ['A', 'B']})
if __name__ == '__main__':
unittest.main()