From 7abf7379d80fb84a5420a07664986bab26ef7ae6 Mon Sep 17 00:00:00 2001 From: veenm Date: Mon, 26 Oct 2026 15:12:30 +0100 Subject: [PATCH] Dashboard Studio 1.12.0: new cards, kiosk doorbell, night mode, editor tools, version history Co-Authored-By: Claude Opus 5.5 --- dashboard_studio/CHANGELOG.md | 12 + dashboard_studio/DOCS.md | 63 ++- dashboard_studio/config.yaml | 2 +- dashboard_studio/server/main.py | 80 ++++ dashboard_studio/server/mock_ha.py | 116 ++++- dashboard_studio/web/app.js | 690 ++++++++++++++++++++++++++--- dashboard_studio/web/i18n.js | 83 +++- dashboard_studio/web/icons.js | 87 ++++ dashboard_studio/web/index.html | 8 +- dashboard_studio/web/style.css | 330 ++++++++++++-- dashboard_studio/web/widgets.js | 439 +++++++++++++++--- 11 files changed, 1728 insertions(+), 182 deletions(-) diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index dd888d8..8147065 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -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. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 3331157..0da6d20 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -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._location` | | Odometer | `sensor._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. diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 069093c..039830a 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -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 diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py index f5e1a58..4d0ed37 100644 --- a/dashboard_studio/server/main.py +++ b/dashboard_studio/server/main.py @@ -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/.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) diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py index 6189b07..8f67334 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -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,"), + 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} diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index a0cc8d7..a5e99f0 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -68,6 +68,13 @@ this.connected = true; this._markReady(); this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card + // Home Assistant's notifications, kept current for the notifications card. + this.send({ type: "persistent_notification/subscribe" }, (ev) => { + if (ev.type === "current") this.notifications = { ...ev.notifications }; + else if (ev.type === "removed") for (const id of Object.keys(ev.notifications || {})) delete this.notifications[id]; + else this.notifications = { ...(this.notifications || {}), ...ev.notifications }; + this.onChange("__notifications"); + }).catch(() => {}); this.onStatus(true); this.onChange(null); return; @@ -179,7 +186,7 @@ const DEFAULT_THEME = { preset: "System", surface: "cards", radius: 20, bgImage: "", blur: 0, shadow: true }; const DEFAULT_GRID = { cols: 12, rowHeight: 72, gap: 14, maxWidth: 1440 }; - const DEFAULT_KIOSK = { saver: 0, exitCorner: "top-right", hideCursor: true }; + const DEFAULT_KIOSK = { saver: 0, saverStyle: "clock", saverSource: "links", saverUrls: "", saverAlbums: [], saverInterval: 30, exitCorner: "top-right", hideCursor: true, doorbell: "", doorbellCamera: "", doorbellSecs: 60, doorbellSound: true }; const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] }); const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, nav: { show: false, style: "icons" }, tabs: [newTab(tr("Overview"))] }); @@ -283,6 +290,9 @@ let current = null; let editMode = lsGet("ds.edit") === "1"; let selectedId = null; + /** Shift- or ⌘-click selects several widgets; `multi` then holds them all, including selectedId. */ + const multi = new Set(); + const isSel = (id) => id === selectedId || multi.has(id); let activeTabId = null; /** The tab whose widgets are on screen. */ const tab = () => current.tabs.find((t) => t.id === activeTabId) || current.tabs[0]; @@ -297,6 +307,7 @@ return null; } const selectedWidget = () => tab().widgets.find((x) => x.id === selectedId); + const selection = () => tab().widgets.filter((w) => isSel(w.id)); /** What the inspector edits: a card inside the selected container, or the selected widget. */ const editTarget = () => { const w = selectedWidget(); return (childIndex != null && w?.cfg.children?.[childIndex]) || w; }; const undoStack = [], redoStack = []; @@ -346,7 +357,7 @@ const idx = store.dashboards.findIndex((d) => d.id === current.id); current = JSON.parse(snapshot); store.dashboards[idx] = current; - if (!tab().widgets.some((w) => w.id === selectedId)) selectedId = null; + if (!tab().widgets.some((w) => w.id === selectedId)) { selectedId = null; multi.clear(); } lastMutKey = null; scheduleSave(); updateUndoButtons(); renderAll(); } @@ -361,7 +372,7 @@ const VAR_FOR = { bg: "--d-bg", tile: "--d-tile", text: "--d-text", text2: "--d-text-2", accent: "--d-accent", onAccent: "--d-on-accent", separator: "--d-separator", fill: "--d-fill", lit: "--d-lit" }; function applyTheme() { - const t = current.theme, s = stageEl.style; + const t = nightDark ? { ...current.theme, ...PRESETS.Dark, preset: "Dark" } : current.theme, s = stageEl.style; stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain"); stageEl.classList.add(`surface-${t.surface || "cards"}`); for (const k of COLOR_KEYS) { @@ -480,7 +491,7 @@ function setTab(id) { activeTabId = id; lsSet(`ds.tab.${current.id}`, id); - selectedId = null; + { selectedId = null; multi.clear(); } renderGrid(); if (editMode) renderInspector(); stageEl.scrollTop = 0; @@ -555,7 +566,7 @@ const ctx = ctxFor(w); const st = w.style || {}; const lit = def.lit?.(w.cfg, ctx) || false; - el.classList.toggle("selected", editMode && w.id === selectedId); + el.classList.toggle("selected", editMode && isSel(w.id)); el.classList.toggle("transparent", !!st.transparent); el.classList.toggle("lit-lamp", lit === "lamp"); el.classList.toggle("lit-on", lit === "on"); @@ -586,6 +597,8 @@ function onStateChange(entityId) { if (livePopup && livePopup.eid === entityId) livePopup.draw(); // an open climate popup follows the device if (!current) return; + if (entityId === current.kiosk.doorbell || entityId == null) checkDoorbell(); + if (entityId === "sun.sun" || entityId == null) applyNight(); if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; } const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId)); if (affected.some((w) => isVisible(w) !== lastVisible[w.id])) { renderGrid(); return; } @@ -593,6 +606,7 @@ const el = gridEl.querySelector(`.w[data-id="${w.id}"]`); if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag if (caches[w.id]?.busy) { caches[w.id].dirty = true; continue; } // mid-swipe: redraw when the swipe ends + if (el?.contains(document.activeElement) && document.activeElement.matches("input[type=text]")) { (caches[w.id] ||= {}).typing = true; continue; } const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null; renderWidget(w, el); if (focused) el.querySelector(`[data-act="${focused}"]`)?.focus(); @@ -604,9 +618,15 @@ } function renderPalette() { - $("#palette-list").innerHTML = Object.entries(Widgets) - .map(([type, d]) => ``).join(""); + const search = $("#palette-search"), q = search.value.trim().toLowerCase(); + search.placeholder = tr("Search widgets"); + const items = Object.entries(Widgets).filter(([type, d]) => !q || [type, d.label, tr(d.label)].some((x) => x.toLowerCase().includes(q))); + $("#palette-list").innerHTML = items.length + ? items.map(([type, d]) => ``).join("") + : `

${tr("No widgets match “{0}”.", esc(search.value.trim()))}

`; } + $("#palette-search").addEventListener("input", renderPalette); + $("#palette-search").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#palette-list .pal-item")?.click(); }); function renderAll() { document.body.classList.toggle("editing", editMode); @@ -614,16 +634,11 @@ renderDashSelect(); renderGrid(); if (editMode) renderInspector(); + applyNight(); } // ------------------------------------------------------------ inspector - function entityOptions(domains) { - return Object.values(ha.states) - .filter((s) => !domains || domains.includes(domainOf(s.entity_id))) - .sort((a, b) => a.entity_id.localeCompare(b.entity_id)) - .map((s) => ``).join(""); - } - const listId = (domains) => `dl-${(domains || ["all"]).join("-")}`; + const entPickAttrs = (domains) => `data-ent-pick="${(domains || []).join(",")}" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="ent-pop"`; function fieldHTML(f, value, path) { f = { ...f, l: tr(f.l), ph: f.ph ? tr(f.ph) : f.ph }; @@ -635,9 +650,11 @@ return `
`; case "seg": return `
${esc(f.l)}
- ${f.o.map((o) => ``).join("")}
`; + ${f.o.map((o) => ``).join("")}`; case "select": return `
${label}
`; + case "time": + return `
${label}
`; case "number": return `
${label}
`; case "textarea": @@ -648,19 +665,19 @@ `; case "icon": - return `
${label}
+ return `
${label}
`; case "entity": { const s = ha.states[value]; - return `
${label} + return `
${label} ${s ? `
${esc(friendly(s))} · ${esc(fmtState(s))}
` : value ? `
${tr("No entity with this ID")}
` : ""}
`; } case "entities": { - const lid = listId(f.domains); + const lid = entPickAttrs(f.domains); const list = Array.isArray(value) ? value : []; return `
${esc(f.l)} - ${list.map((e, i) => `
`).join("")} -
`; + ${list.map((e, i) => `
`).join("")} +
`; } case "area": { const areas = ha.reg?.areas; @@ -678,10 +695,7 @@ } function datalists() { - const groups = new Set(["all"]); - for (const d of Object.values(Widgets)) for (const f of d.fields) if (f.domains) groups.add(f.domains.join("-")); - return [...groups].map((g) => `${entityOptions(g === "all" ? null : g.split("-"))}`).join("") + - `${Icons.names.map((n) => ``; + return `${Icons.names.map((n) => ``; } const STYLE_FIELDS = [ @@ -706,6 +720,16 @@ function renderInspector() { const el = $("#inspector"); + if (multi.size > 1) { + const btn = (k, l) => ``; + el.innerHTML = `
${tr("{0} widgets selected", multi.size)}
+
${tr("Align")}
+
${btn("left", "Left edges")}${btn("right", "Right edges")}${btn("top", "Top edges")}${btn("bottom", "Bottom edges")}${btn("width", "Same width")}${btn("height", "Same height")}
+

${tr("Same width and height follow the widget you clicked last.")}

+
+

${tr("Shift-click or ⌘-click widgets to add or remove them. Drag one to move them all; the arrow keys move them too.")}

`; + return; + } const w = tab().widgets.find((x) => x.id === selectedId); if (w && childIndex != null && w.cfg.children?.[childIndex]) { // Editing one card inside a container: its own fields and style, with a way back. @@ -715,7 +739,7 @@ ${cdef.fields.filter((f) => !f.when || f.when(ch.cfg)).map((f) => fieldHTML(f, ch.cfg[f.k], "cfg")).join("")} ${w.type === "hscroll" ? fieldHTML({ k: "_width", t: "number", l: "Width of this card (px)", ph: "Same as the others" }, ch.cfg._width, "cfg") : ""}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, ch.style?.[f.k], "style")).join("")}
-
`; +
`; return; } if (w) { @@ -728,7 +752,8 @@ ${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}
${visibilityHTML(w)} -
`; +
+

${tr("Drag a widget onto a scroll or swipe card to put it inside.")}

`; fillAlbums(el); return; } @@ -740,9 +765,9 @@ ${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")} ${fieldHTML({ k: "style", t: "seg", l: "Menu style", o: ["icons", "labels"] }, current.nav.style || "icons", "nav")}
${current.tabs.map((t, i) => `
- + - + @@ -772,13 +797,23 @@
${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
${tr("Kiosk")}
${fieldHTML({ k: "saver", t: "number", l: "Screen saver after (minutes)", ph: "0 = off" }, k.saver, "kiosk")} + ${Number(k.saver) > 0 ? fieldHTML({ k: "saverStyle", t: "seg", l: "Screen saver", o: ["clock", "photos"] }, k.saverStyle, "kiosk") : ""} + ${Number(k.saver) > 0 && k.saverStyle === "photos" ? `${fieldHTML({ k: "saverSource", t: "seg", l: "Photos from", o: ["links", "immich"] }, k.saverSource, "kiosk")} + ${k.saverSource === "immich" ? fieldHTML({ k: "saverAlbums", t: "albums", l: "Immich albums" }, k.saverAlbums, "kiosk") : fieldHTML({ k: "saverUrls", t: "textarea", l: "Image URLs, one per line" }, k.saverUrls, "kiosk")} + ${fieldHTML({ k: "saverInterval", t: "number", l: "Change photo every (seconds)" }, k.saverInterval, "kiosk")}` : ""} ${fieldHTML({ k: "exitCorner", t: "select", l: "Exit corner (tap it 5 times to leave)", o: ["top-right", "top-left", "bottom-right", "bottom-left"] }, k.exitCorner, "kiosk")} ${fieldHTML({ k: "hideCursor", t: "check", l: "Hide the pointer when idle" }, k.hideCursor, "kiosk")} + ${fieldHTML({ k: "doorbell", t: "entity", l: "Doorbell", domains: ["binary_sensor", "event", "input_boolean", "switch"] }, k.doorbell, "kiosk")} + ${k.doorbell ? `${fieldHTML({ k: "doorbellCamera", t: "entity", l: "Camera to show", domains: ["camera"] }, k.doorbellCamera, "kiosk")} +
${fieldHTML({ k: "doorbellSecs", t: "number", l: "Close after (seconds)" }, k.doorbellSecs, "kiosk")}
+ ${fieldHTML({ k: "doorbellSound", t: "check", l: "Play a sound" }, k.doorbellSound, "kiosk")}` : ""} +

${tr("When the doorbell rings in kiosk mode, the camera fills the screen.")}

${tr("On a wall tablet, open kiosk in a new tab to hide the Home Assistant sidebar too.")}

-
${tr("Language")}
+
${tr("This device")}
${fieldHTML({ k: "lang", t: "select", l: "Language on this device", o: Object.keys(window.I18n.languages), labels: Object.fromEntries(Object.entries(window.I18n.languages).map(([c, n]) => [c, c === "auto" ? tr(n) : n])) }, window.I18n.preference(), "device")} + ${nightHTML()}
${tr("About")}

${tr("Select a widget to edit it.")}

`; + fillAlbums(el); + } + + function nightHTML() { + const n = nightCfg(); + return `${fieldHTML({ k: "mode", t: "seg", l: "Night mode", o: ["off", "sunset", "times"], labels: { off: tr("Off"), sunset: tr("After sunset"), times: tr("Between times") } }, n.mode, "night")} + ${n.mode === "times" ? `
${fieldHTML({ k: "from", t: "time", l: "From" }, n.from, "night")}${fieldHTML({ k: "to", t: "time", l: "Until" }, n.to, "night")}
` : ""} + ${n.mode !== "off" ? `${fieldHTML({ k: "dim", t: "number", l: "Dim the screen (%)" }, n.dim, "night")}${fieldHTML({ k: "dark", t: "check", l: "Switch to the dark theme" }, n.dark, "night")}` : ""} +

${tr("Night mode is saved on this device only, and is off while editing.")}

`; } /** Album pickers load their list from the server after the inspector is drawn. */ @@ -835,6 +879,15 @@ } else if (op === "del") { mutate(() => kids.splice(i, 1)); childIndex = null; + } else if (op === "out") { // back onto the dashboard as a widget of its own + const c = kids[i], def = Widgets[c.type]; + const nw = { ...c, w: Math.min(c.w || def.size[0], current.grid.cols), h: c.h || def.size[1] }; + delete nw.cfg._width; + Object.assign(nw, findSpot(nw.w, nw.h)); + mutate(() => { kids.splice(i, 1); tab().widgets.push(nw); }); + childIndex = null; + select(nw.id); + return; } renderInspector(); } @@ -846,7 +899,7 @@ const s = ha.states[r.entity]; const opts = [...new Set([s?.state, ...common].filter(Boolean))].map((o) => `