Dashboard Studio 1.4.0: drag-and-drop dashboards for Home Assistant
A Home Assistant app with its own sidebar tab: free-form grid editor, live entity widgets (lights, climate, car, calendar, Immich photos, Afvalwijzer waste collection and more), themes, tabs with a side menu, kiosk mode and English/Dutch translations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
data/
|
||||||
|
.venv/
|
||||||
|
.claude/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Dashboard Studio for Home Assistant
|
||||||
|
|
||||||
|
A Home Assistant app (formerly called an add-on) that adds a **Dashboard Studio** tab to the sidebar. In that tab you build dashboards on a free-form grid with drag-and-drop, live entity widgets and full theme control.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. In Home Assistant, go to **Settings → Apps → App store**, open the **⋮** menu → **Repositories**, and add `https://git.melvanveen.nl/veenm/ha-dashboard-studio`.
|
||||||
|
2. Find **Dashboard Studio** in the store, install it, start it, and turn on **Show in sidebar**.
|
||||||
|
3. Updates appear in the App store automatically whenever the version in `dashboard_studio/config.yaml` goes up.
|
||||||
|
|
||||||
|
**Local install, no GitHub:** copy the `dashboard_studio` folder into the `/addons` share of your Home Assistant host (for example with the Samba or SSH app). Then go to **Settings → Apps → App store → ⋮ → Check for updates**. It appears under **Local apps**.
|
||||||
|
|
||||||
|
The first build happens on your Home Assistant machine and takes a minute or two.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- `dashboard_studio/server/main.py` is a small aiohttp server. It is reachable only through Home Assistant ingress, so only logged-in Home Assistant users can reach it. It stores dashboards in `/data` and relays the Home Assistant websocket API using the Supervisor token. Your browser never needs a long-lived access token.
|
||||||
|
- `dashboard_studio/web/` is the editor and runtime, written in plain JavaScript with no build step. `widgets.js` contains the widget library. To add a widget type, add an entry there with `fields` and a `render` function.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
Without a `SUPERVISOR_TOKEN`, the server runs against a built-in mock Home Assistant with fake lights, sensors, a thermostat and other entities:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install aiohttp && python3 dashboard_studio/server/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:8099. The Photos widget also has fake Immich albums in this mode. To try your real Immich server, set `IMMICH_URL` and `IMMICH_API_KEY` before starting the server.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Dashboard Studio
|
||||||
|
|
||||||
|
A drag-and-drop dashboard builder that lives in its own sidebar tab.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
1. Open **Dashboard Studio** in the Home Assistant sidebar.
|
||||||
|
2. Press **Edit**. Pick widgets from the left panel, drag them anywhere on the grid, and resize from the bottom-right corner. Other widgets move out of the way.
|
||||||
|
3. Set up the selected widget in the right panel: entity, labels, icon, thresholds, colors.
|
||||||
|
4. With nothing selected, the right panel holds dashboard settings: theme, tile style, colors, background image, grid and kiosk options.
|
||||||
|
5. Press **Done**. Changes save automatically.
|
||||||
|
|
||||||
|
Keyboard shortcuts in edit mode: arrow keys move, Shift+arrows resize, ⌘D or Ctrl+D duplicates, Delete removes, ⌘Z or Ctrl+Z undoes, and ⇧⌘Z or Ctrl+Shift+Z redoes.
|
||||||
|
|
||||||
|
## Look and feel
|
||||||
|
|
||||||
|
- **Theme:** *System* follows your device's light or dark appearance. Light, Dark, Nord, Dusk and Forest are fixed themes. Changing any color switches the theme to *Custom*.
|
||||||
|
- **Tile style:** *Cards* puts every widget on a tile. *Outlined* draws a thin border instead. *Plain* removes the cards completely, so widgets sit straight on the background. You can also turn off the background for a single widget under **Style → No background**.
|
||||||
|
- **Lights that are on** glow: the tile turns warm, or takes on the bulb's color for color lights. In Plain style, the light's icon and brightness bar glow instead.
|
||||||
|
- **Icons:** widgets pick an icon automatically. To change one, type an icon name (the field suggests them) or paste an emoji.
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
Heading, entity tile, big value (with warning and critical levels), gauge, light (on/off and brightness), thermostat, history graph (several sensors), clock, weather, entity list, button (runs any Home Assistant action with JSON data), media player, **car**, **calendar**, camera, text (with `{{ sensor.x }}` live values), **photos** and web page.
|
||||||
|
|
||||||
|
### Photos and Immich
|
||||||
|
|
||||||
|
The Photos widget shows one picture or a slideshow that fades between photos. Pick a source:
|
||||||
|
|
||||||
|
- **Links:** paste one or more image URLs, one per line.
|
||||||
|
- **Immich:** tick one or more albums. Both your own albums and albums shared with you appear.
|
||||||
|
|
||||||
|
To use Immich, open this app's **Configuration** tab in Home Assistant. Enter your Immich URL (for example `http://192.168.1.20:2283`) and an API key, which you create in Immich under **Account Settings → API Keys**. Restart the app afterwards. The key stays on the server: browsers only receive the photos, never the key. You can also set how often the photo changes, shuffle the order, and show the date each photo was taken.
|
||||||
|
|
||||||
|
### Calendar
|
||||||
|
|
||||||
|
Add one or more `calendar.` entities to see an agenda of the coming days, grouped under Today, Tomorrow and then dates. Each calendar gets its own color bar. When you show several calendars, each event is also labelled with its calendar's name, so the color isn't the only clue. An event that is happening right now is marked **Now**. It works with every calendar integration in Home Assistant, such as Google, CalDAV and local calendars. Events refresh every few minutes.
|
||||||
|
|
||||||
|
### Waste collection (Afvalwijzer)
|
||||||
|
|
||||||
|
This card works with the [Afvalwijzer](https://github.com/xirixiz/homeassistant-afvalwijzer) integration. Once that integration is set up for your address, the card finds its waste sensors by itself, so there's nothing to configure. To show only some types, pick the sensors under **Waste sensors**.
|
||||||
|
|
||||||
|
- **Next** layout: the coming pickup large, with every waste type collected that day as a colored bin. When the pickup is tomorrow, it adds a reminder to put the bin out tonight. Later pickups follow in a list.
|
||||||
|
- **List** layout: every pickup as a row with its type, "Tomorrow" or "In 6 days", and the date.
|
||||||
|
|
||||||
|
Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and so on), and each bin is always named, so the color is never the only clue. Types with no pickup planned are hidden. You can limit the card to a number of pickups, or to pickups within a number of days.
|
||||||
|
|
||||||
|
### Car
|
||||||
|
|
||||||
|
Point the car widget at whichever entities your car integration provides. Each one is optional:
|
||||||
|
|
||||||
|
| Field | Typical entity |
|
||||||
|
| --- | --- |
|
||||||
|
| Battery or fuel level (%) | `sensor.<car>_battery` |
|
||||||
|
| Range | `sensor.<car>_range` |
|
||||||
|
| Charging | `binary_sensor.<car>_charging` |
|
||||||
|
| Charging power | `sensor.<car>_charging_power` |
|
||||||
|
| Plugged in | `binary_sensor.<car>_plug` |
|
||||||
|
| Charge limit (%) | `number.<car>_charge_limit` |
|
||||||
|
| Doors | `lock.<car>_doors` |
|
||||||
|
| Climate | `climate.<car>_climate` |
|
||||||
|
| Location | `device_tracker.<car>_location` |
|
||||||
|
| Odometer | `sensor.<car>_odometer` |
|
||||||
|
|
||||||
|
The widget shows the level as a bar with the charge limit marked, the range, and a status line (charging and its power, plugged in, parked at home, or away). It also has buttons to lock or unlock the doors and to start climate. You can add a car image by URL. Images you put in Home Assistant's `www` folder are available at `/local/…`.
|
||||||
|
|
||||||
|
## Tabs and side menu
|
||||||
|
|
||||||
|
A dashboard can have several tabs, each with its own widgets, for example *Overview*, *Household* and *Cameras*. To set them up, deselect all widgets in edit mode and use the **Tabs** section of the dashboard settings:
|
||||||
|
|
||||||
|
- **Add tab** creates a tab and turns on the menu.
|
||||||
|
- Each tab has a name and an icon (an icon name or an emoji). Use the arrows to reorder tabs and the bin to delete one; ⌘Z undoes this.
|
||||||
|
- **Show tabs in a side menu** turns the menu on or off.
|
||||||
|
|
||||||
|
The menu sits on the left on computers, becomes a narrow rail on tablets and while editing, and turns into a tab bar at the bottom on phones. To move a widget to another tab, select it and change **Tab** in the inspector. The menu also shows in kiosk mode.
|
||||||
|
|
||||||
|
## Kiosk mode
|
||||||
|
|
||||||
|
Press **Kiosk** in the toolbar, or open the dashboard with `#kiosk` at the end of its address. Kiosk mode:
|
||||||
|
|
||||||
|
- hides the toolbar and editing panels, and shows no buttons of its own
|
||||||
|
- keeps the screen awake, in browsers that support it
|
||||||
|
- hides the pointer after a few seconds of no movement
|
||||||
|
- can show a dimmed clock screen saver after a number of minutes (set this in the dashboard's **Kiosk** settings)
|
||||||
|
|
||||||
|
**Leaving kiosk mode:** tap the top-right corner of the screen 5 times within a couple of seconds. A short prompt appears with **Leave kiosk**, **Full screen** and **Stay**. If nobody answers, it closes by itself after 8 seconds. Taps in that corner never press the widget underneath. On a keyboard, Escape opens the same prompt. You can move the exit gesture to another corner in the dashboard's **Kiosk** settings. A short hint shows when kiosk mode starts, then disappears.
|
||||||
|
|
||||||
|
On a wall tablet, use **New tab** in the kiosk settings. It opens the dashboard on its own, without the Home Assistant sidebar, and full screen works there.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
Dashboard Studio is available in English and Dutch (Nederlands). By default it follows the language you chose in your Home Assistant profile, or your browser's language if that isn't set. To choose a language for one device, such as a wall tablet, deselect all widgets in edit mode and use **Language on this device** at the bottom of the dashboard settings. The app's Configuration tab in Home Assistant is translated as well.
|
||||||
|
|
||||||
|
## Several dashboards
|
||||||
|
|
||||||
|
Use **+** for a new dashboard. The **⋯** menu (in edit mode) can rename, duplicate, export, import or delete a dashboard. Exported JSON files can be imported on another Home Assistant install.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
Dashboards are stored in the app's `/data/dashboards.json`, so they are included in Home Assistant backups.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
ARG BUILD_FROM
|
||||||
|
FROM ${BUILD_FROM}
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 py3-aiohttp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server/ /app/server/
|
||||||
|
COPY web/ /app/web/
|
||||||
|
COPY run.sh /run.sh
|
||||||
|
RUN chmod a+x /run.sh
|
||||||
|
|
||||||
|
CMD ["/run.sh"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
build_from:
|
||||||
|
aarch64: ghcr.io/home-assistant/aarch64-base:3.20
|
||||||
|
amd64: ghcr.io/home-assistant/amd64-base:3.20
|
||||||
|
armv7: ghcr.io/home-assistant/armv7-base:3.20
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: Dashboard Studio
|
||||||
|
version: "1.4.0"
|
||||||
|
slug: dashboard_studio
|
||||||
|
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
|
||||||
|
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
|
||||||
|
arch:
|
||||||
|
- aarch64
|
||||||
|
- amd64
|
||||||
|
- armv7
|
||||||
|
init: false
|
||||||
|
startup: application
|
||||||
|
boot: auto
|
||||||
|
ingress: true
|
||||||
|
ingress_port: 8099
|
||||||
|
ingress_stream: true
|
||||||
|
panel_icon: mdi:view-dashboard-edit
|
||||||
|
panel_title: Dashboard Studio
|
||||||
|
homeassistant_api: true
|
||||||
|
options:
|
||||||
|
immich_url: ""
|
||||||
|
immich_api_key: ""
|
||||||
|
schema:
|
||||||
|
immich_url: str?
|
||||||
|
immich_api_key: password?
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
# with-contenv restores the container environment (including SUPERVISOR_TOKEN),
|
||||||
|
# which the s6 init in Home Assistant base images otherwise leaves out.
|
||||||
|
bashio::log.info "Starting Dashboard Studio"
|
||||||
|
exec python3 -u /app/server/main.py
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Read-only Immich access for the Photos widget.
|
||||||
|
|
||||||
|
The API key stays on the server: the browser only ever sees album names,
|
||||||
|
asset ids and image bytes proxied through these routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
LOG = logging.getLogger("dashboard_studio.immich")
|
||||||
|
ID_RE = re.compile(r"^[0-9a-fA-F-]{8,64}$")
|
||||||
|
CACHE_SECONDS = 600
|
||||||
|
NOT_CONFIGURED = "Immich isn’t set up. Add its URL and an API key on the app’s Configuration tab."
|
||||||
|
|
||||||
|
|
||||||
|
class Immich:
|
||||||
|
def __init__(self, url, api_key, mock=False):
|
||||||
|
self.url = (url or "").rstrip("/")
|
||||||
|
self.key = api_key or ""
|
||||||
|
self.mock = mock and not (self.url and self.key)
|
||||||
|
self.session = None
|
||||||
|
self._assets = {} # album id -> (fetched_at, [assets])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self):
|
||||||
|
return self.mock or bool(self.url and self.key)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
self.session = aiohttp.ClientSession(
|
||||||
|
headers={"x-api-key": self.key, "Accept": "application/json"},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=20),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self.session:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
async def _json(self, method, path, **kwargs):
|
||||||
|
async with self.session.request(method, f"{self.url}/api{path}", **kwargs) as resp:
|
||||||
|
if resp.status in (401, 403):
|
||||||
|
raise web.HTTPBadGateway(text="Immich rejected the API key. Check it on the app’s Configuration tab.")
|
||||||
|
if resp.status >= 400:
|
||||||
|
raise web.HTTPBadGateway(text=f"Immich returned HTTP {resp.status} for {path}.")
|
||||||
|
return await resp.json()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ data
|
||||||
|
|
||||||
|
async def albums(self):
|
||||||
|
if self.mock:
|
||||||
|
return [
|
||||||
|
{"id": "11111111-aaaa-4bbb-8ccc-000000000001", "name": "Summer holiday", "count": 6},
|
||||||
|
{"id": "11111111-aaaa-4bbb-8ccc-000000000002", "name": "Family", "count": 4},
|
||||||
|
{"id": "11111111-aaaa-4bbb-8ccc-000000000003", "name": "Garden", "count": 3},
|
||||||
|
]
|
||||||
|
rows = await self._json("GET", "/albums")
|
||||||
|
shared = await self._json("GET", "/albums", params={"shared": "true"})
|
||||||
|
seen, out = set(), []
|
||||||
|
for a in rows + shared:
|
||||||
|
if a["id"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(a["id"])
|
||||||
|
out.append({"id": a["id"], "name": a.get("albumName") or "Untitled", "count": a.get("assetCount", 0)})
|
||||||
|
return sorted(out, key=lambda a: a["name"].lower())
|
||||||
|
|
||||||
|
async def album_assets(self, album_id):
|
||||||
|
cached = self._assets.get(album_id)
|
||||||
|
if cached and time.time() - cached[0] < CACHE_SECONDS:
|
||||||
|
return cached[1]
|
||||||
|
if self.mock:
|
||||||
|
n = int(album_id[-1]) + 2
|
||||||
|
assets = [{"id": f"{album_id[:-4]}{i:04d}", "date": f"2026-0{(i % 9) + 1}-1{i % 9}T12:00:00Z"} for i in range(n * 2)]
|
||||||
|
else:
|
||||||
|
assets = await self._fetch_album_assets(album_id)
|
||||||
|
self._assets[album_id] = (time.time(), assets)
|
||||||
|
return assets
|
||||||
|
|
||||||
|
async def _fetch_album_assets(self, album_id):
|
||||||
|
album = await self._json("GET", f"/albums/{album_id}")
|
||||||
|
items = album.get("assets")
|
||||||
|
if not items and album.get("assetCount"):
|
||||||
|
# Newer Immich versions leave assets out of the album response; search instead.
|
||||||
|
items, page = [], 1
|
||||||
|
while page and len(items) < 5000:
|
||||||
|
res = await self._json("POST", "/search/metadata", json={"albumIds": [album_id], "type": "IMAGE", "size": 1000, "page": page})
|
||||||
|
block = res.get("assets", {})
|
||||||
|
items += block.get("items", [])
|
||||||
|
page = block.get("nextPage")
|
||||||
|
page = int(page) if page else None
|
||||||
|
return [
|
||||||
|
{"id": a["id"], "date": a.get("localDateTime") or a.get("fileCreatedAt")}
|
||||||
|
for a in items or []
|
||||||
|
if a.get("type", "IMAGE") == "IMAGE" and not a.get("isTrashed")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def preview(self, asset_id):
|
||||||
|
if self.mock:
|
||||||
|
return self._mock_image(asset_id), "image/svg+xml"
|
||||||
|
async with self.session.get(f"{self.url}/api/assets/{asset_id}/thumbnail", params={"size": "preview"}) as resp:
|
||||||
|
if resp.status >= 400:
|
||||||
|
raise web.HTTPBadGateway(text=f"Immich returned HTTP {resp.status} for that photo.")
|
||||||
|
return await resp.read(), resp.content_type or "image/jpeg"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mock_image(asset_id):
|
||||||
|
h = hashlib.md5(asset_id.encode()).hexdigest()
|
||||||
|
c1, c2 = f"#{h[:6]}", f"#{h[6:12]}"
|
||||||
|
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||||
|
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs>
|
||||||
|
<rect width="1200" height="800" fill="url(#g)"/><circle cx="{300 + int(h[12:14], 16) * 3}" cy="260" r="110" fill="#fff" opacity=".35"/>
|
||||||
|
<path d="M0 640 L300 420 L520 600 L760 360 L1200 700 L1200 800 L0 800Z" fill="#000" opacity=".25"/>
|
||||||
|
<text x="40" y="760" fill="#fff" font-family="sans-serif" font-size="32" opacity=".8">Mock photo {asset_id[-4:]}</text></svg>""".encode()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ routes
|
||||||
|
|
||||||
|
def routes(self):
|
||||||
|
def guard():
|
||||||
|
if not self.configured:
|
||||||
|
raise web.HTTPServiceUnavailable(text=NOT_CONFIGURED)
|
||||||
|
|
||||||
|
async def albums(request):
|
||||||
|
guard()
|
||||||
|
try:
|
||||||
|
return web.json_response(await self.albums())
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
LOG.error("Immich albums: %s", err)
|
||||||
|
raise web.HTTPBadGateway(text="Couldn’t reach Immich. Check the URL on the app’s Configuration tab.")
|
||||||
|
|
||||||
|
async def assets(request):
|
||||||
|
guard()
|
||||||
|
album_id = request.match_info["album_id"]
|
||||||
|
if not ID_RE.match(album_id):
|
||||||
|
raise web.HTTPBadRequest()
|
||||||
|
try:
|
||||||
|
return web.json_response(await self.album_assets(album_id))
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
LOG.error("Immich album %s: %s", album_id, err)
|
||||||
|
raise web.HTTPBadGateway(text="Couldn’t reach Immich.")
|
||||||
|
|
||||||
|
async def preview(request):
|
||||||
|
guard()
|
||||||
|
asset_id = request.match_info["asset_id"]
|
||||||
|
if not ID_RE.match(asset_id):
|
||||||
|
raise web.HTTPBadRequest()
|
||||||
|
try:
|
||||||
|
body, ctype = await self.preview(asset_id)
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
raise web.HTTPBadGateway(text="Couldn’t reach Immich.")
|
||||||
|
return web.Response(body=body, content_type=ctype, headers={"Cache-Control": "private, max-age=86400"})
|
||||||
|
|
||||||
|
return [
|
||||||
|
web.get("/api/immich/albums", albums),
|
||||||
|
web.get("/api/immich/albums/{album_id}/assets", assets),
|
||||||
|
web.get("/api/immich/assets/{asset_id}/preview", preview),
|
||||||
|
]
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Dashboard Studio backend.
|
||||||
|
|
||||||
|
Serves the editor UI over Home Assistant ingress, stores dashboards in /data,
|
||||||
|
and proxies the Home Assistant websocket API using the Supervisor token so the
|
||||||
|
browser never needs its own long-lived access token.
|
||||||
|
|
||||||
|
Without a SUPERVISOR_TOKEN (local development) it runs against a mock Home
|
||||||
|
Assistant with a handful of fake entities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from immich import Immich
|
||||||
|
from mock_ha import MockHA
|
||||||
|
|
||||||
|
LOG = logging.getLogger("dashboard_studio")
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
SUPERVISOR_TOKEN = os.environ.get("SUPERVISOR_TOKEN")
|
||||||
|
# Inside Home Assistant the Supervisor always writes /data/options.json. Fake data is
|
||||||
|
# only for local development, never a silent fallback on a real install.
|
||||||
|
IN_HOME_ASSISTANT = Path("/data/options.json").exists()
|
||||||
|
MOCK = not SUPERVISOR_TOKEN and not IN_HOME_ASSISTANT
|
||||||
|
HA_WS_URL = "ws://supervisor/core/websocket"
|
||||||
|
HA_API_URL = "http://supervisor/core/api"
|
||||||
|
INGRESS_PROXY_IP = "172.30.32.2"
|
||||||
|
|
||||||
|
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
|
||||||
|
DATA_DIR = Path("/data") if Path("/data").is_dir() else Path(__file__).resolve().parent.parent / "data"
|
||||||
|
STORE_FILE = DATA_DIR / "dashboards.json"
|
||||||
|
PORT = int(os.environ.get("PORT", "8099"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_options():
|
||||||
|
"""App options from the Configuration tab (/data/options.json), or env vars when developing."""
|
||||||
|
opts = {}
|
||||||
|
path = Path("/data/options.json")
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
opts = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
LOG.exception("Could not read %s", path)
|
||||||
|
return {
|
||||||
|
"immich_url": opts.get("immich_url") or os.environ.get("IMMICH_URL", ""),
|
||||||
|
"immich_api_key": opts.get("immich_api_key") or os.environ.get("IMMICH_API_KEY", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
OPTIONS = load_options()
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def ingress_only(request, handler):
|
||||||
|
# Only the Supervisor ingress proxy may talk to us; it has already
|
||||||
|
# authenticated the Home Assistant user.
|
||||||
|
if not MOCK and request.remote != INGRESS_PROXY_IP:
|
||||||
|
return web.Response(status=403, text="Access only via Home Assistant ingress")
|
||||||
|
response = await handler(request)
|
||||||
|
# Revalidate the UI files so an add-on update shows up immediately.
|
||||||
|
if isinstance(response, web.FileResponse):
|
||||||
|
response.headers.setdefault("Cache-Control", "no-cache")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def load_store():
|
||||||
|
if STORE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STORE_FILE.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
LOG.exception("Could not read %s, starting empty", STORE_FILE)
|
||||||
|
return {"version": 1, "dashboards": []}
|
||||||
|
|
||||||
|
|
||||||
|
def save_store(data):
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = STORE_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2))
|
||||||
|
tmp.replace(STORE_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
async def index(request):
|
||||||
|
return web.FileResponse(WEB_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_dashboards(request):
|
||||||
|
return web.json_response(load_store())
|
||||||
|
|
||||||
|
|
||||||
|
async def put_dashboards(request):
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response({"error": "invalid json"}, status=400)
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("dashboards"), list):
|
||||||
|
return web.json_response({"error": "expected {dashboards: [...]}"}, status=400)
|
||||||
|
save_store(data)
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def camera(request):
|
||||||
|
entity_id = request.match_info["entity_id"]
|
||||||
|
if not entity_id.startswith("camera.") or "/" in entity_id:
|
||||||
|
return web.Response(status=400)
|
||||||
|
if MOCK:
|
||||||
|
return web.Response(body=request.app["mock"].camera_svg(entity_id), content_type="image/svg+xml")
|
||||||
|
session = request.app["http"]
|
||||||
|
async with session.get(
|
||||||
|
f"{HA_API_URL}/camera_proxy/{entity_id}",
|
||||||
|
headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"},
|
||||||
|
) as resp:
|
||||||
|
body = await resp.read()
|
||||||
|
return web.Response(
|
||||||
|
status=resp.status,
|
||||||
|
body=body,
|
||||||
|
content_type=resp.content_type or "image/jpeg",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def websocket(request):
|
||||||
|
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
|
||||||
|
await client.prepare(request)
|
||||||
|
|
||||||
|
if MOCK:
|
||||||
|
await request.app["mock"].serve(client)
|
||||||
|
return client
|
||||||
|
if not SUPERVISOR_TOKEN:
|
||||||
|
await client.send_json({"type": "error", "message": "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it."})
|
||||||
|
await client.close()
|
||||||
|
return client
|
||||||
|
|
||||||
|
session = request.app["http"]
|
||||||
|
try:
|
||||||
|
upstream = await session.ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0)
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
LOG.error("Cannot reach Home Assistant websocket: %s", err)
|
||||||
|
await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"})
|
||||||
|
await client.close()
|
||||||
|
return client
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = await upstream.receive_json() # auth_required
|
||||||
|
await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN})
|
||||||
|
msg = await upstream.receive_json()
|
||||||
|
if msg.get("type") != "auth_ok":
|
||||||
|
LOG.error("Home Assistant rejected the Supervisor token: %s", msg)
|
||||||
|
await client.send_json({"type": "error", "message": "Home Assistant auth failed"})
|
||||||
|
return client
|
||||||
|
await client.send_json({"type": "ready", "ha_version": msg.get("ha_version")})
|
||||||
|
|
||||||
|
async def pump(src, dst):
|
||||||
|
async for m in src:
|
||||||
|
if m.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
await dst.send_str(m.data)
|
||||||
|
elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
|
||||||
|
break
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(pump(client, upstream)), asyncio.create_task(pump(upstream, client))]
|
||||||
|
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
finally:
|
||||||
|
await upstream.close()
|
||||||
|
await client.close()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def on_startup(app):
|
||||||
|
app["http"] = aiohttp.ClientSession()
|
||||||
|
await app["immich"].start()
|
||||||
|
if MOCK:
|
||||||
|
app["mock"] = MockHA()
|
||||||
|
app["mock_task"] = asyncio.create_task(app["mock"].simulate())
|
||||||
|
LOG.warning("No SUPERVISOR_TOKEN: running against mock Home Assistant")
|
||||||
|
|
||||||
|
|
||||||
|
async def on_cleanup(app):
|
||||||
|
await app["http"].close()
|
||||||
|
await app["immich"].close()
|
||||||
|
if MOCK:
|
||||||
|
app["mock_task"].cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def make_app():
|
||||||
|
app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024)
|
||||||
|
app.router.add_get("/", index)
|
||||||
|
app.router.add_get("/api/dashboards", get_dashboards)
|
||||||
|
app.router.add_put("/api/dashboards", put_dashboards)
|
||||||
|
app.router.add_get("/api/camera/{entity_id}", camera)
|
||||||
|
app.router.add_get("/ws", websocket)
|
||||||
|
app["immich"] = Immich(OPTIONS["immich_url"], OPTIONS["immich_api_key"], mock=MOCK)
|
||||||
|
app.router.add_routes(app["immich"].routes())
|
||||||
|
app.router.add_static("/static", WEB_DIR)
|
||||||
|
app.on_startup.append(on_startup)
|
||||||
|
app.on_cleanup.append(on_cleanup)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN:
|
||||||
|
LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant")
|
||||||
|
LOG.info("Dashboard Studio listening on port %s (data in %s)", PORT, DATA_DIR)
|
||||||
|
web.run_app(make_app(), port=PORT, access_log=None)
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""A tiny fake of the Home Assistant websocket API for local development."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from aiohttp import WSMsgType
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso():
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def entity(entity_id, state, **attrs):
|
||||||
|
ts = now_iso()
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"state": state,
|
||||||
|
"attributes": attrs,
|
||||||
|
"last_changed": ts,
|
||||||
|
"last_updated": ts,
|
||||||
|
"context": {"id": "mock"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MockHA:
|
||||||
|
def __init__(self):
|
||||||
|
today = datetime.now().astimezone().date()
|
||||||
|
|
||||||
|
def pickup(kind, days):
|
||||||
|
# Mirrors the Afvalwijzer integration: ISO date state plus collection-date attributes.
|
||||||
|
if days is None:
|
||||||
|
return entity(f"sensor.afvalwijzer_1234ab_12_{kind}", "geen", friendly_name=f"Afvalwijzer {kind}", last_update=now_iso(),
|
||||||
|
days_until_collection_date=None, is_collection_date_today=False, is_collection_date_tomorrow=False, is_collection_date_day_after_tomorrow=False)
|
||||||
|
return entity(f"sensor.afvalwijzer_1234ab_12_{kind}", (today + timedelta(days=days)).isoformat(), friendly_name=f"Afvalwijzer {kind}", last_update=now_iso(),
|
||||||
|
days_until_collection_date=days, is_collection_date_today=days == 0, is_collection_date_tomorrow=days == 1, is_collection_date_day_after_tomorrow=days == 2)
|
||||||
|
|
||||||
|
waste = [pickup("gft", 1), pickup("pmd", 1), pickup("papier", 6), pickup("restafval", 8), pickup("textiel", 23), pickup("kerstbomen", None),
|
||||||
|
entity("sensor.afvalwijzer_1234ab_12_tomorrow", "gft, pmd", friendly_name="Afvalwijzer Morgen"),
|
||||||
|
entity("sensor.afvalwijzer_1234ab_12_next_in_days", "1", friendly_name="Afvalwijzer Dagen tot volgende ophaling")]
|
||||||
|
self.states = {
|
||||||
|
e["entity_id"]: e
|
||||||
|
for e in waste + [
|
||||||
|
entity("light.living_room", "on", friendly_name="Living Room", brightness=180, supported_color_modes=["brightness"]),
|
||||||
|
entity("light.reading_lamp", "on", friendly_name="Reading Lamp", brightness=140, rgb_color=[255, 140, 60], supported_color_modes=["rgb"]),
|
||||||
|
entity("light.kitchen", "off", friendly_name="Kitchen", brightness=None, supported_color_modes=["brightness"]),
|
||||||
|
entity("light.bedroom", "on", friendly_name="Bedroom", brightness=90, supported_color_modes=["brightness"]),
|
||||||
|
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
|
||||||
|
entity("fan.office", "off", friendly_name="Office Fan"),
|
||||||
|
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
|
||||||
|
entity("sensor.outdoor_temperature", "12.8", friendly_name="Outdoor Temperature", unit_of_measurement="°C", device_class="temperature"),
|
||||||
|
entity("sensor.living_room_humidity", "48", friendly_name="Living Room Humidity", unit_of_measurement="%", device_class="humidity"),
|
||||||
|
entity("sensor.power_usage", "640", friendly_name="Power Usage", unit_of_measurement="W", device_class="power"),
|
||||||
|
entity("sensor.solar_production", "1820", friendly_name="Solar Production", unit_of_measurement="W", device_class="power"),
|
||||||
|
entity("sensor.phone_battery", "76", friendly_name="Phone Battery", unit_of_measurement="%", device_class="battery"),
|
||||||
|
entity("binary_sensor.front_door", "off", friendly_name="Front Door", device_class="door"),
|
||||||
|
entity("binary_sensor.hallway_motion", "off", friendly_name="Hallway Motion", device_class="motion"),
|
||||||
|
entity("climate.thermostat", "heat", friendly_name="Thermostat", current_temperature=20.5, temperature=21, min_temp=7, max_temp=30, hvac_modes=["off", "heat", "auto"]),
|
||||||
|
entity("cover.garage_door", "closed", friendly_name="Garage Door"),
|
||||||
|
entity("lock.front_door", "locked", friendly_name="Front Door Lock"),
|
||||||
|
entity("media_player.living_room_speaker", "playing", friendly_name="Living Room Speaker", media_title="Blue in Green", media_artist="Miles Davis", volume_level=0.35),
|
||||||
|
entity("scene.movie_night", "2026-10-24T20:00:00+00:00", friendly_name="Movie Night"),
|
||||||
|
entity("script.good_morning", "off", friendly_name="Good Morning"),
|
||||||
|
entity("weather.home", "partlycloudy", friendly_name="Home", temperature=13, humidity=71, wind_speed=14),
|
||||||
|
entity("camera.front_yard", "idle", friendly_name="Front Yard"),
|
||||||
|
entity("person.alex", "home", friendly_name="Alex"),
|
||||||
|
entity("input_boolean.guest_mode", "off", friendly_name="Guest Mode"),
|
||||||
|
entity("calendar.family", "off", friendly_name="Family"),
|
||||||
|
entity("calendar.work", "on", friendly_name="Work"),
|
||||||
|
entity("sensor.model_y_battery", "68", friendly_name="Model Y Battery", unit_of_measurement="%", device_class="battery"),
|
||||||
|
entity("sensor.model_y_range", "341", friendly_name="Model Y Range", unit_of_measurement="km", device_class="distance"),
|
||||||
|
entity("binary_sensor.model_y_charging", "on", friendly_name="Model Y Charging", device_class="battery_charging"),
|
||||||
|
entity("sensor.model_y_charging_power", "7.4", friendly_name="Model Y Charging Power", unit_of_measurement="kW", device_class="power"),
|
||||||
|
entity("binary_sensor.model_y_plug", "on", friendly_name="Model Y Plug", device_class="plug"),
|
||||||
|
entity("number.model_y_charge_limit", "80", friendly_name="Model Y Charge Limit", unit_of_measurement="%"),
|
||||||
|
entity("lock.model_y_doors", "locked", friendly_name="Model Y Doors"),
|
||||||
|
entity("climate.model_y_climate", "off", friendly_name="Model Y Climate", current_temperature=14, temperature=21, hvac_modes=["off", "heat_cool"]),
|
||||||
|
entity("device_tracker.model_y_location", "home", friendly_name="Model Y Location"),
|
||||||
|
entity("sensor.model_y_odometer", "24816", friendly_name="Model Y Odometer", unit_of_measurement="km", device_class="distance"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.clients = set()
|
||||||
|
|
||||||
|
# -- state handling ---------------------------------------------------
|
||||||
|
|
||||||
|
def set_state(self, entity_id, state=None, **attrs):
|
||||||
|
old = self.states[entity_id]
|
||||||
|
new = json.loads(json.dumps(old))
|
||||||
|
if state is not None and state != old["state"]:
|
||||||
|
new["state"] = state
|
||||||
|
new["last_changed"] = now_iso()
|
||||||
|
new["attributes"].update(attrs)
|
||||||
|
new["last_updated"] = now_iso()
|
||||||
|
self.states[entity_id] = new
|
||||||
|
event = {"event_type": "state_changed", "data": {"entity_id": entity_id, "old_state": old, "new_state": new}, "time_fired": now_iso()}
|
||||||
|
for ws, sub_ids in list(self.clients):
|
||||||
|
for sid in sub_ids:
|
||||||
|
asyncio.ensure_future(self._safe_send(ws, {"id": sid, "type": "event", "event": event}))
|
||||||
|
|
||||||
|
async def _safe_send(self, ws, payload):
|
||||||
|
try:
|
||||||
|
await ws.send_json(payload)
|
||||||
|
except Exception: # client went away
|
||||||
|
pass
|
||||||
|
|
||||||
|
def call_service(self, domain, service, data):
|
||||||
|
targets = data.get("entity_id") or (data.get("target") or {}).get("entity_id") or []
|
||||||
|
if isinstance(targets, str):
|
||||||
|
targets = [targets]
|
||||||
|
for eid in targets:
|
||||||
|
if eid not in self.states:
|
||||||
|
continue
|
||||||
|
st = self.states[eid]["state"]
|
||||||
|
if eid.startswith("climate.") and service in ("turn_on", "turn_off", "toggle"):
|
||||||
|
on = service == "turn_on" or (service == "toggle" and st == "off")
|
||||||
|
self.set_state(eid, (self.states[eid]["attributes"].get("hvac_modes") or ["off", "heat"])[-1] if on else "off")
|
||||||
|
elif service in ("turn_on", "turn_off", "toggle"):
|
||||||
|
on = service == "turn_on" or (service == "toggle" and st != "on")
|
||||||
|
extra = {}
|
||||||
|
if eid.startswith("light."):
|
||||||
|
if "brightness" in data:
|
||||||
|
extra["brightness"] = data["brightness"]
|
||||||
|
elif "brightness_pct" in data:
|
||||||
|
extra["brightness"] = round(data["brightness_pct"] * 2.55)
|
||||||
|
elif on and not self.states[eid]["attributes"].get("brightness"):
|
||||||
|
extra["brightness"] = 255
|
||||||
|
if not on:
|
||||||
|
extra["brightness"] = None
|
||||||
|
self.set_state(eid, "on" if on else "off", **extra)
|
||||||
|
elif service == "set_temperature":
|
||||||
|
self.set_state(eid, None, temperature=data.get("temperature"))
|
||||||
|
elif service == "set_hvac_mode":
|
||||||
|
self.set_state(eid, data.get("hvac_mode"))
|
||||||
|
elif service in ("open_cover", "close_cover"):
|
||||||
|
self.set_state(eid, "open" if service == "open_cover" else "closed")
|
||||||
|
elif service == "toggle_cover" or (domain == "cover" and service == "toggle"):
|
||||||
|
self.set_state(eid, "closed" if st == "open" else "open")
|
||||||
|
elif service in ("lock", "unlock"):
|
||||||
|
self.set_state(eid, "locked" if service == "lock" else "unlocked")
|
||||||
|
elif service == "media_play_pause":
|
||||||
|
self.set_state(eid, "paused" if st == "playing" else "playing")
|
||||||
|
elif service == "volume_set":
|
||||||
|
self.set_state(eid, None, volume_level=data.get("volume_level"))
|
||||||
|
elif domain in ("scene", "script") and service in ("turn_on",):
|
||||||
|
self.set_state(eid, now_iso() if domain == "scene" else "off")
|
||||||
|
|
||||||
|
def calendar_events(self, entity_ids):
|
||||||
|
"""A repeatable week of events relative to today."""
|
||||||
|
today = datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
plan = {
|
||||||
|
"calendar.family": [(0, 18, 30, 90, "Dinner with grandparents", "Home"), (1, None, 0, 0, "Bin collection", ""), (2, 16, 0, 60, "Swimming lessons", "Sports centre"), (5, None, 0, 0, "Emma’s birthday", ""), (6, 10, 0, 120, "Market", "Town square")],
|
||||||
|
"calendar.work": [(0, 9, 0, 30, "Stand-up", ""), (0, 14, 0, 60, "Design review", "Room 3"), (1, 11, 0, 45, "1:1 with Sam", ""), (3, 13, 30, 90, "Quarterly planning", "Main office")],
|
||||||
|
}
|
||||||
|
out = {}
|
||||||
|
for eid in entity_ids:
|
||||||
|
events = []
|
||||||
|
for day, hour, minute, dur, summary, location in plan.get(eid, []):
|
||||||
|
d = today + timedelta(days=day)
|
||||||
|
if hour is None:
|
||||||
|
events.append({"start": d.date().isoformat(), "end": (d + timedelta(days=1)).date().isoformat(), "summary": summary})
|
||||||
|
else:
|
||||||
|
start = d.replace(hour=hour, minute=minute)
|
||||||
|
events.append({"start": start.isoformat(), "end": (start + timedelta(minutes=dur)).isoformat(), "summary": summary, "location": location})
|
||||||
|
out[eid] = {"events": events}
|
||||||
|
return out
|
||||||
|
|
||||||
|
def history(self, entity_ids, start):
|
||||||
|
out = {}
|
||||||
|
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
||||||
|
span = (datetime.now(timezone.utc) - start_dt).total_seconds()
|
||||||
|
for eid in entity_ids:
|
||||||
|
st = self.states.get(eid)
|
||||||
|
if not st:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
base = float(st["state"])
|
||||||
|
except ValueError:
|
||||||
|
out[eid] = [{"s": st["state"], "lu": start_dt.timestamp()}]
|
||||||
|
continue
|
||||||
|
pts = []
|
||||||
|
n = 96
|
||||||
|
seed = sum(map(ord, eid))
|
||||||
|
for i in range(n):
|
||||||
|
t = start_dt.timestamp() + span * i / (n - 1)
|
||||||
|
wave = math.sin(i / 10 + seed) * 0.08 + math.sin(i / 3.3 + seed) * 0.03
|
||||||
|
pts.append({"s": f"{base * (1 + wave):.1f}", "lu": t})
|
||||||
|
pts[-1]["s"] = st["state"]
|
||||||
|
out[eid] = pts
|
||||||
|
return out
|
||||||
|
|
||||||
|
def camera_svg(self, entity_id):
|
||||||
|
stamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
|
||||||
|
<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#4b6584"/><stop offset="1" stop-color="#1e272e"/></linearGradient></defs>
|
||||||
|
<rect width="640" height="360" fill="url(#g)"/>
|
||||||
|
<rect x="0" y="250" width="640" height="110" fill="#2d3e2f"/>
|
||||||
|
<polygon points="180,250 260,170 340,250" fill="#57606f"/><rect x="200" y="250" width="120" height="70" fill="#747d8c"/>
|
||||||
|
<text x="16" y="30" fill="#fff" font-family="monospace" font-size="18">{entity_id} · {stamp}</text>
|
||||||
|
</svg>""".encode()
|
||||||
|
|
||||||
|
async def simulate(self):
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
for eid, spread in (
|
||||||
|
("sensor.power_usage", 60),
|
||||||
|
("sensor.solar_production", 80),
|
||||||
|
("sensor.living_room_temperature", 0.1),
|
||||||
|
("sensor.outdoor_temperature", 0.2),
|
||||||
|
):
|
||||||
|
v = float(self.states[eid]["state"]) + random.uniform(-spread, spread)
|
||||||
|
self.set_state(eid, f"{max(v, 0):.1f}" if spread < 1 else f"{max(v, 0):.0f}")
|
||||||
|
if self.states["binary_sensor.model_y_charging"]["state"] == "on":
|
||||||
|
lvl = float(self.states["sensor.model_y_battery"]["state"])
|
||||||
|
if lvl < 80:
|
||||||
|
self.set_state("sensor.model_y_battery", f"{lvl + 0.2:.1f}")
|
||||||
|
if random.random() < 0.15:
|
||||||
|
eid = "binary_sensor.hallway_motion"
|
||||||
|
self.set_state(eid, "off" if self.states[eid]["state"] == "on" else "on")
|
||||||
|
|
||||||
|
# -- websocket ----------------------------------------------------------
|
||||||
|
|
||||||
|
async def serve(self, ws):
|
||||||
|
subs = set()
|
||||||
|
entry = (ws, frozenset())
|
||||||
|
await ws.send_json({"type": "ready", "ha_version": "mock"})
|
||||||
|
try:
|
||||||
|
async for msg in ws:
|
||||||
|
if msg.type != WSMsgType.TEXT:
|
||||||
|
continue
|
||||||
|
req = json.loads(msg.data)
|
||||||
|
mid, typ = req.get("id"), req.get("type")
|
||||||
|
if typ == "get_states":
|
||||||
|
await ws.send_json({"id": mid, "type": "result", "success": True, "result": list(self.states.values())})
|
||||||
|
elif typ == "subscribe_events":
|
||||||
|
subs.add(mid)
|
||||||
|
self.clients.discard(entry)
|
||||||
|
entry = (ws, frozenset(subs))
|
||||||
|
self.clients.add(entry)
|
||||||
|
await ws.send_json({"id": mid, "type": "result", "success": True, "result": None})
|
||||||
|
elif typ == "call_service" and req.get("domain") == "calendar" and req.get("service") == "get_events":
|
||||||
|
ids = (req.get("target") or {}).get("entity_id") or []
|
||||||
|
ids = [ids] if isinstance(ids, str) else ids
|
||||||
|
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": self.calendar_events(ids)}})
|
||||||
|
elif typ == "call_service":
|
||||||
|
data = dict(req.get("service_data") or {})
|
||||||
|
if req.get("target"):
|
||||||
|
data.setdefault("entity_id", req["target"].get("entity_id"))
|
||||||
|
self.call_service(req["domain"], req["service"], data)
|
||||||
|
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}}})
|
||||||
|
elif typ == "history/history_during_period":
|
||||||
|
start = req.get("start_time") or (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
||||||
|
await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.history(req.get("entity_ids", []), start)})
|
||||||
|
elif typ == "ping":
|
||||||
|
await ws.send_json({"id": mid, "type": "pong"})
|
||||||
|
else:
|
||||||
|
await ws.send_json({"id": mid, "type": "result", "success": False, "error": {"code": "unknown_command", "message": typ}})
|
||||||
|
finally:
|
||||||
|
self.clients.discard(entry)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
configuration:
|
||||||
|
immich_url:
|
||||||
|
name: Immich URL
|
||||||
|
description: Address of your Immich server, for example http://192.168.1.20:2283. Used by the Photos widget.
|
||||||
|
immich_api_key:
|
||||||
|
name: Immich API key
|
||||||
|
description: Create one in Immich under Account Settings → API Keys. Read access to albums and assets is enough.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
configuration:
|
||||||
|
immich_url:
|
||||||
|
name: Immich-URL
|
||||||
|
description: Adres van je Immich-server, bijvoorbeeld http://192.168.1.20:2283. Wordt gebruikt door de widget Foto’s.
|
||||||
|
immich_api_key:
|
||||||
|
name: Immich-API-sleutel
|
||||||
|
description: Maak er een aan in Immich via Accountinstellingen → API-sleutels. Leestoegang tot albums en items is genoeg.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
|||||||
|
/* Translations. English text is the key; a missing translation falls back to English.
|
||||||
|
* Language: the device setting in Dashboard Studio, else Home Assistant's own language
|
||||||
|
* (same origin, so its localStorage is readable), else the browser's. */
|
||||||
|
(function () {
|
||||||
|
const NL = {
|
||||||
|
// --- toolbar, menus, dialogs
|
||||||
|
"Dashboard": "Dashboard",
|
||||||
|
"New dashboard": "Nieuw dashboard",
|
||||||
|
"Undo": "Ongedaan maken",
|
||||||
|
"Redo": "Opnieuw",
|
||||||
|
"More actions": "Meer acties",
|
||||||
|
"Kiosk": "Kiosk",
|
||||||
|
"Kiosk mode": "Kioskmodus",
|
||||||
|
"Edit": "Bewerken",
|
||||||
|
"Done": "Gereed",
|
||||||
|
"Offline": "Offline",
|
||||||
|
"Connected": "Verbonden",
|
||||||
|
"Saving…": "Opslaan…",
|
||||||
|
"Saved": "Opgeslagen",
|
||||||
|
"Not saved": "Niet opgeslagen",
|
||||||
|
"Rename…": "Wijzig naam…",
|
||||||
|
"Duplicate": "Dupliceer",
|
||||||
|
"New dashboard from my devices": "Nieuw dashboard met mijn apparaten",
|
||||||
|
"Created “{0}” from your devices.": "“{0}” gemaakt met je apparaten.",
|
||||||
|
"Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it.": "Dashboard Studio heeft geen toegang tot Home Assistant (SUPERVISOR_TOKEN ontbreekt). Herstart de app; als dit blijft, installeer de app opnieuw.",
|
||||||
|
"Export as JSON": "Exporteer als JSON",
|
||||||
|
"Import JSON…": "Importeer JSON…",
|
||||||
|
"Delete dashboard…": "Verwijder dashboard…",
|
||||||
|
"Widgets": "Widgets",
|
||||||
|
"Inspector": "Inspector",
|
||||||
|
"Add widget": "Widget toevoegen",
|
||||||
|
"Click to add. Drag to move, drag the corner to resize. Arrow keys move the selected widget, Shift+arrows resize, ⌘D duplicates, Delete removes.":
|
||||||
|
"Klik om toe te voegen. Sleep om te verplaatsen, sleep de hoek om het formaat te wijzigen. Pijltjestoetsen verplaatsen de geselecteerde widget, Shift+pijltjes wijzigen het formaat, ⌘D dupliceert, Delete verwijdert.",
|
||||||
|
"Cancel": "Annuleer",
|
||||||
|
"Create": "Maak aan",
|
||||||
|
"Rename": "Wijzig naam",
|
||||||
|
"Rename dashboard": "Wijzig naam van dashboard",
|
||||||
|
"Delete": "Verwijder",
|
||||||
|
"Close": "Sluit",
|
||||||
|
"Run": "Voer uit",
|
||||||
|
"Turn on": "Zet aan",
|
||||||
|
"Turn off": "Zet uit",
|
||||||
|
"Lock": "Vergrendel",
|
||||||
|
"Unlock": "Ontgrendel",
|
||||||
|
"Changed {0}": "Gewijzigd {0}",
|
||||||
|
"Delete “{0}”?": "“{0}” verwijderen?",
|
||||||
|
"The dashboard and its widgets will be removed. This can’t be undone.": "Het dashboard en de widgets worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"This is your only dashboard. Create another one before deleting it.": "Dit is je enige dashboard. Maak eerst een ander dashboard aan voordat je dit verwijdert.",
|
||||||
|
"Imported “{0}”.": "“{0}” geïmporteerd.",
|
||||||
|
"Couldn’t import: {0}.": "Importeren mislukt: {0}.",
|
||||||
|
"the file has no widgets list": "het bestand bevat geen widgets",
|
||||||
|
"Widget deleted. Press ⌘Z to undo.": "Widget verwijderd. Druk op ⌘Z om dit ongedaan te maken.",
|
||||||
|
"Couldn’t save. Your changes are kept here and will save on the next edit.": "Opslaan mislukt. Je wijzigingen blijven hier bewaard en worden bij de volgende wijziging opgeslagen.",
|
||||||
|
"Couldn’t load your dashboards. Check that the app is running.": "Je dashboards konden niet worden geladen. Controleer of de app draait.",
|
||||||
|
"Not connected to Home Assistant. Retrying…": "Niet verbonden met Home Assistant. Opnieuw proberen…",
|
||||||
|
"{0} failed: {1}": "{0} mislukt: {1}",
|
||||||
|
"The button’s action data isn’t valid JSON. Fix it in the inspector.": "De actiegegevens van de knop zijn geen geldige JSON. Pas dit aan in de inspector.",
|
||||||
|
"This button has no action yet. Set one in the inspector.": "Deze knop heeft nog geen actie. Stel er een in via de inspector.",
|
||||||
|
"Unknown widget “{0}”.": "Onbekende widget “{0}”.",
|
||||||
|
"This widget couldn’t be drawn.": "Deze widget kon niet worden getekend.",
|
||||||
|
"Home": "Thuis",
|
||||||
|
"Everything at a glance": "Alles in één oogopslag",
|
||||||
|
"Quick controls": "Snelle bediening",
|
||||||
|
"Temperature, last 24 hours": "Temperatuur, afgelopen 24 uur",
|
||||||
|
"copy": "kopie",
|
||||||
|
|
||||||
|
// --- kiosk
|
||||||
|
"Kiosk mode is on. To leave, tap the {0} corner {1} times.": "Kioskmodus staat aan. Tik {1} keer op de hoek {0} om te stoppen.",
|
||||||
|
"top right": "rechtsboven", "top left": "linksboven", "bottom right": "rechtsonder", "bottom left": "linksonder",
|
||||||
|
"top-right": "Rechtsboven", "top-left": "Linksboven", "bottom-right": "Rechtsonder", "bottom-left": "Linksonder",
|
||||||
|
"Leave kiosk mode?": "Kioskmodus verlaten?",
|
||||||
|
"This closes by itself in a few seconds.": "Dit venster sluit vanzelf over een paar seconden.",
|
||||||
|
"Full screen": "Volledig scherm",
|
||||||
|
"Exit full screen": "Volledig scherm uit",
|
||||||
|
"Stay": "Blijf",
|
||||||
|
"Leave kiosk": "Verlaat kiosk",
|
||||||
|
"Full screen isn’t available in this browser.": "Volledig scherm is niet beschikbaar in deze browser.",
|
||||||
|
"Home Assistant’s frame blocks full screen. Use “New tab” in the kiosk settings instead.": "Home Assistant blokkeert volledig scherm in zijn venster. Gebruik “Nieuw tabblad” bij de kioskinstellingen.",
|
||||||
|
|
||||||
|
// --- inspector
|
||||||
|
"Name": "Naam",
|
||||||
|
"Appearance": "Weergave",
|
||||||
|
"Theme": "Thema",
|
||||||
|
"System follows your device’s light or dark appearance.": "Systeem volgt de lichte of donkere weergave van je apparaat.",
|
||||||
|
"System": "Systeem", "Light": "Licht", "Dark": "Donker", "Nord": "Nord", "Dusk": "Schemer", "Forest": "Bos", "Custom": "Aangepast",
|
||||||
|
"Tile style": "Tegelstijl",
|
||||||
|
"Cards": "Kaarten", "Outline": "Omlijnd", "Plain": "Zonder kaart",
|
||||||
|
"Corner radius (px)": "Hoekafronding (px)",
|
||||||
|
"Colors": "Kleuren",
|
||||||
|
"editing switches to Custom": "aanpassen schakelt naar Aangepast",
|
||||||
|
"Background (color or CSS gradient)": "Achtergrond (kleur of CSS-verloop)",
|
||||||
|
"Tile color": "Tegelkleur",
|
||||||
|
"Text color": "Tekstkleur",
|
||||||
|
"Secondary text color": "Kleur van secundaire tekst",
|
||||||
|
"Accent color": "Accentkleur",
|
||||||
|
"Background image URL": "URL van achtergrondafbeelding",
|
||||||
|
"Blur behind tiles (px)": "Vervaging achter tegels (px)",
|
||||||
|
"For photo backgrounds": "Voor fotoachtergronden",
|
||||||
|
"Grid": "Raster",
|
||||||
|
"Columns": "Kolommen",
|
||||||
|
"Row height (px)": "Rijhoogte (px)",
|
||||||
|
"Spacing (px)": "Tussenruimte (px)",
|
||||||
|
"Maximum width (px)": "Maximale breedte (px)",
|
||||||
|
"0 = full width": "0 = volledige breedte",
|
||||||
|
"Screen saver after (minutes)": "Schermbeveiliging na (minuten)",
|
||||||
|
"0 = off": "0 = uit",
|
||||||
|
"Exit corner (tap it 5 times to leave)": "Uitgangshoek (tik 5 keer om te stoppen)",
|
||||||
|
"Hide the pointer when idle": "Verberg de aanwijzer bij inactiviteit",
|
||||||
|
"Start kiosk": "Start kiosk",
|
||||||
|
"New tab": "Nieuw tabblad",
|
||||||
|
"On a wall tablet, open kiosk in a new tab to hide the Home Assistant sidebar too.": "Open de kiosk op een wandtablet in een nieuw tabblad om ook de zijbalk van Home Assistant te verbergen.",
|
||||||
|
"Select a widget to edit it.": "Selecteer een widget om deze te bewerken.",
|
||||||
|
"Language": "Taal",
|
||||||
|
"Language on this device": "Taal op dit apparaat",
|
||||||
|
"Automatic": "Automatisch",
|
||||||
|
"Size and position": "Formaat en positie",
|
||||||
|
"Column": "Kolom", "Row": "Rij", "Width": "Breedte", "Height": "Hoogte",
|
||||||
|
"Style": "Stijl",
|
||||||
|
"No background": "Geen achtergrond",
|
||||||
|
"Background": "Achtergrond",
|
||||||
|
"Default": "Standaard",
|
||||||
|
"Add an entity…": "Voeg een entiteit toe…",
|
||||||
|
"Remove {0}": "Verwijder {0}",
|
||||||
|
"No entity with this ID": "Geen entiteit met deze ID",
|
||||||
|
"Loading albums…": "Albums laden…",
|
||||||
|
"No albums found in Immich.": "Geen albums gevonden in Immich.",
|
||||||
|
|
||||||
|
// --- widget names and fields
|
||||||
|
"Heading": "Kop", "Entity tile": "Entiteitstegel", "Big value": "Grote waarde", "Gauge": "Meter",
|
||||||
|
"Thermostat": "Thermostaat", "History graph": "Geschiedenisgrafiek", "Clock": "Klok", "Weather": "Weer",
|
||||||
|
"Entity list": "Entiteitenlijst", "Button": "Knop", "Media player": "Mediaspeler", "Camera": "Camera",
|
||||||
|
"Car": "Auto", "Text": "Tekst", "Photos": "Foto’s", "Calendar": "Agenda", "Web page": "Webpagina",
|
||||||
|
"Subtitle": "Ondertitel", "Font size (px)": "Lettergrootte (px)", "Alignment": "Uitlijning",
|
||||||
|
"left": "Links", "center": "Midden", "right": "Rechts",
|
||||||
|
"Welcome home": "Welkom thuis",
|
||||||
|
"Entity": "Entiteit", "Entity name": "Naam van entiteit", "Icon": "Pictogram", "Tap action": "Tikactie",
|
||||||
|
"auto": "Auto", "toggle": "Schakel", "more-info": "Info", "none": "Geen",
|
||||||
|
"Show when it last changed": "Toon wanneer het laatst gewijzigd is",
|
||||||
|
"Unit": "Eenheid", "From entity": "Van entiteit", "Decimals": "Decimalen",
|
||||||
|
"Warn above": "Waarschuwing boven", "Critical above": "Kritiek boven",
|
||||||
|
"Minimum": "Minimum", "Maximum": "Maximum", "Green from": "Groen vanaf", "Yellow from": "Geel vanaf", "Red from": "Rood vanaf",
|
||||||
|
"Step": "Stap", "Title": "Titel", "Sensors": "Sensoren", "Hours to show": "Aantal uren",
|
||||||
|
"Fill under the line": "Vul onder de lijn",
|
||||||
|
"24-hour time": "24-uursnotatie", "Show seconds": "Toon seconden", "Show date": "Toon datum",
|
||||||
|
"Devices": "Apparaten", "Entities": "Entiteiten",
|
||||||
|
"Label": "Label", "Target entity (optional)": "Doelentiteit (optioneel)",
|
||||||
|
"Action (e.g. scene.turn_on)": "Actie (bijv. scene.turn_on)", "Action data (JSON)": "Actiegegevens (JSON)",
|
||||||
|
"Refresh every (seconds)": "Vernieuw elke (seconden)", "Show name": "Toon naam",
|
||||||
|
"My car": "Mijn auto", "Car image URL (optional)": "URL van autoafbeelding (optioneel)",
|
||||||
|
"Drivetrain": "Aandrijving", "electric": "Elektrisch", "fuel": "Brandstof",
|
||||||
|
"Battery or fuel level (%)": "Accu- of brandstofniveau (%)", "Range": "Actieradius",
|
||||||
|
"Charging power": "Laadvermogen", "Charge limit (%)": "Laadlimiet (%)", "Doors": "Portieren",
|
||||||
|
"Climate": "Klimaat", "Location": "Locatie", "Odometer": "Kilometerstand",
|
||||||
|
"Text — use {{ sensor.x }} for live values": "Tekst — gebruik {{ sensor.x }} voor live waarden",
|
||||||
|
"Inside it’s {{ sensor.living_room_temperature }}.": "Binnen is het {{ sensor.living_room_temperature }}.",
|
||||||
|
"Source": "Bron", "links": "Links", "immich": "Immich",
|
||||||
|
"Image URLs, one per line": "Afbeeldings-URL’s, één per regel",
|
||||||
|
"Immich albums": "Immich-albums",
|
||||||
|
"Change photo every (seconds)": "Wissel foto elke (seconden)",
|
||||||
|
"Shuffle": "Willekeurige volgorde",
|
||||||
|
"Fit": "Passend", "cover": "Vullen", "contain": "Volledig",
|
||||||
|
"Show when the photo was taken": "Toon wanneer de foto is gemaakt",
|
||||||
|
"Description (for screen readers)": "Beschrijving (voor schermlezers)",
|
||||||
|
"Upcoming": "Binnenkort", "Calendars": "Agenda’s", "Days ahead": "Aantal dagen vooruit",
|
||||||
|
"Most events to show": "Maximaal aantal afspraken", "Show which calendar": "Toon welke agenda",
|
||||||
|
|
||||||
|
// --- widget content
|
||||||
|
"Unavailable": "Niet beschikbaar", "Unknown": "Onbekend",
|
||||||
|
"On": "Aan", "Off": "Uit", "Open": "Open", "Closed": "Dicht", "Clear": "Vrij", "Motion": "Beweging",
|
||||||
|
"Occupied": "Bezet", "Dry": "Droog", "Wet": "Nat", "Smoke": "Rook", "Away": "Afwezig",
|
||||||
|
"Unplugged": "Niet aangesloten", "Plugged in": "Aangesloten", "Not charging": "Laadt niet", "Charging": "Laden",
|
||||||
|
"Scene": "Scène", "Running": "Actief", "Script": "Script",
|
||||||
|
"just now": "zojuist", "{0} min ago": "{0} min geleden", "{0} h ago": "{0} uur geleden", "{0} d ago": "{0} d geleden",
|
||||||
|
"Can’t find {0}.": "{0} niet gevonden.",
|
||||||
|
"Check the entity ID in the inspector.": "Controleer de entiteit-ID in de inspector.",
|
||||||
|
"Choose {0} in the inspector.": "Kies {0} in de inspector.",
|
||||||
|
"an entity": "een entiteit", "a light": "een lamp", "a thermostat": "een thermostaat",
|
||||||
|
"a weather entity": "een weerentiteit", "a media player": "een mediaspeler", "a camera": "een camera",
|
||||||
|
"Critical": "Kritiek", "High": "Hoog",
|
||||||
|
"Now": "Nu", "Lower to {0}°": "Verlaag naar {0}°", "Raise to {0}°": "Verhoog naar {0}°", "Mode": "Modus",
|
||||||
|
"Add one or more sensors in the inspector.": "Voeg in de inspector een of meer sensoren toe.",
|
||||||
|
"Loading history…": "Geschiedenis laden…",
|
||||||
|
"No numeric history for this period.": "Geen numerieke geschiedenis voor deze periode.",
|
||||||
|
"History for the last {0} hours": "Geschiedenis van de afgelopen {0} uur",
|
||||||
|
"−{0} h": "−{0} u",
|
||||||
|
"Humidity": "Luchtvochtigheid", "Wind": "Wind",
|
||||||
|
"Add entities in the inspector.": "Voeg entiteiten toe in de inspector.",
|
||||||
|
"Previous track": "Vorige track", "Play": "Afspelen", "Pause": "Pauzeer", "Next track": "Volgende track", "Volume": "Volume",
|
||||||
|
"{0} camera": "Camera {0}",
|
||||||
|
"Set the car’s level, range or lock entity in the inspector.": "Stel in de inspector het niveau, de actieradius of het slot van de auto in.",
|
||||||
|
"Parked at home": "Thuis geparkeerd", "Locked": "Vergrendeld", "Unlocked": "Ontgrendeld",
|
||||||
|
"Battery level": "Accuniveau", "Fuel level": "Brandstofniveau", "Charge limit {0}%": "Laadlimiet {0}%",
|
||||||
|
"Climate on": "Klimaat aan", "range": "actieradius", "Low": "Laag",
|
||||||
|
"Choose one or more Immich albums in the inspector.": "Kies in de inspector een of meer Immich-albums.",
|
||||||
|
"Add image URLs in the inspector.": "Voeg in de inspector afbeeldings-URL’s toe.",
|
||||||
|
"Photo slideshow": "Diavoorstelling",
|
||||||
|
"Loading photos…": "Foto’s laden…",
|
||||||
|
"Couldn’t load photos. {0}": "Foto’s laden mislukt. {0}",
|
||||||
|
"These albums have no photos yet.": "Deze albums bevatten nog geen foto’s.",
|
||||||
|
"Add one or more calendars in the inspector.": "Voeg in de inspector een of meer agenda’s toe.",
|
||||||
|
"Loading events…": "Afspraken laden…",
|
||||||
|
"Couldn’t load events. {0}": "Afspraken laden mislukt. {0}",
|
||||||
|
"Nothing planned for the next {0} days.": "Niets gepland voor de komende {0} dagen.",
|
||||||
|
"Embedded page": "Ingesloten pagina", "Add a URL in the inspector.": "Voeg in de inspector een URL toe.",
|
||||||
|
// tabs
|
||||||
|
"Overview": "Overzicht", "Add tab": "Tabblad toevoegen", "Tab {0}": "Tabblad {0}", "Tabs": "Tabbladen", "Tab": "Tabblad",
|
||||||
|
"A dashboard needs at least one tab.": "Een dashboard heeft minstens één tabblad nodig.",
|
||||||
|
"Delete tab “{0}”?": "Tabblad “{0}” verwijderen?",
|
||||||
|
"Its {0} widgets will be deleted too. You can undo this with ⌘Z.": "De {0} widgets erop worden ook verwijderd. Je kunt dit ongedaan maken met ⌘Z.",
|
||||||
|
"Show tabs in a side menu": "Toon tabbladen in een zijmenu", "Tab name": "Naam van tabblad",
|
||||||
|
"Move up": "Omhoog", "Move down": "Omlaag", "Moved to “{0}”.": "Verplaatst naar “{0}”.",
|
||||||
|
// waste
|
||||||
|
"Waste collection": "Afvalkalender", "Layout": "Indeling", "next": "Volgende", "list": "Lijst",
|
||||||
|
"Waste sensors (leave empty to find them automatically)": "Afvalsensoren (leeg laten om ze automatisch te vinden)",
|
||||||
|
"Most pickups to show": "Maximaal aantal ophaalmomenten", "Only pickups within (days)": "Alleen ophaalmomenten binnen (dagen)",
|
||||||
|
"No pickups in the next {0} days.": "Geen ophaalmomenten in de komende {0} dagen.",
|
||||||
|
"No Afvalwijzer sensors found. Install the Afvalwijzer integration, or pick the sensors in the inspector.": "Geen Afvalwijzer-sensoren gevonden. Installeer de Afvalwijzer-integratie of kies de sensoren in de inspector.",
|
||||||
|
"Next pickup": "Volgende ophaalmoment", "Put the bin out tonight": "Zet de container vanavond buiten",
|
||||||
|
"Day after tomorrow": "Overmorgen", "In {0} days": "Over {0} dagen",
|
||||||
|
"Organic": "GFT", "Paper": "Papier", "Residual waste": "Restafval", "Residual waste bags": "Restafvalzakken",
|
||||||
|
"Textile": "Textiel", "Bulky waste": "Grofvuil", "Hazardous waste": "KCA", "Nappies": "Luiers", "Glass": "Glas",
|
||||||
|
"Pruning waste": "Snoeiafval", "Garden waste": "Tuinafval", "Christmas trees": "Kerstbomen", "BEST bag": "BEST-tas",
|
||||||
|
"Hazardous waste van": "Milieubus",
|
||||||
|
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Home Assistant states, translated when shown as text.
|
||||||
|
const STATES_NL = {
|
||||||
|
on: "Aan", off: "Uit", unavailable: "Niet beschikbaar", unknown: "Onbekend", home: "Thuis", not_home: "Afwezig",
|
||||||
|
open: "Open", opening: "Gaat open", closed: "Dicht", closing: "Gaat dicht", locked: "Vergrendeld", unlocked: "Ontgrendeld",
|
||||||
|
locking: "Vergrendelen", unlocking: "Ontgrendelen", jammed: "Vastgelopen", playing: "Speelt", paused: "Gepauzeerd",
|
||||||
|
idle: "Inactief", standby: "Stand-by", buffering: "Bufferen", heat: "Verwarmen", cool: "Koelen", auto: "Automatisch",
|
||||||
|
heat_cool: "Verwarmen/koelen", dry: "Drogen", fan_only: "Alleen ventilator", cleaning: "Reinigen", docked: "Gedockt",
|
||||||
|
returning: "Keert terug", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
|
||||||
|
"clear-night": "Helder", cloudy: "Bewolkt", exceptional: "Uitzonderlijk", fog: "Mist", hail: "Hagel", lightning: "Onweer",
|
||||||
|
"lightning-rainy": "Onweer en regen", partlycloudy: "Halfbewolkt", pouring: "Stortregen", rainy: "Regen", snowy: "Sneeuw",
|
||||||
|
"snowy-rainy": "Natte sneeuw", sunny: "Zonnig", windy: "Winderig", "windy-variant": "Winderig", armed_away: "Ingeschakeld (afwezig)",
|
||||||
|
armed_home: "Ingeschakeld (thuis)", armed_night: "Ingeschakeld (nacht)", disarmed: "Uitgeschakeld", triggered: "Geactiveerd",
|
||||||
|
};
|
||||||
|
|
||||||
|
const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
||||||
|
function detect() {
|
||||||
|
const pref = read("ds.lang");
|
||||||
|
if (pref && pref !== "auto") return pref;
|
||||||
|
let ha = read("selectedLanguage"); // Home Assistant stores it as a JSON string
|
||||||
|
try { ha = ha && JSON.parse(ha); } catch { /* plain string */ }
|
||||||
|
return String(ha || navigator.language || "en").toLowerCase().startsWith("nl") ? "nl" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = detect();
|
||||||
|
const dict = lang === "nl" ? NL : {};
|
||||||
|
const cap = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : "").replace(/_/g, " ");
|
||||||
|
|
||||||
|
window.I18n = {
|
||||||
|
lang,
|
||||||
|
locale: lang === "nl" ? "nl-NL" : undefined,
|
||||||
|
languages: { auto: "Automatic", en: "English", nl: "Nederlands" },
|
||||||
|
preference: () => read("ds.lang") || "auto",
|
||||||
|
/** t("Hello {0}", name) */
|
||||||
|
t(text, ...args) {
|
||||||
|
const s = dict[text] ?? text;
|
||||||
|
return args.length ? s.replace(/\{(\d)\}/g, (_, i) => args[i] ?? "") : s;
|
||||||
|
},
|
||||||
|
/** A Home Assistant state as readable text. */
|
||||||
|
state: (st) => (lang === "nl" && STATES_NL[st]) || cap(st),
|
||||||
|
};
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/* One consistent line-icon set (24px grid, 1.8 stroke, round joins).
|
||||||
|
* Icons.svg(name) returns inline SVG; unknown names return "" so callers can
|
||||||
|
* fall back to whatever text (e.g. an emoji) the person typed. */
|
||||||
|
(function () {
|
||||||
|
const P = {
|
||||||
|
bulb: "M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.6.5 1 1.2 1.1 2V17h5v-1.2c.1-.8.5-1.5 1.1-2A6 6 0 0 0 12 3z",
|
||||||
|
plug: "M9 3v5M15 3v5M7 8h10v3a5 5 0 0 1-10 0V8zM12 16v5",
|
||||||
|
fan: "M12 13.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM12 10.5C12 6 13 3 15.5 3S19 5.5 16 8.5L13.3 11M13.4 12.8c3.8 2.3 5.9 4.7 4.6 6.9s-4.3 1.3-5.4-2.8L12 13.5M10.7 12.6C6.8 14.8 3.7 15.4 2.4 13.2s1-4.2 5-3l3.2.8",
|
||||||
|
thermometer: "M10 13.5V5a2 2 0 1 1 4 0v8.5a4 4 0 1 1-4 0zM12 9v6",
|
||||||
|
droplet: "M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11z",
|
||||||
|
bolt: "M13 2L4 14h7l-1 8 9-12h-7l1-8z",
|
||||||
|
battery: "M3 8h15a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM21.5 11v2",
|
||||||
|
door: "M6 21V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v17M4 21h16M14.5 12.5v.01",
|
||||||
|
"door-open": "M4 21h16M6 21V4a1 1 0 0 1 1-1h4M11 3l7 2.5V21M11 3v18M14 12.5v.01",
|
||||||
|
window: "M5 3h14v18H5zM12 3v18M5 12h14",
|
||||||
|
person: "M12 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 21v-2a6 6 0 0 1 12 0v2",
|
||||||
|
lock: "M6 11h12v10H6zM8 11V7a4 4 0 0 1 8 0v4",
|
||||||
|
unlock: "M6 11h12v10H6zM8 11V7a4 4 0 0 1 7.5-2",
|
||||||
|
speaker: "M11 5L6 9H3v6h3l5 4V5zM15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13",
|
||||||
|
music: "M9 18V5l11-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM20 16a3 3 0 1 1-6 0 3 3 0 0 1 6 0z",
|
||||||
|
sparkles: "M11 3l1.8 4.7 4.7 1.8-4.7 1.8L11 16l-1.8-4.7L4.5 9.5l4.7-1.8zM18.5 14l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z",
|
||||||
|
script: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM10 8.5l5 3.5-5 3.5z",
|
||||||
|
camera: "M4 7h3l2-3h6l2 3h3a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
|
||||||
|
home: "M3 11l9-8 9 8M5 9.5V21h14V9.5M10 21v-6h4v6",
|
||||||
|
car: "M3 13l2-5.2A2 2 0 0 1 6.9 6.5h10.2A2 2 0 0 1 19 7.8L21 13v4a1 1 0 0 1-1 1h-.5M3 13v4a1 1 0 0 0 1 1h.5M3 13h18M8 18h8M8 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM20 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z",
|
||||||
|
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1.5v2M12 20.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M1.5 12h2M20.5 12h2M4.6 19.4L6 18M18 6l1.4-1.4",
|
||||||
|
moon: "M20.5 13.5A8.5 8.5 0 1 1 10.5 3.5a6.5 6.5 0 0 0 10 10z",
|
||||||
|
cloud: "M7 19a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 9.5a4.75 4.75 0 0 1-.5 9.5z",
|
||||||
|
"cloud-sun": "M8.5 6.2A4 4 0 0 1 15 7M5 9.5L3.8 9M8.5 2.5V1.5M3.5 4.5l1 1M8 21a3.8 3.8 0 0 1-.5-7.6 5.3 5.3 0 0 1 10.2-1A4.3 4.3 0 0 1 17.5 21z",
|
||||||
|
rain: "M7 15a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 5.5a4.75 4.75 0 0 1-.5 9.5zM8 22l1-3M12 22l1-3M16 22l1-3",
|
||||||
|
snow: "M7 15a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 5.5a4.75 4.75 0 0 1-.5 9.5zM8 19v.01M12 19v.01M16 19v.01M10 22v.01M14 22v.01",
|
||||||
|
storm: "M7 15a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 5.5a4.75 4.75 0 0 1-.5 9.5zM12.5 14l-2 4h3.5l-2 4",
|
||||||
|
fog: "M4 9h16M4 13h16M6 17h12M8 5h8",
|
||||||
|
wind: "M3 8h11a3 3 0 1 0-3-3M3 12h16a3 3 0 1 1-3 3M3 16h8",
|
||||||
|
plus: "M12 5v14M5 12h14",
|
||||||
|
minus: "M5 12h14",
|
||||||
|
gauge: "M4.2 18a9 9 0 1 1 15.6 0M12 14l4-5",
|
||||||
|
power: "M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0",
|
||||||
|
garage: "M3 21V9l9-6 9 6v12M7 21v-9h10v9M7 15h10M7 18h10",
|
||||||
|
shield: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z",
|
||||||
|
bin: "M4 6h16M6 6l1.2 12.2a1 1 0 0 0 1 .8h7.6a1 1 0 0 0 1-.8L18 6M9 6V3.5h6V6M8.5 21.5a1 1 0 1 0 0-.01M15.5 21.5a1 1 0 1 0 0-.01M10 10v5M14 10v5",
|
||||||
|
tabs: "M3 5h7v14H3zM13 6h8M13 10h8M13 14h5",
|
||||||
|
calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01",
|
||||||
|
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
|
||||||
|
chart: "M3 3v18h18M7 15l4-4 3 3 6-6",
|
||||||
|
list: "M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01",
|
||||||
|
text: "M4 6h16M4 12h16M4 18h10",
|
||||||
|
heading: "M6 4v16M18 4v16M6 12h12",
|
||||||
|
image: "M4 4h16v16H4zM4 16l5-5 4 4 3-3 4 4M15 9a1 1 0 1 0 0-.01",
|
||||||
|
globe: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.5 5.5 3.5 9s-1 6.5-3.5 9c-2.5-2.5-3.5-5.5-3.5-9s1-6.5 3.5-9z",
|
||||||
|
tile: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z",
|
||||||
|
number: "M5 9h14M5 15h14M10 4L8 20M16 4l-2 16",
|
||||||
|
button: "M6 7h12a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-4a3 3 0 0 1 3-3zM9 12h6",
|
||||||
|
undo: "M9 14L4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3",
|
||||||
|
redo: "M15 14l5-5-5-5M20 9H9a5 5 0 0 0 0 10h3",
|
||||||
|
more: "M5 12h.01M12 12h.01M19 12h.01",
|
||||||
|
expand: "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5",
|
||||||
|
close: "M6 6l12 12M18 6L6 18",
|
||||||
|
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
|
||||||
|
trash: "M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3",
|
||||||
|
kiosk: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM11 18h2",
|
||||||
|
edit: "M4 20h4L19 9l-4-4L4 16zM14 6l4 4",
|
||||||
|
check: "M5 12l5 5 9-10",
|
||||||
|
chevron: "M6 9l6 6 6-6",
|
||||||
|
cable: "M7 3v4M12 3v4M5 7h9v3a4.5 4.5 0 0 1-9 0zM9.5 14.5V17a3 3 0 0 0 3 3h3a3 3 0 0 0 3-3V8",
|
||||||
|
pin: "M12 21s7-6.2 7-11.5a7 7 0 0 0-14 0C5 14.8 12 21 12 21zM12 12a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z",
|
||||||
|
toggle: "M7 7h10a5 5 0 0 1 0 10H7A5 5 0 0 1 7 7zM16 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z",
|
||||||
|
robot: "M5 9h14v10H5zM12 5v4M12 5h.01M9 13h.01M15 13h.01M9 16h6",
|
||||||
|
flame: "M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2.5 1.5-4 2.5-5 .5 2 1.5 3 2.5 3 0-3-1-5 0-8z",
|
||||||
|
pulse: "M3 12h4l3-7 4 14 3-7h4",
|
||||||
|
vacuum: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
|
||||||
|
snowflake: "M12 2v20M4 7l16 10M20 7L4 17M9 3.5l3 2 3-2M9 20.5l3-2 3 2",
|
||||||
|
exit: "M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M10 16l-4-4 4-4M6 12h10",
|
||||||
|
road: "M8 3L4 21M16 3l4 18M12 4v3M12 10.5v3M12 17v3",
|
||||||
|
// filled glyphs
|
||||||
|
play: "M7 4.5v15a.8.8 0 0 0 1.2.7l12-7.5a.8.8 0 0 0 0-1.4l-12-7.5A.8.8 0 0 0 7 4.5z",
|
||||||
|
pause: "M6 4h4v16H6zM14 4h4v16h-4z",
|
||||||
|
next: "M4 5.2v13.6a.7.7 0 0 0 1.1.6L15 12.6a.7.7 0 0 0 0-1.2L5.1 4.6a.7.7 0 0 0-1.1.6zM17 4h3v16h-3z",
|
||||||
|
prev: "M20 5.2v13.6a.7.7 0 0 1-1.1.6L9 12.6a.7.7 0 0 1 0-1.2l9.9-6.8a.7.7 0 0 1 1.1.6zM4 4h3v16H4z",
|
||||||
|
};
|
||||||
|
const FILLED = new Set(["play", "pause", "next", "prev"]);
|
||||||
|
|
||||||
|
window.Icons = {
|
||||||
|
names: Object.keys(P).filter((n) => !FILLED.has(n)),
|
||||||
|
has: (n) => Object.prototype.hasOwnProperty.call(P, n),
|
||||||
|
svg(name, cls = "") {
|
||||||
|
if (!this.has(name)) return "";
|
||||||
|
return `<svg class="i ${FILLED.has(name) ? "f" : ""} ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${P[name]}"/></svg>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)">
|
||||||
|
<title>Dashboard Studio</title>
|
||||||
|
<link rel="stylesheet" href="static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header id="topbar" role="toolbar" aria-label="Dashboard">
|
||||||
|
<div class="tb-group">
|
||||||
|
<label class="picker">
|
||||||
|
<span class="sr-only" data-i18n>Dashboard</span>
|
||||||
|
<select id="dash-select"></select>
|
||||||
|
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</label>
|
||||||
|
<button id="btn-new" class="btn icon" aria-label="New dashboard" title="New dashboard"></button>
|
||||||
|
</div>
|
||||||
|
<div class="tb-group">
|
||||||
|
<span id="conn" class="conn" role="status" data-i18n>Offline</span>
|
||||||
|
<span id="save-status" class="save-status edit-only" role="status"></span>
|
||||||
|
<span class="edit-only tb-group">
|
||||||
|
<button id="btn-undo" class="btn icon" aria-label="Undo" title="Undo (⌘Z)"></button>
|
||||||
|
<button id="btn-redo" class="btn icon" aria-label="Redo" title="Redo (⇧⌘Z)"></button>
|
||||||
|
<button id="btn-menu" class="btn icon" aria-label="More actions" aria-haspopup="menu" aria-expanded="false" title="More actions"></button>
|
||||||
|
</span>
|
||||||
|
<button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button>
|
||||||
|
<button id="btn-edit" class="btn primary" data-i18n>Edit</button>
|
||||||
|
</div>
|
||||||
|
<div id="menu" class="menu" role="menu" hidden>
|
||||||
|
<button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button>
|
||||||
|
<button role="menuitem" data-menu="duplicate" data-icon="copy" data-i18n>Duplicate</button>
|
||||||
|
<button role="menuitem" data-menu="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
|
||||||
|
<hr>
|
||||||
|
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
|
||||||
|
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
|
||||||
|
<hr>
|
||||||
|
<button role="menuitem" data-menu="delete" data-icon="trash" class="danger" data-i18n>Delete dashboard…</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="workspace">
|
||||||
|
<aside id="palette" class="edit-only" aria-label="Widgets">
|
||||||
|
<div class="panel-title" data-i18n>Add widget</div>
|
||||||
|
<div id="palette-list"></div>
|
||||||
|
<p class="hint" data-i18n>Click to add. Drag to move, drag the corner to resize. Arrow keys move the selected widget, Shift+arrows resize, ⌘D duplicates, Delete removes.</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="stage">
|
||||||
|
<nav id="tabnav" aria-label="Tabs" hidden></nav>
|
||||||
|
<div id="canvas"><div id="grid"></div></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside id="inspector" class="edit-only" aria-label="Inspector"></aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="saver" hidden aria-hidden="true"><div class="time num"></div><div class="date"></div></div>
|
||||||
|
|
||||||
|
<div id="modal" class="modal" hidden><div class="modal-card" role="dialog" aria-modal="true"></div></div>
|
||||||
|
<div id="toasts" aria-live="polite"></div>
|
||||||
|
<input type="file" id="import-file" accept="application/json,.json" hidden>
|
||||||
|
|
||||||
|
<script src="static/i18n.js"></script>
|
||||||
|
<script src="static/icons.js"></script>
|
||||||
|
<script src="static/widgets.js"></script>
|
||||||
|
<script src="static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
/* ============================================================ tokens
|
||||||
|
* Every color has a light and dark value; contrast figures are against the tile.
|
||||||
|
* text #1d1d1f / #f5f5f7 16.8:1 / 15.6:1
|
||||||
|
* text-2 #6e6e73 / #a1a1a6 5.1:1 / 6.6:1
|
||||||
|
* accent #0066cc / #0a84ff (white label 5.6:1 / 3.7:1, semibold)
|
||||||
|
* lit #ffedc7 / #ffe2a8 (lit-text 6.2:1+ for secondary, 10.7:1 primary)
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--canvas: #f2f2f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--tile: #ffffff;
|
||||||
|
--text: #1d1d1f;
|
||||||
|
--text-2: #6e6e73;
|
||||||
|
--separator: rgba(60, 60, 67, 0.16);
|
||||||
|
--fill: rgba(120, 120, 128, 0.14);
|
||||||
|
--fill-strong: rgba(120, 120, 128, 0.24);
|
||||||
|
--accent: #0066cc;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--lit: #ffedc7;
|
||||||
|
--lit-text: #3d2c0a;
|
||||||
|
--lit-text-2: #6b5426;
|
||||||
|
--warn: #c93400;
|
||||||
|
--crit: #d70015;
|
||||||
|
--ok: #248a3d;
|
||||||
|
--glass: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-edge: rgba(255, 255, 255, 0.6);
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
--focus: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||||
|
--font-num: ui-rounded, "SF Pro Rounded", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--canvas: #0f0f11;
|
||||||
|
--surface: #1c1c1e;
|
||||||
|
--tile: #1c1c1e;
|
||||||
|
--text: #f5f5f7;
|
||||||
|
--text-2: #a1a1a6;
|
||||||
|
--separator: rgba(84, 84, 88, 0.55);
|
||||||
|
--fill: rgba(118, 118, 128, 0.24);
|
||||||
|
--fill-strong: rgba(118, 118, 128, 0.36);
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--lit: #ffe2a8;
|
||||||
|
--warn: #ff9f0a;
|
||||||
|
--crit: #ff453a;
|
||||||
|
--ok: #30d158;
|
||||||
|
--glass: rgba(30, 30, 32, 0.68);
|
||||||
|
--glass-edge: rgba(255, 255, 255, 0.08);
|
||||||
|
--shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
:root { --text-2: var(--text); --separator: color-mix(in srgb, var(--text) 45%, transparent); --glass: var(--surface); }
|
||||||
|
.w { border-color: var(--d-separator) !important; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
|
:root { --glass: var(--surface); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================ base */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body {
|
||||||
|
font: 14px/1.4 var(--font);
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
button, input, select, textarea { font: inherit; color: inherit; }
|
||||||
|
h2, h3 { margin: 0; }
|
||||||
|
.muted { color: var(--text-2); }
|
||||||
|
.small { font-size: 13px; }
|
||||||
|
.num { font-family: var(--font-num); font-variant-numeric: tabular-nums; }
|
||||||
|
.i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }
|
||||||
|
.i.f { fill: currentColor; stroke: none; }
|
||||||
|
.i.inline { vertical-align: -2px; margin-right: 3px; }
|
||||||
|
:focus { outline: none; }
|
||||||
|
:focus-visible { box-shadow: var(--focus); border-radius: 8px; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ============================================================ chrome */
|
||||||
|
#app { position: relative; height: 100vh; }
|
||||||
|
|
||||||
|
/* Floating toolbar: the one glass surface, part of the functional layer. */
|
||||||
|
#topbar {
|
||||||
|
position: absolute; top: 10px; left: 10px; right: 10px; z-index: 30;
|
||||||
|
height: 52px; padding: 0 8px 0 10px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||||
|
border: 1px solid var(--glass-edge);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.tb-group { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
min-height: 36px; min-width: 36px; padding: 0 14px;
|
||||||
|
border: 0; border-radius: 10px; background: var(--fill); cursor: pointer;
|
||||||
|
font-weight: 500; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--fill-strong); }
|
||||||
|
.btn .i { font-size: 18px; }
|
||||||
|
.btn.icon { padding: 0; width: 36px; background: transparent; }
|
||||||
|
.btn.icon:hover { background: var(--fill); }
|
||||||
|
.btn.primary { background: var(--accent); color: var(--on-accent); font-weight: 600; }
|
||||||
|
.btn.primary:hover { filter: brightness(1.08); }
|
||||||
|
.btn.danger { color: var(--crit); }
|
||||||
|
.btn.danger-fill { background: var(--crit); color: #fff; font-weight: 600; }
|
||||||
|
.btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
|
||||||
|
.picker { position: relative; display: flex; align-items: center; }
|
||||||
|
.picker select {
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
border: 0; background: transparent; cursor: pointer;
|
||||||
|
font-size: 17px; font-weight: 650; padding: 6px 28px 6px 8px; border-radius: 10px; max-width: 46vw;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.picker select:hover { background: var(--fill); }
|
||||||
|
.picker .i { position: absolute; right: 8px; pointer-events: none; font-size: 14px; color: var(--text-2); }
|
||||||
|
|
||||||
|
.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--crit); padding: 0 6px; }
|
||||||
|
.conn::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||||
|
.conn.ok { display: none; }
|
||||||
|
.save-status { font-size: 13px; color: var(--text-2); padding: 0 6px; white-space: nowrap; }
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute; right: 8px; top: calc(100% + 6px); z-index: 50;
|
||||||
|
background: var(--glass); backdrop-filter: blur(30px) saturate(1.4); -webkit-backdrop-filter: blur(30px) saturate(1.4);
|
||||||
|
border: 1px solid var(--glass-edge); border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); padding: 6px; display: flex; flex-direction: column; min-width: 220px;
|
||||||
|
}
|
||||||
|
.menu[hidden] { display: none; }
|
||||||
|
.menu button { display: flex; align-items: center; gap: 10px; border: 0; background: transparent; text-align: left; padding: 9px 10px; border-radius: 8px; cursor: pointer; min-height: 36px; }
|
||||||
|
.menu button .i { font-size: 17px; color: var(--text-2); }
|
||||||
|
.menu button:hover { background: var(--fill); }
|
||||||
|
.menu .danger, .menu .danger .i { color: var(--crit); }
|
||||||
|
.menu hr { border: 0; border-top: 1px solid var(--separator); margin: 4px 6px; }
|
||||||
|
|
||||||
|
#workspace { position: absolute; inset: 0; display: flex; }
|
||||||
|
#palette, #inspector {
|
||||||
|
flex: none; overflow-y: auto; background: var(--surface);
|
||||||
|
padding: 76px 14px 20px;
|
||||||
|
}
|
||||||
|
#palette { width: 232px; border-right: 1px solid var(--separator); }
|
||||||
|
#inspector { width: 300px; border-left: 1px solid var(--separator); }
|
||||||
|
.panel-title { font-size: 17px; font-weight: 650; margin: 2px 2px 12px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.panel-title .i { font-size: 20px; color: var(--text-2); }
|
||||||
|
.hint { margin: 16px 2px 0; font-size: 13px; color: var(--text-2); }
|
||||||
|
|
||||||
|
#palette-list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.pal-item {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
min-height: 72px; padding: 10px 4px; border-radius: 12px; border: 0; background: var(--fill);
|
||||||
|
font-size: 13px; text-align: center; cursor: pointer;
|
||||||
|
}
|
||||||
|
.pal-item:hover { background: var(--fill-strong); }
|
||||||
|
.pal-item .i { font-size: 22px; }
|
||||||
|
.pal-item span { hyphens: auto; -webkit-hyphens: auto; overflow-wrap: anywhere; line-height: 1.2; }
|
||||||
|
|
||||||
|
body:not(.editing) .edit-only { display: none !important; }
|
||||||
|
body.editing .view-only { display: none !important; }
|
||||||
|
|
||||||
|
#stage { flex: 1; overflow: auto; min-width: 0; position: relative; padding-top: 72px; display: flex; align-items: flex-start; }
|
||||||
|
#canvas { flex: 1; min-width: 0; align-self: stretch; }
|
||||||
|
#grid { position: relative; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* inspector form: grouped like a settings list */
|
||||||
|
.field { margin-bottom: 12px; }
|
||||||
|
.field > label, .field-label { display: block; font-size: 13px; color: var(--text-2); margin: 0 2px 4px; }
|
||||||
|
.field.check > label { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 14px; min-height: 28px; cursor: pointer; }
|
||||||
|
.field.check input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
|
||||||
|
.section { border-top: 1px solid var(--separator); margin-top: 16px; padding-top: 14px; }
|
||||||
|
.section-title { font-size: 13px; font-weight: 600; color: var(--text-2); margin: 0 2px 10px; }
|
||||||
|
.row { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
|
.row > * { flex: 1; min-width: 0; }
|
||||||
|
select.input, input.input, textarea.input {
|
||||||
|
width: 100%; min-width: 0; min-height: 34px; padding: 6px 10px;
|
||||||
|
background: var(--fill); border: 1px solid transparent; border-radius: 9px;
|
||||||
|
}
|
||||||
|
input.input:focus, select.input:focus, textarea.input:focus { border-color: var(--accent); box-shadow: var(--focus); background: var(--surface); }
|
||||||
|
textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace, Menlo, monospace; font-size: 13px; }
|
||||||
|
.input.invalid { border-color: var(--crit); }
|
||||||
|
.seg { display: flex; background: var(--fill); border-radius: 9px; padding: 2px; gap: 2px; }
|
||||||
|
.seg button { flex: 1; border: 0; background: transparent; border-radius: 7px; min-height: 30px; padding: 0 6px; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
||||||
|
.seg button[aria-pressed="true"] { background: var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); font-weight: 600; }
|
||||||
|
.color-row { display: flex; gap: 6px; }
|
||||||
|
.color-row input[type=color] { width: 34px; height: 34px; padding: 0; border: 1px solid var(--separator); border-radius: 9px; background: none; flex: none; cursor: pointer; }
|
||||||
|
.icon-row { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.icon-row .icon-prev { width: 34px; height: 34px; border-radius: 9px; background: var(--fill); display: grid; place-items: center; flex: none; font-size: 18px; }
|
||||||
|
.ent-item { display: flex; gap: 6px; margin-bottom: 6px; }
|
||||||
|
.ent-item .btn { flex: none; }
|
||||||
|
.ent-hint { font-size: 13px; color: var(--text-2); margin: 4px 2px 0; }
|
||||||
|
.insp-actions { display: flex; gap: 8px; margin-top: 18px; }
|
||||||
|
.insp-actions .btn { flex: 1; }
|
||||||
|
.presets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.preset { height: 52px; border-radius: 10px; border: 2px solid transparent; padding: 0; position: relative; overflow: hidden; cursor: pointer; }
|
||||||
|
.preset span { position: absolute; left: 7px; bottom: 5px; font-size: 12px; font-weight: 600; }
|
||||||
|
.preset i { position: absolute; right: 7px; top: 7px; width: 16px; height: 16px; border-radius: 5px; }
|
||||||
|
.preset[aria-pressed="true"] { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* sheets, alerts, toasts */
|
||||||
|
.modal { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 16px; }
|
||||||
|
.modal[hidden] { display: none; }
|
||||||
|
.modal-card { background: var(--surface); border-radius: 18px; padding: 20px; width: 420px; max-width: 100%; max-height: 85vh; overflow: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
|
||||||
|
.modal-card h3 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.modal-card p { margin: 6px 0 0; color: var(--text-2); }
|
||||||
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; }
|
||||||
|
.modal-actions .btn { min-width: 96px; }
|
||||||
|
.attr-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 14px; }
|
||||||
|
.attr-table td { border-top: 1px solid var(--separator); padding: 7px 4px; vertical-align: top; word-break: break-word; }
|
||||||
|
.attr-table td:first-child { color: var(--text-2); width: 40%; }
|
||||||
|
#toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; }
|
||||||
|
.toast { background: var(--glass); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border: 1px solid var(--glass-edge); color: var(--text); padding: 10px 16px; border-radius: 14px; font-size: 14px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); }
|
||||||
|
.toast.err { color: var(--crit); font-weight: 500; }
|
||||||
|
|
||||||
|
/* ============================================================ dashboard canvas
|
||||||
|
* Themed via --d-* on #stage. "System" leaves them on the adaptive tokens. */
|
||||||
|
#stage {
|
||||||
|
--d-bg: var(--canvas);
|
||||||
|
--d-tile: var(--tile);
|
||||||
|
--d-text: var(--text);
|
||||||
|
--d-text-2: var(--text-2);
|
||||||
|
--d-accent: var(--accent);
|
||||||
|
--d-separator: var(--separator);
|
||||||
|
--d-fill: var(--fill);
|
||||||
|
--d-lit: var(--lit);
|
||||||
|
--d-shadow: var(--shadow);
|
||||||
|
background: var(--d-bg);
|
||||||
|
background-size: cover; background-position: center; background-attachment: local;
|
||||||
|
color: var(--d-text);
|
||||||
|
}
|
||||||
|
.w {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: var(--d-radius, 20px);
|
||||||
|
background: var(--w-bg, var(--d-tile));
|
||||||
|
color: var(--w-color, var(--d-text));
|
||||||
|
--accent-w: var(--w-accent, var(--d-accent));
|
||||||
|
box-shadow: var(--d-shadow);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
backdrop-filter: blur(var(--d-blur, 0px)); -webkit-backdrop-filter: blur(var(--d-blur, 0px));
|
||||||
|
overflow: hidden;
|
||||||
|
transition: left .2s, top .2s, width .2s, height .2s, background-color .25s;
|
||||||
|
user-select: none;
|
||||||
|
container-type: size;
|
||||||
|
}
|
||||||
|
.w.transparent, .surface-plain .w { background: transparent; box-shadow: none; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||||
|
.surface-outline .w:not(.transparent) { background: transparent; box-shadow: none; border-color: var(--d-separator); backdrop-filter: none; }
|
||||||
|
.w-body { position: absolute; inset: 0; padding: 16px; display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.w-body.flush { padding: 0; }
|
||||||
|
.w.transparent .w-body, .surface-plain .w .w-body { padding: 6px 4px; }
|
||||||
|
.pad { padding: 16px; height: 100%; display: flex; }
|
||||||
|
|
||||||
|
/* Lit: a light that is on turns its tile into lamplight — the signature moment. */
|
||||||
|
.w.lit-lamp:not(.transparent) {
|
||||||
|
background: var(--w-bg, color-mix(in srgb, var(--lamp, var(--d-lit)) 32%, var(--d-lit)));
|
||||||
|
color: var(--lit-text);
|
||||||
|
--d-text-2: var(--lit-text-2);
|
||||||
|
--d-fill: rgba(61, 44, 10, 0.12);
|
||||||
|
}
|
||||||
|
.w.lit-lamp .badge { background: rgba(255, 255, 255, 0.55); color: var(--lit-text); }
|
||||||
|
.surface-plain .w.lit-lamp, .surface-outline .w.lit-lamp { background: transparent; color: var(--w-color, var(--d-text)); --d-text-2: var(--text-2); --d-fill: var(--fill); }
|
||||||
|
.surface-plain .w.lit-lamp .badge, .surface-outline .w.lit-lamp .badge { background: color-mix(in srgb, var(--lamp, var(--d-lit)) 45%, var(--d-lit)); color: var(--lit-text); }
|
||||||
|
.w.lit-on .badge { background: var(--accent-w); color: #fff; }
|
||||||
|
|
||||||
|
/* edit mode */
|
||||||
|
body.editing #grid { background-image: var(--cell-bg); background-repeat: repeat; min-height: calc(100% - 16px); }
|
||||||
|
body.editing .w { cursor: grab; }
|
||||||
|
body.editing .w-body { pointer-events: none; }
|
||||||
|
body.editing .surface-plain .w, body.editing .w.transparent { outline: 1px dashed var(--d-separator); outline-offset: -1px; }
|
||||||
|
body.editing .w.selected { outline: 2px solid var(--accent); outline-offset: 2px; z-index: 5; }
|
||||||
|
.w.dragging { transition: none; z-index: 20; cursor: grabbing; box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); }
|
||||||
|
.w-resize { position: absolute; right: 2px; bottom: 2px; width: 24px; height: 24px; cursor: nwse-resize; z-index: 3; opacity: 0; transition: opacity .1s; color: var(--accent); }
|
||||||
|
.w-resize::after { content: ""; position: absolute; right: 5px; bottom: 5px; width: 10px; height: 10px; border-right: 3px solid currentColor; border-bottom: 3px solid currentColor; border-bottom-right-radius: 4px; }
|
||||||
|
.w-tools { position: absolute; top: 8px; right: 8px; z-index: 3; display: none; gap: 4px; }
|
||||||
|
.w-tools button { width: 30px; height: 30px; border-radius: 9px; border: 0; display: grid; place-items: center; background: var(--surface); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); cursor: pointer; font-size: 16px; }
|
||||||
|
.w-tools button.del { color: var(--crit); }
|
||||||
|
body.editing .w.selected .w-tools { display: flex; }
|
||||||
|
body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1; }
|
||||||
|
.placeholder { position: absolute; border-radius: var(--d-radius, 20px); background: color-mix(in srgb, var(--accent) 16%, transparent); border: 2px dashed var(--accent); pointer-events: none; z-index: 1; }
|
||||||
|
|
||||||
|
/* ============================================================ widget content */
|
||||||
|
.wbtn { all: unset; box-sizing: border-box; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||||
|
.wbtn:focus-visible { box-shadow: var(--focus); border-radius: 12px; }
|
||||||
|
.wt { font-size: 14px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wt.wrap { white-space: normal; }
|
||||||
|
.empty { margin: auto; text-align: center; color: var(--d-text-2); font-size: 14px; line-height: 1.45; }
|
||||||
|
.name { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.glyph { font-size: 20px; line-height: 1; }
|
||||||
|
.badge { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-size: 22px; flex: none; transition: background-color .25s, color .25s; }
|
||||||
|
.level { align-self: flex-start; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 99px; color: var(--warn); background: color-mix(in srgb, currentColor 16%, transparent); }
|
||||||
|
|
||||||
|
.heading { display: flex; flex-direction: column; justify-content: center; height: 100%; gap: 2px; }
|
||||||
|
.heading .h-main { font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; }
|
||||||
|
|
||||||
|
.tile { display: flex; flex-direction: column; justify-content: space-between; height: 100%; width: 100%; gap: 8px; }
|
||||||
|
.tile-text { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.state { font-size: 14px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
@container (max-height: 110px) { .tile { flex-direction: row; align-items: center; justify-content: flex-start; } }
|
||||||
|
|
||||||
|
.value { display: flex; flex-direction: column; justify-content: space-between; height: 100%; width: 100%; gap: 4px; }
|
||||||
|
.value-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||||
|
.value-ico { font-size: 20px; color: var(--d-text-2); display: flex; }
|
||||||
|
.big { font-size: clamp(26px, min(30cqh, 24cqw), 56px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
|
||||||
|
.unit { font-size: 0.42em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; }
|
||||||
|
.value.warn .big { color: var(--warn); }
|
||||||
|
.value.crit .big { color: var(--crit); }
|
||||||
|
.value.warn .level { color: var(--warn); }
|
||||||
|
.value.crit .level { color: var(--crit); }
|
||||||
|
.car .level { color: var(--crit); align-self: center; }
|
||||||
|
|
||||||
|
.gauge { display: flex; flex-direction: column; align-items: center; height: 100%; width: 100%; }
|
||||||
|
.gauge svg { flex: 1; width: 100%; min-height: 0; }
|
||||||
|
|
||||||
|
.light { display: flex; flex-direction: column; height: 100%; gap: 10px; justify-content: space-between; }
|
||||||
|
.tile-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
/* Brightness: a wide pill slider like Control Center, filled with the lamp's color. */
|
||||||
|
.slider {
|
||||||
|
-webkit-appearance: none; appearance: none; width: 100%; height: 44px; margin: 0; border-radius: 14px; cursor: pointer;
|
||||||
|
background: linear-gradient(to right, var(--slider-fill, var(--d-text)) var(--pct, 0%), var(--d-fill) var(--pct, 0%));
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.w.lit-lamp .slider { --slider-fill: color-mix(in srgb, var(--lamp, #f0a83a) 65%, #f0a83a); }
|
||||||
|
.surface-plain .w.lit-lamp .slider, .surface-outline .w.lit-lamp .slider { --slider-fill: color-mix(in srgb, var(--lamp, #f5b942) 60%, #f5b942); }
|
||||||
|
.slider::-webkit-slider-thumb { -webkit-appearance: none; width: 6px; height: 26px; border-radius: 3px; background: transparent; }
|
||||||
|
.slider::-moz-range-thumb { width: 6px; height: 26px; border: 0; background: transparent; }
|
||||||
|
.slider:focus-visible { box-shadow: var(--focus); }
|
||||||
|
.slider.thin { height: 28px; border-radius: 10px; flex: 1; min-width: 60px; }
|
||||||
|
|
||||||
|
.climate { display: flex; flex-direction: column; height: 100%; gap: 8px; }
|
||||||
|
.climate .temps { display: flex; align-items: center; justify-content: space-between; flex: 1; gap: 8px; }
|
||||||
|
.climate .target { font-size: clamp(28px, min(22cqh, 18cqw), 56px); font-weight: 700; }
|
||||||
|
.round { width: 44px; height: 44px; border-radius: 50%; background: var(--d-fill); display: grid; place-items: center; font-size: 20px; flex: none; }
|
||||||
|
.round:hover { background: color-mix(in srgb, var(--d-text) 16%, transparent); }
|
||||||
|
.round.primary { background: var(--d-text); color: var(--d-tile); }
|
||||||
|
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.chip { border-radius: 99px; padding: 0 14px; min-height: 32px; display: inline-flex; align-items: center; font-size: 13px; font-weight: 500; background: var(--d-fill); }
|
||||||
|
.chip[aria-pressed="true"] { background: var(--d-text); color: var(--d-tile); font-weight: 600; }
|
||||||
|
|
||||||
|
.graph { display: flex; flex-direction: column; height: 100%; gap: 6px; }
|
||||||
|
.graph .legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 13px; color: var(--d-text-2); }
|
||||||
|
.graph .legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.graph .legend svg { flex: none; }
|
||||||
|
.graph .legend b { font-size: 15px; color: var(--d-text); }
|
||||||
|
.graph .plot { flex: 1; min-height: 0; position: relative; }
|
||||||
|
.graph .plot svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||||
|
.graph .axis { font-size: 11px; fill: var(--d-text-2); }
|
||||||
|
|
||||||
|
.clock { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; text-align: center; }
|
||||||
|
.clock .time { font-size: clamp(30px, min(38cqh, 22cqw), 96px); font-weight: 700; letter-spacing: -0.03em; line-height: 1; }
|
||||||
|
.clock .date { color: var(--d-text-2); margin-top: 6px; font-size: 15px; }
|
||||||
|
|
||||||
|
.cam-btn { position: absolute; inset: 0; }
|
||||||
|
.cam { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
|
||||||
|
.cam-label { position: absolute; left: 12px; bottom: 10px; font-size: 13px; font-weight: 600; color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); pointer-events: none; }
|
||||||
|
.media { display: flex; flex-direction: column; height: 100%; gap: 6px; justify-content: space-between; }
|
||||||
|
.media .title { font-weight: 650; font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.media .ctrls { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.weather { display: flex; align-items: center; gap: 16px; height: 100%; width: 100%; }
|
||||||
|
.wx-ico { font-size: clamp(36px, min(45cqh, 20cqw), 72px); display: flex; color: var(--d-text-2); }
|
||||||
|
.wx-text { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.wx-temp { font-size: clamp(26px, min(26cqh, 16cqw), 48px); font-weight: 700; line-height: 1.05; }
|
||||||
|
.list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; margin: 4px -6px 0; }
|
||||||
|
.list-row { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 6px; border-radius: 10px; }
|
||||||
|
.list-row + .list-row { box-shadow: inset 0 1px 0 var(--d-separator); }
|
||||||
|
.list-row:hover { background: var(--d-fill); }
|
||||||
|
.row-ico { font-size: 20px; display: flex; color: var(--d-text-2); }
|
||||||
|
.list-row .grow { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 15px; }
|
||||||
|
.list-row .st { color: var(--d-text-2); font-size: 14px; white-space: nowrap; }
|
||||||
|
.switch { width: 46px; height: 28px; border-radius: 14px; background: var(--d-fill); position: relative; flex: none; transition: background-color .2s; }
|
||||||
|
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 24px; height: 24px; border-radius: 50%; background: #fff; transition: transform .2s; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); }
|
||||||
|
[aria-checked="true"] > .switch { background: var(--ok); }
|
||||||
|
[aria-checked="true"] > .switch::after { transform: translateX(18px); }
|
||||||
|
.btn-widget { display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; height: 100%; width: 100%; gap: 8px; }
|
||||||
|
.btn-widget:active .badge { background: var(--accent-w); color: #fff; }
|
||||||
|
.text-w { white-space: pre-wrap; overflow: auto; height: 100%; line-height: 1.45; }
|
||||||
|
.frame { width: 100%; height: 100%; border: 0; display: block; }
|
||||||
|
.img-w { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* tab menu: a sidebar on wide screens, a rail on tablets, a tab bar on phones */
|
||||||
|
#tabnav {
|
||||||
|
position: sticky; top: 4px; flex: none; z-index: 10;
|
||||||
|
width: 220px; margin: 8px 0 16px 16px; padding: 8px;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
max-height: calc(100vh - 92px); overflow-y: auto;
|
||||||
|
background: color-mix(in srgb, var(--d-tile) 82%, transparent);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.3); -webkit-backdrop-filter: blur(24px) saturate(1.3);
|
||||||
|
border: 1px solid var(--d-separator); border-radius: var(--d-radius, 20px);
|
||||||
|
color: var(--d-text);
|
||||||
|
}
|
||||||
|
#tabnav[hidden] { display: none; }
|
||||||
|
.tabnav-item {
|
||||||
|
display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 12px;
|
||||||
|
border: 0; border-radius: 12px; background: transparent; color: inherit; cursor: pointer;
|
||||||
|
font-size: 15px; font-weight: 500; text-align: left;
|
||||||
|
}
|
||||||
|
.tabnav-item:hover { background: var(--d-fill); }
|
||||||
|
.tabnav-item[aria-current="page"] { background: color-mix(in srgb, var(--d-accent) 18%, transparent); font-weight: 650; }
|
||||||
|
.tabnav-ico { font-size: 20px; display: flex; color: var(--d-text-2); flex: none; }
|
||||||
|
.tabnav-item[aria-current="page"] .tabnav-ico { color: var(--d-accent); }
|
||||||
|
.tabnav-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tabnav-item.add { color: var(--d-text-2); margin-top: 4px; }
|
||||||
|
.tabnav-item.add .i { font-size: 20px; }
|
||||||
|
body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||||
|
.surface-plain #tabnav { background: transparent; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||||
|
.i.flip { transform: rotate(180deg); }
|
||||||
|
.tab-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
|
||||||
|
.tab-item { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.tab-item .input { min-width: 0; }
|
||||||
|
.tab-item .tab-icon { width: 76px; flex: none; }
|
||||||
|
.tab-item .icon-prev { width: 30px; height: 30px; font-size: 16px; }
|
||||||
|
.tab-item .btn.icon { width: 30px; min-width: 30px; flex: none; }
|
||||||
|
|
||||||
|
/* waste */
|
||||||
|
.bin { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: var(--bin); color: #fff; font-size: 19px; flex: none; }
|
||||||
|
.w-body > .wt { flex: none; }
|
||||||
|
.waste-next { flex: none; display: flex; align-items: center; gap: 14px; padding: 8px 0 12px; }
|
||||||
|
.waste-next + .waste-list { border-top: 1px solid var(--d-separator); padding-top: 4px; }
|
||||||
|
.waste-bins { display: flex; }
|
||||||
|
.waste-bins .bin { width: 52px; height: 52px; border-radius: 14px; font-size: 28px; }
|
||||||
|
.waste-bins .bin + .bin { margin-left: -12px; box-shadow: 0 0 0 3px var(--d-tile); }
|
||||||
|
.waste-next-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
|
||||||
|
.waste-when { font-size: 24px; font-weight: 700; line-height: 1.15; }
|
||||||
|
.waste-next.soon .waste-when { color: var(--d-accent); }
|
||||||
|
.waste-types { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.waste-next .level { color: var(--d-accent); margin-top: 4px; }
|
||||||
|
.waste-row .bin { width: 28px; height: 28px; border-radius: 8px; font-size: 16px; }
|
||||||
|
.waste-day { font-size: 14px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.waste-day.soon { color: var(--d-accent); }
|
||||||
|
@container (max-width: 300px) { .waste-row .st { display: none; } }
|
||||||
|
|
||||||
|
/* photos */
|
||||||
|
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
|
||||||
|
.ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; }
|
||||||
|
.ph-layer.on { opacity: 1; }
|
||||||
|
.ph-msg { position: absolute; inset: 0; display: flex; padding: 16px; color: #fff; --d-text-2: rgba(255, 255, 255, 0.75); }
|
||||||
|
.ph-msg:empty { display: none; }
|
||||||
|
.ph-date { position: absolute; left: 14px; bottom: 12px; font-size: 13px; font-weight: 600; color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
|
||||||
|
.albums { display: flex; flex-direction: column; gap: 2px; background: var(--fill); border-radius: 9px; padding: 4px; max-height: 220px; overflow-y: auto; }
|
||||||
|
.album { display: flex; align-items: center; gap: 10px; min-height: 36px; padding: 0 8px; border-radius: 7px; cursor: pointer; }
|
||||||
|
.album:hover { background: var(--fill); }
|
||||||
|
.album input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
|
||||||
|
.album .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* calendar */
|
||||||
|
.cal { flex: 1; min-height: 0; overflow-y: auto; margin: 4px -4px 0; padding: 0 4px; }
|
||||||
|
.cal-day { font-size: 13px; font-weight: 650; color: var(--d-text-2); margin: 12px 0 4px; }
|
||||||
|
.cal-day:first-child { margin-top: 4px; }
|
||||||
|
.cal-ev { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; }
|
||||||
|
.cal-bar { width: 4px; align-self: stretch; min-height: 20px; border-radius: 2px; flex: none; }
|
||||||
|
.cal-when { width: 92px; flex: none; font-size: 14px; color: var(--d-text-2); padding-top: 1px; }
|
||||||
|
.cal-what { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||||
|
.cal-sum { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cal-sub { font-size: 13px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cal-now { font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 99px; color: var(--d-accent); background: color-mix(in srgb, currentColor 16%, transparent); align-self: center; }
|
||||||
|
@container (max-width: 260px) { .cal-ev { flex-wrap: wrap; } .cal-when { width: auto; order: 2; flex-basis: 100%; padding-left: 14px; } }
|
||||||
|
|
||||||
|
/* car */
|
||||||
|
.car { position: relative; display: flex; flex-direction: column; height: 100%; gap: 8px; }
|
||||||
|
.car-img { position: absolute; right: -4px; top: -6px; width: 46%; height: 58%; object-fit: contain; object-position: right top; pointer-events: none; }
|
||||||
|
.car.has-img .car-head, .car.has-img .car-main { max-width: 56%; }
|
||||||
|
.car-head { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.car-head .name { font-size: 17px; }
|
||||||
|
.car-main { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-top: auto; }
|
||||||
|
.car-range { font-size: 17px; font-weight: 600; }
|
||||||
|
.car-range .wt { font-size: 14px; font-weight: 400; }
|
||||||
|
.car-bar { position: relative; height: 12px; border-radius: 6px; background: var(--d-fill); overflow: visible; flex: none; }
|
||||||
|
.car-fill { position: absolute; inset: 0 auto 0 0; border-radius: 6px; background: var(--d-text); transition: width .4s; }
|
||||||
|
.car-bar.charging .car-fill { background: var(--ok); background-image: linear-gradient(110deg, transparent 40%, rgba(255, 255, 255, 0.45) 50%, transparent 60%); background-size: 200% 100%; animation: charge 2.4s linear infinite; }
|
||||||
|
.car-bar.low .car-fill { background: var(--crit); }
|
||||||
|
.car-limit { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); }
|
||||||
|
@keyframes charge { from { background-position: 100% 0; } to { background-position: -100% 0; } }
|
||||||
|
.car-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.car-act { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 12px; background: var(--d-fill); font-size: 14px; font-weight: 500; }
|
||||||
|
.car-act .i { font-size: 18px; }
|
||||||
|
.car-act[aria-pressed="true"] { background: var(--d-text); color: var(--d-tile); }
|
||||||
|
.car-odo { margin-left: auto; }
|
||||||
|
@container (max-width: 340px) { .car-img { display: none; } .car.has-img .car-head, .car.has-img .car-main { max-width: none; } .car-odo { display: none; } }
|
||||||
|
@container (max-height: 200px) { .car-actions { display: none; } }
|
||||||
|
|
||||||
|
/* ============================================================ kiosk */
|
||||||
|
body.kiosk #topbar, body.kiosk #palette, body.kiosk #inspector { display: none !important; }
|
||||||
|
body.kiosk #stage { padding-top: 6px; }
|
||||||
|
body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
|
||||||
|
#saver { position: fixed; inset: 0; z-index: 80; background: #000; color: #8e8e93; display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: none; }
|
||||||
|
#saver[hidden] { display: none; }
|
||||||
|
#saver .time { font-size: min(22vw, 220px); font-weight: 600; letter-spacing: -0.03em; line-height: 1; }
|
||||||
|
#saver .date { font-size: 22px; margin-top: 12px; color: #636366; }
|
||||||
|
|
||||||
|
/* ============================================================ motion + sizes */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||||
|
}
|
||||||
|
/* While editing (and on tablets) the menu becomes a compact rail to leave room for the grid. */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
body.editing #tabnav { width: 84px; padding: 6px; margin-left: 10px; }
|
||||||
|
body.editing .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; }
|
||||||
|
body.editing .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#tabnav { width: 84px; padding: 6px; margin-left: 10px; }
|
||||||
|
.tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; }
|
||||||
|
.tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; }
|
||||||
|
#palette { width: 180px; }
|
||||||
|
#inspector { width: 260px; }
|
||||||
|
#palette-list { grid-template-columns: 1fr; }
|
||||||
|
.save-status { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body:not(.editing) #tabnav {
|
||||||
|
position: fixed; top: auto; left: 10px; right: 10px; bottom: calc(10px + env(safe-area-inset-bottom)); width: auto; margin: 0;
|
||||||
|
flex-direction: row; max-height: none; overflow-x: auto; padding: 6px; border-radius: 22px;
|
||||||
|
background: var(--glass); border-color: var(--glass-edge); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
body:not(.editing) #tabnav .tabnav-item { flex: 1; min-width: 64px; }
|
||||||
|
body.has-tabs:not(.editing) #canvas { padding-bottom: 96px; }
|
||||||
|
body.editing #tabnav { position: static; width: auto; flex-direction: row; overflow-x: auto; max-height: none; margin: 4px 10px; }
|
||||||
|
body.editing #stage { flex-direction: column; align-items: stretch; }
|
||||||
|
#workspace { flex-direction: column; }
|
||||||
|
body.editing #stage { order: 1; flex: 1; }
|
||||||
|
body.editing #palette { order: 2; width: auto; max-height: 120px; padding: 10px; border: 0; border-top: 1px solid var(--separator); }
|
||||||
|
body.editing #palette .panel-title, body.editing #palette .hint { display: none; }
|
||||||
|
body.editing #palette-list { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
body.editing #inspector { order: 3; width: auto; max-height: 40vh; padding: 14px; border: 0; border-top: 1px solid var(--separator); }
|
||||||
|
.btn.label-wide span { display: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
/* Widget library. Each widget type declares:
|
||||||
|
* label, icon, size [w, h], fields (inspector schema), render(cfg, ctx) -> HTML
|
||||||
|
* optional: entities(cfg) -> ids it depends on, after(el, cfg, ctx), flush, transparent,
|
||||||
|
* lit(cfg, ctx) -> "lamp" | "on" | false, lamp(cfg, ctx) -> CSS color for a lit light
|
||||||
|
* Interactive elements are real <button>s carrying data-act / data-e, handled by app.js.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
const Icons = window.Icons;
|
||||||
|
const { t, state: st, locale: LOCALE } = window.I18n;
|
||||||
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
const domainOf = (eid) => (eid || "").split(".")[0];
|
||||||
|
|
||||||
|
const ON_STATES = new Set(["on", "open", "opening", "unlocked", "playing", "home", "heat", "cool", "auto", "heat_cool", "dry", "fan_only", "cleaning"]);
|
||||||
|
const TOGGLE_DOMAINS = new Set(["light", "switch", "fan", "input_boolean", "automation", "cover", "lock", "media_player", "scene", "script", "button", "input_button", "siren", "humidifier", "vacuum", "climate"]);
|
||||||
|
const STATELESS = new Set(["scene", "script", "button", "input_button"]);
|
||||||
|
|
||||||
|
const isOn = (s) => !!s && ON_STATES.has(s.state);
|
||||||
|
const friendly = (s, override) => override || s?.attributes?.friendly_name || s?.entity_id || "";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ icons
|
||||||
|
const DOMAIN_ICON = {
|
||||||
|
light: "bulb", switch: "plug", fan: "fan", input_boolean: "toggle", automation: "robot", cover: "window",
|
||||||
|
media_player: "speaker", climate: "thermometer", scene: "sparkles", script: "script", camera: "camera",
|
||||||
|
person: "person", device_tracker: "pin", vacuum: "vacuum", button: "button", input_button: "button",
|
||||||
|
sun: "sun", alarm_control_panel: "shield", weather: "cloud-sun", input_number: "number", number: "number", counter: "number",
|
||||||
|
};
|
||||||
|
const CLASS_ICON = {
|
||||||
|
temperature: "thermometer", humidity: "droplet", moisture: "droplet", power: "bolt", energy: "bolt", voltage: "bolt",
|
||||||
|
current: "bolt", battery: "battery", illuminance: "sun", pressure: "gauge", gas: "flame", water: "droplet",
|
||||||
|
distance: "road", speed: "wind", wind_speed: "wind", co2: "pulse", pm25: "fog", aqi: "fog",
|
||||||
|
};
|
||||||
|
const BINARY_ICON = { door: ["door", "door-open"], garage_door: ["garage", "garage"], window: ["window", "window"], opening: ["door", "door-open"], motion: ["person", "person"], occupancy: ["person", "person"], presence: ["home", "home"], smoke: ["flame", "flame"], moisture: ["droplet", "droplet"], plug: ["cable", "cable"], battery_charging: ["battery", "bolt"], lock: ["lock", "unlock"] };
|
||||||
|
const WX = { "clear-night": "moon", cloudy: "cloud", exceptional: "shield", fog: "fog", hail: "snow", lightning: "storm", "lightning-rainy": "storm", partlycloudy: "cloud-sun", pouring: "rain", rainy: "rain", snowy: "snow", "snowy-rainy": "snow", sunny: "sun", windy: "wind", "windy-variant": "wind" };
|
||||||
|
|
||||||
|
function iconName(s) {
|
||||||
|
if (!s) return "pulse";
|
||||||
|
const d = domainOf(s.entity_id), dc = s.attributes.device_class;
|
||||||
|
if (d === "binary_sensor") return (BINARY_ICON[dc] || ["pulse", "pulse"])[isOn(s) ? 1 : 0];
|
||||||
|
if (d === "sensor") return CLASS_ICON[dc] || "pulse";
|
||||||
|
if (d === "lock") return s.state === "locked" ? "lock" : "unlock";
|
||||||
|
if (d === "cover" && dc === "garage") return "garage";
|
||||||
|
if (d === "weather") return WX[s.state] || "cloud";
|
||||||
|
if (d === "media_player" && s.state === "playing") return "music";
|
||||||
|
return DOMAIN_ICON[d] || "pulse";
|
||||||
|
}
|
||||||
|
/** An override can be an icon name from the set or any text, such as an emoji. */
|
||||||
|
function icon(s, override) {
|
||||||
|
if (override) return Icons.has(override) ? Icons.svg(override) : `<span class="glyph">${esc(override)}</span>`;
|
||||||
|
return Icons.svg(iconName(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ formatting
|
||||||
|
const cap = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : "").replace(/_/g, " ");
|
||||||
|
const BINARY_LABELS = { door: ["Closed", "Open"], window: ["Closed", "Open"], garage_door: ["Closed", "Open"], opening: ["Closed", "Open"], motion: ["Clear", "Motion"], occupancy: ["Clear", "Occupied"], moisture: ["Dry", "Wet"], smoke: ["Clear", "Smoke"], presence: ["Away", "Home"], plug: ["Unplugged", "Plugged in"], battery_charging: ["Not charging", "Charging"] };
|
||||||
|
|
||||||
|
function fmtNum(v, decimals) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!isFinite(n)) return v;
|
||||||
|
if (decimals !== "" && decimals != null && !isNaN(decimals)) {
|
||||||
|
const d = Math.max(0, Math.min(6, Number(decimals)));
|
||||||
|
return n.toLocaleString(LOCALE, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
}
|
||||||
|
return n.toLocaleString(LOCALE, { maximumFractionDigits: Math.abs(n) >= 100 ? 0 : 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtState(s) {
|
||||||
|
if (!s) return t("Unavailable");
|
||||||
|
const d = domainOf(s.entity_id), a = s.attributes;
|
||||||
|
if (s.state === "unavailable" || s.state === "unknown") return st(s.state);
|
||||||
|
if (d === "binary_sensor") return t((BINARY_LABELS[a.device_class] || ["Off", "On"])[isOn(s) ? 1 : 0]);
|
||||||
|
if (d === "light") return s.state === "on" ? (a.brightness ? `${Math.round(a.brightness / 2.55)}%` : t("On")) : t("Off");
|
||||||
|
if (d === "climate") return `${st(s.state)}${a.temperature != null ? " · " + a.temperature + "°" : ""}`;
|
||||||
|
if (d === "scene") return t("Scene");
|
||||||
|
if (d === "script") return s.state === "on" ? t("Running") : t("Script");
|
||||||
|
if (d === "media_player" && a.media_title) return `${st(s.state)} · ${a.media_title}`;
|
||||||
|
if (d === "weather") return `${st(s.state)} · ${a.temperature}°`;
|
||||||
|
if (d === "device_tracker" || d === "person") return s.state === "home" ? t("Home") : s.state === "not_home" ? t("Away") : st(s.state);
|
||||||
|
if (a.unit_of_measurement && !isNaN(Number(s.state))) return `${fmtNum(s.state)} ${a.unit_of_measurement}`;
|
||||||
|
return st(s.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relTime = (iso) => {
|
||||||
|
const sec = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||||
|
if (sec < 60) return t("just now");
|
||||||
|
if (sec < 3600) return t("{0} min ago", Math.floor(sec / 60));
|
||||||
|
if (sec < 86400) return t("{0} h ago", Math.floor(sec / 3600));
|
||||||
|
return t("{0} d ago", Math.floor(sec / 86400));
|
||||||
|
};
|
||||||
|
|
||||||
|
const missing = (c, what = "an entity") => `<div class="empty">${c ? t("Can’t find {0}.", esc(c)) + "<br>" + t("Check the entity ID in the inspector.") : t("Choose {0} in the inspector.", t(what))}</div>`;
|
||||||
|
const title = (txt) => (txt ? `<div class="wt">${esc(txt)}</div>` : "");
|
||||||
|
const has = (x) => x !== "" && x != null && isFinite(Number(x));
|
||||||
|
|
||||||
|
function lampColor(s) {
|
||||||
|
const rgb = s?.attributes?.rgb_color;
|
||||||
|
return s?.state === "on" && Array.isArray(rgb) ? `rgb(${rgb.join(",")})` : null;
|
||||||
|
}
|
||||||
|
const litKind = (s) => (!isOn(s) ? false : domainOf(s.entity_id) === "light" ? "lamp" : "on");
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ widgets
|
||||||
|
const W = {};
|
||||||
|
|
||||||
|
W.heading = {
|
||||||
|
label: "Heading", icon: "heading", size: [6, 1], transparent: true,
|
||||||
|
fields: [
|
||||||
|
{ k: "text", t: "text", l: "Text", d: "Welcome home" },
|
||||||
|
{ k: "subtitle", t: "text", l: "Subtitle", d: "" },
|
||||||
|
{ k: "size", t: "number", l: "Font size (px)", d: 30 },
|
||||||
|
{ k: "align", t: "seg", l: "Alignment", o: ["left", "center", "right"], d: "left" },
|
||||||
|
],
|
||||||
|
render: (c) => `<div class="heading" style="text-align:${esc(c.align)}">
|
||||||
|
<h2 class="h-main" style="font-size:${Number(c.size) || 30}px">${esc(c.text)}</h2>
|
||||||
|
${c.subtitle ? `<div class="wt wrap">${esc(c.subtitle)}</div>` : ""}</div>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
W.entity = {
|
||||||
|
label: "Entity tile", icon: "tile", size: [3, 2],
|
||||||
|
fields: [
|
||||||
|
{ k: "entity", t: "entity", l: "Entity" },
|
||||||
|
{ k: "name", t: "text", l: "Name" , ph: "Entity name" },
|
||||||
|
{ k: "icon", t: "icon", l: "Icon" },
|
||||||
|
{ k: "tap", t: "seg", l: "Tap action", o: ["auto", "toggle", "more-info", "none"], d: "auto" },
|
||||||
|
{ k: "showChanged", t: "check", l: "Show when it last changed", d: false },
|
||||||
|
],
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return missing(c.entity);
|
||||||
|
const d = domainOf(c.entity);
|
||||||
|
const tap = c.tap === "auto" ? (TOGGLE_DOMAINS.has(d) ? "toggle" : "more-info") : c.tap;
|
||||||
|
const name = friendly(s, c.name), state = fmtState(s) + (c.showChanged ? " · " + relTime(s.last_changed) : "");
|
||||||
|
const inner = `<span class="badge">${icon(s, c.icon)}</span>
|
||||||
|
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(state)}</span></span>`;
|
||||||
|
if (tap === "none") return `<div class="tile">${inner}</div>`;
|
||||||
|
const pressed = tap === "toggle" && !STATELESS.has(d) ? ` aria-pressed="${isOn(s)}"` : "";
|
||||||
|
return `<button class="tile wbtn" data-act="${tap === "toggle" ? "toggle" : "more"}" data-e="${esc(c.entity)}"${pressed}>${inner}</button>`;
|
||||||
|
},
|
||||||
|
lit: (c, { ha }) => litKind(ha.states[c.entity]),
|
||||||
|
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
|
||||||
|
};
|
||||||
|
|
||||||
|
W.value = {
|
||||||
|
label: "Big value", icon: "number", size: [3, 2],
|
||||||
|
fields: [
|
||||||
|
{ k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number", "counter"] },
|
||||||
|
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||||
|
{ k: "unit", t: "text", l: "Unit", ph: "From entity" },
|
||||||
|
{ k: "decimals", t: "number", l: "Decimals", d: "" },
|
||||||
|
{ k: "warn", t: "number", l: "Warn above", d: "" },
|
||||||
|
{ k: "crit", t: "number", l: "Critical above", d: "" },
|
||||||
|
{ k: "icon", t: "icon", l: "Icon" },
|
||||||
|
],
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return missing(c.entity);
|
||||||
|
const v = Number(s.state);
|
||||||
|
const lvl = isFinite(v) && has(c.crit) && v > Number(c.crit) ? "crit" : isFinite(v) && has(c.warn) && v > Number(c.warn) ? "warn" : "";
|
||||||
|
const unit = c.unit || s.attributes.unit_of_measurement || "";
|
||||||
|
return `<button class="value wbtn ${lvl}" data-act="more" data-e="${esc(c.entity)}">
|
||||||
|
<span class="value-top"><span class="wt">${esc(friendly(s, c.name))}</span><span class="value-ico">${icon(s, c.icon)}</span></span>
|
||||||
|
<span class="big num">${esc(isFinite(v) ? fmtNum(s.state, c.decimals) : cap(s.state))}<span class="unit">${esc(unit)}</span></span>
|
||||||
|
${lvl ? `<span class="level">${lvl === "crit" ? t("Critical") : t("High")}</span>` : ""}</button>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function arc(cx, cy, r, a0, a1) {
|
||||||
|
const p = (a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
||||||
|
const [x0, y0] = p(a0), [x1, y1] = p(a1);
|
||||||
|
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
W.gauge = {
|
||||||
|
label: "Gauge", icon: "gauge", size: [3, 3],
|
||||||
|
fields: [
|
||||||
|
{ k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number"] },
|
||||||
|
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||||
|
{ k: "min", t: "number", l: "Minimum", d: 0 },
|
||||||
|
{ k: "max", t: "number", l: "Maximum", d: 100 },
|
||||||
|
{ k: "green", t: "number", l: "Green from", d: "" },
|
||||||
|
{ k: "yellow", t: "number", l: "Yellow from", d: "" },
|
||||||
|
{ k: "red", t: "number", l: "Red from", d: "" },
|
||||||
|
],
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return missing(c.entity);
|
||||||
|
const v = Number(s.state), min = Number(c.min) || 0, max = Number(c.max) || 100;
|
||||||
|
const frac = isFinite(v) ? Math.max(0, Math.min(1, (v - min) / (max - min || 1))) : 0;
|
||||||
|
let color = "var(--d-text)";
|
||||||
|
if (isFinite(v)) {
|
||||||
|
if (has(c.red) && v >= Number(c.red)) color = "var(--crit)";
|
||||||
|
else if (has(c.yellow) && v >= Number(c.yellow)) color = "var(--warn)";
|
||||||
|
else if (has(c.green) && v >= Number(c.green)) color = "var(--ok)";
|
||||||
|
}
|
||||||
|
const a0 = Math.PI * 0.75, span = Math.PI * 1.5;
|
||||||
|
const unit = s.attributes.unit_of_measurement || "";
|
||||||
|
return `<button class="gauge wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(friendly(s, c.name))}, ${esc(fmtState(s))}">
|
||||||
|
<svg viewBox="0 0 100 82" aria-hidden="true">
|
||||||
|
<path d="${arc(50, 50, 40, a0, a0 + span)}" stroke="var(--d-fill)" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||||
|
${frac > 0.001 ? `<path d="${arc(50, 50, 40, a0, a0 + span * frac)}" stroke="${color}" stroke-width="8" fill="none" stroke-linecap="round"/>` : ""}
|
||||||
|
<text x="50" y="53" text-anchor="middle" class="num" font-size="20" font-weight="700" fill="currentColor">${esc(isFinite(v) ? fmtNum(v) : "–")}</text>
|
||||||
|
<text x="50" y="67" text-anchor="middle" font-size="9" fill="var(--d-text-2)">${esc(unit)}</text>
|
||||||
|
</svg><span class="wt">${esc(friendly(s, c.name))}</span></button>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
W.light = {
|
||||||
|
label: "Light", icon: "bulb", size: [3, 2],
|
||||||
|
fields: [
|
||||||
|
{ k: "entity", t: "entity", l: "Light", domains: ["light"] },
|
||||||
|
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||||
|
],
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return missing(c.entity, "a light");
|
||||||
|
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
|
||||||
|
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
|
||||||
|
const name = friendly(s, c.name);
|
||||||
|
return `<div class="light">
|
||||||
|
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
|
||||||
|
<span class="badge">${icon(s)}</span>
|
||||||
|
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
|
||||||
|
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}</div>`;
|
||||||
|
},
|
||||||
|
lit: (c, { ha }) => litKind(ha.states[c.entity]),
|
||||||
|
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
|
||||||
|
};
|
||||||
|
|
||||||
|
W.climate = {
|
||||||
|
label: "Thermostat", icon: "thermometer", size: [3, 3],
|
||||||
|
fields: [
|
||||||
|
{ k: "entity", t: "entity", l: "Thermostat", domains: ["climate"] },
|
||||||
|
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||||
|
{ k: "step", t: "number", l: "Step", d: 0.5 },
|
||||||
|
],
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return missing(c.entity, "a thermostat");
|
||||||
|
const a = s.attributes, step = Number(c.step) || 0.5, e = esc(c.entity);
|
||||||
|
return `<div class="climate">
|
||||||
|
<div class="wt">${esc(friendly(s, c.name))} · ${t("Now")} ${esc(a.current_temperature ?? "–")}°</div>
|
||||||
|
<div class="temps">
|
||||||
|
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${-step}" aria-label="${t("Lower to {0}°", esc((a.temperature ?? 0) - step))}">${Icons.svg("minus")}</button>
|
||||||
|
<div class="target num" aria-live="polite">${esc(a.temperature ?? "–")}°</div>
|
||||||
|
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${step}" aria-label="${t("Raise to {0}°", esc((a.temperature ?? 0) + step))}">${Icons.svg("plus")}</button>
|
||||||
|
</div>
|
||||||
|
<div class="chips" role="group" aria-label="${t("Mode")}">${(a.hvac_modes || []).map((m) => `<button class="chip wbtn" aria-pressed="${m === s.state}" data-act="hvac" data-e="${e}" data-v="${esc(m)}">${esc(st(m))}</button>`).join("")}</div></div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
W.graph = {
|
||||||
|
label: "History graph", icon: "chart", size: [6, 3],
|
||||||
|
fields: [
|
||||||
|
{ k: "title", t: "text", l: "Title", d: "" },
|
||||||
|
{ k: "entities", t: "entities", l: "Sensors", domains: ["sensor", "input_number", "number"] },
|
||||||
|
{ k: "hours", t: "number", l: "Hours to show", d: 24 },
|
||||||
|
{ k: "fill", t: "check", l: "Fill under the line", d: true },
|
||||||
|
],
|
||||||
|
entities: (c) => c.entities || [],
|
||||||
|
render(c) {
|
||||||
|
if (!c.entities?.length) return `<div class="empty">${t("Add one or more sensors in the inspector.")}</div>`;
|
||||||
|
return `<div class="graph">${title(c.title)}<div class="legend"></div><div class="plot"><div class="empty">${t("Loading history…")}</div></div></div>`;
|
||||||
|
},
|
||||||
|
async after(el, c, ctx) {
|
||||||
|
if (!c.entities?.length) return;
|
||||||
|
const hours = Number(c.hours) || 24;
|
||||||
|
const key = c.entities.join(",") + "|" + hours;
|
||||||
|
const cache = ctx.cache;
|
||||||
|
if (cache.key !== key || Date.now() - cache.at > 10 * 60 * 1000) {
|
||||||
|
cache.key = key; cache.at = Date.now();
|
||||||
|
cache.loading = ctx.ha.history(c.entities, hours).catch(() => ({})).then((s) => (cache.series = s));
|
||||||
|
}
|
||||||
|
// a re-render while the fetch is in flight must wait for the same request
|
||||||
|
await cache.loading;
|
||||||
|
if (!el.isConnected) return;
|
||||||
|
for (const eid of c.entities) {
|
||||||
|
const s = ctx.ha.states[eid];
|
||||||
|
if (!s || !cache.series[eid]) continue;
|
||||||
|
const pts = cache.series[eid], last = pts[pts.length - 1];
|
||||||
|
const t = new Date(s.last_updated).getTime();
|
||||||
|
if (!last || t > last[0]) pts.push([t, Number(s.state)]);
|
||||||
|
const cutoff = Date.now() - hours * 3600e3;
|
||||||
|
while (pts.length > 2 && pts[0][0] < cutoff) pts.shift();
|
||||||
|
}
|
||||||
|
drawGraph(el, c, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Series differ by dash pattern as well as color, so color is never the only cue.
|
||||||
|
const SERIES = [["var(--d-accent)", ""], ["var(--warn)", "6 4"], ["var(--ok)", "2 3"], ["var(--crit)", "10 3 2 3"]];
|
||||||
|
|
||||||
|
function drawGraph(el, c, ctx) {
|
||||||
|
const plot = el.querySelector(".plot"), legend = el.querySelector(".legend");
|
||||||
|
if (!plot) return;
|
||||||
|
const W_ = plot.clientWidth, H = plot.clientHeight;
|
||||||
|
const series = c.entities.map((eid) => ({ eid, pts: (ctx.cache.series || {})[eid] || [] })).filter((s) => s.pts.length);
|
||||||
|
legend.innerHTML = c.entities.map((eid, i) => {
|
||||||
|
const s = ctx.ha.states[eid], [col, dash] = SERIES[i % SERIES.length];
|
||||||
|
return `<span><svg width="18" height="8" aria-hidden="true"><line x1="1" y1="4" x2="17" y2="4" stroke="${col}" stroke-width="2.5" stroke-dasharray="${dash}" stroke-linecap="round"/></svg>${esc(friendly(s))} <b class="num">${esc(s ? fmtState(s) : "–")}</b></span>`;
|
||||||
|
}).join("");
|
||||||
|
if (!series.length || W_ < 10 || H < 10) { plot.innerHTML = `<div class="empty">${t("No numeric history for this period.")}</div>`; return; }
|
||||||
|
const hours = Number(c.hours) || 24;
|
||||||
|
const t1 = Date.now(), t0 = t1 - hours * 3600e3;
|
||||||
|
let lo = Infinity, hi = -Infinity;
|
||||||
|
series.forEach((s) => s.pts.forEach(([, v]) => { if (isFinite(v)) { lo = Math.min(lo, v); hi = Math.max(hi, v); } }));
|
||||||
|
if (!isFinite(lo)) { plot.innerHTML = `<div class="empty">${t("No numeric history for this period.")}</div>`; return; }
|
||||||
|
if (hi - lo < 1e-6) { hi += 1; lo -= 1; }
|
||||||
|
const pad = (hi - lo) * 0.1; lo -= pad; hi += pad;
|
||||||
|
const L = 36, B = 16;
|
||||||
|
const x = (t) => L + ((t - t0) / (t1 - t0)) * (W_ - L);
|
||||||
|
const y = (v) => (1 - (v - lo) / (hi - lo)) * (H - B);
|
||||||
|
const paths = series.map((s) => {
|
||||||
|
const [col, dash] = SERIES[c.entities.indexOf(s.eid) % SERIES.length];
|
||||||
|
const pts = s.pts.filter(([, v]) => isFinite(v));
|
||||||
|
if (!pts.length) return "";
|
||||||
|
let d = "";
|
||||||
|
pts.forEach(([t, v], j) => { d += `${j ? "L" : "M"}${x(Math.max(t, t0)).toFixed(1)} ${y(v).toFixed(1)}`; });
|
||||||
|
d += `L${x(t1).toFixed(1)} ${y(pts[pts.length - 1][1]).toFixed(1)}`;
|
||||||
|
const area = c.fill && series.length === 1 ? `<path d="${d}L${x(t1).toFixed(1)} ${H - B}L${x(Math.max(pts[0][0], t0)).toFixed(1)} ${H - B}Z" fill="${col}" opacity=".12"/>` : "";
|
||||||
|
return `${area}<path d="${d}" fill="none" stroke="${col}" stroke-width="2" stroke-dasharray="${dash}" stroke-linejoin="round" stroke-linecap="round"/>`;
|
||||||
|
}).join("");
|
||||||
|
const ticks = [0, 0.5, 1].map((f) => lo + (hi - lo) * f);
|
||||||
|
plot.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" role="img" aria-label="${t("History for the last {0} hours", hours)}">
|
||||||
|
${ticks.map((v) => `<line x1="${L}" x2="${W_}" y1="${y(v)}" y2="${y(v)}" stroke="var(--d-separator)"/><text class="axis" x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${fmtNum(v)}</text>`).join("")}
|
||||||
|
${paths}
|
||||||
|
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
W.clock = {
|
||||||
|
label: "Clock", icon: "clock", size: [3, 2],
|
||||||
|
fields: [
|
||||||
|
{ k: "h24", t: "check", l: "24-hour time", d: true },
|
||||||
|
{ k: "seconds", t: "check", l: "Show seconds", d: false },
|
||||||
|
{ k: "date", t: "check", l: "Show date", d: true },
|
||||||
|
],
|
||||||
|
render: (c) => `<div class="clock" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clockHTML(h24, sec, date) {
|
||||||
|
const d = new Date();
|
||||||
|
const time = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit", second: sec ? "2-digit" : undefined, hour12: !h24 });
|
||||||
|
return `<div class="time num">${time}</div>${date ? `<div class="date">${d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" })}</div>` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
W.weather = {
|
||||||
|
label: "Weather", icon: "cloud-sun", size: [4, 2],
|
||||||
|
fields: [{ k: "entity", t: "entity", l: "Weather", domains: ["weather"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }],
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return missing(c.entity, "a weather entity");
|
||||||
|
const a = s.attributes;
|
||||||
|
return `<button class="weather wbtn" data-act="more" data-e="${esc(c.entity)}"><span class="wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span>
|
||||||
|
<span class="wx-text"><span class="wx-temp num">${esc(a.temperature ?? "–")}°</span><span class="wt">${esc(friendly(s, c.name))} · ${esc(st(s.state))}</span>
|
||||||
|
<span class="wt">${t("Humidity")} ${esc(a.humidity ?? "–")}% · ${t("Wind")} ${esc(a.wind_speed ?? "–")} ${esc(a.wind_speed_unit || "km/h")}</span></span></button>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
W.list = {
|
||||||
|
label: "Entity list", icon: "list", size: [4, 4],
|
||||||
|
fields: [{ k: "title", t: "text", l: "Title", d: "Devices" }, { k: "entities", t: "entities", l: "Entities" }],
|
||||||
|
entities: (c) => c.entities || [],
|
||||||
|
render(c, { ha }) {
|
||||||
|
if (!c.entities?.length) return `${title(c.title)}<div class="empty">${t("Add entities in the inspector.")}</div>`;
|
||||||
|
const rows = c.entities.map((eid) => {
|
||||||
|
const s = ha.states[eid];
|
||||||
|
if (!s) return `<div class="list-row"><span class="row-ico">${Icons.svg("pulse")}</span><span class="grow muted">${esc(eid)}</span></div>`;
|
||||||
|
const sw = ["light", "switch", "fan", "input_boolean", "automation"].includes(domainOf(eid));
|
||||||
|
// Switches belong in list rows, so this is the one place we draw them.
|
||||||
|
return sw
|
||||||
|
? `<button class="list-row wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="switch"></span></button>`
|
||||||
|
: `<button class="list-row wbtn" data-act="more" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="st">${esc(fmtState(s))}</span></button>`;
|
||||||
|
}).join("");
|
||||||
|
return `${title(c.title)}<div class="list">${rows}</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
W.button = {
|
||||||
|
label: "Button", icon: "button", size: [2, 2],
|
||||||
|
fields: [
|
||||||
|
{ k: "label", t: "text", l: "Label", d: "Run" },
|
||||||
|
{ k: "icon", t: "icon", l: "Icon", d: "sparkles" },
|
||||||
|
{ k: "entity", t: "entity", l: "Target entity (optional)" },
|
||||||
|
{ k: "service", t: "text", l: "Action (e.g. scene.turn_on)", d: "" },
|
||||||
|
{ k: "data", t: "json", l: "Action data (JSON)", d: "" },
|
||||||
|
],
|
||||||
|
render: (c) => `<button class="btn-widget wbtn" data-act="button"><span class="badge">${icon(null, c.icon || "sparkles")}</span><span class="name">${esc(c.label)}</span></button>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
W.media = {
|
||||||
|
label: "Media player", icon: "music", size: [4, 2],
|
||||||
|
fields: [{ k: "entity", t: "entity", l: "Media player", domains: ["media_player"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }],
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return missing(c.entity, "a media player");
|
||||||
|
const a = s.attributes, e = esc(c.entity), playing = s.state === "playing";
|
||||||
|
return `<div class="media"><div class="wt">${esc(friendly(s, c.name))}</div>
|
||||||
|
<div><div class="title">${esc(a.media_title || st(s.state))}</div><div class="wt">${esc(a.media_artist || "")}</div></div>
|
||||||
|
<div class="ctrls">
|
||||||
|
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
|
||||||
|
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="media_player.media_play_pause" aria-label="${playing ? t("Pause") : t("Play")}">${Icons.svg(playing ? "pause" : "play")}</button>
|
||||||
|
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_next_track" aria-label="${t("Next track")}">${Icons.svg("next")}</button>
|
||||||
|
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${t("Volume")}">` : ""}
|
||||||
|
</div></div>`;
|
||||||
|
},
|
||||||
|
lit: (c, { ha }) => (ha.states[c.entity]?.state === "playing" ? "on" : false),
|
||||||
|
};
|
||||||
|
|
||||||
|
W.camera = {
|
||||||
|
label: "Camera", icon: "camera", size: [6, 4], flush: true,
|
||||||
|
fields: [{ k: "entity", t: "entity", l: "Camera", domains: ["camera"] }, { k: "refresh", t: "number", l: "Refresh every (seconds)", d: 10 }, { k: "showName", t: "check", l: "Show name", d: true }],
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return `<div class="pad">${missing(c.entity, "a camera")}</div>`;
|
||||||
|
return `<button class="cam-btn wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(t("{0} camera", friendly(s)))}"><img class="cam" data-cam="${esc(c.entity)}" data-refresh="${Number(c.refresh) || 10}" src="api/camera/${encodeURIComponent(c.entity)}?t=${Date.now()}" alt=""></button>
|
||||||
|
${c.showName ? `<div class="cam-label">${esc(friendly(s))}</div>` : ""}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- car
|
||||||
|
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
|
||||||
|
|
||||||
|
W.car = {
|
||||||
|
label: "Car", icon: "car", size: [6, 3],
|
||||||
|
fields: [
|
||||||
|
{ k: "name", t: "text", l: "Name", d: "My car" },
|
||||||
|
{ k: "image", t: "text", l: "Car image URL (optional)", ph: "https://… or /local/car.png" },
|
||||||
|
{ k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" },
|
||||||
|
{ k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] },
|
||||||
|
{ k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] },
|
||||||
|
{ k: "charging", t: "entity", l: "Charging", domains: ["binary_sensor", "sensor", "switch"] },
|
||||||
|
{ k: "power", t: "entity", l: "Charging power", domains: ["sensor"] },
|
||||||
|
{ k: "plug", t: "entity", l: "Plugged in", domains: ["binary_sensor", "sensor"] },
|
||||||
|
{ k: "limit", t: "entity", l: "Charge limit (%)", domains: ["number", "sensor", "input_number"] },
|
||||||
|
{ k: "lock", t: "entity", l: "Doors", domains: ["lock"] },
|
||||||
|
{ k: "climate", t: "entity", l: "Climate", domains: ["climate", "switch", "input_boolean"] },
|
||||||
|
{ k: "location", t: "entity", l: "Location", domains: ["device_tracker", "person", "sensor"] },
|
||||||
|
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
|
||||||
|
],
|
||||||
|
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
|
||||||
|
render(c, { ha }) {
|
||||||
|
const st = (k) => (c[k] ? ha.states[c[k]] : null);
|
||||||
|
const level = st("level"), range = st("range"), lock = st("lock"), clim = st("climate"), loc = st("location");
|
||||||
|
if (!c.level && !c.range && !c.lock) return `<div class="empty">${t("Set the car’s level, range or lock entity in the inspector.")}</div>`;
|
||||||
|
const pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null;
|
||||||
|
const power = st("power"), plug = st("plug"), limit = st("limit");
|
||||||
|
const charging = truthy(st("charging")) || (!c.charging && power && Number(power.state) > 0);
|
||||||
|
const plugged = charging || truthy(plug);
|
||||||
|
const low = pct != null && pct <= 20;
|
||||||
|
|
||||||
|
let status;
|
||||||
|
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
|
||||||
|
else if (plugged) status = t("Plugged in");
|
||||||
|
else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state);
|
||||||
|
else if (lock) status = lock.state === "locked" ? t("Locked") : t("Unlocked");
|
||||||
|
else status = "";
|
||||||
|
|
||||||
|
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
|
||||||
|
const bar = pct == null ? "" : `
|
||||||
|
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${c.kind === "fuel" ? t("Fuel level") : t("Battery level")}">
|
||||||
|
<span class="car-fill" style="width:${pct}%"></span>
|
||||||
|
${limPct != null && c.kind !== "fuel" ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
|
||||||
|
</div>`;
|
||||||
|
const actions = [];
|
||||||
|
if (lock) {
|
||||||
|
const locked = lock.state === "locked";
|
||||||
|
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.lock)}" aria-pressed="${!locked}">${Icons.svg(locked ? "lock" : "unlock")}<span>${locked ? t("Locked") : t("Unlocked")}</span></button>`);
|
||||||
|
}
|
||||||
|
if (clim) {
|
||||||
|
const on = isOn(clim) || clim.state === "on";
|
||||||
|
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.climate)}" aria-pressed="${on}">${Icons.svg(on ? "fan" : "snowflake")}<span>${on ? t("Climate on") : t("Climate")}</span></button>`);
|
||||||
|
}
|
||||||
|
if (loc) actions.push(`<button class="car-act wbtn" data-act="more" data-e="${esc(c.location)}">${Icons.svg("pin")}<span>${esc(fmtState(loc))}</span></button>`);
|
||||||
|
const odo = st("odometer");
|
||||||
|
|
||||||
|
return `<div class="car ${c.image ? "has-img" : ""}">
|
||||||
|
${c.image ? `<img class="car-img" src="${esc(c.image)}" alt="">` : ""}
|
||||||
|
<div class="car-head"><div class="name">${esc(c.name || t("Car"))}</div><div class="wt">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</div></div>
|
||||||
|
<div class="car-main">
|
||||||
|
${pct != null ? `<span class="big num">${pct}<span class="unit">%</span></span>` : ""}
|
||||||
|
${range ? `<span class="car-range">${esc(fmtState(range))}<span class="wt"> ${t("range")}</span></span>` : ""}
|
||||||
|
${low ? `<span class="level">${t("Low")}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
${bar}
|
||||||
|
${actions.length || odo ? `<div class="car-actions">${actions.join("")}${odo ? `<span class="wt car-odo">${esc(fmtState(odo))}</span>` : ""}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
W.text = {
|
||||||
|
label: "Text", icon: "text", size: [4, 2],
|
||||||
|
fields: [
|
||||||
|
{ k: "text", t: "textarea", l: "Text — use {{ sensor.x }} for live values", d: "Inside it’s {{ sensor.living_room_temperature }}." },
|
||||||
|
{ k: "size", t: "number", l: "Font size (px)", d: 17 },
|
||||||
|
],
|
||||||
|
entities: (c) => [...String(c.text || "").matchAll(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g)].map((m) => m[1]),
|
||||||
|
render(c, { ha }) {
|
||||||
|
const html = esc(c.text).replace(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g, (_, eid) => `<b>${esc(fmtState(ha.states[eid]))}</b>`);
|
||||||
|
return `<div class="text-w" style="font-size:${Math.max(13, Number(c.size) || 17)}px">${html}</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- photos
|
||||||
|
// One image, a slideshow of URLs, or a slideshow of Immich albums (proxied by the server).
|
||||||
|
const urlList = (c) => String(c.urls ?? c.url ?? "").split(/\s*\n\s*/).map((u) => u.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
W.image = {
|
||||||
|
label: "Photos", icon: "image", size: [4, 3], flush: true,
|
||||||
|
fields: [
|
||||||
|
{ k: "source", t: "seg", l: "Source", o: ["links", "immich"], d: "links" },
|
||||||
|
{ k: "urls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.source !== "immich" },
|
||||||
|
{ k: "albums", t: "albums", l: "Immich albums", when: (c) => c.source === "immich" },
|
||||||
|
{ k: "interval", t: "number", l: "Change photo every (seconds)", d: 20 },
|
||||||
|
{ k: "shuffle", t: "check", l: "Shuffle", d: true },
|
||||||
|
{ k: "fit", t: "seg", l: "Fit", o: ["cover", "contain"], d: "cover" },
|
||||||
|
{ k: "showDate", t: "check", l: "Show when the photo was taken", d: false, when: (c) => c.source === "immich" },
|
||||||
|
{ k: "alt", t: "text", l: "Description (for screen readers)" },
|
||||||
|
],
|
||||||
|
render(c) {
|
||||||
|
const empty = c.source === "immich" ? (c.albums?.length ? "" : t("Choose one or more Immich albums in the inspector.")) : urlList(c).length ? "" : t("Add image URLs in the inspector.");
|
||||||
|
if (empty) return `<div class="pad"><div class="empty">${empty}</div></div>`;
|
||||||
|
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
||||||
|
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
|
||||||
|
},
|
||||||
|
async after(el, c, ctx) {
|
||||||
|
const cache = ctx.cache;
|
||||||
|
clearTimeout(cache.timer);
|
||||||
|
const box = el.querySelector(".photos");
|
||||||
|
if (!box) return;
|
||||||
|
const msg = box.querySelector(".ph-msg");
|
||||||
|
const key = JSON.stringify([c.source, c.urls ?? c.url, c.albums, c.shuffle]);
|
||||||
|
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
|
||||||
|
cache.key = key; cache.at = Date.now(); cache.pos = 0;
|
||||||
|
cache.loading = (async () => {
|
||||||
|
if (c.source !== "immich") return urlList(c).map((src) => ({ src }));
|
||||||
|
const lists = await Promise.all((c.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
|
||||||
|
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
|
||||||
|
})().then((items) => {
|
||||||
|
if (c.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
|
||||||
|
cache.items = items; cache.error = null;
|
||||||
|
}).catch((e) => { cache.items = []; cache.error = e.message; });
|
||||||
|
}
|
||||||
|
await cache.loading;
|
||||||
|
if (!el.isConnected) return;
|
||||||
|
if (cache.error) { msg.innerHTML = `<div class="empty">${t("Couldn’t load photos. {0}", esc(cache.error))}</div>`; return; }
|
||||||
|
if (!cache.items.length) { msg.innerHTML = `<div class="empty">${t("These albums have no photos yet.")}</div>`; return; }
|
||||||
|
|
||||||
|
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
|
||||||
|
let front = 0;
|
||||||
|
const show = () => {
|
||||||
|
if (!el.isConnected) return;
|
||||||
|
const item = cache.items[cache.pos % cache.items.length];
|
||||||
|
cache.pos++;
|
||||||
|
const next = layers[1 - front];
|
||||||
|
next.onload = () => {
|
||||||
|
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
|
||||||
|
msg.innerHTML = "";
|
||||||
|
dateEl.textContent = c.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
|
||||||
|
};
|
||||||
|
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
|
||||||
|
next.src = item.src;
|
||||||
|
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(c.interval) || 20) * 1000);
|
||||||
|
};
|
||||||
|
show();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- calendar
|
||||||
|
const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"];
|
||||||
|
const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
const parseWhen = (v) => (typeof v === "string" && !v.includes("T") ? new Date(`${v}T00:00:00`) : new Date(v?.dateTime || v?.date || v));
|
||||||
|
const hm = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
|
||||||
|
|
||||||
|
W.calendar = {
|
||||||
|
label: "Calendar", icon: "calendar", size: [4, 4],
|
||||||
|
fields: [
|
||||||
|
{ k: "title", t: "text", l: "Title", d: "Upcoming" },
|
||||||
|
{ k: "entities", t: "entities", l: "Calendars", domains: ["calendar"] },
|
||||||
|
{ k: "days", t: "number", l: "Days ahead", d: 7 },
|
||||||
|
{ k: "max", t: "number", l: "Most events to show", d: 12 },
|
||||||
|
{ k: "showCalendar", t: "check", l: "Show which calendar", d: true },
|
||||||
|
],
|
||||||
|
entities: (c) => c.entities || [],
|
||||||
|
render(c) {
|
||||||
|
if (!c.entities?.length) return `${title(c.title)}<div class="empty">${t("Add one or more calendars in the inspector.")}</div>`;
|
||||||
|
return `${title(c.title)}<div class="cal"><div class="empty">${t("Loading events…")}</div></div>`;
|
||||||
|
},
|
||||||
|
async after(el, c, ctx) {
|
||||||
|
if (!c.entities?.length) return;
|
||||||
|
const days = Math.max(1, Number(c.days) || 7);
|
||||||
|
const key = c.entities.join(",") + "|" + days;
|
||||||
|
const cache = ctx.cache;
|
||||||
|
if (cache.key !== key || Date.now() - cache.at > 5 * 60 * 1000) {
|
||||||
|
cache.key = key; cache.at = Date.now();
|
||||||
|
const start = new Date(); start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(start.getTime() + days * 86400e3);
|
||||||
|
cache.loading = ctx.ha.calendarEvents(c.entities, start, end)
|
||||||
|
.then((res) => { cache.res = res || {}; cache.error = null; })
|
||||||
|
.catch((e) => { cache.res = {}; cache.error = e.message; });
|
||||||
|
}
|
||||||
|
await cache.loading;
|
||||||
|
const list = el.querySelector(".cal");
|
||||||
|
if (!el.isConnected || !list) return;
|
||||||
|
if (cache.error) { list.innerHTML = `<div class="empty">${t("Couldn’t load events. {0}", esc(cache.error))}</div>`; return; }
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const events = [];
|
||||||
|
c.entities.forEach((eid, i) => {
|
||||||
|
for (const ev of cache.res[eid]?.events || []) {
|
||||||
|
const allDay = typeof ev.start === "string" ? !ev.start.includes("T") : !!ev.start?.date;
|
||||||
|
const s = parseWhen(ev.start), e = parseWhen(ev.end);
|
||||||
|
if (e < now) continue;
|
||||||
|
events.push({ ...ev, s, e, allDay, cal: friendly(ctx.ha.states[eid]), color: CAL_COLORS[i % CAL_COLORS.length] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
events.sort((a, b) => a.s - b.s || b.allDay - a.allDay);
|
||||||
|
const shown = events.slice(0, Math.max(1, Number(c.max) || 12));
|
||||||
|
if (!shown.length) { list.innerHTML = `<div class="empty">${t("Nothing planned for the next {0} days.", days)}</div>`; return; }
|
||||||
|
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
|
const label = (d) => {
|
||||||
|
const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400e3);
|
||||||
|
return diff <= 0 ? t("Today") : diff === 1 ? t("Tomorrow") : d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" });
|
||||||
|
};
|
||||||
|
let html = "", lastDay = "";
|
||||||
|
const multi = c.entities.length > 1 && c.showCalendar;
|
||||||
|
for (const ev of shown) {
|
||||||
|
const day = ev.s < today ? today : ev.s;
|
||||||
|
if (dayKey(day) !== lastDay) { lastDay = dayKey(day); html += `<div class="cal-day">${esc(label(day))}</div>`; }
|
||||||
|
const when = ev.allDay ? t("All day") : `${hm(ev.s)}–${hm(ev.e)}`;
|
||||||
|
const live = !ev.allDay && ev.s <= now && ev.e > now;
|
||||||
|
html += `<div class="cal-ev ${live ? "live" : ""}"><span class="cal-bar" style="background:${ev.color}"></span>
|
||||||
|
<span class="cal-when num">${esc(when)}</span>
|
||||||
|
<span class="cal-what"><span class="cal-sum">${esc(ev.summary || t("Busy"))}</span>${ev.location || multi ? `<span class="cal-sub">${esc([multi ? ev.cal : "", ev.location].filter(Boolean).join(" · "))}</span>` : ""}</span>
|
||||||
|
${live ? `<span class="cal-now">${t("Now")}</span>` : ""}</div>`;
|
||||||
|
}
|
||||||
|
list.innerHTML = html;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
W.iframe = {
|
||||||
|
label: "Web page", icon: "globe", size: [6, 4], flush: true,
|
||||||
|
fields: [{ k: "url", t: "text", l: "URL" }],
|
||||||
|
render: (c) => (c.url ? `<iframe class="frame" src="${esc(c.url)}" loading="lazy" title="${t("Embedded page")}"></iframe>` : `<div class="pad"><div class="empty">${t("Add a URL in the inspector.")}</div></div>`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- waste (Afvalwijzer)
|
||||||
|
// Reads the per-type sensors of the Afvalwijzer integration: state is the next pickup
|
||||||
|
// date (ISO, or a timestamp), with days_until_collection_date and is_collection_date_*.
|
||||||
|
const WASTE_TYPES = {
|
||||||
|
gft: ["Organic", "#4CAF50"], papier: ["Paper", "#2196F3"], pmd: ["PMD", "#800000"], plastic: ["Plastic", "#FF9800"],
|
||||||
|
restafval: ["Residual waste", "#9E9E9E"], restafvalzakken: ["Residual waste bags", "#9E9E9E"], restwagen: ["Residual waste", "#9E9E9E"],
|
||||||
|
textiel: ["Textile", "#00695C"], grofvuil: ["Bulky waste", "#795548"], kca: ["Hazardous waste", "#F44336"], luiers: ["Nappies", "#FFA000"],
|
||||||
|
glas: ["Glass", "#009688"], snoeiafval: ["Pruning waste", "#2E7D32"], tuinafval: ["Garden waste", "#2E7D32"], kerstbomen: ["Christmas trees", "#1B5E20"],
|
||||||
|
best_tas: ["BEST bag", "#6D4C41"], milieubus: ["Hazardous waste van", "#F44336"], grip: ["GRIP", "#607D8B"], maas: ["Maas", "#607D8B"],
|
||||||
|
};
|
||||||
|
const WASTE_KEYS = Object.keys(WASTE_TYPES).sort((a, b) => b.length - a.length);
|
||||||
|
const isWasteSensor = (s) => !!s && s.entity_id.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in s.attributes;
|
||||||
|
const wasteKey = (eid) => WASTE_KEYS.find((k) => eid.endsWith(`_${k}`)) || eid.split("_").pop();
|
||||||
|
|
||||||
|
function wasteItems(c, ha) {
|
||||||
|
const ids = c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => isWasteSensor(ha.states[e]));
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
|
const out = [];
|
||||||
|
for (const eid of ids) {
|
||||||
|
const s = ha.states[eid];
|
||||||
|
if (!s) continue;
|
||||||
|
const raw = s.state;
|
||||||
|
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
|
||||||
|
if (isNaN(date)) continue; // "geen" or unavailable: no pickup planned
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
const days = s.attributes.days_until_collection_date ?? Math.round((date - today) / 86400e3);
|
||||||
|
if (days < 0) continue;
|
||||||
|
const key = wasteKey(eid), known = WASTE_TYPES[key];
|
||||||
|
out.push({ eid, key, days, date, name: known ? t(known[0]) : friendly(s).replace(/^afvalwijzer\s*/i, "") || cap(key), color: known?.[1] || "var(--d-text-2)" });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.days - b.days || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
const whenLabel = (d) => (d === 0 ? t("Today") : d === 1 ? t("Tomorrow") : d === 2 ? t("Day after tomorrow") : t("In {0} days", d));
|
||||||
|
const shortDate = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" });
|
||||||
|
|
||||||
|
W.waste = {
|
||||||
|
label: "Waste collection", icon: "bin", size: [4, 4],
|
||||||
|
fields: [
|
||||||
|
{ k: "title", t: "text", l: "Title", d: "Waste collection" },
|
||||||
|
{ k: "layout", t: "seg", l: "Layout", o: ["next", "list"], d: "next" },
|
||||||
|
{ k: "entities", t: "entities", l: "Waste sensors (leave empty to find them automatically)", domains: ["sensor"] },
|
||||||
|
{ k: "max", t: "number", l: "Most pickups to show", d: 6 },
|
||||||
|
{ k: "within", t: "number", l: "Only pickups within (days)", d: 31 },
|
||||||
|
],
|
||||||
|
// With automatic discovery every Afvalwijzer sensor matters, so listen to all of them.
|
||||||
|
entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("sensor.afvalwijzer_"))),
|
||||||
|
render(c, { ha }) {
|
||||||
|
const within = Number(c.within) || 31;
|
||||||
|
const items = wasteItems(c, ha).filter((i) => i.days <= within).slice(0, Math.max(1, Number(c.max) || 6));
|
||||||
|
if (!items.length) {
|
||||||
|
const any = c.entities?.length || Object.values(ha.states).some(isWasteSensor);
|
||||||
|
return `${title(c.title)}<div class="empty">${any ? t("No pickups in the next {0} days.", within) : t("No Afvalwijzer sensors found. Install the Afvalwijzer integration, or pick the sensors in the inspector.")}</div>`;
|
||||||
|
}
|
||||||
|
const dot = (i) => `<span class="bin" style="--bin:${i.color}" aria-hidden="true">${Icons.svg("bin")}</span>`;
|
||||||
|
let head = "", rest = items;
|
||||||
|
if (c.layout !== "list") {
|
||||||
|
const first = items.filter((i) => i.days === items[0].days);
|
||||||
|
rest = items.slice(first.length);
|
||||||
|
const soon = first[0].days <= 1;
|
||||||
|
head = `<div class="waste-next ${soon ? "soon" : ""}">
|
||||||
|
<div class="waste-bins">${first.map(dot).join("")}</div>
|
||||||
|
<div class="waste-next-text"><span class="wt">${t("Next pickup")}</span>
|
||||||
|
<span class="waste-when">${esc(whenLabel(first[0].days))}</span>
|
||||||
|
<span class="waste-types">${esc(first.map((i) => i.name).join(" · "))}</span>
|
||||||
|
${first[0].days === 1 ? `<span class="level">${t("Put the bin out tonight")}</span>` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
const rows = rest.map((i) => `<button class="list-row waste-row wbtn" data-act="more" data-e="${esc(i.eid)}">${dot(i)}
|
||||||
|
<span class="grow">${esc(i.name)}</span><span class="waste-day ${i.days <= 1 ? "soon" : ""}">${esc(whenLabel(i.days))}</span><span class="st">${esc(shortDate(i.date))}</span></button>`).join("");
|
||||||
|
return `${title(c.title)}${head}${rows ? `<div class="list waste-list">${rows}</div>` : ""}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Widgets = W;
|
||||||
|
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
name: Dashboard Studio Apps
|
||||||
|
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
|
||||||
|
maintainer: veenm
|
||||||
Reference in New Issue
Block a user