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:
@@ -192,10 +192,13 @@ for these two events; the per-screen actions work without it.
|
||||
### Ask Claude
|
||||
|
||||
Use Claude Code in Home Assistant? **Settings → Claude → Install for Claude Code** writes an
|
||||
ESP Screens skill to `/homeassistant/.claude/skills/esp-screens`, so Claude knows these events
|
||||
and every field, color, and icon. Then ask, for example: "Show an alert on all my screens when
|
||||
the mailbox is full." **Download for claude.ai** gives the same skill as a zip to upload in
|
||||
Claude under Customize → Skills. Nothing is written until you press the button.
|
||||
ESP Screens skill to `/homeassistant/.claude/skills/esp-screens`, so Claude knows the events, the
|
||||
tile settings and every field, color and icon. Then ask, for example: "Put the vacuum on the living
|
||||
room screen", "Give the living room lights a brightness slider and make that tile wide", "Move the
|
||||
vacuum to page 1" or "Show an alert on all my screens when the mailbox is full." Claude reads what a
|
||||
screen shows from `sensor.esp_screens_<screen>` and asks before it changes anything. **Download for
|
||||
claude.ai** gives the same skill as a zip to upload in Claude under Customize → Skills. Nothing is
|
||||
written until you press the button.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/editor-settings.png" width="80%" alt="Settings in ESP Screens: New screen and Firmware & USB, the firmware updates, the Alerts cheatsheet, and the Claude skill">
|
||||
|
||||
@@ -176,6 +176,35 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
|
||||
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
|
||||
profiles must come from the set. No changed preferences or keys.
|
||||
|
||||
### Compatibility 0.2.51 / firmware 0.2.43
|
||||
|
||||
App only; no firmware change, no protocol change, no storage change. Layouts keep their shape, so an
|
||||
older app reads what this one writes.
|
||||
|
||||
- `core.TILE_EVENTS` maps four Home Assistant events to an action: `esp_screens_add_tile` (add or
|
||||
change), `esp_screens_remove_tile`, `esp_screens_move_tile` and `esp_screens_order_tiles`.
|
||||
`apply_tile_event(layout, action, data)` returns the new layout and raises `ValueError` with the
|
||||
sentence the log and the answer carry; `match_screen` finds the screen by device name, the name Home
|
||||
Assistant shows, the device, the area or the layout title, and needs no name when one screen is paired.
|
||||
`tile_options` turns the event's fields into tile settings (`color` is the pastel background) and makes
|
||||
a tile with a control, a forecast or a sun path double-width. `event_page`/`event_slot` accept `page`
|
||||
(from 1), `row` (1-3) and `column` (left/right), or `slot` as the editor counts it. `place_tile`,
|
||||
`free_slot` and `pack_page` keep a double-width tile in the left column and leave gaps alone.
|
||||
- `HomeAssistant` subscribes to those events, queues them in `tile_events`, and gained `fire()` (the
|
||||
websocket `fire_event`) and `set_state()` (the REST API, the only way an app can publish a state).
|
||||
`Manager.tile_loop` handles one event at a time, saves through `Manager.save` so validation, the entity
|
||||
check and the push are the editor's, and answers with `TILE_RESULT_EVENT` (`ok`, `screen`, `entity`,
|
||||
`error`).
|
||||
- `Manager.publish_layouts` writes `sensor.esp_screens_<node>` per screen after every sync pass and after
|
||||
every tile event: the state is the tile count, the attributes carry `title`, `pages` and `tiles`
|
||||
(`entity`, `name`, `page`, `row`, `column`, `slot`, `size`, `controls`, `display`). States made over the
|
||||
REST API disappear when Home Assistant restarts; the sync loop writes them again.
|
||||
- The Claude skill gains "Tiles on a screen" with the four events, the fields, the controls and displays
|
||||
per domain (generated from `CONTROLS`/`DISPLAYS`), how to read the sensor first, and the rule to
|
||||
confirm before changing a screen. Its description changed, so an installed skill shows as outdated
|
||||
until it is installed again. `tests/test_tile_events.py` covers the logic and one event end to end;
|
||||
`tests/test_claude_skill.py` checks the section and the two new YAML examples.
|
||||
|
||||
### Compatibility 0.2.50 / firmware 0.2.43
|
||||
|
||||
Firmware, board profiles and the package generator; the app only raises `FIRMWARE_VERSION`. Storage,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Test results app 0.2.51 / firmware 0.2.43 (2026-09-16)
|
||||
|
||||
Tiles from a Home Assistant event, a sensor that reads back what a screen shows, and the Claude skill
|
||||
that teaches both. App only: the firmware, the protocol and the stored layouts are unchanged.
|
||||
See CHANGELOG 0.2.51 and RELEASING "Compatibility 0.2.51".
|
||||
|
||||
## Automated
|
||||
|
||||
- Python (`.venv-portal`): 193 tests OK. New `tests/test_tile_events.py` (18 tests): finding a screen by
|
||||
device name, shown name, area or title; adding, changing, moving, removing and ordering tiles; a
|
||||
control or a forecast widening its own tile; a widened tile keeping its spot only when the cell beside
|
||||
it is free; the refusals (unsupported entity, unknown page, row or column, an occupied spot, a full
|
||||
page, twenty tiles, an entity that is not on the screen); the snapshot a screen publishes; and one
|
||||
event end to end through `Manager.tile_loop`, which saves `screens.json`, answers with
|
||||
`esp_screens_tile_result` and publishes the layout sensor. A refused event writes nothing.
|
||||
- `tests/test_claude_skill.py` checks the new section: the four events, the result event, the sensor,
|
||||
what each kind of entity can do, the rule to wait for a yes, and both new YAML examples.
|
||||
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I . -I components/smart_display`): 13/13 PASS,
|
||||
unchanged this release.
|
||||
- `generate_packages.py --check` green; the board profiles and packages are untouched.
|
||||
|
||||
## Against the running Home Assistant
|
||||
|
||||
Without sending anything to a screen:
|
||||
|
||||
- Subscribed to `esp_screens_add_tile` over the websocket, fired that event over the REST API the same
|
||||
way Claude in Home Assistant does (`POST /api/events/...`, 200) and received it with its data intact.
|
||||
- Published `sensor.esp_screens_probe` with tile attributes over the REST API (201), read it back
|
||||
unchanged, and deleted it again (200). That is the path `Manager.publish_layouts` uses.
|
||||
|
||||
## Not tested / for the owner
|
||||
|
||||
- The full round trip with the app running on Home Assistant: update ESP Screens to 0.2.51, install the
|
||||
skill again from Settings → Claude, and ask Claude for a tile. The screens themselves need no update.
|
||||
- Ordering by how often something is used: Claude reads that from Home Assistant's own history.
|
||||
@@ -1,3 +1,12 @@
|
||||
## 0.2.51 (firmware 0.2.43)
|
||||
|
||||
Ask Claude in Home Assistant to put something on a screen.
|
||||
|
||||
- **Tiles from a Home Assistant event.** ESP Screens now listens for `esp_screens_add_tile`, `esp_screens_remove_tile`, `esp_screens_move_tile` and `esp_screens_order_tiles`. An event names the screen (its device name, the name Home Assistant shows, its area or its title) and the entity, and may set the size, the direct control, the display, the icon, the colour and the spot. The app changes the screen and sends it right away, with the same checks as its own editor: at most twenty tiles, an entity once per screen, and a double-width tile in the left column. A tile with a control or a forecast becomes double-width by itself. Every event gets an answer, `esp_screens_tile_result`, with `ok` or the reason it was refused; a refused event changes nothing.
|
||||
- **A screen you can read.** Every screen also publishes what it shows as `sensor.esp_screens_<device name>`: the number of tiles, and per tile the entity, the page, the row, the column, the width, the control and the display. So an assistant can look before it moves something, and you can use it in a template.
|
||||
- **The Claude skill knows all of it.** Settings → Claude now also covers tiles: the four events, every field, what each kind of entity can do, how to read a screen first, and the rule to show what it is about to do and wait for a yes. Install it again from the Settings page, then ask for example "put the vacuum on the living room screen", "give the living room lights a brightness slider and make that tile wide" or "order page 1 by how often I use them".
|
||||
- No new firmware: screens on 0.2.43 need no update.
|
||||
|
||||
## 0.2.50 (firmware 0.2.43)
|
||||
|
||||
More memory for the CYD, so a busy moment no longer restarts it.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.50"
|
||||
version: "0.2.51"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
|
||||
@@ -11,7 +11,8 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
import claude_skill # noqa: E402
|
||||
import tile_icons # noqa: E402
|
||||
from core import ALERT_EVENT, ALERT_FIELDS, ALERT_LIMITS, ALERT_MIN_FIRMWARE, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW, TILE_BACKGROUNDS # noqa: E402
|
||||
from core import (ALERT_EVENT, ALERT_FIELDS, ALERT_LIMITS, ALERT_MIN_FIRMWARE, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, # noqa: E402
|
||||
BROADCAST_SHOW, CONTROLS, DISPLAYS, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT)
|
||||
|
||||
HAS_YAML = importlib.util.find_spec('yaml') is not None
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
@@ -27,7 +28,7 @@ class SkillText(unittest.TestCase):
|
||||
self.assertIsNotNone(front)
|
||||
self.assertEqual(front[1], 'esp-screens')
|
||||
self.assertLessEqual(len(front[2]), 200, 'claude.ai refuses longer descriptions')
|
||||
for words in ('ESP Screens', 'alerts', 'all screens', 'automation', 'icons', 'awake'):
|
||||
for words in ('ESP Screens', 'tile', 'order', 'alert', 'awake'):
|
||||
self.assertIn(words, front[2])
|
||||
self.assertEqual(text, claude_skill.text(), 'same text every time, so status() can compare')
|
||||
self.assertNotRegex(text, r'\{[A-Z_]+\}', 'no placeholder left over')
|
||||
@@ -51,6 +52,19 @@ class SkillText(unittest.TestCase):
|
||||
self.assertIn(f'`{entity}`', standby)
|
||||
self.assertIn(AUTO_STANDBY_MIN_FIRMWARE, standby)
|
||||
self.assertIn('(#standby-and-brightness)', text, 'the intro links to the section')
|
||||
# Tiles (0.2.51): the events, what a tile can do per domain, how to read a screen and the rule to ask first.
|
||||
tiles = text.split('## Tiles on a screen', 1)[1].split('## All screens', 1)[0]
|
||||
for event in TILE_EVENTS:
|
||||
self.assertIn(f'`{event}`', tiles)
|
||||
self.assertIn(TILE_RESULT_EVENT, tiles)
|
||||
self.assertIn('sensor.esp_screens_<device name>', tiles)
|
||||
for domain, choices in CONTROLS.items():
|
||||
self.assertRegex(tiles, rf'\| `{domain}` \|' + ''.join(rf'.*`{key}`' for key, _ in choices))
|
||||
for domain, names in DISPLAYS.items():
|
||||
for name in names:
|
||||
self.assertIn(f'`{name}`', tiles)
|
||||
self.assertIn('wait for a yes', tiles)
|
||||
self.assertIn('(#tiles-on-a-screen)', text, 'the intro links to the section')
|
||||
icons = text.split('## Icons', 1)[1].split('## When an alert ends', 1)[0]
|
||||
self.assertEqual(set(re.findall(r'`([a-z0-9-]+)`', icons)) - {'icon'}, set(tile_icons.GLYPHS), 'exactly the glyphs the firmware carries')
|
||||
|
||||
@@ -58,15 +72,20 @@ class SkillText(unittest.TestCase):
|
||||
def test_every_yaml_example_parses(self):
|
||||
import yaml
|
||||
blocks = re.findall(r'```yaml\n(.*?)```', claude_skill.text(), re.S)
|
||||
self.assertEqual(len(blocks), 5)
|
||||
self.assertEqual(len(blocks), 7)
|
||||
parsed = [yaml.safe_load(block) for block in blocks]
|
||||
self.assertEqual(parsed[0]['actions'][0]['event'], BROADCAST_SHOW)
|
||||
self.assertEqual(set(parsed[0]['actions'][0]['event_data']), {name for name, *_ in ALERT_FIELDS})
|
||||
self.assertEqual(set(parsed[1]['actions'][0]['data']), {name for name, *_ in ALERT_FIELDS}, 'the per-screen action needs all seven')
|
||||
self.assertEqual(parsed[2][0]['wait_for_trigger'][0]['event_type'], ALERT_EVENT)
|
||||
choose = parsed[3]['actions'][0]['choose']
|
||||
# Tiles first (0.2.51): putting one on a screen, and ordering a page.
|
||||
self.assertEqual(parsed[0]['actions'][0]['event'], 'esp_screens_add_tile')
|
||||
self.assertEqual(set(parsed[0]['actions'][0]['event_data']), {'screen', 'entity'})
|
||||
self.assertEqual(parsed[1]['actions'][0]['event'], 'esp_screens_order_tiles')
|
||||
self.assertEqual(set(parsed[1]['actions'][0]['event_data']), {'screen', 'page', 'entities'})
|
||||
self.assertEqual(parsed[2]['actions'][0]['event'], BROADCAST_SHOW)
|
||||
self.assertEqual(set(parsed[2]['actions'][0]['event_data']), {name for name, *_ in ALERT_FIELDS})
|
||||
self.assertEqual(set(parsed[3]['actions'][0]['data']), {name for name, *_ in ALERT_FIELDS}, 'the per-screen action needs all seven')
|
||||
self.assertEqual(parsed[4][0]['wait_for_trigger'][0]['event_type'], ALERT_EVENT)
|
||||
choose = parsed[5]['actions'][0]['choose']
|
||||
self.assertEqual([option['sequence'][0]['event'] for option in choose], [BROADCAST_SHOW, BROADCAST_DISMISS])
|
||||
awake = parsed[4]
|
||||
awake = parsed[6]
|
||||
self.assertEqual(awake['mode'], 'restart')
|
||||
branch = awake['actions'][0]
|
||||
self.assertEqual((branch['then'][0]['action'], branch['else'][0]['action']), ('switch.turn_off', 'switch.turn_on'))
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Tiles from a Home Assistant event (app 0.2.51): Claude in Home Assistant, or any automation, puts
|
||||
something on a screen, moves it or orders a page, and the app saves and pushes it like the editor does."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
from core import (MAX_PAGES, SLOTS_PER_PAGE, TILE_EVENTS, TILE_RESULT_EVENT, # noqa: E402
|
||||
apply_tile_event, layout_snapshot, match_screen)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from server import Manager
|
||||
|
||||
SCREENS = [{'id': 'text.kitchen_tiles', 'name': 'Kitchen screen', 'node': 'kitchen-screen', 'device': 'Kitchen screen', 'area': 'Kitchen'},
|
||||
{'id': 'text.living_tiles', 'name': 'CYD 2.8in', 'node': 'cyd-2432s028', 'device': 'CYD 2.8in', 'area': 'Living room'}]
|
||||
LAYOUTS = {'text.living_tiles': {'title': 'Living room', 'tiles': []}}
|
||||
|
||||
|
||||
def layout(*tiles):
|
||||
return {'title': 'Living room', 'tiles': [dict(tile) for tile in tiles]}
|
||||
|
||||
|
||||
def tile(entity, slot, **options):
|
||||
item = {'entity': entity, 'name': '', 'slot': slot}
|
||||
if options:
|
||||
item['options'] = options
|
||||
return item
|
||||
|
||||
|
||||
def places(result):
|
||||
return [(item['entity'], item['slot']) for item in sorted(result['tiles'], key=lambda t: t['slot'])]
|
||||
|
||||
|
||||
class ScreenByName(unittest.TestCase):
|
||||
def test_device_name_friendly_name_area_and_title(self):
|
||||
for wanted in ('cyd-2432s028', 'CYD 2.8in', 'living room', 'Living Room', 'cyd 2432s028'):
|
||||
self.assertEqual(match_screen(SCREENS, wanted, LAYOUTS)['id'], 'text.living_tiles', wanted)
|
||||
self.assertEqual(match_screen(SCREENS, 'kitchen', LAYOUTS)['id'], 'text.kitchen_tiles')
|
||||
|
||||
def test_one_screen_needs_no_name_and_unknown_names_say_which_exist(self):
|
||||
self.assertEqual(match_screen(SCREENS[:1], '', LAYOUTS)['id'], 'text.kitchen_tiles')
|
||||
with self.assertRaisesRegex(ValueError, 'Name the screen.*Kitchen screen'):
|
||||
match_screen(SCREENS, None, LAYOUTS)
|
||||
with self.assertRaisesRegex(ValueError, 'No screen called "attic".*Paired'):
|
||||
match_screen(SCREENS, 'attic', LAYOUTS)
|
||||
with self.assertRaisesRegex(ValueError, 'fits more than one screen'):
|
||||
match_screen([{'id': 'a', 'name': 'Screen one'}, {'id': 'b', 'name': 'Screen two'}], 'screen')
|
||||
|
||||
|
||||
class AddAndUpdate(unittest.TestCase):
|
||||
def test_a_new_tile_takes_the_first_free_spot(self):
|
||||
result = apply_tile_event(layout(tile('light.a', 0), tile('switch.b', 2)), 'add', {'entity': 'vacuum.s8'})
|
||||
self.assertEqual(places(result), [('light.a', 0), ('vacuum.s8', 1), ('switch.b', 2)])
|
||||
|
||||
def test_a_control_or_a_forecast_widens_the_tile_itself(self):
|
||||
result = apply_tile_event(layout(), 'add', {'entity': 'light.living', 'controls': 'brightness'})
|
||||
self.assertEqual(result['tiles'][0]['options'], {'controls': 'brightness', 'size': 'wide'})
|
||||
result = apply_tile_event(layout(), 'add', {'entity': 'weather.home', 'display': 'forecast'})
|
||||
self.assertEqual(result['tiles'][0]['options'], {'display': 'forecast', 'size': 'wide'})
|
||||
result = apply_tile_event(layout(), 'add', {'entity': 'light.living', 'size': 'double', 'color': 'orange'})
|
||||
self.assertEqual(result['tiles'][0]['options'], {'size': 'wide', 'background': 'orange'})
|
||||
|
||||
def test_a_wide_tile_starts_in_the_left_column(self):
|
||||
result = apply_tile_event(layout(tile('light.a', 0)), 'add', {'entity': 'weather.home', 'display': 'forecast'})
|
||||
self.assertEqual(places(result), [('light.a', 0), ('weather.home', 2)])
|
||||
|
||||
def test_the_same_entity_again_changes_that_tile(self):
|
||||
start = layout(tile('light.a', 0), tile('switch.b', 1))
|
||||
result = apply_tile_event(start, 'add', {'entity': 'light.a', 'controls': 'brightness', 'name': 'Reading lamp'})
|
||||
self.assertEqual(len(result['tiles']), 2)
|
||||
changed = next(t for t in result['tiles'] if t['entity'] == 'light.a')
|
||||
self.assertEqual((changed['name'], changed['options']), ('Reading lamp', {'controls': 'brightness', 'size': 'wide'}))
|
||||
self.assertEqual(changed['slot'], 2, 'it needs two cells now, so it moves to a row where it fits')
|
||||
|
||||
def test_an_exact_spot_and_a_page(self):
|
||||
start = layout(tile('light.a', 0))
|
||||
self.assertEqual(places(apply_tile_event(start, 'add', {'entity': 'vacuum.s8', 'page': 2}))[-1], ('vacuum.s8', 6))
|
||||
self.assertEqual(places(apply_tile_event(start, 'add', {'entity': 'vacuum.s8', 'page': 1, 'row': 2, 'column': 'right'}))[-1], ('vacuum.s8', 3))
|
||||
with self.assertRaisesRegex(ValueError, 'taken by light.a'):
|
||||
apply_tile_event(start, 'add', {'entity': 'vacuum.s8', 'page': 1, 'row': 1, 'column': 'left'})
|
||||
|
||||
def test_what_it_refuses(self):
|
||||
for data, message in (({'entity': 'lock.front'}, 'cannot go on a screen'),
|
||||
({'entity': ''}, 'Name the entity'),
|
||||
({'entity': 'light.a', 'page': 9}, 'page between 1 and 8'),
|
||||
({'entity': 'light.a', 'row': 4, 'page': 1}, 'row between 1 and 3'),
|
||||
({'entity': 'light.a', 'page': 1, 'column': 'middle'}, 'column is left or right')):
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
apply_tile_event(layout(), 'add', data)
|
||||
full = layout(*[tile(f'light.a{n}', n) for n in range(20)])
|
||||
with self.assertRaisesRegex(ValueError, 'twenty tiles'):
|
||||
apply_tile_event(full, 'add', {'entity': 'light.extra'})
|
||||
|
||||
|
||||
class MoveRemoveAndOrder(unittest.TestCase):
|
||||
def test_moving_to_a_page_takes_its_first_free_spot(self):
|
||||
start = layout(tile('light.a', 0), tile('vacuum.s8', 13))
|
||||
self.assertEqual(places(apply_tile_event(start, 'move', {'entity': 'vacuum.s8', 'page': 1})),
|
||||
[('light.a', 0), ('vacuum.s8', 1)])
|
||||
with self.assertRaisesRegex(ValueError, 'Name the page or the spot'):
|
||||
apply_tile_event(start, 'move', {'entity': 'vacuum.s8'})
|
||||
with self.assertRaisesRegex(ValueError, 'not on this screen'):
|
||||
apply_tile_event(start, 'move', {'entity': 'light.missing', 'page': 1})
|
||||
|
||||
def test_a_full_page_says_so(self):
|
||||
start = layout(*[tile(f'light.a{n}', n) for n in range(6)], tile('vacuum.s8', 6))
|
||||
with self.assertRaisesRegex(ValueError, 'Page 1 is full'):
|
||||
apply_tile_event(start, 'move', {'entity': 'vacuum.s8', 'page': 1})
|
||||
|
||||
def test_removing_keeps_the_others_where_they_are(self):
|
||||
start = layout(tile('light.a', 0), tile('vacuum.s8', 1), tile('switch.b', 4))
|
||||
result = apply_tile_event(start, 'remove', {'entity': 'vacuum.s8'})
|
||||
self.assertEqual(places(result), [('light.a', 0), ('switch.b', 4)])
|
||||
with self.assertRaisesRegex(ValueError, 'is not on this screen'):
|
||||
apply_tile_event(result, 'remove', {'entity': 'vacuum.s8'})
|
||||
|
||||
def test_ordering_the_whole_screen_packs_it_again(self):
|
||||
start = layout(tile('script.night', 0), tile('light.a', 3), tile('vacuum.s8', 8))
|
||||
result = apply_tile_event(start, 'order', {'entities': ['light.a', 'vacuum.s8']})
|
||||
self.assertEqual(places(result), [('light.a', 0), ('vacuum.s8', 1), ('script.night', 2)])
|
||||
|
||||
def test_ordering_one_page_leaves_the_other_pages_alone(self):
|
||||
start = layout(tile('script.night', 0), tile('light.a', 1), tile('vacuum.s8', 2), tile('switch.b', 7))
|
||||
result = apply_tile_event(start, 'order', {'page': 1, 'entities': 'light.a, vacuum.s8'})
|
||||
self.assertEqual(places(result), [('light.a', 0), ('vacuum.s8', 1), ('script.night', 2), ('switch.b', 7)])
|
||||
with self.assertRaisesRegex(ValueError, 'Not on page 1: switch.b'):
|
||||
apply_tile_event(start, 'order', {'page': 1, 'entities': ['switch.b']})
|
||||
with self.assertRaisesRegex(ValueError, 'Not on this screen: light.gone'):
|
||||
apply_tile_event(start, 'order', {'entities': ['light.gone']})
|
||||
with self.assertRaisesRegex(ValueError, 'in the order you want'):
|
||||
apply_tile_event(start, 'order', {})
|
||||
|
||||
def test_ordering_a_page_keeps_wide_tiles_in_the_left_column(self):
|
||||
start = layout(tile('light.a', 0), tile('weather.home', 2, size='wide', display='forecast'))
|
||||
result = apply_tile_event(start, 'order', {'page': 1, 'entities': ['weather.home', 'light.a']})
|
||||
self.assertEqual(places(result), [('weather.home', 0), ('light.a', 2)])
|
||||
|
||||
|
||||
class Snapshot(unittest.TestCase):
|
||||
def test_a_screen_reads_back_as_pages_rows_and_columns(self):
|
||||
data = layout(tile('light.a', 0), tile('weather.home', 2, size='wide', display='forecast'), tile('vacuum.s8', 7))
|
||||
snapshot = layout_snapshot(SCREENS[1], data)
|
||||
self.assertEqual((snapshot['screen'], snapshot['node'], snapshot['title'], snapshot['pages']),
|
||||
('CYD 2.8in', 'cyd-2432s028', 'Living room', 2))
|
||||
self.assertEqual([(t['entity'], t['page'], t['row'], t['column'], t['size']) for t in snapshot['tiles']],
|
||||
[('light.a', 1, 1, 'left', 'single'), ('weather.home', 1, 2, 'left', 'wide'), ('vacuum.s8', 2, 1, 'right', 'single')])
|
||||
self.assertEqual(snapshot['tiles'][1]['display'], 'forecast')
|
||||
|
||||
|
||||
def fake_ha():
|
||||
"""One paired screen and the entities a tile can show."""
|
||||
class HA:
|
||||
online = True
|
||||
|
||||
def __init__(self):
|
||||
self.registry = [{'entity_id': 'text.d1_tiles', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.d1_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.d1_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
|
||||
self.states = {'text.d1_tiles': {'state': 'Synced'}, 'sensor.d1_node': {'state': 'living-room'}, 'sensor.d1_fw': {'state': '0.2.43'}}
|
||||
for entity, name in (('light.reading', 'Reading lamp'), ('vacuum.s8', 'S8'), ('weather.home', 'Home')):
|
||||
self.registry.append({'entity_id': entity, 'platform': 'demo', 'original_name': name, 'device_id': 'd9'})
|
||||
self.states[entity] = {'state': 'on', 'attributes': {'friendly_name': name}}
|
||||
self.devices = [{'id': 'd1', 'name': 'Living room screen'}, {'id': 'd9', 'name': 'Demo'}]
|
||||
self.areas = []
|
||||
self.changed = asyncio.Event()
|
||||
self.tile_events = asyncio.Queue()
|
||||
self.fired, self.published = [], {}
|
||||
|
||||
async def fire(self, event_type, data):
|
||||
self.fired.append((event_type, data))
|
||||
|
||||
async def set_state(self, entity_id, state, attributes):
|
||||
self.published[entity_id] = (state, attributes)
|
||||
return HA()
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Through(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_an_event_saves_the_layout_and_answers(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'screens.json'
|
||||
m = Manager(fake_ha(), path)
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'vacuum.s8', 'screen': 'living room'}))
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'light.reading', 'controls': 'brightness'}))
|
||||
worker = asyncio.create_task(m.tile_loop())
|
||||
for _ in range(200):
|
||||
await asyncio.sleep(0.01)
|
||||
if len(m.ha.fired) == 2:
|
||||
break
|
||||
worker.cancel()
|
||||
saved = json.loads(path.read_text())['screens']['text.d1_tiles']
|
||||
self.assertEqual([(t['entity'], t['slot']) for t in saved['tiles']], [('vacuum.s8', 0), ('light.reading', 2)])
|
||||
self.assertEqual(saved['tiles'][1]['options'], {'controls': 'brightness', 'size': 'wide'})
|
||||
self.assertEqual([(event, answer['ok'], answer['entity']) for event, answer in m.ha.fired],
|
||||
[(TILE_RESULT_EVENT, True, 'vacuum.s8'), (TILE_RESULT_EVENT, True, 'light.reading')])
|
||||
state, attributes = m.ha.published['sensor.esp_screens_living_room']
|
||||
self.assertEqual(state, 2)
|
||||
self.assertEqual([t['entity'] for t in attributes['tiles']], ['vacuum.s8', 'light.reading'])
|
||||
|
||||
async def test_a_refused_event_changes_nothing_and_says_why(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'screens.json'
|
||||
m = Manager(fake_ha(), path)
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'light.reading', 'screen': 'attic'}))
|
||||
worker = asyncio.create_task(m.tile_loop())
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
for _ in range(200):
|
||||
await asyncio.sleep(0.01)
|
||||
if m.ha.fired:
|
||||
break
|
||||
worker.cancel()
|
||||
self.assertFalse(path.exists())
|
||||
event, answer = m.ha.fired[0]
|
||||
self.assertEqual((event, answer['ok']), (TILE_RESULT_EVENT, False))
|
||||
self.assertIn('No screen called "attic"', answer['error'])
|
||||
self.assertTrue(any('refused' in line for line in logs.output), logs.output)
|
||||
|
||||
async def test_every_event_name_is_handled(self):
|
||||
self.assertEqual(set(TILE_EVENTS), {'esp_screens_add_tile', 'esp_screens_remove_tile', 'esp_screens_move_tile', 'esp_screens_order_tiles'})
|
||||
self.assertEqual((MAX_PAGES, SLOTS_PER_PAGE), (8, 6))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user