Take what a tile can do, its tap actions, words and icons from Home Assistant (0.2.67)

GitHub issue #7 asked for cover.toggle on tap. ESP Screens now asks Home
Assistant which actions each entity has instead of keeping lists: the editor
offers On / off (covers included), sliders, controls and displays only where
Home Assistant has the action, and refuses only new unsupported settings.
Perform action runs any action Home Assistant offers for the tile's entity,
under its own names and fields; the screen sends it with a call id and says
Refused when Home Assistant refuses. Sensors round by display precision, and
tiles, the top bar, timelines, vacuum chips and the inspector use Home
Assistant's words where they showed raw states. Tiles show Home Assistant's
icons, picked as its frontend does (the fonts grow to 309 glyphs).

Existing screens keep every setting and tap as before: stored layouts load
leniently, legacy taps route the same (1020 combinations), and messages are
byte-identical without Home Assistant's words and icons. Firmware 0.2.58.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-17 15:23:03 +02:00
parent d60a0f122b
commit 6809d3b941
32 changed files with 3210 additions and 105 deletions
+19
View File
@@ -1,3 +1,22 @@
## 0.2.67 (firmware 0.2.58)
Tiles take what they can do, their words and their icons from Home Assistant, and a tap can run any action Home Assistant offers.
- **On / off for blinds, curtains and garage doors.** Under **On tap**, a cover now has **On / off**: a tap opens or closes it, and stops it while it moves, with Home Assistant's own `cover.toggle`. Holding the tile still opens its card. The same goes for everything else Home Assistant can toggle, such as a script, not only for lights, switches, fans, speakers and climate.
- **Perform action.** Under **On tap**, choose **Perform action** and pick one of the actions Home Assistant has for that entity, under Home Assistant's own names: for a blind that is Open cover, Close cover, Stop cover, Toggle cover, Set cover position and the tilt actions, plus what its integration adds, such as Snapshot for a Sonos. A chosen action shows its fields as Home Assistant describes them: a number with its unit, On or Off, a choice from a list, or text. A field you leave empty is not sent, a field Home Assistant needs is marked until you fill it in, and only fields that fit the device show. Holding the tile still opens its card.
- **The choices come from Home Assistant.** ESP Screens asks Home Assistant which actions each entity has, instead of keeping a list of its own. The tile settings only offer what works: no On / off for a speaker that can't turn on and off, no small slider for a lamp that only switches, no position slider for a cover without a position, no graph for a sensor without numbers, and no forecast for a weather service without daily forecasts. An integration or device that adds an action shows it right away, without an update of ESP Screens.
- **Nothing you set changes by itself.** A tile keeps every setting it has. When Home Assistant can't do one, such as On / off on a Sonos, the tile settings show a warning and what to choose instead. A new setting Home Assistant doesn't support is refused when you save, also when Claude in Home Assistant asks for it.
- **Refused shows on the tile.** When Home Assistant refuses a tap's action, the tile says Refused for a few seconds instead of waiting with a spinner. This goes for Perform action, On / off and the other taps.
- **Numbers as Home Assistant shows them.** A sensor with a display precision in Home Assistant shows that many decimals on its tile and card: 21.456 °C becomes 21.5 °C when Home Assistant shows one decimal. A sensor without one keeps its value as it comes. Rounded values also mean fewer updates for the screen when only the hidden decimals change.
- **Home Assistant's words instead of raw states.** Where a tile or card showed a state as Home Assistant stores it, it now shows Home Assistant's own word: a blind says Open or Closing instead of open or closing, a speaker Playing, a robot Returning to dock, a washing machine Rinsing, and a select of an integration its option's name, such as Vacuum and mop. The top bar and history timelines do the same, the vacuum card's chips name a setting the screen has no short name for as the integration does (Off (raised brush)), and the editor's inspector says Heat/Cool instead of heat_cool. The words come from Home Assistant, so a new device brings its own. The screen's own words stay where it had them: On and Off, Docked, a door's Open and Closed, the person's Home and Away, the chips' Vac & mop and Normal.
- **Home Assistant's icons.** A tile without an icon of its own now shows the icon Home Assistant shows for it: a blind open or closed, a speaker that plays, a door that is open, a battery at its level, a thermostat for climate, a palette for a scene. The icon follows the state, as in Home Assistant, and the tile, the top bar and the editor show the same one. Where the screen lacks Home Assistant's icon, such as an integration's own or one a later Home Assistant adds, it keeps its own icon. An icon you chose stays.
- **A double-width tile gets a control that works.** When its usual control isn't there, such as open, stop and close on a skylight that only has a position, it takes the first one Home Assistant offers.
- **A scene or button that never ran works right away.** Home Assistant shows a scene, button or input button that was never used as unknown, and still lets you press it. The tile said Unavailable and did nothing; it now says Never run and runs when you tap it.
- **The entity list only shows what can be a tile.** Locks, trackers, counters and the other entities for the top bar no longer show under All or in a search, where adding one failed when saving. Input buttons have their own badge and show under Actions.
- **A click on the text under a setting no longer changes it.** Clicking the explanation under Direct control pressed the first choice above it.
- **Claude in Home Assistant can set it.** A tile event takes `action` and `data`, such as `action: cover.set_cover_position` with `data: {position: 50}`, and says what's missing when Home Assistant wouldn't take it. The skill also says what On / off does. Install it again from Settings → Claude to get it.
- Needs firmware 0.2.58 for On / off on covers and other entities that are new to it, Perform action, Refused on the tile, a scene or button that never ran, and the words and new icons on tiles: press **Update** on the screen. Until then a tap opens the card or taps as Automatic, as before. Everything else works right after updating the app, including the rounding and the words in the top bar, timelines, chips and inspector.
## 0.2.66 (firmware 0.2.57)
Camera images on the Guition: see who is at the door.
+3 -2
View File
@@ -40,7 +40,7 @@ def text():
colors = ', '.join(f"`{name}` ({item['label']})" for name, item in TILE_BACKGROUNDS.items() if item['color'])
suggested = ', '.join(f'`{name}`' for name in ALERT_SUGGESTED_ICONS)
groups = '\n'.join(f'- {group}: ' + ', '.join(f'`{name}` ({label})' for name, _, label in icons) for group, icons in tile_icons.GROUPS)
fixed = ', '.join(f'`{name}`' for name, _ in tile_icons.FIXED)
fixed = ', '.join(f'`{name}`' for name, _ in tile_icons.FIXED + tile_icons.HA_DEFAULTS)
endings = ', '.join(f'`{action}` ({label[0].lower() + label[1:]})' for action, label in ALERT_ENDINGS)
controls = '\n'.join(f'| `{domain}` | ' + ', '.join(f'`{key}` ({label.lower()})' for key, label in choices) + ' |'
for domain, choices in CONTROLS.items())
@@ -94,7 +94,8 @@ actions:
| `controls` | What you can operate on the tile itself (see below). |
| `display` | How the tile draws itself (see below). |
| `icon`, `color` | An icon from the list further down, and one of the pastel colors. |
| `tap` | What a tap does: `auto`, `detail` (open the card), `toggle` or `none`. |
| `tap` | What a tap does: `auto`, `detail` (open the card), `toggle`, `action` or `none`. `toggle` works for anything Home Assistant can toggle for that entity, such as a light, a cover (open, close, or stop while it moves) or a speaker that turns on and off. Holding the tile still opens its card. |
| `action`, `data` | Perform action: an action Home Assistant offers for the tile's own entity, such as `cover.set_cover_position`, with `data` for its fields (`position: 50`). Giving `action` sets `tap` to `action`. The target is always the tile's entity. Only actions and fields Home Assistant lists for that entity are accepted; the answer says what is missing. |
| `entities` | Only for `esp_screens_order_tiles`: the entities in the order you want them. |
A tile with a control, a forecast or a sun path is drawn double-width on its own; you don't have to ask for that.
+142 -16
View File
@@ -20,7 +20,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.57'
FIRMWARE_VERSION = '0.2.58'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
@@ -457,6 +457,11 @@ def tile_options(data, current=None):
options[name] = TILE_SIZES.get(loose(value), str(value))
else:
options[name] = str(value).strip()
# Perform action (app 0.2.67): `action` names Home Assistant's action and `data` its fields; the tap follows.
if data.get('action') not in (None, ''):
options['action'] = {'action': str(data['action']).strip(), **({'data': data['data']} if isinstance(data.get('data'), dict) and data['data'] else {})}
if data.get('tap') in (None, ''):
options['tap'] = 'action'
if options.get('controls', 'none') != 'none' or options.get('display') in WIDE_ONLY:
options['size'] = 'wide'
return {key: value for key, value in options.items() if value not in (None, '')}
@@ -588,11 +593,61 @@ def layout_snapshot(screen, layout):
'page': slot // SLOTS_PER_PAGE + 1, 'row': slot % SLOTS_PER_PAGE // 2 + 1,
'column': 'right' if slot % 2 else 'left', 'slot': slot,
'size': options.get('size', 'single'), 'controls': options.get('controls', ''),
'display': options.get('display', 'standard')})
'display': options.get('display', 'standard'), 'tap': options.get('tap', 'auto'),
**({'action': options['action']} if options.get('tap') == 'action' and 'action' in options else {})})
return {'screen': screen.get('name', ''), 'node': screen.get('node') or '', 'title': layout.get('title', ''),
'pages': max([tile['page'] for tile in tiles], default=1), 'tiles': tiles}
def validate_layout(data):
# A tap's own action (app 0.2.67): Home Assistant's `domain.action` with data for its fields. It always acts on the tile's
# entity, so the keys that name a target stay out of the data. Text travels as data and every other value as a template
# Home Assistant renders back into that value (ESPHome's action data is text only); both stay small for the CYD.
ACTION_NAME = re.compile(r'[a-z0-9_]+\.[a-z0-9_]+')
ACTION_FIELD = re.compile(r'[a-z0-9_]{1,32}')
TARGET_KEYS = frozenset(('entity_id', 'device_id', 'area_id', 'floor_id', 'label_id'))
ACTION_MAX_FIELDS = 8
ACTION_MAX_VALUE = 400
ACTION_MAX_BYTES = 800
def action_for_screen(value):
"""A tap's own action as the firmware sends it (0.2.58+): {"s": action, "d": [[key, text]], "t": [[key, template]]}."""
pairs, templates = [], []
for key, item in (value.get('data') or {}).items():
if isinstance(item, str):
pairs.append([key, item])
else:
templates.append([key, '{{ %s | from_json }}' % json.dumps(json.dumps(item, ensure_ascii=False, separators=(',', ':'), allow_nan=False), ensure_ascii=False)])
act = {'s': value['action']}
if pairs:
act['d'] = pairs
if templates:
act['t'] = templates
return act
def validate_tap_action(value):
"""The stored form of a tap's own action; ValueError with what to change."""
if not isinstance(value, dict) or set(value) - {'action', 'data'}:
raise ValueError('Choose an action from the list.')
name, data = value.get('action'), value.get('data', {})
if not isinstance(name, str) or len(name) > 64 or not ACTION_NAME.fullmatch(name):
raise ValueError('Choose an action from the list.')
if not isinstance(data, dict) or len(data) > ACTION_MAX_FIELDS:
raise ValueError(f'An action on a tap sets at most {ACTION_MAX_FIELDS} fields.')
for key in data:
if not isinstance(key, str) or not ACTION_FIELD.fullmatch(key) or key in TARGET_KEYS:
raise ValueError(f'{key} is not a field an action on a tap can set.')
clean = {'action': name, **({'data': dict(data)} if data else {})}
try:
act = action_for_screen(clean)
size = len(json.dumps(act, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode())
except (TypeError, ValueError):
raise ValueError('A value of the action is something the screen cannot send.') from None
if size > ACTION_MAX_BYTES or any(len(item[1].encode()) > ACTION_MAX_VALUE for item in act.get('d', []) + act.get('t', [])):
raise ValueError('The values of the action are too long for the screen.')
return clean
def validate_layout(data, stored=False):
"""A layout as the editor, a tile event or the storage gives it. `stored`: loaded from the app's own data, where a
tile setting this version doesn't know (saved by a newer app) stays as it is instead of stopping the app."""
if not isinstance(data, dict):
raise ValueError('Invalid layout.')
title, tiles = data.get('title'), data.get('tiles')
@@ -611,9 +666,17 @@ def validate_layout(data):
raise ValueError('A tile name may contain at most 80 bytes.')
seen.add(tile['entity'])
item = {'entity': tile['entity'], 'name': name.strip()}
if stored and 'options' in tile:
try:
validate_layout({'title': 'stored', 'tiles': [{'entity': tile['entity'], 'options': tile['options']}]})
except ValueError:
# The screen ignores what its firmware doesn't know either.
item['options'] = dict(tile['options']) if isinstance(tile['options'], dict) else {}
clean.append(item)
continue
if 'options' in tile:
options = tile['options']
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls'}:
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls', 'action'}:
raise ValueError('Unknown tile settings.')
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
raise ValueError('Choose a pastel background color from the palette.')
@@ -621,15 +684,24 @@ def validate_layout(data):
raise ValueError('Choose an icon from the list.')
domain = tile['entity'].split('.')[0]
displays = DISPLAYS.get(domain, ('standard', 'watch'))
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
choices = {'tap': ('auto', 'detail', 'toggle', 'none', 'action'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
for key, allowed in choices.items():
if key in options and options[key] not in allowed:
raise ValueError('Invalid tile setting: ' + key)
# The five-day strip and the sun path only fit a double-width card.
if options.get('display') in WIDE_ONLY:
options = {**options, 'size': 'wide'}
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player','climate'}:
# On / off sends <domain>.toggle. Whether Home Assistant offers that for the entity is checked when saving
# (Manager.check_supported, app 0.2.67); the built-in cards have nothing to switch.
if options.get('tap') == 'toggle' and domain == 'screen':
raise ValueError("This entity doesn't support an on/off action.")
# Perform action (app 0.2.67) keeps its action; another tap choice leaves a stale one behind.
if options.get('tap') == 'action':
if domain == 'screen':
raise ValueError("A built-in card can't perform an action.")
options = {**options, 'action': validate_tap_action(options.get('action'))}
elif 'action' in options:
options = {key: value for key, value in options.items() if key != 'action'}
if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}:
raise ValueError("This entity doesn't support a mini-slider.")
if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)):
@@ -891,24 +963,77 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
return result or None
return None
def tile_icon(tile, attrs):
"""Codepoint the screen shows: the chosen icon, else HA's own mdi icon; None keeps the firmware default."""
def tile_icon(tile, attrs, state=None, entry=None):
"""Codepoint the screen shows: the chosen icon, else HA's own mdi icon, else the icon Home Assistant's frontend shows
for this state (app 0.2.67); None keeps the firmware default."""
choice = tile.get('options', {}).get('icon', 'auto')
return tile_icons.ICONS[choice][0] if choice in tile_icons.ICONS else tile_icons.ha_icon(attrs)
if choice in tile_icons.ICONS:
return tile_icons.ICONS[choice][0]
return tile_icons.ha_icon(attrs) or tile_icons.default_glyph(tile['entity'], state, attrs, entry)
def screen_options(tile, attrs):
def screen_options(tile, attrs, state=None, entry=None):
"""Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before)
and `controls` only as the set the card really shows (firmware 0.2.19+, ignored before)."""
options = {k: v for k, v in tile.get('options', {}).items() if k not in ('icon', 'controls')}
icon = tile_icon(tile, attrs)
options = {k: v for k, v in tile.get('options', {}).items() if k not in ('icon', 'controls', 'action')}
icon = tile_icon(tile, attrs, state, entry)
if icon:
options['icon'] = icon
controls = resolve_controls(tile)
if controls:
options['controls'] = controls
# Perform action travels in the firmware's compact form (0.2.58+); older firmware taps automatically.
action = tile.get('options', {}).get('action')
if options.get('tap') == 'action' and isinstance(action, dict):
try:
options['act'] = action_for_screen(validate_tap_action(action))
except ValueError:
pass
return options if options or 'options' in tile else None
def state_message(index, tile, states, extra=None):
def rounded_state(value, precision):
"""A sensor's state as Home Assistant shows it with a display precision (app 0.2.67): "21.456" at 1 becomes "21.5".
No thousands separators, so the screen still reads the number; anything that isn't a number stays as it is."""
if type(precision) is not int or not 0 <= precision <= 6:
return value
try:
number = float(value)
except (TypeError, ValueError):
return value
if not math.isfinite(number):
return value
from decimal import ROUND_HALF_UP, Decimal
text = format(Decimal(str(value)).quantize(Decimal(1).scaleb(-precision), rounding=ROUND_HALF_UP), 'f')
return text[1:] if text.startswith('-') and not text.strip('-0.') else text
def ha_word(entity_id, suffix, attributes, entry, words):
"""Home Assistant's English word for `suffix` (`state.<state>`, or `state_attributes.<attribute>.state.<value>`) as its
frontend picks it: the integration's word for the entity's translation key, then the domain's word for the state's
device class, then the domain's. None when Home Assistant has none (frontend/get_translations `entity` and
`entity_component`, app 0.2.67)."""
if not isinstance(words, dict) or not words or not isinstance(entity_id, str):
return None
domain = entity_id.split('.', 1)[0]
entry = entry if isinstance(entry, dict) else {}
device_class = attributes.get('device_class') if isinstance(attributes, dict) else None
keys = []
if entry.get('platform') and entry.get('translation_key'):
keys.append(f"component.{entry['platform']}.entity.{domain}.{entry['translation_key']}.{suffix}")
if isinstance(device_class, str) and device_class:
keys.append(f'component.{domain}.entity_component.{device_class}.{suffix}')
keys.append(f'component.{domain}.entity_component._.{suffix}')
return next((words[key] for key in keys if isinstance(words.get(key), str) and words[key]), None)
def state_word(entity_id, state, attributes, entry, words):
"""Home Assistant's word for a state ("rinsing" is "Rinsing", "heat_cool" is "Heat/Cool"), or None."""
return ha_word(entity_id, f'state.{state}', attributes, entry, words) if isinstance(state, str) and state else None
def attribute_word(entity_id, attribute, value, attributes, entry, words):
"""Home Assistant's word for an attribute's value, such as a robot's suction level, or None."""
if not isinstance(value, str) or not value:
return None
return ha_word(entity_id, f'state_attributes.{attribute}.state.{value}', attributes, entry, words)
def state_message(index, tile, states, extra=None, precision=None, entry=None):
if tile['entity'] in BUILTIN:
message = {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
'name': short(tile['name'] or BUILTIN[tile['entity']], 80), 'state': 'ok', 'a': {}}
@@ -937,10 +1062,10 @@ def state_message(index, tile, states, extra=None):
limit = 2 if key == 'hs_color' else 4 if key == 'fan_speed_list' else 8
bounded[key] = [short(v, 48) if isinstance(v, str) else v for v in value[:limit]
if isinstance(v, str) or isinstance(v, (float, int)) and math.isfinite(v) and abs(v) <= 1000000]
options = screen_options(tile, attrs)
options = screen_options(tile, attrs, state.get('state'), entry)
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
'state': short(rounded_state(state.get('state', 'unavailable'), precision) if tile['entity'].startswith('sensor.') else state.get('state', 'unavailable'), 160), 'a': bounded,
**({'o': options} if options is not None else {}), **({'x': extra} if extra else {})}
def encode(message):
@@ -1150,7 +1275,8 @@ def discover(registry, states, devices, areas):
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name') or item.get('name') or item.get('original_name') or eid,
'device': device.get('name_by_user') or device.get('name') or '', 'area': area,
'state': state.get('state', 'unavailable'), 'icon': tile_icons.ha_icon(state.get('attributes')),
'state': state.get('state', 'unavailable'),
'icon': tile_icons.ha_icon(state.get('attributes')) or tile_icons.default_glyph(eid, state.get('state'), state.get('attributes'), item),
# Top-bar-only domains (a phone's tracker, a lock) stay out of the tile picker.
**({} if tile else {'tile': False})})
# YAML entities may not have an entity-registry entry.
+348
View File
@@ -0,0 +1,348 @@
"""What Home Assistant says an entity can do (app 0.2.67).
Home Assistant describes each action with the entities it works on: its `target`, filtered by domain, integration,
device class and supported features. Since 2025.12 it also answers `get_services_for_target` with the actions that fit
one entity. ESP Screens asks it instead of keeping lists of its own, so a device or an integration that brings an action
works without an update: a cover gets On / off because Home Assistant lists `cover.toggle` for it, a speaker that cannot
turn on and off doesn't. `local_actions` gives the same answer from the action descriptions for an older Home Assistant.
What stays ours is which action each of our widgets sends (INLINE, CONTROLS): a small slider on a light is `light.turn_on` with a
brightness, so it only fits a light for which Home Assistant offers that field.
"""
import core
from core import attribute_word, state_word
import header_bar
import history_card
# Screen widgets and the Home Assistant action behind them: (action, field or None). A widget fits an entity when Home
# Assistant lists one of its actions for it, and, where a field is named, offers that field for the entity.
TOGGLE = '{domain}.toggle'
INLINE = {
'light': (('light.turn_on', 'brightness_pct'),),
'fan': (('fan.set_percentage', None),),
'cover': (('cover.set_cover_position', None),),
'media_player': (('media_player.volume_set', None),),
'number': (('number.set_value', None),),
'input_number': (('input_number.set_value', None),),
}
CONTROLS = {
'climate': {'setpoint': (('climate.set_temperature', None),), 'mode': (('climate.set_hvac_mode', None),)},
'switch': {'toggle': (('switch.toggle', None),)},
'input_boolean': {'toggle': (('input_boolean.toggle', None),)},
'light': {'toggle': (('light.toggle', None),), 'brightness': INLINE['light']},
'fan': {'toggle': (('fan.toggle', None),), 'speed': INLINE['fan']},
'vacuum': {'buttons': tuple((action, None) for action in ('vacuum.start', 'vacuum.pause', 'vacuum.stop', 'vacuum.return_to_base', 'vacuum.turn_on'))},
'cover': {'buttons': tuple((action, None) for action in ('cover.open_cover', 'cover.close_cover', 'cover.stop_cover')),
'position': INLINE['cover']},
'media_player': {'volume': (('media_player.volume_set', None), ('media_player.volume_mute', None)),
'playback': tuple((action, None) for action in ('media_player.media_play_pause', 'media_player.media_play',
'media_player.media_pause', 'media_player.media_next_track',
'media_player.media_previous_track'))},
'number': {'stepper': INLINE['number'], 'slider': INLINE['number']},
'input_number': {'stepper': INLINE['input_number'], 'slider': INLINE['input_number']},
'select': {'stepper': (('select.select_option', None),)},
'input_select': {'stepper': (('input_select.select_option', None),)},
'timer': {'buttons': (('timer.start', None), ('timer.cancel', None))},
'scene': {'run': (('scene.turn_on', None),)},
'script': {'run': (('script.turn_on', None),)},
'button': {'run': (('button.press', None),)},
'input_button': {'run': (('input_button.press', None),)},
}
# Weather forecast bits (WeatherEntityFeature): the forecast card draws days.
WEATHER_DAILY = 1
def _as_list(value):
if value is None:
return []
return list(value) if isinstance(value, (list, tuple)) else [value]
def _features(attributes):
value = (attributes or {}).get('supported_features')
return value if isinstance(value, int) and not isinstance(value, bool) else 0
def entity_filter_matches(entity_filter, entity_id, attributes, platform):
"""One `target.entity` filter of an action description, as Home Assistant applies it to an entity: every named
condition must hold. A `supported_features` entry is a mask the entity must support completely, and any entry will
do (the action's own required features)."""
if not isinstance(entity_filter, dict):
return False
domain = entity_id.split('.', 1)[0]
domains = _as_list(entity_filter.get('domain'))
if domains and domain not in domains:
return False
integration = entity_filter.get('integration')
if integration and integration != platform:
return False
classes = _as_list(entity_filter.get('device_class'))
if classes and (attributes or {}).get('device_class') not in classes:
return False
masks = [mask for mask in _as_list(entity_filter.get('supported_features')) if isinstance(mask, int)]
if masks and not any(_features(attributes) & mask == mask for mask in masks):
return False
return True
def target_matches(target, entity_id, attributes, platform):
"""Whether an action works on this entity. An action without a target is not an entity action; one whose target
names no entity filter takes every entity (homeassistant.toggle)."""
if not isinstance(target, dict):
return False
filters = target.get('entity')
if filters is None or filters == []:
return 'entity' not in target or filters == []
return any(entity_filter_matches(item, entity_id, attributes, platform) for item in _as_list(filters))
def local_actions(services, entity_id, attributes, platform):
"""`get_services_for_target` for one entity, from the action descriptions: for a Home Assistant before 2025.12."""
found = []
for domain, actions in (services or {}).items():
if not isinstance(actions, dict):
continue
for name, description in actions.items():
if isinstance(description, dict) and 'target' in description and target_matches(description['target'], entity_id, attributes, platform):
found.append(f'{domain}.{name}')
return sorted(found)
def find_field(description, name):
"""A field of an action description, also inside a section (light.turn_on keeps some under `advanced_fields`)."""
fields = (description or {}).get('fields') or {}
if name in fields and isinstance(fields[name], dict):
return fields[name]
for value in fields.values():
if isinstance(value, dict) and isinstance(value.get('fields'), dict) and name in value['fields']:
return value['fields'][name]
return None
def field_matches(field, attributes):
"""Whether Home Assistant offers a field for this entity: its `filter` names supported features (any one) or
attribute values (any one); a field without a filter is always there."""
field_filter = (field or {}).get('filter')
if not isinstance(field_filter, dict) or not field_filter:
return True
features = [mask for mask in _as_list(field_filter.get('supported_features')) if isinstance(mask, int)]
if features and any(_features(attributes) & mask for mask in features):
return True
for attribute, values in (field_filter.get('attribute') or {}).items():
current = (attributes or {}).get(attribute)
wanted = _as_list(values)
if isinstance(current, (list, tuple)) and any(value in current for value in wanted):
return True
if not isinstance(current, (list, tuple)) and current in wanted:
return True
return False
def _fits(requirements, actions, attributes, services):
for action, field in requirements:
if action not in actions:
continue
if field is None:
return True
domain, name = action.split('.', 1)
found = find_field(((services or {}).get(domain) or {}).get(name), field)
if found is not None and field_matches(found, attributes):
return True
return False
def capabilities(entity_id, actions, state, services):
"""What the editor may offer for this entity, in Home Assistant's own terms: On / off, a small slider, which direct
controls and which displays. `actions` are the actions Home Assistant lists for the entity."""
domain = entity_id.split('.', 1)[0]
attributes = (state or {}).get('attributes') or {}
actions = set(actions or ())
displays = ['standard', 'watch']
if domain == 'sensor':
# The graph draws numbers; a status sensor (a washing machine's programme) has none.
if history_card.kind(entity_id, state) == 'line':
displays.append('graph')
elif domain == 'weather':
features = attributes.get('supported_features')
# An entity that reports no features yet (unavailable) keeps the forecast, as forecast_kinds does.
if not isinstance(features, int) or isinstance(features, bool) or features & WEATHER_DAILY:
displays.append('forecast')
elif domain == 'sun':
displays.append('sunpath')
return {
'toggle': TOGGLE.format(domain=domain) in actions,
'inline': domain in INLINE and _fits(INLINE[domain], actions, attributes, services),
'controls': [key for key, requirements in CONTROLS.get(domain, {}).items() if _fits(requirements, actions, attributes, services)],
'displays': displays,
}
def fields_for(description, attributes):
"""The fields of an action Home Assistant offers for this entity, in its own order, sections flattened."""
found = []
for key, field in ((description or {}).get('fields') or {}).items():
if not isinstance(field, dict):
continue
if isinstance(field.get('fields'), dict) and 'selector' not in field:
found += [(inner_key, inner) for inner_key, inner in field['fields'].items() if isinstance(inner, dict) and field_matches(inner, attributes)]
elif field_matches(field, attributes):
found.append((key, field))
return found
def attribute_options(attributes, attribute):
"""The values an entity offers for one of its attributes, where a `state` selector asks for one: an effect from
effect_list, a source from source_list, a fan mode from fan_modes, a speed from supported_speeds."""
for key in (f'{attribute}_list', f'{attribute}s', f'available_{attribute}s', f'supported_{attribute}s'):
values = (attributes or {}).get(key)
if isinstance(values, list) and values and all(isinstance(value, (str, int, float)) and not isinstance(value, bool) for value in values):
return [str(value) for value in values][:60]
return None
def answers_only(description):
"""An action that must return data (weather.get_forecasts): a tap has nowhere to show it."""
return ((description or {}).get('response') or {}).get('optional') is False
def field_choice(base, key, field, attributes, names):
"""One field for the editor: Home Assistant's name, description and selector, and for a `state` selector the values
this entity has for that attribute."""
selector = field.get('selector') or {}
found = {'key': key, 'name': names.get(f'{base}.fields.{key}.name') or field.get('name') or key,
'description': names.get(f'{base}.fields.{key}.description') or field.get('description') or '',
'required': bool(field.get('required')), 'selector': selector}
if 'example' in field:
found['example'] = field['example']
attribute = (selector.get('state') or {}).get('attribute') if isinstance(selector.get('state'), dict) else None
options = attribute_options(attributes, attribute) if attribute else None
if options:
found['options'] = options
return found
def action_choices(entity_id, actions, state, services, names, platform=None):
"""Perform action in the editor (app 0.2.67): every action Home Assistant offers for the entity, under the names and
descriptions Home Assistant shows (frontend/get_translations, `services`), with the fields it offers for this entity.
The entity's own domain comes first, then its integration's actions, then Home Assistant's general ones."""
domain = entity_id.split('.', 1)[0]
attributes = (state or {}).get('attributes') or {}
names = names or {}
found = []
for action in sorted(actions or ()):
action_domain, service = action.split('.', 1)
description = ((services or {}).get(action_domain) or {}).get(service) or {}
if answers_only(description):
continue
base = f'component.{action_domain}.services.{service}'
found.append({
'action': action,
'name': names.get(f'{base}.name') or description.get('name') or action,
'description': names.get(f'{base}.description') or description.get('description') or '',
'fields': [field_choice(base, key, field, attributes, names) for key, field in fields_for(description, attributes)],
})
order = lambda item: (0 if item['action'].startswith(domain + '.') else 1 if platform and item['action'].startswith(platform + '.') else 2,
item['name'].casefold())
return sorted(found, key=order)
def action_problem(entity_id, label, action, state, actions, services):
"""Why Home Assistant wouldn't take a tap's own action for this entity, as the sentence a save gets; None when it
would, or while Home Assistant can't say."""
name, data = action.get('action'), action.get('data') or {}
if actions is None:
return None
if name not in actions:
return f"Home Assistant doesn't offer {name} for {label}. Choose an action from the list."
action_domain, service = name.split('.', 1)
description = ((services or {}).get(action_domain) or {}).get(service)
if description is None:
return None
if answers_only(description):
return f"{name} only answers with data, which a screen can't show."
fields = dict(fields_for(description, (state or {}).get('attributes') or {}))
for key in data:
if key not in fields:
return f"Home Assistant has no field {key} in {name} for {label}."
for key, field in fields.items():
if field.get('required') and key not in data:
return f"{name} needs a value for {key}."
return None
def refusal(entity_id, name, key, value):
"""The sentence a save or a tile event gets for a setting Home Assistant doesn't support for this entity."""
label = name or entity_id
if key == 'tap':
return f"Home Assistant can't turn {label} on and off, so On / off would do nothing. Choose another tap action."
if key == 'inline':
return f"{label} has nothing a small slider can change in Home Assistant."
if key == 'display' and value == 'graph':
return f"{label} has no numbers to draw a graph of."
if key == 'display' and value == 'forecast':
return f"{label} has no daily forecast in Home Assistant."
return f"Home Assistant doesn't offer that direct control for {label}."
def unsupported(tile, previous, caps):
"""The first setting of a tile that Home Assistant doesn't support and that isn't already saved like this, as
(key, value), or None. Settings a screen already has stay, also when Home Assistant no longer supports them: they
get a warning in the editor, and a save never fails on a tile nobody changed."""
if caps is None:
return None
options, before = tile.get('options') or {}, (previous or {}).get('options') or {}
checks = (
('tap', options.get('tap') == 'toggle' and not caps.get('toggle')),
('inline', options.get('inline') == 'slider' and not caps.get('inline')),
('controls', options.get('controls', 'none') != 'none' and options.get('controls') not in caps.get('controls', ())),
('display', options.get('display') in ('graph', 'forecast') and options.get('display') not in caps.get('displays', ())),
)
for key, refused in checks:
if refused and (previous is None or before.get(key) != options.get(key)):
return key, options.get(key)
return None
# Domains whose tiles and cards show the raw state where no word of the screen's own fits (app 0.2.67): the app sends
# Home Assistant's word for it. The screen's own words (On, Off, Docked, a binary sensor's Open) stay as they are.
WORD_DOMAINS = frozenset(('cover', 'media_player', 'vacuum', 'select', 'input_select', 'sensor'))
WORD_BYTES = 32
def screen_word(entity_id, state, attributes, entry, words):
"""The word a tile shows for its state, where the screen would show the raw state: folded to the glyphs its fonts
carry and short enough for a tile; None when Home Assistant has no other word."""
domain = entity_id.split('.', 1)[0]
if domain not in WORD_DOMAINS or (domain == 'sensor' and header_bar.numeric(state) is not None):
return None
word = state_word(entity_id, state, attributes, entry, words)
if not word:
return None
word = header_bar.short(header_bar.clean_text(word), WORD_BYTES)
return word if word and word != state else None
def chip_words(extra, entity_id, states, device, words):
"""A vacuum card's chips in Home Assistant's words where the screen has no short label of its own (app 0.2.67): a
cleaning mode or mop intensity as the integration names the select's option, a suction level as it names the
vacuum's fan speed. The screen's own labels ("Vac & mop", "Normal") stay; `extra` is changed in place."""
if not isinstance(extra, dict) or not words:
return extra
entries = {item.get('entity_id'): item for item in device or () if isinstance(item, dict)}
def relabel(row, word):
if not isinstance(row, dict) or not isinstance(row.get('o'), list) or not isinstance(row.get('l'), list):
return
for index, value in enumerate(row['o'][:len(row['l'])]):
found = None if value in core.VACUUM_LABELS else word(value)
if found:
row['l'][index] = header_bar.short(header_bar.clean_text(found), 24) or row['l'][index]
for key in ('mode', 'water'):
row = extra.get(key)
select = row.get('e') if isinstance(row, dict) else None
if isinstance(select, str):
attributes = (states.get(select) or {}).get('attributes')
relabel(row, lambda value: state_word(select, value, attributes, entries.get(select), words))
attributes = (states.get(entity_id) or {}).get('attributes')
relabel(extra.get('fan'), lambda value: attribute_word(entity_id, 'fan_speed', value, attributes, entries.get(entity_id), words))
return extra
+17 -15
View File
@@ -15,7 +15,7 @@ import unicodedata
from datetime import datetime, time
import tile_icons
from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short
from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short, state_word
# Characters the top bar's text font carries on both boards (`sublabel_big` in the profiles;
# tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes.
@@ -142,8 +142,9 @@ def numeric(value):
def short_date(moment):
return f'{moment.day} {MONTHS[moment.month - 1]}'
def value(entity, state, entry=None, units=None, tz=None):
"""(text, unix time) an entity shows as its state; exactly one of them is set."""
def value(entity, state, entry=None, units=None, tz=None, words=None):
"""(text, unix time) an entity shows as its state; exactly one of them is set. `words` are Home Assistant's
translations: its word shows where the bar has none of its own ("Rinsing" for rinsing, app 0.2.67)."""
domain = entity.split('.')[0]
raw = state.get('state') if state else None
attrs = (state or {}).get('attributes') or {}
@@ -192,10 +193,10 @@ def value(entity, state, entry=None, units=None, tz=None):
if number is not None and domain in ('sensor', 'number', 'input_number', 'counter', 'zone'):
return with_unit(number_text(number, precision_of(entry)), attrs.get('unit_of_measurement')), None
if domain in ('input_text', 'input_select', 'select'):
return raw, None
return state_word(entity, raw, attrs, entry, words) or raw, None
if raw in STATES:
return STATES[raw], None
return raw[:1].upper() + raw[1:], None
return state_word(entity, raw, attrs, entry, words) or raw[:1].upper() + raw[1:], None
def active(entity, state):
""""Only when active": on, open, home, detected, playing, heating, or a number other than zero."""
@@ -236,11 +237,12 @@ def accent(entity, state):
return TEAL
return None
def auto_icon(entity, state):
"""Codepoint of the icon Home Assistant would show: its own mdi icon, else the device class, else the domain."""
def auto_icon(entity, state, entry=None):
"""Codepoint of the icon Home Assistant would show: its own mdi icon, else Home Assistant's default icon for this state
(app 0.2.67, the same as a tile), else the device class and domain tables."""
attrs = (state or {}).get('attributes') or {}
raw = (state or {}).get('state')
own = tile_icons.ha_icon(attrs)
own = tile_icons.ha_icon(attrs) or tile_icons.default_glyph(entity, raw, attrs, entry)
if own:
return own
domain, device_class = entity.split('.')[0], attrs.get('device_class')
@@ -260,20 +262,20 @@ def auto_icon(entity, state):
name = DOMAIN_ICONS.get(domain, tile_icons.FALLBACK)
return tile_icons.GLYPHS[name]
def entity_item(item, states, registry=None, units=None, tz=None):
def entity_item(item, states, registry=None, units=None, tz=None, words=None):
"""(wire item, shown) for one entity item; the wire item exists even while hidden, for the editor."""
entity = item['entity']
state = states.get(entity)
wire = {'k': 'text'}
if item['icon'] == 'auto':
wire['i'] = auto_icon(entity, state)
wire['i'] = auto_icon(entity, state, (registry or {}).get(entity) if isinstance(registry, dict) else None)
elif item['icon'] != 'none':
wire['i'] = tile_icons.ICONS[item['icon']][0]
if item['content'] == 'last_changed':
moment = epoch((state or {}).get('last_changed'))
text, moment = (None, moment) if moment else (UNAVAILABLE, None)
else:
text, moment = value(entity, state, (registry or {}).get(entity), units, tz)
text, moment = value(entity, state, (registry or {}).get(entity), units, tz, words)
if moment:
wire.update(k='ago', e=moment)
else:
@@ -283,25 +285,25 @@ def entity_item(item, states, registry=None, units=None, tz=None):
wire['c'] = color
return wire, item['show'] == 'always' or active(entity, state)
def message(layout, states, registry=None, units=None, tz=None):
def message(layout, states, registry=None, units=None, tz=None, words=None):
items = []
for item in header_items(layout):
if item['type'] in HEADER_BUILTIN:
items.append({'k': item['type']})
continue
wire, shown = entity_item(item, states, registry, units, tz)
wire, shown = entity_item(item, states, registry, units, tz, words)
if shown:
items.append(wire)
return {'v': 1, 'op': 'header', 'items': items}
def preview(header, states, registry=None, units=None, tz=None):
def preview(header, states, registry=None, units=None, tz=None, words=None):
"""What the editor's mockup shows per item, hidden ones included and marked."""
result = []
for item in header['items']:
if item['type'] in HEADER_BUILTIN:
result.append({'k': item['type'], 'name': HEADER_BUILTIN[item['type']], 'shown': True})
continue
wire, shown = entity_item(item, states, registry, units, tz)
wire, shown = entity_item(item, states, registry, units, tz, words)
attrs = (states.get(item['entity']) or {}).get('attributes') or {}
result.append({**wire, 'name': attrs.get('friendly_name') or item['entity'], 'shown': shown,
'auto_icon': auto_icon(item['entity'], states.get(item['entity']))})
+13 -9
View File
@@ -11,6 +11,7 @@ import math
from datetime import datetime, timedelta, timezone
import header_bar
from core import state_word
RANGES = (1, 24, 168)
POINTS = 24
@@ -211,8 +212,9 @@ def line(entity, hours, changes, start, end, tz, entry=None, unit='', extreme_ch
return message
def state_label(domain, state, attrs):
"""Home Assistant's words for a state, as the screen shows them elsewhere."""
def state_label(domain, state, attrs, word=None):
"""Home Assistant's words for a state, as the screen shows them elsewhere; `word` is Home Assistant's own word, for a
state the screen has no word of its own for (app 0.2.67)."""
if state in (None, 'unavailable'):
return 'Unavailable'
if state == 'unknown':
@@ -222,7 +224,7 @@ def state_label(domain, state, attrs):
return pair[0] if state == 'on' else pair[1] if state == 'off' else state
if domain == 'person' and state not in header_bar.STATES:
return state.replace('_', ' ')[:1].upper() + state.replace('_', ' ')[1:]
return header_bar.STATES.get(state) or (state[:1].upper() + state[1:]).replace('_', ' ')
return header_bar.STATES.get(state) or word or (state[:1].upper() + state[1:]).replace('_', ' ')
def state_color(domain, state, others):
@@ -237,10 +239,12 @@ def state_color(domain, state, others):
return PALETTE[others % len(PALETTE)]
def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS):
def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS, entry=None, translations=None):
"""The `history` message for states: the timeline as runs of slots, each state's time, and how often the
active state began (a door opened, a person came home)."""
active state began (a door opened, a person came home). `entry` and `translations` give Home Assistant's words."""
domain = entity.split('.')[0]
def label(state):
return header_bar.clean_text(state_label(domain, state, attrs, state_word(entity, state, attrs, entry, translations)))[:24]
changes = sorted(changes, key=lambda change: change[0])
# The state in force over each stretch of the range.
pieces, current, index = [], None, 0
@@ -271,7 +275,7 @@ def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS):
shown = ranked[:STATES_SHOWN] if len(ranked) <= STATES_SHOWN else ranked[:STATES_SHOWN - 1]
index_of = {state: i for i, state in enumerate(shown)}
others = [state for state in order if state_color(domain, state, 0) == PALETTE[0]]
legend = [[header_bar.clean_text(state_label(domain, state, attrs))[:24],
legend = [[label(state),
state_color(domain, state, others.index(state) if state in others else 0), int(totals[state])] for state in shown]
rest = [state for state in ranked if state not in index_of]
if rest:
@@ -300,7 +304,7 @@ def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS):
shown_active = index_of.get(active, -1) if active in shown else -1
return {'v': 1, 'op': 'history', 'entity': entity, 'hours': hours, 'kind': 'timeline', 'start': start, 'end': end,
'off': utc_offset(end, tz), 'xt': time_ticks(start, end, hours, tz), 'slots': slots, 'states': legend,
'seg': run_times(runs, pieces, index_of, start, span, slots), 'words': words(domain, order + [current], attrs),
'seg': run_times(runs, pieces, index_of, start, span, slots), 'words': words(domain, order + [current], attrs, label),
'began': began, 'active': shown_active}
@@ -326,13 +330,13 @@ def run_times(runs, pieces, index_of, start, span, slots):
return out
def words(domain, states, attrs):
def words(domain, states, attrs, label=None):
"""[raw state, words] for the states of the range and a binary sensor's on and off: the card's heading shows the
state now in these words, also after it changed while the card is open."""
found = {}
for state in states + (['on', 'off'] if domain == 'binary_sensor' else []):
if isinstance(state, str) and len(state) <= 32 and state not in found and len(found) < 10:
found[state] = header_bar.clean_text(state_label(domain, state, attrs))[:24]
found[state] = label(state) if label else header_bar.clean_text(state_label(domain, state, attrs))[:24]
return [[state, text] for state, text in found.items()]
+171 -15
View File
@@ -13,12 +13,13 @@ import math
import camera_feed
import claude_skill
from firmware import Firmware
import ha_catalogue
import tile_icons
from updates import Updater
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state, state_word
import header_bar
import history_card
from zoneinfo import ZoneInfo
@@ -106,6 +107,18 @@ class HomeAssistant:
# Assistant's own list; refreshed when a device registers its actions again.
self.responses = set()
self.services_changed = asyncio.Event()
# Every action Home Assistant describes (get_services), which the editor's choices per entity follow (app
# 0.2.67); `services_rev` counts the refreshes. `targets` keeps Home Assistant's answer per entity, and
# `target_lookup` stays None until it is known whether Home Assistant answers get_services_for_target (2025.12+).
self.services, self.services_rev, self.targets, self.target_lookup = {}, 0, {}, None
# Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations, English
# like the editor), for Perform action (app 0.2.67). `get_services` has carried no translated names since 2025.10.
self.service_names = {}
# Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`, English), which a
# tile shows where it would show the raw state (app 0.2.67).
self.state_words = {}
self.esphome_services = False
self._platforms_source, self._platforms = None, {}
# History a detail card asks for when it opens (firmware 0.2.51+): {inbox, entity, hours}, for Manager.card_history_loop.
self.history_requests = asyncio.Queue()
# A camera a screen opens full screen (firmware 0.2.57+): {inbox, entity}, for Manager.camera_loop.
@@ -159,8 +172,10 @@ class HomeAssistant:
elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set()
elif event.get('event_type') in SERVICE_EVENTS:
if body.get('domain') == 'esphome':
self.services_changed.set()
# Every action counts for what the editor offers (app 0.2.67); a screen's own actions also decide
# how the app talks to it.
self.esphome_services |= body.get('domain') == 'esphome'
self.services_changed.set()
elif event.get('event_type') in BROADCAST_EVENTS:
# Queued, not handled here: the calls wait for results this reader has to deliver.
self.broadcasts.put_nowait((event['event_type'], body))
@@ -182,9 +197,34 @@ class HomeAssistant:
self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list'))
async def fetch_services(self):
"""Which ESPHome actions answer: Home Assistant lists `response` for an action that can return one."""
esphome = (await self.request('get_services') or {}).get('esphome', {})
"""Every action Home Assistant describes: which ESPHome actions answer (Home Assistant lists `response` for an
action that can return one), and the descriptions the editor's choices per entity follow (app 0.2.67)."""
services = await self.request('get_services') or {}
esphome = services.get('esphome', {})
self.responses = {f'esphome.{name}' for name, spec in esphome.items() if isinstance(spec, dict) and spec.get('response')}
if isinstance(services, dict):
self.services, self.services_rev, self.targets = services, self.services_rev + 1, {}
try:
names = await self.request('frontend/get_translations', language='en', category='services')
if isinstance((names or {}).get('resources'), dict):
self.service_names = names['resources']
words = {}
for category in ('entity_component', 'entity'):
found = await self.request('frontend/get_translations', language='en', category=category)
if isinstance((found or {}).get('resources'), dict):
words.update(found['resources'])
if words:
self.state_words = words
icons = {}
for category in ('entity_component', 'entity'):
found = await self.request('frontend/get_icons', category=category)
if isinstance((found or {}).get('resources'), dict):
icons[category] = found['resources']
if icons:
tile_icons.use_ha_icons(icons)
except (Refused, TimeoutError) as error:
# The editor then shows the names services.yaml still carries, and tiles keep their raw states.
LOG.info('No action names or state words from Home Assistant (%s)', type(error).__name__)
async def refresh_services(self):
"""fetch_services for the connection loop: a list that cannot be read only means no answers are asked for."""
@@ -194,6 +234,49 @@ class HomeAssistant:
LOG.warning('Reading the actions from Home Assistant failed (%s); screens are not asked for answers', type(error).__name__)
self.responses = set()
def platform_of(self, entity_id):
"""The integration behind an entity, from the entity registry (an action can be meant for one integration)."""
registry = self.registry
if self._platforms_source is not registry:
self._platforms_source = registry
self._platforms = {item.get('entity_id'): item.get('platform') for item in registry if isinstance(item, dict)}
return self._platforms.get(entity_id)
async def entity_actions(self, entity_id):
"""The actions Home Assistant offers for one entity, or None while that is unknown (no action list yet, or no
state). Home Assistant 2025.12+ answers get_services_for_target itself; an older one gets the same answer from the
action descriptions."""
state = self.states.get(entity_id)
if state is None or not self.services:
return None
attributes = state.get('attributes') or {}
key = (self.services_rev, id(self.registry), attributes.get('supported_features'), attributes.get('device_class'))
cached = self.targets.get(entity_id)
if cached and cached[0] == key:
return cached[1]
actions = None
if self.target_lookup is not False:
try:
answer = await self.request('get_services_for_target', target={'entity_id': [entity_id]}, expand_group=False)
if isinstance(answer, list):
actions, self.target_lookup = frozenset(answer), True
except Refused as error:
if 'unknown command' in (error.detail or '').lower():
self.target_lookup = False
except (ConnectionError, TimeoutError):
pass
if actions is None:
actions = frozenset(ha_catalogue.local_actions(self.services, entity_id, attributes, self.platform_of(entity_id)))
self.targets[entity_id] = (key, actions)
return actions
async def capabilities(self, entity_id):
"""What the editor may offer for one entity (ha_catalogue.capabilities), or None while Home Assistant can't say."""
actions = await self.entity_actions(entity_id)
if actions is None:
return None
return ha_catalogue.capabilities(entity_id, actions, self.states.get(entity_id), self.services)
async def run(self):
url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api'
else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket')
@@ -243,7 +326,9 @@ class HomeAssistant:
await asyncio.sleep(1)
self.services_changed.clear()
await self.refresh_services()
self.changed.set()
if self.esphome_services:
self.esphome_services = False
self.changed.set()
if self.registry_changed.is_set():
await asyncio.sleep(1)
self.registry_changed.clear()
@@ -425,7 +510,8 @@ class Manager:
# Versioned persistent data. Never silently overwrite an unknown schema.
if raw.get('version') != 1 or not isinstance(raw.get('screens'), dict):
raise ValueError('Unknown storage version; data stays unchanged.')
self.layouts = {key: validate_layout(value) for key, value in raw['screens'].items()}
# Loaded leniently: a tile setting this version doesn't know (saved by a newer one) never stops the app.
self.layouts = {key: validate_layout(value, stored=True) for key, value in raw['screens'].items()}
def inventory(self):
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving."""
@@ -763,7 +849,8 @@ class Manager:
tz = getattr(self.ha, 'time_zone', None)
attrs = (self.ha.states.get(entity) or {}).get('attributes') or {}
if what == 'timeline':
return history_card.timeline(entity, hours, await self.ha.state_changes(entity, hours), start, end, tz, attrs)
return history_card.timeline(entity, hours, await self.ha.state_changes(entity, hours), start, end, tz, attrs,
entry=self.registry_index().get(entity), translations=getattr(self.ha, 'state_words', None))
entry, unit = self.registry_index().get(entity), attrs.get('unit_of_measurement') or ''
rows = await self.ha.statistic_rows(entity, hours) if hours > 1 else []
if rows:
@@ -802,7 +889,8 @@ class Manager:
return ()
def header_message(self, layout):
return header_bar.message(layout, self.ha.states, self.registry_index(), getattr(self.ha, 'units', {}), getattr(self.ha, 'time_zone', None))
return header_bar.message(layout, self.ha.states, self.registry_index(), getattr(self.ha, 'units', {}), getattr(self.ha, 'time_zone', None),
getattr(self.ha, 'state_words', None))
def needs_firmware(self, inbox, layout, screen=None):
"""Version string the screen must run first, or None when the layout can be sent."""
@@ -872,6 +960,32 @@ class Manager:
self.ha.changed.set()
self.notify()
async def check_supported(self, inbox, data):
"""Refuse a tile setting Home Assistant doesn't support for its entity, before saving (app 0.2.67): On / off on a
speaker that can't turn on and off, a small slider on a light without brightness. A setting a tile already has
stays, and nothing is refused while Home Assistant can't say."""
capabilities = getattr(self.ha, 'capabilities', None)
if capabilities is None:
return
layout = validate_layout(data)
inbox = self.aliases.get(inbox, inbox)
before = {tile['entity']: tile for tile in self.layouts.get(inbox, {}).get('tiles', [])}
for tile in layout['tiles']:
previous = before.get(tile['entity'])
if tile['entity'] in BUILTIN or not tile.get('options') or (previous or {}).get('options') == tile['options']:
continue
name = tile.get('name') or self.ha.states.get(tile['entity'], {}).get('attributes', {}).get('friendly_name') or tile['entity']
found = ha_catalogue.unsupported(tile, previous, await capabilities(tile['entity']))
if found:
raise ValueError(ha_catalogue.refusal(tile['entity'], name, *found))
# Perform action (app 0.2.67): an action Home Assistant offers for the entity, with its required fields.
action = tile['options'].get('action')
if tile['options'].get('tap') == 'action' and action != ((previous or {}).get('options') or {}).get('action'):
problem = ha_catalogue.action_problem(tile['entity'], name, action, self.ha.states.get(tile['entity']),
await self.ha.entity_actions(tile['entity']), self.ha.services)
if problem:
raise ValueError(problem)
def notify(self):
for listener in self.listeners:
listener.set()
@@ -931,7 +1045,17 @@ class Manager:
hourly=await self.cached(self.forecasts, (entity,'hourly'), FORECAST_SECONDS, (lambda: self.ha.forecast(entity,'hourly')) if 'hourly' in kinds else nothing)
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46), a cover's card its battery (0.2.58).
device=self.device_entries(tile['entity']) if tile['entity'].startswith(('vacuum.', 'cover.')) else None
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device))
entry=self.registry_index().get(tile['entity'])
extra=extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device)
if tile['entity'].startswith('vacuum.'):
ha_catalogue.chip_words(extra,tile['entity'],self.ha.states,device,getattr(self.ha,'state_words',None))
message=state_message(index,tile,self.ha.states,extra,
precision=header_bar.precision_of(entry) if tile['entity'].startswith('sensor.') else None,entry=entry)
# Home Assistant's word where the screen would show the raw state (firmware 0.2.58+ shows it).
state=self.ha.states.get(tile['entity'],{})
word=ha_catalogue.screen_word(tile['entity'],message['state'],state.get('attributes'),entry,getattr(self.ha,'state_words',None))
if word:
message.setdefault('x',{})['w']=word
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
hours=tile.get('options',{}).get('history_hours',24)
entry=self.histories.get((tile['entity'],hours))
@@ -1289,8 +1413,9 @@ class Manager:
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does."""
screen = match_screen(self.screens(), data.get('screen'), self.layouts)
inbox = self.aliases.get(screen['id'], screen['id'])
layout = self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}
self.save(inbox, apply_tile_event(layout, TILE_EVENTS[event_type], data))
layout = apply_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data)
await self.check_supported(inbox, layout)
self.save(inbox, layout)
return screen
async def tile_loop(self):
@@ -1411,7 +1536,8 @@ def create_app(manager, development=False):
data = await request.json()
header = validate_header(data.get('header') if isinstance(data, dict) else None)
return web.json_response({'items': header_bar.preview(header, manager.ha.states, manager.registry_index(),
getattr(manager.ha, 'units', {}), getattr(manager.ha, 'time_zone', None))})
getattr(manager.ha, 'units', {}), getattr(manager.ha, 'time_zone', None),
getattr(manager.ha, 'state_words', None))})
async def events(request):
"""Server-sent events: pushes the light inventory whenever it changes, so the page need not poll."""
response = web.StreamResponse(headers={'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store',
@@ -1438,8 +1564,33 @@ def create_app(manager, development=False):
manager.listeners.discard(wake)
return response
async def save(request):
manager.save(request.match_info['inbox'], await request.json())
data = await request.json()
await manager.check_supported(request.match_info['inbox'], data)
manager.save(request.match_info['inbox'], data)
return web.json_response({'saved': True})
async def capabilities(request):
"""What Home Assistant says each entity can do, for the tile settings (app 0.2.67). Unknown is null: the editor
then offers what it always offered."""
entities = list(dict.fromkeys(request.query.getall('entity', [])))[:40]
lookup = getattr(manager.ha, 'capabilities', None)
async def one(entity):
try:
return await lookup(entity) if lookup else None
except Exception as error:
LOG.info('No capabilities for %s (%s)', entity, type(error).__name__)
return None
found = await asyncio.gather(*(one(entity) for entity in entities))
return web.json_response({'capabilities': dict(zip(entities, found))})
async def entity_actions(request):
"""Perform action (app 0.2.67): the actions Home Assistant offers for one entity, with its names and the fields it
offers for that entity. Null while Home Assistant can't say."""
entity = request.query.get('entity', '')
ha = manager.ha
actions = await ha.entity_actions(entity) if entity_id(entity) and hasattr(ha, 'entity_actions') else None
if actions is None:
return web.json_response({'actions': None})
return web.json_response({'actions': ha_catalogue.action_choices(entity, actions, ha.states.get(entity), ha.services,
getattr(ha, 'service_names', {}), ha.platform_of(entity))})
async def change_settings(request):
"""Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57)."""
data = await request.json()
@@ -1466,6 +1617,9 @@ def create_app(manager, development=False):
return web.json_response({'screen':screen,
'delivery':manager.status.get(inbox), 'layout':layout,
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
# Home Assistant's word for the state, as its own pages show it ("Heat/Cool", app 0.2.67).
'word':state_word(t['entity'],manager.ha.states.get(t['entity'],{}).get('state'),manager.ha.states.get(t['entity'],{}).get('attributes'),
manager.registry_index().get(t['entity']),getattr(manager.ha,'state_words',None)),
'attributes':state_message(i,t,manager.ha.states)['a'],
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
async def install_claude_skill(request):
@@ -1504,6 +1658,8 @@ def create_app(manager, development=False):
app.router.add_post('/api/firmware/profiles', firmware_create)
app.router.add_get('/', index)
app.router.add_get('/api/inventory', inventory)
app.router.add_get('/api/capabilities', capabilities)
app.router.add_get('/api/entity-actions', entity_actions)
app.router.add_post('/api/header-preview', header_preview)
app.router.add_post('/api/claude-skill', install_claude_skill)
app.router.add_get('/api/claude-skill.zip', download_claude_skill)
+244 -15
View File
@@ -85,6 +85,35 @@ function select(id) {
renderResults();
renderSettings();
loadTopbarPreview(0);
loadCapabilities(layout.tiles.map((t) => t.entity));
}
// What Home Assistant says each entity can do (app 0.2.67): On / off, a small slider, direct controls and the
// graph and forecast displays follow its own action list, so a device that can do more gets more. Unknown (Home
// Assistant not answering) is null, and the sheet then offers what it always did. A choice a tile already has stays
// visible with a warning instead of disappearing.
const capabilities = {}, askedCapabilities = new Set();
const TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"];
async function loadCapabilities(entities) {
const wanted = [...new Set(entities)].filter((id) => !askedCapabilities.has(id) && !id.startsWith("screen."));
if (!wanted.length) return;
wanted.forEach((id) => askedCapabilities.add(id));
try {
for (let i = 0; i < wanted.length; i += 40) {
const query = wanted.slice(i, i + 40).map((id) => `entity=${encodeURIComponent(id)}`).join("&");
Object.assign(capabilities, (await (await api(`capabilities?${query}`)).json()).capabilities || {});
}
} catch {
wanted.forEach((id) => askedCapabilities.delete(id));
return;
}
if (sheetIndex >= 0 && wanted.includes(layout?.tiles[sheetIndex]?.entity)) renderTileSheet();
}
// A hint under a field sits beside its label, not in it: a click on a label presses the first choice inside.
function withHint(wrap, text, warn = false) {
const group = node("div", undefined, "sheet-hinted");
group.append(wrap, node("small", text, warn ? "field-hint warn" : "field-hint"));
return group;
}
// ----- Screen settings: the same groups and rows as the settings page on the screen itself -----
// Every change applies at once, like on the screen; no Save needed. A screen with firmware 0.2.49+ owns its
@@ -802,6 +831,7 @@ const domains = {
select: ["Select", "≡", "#5862af", "#eaecfa"],
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
button: ["Action", "↗", "#5862af", "#eaecfa"],
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
screen: ["Clock", "◷", "#25282c", "#e9ecf1"],
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
timer: ["Timer", "⏱", "#008577", "#def3ed"],
@@ -934,6 +964,7 @@ function commit(result) {
return changed;
}
function placeTile(tile, target) {
loadCapabilities([tile.entity]);
const result = arrange(layout.tiles, tile, target);
return result ? commit(result) : false;
}
@@ -1135,7 +1166,8 @@ function removeTile(index) {
// so the card behind it shows the result while you pick.
let sheetIndex = -1, iconPickerOpen = false;
function openTileSheet(index) {
if (index !== sheetIndex) iconPickerOpen = false;
loadCapabilities([layout.tiles[index].entity]);
if (index !== sheetIndex) { iconPickerOpen = false; actionPickerOpen = false; actionSearch = ""; }
sheetIndex = index;
selectedTile = layout.tiles[index].entity;
renderTileSheet();
@@ -1260,6 +1292,169 @@ function iconPicker({ selected, automatic, autoLabel, allowNone = false, onPick,
if (note) wrap.append(node("small", note, "icon-hint"));
return wrap;
}
// ----- Perform action (app 0.2.67): the actions Home Assistant offers for a tile's entity, under its own names -----
const entityActions = {}, askedActions = new Set();
let actionPickerOpen = false, actionSearch = "";
async function loadEntityActions(entity) {
if (askedActions.has(entity)) return;
askedActions.add(entity);
try {
entityActions[entity] = (await (await api(`entity-actions?entity=${encodeURIComponent(entity)}`)).json()).actions;
} catch {
askedActions.delete(entity);
return;
}
if (sheetIndex >= 0 && layout?.tiles[sheetIndex]?.entity === entity) renderTileSheet();
}
// One field of an action, in the kind of value Home Assistant asks for (its selector). An empty field is left out.
function actionField(field, value, onChange) {
const selector = field.selector || {}, kind = Object.keys(selector)[0] || "text", config = selector[kind] || {};
const wrap = node("div", undefined, "sheet-field action-field");
wrap.append(node("span", field.required ? field.name : `${field.name} (optional)`));
const example = field.example === undefined ? "" : typeof field.example === "string" ? field.example : JSON.stringify(field.example);
// A select's own options, or the values this entity has where Home Assistant asks for one of them (a source, an effect).
const choices = Array.isArray(field.options) ? field.options.map((o) => [o, o])
: kind === "select" && Array.isArray(config.options) && !config.multiple
? config.options.map((o) => (o && typeof o === "object" ? [String(o.value), String(o.label ?? o.value)] : [String(o), String(o)]))
: null;
if (kind === "boolean") {
wrap.append(segmented([["", "Not set"], ["true", "On"], ["false", "Off"]], value === undefined ? "" : String(value), (v) => onChange(v === "" ? undefined : v === "true")));
} else if (choices && choices.length <= 4) {
wrap.append(segmented([["", "Not set"], ...choices], value === undefined ? "" : String(value), (v) => onChange(v === "" ? undefined : v)));
} else if (choices) {
const list = node("select");
list.setAttribute("aria-label", field.name);
for (const [key, text] of [["", "Not set"], ...choices]) {
const option = node("option", text);
option.value = key;
option.selected = String(value ?? "") === key;
list.append(option);
}
list.onchange = () => onChange(list.value === "" ? undefined : list.value);
wrap.append(list);
} else if (kind === "color_rgb") {
const row = node("div", undefined, "action-number"), input = node("input"), clear = node("button", "Not set", "quiet");
input.type = "color";
input.setAttribute("aria-label", field.name);
const hex = (rgb) => "#" + rgb.map((c) => Math.max(0, Math.min(255, Number(c) || 0)).toString(16).padStart(2, "0")).join("");
input.value = Array.isArray(value) && value.length === 3 ? hex(value) : "#ffffff";
input.oninput = () => onChange([1, 3, 5].map((i) => parseInt(input.value.slice(i, i + 2), 16)));
clear.type = "button";
clear.onclick = () => onChange(undefined);
row.append(input, clear);
wrap.append(row);
} else if (kind === "number" || kind === "color_temp") {
const row = node("div", undefined, "action-number"), input = node("input");
input.type = "number";
input.setAttribute("aria-label", field.name);
for (const key of ["min", "max", "step"]) if (typeof config[key] === "number") input[key] = config[key];
if (config.step === "any") input.step = "any";
input.value = value ?? "";
input.placeholder = example;
input.oninput = () => onChange(input.value === "" || !Number.isFinite(Number(input.value)) ? undefined : Number(input.value));
row.append(input);
const unit = config.unit_of_measurement || (kind === "color_temp" ? config.unit || "" : "");
if (unit) row.append(node("span", unit));
wrap.append(row);
} else {
// Text as it is; anything else Home Assistant asks for (a list, an object, a time) as JSON, or as text when it isn't.
const input = node("input");
input.type = "text";
input.setAttribute("aria-label", field.name);
input.value = value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value);
input.placeholder = example;
input.oninput = () => {
const text = input.value.trim();
if (!text) return onChange(undefined);
if (kind === "text") return onChange(input.value);
try { onChange(JSON.parse(text)); } catch { onChange(input.value); }
};
wrap.append(input);
}
if (field.description) wrap.append(node("small", field.description, "field-hint"));
return wrap;
}
function actionPicker(tile) {
const wrap = node("div", undefined, "sheet-field");
wrap.append(node("span", "Action"));
const list = entityActions[tile.entity], chosen = tile.options?.action;
if (list === undefined) {
wrap.append(node("small", "Asking Home Assistant which actions this entity has…", "field-hint"));
loadEntityActions(tile.entity);
return wrap;
}
if (list === null) {
wrap.append(node("small", "Home Assistant isn't answering right now. Try again in a moment.", "field-hint warn"));
return wrap;
}
const entry = list.find((a) => a.action === chosen?.action);
const open = actionPickerOpen || !chosen;
const summary = node("button", undefined, "icon-current action-current");
summary.type = "button";
summary.setAttribute("aria-expanded", String(open));
const text = node("span");
text.append(node("strong", entry ? entry.name : chosen ? chosen.action : "Choose an action"));
if (chosen) text.append(node("small", chosen.action, "action-id"));
summary.append(text, node("small", open ? "Close" : "Change"));
summary.onclick = () => { actionPickerOpen = !open; renderTileSheet(); };
const panel = node("div", undefined, "icon-picker");
panel.hidden = !open;
const search = node("input"), rows = node("div", undefined, "action-list"), empty = node("p", "No action found.", "hint");
search.type = "search";
search.placeholder = "Search, for example toggle, position, or play";
search.setAttribute("aria-label", "Search for an action");
search.value = actionSearch;
for (const action of list) {
const b = node("button", undefined, "action-choice");
b.type = "button";
b.dataset.search = `${action.name} ${action.action} ${action.description}`.toLocaleLowerCase();
b.setAttribute("aria-pressed", String(action.action === chosen?.action));
b.append(node("strong", action.name), node("small", action.description ? `${action.action} · ${action.description}` : action.action));
b.onclick = () => {
const same = action.action === chosen?.action;
tile.options = { ...tile.options, action: same ? chosen : { action: action.action } };
actionPickerOpen = false;
markDirty();
renderTileSheet();
};
rows.append(b);
}
const filter = () => {
const query = search.value.trim().toLocaleLowerCase();
let found = 0;
for (const b of rows.children) { b.hidden = !b.dataset.search.includes(query); if (!b.hidden) found++; }
empty.hidden = found > 0;
};
search.oninput = () => { actionSearch = search.value; filter(); };
filter();
panel.append(search, rows, empty);
wrap.append(summary, panel);
if (chosen && !entry)
wrap.append(node("small", "Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.", "field-hint warn"));
if (entry && !open) {
const missing = node("small", "", "field-hint warn");
const check = () => {
const data = tile.options.action?.data || {};
const names = entry.fields.filter((f) => f.required && data[f.key] === undefined).map((f) => f.name);
missing.textContent = names.length ? `Home Assistant needs ${names.join(", ")}.` : "";
missing.hidden = !names.length;
};
for (const field of entry.fields)
wrap.append(actionField(field, chosen.data?.[field.key], (value) => {
const data = { ...(tile.options.action?.data || {}) };
if (value === undefined) delete data[field.key];
else data[field.key] = value;
tile.options = { ...tile.options, action: { action: chosen.action, ...(Object.keys(data).length ? { data } : {}) } };
markDirty();
check();
}));
check();
wrap.append(missing);
}
if (!supportsFirmware(0, 2, 58))
wrap.append(node("small", "The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic.", "field-hint"));
return wrap;
}
function renderTileSheet() {
const sheet = $("#tile-sheet"), tile = layout.tiles[sheetIndex];
if (!tile) return;
@@ -1283,13 +1478,15 @@ function renderTileSheet() {
// The clock, forecast and sun path cards draw no tile icon.
if (inventory.icons && domain !== "screen" && !["forecast", "sunpath"].includes(tile.options?.display))
body.append(iconField(tile, () => { const next = tileBadge(tile); badge.replaceWith(next); badge = next; }));
const caps = capabilities[tile.entity];
const current = (key, fallback) => tile.options?.[key] ?? fallback;
const display = current("display", domain === "screen" ? "digital" : "standard");
const displays = domain === "screen"
? [["digital", "Digital clock"], ["analog", "Analog clock"]]
: [["standard", "Name and status"], ["watch", "Large value"]];
if (domain === "weather") displays.push(["forecast", "Weather forecast"]);
if (domain === "sensor") displays.push(["graph", "Graph"]);
if (domain === "weather" && (!caps || caps.displays.includes("forecast") || display === "forecast")) displays.push(["forecast", "Weather forecast"]);
if (domain === "sensor" && (!caps || caps.displays.includes("graph") || display === "graph")) displays.push(["graph", "Graph"]);
if (domain === "sun") displays.push(["sunpath", "Sun path"]);
const current = (key, fallback) => tile.options?.[key] ?? fallback;
const set = (key, value) => {
const wasWide = isWide(tile);
tile.options = { ...tile.options, [key]: value };
@@ -1298,6 +1495,10 @@ function renderTileSheet() {
if (key === "display" && ["forecast", "sunpath"].includes(value)) tile.options.size = "wide";
if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (inventory.controls?.[domain]) tile.options.controls = "none"; }
if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; }
// A card that becomes wide gets the first direct control Home Assistant offers when the usual one isn't there.
const catalogue = inventory.controls?.[domain];
if (key === "size" && value === "wide" && caps && catalogue && !("controls" in tile.options) && !caps.controls.includes(catalogue.default))
tile.options.controls = catalogue.choices.find((c) => c.key !== "none" && caps.controls.includes(c.key))?.key || "none";
markDirty();
// A card that becomes double-wide keeps its row when the cell beside it is free, else it
// takes the nearest free row (below first); every other tile stays where it is.
@@ -1308,23 +1509,46 @@ function renderTileSheet() {
}
renderTiles();
};
body.append(field("Display", segmented(displays, current("display", domain === "screen" ? "digital" : "standard"), (v) => set("display", v))));
const displayField = field("Display", segmented(displays, display, (v) => set("display", v)));
if (caps && display === "graph" && !caps.displays.includes("graph"))
body.append(withHint(displayField, "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.", true));
else if (caps && display === "forecast" && !caps.displays.includes("forecast"))
body.append(withHint(displayField, "This weather service has no daily forecast in Home Assistant. Choose another display.", true));
else body.append(displayField);
body.append(field("Width", segmented([["single", "Normal"], ["wide", "Double-width"]], current("size", "single"), (v) => set("size", v))));
const catalogue = inventory.controls?.[domain];
if (catalogue && current("size", "single") === "wide") {
const wrap = field("Direct control on the tile", segmented(catalogue.choices.map((c) => [c.key, c.label]), current("controls", catalogue.default), (v) => set("controls", v)));
wrap.append(node("small", supportsFirmware(0, 2, 19)
const chosen = current("controls", catalogue.default);
const choices = catalogue.choices.filter((c) => !caps || c.key === "none" || c.key === chosen || caps.controls.includes(c.key));
const wrap = field("Direct control on the tile", segmented(choices.map((c) => [c.key, c.label]), chosen, (v) => set("controls", v)));
if (caps && chosen !== "none" && !caps.controls.includes(chosen))
body.append(withHint(wrap, "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.", true));
else body.append(withHint(wrap, supportsFirmware(0, 2, 19)
? "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as configured below."
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.", "field-hint"));
body.append(wrap);
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was."));
}
if (domain !== "screen") {
const tap = current("tap", "auto");
const taps = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]];
if (["light", "switch", "input_boolean", "fan", "media_player", "climate"].includes(domain)) taps.push(["toggle", "On / off"]);
body.append(field("On tap", segmented(taps, current("tap", "auto"), (v) => set("tap", v))));
// On / off where Home Assistant can toggle the entity, such as a cover; a speaker without on and off gets none.
if ((caps ? caps.toggle : TOGGLE_BEFORE.includes(domain)) || tap === "toggle") taps.push(["toggle", "On / off"]);
taps.push(["action", "Perform action"]);
const wrap = field("On tap", segmented(taps, tap, (v) => set("tap", v)));
if (tap === "toggle" && caps && !caps.toggle)
body.append(withHint(wrap, "Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.", true));
else if (tap === "toggle" && !TOGGLE_BEFORE.includes(domain) && !supportsFirmware(0, 2, 58))
body.append(withHint(wrap, "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card."));
else if (tap === "toggle") body.append(withHint(wrap, "Hold the tile to open its card."));
else body.append(wrap);
if (tap === "action") body.append(actionPicker(tile));
}
const inline = current("inline", "none");
if (SLIDER_DOMAINS.includes(domain) && (!caps || caps.inline || inline === "slider")) {
const wrap = field("Small slider on the tile", segmented([["none", "No"], ["slider", "Yes, control directly"]], inline, (v) => set("inline", v)));
if (inline === "slider" && caps && !caps.inline)
body.append(withHint(wrap, "Home Assistant has nothing a slider can change for this entity. Choose No.", true));
else body.append(wrap);
}
if (["light", "fan", "cover", "number", "input_number", "media_player"].includes(domain))
body.append(field("Small slider on the tile", segmented([["none", "No"], ["slider", "Yes, control directly"]], current("inline", "none"), (v) => set("inline", v))));
if (domain === "sensor")
body.append(field("History", segmented([[1, "1 hour"], [6, "6 hours"], [24, "24 hours"]], current("history_hours", 24), (v) => set("history_hours", Number(v)))));
const palette = node("div", undefined, "sheet-field");
@@ -2007,6 +2231,10 @@ function endDrag(drop) {
if (!(drop && preview && commit(preview))) renderPreview();
}
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
// The picker offers what a tile can show: the inventory also lists locks, trackers and counters for the top bar (tile: false).
function pickable(entity) {
return entity.tile !== false;
}
function renderResults() {
if (!layout) return;
const query = $("#search").value.toLocaleLowerCase();
@@ -2015,9 +2243,10 @@ function renderResults() {
const guition = inventory.screens.find((s) => s.id === selected)?.board === "guition";
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
(e) =>
pickable(e) &&
(guition || !["camera", "image"].includes(e.id.split(".")[0])) &&
(!filter || e.id.startsWith(filter + ".") ||
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun"}[filter] === e.id.split(".")[0])) &&
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun", button:"input_button"}[filter] === e.id.split(".")[0])) &&
`${e.name} ${e.id} ${e.device} ${e.area}`
.toLocaleLowerCase()
.includes(query),
@@ -2541,7 +2770,7 @@ async function inspect(entity) {
const card = node("article", undefined, "inspection-tile");
card.append(
node("strong", tile.entity),
node("p", `Status: ${tile.state}`),
node("p", `Status: ${tile.word || tile.state}`),
);
const options =
layout.tiles.find((t) => t.entity === tile.entity)?.options || {};
+14
View File
@@ -1158,6 +1158,8 @@ pre {
.preview-toggle::after { content: ""; position: absolute; top: 3px; right: 3px; width: 14px; height: 14px; border-radius: 50%; background: white; }
.preview-run { height: 22px; padding: 0 10px; border-radius: 11px; background: #eceef1; font-size: 12px; font-weight: 600; line-height: 22px; }
.field-hint { font-weight: 400; }
.sheet-hinted { display: grid; gap: 7px; font-size: 12px; color: var(--muted); }
.field-hint.warn { color: #9a6200; }
.preview-tile small { flex-basis: 100%; }
.result:hover:not(:disabled), .preview-tile:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; }
@@ -1225,6 +1227,18 @@ pre {
.icon-choice.icon-auto .mdi { font-size: 22px; }
.icon-picker .hint { margin: 0; }
.icon-hint { font-weight: 400; }
/* Perform action: Home Assistant's actions for the entity, then the fields of the chosen one. */
.action-current > span { display: grid; gap: 2px; flex: 1; min-width: 0; padding-left: 5px; }
.action-current .action-id { color: var(--muted); font-weight: 400; }
.action-list { display: grid; gap: 6px; max-height: 300px; overflow: auto; padding-right: 2px; }
.action-choice { display: grid; gap: 2px; padding: 8px 12px; background: white; color: var(--ink); border: 1px solid var(--line); border-radius: 10px; font-size: 13px; text-align: left; }
.action-choice strong { font-weight: 600; }
.action-choice small { color: var(--muted); font-weight: 400; }
.action-choice[aria-pressed="true"] { background: var(--soft); border-color: #9dbcf5; box-shadow: inset 0 0 0 1px #9dbcf5; }
.action-field { margin-top: 4px; }
.action-field input, .action-field select { font-size: 14px; font-weight: 400; }
.action-number { display: flex; align-items: center; gap: 8px; }
.action-number input { flex: 1; min-width: 0; }
/* New screen: profile, Wi-Fi, and the first flash in one window. */
.link {
Binary file not shown.
+226 -1
View File
@@ -6,6 +6,8 @@ the three icon fonts of both board profiles and builds the editor font
fonts/materialdesignicons-webfont.ttf. Firmware 0.2.18+ carries every glyph here.
"""
import re
# (group, ((MDI name, codepoint, label), ...)) in picker order.
GROUPS = (
('Lighting', (
@@ -228,9 +230,141 @@ FIXED = (
('lightbulb-off', 'F0E4F'),
)
# Home Assistant's own default icons for the domains a tile or the top bar shows (frontend/get_icons, `entity_component`,
# Home Assistant 2026.9), with their state, range and device class variants and the few its frontend picks in code: a
# closed blind, a playing speaker, a battery at 40 %, a phone's tracker on the router (app 0.2.67). The firmware fonts carry them, so a tile shows the icon Home Assistant shows; a name missing here, such as
# one a later Home Assistant adds, keeps the screen's own default. Not pickable in the editor.
HA_DEFAULTS = (
('account-arrow-right', 'F0B53'),
('air-filter', 'F0D43'),
('air-humidifier', 'F1099'),
('air-humidifier-off', 'F1466'),
('alert-circle', 'F0028'),
('angle-acute', 'F0937'),
('arrow-bottom-left', 'F0042'),
('arrow-bottom-right', 'F0043'),
('arrow-down-box', 'F06C0'),
('arrow-left-right', 'F0E73'),
('arrow-right', 'F0054'),
('arrow-split-vertical', 'F093C'),
('arrow-top-left', 'F005B'),
('arrow-top-right', 'F005C'),
('arrow-up-box', 'F06C3'),
('audio-video', 'F093D'),
('audio-video-off', 'F11B6'),
('battery', 'F0079'),
('battery-10', 'F007A'),
('battery-20', 'F007B'),
('battery-30', 'F007C'),
('battery-40', 'F007D'),
('battery-50', 'F007E'),
('battery-60', 'F007F'),
('battery-70', 'F0080'),
('battery-80', 'F0081'),
('battery-90', 'F0082'),
('battery-alert', 'F0083'),
('battery-charging', 'F0084'),
('battery-outline', 'F008E'),
('battery-unknown', 'F0091'),
('blinds-horizontal', 'F1A2B'),
('blinds-horizontal-closed', 'F1A2C'),
('bluetooth', 'F00AF'),
('bluetooth-connect', 'F00B1'),
('brightness-5', 'F00DE'),
('brightness-7', 'F00E0'),
('button-pointer', 'F1B50'),
('car-battery', 'F010C'),
('car-coolant-level', 'F1019'),
('cash', 'F0114'),
('cast-connected', 'F0119'),
('cast-off', 'F078A'),
('check-circle', 'F05E0'),
('check-circle-outline', 'F05E1'),
('check-network-outline', 'F0C54'),
('checkbox-marked-circle', 'F0133'),
('circle-slice-8', 'F0AA5'),
('clock', 'F0954'),
('clock-start', 'F0155'),
('close-circle-outline', 'F015A'),
('close-network-outline', 'F0C5F'),
('compass-rose', 'F1382'),
('crop-portrait', 'F01A1'),
('crosshairs-question', 'F1136'),
('current-ac', 'F1480'),
('database', 'F01BC'),
('ear-hearing', 'F07C5'),
('eye', 'F0208'),
('eye-check', 'F0D04'),
('fan-off', 'F081D'),
('format-list-bulleted', 'F0279'),
('garage-open', 'F06DA'),
('gate', 'F0299'),
('gate-open', 'F116A'),
('home-outline', 'F06A1'),
('lan-connect', 'F0318'),
('lan-disconnect', 'F0319'),
('lightning-bolt', 'F140B'),
('lock-alert', 'F08EE'),
('lock-clock', 'F097F'),
('lock-open', 'F033F'),
('molecule', 'F0BAC'),
('molecule-co', 'F12FE'),
('motion-sensor-off', 'F1435'),
('music-note-off', 'F038A'),
('octagon', 'F03C3'),
('package', 'F03D3'),
('package-up', 'F03D5'),
('ph', 'F17C5'),
('pipe-valve', 'F184D'),
('power-plug-off', 'F06A6'),
('progress-clock', 'F0996'),
('projector-off', 'F1A23'),
('radioactive', 'F043C'),
('ray-vertex', 'F0445'),
('roller-shade', 'F1A6B'),
('script-text-play', 'F1727'),
('security', 'F0483'),
('shield', 'F0498'),
('shield-airplane', 'F06BB'),
('shield-lock', 'F099D'),
('shield-moon', 'F1828'),
('shield-off', 'F099E'),
('shield-outline', 'F0499'),
('sine-wave', 'F095B'),
('smoke-detector-alert', 'F192E'),
('smoke-detector-variant', 'F180B'),
('smoke-detector-variant-alert', 'F1930'),
('speaker-off', 'F04C4'),
('speaker-pause', 'F1B73'),
('speaker-play', 'F1B72'),
('speedometer', 'F04C5'),
('spoon-sugar', 'F1429'),
('sprout-outline', 'F0E67'),
('square', 'F0764'),
('square-outline', 'F0763'),
('storage-tank', 'F1A75'),
('sun-wireless', 'F17FE'),
('television-off', 'F083B'),
('television-pause', 'F0F89'),
('television-play', 'F0ECF'),
('texture-box', 'F0FE6'),
('thermometer-lines', 'F0510'),
('toggle-switch-variant', 'F1A25'),
('toggle-switch-variant-off', 'F1A26'),
('transmission-tower', 'F0D3E'),
('vibrate', 'F0566'),
('water-boiler-off', 'F11B4'),
('water-off', 'F058D'),
('water-opacity', 'F1855'),
('weight', 'F05A1'),
('wifi', 'F05A9'),
('window-closed', 'F05AE'),
('window-open', 'F05B1'),
)
# Pickable icons by name, and every glyph the firmware fonts contain.
ICONS = {name: (codepoint, label) for _, icons in GROUPS for name, codepoint, label in icons}
GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(FIXED)}
GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(FIXED), **dict(HA_DEFAULTS)}
# Mirrors runtime_tiles::icon_for() so the editor mockup shows what the screen draws.
DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot-vacuum', 'fan': 'fan',
@@ -248,6 +382,97 @@ WEATHER = {'sunny': 'weather-sunny', 'clear-night': 'weather-night', 'cloudy': '
'lightning': 'weather-lightning', 'lightning-rainy': 'weather-lightning-rainy', 'windy': 'weather-windy',
'windy-variant': 'weather-windy', 'exceptional': 'alert-circle-outline'}
# Domains whose icon the screen draws from the state itself: a weather condition, the sun above or below the horizon.
OWN_ICON_DOMAINS = frozenset(('weather', 'sun', 'screen'))
# Home Assistant's icon resources ({'entity_component': ..., 'entity': ...}), set by the app when it reads them; empty keeps
# the tables of earlier releases.
HA_ICONS = {}
def use_ha_icons(resources):
"""The icon resources Home Assistant answered frontend/get_icons with, for every icon lookup of this app."""
global HA_ICONS
HA_ICONS = resources if isinstance(resources, dict) else {}
# A number as JavaScript's Number() reads a state: Home Assistant's frontend picks a range icon by it.
JS_NUMBER = re.compile(r'[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?Infinity')
def js_number(text):
"""The number JavaScript's Number() reads from a state or a range key, None where it reads NaN (a blank text is 0)."""
if not isinstance(text, str):
return None
text = text.strip()
if not text:
return 0.0
return float(text.replace('Infinity', 'inf')) if JS_NUMBER.fullmatch(text) else None
def icon_for_state(state, spec):
"""Home Assistant's getIconFromTranslations: the icon for this state, else the range's icon for a number at or above its
lowest step (the highest step not above it), else the default."""
if not isinstance(spec, dict):
return None
states, ranges = spec.get('state'), spec.get('range')
if state and isinstance(states, dict) and states.get(state):
return states[state]
value = js_number(state) if isinstance(ranges, dict) else None
if value is not None:
steps = sorted(step for step in map(js_number, ranges) if step is not None)
below = [step for step in steps if step <= value]
if below:
step = below[-1]
icon = ranges.get(str(int(step)) if step.is_integer() else repr(step))
if icon:
return icon
return spec.get('default')
def state_icon(domain, state, attributes):
"""Home Assistant's stateIcon: the icons its frontend picks in code, before those of the integration."""
if domain == 'update':
return 'mdi:package-down' if attributes.get('in_progress') else 'mdi:package-up' if state == 'on' else 'mdi:package'
if domain == 'device_tracker':
source = attributes.get('source_type')
if source == 'router':
return 'mdi:lan-connect' if state == 'home' else 'mdi:lan-disconnect'
if source in ('bluetooth', 'bluetooth_le'):
return 'mdi:bluetooth-connect' if state == 'home' else 'mdi:bluetooth'
return 'mdi:account-arrow-right' if state == 'not_home' else 'mdi:account'
if domain == 'sun':
return 'mdi:white-balance-sunny' if state == 'above_horizon' else 'mdi:weather-night'
if domain == 'input_datetime':
if not attributes.get('has_date'):
return 'mdi:clock'
if not attributes.get('has_time'):
return 'mdi:calendar'
return None
def ha_default_icon(entity_id, state, attributes, entry, icons):
"""The icon Home Assistant's frontend shows for an entity without one of its own (frontend/get_icons, app 0.2.67), in
its order: the integration's icon for the entity's translation key, then the icon its frontend picks in code, then the
domain's icon for the state's device class or else the domain's. The MDI name, or None when Home Assistant has none;
`state` None is an entity without a state, which gets the defaults."""
if not isinstance(icons, dict):
return None
domain = entity_id.split('.', 1)[0]
entry = entry if isinstance(entry, dict) else {}
attributes = attributes if isinstance(attributes, dict) else None
icon = None
if entry.get('platform') and entry.get('translation_key'):
platform = ((icons.get('entity') or {}).get(entry['platform']) or {}).get(domain) or {}
icon = icon_for_state(state, platform.get(entry['translation_key']))
if not icon and attributes is not None and state is not None:
icon = state_icon(domain, state, attributes)
if not icon:
component = (icons.get('entity_component') or {}).get(domain) or {}
device_class = (attributes or {}).get('device_class')
icon = icon_for_state(state, (isinstance(device_class, str) and component.get(device_class)) or component.get('_'))
return icon[4:] if isinstance(icon, str) and icon.startswith('mdi:') else None
def default_glyph(entity_id, state, attributes, entry=None, icons=None):
"""Codepoint of Home Assistant's default icon when the firmware fonts carry it; None keeps the screen's own icon."""
if entity_id.split('.', 1)[0] in OWN_ICON_DOMAINS:
return None
name = ha_default_icon(entity_id, state, attributes, entry, HA_ICONS if icons is None else icons)
return GLYPHS.get(name) if name else None
def ha_icon(attributes):
"""Codepoint of Home Assistant's own `mdi:` icon when the firmware carries it, else None."""
icon = attributes.get('icon') if isinstance(attributes, dict) else None
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.66"
version: "0.2.67"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen