Send one alert to every screen, teach Claude about ESP Screens, and add a Settings page (0.2.45)

- The app listens for the Home Assistant events esp_screens_show_alert and
  esp_screens_dismiss_alert and calls each screen's show_alert/dismiss_alert action,
  screens added later included. Fields are typed like Home Assistant's ESPHome action
  schema; an unusable value is left empty instead of losing the alert.
- Settings → Claude installs an ESP Screens skill for Claude Code in Home Assistant
  (/homeassistant/.claude/skills/esp-screens) or downloads it as a zip for claude.ai.
  It is generated from the alert reference, so fields, colors and icons match the firmware.
- New screen, Firmware & USB, firmware updates, Alerts and Claude move to a Settings view;
  the header keeps one button. Unselected screens in My screens get a light grey card.

App only; firmware stays 0.2.38.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 14:52:27 +02:00
parent 3f1084a171
commit 4ff0d44312
18 changed files with 1192 additions and 165 deletions
+10
View File
@@ -1,3 +1,13 @@
## 0.2.45 (firmware 0.2.38)
One alert for every screen, a Claude skill, and a Settings page that clears up the main page.
- **One alert for every screen.** Fire the Home Assistant event `esp_screens_show_alert` with the usual alert fields and ESP Screens shows the alert on every screen that is online, screens added later included. `esp_screens_dismiss_alert` clears it everywhere. Fields you leave out stay empty. A value that doesn't fit, such as a bare `Yes` that YAML turns into true, is left empty with a line in the log instead of losing the alert. The per-screen actions stay as they are.
- **Ask Claude.** Settings → Claude installs an ESP Screens skill for Claude Code in Home Assistant (`/homeassistant/.claude/skills/esp-screens`), or downloads it as a zip for claude.ai. Claude then knows the alert events and every field, color and icon, so "show an alert on all my screens when the mailbox is full" becomes a working automation. Nothing is written until you press the button, and the page says when a newer version of the skill is ready.
- **Settings page.** New screen, Firmware & USB, the firmware updates, Alerts and Claude moved from the header and the sidebar to a page of their own; the header keeps one Settings button. The Update button of each screen stays in My screens.
- **Clearer screen list.** Every screen in My screens sits on a light grey card; the selected screen is white.
- No new firmware: screens on firmware 0.2.31 or newer show alerts for every screen.
## 0.2.44 (firmware 0.2.38)
- **Vacuum card on the Guition.** The state line under the name is gone; the card below already shows it in its badge, and the two sat on top of each other.
+7 -2
View File
@@ -2,16 +2,21 @@
See [the full installation guide](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md).
Open **New screen** to install a screen: connect it via USB to the
Open **Settings → New screen** to install a screen: connect it via USB to the
Home Assistant machine, choose the board, name and USB port, then click **Install**.
The profile with unique keys goes into the ESPHome folder of the HA configuration,
any missing Wi-Fi goes into `secrets.yaml` (existing secrets are left untouched), and the
build and flash run in the same window; it then shows the API key
for pairing. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Firmware & USB** is
for pairing. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Settings → Firmware & USB** is
for existing profiles: check, build or reinstall via USB or
the IP address (OTA). Existing ESPHome profiles in the HA config folder are
found automatically.
**Settings** (top right) also has the nightly firmware updates, the **Alerts** cheatsheet
(an alert on one screen, or on every screen with the `esp_screens_show_alert` event)
and **Claude**: install the ESP Screens skill for Claude Code in Home Assistant, or
download it for claude.ai, and ask Claude for the alert automation.
At the top of the editor you set the **Top bar** for each screen: the name on the left, up
to six items on the right (time, analog clock, date, or an entity with an icon, such as
temperature, a door, the alarm, or "last changed"). Drag to reorder, tap
+196
View File
@@ -0,0 +1,196 @@
"""The Claude skill ESP Screens offers under Settings → Claude: installed for Claude Code, or downloaded for claude.ai.
Claude Code finds project skills in `.claude/skills/<name>/SKILL.md` of the folder it starts in; the
Claude Code apps for Home Assistant start in the configuration folder. claude.ai takes the same folder as
a zip. The text is built from the same reference as the Alerts cheatsheet, so it never names a field,
color or icon the firmware lacks, and it is the same on every call: status() compares it byte for byte
to tell an outdated copy.
"""
import io
import os
from pathlib import Path
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, BROADCAST_DISMISS, BROADCAST_SHOW, TILE_BACKGROUNDS)
NAME = 'esp-screens'
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
DESCRIPTION = ('Show alerts or notifications on ESP Screens (CYD and Guition touchscreens run from Home Assistant): '
'all screens or one, now or in an automation, with the icons and colors they support.')
TYPES = {'string': 'text', 'int': 'number', 'bool': 'on/off'}
def skill_dir(config=None):
"""Where the skill goes: <Home Assistant configuration>/.claude/skills/esp-screens."""
return Path(config or os.environ.get('HA_CONFIG', '/homeassistant')) / '.claude' / 'skills' / NAME
def _limit(name, kind):
if name in ALERT_LIMITS['cyd']:
return f"CYD {ALERT_LIMITS['cyd'][name]} · Guition {ALERT_LIMITS['guition'][name]} bytes"
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."""
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)
return f'''---
name: {NAME}
description: {DESCRIPTION}
---
# Alerts 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.
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.
## 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.
```yaml
actions:
- event: {BROADCAST_SHOW}
event_data:
title: "Mail!"
subtitle: "There is post in the mailbox"
icon: mailbox
color: orange
button_text: "OK"
timeout: 0
flash: true
```
`{BROADCAST_DISMISS}` (no data) takes the alert off every screen. Every field of the event is optional: a missing or unusable value becomes empty text, `0` or off. Templates in `event_data` work as usual. For an alert right now, without an automation, fire the same event through Home Assistant's REST API (`POST /api/events/{BROADCAST_SHOW}` with the fields as JSON; inside a Home Assistant app such as Claude Code that is `http://supervisor/core/api/events/{BROADCAST_SHOW}` with `$SUPERVISOR_TOKEN` as the bearer token) or under Developer tools → Events. The ESP Screen Manager log tells how many screens got it.
## One screen: call its action
Every screen also has its own actions, `esphome.<device_name>_show_alert` and `esphome.<device_name>_dismiss_alert` (dashes in the device name become underscores). List Home Assistant's `esphome` actions to see which screens exist. These actions need all seven fields; send `""`, `0` or `false` for the ones you don't use.
```yaml
actions:
- action: esphome.kitchen_screen_show_alert
data:
title: "Laundry is done"
subtitle: ""
icon: washing-machine
color: green
button_text: ""
timeout: 600
flash: false
```
## Fields
| Field | Type | Meaning | Limit |
|---|---|---|---|
{fields}
Text limits are in bytes; an accented letter takes two. Keep the title short, the CYD shows about forty characters on one line, and put details in the subtitle.
## Colors
`color` takes one of {colors}. Empty or unknown gives the white card.
## Icons
`icon` takes one of the names below, exactly as written (`mdi:` in front works too). The firmware carries only these glyphs and shows the warning triangle (`{ALERT_FALLBACK_ICON}`) for any other name, so pick the closest name from this list rather than another Material Design icon.
Good for alerts: {suggested}.
{groups}
- Also available: {fixed}
## When an alert ends
Each screen fires `{ALERT_EVENT}` with `action`: {endings}. The event also carries `title`, `screen` (the device name) and `device_id`. To react to the button:
```yaml
- wait_for_trigger:
- trigger: event
event_type: {ALERT_EVENT}
event_data:
action: ok
timeout: "00:05:00"
```
## Writing the automation
1. Find the real trigger in Home Assistant, such as the mailbox sensor. Never invent an entity ID; ask when several entities could fit.
2. All screens: the `{BROADCAST_SHOW}` event. One screen the user names: that screen's own action.
3. When the situation clears (mailbox emptied, door closed), fire `{BROADCAST_DISMISS}` from the same automation.
4. Take the icon and color from the lists above.
5. Ask before sending a test alert: it lights up the screens around the house.
Example, with the user's own entity in place of `binary_sensor.mailbox`:
```yaml
alias: Mailbox alert on the screens
triggers:
- trigger: state
entity_id: binary_sensor.mailbox
to: "on"
id: mail
- trigger: state
entity_id: binary_sensor.mailbox
to: "off"
id: emptied
actions:
- choose:
- conditions:
- condition: trigger
id: mail
sequence:
- event: {BROADCAST_SHOW}
event_data:
title: "Mail!"
subtitle: "There is post in the mailbox"
icon: mailbox
color: orange
timeout: 0
flash: true
- conditions:
- condition: trigger
id: emptied
sequence:
- event: {BROADCAST_DISMISS}
```
'''
def status(directory):
"""Whether the skill is there and equal to what this app version writes."""
try:
installed = (directory / 'SKILL.md').read_text(encoding='utf8')
except FileNotFoundError:
return {'installed': False, 'current': False, 'path': str(directory)}
except (OSError, UnicodeDecodeError):
installed = None
return {'installed': True, 'current': installed == text(), 'path': str(directory)}
def archive():
"""The skill folder as a zip, the way claude.ai takes an uploaded skill: esp-screens/SKILL.md."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as bundle:
entry = zipfile.ZipInfo(f'{NAME}/SKILL.md', date_time=(2026, 1, 1, 0, 0, 0))
entry.compress_type = zipfile.ZIP_DEFLATED
entry.external_attr = 0o644 << 16
bundle.writestr(entry, text())
return buffer.getvalue()
def install(directory):
"""Write SKILL.md, replacing an older copy. `restart`: the skills folder is new, and Claude Code only
watches skills folders that existed when it started."""
restart = not directory.parent.is_dir()
try:
directory.mkdir(parents=True, exist_ok=True)
temp = directory / 'SKILL.md.tmp'
temp.write_text(text(), encoding='utf8')
temp.replace(directory / 'SKILL.md')
except OSError as error:
raise ValueError(f"Couldn't write the skill to {directory} ({error.strerror or type(error).__name__}).") from error
return {**status(directory), 'restart': restart}
+78
View File
@@ -526,6 +526,12 @@ ALERT_LIMITS = {'cyd': {'title': 48, 'subtitle': 160, 'button_text': 12}, 'guiti
ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alarm-light', 'lock', 'lock-open-variant', 'door-open',
'window-closed-variant', 'motion-sensor', 'cctv', 'smoke-detector', 'water-alert', 'fire', 'mailbox', 'car',
'account', 'account-group', 'washing-machine', 'robot-vacuum', 'timer-outline', 'check')
# The firmware's MAX_TIMEOUT_SECONDS.
ALERT_MAX_TIMEOUT = 86400
# One alert for every screen (app 0.2.45): an automation fires one of these Home Assistant events and the
# app calls the matching action on each screen that can show it, screens added later included.
BROADCAST_SHOW, BROADCAST_DISMISS = 'esp_screens_show_alert', 'esp_screens_dismiss_alert'
BROADCAST_EVENTS = {BROADCAST_SHOW: 'show_alert', BROADCAST_DISMISS: 'dismiss_alert'}
def alert_service(node, action='show_alert'):
"""Home Assistant registers a device's actions as esphome.<node>_<action>, dashes as underscores."""
@@ -534,6 +540,7 @@ def alert_service(node, action='show_alert'):
def alert_reference():
"""Everything the Alerts cheatsheet shows besides the screens themselves."""
return {'min_firmware': ALERT_MIN_FIRMWARE, 'event': ALERT_EVENT,
'broadcast': {'show': BROADCAST_SHOW, 'dismiss': BROADCAST_DISMISS},
'endings': [{'action': action, 'label': label} for action, label in ALERT_ENDINGS],
'fallback_icon': ALERT_FALLBACK_ICON, 'fallback_cp': tile_icons.GLYPHS[ALERT_FALLBACK_ICON],
'fields': [{'name': name, 'type': kind, 'label': label, 'help': help_, 'example': example} for name, kind, label, help_, example in ALERT_FIELDS],
@@ -542,6 +549,77 @@ def alert_reference():
'suggested_icons': [{'name': name, 'cp': tile_icons.GLYPHS[name]} for name in ALERT_SUGGESTED_ICONS],
'extra_icons': [{'name': name, 'cp': cp} for name, cp in tile_icons.FIXED]}
def _whole(value):
"""A whole number from an int, a finite float or a numeric string; None for anything else."""
if isinstance(value, bool):
return None
if isinstance(value, str):
try:
value = float(value.strip())
except ValueError:
return None
if isinstance(value, float):
return int(value) if math.isfinite(value) else None
return value if isinstance(value, int) else None
def _flag(value):
"""True/False from a bool, 0/1 or the words Home Assistant accepts; None for anything else."""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)) and value in (0, 1):
return bool(value)
text = value.strip().lower() if isinstance(value, str) else None
return True if text in ('true', 'on', 'yes', '1') else False if text in ('false', 'off', 'no', '0') else None
def alert_data(data):
"""(service data, unusable field names): the seven show_alert fields from an event's data, typed the way
Home Assistant validates ESPHome actions. A missing field is empty; so is an unusable one (YAML turns a bare
`Yes` into a boolean), so one bad value never loses the whole alert. The firmware clips texts itself."""
data = data if isinstance(data, dict) else {}
service, unusable = {}, []
for name, kind, *_ in ALERT_FIELDS:
value = data.get(name)
missing = value is None or value == ''
if kind == 'string':
usable = isinstance(value, (str, int, float)) and not isinstance(value, bool)
service[name] = str(value) if usable and not missing else ''
elif kind == 'int':
number = _whole(value)
usable = number is not None
service[name] = min(max(number, 0), ALERT_MAX_TIMEOUT) if usable else 0
else:
flag = _flag(value)
usable = flag is not None
service[name] = bool(flag)
if not usable and not missing:
unusable.append(name)
return service, unusable
def alert_targets(screens):
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason.
One call per device: a screen that shows up twice (an old inbox next to a renamed one) counts once."""
minimum = tuple(int(part) for part in ALERT_MIN_FIRMWARE.split('.'))
ready, skipped, nodes = [], [], set()
for screen in screens:
try:
version = tuple(int(part) for part in str(screen.get('firmware') or '').split('.'))
except ValueError:
version = ()
node = screen.get('node')
if node in nodes:
continue
if not node:
skipped.append((screen, 'device name unknown'))
elif not screen.get('online'):
skipped.append((screen, 'offline'))
elif len(version) != 3 or version < minimum:
skipped.append((screen, f"firmware {screen.get('firmware') or 'unknown'}"))
else:
ready.append(screen)
nodes.add(node)
return ready, skipped
def screen_items(registry):
"""The ESPHome entities that describe a screen; the subset `discover_screens` needs."""
return [item for item in registry if item.get('platform') == 'esphome' and item.get('original_name') in SCREEN_ENTITY_NAMES]
+51 -4
View File
@@ -1,4 +1,4 @@
"""HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+) and one persistent notification when a nightly update stops."""
"""HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+), each screen's alert actions when an alert event for every screen fires, and one persistent notification when a nightly update stops."""
import asyncio
import contextlib
import json
@@ -9,12 +9,13 @@ import secrets
import time
from datetime import datetime, timedelta, timezone
import math
import claude_skill
from firmware import Firmware
import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BUILTIN, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_reference, alert_service, 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, 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
@@ -80,6 +81,8 @@ class HomeAssistant:
self.time_zone = None
# Home Assistant's unit system; a climate entity's temperature carries no unit of its own.
self.units = {}
# (event type, data) of alert events for every screen, for Manager.alert_loop.
self.broadcasts = asyncio.Queue()
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -123,6 +126,9 @@ class HomeAssistant:
self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set()
elif event.get('event_type') in BROADCAST_EVENTS:
# Queued, not handled here: the calls wait for results this reader has to deliver.
self.broadcasts.put_nowait((event['event_type'], body))
raise ConnectionError('Home Assistant connection lost.')
def describe_close(self, connected):
@@ -153,7 +159,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:
for event_type in (*REGISTRY_EVENTS, *BROADCAST_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()
@@ -214,6 +220,11 @@ class HomeAssistant:
await self.request('call_service', domain='text', service='set_value',
service_data={'entity_id': inbox, 'value': packet})
async def call(self, action, data):
"""One Home Assistant action, such as a screen's esphome.<node>_show_alert."""
domain, service = action.split('.', 1)
await self.request('call_service', domain=domain, service=service, service_data=data)
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',
@@ -278,6 +289,7 @@ class Manager:
self.listeners = set() # asyncio.Event per open /api/events stream
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
self.updates = Updater(self, self.path.parent / 'updates.json')
self.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, []
self._screens_key, self._screens = None, []
@@ -747,6 +759,31 @@ class Manager:
except Exception as error:
LOG.warning('Refreshing history failed (%s)', type(error).__name__)
async def broadcast(self, event_type, data):
"""An alert event for every screen: the matching action on each screen that can show it, all at once."""
action = BROADCAST_EVENTS[event_type]
service_data, unusable = alert_data(data) if action == 'show_alert' else ({}, [])
if unusable:
LOG.warning('%s: unusable %s left empty', event_type, ', '.join(unusable))
ready, skipped = alert_targets(self.screens())
results = await asyncio.gather(*(self.ha.call(alert_service(screen['node'], action), service_data) for screen in ready),
return_exceptions=True)
failed = [(screen, type(result).__name__) for screen, result in zip(ready, results) if isinstance(result, BaseException)]
notes = [f"{screen['name']} {reason}" for screen, reason in skipped + failed]
LOG.info('%s: %d of %d screens%s', event_type, len(ready) - len(failed), len(ready) + len(skipped),
f" (not: {'; '.join(notes)})" if notes else '')
return {'sent': len(ready) - len(failed), 'skipped': len(skipped), 'failed': len(failed)}
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)
while queue is not None:
event_type, data = await queue.get()
try:
await self.broadcast(event_type, data)
except Exception as error:
LOG.warning('%s failed (%s)', event_type, type(error).__name__)
def create_app(manager, development=False):
csrf = secrets.token_urlsafe(32)
@web.middleware
@@ -799,6 +836,7 @@ def create_app(manager, development=False):
payload['controls'] = controls_catalogue()
payload['icons'] = tile_icons.editor()
payload['alerts'] = alert_reference()
payload['claude_skill'] = claude_skill.status(manager.skill_dir)
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Built into the screen', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}}
@@ -855,6 +893,13 @@ def create_app(manager, development=False):
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
'attributes':state_message(i,t,manager.ha.states)['a'],
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
async def install_claude_skill(request):
"""Settings → Claude → Install: writes the skill into Home Assistant's configuration folder, only on request."""
return web.json_response(claude_skill.install(manager.skill_dir))
async def download_claude_skill(request):
"""Settings → Claude → Download: the same skill as a zip for claude.ai; writes nothing."""
return web.Response(body=claude_skill.archive(), content_type='application/zip',
headers={'Content-Disposition': f'attachment; filename="{claude_skill.NAME}.zip"'})
async def firmware_status(request): return web.json_response(manager.firmware.status())
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json()))
async def firmware_create(request):
@@ -876,6 +921,8 @@ def create_app(manager, development=False):
app.router.add_get('/', index)
app.router.add_get('/api/inventory', inventory)
app.router.add_post('/api/header-preview', header_preview)
app.router.add_post('/api/claude-skill', install_claude_skill)
app.router.add_get('/api/claude-skill.zip', download_claude_skill)
app.router.add_get('/api/events', events)
app.router.add_put('/api/screens/{inbox}', save)
app.router.add_static('/static/', static)
@@ -895,7 +942,7 @@ 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())
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(), manager.alert_loop())
finally:
await runner.cleanup()
+76 -7
View File
@@ -301,7 +301,7 @@ function renderPending() {
node("strong", p.friendly),
node("small", p.installed
? "Installed, but not yet in Home Assistant. That happens outside ESP Screens: add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure."
: `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → ${p.file}.`),
: `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Settings → Firmware & USB → ${p.file}.`),
);
const actions = node("div", undefined, "pending-actions");
const go = node("button", "Open Devices & services", "mini");
@@ -358,14 +358,16 @@ function renderScreens() {
}
function renderUpdates() {
const u = inventory.updates;
$("#updates").hidden = !u || !inventory.screens.length;
$("#updates").hidden = !u;
if (!u) return;
const outdated = inventory.screens.filter((s) => s.update?.available).length;
$("#updates-hint").textContent = u.busy
? `Updating to firmware ${u.target}…`
: outdated
? `Firmware ${u.target} is available for ${outdated} screen${outdated === 1 ? "" : "s"}.`
: `All screens have firmware ${u.target}.`;
: !inventory.screens.length
? `No screen paired yet. New screens get firmware ${u.target}.`
: outdated
? `Firmware ${u.target} is available for ${outdated} screen${outdated === 1 ? "" : "s"}.`
: `All screens have firmware ${u.target}.`;
$("#update-all").hidden = !u.pending || !!u.busy || u.pending < 2;
$("#update-all").textContent = `Update all ${u.pending} screens`;
if (document.activeElement !== $("#auto-update"))
@@ -379,6 +381,56 @@ $("#update-all").onclick = async () => {
toast(e.message);
}
};
// ----- Settings: a view of its own next to My screens; #settings keeps it open across a reload -----
function showView() {
const settings = location.hash === "#settings";
$("#home-view").hidden = settings;
$("#settings-view").hidden = !settings;
$("#open-settings-label").textContent = settings ? "My screens" : "Settings";
$("#open-settings-icon").textContent = settings ? "▦" : "⚙";
$("#open-settings-hint").textContent = settings ? "Tiles and top bar" : "New screen, updates, alerts, Claude";
$("#open-settings").setAttribute("aria-expanded", String(settings));
if (settings) {
renderUpdates();
renderClaude();
}
window.scrollTo(0, 0);
}
$("#open-settings").onclick = () => {
location.hash = location.hash === "#settings" ? "" : "#settings";
};
$("#close-settings").onclick = () => {
location.hash = "";
};
window.addEventListener("hashchange", showView);
showView();
function renderClaude() {
const skill = inventory.claude_skill, button = $("#claude-install"), status = $("#claude-status");
button.disabled = !skill;
if (!skill) {
status.textContent = "Loading…";
return;
}
$("#claude-path").textContent = skill.path;
button.textContent = !skill.installed ? "Install for Claude Code" : skill.current ? "Install again" : "Update the skill";
button.classList.toggle("again", skill.installed && skill.current);
status.textContent = !skill.installed ? "Not installed" : skill.current ? "● Installed" : "Installed · a newer version is ready";
status.className = !skill.installed ? "hint" : skill.current ? "badge online" : "badge update";
}
$("#claude-install").onclick = async () => {
const button = $("#claude-install");
button.disabled = true;
try {
inventory.claude_skill = await (await api("claude-skill", { method: "POST" })).json();
toast(inventory.claude_skill.restart
? "Skill installed. Restart Claude Code once so it finds the new skills folder."
: "Skill installed. Claude Code picks it up right away.");
} catch (e) {
toast(e.message);
} finally {
renderClaude();
}
};
$("#auto-update").onchange = async () => {
try {
await api("updates", {
@@ -1675,6 +1727,7 @@ async function refresh(full = true) {
: "Reconnecting to Home Assistant…";
$("#connection").classList.toggle("online", inventory.connected);
renderScreens();
if (full) renderClaude();
if (selected) renderSettingsSupport();
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
if ($("#alerts-dialog").open) renderAlertScreens();
@@ -1784,7 +1837,7 @@ function renderTargetHint() {
$("#target-hint").textContent = !ports
? "No USB port found. Connect the screen with a data cable to the Home Assistant machine; the list refreshes on its own."
: !select.value
? "The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."
? "The profile goes into the ESPHome folder. You can install later via Settings → Firmware & USB, or from ESPHome Device Builder."
: ports > 1
? "More than one board connected: choose this screen's port."
: "Once over USB; after that, everything is wireless.";
@@ -1887,7 +1940,7 @@ function showSaved() {
$("#install-title").textContent = "Profile saved.";
$("#progress-title").textContent = `${installer.file} is in the ESPHome folder`;
$("#progress-detail").textContent =
"You can install once the screen is connected to the Home Assistant machine: Firmware & USB → this profile → USB port → Build & install. Or open the profile in ESPHome Device Builder (same folder) and flash from your browser. Save the API key for pairing:";
"You can install once the screen is connected to the Home Assistant machine: Settings → Firmware & USB → this profile → USB port → Build & install. Or open the profile in ESPHome Device Builder (same folder) and flash from your browser. Save the API key for pairing:";
$("#install-result").hidden = false;
renderSteps();
$("#install-log-wrap").hidden = true;
@@ -2219,6 +2272,20 @@ function alertWaitYaml(action) {
` message: "Someone is coming to the door."`,
].join("\n");
}
// One event for every screen (app 0.2.45): an action for "Edit in YAML" of the Event action.
function alertAllYaml() {
const fields = inventory.alerts?.fields || [];
const lines = fields.map((f) => ` ${f.name}: ${f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : String(f.example)}`);
return `event: ${inventory.alerts?.broadcast?.show || "esp_screens_show_alert"}\nevent_data:\n${lines.join("\n")}`;
}
function renderAlertBroadcast() {
const broadcast = inventory.alerts?.broadcast;
if (broadcast) {
$("#alerts-all-event").textContent = broadcast.show;
$("#alerts-all-dismiss").textContent = broadcast.dismiss;
}
$("#alerts-all-example").textContent = alertAllYaml();
}
function renderAlertScreens() {
const alerts = inventory.alerts, list = $("#alerts-screen-list");
if (!alerts) return;
@@ -2374,6 +2441,7 @@ function renderAlerts() {
}
renderAlertTables();
renderAlertScreens();
renderAlertBroadcast();
renderAlertIcons();
renderAlertColors();
return true;
@@ -2385,6 +2453,7 @@ $("#close-alerts").onclick = () => $("#alerts-dialog").close();
$("#alerts-example-screen").onchange = renderAlertExamples;
$("#alerts-example-copy").onclick = () => copyText($("#alerts-example").textContent, $("#alerts-example"), "YAML");
$("#alerts-wait-copy").onclick = () => copyText($("#alerts-wait-example").textContent, $("#alerts-wait-example"), "YAML");
$("#alerts-all-copy").onclick = () => copyText($("#alerts-all-example").textContent, $("#alerts-all-example"), "YAML");
$("#alerts-icon-search").oninput = renderAlertIcons;
for (const b of document.querySelectorAll("#alerts-dialog [data-jump]")) {
b.onclick = () => document.getElementById(b.dataset.jump)?.scrollIntoView({ behavior: "smooth", block: "start" });
+209 -135
View File
@@ -17,44 +17,165 @@
</div>
</div>
<nav class="toolbar" aria-label="Actions">
<button id="new-screen" class="tool tool-primary">
<span class="tool-icon" aria-hidden="true">+</span>
<button id="open-settings" class="tool" type="button" aria-controls="settings-view">
<span class="tool-icon" id="open-settings-icon" aria-hidden="true">⚙</span>
<span class="tool-text"
><b>New screen</b><small>Connect and install</small></span
>
</button>
<button id="open-firmware" class="tool">
<span class="tool-icon" aria-hidden="true">⇪</span>
<span class="tool-text"
><b>Firmware & USB</b
><small>Build and install via USB or Wi-Fi</small></span
>
</button>
<button id="open-alerts" class="tool">
<span class="tool-icon" aria-hidden="true">!</span>
<span class="tool-text"
><b>Alerts</b
><small>Full-screen notifications, from an automation</small></span
><b id="open-settings-label">Settings</b
><small id="open-settings-hint">New screen, updates, alerts, Claude</small></span
>
</button>
</nav>
</header>
<main>
<div class="intro">
<div>
<p class="eyebrow">HOME ASSISTANT</p>
<h1>Manage your remote screens here.</h1>
<div id="home-view">
<div class="intro">
<div>
<p class="eyebrow">HOME ASSISTANT</p>
<h1>Manage your remote screens here.</h1>
</div>
<span id="connection" role="status" class="badge">Connecting…</span>
</div>
<div class="workspace">
<aside>
<h2>My screens</h2>
<div id="screens"></div>
<div id="pending"></div>
<button class="quiet" id="refresh">↻ Refresh</button>
</aside>
<section id="empty" class="empty">
<span class="empty-icon">▦</span>
<h2>Start with a screen</h2>
<p>
Connect a CYD or Guition via USB to your Home Assistant machine.
Profile, firmware, and the first flash happen in one window.
</p>
<button id="start">Install new screen</button>
</section>
<section id="editor" hidden>
<div class="editor-head">
<div>
<p class="eyebrow" id="screen-name"></p>
<h2>Make it your screen</h2>
</div>
<span id="delivery" class="badge"></span>
</div>
<section class="topbar-editor" id="topbar" aria-labelledby="topbar-title">
<div class="section-title">
<h3 id="topbar-title">Top bar</h3>
<span id="topbar-count"></span>
</div>
<div class="topbar-fields">
<label class="topbar-name"
>Left: name<input
id="title"
maxlength="60"
placeholder="For example: Living room"
/></label>
<div class="topbar-right">
<span class="topbar-caption" id="topbar-caption">Right: time, sensors, or a clock</span>
<div
id="topbar-chips"
class="topbar-chips"
role="list"
aria-labelledby="topbar-caption"
></div>
</div>
</div>
<p class="hint" id="topbar-hint"></p>
</section>
<div class="columns" id="tile-section">
<div class="tile-editor">
<div class="section-title">
<h3>Your tiles</h3>
<span id="count">0 / 10</span>
</div>
<div id="layout-preview" aria-label="Screen layout"></div>
<button id="add-page" class="quiet add-page" type="button">
+ Add page
</button>
<p id="no-tiles" class="hint">
Add your first light, scene, or device via the picker.
</p>
<p id="positions-hint" class="hint" hidden></p>
</div>
<div class="picker">
<label for="search">Add an entity</label
><input
id="search"
type="search"
placeholder="Search by name, device, or room…"
autocomplete="off"
/>
<div class="filters" id="filters"></div>
<div id="results" aria-live="polite"></div>
</div>
</div>
<details class="screen-settings" id="general-settings" open>
<summary>
Screen settings
<span class="hint">Brightness, quiet hours, and display</span>
</summary>
<p class="hint" id="settings-support"></p>
<div class="settings-grid" id="settings-fields"></div>
<p class="hint">
Night hours apply to the brightness during standby. The first
touch wakes the screen; the next one controls your home.
Matching start and end times turn the night window off.
</p>
</details>
<details class="screen-settings" id="inspector-section">
<summary>
Inspector
<span class="hint"
>Connection, status, and supported properties</span
>
</summary>
<button id="inspect" class="quiet">Read current data</button>
<div id="inspection-summary"></div>
<pre id="inspection">Select a screen and read the data.</pre>
</details>
<footer class="savebar">
<div>
<strong id="dirty">All saved</strong
><small id="save-detail"
>Changes apply without reflashing.</small
>
</div>
<button id="save">Save & send to screen</button>
</footer>
</section>
</div>
<span id="connection" role="status" class="badge">Connecting…</span>
</div>
<div class="workspace">
<aside>
<h2>My screens</h2>
<div id="screens"></div>
<div id="pending"></div>
<button class="quiet" id="refresh">↻ Refresh</button>
<div class="updates" id="updates" hidden>
<h2>Firmware updates</h2>
<section id="settings-view" class="settings-view" hidden aria-labelledby="settings-title">
<div class="intro">
<div>
<p class="eyebrow">SETTINGS</p>
<h1 id="settings-title">Everything around your screens.</h1>
</div>
<button type="button" class="quiet" id="close-settings">← My screens</button>
</div>
<div class="settings-groups">
<section class="settings-group" aria-labelledby="settings-screens-title">
<h2 id="settings-screens-title">Screens</h2>
<p class="hint">Add a screen, or build and reinstall the firmware of one you already have.</p>
<div class="settings-tools">
<button id="new-screen" class="tool tool-primary" type="button">
<span class="tool-icon" aria-hidden="true">+</span>
<span class="tool-text"
><b>New screen</b><small>Connect and install</small></span
>
</button>
<button id="open-firmware" class="tool" type="button">
<span class="tool-icon" aria-hidden="true">⇪</span>
<span class="tool-text"
><b>Firmware & USB</b
><small>Build and install via USB or Wi-Fi</small></span
>
</button>
</div>
</section>
<section class="settings-group updates" id="updates" aria-labelledby="updates-title">
<h2 id="updates-title">Firmware updates</h2>
<p class="hint" id="updates-hint"></p>
<button id="update-all" hidden>Update all screens</button>
<label class="check">
@@ -66,111 +187,45 @@
></span
>
</label>
</div>
</aside>
<section id="empty" class="empty">
<span class="empty-icon">▦</span>
<h2>Start with a screen</h2>
<p>
Connect a CYD or Guition via USB to your Home Assistant machine.
Profile, firmware, and the first flash happen in one window.
</p>
<button id="start">Install new screen</button>
</section>
<section id="editor" hidden>
<div class="editor-head">
<div>
<p class="eyebrow" id="screen-name"></p>
<h2>Make it your screen</h2>
</div>
<span id="delivery" class="badge"></span>
</div>
<section class="topbar-editor" id="topbar" aria-labelledby="topbar-title">
<div class="section-title">
<h3 id="topbar-title">Top bar</h3>
<span id="topbar-count"></span>
</div>
<div class="topbar-fields">
<label class="topbar-name"
>Left: name<input
id="title"
maxlength="60"
placeholder="For example: Living room"
/></label>
<div class="topbar-right">
<span class="topbar-caption" id="topbar-caption">Right: time, sensors, or a clock</span>
<div
id="topbar-chips"
class="topbar-chips"
role="list"
aria-labelledby="topbar-caption"
></div>
</div>
</div>
<p class="hint" id="topbar-hint"></p>
</section>
<div class="columns" id="tile-section">
<div class="tile-editor">
<div class="section-title">
<h3>Your tiles</h3>
<span id="count">0 / 10</span>
</div>
<div id="layout-preview" aria-label="Screen layout"></div>
<button id="add-page" class="quiet add-page" type="button">
+ Add page
</button>
<p id="no-tiles" class="hint">
Add your first light, scene, or device via the picker.
</p>
<p id="positions-hint" class="hint" hidden></p>
</div>
<div class="picker">
<label for="search">Add an entity</label
><input
id="search"
type="search"
placeholder="Search by name, device, or room…"
autocomplete="off"
/>
<div class="filters" id="filters"></div>
<div id="results" aria-live="polite"></div>
</div>
</div>
<details class="screen-settings" id="general-settings" open>
<summary>
Screen settings
<span class="hint">Brightness, quiet hours, and display</span>
</summary>
<p class="hint" id="settings-support"></p>
<div class="settings-grid" id="settings-fields"></div>
<section class="settings-group" aria-labelledby="settings-alerts-title">
<h2 id="settings-alerts-title">Alerts</h2>
<p class="hint">
Night hours apply to the brightness during standby. The first
touch wakes the screen; the next one controls your home.
Matching start and end times turn the night window off.
A card over the whole screen from an automation, on one screen or on all of them.
The cheatsheet has the action names, fields, icons, and colors.
</p>
</details>
<details class="screen-settings" id="inspector-section">
<summary>
Inspector
<span class="hint"
>Connection, status, and supported properties</span
>
</summary>
<button id="inspect" class="quiet">Read current data</button>
<div id="inspection-summary"></div>
<pre id="inspection">Select a screen and read the data.</pre>
</details>
<footer class="savebar">
<div>
<strong id="dirty">All saved</strong
><small id="save-detail"
>Changes apply without reflashing.</small
>
<div class="settings-tools">
<button id="open-alerts" class="tool" type="button">
<span class="tool-icon" aria-hidden="true">!</span>
<span class="tool-text"
><b>Alerts</b
><small>Full-screen notifications, from an automation</small></span
>
</button>
</div>
<button id="save">Save & send to screen</button>
</footer>
</section>
</div>
</section>
<section class="settings-group" id="claude" aria-labelledby="claude-title">
<h2 id="claude-title">Claude</h2>
<p class="hint">
Teach Claude about ESP Screens, then ask: "Show an alert on all my screens when the
mailbox is full." Claude knows the alert events, every field, color, and icon.
</p>
<div class="claude-skill">
<button type="button" id="claude-install">Install for Claude Code</button>
<span id="claude-status" class="hint" role="status"></span>
</div>
<small>
For Claude Code in Home Assistant. Writes one file to
<code id="claude-path">/homeassistant/.claude/skills/esp-screens</code>; installing
again replaces it.
</small>
<div class="claude-skill">
<a class="button again" id="claude-download" href="api/claude-skill.zip" download="esp-screens.zip">Download for claude.ai</a>
<small>Upload the zip in Claude under Customize → Skills.</small>
</div>
</section>
</div>
</section>
</main>
<dialog id="installer">
<form id="install-form">
@@ -349,6 +404,7 @@
<nav class="alerts-nav" aria-label="Sections">
<button type="button" data-jump="alerts-screens">Your screens</button>
<button type="button" data-jump="alerts-howto">In an automation</button>
<button type="button" data-jump="alerts-all">All screens</button>
<button type="button" data-jump="alerts-fields">The fields</button>
<button type="button" data-jump="alerts-icons">Icons</button>
<button type="button" data-jump="alerts-colors">Colors</button>
@@ -361,8 +417,8 @@
<h3>Your screens</h3>
<p>
Every screen has its own action, with the device name in it. From firmware
<code id="alerts-min-firmware">0.2.31</code>; update an older screen under
Firmware updates.
<code id="alerts-min-firmware">0.2.31</code>; update an older screen with its
Update button under My screens.
</p>
<div id="alerts-screen-list"></div>
</section>
@@ -387,6 +443,21 @@
</div>
</div>
</section>
<section id="alerts-all">
<h3>All screens at once</h3>
<p>
Fire the event <code id="alerts-all-event">esp_screens_show_alert</code> with the same
fields, and ESP Screens passes the alert on to every screen that is online, including
screens you add later. <code id="alerts-all-dismiss">esp_screens_dismiss_alert</code>
clears it everywhere. In an automation, add the <b>Event</b> action or paste the example
under <b>Edit in YAML</b>. Leave out the fields you don't use. ESP Screens has to be
running for this.
</p>
<div class="copy-line">
<pre id="alerts-all-example"></pre>
<button type="button" class="mini" id="alerts-all-copy">Copy YAML</button>
</div>
</section>
<section id="alerts-fields">
<h3>The fields</h3>
<p>
@@ -469,8 +540,11 @@
"Turn off". The event then tells you who responded.</li>
<li>For an alert that resolves itself (the door opens, the laundry gets taken out)
close it in the same automation with <code>dismiss_alert</code>.</li>
<li>Multiple screens? Call each screen's action; they work independently and
each reports with its own <code>screen</code>.</li>
<li>Every screen? Fire <code>esp_screens_show_alert</code> once (see All screens at
once). The screens still work independently and each reports with its own
<code>screen</code>.</li>
<li>Rather <b>ask Claude</b> for the automation? Install the ESP Screens skill under
Settings → Claude.</li>
</ul>
</section>
</dialog>
+63 -8
View File
@@ -167,6 +167,10 @@ aside h2 {
.screen {
border: 1px solid transparent;
border-radius: 14px;
background: #e9ecf1;
}
.screen:hover:not(.selected) {
background: #e2e6ec;
}
.screen.selected {
background: white;
@@ -234,18 +238,69 @@ aside h2 {
transform: rotate(360deg);
}
}
.updates {
margin-top: 22px;
/* Settings: a view of its own next to My screens (app 0.2.45). */
.settings-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.updates h2 {
margin-bottom: 4px;
@media (max-width: 800px) {
.settings-groups {
grid-template-columns: 1fr;
}
}
.updates .hint {
margin: 0 0 10px;
.settings-group {
display: grid;
gap: 12px;
align-content: start;
background: white;
border: 1px solid var(--line);
border-radius: 18px;
padding: 20px 22px;
}
.settings-group h2 {
margin: 0;
}
.settings-group > .hint,
.settings-group > p {
margin: 0;
}
.settings-group code {
background: var(--soft);
padding: 1px 6px;
border-radius: 6px;
font-size: 12px;
overflow-wrap: anywhere;
}
.settings-tools {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.updates #update-all {
width: 100%;
margin-bottom: 12px;
justify-self: start;
}
.claude-skill {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
a.button {
display: inline-block;
border-radius: 11px;
padding: 11px 18px;
font-weight: 600;
text-decoration: none;
}
.again {
background: white;
color: var(--ink);
border: 1px solid var(--line);
}
.again:hover {
border-color: #b9cbec;
filter: none;
}
.check {
display: flex;
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.44"
version: "0.2.45"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen