diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md
index 8825456..18c62e5 100644
--- a/dashboard_studio/CHANGELOG.md
+++ b/dashboard_studio/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 2.2.0
+
+- **Robot vacuum:** the robot drawn from above beside its dock, placed by what it does (charging on the dock, cleaning with its path behind it, on its way back), the last clean, and maintenance bars for the filter and brushes.
+- **Formula One:** Formula 1's own look, with the race in large type, a countdown, the weekend's sessions with the next one in red, and standings set like the timing tower with the team colours. Add the official logo yourself (it's a trademark, so it doesn't come with the app).
+- **Nine more themes:** Midnight, Espresso, Ember, Lagoon, Paper, Arctic, Moss, Sakura and Concrete.
+- **Each person's own look:** on **Admin → People**, give someone their own theme, a larger text size or their own language.
+- **Guest invites:** a link and a QR code that opens one dashboard for guests without a Home Assistant login, until the time you choose, on your home Wi-Fi. Turn guest links on in the app's Network settings first.
+- **Haven 2.2:** home screen widgets for a light, scene or thermostat; three quick settings tiles; several homes (a holiday home, your parents'); and it offers to update itself when Dashboard Studio brings a newer Haven.
+
## 2.1.0
- **Every card redesigned,** with a desktop, a tablet and a phone design that apply by themselves: compact with hover states on a computer; larger type and 44px buttons on tablets and wall panels; touch sizes in a tighter frame on phones, with rows that scroll sideways instead of wrapping. Narrow cards put their figures under the names.
diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md
index c4b99f2..cc2681c 100644
--- a/dashboard_studio/DOCS.md
+++ b/dashboard_studio/DOCS.md
@@ -19,14 +19,25 @@ Everyone with a login sees Dashboard Studio in the Home Assistant sidebar.
## The Admin screen
- **Dashboards:** every dashboard as a card, with who sees it. **Open** shows it, **Build** opens it in the builder, and **⋯** renames, duplicates, shows the version history, shares, exports or deletes it. At the top: a new dashboard, one made from your devices, importing a file, and importing one of Home Assistant's own dashboards.
-- **People:** everyone with a Home Assistant login. Admins see everything. For everyone else, choose **All dashboards** or **Only these** and tick the dashboards, plus the one that **Opens first**. **Everyone else** at the top sets this for people without their own choice, and for anyone added to Home Assistant later. **Preview** shows the dashboards exactly as that person gets them; **Stop preview** brings you back.
+- **People:** everyone with a Home Assistant login. Admins see everything. For everyone else, choose **All dashboards** or **Only these** and tick the dashboards, plus the one that **Opens first**. **Everyone else** at the top sets this for people without their own choice, and for anyone added to Home Assistant later. **Preview** shows the dashboards exactly as that person gets them; **Stop preview** brings you back. Each person can also get their own **Theme** (it replaces the dashboard's colours on their devices), a **Text** size (*Large* or *Extra large*, handy for grandparents) and a **Language**. A language chosen on a device itself (under **This device**) still comes first.
- **Devices:** every phone, tablet and computer that opened Dashboard Studio, with who used it, whether it was Haven or a browser, and when. Name a device, give it its own dashboard (that comes before the person's choice, handy for a wall tablet), have admins start it in kiosk mode, or forget it. **Send a message to the screens** puts a full-screen message on all screens in kiosk mode, or on one named device.
+- **Guests:** invites for people without a Home Assistant login (see below).
- **Haven app:** a QR code to download Haven on a phone, the address to enter in it, and how signing in works.
- **Activity:** who changed which dashboard, and when, with a shortcut to its versions.
- **About:** the version, what's new and the source code.
Screens pick up what an admin changes, a dashboard or who sees what, within a minute, without reloading.
+## Guest invites
+
+An invite is a link and a QR code that opens one dashboard on a guest's phone, without a Home Assistant login, until the time you choose: four hours, tonight, 24 hours, until after the weekend, or a week. Guests can only see and control what is on that dashboard; Dashboard Studio filters everything else out. Invites only work on your home Wi-Fi.
+
+1. Turn guest links on once: in Home Assistant, open **Settings → Apps → Dashboard Studio → Configuration**, and under **Network** give *Guest invite links* a port, for example 8199. Save.
+2. Make a dashboard for guests (lights, the thermostat, the TV), or use one you have.
+3. Open **Admin → Guests**, fill in who it's for, the dashboard and until when, and choose **Make invite**. Let your guests scan the QR code.
+
+Under **Address for the link**, use the address of Home Assistant on your home network, such as `homeassistant.local` or `192.168.1.20`. **End** stops an invite at once, also on phones that have it open.
+
## Haven, the Android app
Haven shows the dashboards and nothing else: no builder and no Admin screen, for admins too.
@@ -37,6 +48,11 @@ Haven shows the dashboards and nothing else: no builder and no Admin screen, for
From then on Haven opens straight on your dashboard, full screen with its own icon, and keeps you signed in. It works with plain http addresses too. Kiosk mode keeps the screen on and hides Android's bars. To connect to another Home Assistant: tap the phone button in the top bar (admins) or the kiosk corner five times (everyone else), then **Change address**. Haven needs Android 7 or newer. The earlier *Dashboard Studio* Android app is replaced by Haven; you can uninstall it.
+- **Several homes:** under **Change address**, **Add another home** connects Haven to another Home Assistant, such as a holiday home or your parents'. Each home keeps its own sign-in; tap a home to open it, × removes it.
+- **Home screen widgets:** long-press your home screen, choose **Widgets → Haven**, and pick a light, switch, scene or thermostat. A tap does what it does on the dashboard; a thermostat gets − and + for its temperature.
+- **Quick settings:** Haven offers three tiles for Android's pull-down menu. Edit the menu, drag a Haven tile in, and tap it to choose what it controls. Long-press a tile to choose again.
+- **Updates:** when Dashboard Studio is updated with a newer Haven, Haven offers to install it the next time it opens. Android asks once to allow installs from Haven.
+
## Building
1. Open **Dashboard Studio** in the Home Assistant sidebar.
@@ -65,7 +81,7 @@ More editing tools:
- **Cards for every screen:** each card has a desktop, a tablet and a phone design, applied by itself. On a computer with a mouse, cards are compact with hover states. On a tablet or wall panel (any touch screen, or a screen 641-1100px wide) the type is larger and every button, switch and key is at least 44px, so it can be read and used from across the room. On a phone the same touch sizes sit in a tighter frame, and rows such as a light's colours scroll sideways instead of wrapping. Within that, each card still adapts to its own size: a narrow card puts its figures under the names instead of beside them.
- **How cards look:** the answer is the largest thing on each card (the temperature, the battery, *Closed*), in thin, even figures with the unit in grey. Icons are plain outlines that take colour only when something is on or needs attention; status shows as a pale pill; buttons are flat.
- **Light and dark versions:** turn on **Switch to a dark or light version with the device** under a theme to use a matching dark version of a light theme when the device is in dark mode, and the other way round. *System* always does this.
-- **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*.
+- **Theme:** *System* follows your device's light or dark appearance. The other 23 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, Midnight (true black, for OLED screens), Espresso, Ember, Lagoon, Paper, Arctic, Moss, Sakura, Concrete, 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, 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.
@@ -172,11 +188,13 @@ For the TeamTracker integration: the next match with both teams, their logos, th
### Formula One
-For the F1 Sensor integration. Pick the *next race* sensor and the card finds the standings and results sensors itself. It shows the next race with a countdown and the times of every session in your own time zone, and tabs for the driver standings, the constructor standings and the last race's results.
+For the F1 Sensor integration. Pick the *next race* sensor and the card finds the standings and results sensors itself. It has Formula 1's own look: carbon black, F1 red and white, whatever your dashboard's theme (choose **Look → Theme** to use your theme instead). It shows the round, the race in large type, a countdown in days, hours and minutes, the weekend's sessions in your own time zone with the next one marked in red, and tabs for the drivers, the teams and the last race, set like the timing tower: position, the team's colour, the driver's three-letter code and the points.
+
+**The logo:** the official Formula 1 logo is a trademark, so it doesn't come with Dashboard Studio. To show it, put the logo file in Home Assistant's `www` folder (for example as `f1-logo.svg`) and enter `/local/f1-logo.svg` under **Logo image**. Without it, a red mark takes its place.
### Vacuum
-The card grows with its size. On a small card it's a compact row with the status, battery and buttons. On a bigger card, the robot sits in the middle with a battery ring around it, a pulse while it cleans and a bolt while it charges. Below it are tiles for battery, cleaned area, cleaning time and any extra sensors you add (such as filter or brush life), the suction power levels, and labelled buttons to start or pause, send it back to the dock and locate it. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
+The card shows the robot from above next to its dock, placed by what it is doing: on the dock with a green light while it charges, out on the floor with a dotted path behind it and a turning brush while it cleans, on its way back while it returns, and outlined in red when it is stuck. Above it are the state in words and the battery with a bar. Below it: the last clean (area and time), **Maintenance** with a bar for every sensor in percent you add under **More sensors** (filter, main brush, side brush; amber below 15%), the suction power levels, and buttons to start or pause, send it back to the dock and locate it. The smaller the card, the more it leaves out, from the bottom up. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
### Energy
diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml
index 1c7f646..16fe5ae 100644
--- a/dashboard_studio/config.yaml
+++ b/dashboard_studio/config.yaml
@@ -1,5 +1,5 @@
name: Dashboard Studio
-version: "2.1.0"
+version: "2.2.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
@@ -18,6 +18,10 @@ panel_title: Dashboard Studio
# Everyone with a login gets it in the sidebar; Dashboard Studio itself decides what each person sees.
panel_admin: false
homeassistant_api: true
+ports:
+ 8199/tcp: null
+ports_description:
+ 8199/tcp: Guest invite links (answers the local network only)
options:
immich_url: ""
immich_api_key: ""
diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py
index 68cabea..e105778 100644
--- a/dashboard_studio/server/main.py
+++ b/dashboard_studio/server/main.py
@@ -9,10 +9,12 @@ Assistant with a handful of fake entities.
"""
import asyncio
+import ipaddress
import json
import logging
import os
import re
+import secrets
import time
from pathlib import Path
@@ -187,11 +189,12 @@ async def ha_call(app, msg):
USERS_TTL = 60
+USERS_CACHE = {}
async def ha_users(app):
"""Home Assistant's people (not its system users), with whether each is an administrator. Cached for a minute."""
- cached = app.get("users_cache")
+ cached = USERS_CACHE.get("users")
if cached and time.time() - cached[0] < USERS_TTL:
return cached[1]
rows = MockHA.USERS_LIST() if MOCK else await ha_call(app, {"type": "config/auth/list"})
@@ -199,7 +202,7 @@ async def ha_users(app):
"admin": bool(u.get("is_owner")) or "system-admin" in (u.get("group_ids") or []),
"active": u.get("is_active", True)}
for u in rows if not u.get("system_generated")}
- app["users_cache"] = (time.time(), users)
+ USERS_CACHE["users"] = (time.time(), users)
return users
@@ -349,6 +352,11 @@ async def service_worker(request):
return web.Response(text=js, content_type="application/javascript", headers={"Cache-Control": "no-store"})
+async def haven_version(request):
+ """Which Haven this Dashboard Studio serves, so Haven can offer itself the update."""
+ return web.FileResponse(WEB_DIR / "haven.json", headers={"Content-Type": "application/json", "Cache-Control": "no-store"})
+
+
async def haven_app(request):
"""Haven, the Android app for dashboards (built from haven/ in the repository), to download on a phone."""
return web.FileResponse(WEB_DIR / "haven.apk", headers={
@@ -384,6 +392,7 @@ async def get_dashboards(request):
"dashboards": [d for d in store.get("dashboards", []) if d.get("id") in ids],
"assign": {"devices": {request.query["device"]: dev} if dev else {}, "users": {}},
"access": {"me": access_of(store, me.get("id"))},
+ "look": (store.get("looks") or {}).get(me.get("id")) or {},
"role": "basic",
}, headers={"Cache-Control": "no-store"})
@@ -450,12 +459,15 @@ async def activity(request):
async def camera(request):
- entity_id = request.match_info["entity_id"]
+ return await camera_image(request.app, request.match_info["entity_id"])
+
+
+async def camera_image(app, entity_id):
if not entity_id.startswith("camera.") or "/" in entity_id:
return web.Response(status=400)
if MOCK:
- return web.Response(body=request.app["mock"].camera_svg(entity_id), content_type="image/svg+xml")
- session = request.app["http"]
+ return web.Response(body=app["mock"].camera_svg(entity_id), content_type="image/svg+xml")
+ session = app["http"]
async with session.get(
f"{HA_API_URL}/camera_proxy/{entity_id}",
headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"},
@@ -495,28 +507,39 @@ def denied(req):
"error": {"code": "unauthorized", "message": "Only Home Assistant administrators can do this"}}
-async def websocket(request):
- client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
- await client.prepare(request)
+class GuardedSocket:
+ """The browser's socket as the mock Home Assistant sees it, with an outbound filter in between."""
+ def __init__(self, ws, shape):
+ self.ws, self.shape = ws, shape
- allow = None if await role_of(request) == "admin" else allowed_for_basic
+ def __aiter__(self):
+ return self.ws.__aiter__()
+
+ async def send_json(self, data):
+ data = self.shape(data)
+ if data is not None:
+ await self.ws.send_json(data)
+
+ def __getattr__(self, name):
+ return getattr(self.ws, name)
+
+
+async def serve_ha(app, client, allow=None, shape=None):
+ """Connect a browser socket to Home Assistant. `allow` vets what the browser asks; `shape` filters what comes back."""
if MOCK:
- await request.app["mock"].serve(client, allow)
- return client
+ await app["mock"].serve(GuardedSocket(client, shape) if shape else client, allow)
+ return
if not SUPERVISOR_TOKEN:
await client.send_json({"type": "error", "message": "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it."})
await client.close()
- return client
-
- session = request.app["http"]
+ return
try:
- upstream = await session.ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0)
+ upstream = await app["http"].ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0)
except aiohttp.ClientError as err:
LOG.error("Cannot reach Home Assistant websocket: %s", err)
await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"})
await client.close()
- return client
-
+ return
try:
msg = await upstream.receive_json() # auth_required
await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN})
@@ -524,34 +547,275 @@ async def websocket(request):
if msg.get("type") != "auth_ok":
LOG.error("Home Assistant rejected the Supervisor token: %s", msg)
await client.send_json({"type": "error", "message": "Home Assistant auth failed"})
- return client
+ return
await client.send_json({"type": "ready", "ha_version": msg.get("ha_version")})
- async def pump(src, dst, check=None):
- async for m in src:
+ async def inbound():
+ async for m in client:
if m.type == aiohttp.WSMsgType.TEXT:
- if check:
+ if allow:
try:
req = json.loads(m.data)
except json.JSONDecodeError:
continue
- if not isinstance(req, dict) or not check(req):
+ if not isinstance(req, dict) or not allow(req):
await client.send_json(denied(req if isinstance(req, dict) else {}))
continue
- await dst.send_str(m.data)
+ await upstream.send_str(m.data)
elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
break
- tasks = [asyncio.create_task(pump(client, upstream, allow)), asyncio.create_task(pump(upstream, client))]
+ async def outbound():
+ async for m in upstream:
+ if m.type == aiohttp.WSMsgType.TEXT:
+ if shape:
+ out = shape(json.loads(m.data))
+ if out is not None:
+ await client.send_json(out)
+ else:
+ await client.send_str(m.data)
+ elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
+ break
+
+ tasks = [asyncio.create_task(inbound()), asyncio.create_task(outbound())]
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
finally:
await upstream.close()
await client.close()
+
+
+async def websocket(request):
+ client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
+ await client.prepare(request)
+ await serve_ha(request.app, client, None if await role_of(request) == "admin" else allowed_for_basic)
return client
+# ---------------------------------------------------------------- guest links
+# An admin makes an invite: a link with a code to one dashboard, until a set time. It is served on a second
+# port that only answers the local network, and it shows that dashboard in kiosk mode. A guest can read and
+# control only the entities on that dashboard: everything else is filtered out in both directions.
+INVITES_FILE = DATA_DIR / "invites.json"
+GUEST_PORT = int(os.environ.get("GUEST_PORT", "8199"))
+ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
+
+
+def load_invites():
+ now = time.time()
+ return {k: v for k, v in load_json(INVITES_FILE, {}).items() if v.get("until", 0) > now - 7 * 86400} # ended ones linger a week
+
+
+def entities_in(obj, found=None):
+ """Every entity ID (and service name) mentioned anywhere in a dashboard."""
+ found = set() if found is None else found
+ if isinstance(obj, dict):
+ for v in obj.values():
+ entities_in(v, found)
+ elif isinstance(obj, list):
+ for v in obj:
+ entities_in(v, found)
+ elif isinstance(obj, str) and ENTITY_ID.match(obj):
+ found.add(obj)
+ return found
+
+
+def guest_policy(allowed):
+ """What a guest may ask Home Assistant, and what it may hear back."""
+ asked = {}
+
+ def ids_of(req):
+ out = []
+ for src in (req.get("target") or {}, req.get("service_data") or {}):
+ v = src.get("entity_id")
+ out += [v] if isinstance(v, str) else list(v or [])
+ return out
+
+ def allow(req):
+ t = req.get("type")
+ asked[req.get("id")] = t
+ if t in ("get_states", "get_config", "ping", "unsubscribe_events", "config/area_registry/list", "config/entity_registry/list", "config/device_registry/list"):
+ return True
+ if t == "subscribe_events":
+ return req.get("event_type") in ("state_changed", "dashboard_studio_announce")
+ if t == "call_service":
+ ids = ids_of(req)
+ if req.get("domain") in BLOCKED_DOMAINS or req.get("domain") == "homeassistant" and req.get("service") not in ("toggle", "turn_on", "turn_off"):
+ return False
+ return all(i in allowed for i in ids) if ids else f"{req.get('domain')}.{req.get('service')}" in allowed
+ if t == "todo/item/list":
+ return req.get("entity_id") in allowed
+ if t == "history/history_during_period":
+ return all(i in allowed for i in req.get("entity_ids") or [])
+ if t == "recorder/statistics_during_period":
+ return all(i in allowed for i in req.get("statistic_ids") or [])
+ return False
+
+ def shape(msg):
+ if not isinstance(msg, dict):
+ return msg
+ kind = asked.get(msg.get("id"))
+ if msg.get("type") == "result" and isinstance(msg.get("result"), list):
+ if kind == "get_states":
+ msg["result"] = [x for x in msg["result"] if x.get("entity_id") in allowed]
+ elif kind == "config/entity_registry/list":
+ msg["result"] = [x for x in msg["result"] if x.get("entity_id") in allowed]
+ if msg.get("type") == "event":
+ ev = msg.get("event") or {}
+ if ev.get("event_type") == "state_changed" and (ev.get("data") or {}).get("entity_id") not in allowed:
+ return None
+ return msg
+
+ return allow, shape
+
+
+def lan_only(handler):
+ """Guest links answer the local network only."""
+ async def wrapped(request):
+ try:
+ ip = ipaddress.ip_address((request.remote or "").split("%")[0])
+ except ValueError:
+ return web.Response(status=403, text="Guest links work on the local network only")
+ if not (ip.is_private or ip.is_loopback or ip.is_link_local):
+ return web.Response(status=403, text="Guest links work on the local network only")
+ return await handler(request)
+ return wrapped
+
+
+ENDED_PAGE = """
+
${tr("An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.")}
+ ${data.port ? "" : `
${Icons.svg("wifi")}${tr("Turn on guest links first")}
+
${tr("In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.")}
`}
+
${Icons.svg("plus")}${tr("New invite")}
+
+
+
+
+
+
+
${tr("The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.")}
+ ${fresh ? `
${window.QR.svg(link(fresh.token), { label: tr("QR code for the invite") })}