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
+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()