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>
This commit is contained in:
Max Gramser
2026-09-18 21:38:20 +02:00
parent 86e6ce79fe
commit e7e173d3b7
33 changed files with 1409 additions and 17 deletions
+210
View File
@@ -0,0 +1,210 @@
"""A light's effects page (app 0.2.83 / firmware 0.2.70): what the screen shows for a lamp with modes, such as a WLED.
Home Assistant is the source of every name and list here. The light's own `effect_list` is one row; the select and
number entities on the light's device (WLED: color palette, preset, playlist, speed, intensity) are the others, named
as Home Assistant names them in English (frontend/get_translations `entity`) with the icons its frontend shows. Nothing
about a brand is hardcoded: a lamp with only an effect list gets one row, a lamp without any gets no page.
The current values travel in the tile's state message (`x.rows`, `x.nums`); the lists themselves are asked for when a
picker opens (`esphome.screen_options` -> `op: options`), one page per message, so the screen holds no list while the
picker is closed and always shows what Home Assistant has at that moment.
"""
import json
# LightEntityFeature.EFFECT
EFFECT_FEATURE = 4
# Rows the effects page has room for: the effect plus three selects, and two numbers (a Guition shows them all).
SELECT_ROWS = 3
NUMBER_ROWS = 2
# Where these translation keys occur they come first, in this order; everything else follows by name. This is
# presentation only: the rows themselves still come from the device.
PREFERRED_SELECTS = ('color_palette', 'preset', 'playlist')
PREFERRED_NUMBERS = ('speed', 'intensity')
# A message to the screen is at most 4096 bytes; the names of one page stay well under it.
PAGE_BYTES = 3400
NAME_LIMIT = 48
OPTIONS_EVENT = 'esphome.screen_options'
def supports_effects(attrs):
"""Whether the light offers effects: Home Assistant's EFFECT feature and a list to choose from."""
features = (attrs or {}).get('supported_features')
return isinstance(features, int) and bool(features & EFFECT_FEATURE) and isinstance((attrs or {}).get('effect_list'), list)
def effect_options(attrs):
"""The light's effects as the picker lists them: alphabetically, with "Solid" (WLED's plain colour) first."""
names = [str(v) for v in (attrs or {}).get('effect_list') or [] if isinstance(v, (str, int, float)) and not isinstance(v, bool)]
names = [n for n in names if n.strip()]
solid = [n for n in names if n.strip().lower() == 'solid']
rest = sorted((n for n in names if n.strip().lower() != 'solid'), key=lambda n: (n.casefold(), n))
return solid[:1] + rest
def _numeric(options):
"""A select whose every option is a bare number (WLED's live override: 0, 1, 2) is a technical setting, not a mode."""
return all(isinstance(o, str) and o.strip().lstrip('-').isdigit() for o in options)
def _qualifies(item, states):
"""A select or number of the device the page can show: enabled, not hidden, not diagnostic."""
eid = item.get('entity_id') or ''
if item.get('disabled_by') or item.get('hidden_by') or item.get('entity_category') == 'diagnostic':
return False
state = states.get(eid)
if not isinstance(state, dict):
return False
attrs = state.get('attributes') or {}
if eid.startswith('select.'):
options = attrs.get('options')
return isinstance(options, list) and bool(options) and all(isinstance(o, str) for o in options) and not _numeric(options)
if eid.startswith('number.'):
try:
return float(attrs.get('min')) < float(attrs.get('max'))
except (TypeError, ValueError):
return False
return False
def _order(items, preferred, names):
def rank(item):
key = item.get('translation_key') or ''
return (preferred.index(key) if key in preferred else len(preferred), names[item['entity_id']].casefold())
return sorted(items, key=rank)
def entity_name(eid, entry, states, words):
"""Home Assistant's English name of a device's entity: its integration's translation for the entity's key
(component.wled.entity.select.color_palette.name = "Color palette"), else the name Home Assistant shows, without the
device's name in front of it ("VUELTA Kleurenpalet" is "Kleurenpalet")."""
entry = entry if isinstance(entry, dict) else {}
domain = eid.split('.', 1)[0]
if isinstance(words, dict) and entry.get('platform') and entry.get('translation_key'):
word = words.get(f"component.{entry['platform']}.entity.{domain}.{entry['translation_key']}.name")
if isinstance(word, str) and word and '{' not in word:
return word
name = entry.get('name') or entry.get('original_name') or (states.get(eid, {}).get('attributes') or {}).get('friendly_name') or eid
return str(name)
def strip_device_name(name, device_name):
""""VUELTA Kleurenpalet" without the device's name, as Home Assistant's device page shows it."""
if isinstance(device_name, str) and device_name and name.lower().startswith(device_name.lower() + ' '):
return name[len(device_name) + 1:] or name
return name
def siblings(entity, device, states):
"""The select and number entities on the light's device that its effects page shows, in page order."""
selects, numbers = [], []
for item in device or ():
eid = item.get('entity_id') or ''
if eid == entity or not _qualifies(item, states):
continue
(selects if eid.startswith('select.') else numbers).append(item)
return selects, numbers
def related(entity, device, states):
"""Entity ids the light's card reads besides its own, for the manager's watch list."""
selects, numbers = siblings(entity, device, states)
return tuple(item['entity_id'] for item in selects + numbers)
def rows(entity, device, states, entries=None, words=None, icon_of=None, device_name=None):
"""The `rows` (selects) and `nums` (numbers) of the light's effects page for the tile's extra block, or {}.
`entries` is the registry by entity id (names and translation keys), `words` Home Assistant's English entity
translations, `icon_of(eid, state, attributes, entry)` the codepoint of the icon Home Assistant shows, or None."""
selects, numbers = siblings(entity, device, states)
entries = entries or {}
names = {}
for item in selects + numbers:
eid = item['entity_id']
entry = entries.get(eid, item)
names[eid] = strip_device_name(entity_name(eid, entry, states, words), device_name)
result = {}
found = []
for item in _order(selects, PREFERRED_SELECTS, names)[:SELECT_ROWS]:
eid = item['entity_id']
state = states[eid]
attrs = state.get('attributes') or {}
row = {'e': eid, 'n': _short(names[eid], 32), 's': _short(state.get('state') if isinstance(state.get('state'), str) else '', NAME_LIMIT),
'c': len(attrs.get('options') or [])}
icon = icon_of(eid, state.get('state'), attrs, entries.get(eid, item)) if icon_of else None
if icon:
row['i'] = icon
found.append(row)
if found:
result['rows'] = found
found = []
for item in _order(numbers, PREFERRED_NUMBERS, names)[:NUMBER_ROWS]:
eid = item['entity_id']
state = states[eid]
attrs = state.get('attributes') or {}
row = {'e': eid, 'n': _short(names[eid], 32), 'lo': _number(attrs.get('min'), 0), 'hi': _number(attrs.get('max'), 100),
'st': _number(attrs.get('step'), 1)}
value = _number(state.get('state'), None)
if value is not None:
row['v'] = value
icon = icon_of(eid, state.get('state'), attrs, entries.get(eid, item)) if icon_of else None
if icon:
row['i'] = icon
found.append(row)
if found:
result['nums'] = found
return result
def options_for(entity, states, layout_entities=(), device_of=None):
"""The names a picker lists for `entity`, or None when the screen may not ask for them: a light on the layout (its
effects) or a select on the device of such a light (its options). `device_of(light)` gives that device's entries."""
state = states.get(entity)
if not isinstance(state, dict):
return None
attrs = state.get('attributes') or {}
if entity.startswith('light.'):
return effect_options(attrs) if entity in layout_entities and supports_effects(attrs) else None
if entity.startswith('select.'):
for light in layout_entities:
if light.startswith('light.') and device_of and entity in related(light, device_of(light), states):
options = attrs.get('options')
return [str(o) for o in options if isinstance(o, str)] if isinstance(options, list) else None
return None
def pages(names):
"""The names cut into pages whose message stays under the screen's limit (216 WLED effects are one page)."""
result, page, size = [], [], 0
for name in names:
name = _short(name, NAME_LIMIT)
cost = len(json.dumps(name, ensure_ascii=False).encode()) + 1
if page and size + cost > PAGE_BYTES:
result.append(page)
page, size = [], 0
page.append(name)
size += cost
if page or not result:
result.append(page)
return result
def message(entity, page, all_pages):
"""One page of a picker's names, as the screen parses it (`op: options`)."""
page = max(0, min(int(page), len(all_pages) - 1))
return {'v': 1, 'op': 'options', 'e': entity, 'i': page, 'n': len(all_pages), 'o': all_pages[page]}
def _short(value, limit):
value = str(value if value is not None else '')
return value if len(value) <= limit else value[:limit - 1] + '…'
def _number(value, fallback):
try:
number = float(value)
except (TypeError, ValueError):
return fallback
if number != number or number in (float('inf'), float('-inf')):
return fallback
return int(number) if number.is_integer() else round(number, 3)