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:
@@ -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.
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user