Rebuild the editor as a Vue app: sidebar, pages side by side, tile settings in a drawer (0.2.74)
The page is now web/ (Vue 3 + Vite + TypeScript), built into screen_manager/app/static with hashed file names; server.py serves index.html as built and /assets/. The Python API, the data and the firmware are unchanged. Your screens sit in a sidebar, the pages of a screen stand next to each other at the screen's own shape, the library sits on the right, and a tile's settings slide in as a drawer while the mockup stays in view. New screen, Firmware & USB, Alerts, Override YAML and Settings are pages of their own. Fonts moved to web/src/assets; the tests read the source through tests/editor_sources.py. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,10 @@ and screens for lookups and tests. If that file is missing you are not on his ma
|
||||
|
||||
- Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs
|
||||
in the gitignored local profiles.
|
||||
- The editor is `web/` (Vue 3 + Vite, TypeScript; app 0.2.74+). `screen_manager/app/static` is its build
|
||||
output: change `web/src`, run `cd web && npm run build`, and commit both. Keep every URL the page asks for
|
||||
relative (`api/...`), so it works behind Home Assistant's ingress path. Tests read the source through
|
||||
`tests/editor_sources.py`; docs/RELEASING.md "Local development" has the dev-server recipe.
|
||||
- Colours live in one table: `components/smart_display/theme.h` (firmware 0.2.54+). Every role has a
|
||||
light and a dark value, board profiles name paints (`styles: paint_card`) instead of writing a colour,
|
||||
and firmware code asks for a role (`theme::color(theme::INK)`). Never write a hex colour in a profile or
|
||||
|
||||
@@ -18,7 +18,7 @@ the bell, and a Guition shows who is there with the doorbell camera's picture. A
|
||||
action Home Assistant has for a tile, and Dark mode suits a screen beside the bed.
|
||||
|
||||
**How.** ESP Screen Manager is an app inside Home Assistant. It flashes a new screen over USB, updates
|
||||
it over Wi-Fi and sends it your tiles. Changing a screen is pick, drag and **Save & send to screen**:
|
||||
it over Wi-Fi and sends it your tiles. Changing a screen is pick, drag and **Save & send**:
|
||||
no reflash, no YAML to write, no blueprint, MQTT or token. Brightness, night hours and Dark mode can
|
||||
also be changed on the screen or by an automation, and your own YAML for one screen survives every update.
|
||||
|
||||
@@ -153,7 +153,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 **Settings → New screen**: CYD or Guition, a name, the USB port, and
|
||||
and choose **New screen** in the sidebar: 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
|
||||
@@ -169,7 +169,7 @@ For Home Assistant OS with Apps/Add-ons on **aarch64 or amd64**:
|
||||
shows after installation (copy button). Grant the device permission to
|
||||
perform Home Assistant actions.
|
||||
5. Select the screen in ESP Screens, choose your tiles, and click
|
||||
**Save & send to screen**. Then test the physical controls.
|
||||
**Save & send**. Then test the physical controls.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/editor-new-screen.png" width="37%" alt="New screen in ESP Screens: choose the board, give it a name, pick the USB port and install">
|
||||
@@ -177,7 +177,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 **Settings → Firmware & USB → Wi-Fi / OTA**.
|
||||
You can install new firmware later from **Firmware & USB → Wi-Fi / OTA** in the sidebar.
|
||||
For an existing screen, always use the existing profile; creating a new
|
||||
installation profile generates new keys.
|
||||
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ the sidebar panel; here, open Home Assistant yourself.
|
||||
|
||||
## Using it
|
||||
|
||||
From here it works like the app: **Settings → New screen** installs a screen, you pair it
|
||||
From here it works like the app: **New screen** installs a screen, you pair it
|
||||
under **Settings → Devices & services** in Home Assistant, and then choose the tiles. See the
|
||||
[installation guide](EASY_SETUP.md).
|
||||
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ A new screen, from USB to everyday use
|
||||
|
||||
With **ESP Screen Manager**, you choose your tiles in Home Assistant. You search by
|
||||
name, device, room, or entity ID, arrange them in the order you want, and click
|
||||
**Save & send to screen**. After that, the status stays automatically up to date.
|
||||
**Save & send**. After that, the status stays automatically up to date.
|
||||
You don't need to **reflash** for different tiles.
|
||||
|
||||
This works with Home Assistant OS on a 64-bit Raspberry Pi or an amd64 machine,
|
||||
@@ -77,7 +77,7 @@ within reach at all. Then put the firmware on the screen from your own computer:
|
||||
|
||||
The file holds your Wi-Fi password and the screen's keys: keep it to yourself. Pairing works
|
||||
as in chapter 3, and every later update goes over Wi-Fi, so the cable is only needed once.
|
||||
For an existing profile, the same file is under **Settings → Firmware & USB**: choose the
|
||||
For an existing profile, the same file is under **Firmware & USB** (in the sidebar): choose the
|
||||
profile and **Download · flash from your own computer**, then **Build & download**.
|
||||
|
||||
**CYD:** calibration appears on first boot. Calmly tap the visible crosshair
|
||||
@@ -146,7 +146,7 @@ Adding without a chosen slot fills the first free slot. Fixed slots and empty
|
||||
slots work on the screen from firmware 0.2.26 on; older firmware shifts the
|
||||
tiles up to the first free slot, and the editor notes that below the preview.
|
||||
The preview shows the layout, not live sensor values.
|
||||
Click **Save & send to screen** to send your changes.
|
||||
Click **Save & send** to send your changes.
|
||||
|
||||
- Light, switch, input_boolean, and fan: tap to turn on/off.
|
||||
- Long press a light: brightness, rainbow color, and white temperature, as far as
|
||||
@@ -189,7 +189,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 **Update automatically every night** under Settings (manually: Settings → 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: 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
|
||||
@@ -264,7 +264,7 @@ HA mechanisms used: [Ingress](https://developers.home-assistant.io/docs/apps/pre
|
||||
Update ESP Screen Manager to 0.1.2 and install the new firmware once
|
||||
via your **existing** ESPHome device → Install → Wirelessly. Keep your own YAML
|
||||
with Wi-Fi and keys. Then open the screen in ESP Screen Manager and expand
|
||||
**Screen settings**. After adjusting, click **Save & send to screen**.
|
||||
**Screen settings**. After adjusting, click **Save & send**.
|
||||
After this, changes to these settings don't need a new firmware flash.
|
||||
|
||||
| Setting | Options | Default |
|
||||
|
||||
+13
-1
@@ -20,6 +20,9 @@
|
||||
|
||||
1. Update the source profiles and shared components. Run
|
||||
`python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly.
|
||||
The editor is the Vue app in `web/`: after a change under `web/src`, run
|
||||
`cd web && npm ci && npm run build` and commit `screen_manager/app/static` with it
|
||||
(that folder is the build output; never edit it by hand).
|
||||
2. Run all Python tests with aiohttp installed, all `tests/*.cpp`, the
|
||||
generator with `--check`, and compile both Easy Setup profiles. Do that
|
||||
sequentially: profiles with the same `DEVICE_NAME` share one build folder, and a
|
||||
@@ -53,6 +56,15 @@ python3 -m venv .venv-portal
|
||||
.venv-portal/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
For the editor (`web/`, Vue 3 + Vite + TypeScript): `cd web && npm ci`, then `npm run dev` serves
|
||||
http://localhost:5173 with hot reload and proxies `/api` to a server on 127.0.0.1:8099 (SCREEN_DEV or a demo
|
||||
home). `npm run check` type-checks, `npm run build` writes the page into `screen_manager/app/static`
|
||||
(clearing `assets/` first). Vite names every file after a hash of its content, which is what keeps a browser
|
||||
from combining an old script with a new page; `server.py` serves `index.html` as built and `/assets/`.
|
||||
Everything the page asks for is a relative URL (`api/...`, `./assets/...`), so it works under Home
|
||||
Assistant's ingress path as well as on a bare localhost. The Python tests read the source through
|
||||
`tests/editor_sources.py`.
|
||||
|
||||
A temporary development server supports `SCREEN_DEV=1`, `HA_API` (ending in
|
||||
`/api`), `HA_TOKEN_FILE`, and `SCREEN_DATA`. It only binds on localhost. Never put a
|
||||
token in source code, URLs, or Git. Production uses Supervisor and only accepts
|
||||
@@ -171,7 +183,7 @@ An old editor that omits `icon` keeps the stored choice.
|
||||
|
||||
`tile_icons.py` is the single source list. `tools/generate_icons.py` writes the glyphs
|
||||
as a YAML anchor into the three MDI fonts of both board profiles and builds
|
||||
`static/tile-icons.woff` for the editor (with left bearing equal to xMin, otherwise
|
||||
`web/src/assets/tile-icons.woff` for the editor (with left bearing equal to xMin, otherwise
|
||||
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.
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
## 0.2.73 (firmware 0.2.61)
|
||||
|
||||
A new editor, built as a Vue app, with the same firmware.
|
||||
|
||||
- **One workspace instead of one long page.** Your screens sit in a sidebar on the left, the screen you chose fills the middle, and the entity library sits on the right. The big title, the separate Settings view and the sticky save bar are gone: **Save & send** appears in the head the moment something changed, and says *Saved · sent to screen* when it is done.
|
||||
- **Tile settings in a drawer.** Tap a tile and its settings slide in from the right; the mockup stays visible, so a wider tile, another background or a direct control shows up on the page while you choose. Tap another tile and the drawer follows. Esc or a tap beside the pages closes it.
|
||||
- **Pages side by side.** The pages of a screen stand next to each other, at the screen's own shape (square for a Guition, 4:3 for a CYD), the way you swipe through them. Dragging from the library onto a page and between cells works as before, with mouse and touch; drop past the last page for a new one.
|
||||
- **The top bar is part of the mockup.** Tap the bar on any page to name the screen and choose its items; the drawer shows every item as the screen draws it. Adding the time, the date, the dial or an entity happens in the same drawer.
|
||||
- **Screen settings as a tab** next to Layout, with the same rows as the settings page on the screen. **Read current data** and **Override YAML** live in the ··· menu of the screen; New screen, Firmware & USB, Alerts and the app Settings are pages of their own, reached from the sidebar.
|
||||
- **Dark mode follows your browser**, as Home Assistant's own Auto theme does.
|
||||
- Same add-on API, same data, same firmware: nothing to update on the screens. The page is now built with Vue and Vite (`web/`); its files carry a hash of their content, so an update never runs an old script against a new page.
|
||||
|
||||
## 0.2.72 (firmware 0.2.61)
|
||||
|
||||
Next, Next, Next: the page buttons keep up with your finger.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
See [the full installation guide](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md).
|
||||
|
||||
Open **Settings → New screen** to install a screen: connect it via USB to the
|
||||
Open **New screen** in the sidebar 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
|
||||
@@ -10,26 +10,26 @@ build and flash run in the same window; it then shows the API key
|
||||
for pairing. Is the Home Assistant machine out of reach of the screen, such as a server or a
|
||||
virtual machine? Choose **Download** under **Install via**: ESP Screens builds the firmware and
|
||||
you put it on the screen from your own computer with [ESPHome Web](https://web.esphome.io) in
|
||||
Chrome or Edge. 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
|
||||
Chrome or Edge. 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** in the sidebar is
|
||||
for existing profiles: check, build or reinstall via USB, the IP address (OTA) or
|
||||
Download. Existing ESPHome profiles in the HA config folder are
|
||||
found automatically.
|
||||
|
||||
**Settings** (top right) also has the nightly firmware updates, the **Alerts** cheatsheet
|
||||
**Settings** in the sidebar 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
|
||||
Tap the bar at the top of any page in the mockup to set the **Top bar** of a 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
|
||||
to configure; firmware 0.2.32 or newer renders them.
|
||||
|
||||
After pairing via the HA ESPHome integration, choose the tiles in ESP Screens.
|
||||
**Tile settings** has click behavior, larger values and mini-sliders.
|
||||
**Screen settings** has brightness and standby. **Inspector** helps with
|
||||
Tap a tile and its settings open in a drawer on the right, with the mockup still in view: click behavior, larger values, mini-sliders, a wider tile, backgrounds.
|
||||
**Screen settings**, the tab next to Layout, has brightness and standby. **Read current data** in the ··· menu of a screen helps with
|
||||
missing attributes or an offline screen. New cards require firmware 0.2.0.
|
||||
Each screen also has **Override YAML** for a persistent, hardware-specific
|
||||
The ··· menu of a screen also has **Override YAML** for a persistent, hardware-specific
|
||||
`<screen>.local.yaml` file. It is loaded after the shared package and does not
|
||||
replace the managed name, credentials, Wi-Fi, OTA or package connection.
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""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 hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -1495,12 +1494,13 @@ def create_app(manager, development=False):
|
||||
app = web.Application(middlewares=[guard], client_max_size=16*1024)
|
||||
static = Path(__file__).parent / 'static'
|
||||
|
||||
# The page asks for its script and styles with a stamp of their content, so a browser that keeps old copies
|
||||
# anyway (Safari did after an update) never runs an old script against a new page.
|
||||
stamp = hashlib.sha1(b''.join((static / name).read_bytes() for name in ('app.js', 'style.css'))).hexdigest()[:12]
|
||||
page = (static / 'index.html').read_text().replace('"static/app.js"', f'"static/app.js?v={stamp}"').replace('"static/style.css"', f'"static/style.css?v={stamp}"')
|
||||
# The page is the Vite build of web/ (npm run build writes it here): index.html names its script and styles
|
||||
# by a hash of their content, so a browser that keeps old copies anyway (Safari did after an update) never
|
||||
# runs an old script against a new page.
|
||||
page = (static / 'index.html').read_text()
|
||||
async def index(request):
|
||||
return web.Response(text=page, content_type='text/html')
|
||||
# SCREEN_DEV reads the file every time, so a fresh `npm run build` shows up without a restart.
|
||||
return web.Response(text=(static / 'index.html').read_text() if development else page, content_type='text/html')
|
||||
def light_payload(screens=None):
|
||||
"""Screens and update status: everything that changes while the page is open."""
|
||||
if screens is None:
|
||||
@@ -1674,7 +1674,7 @@ def create_app(manager, development=False):
|
||||
app.router.add_get('/api/events', events)
|
||||
app.router.add_put('/api/screens/{inbox}', save)
|
||||
app.router.add_put('/api/screens/{inbox}/settings', change_settings)
|
||||
app.router.add_static('/static/', static)
|
||||
app.router.add_static('/assets/', static / 'assets')
|
||||
return app
|
||||
|
||||
async def main():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,609 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>ESP Screens</title>
|
||||
<link rel="stylesheet" href="static/style.css" />
|
||||
<script src="static/app.js" defer></script>
|
||||
<script type="module" crossorigin src="./assets/index-DOjlT4hj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D7h6hKdu.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<span class="mark">▦</span>
|
||||
<div>
|
||||
<strong>ESP Screens</strong
|
||||
><small>Tiles for your CYD and Guition, without reflashing</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="toolbar" aria-label="Actions">
|
||||
<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 id="open-settings-label">Settings</b
|
||||
><small id="open-settings-hint">New screen, updates, alerts, Claude</small></span
|
||||
>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<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>
|
||||
<!-- Hidden until the first inventory, so neither card flashes while the page loads. -->
|
||||
<section id="choose" class="empty" hidden>
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Choose a screen</h2>
|
||||
<p>Pick one of your screens to change its tiles, top bar and settings.</p>
|
||||
</section>
|
||||
<section id="empty" class="empty" hidden>
|
||||
<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>
|
||||
<div class="editor-head-actions">
|
||||
<button id="open-override" class="quiet mini" type="button">Override YAML</button>
|
||||
<span id="delivery" class="badge"></span>
|
||||
</div>
|
||||
</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>
|
||||
<section class="screen-settings" id="general-settings" aria-labelledby="settings-heading">
|
||||
<div class="section-title settings-title">
|
||||
<h3 id="settings-heading">Screen settings</h3>
|
||||
<span class="hint" id="settings-status" role="status"></span>
|
||||
</div>
|
||||
<div class="settings-cards" id="settings-groups"></div>
|
||||
</section>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<input type="checkbox" id="auto-update" />
|
||||
<span
|
||||
>Update automatically every night<small
|
||||
>Between 03:00 and 06:00, one screen at a time. Stops on an
|
||||
error and reports it in Home Assistant.</small
|
||||
></span
|
||||
>
|
||||
</label>
|
||||
</section>
|
||||
<section class="settings-group" aria-labelledby="settings-alerts-title">
|
||||
<h2 id="settings-alerts-title">Alerts</h2>
|
||||
<p class="hint">
|
||||
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>
|
||||
<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>
|
||||
</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">
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<p class="eyebrow">NEW SCREEN</p>
|
||||
<h2 id="install-title">Connect and install.</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="quiet"
|
||||
id="close-install"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div id="install-setup">
|
||||
<p>
|
||||
ESP Screens creates the screen's own profile with unique keys and
|
||||
builds the firmware. Connect the screen with a USB data cable to the
|
||||
machine running Home Assistant, or download the firmware and put it
|
||||
on the screen from your own computer.
|
||||
</p>
|
||||
<fieldset>
|
||||
<legend>Which screen do you have?</legend>
|
||||
<label class="board"
|
||||
><input type="radio" name="board" value="cyd" checked /><span
|
||||
><b>CYD · 2.8 inch</b
|
||||
><small>ESP32-2432S028 · 320 × 240</small></span
|
||||
></label
|
||||
><label class="board"
|
||||
><input type="radio" name="board" value="guition" /><span
|
||||
><b>Guition · 4 inch</b
|
||||
><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
<label
|
||||
>Name<input
|
||||
name="friendly_name"
|
||||
required
|
||||
maxlength="60"
|
||||
placeholder="Living room screen"
|
||||
autocomplete="off" /><small class="node-line"
|
||||
>Device name <code id="node-preview">screen</code
|
||||
><button type="button" class="link" id="edit-node">
|
||||
customize
|
||||
</button></small
|
||||
><small
|
||||
>Every screen gets its own profile with its own keys. Four
|
||||
screens? Go through this four times with a different name.</small
|
||||
></label
|
||||
>
|
||||
<label id="node-label" hidden
|
||||
>Device name<input
|
||||
name="name"
|
||||
pattern="[a-z][a-z0-9\-]{0,29}"
|
||||
maxlength="30"
|
||||
autocomplete="off" /><small
|
||||
>Lowercase letters, digits, and dashes. A different name for
|
||||
each screen.</small
|
||||
></label
|
||||
>
|
||||
<fieldset id="wifi-fields" class="wifi" hidden disabled>
|
||||
<legend>Wi-Fi</legend>
|
||||
<p class="hint" id="wifi-status"></p>
|
||||
<label id="wifi-ssid-label"
|
||||
>Wi-Fi name (2.4 GHz)<input name="wifi_ssid" autocomplete="off"
|
||||
/></label>
|
||||
<label id="wifi-password-label"
|
||||
>Wi-Fi password<input
|
||||
name="wifi_password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
</fieldset>
|
||||
<label
|
||||
>Install via<select name="target" id="install-target"></select
|
||||
><small id="target-hint"></small
|
||||
></label>
|
||||
<p class="hint" id="install-note"></p>
|
||||
<div class="install-actions">
|
||||
<button type="submit" id="install-go">Install</button>
|
||||
<span id="install-status" role="status"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="install-progress" hidden>
|
||||
<div class="progress-head">
|
||||
<span class="spin" id="progress-spin"></span>
|
||||
<span class="outcome" id="progress-mark" hidden></span>
|
||||
<strong id="progress-title"></strong>
|
||||
</div>
|
||||
<p id="progress-detail"></p>
|
||||
<div id="install-download" class="download-box" hidden>
|
||||
<a class="button" id="download-firmware" href="">Download firmware</a>
|
||||
<ol class="steps" id="download-steps"></ol>
|
||||
<small>The file holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
</div>
|
||||
<div id="install-result" hidden>
|
||||
<div class="key-box">
|
||||
<span>API key</span><code id="api-key"></code>
|
||||
<button type="button" class="mini" id="copy-key">Copy</button>
|
||||
</div>
|
||||
<ol class="steps" id="install-steps"></ol>
|
||||
</div>
|
||||
<details id="install-log-wrap">
|
||||
<summary>ESPHome log</summary>
|
||||
<pre id="install-log"></pre>
|
||||
</details>
|
||||
<div class="install-actions">
|
||||
<button type="button" id="install-retry" hidden>
|
||||
Retry
|
||||
</button>
|
||||
<button type="button" class="quiet" id="install-close">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="firmware-dialog">
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<p class="eyebrow">FIRMWARE & USB</p>
|
||||
<h2>Build and install screen firmware</h2>
|
||||
</div>
|
||||
<button id="close-firmware" class="quiet" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<p>
|
||||
The built-in ESPHome CLI uses your own profile (YAML with keys)
|
||||
and secrets. Choose the screen's profile, then a USB port (screen
|
||||
connected to the Home Assistant machine), <b>Wi-Fi / OTA</b> with the hostname,
|
||||
or <b>Download</b> to put the firmware on the screen from your own computer.
|
||||
<b>Check</b> only validates, <b>Build only</b> compiles,
|
||||
<b>Build & install</b> puts the firmware on the screen. Your tiles
|
||||
stay as they are in ESP Screens. The first build takes a few minutes.
|
||||
</p>
|
||||
<label>ESPHome profile<select id="firmware-file"></select></label>
|
||||
<label
|
||||
>Install to<select id="firmware-port">
|
||||
<option value="ota">Wi-Fi / OTA</option>
|
||||
</select></label
|
||||
>
|
||||
<label id="firmware-host-label"
|
||||
>IP address or hostname<input
|
||||
id="firmware-host"
|
||||
placeholder="screen-livingroom.local"
|
||||
/></label>
|
||||
<div class="yaml-actions">
|
||||
<button id="firmware-validate" class="quiet">Check</button
|
||||
><button id="firmware-build" class="quiet">Build only</button
|
||||
><button id="firmware-install">Build & install</button>
|
||||
</div>
|
||||
<p id="firmware-status" role="status"></p>
|
||||
<div id="firmware-download" class="download-box" hidden>
|
||||
<a class="button" id="firmware-download-link" href="">Download firmware</a>
|
||||
<small>
|
||||
Put it on the screen with
|
||||
<a href="https://web.esphome.io/?dashboard_install" target="_blank" rel="noopener">ESPHome Web</a>
|
||||
in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file.
|
||||
It holds your Wi-Fi password and the screen's keys: keep it to yourself.
|
||||
</small>
|
||||
</div>
|
||||
<pre id="firmware-log"></pre>
|
||||
</dialog>
|
||||
<dialog id="override-dialog" class="override-dialog" aria-labelledby="override-title">
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<p class="eyebrow">ADVANCED · LOCAL OVERRIDE</p>
|
||||
<h2 id="override-title">Override YAML</h2>
|
||||
</div>
|
||||
<button id="close-override" class="quiet" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<p class="override-intro">
|
||||
This small file is yours. It is loaded after the shared screen package and stays in place when
|
||||
ESP Screen Manager updates. Change hardware-specific details here, such as a display model.
|
||||
</p>
|
||||
<div class="override-notice">
|
||||
<span class="override-icon" aria-hidden="true">⌘</span>
|
||||
<div>
|
||||
<strong>What is safe to change?</strong>
|
||||
<span>Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="override-file-row">
|
||||
<span id="override-file" class="override-file"></span>
|
||||
<span id="override-state" class="badge"></span>
|
||||
</div>
|
||||
<div class="yaml-editor" id="yaml-editor-wrap">
|
||||
<div id="override-gutter" class="yaml-gutter" aria-hidden="true">1</div>
|
||||
<textarea id="override-editor" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off"
|
||||
aria-label="Local override YAML" placeholder="# Example\ndisplay:\n - id: !extend my_display\n model: ST7789V"></textarea>
|
||||
</div>
|
||||
<div class="override-tools">
|
||||
<button id="override-example" class="quiet mini" type="button">Use example</button>
|
||||
<button id="override-empty" class="quiet mini" type="button">Clear</button>
|
||||
<span id="override-count" class="hint"></span>
|
||||
</div>
|
||||
<p id="override-status" class="override-status" role="status"></p>
|
||||
<div class="yaml-actions override-actions">
|
||||
<button id="override-check" class="quiet" type="button">Save & check</button>
|
||||
<button id="override-save" type="button">Save override</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="tile-sheet" class="tile-sheet" aria-label="Configure tile"></dialog>
|
||||
<dialog id="topbar-sheet" class="tile-sheet" aria-label="Top bar"></dialog>
|
||||
<dialog id="alerts-dialog" class="alerts" aria-label="Alerts">
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<p class="eyebrow">ALERTS · CHEATSHEET</p>
|
||||
<h2>A full-screen notification, from an automation.</h2>
|
||||
</div>
|
||||
<button id="close-alerts" class="quiet" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<p>
|
||||
Every screen has the <code>show_alert</code> action. It places a card over everything,
|
||||
wakes the screen, and keeps the backlight at normal brightness until someone taps
|
||||
the button or the timeout runs out. Home Assistant itself shows no explanation or
|
||||
dropdowns for ESPHome actions; everything you can fill in is listed here.
|
||||
</p>
|
||||
<div class="alert-hero">
|
||||
<div class="alert-mock" id="alert-mock" aria-hidden="true">
|
||||
<div class="alert-mock-card">
|
||||
<span class="mdi alert-mock-icon" id="alert-mock-icon"></span>
|
||||
<strong id="alert-mock-title">Someone is at the door</strong>
|
||||
<span id="alert-mock-subtitle">Door 3, back</span>
|
||||
<button type="button" id="alert-mock-button" tabindex="-1">Coming</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<button type="button" data-jump="alerts-behaviour">Behavior</button>
|
||||
<button type="button" data-jump="alerts-events">Events</button>
|
||||
<button type="button" data-jump="alerts-tips">Tips</button>
|
||||
</nav>
|
||||
</div>
|
||||
<section id="alerts-screens">
|
||||
<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 with its
|
||||
Update button under My screens.
|
||||
</p>
|
||||
<div id="alerts-screen-list"></div>
|
||||
</section>
|
||||
<section id="alerts-howto">
|
||||
<h3>In an automation</h3>
|
||||
<ol class="steps">
|
||||
<li><b>Add action</b> and search for <b>ESPHome</b>. Choose your screen's action:
|
||||
Home Assistant calls it "Performs the action show_alert of the node
|
||||
<i>device name</i>".</li>
|
||||
<li><b>Fill in the fields.</b> Home Assistant asks for all seven; leave what you don't
|
||||
use empty (<code>""</code>, <code>0</code>, off).</li>
|
||||
<li>Prefer YAML? Choose <b>Edit in YAML</b> on the action and paste the example
|
||||
below.</li>
|
||||
</ol>
|
||||
<div class="alert-example">
|
||||
<label
|
||||
>Example for<select id="alerts-example-screen"></select
|
||||
></label>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-example"></pre>
|
||||
<button type="button" class="mini" id="alerts-example-copy">Copy YAML</button>
|
||||
</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>
|
||||
Text is free-form; the limit is in bytes, and an accented letter counts as two.
|
||||
What doesn't fit gets an ellipsis (title) or is dropped (subtitle).
|
||||
</p>
|
||||
<div class="table-scroll"><table id="alerts-field-table"></table></div>
|
||||
</section>
|
||||
<section id="alerts-icons">
|
||||
<h3>Icons</h3>
|
||||
<p>
|
||||
Fill in the name in <code>icon</code>, for example <code>doorbell</code>.
|
||||
<code>mdi:doorbell</code> and the hex codepoint (<code>F12E6</code>) also work.
|
||||
Only these icons are included in the firmware; any other name shows the
|
||||
warning triangle. Tap an icon to copy its name.
|
||||
</p>
|
||||
<p class="alerts-subhead">Handy for notifications</p>
|
||||
<div class="chips" id="alerts-suggested"></div>
|
||||
<label class="alerts-search"
|
||||
>Search for an icon<input
|
||||
id="alerts-icon-search"
|
||||
type="search"
|
||||
placeholder="Search by name or description…"
|
||||
autocomplete="off"
|
||||
/></label>
|
||||
<div id="alerts-icon-groups"></div>
|
||||
</section>
|
||||
<section id="alerts-colors">
|
||||
<h3>Colors</h3>
|
||||
<p>
|
||||
Fill in the name in <code>color</code>. These are the pastel shades of the tiles,
|
||||
with dark text. Empty or unknown gives the white card. Tap to copy.
|
||||
</p>
|
||||
<div class="swatches" id="alerts-swatches"></div>
|
||||
</section>
|
||||
<section id="alerts-behaviour">
|
||||
<h3>Behavior</h3>
|
||||
<ul class="alerts-list">
|
||||
<li><b>Wakes the screen.</b> An alert immediately wakes a dimmed or dark screen and
|
||||
appears above every page, every open light card, and the standby layer.</li>
|
||||
<li><b>On until the button.</b> As long as the card is showing, the backlight stays at
|
||||
normal brightness. Standby and night hours wait. After the button, the regular
|
||||
standby time starts again.</li>
|
||||
<li><b>Timeout.</b> <code>timeout: 0</code> waits for the button, however long that
|
||||
takes. With <code>timeout: 60</code> the card disappears after a minute, but the
|
||||
button closes it sooner.</li>
|
||||
<li><b>Blinking.</b> <code>flash: true</code> blinks the backlight four times
|
||||
when the alert arrives (about a second) and leaves the screen on afterward.</li>
|
||||
<li><b>Replacing.</b> A new alert replaces the current one; there's no stack.
|
||||
The replaced alert reports itself as <code>replaced</code>.</li>
|
||||
<li><b>Closing remotely.</b> <code>dismiss_alert</code> clears the card,
|
||||
for example if the door is already open. Without an alert, that action does nothing.</li>
|
||||
<li><b>Tapping beside the card</b> does nothing; only the button closes it. That way a
|
||||
wake-up tap never accidentally dismisses the alert.</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="alerts-events">
|
||||
<h3>Events</h3>
|
||||
<p>
|
||||
Every end fires the event <code id="alerts-event-name">esphome.screen_alert</code>
|
||||
with <code>action</code>, <code>title</code>, <code>screen</code> (the device name)
|
||||
and the <code>device_id</code> that Home Assistant adds. Visible under
|
||||
Settings → Tools → Events.
|
||||
</p>
|
||||
<div class="table-scroll"><table id="alerts-ending-table"></table></div>
|
||||
<p class="alerts-subhead">Waiting until someone presses the button</p>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-wait-example"></pre>
|
||||
<button type="button" class="mini" id="alerts-wait-copy">Copy YAML</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-tips">
|
||||
<h3>Tips</h3>
|
||||
<ul class="alerts-list">
|
||||
<li>Use a <b>color per kind of notification</b>: orange for the door, red for
|
||||
smoke or water, green for "done". The text always stays dark and readable.</li>
|
||||
<li>Keep the title <b>short</b>; a screen shows about twenty characters on one line and
|
||||
ends a longer title with an ellipsis. Put details in the subtitle.</li>
|
||||
<li>A <b>custom button text</b> turns the notification into a question: "Coming", "Seen",
|
||||
"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>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>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.72"
|
||||
version: "0.2.73"
|
||||
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,25 @@
|
||||
"""The editor's source, for the tests that read it.
|
||||
|
||||
The page is a Vue app in web/ (app 0.2.74); `npm run build` writes it to screen_manager/app/static. Tests look at
|
||||
the source, not the build: every .ts and .vue file under web/src as one text (SCRIPT, which is also the markup:
|
||||
a .vue file carries its template), and the stylesheets as another (CSS).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / 'web/src'
|
||||
STATIC = ROOT / 'screen_manager/app/static'
|
||||
|
||||
FILES = sorted(p for p in WEB.rglob('*') if p.suffix in ('.ts', '.vue'))
|
||||
SCRIPT = '\n'.join(p.read_text() for p in FILES)
|
||||
PAGE = SCRIPT
|
||||
CSS = '\n'.join(p.read_text() for p in sorted(WEB.glob('styles/*.css')))
|
||||
|
||||
|
||||
def source(name):
|
||||
"""One file by name, e.g. 'store.ts' or 'components/Sidebar.vue'."""
|
||||
return (WEB / name).read_text()
|
||||
|
||||
|
||||
def component(name):
|
||||
return source(f'components/{name}.vue')
|
||||
@@ -182,14 +182,13 @@ class Broadcast(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
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"',
|
||||
import editor_sources
|
||||
cheatsheet = editor_sources.component('AlertsView')
|
||||
for marker in ('id="alerts-all"', '["alerts-all", "All screens"]', '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)
|
||||
for marker in ('const allYaml = computed', 'broadcast?.show', 'copyText(allYaml, undefined, \'YAML\')'):
|
||||
self.assertIn(marker, cheatsheet, marker)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -55,13 +55,13 @@ class ReferenceTests(unittest.TestCase):
|
||||
self.assertLessEqual(parse_version(ALERT_MIN_FIRMWARE), parse_version(FIRMWARE_VERSION))
|
||||
|
||||
def test_page_carries_the_cheatsheet(self):
|
||||
html = (STATIC / 'index.html').read_text()
|
||||
import editor_sources
|
||||
html = editor_sources.PAGE
|
||||
for marker in ('id="open-alerts"', 'id="alerts-dialog"', 'id="close-alerts"', 'id="alerts-screens"', 'id="alerts-icons"', 'id="alerts-colors"', 'id="alerts-fields"', 'id="alerts-example"'):
|
||||
self.assertIn(marker, html, marker)
|
||||
script = (STATIC / 'app.js').read_text()
|
||||
for marker in ('inventory.alerts', 'alert_action', 'dismiss_action', 'function renderAlerts', '#open-alerts'):
|
||||
self.assertIn(marker, script, marker)
|
||||
self.assertIn('#alerts-dialog', (STATIC / 'style.css').read_text())
|
||||
for marker in ('inventory.alerts', 'alert_action', 'dismiss_action', 'AlertsView', 'go(\'#alerts\')'):
|
||||
self.assertIn(marker, editor_sources.SCRIPT, marker)
|
||||
self.assertIn('.alert-icon-grid', editor_sources.CSS)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
"""Opening ESP Screens.
|
||||
|
||||
App 0.2.58: the page asks for the full inventory and opens the live stream at the same time. An add-on that is
|
||||
building firmware answers the stream first, whose light payload has screens but no entities, so a screen opened
|
||||
then had an empty catalogue: the entity list said "No entities found" and the tiles showed entity ids until a
|
||||
filter chip was pressed. The first full inventory now draws those parts again.
|
||||
building firmware answers the stream first, whose light payload has screens but no entities. The page keeps the
|
||||
catalogue it has on a light poll and every name on the page follows the inventory, so the first full inventory
|
||||
fills in what was missing.
|
||||
|
||||
App 0.2.65: the page no longer opens the first screen by itself; the owner picks one. Until then a card asks for
|
||||
that, or offers the install when there are no screens yet. A screen picked before the full inventory arrives
|
||||
still gets its entity names from the redraw above.
|
||||
that, or offers the install when there are no screens yet.
|
||||
|
||||
App 0.2.74: the page is the Vue app in web/; these tests read its source (tests/editor_sources.py).
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
PAGE = (ROOT / 'screen_manager/app/static/index.html').read_text()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import editor_sources # noqa: E402
|
||||
|
||||
|
||||
def function(name):
|
||||
start = SCRIPT.index(f'function {name}(')
|
||||
return SCRIPT[start:SCRIPT.index('\n}\n', start)]
|
||||
SCRIPT = editor_sources.SCRIPT
|
||||
STORE = editor_sources.source('store.ts')
|
||||
|
||||
|
||||
class Startup(unittest.TestCase):
|
||||
@@ -28,33 +27,28 @@ class Startup(unittest.TestCase):
|
||||
self.assertNotIn('inventory.screens[0]', SCRIPT)
|
||||
# The one way into a screen is its button in the list.
|
||||
self.assertEqual(re.findall(r'(?<![\w.])(?<!function )select\(([^)]*)\)', SCRIPT), ['screen.id'])
|
||||
self.assertIn('@click="select(screen.id)"', editor_sources.component('Sidebar'))
|
||||
for name in ('refresh', 'applyLive'):
|
||||
self.assertNotRegex(function(name), r'(?<![\w.])select\(', name)
|
||||
body = STORE[STORE.index(f'function {name}('):]
|
||||
self.assertNotRegex(body[:body.index('\n}\n')], r'(?<![\w.])select\(', name)
|
||||
|
||||
def test_the_right_side_asks_for_a_screen_until_one_is_chosen(self):
|
||||
choose = re.search(r'<section id="choose" class="empty" hidden>(.*?)</section>', PAGE, re.S)
|
||||
self.assertTrue(choose, 'hidden until the first inventory')
|
||||
empty = editor_sources.component('EmptyState')
|
||||
choose = re.search(r'<section v-if="state.inventory.screens.length" id="choose" class="empty">(.*?)</section>', empty, re.S)
|
||||
self.assertTrue(choose, 'the card asks for a screen while there are screens')
|
||||
self.assertIn('<h2>Choose a screen</h2>', choose[1])
|
||||
self.assertNotIn('<button', choose[1], 'the list beside it is the choice')
|
||||
self.assertIn('<section id="empty" class="empty" hidden>', PAGE, 'no install card flashing while the page loads')
|
||||
self.assertIn('<section id="editor" hidden>', PAGE)
|
||||
body = function('renderScreens')
|
||||
self.assertIn('if (!selected) {\n $("#choose").hidden = !inventory.screens.length;\n'
|
||||
' $("#empty").hidden = !!inventory.screens.length;\n }', body)
|
||||
self.assertLess(body.index('$("#choose").hidden'), body.index('if ($("#screens .screen-host"))'),
|
||||
'also while an address form keeps the list')
|
||||
opened = function('select')
|
||||
for part in ('$("#editor").hidden = false;', '$("#empty").hidden = true;', '$("#choose").hidden = true;'):
|
||||
self.assertIn(part, opened)
|
||||
self.assertIn('id="empty" class="empty"', empty, 'the install card when there are no screens yet')
|
||||
self.assertIn('id="start"', empty)
|
||||
app = editor_sources.source('App.vue')
|
||||
self.assertIn('currentScreen.value && state.layout ? ScreenView : EmptyState', app)
|
||||
|
||||
def test_the_first_full_inventory_draws_what_needs_entities_again(self):
|
||||
body = function('refresh')
|
||||
first = body.index('const firstCatalogue = full && !inventory.entities?.length;')
|
||||
self.assertLess(first, body.index('inventory = full ? data'), 'decided before the catalogue is replaced')
|
||||
redraw = re.search(r'if \(firstCatalogue && selected && layout && !drag\.active && !chipDrag\.active\) '
|
||||
r'\{ renderTopbar\(\); renderTiles\(\); renderResults\(\); \}', body)
|
||||
self.assertTrue(redraw, 'tiles, top bar and entity list are drawn again, never in the middle of a drag')
|
||||
self.assertRegex(function('select'), r'renderTopbar\(\);\s+renderTiles\(\);\s+renderResults\(\);')
|
||||
def test_a_light_poll_keeps_the_catalogue_and_names_follow_the_inventory(self):
|
||||
self.assertIn('state.inventory = full ? data : { ...state.inventory, ...data };', STORE)
|
||||
self.assertIn('inventory?light=1', STORE)
|
||||
# Names come from the inventory at render time, so they appear as soon as the full inventory does.
|
||||
self.assertIn('state.inventory.entities.find((e) => e.id === id)?.name', STORE)
|
||||
self.assertIn('entityName(props.tile.entity)', editor_sources.component('TileCard'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -17,7 +17,7 @@ from core import FIRMWARE_VERSION, HEADER_MIN_FIRMWARE, discover, header_items,
|
||||
|
||||
FIRMWARE = (ROOT / 'components/smart_display/header_bar.h').read_text()
|
||||
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
|
||||
EDITOR = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
EDITOR = (ROOT / 'web/src/model/topbar.ts').read_text()
|
||||
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml')
|
||||
|
||||
def state(value, last_changed='2026-09-13T14:00:00+00:00', **attributes):
|
||||
|
||||
+23
-11
@@ -1,11 +1,11 @@
|
||||
"""The page loads its own script and styles by content stamp (app 0.2.58).
|
||||
"""The page loads its own script and styles by content hash (app 0.2.58, Vite build since 0.2.74).
|
||||
|
||||
After updating to 0.2.57, Safari showed an empty Screen settings panel: it kept a cached script from before
|
||||
the update. The page now asks for app.js and style.css with a stamp of their content, so a new version is
|
||||
always fetched and an old one is never combined with a new page.
|
||||
the update. Vite names every built file after a hash of its content and index.html asks for those names, so a
|
||||
new version is always fetched and an old one is never combined with a new page.
|
||||
"""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -15,28 +15,40 @@ STATIC = ROOT / 'screen_manager/app/static'
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
|
||||
|
||||
class Build(unittest.TestCase):
|
||||
def test_the_build_is_committed_and_names_its_files_by_hash(self):
|
||||
page = (STATIC / 'index.html').read_text()
|
||||
script = re.search(r'<script type="module" crossorigin src="\./(assets/index-[\w-]+\.js)"></script>', page)
|
||||
styles = re.search(r'<link rel="stylesheet" crossorigin href="\./(assets/index-[\w-]+\.css)">', page)
|
||||
self.assertTrue(script and styles, 'index.html is the Vite build of web/ (cd web && npm run build)')
|
||||
for name in (script[1], styles[1]):
|
||||
self.assertTrue((STATIC / name).exists(), name)
|
||||
self.assertFalse((STATIC / 'app.js').exists(), 'the old script is gone')
|
||||
self.assertEqual(len(list((STATIC / 'assets').glob('index-*.js'))), 1, 'one build at a time: npm run build clears assets/')
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Assets(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_script_and_styles_carry_a_stamp_of_their_content(self):
|
||||
async def test_the_server_serves_the_page_and_its_hashed_assets(self):
|
||||
import sys
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
stamp = hashlib.sha1((STATIC / 'app.js').read_bytes() + (STATIC / 'style.css').read_bytes()).hexdigest()[:12]
|
||||
page = (STATIC / 'index.html').read_text()
|
||||
script = re.search(r'src="\./(assets/index-[\w-]+\.js)"', page)[1]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
response = await client.get('/')
|
||||
page = await response.text()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn(f'<script src="static/app.js?v={stamp}" defer></script>', page)
|
||||
self.assertIn(f'<link rel="stylesheet" href="static/style.css?v={stamp}" />', page)
|
||||
self.assertEqual(await response.text(), page)
|
||||
self.assertEqual(response.headers['Cache-Control'], 'no-store')
|
||||
self.assertIn('text/html', response.headers['Content-Type'])
|
||||
script = await client.get(f'/static/app.js?v={stamp}')
|
||||
self.assertEqual((script.status, await script.read()), (200, (STATIC / 'app.js').read_bytes()))
|
||||
asset = await client.get(f'/{script}')
|
||||
self.assertEqual((asset.status, await asset.read()), (200, (STATIC / script).read_bytes()))
|
||||
self.assertEqual((await client.get('/static/app.js')).status, 404)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -545,21 +545,23 @@ class SettingsRoute(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
class Editor(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()
|
||||
import editor_sources
|
||||
self.html = editor_sources.PAGE
|
||||
self.script = editor_sources.SCRIPT
|
||||
self.css = editor_sources.CSS
|
||||
self.tab = editor_sources.component('SettingsTab')
|
||||
|
||||
def test_the_panel_replaces_the_form(self):
|
||||
self.assertIn('<section class="screen-settings" id="general-settings"', self.html)
|
||||
self.assertIn('<div class="settings" id="general-settings"', self.tab)
|
||||
for element in ('settings-groups', 'settings-status'):
|
||||
self.assertEqual(self.html.count(f'id="{element}"'), 1)
|
||||
self.assertNotIn('settings-fields', self.html + self.script)
|
||||
self.assertNotIn('settingDefinitions', self.script)
|
||||
for rule in ('.settings-card', '.setting-row', '.switch[aria-checked="true"]', '.switch.unknown::after', '.step', '.setting-value'):
|
||||
for rule in ('.set-card', '.srow', '.switch[aria-checked="true"]', '.switch.unknown::after', '.step', '.step output'):
|
||||
self.assertIn(rule, self.css)
|
||||
# A value Home Assistant does not have (offline, entity off) shows as unknown, not as a default.
|
||||
self.assertIn('if (value === null || value === undefined) return "—";', self.script)
|
||||
self.assertIn('refs.toggle.classList.toggle("unknown", values[key] === null || values[key] === undefined);', self.script)
|
||||
self.assertIn(':class="{ unknown: values[row.key] === null || values[row.key] === undefined }"', self.tab)
|
||||
|
||||
def test_the_rows_are_the_settings_of_the_screen_page(self):
|
||||
keys = re.findall(r'\{ key: "(\w+)", label: "[^"]+", kind: "(\w+)"', self.script)
|
||||
@@ -567,21 +569,21 @@ class Editor(unittest.TestCase):
|
||||
self.assertEqual({key for key, _ in keys}, set(SETTING_RULES) - {'show_clock'})
|
||||
# Labels and steps as on the screen: the same rows, the same -/+ steps, the same duration ladder.
|
||||
page_labels = set(re.findall(r'(?:number|toggle|duration|moment|choice)\("([^"]+)"', SCREEN_PAGE))
|
||||
groups = self.script[self.script.index('const SETTING_GROUPS = ['):self.script.index('const SETTING_ROWS =')]
|
||||
groups = self.script[self.script.index('const SETTING_GROUPS = ['):self.script.index('export type SettingRow')]
|
||||
script_labels = set(re.findall(r'label: "([^"]+)"', groups))
|
||||
self.assertEqual(page_labels, script_labels)
|
||||
for key, step in (('brightness', 5), ('standby_brightness', 5), ('night_brightness', 5)):
|
||||
self.assertRegex(self.script, rf'key: "{key}", [^}}]*step: {step}')
|
||||
ladder = re.search(r'if \(seconds < 300\) return 30;\s+if \(seconds < 900\) return 60;\s+if \(seconds < 3600\) return 300;\s+'
|
||||
r'if \(seconds < 7200\) return 900;\s+return 1800;', SCREEN_PAGE)
|
||||
self.assertTrue(ladder, 'the ladder in settings_screen.h changed: change ladderStep in app.js with it')
|
||||
self.assertTrue(ladder, 'the ladder in settings_screen.h changed: change ladderStep in web/src/store.ts with it')
|
||||
self.assertIn('seconds < 300 ? 30 : seconds < 900 ? 60 : seconds < 3600 ? 300 : seconds < 7200 ? 900 : 1800', self.script)
|
||||
|
||||
def test_settings_have_their_own_call_and_save_leaves_them_out(self):
|
||||
self.assertIn('api(`screens/${encodeURIComponent(screen)}/settings`', self.script)
|
||||
self.assertIn('const { settings: _settings, ...tiles } = layout;', self.script)
|
||||
self.assertIn('body: JSON.stringify(tiles)', self.script)
|
||||
self.assertIn('setSetting("clock_24h", v === "24", 150);', self.script, 'the top bar clock format is the same setting')
|
||||
self.assertIn('const { settings: _settings, ...tiles } = state.layout;', self.script)
|
||||
self.assertIn('await send(`screens/${encodeURIComponent(state.selected)}`, "PUT", tiles);', self.script)
|
||||
self.assertIn("setSetting('clock_24h', v === '24', 150)", self.script, 'the top bar clock format is the same setting')
|
||||
|
||||
|
||||
class Firmware(unittest.TestCase):
|
||||
|
||||
+34
-32
@@ -1,51 +1,53 @@
|
||||
"""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
|
||||
page into a view of their own. App 0.2.74: the sidebar carries the way in (New screen, Firmware & USB, Alerts,
|
||||
Settings), each once; the Settings page keeps the updates and Claude."""
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
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]
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import editor_sources # noqa: E402
|
||||
|
||||
|
||||
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()
|
||||
self.sidebar = editor_sources.component('Sidebar')
|
||||
self.settings = editor_sources.component('AppSettingsView')
|
||||
self.script = editor_sources.SCRIPT
|
||||
self.css = editor_sources.CSS
|
||||
|
||||
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_the_sidebar_is_the_one_way_to_the_tools(self):
|
||||
more = self.sidebar.split('<div class="more">', 1)[1]
|
||||
self.assertEqual(re.findall(r'<button id="([\w-]+)"', more), ['open-firmware', 'open-alerts', 'open-settings', 'refresh'])
|
||||
self.assertEqual(self.sidebar.count('id="new-screen"'), 1)
|
||||
for element in ('new-screen', 'open-firmware', 'open-alerts', 'open-settings'):
|
||||
self.assertEqual(editor_sources.PAGE.count(f'id="{element}"'), 1, element)
|
||||
|
||||
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)
|
||||
for element in ('updates', 'update-all', 'auto-update', 'claude-install', 'claude-status', 'claude-path', 'claude-download', 'close-settings'):
|
||||
self.assertEqual(editor_sources.PAGE.count(f'id="{element}"'), 1, element)
|
||||
self.assertIn(f'id="{element}"', self.settings, element)
|
||||
# The first-run button stays where a new user starts.
|
||||
self.assertIn('id="start"', home)
|
||||
self.assertIn('href="api/claude-skill.zip"', settings)
|
||||
self.assertIn('id="start"', editor_sources.component('EmptyState'))
|
||||
self.assertIn('href="api/claude-skill.zip"', self.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)'):
|
||||
store = editor_sources.source('store.ts')
|
||||
for marker in ('export const routes = ["", "#settings", "#new-screen", "#firmware", "#alerts", "#override"] as const;',
|
||||
'window.addEventListener("hashchange"', 'export async function installClaudeSkill', 'send("claude-skill", "POST")'):
|
||||
self.assertIn(marker, store, marker)
|
||||
app = editor_sources.source('App.vue')
|
||||
for marker in ('route.value === "#settings") return AppSettingsView', 'route.value === "#new-screen") return InstallerView',
|
||||
'route.value === "#firmware") return FirmwareView', 'route.value === "#alerts") return AlertsView'):
|
||||
self.assertIn(marker, app, marker)
|
||||
for marker in ('.side', '.nav-item[aria-current="true"]', '.panel', '.card'):
|
||||
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 {', '}'))
|
||||
def test_the_chosen_screen_is_marked_in_the_list(self):
|
||||
self.assertIn(':aria-current="screen.id === state.selected && route === \'\' ? \'true\' : \'false\'"', self.sidebar)
|
||||
rule = self.css.split('\n.nav-item[aria-current="true"] {', 1)[1].split('}', 1)[0]
|
||||
self.assertIn('background: var(--seg)', rule)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -220,16 +220,19 @@ class SavingAndTheEditor(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class Editor(unittest.TestCase):
|
||||
SCRIPT = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
def setUp(self):
|
||||
import editor_sources
|
||||
self.SCRIPT = editor_sources.SCRIPT
|
||||
self.PICKER = editor_sources.component('ActionPicker')
|
||||
|
||||
def test_perform_action_is_a_tap_choice_with_home_assistants_list(self):
|
||||
self.assertIn('taps.push(["action", "Perform action"]);', self.SCRIPT)
|
||||
self.assertIn('api(`entity-actions?entity=${encodeURIComponent(entity)}`)', self.SCRIPT)
|
||||
self.assertIn('if (tap === "action") body.append(actionPicker(tile));', self.SCRIPT)
|
||||
self.assertIn('list.push(["action", "Perform action"]);', self.SCRIPT)
|
||||
self.assertIn('getJson(`entity-actions?entity=${encodeURIComponent(entity)}`)', self.SCRIPT)
|
||||
self.assertIn('<ActionPicker v-if="domain !== \'screen\' && tap === \'action\'" :tile="tile" />', self.SCRIPT)
|
||||
# Fields follow Home Assistant's selectors; an empty field is left out of the data.
|
||||
for marker in ('kind === "boolean"', 'kind === "number" || kind === "color_temp"', 'config.options', 'delete data[field.key]'):
|
||||
self.assertIn(marker, self.SCRIPT)
|
||||
self.assertIn('supportsFirmware(0, 2, 58)', self.SCRIPT)
|
||||
for marker in ("kindOf(field) === 'boolean'", "kindOf(field) === 'number' || kindOf(field) === 'color_temp'", 'config.options', 'delete data[key]'):
|
||||
self.assertIn(marker, self.PICKER, marker)
|
||||
self.assertIn('supports(0, 2, 58)', self.PICKER)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ class Setting(unittest.TestCase):
|
||||
page = (COMPONENT / 'settings_screen.h').read_text()
|
||||
self.assertIn('toggle("Dark mode", []() -> int32_t { return dark_mode; },', page)
|
||||
self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page)
|
||||
editor = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
editor = (ROOT / 'web/src/store.ts').read_text()
|
||||
self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor)
|
||||
brightness = editor.split('{ title: "Brightness"', 1)[1].split('] },', 1)[0]
|
||||
self.assertLess(brightness.index('"brightness"'), brightness.index('"dark_mode"'), 'right under Brightness, as on the screen')
|
||||
|
||||
@@ -28,7 +28,7 @@ class IconSetTests(unittest.TestCase):
|
||||
block = text.split('glyphs: &tile_icons', 1)[1].split('\n\n', 1)[0]
|
||||
self.assertEqual(re.findall(r'- "(\\U000F[0-9A-F]{4})"', block), wanted, name)
|
||||
self.assertNotIn('MDI_GLYPH', text, name)
|
||||
self.assertTrue((ROOT / 'screen_manager/app/static/tile-icons.woff').exists())
|
||||
self.assertTrue((ROOT / 'web/src/assets/tile-icons.woff').exists())
|
||||
|
||||
def test_firmware_name_table_mirrors_the_set(self):
|
||||
header = (ROOT / 'components/smart_display/tile_icon_names.h').read_text()
|
||||
|
||||
@@ -24,8 +24,8 @@ import tile_icons # noqa: E402
|
||||
|
||||
TTF = ROOT / 'fonts/materialdesignicons-webfont.ttf'
|
||||
PROFILES = [ROOT / 'home-like-2432s028.yaml', ROOT / 'guition-4848s040.yaml']
|
||||
WEB_FONT = ROOT / 'screen_manager/app/static/tile-icons.woff'
|
||||
BAR_FONTS = {weight: ROOT / f'screen_manager/app/static/bar-roboto-{weight}.woff' for weight in (400, 500)}
|
||||
WEB_FONT = ROOT / 'web/src/assets/tile-icons.woff'
|
||||
BAR_FONTS = {weight: ROOT / f'web/src/assets/bar-roboto-{weight}.woff' for weight in (400, 500)}
|
||||
NAME_TABLE = ROOT / 'components/smart_display/tile_icon_names.h'
|
||||
FONT_BLOCK = re.compile(r"( - file: 'fonts/materialdesignicons-webfont\.ttf'\n id: \w+\n size: \d+\n) glyphs:.*\n(?: .*\n)*")
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>ESP Screens</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1456
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "esp-screens-editor",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "rm -rf ../screen_manager/app/static/assets && vite build",
|
||||
"check": "vue-tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vue-tsc": "^3.0.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import Sidebar from "./components/Sidebar.vue";
|
||||
import Toast from "./components/Toast.vue";
|
||||
import ScreenView from "./components/ScreenView.vue";
|
||||
import EmptyState from "./components/EmptyState.vue";
|
||||
import AppSettingsView from "./components/AppSettingsView.vue";
|
||||
import InstallerView from "./components/InstallerView.vue";
|
||||
import FirmwareView from "./components/FirmwareView.vue";
|
||||
import AlertsView from "./components/AlertsView.vue";
|
||||
import OverrideView from "./components/OverrideView.vue";
|
||||
import { currentScreen, route, state } from "./store";
|
||||
|
||||
const view = computed(() => {
|
||||
if (route.value === "#settings") return AppSettingsView;
|
||||
if (route.value === "#new-screen") return InstallerView;
|
||||
if (route.value === "#firmware") return FirmwareView;
|
||||
if (route.value === "#alerts") return AlertsView;
|
||||
if (route.value === "#override") return OverrideView;
|
||||
return currentScreen.value && state.layout ? ScreenView : EmptyState;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app" :class="{ dragging: state.drag.active }">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<component :is="view" />
|
||||
</main>
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
// Relative URLs on purpose: the page lives under Home Assistant's ingress path
|
||||
// (/api/hassio_ingress/<token>/) and on http://127.0.0.1:8099 in SCREEN_DEV; `api/...`
|
||||
// resolves against the page in both. Writes carry the CSRF token the inventory handed out.
|
||||
let csrf = "";
|
||||
export function setCsrf(token: string) {
|
||||
csrf = token || "";
|
||||
}
|
||||
|
||||
export async function api(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const response = await fetch(`api/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Screen-CSRF": csrf,
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = "That didn't work. Refresh the page and try again.";
|
||||
try {
|
||||
message = (await response.json()).error || message;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const getJson = async <T = any>(path: string): Promise<T> => (await api(path)).json();
|
||||
export const send = async <T = any>(path: string, method: string, body?: unknown, extra: RequestInit = {}): Promise<T> => {
|
||||
const response = await api(path, { method, body: body === undefined ? undefined : JSON.stringify(body), ...extra });
|
||||
const text = await response.text();
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
// Perform action (app 0.2.67): the actions Home Assistant offers for a tile's entity, under its own names,
|
||||
// and one field per value Home Assistant asks for (its selector). An empty field is left out.
|
||||
import { computed } from "vue";
|
||||
import { loadEntityActions, markDirty, state, supports } from "../store";
|
||||
import type { EntityAction, Tile } from "../types";
|
||||
import Segmented from "./Segmented.vue";
|
||||
|
||||
const props = defineProps<{ tile: Tile }>();
|
||||
const list = computed(() => state.entityActions[props.tile.entity]);
|
||||
const chosen = computed(() => props.tile.options?.action);
|
||||
const entry = computed(() => list.value?.find((a) => a.action === chosen.value?.action));
|
||||
const open = computed(() => state.actionPickerOpen || !chosen.value);
|
||||
const rows = computed(() => {
|
||||
const q = state.actionSearch.trim().toLocaleLowerCase();
|
||||
return (list.value || []).filter((a) => `${a.name} ${a.action} ${a.description}`.toLocaleLowerCase().includes(q));
|
||||
});
|
||||
if (list.value === undefined) loadEntityActions(props.tile.entity);
|
||||
|
||||
function pick(action: EntityAction) {
|
||||
const same = action.action === chosen.value?.action;
|
||||
props.tile.options = { ...props.tile.options, action: same ? chosen.value : { action: action.action } };
|
||||
state.actionPickerOpen = false;
|
||||
markDirty();
|
||||
}
|
||||
function setField(key: string, value: unknown) {
|
||||
const data: Record<string, unknown> = { ...(props.tile.options?.action?.data || {}) };
|
||||
if (value === undefined) delete data[key];
|
||||
else data[key] = value;
|
||||
props.tile.options = { ...props.tile.options, action: { action: chosen.value!.action, ...(Object.keys(data).length ? { data } : {}) } };
|
||||
markDirty();
|
||||
}
|
||||
const missing = computed(() => {
|
||||
if (!entry.value) return [];
|
||||
const data = props.tile.options?.action?.data || {};
|
||||
return entry.value.fields.filter((f) => f.required && data[f.key] === undefined).map((f) => f.name);
|
||||
});
|
||||
type Field = EntityAction["fields"][number];
|
||||
const kindOf = (field: Field) => Object.keys(field.selector || {})[0] || "text";
|
||||
const configOf = (field: Field) => (field.selector || {})[kindOf(field)] || {};
|
||||
const exampleOf = (field: Field) => (field.example === undefined ? "" : typeof field.example === "string" ? field.example : JSON.stringify(field.example));
|
||||
// A select's own options, or the values this entity has where Home Assistant asks for one of them (a source, an effect).
|
||||
function choicesOf(field: Field): [string, string][] | null {
|
||||
const config = configOf(field), kind = kindOf(field);
|
||||
if (Array.isArray(field.options)) return field.options.map((o) => [o, o]);
|
||||
if (kind === "select" && Array.isArray(config.options) && !config.multiple)
|
||||
return config.options.map((o: any) => (o && typeof o === "object" ? [String(o.value), String(o.label ?? o.value)] : [String(o), String(o)]));
|
||||
return null;
|
||||
}
|
||||
const hex = (rgb: number[]) => "#" + rgb.map((c) => Math.max(0, Math.min(255, Number(c) || 0)).toString(16).padStart(2, "0")).join("");
|
||||
const colorValue = (value: unknown) => (Array.isArray(value) && value.length === 3 ? hex(value as number[]) : "#ffffff");
|
||||
const textValue = (value: unknown) => (value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value));
|
||||
function onText(field: Field, raw: string) {
|
||||
const text = raw.trim();
|
||||
if (!text) return setField(field.key, undefined);
|
||||
if (kindOf(field) === "text") return setField(field.key, raw);
|
||||
try { setField(field.key, JSON.parse(text)); } catch { setField(field.key, raw); }
|
||||
}
|
||||
function onNumber(field: Field, raw: string) {
|
||||
setField(field.key, raw === "" || !Number.isFinite(Number(raw)) ? undefined : Number(raw));
|
||||
}
|
||||
const unitOf = (field: Field) => configOf(field).unit_of_measurement || (kindOf(field) === "color_temp" ? configOf(field).unit || "" : "");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Action</span>
|
||||
<small v-if="list === undefined">Asking Home Assistant which actions this entity has…</small>
|
||||
<small v-else-if="list === null" class="warn">Home Assistant isn't answering right now. Try again in a moment.</small>
|
||||
<template v-else>
|
||||
<button type="button" class="row" :aria-expanded="open ? 'true' : 'false'" @click="state.actionPickerOpen = !open">
|
||||
<span class="tx">
|
||||
<b>{{ entry ? entry.name : chosen ? chosen.action : "Choose an action" }}</b>
|
||||
<small v-if="chosen" class="mono">{{ chosen.action }}</small>
|
||||
</span>
|
||||
<span class="link">{{ open ? "Close" : "Change" }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="picker">
|
||||
<input v-model="state.actionSearch" type="search" placeholder="Search, for example toggle, position, or play" aria-label="Search for an action" />
|
||||
<div class="action-list">
|
||||
<button v-for="action in rows" :key="action.action" type="button" class="action-choice" :aria-pressed="action.action === chosen?.action ? 'true' : 'false'" @click="pick(action)">
|
||||
<strong>{{ action.name }}</strong>
|
||||
<small>{{ action.description ? `${action.action} · ${action.description}` : action.action }}</small>
|
||||
</button>
|
||||
<p v-if="!rows.length" class="hint">No action found.</p>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="chosen && !entry" class="warn">Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.</small>
|
||||
<template v-if="entry && !open">
|
||||
<div v-for="field in entry.fields" :key="field.key" class="f">
|
||||
<span class="f-label">{{ field.required ? field.name : `${field.name} (optional)` }}</span>
|
||||
<Segmented v-if="kindOf(field) === 'boolean'" :choices="[['', 'Not set'], ['true', 'On'], ['false', 'Off']]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
@pick="(v) => setField(field.key, v === '' ? undefined : v === 'true')" />
|
||||
<Segmented v-else-if="choicesOf(field) && choicesOf(field)!.length <= 4" :choices="[['', 'Not set'], ...choicesOf(field)!]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
@pick="(v) => setField(field.key, v === '' ? undefined : v)" />
|
||||
<select v-else-if="choicesOf(field)" :aria-label="field.name" :value="String(chosen?.data?.[field.key] ?? '')" @change="setField(field.key, ($event.target as HTMLSelectElement).value === '' ? undefined : ($event.target as HTMLSelectElement).value)">
|
||||
<option value="">Not set</option>
|
||||
<option v-for="[key, text] in choicesOf(field)!" :key="key" :value="key">{{ text }}</option>
|
||||
</select>
|
||||
<div v-else-if="kindOf(field) === 'color_rgb'" class="action-number">
|
||||
<input type="color" :aria-label="field.name" :value="colorValue(chosen?.data?.[field.key])" @input="setField(field.key, [1, 3, 5].map((i) => parseInt(($event.target as HTMLInputElement).value.slice(i, i + 2), 16)))" />
|
||||
<button type="button" class="btn quiet mini" @click="setField(field.key, undefined)">Not set</button>
|
||||
</div>
|
||||
<div v-else-if="kindOf(field) === 'number' || kindOf(field) === 'color_temp'" class="action-number">
|
||||
<input type="number" :aria-label="field.name" :min="configOf(field).min" :max="configOf(field).max" :step="configOf(field).step === 'any' ? 'any' : configOf(field).step"
|
||||
:value="chosen?.data?.[field.key] ?? ''" :placeholder="exampleOf(field)" @input="onNumber(field, ($event.target as HTMLInputElement).value)" />
|
||||
<span v-if="unitOf(field)">{{ unitOf(field) }}</span>
|
||||
</div>
|
||||
<input v-else type="text" :aria-label="field.name" :value="textValue(chosen?.data?.[field.key])" :placeholder="exampleOf(field)" @input="onText(field, ($event.target as HTMLInputElement).value)" />
|
||||
<small v-if="field.description">{{ field.description }}</small>
|
||||
</div>
|
||||
<small v-if="missing.length" class="warn">Home Assistant needs {{ missing.join(", ") }}.</small>
|
||||
</template>
|
||||
<small v-if="!supports(0, 2, 58)">The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic.</small>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
// Alerts: the cheatsheet for esphome.<node>_show_alert, built from the inventory.
|
||||
import { computed, ref } from "vue";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, state } from "../store";
|
||||
|
||||
const alerts = computed(() => state.inventory.alerts);
|
||||
const icons = computed(() => state.inventory.icons);
|
||||
const exampleAction = ref("");
|
||||
const iconQuery = ref("");
|
||||
const yamlString = (text: unknown) => `"${String(text).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
const fieldValue = (f: any) => f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : f.example === true ? "true" : f.example === false ? "false" : String(f.example);
|
||||
const screensWithAction = computed(() => state.inventory.screens.filter((s) => s.alert_action));
|
||||
const chosenAction = computed(() => exampleAction.value || screensWithAction.value[0]?.alert_action || "");
|
||||
const exampleYaml = computed(() => {
|
||||
const lines = (alerts.value?.fields || []).map((f: any) => ` ${f.name}: ${fieldValue(f)}`);
|
||||
return `action: ${chosenAction.value || "esphome.<device_name>_show_alert"}\ndata:\n${lines.join("\n")}`;
|
||||
});
|
||||
const waitYaml = computed(() => [
|
||||
`# Doorbell: show the alert and wait until someone presses the button.`,
|
||||
`actions:`,
|
||||
` - action: ${chosenAction.value || "esphome.<device_name>_show_alert"}`,
|
||||
` data:`,
|
||||
` title: "Someone is at the door"`,
|
||||
` subtitle: "Door 3, back"`,
|
||||
` icon: doorbell`,
|
||||
` color: orange`,
|
||||
` button_text: "Coming"`,
|
||||
` timeout: 0`,
|
||||
` flash: true`,
|
||||
` - wait_for_trigger:`,
|
||||
` - trigger: event`,
|
||||
` event_type: ${alerts.value?.event || "esphome.screen_alert"}`,
|
||||
` event_data:`,
|
||||
` action: ok`,
|
||||
` timeout: "00:05:00"`,
|
||||
` - if:`,
|
||||
` - condition: template`,
|
||||
` value_template: "{{ wait.trigger is not none }}"`,
|
||||
` then:`,
|
||||
` - action: notify.notify`,
|
||||
` data:`,
|
||||
` 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.
|
||||
const allYaml = computed(() => {
|
||||
const lines = (alerts.value?.fields || []).map((f: any) => ` ${f.name}: ${fieldValue(f)}`);
|
||||
const camera = alerts.value?.camera;
|
||||
if (camera) lines.push(` # ${camera.name}: ${camera.example} # a Guition shows its picture on the card`);
|
||||
return `event: ${alerts.value?.broadcast?.show || "esp_screens_show_alert"}\nevent_data:\n${lines.join("\n")}`;
|
||||
});
|
||||
const iconGroups = computed(() => {
|
||||
if (!alerts.value || !icons.value) return [];
|
||||
const query = iconQuery.value.trim().toLowerCase();
|
||||
const all = [...icons.value.groups, { label: "Also available: control and weather icons", icons: alerts.value.extra_icons }];
|
||||
return all.map((group) => ({ label: group.label, icons: group.icons.filter((i: any) => !query || i.name.includes(query) || (i.label || "").toLowerCase().includes(query)) })).filter((g) => g.icons.length);
|
||||
});
|
||||
const doorbell = computed(() => alerts.value?.suggested_icons?.find((i: any) => i.name === "doorbell"));
|
||||
const orange = computed(() => alerts.value?.colors?.find((c: any) => c.name === "orange"));
|
||||
const types: Record<string, string> = { string: "text", int: "number", bool: "on / off" };
|
||||
function jump(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
|
||||
const sections = [["alerts-screens", "Your screens"], ["alerts-howto", "In an automation"], ["alerts-all", "All screens"], ["alerts-fields", "The fields"], ["alerts-icons", "Icons"], ["alerts-colors", "Colors"], ["alerts-behaviour", "Behavior"], ["alerts-events", "Events"], ["alerts-tips", "Tips"]];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel wide alerts" id="alerts-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Alerts · cheatsheet</span>
|
||||
<h1>A full-screen notification, from an automation.</h1>
|
||||
<p>Every screen has the <code>show_alert</code> action. It places a card over everything, wakes the screen, and keeps the backlight at normal brightness until someone taps the button or the timeout runs out. Home Assistant itself shows no explanation or dropdowns for ESPHome actions; everything you can fill in is listed here.</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-alerts" @click="go('')">← Back</button>
|
||||
</div>
|
||||
<p v-if="!alerts" class="hint">The cheatsheet is still loading; one moment.</p>
|
||||
<template v-else>
|
||||
<div class="alert-hero">
|
||||
<div class="alert-mock" id="alert-mock" aria-hidden="true">
|
||||
<div class="alert-mock-card" :style="orange ? { background: orange.color } : undefined">
|
||||
<span class="mdi alert-mock-icon" id="alert-mock-icon">{{ doorbell ? glyph(doorbell.cp) : "" }}</span>
|
||||
<strong id="alert-mock-title">Someone is at the door</strong>
|
||||
<span id="alert-mock-subtitle">Door 3, back</span>
|
||||
<span class="fake" id="alert-mock-button">Coming</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="alerts-nav" aria-label="Sections">
|
||||
<button v-for="[id, label] in sections" :key="id" type="button" :data-jump="id" @click="jump(id)">{{ label }}</button>
|
||||
</nav>
|
||||
</div>
|
||||
<section id="alerts-screens" class="card">
|
||||
<h2>Your screens</h2>
|
||||
<p>Every screen has its own action, with the device name in it. From firmware <code id="alerts-min-firmware">{{ alerts.min_firmware }}</code>; update an older screen with its Update button under Screens.</p>
|
||||
<div id="alerts-screen-list" class="options">
|
||||
<p v-if="!state.inventory.screens.length" class="hint">No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.</p>
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="alert-screen">
|
||||
<div class="alert-screen-head">
|
||||
<strong>{{ screen.name }}</strong>
|
||||
<span class="chip" :class="versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
|
||||
{{ versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="[label, action] in [['Show alert', screen.alert_action], ['Dismiss alert', screen.dismiss_action]]" :key="label" class="copy-line">
|
||||
<span class="copy-label">{{ label }}</span><code>{{ action || "esphome.<device_name>_show_alert" }}</code>
|
||||
<button v-if="action" type="button" class="btn quiet mini" @click="copyText(action!, undefined, 'Action name')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-howto" class="card">
|
||||
<h2>In an automation</h2>
|
||||
<ol class="steps">
|
||||
<li><b>Add action</b> and search for <b>ESPHome</b>. Choose your screen's action: Home Assistant calls it "Performs the action show_alert of the node <i>device name</i>".</li>
|
||||
<li><b>Fill in the fields.</b> Home Assistant asks for all seven; leave what you don't use empty (<code>""</code>, <code>0</code>, off).</li>
|
||||
<li>Prefer YAML? Choose <b>Edit in YAML</b> on the action and paste the example below.</li>
|
||||
</ol>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-example-screen">Example for</label>
|
||||
<select id="alerts-example-screen" v-model="exampleAction" style="max-width: 360px">
|
||||
<option v-for="screen in screensWithAction" :key="screen.id" :value="screen.alert_action">{{ screen.name }}</option>
|
||||
<option v-if="!screensWithAction.length" value="">a screen (not paired yet)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-example">{{ exampleYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-example-copy" @click="copyText(exampleYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-all" class="card">
|
||||
<h2>All screens at once</h2>
|
||||
<p>Fire the event <code id="alerts-all-event">{{ alerts.broadcast?.show || "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">{{ alerts.broadcast?.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">{{ allYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-all-copy" @click="copyText(allYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-fields" class="card">
|
||||
<h2>The fields</h2>
|
||||
<p>Text is free-form; the limit is in bytes, and an accented letter counts as two. What doesn't fit gets an ellipsis (title) or is dropped (subtitle).</p>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-field-table">
|
||||
<tr><th>Field</th><th>Type</th><th>What it does</th><th>Example</th><th>Limit</th></tr>
|
||||
<tr v-for="field in alerts.fields" :key="field.name">
|
||||
<td><code>{{ field.name }}</code><small>{{ field.label }}</small></td>
|
||||
<td>{{ types[field.type] || field.type }}</td>
|
||||
<td>{{ field.help }}</td>
|
||||
<td><code>{{ typeof field.example === "string" ? field.example : String(field.example) }}</code></td>
|
||||
<td>{{ alerts.limits.cyd[field.name] ? `CYD ${alerts.limits.cyd[field.name]} · Guition ${alerts.limits.guition[field.name]} bytes` : field.type === "int" ? "0 to 86400 s" : "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="alerts.camera">
|
||||
<td><code>{{ alerts.camera.name }}</code><small>{{ alerts.camera.label }}</small></td>
|
||||
<td>entity</td>
|
||||
<td>{{ alerts.camera.help }}</td>
|
||||
<td><code>{{ alerts.camera.example }}</code></td>
|
||||
<td>Guition, firmware 0.2.57+</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-icons" class="card">
|
||||
<h2>Icons</h2>
|
||||
<p>Fill in the name in <code>icon</code>, for example <code>doorbell</code>. <code>mdi:doorbell</code> and the hex codepoint (<code>F12E6</code>) also work. Only these icons are included in the firmware; any other name shows the warning triangle. Tap an icon to copy its name.</p>
|
||||
<p class="subhead">Handy for notifications</p>
|
||||
<div class="chips" id="alerts-suggested">
|
||||
<button v-for="icon in alerts.suggested_icons" :key="icon.name" type="button" class="chip" @click="copyText(icon.name, undefined, 'Icon name')"><span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code></button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-icon-search">Search for an icon</label>
|
||||
<input id="alerts-icon-search" v-model="iconQuery" type="search" placeholder="Search by name or description…" autocomplete="off" />
|
||||
</div>
|
||||
<div id="alerts-icon-groups">
|
||||
<div v-for="group in iconGroups" :key="group.label" class="field">
|
||||
<p class="subhead">{{ group.label }} · {{ group.icons.length }}</p>
|
||||
<div class="alert-icon-grid">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="alert-icon" :title="`Copy ${icon.name}`" @click="copyText(icon.name, undefined, 'Icon name')">
|
||||
<span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code><small v-if="icon.label">{{ icon.label }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!iconGroups.length" class="hint">No icon for "{{ iconQuery }}". Unknown names show the warning triangle ({{ alerts.fallback_icon }}).</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-colors" class="card">
|
||||
<h2>Colors</h2>
|
||||
<p>Fill in the name in <code>color</code>. These are the pastel shades of the tiles, with dark text. Empty or unknown gives the white card. Tap to copy.</p>
|
||||
<div class="swatches" id="alerts-swatches">
|
||||
<button type="button" class="swatch" @click="copyText('', undefined, 'Empty color')"><i style="background: #ffffff"></i><span><code>empty</code><small>White (default)</small></span></button>
|
||||
<button v-for="colour in alerts.colors" :key="colour.name" type="button" class="swatch" @click="copyText(colour.name, undefined, 'Color name')"><i :style="{ background: colour.color }"></i><span><code>{{ colour.name }}</code><small>{{ colour.label }} · {{ colour.color }}</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-behaviour" class="card">
|
||||
<h2>Behavior</h2>
|
||||
<ul class="alerts-list">
|
||||
<li><b>Wakes the screen.</b> An alert immediately wakes a dimmed or dark screen and appears above every page, every open light card, and the standby layer.</li>
|
||||
<li><b>On until the button.</b> As long as the card is showing, the backlight stays at normal brightness. Standby and night hours wait. After the button, the regular standby time starts again.</li>
|
||||
<li><b>Timeout.</b> <code>timeout: 0</code> waits for the button, however long that takes. With <code>timeout: 60</code> the card disappears after a minute, but the button closes it sooner.</li>
|
||||
<li><b>Blinking.</b> <code>flash: true</code> blinks the backlight four times when the alert arrives (about a second) and leaves the screen on afterward.</li>
|
||||
<li><b>Replacing.</b> A new alert replaces the current one; there's no stack. The replaced alert reports itself as <code>replaced</code>.</li>
|
||||
<li><b>Closing remotely.</b> <code>dismiss_alert</code> clears the card, for example if the door is already open. Without an alert, that action does nothing.</li>
|
||||
<li><b>Tapping beside the card</b> does nothing; only the button closes it. That way a wake-up tap never accidentally dismisses the alert.</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="alerts-events" class="card">
|
||||
<h2>Events</h2>
|
||||
<p>Every end fires the event <code id="alerts-event-name">{{ alerts.event }}</code> with <code>action</code>, <code>title</code>, <code>screen</code> (the device name) and the <code>device_id</code> that Home Assistant adds. Visible under Settings → Tools → Events.</p>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-ending-table">
|
||||
<tr><th>action</th><th>When</th></tr>
|
||||
<tr v-for="ending in alerts.endings" :key="ending.action"><td><code>{{ ending.action }}</code></td><td>{{ ending.label }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<p class="subhead">Waiting until someone presses the button</p>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-wait-example">{{ waitYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-wait-copy" @click="copyText(waitYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-tips" class="card">
|
||||
<h2>Tips</h2>
|
||||
<ul class="alerts-list">
|
||||
<li>Use a <b>color per kind of notification</b>: orange for the door, red for smoke or water, green for "done". The text always stays dark and readable.</li>
|
||||
<li>Keep the title <b>short</b>; a screen shows about twenty characters on one line and ends a longer title with an ellipsis. Put details in the subtitle.</li>
|
||||
<li>A <b>custom button text</b> turns the notification into a question: "Coming", "Seen", "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>Every screen? Fire <code>{{ alerts.broadcast?.show || "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>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
// Everything around the screens: firmware updates, alerts, Claude.
|
||||
import { computed, ref } from "vue";
|
||||
import { go, installClaudeSkill, runUpdateAll, setAutoUpdate, state } from "../store";
|
||||
|
||||
const u = computed(() => state.inventory.updates);
|
||||
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
|
||||
const updatesHint = computed(() => !u.value ? "" : u.value.busy
|
||||
? `Updating to firmware ${u.value.target}…`
|
||||
: !state.inventory.screens.length
|
||||
? `No screen paired yet. New screens get firmware ${u.value.target}.`
|
||||
: outdated.value
|
||||
? `Firmware ${u.value.target} is available for ${outdated.value} screen${outdated.value === 1 ? "" : "s"}.`
|
||||
: `All screens have firmware ${u.value.target}.`);
|
||||
const skill = computed(() => state.inventory.claude_skill);
|
||||
const installing = ref(false);
|
||||
async function install() {
|
||||
installing.value = true;
|
||||
try { await installClaudeSkill(); } finally { installing.value = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="settings-view">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Settings</span>
|
||||
<h1 id="settings-title">Everything around your screens.</h1>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-settings" @click="go('')">← My screens</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<section class="card">
|
||||
<h2>Screens</h2>
|
||||
<p>Add a screen, or build and reinstall the firmware of one you already have.</p>
|
||||
<div class="tools">
|
||||
<button type="button" class="tool" @click="go('#new-screen')"><span class="tool-icon">+</span><span class="tx"><b>New screen</b><small>Connect and install</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#firmware')"><span class="tool-icon">⇪</span><span class="tx"><b>Firmware & USB</b><small>Build and install via USB or Wi-Fi</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="u" class="card updates" id="updates">
|
||||
<h2>Firmware updates</h2>
|
||||
<p id="updates-hint">{{ updatesHint }}</p>
|
||||
<button v-if="u.pending && !u.busy && u.pending >= 2" id="update-all" type="button" class="btn primary" @click="runUpdateAll">Update all {{ u.pending }} screens</button>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="auto-update" :checked="Boolean(u.auto)" @change="setAutoUpdate(($event.target as HTMLInputElement).checked)" />
|
||||
<span>Update automatically every night<small>Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant.</small></span>
|
||||
</label>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Alerts</h2>
|
||||
<p>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>
|
||||
<div class="tools">
|
||||
<button type="button" class="tool" @click="go('#alerts')"><span class="tool-icon">!</span><span class="tx"><b>Alerts</b><small>Full-screen notifications, from an automation</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card" id="claude">
|
||||
<h2>Claude</h2>
|
||||
<p>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="actions">
|
||||
<button type="button" id="claude-install" class="btn" :class="skill?.installed && skill.current ? 'quiet' : 'primary'" :disabled="!skill || installing" @click="install">
|
||||
{{ !skill || !skill.installed ? "Install for Claude Code" : skill.current ? "Install again" : "Update the skill" }}
|
||||
</button>
|
||||
<span id="claude-status" class="chip" :class="!skill ? '' : !skill.installed ? '' : skill.current ? 'good' : 'update'" role="status">
|
||||
{{ !skill ? "Loading…" : !skill.installed ? "Not installed" : skill.current ? "● Installed" : "Installed · a newer version is ready" }}
|
||||
</span>
|
||||
</div>
|
||||
<small>For Claude Code in Home Assistant. Writes one file to <code id="claude-path">{{ skill?.path || "/homeassistant/.claude/skills/esp-screens" }}</code>; installing again replaces it.</small>
|
||||
<div class="actions">
|
||||
<a class="btn quiet" 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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
// One page of the screen as the mockup draws it: the top bar and a 2 × 3 grid of cells.
|
||||
import { computed } from "vue";
|
||||
import { isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { isGuition, openBar, removePage, state } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
import TileCard from "./TileCard.vue";
|
||||
import TopbarSvg from "./TopbarSvg.vue";
|
||||
|
||||
const props = defineProps<{ page: number; entries: { tile: Tile; slot: number }[]; pages: number; moving: Tile | null }>();
|
||||
const bySlot = computed(() => new Map(props.entries.map((e) => [e.slot, e])));
|
||||
const covered = computed(() => new Set(props.entries.filter((e) => isWide(e.tile)).map((e) => e.slot + 1)));
|
||||
const cells = computed(() => Array.from({ length: SLOTS_PER_PAGE }, (_, cell) => props.page * SLOTS_PER_PAGE + cell).filter((slot) => !covered.value.has(slot)));
|
||||
const empty = computed(() => props.pages > 1 && !props.entries.some((e) => pageOf(e.slot) === props.page));
|
||||
const barSelected = computed(() => state.inspector?.kind === "bar" || state.inspector?.kind === "bar-add");
|
||||
const filled = computed(() => props.entries.filter((e) => pageOf(e.slot) === props.page).reduce((n, e) => n + (isWide(e.tile) ? 2 : 1), 0));
|
||||
function pickCell(slot: number) {
|
||||
const marked = state.insertAt === slot;
|
||||
state.insertAt = marked ? -1 : slot;
|
||||
if (state.insertAt >= 0) document.querySelector<HTMLInputElement>("#search")?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-label">
|
||||
<span>Page {{ page + 1 }}</span>
|
||||
<button v-if="empty" type="button" class="btn mini" title="The pages after this one shift up one slot" @click="removePage(page)">Remove page</button>
|
||||
<span v-else>{{ filled }} / {{ SLOTS_PER_PAGE }}</span>
|
||||
</div>
|
||||
<div class="device" :class="{ cyd: !isGuition }">
|
||||
<div class="bar-wrap" :class="{ selected: barSelected }" title="Edit top bar" role="button" tabindex="0"
|
||||
@click="openBar(0)" @keydown.enter.prevent="openBar(0)">
|
||||
<TopbarSvg />
|
||||
</div>
|
||||
<div class="tiles">
|
||||
<template v-for="slot in cells" :key="slot">
|
||||
<TileCard v-if="bySlot.get(slot)" :tile="bySlot.get(slot)!.tile" :slot="slot" :placeholder="bySlot.get(slot)!.tile === moving" />
|
||||
<button v-else type="button" class="cell" :class="{ 'insert-here': state.insertAt === slot }" :data-slot="slot"
|
||||
title="Empty slot. Click to add a tile here, or drag one over."
|
||||
:aria-label="`Empty slot ${(slot % SLOTS_PER_PAGE) + 1} on page ${page + 1}: add the next tile here`" @click="pickCell(slot)">
|
||||
<span>+</span><small>{{ state.insertAt === slot ? "Next tile goes here" : "Empty" }}</small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// The inspector slides in from the right and leaves the mockup visible; its content follows what is selected.
|
||||
import { computed } from "vue";
|
||||
import { currentTile, state } from "../store";
|
||||
import TileInspector from "./TileInspector.vue";
|
||||
import TopbarInspector from "./TopbarInspector.vue";
|
||||
import TopbarAdd from "./TopbarAdd.vue";
|
||||
import InspectPanel from "./InspectPanel.vue";
|
||||
|
||||
const open = computed(() => Boolean(state.inspector && (state.inspector.kind !== "tile" || currentTile.value)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="drawer" id="tile-sheet" :class="{ open }" :aria-hidden="open ? 'false' : 'true'" @click.stop>
|
||||
<template v-if="state.inspector">
|
||||
<TileInspector v-if="state.inspector.kind === 'tile' && currentTile" :tile="currentTile" />
|
||||
<TopbarInspector v-else-if="state.inspector.kind === 'bar'" :index="state.inspector.index" />
|
||||
<TopbarAdd v-else-if="state.inspector.kind === 'bar-add'" />
|
||||
<InspectPanel v-else-if="state.inspector.kind === 'inspect'" :entity="state.inspector.entity" />
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { go, state } from "../store";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="state.inventory.screens.length" id="choose" class="empty">
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Choose a screen</h2>
|
||||
<p>Pick one of your screens on the left to change its tiles, top bar and settings.</p>
|
||||
</section>
|
||||
<section v-else 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 go.</p>
|
||||
<button id="start" type="button" class="btn primary" @click="go('#new-screen')">Install new screen</button>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
// Shared firmware workspace; always a concrete profile and upload target.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { go, toast } from "../store";
|
||||
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
const data = ref<any>(null);
|
||||
const file = ref("");
|
||||
const target = ref("ota");
|
||||
const host = ref("");
|
||||
let timer = 0;
|
||||
async function refreshFirmware(initial = false) {
|
||||
try {
|
||||
const next = await getJson("firmware");
|
||||
data.value = next;
|
||||
if (initial || !file.value) file.value = next.profiles[0]?.file || "";
|
||||
// USB stays chosen while the board is replugged; a port that went away falls back to the first one.
|
||||
const ports: string[] = next.ports || [];
|
||||
if (target.value.startsWith("/") && !ports.includes(target.value)) target.value = ports[0] || "usb";
|
||||
if (target.value === "usb" && ports.length) target.value = ports[0];
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
const running = computed(() => data.value?.job?.state === "running");
|
||||
const disabled = computed(() => !data.value || running.value || !data.value.available || !data.value.profiles?.length);
|
||||
const installDisabled = computed(() => disabled.value || target.value === "usb");
|
||||
const ports = computed<string[]>(() => data.value?.ports || []);
|
||||
const statusText = computed(() => !data.value ? "Loading…" : !data.value.available
|
||||
? "The ESPHome CLI is missing. Update the app to 0.2.0."
|
||||
: data.value.job
|
||||
? `${data.value.job.file} · ${data.value.job.action} · ${data.value.job.state}`
|
||||
: "Choose the intended profile, then a USB port, an IP address, or Download.");
|
||||
const downloadReady = computed(() => target.value === "download" && !!file.value && !!data.value?.downloads?.includes(file.value));
|
||||
const image = computed(() => ({ href: `api/firmware/profiles/${encodeURIComponent(file.value)}/download`, name: file.value.replace(/\.yaml$/, "") + ".factory.bin" }));
|
||||
async function run(action: "validate" | "build" | "install") {
|
||||
try {
|
||||
await send("firmware/jobs", "POST", {
|
||||
file: file.value,
|
||||
action: action === "validate" ? "validate" : action === "build" ? "build" : target.value === "download" ? "download" : "install",
|
||||
target: target.value === "ota" ? host.value.trim() : target.value === "download" ? "" : target.value,
|
||||
});
|
||||
await refreshFirmware();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
onMounted(() => { refreshFirmware(true); timer = window.setInterval(() => refreshFirmware(), 3000); });
|
||||
onBeforeUnmount(() => clearInterval(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="firmware-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Firmware & USB</span>
|
||||
<h1>Build and install screen firmware</h1>
|
||||
<p>The built-in ESPHome CLI uses your own profile (YAML with keys) and secrets. Choose the screen's profile, then a USB port (screen connected to the Home Assistant machine), <b>Wi-Fi / OTA</b> with the hostname, or <b>Download</b> to put the firmware on the screen from your own computer. <b>Check</b> only validates, <b>Build only</b> compiles, <b>Build & install</b> puts the firmware on the screen. Your tiles stay as they are in ESP Screens. The first build takes a few minutes.</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-firmware" @click="go('')">← Back</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-grid">
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-file">ESPHome profile</label>
|
||||
<select id="firmware-file" v-model="file">
|
||||
<option v-for="p in data?.profiles || []" :key="p.file" :value="p.file">{{ p.file }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-port">Install to</label>
|
||||
<select id="firmware-port" v-model="target">
|
||||
<option value="ota">Wi-Fi / OTA</option>
|
||||
<option v-if="!ports.length" value="usb">USB · no board found on the Home Assistant machine yet</option>
|
||||
<option v-for="p in ports" :key="p" :value="p">{{ p }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="target === 'ota'" class="field" id="firmware-host-label">
|
||||
<label class="f-label" for="firmware-host">IP address or hostname</label>
|
||||
<input id="firmware-host" v-model="host" :placeholder="file ? file.replace(/\.yaml$/, '.local') : 'screen-livingroom.local'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet" id="firmware-validate" :disabled="disabled" @click="run('validate')">Check</button>
|
||||
<button type="button" class="btn quiet" id="firmware-build" :disabled="disabled" @click="run('build')">Build only</button>
|
||||
<button type="button" class="btn primary" id="firmware-install" :disabled="installDisabled" @click="run('install')">{{ target === "download" ? "Build & download" : "Build & install" }}</button>
|
||||
<span v-if="running" class="spin"></span>
|
||||
</div>
|
||||
<p id="firmware-status" class="status-line" role="status">{{ statusText }}</p>
|
||||
<div v-if="downloadReady" id="firmware-download" class="card" style="background: var(--surface-2)">
|
||||
<a class="btn primary" id="firmware-download-link" :href="image.href" :download="image.name">Download {{ image.name }}</a>
|
||||
<small>Put it on the screen with <a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a> in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file. It holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="firmware-log" class="log">{{ (data?.logs || []).join("\n") }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
// The icon choice for tiles and top bar items: automatic, optionally none, or one from the set.
|
||||
// A choice applies live; the open picker keeps its search and scroll position.
|
||||
import { computed, ref } from "vue";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { iconNamed, state } from "../store";
|
||||
|
||||
const props = defineProps<{ selected: string; automatic: string; autoLabel: string; allowNone?: boolean; note?: string }>();
|
||||
const emit = defineEmits<{ (e: "pick", name: string): void }>();
|
||||
const query = ref("");
|
||||
const chosen = computed(() => iconNamed(props.selected));
|
||||
const currentGlyph = computed(() => (props.selected === "none" ? "" : glyph(chosen.value?.cp || props.automatic)));
|
||||
const currentText = computed(() => (props.selected === "none" ? "No icon" : chosen.value?.label || props.autoLabel));
|
||||
const groups = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
return (state.inventory.icons?.groups || []).map((group) => ({
|
||||
label: group.label,
|
||||
icons: group.icons.filter((i) => !q || `${i.label} ${i.name.replaceAll("-", " ")} ${group.label}`.toLocaleLowerCase().includes(q)),
|
||||
})).filter((g) => g.icons.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Icon</span>
|
||||
<button type="button" class="row" :aria-expanded="state.iconPickerOpen ? 'true' : 'false'" @click="state.iconPickerOpen = !state.iconPickerOpen">
|
||||
<span class="av mdi">{{ currentGlyph }}</span>
|
||||
<span class="tx"><b>{{ currentText }}</b></span>
|
||||
<span class="link">{{ state.iconPickerOpen ? "Close" : "Change" }}</span>
|
||||
</button>
|
||||
<div v-if="state.iconPickerOpen" class="picker">
|
||||
<input v-model="query" type="search" placeholder="Search, for example lamp, music, or door" aria-label="Search for an icon" />
|
||||
<button type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'auto' ? 'true' : 'false'" :title="autoLabel" @click="emit('pick', 'auto')">
|
||||
<span class="mdi">{{ glyph(automatic) }}</span><span>{{ autoLabel }}</span>
|
||||
</button>
|
||||
<button v-if="allowNone" type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'none' ? 'true' : 'false'" title="No icon" @click="emit('pick', 'none')">
|
||||
<span class="mdi"></span><span>No icon, text only</span>
|
||||
</button>
|
||||
<div class="icon-list">
|
||||
<section v-for="group in groups" :key="group.label">
|
||||
<small>{{ group.label }}</small>
|
||||
<div class="icon-grid">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="icon-choice" :title="icon.label" :aria-label="`Icon: ${icon.label}`"
|
||||
:aria-pressed="selected === icon.name ? 'true' : 'false'" @click="emit('pick', icon.name)"><span class="mdi">{{ glyph(icon.cp) }}</span></button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="!groups.length" class="hint">No icon found.</p>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="note">{{ note }}</small>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
// Read current data: what Home Assistant reports for the screen's tiles right now, and how each tile is set.
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { getJson } from "../api";
|
||||
import { controlsLabel, displayNames } from "../model/layout";
|
||||
import { closeInspector, state, toast } from "../store";
|
||||
|
||||
const props = defineProps<{ entity?: string }>();
|
||||
const summary = ref<any[] | null>(null);
|
||||
const raw = ref("Reading…");
|
||||
const error = ref("");
|
||||
async function load() {
|
||||
if (!state.selected) return;
|
||||
summary.value = null;
|
||||
error.value = "";
|
||||
raw.value = "Fetching current HA status...";
|
||||
try {
|
||||
const data = await getJson(`screens/${encodeURIComponent(state.selected)}/inspect`);
|
||||
const tiles = props.entity ? data.tiles.filter((t: any) => t.entity === props.entity) : data.tiles;
|
||||
summary.value = tiles;
|
||||
raw.value = JSON.stringify(props.entity ? tiles[0] : data, null, 2);
|
||||
} catch (e: any) {
|
||||
error.value = e.message;
|
||||
raw.value = "";
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
const optionsText = (entity: string) => {
|
||||
const options = state.layout?.tiles.find((t) => t.entity === entity)?.options || {};
|
||||
return `Small slider: ${options.inline === "slider" ? "yes" : "no"} · Display: ${displayNames[options.display || "standard"] || options.display} · Width: ${options.size === "wide" ? "double" : "normal"} · Control: ${controlsLabel({ entity, name: "", slot: 0, options }, state.inventory)} · Background: ${state.inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`;
|
||||
};
|
||||
onMounted(load);
|
||||
watch(() => props.entity, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av" style="background: var(--seg); color: var(--ink-2); font-size: 16px">⌕</span>
|
||||
<span class="tx"><b>Current data</b><small :class="{ mono: entity }">{{ entity || "Connection, status, and supported properties" }}</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body" id="inspector-section">
|
||||
<div id="inspection-summary">
|
||||
<p v-if="error" class="hint warn">{{ error }}</p>
|
||||
<p v-else-if="!summary" class="hint">Fetching current HA status...</p>
|
||||
<article v-for="tile in summary || []" :key="tile.entity" class="inspection-tile">
|
||||
<strong>{{ tile.entity }}</strong>
|
||||
<span>Status: {{ tile.word || tile.state }}</span>
|
||||
<small>{{ optionsText(tile.entity) }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<pre id="inspection">{{ raw }}</pre>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn quiet" @click="load">Read again</button>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
// New screen: profile, Wi-Fi, and the first flash in one go.
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { copyText, go, openIntegrations, toast } from "../store";
|
||||
|
||||
// Download: ESP Screens builds, the owner flashes the file from their own computer. ESPHome Web is ESPHome's own
|
||||
// browser flasher; this address opens it with its hint for a downloaded project (as ESPHome Device Builder does).
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
const form = reactive({ board: "cyd", friendly_name: "", name: "", wifi_ssid: "", wifi_password: "", target: "" });
|
||||
const installer = reactive({
|
||||
view: "setup" as "setup" | "progress" | "done", file: null as string | null, friendly: "", board: "cyd", target: "",
|
||||
apiKey: null as string | null, nodeEdited: false, jobState: null as string | null, picked: false, action: null as string | null,
|
||||
});
|
||||
const nodeVisible = ref(false);
|
||||
const data = ref<any>(null);
|
||||
const job = ref<any>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
const note = ref("");
|
||||
const status = ref("");
|
||||
const submitting = ref(false);
|
||||
const logOpen = ref(false);
|
||||
const keyBox = ref<HTMLElement | null>(null);
|
||||
let poll = 0;
|
||||
|
||||
// ESPHome's node-name rule: lowercase ASCII, digits and dashes, starting with a letter.
|
||||
function slug(text: string) {
|
||||
const clean = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 30).replace(/-+$/, "");
|
||||
return clean || "screen";
|
||||
}
|
||||
function portLabel(port: string) {
|
||||
const id = port.replace(/^\/dev\/serial\/by-id\/usb-/, "").replace(/-if\d+(-port\d+)?$/, "").replace(/_/g, " ");
|
||||
return `USB · ${id === port ? port.replace(/^\/dev\//, "") : id}`;
|
||||
}
|
||||
watch(() => form.friendly_name, () => { if (!installer.nodeEdited) form.name = slug(form.friendly_name); });
|
||||
const nodePreview = computed(() => form.name || "…");
|
||||
const ports = computed<string[]>(() => data.value?.ports || []);
|
||||
const wifi = computed(() => data.value?.wifi);
|
||||
const askWifi = computed(() => wifi.value?.state === "new" || wifi.value?.state === "missing");
|
||||
const wifiMissing = computed<string[]>(() => wifi.value?.missing || []);
|
||||
const wifiStatus = computed(() => wifi.value?.state === "new"
|
||||
? "Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically."
|
||||
: "Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines.");
|
||||
const wifiNote = computed(() => wifi.value?.state === "ready"
|
||||
? "Wi-Fi comes from your ESPHome secrets.yaml."
|
||||
: wifi.value?.state === "invalid"
|
||||
? "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten."
|
||||
: "");
|
||||
const targetHint = computed(() => form.target === "usb"
|
||||
? "Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer."
|
||||
: form.target === "download"
|
||||
? "Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi."
|
||||
: !form.target
|
||||
? "The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."
|
||||
: ports.value.length > 1
|
||||
? "More than one board connected: choose this screen's port."
|
||||
: "Once over USB; after that, everything is wireless.");
|
||||
const goLabel = computed(() => (form.target === "download" ? "Build & download" : form.target ? "Install" : "Save profile"));
|
||||
const busyElsewhere = computed(() => data.value?.job?.state === "running" && !(installer.file && data.value.job.file === installer.file));
|
||||
const goDisabled = computed(() => submitting.value || wifi.value?.state === "invalid" || form.target === "usb" ||
|
||||
(!!form.target && (busyElsewhere.value || !data.value?.available)));
|
||||
// USB on the Home Assistant machine is always listed first, also before a board is plugged in, so nobody
|
||||
// concludes it isn't possible; "usb" stands for that port until one shows up.
|
||||
function syncTarget() {
|
||||
const current = form.target;
|
||||
const kept = current === "download" || current === "" ? installer.picked : ports.value.includes(current);
|
||||
if (!kept) form.target = ports.value[0] || "usb";
|
||||
}
|
||||
async function installerRefresh() {
|
||||
let next: any;
|
||||
try {
|
||||
next = await getJson("firmware");
|
||||
} catch (e: any) {
|
||||
note.value = e.message;
|
||||
return;
|
||||
}
|
||||
data.value = next;
|
||||
const current = next.job;
|
||||
const ours = current && installer.file && current.file === installer.file;
|
||||
if (ours) installer.jobState = current.state;
|
||||
if (installer.view === "setup") {
|
||||
if (ours && current.state === "running") { showProgress(current, next.logs || []); return; }
|
||||
syncTarget();
|
||||
note.value = busyElsewhere.value
|
||||
? `A build or installation is already running (${current.file}). Wait for it to finish.`
|
||||
: !next.available && form.target
|
||||
? "The ESPHome CLI is missing from this installation; only saving the profile is possible."
|
||||
: wifiNote.value;
|
||||
} else if (installer.view === "progress" && ours) {
|
||||
job.value = current;
|
||||
logs.value = next.logs || [];
|
||||
if (current.state !== "running" && current.state !== "success") logOpen.value = true;
|
||||
}
|
||||
}
|
||||
function showProgress(current: any, lines: string[]) {
|
||||
installer.view = "progress";
|
||||
installer.jobState = current.state;
|
||||
installer.action = current.action;
|
||||
job.value = current;
|
||||
logs.value = lines;
|
||||
}
|
||||
const running = computed(() => job.value?.state === "running");
|
||||
const ok = computed(() => installer.view === "done" || job.value?.state === "success");
|
||||
const download = computed(() => installer.action === "download");
|
||||
const title = computed(() => installer.view === "done" ? "Profile saved." : running.value ? "One moment…" : ok.value ? (download.value ? "Ready to download." : "Done.") : "That didn't work.");
|
||||
const progressTitle = computed(() => installer.view === "done"
|
||||
? `${installer.file} is in the ESPHome folder`
|
||||
: running.value
|
||||
? job.value?.stage === "upload" ? `Writing firmware to ${installer.friendly}…` : "Building firmware…"
|
||||
: ok.value
|
||||
? download.value ? `Firmware for ${installer.friendly} is ready` : `Firmware is on ${installer.friendly}`
|
||||
: download.value ? "Build failed" : "Install failed");
|
||||
const progressDetail = computed(() => installer.view === "done"
|
||||
? "Install it later from Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:"
|
||||
: running.value
|
||||
? job.value?.stage === "upload"
|
||||
? "Don't disconnect the USB cable yet."
|
||||
: `A first build takes a few minutes on a Raspberry Pi. You can leave this page: the ${download.value ? "build" : "installation"} keeps running and you'll find it again under New screen.`
|
||||
: ok.value
|
||||
? download.value
|
||||
? "Put it on the screen from your own computer, then pair the screen with Home Assistant."
|
||||
: `The screen boots up and connects to your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""} Pair it with Home Assistant now:`
|
||||
: logs.value.filter((l) => /error/i.test(l)).pop() || logs.value.filter((l) => /failed/i.test(l)).pop() || "See the log below.");
|
||||
const image = computed(() => ({ href: `api/firmware/profiles/${encodeURIComponent(installer.file || "")}/download`, name: (installer.file || "").replace(/\.yaml$/, "") + ".factory.bin" }));
|
||||
const pairingSteps = computed(() => [
|
||||
["Go to Home Assistant → Settings → Devices & services.", ` This happens outside ESP Screens. Home Assistant discovers ${installer.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `],
|
||||
["Paste the API key", " above when Home Assistant asks for an encryption key."],
|
||||
["Allow HA actions:", " ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."],
|
||||
["Choose your tiles.", " Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."],
|
||||
]);
|
||||
async function submit(event: Event) {
|
||||
const element = event.target as HTMLFormElement;
|
||||
if (!installer.nodeEdited) form.name = slug(form.friendly_name);
|
||||
if (!element.reportValidity()) return;
|
||||
submitting.value = true;
|
||||
status.value = "";
|
||||
try {
|
||||
const payload: Record<string, string> = { board: form.board, friendly_name: form.friendly_name, name: form.name, target: form.target };
|
||||
if (askWifi.value) { if (wifiMissing.value.includes("wifi_ssid")) payload.wifi_ssid = form.wifi_ssid; if (wifiMissing.value.includes("wifi_password")) payload.wifi_password = form.wifi_password; }
|
||||
const result = await send("firmware/profiles", "POST", payload);
|
||||
Object.assign(installer, { file: result.file, apiKey: result.api_key, friendly: form.friendly_name.trim(), board: form.board, target: form.target });
|
||||
form.wifi_password = "";
|
||||
if (result.job) showProgress(result.job, []);
|
||||
else installer.view = "done";
|
||||
} catch (err: any) {
|
||||
status.value = err.message;
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function retry() {
|
||||
try {
|
||||
if (!download.value) {
|
||||
const { ports: fresh } = await getJson("firmware");
|
||||
// The board may have been replugged; a single visible port is unambiguous.
|
||||
if (!fresh.includes(installer.target) && fresh.length === 1) installer.target = fresh[0];
|
||||
}
|
||||
const next = await send("firmware/jobs", "POST", download.value
|
||||
? { file: installer.file, action: "download" }
|
||||
: { file: installer.file, action: "install", target: installer.target });
|
||||
showProgress(next, []);
|
||||
} catch (err: any) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, jobState: null, target: "", picked: false, action: null });
|
||||
Object.assign(form, { board: "cyd", friendly_name: "", name: "", wifi_ssid: "", wifi_password: "", target: "" });
|
||||
nodeVisible.value = false; job.value = null; logs.value = []; status.value = ""; note.value = ""; logOpen.value = false;
|
||||
installerRefresh();
|
||||
}
|
||||
function close() {
|
||||
if (installer.view === "progress" && installer.jobState !== "running") installer.view = "done";
|
||||
go("");
|
||||
}
|
||||
onMounted(() => { installerRefresh(); poll = window.setInterval(installerRefresh, 3000); });
|
||||
onBeforeUnmount(() => clearInterval(poll));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="installer">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">New screen</span>
|
||||
<h1 id="install-title">{{ installer.view === "setup" ? "Connect and install." : title }}</h1>
|
||||
<p v-if="installer.view === 'setup'">ESP Screens creates the screen's own profile with unique keys and builds the firmware. Connect the screen with a USB data cable to the machine running Home Assistant, or download the firmware and put it on the screen from your own computer.</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-install" aria-label="Close" @click="close">← Back</button>
|
||||
</div>
|
||||
<form v-if="installer.view === 'setup'" id="install-form" class="card" @submit.prevent="submit">
|
||||
<fieldset>
|
||||
<legend>Which screen do you have?</legend>
|
||||
<div class="boards">
|
||||
<label class="board"><input type="radio" name="board" value="cyd" v-model="form.board" /><span><b>CYD · 2.8 inch</b><small>ESP32-2432S028 · 320 × 240</small></span></label>
|
||||
<label class="board"><input type="radio" name="board" value="guition" v-model="form.board" /><span><b>Guition · 4 inch</b><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="friendly_name">Name</label>
|
||||
<input id="friendly_name" name="friendly_name" v-model="form.friendly_name" required maxlength="60" placeholder="Living room screen" autocomplete="off" />
|
||||
<small class="node-line">Device name <code id="node-preview">{{ nodePreview }}</code><button type="button" class="btn link mini" id="edit-node" @click="installer.nodeEdited = true; nodeVisible = true">customize</button></small>
|
||||
<small>Every screen gets its own profile with its own keys. Four screens? Go through this four times with a different name.</small>
|
||||
</div>
|
||||
<div v-if="nodeVisible" class="field" id="node-label">
|
||||
<label class="f-label" for="node-name">Device name</label>
|
||||
<input id="node-name" name="name" v-model="form.name" pattern="[a-z][a-z0-9\-]{0,29}" maxlength="30" autocomplete="off" @input="installer.nodeEdited = true" />
|
||||
<small>Lowercase letters, digits, and dashes. A different name for each screen.</small>
|
||||
</div>
|
||||
<fieldset v-if="askWifi" id="wifi-fields" class="wifi">
|
||||
<legend>Wi-Fi</legend>
|
||||
<p class="hint" id="wifi-status">{{ wifiStatus }}</p>
|
||||
<div v-if="wifiMissing.includes('wifi_ssid')" class="field" id="wifi-ssid-label"><label class="f-label" for="wifi_ssid">Wi-Fi name (2.4 GHz)</label><input id="wifi_ssid" name="wifi_ssid" v-model="form.wifi_ssid" autocomplete="off" /></div>
|
||||
<div v-if="wifiMissing.includes('wifi_password')" class="field" id="wifi-password-label"><label class="f-label" for="wifi_password">Wi-Fi password</label><input id="wifi_password" name="wifi_password" type="password" v-model="form.wifi_password" autocomplete="new-password" /></div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="install-target">Install via</label>
|
||||
<select id="install-target" name="target" v-model="form.target" @change="installer.picked = true; installerRefresh()">
|
||||
<option v-if="!ports.length" value="usb">USB · no board found on the Home Assistant machine yet</option>
|
||||
<option v-for="p in ports" :key="p" :value="p">{{ portLabel(p) }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
<option value="">Later · save profile only</option>
|
||||
</select>
|
||||
<small id="target-hint">{{ targetHint }}</small>
|
||||
</div>
|
||||
<p v-if="note" class="hint" id="install-note">{{ note }}</p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn primary" id="install-go" :disabled="goDisabled">{{ goLabel }}</button>
|
||||
<span id="install-status" class="status-line error" role="status">{{ status }}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else id="install-progress" class="card">
|
||||
<div class="progress-head">
|
||||
<span v-if="running" class="spin" id="progress-spin"></span>
|
||||
<span v-else class="outcome" :class="ok ? 'ok' : 'bad'" id="progress-mark">{{ ok ? "✓" : "✕" }}</span>
|
||||
<strong id="progress-title">{{ progressTitle }}</strong>
|
||||
</div>
|
||||
<p id="progress-detail">{{ progressDetail }}</p>
|
||||
<div v-if="ok && download && installer.view !== 'done'" id="install-download" class="card" style="background: var(--surface-2)">
|
||||
<a class="btn primary" id="download-firmware" :href="image.href" :download="image.name">Download {{ image.name }}</a>
|
||||
<ol class="steps" id="download-steps">
|
||||
<li><b>Plug the screen into your computer</b> with a USB data cable.</li>
|
||||
<li><b>Open <a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a></b> in Chrome or Edge, click Connect and choose the screen's USB port.</li>
|
||||
<li><b>Click Install</b> and select {{ image.name }}. The screen restarts and joins your Wi-Fi.{{ installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : "" }}</li>
|
||||
</ol>
|
||||
<small>The file holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
</div>
|
||||
<div v-if="ok" id="install-result" class="field">
|
||||
<div class="key-box">
|
||||
<span>API key</span><code id="api-key" ref="keyBox">{{ installer.apiKey || "" }}</code>
|
||||
<button type="button" class="btn quiet mini" id="copy-key" @click="copyText(installer.apiKey || '', keyBox)">Copy</button>
|
||||
</div>
|
||||
<ol class="steps" id="install-steps">
|
||||
<li v-for="([b, t], i) in pairingSteps" :key="i"><b>{{ b }}</b>{{ t }}<button v-if="i === 0" type="button" class="btn quiet mini" @click="openIntegrations">Open Devices & services</button></li>
|
||||
</ol>
|
||||
</div>
|
||||
<details v-if="installer.view !== 'done'" id="install-log-wrap" class="log-wrap" :open="logOpen" @toggle="logOpen = ($event.target as HTMLDetailsElement).open">
|
||||
<summary>ESPHome log</summary>
|
||||
<pre id="install-log" class="log">{{ logs.join("\n") }}</pre>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<button v-if="!running && !ok" type="button" class="btn primary" id="install-retry" @click="retry">Retry</button>
|
||||
<button type="button" class="btn quiet" id="install-close" @click="reset">{{ ok ? "Install another screen" : "Start over" }}</button>
|
||||
<button type="button" class="btn" :class="ok ? 'primary' : 'quiet'" @click="close">{{ ok ? "Done" : "Close" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// The pages side by side, like swiping on the screen, and the library on the right.
|
||||
import { computed } from "vue";
|
||||
import { entriesOf, hasGaps, MAX_PAGES, pageCount } from "../model/layout";
|
||||
import { addPage, closeInspector, currentScreen, pagesShown, state, supports, tileLimit } from "../store";
|
||||
import DevicePage from "./DevicePage.vue";
|
||||
import Library from "./Library.vue";
|
||||
|
||||
const layout = computed(() => state.layout!);
|
||||
const entries = computed(() => state.drag.preview || entriesOf(layout.value));
|
||||
const pages = computed(() => pageCount(entries.value, layout.value.pages));
|
||||
const shown = computed(() => pagesShown());
|
||||
const canAdd = computed(() => pages.value < MAX_PAGES);
|
||||
const positionsHint = computed(() => hasGaps(layout.value.tiles) && !supports(0, 2, 26)
|
||||
? `Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${currentScreen.value?.firmware || "unknown"}) shifts the tiles up to the first free slot until that update.`
|
||||
: "");
|
||||
function onCanvasClick(e: MouseEvent) {
|
||||
// A click beside the pages closes the drawer; the cards and the bar handle their own clicks.
|
||||
if ((e.target as HTMLElement).closest(".device, .page-label, .canvas-head")) return;
|
||||
if (state.inspector) closeInspector();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas" id="canvas" @click="onCanvasClick">
|
||||
<div class="canvas-head">
|
||||
<b id="count">{{ pages }} {{ pages === 1 ? "page" : "pages" }} · {{ layout.tiles.length }} / {{ tileLimit }} tiles</b>
|
||||
<span v-if="!layout.tiles.length" id="no-tiles">Add your first light, scene, or device from the library.</span>
|
||||
<span v-else>Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.</span>
|
||||
<span v-if="positionsHint" id="positions-hint" class="warn">{{ positionsHint }}</span>
|
||||
</div>
|
||||
<div class="pages" id="layout-preview" aria-label="Screen layout">
|
||||
<DevicePage v-for="page in shown" :key="page" :page="page - 1" :entries="entries" :pages="pages" :moving="state.drag.moving" />
|
||||
<div class="page ghost" :class="{ disabled: !canAdd }">
|
||||
<div class="page-label"><span>Page {{ shown + 1 }}</span></div>
|
||||
<div class="device" :class="{ cyd: currentScreen?.board !== 'guition' }" id="add-page" role="button" :tabindex="canAdd ? 0 : -1" @click="canAdd && addPage()" @keydown.enter.prevent="canAdd && addPage()">
|
||||
{{ canAdd ? "+ Add page" : `${MAX_PAGES} pages is the most` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Library />
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// The entities a tile can show, with a search and one filter per domain. A click adds the entity to the
|
||||
// marked empty cell or the first free one; a drag puts it exactly where it lands.
|
||||
import { computed } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, isGuition, state, tileLimit } from "../store";
|
||||
|
||||
const FILTERS: [string, string][] = [
|
||||
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
|
||||
["script", "Scripts"], ["fan", "Fans"], ["cover", "Covers"], ["scene", "Scenes"], ["vacuum", "Vacuum"], ["sensor", "Sensors"],
|
||||
["media_player", "Media"], ["weather", "Weather"], ["number", "Values"], ["select", "Selects"], ["person", "People"], ["timer", "Timers"], ["screen", "Clock"],
|
||||
];
|
||||
const ALIAS: Record<string, string> = { switch: "input_boolean", number: "input_number", select: "input_select", weather: "sun", button: "input_button" };
|
||||
const chosen = computed(() => new Set(state.layout?.tiles.map((t) => t.entity) || []));
|
||||
const matches = computed(() => {
|
||||
const query = state.search.toLocaleLowerCase(), filter = state.filter;
|
||||
// The picker offers what a tile can show; camera images need a Guition (app 0.2.66).
|
||||
return [...(state.inventory.builtin || []), ...state.inventory.entities].filter((e) =>
|
||||
e.tile !== false &&
|
||||
(isGuition.value || !["camera", "image"].includes(e.id.split(".")[0])) &&
|
||||
(!filter || e.id.startsWith(filter + ".") || ALIAS[filter] === e.id.split(".")[0]) &&
|
||||
`${e.name} ${e.id} ${e.device || ""} ${e.area || ""}`.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const full = computed(() => (state.layout?.tiles.length || 0) >= tileLimit.value);
|
||||
const count = computed(() => state.inventory.entities.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="library" id="library">
|
||||
<div class="lib-head">
|
||||
<div class="lib-title">Library <small>{{ count }} entities</small></div>
|
||||
<input id="search" v-model="state.search" type="search" placeholder="Search by name, device, or room…" autocomplete="off" aria-label="Add an entity" />
|
||||
<div class="filters" id="filters">
|
||||
<button v-for="[value, label] in FILTERS" :key="value" type="button" :aria-pressed="state.filter === value ? 'true' : 'false'" @click="state.filter = value">
|
||||
<span v-if="value" class="domain-icon" :style="{ color: domainInfo(value + '.')[2], background: domainInfo(value + '.')[3] }" aria-hidden="true">{{ domainInfo(value + ".")[1] }}</span>{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-list" id="results" aria-live="polite">
|
||||
<button v-for="entity in matches.slice(0, 80)" :key="entity.id" type="button" class="ent" :title="entity.id"
|
||||
:disabled="chosen.has(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
|
||||
<span class="av mdi" :style="{ color: domainInfo(entity.id)[2], background: domainInfo(entity.id)[3] }">{{ state.inventory.icons ? glyph(automaticIcon(entity.id)) : domainInfo(entity.id)[1] }}</span>
|
||||
<span class="tx">
|
||||
<b>{{ entity.name }}</b>
|
||||
<small>{{ [domainInfo(entity.id)[0], entity.area, entity.device].filter(Boolean).join(" · ") }}</small>
|
||||
</span>
|
||||
<span class="add" :class="{ done: chosen.has(entity.id) }">{{ chosen.has(entity.id) ? "✓" : "+" }}</span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">No entities found. Try a different name.</p>
|
||||
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
</div>
|
||||
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit === 10 ? "; update its firmware for 20" : ""}.` : "Drag onto a page, or tap + to add to the first free slot." }}</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
// Per-screen local YAML override: a small file of the owner's, loaded after the shared screen package.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { go, state, toast } from "../store";
|
||||
|
||||
const OVERRIDE_EXAMPLE = `# Hardware-specific changes for this screen.
|
||||
# This file is kept when the shared firmware package updates.
|
||||
# Do not add esphome:, api:, ota:, wifi: or packages: here.
|
||||
|
||||
# Example: a CYD with the ST7789V display controller.
|
||||
# "!extend" changes the display the shared package already defines;
|
||||
# a bare id would add a second, incomplete display and fail the build.
|
||||
display:
|
||||
- id: !extend my_display
|
||||
model: ST7789V
|
||||
`;
|
||||
const profile = computed(() => state.overrideProfile);
|
||||
const content = ref("");
|
||||
const file = ref("");
|
||||
const attached = ref(false);
|
||||
const status = ref("Loading…");
|
||||
const kind = ref("");
|
||||
const busy = ref(false);
|
||||
const editor = ref<HTMLTextAreaElement | null>(null);
|
||||
const gutter = ref<HTMLDivElement | null>(null);
|
||||
const lines = computed(() => content.value.split("\n").length);
|
||||
const gutterText = computed(() => Array.from({ length: lines.value }, (_, i) => i + 1).join("\n"));
|
||||
const count = computed(() => `${content.value.length} characters · ${lines.value} line${lines.value === 1 ? "" : "s"}`);
|
||||
let alive = true;
|
||||
function setStatus(message: string, k = "") { status.value = message; kind.value = k; }
|
||||
async function load() {
|
||||
if (!profile.value) { setStatus("No ESPHome profile was found for this screen.", "error"); return; }
|
||||
setStatus("Loading…");
|
||||
try {
|
||||
const data = await getJson(`firmware/profiles/${encodeURIComponent(profile.value)}/override`);
|
||||
file.value = data.override_file;
|
||||
attached.value = Boolean(data.attached);
|
||||
content.value = data.exists && data.content !== "{}\n" ? data.content : "";
|
||||
setStatus(data.attached ? "Changes here apply on the next build." : "Save once to attach this file to the profile.");
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
}
|
||||
async function saveOverride(runCheck = false) {
|
||||
if (!profile.value || busy.value) return;
|
||||
busy.value = true;
|
||||
setStatus("Checking and saving…");
|
||||
try {
|
||||
const data = await send(`firmware/profiles/${encodeURIComponent(profile.value)}/override`, "PUT", { content: content.value });
|
||||
file.value = data.override_file;
|
||||
attached.value = true;
|
||||
if (!runCheck) {
|
||||
setStatus("Saved. The override is kept during firmware updates.", "ok");
|
||||
toast("Override YAML saved.");
|
||||
return;
|
||||
}
|
||||
setStatus("Saved. ESPHome is checking the complete profile…");
|
||||
await send("firmware/jobs", "POST", { file: profile.value, action: "validate" });
|
||||
pollCheck();
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
function pollCheck() {
|
||||
const started = Date.now(), checked = profile.value;
|
||||
const poll = async () => {
|
||||
if (!alive || profile.value !== checked) return; // the page closed or shows another screen
|
||||
try {
|
||||
const data = await getJson("firmware");
|
||||
const current = data.job;
|
||||
if (current && current.file === checked && current.state === "running") {
|
||||
setStatus(`ESPHome is checking the profile… ${current.stage || ""}`.trim());
|
||||
} else if (current && current.file === checked && current.state === "success") {
|
||||
setStatus("The complete profile is valid. Safe to build and install.", "ok");
|
||||
return;
|
||||
} else if (current && current.file === checked && current.state === "failed") {
|
||||
const error = (data.logs || []).filter((line: string) => /error|failed/i.test(line)).pop();
|
||||
setStatus(error || "ESPHome rejected the complete profile. See Firmware & USB for the full log.", "error");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - started < 7200000) setTimeout(poll, 1200);
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
};
|
||||
poll();
|
||||
}
|
||||
function useExample() {
|
||||
if (!content.value.trim() || confirm("Replace the current text with the example?")) {
|
||||
content.value = OVERRIDE_EXAMPLE;
|
||||
setStatus("Example loaded. Save it when you are ready.");
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
function clear() {
|
||||
if (!content.value.trim() || confirm("Clear the local override?")) {
|
||||
content.value = "";
|
||||
setStatus("The override will be empty after you save.");
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
const area = editor.value!;
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const start = area.selectionStart, end = area.selectionEnd;
|
||||
area.setRangeText(" ", start, end, "end");
|
||||
content.value = area.value;
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
saveOverride(false);
|
||||
}
|
||||
}
|
||||
function syncScroll() { if (gutter.value && editor.value) gutter.value.scrollTop = editor.value.scrollTop; }
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => { alive = false; });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="override-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Advanced · local override</span>
|
||||
<h1 id="override-title">Override YAML{{ state.overrideFriendly ? ` · ${state.overrideFriendly}` : "" }}</h1>
|
||||
<p>This small file is yours. It is loaded after the shared screen package and stays in place when ESP Screen Manager updates. Change hardware-specific details here, such as a display model.</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-override" @click="go('')">← Back</button>
|
||||
</div>
|
||||
<div class="notice">
|
||||
<span class="mdi">{{ glyph("F0493") }}</span>
|
||||
<div><strong>What is safe to change?</strong><span>Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.</span></div>
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<code id="override-file">{{ file || profile || "" }}</code>
|
||||
<span id="override-state" class="chip" :class="{ good: attached }">{{ attached ? "Active" : "Ready to attach" }}</span>
|
||||
</div>
|
||||
<div class="yaml-editor" id="yaml-editor-wrap">
|
||||
<div ref="gutter" class="yaml-gutter" id="override-gutter" aria-hidden="true">{{ gutterText }}</div>
|
||||
<textarea ref="editor" id="override-editor" v-model="content" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off"
|
||||
aria-label="Local override YAML" placeholder="# Example display: - id: !extend my_display model: ST7789V" @keydown="onKey" @scroll="syncScroll"></textarea>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet mini" id="override-example" @click="useExample">Use example</button>
|
||||
<button type="button" class="btn quiet mini" id="override-empty" @click="clear">Clear</button>
|
||||
<span id="override-count" class="hint">{{ count }}</span>
|
||||
</div>
|
||||
<p id="override-status" class="status-line" :class="kind" role="status">{{ status }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet" id="override-check" :disabled="busy || !profile" @click="saveOverride(true)">Save & check</button>
|
||||
<button type="button" class="btn primary" id="override-save" :disabled="busy || !profile" @click="saveOverride(false)">Save override</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
// One screen: the head with its status, the Layout and Settings tabs, and the drawer over the right side.
|
||||
import { computed, onBeforeUnmount, onMounted } from "vue";
|
||||
import { closeInspector, currentScreen, go, save, state } from "../store";
|
||||
import LayoutView from "./LayoutView.vue";
|
||||
import SettingsTab from "./SettingsTab.vue";
|
||||
import Drawer from "./Drawer.vue";
|
||||
|
||||
const screen = computed(() => currentScreen.value!);
|
||||
const statusText = computed(() => screen.value.online
|
||||
? `${screen.value.delivery} · ${screen.value.status}`
|
||||
: "Offline · changes are saved");
|
||||
const updateReady = computed(() => screen.value.update?.available && screen.value.online && screen.value.update?.profile);
|
||||
function openOverride() {
|
||||
state.menuOpen = false;
|
||||
state.overrideProfile = screen.value.update?.profile || null;
|
||||
state.overrideFriendly = screen.value.name;
|
||||
go("#override");
|
||||
}
|
||||
function inspectAll() {
|
||||
state.menuOpen = false;
|
||||
state.selectedTile = null;
|
||||
state.inspector = { kind: "inspect" };
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (state.menuOpen) state.menuOpen = false;
|
||||
else if (state.inspector && !(e.target as HTMLElement)?.closest?.(".picker")) closeInspector();
|
||||
}
|
||||
}
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (state.menuOpen && !(e.target as HTMLElement).closest(".head-right .menu, #more")) state.menuOpen = false;
|
||||
}
|
||||
onMounted(() => { document.addEventListener("keydown", onKey); document.addEventListener("click", onDocClick); });
|
||||
onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document.removeEventListener("click", onDocClick); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="main-head">
|
||||
<div>
|
||||
<span class="eyebrow" id="screen-name">{{ screen.name }}</span>
|
||||
<h1>{{ state.layout?.title || "Home" }}</h1>
|
||||
</div>
|
||||
<span id="delivery" class="chip" :class="{ off: !screen.online }" :title="statusText"><span class="dot"></span>{{ statusText }}</span>
|
||||
<div class="head-right">
|
||||
<div class="seg" role="tablist">
|
||||
<button type="button" id="tab-layout" role="tab" :aria-pressed="state.tab === 'layout' ? 'true' : 'false'" @click="state.tab = 'layout'">Layout</button>
|
||||
<button type="button" id="tab-settings" role="tab" :aria-pressed="state.tab === 'settings' ? 'true' : 'false'" @click="state.tab = 'settings'; closeInspector()">Screen settings</button>
|
||||
</div>
|
||||
<span v-if="!state.dirty" id="dirty" class="chip" :class="{ sent: state.saved }">{{ state.saved ? "Saved · sent to screen" : "All saved" }}</span>
|
||||
<span v-else id="dirty" class="chip dirty">Unsaved changes</span>
|
||||
<button v-if="state.dirty" id="save" type="button" class="btn primary" :disabled="state.busy" @click="save()">
|
||||
<span v-if="state.busy" class="spin small"></span>{{ state.busy ? "Saving…" : "Save & send" }}
|
||||
</button>
|
||||
<button id="more" type="button" class="icon-btn" aria-label="More" aria-haspopup="menu" :aria-expanded="state.menuOpen ? 'true' : 'false'" @click.stop="state.menuOpen = !state.menuOpen">···</button>
|
||||
<div v-if="state.menuOpen" class="menu" role="menu">
|
||||
<button type="button" role="menuitem" id="inspect" @click="inspectAll">Read current data <small>from Home Assistant</small></button>
|
||||
<button type="button" role="menuitem" id="open-override" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found for this screen'" @click="openOverride">Override YAML <small>advanced</small></button>
|
||||
<button type="button" role="menuitem" @click="state.menuOpen = false; go('#firmware')">Firmware & USB</button>
|
||||
<button v-if="updateReady" type="button" role="menuitem" @click="state.menuOpen = false; $emit('update')">Update firmware <small>{{ screen.update?.target }}</small></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body" id="body">
|
||||
<LayoutView v-if="state.tab === 'layout'" />
|
||||
<SettingsTab v-else />
|
||||
<Drawer />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ choices: readonly (readonly [unknown, string])[]; value: unknown; disabled?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "pick", value: any): void }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seg wrap" role="group">
|
||||
<button v-for="[key, text] in choices" :key="String(key)" type="button" :disabled="disabled"
|
||||
:aria-pressed="String(key) === String(value) ? 'true' : 'false'" @click="emit('pick', key)">{{ text }}</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
// Screen settings: the same groups and rows as the settings page on the screen itself. Every change applies at
|
||||
// once, like on the screen; no Save needed.
|
||||
import { computed } from "vue";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { currentScreen, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
|
||||
|
||||
const view = computed(() => settingsView());
|
||||
const values = computed(() => settingValues());
|
||||
const offline = computed(() => view.value?.owner === "screen" && !currentScreen.value?.online);
|
||||
const groups = computed(() => SETTING_GROUPS.map((group) => ({ ...group, rows: (group.rows as readonly SettingRow[]).filter((row) => view.value?.keys.includes(row.key)) })).filter((g) => g.rows.length));
|
||||
const unavailable = (row: SettingRow) => offline.value || Boolean(view.value?.unavailable.includes(row.key));
|
||||
const needs = (row: SettingRow) => (row.needs ? Boolean(values.value[row.needs]) : true);
|
||||
const status = computed(() => offline.value
|
||||
? "This screen is offline. Its settings can change once it's back."
|
||||
: state.settingPending
|
||||
? "Saving…"
|
||||
: view.value?.owner === "screen"
|
||||
? "Changes apply on the screen at once, and show up here when they change there."
|
||||
: "Changes apply at once. Firmware 0.2.49 lets the screen keep them itself.");
|
||||
const stepDisabled = (row: SettingRow, direction: number) => {
|
||||
const value = values.value[row.key];
|
||||
if (unavailable(row) || !needs(row) || value === null || value === undefined) return true;
|
||||
return row.kind !== "moment" && steppedSetting(row, value, direction, false, values.value) === value;
|
||||
};
|
||||
// A key held down steps again and again, faster after a moment, like the -/+ keys on the screen.
|
||||
const holds = new WeakMap<HTMLElement, { timer: number; held: boolean }>();
|
||||
function step(row: SettingRow, direction: number, held: boolean) {
|
||||
const v = settingValues();
|
||||
const next = steppedSetting(row, v[row.key], direction, held, v);
|
||||
if (next !== v[row.key]) setSetting(row.key, next, 600);
|
||||
}
|
||||
function down(e: PointerEvent, row: SettingRow, direction: number) {
|
||||
const button = e.currentTarget as HTMLButtonElement;
|
||||
if (button.disabled || e.button !== 0) return;
|
||||
const hold = { timer: 0, held: false };
|
||||
holds.set(button, hold);
|
||||
let repeats = 0;
|
||||
hold.timer = window.setTimeout(function repeat() {
|
||||
hold.held = true;
|
||||
repeats += 1;
|
||||
step(row, direction, repeats > 5);
|
||||
hold.timer = window.setTimeout(repeat, 180);
|
||||
}, 450);
|
||||
}
|
||||
function up(e: PointerEvent) {
|
||||
const hold = holds.get(e.currentTarget as HTMLElement);
|
||||
if (hold) clearTimeout(hold.timer);
|
||||
}
|
||||
function click(e: MouseEvent, row: SettingRow, direction: number) {
|
||||
const hold = holds.get(e.currentTarget as HTMLElement);
|
||||
if (hold?.held) { hold.held = false; return; }
|
||||
step(row, direction, false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings" id="general-settings" :class="{ offline }">
|
||||
<span class="status" id="settings-status" role="status">{{ status }}</span>
|
||||
<div v-if="view" class="set-grid" id="settings-groups">
|
||||
<section v-for="group in groups" :key="group.title" class="set-card">
|
||||
<h4><span class="mdi">{{ glyph(group.icon) }}</span>{{ group.title }}</h4>
|
||||
<div v-for="row in group.rows" :key="row.key" class="srow" :class="[`setting-${row.kind}`, { inactive: !needs(row) || unavailable(row) }]" :data-setting="row.key"
|
||||
:title="unavailable(row) && !offline ? 'This entity is off in Home Assistant, or the screen is restarting.' : ''"
|
||||
@click="row.kind === 'toggle' && ($event.target as HTMLElement).closest('.srow') === $event.currentTarget && !($event.target as HTMLElement).closest('button') && !unavailable(row) && setSetting(row.key, !values[row.key], 150)">
|
||||
<span class="s-label" :id="`setting-label-${row.key}`">{{ row.label }}</span>
|
||||
<div class="s-control">
|
||||
<button v-if="row.kind === 'toggle'" type="button" class="switch" :class="{ unknown: values[row.key] === null || values[row.key] === undefined }" role="switch"
|
||||
:id="`setting-${row.key}`" :aria-checked="Boolean(values[row.key]) ? 'true' : 'false'" :aria-labelledby="`setting-label-${row.key}`"
|
||||
:disabled="unavailable(row)" @click.stop="setSetting(row.key, !values[row.key], 150)"></button>
|
||||
<div v-else-if="row.kind === 'choice'" class="seg" role="group" :aria-labelledby="`setting-label-${row.key}`">
|
||||
<button v-for="[value, text] in row.options!" :key="String(value)" type="button" :aria-pressed="values[row.key] === value ? 'true' : 'false'" :disabled="unavailable(row)" @click="setSetting(row.key, value, 150)">{{ text }}</button>
|
||||
</div>
|
||||
<div v-else class="step">
|
||||
<button type="button" :aria-label="`${row.label} lower`" :disabled="stepDisabled(row, -1)" @pointerdown="down($event, row, -1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, -1)"><span class="mdi">{{ glyph("F0374") }}</span></button>
|
||||
<output :id="`setting-${row.key}`" :aria-labelledby="`setting-label-${row.key}`">{{ settingText(row, values) }}</output>
|
||||
<button type="button" :aria-label="`${row.label} higher`" :disabled="stepDisabled(row, 1)" @pointerdown="down($event, row, 1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, 1)"><span class="mdi">{{ glyph("F0415") }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, openIntegrations, PHASES, refresh, route, select, startUpdate, state } from "../store";
|
||||
import type { Screen } from "../types";
|
||||
|
||||
const hostFor = ref<string | null>(null);
|
||||
const host = ref("");
|
||||
function updateState(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.state === "running" || state.updating.includes(screen.id)) return { kind: "running", text: PHASES[u.phase || ""] || "Starting update…" };
|
||||
if (u.state === "queued") return { kind: "queued", text: "Queued for the update" };
|
||||
if (u.available && screen.online) return { kind: "available", text: `Update ${u.target}` };
|
||||
if (u.result && Date.now() / 1000 - u.result.time < 86400) return { kind: u.result.state === "failed" ? "failed" : "done", text: u.result.message };
|
||||
return null;
|
||||
}
|
||||
function update(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.host && u.profile) startUpdate(screen);
|
||||
else if (u.profile) { hostFor.value = screen.id; host.value = ""; }
|
||||
}
|
||||
function startWithHost(screen: Screen) {
|
||||
const address = host.value.trim();
|
||||
if (!address) return;
|
||||
hostFor.value = null;
|
||||
startUpdate(screen, address);
|
||||
}
|
||||
const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: string }) => p.installed
|
||||
? "Installed, but not yet in Home Assistant. 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."
|
||||
: p.downloaded
|
||||
? "Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and allow the 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}.`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="side">
|
||||
<div class="brand"><span class="mark">▦</span><span>ESP Screens</span></div>
|
||||
<span id="connection" class="conn" :class="{ online: state.connected }" role="status">
|
||||
{{ !state.reachable ? "Management page unreachable · retrying…" : state.connected ? "Home Assistant connected" : "Reconnecting to Home Assistant…" }}
|
||||
</span>
|
||||
<div class="label">Screens</div>
|
||||
<div id="screens">
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="screen-item" :class="{ selected: screen.id === state.selected && route === '' }">
|
||||
<button type="button" class="nav-item" :aria-current="screen.id === state.selected && route === '' ? 'true' : 'false'" @click="select(screen.id)">
|
||||
<span class="dot" :class="{ off: !screen.online }"></span>
|
||||
<span class="txt">
|
||||
<span>{{ screen.name }}</span>
|
||||
<small>{{ screen.online ? "Online" : "Offline" }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || "unknown" }}</small>
|
||||
</span>
|
||||
<span v-if="updateState(screen)?.kind === 'available'" class="pill">Update</span>
|
||||
<span v-else-if="updateState(screen)?.kind === 'running'" class="spin small"></span>
|
||||
</button>
|
||||
<div v-if="updateState(screen)" class="screen-update" :class="updateState(screen)!.kind">
|
||||
<template v-if="updateState(screen)!.kind === 'available'">
|
||||
<template v-if="hostFor === screen.id">
|
||||
<small>The IP address, once; new firmware reports it itself.</small>
|
||||
<form class="screen-host" @submit.prevent="startWithHost(screen)">
|
||||
<input v-model="host" placeholder="IP address, e.g. 192.168.1.50" required pattern="[A-Za-z0-9][A-Za-z0-9.\-]*" aria-label="IP address" autofocus />
|
||||
<button type="submit" class="btn mini primary">Start</button>
|
||||
<button type="button" class="icon-btn" aria-label="Cancel" @click="hostFor = null">✕</button>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<small>{{ updateState(screen)!.text }}</small>
|
||||
<button type="button" class="btn mini primary" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found with this device name.'" @click="update(screen)">Update</button>
|
||||
</template>
|
||||
</template>
|
||||
<small v-else :class="{ failed: updateState(screen)!.kind === 'failed' }">{{ updateState(screen)!.text }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending">
|
||||
<div v-for="p in state.inventory.pending || []" :key="p.file" class="pending">
|
||||
<strong>{{ p.friendly }}</strong>
|
||||
<small>{{ pendingText(p) }}</small>
|
||||
<div class="pending-actions">
|
||||
<button type="button" class="btn mini quiet" @click="openIntegrations">Open Devices & services</button>
|
||||
<button v-if="p.api_key" type="button" class="btn mini quiet" @click="copyText(p.api_key!)">Copy API key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="new-screen" type="button" class="nav-item ghost" :aria-current="route === '#new-screen' ? 'true' : 'false'" @click="go('#new-screen')">
|
||||
<span class="plus">+</span><span class="txt">New screen</span>
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="more">
|
||||
<div class="label">More</div>
|
||||
<button id="open-firmware" type="button" class="nav-item" :aria-current="route === '#firmware' ? 'true' : 'false'" @click="go('#firmware')"><span class="mdi">{{ glyph("F0241") }}</span><span class="txt">Firmware & USB</span></button>
|
||||
<button id="open-alerts" type="button" class="nav-item" :aria-current="route === '#alerts' ? 'true' : 'false'" @click="go('#alerts')"><span class="mdi">{{ glyph("F0594") }}</span><span class="txt">Alerts</span></button>
|
||||
<button id="open-settings" type="button" class="nav-item" :aria-current="route === '#settings' ? 'true' : 'false'" @click="go('#settings')"><span class="mdi">{{ glyph("F0493") }}</span><span class="txt">Settings</span></button>
|
||||
<button id="refresh" type="button" class="nav-item ghost" @click="refresh()"><span class="plus">↻</span><span class="txt">Refresh</span></button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// A card on the mockup. A placeholder is the tile being dragged, drawn where it will land.
|
||||
import { computed, nextTick } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { displayNames, effectiveControls, isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { entityName, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
const name = computed(() => props.tile.name || entityName(props.tile.entity));
|
||||
const wide = computed(() => isWide(props.tile));
|
||||
const background = computed(() => state.inventory.backgrounds?.[props.tile.options?.background || ""]?.color);
|
||||
const bare = computed(() => props.tile.options?.background === "none");
|
||||
const display = computed(() => props.tile.options?.display || "standard");
|
||||
const note = computed(() => (display.value !== "standard" ? displayNames[display.value] || display.value : ""));
|
||||
const controls = computed(() => effectiveControls(props.tile, state.inventory));
|
||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const cp = computed(() => state.inventory.icons?.controls || {});
|
||||
const key = (n: string) => (cp.value[n] ? glyph(cp.value[n]) : "");
|
||||
const chosen = computed(() => state.selectedTile === props.tile.entity && state.inspector?.kind === "tile");
|
||||
const live = computed(() => !props.placeholder && state.layout?.tiles.includes(props.tile));
|
||||
const label = computed(() => `${name.value}, slot ${(props.slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(props.slot) + 1}. Enter: configure, arrow keys: move`);
|
||||
const now = computed(() => new Date(state.now));
|
||||
const hourAngle = computed(() => (now.value.getHours() % 12 + now.value.getMinutes() / 60) * 30);
|
||||
const minuteAngle = computed(() => now.value.getMinutes() * 6);
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile.entity); return; }
|
||||
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
// A wide card owns its row: left and right mean the row above and below.
|
||||
if (placeTile(props.tile, props.tile.slot + (wide.value ? Math.sign(step) * 2 : step))) {
|
||||
await nextTick();
|
||||
document.querySelector<HTMLElement>(`.pages [data-slot="${props.tile.slot}"]`)?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tile" :class="{ wide, bare, placeholder: placeholder || !live, chosen }" :data-slot="slot"
|
||||
:style="background && !bare ? { backgroundColor: background } : undefined"
|
||||
:tabindex="live ? 0 : -1" :role="live ? 'button' : undefined" :aria-label="live ? label : undefined"
|
||||
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile.entity)" @keydown="live && onKey($event)">
|
||||
<template v-if="display === 'analog'">
|
||||
<svg class="clockface" viewBox="0 0 60 60" aria-hidden="true">
|
||||
<circle cx="30" cy="30" r="27" fill="#fff" stroke="#c9ccd1" />
|
||||
<line v-for="a in [0, 90, 180, 270]" :key="a" x1="30" y1="5" x2="30" y2="9" stroke="#1b1b1b" stroke-width="1.5" :transform="`rotate(${a} 30 30)`" />
|
||||
<line x1="30" y1="30" x2="30" y2="16" stroke="#1b1b1b" stroke-width="2.4" stroke-linecap="round" :transform="`rotate(${hourAngle} 30 30)`" />
|
||||
<line x1="30" y1="30" x2="30" y2="11" stroke="#1b1b1b" stroke-width="1.6" stroke-linecap="round" :transform="`rotate(${minuteAngle} 30 30)`" />
|
||||
<circle cx="30" cy="30" r="1.8" fill="#1b1b1b" />
|
||||
</svg>
|
||||
<span v-if="wide" class="lead"><span class="tx"><span class="nm">{{ name }}</span><span class="st">{{ note }}</span></span></span>
|
||||
</template>
|
||||
<template v-else-if="wide">
|
||||
<span class="lead">
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx"><span class="nm">{{ name }}</span><span v-if="note" class="st">{{ note }}</span></span>
|
||||
</span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
|
||||
<span v-if="controls" class="ctl">
|
||||
<span v-if="controls === 'toggle'" class="tog"></span>
|
||||
<span v-else-if="controls === 'setpoint'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>20°</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
|
||||
<template v-else-if="controls === 'stepper' && domain.endsWith('select')"><span class="key mdi">{{ key("chevron-left") }}</span><span class="key mdi">{{ key("chevron-right") }}</span></template>
|
||||
<span v-else-if="controls === 'stepper'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>50</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
|
||||
<template v-else-if="controls === 'mode'"><span class="key mdi">{{ key("power") }}</span><span class="key mdi">{{ key("fire") }}</span><span class="key mdi">{{ key("snowflake") }}</span></template>
|
||||
<template v-else-if="controls === 'volume'"><span class="range"></span><span class="key mdi">{{ key("volume-high") }}</span></template>
|
||||
<template v-else-if="controls === 'playback'"><span class="key mdi">{{ key("skip-previous") }}</span><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("skip-next") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'cover'"><span class="key mdi">{{ key("arrow-expand-horizontal") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("arrow-collapse-horizontal") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'vacuum'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("home-map-marker") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'timer'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("close") }}</span></template>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ ({ scene: "Activate", script: "Run" } as Record<string, string>)[domain] || "Press" }}</span>
|
||||
<span v-else class="range"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="note" class="st">{{ note }}</span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
|
||||
</span>
|
||||
</template>
|
||||
<button v-if="live" type="button" class="remove" title="Remove tile" :aria-label="`Remove ${name}`" @click.stop="removeTile(tile)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
// One tile's settings. Every change applies live, so the card on the mockup shows the result while you pick.
|
||||
import { computed } from "vue";
|
||||
import { domainInfo, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { automaticIcon, closeInspector, entityName, markDirty, removeTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
import ActionPicker from "./ActionPicker.vue";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
import Segmented from "./Segmented.vue";
|
||||
|
||||
const props = defineProps<{ tile: Tile }>();
|
||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const name = computed(() => entityName(props.tile.entity));
|
||||
const caps = computed(() => state.capabilities[props.tile.entity]);
|
||||
const current = (key: string, fallback: unknown) => props.tile.options?.[key] ?? fallback;
|
||||
const display = computed(() => current("display", domain.value === "screen" ? "digital" : "standard") as string);
|
||||
const displays = computed(() => {
|
||||
const list: [string, string][] = domain.value === "screen"
|
||||
? [["digital", "Digital clock"], ["analog", "Analog clock"]]
|
||||
: [["standard", "Name and status"], ["watch", "Large value"]];
|
||||
const c = caps.value;
|
||||
if (domain.value === "weather" && (!c || c.displays.includes("forecast") || display.value === "forecast")) list.push(["forecast", "Weather forecast"]);
|
||||
if (domain.value === "sensor" && (!c || c.displays.includes("graph") || display.value === "graph")) list.push(["graph", "Graph"]);
|
||||
if (domain.value === "sun") list.push(["sunpath", "Sun path"]);
|
||||
return list;
|
||||
});
|
||||
const displayHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && display.value === "graph" && !c.displays.includes("graph")) return "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.";
|
||||
if (c && display.value === "forecast" && !c.displays.includes("forecast")) return "This weather service has no daily forecast in Home Assistant. Choose another display.";
|
||||
return "";
|
||||
});
|
||||
const size = computed(() => current("size", "single") as string);
|
||||
const catalogue = computed(() => state.inventory.controls?.[domain.value]);
|
||||
const controls = computed(() => current("controls", catalogue.value?.default) as string);
|
||||
const controlChoices = computed(() => {
|
||||
const c = caps.value;
|
||||
return (catalogue.value?.choices || []).filter((ch) => !c || ch.key === "none" || ch.key === controls.value || c.controls.includes(ch.key)).map((ch) => [ch.key, ch.label] as [string, string]);
|
||||
});
|
||||
const controlHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && controls.value !== "none" && !c.controls.includes(controls.value)) return { text: "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.", warn: true };
|
||||
return { text: supports(0, 2, 19)
|
||||
? "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below."
|
||||
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.", warn: false };
|
||||
});
|
||||
const tap = computed(() => current("tap", "auto") as string);
|
||||
const taps = computed(() => {
|
||||
const list: [string, string][] = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]];
|
||||
// On / off where Home Assistant can toggle the entity, such as a cover; a speaker without on and off gets none.
|
||||
if ((caps.value ? caps.value.toggle : TOGGLE_BEFORE.includes(domain.value)) || tap.value === "toggle") list.push(["toggle", "On / off"]);
|
||||
list.push(["action", "Perform action"]);
|
||||
return list;
|
||||
});
|
||||
const tapHint = computed(() => {
|
||||
if (tap.value === "toggle" && caps.value && !caps.value.toggle) return { text: "Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.", warn: true };
|
||||
if (tap.value === "toggle" && !TOGGLE_BEFORE.includes(domain.value) && !supports(0, 2, 58)) return { text: "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.", warn: false };
|
||||
if (tap.value === "toggle") return { text: "Hold the tile to open its card.", warn: false };
|
||||
return null;
|
||||
});
|
||||
const inline = computed(() => current("inline", "none") as string);
|
||||
const showSlider = computed(() => SLIDER_DOMAINS.includes(domain.value) && (!caps.value || caps.value.inline || inline.value === "slider"));
|
||||
const sliderWarn = computed(() => inline.value === "slider" && caps.value && !caps.value.inline);
|
||||
const history = computed(() => current("history_hours", 24) as number);
|
||||
const backgrounds = computed(() => Object.entries(state.inventory.backgrounds || {}));
|
||||
const fromHA = computed(() => Boolean(state.inventory.entities.find((e) => e.id === props.tile.entity)?.icon));
|
||||
const showIcon = computed(() => Boolean(state.inventory.icons) && domain.value !== "screen" && !["forecast", "sunpath"].includes(display.value));
|
||||
function rename(value: string) {
|
||||
props.tile.name = value;
|
||||
markDirty();
|
||||
}
|
||||
function inspect() {
|
||||
state.inspector = { kind: "inspect", entity: props.tile.entity };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av mdi" :style="{ color: domainInfo(tile.entity)[2], background: domainInfo(tile.entity)[3] }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx"><b>{{ tile.name || name }}</b><small class="mono">{{ tile.entity }}</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="tile-name">Name on the screen</label>
|
||||
<input id="tile-name" :value="tile.name" :placeholder="name" maxlength="60" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<IconPicker v-if="showIcon" :selected="tile.options?.icon || 'auto'" :automatic="automaticIcon(tile.entity)"
|
||||
:auto-label="`Automatic (${fromHA ? 'from Home Assistant' : 'default'})`"
|
||||
:note="supports(0, 2, 18) ? '' : 'The screen shows a chosen icon from firmware 0.2.18.'"
|
||||
@pick="(n) => setTileOption(tile, 'icon', n)" />
|
||||
<div class="f">
|
||||
<span class="f-label">Display</span>
|
||||
<Segmented :choices="displays" :value="display" @pick="(v) => setTileOption(tile, 'display', v)" />
|
||||
<small v-if="displayHint" class="warn">{{ displayHint }}</small>
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Width</span>
|
||||
<Segmented :choices="[['single', 'Normal'], ['wide', 'Double-width']]" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
||||
</div>
|
||||
<div v-if="catalogue && size === 'wide'" class="f">
|
||||
<span class="f-label">Direct control on the tile</span>
|
||||
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
|
||||
<small :class="{ warn: controlHint.warn }">{{ controlHint.text }}</small>
|
||||
</div>
|
||||
<div v-if="domain !== 'screen'" class="f">
|
||||
<span class="f-label">On tap</span>
|
||||
<Segmented :choices="taps" :value="tap" @pick="(v) => setTileOption(tile, 'tap', v)" />
|
||||
<small v-if="tapHint" :class="{ warn: tapHint.warn }">{{ tapHint.text }}</small>
|
||||
</div>
|
||||
<ActionPicker v-if="domain !== 'screen' && tap === 'action'" :tile="tile" />
|
||||
<div v-if="showSlider" class="f">
|
||||
<span class="f-label">Small slider on the tile</span>
|
||||
<Segmented :choices="[['none', 'No'], ['slider', 'Yes, control directly']]" :value="inline" @pick="(v) => setTileOption(tile, 'inline', v)" />
|
||||
<small v-if="sliderWarn" class="warn">Home Assistant has nothing a slider can change for this entity. Choose No.</small>
|
||||
</div>
|
||||
<div v-if="domain === 'sensor'" class="f">
|
||||
<span class="f-label">History</span>
|
||||
<Segmented :choices="[[1, '1 hour'], [6, '6 hours'], [24, '24 hours']]" :value="history" @pick="(v) => setTileOption(tile, 'history_hours', Number(v))" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Pastel background</span>
|
||||
<div class="sw">
|
||||
<button v-for="[key, choice] in backgrounds" :key="key" type="button" :aria-label="`Background: ${choice.label}`"
|
||||
:aria-pressed="(tile.options?.background || 'auto') === key ? 'true' : 'false'" @click="setTileOption(tile, 'background', key)">
|
||||
<i :class="choice.color ? '' : key === 'none' ? 'none' : 'auto'" :style="choice.color ? { background: choice.color } : undefined"></i>{{ choice.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTile(tile)">Remove</button>
|
||||
<span class="spacer"></span>
|
||||
<button v-if="domain !== 'screen'" type="button" class="btn quiet" @click="inspect">Read current data</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { dismissToast, state } from "../store";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="state.toast" id="toast" class="toast" role="status">
|
||||
<span>{{ state.toast.message }}</span>
|
||||
<button v-if="state.toast.action" type="button" @click="state.toast.action.run(); dismissToast()">{{ state.toast.action.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
// Adding to the top bar: the screen's own items, Home Assistant's suggestions, or any entity.
|
||||
import { computed, ref } from "vue";
|
||||
import { BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import { addTopbarItem, automaticIcon, closeInspector, iconNamed, settingValues, state, topbarItems, topbarMax } from "../store";
|
||||
import type { HeaderItem } from "../types";
|
||||
|
||||
const query = ref("");
|
||||
const taken = computed(() => new Set(topbarItems().map(itemKey)));
|
||||
const clock24 = computed(() => settingValues().clock_24h !== false);
|
||||
const samples = computed(() => ({ clock: clockText(clock24.value, new Date(state.now)), analog: "Small dial with the time", date: dateText(new Date(state.now)) } as Record<string, string>));
|
||||
const suggested = computed(() => state.inventory.header?.suggestions?.[state.selected || ""] || []);
|
||||
const matches = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
return state.inventory.entities.filter((e) => `${e.name} ${e.id} ${e.area || ""} ${e.device || ""}`.toLocaleLowerCase().includes(q));
|
||||
});
|
||||
const entityItem = (id: string): HeaderItem => ({ type: "entity", entity: id, content: "state", icon: "auto", show: "always" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av" style="background: var(--seg); color: var(--ink-2); font-size: 18px">+</span>
|
||||
<span class="tx"><b>Add to the top bar</b><small>{{ topbarItems().length }} of {{ topbarMax() }} slots used</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<span class="f-label">From the screen itself</span>
|
||||
<div class="options">
|
||||
<button v-for="b in state.inventory.header?.builtin || []" :key="b.type" type="button" class="option" :disabled="taken.has(itemKey({ type: b.type }))" @click="addTopbarItem({ type: b.type })">
|
||||
<span class="mdi">{{ glyph(iconNamed(BUILTIN_ICONS[b.type])?.cp || "F0150") }}</span>
|
||||
<span class="tx"><strong>{{ b.label }}</strong><small>{{ taken.has(itemKey({ type: b.type })) ? "Already added" : samples[b.type] }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="suggested.length" class="f">
|
||||
<span class="f-label">Suggestions from Home Assistant</span>
|
||||
<div class="options">
|
||||
<button v-for="s in suggested" :key="itemKey(s.item)" type="button" class="option" :disabled="taken.has(itemKey(s.item))" @click="addTopbarItem(s.item)">
|
||||
<span class="mdi">{{ glyph(s.icon || automaticIcon(s.item.entity!)) }}</span>
|
||||
<span class="tx"><strong>{{ s.label }}</strong><small>{{ taken.has(itemKey(s.item)) ? "Already added" : [s.name, s.area].filter(Boolean).join(" · ") }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f">
|
||||
<label class="f-label" for="topbar-search">An entity</label>
|
||||
<input id="topbar-search" v-model="query" type="search" placeholder="Search by name, room, or entity, e.g. temperature or door" aria-label="Search for an entity for the top bar" />
|
||||
<div class="options">
|
||||
<button v-for="e in matches.slice(0, 40)" :key="e.id" type="button" class="option" :disabled="taken.has(itemKey(entityItem(e.id)))" @click="addTopbarItem(entityItem(e.id))">
|
||||
<span class="mdi">{{ glyph(automaticIcon(e.id)) }}</span>
|
||||
<span class="tx"><strong>{{ e.name }}</strong><small>{{ [e.area, e.id].filter(Boolean).join(" · ") }}</small></span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">No entities found.</p>
|
||||
<p v-else-if="matches.length > 40" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" @click="closeInspector">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
// The top bar: the name on the left; on the right up to six items: the time, an analog clock, the date, or an
|
||||
// entity's state or last change. Choices apply live; the bar on every page follows.
|
||||
import { computed, ref } from "vue";
|
||||
import { barLayout, BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import {
|
||||
automaticIcon, barMetrics, closeInspector, entityName, iconNamed, markDirty, moveTopbarItem, openBar, openBarAdd,
|
||||
removeTopbarItem, setSetting, settingValues, setTopbarItems, state, supports, topbarItems, topbarLabel, topbarMax, topbarView,
|
||||
} from "../store";
|
||||
import type { HeaderItem } from "../types";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
import Segmented from "./Segmented.vue";
|
||||
import TopbarSvg from "./TopbarSvg.vue";
|
||||
|
||||
const props = defineProps<{ index: number }>();
|
||||
const items = computed(() => topbarItems());
|
||||
const item = computed<HeaderItem | undefined>(() => items.value[props.index]);
|
||||
const lay = computed(() => {
|
||||
void state.fontsVersion; void state.now; void state.topbarPreviews;
|
||||
return barLayout(items.value, barMetrics.value, state.layout?.title || "Home", topbarView);
|
||||
});
|
||||
const overflow = computed(() => lay.value.dropped);
|
||||
const needed = computed(() => state.inventory.header?.min_firmware || "0.2.32");
|
||||
const supported = computed(() => { const [a, b, c] = needed.value.split(".").map(Number); return supports(a, b, c); });
|
||||
const hint = computed(() => supported.value
|
||||
? overflow.value.size ? "Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name." : "Drag to reorder; tap an item to configure it."
|
||||
: `Sensors, the date, and the analog clock appear from firmware ${needed.value}; until that update, this screen shows the name and, if the time is in the bar, the clock.`);
|
||||
const detail = (it: HeaderItem, i: number) => {
|
||||
const view = topbarView(it);
|
||||
return !view.shown ? "Hidden: not active right now" : overflow.value.has(i) ? "Doesn't fit next to the name" : view.analog ? "Dial" : view.text;
|
||||
};
|
||||
const iconOf = (it: HeaderItem) => {
|
||||
const view = topbarView(it);
|
||||
return view.analog || it.type !== "entity" ? iconNamed(BUILTIN_ICONS[it.type])?.cp : view.icon;
|
||||
};
|
||||
const justAdded = (it: HeaderItem) => state.topbarAdded?.key === itemKey(it) && Date.now() - state.topbarAdded.time < 1200;
|
||||
function rename(value: string) {
|
||||
if (!state.layout) return;
|
||||
state.layout.title = value;
|
||||
markDirty();
|
||||
}
|
||||
function update(patch: Partial<HeaderItem>) {
|
||||
const list = [...items.value];
|
||||
list[props.index] = { ...list[props.index], ...patch };
|
||||
setTopbarItems(list);
|
||||
}
|
||||
const liveNote = computed(() => {
|
||||
if (!item.value) return "";
|
||||
const view = topbarView(item.value);
|
||||
return !view.shown ? "Hidden right now: not active" : overflow.value.has(props.index) ? "Doesn't currently fit next to the name" : "How it looks on the screen";
|
||||
});
|
||||
const clock24 = computed(() => settingValues().clock_24h !== false);
|
||||
const samples = computed(() => ({ clock: clockText(clock24.value, new Date(state.now)), analog: "Small dial with the time", date: dateText(new Date(state.now)) } as Record<string, string>));
|
||||
|
||||
// Pointer drag between the rows, mouse and touch (touch after a short hold, so the list still scrolls). The order
|
||||
// updates while dragging, the mockup follows, and a finished drag is not a click.
|
||||
const drag = ref({ index: -1, active: false, moved: false });
|
||||
let start: { x: number; y: number } | null = null, timer = 0, pointerId: number | null = null, suppressUntil = 0;
|
||||
function down(e: PointerEvent, i: number) {
|
||||
if (e.button !== 0 || (e.target as HTMLElement).closest(".x")) return;
|
||||
if (e.pointerType !== "touch") e.preventDefault();
|
||||
drag.value = { index: i, active: false, moved: false };
|
||||
start = { x: e.clientX, y: e.clientY };
|
||||
pointerId = e.pointerId;
|
||||
clearTimeout(timer);
|
||||
if (e.pointerType === "touch") timer = window.setTimeout(begin, 260);
|
||||
document.addEventListener("pointermove", move);
|
||||
document.addEventListener("pointerup", end);
|
||||
document.addEventListener("pointercancel", end);
|
||||
}
|
||||
function begin() {
|
||||
drag.value.active = true;
|
||||
document.addEventListener("touchmove", block, { passive: false });
|
||||
}
|
||||
function block(e: TouchEvent) { if (drag.value.active) e.preventDefault(); }
|
||||
function move(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId || !start) return;
|
||||
if (!drag.value.active) {
|
||||
const distance = Math.hypot(e.clientX - start.x, e.clientY - start.y);
|
||||
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(timer); start = null; } return; }
|
||||
if (distance < 6) return;
|
||||
begin();
|
||||
}
|
||||
// The target is the row whose middle the pointer passed.
|
||||
const rows = [...document.querySelectorAll<HTMLElement>(".items .item[data-index]")];
|
||||
let target = drag.value.index;
|
||||
rows.forEach((row, i) => {
|
||||
const r = row.getBoundingClientRect();
|
||||
if (i < drag.value.index && e.clientY < r.top + r.height / 2) target = Math.min(target, i);
|
||||
if (i > drag.value.index && e.clientY > r.top + r.height / 2) target = Math.max(target, i);
|
||||
});
|
||||
if (target !== drag.value.index && state.layout) {
|
||||
const list = [...items.value];
|
||||
list.splice(target, 0, ...list.splice(drag.value.index, 1));
|
||||
state.layout.header = { items: list };
|
||||
const selectedMoved = props.index === drag.value.index;
|
||||
drag.value.index = target;
|
||||
drag.value.moved = true;
|
||||
if (selectedMoved) openBar(target);
|
||||
}
|
||||
}
|
||||
function end(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("pointermove", move);
|
||||
document.removeEventListener("pointerup", end);
|
||||
document.removeEventListener("pointercancel", end);
|
||||
document.removeEventListener("touchmove", block);
|
||||
if (drag.value.active) {
|
||||
suppressUntil = Date.now() + 400;
|
||||
if (drag.value.moved) markDirty();
|
||||
}
|
||||
drag.value = { index: -1, active: false, moved: false };
|
||||
start = null; pointerId = null;
|
||||
}
|
||||
function pick(i: number) {
|
||||
if (Date.now() < suppressUntil) return;
|
||||
openBar(i);
|
||||
}
|
||||
function onKey(e: KeyboardEvent, i: number) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openBar(i); return; }
|
||||
const step = ({ ArrowUp: -1, ArrowDown: 1 } as Record<string, number>)[e.key];
|
||||
if (step && moveTopbarItem(i, i + step)) { if (props.index === i) openBar(i + step); e.preventDefault(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av mdi" style="background: var(--seg); color: var(--ink-2)">{{ item ? glyph(iconOf(item) || "F0150") : glyph("F0150") }}</span>
|
||||
<span class="tx">
|
||||
<b>{{ item ? (item.type === "entity" ? entityName(item.entity!) : topbarLabel(item)) : "Top bar" }}</b>
|
||||
<small :class="{ mono: item?.type === 'entity' }">{{ item ? (item.type === "entity" ? item.entity : "From the screen itself, also works without Home Assistant") : `${items.length} of ${topbarMax()} items` }}</small>
|
||||
</span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="title">Left: name</label>
|
||||
<input id="title" :value="state.layout?.title" maxlength="60" placeholder="For example: Living room" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label" id="topbar-caption">Right: time, sensors, or a clock <span style="text-transform: none; letter-spacing: 0; font-weight: 500"> · {{ items.length }} / {{ topbarMax() }}</span></span>
|
||||
<div class="items" id="topbar-chips" role="list" aria-labelledby="topbar-caption">
|
||||
<div v-for="(it, i) in items" :key="itemKey(it) + i" class="item" role="listitem" tabindex="0" :data-index="i"
|
||||
:class="{ selected: i === index, 'is-hidden': !topbarView(it).shown, 'is-overflow': overflow.has(i), 'just-added': justAdded(it), 'dragging-chip': drag.active && drag.index === i }"
|
||||
:aria-label="`${topbarLabel(it)}, slot ${i + 1}. Enter: configure, arrows: move`"
|
||||
@pointerdown="down($event, i)" @click="pick(i)" @keydown="onKey($event, i)">
|
||||
<span class="grip" aria-hidden="true">⋮⋮</span>
|
||||
<span class="av mdi" :style="topbarView(it).color ? { color: topbarView(it).color! } : undefined">{{ iconOf(it) ? glyph(iconOf(it)!) : "" }}</span>
|
||||
<span class="tx"><b>{{ topbarLabel(it) }}</b><small>{{ detail(it, i) }}</small></span>
|
||||
<button type="button" class="x" :aria-label="`Remove ${topbarLabel(it)} from the top bar`" @click.stop="removeTopbarItem(i)">✕</button>
|
||||
</div>
|
||||
<button type="button" class="ghost-btn" id="topbar-add" :disabled="items.length >= topbarMax()" :title="items.length >= topbarMax() ? `Maximum ${topbarMax()} items` : 'Add the time, date, analog clock, or an entity'" @click="openBarAdd">+ Add time, date, or an entity</button>
|
||||
</div>
|
||||
<small id="topbar-hint" :class="{ warn: overflow.size > 0 && supported }">{{ hint }}</small>
|
||||
</div>
|
||||
<template v-if="item">
|
||||
<div class="live" id="topbar-live">
|
||||
<small>{{ liveNote }}</small>
|
||||
<TopbarSvg :items="[item]" single />
|
||||
</div>
|
||||
<template v-if="item.type === 'entity'">
|
||||
<div class="f">
|
||||
<span class="f-label">What to show</span>
|
||||
<Segmented :choices="(state.inventory.header?.contents || []).map((c) => [c.key, c.label] as [string, string])" :value="item.content" @pick="(v) => update({ content: v })" />
|
||||
<small>Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.</small>
|
||||
</div>
|
||||
<IconPicker :selected="item.icon || 'auto'" :automatic="state.topbarPreviews[itemKey(item)]?.auto_icon || automaticIcon(item.entity!)" auto-label="Automatic (like Home Assistant)" allow-none @pick="(n) => update({ icon: n })" />
|
||||
<div class="f">
|
||||
<span class="f-label">Show</span>
|
||||
<Segmented :choices="(state.inventory.header?.shows || []).map((s) => [s.key, s.label] as [string, string])" :value="item.show" @pick="(v) => update({ show: v })" />
|
||||
<small>Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.</small>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="item.type !== 'date'" class="f">
|
||||
<span class="f-label">Format</span>
|
||||
<Segmented :choices="[['24', '24 hour'], ['12', '12 hour']]" :value="clock24 ? '24' : '12'" @pick="(v) => setSetting('clock_24h', v === '24', 150)" />
|
||||
<small>The screen's own clock setting: it applies at once to every clock on this screen, including the clock tiles.</small>
|
||||
</div>
|
||||
<small v-else>{{ samples.date }} today. The date follows the screen's own clock.</small>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item" class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTopbarItem(index)">Remove</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" :disabled="index === 0" @click="moveTopbarItem(index, index - 1) && openBar(index - 1)">↑ Up</button>
|
||||
<button type="button" class="btn quiet" :disabled="index >= items.length - 1" @click="moveTopbarItem(index, index + 1) && openBar(index + 1)">↓ Down</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
// The bar at the top of a mockup page, drawn as the screen draws it: name left, items right.
|
||||
import { computed } from "vue";
|
||||
import { barLayout, dotted, type BarPart } from "../model/topbar";
|
||||
import { barMetrics, state, topbarItems, topbarView } from "../store";
|
||||
|
||||
const props = defineProps<{ items?: any[]; nameText?: string; single?: boolean }>();
|
||||
|
||||
const lay = computed(() => {
|
||||
void state.fontsVersion;
|
||||
void state.now;
|
||||
void state.topbarPreviews;
|
||||
const items = props.items || topbarItems();
|
||||
return barLayout(items, barMetrics.value, props.nameText ?? (state.layout?.title || "Home"), topbarView);
|
||||
});
|
||||
const m = computed(() => lay.value.metrics);
|
||||
const height = computed(() => (props.single ? Math.round(m.value.text * 1.4) : m.value.top + Math.round(m.value.name * 0.45)));
|
||||
const baseline = computed(() => (props.single ? Math.round(m.value.text * 1.05) : m.value.top));
|
||||
const parts = computed<BarPart[]>(() => {
|
||||
if (!props.single) return lay.value.placed;
|
||||
const part = lay.value.parts[0];
|
||||
if (part) part.x = 0;
|
||||
return part ? [part] : [];
|
||||
});
|
||||
const viewBox = computed(() => (props.single
|
||||
? `-2 0 ${Math.max(1, parts.value[0]?.width || 1) + 4} ${height.value}`
|
||||
: `0 0 ${m.value.width} ${height.value}`));
|
||||
const capMiddle = computed(() => baseline.value + (lay.value.zero.top + lay.value.zero.bottom) / 2);
|
||||
const name = computed(() => dotted(lay.value.nameText, lay.value.fonts.name, Math.min(lay.value.natural, lay.value.nameRoom)));
|
||||
const now = computed(() => new Date(state.now));
|
||||
function hands(d: number) {
|
||||
const n = now.value;
|
||||
return [[(n.getHours() % 12 + n.getMinutes() / 60) * 30, d * 0.24], [n.getMinutes() * 6, d * 0.34]].map(([angle, length]) => {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
return { x2: length * Math.sin(rad), y2: -length * Math.cos(rad) };
|
||||
});
|
||||
}
|
||||
const iconX = (p: BarPart) => p.x! - p.icon!.ink.left;
|
||||
const textX = (p: BarPart) => (p.icon ? p.x! + (p.icon.ink.right - p.icon.ink.left) + lay.value.gaps.icon : p.x!) - p.text!.ink.left;
|
||||
// `font` goes through CSSOM: the add-on's CSP blocks style attributes, and Vue sets styles through the CSSOM.
|
||||
const singleWidth = computed(() => `${(Math.max(1, parts.value[0]?.width || 1) + 4) * (26 / m.value.icon)}px`);
|
||||
defineExpose({ lay });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :viewBox="viewBox" role="img" :aria-label="single ? 'How it looks on the screen' : `Top bar: ${lay.nameText}`" :style="single ? { width: singleWidth } : undefined">
|
||||
<text v-if="!single" x="0" :y="m.top" fill="#1b1b1b" :style="{ font: lay.fonts.name }">{{ name }}</text>
|
||||
<g>
|
||||
<template v-for="p in parts" :key="p.index">
|
||||
<template v-if="p.dial">
|
||||
<circle :cx="p.x! + p.dial / 2" :cy="capMiddle" :r="p.dial / 2 - Math.max(1, Math.round(p.dial / 10)) / 2" fill="none" :stroke="p.view.color || '#46525e'" :stroke-width="Math.max(1, Math.round(p.dial / 10))" />
|
||||
<line v-for="(h, i) in hands(p.dial)" :key="i" :x1="p.x! + p.dial / 2" :y1="capMiddle" :x2="p.x! + p.dial / 2 + h.x2" :y2="capMiddle + h.y2" :stroke="p.view.color || '#46525e'" :stroke-width="Math.max(1, Math.round(p.dial * 0.075))" stroke-linecap="round" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<text v-if="p.icon" :x="iconX(p)" :y="capMiddle - (p.icon.ink.top + p.icon.ink.bottom) / 2" :fill="p.view.color || '#46525e'" :style="{ font: lay.fonts.icon }">{{ p.icon.glyph }}</text>
|
||||
<text v-if="p.text" :x="textX(p)" :y="baseline" fill="#46525e" :style="{ font: lay.fonts.text }">{{ p.text.value }}</text>
|
||||
</template>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Pointer-based drag & drop, mouse and touch, from the library into the mockup and between
|
||||
// cells. Touch starts after a short hold so the page still scrolls. While dragging, the
|
||||
// mockup already shows where everything ends up; the drop confirms exactly that, and a
|
||||
// drop off the grid changes nothing. A finished drag never doubles as a click.
|
||||
import type { Directive } from "vue";
|
||||
import { arrange, newTile } from "./model/layout";
|
||||
import { markDirty, placeTile, state } from "./store";
|
||||
import type { Tile } from "./types";
|
||||
|
||||
export type DragSource = { kind: "tile"; tile: Tile } | { kind: "entity"; id: string };
|
||||
type Drag = {
|
||||
source: DragSource | null; element: HTMLElement | null; ghost: HTMLElement | null; timer: number;
|
||||
start: { x: number; y: number } | null; offset: { x: number; y: number }; pointerId: number | null;
|
||||
suppressUntil: number; last: { x: number; y: number } | null; scroller: number; target: number | null;
|
||||
};
|
||||
const drag: Drag = { source: null, element: null, ghost: null, timer: 0, start: null, offset: { x: 0, y: 0 }, pointerId: null, suppressUntil: 0, last: null, scroller: 0, target: null };
|
||||
|
||||
export const vDrag: Directive<HTMLElement, DragSource> = {
|
||||
mounted(element, binding) {
|
||||
(element as any).__dragSource = binding.value;
|
||||
element.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
const source = (element as any).__dragSource as DragSource;
|
||||
if (e.button !== 0 || (element as HTMLButtonElement).disabled || (e.target as HTMLElement).closest(".remove")) return;
|
||||
// No text selection while the mouse drags; touch keeps its default so the page can scroll.
|
||||
if (e.pointerType !== "touch") e.preventDefault();
|
||||
Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId });
|
||||
// A fast flick may leave the card before its first move event: keep the pointer until the drag begins.
|
||||
try { element.setPointerCapture(e.pointerId); } catch {}
|
||||
clearTimeout(drag.timer);
|
||||
if (e.pointerType === "touch") drag.timer = window.setTimeout(() => beginDrag(e), 260);
|
||||
});
|
||||
element.addEventListener("pointermove", (e: PointerEvent) => {
|
||||
if (state.drag.active || !drag.start || drag.element !== element) return;
|
||||
const distance = Math.hypot(e.clientX - drag.start.x, e.clientY - drag.start.y);
|
||||
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; }
|
||||
if (distance >= 6) beginDrag(e);
|
||||
});
|
||||
const cancel = () => { if (drag.element === element && !state.drag.active) { clearTimeout(drag.timer); drag.start = null; } };
|
||||
element.addEventListener("pointerup", cancel);
|
||||
element.addEventListener("pointercancel", cancel);
|
||||
},
|
||||
updated(element, binding) {
|
||||
(element as any).__dragSource = binding.value;
|
||||
},
|
||||
};
|
||||
|
||||
function beginDrag(e: PointerEvent) {
|
||||
if (state.drag.active || !drag.start || !drag.source || !drag.element) return;
|
||||
state.drag.active = true;
|
||||
state.drag.moving = drag.source.kind === "tile" ? drag.source.tile : newTile(drag.source.id);
|
||||
state.drag.preview = null;
|
||||
drag.target = null;
|
||||
getSelection()?.removeAllRanges();
|
||||
const rect = drag.element.getBoundingClientRect();
|
||||
const ghost = drag.element.cloneNode(true) as HTMLElement;
|
||||
ghost.classList.add("drag-ghost");
|
||||
ghost.style.width = `${rect.width}px`;
|
||||
ghost.style.height = `${rect.height}px`;
|
||||
drag.offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
document.body.append(ghost);
|
||||
drag.ghost = ghost;
|
||||
// The mockup re-renders while hovering, so the pointer is followed on the document, not the card.
|
||||
document.addEventListener("pointermove", moveDrag);
|
||||
document.addEventListener("pointerup", finishDrag);
|
||||
document.addEventListener("pointercancel", finishDrag);
|
||||
try { drag.element.releasePointerCapture(drag.pointerId!); } catch {}
|
||||
try { document.documentElement.setPointerCapture(drag.pointerId!); } catch {}
|
||||
document.addEventListener("touchmove", blockScroll, { passive: false });
|
||||
// Near the edges of the canvas the pages scroll along, so every page can be reached.
|
||||
drag.scroller = window.setInterval(() => {
|
||||
if (!drag.last) return;
|
||||
const canvas = document.querySelector<HTMLElement>(".canvas");
|
||||
if (!canvas) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const dx = drag.last.x < r.left + 60 ? -12 : drag.last.x > r.right - 60 ? 12 : 0;
|
||||
const dy = drag.last.y < r.top + 60 ? -12 : drag.last.y > r.bottom - 60 ? 12 : 0;
|
||||
if (dx || dy) { canvas.scrollBy(dx, dy); setTarget(slotAt(drag.last.x, drag.last.y)); }
|
||||
}, 16);
|
||||
setTarget(-1);
|
||||
moveDrag(e);
|
||||
}
|
||||
function blockScroll(e: TouchEvent) { if (state.drag.active) e.preventDefault(); }
|
||||
function finishDrag(e: PointerEvent) { if (e.pointerId === drag.pointerId) endDrag(e.type === "pointerup"); }
|
||||
function moveDrag(e: PointerEvent) {
|
||||
if (!drag.ghost || e.pointerId !== drag.pointerId) return;
|
||||
drag.last = { x: e.clientX, y: e.clientY };
|
||||
drag.ghost.style.transform = `translate(${e.clientX - drag.offset.x}px, ${e.clientY - drag.offset.y}px)`;
|
||||
setTarget(slotAt(e.clientX, e.clientY));
|
||||
}
|
||||
// The cell under the pointer: the nearest card or empty cell (the gaps between them count
|
||||
// too); on a wide card the left or right half decides. -1 away from the mockup.
|
||||
function slotAt(x: number, y: number) {
|
||||
let best: { cell: HTMLElement; r: DOMRect } | null = null, nearest = Infinity;
|
||||
for (const cell of document.querySelectorAll<HTMLElement>(".pages [data-slot]")) {
|
||||
const r = cell.getBoundingClientRect();
|
||||
const distance = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
|
||||
if (distance < nearest) { nearest = distance; best = { cell, r }; }
|
||||
}
|
||||
if (!best || nearest > 16) return -1;
|
||||
let slot = Number(best.cell.dataset.slot);
|
||||
if (best.cell.classList.contains("wide") && x > (best.r.left + best.r.right) / 2) slot += 1;
|
||||
return slot;
|
||||
}
|
||||
function setTarget(slot: number) {
|
||||
if (drag.target === slot || !state.layout || !state.drag.moving) return;
|
||||
drag.target = slot;
|
||||
// Off the grid: a tile from the grid shows where it came from; a new one shows nowhere yet.
|
||||
state.drag.preview = slot >= 0 ? arrange(state.layout.tiles, state.drag.moving, slot) : null;
|
||||
}
|
||||
function endDrag(drop: boolean) {
|
||||
const preview = state.drag.preview, moving = state.drag.moving;
|
||||
document.removeEventListener("pointermove", moveDrag);
|
||||
document.removeEventListener("pointerup", finishDrag);
|
||||
document.removeEventListener("pointercancel", finishDrag);
|
||||
document.removeEventListener("touchmove", blockScroll);
|
||||
try { document.documentElement.releasePointerCapture(drag.pointerId!); } catch {}
|
||||
drag.ghost?.remove();
|
||||
Object.assign(drag, { ghost: null, suppressUntil: Date.now() + 400, last: null, start: null, target: null, element: null, source: null });
|
||||
clearInterval(drag.scroller);
|
||||
state.drag.active = false;
|
||||
state.drag.preview = null;
|
||||
state.drag.moving = null;
|
||||
if (drop && preview && moving && state.layout) {
|
||||
const before = state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
|
||||
for (const { tile, slot } of preview) { tile.slot = slot; if (!state.layout.tiles.includes(tile)) state.layout.tiles.push(tile); }
|
||||
state.layout.tiles.sort((a, b) => a.slot - b.slot);
|
||||
if (state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before) markDirty();
|
||||
// Placing goes through the same rules as a click; a new tile also asks for its capabilities.
|
||||
if (!before.includes(`${moving.entity}@`)) placeTile(moving, moving.slot);
|
||||
}
|
||||
}
|
||||
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
|
||||
export const dragSuppressed = () => Date.now() < drag.suppressUntil;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import { boot } from "./store";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
boot();
|
||||
@@ -0,0 +1,162 @@
|
||||
// ---- Grid positions ----
|
||||
// Two columns, three rows per page, at most eight pages. A tile's `slot` is its absolute
|
||||
// cell (page * 6 + row * 2 + column); a wide tile starts in the left column and also covers
|
||||
// the cell to its right. Empty cells are allowed and stay exactly where they are.
|
||||
import type { Inventory, Layout, Tile } from "../types";
|
||||
|
||||
export const SLOTS_PER_PAGE = 6;
|
||||
export const MAX_PAGES = 8;
|
||||
export const MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
|
||||
|
||||
export type Entry = { tile: Tile; slot: number };
|
||||
|
||||
export const isWide = (tile: Tile) => tile.options?.size === "wide";
|
||||
export const cellsOf = (slot: number, wide: boolean) => (wide ? [slot, slot + 1] : [slot]);
|
||||
export const rowStart = (slot: number) => slot - (slot % 2);
|
||||
export const pageOf = (slot: number) => Math.floor(slot / SLOTS_PER_PAGE);
|
||||
export const entriesOf = (layout: Layout): Entry[] => layout.tiles.map((tile) => ({ tile, slot: tile.slot }));
|
||||
|
||||
// In-order packing: the rule before positions existed, and what firmware below 0.2.26 still draws.
|
||||
export function packSlots(tiles: Tile[]) {
|
||||
let position = 0;
|
||||
return tiles.map((tile) => {
|
||||
const wide = isWide(tile);
|
||||
if (wide && position % 2 === 1) position++;
|
||||
const slot = position;
|
||||
position += wide ? 2 : 1;
|
||||
return slot;
|
||||
});
|
||||
}
|
||||
export function hasGaps(tiles: Tile[]) {
|
||||
const packed = packSlots(tiles);
|
||||
return tiles.some((tile, i) => tile.slot !== packed[i]);
|
||||
}
|
||||
// Every tile gets a position (older layouts pack in order) and the list stays in reading order.
|
||||
export function normalize(layout: Layout) {
|
||||
if (layout.tiles.some((t) => !Number.isInteger(t.slot))) {
|
||||
const packed = packSlots(layout.tiles);
|
||||
layout.tiles.forEach((t, i) => (t.slot = packed[i]));
|
||||
}
|
||||
layout.tiles.sort((a, b) => a.slot - b.slot);
|
||||
}
|
||||
export function occupied(entries: Entry[]) {
|
||||
const taken = new Set<number>();
|
||||
for (const { tile, slot } of entries) for (const cell of cellsOf(slot, isWide(tile))) taken.add(cell);
|
||||
return taken;
|
||||
}
|
||||
export const fits = (taken: Set<number>, slot: number, wide: boolean) =>
|
||||
Number.isInteger(slot) && slot >= 0 && slot + (wide ? 1 : 0) < MAX_SLOTS && !(wide && slot % 2) && cellsOf(slot, wide).every((c) => !taken.has(c));
|
||||
export function firstFree(taken: Set<number>, wide: boolean) {
|
||||
for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide)) return slot;
|
||||
return -1;
|
||||
}
|
||||
// The free position closest to `origin`; on a tie the later one, so a nudged tile moves down, not up.
|
||||
export function nearestFree(taken: Set<number>, wide: boolean, origin: number) {
|
||||
let best = -1;
|
||||
for (let slot = 0; slot < MAX_SLOTS; slot++)
|
||||
if (fits(taken, slot, wide) && (best < 0 || Math.abs(slot - origin) <= Math.abs(best - origin))) best = slot;
|
||||
return best;
|
||||
}
|
||||
// The arrangement after putting `moving` (a tile on the grid, or a new one) at `target`:
|
||||
// it lands exactly there; tiles in its way take the cells it left (a swap) or else the
|
||||
// nearest free cell; everything else stays put. Null when the target is off the grid.
|
||||
export function arrange(tiles: Tile[], moving: Tile, target: number): Entry[] | null {
|
||||
const wide = isWide(moving);
|
||||
if (wide) target = rowStart(target);
|
||||
if (!fits(new Set(), target, wide)) return null;
|
||||
const footprint = cellsOf(target, wide);
|
||||
const vacated = tiles.includes(moving) ? cellsOf(moving.slot, wide) : [];
|
||||
const result: Entry[] = [{ tile: moving, slot: target }];
|
||||
const displaced: Tile[] = [];
|
||||
for (const tile of tiles) {
|
||||
if (tile === moving) continue;
|
||||
if (cellsOf(tile.slot, isWide(tile)).some((c) => footprint.includes(c))) displaced.push(tile);
|
||||
else result.push({ tile, slot: tile.slot });
|
||||
}
|
||||
for (const tile of displaced) {
|
||||
const w = isWide(tile), taken = occupied(result);
|
||||
let slot = vacated.map((c) => (w ? rowStart(c) : c)).find((c) => fits(taken, c, w));
|
||||
if (slot === undefined) slot = nearestFree(taken, w, tile.slot);
|
||||
if (slot < 0) return null;
|
||||
result.push({ tile, slot });
|
||||
}
|
||||
return result.sort((a, b) => a.slot - b.slot);
|
||||
}
|
||||
// Pages the tiles need, or more when the user keeps empty pages on purpose (`layout.pages`).
|
||||
export function pageCount(entries: Entry[], wanted = 1) {
|
||||
const last = Math.max(0, ...entries.map(({ tile, slot }) => slot + (isWide(tile) ? 2 : 1)));
|
||||
return Math.min(MAX_PAGES, Math.max(1, Math.ceil(last / SLOTS_PER_PAGE), wanted || 1));
|
||||
}
|
||||
// New tiles start with the card that shows the entity best.
|
||||
export function defaultOptions(id: string): Partial<Tile> {
|
||||
const domain = id.split(".")[0];
|
||||
if (domain === "sun") return { options: { display: "sunpath", size: "wide" } };
|
||||
if (domain === "weather") return { options: { display: "forecast", size: "wide" } };
|
||||
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
|
||||
return {};
|
||||
}
|
||||
export const newTile = (id: string): Tile => ({ entity: id, name: "", slot: -1, ...defaultOptions(id) } as Tile);
|
||||
|
||||
// Same rule as the add-on: only a wide card in the standard layout shows direct controls;
|
||||
// without a choice the domain's first control set applies.
|
||||
export function effectiveControls(tile: Tile, inventory: Inventory): string | null {
|
||||
const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {};
|
||||
if (!catalogue || o.size !== "wide" || (o.display || "standard") !== "standard" || o.inline === "slider") return null;
|
||||
const choice = o.controls ?? catalogue.default;
|
||||
return choice === "none" ? null : choice;
|
||||
}
|
||||
export function controlsLabel(tile: Tile, inventory: Inventory) {
|
||||
const key = effectiveControls(tile, inventory);
|
||||
if (!key) return "none";
|
||||
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
|
||||
}
|
||||
|
||||
export const parseVersion = (v: string | undefined) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
|
||||
export function versionAtLeast(version: string | undefined, minimum: string) {
|
||||
const [a, b] = [parseVersion(version), parseVersion(minimum)];
|
||||
if (a.length !== 3 || b.length !== 3) return false;
|
||||
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
||||
return true;
|
||||
}
|
||||
export const supportsFirmware = (firmware: string | undefined, major: number, minor: number, patch: number) =>
|
||||
versionAtLeast(firmware, `${major}.${minor}.${patch}`);
|
||||
export function tileLimit(firmware: string | undefined) {
|
||||
const match = parseVersion(firmware);
|
||||
if (match.length !== 3) return 10;
|
||||
return match[0] > 0 || match[1] > 2 || (match[1] === 2 && match[2] >= 7) ? 20 : 10;
|
||||
}
|
||||
export const displayNames: Record<string, string> = {
|
||||
standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph",
|
||||
digital: "digital clock", analog: "analog clock", sunpath: "sun path",
|
||||
};
|
||||
export const TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
|
||||
export const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"];
|
||||
|
||||
export const domains: Record<string, [string, string, string, string]> = {
|
||||
light: ["Light", "☀", "#ad7600", "#fff3d3"],
|
||||
climate: ["Climate", "❄", "#c86620", "#ffebdc"],
|
||||
vacuum: ["Vacuum", "◉", "#008577", "#def3ed"],
|
||||
fan: ["Fan", "✣", "#008aab", "#def5fa"],
|
||||
cover: ["Cover", "▤", "#8053af", "#eee5f8"],
|
||||
media_player: ["Media", "▶", "#007cad", "#def2fc"],
|
||||
sensor: ["Sensor", "⌁", "#3476b1", "#e5effa"],
|
||||
binary_sensor: ["Status", "◈", "#ad7600", "#fff3d3"],
|
||||
switch: ["Switch", "⏻", "#ad7600", "#fff3d3"],
|
||||
input_boolean: ["Switch", "⏻", "#ad7600", "#fff3d3"],
|
||||
scene: ["Scene", "✦", "#8053af", "#eee5f8"],
|
||||
script: ["Script", "▷", "#8053af", "#eee5f8"],
|
||||
weather: ["Weather", "☁", "#007cad", "#def2fc"],
|
||||
number: ["Value", "±", "#008577", "#def3ed"],
|
||||
input_number: ["Value", "±", "#008577", "#def3ed"],
|
||||
select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
screen: ["Clock", "◷", "#25282c", "#e9ecf1"],
|
||||
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
|
||||
timer: ["Timer", "⏱", "#008577", "#def3ed"],
|
||||
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
|
||||
camera: ["Camera", "◧", "#3d4a57", "#e6ebf0"],
|
||||
image: ["Image", "◧", "#3d4a57", "#e6ebf0"],
|
||||
};
|
||||
export const domainInfo = (id: string) => domains[id.split(".")[0]] || (["Entity", "◇", "#637184", "#edf0f4"] as [string, string, string, string]);
|
||||
@@ -0,0 +1,117 @@
|
||||
// ---- The top bar as the screen draws it ----
|
||||
// One rule set with header_bar.h in the firmware, in screen pixels of the board: every value (the
|
||||
// time too) in the same 400 Roboto on the name's baseline; icons and the dial centred on the
|
||||
// height of the digits; the gaps measured between what you see (glyph ink), not between boxes, so
|
||||
// an icon with side bearings sits exactly as close to its value as one without.
|
||||
import type { HeaderItem } from "../types";
|
||||
|
||||
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
export const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
export const BUILTIN_ICONS: Record<string, string> = { clock: "clock-outline", analog: "clock-outline", date: "calendar" };
|
||||
export const itemKey = (item: HeaderItem) => JSON.stringify(item);
|
||||
export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16));
|
||||
|
||||
export function clockText(clock24: boolean, now = new Date()) {
|
||||
let hours = now.getHours();
|
||||
// The screen formats with %I:%M when the 24-hour clock is off.
|
||||
if (!clock24) hours = hours % 12 || 12;
|
||||
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
export const dateText = (now = new Date()) => `${WEEKDAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`;
|
||||
// Same wording and thresholds as header_bar::ago_text() in the firmware.
|
||||
export function agoText(then: number, now = Math.floor(Date.now() / 1000)) {
|
||||
const seconds = now - then, span = Math.abs(seconds), per = (unit: number) => Math.floor(span / unit);
|
||||
if (seconds < 0) {
|
||||
if (span < 3600) return `In ${Math.max(1, per(60))} min`;
|
||||
if (span < 86400) return per(3600) === 1 ? "In 1 hour" : `In ${per(3600)} hours`;
|
||||
if (span < 172800) return "Tomorrow";
|
||||
return `In ${per(86400)} days`;
|
||||
}
|
||||
if (span < 60) return "Just now";
|
||||
if (span < 3600) return `${per(60)} min ago`;
|
||||
if (span < 86400) return per(3600) === 1 ? "1 hour ago" : `${per(3600)} hours ago`;
|
||||
if (span < 172800) return "Yesterday";
|
||||
if (span < 604800) return `${per(86400)} days ago`;
|
||||
if (span < 2592000) return per(604800) === 1 ? "1 week ago" : `${per(604800)} weeks ago`;
|
||||
if (span < 31536000) return per(2592000) === 1 ? "1 month ago" : `${per(2592000)} months ago`;
|
||||
return per(31536000) === 1 ? "1 year ago" : `${per(31536000)} years ago`;
|
||||
}
|
||||
|
||||
export type BarMetrics = { width: number; top: number; name: number; text: number; icon: number };
|
||||
export const BAR_METRICS: Record<string, BarMetrics> = {
|
||||
guition: { width: 448, top: 36, name: 27, text: 21, icon: 26 },
|
||||
cyd: { width: 298, top: 24, name: 18, text: 14, icon: 18 },
|
||||
};
|
||||
export type ItemView = { icon?: string | null; text?: string; color?: string | null; shown: boolean; analog?: boolean; loading?: boolean };
|
||||
type Ink = { left: number; right: number; top: number; bottom: number; advance: number };
|
||||
|
||||
const measure = document.createElement("canvas").getContext("2d")!;
|
||||
const inkCache = new Map<string, Ink>();
|
||||
export const clearInkCache = () => inkCache.clear();
|
||||
// Ink box of a string relative to its origin on the baseline: left/right, and top (negative, up)/bottom.
|
||||
export function inkOf(text: string, font: string): Ink {
|
||||
const key = `${font}|${text}`;
|
||||
let ink = inkCache.get(key);
|
||||
if (!ink) {
|
||||
measure.font = font;
|
||||
const m = measure.measureText(text);
|
||||
ink = { left: -m.actualBoundingBoxLeft, right: m.actualBoundingBoxRight, top: -m.actualBoundingBoxAscent, bottom: m.actualBoundingBoxDescent, advance: m.width };
|
||||
inkCache.set(key, ink);
|
||||
}
|
||||
return ink;
|
||||
}
|
||||
export const barFonts = (m: BarMetrics) => ({ name: `500 ${m.name}px "Bar Roboto"`, text: `400 ${m.text}px "Bar Roboto"`, icon: `${m.icon}px "Tile Icons"` });
|
||||
// Same integer arithmetic as header_bar::gaps() in the firmware, from the digit height in pixels.
|
||||
export function barGaps(cap: number) {
|
||||
return { icon: Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10)) };
|
||||
}
|
||||
export type BarPart = {
|
||||
index: number; item: HeaderItem; view: ItemView; shown: boolean; width: number; x?: number;
|
||||
dial?: number; icon?: { glyph: string; ink: Ink }; text?: { value: string; ink: Ink };
|
||||
};
|
||||
export type BarLayout = ReturnType<typeof barLayout>;
|
||||
// The parts per item with their ink widths, the placement, and which items fall off.
|
||||
export function barLayout(items: HeaderItem[], metrics: BarMetrics, nameText: string, viewOf: (item: HeaderItem) => ItemView) {
|
||||
const fonts = barFonts(metrics);
|
||||
// The firmware reads the digit height as a whole number of pixels (the glyph box of "0").
|
||||
const zero = inkOf("0", fonts.text), cap = Math.round(zero.bottom - zero.top), gaps = barGaps(cap);
|
||||
const dialInk = inkOf(String.fromCodePoint(0xf0150), fonts.icon), dial = Math.round(dialInk.bottom - dialInk.top);
|
||||
const parts: BarPart[] = items.map((item, index) => {
|
||||
const view = viewOf(item);
|
||||
const part: BarPart = { index, item, view, shown: view.shown, width: 0 };
|
||||
if (view.analog) { part.dial = dial; part.width = dial; return part; }
|
||||
if (view.icon) { part.icon = { glyph: glyph(view.icon), ink: inkOf(glyph(view.icon), fonts.icon) }; part.width += part.icon.ink.right - part.icon.ink.left; }
|
||||
if (view.text) {
|
||||
part.text = { value: view.text, ink: inkOf(view.text, fonts.text) };
|
||||
part.width += (part.icon ? gaps.icon : 0) + part.text.ink.right - part.text.ink.left;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
const shown = parts.filter((p) => p.shown);
|
||||
const natural = inkOf(nameText, fonts.name).advance;
|
||||
const minName = Math.min(natural, Math.floor((metrics.width * 35) / 100));
|
||||
const total = (list: BarPart[]) => list.reduce((sum, p) => sum + p.width, 0) + Math.max(0, list.length - 1) * gaps.item;
|
||||
let first = 0;
|
||||
while (first < shown.length && total(shown.slice(first)) + gaps.name + minName > metrics.width) first++;
|
||||
const placed = shown.slice(first), dropped = new Set(shown.slice(0, first).map((p) => p.index));
|
||||
let x = metrics.width - total(placed);
|
||||
for (const p of placed) { p.x = x; x += p.width + gaps.item; }
|
||||
const nameRoom = placed.length ? placed[0].x! - gaps.name : metrics.width;
|
||||
return { metrics, fonts, cap, zero, gaps, parts, placed, dropped, nameText, natural, nameRoom };
|
||||
}
|
||||
// LVGL's LV_LABEL_LONG_DOT: the longest start that fits with "..." after it.
|
||||
export function dotted(text: string, font: string, room: number) {
|
||||
if (inkOf(text, font).advance <= room) return text;
|
||||
const chars = [...text];
|
||||
while (chars.length && inkOf(chars.join("") + "...", font).advance > room) chars.pop();
|
||||
return chars.join("") + "...";
|
||||
}
|
||||
// The fonts load on first use; measurements before that are wrong, so draw again once they are in.
|
||||
export function whenBarFontsLoad(then: () => void) {
|
||||
if (!("fonts" in document)) return;
|
||||
Promise.all([
|
||||
document.fonts.load('500 27px "Bar Roboto"', "Studio 0"),
|
||||
document.fonts.load('400 21px "Bar Roboto"', "Away 0"),
|
||||
document.fonts.load('26px "Tile Icons"', String.fromCodePoint(0xf0150)),
|
||||
]).then(() => { clearInkCache(); then(); }).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
// One reactive state for the whole editor. The Python API (server.py) is unchanged: this file is the
|
||||
// former app.js state and its calls, with the DOM work moved into the components.
|
||||
import { computed, reactive } from "vue";
|
||||
import { api, getJson, send, setCsrf } from "./api";
|
||||
import {
|
||||
arrange, entriesOf, firstFree, fits, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
|
||||
rowStart, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
|
||||
} from "./model/layout";
|
||||
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
|
||||
import type { Capability, EntityAction, HeaderItem, Inventory, Layout, Screen, Tile } from "./types";
|
||||
|
||||
export type Inspector =
|
||||
| { kind: "tile" }
|
||||
| { kind: "bar"; index: number }
|
||||
| { kind: "bar-add" }
|
||||
| { kind: "inspect"; entity?: string };
|
||||
export type DragState = { active: boolean; moving: Tile | null; preview: { tile: Tile; slot: number }[] | null };
|
||||
|
||||
export const state = reactive({
|
||||
inventory: { screens: [], entities: [] } as Inventory,
|
||||
connected: false,
|
||||
reachable: true,
|
||||
selected: null as string | null,
|
||||
layout: null as Layout | null,
|
||||
dirty: false,
|
||||
busy: false,
|
||||
saved: 0,
|
||||
tab: "layout" as "layout" | "settings",
|
||||
selectedTile: null as string | null,
|
||||
inspector: null as Inspector | null,
|
||||
iconPickerOpen: false,
|
||||
actionPickerOpen: false,
|
||||
actionSearch: "",
|
||||
insertAt: -1,
|
||||
filter: "",
|
||||
search: "",
|
||||
capabilities: {} as Record<string, Capability | null>,
|
||||
entityActions: {} as Record<string, EntityAction[] | null | undefined>,
|
||||
topbarPreviews: {} as Record<string, any>,
|
||||
topbarAdded: null as null | { key: string; time: number },
|
||||
topbarOverflow: [] as number[],
|
||||
settingEdits: {} as Record<string, { value: any; at: number }>,
|
||||
settingPending: false,
|
||||
updating: [] as string[],
|
||||
toast: null as null | { message: string; action?: { label: string; run: () => void } },
|
||||
now: Date.now(),
|
||||
fontsVersion: 0,
|
||||
route: location.hash,
|
||||
overrideProfile: null as string | null,
|
||||
overrideFriendly: "",
|
||||
drag: { active: false, moving: null, preview: null } as DragState,
|
||||
menuOpen: false,
|
||||
});
|
||||
|
||||
export const currentScreen = computed<Screen | undefined>(() => state.inventory.screens.find((s) => s.id === state.selected));
|
||||
export const firmwareOf = computed(() => currentScreen.value?.firmware || "");
|
||||
export const supports = (major: number, minor: number, patch: number) => supportsVersion(firmwareOf.value, major, minor, patch);
|
||||
export const tileLimit = computed(() => limitFor(firmwareOf.value));
|
||||
export const isGuition = computed(() => currentScreen.value?.board === "guition");
|
||||
export const barMetrics = computed(() => BAR_METRICS[isGuition.value ? "guition" : "cyd"]);
|
||||
export const currentTile = computed<Tile | undefined>(() =>
|
||||
state.selectedTile && state.layout ? state.layout.tiles.find((t) => t.entity === state.selectedTile) : undefined);
|
||||
|
||||
// ---- Toasts ----
|
||||
let toastTimer = 0;
|
||||
export function toast(message: string, action?: { label: string; run: () => void }) {
|
||||
state.toast = { message, action };
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => (state.toast = null), action ? 8000 : 5000);
|
||||
}
|
||||
export function dismissToast() {
|
||||
state.toast = null;
|
||||
}
|
||||
export async function copyText(text: string, element?: Element | null, what = "API key") {
|
||||
try {
|
||||
if (!navigator.clipboard || !window.isSecureContext) throw new Error();
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast(`${what} copied.`);
|
||||
} catch {
|
||||
if (element) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
toast(document.execCommand("copy") ? `${what} copied.` : `${what} is selected. Copy with Ctrl+C or Command+C.`);
|
||||
}
|
||||
}
|
||||
export function openIntegrations() {
|
||||
// Pairing happens in Home Assistant itself. This page lives in HA's ingress iframe,
|
||||
// so send the top window to Devices & services (same origin); elsewhere open a tab.
|
||||
const path = "/config/integrations/dashboard";
|
||||
try {
|
||||
window.top!.location.assign(path);
|
||||
} catch {
|
||||
window.open(path, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Routes: the hash keeps a view open across a reload (#settings did before) ----
|
||||
export const routes = ["", "#settings", "#new-screen", "#firmware", "#alerts", "#override"] as const;
|
||||
export type Route = (typeof routes)[number];
|
||||
export const route = computed<Route>(() => (routes.includes(state.route as Route) ? (state.route as Route) : ""));
|
||||
export function go(target: Route) {
|
||||
if (location.hash === target) { state.route = target; return; }
|
||||
location.hash = target;
|
||||
}
|
||||
window.addEventListener("hashchange", () => { state.route = location.hash; window.scrollTo(0, 0); });
|
||||
|
||||
// ---- Names and icons ----
|
||||
export function entityName(id: string) {
|
||||
return state.inventory.entities.find((e) => e.id === id)?.name || state.inventory.builtin?.find((e) => e.id === id)?.name || id;
|
||||
}
|
||||
let iconIndex: { source: unknown; byName: Record<string, { name: string; cp: string; label: string }> } = { source: null, byName: {} };
|
||||
export function iconNamed(name: string | undefined) {
|
||||
if (iconIndex.source !== state.inventory.icons)
|
||||
iconIndex = { source: state.inventory.icons, byName: Object.fromEntries((state.inventory.icons?.groups || []).flatMap((g) => g.icons.map((i) => [i.name, i]))) };
|
||||
return name ? iconIndex.byName[name] : undefined;
|
||||
}
|
||||
// What the firmware draws without a choice: Home Assistant's own icon, else the domain icon.
|
||||
export function automaticIcon(id: string): string {
|
||||
const icons = state.inventory.icons;
|
||||
if (!icons) return "F0335";
|
||||
const entity = state.inventory.entities.find((e) => e.id === id), domain = id.split(".")[0];
|
||||
if (icons.builtin?.[id]) return icons.builtin[id];
|
||||
if (entity?.icon) return entity.icon;
|
||||
if (domain === "weather") return icons.weather[entity?.state || ""] || icons.weather.partlycloudy;
|
||||
if (domain === "sun") return icons.sun[entity?.state || ""] || icons.sun.below_horizon;
|
||||
return icons.defaults[domain] || icons.fallback;
|
||||
}
|
||||
export const tileIconCp = (tile: Tile) => iconNamed(tile.options?.icon)?.cp || automaticIcon(tile.entity);
|
||||
|
||||
// ---- Capabilities and actions from Home Assistant ----
|
||||
const askedCapabilities = new Set<string>();
|
||||
export async function loadCapabilities(entities: string[]) {
|
||||
const wanted = [...new Set(entities)].filter((id) => !askedCapabilities.has(id) && !id.startsWith("screen."));
|
||||
if (!wanted.length) return;
|
||||
wanted.forEach((id) => askedCapabilities.add(id));
|
||||
try {
|
||||
for (let i = 0; i < wanted.length; i += 40) {
|
||||
const query = wanted.slice(i, i + 40).map((id) => `entity=${encodeURIComponent(id)}`).join("&");
|
||||
Object.assign(state.capabilities, (await getJson(`capabilities?${query}`)).capabilities || {});
|
||||
}
|
||||
} catch {
|
||||
wanted.forEach((id) => askedCapabilities.delete(id));
|
||||
}
|
||||
}
|
||||
const askedActions = new Set<string>();
|
||||
export async function loadEntityActions(entity: string) {
|
||||
if (askedActions.has(entity)) return;
|
||||
askedActions.add(entity);
|
||||
try {
|
||||
state.entityActions[entity] = (await getJson(`entity-actions?entity=${encodeURIComponent(entity)}`)).actions;
|
||||
} catch {
|
||||
askedActions.delete(entity);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selecting a screen and editing its layout ----
|
||||
export function markDirty() {
|
||||
state.dirty = true;
|
||||
state.saved = 0;
|
||||
}
|
||||
export function select(id: string | null) {
|
||||
if (id !== state.selected && state.dirty && !confirm("You have unsaved changes. Open a different screen anyway?")) return;
|
||||
if (id !== state.selected) {
|
||||
flushSettings();
|
||||
state.settingEdits = {};
|
||||
}
|
||||
state.selected = id;
|
||||
state.selectedTile = null;
|
||||
state.inspector = null;
|
||||
state.tab = "layout";
|
||||
state.menuOpen = false;
|
||||
const screen = state.inventory.screens.find((s) => s.id === id);
|
||||
if (!screen) { state.layout = null; return; }
|
||||
// A plain copy: the inventory is reactive, and structuredClone refuses a proxy.
|
||||
state.layout = JSON.parse(JSON.stringify(screen.layout));
|
||||
normalize(state.layout);
|
||||
state.layout.pages = pageCount(entriesOf(state.layout), state.layout.pages);
|
||||
state.insertAt = -1;
|
||||
state.dirty = false;
|
||||
state.saved = 0;
|
||||
loadTopbarPreview(0);
|
||||
loadCapabilities(state.layout.tiles.map((t) => t.entity));
|
||||
go("");
|
||||
}
|
||||
export const liveEntries = () => (state.layout ? entriesOf(state.layout) : []);
|
||||
// Apply an arrangement; a new tile joins the layout. True when anything changed.
|
||||
function commit(result: { tile: Tile; slot: number }[]) {
|
||||
const layout = state.layout!;
|
||||
const before = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
|
||||
for (const { tile, slot } of result) { tile.slot = slot; if (!layout.tiles.includes(tile)) layout.tiles.push(tile); }
|
||||
normalize(layout);
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
const changed = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before;
|
||||
if (changed) markDirty();
|
||||
return changed;
|
||||
}
|
||||
export function placeTile(tile: Tile, target: number) {
|
||||
if (!state.layout) return false;
|
||||
loadCapabilities([tile.entity]);
|
||||
const result = arrange(state.layout.tiles, tile, target);
|
||||
return result ? commit(result) : false;
|
||||
}
|
||||
// A click in the picker: the marked empty cell, else the first free cell.
|
||||
export function addTile(id: string) {
|
||||
const layout = state.layout;
|
||||
if (!layout || layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit.value) return;
|
||||
const tile = newTile(id);
|
||||
const slot = state.insertAt >= 0 ? state.insertAt : firstFree(occupied(entriesOf(layout)), isWide(tile));
|
||||
state.insertAt = -1;
|
||||
if (slot >= 0 && placeTile(tile, slot)) openTile(id);
|
||||
}
|
||||
export function removeTile(tile: Tile) {
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
const index = layout.tiles.indexOf(tile);
|
||||
if (index < 0) return;
|
||||
layout.tiles.splice(index, 1);
|
||||
if (state.selectedTile === tile.entity) closeInspector();
|
||||
markDirty();
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
toast(`${tile.name || entityName(tile.entity)} removed`, { label: "Undo", run: () => placeTile(tile, tile.slot) });
|
||||
}
|
||||
export function addPage() {
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
layout.pages = Math.min(MAX_PAGES, pageCount(entriesOf(layout), layout.pages) + 1);
|
||||
markDirty();
|
||||
}
|
||||
// An empty page goes; the pages after it move up.
|
||||
export function removePage(page: number) {
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
for (const tile of layout.tiles) if (pageOf(tile.slot) > page) tile.slot -= SLOTS_PER_PAGE;
|
||||
layout.pages = Math.max(1, pageCount(entriesOf(layout), layout.pages) - 1);
|
||||
markDirty();
|
||||
}
|
||||
export function pagesShown() {
|
||||
const layout = state.layout;
|
||||
if (!layout) return 1;
|
||||
const entries = state.drag.preview || entriesOf(layout);
|
||||
const pages = pageCount(entries, layout.pages);
|
||||
// While dragging, one more page waits after the last one.
|
||||
return state.drag.active && pages < MAX_PAGES ? pages + 1 : pages;
|
||||
}
|
||||
// The tile's options change live; a card that becomes double-wide keeps its row when the cell beside it is
|
||||
// free, else it takes the nearest free row (below first); every other tile stays where it is.
|
||||
export function setTileOption(tile: Tile, key: string, value: unknown) {
|
||||
const domain = tile.entity.split(".")[0], caps = state.capabilities[tile.entity], wasWide = isWide(tile);
|
||||
tile.options = { ...tile.options, [key]: value };
|
||||
// Direct controls need the standard layout without a mini slider, and vice versa.
|
||||
if (key === "display" && value === "watch") { tile.options.inline = "none"; if (state.inventory.controls?.[domain]) tile.options.controls = "none"; }
|
||||
if (key === "display" && ["forecast", "sunpath"].includes(value as string)) tile.options.size = "wide";
|
||||
if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (state.inventory.controls?.[domain]) tile.options.controls = "none"; }
|
||||
if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; }
|
||||
// A card that becomes wide gets the first direct control Home Assistant offers when the usual one isn't there.
|
||||
const catalogue = state.inventory.controls?.[domain];
|
||||
if (key === "size" && value === "wide" && caps && catalogue && !("controls" in tile.options) && !caps.controls.includes(catalogue.default))
|
||||
tile.options.controls = catalogue.choices.find((c) => c.key !== "none" && caps.controls.includes(c.key))?.key || "none";
|
||||
markDirty();
|
||||
if (state.layout && isWide(tile) && !wasWide) {
|
||||
const taken = occupied(entriesOf(state.layout).filter((e) => e.tile !== tile)), own = rowStart(tile.slot);
|
||||
const slot = fits(taken, own, true) ? own : nearestFree(taken, true, own);
|
||||
if (slot >= 0) tile.slot = slot;
|
||||
normalize(state.layout);
|
||||
state.layout.pages = pageCount(entriesOf(state.layout), state.layout.pages);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inspector (the drawer) ----
|
||||
export function openTile(entity: string) {
|
||||
if (state.selectedTile !== entity) { state.iconPickerOpen = false; state.actionPickerOpen = false; state.actionSearch = ""; }
|
||||
state.selectedTile = entity;
|
||||
state.inspector = { kind: "tile" };
|
||||
loadCapabilities([entity]);
|
||||
}
|
||||
export function openBar(index: number) {
|
||||
if (!(state.inspector?.kind === "bar" && state.inspector.index === index)) state.iconPickerOpen = false;
|
||||
state.selectedTile = null;
|
||||
state.inspector = { kind: "bar", index };
|
||||
}
|
||||
export function openBarAdd() {
|
||||
state.selectedTile = null;
|
||||
state.inspector = { kind: "bar-add" };
|
||||
}
|
||||
export function closeInspector() {
|
||||
state.inspector = null;
|
||||
state.selectedTile = null;
|
||||
}
|
||||
|
||||
// ---- Save ----
|
||||
export async function save() {
|
||||
if (state.busy || !state.layout || !state.selected) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
// Screen settings apply on their own (flushSettings); the stored ones stay as they are.
|
||||
const { settings: _settings, ...tiles } = state.layout;
|
||||
await send(`screens/${encodeURIComponent(state.selected)}`, "PUT", tiles);
|
||||
state.dirty = false;
|
||||
state.saved = Date.now();
|
||||
toast("Saved. Your screen is being updated.");
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Top bar ----
|
||||
// Without a stored top bar the screen shows what it always did: the clock of show_clock.
|
||||
export const topbarItems = (): HeaderItem[] =>
|
||||
state.layout?.header?.items ?? ((state.layout?.settings?.show_clock ?? true) ? [{ type: "clock" }] : []);
|
||||
export const topbarMax = () => state.inventory.header?.max_items || 6;
|
||||
export function setTopbarItems(items: HeaderItem[]) {
|
||||
if (!state.layout) return;
|
||||
state.layout.header = { items };
|
||||
markDirty();
|
||||
loadTopbarPreview();
|
||||
}
|
||||
let topbarTimer = 0;
|
||||
// Entity text as the screen will show it, for the items not previewed yet.
|
||||
export function loadTopbarPreview(delay = 150) {
|
||||
clearTimeout(topbarTimer);
|
||||
topbarTimer = window.setTimeout(async () => {
|
||||
const items = topbarItems();
|
||||
if (!items.some((item) => item.type === "entity")) return;
|
||||
try {
|
||||
const data = await send("header-preview", "POST", { header: { items } });
|
||||
items.forEach((item, i) => (state.topbarPreviews[itemKey(item)] = data.items[i]));
|
||||
} catch {
|
||||
// Keep the last preview; the next edit or refresh tries again.
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
export function topbarLabel(item: HeaderItem) {
|
||||
if (item.type === "entity") return entityName(item.entity!);
|
||||
return state.inventory.header?.builtin.find((b) => b.type === item.type)?.label || item.type;
|
||||
}
|
||||
// What the item shows right now: { icon, text, color, shown }. Entities wait for the add-on's preview.
|
||||
export function topbarView(item: HeaderItem): ItemView {
|
||||
const now = new Date(state.now);
|
||||
if (item.type === "clock") return { text: clockText(settingValues().clock_24h !== false, now), shown: true };
|
||||
if (item.type === "date") return { text: dateText(now), shown: true };
|
||||
if (item.type === "analog") return { analog: true, shown: true };
|
||||
const p = state.topbarPreviews[itemKey(item)];
|
||||
if (!p) return { icon: item.icon === "none" ? null : iconNamed(item.icon)?.cp || automaticIcon(item.entity!), text: "…", shown: true, loading: true };
|
||||
return { icon: p.i || null, text: p.k === "ago" ? agoText(p.e, Math.floor(state.now / 1000)) : p.t, color: p.c ? `#${p.c}` : null, shown: p.shown };
|
||||
}
|
||||
export function moveTopbarItem(from: number, to: number) {
|
||||
const items = [...topbarItems()];
|
||||
if (to < 0 || to >= items.length || from === to) return false;
|
||||
items.splice(to, 0, ...items.splice(from, 1));
|
||||
setTopbarItems(items);
|
||||
return true;
|
||||
}
|
||||
export function removeTopbarItem(index: number) {
|
||||
const items = [...topbarItems()];
|
||||
const [item] = items.splice(index, 1);
|
||||
if (!item) return;
|
||||
if (state.inspector?.kind === "bar") closeInspector();
|
||||
setTopbarItems(items);
|
||||
toast(`${topbarLabel(item)} removed from the top bar`, {
|
||||
label: "Undo",
|
||||
run: () => { const back = [...topbarItems()]; back.splice(Math.min(index, back.length), 0, item); setTopbarItems(back); },
|
||||
});
|
||||
}
|
||||
export function addTopbarItem(item: HeaderItem) {
|
||||
const items = topbarItems();
|
||||
if (items.length >= topbarMax()) return toast(`The top bar has room for ${topbarMax()} items.`);
|
||||
if (items.some((other) => itemKey(other) === itemKey(item))) return toast("This is already in the top bar.");
|
||||
// The new chip lights up briefly so the eye finds it.
|
||||
state.topbarAdded = { key: itemKey(item), time: Date.now() };
|
||||
setTopbarItems([...items, item]);
|
||||
openBar(items.length);
|
||||
}
|
||||
|
||||
// ---- Screen settings: the same groups and rows as the settings page on the screen itself ----
|
||||
// Every change applies at once, like on the screen; no Save needed. A screen with firmware 0.2.49+ owns its
|
||||
// settings and ESP Screens changes them through its entities in Home Assistant.
|
||||
export const SETTING_GROUPS = [
|
||||
{ title: "Brightness", icon: "F0599", rows: [
|
||||
{ key: "brightness", label: "Brightness", kind: "number", min: 5, max: 100, step: 5, unit: "%" },
|
||||
{ key: "dark_mode", label: "Dark mode", kind: "toggle" },
|
||||
{ key: "standby_enabled", label: "Auto standby", kind: "toggle" },
|
||||
{ key: "standby_seconds", label: "Standby after", kind: "duration", min: 60, max: 86400, needs: "standby_enabled" },
|
||||
{ key: "standby_brightness", label: "Standby brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "standby_enabled", cap: "brightness" },
|
||||
] },
|
||||
{ title: "Night", icon: "F0594", rows: [
|
||||
{ key: "night_enabled", label: "Night mode", kind: "toggle" },
|
||||
{ key: "night_start", label: "Starts", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_end", label: "Ends", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_brightness", label: "Night brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "night_enabled", cap: "brightness" },
|
||||
] },
|
||||
{ title: "Screen", icon: "F0379", rows: [
|
||||
{ key: "clock_24h", label: "Clock", kind: "choice", options: [[false, "12 hour"], [true, "24 hour"]] },
|
||||
{ key: "auto_home", label: "Back to page 1", kind: "toggle" },
|
||||
{ key: "auto_home_seconds", label: "After", kind: "duration", min: 30, max: 3600, needs: "auto_home" },
|
||||
{ key: "home_on_standby", label: "Also on standby", kind: "toggle" },
|
||||
{ key: "swipe_pages", label: "Swipe between pages", kind: "toggle" },
|
||||
{ key: "rotation", label: "Rotation", kind: "choice", options: [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]] },
|
||||
] },
|
||||
] as const;
|
||||
export type SettingRow = (typeof SETTING_GROUPS)[number]["rows"][number] & { min?: number; max?: number; step?: number; unit?: string; needs?: string; cap?: string; options?: readonly (readonly [unknown, string])[] };
|
||||
// Changes made here that the screen has not reported back yet win over what Home Assistant still shows for a
|
||||
// few seconds, so a value never flicks back while it travels.
|
||||
const SETTING_EDIT_MS = 4000;
|
||||
let settingQueue: Record<string, any> = {}, settingTarget: string | null = null, settingTimer = 0, settingFlight: Promise<Response> | null = null;
|
||||
export const settingsView = () => currentScreen.value?.settings;
|
||||
export function settingValues(): Record<string, any> {
|
||||
const view = settingsView(), values = { ...(view?.values || {}) };
|
||||
for (const [key, edit] of Object.entries(state.settingEdits)) values[key] = edit.value;
|
||||
return values;
|
||||
}
|
||||
// The same steps as settings_screen.h: seconds low down, quarters of an hour up top; times by the quarter,
|
||||
// whole hours while held.
|
||||
export const ladderStep = (seconds: number) => (seconds < 300 ? 30 : seconds < 900 ? 60 : seconds < 3600 ? 300 : seconds < 7200 ? 900 : 1800);
|
||||
export function steppedSetting(row: SettingRow, value: number, direction: number, held: boolean, values: Record<string, any>) {
|
||||
if (row.kind === "moment") {
|
||||
let next = held && value % 60 ? Math.floor(value / 60) * 60 + (direction > 0 ? 60 : 0) : value + direction * (held ? 60 : 15);
|
||||
next %= 1440;
|
||||
return next < 0 ? next + 1440 : next;
|
||||
}
|
||||
const step = row.kind === "duration" ? ladderStep(direction < 0 ? value - 1 : value) : row.step!;
|
||||
const max = row.cap ? Math.min(row.max!, values[row.cap]) : row.max!;
|
||||
return Math.min(max, Math.max(row.min!, value + direction * step));
|
||||
}
|
||||
export function durationText(seconds: number) {
|
||||
if (seconds < 60) return `${seconds} sec`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} min`;
|
||||
const hours = Math.floor(seconds / 3600), minutes = Math.floor((seconds % 3600) / 60);
|
||||
return minutes ? `${hours} h ${String(minutes).padStart(2, "0")}` : `${hours} h`;
|
||||
}
|
||||
export function momentText(minutes: number, clock24: boolean) {
|
||||
const hour = Math.floor(minutes / 60), minute = String(minutes % 60).padStart(2, "0");
|
||||
return clock24 ? `${String(hour).padStart(2, "0")}:${minute}` : `${hour % 12 || 12}:${minute} ${hour < 12 ? "AM" : "PM"}`;
|
||||
}
|
||||
export function settingText(row: SettingRow, values: Record<string, any>) {
|
||||
const value = values[row.key];
|
||||
// Home Assistant has no value while the screen is offline or the entity is off.
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (row.kind === "number") return `${value}${row.unit || ""}`;
|
||||
if (row.kind === "duration") return durationText(value);
|
||||
if (row.kind === "moment") return momentText(value, values.clock_24h !== false);
|
||||
return "";
|
||||
}
|
||||
export function setSetting(key: string, value: any, delay: number) {
|
||||
// One screen's changes at a time: the ones for the screen shown before go out first.
|
||||
if (settingTarget && settingTarget !== state.selected && Object.keys(settingQueue).length) {
|
||||
flushSettings();
|
||||
toast("Still saving the other screen's settings. Try again in a moment.");
|
||||
return;
|
||||
}
|
||||
settingTarget = state.selected;
|
||||
const values = settingValues();
|
||||
state.settingEdits[key] = { value, at: Date.now() };
|
||||
settingQueue[key] = value;
|
||||
// A lower brightness pulls both dim levels down with it, as on the screen.
|
||||
if (key === "brightness")
|
||||
for (const dim of ["standby_brightness", "night_brightness"])
|
||||
if (values[dim] > value) state.settingEdits[dim] = { value, at: Date.now() };
|
||||
state.settingPending = true;
|
||||
clearTimeout(settingTimer);
|
||||
settingTimer = window.setTimeout(() => flushSettings(), delay);
|
||||
}
|
||||
export async function flushSettings(unloading = false) {
|
||||
clearTimeout(settingTimer);
|
||||
if (settingFlight || !Object.keys(settingQueue).length || !settingTarget) return;
|
||||
const screen = settingTarget, changes = settingQueue;
|
||||
settingQueue = {};
|
||||
const request = api(`screens/${encodeURIComponent(screen)}/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ settings: changes }),
|
||||
keepalive: unloading,
|
||||
});
|
||||
settingFlight = request;
|
||||
try {
|
||||
const view = await (await request).json();
|
||||
const current = state.inventory.screens.find((s) => s.id === screen);
|
||||
if (current) current.settings = view;
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
// What did not arrive is not kept: the panel shows the screen's own values again.
|
||||
if (screen === state.selected) for (const key of Object.keys(changes)) delete state.settingEdits[key];
|
||||
if (screen === state.selected && changes.brightness !== undefined) for (const dim of ["standby_brightness", "night_brightness"]) delete state.settingEdits[dim];
|
||||
} finally {
|
||||
settingFlight = null;
|
||||
if (Object.keys(settingQueue).length) settingTimer = window.setTimeout(() => flushSettings(), 150);
|
||||
else settingTarget = null;
|
||||
state.settingPending = Boolean(Object.keys(settingQueue).length);
|
||||
if (screen === state.selected) settleSettings();
|
||||
// A value the screen refused or clamped comes back without a live update: look again once edits expire.
|
||||
setTimeout(() => { if (screen === state.selected) settleSettings(); }, SETTING_EDIT_MS + 100);
|
||||
}
|
||||
}
|
||||
// Values Home Assistant reports take over again once they match a change made here, or after a few seconds
|
||||
// (the screen refused or clamped it).
|
||||
export function settleSettings() {
|
||||
const view = settingsView();
|
||||
for (const [key, edit] of Object.entries(state.settingEdits)) {
|
||||
if (settingQueue[key] !== undefined || settingFlight) continue;
|
||||
if ((view && view.values[key] === edit.value) || Date.now() - edit.at > SETTING_EDIT_MS) delete state.settingEdits[key];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Updates ----
|
||||
export const PHASES: Record<string, string> = {
|
||||
install: "Building and installing…",
|
||||
verify: "Waiting for the screen to come back…",
|
||||
settle: "Checking that it stays stable…",
|
||||
};
|
||||
export async function startUpdate(screen: Screen, host?: string) {
|
||||
state.updating.push(screen.id);
|
||||
try {
|
||||
await send(`screens/${encodeURIComponent(screen.id)}/update`, "POST", host ? { host } : {});
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
state.updating = state.updating.filter((id) => id !== screen.id);
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
export async function runUpdateAll() {
|
||||
try {
|
||||
await send("updates/run", "POST");
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
export async function setAutoUpdate(auto: boolean) {
|
||||
try {
|
||||
await send("updates", "PUT", { auto });
|
||||
if (state.inventory.updates) state.inventory.updates.auto = auto;
|
||||
toast(auto ? "Screens will now update automatically at night." : "Automatic updates are off.");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
export async function installClaudeSkill() {
|
||||
try {
|
||||
state.inventory.claude_skill = await send("claude-skill", "POST");
|
||||
toast(state.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: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inventory: full catalogue, light polls, and the live stream ----
|
||||
export async function refresh(full = true) {
|
||||
try {
|
||||
const data = await getJson(full ? "inventory" : "inventory?light=1");
|
||||
// A light poll carries only screens and update status; keep the catalogues we have.
|
||||
state.inventory = full ? data : { ...state.inventory, ...data };
|
||||
if (data.csrf) setCsrf(data.csrf);
|
||||
state.connected = Boolean(state.inventory.connected);
|
||||
state.reachable = true;
|
||||
for (const screen of state.inventory.screens) if (screen.update?.state === "running") state.updating = state.updating.filter((id) => id !== screen.id);
|
||||
if (state.selected) settleSettings();
|
||||
} catch {
|
||||
state.reachable = false;
|
||||
}
|
||||
}
|
||||
function applyLive(data: Partial<Inventory>) {
|
||||
state.inventory = { ...state.inventory, ...data } as Inventory;
|
||||
state.connected = Boolean(state.inventory.connected);
|
||||
for (const screen of state.inventory.screens) if (screen.update?.state === "running") state.updating = state.updating.filter((id) => id !== screen.id);
|
||||
if (state.selected) settleSettings();
|
||||
}
|
||||
let pollTimer = 0, lastFull = Date.now(), live = false, stream: EventSource | null = null;
|
||||
function listen() {
|
||||
if (stream || typeof EventSource === "undefined") return;
|
||||
stream = new EventSource("api/events");
|
||||
stream.onopen = () => { live = true; poll(); };
|
||||
stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); };
|
||||
stream.onerror = () => { live = false; poll(); };
|
||||
}
|
||||
// Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy.
|
||||
// Live updates arrive over server-sent events; polling is the fallback while the stream is down,
|
||||
// plus a full catalogue refresh every 5 minutes.
|
||||
function poll() {
|
||||
clearTimeout(pollTimer);
|
||||
const wait = live ? 60000 : state.inventory.updates?.busy ? 3000 : 10000;
|
||||
pollTimer = window.setTimeout(async () => {
|
||||
if (!document.hidden) {
|
||||
const full = Date.now() - lastFull >= 300000;
|
||||
if (full) lastFull = Date.now();
|
||||
if (full || !live) await refresh(full);
|
||||
}
|
||||
poll();
|
||||
}, wait);
|
||||
}
|
||||
let booted = false;
|
||||
export function boot() {
|
||||
if (booted) return;
|
||||
booted = true;
|
||||
refresh();
|
||||
listen();
|
||||
poll();
|
||||
whenBarFontsLoad(() => state.fontsVersion++);
|
||||
// The mockup's clocks tick and entity values in the top bar follow Home Assistant while the page is open.
|
||||
setInterval(() => {
|
||||
if (!state.layout || document.hidden || state.drag.active) return;
|
||||
state.now = Date.now();
|
||||
loadTopbarPreview(0);
|
||||
}, 30000);
|
||||
document.addEventListener("visibilitychange", async () => {
|
||||
if (document.hidden) return;
|
||||
lastFull = Date.now();
|
||||
state.now = Date.now();
|
||||
await refresh();
|
||||
poll();
|
||||
});
|
||||
// A change still waiting for its short pause goes out when the page closes.
|
||||
window.addEventListener("pagehide", () => flushSettings(true));
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (state.dirty) { e.preventDefault(); e.returnValue = ""; }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/* ESP Screens editor. Hairlines instead of shadows, one accent for the action of the moment,
|
||||
13 px UI text on an 8 px grid. The device mockup keeps the screen's own light palette. */
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 13px/1.45 var(--ui-font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { background: none; border: 0; padding: 0; cursor: pointer; border-radius: 8px; }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible, summary:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||
}
|
||||
input, select, textarea {
|
||||
background: var(--surface); border: 1px solid var(--line-strong); border-radius: 8px; padding: 7px 10px; width: 100%; color: var(--ink);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: var(--muted); }
|
||||
select { appearance: auto; }
|
||||
h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
|
||||
h1 { font-size: 20px; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 15px; }
|
||||
h3 { font-size: 13px; }
|
||||
h4 { font-size: 13px; }
|
||||
p { margin: 0; }
|
||||
small, .hint { display: block; color: var(--muted); font-size: 11.5px; font-weight: 400; line-height: 1.4; }
|
||||
.hint.warn, small.warn { color: var(--warn); }
|
||||
code { font-family: var(--mono); font-size: 0.92em; background: var(--code-bg); padding: 1px 6px; border-radius: 5px; overflow-wrap: anywhere; }
|
||||
pre { margin: 0; padding: 12px 14px; background: var(--code-bg); border: 1px solid var(--line); border-radius: 10px; overflow: auto; font: 12px/1.5 var(--mono); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.mdi { font-family: "Tile Icons"; font-style: normal; font-weight: normal; line-height: 1; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ---- Shell ---- */
|
||||
.app { display: grid; grid-template-columns: 232px minmax(0, 1fr); height: 100%; min-height: 100vh; }
|
||||
.app.dragging { user-select: none; -webkit-user-select: none; cursor: grabbing; }
|
||||
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
|
||||
/* Sidebar */
|
||||
.side { background: var(--surface); border-right: 1px solid var(--line); display: flex; flex-direction: column; padding: 14px 10px; gap: 4px; min-height: 0; overflow: auto; position: sticky; top: 0; height: 100vh; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; padding: 4px 8px 6px; font-weight: 600; font-size: 13px; }
|
||||
.brand .mark { width: 22px; height: 22px; border-radius: 6px; background: var(--ink); color: var(--surface); display: grid; place-items: center; font-size: 14px; line-height: 1; }
|
||||
.conn { display: block; padding: 0 8px 8px; font-size: 11px; color: var(--muted); }
|
||||
.conn::before { content: "●"; margin-right: 6px; color: var(--warn); }
|
||||
.conn.online::before { color: var(--good); }
|
||||
.side .label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); padding: 10px 8px 4px; }
|
||||
.nav-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 8px; border-radius: 8px; text-align: left; color: var(--ink-2); }
|
||||
.nav-item:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.nav-item[aria-current="true"] { background: var(--seg); color: var(--ink); font-weight: 500; }
|
||||
.nav-item .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); flex: none; }
|
||||
.nav-item .dot.off { background: var(--line-strong); }
|
||||
.nav-item .txt { display: grid; min-width: 0; flex: 1; }
|
||||
.nav-item .txt small { font-size: 11px; color: var(--muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-item .mdi { font-size: 16px; width: 18px; text-align: center; color: var(--muted); }
|
||||
.nav-item .plus { width: 18px; text-align: center; font-size: 14px; }
|
||||
.nav-item.ghost { color: var(--muted); }
|
||||
.nav-item.ghost:hover { color: var(--accent); background: var(--accent-soft); }
|
||||
.pill { font-size: 10.5px; font-weight: 600; padding: 2px 7px; border-radius: 999px; background: var(--warn-soft); color: var(--warn); flex: none; }
|
||||
.side .spacer { flex: 1; }
|
||||
.screen-item { display: grid; gap: 4px; }
|
||||
.screen-update { display: grid; gap: 6px; padding: 2px 8px 8px 24px; }
|
||||
.screen-update.available { grid-template-columns: 1fr auto; align-items: center; }
|
||||
.screen-update.available:has(.screen-host) { grid-template-columns: 1fr; }
|
||||
.screen-update small.failed { color: var(--danger); }
|
||||
.screen-host { display: flex; gap: 6px; align-items: center; }
|
||||
.screen-host input { padding: 5px 8px; font-size: 12px; }
|
||||
.pending { margin: 4px 0; padding: 10px; border: 1px dashed var(--line-strong); border-radius: 10px; display: grid; gap: 6px; }
|
||||
.pending strong { font-size: 12.5px; }
|
||||
.pending-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
/* Buttons and chips */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 12px; border-radius: 8px; font-weight: 600; font-size: 12.5px; white-space: nowrap; }
|
||||
.btn.primary { background: var(--accent); color: var(--accent-ink); }
|
||||
.btn.primary:hover:not(:disabled) { filter: brightness(1.06); }
|
||||
.btn.quiet { color: var(--ink-2); border: 1px solid var(--line-strong); background: var(--surface); }
|
||||
.btn.quiet:hover:not(:disabled) { background: var(--surface-2); color: var(--ink); }
|
||||
.btn.danger { color: var(--danger); }
|
||||
.btn.danger:hover:not(:disabled) { background: var(--danger-soft); }
|
||||
.btn.mini { padding: 4px 9px; font-size: 11.5px; }
|
||||
.btn.link { color: var(--accent); padding: 0; font-weight: 600; }
|
||||
.icon-btn { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 8px; color: var(--ink-2); font-size: 15px; flex: none; }
|
||||
.icon-btn:hover { background: var(--seg); color: var(--ink); }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); white-space: nowrap; }
|
||||
.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--good); flex: none; }
|
||||
.chip.off .dot { background: var(--line-strong); }
|
||||
.chip.dirty { color: var(--warn); background: var(--warn-soft); border-color: transparent; }
|
||||
.chip.sent, .chip.good { color: var(--good); background: var(--good-soft); border-color: transparent; }
|
||||
.chip.update { color: var(--warn); background: var(--warn-soft); border-color: transparent; }
|
||||
.seg { display: inline-flex; padding: 2px; background: var(--seg); border-radius: 8px; gap: 2px; }
|
||||
.seg button { padding: 4px 11px; border-radius: 6px; font-size: 12px; font-weight: 500; color: var(--ink-2); white-space: nowrap; }
|
||||
.seg button[aria-pressed="true"] { background: var(--surface); color: var(--ink); box-shadow: 0 1px 2px rgba(20, 24, 40, 0.14); }
|
||||
.seg.wrap { flex-wrap: wrap; }
|
||||
.seg button:hover:not(:disabled):not([aria-pressed="true"]) { color: var(--ink); }
|
||||
.spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--line-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; flex: none; }
|
||||
.spin.small { width: 11px; height: 11px; border-width: 1.5px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.switch { width: 34px; height: 20px; border-radius: 10px; background: var(--line-strong); position: relative; flex: none; padding: 0; }
|
||||
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); transition: left 0.12s; }
|
||||
.switch[aria-checked="true"] { background: var(--accent); }
|
||||
.switch[aria-checked="true"]::after { left: 16px; }
|
||||
.switch.unknown::after { left: 9px; opacity: 0.6; }
|
||||
@media (prefers-reduced-motion: reduce) { .switch::after, .drawer, .spin { transition: none; animation: none; } }
|
||||
|
||||
/* Main head */
|
||||
.main-head { display: flex; align-items: center; gap: 12px; padding: 0 18px; min-height: 52px; border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 4; }
|
||||
.main-head h1 { font-size: 15px; }
|
||||
.main-head .eyebrow { font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
|
||||
.head-right { margin-left: auto; display: flex; align-items: center; gap: 8px; position: relative; }
|
||||
.menu { position: absolute; top: 38px; right: 0; min-width: 210px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); padding: 6px; display: grid; z-index: 6; }
|
||||
.menu button { text-align: left; padding: 7px 10px; border-radius: 7px; color: var(--ink); display: flex; justify-content: space-between; gap: 12px; width: 100%; }
|
||||
.menu button small { color: var(--muted); display: inline; }
|
||||
.menu button:hover:not(:disabled) { background: var(--surface-2); }
|
||||
|
||||
/* Body: canvas + library, drawer over the right side */
|
||||
.body { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 300px; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.canvas { overflow: auto; padding: 22px 22px 26px; min-width: 0; }
|
||||
.canvas-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; color: var(--muted); font-size: 12px; flex-wrap: wrap; }
|
||||
.canvas-head b { color: var(--ink); font-weight: 600; font-size: 13px; }
|
||||
.canvas-head .warn { color: var(--warn); }
|
||||
.pages { display: flex; gap: 18px; align-items: flex-start; }
|
||||
.page { flex: 0 0 292px; display: grid; gap: 8px; }
|
||||
.page-label { display: flex; justify-content: space-between; align-items: center; font-size: 11.5px; color: var(--muted); padding: 0 4px; min-height: 22px; }
|
||||
.page-label .btn.mini { color: var(--muted); }
|
||||
.page-label .btn.mini:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
.page.ghost .device { display: grid; place-items: center; border: 1.5px dashed var(--line-strong); background: transparent; color: var(--muted); font-weight: 500; cursor: pointer; }
|
||||
.page.ghost .device:hover, .page.ghost .device:focus-visible { color: var(--accent); border-color: var(--accent); }
|
||||
.page.ghost .device.drop { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.page.ghost.disabled .device { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* The device mock keeps the Guition light palette on purpose: it is the screen, not the page. */
|
||||
.device { background: #e7e7e7; border-radius: 18px; padding: 10px; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; color: #1b1b1b; aspect-ratio: 1 / 1; }
|
||||
.device.cyd { aspect-ratio: 4 / 3; }
|
||||
.bar-wrap { cursor: pointer; border-radius: 8px; padding: 0 2px; }
|
||||
.bar-wrap:hover { background: #dedede; }
|
||||
.bar-wrap.selected { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.bar-wrap svg { display: block; width: 100%; height: auto; }
|
||||
.tiles { display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: minmax(0, 1fr); gap: 8px; min-height: 0; }
|
||||
.tile { position: relative; background: #fff; border: 1px solid #dcdcdc; border-radius: 12px; padding: 8px 9px; display: flex; flex-direction: column; justify-content: space-between; gap: 4px; min-height: 0; cursor: pointer; text-align: left; overflow: hidden; color: #1b1b1b; touch-action: pan-y; }
|
||||
.tile:hover { border-color: #b9c4d3; }
|
||||
.tile.chosen { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.tile.wide { grid-column: span 2; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.tile.bare { background: transparent; border-style: dashed; border-color: #c3c9d0; }
|
||||
.tile.placeholder { opacity: 0.55; border: 2px dashed var(--accent); background: var(--accent-soft); cursor: default; }
|
||||
.tile .ic { font-size: 18px; line-height: 1; color: #1b1b1b; flex: none; }
|
||||
.tile .lead { display: grid; gap: 2px; min-width: 0; flex: 1; align-content: end; }
|
||||
.tile.wide .lead { display: flex; align-items: center; gap: 8px; align-content: initial; }
|
||||
.tile.wide .lead .tx { display: grid; min-width: 0; gap: 1px; }
|
||||
.tile .nm { font-size: 11.5px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tile .st { font-size: 10.5px; color: #5a5f66; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.device.cyd .tile { padding: 6px 7px; }
|
||||
.device.cyd .tile .nm { font-size: 10.5px; }
|
||||
.device.cyd .tile .ic { font-size: 15px; }
|
||||
.tile .mini-slider { height: 10px; border-radius: 5px; background: linear-gradient(to right, #ffbf38 60%, #fff1d3 60%); flex: none; width: 100%; }
|
||||
.tile.wide .mini-slider { width: 70px; }
|
||||
.tile .ctl { flex: none; display: flex; align-items: center; gap: 4px; color: #1b1b1b; }
|
||||
.tile .tog { width: 30px; height: 17px; border-radius: 9px; background: #2196f3; position: relative; }
|
||||
.tile .tog::after { content: ""; position: absolute; top: 2px; right: 2px; width: 13px; height: 13px; border-radius: 50%; background: #fff; }
|
||||
.tile .range { width: 56px; height: 14px; border-radius: 7px; background: linear-gradient(to right, #2196f3 35%, #d3e8fb 35%); }
|
||||
.tile .key { height: 20px; min-width: 22px; padding: 0 5px; border-radius: 6px; background: #eceef1; display: grid; place-items: center; font-size: 12px; }
|
||||
.tile .stp { display: flex; align-items: center; gap: 5px; height: 20px; padding: 0 7px; border-radius: 10px; background: #eceef1; font-size: 11px; font-weight: 600; }
|
||||
.tile .stp .mdi { font-size: 12px; }
|
||||
.tile .run { height: 20px; padding: 0 9px; border-radius: 10px; background: #eceef1; font-size: 11px; font-weight: 600; line-height: 20px; }
|
||||
.tile .remove { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border-radius: 50%; background: #ffffffd9; color: #5a5f66; border: 1px solid #dcdcdc; font-size: 10px; line-height: 1; display: grid; place-items: center; opacity: 0; transition: opacity 0.12s; }
|
||||
.tile:hover .remove, .tile:focus-within .remove { opacity: 1; }
|
||||
.tile .remove:hover { color: var(--danger); border-color: #e7b4b0; background: #fff; }
|
||||
@media (hover: none) { .tile .remove { opacity: 1; } }
|
||||
.tile .clockface { width: 46px; height: 46px; margin: auto; }
|
||||
.tile.wide .clockface { margin: 0; flex: none; }
|
||||
.cell { min-height: 0; border: 1px dashed #cfd4da; border-radius: 12px; background: transparent; color: #a3abb5; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; font-weight: 500; cursor: pointer; padding: 6px; }
|
||||
.cell span { font-size: 18px; line-height: 1; }
|
||||
.cell small { font-size: 10.5px; color: inherit; }
|
||||
.cell:hover, .cell:focus-visible { border-color: #9eb6cf; color: #4b5563; background: #f6f7f9; }
|
||||
.cell.insert-here { border: 2px dashed var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
.drag-ghost { position: fixed; left: 0; top: 0; pointer-events: none; z-index: 50; opacity: 0.92; box-shadow: var(--shadow); border-radius: 12px; }
|
||||
|
||||
/* Library */
|
||||
.library { border-left: 1px solid var(--line); background: var(--surface); display: flex; flex-direction: column; min-height: 0; }
|
||||
.lib-head { padding: 14px 14px 10px; display: grid; gap: 10px; border-bottom: 1px solid var(--line); }
|
||||
.lib-title { display: flex; align-items: baseline; justify-content: space-between; font-weight: 600; gap: 8px; }
|
||||
.lib-title small { color: var(--muted); font-weight: 400; font-size: 11.5px; }
|
||||
.filters { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
|
||||
.filters::-webkit-scrollbar { display: none; }
|
||||
.filters button { flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); }
|
||||
.filters button[aria-pressed="true"] { background: var(--ink); color: var(--surface); border-color: var(--ink); }
|
||||
.filters .domain-icon { width: 16px; height: 16px; font-size: 11px; border-radius: 50%; display: grid; place-items: center; }
|
||||
.lib-list { overflow: auto; padding: 8px; display: grid; gap: 2px; align-content: start; flex: 1; }
|
||||
.ent { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 9px; width: 100%; text-align: left; cursor: grab; touch-action: pan-y; }
|
||||
.ent:hover:not(:disabled) { background: var(--surface-2); }
|
||||
.ent:disabled { opacity: 0.55; cursor: default; }
|
||||
.ent .av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; font-size: 17px; flex: none; }
|
||||
.ent .tx { display: grid; min-width: 0; flex: 1; }
|
||||
.ent .tx b { font-weight: 500; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ent .tx small { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ent .add { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; color: var(--accent); background: var(--accent-soft); font-size: 15px; font-weight: 600; flex: none; }
|
||||
.ent .add.done { color: var(--good); background: var(--good-soft); font-size: 12px; }
|
||||
.lib-foot { padding: 10px 14px; border-top: 1px solid var(--line); font-size: 11.5px; color: var(--muted); }
|
||||
.domain-icon { display: inline-grid; place-items: center; border-radius: 50%; font-weight: 500; }
|
||||
|
||||
/* Drawer */
|
||||
.drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 360px; max-width: 100%; background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow); transform: translateX(102%); transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); display: flex; flex-direction: column; min-height: 0; z-index: 5; }
|
||||
.drawer.open { transform: none; }
|
||||
.dr-head { display: flex; align-items: center; gap: 10px; padding: 12px 12px 12px 16px; border-bottom: 1px solid var(--line); }
|
||||
.dr-head .av { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; font-size: 20px; flex: none; }
|
||||
.dr-head .tx { flex: 1; min-width: 0; display: grid; }
|
||||
.dr-head .tx b { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dr-head .tx small { color: var(--muted); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dr-head .tx small.mono { font-family: var(--mono); }
|
||||
.dr-body { overflow: auto; padding: 14px 16px 18px; display: grid; gap: 16px; align-content: start; flex: 1; }
|
||||
.f { display: grid; gap: 7px; }
|
||||
.f > .f-label { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
|
||||
.f input, .f select { font-size: 13px; }
|
||||
.row { display: flex; align-items: center; gap: 10px; padding: 7px 10px 7px 8px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--surface); width: 100%; text-align: left; }
|
||||
.row .av { width: 30px; height: 30px; border-radius: 8px; background: var(--surface-2); display: grid; place-items: center; font-size: 18px; flex: none; }
|
||||
.row .tx { flex: 1; min-width: 0; display: grid; }
|
||||
.row .tx b { font-weight: 500; }
|
||||
.row .tx small { font-size: 11px; }
|
||||
.row .link { color: var(--accent); font-weight: 600; font-size: 12px; flex: none; }
|
||||
.sw { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||
.sw button { display: grid; justify-items: center; gap: 4px; padding: 6px 4px 5px; border-radius: 9px; border: 1.5px solid transparent; font-size: 10.5px; color: var(--ink-2); }
|
||||
.sw button i { width: 30px; height: 22px; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.08); display: block; }
|
||||
.sw button i.auto { background: linear-gradient(135deg, #fff 50%, #e5e7eb 50%); }
|
||||
.sw button i.none { background: linear-gradient(135deg, transparent 46%, #8b95a1 46%, #8b95a1 54%, transparent 54%), #e7e7e7; border-style: dashed; }
|
||||
.sw button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--ink); }
|
||||
.picker { display: grid; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.picker input { background: var(--surface); }
|
||||
.icon-list { display: grid; gap: 12px; max-height: 300px; overflow: auto; padding-right: 2px; }
|
||||
.icon-list small { margin-bottom: 6px; }
|
||||
.icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 6px; }
|
||||
.icon-choice { display: grid; place-items: center; height: 40px; background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 9px; }
|
||||
.icon-choice .mdi { font-size: 22px; }
|
||||
.icon-choice[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
||||
.icon-choice.icon-auto { display: flex; justify-content: flex-start; gap: 10px; padding: 0 12px; font-size: 12.5px; font-weight: 500; height: 38px; }
|
||||
.action-list { display: grid; gap: 6px; max-height: 300px; overflow: auto; padding-right: 2px; }
|
||||
.action-choice { display: grid; gap: 2px; padding: 8px 12px; background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 9px; font-size: 12.5px; text-align: left; }
|
||||
.action-choice strong { font-weight: 600; }
|
||||
.action-choice[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); }
|
||||
.action-number { display: flex; align-items: center; gap: 8px; }
|
||||
.action-number input { flex: 1; min-width: 0; }
|
||||
.items { display: grid; gap: 6px; }
|
||||
.item { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--surface); width: 100%; text-align: left; cursor: pointer; touch-action: pan-y; }
|
||||
.item:hover { border-color: var(--accent); }
|
||||
.item.selected { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.item.is-hidden { border-style: dashed; }
|
||||
.item.is-hidden .av, .item.is-hidden b { opacity: 0.55; }
|
||||
.item.is-overflow { border-style: dashed; border-color: var(--warn); }
|
||||
.item.is-overflow small { color: var(--warn); }
|
||||
.item.dragging-chip { border: 2px dashed var(--accent); background: var(--accent-soft); cursor: grabbing; }
|
||||
.item.just-added { animation: chip-added 1.2s ease-out; }
|
||||
@keyframes chip-added { from { background: var(--accent-soft); } to { background: var(--surface); } }
|
||||
.item .grip { color: var(--line-strong); font-size: 13px; cursor: grab; letter-spacing: -2px; flex: none; }
|
||||
.item .av { width: 26px; height: 26px; border-radius: 7px; background: var(--surface-2); display: grid; place-items: center; font-size: 15px; flex: none; }
|
||||
.item .tx { flex: 1; min-width: 0; display: grid; }
|
||||
.item .tx b { font-weight: 500; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.item .tx small { color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.item .x { color: var(--muted); width: 22px; height: 22px; display: grid; place-items: center; border-radius: 6px; flex: none; font-size: 11px; }
|
||||
.item .x:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
.ghost-btn { border: 1.5px dashed var(--line-strong); border-radius: 9px; padding: 8px; color: var(--accent); font-weight: 600; width: 100%; background: transparent; }
|
||||
.ghost-btn:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.dr-foot { margin-top: auto; display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); background: var(--surface-2); flex-wrap: wrap; }
|
||||
.dr-foot .spacer { flex: 1; }
|
||||
.live { display: grid; gap: 6px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: #e7e7e7; color: #1b1b1b; justify-items: start; }
|
||||
.live small { color: #5a5f66; }
|
||||
.live svg { display: block; height: 28px; }
|
||||
.option { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); width: 100%; text-align: left; }
|
||||
.option:hover:not(:disabled) { border-color: var(--accent); }
|
||||
.option .mdi { font-size: 20px; width: 26px; text-align: center; color: var(--ink-2); flex: none; }
|
||||
.option .tx { display: grid; min-width: 0; }
|
||||
.option .tx strong { font-weight: 500; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.option .tx small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.options { display: grid; gap: 6px; }
|
||||
.inspection-tile { display: grid; gap: 3px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; margin-bottom: 6px; }
|
||||
.inspection-tile strong { font-size: 12.5px; }
|
||||
|
||||
/* Screen settings tab */
|
||||
.settings { padding: 22px; overflow: auto; display: grid; gap: 14px; align-content: start; grid-column: 1 / -1; }
|
||||
.settings .status { font-size: 12px; color: var(--muted); }
|
||||
.set-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 14px; }
|
||||
.set-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 6px 14px 8px; }
|
||||
.set-card h4 { display: flex; align-items: center; gap: 8px; padding: 10px 0 8px; }
|
||||
.set-card h4 .mdi { color: var(--muted); font-size: 16px; }
|
||||
.srow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 0; border-top: 1px solid var(--line); }
|
||||
.srow > .s-label { color: var(--ink-2); }
|
||||
.srow.inactive .s-label, .srow.inactive .s-control { opacity: 0.45; }
|
||||
.settings.offline .set-grid { opacity: 0.6; }
|
||||
.step { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.step button { width: 26px; height: 26px; border-radius: 6px; color: var(--ink-2); background: var(--seg); display: grid; place-items: center; }
|
||||
.step button .mdi { font-size: 15px; }
|
||||
.step button:hover:not(:disabled) { color: var(--ink); background: var(--line-strong); }
|
||||
.step output { min-width: 56px; text-align: center; font-weight: 500; }
|
||||
|
||||
/* Pages that replace the old dialogs: new screen, firmware, alerts, override, settings */
|
||||
.panel { padding: 22px 26px 40px; overflow: auto; display: grid; gap: 18px; align-content: start; max-width: 960px; width: 100%; }
|
||||
.panel.wide { max-width: 1120px; }
|
||||
.panel-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.panel-head .tx { display: grid; gap: 2px; flex: 1; min-width: 0; }
|
||||
.panel-head h1 { font-size: 20px; }
|
||||
.panel-head p { color: var(--muted); font-size: 13px; }
|
||||
.panel p, .panel li { color: var(--ink-2); font-size: 13px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px 18px; display: grid; gap: 12px; align-content: start; }
|
||||
.card h2 { font-size: 14px; }
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
.field { display: grid; gap: 6px; }
|
||||
.field > .f-label, fieldset legend { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
|
||||
fieldset { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
|
||||
fieldset:disabled { opacity: 0.6; }
|
||||
.boards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; }
|
||||
.board { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: 10px; cursor: pointer; }
|
||||
.board:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.board input { width: auto; margin: 0; accent-color: var(--accent); }
|
||||
.board b { display: block; font-weight: 600; }
|
||||
.actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.status-line { font-size: 12.5px; color: var(--ink-2); }
|
||||
.status-line.ok { color: var(--good); }
|
||||
.status-line.error { color: var(--danger); }
|
||||
.progress-head { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 14px; }
|
||||
.outcome { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-size: 12px; font-weight: 700; }
|
||||
.outcome.ok { background: var(--good); }
|
||||
.outcome.bad { background: var(--danger); }
|
||||
.steps { margin: 0; padding-left: 20px; display: grid; gap: 8px; }
|
||||
.steps li b { color: var(--ink); }
|
||||
.key-box { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); flex-wrap: wrap; }
|
||||
.key-box code { flex: 1; min-width: 0; background: none; padding: 0; font-size: 12px; }
|
||||
.log { max-height: 320px; }
|
||||
details.log-wrap summary { cursor: pointer; font-weight: 500; color: var(--ink-2); font-size: 12.5px; }
|
||||
details.log-wrap pre { margin-top: 8px; }
|
||||
.check { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; }
|
||||
.check input { width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--accent); }
|
||||
.check span { display: grid; gap: 2px; font-weight: 500; }
|
||||
.node-line { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.tools { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.tool { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); text-align: left; min-width: 220px; }
|
||||
.tool:hover { border-color: var(--accent); }
|
||||
.tool .tool-icon { width: 30px; height: 30px; border-radius: 8px; background: var(--seg); display: grid; place-items: center; font-size: 15px; color: var(--ink-2); flex: none; }
|
||||
.tool .tx { display: grid; }
|
||||
.tool .tx b { font-weight: 600; }
|
||||
|
||||
/* Override YAML editor */
|
||||
.yaml-editor { display: grid; grid-template-columns: 44px minmax(0, 1fr); border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; background: var(--code-bg); min-height: 280px; max-height: 60vh; }
|
||||
.yaml-gutter { padding: 12px 8px; text-align: right; color: var(--muted); font: 12px/1.5 var(--mono); white-space: pre; user-select: none; overflow: hidden; border-right: 1px solid var(--line); }
|
||||
.yaml-editor textarea { border: 0; border-radius: 0; background: transparent; resize: none; font: 12px/1.5 var(--mono); padding: 12px; white-space: pre; overflow: auto; min-height: 280px; tab-size: 2; }
|
||||
.file-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.file-row code { font-size: 12px; }
|
||||
.notice { display: flex; gap: 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.notice .mdi { font-size: 20px; color: var(--ink-2); }
|
||||
.notice strong { display: block; font-weight: 600; margin-bottom: 2px; }
|
||||
|
||||
/* Alerts cheatsheet */
|
||||
.alerts section { display: grid; gap: 10px; }
|
||||
.alerts h2 { font-size: 15px; }
|
||||
.alerts-nav { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.alerts-nav button { background: var(--accent-soft); color: var(--accent); padding: 5px 11px; font-size: 12px; border-radius: 20px; font-weight: 500; }
|
||||
.alert-hero { display: grid; grid-template-columns: minmax(0, 300px) 1fr; gap: 20px; align-items: center; }
|
||||
.alert-mock { background: #101820; border-radius: 16px; padding: 22px 18px; display: grid; place-items: center; }
|
||||
.alert-mock-card { position: relative; width: 100%; background: #ffe1c6; border: 1px solid #0001; border-radius: 14px; padding: 12px 12px 48px 52px; color: #1b1b1b; display: grid; gap: 2px; }
|
||||
.alert-mock-icon { position: absolute; left: 12px; top: 12px; font-size: 30px; }
|
||||
.alert-mock-card strong { font-size: 15px; line-height: 1.2; }
|
||||
.alert-mock-card > span { font-size: 12px; color: #3a3a3c; }
|
||||
.alert-mock-card .fake { position: absolute; right: 10px; bottom: 10px; padding: 6px 16px; border-radius: 999px; background: #1b1b1b; color: white; font-size: 13px; }
|
||||
.alert-screen { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; display: grid; gap: 8px; background: var(--surface); }
|
||||
.alert-screen-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||
.copy-line { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; }
|
||||
.copy-line .copy-label { flex: none; width: 96px; font-size: 12px; color: var(--muted); padding-top: 2px; }
|
||||
.copy-line > code { flex: 1; min-width: 0; padding: 4px 8px; }
|
||||
.copy-line pre { flex: 1; min-width: 0; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; min-width: 560px; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--line); }
|
||||
th { font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
|
||||
td code { white-space: nowrap; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chips .chip .mdi { font-size: 16px; }
|
||||
.chips .chip code { background: none; padding: 0; }
|
||||
.alert-icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
|
||||
.alert-icon { display: grid; justify-items: center; align-content: start; gap: 4px; min-height: 86px; padding: 10px 6px 8px; background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; font-weight: 500; }
|
||||
.alert-icon:hover { border-color: var(--accent); }
|
||||
.alert-icon .mdi { font-size: 26px; }
|
||||
.alert-icon code { background: none; padding: 0; font-size: 11px; color: var(--accent); text-align: center; white-space: normal; }
|
||||
.alert-icon small { font-size: 11px; text-align: center; }
|
||||
.swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||
.swatch { display: flex; align-items: center; gap: 10px; padding: 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); font-size: 12px; text-align: left; font-weight: 500; }
|
||||
.swatch:hover { border-color: var(--accent); }
|
||||
.swatch i { width: 32px; height: 32px; border-radius: 8px; border: 1px solid #0001; flex: none; display: block; }
|
||||
.swatch span { display: grid; gap: 2px; min-width: 0; }
|
||||
.swatch code { background: none; padding: 0; }
|
||||
.alerts-list { padding-left: 18px; margin: 0; display: grid; gap: 8px; }
|
||||
.alerts-list b { color: var(--ink); }
|
||||
.subhead { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin-top: 6px; }
|
||||
|
||||
/* Empty states */
|
||||
.empty { margin: auto; max-width: 420px; text-align: center; display: grid; gap: 10px; justify-items: center; padding: 60px 20px; }
|
||||
.empty-icon { width: 48px; height: 48px; border-radius: 14px; background: var(--seg); color: var(--muted); display: grid; place-items: center; font-size: 26px; }
|
||||
.empty h2 { font-size: 18px; }
|
||||
.empty p { color: var(--muted); }
|
||||
|
||||
/* Toast */
|
||||
.toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; align-items: center; gap: 14px; padding: 10px 16px; background: var(--ink); color: var(--surface); border-radius: 10px; box-shadow: var(--shadow); font-size: 13px; z-index: 60; max-width: min(92vw, 560px); }
|
||||
.toast button { color: #9fc4ff; font-weight: 650; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.app { grid-template-columns: 1fr; min-height: 100vh; }
|
||||
.side { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--line); padding: 10px; gap: 4px 6px; }
|
||||
.side .label, .side .spacer, .conn { display: none; }
|
||||
.side #screens { display: contents; }
|
||||
.screen-item { display: contents; }
|
||||
.screen-update { display: none; }
|
||||
.side #pending { flex-basis: 100%; }
|
||||
.nav-item { width: auto; }
|
||||
.more { display: contents; }
|
||||
.main-head { flex-wrap: wrap; padding: 10px 14px; gap: 8px; position: static; }
|
||||
.head-right { margin-left: 0; width: 100%; }
|
||||
.body { grid-template-columns: 1fr; overflow: visible; }
|
||||
.canvas { padding: 16px; overflow: visible; }
|
||||
.pages { overflow-x: auto; padding-bottom: 8px; }
|
||||
.library { border-left: 0; border-top: 1px solid var(--line); }
|
||||
.lib-list { flex: none; max-height: 360px; }
|
||||
.drawer { position: fixed; top: 0; width: 100%; }
|
||||
.panel { padding: 16px; }
|
||||
.alert-hero { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Design tokens. Light is the default; dark follows the browser (Home Assistant's "auto" theme does too). */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--ground: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f9fafb;
|
||||
--line: #e4e7ec;
|
||||
--line-strong: #d3d8e0;
|
||||
--ink: #16181d;
|
||||
--ink-2: #565d6b;
|
||||
--muted: #7a8290;
|
||||
--accent: #2f63f0;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: #edf2ff;
|
||||
--good: #1e9a5f;
|
||||
--good-soft: #e6f6ee;
|
||||
--warn: #b56d00;
|
||||
--warn-soft: #fff3df;
|
||||
--danger: #c6372e;
|
||||
--danger-soft: #fdecea;
|
||||
--seg: #eef0f3;
|
||||
--shadow: 0 12px 40px rgba(20, 24, 40, 0.12), 0 1px 2px rgba(20, 24, 40, 0.06);
|
||||
--code-bg: #f1f3f6;
|
||||
--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ground: #0e1014;
|
||||
--surface: #161a21;
|
||||
--surface-2: #1b2029;
|
||||
--line: #262c37;
|
||||
--line-strong: #333b49;
|
||||
--ink: #e9ecf1;
|
||||
--ink-2: #b4bbc7;
|
||||
--muted: #8a93a3;
|
||||
--accent: #6d93ff;
|
||||
--accent-ink: #0b1633;
|
||||
--accent-soft: #1b2540;
|
||||
--good: #3fbf7f;
|
||||
--good-soft: #14302a;
|
||||
--warn: #e0a03a;
|
||||
--warn-soft: #302610;
|
||||
--danger: #f07167;
|
||||
--danger-soft: #3a1b19;
|
||||
--seg: #222834;
|
||||
--shadow: 0 16px 48px rgba(0, 0, 0, 0.55), 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--code-bg: #12151b;
|
||||
}
|
||||
}
|
||||
/* The screens draw Material Design Icons; tile-icons.woff holds the same glyphs. The top bar
|
||||
mockup measures text in the firmware's Roboto. */
|
||||
@font-face {
|
||||
font-family: "Tile Icons";
|
||||
src: url("../assets/tile-icons.woff") format("woff");
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bar Roboto";
|
||||
font-weight: 400;
|
||||
src: url("../assets/bar-roboto-400.woff") format("woff");
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bar Roboto";
|
||||
font-weight: 500;
|
||||
src: url("../assets/bar-roboto-500.woff") format("woff");
|
||||
font-display: block;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export type TileOptions = {
|
||||
display?: string;
|
||||
size?: string;
|
||||
controls?: string;
|
||||
inline?: string;
|
||||
tap?: string;
|
||||
icon?: string;
|
||||
background?: string;
|
||||
history_hours?: number;
|
||||
action?: { action: string; data?: Record<string, unknown> };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type Tile = { entity: string; name: string; slot: number; options?: TileOptions };
|
||||
export type HeaderItem = { type: string; entity?: string; content?: string; icon?: string; show?: string };
|
||||
export type Layout = {
|
||||
title: string;
|
||||
tiles: Tile[];
|
||||
pages?: number;
|
||||
header?: { items: HeaderItem[] };
|
||||
settings?: Record<string, any>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type UpdateInfo = {
|
||||
available?: boolean; target?: string; state?: string; phase?: string; host?: string; profile?: string;
|
||||
result?: { state: string; message: string; time: number };
|
||||
};
|
||||
export type SettingsView = { owner: string; keys: string[]; values: Record<string, any>; unavailable: string[] };
|
||||
export type Screen = {
|
||||
id: string; name: string; online: boolean; area?: string; firmware?: string; board?: string;
|
||||
layout: Layout; update?: UpdateInfo; settings?: SettingsView; delivery?: string; status?: string;
|
||||
alert_action?: string; dismiss_action?: string;
|
||||
};
|
||||
export type Entity = { id: string; name: string; area?: string; device?: string; icon?: string; state?: string; tile?: boolean };
|
||||
export type IconInfo = { name: string; cp: string; label: string };
|
||||
export type Inventory = {
|
||||
csrf?: string;
|
||||
connected?: boolean;
|
||||
screens: Screen[];
|
||||
entities: Entity[];
|
||||
builtin?: Entity[];
|
||||
pending?: { friendly: string; file: string; installed?: boolean; downloaded?: boolean; api_key?: string }[];
|
||||
updates?: { target: string; busy?: boolean; pending?: number; auto?: boolean };
|
||||
claude_skill?: { path: string; installed: boolean; current: boolean; restart?: boolean };
|
||||
icons?: {
|
||||
groups: { label: string; icons: IconInfo[] }[];
|
||||
builtin?: Record<string, string>; weather: Record<string, string>; sun: Record<string, string>;
|
||||
defaults: Record<string, string>; fallback: string; controls?: Record<string, string>;
|
||||
};
|
||||
backgrounds?: Record<string, { label: string; color?: string }>;
|
||||
controls?: Record<string, { default: string; choices: { key: string; label: string }[] }>;
|
||||
header?: {
|
||||
max_items?: number; min_firmware?: string;
|
||||
builtin: { type: string; label: string }[];
|
||||
contents: { key: string; label: string }[];
|
||||
shows: { key: string; label: string }[];
|
||||
suggestions?: Record<string, { item: HeaderItem; label: string; name?: string; area?: string; icon?: string }[]>;
|
||||
};
|
||||
alerts?: any;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type Capability = { toggle: boolean; inline: boolean; controls: string[]; displays: string[] };
|
||||
export type EntityAction = {
|
||||
action: string; name: string; description: string;
|
||||
fields: { key: string; name: string; required?: boolean; description?: string; example?: unknown; selector?: Record<string, any>; options?: string[] }[];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"noUnusedLocals": false,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// The add-on serves the built page from screen_manager/app/static behind Home Assistant ingress,
|
||||
// which lives on a sub path with a token. Everything is relative so the page works there and on
|
||||
// a bare http://127.0.0.1:8099 (SCREEN_DEV). The Python server keeps serving /api; only the static
|
||||
// files change. `npm run dev` proxies /api to a running server (the demo home or SCREEN_DEV).
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: "../screen_manager/app/static",
|
||||
emptyOutDir: false,
|
||||
target: "es2022",
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { "/api": { target: "http://127.0.0.1:8099", changeOrigin: false } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user