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
+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;