diff --git a/README.md b/README.md index cc94013..88f41bd 100644 --- a/README.md +++ b/README.md @@ -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._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. + +

+ Settings in ESP Screens: New screen and Firmware & USB, the firmware updates, the Alerts cheatsheet, and the Claude skill +

+ ## 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**:

New screen (step 3) and choosing your tiles (step 5).

-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 diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index f6ab69a..c2a287b 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -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 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3d08d2a..a8f29f4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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._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 `: 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 diff --git a/docs/TEST_RESULTS_0245.md b/docs/TEST_RESULTS_0245.md new file mode 100644 index 0000000..b0d912d --- /dev/null +++ b/docs/TEST_RESULTS_0245.md @@ -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. diff --git a/docs/images/editor-settings.png b/docs/images/editor-settings.png new file mode 100644 index 0000000..f832003 Binary files /dev/null and b/docs/images/editor-settings.png differ diff --git a/docs/images/editor.png b/docs/images/editor.png index ff2c9a0..1dfde0d 100644 Binary files a/docs/images/editor.png and b/docs/images/editor.png differ diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 6024a3f..44b89c2 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -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. diff --git a/screen_manager/DOCS.md b/screen_manager/DOCS.md index ba4d05f..ae3a441 100644 --- a/screen_manager/DOCS.md +++ b/screen_manager/DOCS.md @@ -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 diff --git a/screen_manager/app/claude_skill.py b/screen_manager/app/claude_skill.py new file mode 100644 index 0000000..1bad7ce --- /dev/null +++ b/screen_manager/app/claude_skill.py @@ -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//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: /.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._show_alert` and `esphome._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} diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 4713adb..86971df 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -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._, 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] diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index e15dd76..f20aad5 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -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._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() diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js index 17022f1..234c580 100644 --- a/screen_manager/app/static/app.js +++ b/screen_manager/app/static/app.js @@ -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" }); diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html index f49d36e..4e7c86a 100644 --- a/screen_manager/app/static/index.html +++ b/screen_manager/app/static/index.html @@ -17,44 +17,165 @@
-
-
-

HOME ASSISTANT

-

Manage your remote screens here.

+
+
+
+

HOME ASSISTANT

+

Manage your remote screens here.

+
+ Connecting… +
+
+ +
+ ▦ +

Start with a screen

+

+ Connect a CYD or Guition via USB to your Home Assistant machine. + Profile, firmware, and the first flash happen in one window. +

+ +
+
- Connecting…
-
- -
- ▦ -

Start with a screen

-

- Connect a CYD or Guition via USB to your Home Assistant machine. - Profile, firmware, and the first flash happen in one window. -

- -
- -
+ +
+

Claude

+

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

+
+ + +
+ + For Claude Code in Home Assistant. Writes one file to + /homeassistant/.claude/skills/esp-screens; installing + again replaces it. + +
+ Download for claude.ai + Upload the zip in Claude under Customize → Skills. +
+
+
+
@@ -349,6 +404,7 @@
diff --git a/screen_manager/app/static/style.css b/screen_manager/app/static/style.css index ec822d9..13ab06c 100644 --- a/screen_manager/app/static/style.css +++ b/screen_manager/app/static/style.css @@ -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; diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 24c1123..9eb6def 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -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 diff --git a/tests/test_alert_broadcast.py b/tests/test_alert_broadcast.py new file mode 100644 index 0000000..1ebeea1 --- /dev/null +++ b/tests/test_alert_broadcast.py @@ -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() diff --git a/tests/test_claude_skill.py b/tests/test_claude_skill.py new file mode 100644 index 0000000..584bb39 --- /dev/null +++ b/tests/test_claude_skill.py @@ -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() diff --git a/tests/test_settings_view.py b/tests/test_settings_view.py new file mode 100644 index 0000000..9230d20 --- /dev/null +++ b/tests/test_settings_view.py @@ -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, '
', '
') + self.assertEqual(re.findall(r'