Let Claude in Home Assistant put tiles on a screen (0.2.51)

Four Home Assistant events change a screen the way the app's own editor does:
esp_screens_add_tile (add or change), esp_screens_remove_tile,
esp_screens_move_tile and esp_screens_order_tiles. An event names the screen by
device name, shown name, area or title, and may set the size, the direct
control, the display, the icon, the colour and the spot; a tile with a control
or a forecast becomes double-width by itself. apply_tile_event returns the new
layout and Manager.save does the validating and the pushing, so nothing can get
past the twenty-tile limit, a duplicate entity or an occupied spot.

Every event is answered with esp_screens_tile_result, and every screen publishes
what it shows as sensor.esp_screens_<node>, with the page, row, column, width,
control and display per tile. So an assistant can read a screen before it moves
anything, and a refused event says why and changes nothing.

The Claude skill gains a Tiles section with the events, the fields, what each
kind of entity can do, how to read a screen first, and the rule to show the plan
and wait for a yes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 08:19:49 +02:00
parent 08197c4ae1
commit 93c789dc48
10 changed files with 724 additions and 24 deletions
+91 -7
View File
@@ -14,12 +14,12 @@ import zipfile
import tile_icons
from core import (ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
TILE_BACKGROUNDS)
CONTROLS, DISPLAYS, MAX_PAGES, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT)
NAME = 'esp-screens'
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
DESCRIPTION = ('Alerts and standby on ESP Screens (CYD and Guition touchscreens run from Home Assistant): alerts on '
'all screens or one with the icons they support, and an automation that keeps screens awake.')
DESCRIPTION = ('ESP Screens (CYD and Guition touchscreens run from Home Assistant): put a tile on a screen, move or '
'order tiles, show an alert on one screen or all of them, and keep a screen awake.')
TYPES = {'string': 'text', 'int': 'number', 'bool': 'on/off'}
def skill_dir(config=None):
@@ -32,27 +32,111 @@ def _limit(name, kind):
return f'0 to {ALERT_MAX_TIMEOUT} s' if kind == 'int' else ''
def text():
"""SKILL.md: when to use it, the event for every screen, the per-screen action, fields, colors and icons,
then the standby and brightness entities every screen has in Home Assistant."""
"""SKILL.md: the tile events and how to read a screen, then the alert event for every screen, the
per-screen action, fields, colors and icons, and the standby and brightness entities."""
fields = '\n'.join(f'| `{name}` | {TYPES[kind]} | {help_} | {_limit(name, kind)} |' for name, kind, _, help_, _ in ALERT_FIELDS)
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)
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())
displays = '\n'.join(f'| `{domain}` | ' + ', '.join(f'`{name}`' for name in names) + ' |' for domain, names in DISPLAYS.items())
events = '\n'.join(f'| `{event}` | {what} |' for event, what in
(('esp_screens_add_tile', 'Puts an entity on a screen, or changes the tile that is already there'),
('esp_screens_remove_tile', 'Takes a tile off a screen'),
('esp_screens_move_tile', 'Moves a tile to another page or spot'),
('esp_screens_order_tiles', 'Puts tiles in the order you give')))
return f'''---
name: {NAME}
description: {DESCRIPTION}
---
# Alerts and standby on ESP Screens
# Tiles, alerts and standby on ESP Screens
Made by ESP Screen Manager (ESP Screens → Settings → Claude). Installing it again from there replaces this file, so changes made here get lost.
Two things Home Assistant can do with the screens: show an alert (below), and keep a screen awake or let it dim ([Standby and brightness](#standby-and-brightness)).
Three things Home Assistant can do with the screens: choose what a screen shows ([Tiles](#tiles-on-a-screen)), show an alert (below), and keep a screen awake or let it dim ([Standby and brightness](#standby-and-brightness)).
An alert is a card over the whole screen with an icon, a title, a subtitle and one button. It wakes the screen and stays until someone presses the button or the timeout runs out. A new alert replaces the one showing.
## Tiles on a screen
A screen shows tiles: two columns and {SLOTS_PER_PAGE // 2} rows per page, at most {MAX_PAGES} pages and twenty tiles. A tile is single or double-width; a double-width one starts in the left column and takes two spots.
Fire one of these events and ESP Screens changes that screen and sends it right away, the same way its own editor does.
| Event | What it does |
|---|---|
{events}
```yaml
actions:
- event: esp_screens_add_tile
event_data:
screen: living room
entity: vacuum.s8
```
### What you can put in the event
| Field | Meaning |
|---|---|
| `screen` | Which screen: its device name, the name Home Assistant shows, its area, or the title on the screen. With one screen paired you can leave this out. |
| `entity` | The entity the tile shows. Use the real entity ID; never invent one. |
| `name` | A name of your own on the tile; leave it out to keep Home Assistant's. |
| `page` | Page, counted from 1. Without a spot the tile takes the first free one on that page. |
| `row`, `column` | An exact spot on that page: row 1 to {SLOTS_PER_PAGE // 2}, column `left` or `right`. |
| `size` | `single` or `wide`. |
| `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`. |
| `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.
### What a tile can do, per kind of entity
| Entity | `controls` |
|---|---|
{controls}
| Entity | `display` |
|---|---|
{displays}
Everything else shows its name and state, and opens a card of its own on a long press.
### Reading a screen first
Every screen also publishes what it shows, as `sensor.esp_screens_<device name>`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `size`, `controls` and `display` per tile. Read that before moving things around, so you know what is already there and where.
Ordering a page means naming the tiles that are on it, in the order you want:
```yaml
actions:
- event: esp_screens_order_tiles
event_data:
screen: living room
page: 1
entities: [light.kitchen, light.dining, vacuum.s8]
```
A tile from another page has to be moved there first (`esp_screens_move_tile` with `page`). Leave `page` out to order the whole screen: the entities you name come first, the rest keeps its order behind them.
### What comes back
ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity` and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has twenty tiles"). The add-on log says the same. Nothing changes on a refused event.
### How to work
1. Read the screen's sensor, and Home Assistant's own entities for what the user names. Ask which screen when several could fit.
2. Say what you are going to do: which tile, which screen, which spot. A tile appears on a screen in someone's house, so wait for a yes before firing the event.
3. Fire the events one at a time and read `{TILE_RESULT_EVENT}` (or the sensor) before the next one.
4. Sorting by how much something is used comes from Home Assistant itself (history or the logbook), not from the screen.
## All screens: fire an event
ESP Screen Manager listens for two Home Assistant events and passes them on to every paired screen that is online, screens added later included. Screens need firmware {ALERT_MIN_FIRMWARE} or newer, and the app has to be running.
+224
View File
@@ -261,6 +261,230 @@ def min_firmware(layout):
def short(value, limit):
return str(value).encode('utf-8')[:limit].decode('utf-8', errors='ignore')
# ----- Tiles from a Home Assistant event (app 0.2.51) -----
# Claude in Home Assistant, or any automation, can put something on a screen without opening the editor:
# it fires one of these events and the app changes that screen's layout, with the same validation and the
# same push. The app answers with TILE_RESULT_EVENT, and publishes every layout as a sensor
# (layout_snapshot) so an assistant can see what is where before it changes anything.
TILE_EVENTS = {'esp_screens_add_tile': 'add', 'esp_screens_remove_tile': 'remove',
'esp_screens_move_tile': 'move', 'esp_screens_order_tiles': 'order'}
TILE_RESULT_EVENT = 'esp_screens_tile_result'
# What an event may set on a tile: the editor's own settings, with `color` as a friendlier name for the
# pastel background.
TILE_EVENT_OPTIONS = {'size': 'size', 'controls': 'controls', 'display': 'display', 'icon': 'icon',
'color': 'background', 'background': 'background', 'tap': 'tap', 'inline': 'inline',
'history_hours': 'history_hours'}
TILE_SIZES = {'wide': 'wide', 'double': 'wide', 'large': 'wide', 'big': 'wide',
'single': 'single', 'small': 'single', 'normal': 'single'}
def loose(text):
"""A name as people write it: case, spaces, dashes and underscores don't matter."""
return re.sub(r'[\s_-]+', ' ', str(text or '')).strip().casefold()
def match_screen(screens, wanted, layouts=None):
"""The screen an event means: by its device name, the name Home Assistant shows, or its title.
With one screen paired, an event doesn't have to name it."""
listing = ', '.join(sorted(screen['name'] for screen in screens)) or 'none yet'
if not loose(wanted):
if len(screens) == 1:
return screens[0]
raise ValueError(f'Name the screen. Paired: {listing}.')
key = loose(wanted)
def names(screen):
title = (layouts or {}).get(screen['id'], {}).get('title', '')
return [loose(value) for value in (screen.get('node'), screen.get('name'), screen.get('device'), title, screen.get('area')) if value]
found = [s for s in screens if key in names(s)] or [s for s in screens if any(key in name for name in names(s))]
if not found:
raise ValueError(f'No screen called "{wanted}". Paired: {listing}.')
if len(found) > 1:
raise ValueError(f'"{wanted}" fits more than one screen: ' + ', '.join(sorted(s['name'] for s in found)) + '.')
return found[0]
def occupied_cells(tiles, skip=None):
cells = set()
for tile in tiles:
if tile is skip or 'slot' not in tile:
continue
cells.update(footprint(tile['slot'], is_wide(tile)))
return cells
def free_slot(tiles, wide, page=None, skip=None):
"""The first cell a tile of this width fits in, on `page` or anywhere; None when there is no room."""
cells = occupied_cells(tiles, skip)
first = 0 if page is None else page * SLOTS_PER_PAGE
last = MAX_SLOTS if page is None else min(MAX_SLOTS, first + SLOTS_PER_PAGE)
for slot in range(first, last):
if wide and (slot % 2 or slot + 1 >= last):
continue
if not set(footprint(slot, wide)) & cells:
return slot
return None
def place_tile(tile, tiles, page=None, slot=None):
"""Give a tile its cell: the one asked for when it is free, else the first free one (on `page`)."""
wide = is_wide(tile)
if slot is not None:
if wide and slot % 2:
slot -= 1
taken = next((t for t in tiles if t is not tile and slot in footprint(t.get('slot', -9), is_wide(t))), None)
if taken:
raise ValueError(f"That spot is taken by {taken['entity']}; give another spot or move that one first.")
tile['slot'] = slot
return
free = free_slot(tiles, wide, page, skip=tile)
if free is None:
raise ValueError(f'Page {page + 1} is full.' if page is not None else 'This screen has no room left.')
tile['slot'] = free
def tile_options(data, current=None):
"""The settings an event asks for, on top of what the tile already has. A direct control, a forecast
and a sun path only fit a double-width card, so they widen the tile themselves."""
options = dict(current or {})
for key, name in TILE_EVENT_OPTIONS.items():
if key not in data or data[key] in (None, ''):
continue
value = data[key]
if name == 'history_hours':
options[name] = int(value) if str(value).isdigit() else value
elif name == 'size':
options[name] = TILE_SIZES.get(loose(value), str(value))
else:
options[name] = str(value).strip()
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, '')}
def event_page(data):
"""The page an event names, counted from one as people do; None when it doesn't name one."""
page = data.get('page')
if page in (None, ''):
return None
if not str(page).strip().isdigit() or not 1 <= int(page) <= MAX_PAGES:
raise ValueError(f'Choose a page between 1 and {MAX_PAGES}.')
return int(page) - 1
def event_slot(data, page):
"""An exact spot: `slot` as the editor counts it, or `row` and `column` within a page."""
if data.get('slot') not in (None, ''):
if not str(data['slot']).strip().isdigit() or not 0 <= int(data['slot']) < MAX_SLOTS:
raise ValueError(f'Choose a spot between 0 and {MAX_SLOTS - 1}.')
return int(data['slot'])
if data.get('row') in (None, '') and data.get('column') in (None, ''):
return None
if page is None:
raise ValueError('Name the page for that row or column.')
row = str(data.get('row', 1)).strip()
if not row.isdigit() or not 1 <= int(row) <= SLOTS_PER_PAGE // 2:
raise ValueError(f'Choose a row between 1 and {SLOTS_PER_PAGE // 2}.')
column = loose(data.get('column') or 'left')
if column not in ('left', 'right'):
raise ValueError('The column is left or right.')
return page * SLOTS_PER_PAGE + (int(row) - 1) * 2 + (1 if column == 'right' else 0)
def page_of(tile):
return tile.get('slot', 0) // SLOTS_PER_PAGE
def pack_page(tiles, page):
"""Give these tiles the cells of one page, in the order they are in."""
position = page * SLOTS_PER_PAGE
last = position + SLOTS_PER_PAGE
for tile in tiles:
wide = is_wide(tile)
if wide and position % 2:
position += 1
if position + (2 if wide else 1) > last:
raise ValueError(f'That does not fit on page {page + 1}; a double-width tile takes two spots.')
tile['slot'] = position
position += 2 if wide else 1
def apply_tile_event(layout, action, data):
"""The layout after one tile event. Raises ValueError with the sentence the log and the answer show."""
result = {key: value for key, value in layout.items() if key != 'tiles'}
tiles = [dict(tile) for tile in layout.get('tiles', [])]
entity = str(data.get('entity') or '').strip()
page, slot = event_page(data), None
slot = event_slot(data, page)
if slot is not None and page is None:
page = slot // SLOTS_PER_PAGE
if action == 'order':
wanted = data.get('entities') or data.get('order') or []
if isinstance(wanted, str):
wanted = [part.strip() for part in wanted.split(',')]
wanted = [str(item).strip() for item in wanted if str(item).strip()]
if not wanted:
raise ValueError('Give the entities in the order you want them.')
known = {tile['entity']: tile for tile in tiles}
missing = [name for name in wanted if name not in known]
if missing:
raise ValueError('Not on this screen: ' + ', '.join(missing) + '.')
if page is None:
rest = [tile for tile in tiles if tile['entity'] not in wanted]
tiles = [known[name] for name in wanted] + rest
for tile, cell in zip(tiles, pack_slots(tiles)):
tile['slot'] = cell
else:
elsewhere = [name for name in wanted if page_of(known[name]) != page]
if elsewhere:
raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.join(elsewhere)))
on_page = [known[name] for name in wanted] + [t for t in tiles if page_of(t) == page and t['entity'] not in wanted]
pack_page(on_page, page)
result['tiles'] = tiles
return result
if not entity:
raise ValueError('Name the entity.')
found = next((tile for tile in tiles if tile['entity'] == entity), None)
if action == 'remove':
if not found:
raise ValueError(f'{entity} is not on this screen.')
tiles.remove(found)
elif action == 'move':
if not found:
raise ValueError(f'{entity} is not on this screen; add it first.')
if page is None and slot is None:
raise ValueError('Name the page or the spot to move it to.')
found.pop('slot', None)
place_tile(found, tiles, page, slot)
else:
if not entity_id(entity) and entity not in BUILTIN:
raise ValueError(f'{entity} cannot go on a screen.')
was_wide, had_slot = bool(found) and is_wide(found), (found or {}).get('slot')
options = tile_options(data, (found or {}).get('options'))
tile = found or {'entity': entity, 'name': ''}
if data.get('name') not in (None, ''):
tile['name'] = str(data['name']).strip()
if options:
tile['options'] = options
elif found:
tile.pop('options', None)
if found is None:
if len(tiles) >= 20:
raise ValueError('This screen already has twenty tiles; remove one first.')
tiles.append(tile)
wide = is_wide(tile)
move = found is None or had_slot is None or page is not None or slot is not None
if not move and wide != was_wide:
# A tile that just grew keeps its spot when the cell beside it is free.
move = (wide and had_slot % 2) or bool(set(footprint(had_slot, wide)) & occupied_cells([t for t in tiles if t is not tile]))
if move:
tile.pop('slot', None)
place_tile(tile, tiles, page, slot)
result['tiles'] = tiles
return result
def layout_snapshot(screen, layout):
"""What a screen shows, for the sensor the app publishes in Home Assistant: one entry per tile with
the page and the spot it is in, so an assistant can read the screen before it changes it."""
tiles = []
for tile in sorted(layout.get('tiles', []), key=lambda item: item.get('slot', 0)):
slot, options = tile.get('slot', 0), tile.get('options', {})
tiles.append({'entity': tile['entity'], 'name': tile.get('name') or '',
'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')})
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):
if not isinstance(data, dict):
raise ValueError('Invalid layout.')
+70 -3
View File
@@ -15,7 +15,7 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BROADCAST_EVENTS, BUILTIN, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, 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 BROADCAST_EVENTS, BUILTIN, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
import header_bar
from zoneinfo import ZoneInfo
@@ -83,6 +83,8 @@ class HomeAssistant:
self.units = {}
# (event type, data) of alert events for every screen, for Manager.alert_loop.
self.broadcasts = asyncio.Queue()
# (event type, data) of tile events (app 0.2.51+), for Manager.tile_loop.
self.tile_events = asyncio.Queue()
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -129,6 +131,8 @@ class HomeAssistant:
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))
elif event.get('event_type') in TILE_EVENTS:
self.tile_events.put_nowait((event['event_type'], body))
raise ConnectionError('Home Assistant connection lost.')
def describe_close(self, connected):
@@ -159,7 +163,7 @@ class HomeAssistant:
reader = asyncio.create_task(self.read())
await self.request('subscribe_events', event_type='state_changed')
await self.request('subscribe_events', event_type='esphome.screen_setting')
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS):
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS):
await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries()
@@ -225,6 +229,18 @@ class HomeAssistant:
domain, service = action.split('.', 1)
await self.request('call_service', domain=domain, service=service, service_data=data)
async def fire(self, event_type, data):
"""One Home Assistant event of our own, such as the answer to a tile event."""
await self.request('fire_event', event_type=event_type, event_data=data)
async def set_state(self, entity_id, state, attributes):
"""A state this app publishes itself (the layout sensors). Home Assistant's websocket has no
command for that, so this goes over the REST API with the same token."""
async with self.session.post(f'{self.base}/states/{entity_id}',
headers={'Authorization': 'Bearer ' + self.token},
json={'state': state, 'attributes': attributes}) as response:
response.raise_for_status()
async def forecast(self, entity, kind='daily'):
# Forecasts left the weather attributes in HA 2024.4; ask the service instead.
result = await self.request('call_service', domain='weather', service='get_forecasts',
@@ -298,6 +314,8 @@ class Manager:
# Inbox entity ids seen this run with their Home Assistant device, and old inbox ids that a screen
# now reports under a new id (see follow_renamed_inboxes); the updater follows a screen through them.
self._inbox_devices, self.aliases = {}, {}
# The layout snapshot each screen's sensor already carries, so it is only written when it changes.
self.published = {}
self._prefix_source, self._prefixes = None, {}
if self.path.exists():
raw = json.loads(self.path.read_text())
@@ -723,6 +741,7 @@ class Manager:
LOG.warning('Retrying screen sync (%s)', type(error).__name__)
await asyncio.sleep(1)
changed = changed or self.status.get(inbox) != before
await self.publish_layouts()
if changed:
self.notify()
@@ -796,6 +815,53 @@ class Manager:
f" (not: {'; '.join(notes)})" if notes else '')
return {'sent': len(ready) - len(failed), 'skipped': len(skipped), 'failed': len(failed)}
async def tile_event(self, event_type, data):
"""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))
return screen
async def tile_loop(self):
"""Tile events (app 0.2.51+) in arrival order, apart from the sync loop, which can be busy for a
while. Every event gets an answer, so whoever fired it knows what happened."""
queue = getattr(self.ha, 'tile_events', None)
while queue is not None:
event_type, data = await queue.get()
answer = {'event': event_type, 'screen': data.get('screen') or '', 'entity': data.get('entity') or ''}
try:
screen = await self.tile_event(event_type, data)
answer.update(ok=True, screen=screen['name'])
LOG.info('%s: %s on %s', event_type, answer['entity'] or 'order', screen['name'])
await self.publish_layouts()
except Exception as error:
answer.update(ok=False, error=str(error) if isinstance(error, ValueError) else type(error).__name__)
LOG.warning('%s refused: %s', event_type, answer['error'])
try:
await self.ha.fire(TILE_RESULT_EVENT, answer)
except Exception as error:
LOG.warning('%s: no answer sent (%s)', event_type, type(error).__name__)
async def publish_layouts(self):
"""Each screen's layout as a sensor in Home Assistant, so an assistant can read what is where.
Published again after every change and after a reconnect; a state made this way is gone once
Home Assistant restarts."""
for screen in self.screens():
inbox = self.aliases.get(screen['id'], screen['id'])
layout, node = self.layouts.get(inbox), screen.get('node')
if not layout or not node:
continue
snapshot = layout_snapshot(screen, layout)
if self.published.get(inbox) == snapshot:
continue
try:
await self.ha.set_state('sensor.esp_screens_' + node.replace('-', '_'), len(snapshot['tiles']),
{'friendly_name': f"{screen['name']} tiles", 'icon': 'mdi:view-dashboard-outline', **snapshot})
self.published[inbox] = snapshot
except Exception as error:
LOG.warning('Publishing the layout of %s failed (%s)', screen['name'], type(error).__name__)
async def alert_loop(self):
"""Alert events for every screen, in arrival order and apart from the sync loop, which can be busy for a while."""
queue = getattr(self.ha, 'broadcasts', None)
@@ -964,7 +1030,8 @@ async def main():
await runner.setup()
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
try:
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(), manager.alert_loop())
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(),
manager.alert_loop(), manager.tile_loop())
finally:
await runner.cleanup()