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
+38 -5
View File
@@ -150,13 +150,46 @@ data:
night mode wait as long as the card is showing.
- **`flash`**: `true` makes the backlight blink four times when the alert arrives.
In ESP Screens, the **Alerts** button opens a cheatsheet with the exact action name for each screen,
In ESP Screens, **Settings → Alerts** opens a cheatsheet with the exact action name for each screen,
a ready-to-paste example, and all fields, icons, and colors. Home Assistant asks for all seven fields; leave a field empty (`""`, `0`, `false`) if you
don't use it. A new alert replaces the current one. Every end is reported as the event
**`esphome.screen_alert`** with `action` (`ok`, `timeout`, `replaced`, or `remote`), `title`,
`screen`, and the `device_id` that Home Assistant adds, so an automation can wait for OK.
**`esphome.<screen>_dismiss_alert`** clears the card remotely.
### All screens at once
From app 0.2.45, one event reaches every screen that is online, screens you add later included.
ESP Screen Manager passes it on to each screen's `show_alert` action. The fields are the same;
the ones you leave out stay empty:
```yaml
actions:
- event: esp_screens_show_alert
event_data:
title: "Mail!"
subtitle: "There is post in the mailbox"
icon: mailbox
color: orange
timeout: 0
flash: true
```
**`esp_screens_dismiss_alert`** clears the alert on every screen. The app has to be running
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.
<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">
</p>
## Installing from Home Assistant
### Do I need ESPHome?
@@ -192,7 +225,7 @@ For Home Assistant OS with Apps/Add-ons on **aarch64 or amd64**:
2. Install **ESP Screen Manager**, start the app, and open **ESP Screens**.
ESPHome Device Builder is optional: the ESPHome CLI is already in this app.
3. Connect the screen with a USB data cable to the **Home Assistant machine**
and choose **New screen**: CYD or Guition, a name, the USB port, and
and choose **Settings → New screen**: CYD or Guition, a name, the USB port, and
**Install**. If Wi-Fi is missing from the ESPHome `secrets.yaml`, the window
asks for it once and ESP Screens only adds the missing lines. The profile
with unique API and OTA keys goes into the ESPHome folder; the build
@@ -212,7 +245,7 @@ For Home Assistant OS with Apps/Add-ons on **aarch64 or amd64**:
</p>
<p align="center"><sub>New screen (step 3) and choosing your tiles (step 5).</sub></p>
You can install new firmware later from **Firmware & USB → Wi-Fi / OTA**.
You can install new firmware later from **Settings → Firmware & USB → Wi-Fi / OTA**.
For an existing screen, always use the existing profile; creating a new
installation profile generates new keys.
@@ -270,7 +303,7 @@ only the name and the time (if that's in the bar).
| --- | --- |
| Tiles, names, colors, order, or screen settings | Save in ESP Screens; no firmware flash |
| New version of the management page | Update ESP Screen Manager in the HA App store |
| New feature on the physical screen | The **Update** button on the screen (badge *Update x.y.z*), or the **Update automatically every night** checkbox |
| New feature on the physical screen | The **Update** button on the screen (badge *Update x.y.z*), or **Update automatically every night** under Settings |
Every app version belongs to one firmware version. After an app update, the list
shows per screen whether newer firmware is available. **Update** builds that screen's own profile
@@ -278,7 +311,7 @@ with the built-in CLI, installs it wirelessly, and waits until the screen is bac
With the checkbox enabled, that happens automatically at night, one screen at a time; a
failure stops the round and posts a notification in Home Assistant. Firmware
0.2.17+ reports its own device name and IP address for this; an older screen asks
for the IP address once. You can still do it manually via Firmware & USB → Wi-Fi / OTA.
for the IP address once. You can still do it manually via Settings → Firmware & USB → Wi-Fi / OTA.
The device's own YAML and Wi-Fi/API/OTA settings stay in the ESPHome config folder.
Tile layouts and options live in the app's persistent data. CYD calibration and
+3 -3
View File
@@ -38,7 +38,7 @@ Use the GitHub version for updates; a local test add-on is a separate app.
1. Connect the screen with a **USB data cable** to the machine running Home
Assistant. With multiple boards: connect them one at a time for the first
installation, or check which port belongs to this screen.
2. Click **New screen**. Choose CYD or Guition and give the screen a name,
2. Open **Settings** at the top right of ESP Screens and click **New screen**. Choose CYD or Guition and give the screen a name,
for example `Kitchen`. The device name (`kitchen`) follows from that; use
**customize** to choose a different one.
3. Wi-Fi: if `wifi_ssid` and `wifi_password` are already in the ESPHome `secrets.yaml`,
@@ -67,7 +67,7 @@ No USB port in the list? A cable plugged into your laptop isn't visible to the
Raspberry Pi. In that case, choose **Later · save profile only**: the profile then
sits in the same folder ESPHome Device Builder uses, so you can open it there
and flash from your browser (**Install → Plug into this computer**), or
install it to the HA machine later via **Firmware & USB**.
install it to the HA machine later via **Settings → Firmware & USB**.
**CYD:** calibration appears on first boot. Calmly tap the visible crosshair
three times, hold each tap briefly, and follow each next crosshair in turn.
@@ -171,7 +171,7 @@ as soon as the screen comes back. The app must keep running for current tile dat
| --- | --- | --- |
| Different entities, names, or order | Save in ESP Screens | Wi-Fi, keys, calibration |
| New management page/app version | App store → ESP Screen Manager → Update | All layouts in `/data/screens.json` |
| New screen feature/card | The **Update** button on the screen, or the **Update automatically every night** checkbox (manually: Firmware & USB → Wi-Fi / OTA) | Own YAML, keys, and CYD calibration; the app resends tiles |
| New screen feature/card | The **Update** button on the screen, or **Update automatically every night** under Settings (manually: Settings → Firmware & USB → Wi-Fi / OTA) | Own YAML, keys, and CYD calibration; the app resends tiles |
The device's own YAML references the firmware packages on `main`. On a new build,
ESPHome fetches the latest published package and component code. So you don't
+20
View File
@@ -176,6 +176,26 @@ 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.45 (firmware stays 0.2.38)
App only: storage version, tile protocol, preferences and keys are unchanged.
- Alerts for every screen: on connect the app also subscribes to the Home Assistant events
`esp_screens_show_alert` and `esp_screens_dismiss_alert`. The reader only queues them;
`Manager.alert_loop` handles them in arrival order, apart from the sync loop, and calls
`esphome.<node>_show_alert` / `_dismiss_alert` at once on every screen from `discover_screens`
that is online, has a device name and reports firmware 0.2.31+ (one call per node).
`core.alert_data` types the seven fields the way Home Assistant validates ESPHome actions (text,
an int clamped to 0..86400, a bool) and leaves an unusable value empty instead of failing the call.
The log line `<event>: N of M screens (not: …)` names skipped and failed screens.
- `claude_skill.py` builds SKILL.md from the alert reference and `tile_icons`, byte for byte the same
on every call. `POST /api/claude-skill` writes `$HA_CONFIG/.claude/skills/esp-screens/SKILL.md`
(default `/homeassistant`, the start folder of the Claude Code apps) only on request;
`GET /api/claude-skill.zip` returns `esp-screens/SKILL.md` for claude.ai, whose description limit
is 200 characters. The full inventory's `claude_skill` compares the file with the current text.
- Editor: the header tools and the updates block moved into `#settings-view`, shown for the hash
`#settings`; element ids and their handlers are unchanged.
### Compatibility 0.2.44 / firmware 0.2.38
Firmware only: `show_detail()` creates no `detail_status` for a large vacuum card (the hero badge
+55
View File
@@ -0,0 +1,55 @@
# Test results app 0.2.45 (firmware stays 0.2.38, 2026-09-15)
One alert for every screen (`esp_screens_show_alert` / `esp_screens_dismiss_alert`), the Claude skill
(install for Claude Code or download for claude.ai), the Settings page and grey screen cards. See
CHANGELOG 0.2.45 and RELEASING "Compatibility 0.2.45". App only; no firmware or package changes.
## Automated
- Python (`.venv-portal`): 164 tests OK, also in a clean worktree at the release parent with only
this release's changes applied.
- New: `tests/test_alert_broadcast.py` covers field typing, target selection, fan-out, failures,
queue order, and that the reader only queues.
- New: `tests/test_claude_skill.py` covers skill text, YAML examples, zip, install/update and the endpoints.
- New: `tests/test_settings_view.py` covers the header, Settings contents, view switching and the grey cards.
- `node --check app.js` clean.
- No C++ or ESPHome builds: firmware code, board profiles and packages are unchanged.
## Live Home Assistant (owner's HA 2026.9.1, dry run)
- The new `HomeAssistant` and `Manager` code ran from a development machine against the owner's
Home Assistant with a long-lived token. Screen actions were recorded, not sent.
- `POST /api/events/esp_screens_show_alert` with `timeout: "5"`, `flash: "yes"`, `button_text: true`:
- the subscription received it;
- the log said `esp_screens_show_alert: 2 of 2 screens`, with a warning that `button_text` was left empty;
- calls were recorded for `esphome.guition_wallbox_show_alert` and `esphome.cyd_2432s028_show_alert`
with `timeout: 5`, `flash: true`, `button_text: ""`.
- `esp_screens_dismiss_alert` gave both `_dismiss_alert` calls with empty data.
- Home Assistant lists both per-screen actions with the same seven fields.
## Editor (local server with a fake Home Assistant, desktop 1280 px and phone 375 px)
- The header shows one Settings button. `#settings` survives a reload; the header button, "← My screens"
and browser back/forward switch the views.
- Settings → New screen opens and closes the installer. Firmware & USB opens the firmware window
(`GET /api/firmware` 200).
- The nightly update checkbox on/off gives `PUT /api/updates` 200 with both toasts. Update all
(forced to two pending screens) posts `/api/updates/run` with the CSRF header.
- Alerts opens the cheatsheet. "Copy YAML" under All screens copies the event example.
- Claude:
- Install shows the restart toast when the skills folder is new, then "● Installed".
- After a changed skill text it shows "Installed · a newer version is ready" with "Update the skill".
- The zip download returns `application/zip` with `esp-screens/SKILL.md`.
- My screens: the unselected screen sits on the grey card (`#e9ecf1`), the selected one is white.
- No console errors; every API request 200. At 375 px the Settings cards stack, without
horizontal scrolling.
- README images `editor.png` and `editor-settings.png` come from the demo home (`.esphome/readme-render`).
## Not tested / for the owner
- The released app inside Home Assistant (ingress, Supervisor token, `/homeassistant` mount):
- the Install button writing `/homeassistant/.claude/skills/esp-screens/SKILL.md`;
- the zip download inside the HA iframe.
- A real alert on the screens through the event (the dry run did not call the screens).
- Claude Code in Home Assistant picking up the skill and writing the mailbox automation.
- The claude.ai upload of the zip.
Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

After

Width:  |  Height:  |  Size: 373 KiB

+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
+196
View File
@@ -0,0 +1,196 @@
"""One alert for every screen (app 0.2.45): Home Assistant fires esp_screens_show_alert or
esp_screens_dismiss_alert, and the app calls that action on each screen that can show it."""
import asyncio
import importlib.util
from pathlib import Path
import re
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import (ALERT_FIELDS, ALERT_MAX_TIMEOUT, BROADCAST_DISMISS, BROADCAST_EVENTS, BROADCAST_SHOW, # noqa: E402
alert_data, alert_reference, alert_targets)
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
from aiohttp import WSMsgType
from server import HomeAssistant, Manager
STATIC = ROOT / 'screen_manager/app/static'
class AlertData(unittest.TestCase):
def test_complete_data_passes_through_typed(self):
data = {'title': 'Mail!', 'subtitle': 'There is post', 'icon': 'mailbox', 'color': 'orange',
'button_text': 'OK', 'timeout': 30, 'flash': True}
self.assertEqual(alert_data(data), (data, []))
self.assertEqual(list(alert_data(data)[0]), [name for name, *_ in ALERT_FIELDS], 'all seven, in field order')
def test_missing_fields_become_empty_without_a_warning(self):
empty = {'title': '', 'subtitle': '', 'icon': '', 'color': '', 'button_text': '', 'timeout': 0, 'flash': False}
for data in ({}, None, 'text', {'title': None, 'timeout': '', 'flash': ''}):
self.assertEqual(alert_data(data), (empty, []), data)
def test_values_are_coerced_like_home_assistant_does(self):
service, unusable = alert_data({'title': 42, 'timeout': '90', 'flash': 'on'})
self.assertEqual((service['title'], service['timeout'], service['flash'], unusable), ('42', 90, True, []))
self.assertEqual(alert_data({'timeout': 12.7})[0]['timeout'], 12)
self.assertEqual(alert_data({'timeout': -5})[0]['timeout'], 0, 'the firmware clamps too')
self.assertEqual(alert_data({'timeout': 10**9})[0]['timeout'], ALERT_MAX_TIMEOUT)
self.assertEqual(alert_data({'flash': 1})[0]['flash'], True)
self.assertEqual(alert_data({'flash': 'Off'})[0]['flash'], False)
def test_one_unusable_value_is_left_empty_and_named(self):
# A bare `Yes` in YAML arrives as a boolean; the rest of the alert still goes out.
service, unusable = alert_data({'title': 'Door', 'button_text': True, 'timeout': 'soon', 'flash': 'maybe', 'icon': ['bell']})
self.assertEqual(service, {'title': 'Door', 'subtitle': '', 'icon': '', 'color': '', 'button_text': '', 'timeout': 0, 'flash': False})
self.assertEqual(unusable, ['icon', 'button_text', 'timeout', 'flash'])
self.assertEqual(alert_data({'timeout': float('nan')})[1], ['timeout'])
def test_limits_follow_the_firmware(self):
header = (ROOT / 'components/smart_display/alert_overlay.h').read_text()
self.assertEqual(int(re.search(r'MAX_TIMEOUT_SECONDS = (\d+);', header)[1]), ALERT_MAX_TIMEOUT)
self.assertEqual(BROADCAST_EVENTS, {'esp_screens_show_alert': 'show_alert', 'esp_screens_dismiss_alert': 'dismiss_alert'})
self.assertEqual(alert_reference()['broadcast'], {'show': BROADCAST_SHOW, 'dismiss': BROADCAST_DISMISS})
class AlertTargets(unittest.TestCase):
def test_only_screens_that_can_show_it_now(self):
screens = [{'name': 'Kitchen', 'node': 'kitchen', 'online': True, 'firmware': '0.2.31'},
{'name': 'Hall', 'node': 'hall', 'online': True, 'firmware': '0.2.38'},
{'name': 'Attic', 'node': 'attic', 'online': False, 'firmware': '0.2.38'},
{'name': 'Garage', 'node': 'garage', 'online': True, 'firmware': '0.2.30'},
{'name': 'Shed', 'node': 'shed', 'online': True, 'firmware': 'unknown'},
{'name': 'New', 'node': None, 'online': True, 'firmware': '0.2.38'},
{'name': 'Hall again', 'node': 'hall', 'online': True, 'firmware': '0.2.38'}]
ready, skipped = alert_targets(screens)
self.assertEqual([s['name'] for s in ready], ['Kitchen', 'Hall'])
self.assertEqual([(s['name'], reason) for s, reason in skipped],
[('Attic', 'offline'), ('Garage', 'firmware 0.2.30'), ('Shed', 'firmware unknown'), ('New', 'device name unknown')])
self.assertEqual(alert_targets([]), ([], []))
def fake_ha():
"""Three paired screens as Home Assistant's registry describes them: two current, one too old."""
class HA:
online = True
def __init__(self):
self.registry, self.devices, self.states = [], [], {}
for device, node, firmware in (('d1', 'kitchen-screen', '0.2.38'), ('d2', 'hall', '0.2.31'), ('d3', 'attic', '0.2.30')):
self.registry += [{'entity_id': f'text.{device}_tiles', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': device},
{'entity_id': f'sensor.{device}_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': device},
{'entity_id': f'sensor.{device}_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': device}]
self.devices.append({'id': device, 'name': node.title()})
self.states.update({f'text.{device}_tiles': {'state': 'Synced'}, f'sensor.{device}_node': {'state': node},
f'sensor.{device}_fw': {'state': firmware}})
self.areas = []
self.changed = asyncio.Event()
self.broadcasts = asyncio.Queue()
self.calls, self.failing = [], set()
async def call(self, action, data):
self.calls.append((action, data))
if action in self.failing:
raise ConnectionError('Home Assistant refused the command.')
return HA()
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Broadcast(unittest.IsolatedAsyncioTestCase):
async def test_show_and_dismiss_reach_every_current_screen(self):
with tempfile.TemporaryDirectory() as tmp:
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
with self.assertLogs('screen_manager', 'INFO') as logs:
result = await m.broadcast(BROADCAST_SHOW, {'title': 'Mail!', 'icon': 'mailbox', 'flash': True})
self.assertEqual(result, {'sent': 2, 'skipped': 1, 'failed': 0})
data = {'title': 'Mail!', 'subtitle': '', 'icon': 'mailbox', 'color': '', 'button_text': '', 'timeout': 0, 'flash': True}
self.assertEqual(sorted(m.ha.calls), [('esphome.hall_show_alert', data), ('esphome.kitchen_screen_show_alert', data)])
self.assertIn('esp_screens_show_alert: 2 of 3 screens (not: Attic firmware 0.2.30)', logs.output[-1])
m.ha.calls.clear()
await m.broadcast(BROADCAST_DISMISS, {'title': 'ignored'})
self.assertEqual(sorted(m.ha.calls), [('esphome.hall_dismiss_alert', {}), ('esphome.kitchen_screen_dismiss_alert', {})])
async def test_a_screen_added_later_gets_the_next_alert(self):
with tempfile.TemporaryDirectory() as tmp:
ha = fake_ha()
m = Manager(ha, Path(tmp) / 'screens.json')
await m.broadcast(BROADCAST_SHOW, {'title': 'One'})
ha.registry = ha.registry + [{'entity_id': 'text.d4_tiles', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd4'},
{'entity_id': 'sensor.d4_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd4'},
{'entity_id': 'sensor.d4_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd4'}]
ha.devices = ha.devices + [{'id': 'd4', 'name': 'Study'}]
ha.states.update({'text.d4_tiles': {'state': 'Synced'}, 'sensor.d4_node': {'state': 'study'}, 'sensor.d4_fw': {'state': '0.2.38'}})
ha.calls.clear()
self.assertEqual((await m.broadcast(BROADCAST_SHOW, {'title': 'Two'}))['sent'], 3)
self.assertIn('esphome.study_show_alert', [action for action, _ in ha.calls])
async def test_one_failing_screen_does_not_stop_the_others(self):
with tempfile.TemporaryDirectory() as tmp:
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
m.ha.failing.add('esphome.hall_show_alert')
with self.assertLogs('screen_manager', 'INFO') as logs:
result = await m.broadcast(BROADCAST_SHOW, {'title': 'Smoke', 'timeout': 'later'})
self.assertEqual(result, {'sent': 1, 'skipped': 1, 'failed': 1})
self.assertEqual(len(m.ha.calls), 2)
self.assertTrue(any('unusable timeout left empty' in line for line in logs.output), logs.output)
self.assertIn('1 of 3 screens (not: Attic firmware 0.2.30; Hall ConnectionError)', logs.output[-1])
async def test_the_loop_keeps_the_order_and_survives_errors(self):
with tempfile.TemporaryDirectory() as tmp:
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
original = m.broadcast
async def broadcast(event_type, data):
if data.get('title') == 'boom':
raise RuntimeError('unexpected')
return await original(event_type, data)
m.broadcast = broadcast
task = asyncio.create_task(m.alert_loop())
for item in ((BROADCAST_SHOW, {'title': 'boom'}), (BROADCAST_SHOW, {'title': 'Door'}), (BROADCAST_DISMISS, {})):
m.ha.broadcasts.put_nowait(item)
with self.assertLogs('screen_manager', 'INFO'):
for _ in range(50):
if len(m.ha.calls) == 4:
break
await asyncio.sleep(0.01)
task.cancel()
self.assertEqual([action.rsplit('_', 2)[-2:] for action, _ in m.ha.calls], [['show', 'alert']] * 2 + [['dismiss', 'alert']] * 2)
async def test_home_assistant_queues_the_events_instead_of_calling_from_the_reader(self):
class Message:
type = WSMsgType.TEXT
def __init__(self, data): self.data = data
def json(self): return self.data
class Socket:
def __init__(self, messages): self.messages = messages
def __aiter__(self): return self._iterate()
async def _iterate(self):
for message in self.messages:
yield Message(message)
ha = HomeAssistant(None, 'http://ha/api', 'token')
ha.ws = Socket([{'type': 'event', 'event': {'event_type': BROADCAST_SHOW, 'data': {'title': 'Mail!'}}},
{'type': 'event', 'event': {'event_type': 'call_service', 'data': {}}},
{'type': 'event', 'event': {'event_type': BROADCAST_DISMISS, 'data': {}}}])
with self.assertRaises(ConnectionError):
await ha.read()
self.assertEqual([ha.broadcasts.get_nowait() for _ in range(ha.broadcasts.qsize())],
[(BROADCAST_SHOW, {'title': 'Mail!'}), (BROADCAST_DISMISS, {})])
self.assertIn('*BROADCAST_EVENTS', (ROOT / 'screen_manager/app/server.py').read_text(), 'subscribed on connect')
class Page(unittest.TestCase):
def test_cheatsheet_explains_the_event(self):
html = (STATIC / 'index.html').read_text()
cheatsheet = html.split('id="alerts-dialog"', 1)[1]
for marker in ('id="alerts-all"', 'data-jump="alerts-all"', 'id="alerts-all-example"', 'id="alerts-all-copy"',
'esp_screens_show_alert', 'esp_screens_dismiss_alert', 'Settings → Claude'):
self.assertIn(marker, cheatsheet, marker)
script = (STATIC / 'app.js').read_text()
for marker in ('function alertAllYaml', 'broadcast?.show', 'renderAlertBroadcast();', '#alerts-all-copy").onclick'):
self.assertIn(marker, script, marker)
if __name__ == '__main__':
unittest.main()
+137
View File
@@ -0,0 +1,137 @@
"""The Claude Code skill behind Alerts → Install for Claude: complete, valid, and written only on request."""
import asyncio
import importlib.util
from pathlib import Path
import re
import sys
import tempfile
import unittest
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, BROADCAST_DISMISS, BROADCAST_SHOW, TILE_BACKGROUNDS # noqa: E402
HAS_YAML = importlib.util.find_spec('yaml') is not None
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
class SkillText(unittest.TestCase):
def test_frontmatter_names_the_skill_and_says_when_to_use_it(self):
text = claude_skill.text()
front = re.match(r'---\nname: (.+)\ndescription: (.+)\n---\n', text)
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'):
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')
self.assertLess(text.count('\n'), 500, 'Claude Code advises SKILL.md under 500 lines')
def test_it_carries_everything_the_cheatsheet_does(self):
text = claude_skill.text()
for needle in (BROADCAST_SHOW, BROADCAST_DISMISS, ALERT_EVENT, ALERT_MIN_FIRMWARE, '_show_alert', '_dismiss_alert'):
self.assertIn(needle, text)
for name, kind, *_ in ALERT_FIELDS:
self.assertRegex(text, rf'\| `{name}` \| ', name)
for field, limit in ALERT_LIMITS['guition'].items():
self.assertIn(f"CYD {ALERT_LIMITS['cyd'][field]} · Guition {limit} bytes", text)
for name, item in TILE_BACKGROUNDS.items():
if item['color']:
self.assertIn(f'`{name}`', text)
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')
@unittest.skipUnless(HAS_YAML, 'PyYAML needed')
def test_every_yaml_example_parses(self):
import yaml
blocks = re.findall(r'```yaml\n(.*?)```', claude_skill.text(), re.S)
self.assertEqual(len(blocks), 4)
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']
self.assertEqual([option['sequence'][0]['event'] for option in choose], [BROADCAST_SHOW, BROADCAST_DISMISS])
class Archive(unittest.TestCase):
def test_zip_holds_the_skill_folder_as_claude_ai_expects(self):
import io
import zipfile
data = claude_skill.archive()
self.assertEqual(data, claude_skill.archive(), 'same bytes every time')
with zipfile.ZipFile(io.BytesIO(data)) as bundle:
self.assertEqual(bundle.namelist(), ['esp-screens/SKILL.md'])
self.assertEqual(bundle.read('esp-screens/SKILL.md').decode('utf8'), claude_skill.text())
class Install(unittest.TestCase):
def test_status_install_update(self):
with tempfile.TemporaryDirectory() as tmp:
target = claude_skill.skill_dir(tmp)
self.assertEqual(target, Path(tmp) / '.claude' / 'skills' / 'esp-screens')
self.assertEqual(claude_skill.status(target), {'installed': False, 'current': False, 'path': str(target)})
first = claude_skill.install(target)
self.assertEqual(first, {'installed': True, 'current': True, 'path': str(target), 'restart': True})
self.assertEqual((target / 'SKILL.md').read_text(encoding='utf8'), claude_skill.text())
self.assertEqual(sorted(p.name for p in target.iterdir()), ['SKILL.md'], 'no temp file left behind')
self.assertFalse(claude_skill.install(target)['restart'], 'the skills folder already existed')
(target / 'SKILL.md').write_text('older text', encoding='utf8')
self.assertEqual(claude_skill.status(target)['current'], False)
self.assertTrue(claude_skill.install(target)['current'])
def test_a_new_skill_next_to_existing_skills_needs_no_restart(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / '.claude' / 'skills' / 'other').mkdir(parents=True)
self.assertFalse(claude_skill.install(claude_skill.skill_dir(tmp))['restart'])
self.assertTrue((Path(tmp) / '.claude' / 'skills' / 'other').is_dir(), 'other skills untouched')
def test_a_folder_that_cannot_be_written_gives_a_message(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / '.claude').write_text('a file, not a folder')
with self.assertRaises(ValueError) as caught:
claude_skill.install(claude_skill.skill_dir(tmp))
self.assertIn("Couldn't write the skill", str(caught.exception))
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Endpoint(unittest.IsolatedAsyncioTestCase):
async def test_inventory_reports_and_the_button_installs(self):
class HA:
online = True
registry, devices, areas, states = [], [], [], {}
changed = asyncio.Event()
with tempfile.TemporaryDirectory() as tmp:
manager = Manager(HA(), Path(tmp) / 'data' / 'screens.json')
manager.skill_dir = claude_skill.skill_dir(Path(tmp) / 'config')
async with TestClient(TestServer(create_app(manager, True))) as client:
full = await (await client.get('/api/inventory')).json()
self.assertEqual(full['claude_skill'], {'installed': False, 'current': False, 'path': str(manager.skill_dir)})
self.assertNotIn('claude_skill', await (await client.get('/api/inventory?light=1')).json())
download = await client.get('/api/claude-skill.zip')
self.assertEqual(download.status, 200)
self.assertEqual(download.headers['Content-Type'], 'application/zip')
self.assertEqual(download.headers['Content-Disposition'], 'attachment; filename="esp-screens.zip"')
self.assertEqual(await download.read(), claude_skill.archive())
self.assertFalse(manager.skill_dir.exists(), 'opening the page or downloading writes nothing')
refused = await client.post('/api/claude-skill')
self.assertEqual(refused.status, 403, 'CSRF like every other change')
response = await client.post('/api/claude-skill', headers={'X-Screen-CSRF': full['csrf']})
self.assertEqual(await response.json(), {'installed': True, 'current': True, 'path': str(manager.skill_dir), 'restart': True})
self.assertTrue((manager.skill_dir / 'SKILL.md').is_file())
(Path(tmp) / 'blocked').write_text('a file, not a folder')
manager.skill_dir = claude_skill.skill_dir(Path(tmp) / 'blocked')
failed = await client.post('/api/claude-skill', headers={'X-Screen-CSRF': full['csrf']})
self.assertEqual(failed.status, 400)
self.assertIn("Couldn't write the skill", (await failed.json())['error'])
if __name__ == '__main__':
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
"""Settings (app 0.2.45): New screen, Firmware & USB, firmware updates, Alerts and Claude moved off the main
page into a view of their own, so the header keeps one button and My screens keeps the editor."""
from pathlib import Path
import re
import unittest
STATIC = Path(__file__).resolve().parents[1] / 'screen_manager/app/static'
def between(text, start, end):
return text.split(start, 1)[1].split(end, 1)[0]
class SettingsView(unittest.TestCase):
def setUp(self):
self.html = (STATIC / 'index.html').read_text()
self.script = (STATIC / 'app.js').read_text()
self.css = (STATIC / 'style.css').read_text()
def test_header_keeps_only_the_settings_button(self):
header = between(self.html, '<header>', '</header>')
self.assertEqual(re.findall(r'<button id="([\w-]+)"', header), ['open-settings'])
def test_every_tool_lives_in_the_settings_view_once(self):
settings = between(self.html, '<section id="settings-view"', '</main>')
home = between(self.html, '<div id="home-view">', '<section id="settings-view"')
for element in ('new-screen', 'open-firmware', 'updates', 'update-all', 'auto-update', 'open-alerts',
'claude-install', 'claude-status', 'claude-path', 'claude-download', 'close-settings'):
self.assertEqual(self.html.count(f'id="{element}"'), 1, element)
self.assertIn(f'id="{element}"', settings, element)
self.assertNotIn(f'id="{element}"', home, element)
# The first-run button stays where a new user starts.
self.assertIn('id="start"', home)
self.assertIn('href="api/claude-skill.zip"', settings)
def test_the_view_switches_by_hash_and_renders_what_it_shows(self):
for marker in ('function showView', 'location.hash === "#settings"', 'window.addEventListener("hashchange", showView)',
'$("#home-view").hidden = settings', 'function renderClaude', 'api("claude-skill", { method: "POST" })',
'if (full) renderClaude();', '$("#new-screen").onclick = $("#start").onclick = openInstaller'):
self.assertIn(marker, self.script, marker)
self.assertNotIn('Not flashed yet? Firmware & USB', self.script, 'texts point to Settings')
for marker in ('.settings-groups', '.settings-group', '.claude-skill', '.screen:hover:not(.selected)'):
self.assertIn(marker, self.css, marker)
def test_unselected_screens_get_a_grey_card(self):
rule = between(self.css, '\n.screen {', '}')
self.assertIn('background: #e9ecf1', rule)
self.assertIn('background: white', between(self.css, '\n.screen.selected {', '}'))
if __name__ == '__main__':
unittest.main()