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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user