Dashboard Studio 1.12.0: new cards, kiosk doorbell, night mode, editor tools, version history
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 1.12.0
|
||||
|
||||
- **Seven new cards:** a to-do and shopping list (add, tick off, swipe to delete), who's home (photo, home or away, for how long, which zone), a weather forecast (hourly with rain chance, the next days, sunrise and sunset), a kitchen timer (quick starts, countdown ring, a beep when done), blinds and covers (position, tilt, open or close all), an alarm panel (keypad, arming modes, warns about open doors and windows) and notifications (dismiss them, install updates).
|
||||
- **Light groups redesigned:** white tones and colors for the whole group, and a brightness slider for every light in it. Tap a light for its own panel with brightness, color temperature and colors; tapping a light elsewhere opens the same panel.
|
||||
- **Car card redesigned** around a round gauge with the range, the charge limit and when it will be full, with buttons for the doors, climate, charging and location.
|
||||
- **Over 130 icons**, with new ones for rooms, appliances, tools and devices, and an icon picker with search.
|
||||
- **Scroll and swipe cards:** set your own text for when none of their cards are showing, or hide the card.
|
||||
- **Kiosk:** the doorbell camera fills the screen when someone rings and closes by itself after a minute, and the screen saver can show photos from links or Immich albums.
|
||||
- **Night mode** per device: dim the screen and switch to the dark theme after sunset or between two times.
|
||||
- **Editor:** search the widget list; entity fields show a list with icons, names and states; select several widgets with Shift-click to move, align, duplicate or delete them together; copy widgets to another tab or dashboard; drag a widget onto a scroll or swipe card to put it inside, and take it out again.
|
||||
- **Version history:** bring back an earlier save of a dashboard from the ⋯ menu.
|
||||
|
||||
## 1.11.0
|
||||
|
||||
- **Energy card.** A live flow diagram of solar, the grid, a home battery and your home: the lines are as thick and as fast as the power flowing through them. Shows the battery level, and any totals for today you add.
|
||||
|
||||
@@ -12,12 +12,20 @@ A drag-and-drop dashboard builder that lives in its own sidebar tab.
|
||||
|
||||
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.
|
||||
|
||||
More editing tools:
|
||||
|
||||
- **Search** at the top of the widget list finds a widget by name. Press Enter to add the first match.
|
||||
- **Entity fields** open a list of your entities with their icon, name, ID and current state. Type to filter by name or ID, use the arrow keys and Enter, or click one.
|
||||
- **Several widgets at once:** Shift-click or ⌘-click (Ctrl-click) widgets to select them together. Drag one to move them all, or use the arrow keys. The right panel can line up their left, right, top or bottom edges, give them the same width or height (that of the widget you clicked last), duplicate them, copy them elsewhere or delete them.
|
||||
- **Copy to…** (in the right panel) copies the selected widgets to another tab or another dashboard.
|
||||
- **Into a scroll or swipe card:** drag a widget onto a horizontal scroll or swipe card; it lights up, and when you let go the widget becomes one of its cards. To take a card out again, open it in the container's settings and choose **Take out of this card**.
|
||||
|
||||
## Look and feel
|
||||
|
||||
- **Theme:** *System* follows your device's light or dark appearance. The other 14 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, and High contrast for maximum readability. Text meets accessibility contrast levels in every theme. 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.
|
||||
- **Icons:** widgets pick an icon automatically. To change one, click the icon next to the field to open the icon picker (over 130 icons for rooms, appliances, tools and devices, searchable in English and Dutch), type an icon name, or paste an emoji.
|
||||
|
||||
## Widgets
|
||||
|
||||
@@ -59,11 +67,41 @@ These cards hold other cards. Select one in edit mode, pick a card type under **
|
||||
- **Horizontal scroll** puts its cards in a row you can scroll sideways, by touch, trackpad or the arrow buttons that appear on hover. Set the card width in pixels, or as the number of cards visible at once (2.5 lets half a card peek out, so it's clear the row scrolls). A single card can get its own width in its settings. Add an optional title. The row keeps its scroll position when something inside it changes.
|
||||
- **Swipe cards** shows one card at a time, filling the whole card. Swipe with a finger or drag with the mouse: the card follows along, resists at the first and last card, and a quick flick is enough. The left and right arrow keys work too. Small dots appear at the bottom while you swipe or hover, and hide again after a few seconds; tap one to jump to that card. It can also move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on.
|
||||
|
||||
Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off.
|
||||
Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off. When none of its cards are showing, the container shows a short message: change it with **Text when none of its cards are showing**, or tick **Hide this card when none of its cards are showing** to hide the container itself.
|
||||
|
||||
### Light groups
|
||||
|
||||
When the light card points at a light group, it also lists every light in the group, each with its own switch and brightness. The group's own switch and slider still control all of them at once. When you pick a group, the card grows to four rows so the list fits. Turn off **For a light group, show each light** to hide the list.
|
||||
When the light card points at a light group, it becomes a control panel for the whole group: a switch and brightness slider for all lights at once, a row of white tones and colors (shown when the lights support them), and every light in the group with its own switch and brightness slider. Tap a light's colored dot to open its own panel with brightness, color temperature, colors and a free color picker. When you pick a group, the card grows to four rows so the list fits. Turn off **For a light group, show each light** to hide the list.
|
||||
|
||||
Tapping a light anywhere else on the dashboard (for example in a room card) opens the same light panel.
|
||||
|
||||
### To-do and shopping lists
|
||||
|
||||
Pick a to-do list (`todo.*`, such as Home Assistant's shopping list). Add items in the field at the top, tick them off, and swipe an item to the left (or use its delete button) to remove it. Completed items are hidden unless you turn on **Show completed items**; there's also a toggle in the card.
|
||||
|
||||
### Who's home
|
||||
|
||||
Shows each person (`person.*`, or everyone when you leave the list empty) with their photo or initials, home or away, how long they've been there or away, and the zone they're in. Choose a grid or a list.
|
||||
|
||||
### Weather forecast
|
||||
|
||||
An hourly strip (temperature, icon and chance of rain) and the next days with highs, lows and rain chance, from any weather entity. Choose hourly, daily or both, how far ahead, and whether to show today's sunrise and sunset (from `sun.sun`).
|
||||
|
||||
### Timer
|
||||
|
||||
For Home Assistant timer helpers (`timer.*`). A ring counts down with the time left. Start it with the quick-start buttons (5, 10, 15 and 30 minutes by default, which you can change), and pause, resume or cancel it. When the timer finishes, the card beeps (you can turn that off) and shows that it's done for a minute, or until you tap **OK**.
|
||||
|
||||
### Blinds and covers
|
||||
|
||||
Lists blinds, curtains or shutters (`cover.*`) with a position slider, a tilt slider for blinds that tilt, and open, stop and close buttons. **Open all** and **Close all** control every cover in the card at once.
|
||||
|
||||
### Alarm
|
||||
|
||||
An alarm panel (`alarm_control_panel.*`) with its state, a keypad for the code (shown when the panel asks for one), and buttons for the arming modes it supports. Add the doors and windows to check, or leave it empty to check every door and window sensor: open ones are listed, and arming asks for confirmation while something is open.
|
||||
|
||||
### Notifications
|
||||
|
||||
Home Assistant's notifications, with a button to dismiss each one or all, plus available updates (`update.*`) with a button to install them (after confirmation).
|
||||
|
||||
### Vacuum
|
||||
|
||||
@@ -123,7 +161,9 @@ Point the car widget at whichever entities your car integration provides. Each o
|
||||
| Location | `device_tracker.<car>_location` |
|
||||
| Odometer | `sensor.<car>_odometer` |
|
||||
|
||||
The card is designed around 3×4. It shows a drawn car (or your own photo) that glows with a bolt while charging, the charge level and range, the battery bar with the charge limit marked, a status (charging and its power, plugged in, parked at home or away) and buttons for the doors, climate and location. On a short card it becomes a compact strip, on a wide card the car sits beside the numbers, and a tall card adds tiles for the odometer, charge limit and charging power. You can add a car image by URL. Images you put in Home Assistant's `www` folder are available at `/local/…`.
|
||||
Two optional extra fields: **Time until full** (a sensor in minutes or an end time) and **Start/stop charging** (a switch or button).
|
||||
|
||||
The card is designed around 3×4. It shows a drawn car (or your own photo) that glows with a bolt while charging, a round gauge with the charge level, the range in the middle and the charge limit marked, a status (charging with its power and when it will be full, plugged in, parked at home or away) and round buttons for the doors, climate, charging and location. On a short card it becomes a compact strip, on a wide card the car sits beside the numbers, and a tall card adds tiles for the odometer, charge limit and charging power. You can add a car image by URL. Images you put in Home Assistant's `www` folder are available at `/local/…`.
|
||||
|
||||
## Conditional widgets
|
||||
|
||||
@@ -155,20 +195,27 @@ Press **Kiosk** in the toolbar, or open the dashboard with `#kiosk` at the end o
|
||||
- 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)
|
||||
- can show a screen saver after a number of minutes (set this in the dashboard's **Kiosk** settings): a dimmed clock, or a slideshow of photos from image links or Immich albums with the time in the corner
|
||||
- can show the doorbell camera full screen when someone rings (see below)
|
||||
|
||||
**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.
|
||||
|
||||
**Doorbell:** in the **Kiosk** settings, pick the doorbell (a binary sensor that turns on, an `event.*` doorbell entity, or a switch or helper) and the camera to show. When it rings while kiosk mode is on, the camera fills the screen with a live picture, the screen saver goes away and a short chime plays (you can turn that off). It closes by itself after a minute, or after the number of seconds you set, or when you tap **Close**.
|
||||
|
||||
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
|
||||
## Language and night mode
|
||||
|
||||
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.
|
||||
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** under **This device** at the bottom of the dashboard settings. The app's Configuration tab in Home Assistant is translated as well.
|
||||
|
||||
**Night mode** is set per device in the same place. Choose **After sunset** (using `sun.sun`) or **Between times**, how much to dim the screen, and whether to switch to the dark theme. It turns on and off by itself, and is off while you're editing.
|
||||
|
||||
## 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.
|
||||
|
||||
**Version history…** in the **⋯** menu lists the earlier saves of the dashboard, up to 30, with the time and the number of widgets. Changes made within five minutes of each other count as one version. **Restore** brings a version back; ⌘Z undoes that.
|
||||
|
||||
## Which dashboard opens where
|
||||
|
||||
Use **⋯ → Who sees which dashboard…** in edit mode to choose the dashboard that opens first:
|
||||
@@ -185,4 +232,4 @@ The version you're running is shown at the bottom of the dashboard settings, und
|
||||
|
||||
## Data
|
||||
|
||||
Dashboards are stored in the app's `/data/dashboards.json`, so they are included in Home Assistant backups.
|
||||
Dashboards are stored in the app's `/data/dashboards.json`, and their version history in `/data/history/`, so both are included in Home Assistant backups.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Dashboard Studio
|
||||
version: "1.11.0"
|
||||
version: "1.12.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
|
||||
|
||||
@@ -13,6 +13,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
@@ -99,6 +100,50 @@ def save_store(data):
|
||||
tmp.replace(STORE_FILE)
|
||||
|
||||
|
||||
# Version history: each dashboard keeps its last saves in history/<id>.json, newest last.
|
||||
# Saves within a few minutes of each other count as one version, so typing doesn't fill the list.
|
||||
HISTORY_DIR = DATA_DIR / "history"
|
||||
HISTORY_MAX = 30
|
||||
HISTORY_MERGE_S = 5 * 60
|
||||
SAFE_ID = re.compile(r"^[\w-]{1,64}$")
|
||||
|
||||
|
||||
def history_file(dash_id):
|
||||
return HISTORY_DIR / f"{dash_id}.json"
|
||||
|
||||
|
||||
def load_history(dash_id):
|
||||
try:
|
||||
return json.loads(history_file(dash_id).read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def record_history(old, new):
|
||||
"""Add every dashboard that changed in this save to its history."""
|
||||
before = {d.get("id"): d for d in old.get("dashboards", []) if isinstance(d, dict)}
|
||||
now = time.time()
|
||||
for d in new.get("dashboards", []):
|
||||
dash_id = d.get("id") if isinstance(d, dict) else None
|
||||
if not isinstance(dash_id, str) or not SAFE_ID.match(dash_id) or before.get(dash_id) == d:
|
||||
continue
|
||||
entries = load_history(dash_id)
|
||||
if not entries and dash_id in before: # the first time: keep what was there before this save
|
||||
try:
|
||||
saved_at = min(STORE_FILE.stat().st_mtime, now - HISTORY_MERGE_S - 1)
|
||||
except OSError:
|
||||
saved_at = now - HISTORY_MERGE_S - 1
|
||||
entries.append({"at": saved_at, "data": before[dash_id]})
|
||||
if entries and now - entries[-1]["at"] < HISTORY_MERGE_S:
|
||||
entries[-1] = {"at": now, "data": d}
|
||||
else:
|
||||
entries.append({"at": now, "data": d})
|
||||
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = history_file(dash_id).with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(entries[-HISTORY_MAX:]))
|
||||
tmp.replace(history_file(dash_id))
|
||||
|
||||
|
||||
async def index(request):
|
||||
# Stamp the version on every asset URL, so an update can never be served from a stale cache.
|
||||
html = (WEB_DIR / "index.html").read_text()
|
||||
@@ -164,10 +209,43 @@ async def put_dashboards(request):
|
||||
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)
|
||||
try:
|
||||
record_history(load_store(), data)
|
||||
except OSError:
|
||||
LOG.exception("Could not write the version history")
|
||||
save_store(data)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
def widget_count(d):
|
||||
return sum(len(t.get("widgets", [])) for t in d.get("tabs", []) if isinstance(t, dict))
|
||||
|
||||
|
||||
async def get_history(request):
|
||||
dash_id = request.match_info["dash_id"]
|
||||
if not SAFE_ID.match(dash_id):
|
||||
return web.Response(status=400)
|
||||
entries = load_history(dash_id)
|
||||
return web.json_response([
|
||||
{"at": e["at"], "name": e["data"].get("name", ""), "tabs": len(e["data"].get("tabs", [])), "widgets": widget_count(e["data"])}
|
||||
for e in reversed(entries)
|
||||
], headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
async def get_history_version(request):
|
||||
dash_id = request.match_info["dash_id"]
|
||||
if not SAFE_ID.match(dash_id):
|
||||
return web.Response(status=400)
|
||||
try:
|
||||
at = float(request.match_info["at"])
|
||||
except ValueError:
|
||||
return web.Response(status=400)
|
||||
for e in load_history(dash_id):
|
||||
if abs(e["at"] - at) < 0.001:
|
||||
return web.json_response(e["data"], headers={"Cache-Control": "no-store"})
|
||||
return web.Response(status=404)
|
||||
|
||||
|
||||
async def camera(request):
|
||||
entity_id = request.match_info["entity_id"]
|
||||
if not entity_id.startswith("camera.") or "/" in entity_id:
|
||||
@@ -261,6 +339,8 @@ def make_app():
|
||||
app.router.add_get("/api/users", users)
|
||||
app.router.add_get("/api/dashboards", get_dashboards)
|
||||
app.router.add_put("/api/dashboards", put_dashboards)
|
||||
app.router.add_get("/api/history/{dash_id}", get_history)
|
||||
app.router.add_get("/api/history/{dash_id}/{at}", get_history_version)
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aiohttp import WSMsgType
|
||||
@@ -43,11 +44,12 @@ class MockHA:
|
||||
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.living_room", "on", friendly_name="Living Room", brightness=180, supported_color_modes=["color_temp", "rgb"], color_mode="color_temp",
|
||||
color_temp_kelvin=2700, rgb_color=[255, 167, 87], min_color_temp_kelvin=2000, max_color_temp_kelvin=6500),
|
||||
entity("light.reading_lamp", "on", friendly_name="Reading Lamp", brightness=140, rgb_color=[255, 140, 60], supported_color_modes=["rgb"], color_mode="rgb"),
|
||||
entity("light.kitchen", "off", friendly_name="Kitchen", brightness=None, supported_color_modes=["color_temp"], min_color_temp_kelvin=2200, max_color_temp_kelvin=6500),
|
||||
entity("light.bedroom", "on", friendly_name="Bedroom", brightness=90, supported_color_modes=["brightness"]),
|
||||
entity("light.downstairs", "on", friendly_name="Downstairs", brightness=160, supported_color_modes=["brightness"],
|
||||
entity("light.downstairs", "on", friendly_name="Downstairs", brightness=160, supported_color_modes=["color_temp", "rgb"],
|
||||
entity_id=["light.living_room", "light.kitchen", "light.reading_lamp"]),
|
||||
entity("vacuum.robot", "docked", friendly_name="Robot Vacuum", battery_level=82, fan_speed="standard",
|
||||
fan_speed_list=["quiet", "standard", "turbo", "max"], status="Charging"),
|
||||
@@ -63,6 +65,7 @@ class MockHA:
|
||||
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.doorbell", "off", friendly_name="Doorbell", device_class="occupancy"),
|
||||
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"],
|
||||
preset_modes=["none", "eco", "comfort", "away"], preset_mode="comfort", fan_modes=["auto", "low", "high"], fan_mode="auto", hvac_action="heating", current_humidity=46, target_temp_step=0.5),
|
||||
@@ -101,7 +104,17 @@ class MockHA:
|
||||
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", user_id="mock-user-alex"),
|
||||
entity("person.alex", "home", friendly_name="Alex", user_id="mock-user-alex", entity_picture="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='%23f2b880'/><circle cx='32' cy='26' r='12' fill='%23fff3e6'/><path d='M10 64c2-14 12-20 22-20s20 6 22 20z' fill='%23fff3e6'/></svg>"),
|
||||
entity("person.noor", "Work", friendly_name="Noor"),
|
||||
entity("todo.shopping_list", "3", friendly_name="Shopping list"),
|
||||
entity("cover.living_room_blinds", "open", friendly_name="Living Room Blinds", current_position=60, current_tilt_position=30),
|
||||
entity("cover.bedroom_curtain", "closed", friendly_name="Bedroom Curtain", current_position=0),
|
||||
entity("alarm_control_panel.home", "disarmed", friendly_name="Home Alarm", code_format="number", supported_features=7),
|
||||
entity("update.home_assistant_core_update", "on", friendly_name="Home Assistant Core Update", title="Home Assistant Core",
|
||||
installed_version="2026.10.1", latest_version="2026.10.3", release_url="https://www.home-assistant.io/blog/"),
|
||||
entity("timer.kitchen", "idle", friendly_name="Kitchen timer", duration="0:10:00", remaining="0:10:00"),
|
||||
entity("sun.sun", "above_horizon", friendly_name="Sun",
|
||||
next_rising=(datetime.now(timezone.utc) + timedelta(hours=9)).isoformat(), next_setting=(datetime.now(timezone.utc) + timedelta(hours=3)).isoformat()),
|
||||
entity("person.sam", "not_home", friendly_name="Sam", user_id="mock-user-sam"),
|
||||
entity("input_boolean.guest_mode", "off", friendly_name="Guest Mode"),
|
||||
entity("calendar.family", "off", friendly_name="Family"),
|
||||
@@ -113,12 +126,22 @@ class MockHA:
|
||||
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("sensor.model_y_time_to_full", "48", friendly_name="Model Y Time to full", unit_of_measurement="min", device_class="duration"),
|
||||
entity("switch.model_y_charger", "on", friendly_name="Model Y Charger"),
|
||||
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()
|
||||
self.todo = [{"uid": "1", "summary": "Milk", "status": "needs_action"}, {"uid": "2", "summary": "Bread", "status": "needs_action"},
|
||||
{"uid": "3", "summary": "Coffee beans", "status": "needs_action"}, {"uid": "4", "summary": "Batteries", "status": "completed"}]
|
||||
self._timer_handle = None
|
||||
self.notes = {
|
||||
"welcome": {"notification_id": "welcome", "title": "New devices found", "message": "Home Assistant found **2 new devices**. [Check them](/config/integrations).", "created_at": now_iso()},
|
||||
"backup": {"notification_id": "backup", "title": "Backup finished", "message": "Last night's backup completed.", "created_at": now_iso()},
|
||||
}
|
||||
self.note_subs = set()
|
||||
|
||||
# -- state handling ---------------------------------------------------
|
||||
|
||||
@@ -144,6 +167,24 @@ class MockHA:
|
||||
except Exception: # client went away
|
||||
pass
|
||||
|
||||
def _timer(self, eid, service, data):
|
||||
a = self.states[eid]["attributes"]
|
||||
secs = lambda v: sum(int(x) * m for x, m in zip(reversed(str(v).split(":")), (1, 60, 3600)))
|
||||
fmt = lambda n: f"{n // 3600}:{n % 3600 // 60:02d}:{n % 60:02d}"
|
||||
if self._timer_handle:
|
||||
self._timer_handle.cancel()
|
||||
if service == "start":
|
||||
dur = secs(data["duration"]) if data.get("duration") else (secs(a["remaining"]) if self.states[eid]["state"] == "paused" else secs(a["duration"]))
|
||||
total = secs(data["duration"]) if data.get("duration") else secs(a["duration"])
|
||||
fin = datetime.now(timezone.utc) + timedelta(seconds=dur)
|
||||
self.set_state(eid, "active", duration=fmt(total), remaining=fmt(dur), finishes_at=fin.isoformat())
|
||||
self._timer_handle = asyncio.get_event_loop().call_later(dur, lambda: self.set_state(eid, "idle", remaining=fmt(total), finishes_at=None))
|
||||
elif service == "pause":
|
||||
left = max(0, int((datetime.fromisoformat(a["finishes_at"]) - datetime.now(timezone.utc)).total_seconds())) if a.get("finishes_at") else secs(a["remaining"])
|
||||
self.set_state(eid, "paused", remaining=fmt(left), finishes_at=None)
|
||||
elif service in ("cancel", "finish"):
|
||||
self.set_state(eid, "idle", remaining=a["duration"], finishes_at=None)
|
||||
|
||||
def _vacuum(self, eid, service, data):
|
||||
if service == "start":
|
||||
self.set_state(eid, "cleaning", status="Cleaning")
|
||||
@@ -175,6 +216,35 @@ class MockHA:
|
||||
for eid in expanded:
|
||||
if eid not in self.states:
|
||||
continue
|
||||
if domain == "todo":
|
||||
item = data.get("item")
|
||||
if service == "add_item":
|
||||
self.todo.insert(0, {"uid": str(int(time.time() * 1000)), "summary": item, "status": "needs_action"})
|
||||
for it in list(self.todo):
|
||||
if it["uid"] == item or it["summary"] == item:
|
||||
if service == "update_item":
|
||||
it["status"] = data.get("status", it["status"])
|
||||
elif service == "remove_item":
|
||||
self.todo.remove(it)
|
||||
self.set_state(eid, str(sum(1 for it in self.todo if it["status"] == "needs_action")))
|
||||
continue
|
||||
if domain == "cover":
|
||||
pos = self.states[eid]["attributes"].get("current_position")
|
||||
if service == "set_cover_position":
|
||||
p_ = int(data.get("position", 0)); self.set_state(eid, "open" if p_ > 0 else "closed", current_position=p_)
|
||||
elif service == "set_cover_tilt_position":
|
||||
self.set_state(eid, None, current_tilt_position=int(data.get("tilt_position", 0)))
|
||||
elif service in ("open_cover", "close_cover"):
|
||||
self.set_state(eid, "open" if service == "open_cover" else "closed", current_position=100 if service == "open_cover" else 0)
|
||||
continue
|
||||
if domain == "alarm_control_panel":
|
||||
if service != "alarm_disarm" and data.get("code") not in (None, "1234") or service == "alarm_disarm" and data.get("code") != "1234":
|
||||
raise ValueError("Invalid code")
|
||||
self.set_state(eid, {"alarm_arm_home": "armed_home", "alarm_arm_away": "armed_away", "alarm_arm_night": "armed_night", "alarm_disarm": "disarmed"}[service])
|
||||
continue
|
||||
if domain == "timer":
|
||||
self._timer(eid, service, data)
|
||||
continue
|
||||
if eid.startswith("button.dishwasher_"): # Home Connect program buttons
|
||||
st_ = {"stop": "aborting", "pause": "pause", "resume": "run"}[eid.split("_")[-2]]
|
||||
self.set_state("sensor.dishwasher_operation_state", st_)
|
||||
@@ -198,6 +268,10 @@ class MockHA:
|
||||
extra["brightness"] = round(data["brightness_pct"] * 2.55)
|
||||
elif on and not self.states[eid]["attributes"].get("brightness"):
|
||||
extra["brightness"] = 255
|
||||
if "rgb_color" in data:
|
||||
extra.update(rgb_color=data["rgb_color"], color_mode="rgb")
|
||||
if "color_temp_kelvin" in data:
|
||||
extra.update(color_temp_kelvin=data["color_temp_kelvin"], color_mode="color_temp")
|
||||
if not on:
|
||||
extra["brightness"] = None
|
||||
self.set_state(eid, "on" if on else "off", **extra)
|
||||
@@ -323,12 +397,35 @@ class MockHA:
|
||||
entry = (ws, frozenset(subs))
|
||||
self.clients.add(entry)
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": None})
|
||||
elif typ == "persistent_notification/subscribe":
|
||||
self.note_subs.add((ws, mid))
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": None})
|
||||
await ws.send_json({"id": mid, "type": "event", "event": {"type": "current", "notifications": self.notes}})
|
||||
elif typ == "call_service" and req.get("domain") == "persistent_notification":
|
||||
gone = dict(self.notes) if req["service"] == "dismiss_all" else {k: v for k, v in self.notes.items() if k == (req.get("service_data") or {}).get("notification_id")}
|
||||
for k in gone:
|
||||
self.notes.pop(k, None)
|
||||
for w2, sid in list(self.note_subs):
|
||||
asyncio.ensure_future(self._safe_send(w2, {"id": sid, "type": "event", "event": {"type": "removed", "notifications": gone}}))
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}}})
|
||||
elif typ == "todo/item/list":
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"items": self.todo}})
|
||||
elif typ == "call_service" and req.get("domain") == "weather" and req.get("service") == "get_forecasts" and (req.get("service_data") or {}).get("type") == "hourly":
|
||||
ids = (req.get("target") or {}).get("entity_id") or []
|
||||
ids = [ids] if isinstance(ids, str) else ids
|
||||
hour = datetime.now().astimezone().replace(minute=0, second=0, microsecond=0)
|
||||
conds = ["partlycloudy", "partlycloudy", "cloudy", "rainy", "rainy", "cloudy", "partlycloudy", "sunny"]
|
||||
forecast = [{"datetime": (hour + timedelta(hours=i + 1)).isoformat(), "condition": conds[i % 8], "temperature": round(13 + 3 * math.sin(i / 4), 1),
|
||||
"precipitation_probability": [0, 10, 30, 70, 80, 40, 10, 0][i % 8]} for i in range(24)]
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": {e: {"forecast": forecast} for e in ids}}})
|
||||
elif typ == "call_service" and req.get("domain") == "weather" and req.get("service") == "get_forecasts":
|
||||
ids = (req.get("target") or {}).get("entity_id") or []
|
||||
ids = [ids] if isinstance(ids, str) else ids
|
||||
day = datetime.now().astimezone().replace(hour=12, minute=0, second=0, microsecond=0)
|
||||
forecast = [{"datetime": (day + timedelta(days=i)).isoformat(), "condition": c, "temperature": hi, "templow": lo}
|
||||
for i, (c, hi, lo) in enumerate([("partlycloudy", 15, 8), ("rainy", 12, 7), ("sunny", 16, 6)])]
|
||||
for i, (c, hi, lo) in enumerate([("partlycloudy", 15, 8), ("rainy", 12, 7), ("sunny", 16, 6), ("cloudy", 14, 9), ("pouring", 11, 8), ("partlycloudy", 13, 6), ("sunny", 17, 7)])]
|
||||
for i, f in enumerate(forecast):
|
||||
f["precipitation_probability"] = [10, 80, 0, 20, 90, 30, 0][i]
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": {e: {"forecast": forecast} for e in ids}}})
|
||||
elif typ == "call_service" and req.get("domain") == "calendar" and req.get("service") == "get_events":
|
||||
ids = (req.get("target") or {}).get("entity_id") or []
|
||||
@@ -338,7 +435,11 @@ class MockHA:
|
||||
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)
|
||||
try:
|
||||
self.call_service(req["domain"], req["service"], data)
|
||||
except ValueError as err:
|
||||
await ws.send_json({"id": mid, "type": "result", "success": False, "error": {"code": "service_validation_error", "message": str(err)}})
|
||||
continue
|
||||
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()
|
||||
@@ -371,3 +472,4 @@ class MockHA:
|
||||
await ws.send_json({"id": mid, "type": "result", "success": False, "error": {"code": "unknown_command", "message": typ}})
|
||||
finally:
|
||||
self.clients.discard(entry)
|
||||
self.note_subs = {x for x in self.note_subs if x[0] is not ws}
|
||||
|
||||
+638
-52
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,8 @@
|
||||
"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.",
|
||||
"Click to add. Drag to move, drag the corner to resize. Shift-click to select several. 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. Shift-klik om er meer te selecteren. Pijltjestoetsen verplaatsen de geselecteerde widget, Shift+pijltjes wijzigen het formaat, ⌘D dupliceert, Delete verwijdert.",
|
||||
"Cancel": "Annuleer",
|
||||
"Create": "Maak aan",
|
||||
"Rename": "Wijzig naam",
|
||||
@@ -301,6 +301,85 @@
|
||||
"Power switch": "Aan/uit-schakelaar", "Alerts, e.g. salt or rinse aid low": "Meldingen, bijv. zout of glansspoelmiddel bijna op",
|
||||
"Starts later": "Start later", "Action needed": "Actie nodig", "Error": "Fout", "Stopping": "Stoppen", "Door locked": "Deur vergrendeld",
|
||||
"Ready at {0}": "Klaar om {0}", "Check the appliance": "Kijk even bij het apparaat", "Something went wrong": "Er ging iets mis", "Stop": "Stop",
|
||||
"Hide this card when none of its cards are showing": "Verberg deze kaart als geen van de kaarten erin zichtbaar is",
|
||||
"Text when none of its cards are showing": "Tekst als geen van de kaarten erin zichtbaar is",
|
||||
"Choose an icon": "Kies een pictogram", "No icons match.": "Geen pictogrammen gevonden.", "Search, e.g. kitchen or keuken": "Zoek, bijv. keuken of slaapkamer",
|
||||
"Search icons": "Zoek pictogrammen", "You can also type an emoji in the field instead.": "Je kunt in het veld ook een emoji typen.", "No icon": "Geen pictogram",
|
||||
"Warm white": "Warm wit", "Neutral white": "Neutraal wit", "Cool white": "Koel wit", "Red": "Rood", "Orange": "Oranje", "Yellow": "Geel", "Green": "Groen",
|
||||
"Light blue": "Lichtblauw", "Blue": "Blauw", "Purple": "Paars", "Pink": "Roze", "More colors and settings": "Meer kleuren en instellingen",
|
||||
"Show colors": "Toon kleuren", "{0} of {1} on": "{0} van {1} aan", "Settings for {0}": "Instellingen voor {0}", "{0} brightness": "Helderheid {0}",
|
||||
"Brightness": "Helderheid", "Color temperature": "Kleurtemperatuur", "Color": "Kleur", "Any color": "Andere kleur",
|
||||
"Full at {0}": "Vol om {0}", "inside": "binnen", "Stop charging": "Stop laden", "Start charging": "Start laden",
|
||||
"Time until full (minutes or end time)": "Tijd tot vol (minuten of eindtijd)", "Start/stop charging (switch)": "Laden starten/stoppen (schakelaar)",
|
||||
"Car image URL (optional, a side view with a transparent background looks best)": "URL van autoafbeelding (optioneel, een zijaanzicht met transparante achtergrond werkt het best)",
|
||||
"To-do list": "Takenlijst", "List": "Lijst", "List name": "Naam van de lijst", "Show completed items": "Toon afgevinkte items", "a to-do list": "een takenlijst",
|
||||
"Delete {0}": "Verwijder {0}", "Add an item…": "Voeg iets toe…", "Add an item": "Voeg iets toe", "Nothing on the list.": "Er staat niets op de lijst.", "Loading…": "Laden…",
|
||||
"Hide completed ({0})": "Verberg afgevinkt ({0})", "Show completed ({0})": "Toon afgevinkt ({0})",
|
||||
"Who's home": "Wie is er thuis", "People (leave empty for everyone)": "Personen (leeg laten voor iedereen)", "grid": "Raster",
|
||||
"No people found. Add person entities in Home Assistant.": "Geen personen gevonden. Voeg personen toe in Home Assistant.",
|
||||
"Weather forecast": "Weersverwachting", "Show": "Toon", "both": "Beide", "hourly": "Per uur", "daily": "Per dag", "Hours ahead": "Aantal uren vooruit",
|
||||
"Show sunrise and sunset": "Toon zonsopkomst en -ondergang", "Loading the forecast…": "Verwachting laden…",
|
||||
"Timer": "Timer", "Quick starts in minutes": "Snelstart in minuten", "Beep when done": "Piep als hij klaar is", "a timer": "een timer", "Done!": "Klaar!", "OK": "OK", "min": "min",
|
||||
"Blinds": "Zonwering", "e.g. Living room": "bijv. Woonkamer", "Blinds, curtains or shutters": "Jaloezieën, gordijnen of rolluiken",
|
||||
"Add one or more blinds in the inspector.": "Voeg in de inspector een of meer jaloezieën toe.", "Open": "Open", "position": "positie", "tilt": "kanteling", "Tilt": "Kanteling",
|
||||
"Open all": "Alles open", "Close all": "Alles dicht",
|
||||
"Alarm": "Alarm", "Alarm panel": "Alarmsysteem", "Doors and windows to check (leave empty for all)": "Deuren en ramen om te controleren (leeg laten voor alle)",
|
||||
"an alarm panel": "een alarmsysteem", "On · home": "Aan · thuis", "On · away": "Aan · afwezig", "On · night": "Aan · nacht", "On · vacation": "Aan · vakantie",
|
||||
"Turning on…": "Inschakelen…", "Entry delay…": "Inloopvertraging…", "Alarm!": "Alarm!", "Away": "Afwezig", "Night": "Nacht", "Vacation": "Vakantie",
|
||||
"Enter the code": "Voer de code in", "Clear": "Wis", "Delete last digit": "Laatste cijfer wissen", "{0} still open: {1}": "{0} nog open: {1}",
|
||||
"Turn on the alarm anyway?": "Alarm toch inschakelen?",
|
||||
"Notifications": "Meldingen", "Show notifications": "Toon meldingen", "Show available updates": "Toon beschikbare updates", "{0} → {1}": "{0} → {1}",
|
||||
"Install": "Installeer", "Dismiss": "Wegklikken", "Dismiss all": "Alles wegklikken", "All caught up": "Alles bijgewerkt",
|
||||
"Install {0}?": "{0} installeren?", "Version {0} will be installed. Home Assistant may restart.": "Versie {0} wordt geïnstalleerd. Home Assistant kan opnieuw opstarten.",
|
||||
"Screen saver": "Schermbeveiliging",
|
||||
"clock": "Klok",
|
||||
"photos": "Foto's",
|
||||
"Photos from": "Foto's uit",
|
||||
"Doorbell": "Deurbel",
|
||||
"Camera to show": "Camera om te tonen",
|
||||
"Close after (seconds)": "Sluiten na (seconden)",
|
||||
"Play a sound": "Geluid afspelen",
|
||||
"When the doorbell rings in kiosk mode, the camera fills the screen.": "Als de deurbel gaat in kioskmodus, vult de camera het scherm.",
|
||||
"Someone’s at the door": "Er staat iemand voor de deur",
|
||||
"Night mode": "Nachtmodus",
|
||||
"After sunset": "Na zonsondergang",
|
||||
"Between times": "Tussen tijden",
|
||||
"From": "Van",
|
||||
"Until": "Tot",
|
||||
"Dim the screen (%)": "Scherm dimmen (%)",
|
||||
"Switch to the dark theme": "Overschakelen naar het donkere thema",
|
||||
"Night mode is saved on this device only, and is off while editing.": "Nachtmodus wordt alleen op dit apparaat bewaard en staat uit tijdens het bewerken.",
|
||||
"{0} widgets deleted. Press ⌘Z to undo.": "{0} widgets verwijderd. Druk op ⌘Z om ongedaan te maken.",
|
||||
"Copy {0} widgets to…": "{0} widgets kopiëren naar…",
|
||||
"Copy to…": "Kopiëren naar…",
|
||||
"Copy": "Kopiëren",
|
||||
"Copied to “{0}”.": "Gekopieerd naar “{0}”.",
|
||||
"Moved into “{0}”.": "Verplaatst naar “{0}”.",
|
||||
"{0} widgets selected": "{0} widgets geselecteerd",
|
||||
"Align": "Uitlijnen",
|
||||
"Left edges": "Linkerkanten",
|
||||
"Right edges": "Rechterkanten",
|
||||
"Top edges": "Bovenkanten",
|
||||
"Bottom edges": "Onderkanten",
|
||||
"Same width": "Zelfde breedte",
|
||||
"Same height": "Zelfde hoogte",
|
||||
"Same width and height follow the widget you clicked last.": "Zelfde breedte en hoogte volgen de widget waarop je het laatst klikte.",
|
||||
"Shift-click or ⌘-click widgets to add or remove them. Drag one to move them all; the arrow keys move them too.": "Shift-klik of ⌘-klik op widgets om ze toe te voegen of weg te halen. Sleep er één om ze allemaal te verplaatsen; de pijltjestoetsen werken ook.",
|
||||
"Take out of this card": "Uit deze kaart halen",
|
||||
"Drag a widget onto a scroll or swipe card to put it inside.": "Sleep een widget op een scroll- of veegkaart om hem erin te zetten.",
|
||||
"No matching entities": "Geen entiteiten gevonden",
|
||||
"Search widgets": "Widgets zoeken",
|
||||
"No widgets match “{0}”.": "Geen widgets gevonden voor “{0}”.",
|
||||
"Version history…": "Versiegeschiedenis…",
|
||||
"Version history": "Versiegeschiedenis",
|
||||
"Couldn’t load the version history. {0}": "De versiegeschiedenis kon niet worden geladen. {0}",
|
||||
"{0} widgets": "{0} widgets",
|
||||
"{0} tabs": "{0} tabbladen",
|
||||
"Current": "Huidige",
|
||||
"Restore": "Terugzetten",
|
||||
"Each save is kept here, up to 30 versions. Changes within five minutes count as one.": "Elke opslag wordt hier bewaard, tot 30 versies. Wijzigingen binnen vijf minuten tellen als één.",
|
||||
"No earlier versions yet. They appear after your next changes.": "Nog geen eerdere versies. Ze verschijnen na je volgende wijzigingen.",
|
||||
"Restored the version from {0}. Press ⌘Z to undo.": "De versie van {0} is teruggezet. Druk op ⌘Z om ongedaan te maken.",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
|
||||
@@ -86,11 +86,98 @@
|
||||
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",
|
||||
// rooms
|
||||
bed: "M3 18v-7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v7M3 15h18M7 9V7a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2M3 18v2M21 18v2",
|
||||
bath: "M4 12h16v3a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4zM6 12V6a2 2 0 0 1 3.5-1.3M7 19l-1 2M17 19l1 2",
|
||||
shower: "M4 21V8a4 4 0 0 1 8 0M9 8h6M11 12v.01M13 14v.01M15 12v.01M11 16v.01M15 16v.01M13 18v.01",
|
||||
kitchen: "M4 10h16v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4zM2 10h20M9 6c0-1 1-1.5 1-2.5M14 6c0-1 1-1.5 1-2.5",
|
||||
dining: "M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 3c-2 0-3 2-3 5s1 4 3 4v9",
|
||||
desk: "M3 10h18M5 10v9M19 10v9M14 14h5M8 4h8v6H8z",
|
||||
kids: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM9.5 13h.01M14.5 13h.01M10 16.5c1 .7 3 .7 4 0M7 8.5 5 6M17 8.5 19 6",
|
||||
laundry: "M4 9h16l-1.5 11a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM8 9l3-5M16 9l-3-5M9 13v4M15 13v4",
|
||||
stairs: "M3 20h5v-4h4v-4h4V8h5",
|
||||
tree: "M12 22v-6M12 16a5 5 0 0 0 5-5 5 5 0 0 0-2-4 4 4 0 0 0-6 0 5 5 0 0 0-2 4 5 5 0 0 0 5 5z",
|
||||
plant: "M12 21v-8M12 13c0-4 3-7 7-7 0 4-3 7-7 7zM12 15c0-3-2.5-5.5-6-5.5 0 3.5 2.5 5.5 6 5.5zM8 21h8",
|
||||
// tools
|
||||
wrench: "M14.7 6.3a4 4 0 0 0-5.4 5.2L3 17.8V21h3.2l6.3-6.3a4 4 0 0 0 5.2-5.4l-2.6 2.6-2.4-.6-.6-2.4z",
|
||||
hammer: "M14 6l4 4M11 3l6 6-2 2-6-6zM13 9l-9 9a1.5 1.5 0 0 0 2 2l9-9",
|
||||
toolbox: "M3 9h18v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zM8 9V6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3M3 14h18M10 14v2M14 14v2",
|
||||
sliders: "M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M14 4v4M8 10v4M16 16v4",
|
||||
key: "M8 15a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 11h9M18 11v3M15 11v2",
|
||||
// appliances
|
||||
fridge: "M6 2h12a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM5 10h14M9 5v2M9 13v3",
|
||||
oven: "M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 8h18M7 5.5h.01M11 5.5h.01M7 12h10v5H7z",
|
||||
microwave: "M3 5h18a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM5 8h11v8H5zM19 8v.01M19 11v.01M19 14v2",
|
||||
kettle: "M6 9h10l1 10a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1zM7 9l1-4h6l1 4M16 11h2a2 2 0 0 1 0 4h-1.5M9 3h4",
|
||||
coffee: "M5 9h11v6a5 5 0 0 1-5 5h-1a5 5 0 0 1-5-5zM16 11h1.5a2.5 2.5 0 0 1 0 5H16M8 3c0 1 1 1.5 1 2.5M12 3c0 1 1 1.5 1 2.5",
|
||||
tv: "M3 5h18a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM8 21h8M12 18v3",
|
||||
radiator: "M6 4v16M10 4v16M14 4v16M18 4v16M4 7h16M4 17h16",
|
||||
purifier: "M7 3h10a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM9 7h6M9 10h6M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z",
|
||||
aircon: "M3 5h18a1 1 0 0 1 1 1v6H2V6a1 1 0 0 1 1-1zM5 9h14M7 15c0 1.5-1 2-1 3M12 15c0 1.5-1 2-1 3M17 15c0 1.5-1 2-1 3",
|
||||
// devices
|
||||
phone: "M8 2h8a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM11 18h2",
|
||||
laptop: "M5 5h14a1 1 0 0 1 1 1v9H4V6a1 1 0 0 1 1-1zM2 18h20l-1.5 2h-17z",
|
||||
wifi: "M2 9a15 15 0 0 1 20 0M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5v.01",
|
||||
printer: "M7 8V3h10v5M17 16h4v-6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6h4M7 13h10v8H7z",
|
||||
server: "M4 3h16v7H4zM4 14h16v7H4zM8 6.5h.01M8 17.5h.01",
|
||||
gamepad: "M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6L14 15h-4l-1 .6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4zM7 10v4M5 12h4M15.5 11.5v.01M17.5 13.5v.01",
|
||||
headphones: "M4 15v-3a8 8 0 0 1 16 0v3M4 15a2 2 0 0 1 2-2h1v7H6a2 2 0 0 1-2-2zM20 15a2 2 0 0 0-2-2h-1v7h1a2 2 0 0 0 2-2z",
|
||||
bell: "M6 16v-5a6 6 0 0 1 12 0v5l2 2H4zM10 20a2 2 0 0 0 4 0",
|
||||
smoke: "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM4 12a8 8 0 0 0 16 0M7 4.5c1.5-1 3-1.5 5-1.5s3.5.5 5 1.5",
|
||||
leak: "M12 3s5 5.5 5 9a5 5 0 0 1-10 0c0-3.5 5-9 5-9zM3 21h18M6 18h.01M18 18h.01",
|
||||
solar: "M4 4h16l2 10H2zM2 14h20M8 4l-1 10M16 4l1 10M3 9h18M12 14v4M8 21h8",
|
||||
charger: "M6 3h8a1 1 0 0 1 1 1v17H5V4a1 1 0 0 1 1-1zM3 21h14M15 9h2a2 2 0 0 1 2 2v5a1.5 1.5 0 0 0 3 0V8l-3-3M10 7l-2 4h4l-2 4",
|
||||
lamp: "M9 3h6l3 7H6zM12 10v10M8 21h8",
|
||||
ceiling: "M12 2v5M7 12a5 5 0 0 1 10 0zM9 15l-1 2M12 15v3M15 15l1 2",
|
||||
ledstrip: "M3 8h18v8H3zM6 11v2M10 11v2M14 11v2M18 11v2",
|
||||
blinds: "M4 3h16M5 3v18M19 3v18M5 7h14M5 11h14M5 15h14M12 15v4",
|
||||
curtain: "M3 3h18M5 3c0 6 1 12 3 18M19 3c0 6-1 12-3 18M8 3c0 4 .5 9 2 13M16 3c0 4-.5 9-2 13",
|
||||
fence: "M3 21V7l3-3 3 3v14M15 21V7l3-3 3 3v14M3 11h18M3 17h18",
|
||||
mailbox: "M4 10a4 4 0 0 1 8 0v9H4zM12 10h6a2 2 0 0 1 2 2v7h-8M8 19v2M16 8V3h3v2h-3",
|
||||
paw: "M12 13c-3 0-5 3-5 5a2 2 0 0 0 2 2c1 0 2-.5 3-.5s2 .5 3 .5a2 2 0 0 0 2-2c0-2-2-5-5-5zM4.5 9.5a1.5 2 0 1 0 3 0 1.5 2 0 1 0-3 0M16.5 9.5a1.5 2 0 1 0 3 0 1.5 2 0 1 0-3 0M8 5.5a1.5 2 0 1 0 3 0 1.5 2 0 1 0-3 0M13 5.5a1.5 2 0 1 0 3 0 1.5 2 0 1 0-3 0",
|
||||
bike: "M2 17.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 1 0-7 0M15 17.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 1 0-7 0M5.5 17.5 9 10h6l3.5 7.5M9 10 8 7H6M15 10l-2.5 7.5",
|
||||
// for the new cards and the editor
|
||||
sunrise: "M12 3v5M8.5 6.5 12 3l3.5 3.5M6.3 14.3 5 13M17.7 14.3 19 13M8 18a4 4 0 0 1 8 0M2 21h20",
|
||||
sunset: "M12 8V3M8.5 4.5 12 8l3.5-3.5M6.3 14.3 5 13M17.7 14.3 19 13M8 18a4 4 0 0 1 8 0M2 21h20",
|
||||
umbrella: "M3 12a9 9 0 0 1 18 0zM12 12v7a2 2 0 0 0 4 0",
|
||||
timer: "M12 21a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM12 9v4l2.5 2M10 2h4M19 5l1.5-1.5",
|
||||
checkbox: "M5 4h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8 12l3 3 5-6",
|
||||
circle: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z",
|
||||
history: "M3 12a9 9 0 1 0 3-6.7M3 4v5h5M12 7v5l3 2",
|
||||
search: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-5-5",
|
||||
people: "M9 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM3 20v-1a6 6 0 0 1 12 0v1M16 5a3 3 0 0 1 0 6M21 20v-1a5 5 0 0 0-3-4.6",
|
||||
palette: "M12 21a9 9 0 1 1 9-9c0 2-1.5 3-3 3h-2a2 2 0 0 0-1.5 3.3A1.5 1.5 0 0 1 12 21zM7.5 11.5v.01M10 7.5v.01M14.5 7.5v.01M17 11.5v.01",
|
||||
keypad: "M6 4h.01M12 4h.01M18 4h.01M6 10h.01M12 10h.01M18 10h.01M6 16h.01M12 16h.01M18 16h.01M12 21h.01",
|
||||
"arrow-up": "M12 19V5M5 12l7-7 7 7",
|
||||
"arrow-down": "M12 5v14M19 12l-7 7-7-7",
|
||||
"copy-to": "M9 9h11v11H9zM5 15H4V4h11v1M13 14.5h4M15 12.5l2 2-2 2",
|
||||
};
|
||||
const FILLED = new Set(["play", "pause", "next", "prev"]);
|
||||
// Dutch (and some English) words for the icon search, so "keuken" or "slaapkamer" find something.
|
||||
const WORDS = {
|
||||
bed: "slaapkamer bedroom", bath: "badkamer bad bathroom", shower: "douche badkamer", kitchen: "keuken koken pan", dining: "eetkamer eten bestek",
|
||||
desk: "kantoor bureau werkkamer office", kids: "kinderkamer kind baby", laundry: "was wasmand washok", stairs: "trap overloop", tree: "tuin boom garden",
|
||||
plant: "plant bloem", sofa: "woonkamer bank living", garage: "garage", home: "huis thuis", wrench: "gereedschap moersleutel tool",
|
||||
hammer: "hamer gereedschap klus", toolbox: "gereedschapskist klus", sliders: "instellingen regelaar", key: "sleutel", fridge: "koelkast vriezer",
|
||||
oven: "oven fornuis", microwave: "magnetron", kettle: "waterkoker thee", coffee: "koffie", tv: "televisie scherm", radiator: "verwarming radiator heater",
|
||||
purifier: "luchtreiniger luchtbevochtiger", aircon: "airco koeling", phone: "telefoon mobiel", laptop: "laptop computer", wifi: "wifi router netwerk internet",
|
||||
printer: "printer", server: "server nas", gamepad: "spelcomputer games console", headphones: "koptelefoon", bell: "deurbel bel melding",
|
||||
smoke: "rookmelder brand", leak: "lekkage water", solar: "zonnepanelen zon", charger: "laadpaal auto laden", lamp: "lamp staande lamp",
|
||||
ceiling: "plafondlamp hanglamp", ledstrip: "ledstrip", blinds: "jaloezie lamellen rolluik", curtain: "gordijn", fence: "hek poort", mailbox: "brievenbus post",
|
||||
paw: "huisdier hond kat", bike: "fiets", bulb: "lamp licht", plug: "stopcontact stekker", fan: "ventilator", thermometer: "temperatuur thermostaat",
|
||||
droplet: "water vocht", washer: "wasmachine", dryer: "droger", dishwasher: "vaatwasser", vacuum: "stofzuiger robot", car: "auto", lock: "slot",
|
||||
camera: "camera", speaker: "speaker luidspreker", music: "muziek", door: "deur", window: "raam", timer: "timer kookwekker", people: "personen familie",
|
||||
};
|
||||
|
||||
/** Icon names matching a search, by name or by the words above. */
|
||||
function search(filter) {
|
||||
const f = String(filter || "").trim().toLowerCase();
|
||||
return Object.keys(P).filter((n) => !FILLED.has(n)).sort().filter((n) => !f || n.includes(f) || (WORDS[n] || "").includes(f));
|
||||
}
|
||||
|
||||
window.Icons = {
|
||||
names: Object.keys(P).filter((n) => !FILLED.has(n)),
|
||||
search,
|
||||
has: (n) => Object.prototype.hasOwnProperty.call(P, n),
|
||||
svg(name, cls = "") {
|
||||
if (!this.has(name)) return "";
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<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>
|
||||
<button role="menuitem" data-menu="assign" data-icon="person" data-i18n>Who sees which dashboard…</button>
|
||||
<button role="menuitem" data-menu="history" data-icon="history" data-i18n>Version history…</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>
|
||||
@@ -48,8 +49,9 @@
|
||||
<div id="workspace">
|
||||
<aside id="palette" class="edit-only" aria-label="Widgets">
|
||||
<div class="panel-title" data-i18n>Add widget</div>
|
||||
<input id="palette-search" class="input" type="search" aria-label="Search widgets" autocomplete="off" spellcheck="false">
|
||||
<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>
|
||||
<p class="hint" data-i18n>Click to add. Drag to move, drag the corner to resize. Shift-click to select several. Arrow keys move the selected widget, Shift+arrows resize, ⌘D duplicates, Delete removes.</p>
|
||||
</aside>
|
||||
|
||||
<main id="stage">
|
||||
@@ -61,7 +63,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="saver" hidden aria-hidden="true"><div class="time num"></div><div class="date"></div></div>
|
||||
<div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div>
|
||||
<div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div>
|
||||
<div id="night-dim" aria-hidden="true"></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>
|
||||
|
||||
+283
-47
@@ -190,12 +190,20 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
|
||||
.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; }
|
||||
.ip-search { margin: 12px 0 10px; }
|
||||
.ip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 6px; max-height: 50vh; overflow-y: auto; padding: 2px; }
|
||||
.ip-icon { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 10px 4px 8px; border: 0; border-radius: 10px; background: var(--fill); cursor: pointer; min-width: 0; }
|
||||
.ip-icon .i { font-size: 22px; }
|
||||
.ip-icon span { font-size: 11px; color: var(--text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ip-icon:hover { background: var(--fill-strong); }
|
||||
.ip-icon.on { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
.icon-prev { border: 0; cursor: pointer; color: inherit; }
|
||||
.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; }
|
||||
.insp-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
|
||||
.insp-actions .btn { flex: 1 1 auto; white-space: nowrap; }
|
||||
.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; }
|
||||
@@ -305,6 +313,22 @@ 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; }
|
||||
body.editing .w.drop-into { outline: 3px solid var(--accent); outline-offset: 4px; }
|
||||
body.editing .w.drop-into::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: color-mix(in srgb, var(--accent) 18%, transparent); pointer-events: none; }
|
||||
.align-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.align-grid .btn { justify-content: center; }
|
||||
#palette-search { width: 100%; margin-bottom: 10px; }
|
||||
.ent-pop { position: fixed; z-index: 120; overflow-y: auto; background: var(--surface, var(--bg)); border: 1px solid var(--separator); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,.18); padding: 4px; }
|
||||
.ent-pop[hidden] { display: none; }
|
||||
.ep-item { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 8px; cursor: pointer; min-height: 40px; }
|
||||
.ep-item:hover { background: var(--fill); }
|
||||
.ep-item.on { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||
.ep-item > svg { width: 20px; height: 20px; flex: none; color: var(--text-2, inherit); }
|
||||
.ep-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.ep-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ep-id { font-size: 11px; color: var(--text-2, #8e8e93); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ep-state { font-size: 11px; color: var(--text-2, #8e8e93); flex: none; max-width: 30%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ep-none { padding: 10px; font-size: 13px; color: var(--text-2, #8e8e93); }
|
||||
.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; }
|
||||
@@ -411,7 +435,7 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
|
||||
.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 { display: inline-block; vertical-align: middle; 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); }
|
||||
@@ -653,6 +677,45 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.pos9 button[aria-pressed="true"] { background: var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); }
|
||||
.pos9 button[aria-pressed="true"] i { width: 14px; height: 10px; border-radius: 3px; background: var(--accent); opacity: 1; }
|
||||
|
||||
/* light card */
|
||||
.light-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.light-badge { cursor: pointer; }
|
||||
.light-toggle { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.light-switch { flex: none; display: flex; }
|
||||
.lm-switch { display: flex; }
|
||||
.swatches { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; flex: none; }
|
||||
.swatch { width: 26px; height: 26px; border-radius: 50%; background: var(--sw); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12); flex: none; display: grid; place-items: center; font-size: 15px; }
|
||||
.swatches.compact .swatch { width: 22px; height: 22px; }
|
||||
.swatch.more { background: var(--d-fill); }
|
||||
.swatch:hover { transform: scale(1.1); }
|
||||
.light.is-group { justify-content: flex-start; gap: 10px; }
|
||||
.light-members2 { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; margin: 0 -4px; padding: 0 4px; }
|
||||
.lm-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 12px; background: var(--d-fill); }
|
||||
.lm-dot { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; background: color-mix(in srgb, var(--d-text) 8%, transparent); }
|
||||
.lm-row.on .lm-dot { background: var(--tint, var(--d-lit)); color: #2a1d05; }
|
||||
.lm-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.lm-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lm-state { font-size: 12px; color: var(--d-text-2); }
|
||||
.lm-slider { height: 8px; border-radius: 4px; }
|
||||
.lm-slider::-webkit-slider-thumb { height: 16px; width: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.35); }
|
||||
.lm-slider::-moz-range-thumb { height: 16px; width: 16px; border-radius: 50%; background: #fff; }
|
||||
.lm-pct { font-size: 12px; color: var(--d-text-2); min-width: 32px; text-align: right; }
|
||||
.w.lit-lamp .lm-row { background: rgba(255, 255, 255, 0.45); }
|
||||
.w.lit-lamp .swatch.more { background: rgba(255, 255, 255, 0.55); }
|
||||
/* light popup */
|
||||
.lp-head { display: flex; align-items: center; gap: 12px; }
|
||||
.lp-head p { margin: 2px 0 0; }
|
||||
.lp-dot { width: 48px; height: 48px; border-radius: 50%; display: grid; place-items: center; font-size: 24px; background: var(--tint); color: #2a1d05; flex: none; }
|
||||
.lp-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 48px; border-radius: 14px; margin: 0; cursor: pointer;
|
||||
background: linear-gradient(to right, var(--fill-c, #ffd58a) var(--pct, 0%), var(--fill) var(--pct, 0%)); }
|
||||
.lp-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 6px; height: 30px; border-radius: 3px; background: rgba(255, 255, 255, .9); box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||
.lp-slider::-moz-range-thumb { width: 6px; height: 30px; border: 0; border-radius: 3px; background: #fff; }
|
||||
.lp-temp { height: 36px; background: linear-gradient(to right, #ffb35c, #fff4e5 55%, #cfe2ff); }
|
||||
.lp-swatches { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
||||
.swatch.big { width: 38px; height: 38px; border: 0; cursor: pointer; }
|
||||
.swatch.custom { background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red); color: #fff; position: relative; overflow: hidden; }
|
||||
.swatch.custom input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
|
||||
/* light groups */
|
||||
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
|
||||
.light:has(.light-members) { justify-content: flex-start; }
|
||||
@@ -677,6 +740,145 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.ef-totals { flex: none; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--d-text-2); justify-content: center; }
|
||||
.ef-total b { color: var(--d-text); font-size: 13px; }
|
||||
|
||||
/* to-do */
|
||||
.todo { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||
.todo-head { display: flex; align-items: center; gap: 8px; }
|
||||
.todo-head .name { flex: 1; min-width: 0; font-size: 17px; }
|
||||
.todo-count { font-size: 13px; font-weight: 700; min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px; display: grid; place-items: center; background: var(--d-fill); }
|
||||
.todo-add { display: flex; gap: 6px; flex: none; margin: 0; }
|
||||
.todo-input { flex: 1; min-width: 0; min-height: 40px; padding: 0 12px; border: 0; border-radius: 12px; background: var(--d-fill); color: inherit; font: inherit; outline: none; }
|
||||
.todo-input:focus { box-shadow: var(--focus); }
|
||||
.todo-plus { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: var(--d-text); color: var(--d-tile); font-size: 20px; flex: none; }
|
||||
.todo-list { flex: 1; min-height: 0; overflow-y: auto; margin: 0 -4px; padding: 0 4px; }
|
||||
.todo-row { display: flex; align-items: center; gap: 10px; min-height: 44px; border-bottom: 1px solid var(--d-separator); transition: transform .15s, opacity .15s; touch-action: pan-y; }
|
||||
.todo-check { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--d-text-2); display: grid; place-items: center; font-size: 14px; color: transparent; flex: none; }
|
||||
.todo-row.done .todo-check { background: var(--ok); border-color: var(--ok); color: #fff; }
|
||||
.todo-text { flex: 1; min-width: 0; font-size: 15px; display: flex; flex-direction: column; }
|
||||
.todo-text small { font-size: 12px; color: var(--d-text-2); }
|
||||
.todo-row.done .todo-text { color: var(--d-text-2); text-decoration: line-through; }
|
||||
.todo-del { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; color: var(--d-text-2); opacity: 0; font-size: 15px; }
|
||||
.todo-row:hover .todo-del, .todo-del:focus-visible { opacity: 1; }
|
||||
@media (hover: none) { .todo-del { opacity: .5; } }
|
||||
.todo-more { display: block; margin: 8px 0; font-size: 13px; font-weight: 600; color: var(--d-text-2); }
|
||||
.todo-empty { color: var(--d-text-2); font-size: 14px; padding: 12px 0; }
|
||||
|
||||
/* who's home */
|
||||
.people { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 10px; align-content: start; height: 100%; overflow-y: auto; }
|
||||
.pp { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; min-width: 0; padding: 4px 0; }
|
||||
.pp-av { position: relative; width: clamp(48px, 30cqh, 72px); aspect-ratio: 1; border-radius: 50%; background: var(--d-fill); display: grid; place-items: center; box-shadow: 0 0 0 3px var(--d-fill); }
|
||||
.pp.home .pp-av { box-shadow: 0 0 0 3px var(--ok); }
|
||||
.pp-av img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
|
||||
.pp:not(.home) .pp-av img { filter: grayscale(.7); opacity: .8; }
|
||||
.pp-init { font-size: clamp(16px, 9cqh, 24px); font-weight: 700; }
|
||||
.pp-dot { position: absolute; right: 2%; bottom: 2%; width: 26%; aspect-ratio: 1; border-radius: 50%; background: var(--d-text-2); box-shadow: 0 0 0 2px var(--w-bg, var(--d-tile)); }
|
||||
.pp.home .pp-dot { background: var(--ok); }
|
||||
.pp-text { display: flex; flex-direction: column; min-width: 0; max-width: 100%; }
|
||||
.pp-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pp-where { font-size: 12px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.people.as-list { grid-template-columns: 1fr; gap: 4px; }
|
||||
.people.as-list .pp { flex-direction: row; text-align: left; gap: 12px; }
|
||||
.people.as-list .pp-av { width: 40px; }
|
||||
|
||||
/* forecast */
|
||||
.fc { display: flex; flex-direction: column; height: 100%; gap: 12px; min-height: 0; }
|
||||
.fc-now { display: flex; align-items: center; gap: 12px; flex: none; }
|
||||
.fc-ico { font-size: 40px; display: flex; color: var(--d-text-2); }
|
||||
.fc-temp { font-size: 40px; font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
|
||||
.fc-cond { display: flex; flex-direction: column; min-width: 0; font-size: 13px; color: var(--d-text-2); }
|
||||
.fc-cond b { font-size: 15px; color: var(--d-text); }
|
||||
.fc-sun { margin-left: auto; display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: var(--d-text-2); }
|
||||
.fc-sun span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.fc-hours { display: flex; gap: 6px; overflow-x: auto; flex: none; scrollbar-width: none; padding-bottom: 2px; }
|
||||
.fc-hours::-webkit-scrollbar { display: none; }
|
||||
.fc-hour { flex: 0 0 52px; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 0; border-radius: 12px; background: var(--d-fill); font-size: 18px; }
|
||||
.fc-hour b { font-size: 14px; }
|
||||
.fc-t { font-size: 12px; color: var(--d-text-2); }
|
||||
.fc-rain { font-size: 11px; color: var(--d-accent); font-weight: 600; min-height: 13px; }
|
||||
.fc-days { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.fc-day { display: grid; grid-template-columns: 52px 22px 36px 34px 1fr 34px; align-items: center; gap: 8px; min-height: 36px; font-size: 14px; border-top: 1px solid var(--d-separator); }
|
||||
.fc-day .i { font-size: 18px; }
|
||||
.fc-dn { font-weight: 600; text-transform: capitalize; }
|
||||
.fc-lo { color: var(--d-text-2); text-align: right; }
|
||||
.fc-bar { position: relative; height: 6px; border-radius: 3px; background: var(--d-fill); }
|
||||
.fc-bar i { position: absolute; top: 0; bottom: 0; border-radius: 3px; background: linear-gradient(to right, #5ac8fa, #ffcc00, #ff9500); }
|
||||
@container (max-height: 300px) { .fc-days { display: none; } }
|
||||
@container (max-width: 360px) { .fc-sun { display: none; } }
|
||||
|
||||
/* timer */
|
||||
.tm { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 8px; }
|
||||
.tm-name { align-self: flex-start; font-size: 15px; font-weight: 600; }
|
||||
.tm-ring { position: relative; flex: 1; min-height: 0; aspect-ratio: 1; display: grid; place-items: center; }
|
||||
.tm-ring > svg { position: absolute; inset: 0; transform: rotate(-90deg); }
|
||||
.tm-ring circle { fill: none; stroke-width: 7; }
|
||||
.tm-ring .track { stroke: var(--d-fill); }
|
||||
.tm-ring .fill { stroke: var(--d-accent); stroke-linecap: round; transition: stroke-dasharray 1s linear; }
|
||||
.tm.idle .tm-ring .fill { stroke: var(--d-text-2); opacity: .4; }
|
||||
.tm-time { font-size: clamp(22px, 14cqh, 40px); font-weight: 700; }
|
||||
.tm.done .tm-time { color: var(--ok); animation: tm-blink 1s ease-in-out infinite; }
|
||||
.tm.done .tm-ring .fill { stroke: var(--ok); }
|
||||
@keyframes tm-blink { 50% { opacity: .35; } }
|
||||
.tm-ctrls { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; flex: none; }
|
||||
|
||||
/* blinds */
|
||||
.cv { display: flex; flex-direction: column; height: 100%; gap: 10px; min-height: 0; }
|
||||
.cv-head { display: flex; align-items: center; gap: 6px; }
|
||||
.cv-head .name { flex: 1; min-width: 0; }
|
||||
.cv-win { position: relative; width: 44px; aspect-ratio: 3 / 4; border-radius: 6px; border: 2px solid var(--d-text-2); background: linear-gradient(#bfe0ff, #e8f4ff); overflow: hidden; flex: none; }
|
||||
.cv-blind { position: absolute; left: 0; right: 0; top: 0; background: repeating-linear-gradient(to bottom, var(--d-text-2) 0 3px, color-mix(in srgb, var(--d-text-2) 60%, transparent) 3px 5px); transition: height .4s; }
|
||||
.cv.one .cv-main { flex: 1; min-height: 0; display: flex; align-items: center; gap: 16px; }
|
||||
.cv.one .cv-win { width: auto; height: min(100%, 150px); }
|
||||
.cv-side { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.cv-pct { font-size: 28px; font-weight: 700; }
|
||||
.cv-btns { display: flex; gap: 6px; flex: none; }
|
||||
.cv-btns .round { width: 40px; height: 40px; }
|
||||
.cv-btns .round:disabled { opacity: .35; cursor: default; }
|
||||
.cv-tilt { display: flex; align-items: center; gap: 10px; }
|
||||
.cv-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.cv-row { display: flex; align-items: center; gap: 12px; }
|
||||
.cv-row .cv-win { width: 30px; }
|
||||
.cv-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.cv-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cv-name b { color: var(--d-text-2); font-weight: 600; }
|
||||
.cv-slider { height: 10px; }
|
||||
|
||||
/* alarm */
|
||||
.al { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||
.al-head { display: flex; align-items: center; gap: 12px; }
|
||||
.al-shield { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-size: 22px; background: var(--d-fill); flex: none; }
|
||||
.al[class*="state-armed"] .al-shield { background: var(--d-accent); color: var(--d-on-accent, #fff); }
|
||||
.al.state-triggered .al-shield { background: var(--crit); color: #fff; animation: tm-blink 1s infinite; }
|
||||
.al.state-disarmed .al-shield { color: var(--d-text-2); }
|
||||
.al-state { font-size: 14px; color: var(--d-text-2); font-weight: 600; }
|
||||
.al.state-triggered .al-state { color: var(--crit); }
|
||||
.al-open { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 12px; color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); font-size: 13px; font-weight: 600; }
|
||||
.al-open span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.al-code { text-align: center; font-size: 26px; letter-spacing: .3em; min-height: 32px; }
|
||||
.al-code .wt { letter-spacing: 0; font-size: 14px; }
|
||||
.al-keys { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; flex: 1; min-height: 0; }
|
||||
.al-key { display: grid; place-items: center; min-height: 40px; border-radius: 12px; background: var(--d-fill); font-size: 20px; font-weight: 600; }
|
||||
.al-key:active { background: color-mix(in srgb, var(--d-text) 20%, transparent); }
|
||||
.al-modes { display: flex; gap: 6px; flex: none; }
|
||||
.al-mode { flex: 1; min-height: 44px; border-radius: 12px; display: grid; place-items: center; background: var(--d-fill); font-weight: 600; font-size: 14px; }
|
||||
.al-mode.on { background: var(--d-accent); color: var(--d-on-accent, #fff); }
|
||||
.al-mode.off { background: var(--d-text); color: var(--d-tile); }
|
||||
|
||||
/* notifications */
|
||||
.nt { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||
.nt-head { display: flex; align-items: center; gap: 8px; }
|
||||
.nt-head .name { flex: 1; font-size: 17px; }
|
||||
.nt-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.nt-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px; border-radius: 12px; background: var(--d-fill); }
|
||||
.nt-item.update .nt-ico { color: var(--d-accent); }
|
||||
.nt-ico { font-size: 18px; display: flex; padding-top: 1px; color: var(--d-text-2); }
|
||||
.nt-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; font-size: 13px; }
|
||||
.nt-body b { font-size: 14px; }
|
||||
.nt-msg { color: var(--d-text-2); overflow-wrap: anywhere; }
|
||||
.nt-msg a, .nt-link { color: var(--d-accent); }
|
||||
.nt-time { font-size: 11px; color: var(--d-text-2); }
|
||||
.nt-x { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; color: var(--d-text-2); }
|
||||
.nt-empty { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--d-text-2); font-size: 14px; }
|
||||
.nt-empty .i { font-size: 28px; color: var(--ok); }
|
||||
|
||||
/* room */
|
||||
.room { position: relative; display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; }
|
||||
.room.on-image { margin: -16px; padding: 16px; border-radius: inherit; color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); --d-fill: rgba(255, 255, 255, 0.18);
|
||||
@@ -772,51 +974,63 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
/* pill button with icon and label (media "Turn on") */
|
||||
.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: built around 3×4; compact when short, side by side when wide */
|
||||
.car2 { display: grid; grid-template-areas: "top" "visual" "info"; grid-template-rows: auto 1fr auto; gap: 10px; height: 100%; min-height: 0; }
|
||||
.car-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.car-top .name { font-size: 17px; flex: 1; min-width: 0; }
|
||||
.car-status { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
|
||||
.car-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
|
||||
.car-visual { grid-area: visual; position: relative; min-height: 0; display: grid; place-items: center; }
|
||||
.car-photo { width: 100%; height: 100%; object-fit: contain; }
|
||||
.car-art { width: 100%; max-height: 100%; }
|
||||
.car-body { fill: var(--d-fill); stroke: var(--d-text-2); stroke-width: 1.5; }
|
||||
.car-glass { fill: color-mix(in srgb, var(--d-text) 12%, transparent); }
|
||||
.car-wheel { fill: var(--d-text); opacity: .85; }
|
||||
.car-hub { fill: var(--d-tile); }
|
||||
.car-visual.is-charging::before { content: ""; position: absolute; inset: 15% 10%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 35%, transparent), transparent); animation: glow 2.4s ease-in-out infinite; }
|
||||
@keyframes glow { 50% { opacity: .45; } }
|
||||
.car-bolt { position: absolute; right: 8%; top: 4%; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: 17px; }
|
||||
.car-info { grid-area: info; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.car-nums { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.car-pct { font-size: clamp(32px, 14cqh, 52px); font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
|
||||
.car-range { font-size: 17px; font-weight: 600; color: var(--d-text-2); }
|
||||
.car2 .level { color: var(--crit); align-self: center; }
|
||||
.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-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; }
|
||||
.car-stat { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); min-width: 0; }
|
||||
.car-stat-v { font-size: 15px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.car-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.car-actions2 { display: flex; gap: 6px; }
|
||||
.car-btn { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); }
|
||||
.car-btn-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; }
|
||||
.car-btn[aria-pressed="true"] .car-btn-ico { background: var(--d-text); color: var(--d-tile); }
|
||||
.car-btn-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* sizes */
|
||||
@container (max-height: 380px) { .car-stats { display: none; } }
|
||||
@container (max-height: 250px) and (max-width: 479px) { .car2 { grid-template-areas: "top" "info"; grid-template-rows: auto 1fr; } .car-visual { display: none; } .car-info { justify-content: flex-end; } }
|
||||
@container (max-height: 160px) { .car-actions2 { display: none; } }
|
||||
@container (min-width: 480px) and (min-height: 150px) {
|
||||
.car2 { grid-template-areas: "top top" "visual info"; grid-template-columns: 1.1fr 1fr; grid-template-rows: auto 1fr; column-gap: 18px; }
|
||||
.car-info { justify-content: center; }
|
||||
/* car: a round gauge with the range in the middle and the car in front */
|
||||
.car3 { --car-top: color-mix(in srgb, var(--d-accent) 55%, var(--d-text)); --car-bottom: color-mix(in srgb, var(--d-accent) 70%, #000);
|
||||
display: grid; grid-template-areas: "top" "gauge" "info"; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; height: 100%; min-height: 0; }
|
||||
.car3-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.car3-top .name { font-size: 17px; flex: 1; min-width: 0; }
|
||||
.car3-status { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 62%; }
|
||||
.car3-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
|
||||
.cg { grid-area: gauge; position: relative; min-height: 0; height: 100%; max-height: 260px; max-width: 100%; justify-self: center; align-self: center; aspect-ratio: 1 / .92; }
|
||||
.cg > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.cg > svg path { fill: none; stroke-width: 12; stroke-linecap: round; }
|
||||
.cg-track { stroke: var(--d-fill); }
|
||||
.cg-fill { stroke: var(--d-text); }
|
||||
.cg.charging .cg-fill { stroke: var(--ok); animation: cg-pulse 2.4s ease-in-out infinite; }
|
||||
.cg.low .cg-fill { stroke: var(--crit); }
|
||||
@keyframes cg-pulse { 50% { opacity: .65; } }
|
||||
.cg-limit { stroke: var(--d-text-2); stroke-width: 3; stroke-linecap: round; }
|
||||
.cg-center { position: absolute; left: 0; right: 0; top: 22%; display: flex; flex-direction: column; align-items: center; }
|
||||
.cg-range { font-size: clamp(26px, 11cqh, 44px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
|
||||
.cg-range small { font-size: .42em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; }
|
||||
.cg-pct { margin-top: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); }
|
||||
.cg.charging .cg-pct { color: var(--ok); }
|
||||
.cg-car { position: absolute; left: 6%; right: 6%; bottom: 2%; display: flex; justify-content: center; }
|
||||
.cg-car img { max-width: 100%; max-height: 72px; object-fit: contain; filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .3)); }
|
||||
.car-art2 { width: 100%; max-height: 74px; }
|
||||
.car-shadow { fill: rgba(0, 0, 0, .28); }
|
||||
.car-body2 { fill: url(#carBody); }
|
||||
.car-glass2 { fill: url(#carGlass); }
|
||||
.car-line { stroke: rgba(255, 255, 255, .25); stroke-width: 1; }
|
||||
.car-light { fill: #fff6d6; }
|
||||
.car-tire { fill: #17181b; } .car-rim { fill: #8e9097; }
|
||||
.car3.is-charging .cg-car::after { content: ""; position: absolute; left: 20%; right: 20%; bottom: 0; height: 40%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 45%, transparent), transparent); z-index: -1; }
|
||||
.car3-info { grid-area: info; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.cchips { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
|
||||
.cchip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
|
||||
.cchip .i { font-size: 14px; color: var(--d-text-2); }
|
||||
.cbs { display: flex; gap: 6px; }
|
||||
.cb { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); }
|
||||
.cb-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; transition: background-color .2s; }
|
||||
.cb[aria-pressed="true"] .cb-ico { background: var(--d-text); color: var(--d-tile); }
|
||||
.cb-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cb-sub { font-size: 11px; color: var(--d-text-2); white-space: nowrap; }
|
||||
/* short: gauge small on the left, numbers and buttons beside it */
|
||||
@container (max-height: 250px) {
|
||||
.car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; column-gap: 14px; }
|
||||
.cg { width: auto; height: 100%; max-width: none; aspect-ratio: 1; }
|
||||
.cg-car { display: none; }
|
||||
.cg-center { top: 30%; }
|
||||
.car3-info { justify-content: center; }
|
||||
.cchips { justify-content: flex-start; }
|
||||
}
|
||||
@container (max-width: 230px) { .car-btn-label { display: none; } .car-range { font-size: 15px; } }
|
||||
@container (max-height: 160px) { .cbs { display: none; } }
|
||||
/* wide: gauge on the left, the rest on the right */
|
||||
@container (min-width: 480px) and (min-height: 251px) {
|
||||
.car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto 1fr; column-gap: 20px; }
|
||||
.car3-info { justify-content: center; }
|
||||
}
|
||||
@container (max-width: 300px) { .cb-label, .cb-sub { display: none; } .cb { padding: 6px 2px; } }
|
||||
|
||||
/* ============================================================ phone dashboards */
|
||||
body.phone-ui:not(.peek) #topbar { display: none; }
|
||||
@@ -833,6 +1047,23 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
|
||||
#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; }
|
||||
#saver .photos { display: none; }
|
||||
#saver.photo .photos { display: block; }
|
||||
#saver.photo .saver-clock { position: absolute; left: 32px; bottom: 28px; text-align: left; color: #fff; text-shadow: 0 1px 12px rgba(0,0,0,.55); }
|
||||
#saver.photo .time { font-size: clamp(40px, 9vw, 96px); }
|
||||
#saver.photo .date { color: rgba(255,255,255,.85); font-size: 18px; margin-top: 4px; }
|
||||
#saver .ph-msg { position: absolute; inset: 0; display: grid; place-items: center; }
|
||||
#doorbell { position: fixed; inset: 0; z-index: 90; background: #000; display: flex; flex-direction: column; }
|
||||
#doorbell[hidden] { display: none; }
|
||||
#doorbell .db-cam { flex: 1; min-height: 0; display: grid; place-items: center; }
|
||||
#doorbell .db-cam > img { width: 100%; height: 100%; object-fit: contain; }
|
||||
#doorbell .db-icon > svg { width: 120px; height: 120px; color: #fff; animation: db-ring 1s ease-in-out infinite; }
|
||||
@keyframes db-ring { 0%, 100% { transform: rotate(0); } 25% { transform: rotate(12deg); } 75% { transform: rotate(-12deg); } }
|
||||
#doorbell .db-bar { display: flex; align-items: center; gap: 16px; padding: 18px 24px calc(18px + env(safe-area-inset-bottom)); background: rgba(28,28,30,.9); color: #fff; }
|
||||
#doorbell .db-title { flex: 1; font-size: 22px; font-weight: 600; }
|
||||
#doorbell .db-close { min-height: 52px; padding: 0 28px; font-size: 18px; }
|
||||
#night-dim { position: fixed; inset: 0; z-index: 70; pointer-events: none; background: #000; opacity: var(--night-dim, 0); transition: opacity 2s ease; }
|
||||
@media (prefers-reduced-motion: reduce) { #doorbell .db-icon > svg { animation: none; } #night-dim { transition: none; } }
|
||||
|
||||
/* ============================================================ motion + sizes */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -874,3 +1105,8 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
|
||||
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; }
|
||||
}
|
||||
|
||||
.hist-list { display: flex; flex-direction: column; gap: 2px; max-height: 50vh; overflow-y: auto; margin-top: 12px; }
|
||||
.hist-row { display: flex; align-items: center; gap: 12px; padding: 8px 4px; border-bottom: 1px solid var(--separator); }
|
||||
.hist-row:last-child { border-bottom: 0; }
|
||||
.hist-when { font-weight: 500; }
|
||||
|
||||
+376
-63
@@ -202,11 +202,36 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- light (and light groups)
|
||||
const COLOR_MODES = ["hs", "xy", "rgb", "rgbw", "rgbww"];
|
||||
const canColor = (s) => (s?.attributes.supported_color_modes || []).some((m) => COLOR_MODES.includes(m));
|
||||
const canTemp = (s) => (s?.attributes.supported_color_modes || []).includes("color_temp");
|
||||
// Swatches: three whites by color temperature, then colors. Values are what light.turn_on takes.
|
||||
const WHITES = [["k:2700", "#ffc46b", "Warm white"], ["k:4000", "#ffe9cf", "Neutral white"], ["k:6500", "#dbe9ff", "Cool white"]];
|
||||
const COLORS = [["rgb:255,59,48", "#ff3b30", "Red"], ["rgb:255,149,0", "#ff9500", "Orange"], ["rgb:255,214,10", "#ffd60a", "Yellow"], ["rgb:52,199,89", "#34c759", "Green"],
|
||||
["rgb:90,200,250", "#5ac8fa", "Light blue"], ["rgb:0,122,255", "#007aff", "Blue"], ["rgb:175,82,222", "#af52de", "Purple"], ["rgb:255,45,85", "#ff2d55", "Pink"]];
|
||||
/** The color a light shows as: its RGB color, or a white from its color temperature. */
|
||||
function lightTint(s) {
|
||||
const a = s?.attributes || {};
|
||||
if (s?.state !== "on") return "";
|
||||
if (Array.isArray(a.rgb_color) && a.color_mode !== "color_temp") return `rgb(${a.rgb_color.join(",")})`;
|
||||
const k = Number(a.color_temp_kelvin);
|
||||
return isFinite(k) && k > 0 ? (k < 3300 ? "#ffc46b" : k < 5000 ? "#ffe9cf" : "#dbe9ff") : "#ffd58a";
|
||||
}
|
||||
function swatches(eid, s, { compact = false } = {}) {
|
||||
const list = [...(canTemp(s) ? WHITES : []), ...(canColor(s) ? COLORS : [])];
|
||||
if (!list.length) return "";
|
||||
return `<div class="swatches ${compact ? "compact" : ""}" role="group" aria-label="${t("Colors")}">${(compact ? list.slice(0, 7) : list).map(([v, col, lbl]) =>
|
||||
`<button class="swatch wbtn" style="--sw:${col}" data-act="light-color" data-e="${esc(eid)}" data-v="${v}" aria-label="${esc(t(lbl))}" title="${esc(t(lbl))}"></button>`).join("")}
|
||||
<button class="swatch more wbtn" data-act="light-popup" data-e="${esc(eid)}" aria-label="${t("More colors and settings")}" title="${t("More colors and settings")}">${Icons.svg("palette")}</button></div>`;
|
||||
}
|
||||
|
||||
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" },
|
||||
{ k: "colors", t: "check", l: "Show colors", d: true },
|
||||
{ k: "members", t: "check", l: "For a light group, show each light", d: true },
|
||||
],
|
||||
// A light group lists its lights in the entity_id attribute; follow them too.
|
||||
@@ -214,20 +239,33 @@
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a light");
|
||||
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id : [];
|
||||
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id.filter((e) => ha.states[e]) : [];
|
||||
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">` : ""}
|
||||
${members.length ? `<div class="list light-members" role="group" aria-label="${t("Lights in {0}", esc(name))}">${members.map((eid) => {
|
||||
const m = ha.states[eid];
|
||||
if (!m) return "";
|
||||
return `<button class="list-row wbtn" role="switch" aria-checked="${isOn(m)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(m)}</span><span class="grow">${esc(friendly(m))}</span><span class="st">${esc(fmtState(m))}</span><span class="switch"></span></button>`;
|
||||
}).join("")}</div>` : ""}</div>`;
|
||||
const onCount = members.filter((e) => ha.states[e].state === "on").length;
|
||||
const state = members.length ? (onCount ? t("{0} of {1} on", onCount, members.length) : t("Off")) : fmtState(s);
|
||||
const head = `<div class="light-head">
|
||||
<button class="badge light-badge wbtn" data-act="light-popup" data-e="${esc(c.entity)}" aria-label="${esc(t("Settings for {0}", name))}">${icon(s)}</button>
|
||||
<button class="tile-text light-toggle wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}"><span class="name">${esc(name)}</span><span class="state">${esc(state)}${pct && members.length ? ` · ${pct}%` : ""}</span></button>
|
||||
<button class="light-switch wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(c.entity)}" aria-label="${esc(name)}"><span class="switch"></span></button></div>`;
|
||||
const slider = 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(t("{0} brightness", name))}">` : "";
|
||||
const colors = c.colors !== false ? swatches(c.entity, s, { compact: !!members.length }) : "";
|
||||
|
||||
// In a group, every light gets a row: its color (tap for its own settings), name, brightness and switch.
|
||||
const rows = members.map((eid) => {
|
||||
const m = ha.states[eid], on = m.state === "on", mp = on ? Math.round((m.attributes.brightness || 255) / 2.55) : 0;
|
||||
const tint = lightTint(m), mdim = (m.attributes.supported_color_modes || []).some((x) => x !== "onoff");
|
||||
const short = friendly(m).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "") || friendly(m);
|
||||
return `<div class="lm-row ${on ? "on" : ""}">
|
||||
<button class="lm-dot wbtn" style="${tint ? `--tint:${tint}` : ""}" data-act="light-popup" data-e="${esc(eid)}" aria-label="${esc(t("Settings for {0}", friendly(m)))}">${icon(m)}</button>
|
||||
<div class="lm-main"><span class="lm-name">${esc(short)}</span>
|
||||
${mdim ? `<input class="slider lm-slider" type="range" min="1" max="100" value="${mp || 1}" style="--pct:${mp}%" data-act="brightness" data-e="${esc(eid)}" aria-label="${esc(t("{0} brightness", friendly(m)))}">` : `<span class="lm-state">${esc(fmtState(m))}</span>`}</div>
|
||||
<span class="lm-pct num">${on && mdim ? `${mp}%` : ""}</span>
|
||||
<button class="lm-switch wbtn" role="switch" aria-checked="${on}" data-act="toggle" data-e="${esc(eid)}" aria-label="${esc(friendly(m))}"><span class="switch"></span></button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
return `<div class="light ${members.length ? "is-group" : ""}">${head}${slider}${colors}${rows ? `<div class="light-members2" role="group" aria-label="${t("Lights in {0}", esc(name))}">${rows}</div>` : ""}</div>`;
|
||||
},
|
||||
lit: (c, { ha }) => litKind(ha.states[c.entity]),
|
||||
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
|
||||
@@ -477,6 +515,254 @@
|
||||
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- to-do / shopping list
|
||||
// Items come from todo/item/list; adding, ticking off and deleting use the todo actions (handled in app.js).
|
||||
W.todo = {
|
||||
label: "To-do list", icon: "checkbox", size: [4, 4],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "List", domains: ["todo"] },
|
||||
{ k: "title", t: "text", l: "Title", ph: "List name" },
|
||||
{ k: "showDone", t: "check", l: "Show completed items", d: false },
|
||||
],
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a to-do list");
|
||||
const items = cache.items || [];
|
||||
const open = items.filter((i) => i.status !== "completed"), done = items.filter((i) => i.status === "completed");
|
||||
const row = (i) => `<div class="todo-row ${i.status === "completed" ? "done" : ""}" data-uid="${esc(i.uid)}">
|
||||
<button class="todo-check wbtn" role="checkbox" aria-checked="${i.status === "completed"}" data-act="todo-toggle" data-e="${esc(c.entity)}" data-v="${esc(i.uid)}" aria-label="${esc(i.summary)}">${Icons.svg("check")}</button>
|
||||
<span class="todo-text">${esc(i.summary)}${i.due ? `<small>${esc(new Date(i.due).toLocaleDateString(LOCALE, { day: "numeric", month: "short" }))}</small>` : ""}</span>
|
||||
<button class="todo-del wbtn" data-act="todo-del" data-e="${esc(c.entity)}" data-v="${esc(i.uid)}" aria-label="${esc(t("Delete {0}", i.summary))}">${Icons.svg("close")}</button></div>`;
|
||||
const showDone = cache.showDone ?? c.showDone;
|
||||
return `<div class="todo">
|
||||
<div class="todo-head"><span class="name">${esc(c.title || friendly(s))}</span><span class="todo-count num">${open.length}</span></div>
|
||||
<form class="todo-add" data-e="${esc(c.entity)}"><input class="todo-input" type="text" placeholder="${t("Add an item…")}" aria-label="${t("Add an item")}" enterkeyhint="done">
|
||||
<button type="button" class="todo-plus wbtn" data-act="todo-add" data-e="${esc(c.entity)}" aria-label="${t("Add")}">${Icons.svg("plus")}</button></form>
|
||||
<div class="todo-list">${cache.items ? open.map(row).join("") || `<div class="todo-empty">${t("Nothing on the list.")}</div>` : `<div class="todo-empty">${t("Loading…")}</div>`}
|
||||
${done.length ? `<button class="todo-more wbtn" data-act="todo-showdone">${showDone ? t("Hide completed ({0})", done.length) : t("Show completed ({0})", done.length)}</button>${showDone ? done.map(row).join("") : ""}` : ""}</div></div>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
const s = ctx.ha.states[c.entity];
|
||||
if (!s) return;
|
||||
// The list's state is the number of open items; refetch when it (or the card) changes.
|
||||
const key = `${c.entity}|${s.last_updated}|${ctx.cache.bump || 0}`;
|
||||
if (ctx.cache.key !== key) {
|
||||
ctx.cache.key = key;
|
||||
try { ctx.cache.items = (await ctx.ha.send({ type: "todo/item/list", entity_id: c.entity }))?.items || []; } catch { ctx.cache.items = ctx.cache.items || []; }
|
||||
if (el.isConnected) ctx.rerender();
|
||||
return;
|
||||
}
|
||||
// Swipe a row to the left to delete it (touch); the ✕ does the same with a mouse.
|
||||
el.querySelectorAll(".todo-row").forEach((r) => {
|
||||
let x0 = null, dx = 0;
|
||||
r.addEventListener("pointerdown", (e) => { if (e.pointerType !== "mouse" && !e.target.closest("button")) { x0 = e.clientX; dx = 0; } });
|
||||
r.addEventListener("pointermove", (e) => { if (x0 == null) return; dx = Math.min(0, e.clientX - x0); r.style.transform = `translateX(${dx}px)`; r.style.opacity = String(1 + dx / 300); });
|
||||
const end = () => { if (x0 == null) return; x0 = null; if (dx < -90) r.querySelector(".todo-del")?.click(); else { r.style.transform = ""; r.style.opacity = ""; } };
|
||||
r.addEventListener("pointerup", end); r.addEventListener("pointercancel", end);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- who's home
|
||||
W.people = {
|
||||
label: "Who's home", icon: "people", size: [4, 2],
|
||||
fields: [{ k: "entities", t: "entities", l: "People (leave empty for everyone)", domains: ["person"] }, { k: "layout", t: "seg", l: "Layout", o: ["grid", "list"], d: "grid" }],
|
||||
entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("person."))),
|
||||
render(c, { ha }) {
|
||||
const ids = (c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => e.startsWith("person."))).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return `<div class="empty">${t("No people found. Add person entities in Home Assistant.")}</div>`;
|
||||
const ppl = ids.map((e) => {
|
||||
const s = ha.states[e], home = s.state === "home", away = s.state === "not_home";
|
||||
const where = home ? t("Home") : away ? t("Away") : st(s.state);
|
||||
const name = friendly(s), initials = name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
const pic = s.attributes.entity_picture ? `<img src="${esc(s.attributes.entity_picture)}" alt="">` : `<span class="pp-init">${esc(initials)}</span>`;
|
||||
return `<button class="pp ${home ? "home" : ""} wbtn" data-act="more" data-e="${esc(e)}" aria-label="${esc(`${name}: ${where}`)}">
|
||||
<span class="pp-av">${pic}<span class="pp-dot" aria-hidden="true"></span></span>
|
||||
<span class="pp-text"><span class="pp-name">${esc(name.split(" ")[0])}</span><span class="pp-where">${esc(where)} · ${esc(relTime(s.last_changed).replace(/ geleden| ago/, ""))}</span></span></button>`;
|
||||
}).join("");
|
||||
return `<div class="people ${c.layout === "list" ? "as-list" : ""}">${ppl}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- weather forecast
|
||||
W.forecast = {
|
||||
label: "Weather forecast", icon: "umbrella", size: [6, 4],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Weather", domains: ["weather"] },
|
||||
{ k: "show", t: "seg", l: "Show", o: ["both", "hourly", "daily"], d: "both" },
|
||||
{ k: "hours", t: "number", l: "Hours ahead", d: 12 },
|
||||
{ k: "days", t: "number", l: "Days ahead", d: 5 },
|
||||
{ k: "sun", t: "check", l: "Show sunrise and sunset", d: true },
|
||||
],
|
||||
entities: (c) => [c.entity, "sun.sun"].filter(Boolean),
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a weather entity");
|
||||
const a = s.attributes, unit = a.temperature_unit || "°";
|
||||
const deg = (v) => (v == null ? "–" : `${Math.round(v)}°`);
|
||||
const today = cache.daily?.[0];
|
||||
const sun = ha.states["sun.sun"]?.attributes;
|
||||
const hm = (iso) => new Date(iso).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
|
||||
const hourly = c.show !== "daily" && cache.hourly ? `<div class="fc-hours">${cache.hourly.slice(0, Math.max(3, Number(c.hours) || 12)).map((h) =>
|
||||
`<div class="fc-hour"><span class="fc-t">${esc(new Date(h.datetime).toLocaleTimeString(LOCALE, { hour: "2-digit" }))}</span>${Icons.svg(WX[h.condition] || "cloud")}<b class="num">${deg(h.temperature)}</b>${h.precipitation_probability ? `<span class="fc-rain">${Math.round(h.precipitation_probability)}%</span>` : `<span class="fc-rain"></span>`}</div>`).join("")}</div>` : "";
|
||||
let daily = "";
|
||||
if (c.show !== "hourly" && cache.daily) {
|
||||
const days = cache.daily.slice(0, Math.max(2, Number(c.days) || 5));
|
||||
const lo = Math.min(...days.map((d) => d.templow ?? d.temperature)), hi = Math.max(...days.map((d) => d.temperature));
|
||||
daily = `<div class="fc-days">${days.map((d, i) => {
|
||||
const l = d.templow ?? d.temperature, left = ((l - lo) / (hi - lo || 1)) * 100, right = ((d.temperature - lo) / (hi - lo || 1)) * 100;
|
||||
return `<div class="fc-day"><span class="fc-dn">${i === 0 ? t("Today") : esc(new Date(d.datetime).toLocaleDateString(LOCALE, { weekday: "short" }))}</span>${Icons.svg(WX[d.condition] || "cloud")}
|
||||
<span class="fc-rain">${d.precipitation_probability ? `${Math.round(d.precipitation_probability)}%` : ""}</span><span class="fc-lo num">${deg(l)}</span>
|
||||
<span class="fc-bar"><i style="left:${left}%;right:${100 - right}%"></i></span><span class="fc-hi num">${deg(d.temperature)}</span></div>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
return `<div class="fc">
|
||||
<button class="fc-now wbtn" data-act="more" data-e="${esc(c.entity)}"><span class="fc-ico">${Icons.svg(WX[s.state] || "cloud")}</span>
|
||||
<span class="fc-temp num">${esc(a.temperature ?? "–")}°</span>
|
||||
<span class="fc-cond"><b>${esc(st(s.state))}</b>${today ? `<span>${t("High {0}° · Low {1}°", Math.round(today.temperature), Math.round(today.templow ?? today.temperature))}</span>` : ""}</span>
|
||||
${c.sun !== false && sun ? `<span class="fc-sun"><span>${Icons.svg("sunrise")}${esc(hm(sun.next_rising))}</span><span>${Icons.svg("sunset")}${esc(hm(sun.next_setting))}</span></span>` : ""}</button>
|
||||
${hourly}${daily}${!cache.daily && !cache.hourly ? `<div class="empty">${t("Loading the forecast…")}</div>` : ""}</div>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
if (!ctx.ha.states[c.entity]) return;
|
||||
const cache = ctx.cache;
|
||||
if (cache.key === c.entity && Date.now() - cache.at < 30 * 60 * 1000) return;
|
||||
cache.key = c.entity; cache.at = Date.now();
|
||||
const [daily, hourly] = await Promise.all([ctx.ha.forecast(c.entity, "daily").catch(() => null), ctx.ha.forecast(c.entity, "hourly").catch(() => null)]);
|
||||
cache.daily = daily; cache.hourly = hourly;
|
||||
if (el.isConnected) ctx.rerender();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- timer
|
||||
// Counts down in the browser from finishes_at (ticked by app.js every second); presets start it with a duration.
|
||||
const hms = (secs) => { secs = Math.max(0, Math.round(secs)); const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), x = secs % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(x).padStart(2, "0")}` : `${m}:${String(x).padStart(2, "0")}`; };
|
||||
const toSecs = (v) => { const p = String(v || "0").split(":").map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p.length === 2 ? p[0] * 60 + p[1] : p[0]; };
|
||||
|
||||
W.timer = {
|
||||
label: "Timer", icon: "timer", size: [3, 3],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Timer", domains: ["timer"] },
|
||||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||
{ k: "presets", t: "text", l: "Quick starts in minutes", d: "5, 10, 15, 30" },
|
||||
{ k: "sound", t: "check", l: "Beep when done", d: true },
|
||||
],
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a timer");
|
||||
const a = s.attributes, e = esc(c.entity);
|
||||
const dur = toSecs(a.duration);
|
||||
// Spot the moment it runs out: active → idle around the finish time.
|
||||
if (cache.prev === "active" && s.state === "idle" && cache.finish && Date.now() >= cache.finish - 3000) {
|
||||
cache.doneUntil = Date.now() + 60000;
|
||||
if (c.sound !== false) window.HAFmt.beep?.();
|
||||
}
|
||||
cache.prev = s.state;
|
||||
cache.finish = a.finishes_at ? new Date(a.finishes_at).getTime() : null;
|
||||
const done = cache.doneUntil && Date.now() < cache.doneUntil && s.state === "idle";
|
||||
const left = s.state === "active" && a.finishes_at ? (new Date(a.finishes_at) - Date.now()) / 1000 : s.state === "paused" ? toSecs(a.remaining) : dur;
|
||||
const R = 44, C = 2 * Math.PI * R, frac = dur ? Math.max(0, Math.min(1, left / dur)) : 0;
|
||||
const presets = String(c.presets || "").split(/[,\s]+/).map(Number).filter((m) => m > 0).slice(0, 6);
|
||||
const ctl = (svc, ico, lbl, data) => `<button class="round wbtn" data-act="timer" data-e="${e}" data-v="${svc}" ${data ? `data-d="${data}"` : ""} aria-label="${esc(lbl)}" title="${esc(lbl)}">${Icons.svg(ico)}</button>`;
|
||||
return `<div class="tm ${s.state} ${done ? "done" : ""}">
|
||||
<div class="tm-name">${esc(friendly(s, c.name))}</div>
|
||||
<div class="tm-ring"><svg viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/><circle cx="50" cy="50" r="${R}" class="fill" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}" data-ring="${C.toFixed(1)}"/></svg>
|
||||
<span class="tm-time num" ${s.state === "active" && a.finishes_at ? `data-countdown="${esc(a.finishes_at)}" data-dur="${dur}"` : ""} aria-live="off">${done ? t("Done!") : hms(left)}</span></div>
|
||||
<div class="tm-ctrls">${done ? `<button class="chip wbtn" data-act="timer-ok" aria-pressed="true">${t("OK")}</button>`
|
||||
: s.state === "active" ? ctl("pause", "pause", t("Pause")) + ctl("cancel", "stop", t("Stop"))
|
||||
: s.state === "paused" ? ctl("start", "play", t("Resume")) + ctl("cancel", "stop", t("Stop"))
|
||||
: presets.map((m) => `<button class="chip wbtn" data-act="timer" data-e="${e}" data-v="start" data-d="${m}">${m} ${t("min")}</button>`).join("") + (dur ? ctl("start", "play", t("Start")) : "")}</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (ha.states[c.entity]?.state === "active" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- blinds / covers
|
||||
const coverPos = (s) => (s.attributes.current_position != null ? Number(s.attributes.current_position) : s.state === "open" ? 100 : s.state === "closed" ? 0 : 50);
|
||||
W.covers = {
|
||||
label: "Blinds", icon: "blinds", size: [4, 3],
|
||||
fields: [{ k: "title", t: "text", l: "Title", ph: "e.g. Living room" }, { k: "entities", t: "entities", l: "Blinds, curtains or shutters", domains: ["cover"] }],
|
||||
entities: (c) => c.entities || [],
|
||||
render(c, { ha }) {
|
||||
const ids = (c.entities || []).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return `<div class="empty">${t("Add one or more blinds in the inspector.")}</div>`;
|
||||
const btns = (e, s) => `<div class="cv-btns">
|
||||
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.open_cover" aria-label="${t("Open")}" ${coverPos(s) >= 100 ? "disabled" : ""}>${Icons.svg("arrow-up")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.stop_cover" aria-label="${t("Stop")}">${Icons.svg("stop")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.close_cover" aria-label="${t("Close")}" ${coverPos(s) <= 0 ? "disabled" : ""}>${Icons.svg("arrow-down")}</button></div>`;
|
||||
const slider = (e, s, kind = "pos") => {
|
||||
const v = kind === "pos" ? coverPos(s) : Number(s.attributes.current_tilt_position ?? 0);
|
||||
return `<input class="slider thin cv-slider" type="range" min="0" max="100" value="${v}" style="--pct:${v}%" data-act="cover-${kind}" data-e="${esc(e)}" aria-label="${esc(`${friendly(s)} ${kind === "pos" ? t("position") : t("tilt")}`)}">`;
|
||||
};
|
||||
// a window whose blind comes down as the cover closes
|
||||
const win = (s) => { const closed = 100 - coverPos(s); return `<div class="cv-win" aria-hidden="true"><div class="cv-blind" style="height:${closed}%"></div></div>`; };
|
||||
const moving = (s) => (["opening", "closing"].includes(s.state) ? ` · ${st(s.state)}` : "");
|
||||
if (ids.length === 1) {
|
||||
const e = ids[0], s = ha.states[e];
|
||||
return `<div class="cv one">${c.title ? `<div class="wt">${esc(c.title)}</div>` : ""}
|
||||
<div class="cv-main">${win(s)}<div class="cv-side"><span class="name">${esc(friendly(s))}</span><span class="cv-pct num">${coverPos(s)}%${esc(moving(s))}</span>${btns(e, s)}</div></div>
|
||||
${slider(e, s)}${s.attributes.current_tilt_position != null ? `<div class="cv-tilt"><span class="wt">${t("Tilt")}</span>${slider(e, s, "tilt")}</div>` : ""}</div>`;
|
||||
}
|
||||
return `<div class="cv">
|
||||
<div class="cv-head"><span class="name">${esc(c.title || t("Blinds"))}</span>
|
||||
<button class="chip wbtn" data-act="svc-many" data-v="cover.open_cover" data-ids="${esc(ids.join(","))}">${t("Open all")}</button>
|
||||
<button class="chip wbtn" data-act="svc-many" data-v="cover.close_cover" data-ids="${esc(ids.join(","))}">${t("Close all")}</button></div>
|
||||
<div class="cv-list">${ids.map((e) => { const s = ha.states[e]; return `<div class="cv-row">${win(s)}<div class="cv-row-main"><span class="cv-name">${esc(friendly(s))} <b class="num">${coverPos(s)}%</b>${esc(moving(s))}</span>${slider(e, s)}</div>${btns(e, s)}</div>`; }).join("")}</div></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- alarm
|
||||
const ALARM_FEATURES = [["armed_home", 1, "alarm_arm_home", "Home"], ["armed_away", 2, "alarm_arm_away", "Away"], ["armed_night", 4, "alarm_arm_night", "Night"], ["armed_vacation", 32, "alarm_arm_vacation", "Vacation"]];
|
||||
const isOpening = (s) => s.entity_id.startsWith("binary_sensor.") && ["door", "window", "opening", "garage_door"].includes(s.attributes.device_class);
|
||||
W.alarm = {
|
||||
label: "Alarm", icon: "shield", size: [4, 5],
|
||||
fields: [{ k: "entity", t: "entity", l: "Alarm panel", domains: ["alarm_control_panel"] }, { k: "openings", t: "entities", l: "Doors and windows to check (leave empty for all)", domains: ["binary_sensor"] }],
|
||||
entities: (c, ha) => [c.entity, ...(c.openings?.length ? c.openings : Object.values(ha?.states || {}).filter(isOpening).map((s) => s.entity_id))].filter(Boolean),
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "an alarm panel");
|
||||
const a = s.attributes, f = Number(a.supported_features) || 7, armed = s.state.startsWith("armed"), alarm = s.state === "triggered";
|
||||
const open = (c.openings?.length ? c.openings.map((e) => ha.states[e]).filter(Boolean) : Object.values(ha.states).filter(isOpening)).filter((o) => o.state === "on");
|
||||
const label = { disarmed: t("Off"), armed_home: t("On · home"), armed_away: t("On · away"), armed_night: t("On · night"), armed_vacation: t("On · vacation"), arming: t("Turning on…"), pending: t("Entry delay…"), triggered: t("Alarm!") }[s.state] || st(s.state);
|
||||
const code = cache.code || "";
|
||||
const needsCode = !!a.code_format;
|
||||
const modes = ALARM_FEATURES.filter(([, bit]) => f & bit).map(([st_, , svc, lbl]) => `<button class="al-mode wbtn ${s.state === st_ ? "on" : ""}" data-act="alarm" data-e="${esc(c.entity)}" data-v="${svc}" aria-pressed="${s.state === st_}">${esc(t(lbl))}</button>`).join("");
|
||||
const keys = needsCode ? `<div class="al-code" aria-live="polite">${code ? "•".repeat(code.length) : `<span class="wt">${t("Enter the code")}</span>`}</div>
|
||||
<div class="al-keys">${["1", "2", "3", "4", "5", "6", "7", "8", "9", "C", "0", "⌫"].map((k) => `<button class="al-key wbtn" data-act="alarm-key" data-v="${k}" aria-label="${k === "C" ? t("Clear") : k === "⌫" ? t("Delete last digit") : k}">${k}</button>`).join("")}</div>` : "";
|
||||
return `<div class="al state-${esc(s.state)}">
|
||||
<div class="al-head"><span class="al-shield">${Icons.svg("shield")}</span><span class="tile-text"><span class="name">${esc(friendly(s))}</span><span class="al-state">${esc(label)}</span></span></div>
|
||||
${open.length && !armed ? `<button class="al-open wbtn" data-act="more" data-e="${esc(open[0].entity_id)}">${Icons.svg("door-open")}<span>${esc(t("{0} still open: {1}", open.length, open.map((o) => friendly(o)).join(", ")))}</span></button>` : ""}
|
||||
${keys}
|
||||
<div class="al-modes">${armed || alarm || s.state === "arming" || s.state === "pending" ? `<button class="al-mode off wbtn" data-act="alarm" data-e="${esc(c.entity)}" data-v="alarm_disarm">${t("Turn off")}</button>` : modes}</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (ha.states[c.entity]?.state === "triggered" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- notifications
|
||||
// Home Assistant's notifications (kept up to date by a subscription in app.js) and available updates.
|
||||
const mdLite = (txt) => esc(txt).replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]*)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>').replace(/\n/g, "<br>");
|
||||
W.notifications = {
|
||||
label: "Notifications", icon: "bell", size: [4, 4],
|
||||
fields: [{ k: "notes", t: "check", l: "Show notifications", d: true }, { k: "updates", t: "check", l: "Show available updates", d: true }],
|
||||
entities: (c, ha) => ["__notifications", ...Object.keys(ha?.states || {}).filter((e) => e.startsWith("update."))],
|
||||
render(c, { ha }) {
|
||||
const notes = c.notes !== false ? Object.values(ha.notifications || {}).sort((x, y) => String(y.created_at).localeCompare(String(x.created_at))) : [];
|
||||
const updates = c.updates !== false ? Object.values(ha.states).filter((s) => s.entity_id.startsWith("update.") && s.state === "on") : [];
|
||||
const count = notes.length + updates.length;
|
||||
const items = [
|
||||
...updates.map((u) => `<div class="nt-item update"><span class="nt-ico">${Icons.svg("sparkles")}</span><div class="nt-body"><b>${esc(u.attributes.title || friendly(u))}</b>
|
||||
<span class="wt">${esc(t("{0} → {1}", u.attributes.installed_version || "?", u.attributes.latest_version || "?"))}</span>
|
||||
${u.attributes.release_url ? `<a href="${esc(u.attributes.release_url)}" target="_blank" rel="noopener" class="nt-link">${t("What’s new")}</a>` : ""}</div>
|
||||
<button class="chip wbtn" data-act="update-install" data-e="${esc(u.entity_id)}">${t("Install")}</button></div>`),
|
||||
...notes.map((n) => `<div class="nt-item"><span class="nt-ico">${Icons.svg("bell")}</span><div class="nt-body">${n.title ? `<b>${esc(n.title)}</b>` : ""}<span class="nt-msg">${mdLite(n.message || "")}</span>
|
||||
<span class="nt-time">${esc(relTime(n.created_at))}</span></div>
|
||||
<button class="nt-x wbtn" data-act="note-dismiss" data-v="${esc(n.notification_id)}" aria-label="${t("Dismiss")}">${Icons.svg("close")}</button></div>`),
|
||||
].join("");
|
||||
return `<div class="nt"><div class="nt-head"><span class="name">${t("Notifications")}</span>${count ? `<span class="todo-count num">${count}</span>` : ""}
|
||||
${notes.length > 1 ? `<button class="chip wbtn" data-act="note-dismiss-all">${t("Dismiss all")}</button>` : ""}</div>
|
||||
<div class="nt-list">${items || `<div class="nt-empty">${Icons.svg("check")}<span>${t("All caught up")}</span></div>`}</div></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- energy
|
||||
// Live power flow between solar, grid, battery and home. Lines are as thick and as fast as the power.
|
||||
function watts(s) {
|
||||
@@ -848,11 +1134,23 @@
|
||||
// ---------------------------------------------------------------- car
|
||||
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
|
||||
|
||||
// Built around a round gauge, like an instrument cluster: charge as an arc with the charge limit marked,
|
||||
// the range large in the middle, and the car in front. Sizes adapt through container queries.
|
||||
const CAR_ART = `<svg class="car-art2" viewBox="0 0 200 78" aria-hidden="true">
|
||||
<defs><linearGradient id="carBody" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--car-top)"/><stop offset="1" stop-color="var(--car-bottom)"/></linearGradient>
|
||||
<linearGradient id="carGlass" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#cfe3ff" stop-opacity=".55"/><stop offset="1" stop-color="#7a8cab" stop-opacity=".35"/></linearGradient></defs>
|
||||
<ellipse cx="100" cy="70" rx="86" ry="5" class="car-shadow"/>
|
||||
<path class="car-body2" d="M14 56c0-6 4-9 12-11l30-6c10-10 22-15 40-16h24c14 0 26 5 36 13l14 5c12 2 18 6 19 12v5c0 3-2 4-5 4H16c-1.5 0-2-2-2-6z"/>
|
||||
<path class="car-glass2" d="M64 39c8-8 18-12 32-12h12v12zM112 27h8c11 0 20 4 28 11l-36 1z"/>
|
||||
<path class="car-line" d="M30 50h150"/><circle cx="182" cy="52" r="2.2" class="car-light"/>
|
||||
<circle cx="52" cy="62" r="12" class="car-tire"/><circle cx="52" cy="62" r="6" class="car-rim"/>
|
||||
<circle cx="154" cy="62" r="12" class="car-tire"/><circle cx="154" cy="62" r="6" class="car-rim"/></svg>`;
|
||||
|
||||
W.car = {
|
||||
label: "Car", icon: "car", size: [3, 4],
|
||||
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: "image", t: "text", l: "Car image URL (optional, a side view with a transparent background looks best)", 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"] },
|
||||
@@ -860,72 +1158,70 @@
|
||||
{ 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: "toFull", t: "entity", l: "Time until full (minutes or end time)", domains: ["sensor"] },
|
||||
{ k: "chargeSwitch", t: "entity", l: "Start/stop charging (switch)", domains: ["switch", "button"] },
|
||||
{ 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),
|
||||
// Layout follows the card's size (container queries in style.css): designed around 3×4,
|
||||
// a compact strip when short, and the car beside the numbers when wide.
|
||||
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "toFull", "chargeSwitch", "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");
|
||||
const S = (k) => (c[k] ? ha.states[c[k]] : null);
|
||||
const level = S("level"), range = S("range"), lock = S("lock"), clim = S("climate"), loc = S("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"), odo = st_("odometer");
|
||||
const charging = truthy(st_("charging")) || (!c.charging && power && Number(power.state) > 0);
|
||||
const power = S("power"), plug = S("plug"), limit = S("limit"), odo = S("odometer"), toFull = S("toFull"), chg = S("chargeSwitch");
|
||||
const charging = truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on");
|
||||
const plugged = charging || truthy(plug);
|
||||
const low = pct != null && pct <= 20;
|
||||
const fuel = c.kind === "fuel";
|
||||
const fuel = c.kind === "fuel", low = pct != null && pct <= 20;
|
||||
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
|
||||
|
||||
let status;
|
||||
// status line and the "full at" time
|
||||
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 = "";
|
||||
let fullAt = "";
|
||||
if (charging && toFull) {
|
||||
const v = toFull.state, mins = /^\d{4}-\d{2}-\d{2}T/.test(v) ? (new Date(v) - Date.now()) / 60000 : Number(v) * (String(toFull.attributes.unit_of_measurement || "min").toLowerCase().startsWith("h") ? 60 : 1);
|
||||
if (isFinite(mins) && mins > 0) fullAt = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }));
|
||||
}
|
||||
|
||||
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
|
||||
// Drawn car for when there's no photo: a side view, with a glow and a bolt while charging.
|
||||
const drawn = `<svg class="car-art" viewBox="0 0 200 84" aria-hidden="true">
|
||||
<path class="car-body" d="M10 60c1-10 9-15 22-17l26-15c7-4 15-6 24-6h40c10 0 18 3 25 9l15 12c13 1 24 4 28 11l2 8c0 4-2 6-6 6H12c-3 0-4-3-2-8z"/>
|
||||
<path class="car-glass" d="M64 31c6-3 12-4 19-4h16v18H52zM105 27h17c8 0 14 2 20 7l9 11h-46z"/>
|
||||
<circle class="car-wheel" cx="46" cy="68" r="13"/><circle class="car-wheel" cx="154" cy="68" r="13"/>
|
||||
<circle class="car-hub" cx="46" cy="68" r="5"/><circle class="car-hub" cx="154" cy="68" r="5"/></svg>`;
|
||||
const visual = `<div class="car-visual ${charging ? "is-charging" : ""}">${c.image ? `<img class="car-photo" src="${esc(c.image)}" alt="">` : drawn}
|
||||
${charging ? `<span class="car-bolt" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>`;
|
||||
// the gauge: 240° arc, level filled, charge limit as a tick
|
||||
const A0 = Math.PI * (150 / 180), SPAN = Math.PI * (240 / 180), R = 80;
|
||||
const ang = (p) => A0 + SPAN * Math.max(0, Math.min(1, p / 100));
|
||||
const pt = (p, r = R) => [100 + r * Math.cos(ang(p)), 100 + r * Math.sin(ang(p))];
|
||||
const arc = (p0, p1) => { const [x0, y0] = pt(p0), [x1, y1] = pt(p1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(p1) - ang(p0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
|
||||
const tick = limPct != null && !fuel ? (() => { const [x0, y0] = pt(limPct, R - 11), [x1, y1] = pt(limPct, R + 11); return `<line x1="${x0.toFixed(1)}" y1="${y0.toFixed(1)}" x2="${x1.toFixed(1)}" y2="${y1.toFixed(1)}" class="cg-limit"/>`; })() : "";
|
||||
const gauge = `<div class="cg ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct ?? 0}" aria-label="${esc(fuel ? t("Fuel level") : t("Battery level"))}">
|
||||
<svg viewBox="0 0 200 200" aria-hidden="true"><path d="${arc(0, 100)}" class="cg-track"/>${pct ? `<path d="${arc(0, pct)}" class="cg-fill"/>` : ""}${tick}</svg>
|
||||
<div class="cg-center">${range ? `<span class="cg-range num">${esc(fmtNum(range.state))}<small>${esc(range.attributes.unit_of_measurement || "km")}</small></span>` : ""}
|
||||
${pct != null ? `<span class="cg-pct num">${charging ? Icons.svg("bolt", "inline") : ""}${pct}%${low ? ` · ${t("Low")}` : ""}</span>` : ""}</div>
|
||||
<div class="cg-car">${c.image ? `<img src="${esc(c.image)}" alt="">` : CAR_ART}</div></div>`;
|
||||
|
||||
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="${fuel ? t("Fuel level") : t("Battery level")}">
|
||||
<span class="car-fill" style="width:${pct}%"></span>
|
||||
${limPct != null && !fuel ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
|
||||
</div>`;
|
||||
const btn = (act, eid, on, ico, label, sub) => `<button class="cb wbtn" data-act="${act}" data-e="${esc(eid)}" aria-label="${esc(sub ? `${label}, ${sub}` : label)}" title="${esc(label)}" ${act === "toggle" ? `aria-pressed="${on}"` : ""}>
|
||||
<span class="cb-ico">${Icons.svg(ico)}</span><span class="cb-label">${esc(label)}</span>${sub ? `<span class="cb-sub num">${esc(sub)}</span>` : ""}</button>`;
|
||||
const buttons = [];
|
||||
if (lock) { const locked = lock.state === "locked"; buttons.push(btn("toggle", c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
|
||||
if (clim) {
|
||||
const on = isOn(clim) || clim.state === "on", inside = clim.attributes.current_temperature;
|
||||
buttons.push(btn("toggle", c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"), inside != null ? `${inside}° ${t("inside")}` : ""));
|
||||
}
|
||||
if (chg && !fuel) {
|
||||
const isBtn = domainOf(c.chargeSwitch) === "button";
|
||||
buttons.push(isBtn ? `<button class="cb wbtn" data-act="svc" data-e="${esc(c.chargeSwitch)}" data-v="button.press"><span class="cb-ico">${Icons.svg("bolt")}</span><span class="cb-label">${esc(friendly(chg))}</span></button>`
|
||||
: btn("toggle", c.chargeSwitch, charging, "bolt", charging ? t("Stop charging") : t("Start charging")));
|
||||
}
|
||||
if (loc) buttons.push(btn("more", c.location, false, "pin", fmtState(loc)));
|
||||
|
||||
const action = (eid, on, ico, label) => `<button class="car-btn wbtn" data-act="${eid === c.location ? "more" : "toggle"}" data-e="${esc(eid)}" ${eid === c.location ? "" : `aria-pressed="${on}"`}>
|
||||
<span class="car-btn-ico">${Icons.svg(ico)}</span><span class="car-btn-label">${esc(label)}</span></button>`;
|
||||
const actions = [];
|
||||
if (lock) { const locked = lock.state === "locked"; actions.push(action(c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
|
||||
if (clim) { const on = isOn(clim) || clim.state === "on"; actions.push(action(c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"))); }
|
||||
if (loc) actions.push(action(c.location, false, "pin", fmtState(loc)));
|
||||
const chips = [fullAt && `<span class="cchip">${Icons.svg("clock")}${esc(fullAt)}</span>`, odo && `<span class="cchip">${Icons.svg("road")}${esc(fmtState(odo))}</span>`].filter(Boolean).join("");
|
||||
|
||||
const stat = (label, value) => `<div class="car-stat"><span class="car-stat-v num">${esc(value)}</span><span class="car-stat-l">${esc(label)}</span></div>`;
|
||||
const stats = [
|
||||
odo && stat(t("Odometer"), fmtState(odo)),
|
||||
limPct != null && !fuel && stat(t("Charge limit"), `${limPct}%`),
|
||||
charging && power && stat(t("Charging power"), fmtState(power)),
|
||||
].filter(Boolean).join("");
|
||||
|
||||
return `<div class="car2 ${c.image ? "has-photo" : ""}">
|
||||
<div class="car-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car-status ${charging ? "charging" : ""}">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</span>` : ""}</div>
|
||||
${visual}
|
||||
<div class="car-info">
|
||||
<div class="car-nums">${pct != null ? `<span class="car-pct num">${pct}<span class="unit">%</span></span>` : ""}
|
||||
${range ? `<span class="car-range num">${esc(fmtState(range))}</span>` : ""}${low ? `<span class="level">${t("Low")}</span>` : ""}</div>
|
||||
${bar}
|
||||
${stats ? `<div class="car-stats">${stats}</div>` : ""}
|
||||
${actions.length ? `<div class="car-actions2">${actions.join("")}</div>` : ""}
|
||||
</div></div>`;
|
||||
return `<div class="car3 ${charging ? "is-charging" : ""}">
|
||||
<div class="car3-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car3-status ${charging ? "charging" : ""}">${esc(status)}</span>` : ""}</div>
|
||||
${gauge}
|
||||
<div class="car3-info">${chips ? `<div class="cchips">${chips}</div>` : ""}${buttons.length ? `<div class="cbs">${buttons.join("")}</div>` : ""}</div>
|
||||
</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1171,7 +1467,13 @@
|
||||
// ctx.renderChildren fills them using the normal widget renderer.
|
||||
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
|
||||
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}" ${Number(ch.cfg?._width) > 0 ? `style="--own-w:${Number(ch.cfg._width)}px"` : ""}></div>`;
|
||||
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
|
||||
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? esc(c.emptyText || t("None of these cards are showing right now.")) : t("Add cards in the inspector.")}</div></div>`;
|
||||
// What a container shows when all its cards are hidden by their conditions: a text of your own, or nothing at all.
|
||||
const EMPTY_FIELDS = [
|
||||
{ k: "hideEmpty", t: "check", l: "Hide this card when none of its cards are showing", d: false },
|
||||
{ k: "emptyText", t: "text", l: "Text when none of its cards are showing", ph: "None of these cards are showing right now.", when: (c) => !c.hideEmpty },
|
||||
];
|
||||
const containerHidden = (c, ctx) => !!c.hideEmpty && (c.children || []).length > 0 && !(c.children || []).some((ch) => ctx.isVisible(ch));
|
||||
|
||||
W.hscroll = {
|
||||
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
|
||||
@@ -1180,7 +1482,9 @@
|
||||
{ k: "widthMode", t: "seg", l: "Card width", o: ["pixels", "per-view"], d: "pixels" },
|
||||
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260, when: (c) => c.widthMode !== "per-view" },
|
||||
{ k: "perView", t: "number", l: "Cards visible at once", d: 2.5, ph: "e.g. 1, 2 or 2.5", when: (c) => c.widthMode === "per-view" },
|
||||
...EMPTY_FIELDS,
|
||||
],
|
||||
autoHide: containerHidden,
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
@@ -1212,7 +1516,9 @@
|
||||
fields: [
|
||||
{ k: "autoplay", t: "number", l: "Next card every (seconds)", d: 0, ph: "0 = off" },
|
||||
{ k: "dots", t: "check", l: "Show page dots while swiping", d: true },
|
||||
...EMPTY_FIELDS,
|
||||
],
|
||||
autoHide: containerHidden,
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
@@ -1328,5 +1634,12 @@
|
||||
};
|
||||
|
||||
window.Widgets = W;
|
||||
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
|
||||
function beep() {
|
||||
try {
|
||||
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
||||
[0, 0.35, 0.7].forEach((t0) => { const o = ac.createOscillator(), g = ac.createGain(); o.frequency.value = 880; g.gain.setValueAtTime(0.0001, ac.currentTime + t0);
|
||||
g.gain.exponentialRampToValueAtTime(0.3, ac.currentTime + t0 + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + t0 + 0.25); o.connect(g).connect(ac.destination); o.start(ac.currentTime + t0); o.stop(ac.currentTime + t0 + 0.3); });
|
||||
} catch { /* no audio */ }
|
||||
}
|
||||
window.HAFmt = { slideshow, beep, hms, esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap, lightTint, canColor, canTemp, WHITES, COLORS };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user