Files
ha-dashboard-studio/dashboard_studio/server/main.py
T
veenm 3d958d0411 Dashboard Studio 2.0.0: Admin, Builder and Dashboard screens, roles, Haven
- Home Assistant administrators are admins: Dashboard, Builder and Admin screens
- Everyone else only sees the dashboards an admin chose, always in kiosk mode;
  the server enforces it (saving, people, devices, history are admin-only, and
  the websocket only allows reading states and controlling devices)
- Admin screen: dashboards, people and what each sees (with preview), devices,
  messages to the screens, the Haven app, activity, about
- Haven: new Android app (haven/) that only shows dashboards; replaces the
  Dashboard Studio Android app and signs in through Home Assistant's page
- Screens pick up dashboard and access changes within a minute
- Version history records who made each change; panel visible to all users

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:20:55 +01:00

604 lines
24 KiB
Python

"""Dashboard Studio backend.
Serves the editor UI over Home Assistant ingress, stores dashboards in /data,
and proxies the Home Assistant websocket API using the Supervisor token so the
browser never needs its own long-lived access token.
Without a SUPERVISOR_TOKEN (local development) it runs against a mock Home
Assistant with a handful of fake entities.
"""
import asyncio
import json
import logging
import os
import re
import time
from pathlib import Path
import aiohttp
from aiohttp import web
from immich import Immich
from mock_ha import MockHA
LOG = logging.getLogger("dashboard_studio")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
SUPERVISOR_TOKEN = os.environ.get("SUPERVISOR_TOKEN")
# Inside Home Assistant the Supervisor always writes /data/options.json. Fake data is
# only for local development, never a silent fallback on a real install.
IN_HOME_ASSISTANT = Path("/data/options.json").exists()
MOCK = not SUPERVISOR_TOKEN and not IN_HOME_ASSISTANT
HA_WS_URL = "ws://supervisor/core/websocket"
HA_API_URL = "http://supervisor/core/api"
INGRESS_PROXY_IP = "172.30.32.2"
APP_DIR = Path(__file__).resolve().parent.parent
WEB_DIR = APP_DIR / "web"
def read_version():
"""The app version from config.yaml (copied into the image next to server/ and web/)."""
try:
m = re.search(r'^version:\s*"?([^"\n]+)"?', (APP_DIR / "config.yaml").read_text(), re.M)
return m.group(1).strip() if m else "dev"
except OSError:
return "dev"
VERSION = read_version()
DATA_DIR = Path("/data") if Path("/data").is_dir() else Path(__file__).resolve().parent.parent / "data"
STORE_FILE = DATA_DIR / "dashboards.json"
PORT = int(os.environ.get("PORT", "8099"))
def load_options():
"""App options from the Configuration tab (/data/options.json), or env vars when developing."""
opts = {}
path = Path("/data/options.json")
if path.exists():
try:
opts = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
LOG.exception("Could not read %s", path)
return {
"immich_url": opts.get("immich_url") or os.environ.get("IMMICH_URL", ""),
"immich_api_key": opts.get("immich_api_key") or os.environ.get("IMMICH_API_KEY", ""),
}
OPTIONS = load_options()
@web.middleware
async def ingress_only(request, handler):
# Only the Supervisor ingress proxy may talk to us; it has already
# authenticated the Home Assistant user.
if not MOCK and request.remote != INGRESS_PROXY_IP:
return web.Response(status=403, text="Access only via Home Assistant ingress")
response = await handler(request)
# Revalidate the UI files so an add-on update shows up immediately.
if isinstance(response, web.FileResponse):
response.headers.setdefault("Cache-Control", "no-cache")
return response
def load_store():
if STORE_FILE.exists():
try:
return json.loads(STORE_FILE.read_text())
except (OSError, json.JSONDecodeError):
LOG.exception("Could not read %s, starting empty", STORE_FILE)
return {"version": 1, "dashboards": []}
def save_store(data):
DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = STORE_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2))
tmp.replace(STORE_FILE)
# 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, by=""):
"""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 and entries[-1].get("by", "") == by:
entries[-1] = {"at": now, "data": d, "by": by}
else:
entries.append({"at": now, "data": d, "by": by})
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()
# Locally, files change without a new version number: stamp them with their last change instead.
stamp = VERSION if not MOCK else f"{VERSION}-{int(max(f.stat().st_mtime for f in WEB_DIR.iterdir()))}"
html = re.sub(r'(static/[\w.-]+\.(?:js|css|png))"', rf'\1?v={stamp}"', html)
html = html.replace("__VERSION__", VERSION)
return web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store"})
USERS_FILE = DATA_DIR / "users.json"
DEVICES_FILE = DATA_DIR / "devices.json"
def who(request):
"""The Home Assistant user, from the headers the Supervisor's ingress proxy adds."""
if MOCK: # locally, a cookie picks one of the mock users, to try the app as someone else
uid = request.cookies.get("mock_user") or os.environ.get("MOCK_USER_ID", "mock-user-alex")
u = MockHA.USERS.get(uid) or MockHA.USERS["mock-user-alex"]
return {"id": uid if uid in MockHA.USERS else "mock-user-alex", "name": u["username"], "display": u["name"]}
h = request.headers
uid = h.get("X-Remote-User-Id")
return {"id": uid, "name": h.get("X-Remote-User-Name", ""), "display": h.get("X-Remote-User-Display-Name", "")} if uid else None
async def ha_call(app, msg):
"""One request to Home Assistant's websocket API with the Supervisor token."""
async with app["http"].ws_connect(HA_WS_URL, max_msg_size=0) as ws:
await ws.receive_json() # auth_required
await ws.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN})
if (await ws.receive_json()).get("type") != "auth_ok":
raise RuntimeError("Home Assistant rejected the Supervisor token")
await ws.send_json({"id": 1, **msg})
while True:
r = await ws.receive_json()
if r.get("id") == 1:
break
if not r.get("success"):
raise RuntimeError((r.get("error") or {}).get("message", "request failed"))
return r["result"]
USERS_TTL = 60
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")
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"})
users = {u["id"]: {"id": u["id"], "name": u.get("name") or u.get("username") or "", "username": u.get("username") or "",
"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)
return users
async def role_of(request):
""""admin" for Home Assistant administrators, "basic" for everyone else."""
if "role" in request:
return request["role"]
me = who(request)
role = "basic"
if me:
try:
role = "admin" if (await ha_users(request.app)).get(me["id"], {}).get("admin") else "basic"
except Exception: # noqa: BLE001 - never let a failed lookup make someone an admin
LOG.exception("Could not look up Home Assistant's users")
request["role"] = role
return role
def admin_only(handler):
async def wrapped(request):
if await role_of(request) != "admin":
return web.json_response({"error": "Only Home Assistant administrators can do this"}, status=403)
return await handler(request)
return wrapped
def load_json(path, default):
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return default
def save_json(path, data):
DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2))
tmp.replace(path)
def load_users():
return load_json(USERS_FILE, {})
async def whoami(request):
me = who(request)
if me: # remember who has used the app and when
users = load_users()
prev = users.get(me["id"], {})
if prev.get("display") != me["display"] or time.time() - prev.get("seen", 0) > 300:
users[me["id"]] = {"name": me["name"], "display": me["display"], "seen": time.time()}
save_json(USERS_FILE, users)
return web.json_response({**(me or {}), "admin": await role_of(request) == "admin"}, headers={"Cache-Control": "no-store"})
@admin_only
async def people(request):
"""Everyone with a Home Assistant login, for choosing what they see."""
seen = load_users()
try:
users = await ha_users(request.app)
except Exception as err: # noqa: BLE001
return web.json_response({"error": f"Couldn't read Home Assistant's users: {err}"}, status=502)
out = [{**u, "seen": seen.get(u["id"], {}).get("seen")} for u in users.values() if u["active"]]
return web.json_response(sorted(out, key=lambda u: (not u["admin"], u["name"].lower())), headers={"Cache-Control": "no-store"})
SAFE_DEVICE = re.compile(r"^[\w-]{4,64}$")
async def device_seen(request):
"""Every device reports in when it opens, so an admin can give it a dashboard."""
try:
body = await request.json()
except json.JSONDecodeError:
return web.Response(status=400)
dev = str(body.get("device", ""))
if not SAFE_DEVICE.match(dev):
return web.Response(status=400)
me = who(request) or {}
devices = load_json(DEVICES_FILE, {})
devices[dev] = {"user": me.get("id"), "display": me.get("display", ""), "seen": time.time(),
"app": "haven" if body.get("app") == "haven" else "browser", "kind": str(body.get("kind", ""))[:20]}
if len(devices) > 200: # forget the longest-unseen ones
devices = dict(sorted(devices.items(), key=lambda kv: kv[1].get("seen", 0))[-200:])
save_json(DEVICES_FILE, devices)
return web.json_response({"ok": True})
@admin_only
async def get_devices(request):
return web.json_response(load_json(DEVICES_FILE, {}), headers={"Cache-Control": "no-store"})
@admin_only
async def forget_device(request):
dev = request.match_info["device"]
devices = load_json(DEVICES_FILE, {})
devices.pop(dev, None)
save_json(DEVICES_FILE, devices)
return web.json_response({"ok": True})
async def changelog(request):
try:
text = (APP_DIR / "CHANGELOG.md").read_text()
except OSError:
text = ""
return web.Response(text=text, content_type="text/markdown", headers={"Cache-Control": "no-store"})
async def version(request):
return web.json_response({"version": VERSION}, headers={"Cache-Control": "no-store"})
def all_widgets(widgets):
for w in widgets or []:
if isinstance(w, dict):
yield w
yield from all_widgets((w.get("cfg") or {}).get("children"))
async def manifest(request):
"""The web app manifest, so the dashboard can be installed on a phone's home screen.
Widgets marked as a shortcut appear when you long-press the app icon (Android)."""
shortcuts = []
for d in load_store().get("dashboards", []):
for t in d.get("tabs", []):
for w in all_widgets(t.get("widgets")):
if w.get("shortcut") and len(shortcuts) < 4:
shortcuts.append({"name": str(w["shortcut"])[:40], "url": f"./?ds_action={w.get('id')}",
"icons": [{"src": "static/icon-192.png", "sizes": "192x192", "type": "image/png"}]})
body = {
"name": "Dashboard Studio", "short_name": "Dashboards", "id": "./", "start_url": "./", "scope": "./",
"display": "standalone", "background_color": "#0f1526", "theme_color": "#0f1526",
"icons": [{"src": "static/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "static/icon-512.png", "sizes": "512x512", "type": "image/png"},
{"src": "static/app-icon.svg", "sizes": "any", "type": "image/svg+xml"}],
"shortcuts": shortcuts,
}
return web.json_response(body, content_type="application/manifest+json", headers={"Cache-Control": "no-store"})
async def service_worker(request):
"""Network first; the last good copy of the page, its files and the dashboards when the network fails."""
js = (WEB_DIR / "sw.js").read_text().replace("__VERSION__", VERSION)
return web.Response(text=js, content_type="application/javascript", headers={"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={
"Content-Type": "application/vnd.android.package-archive",
"Content-Disposition": 'attachment; filename="Haven.apk"', "Cache-Control": "no-store"})
def access_of(store, uid):
"""What a person may see: {"all": true} or {"dashboards": [...]}, plus the one to open first ("home")."""
acc = store.get("access") or {}
mine = (acc.get("users") or {}).get(uid) if uid else None
return mine if isinstance(mine, dict) else acc.get("everyone") or {"all": True}
def visible_ids(store, uid, device):
ids = [d.get("id") for d in store.get("dashboards", []) if isinstance(d, dict)]
entry = access_of(store, uid)
seen = ids if entry.get("all") else [i for i in ids if i in (entry.get("dashboards") or [])]
dev = ((store.get("assign") or {}).get("devices") or {}).get(device or "") or {}
if dev.get("dashboard") in ids and dev["dashboard"] not in seen: # an admin put it on this screen
seen.append(dev["dashboard"])
return seen, dev
async def get_dashboards(request):
store = load_store()
if await role_of(request) == "admin":
return web.json_response({**store, "role": "admin"}, headers={"Cache-Control": "no-store"})
me = who(request) or {}
ids, dev = visible_ids(store, me.get("id"), request.query.get("device"))
return web.json_response({
"version": store.get("version", 1),
"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"))},
"role": "basic",
}, headers={"Cache-Control": "no-store"})
@admin_only
async def put_dashboards(request):
try:
data = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "invalid json"}, status=400)
if not isinstance(data, dict) or not isinstance(data.get("dashboards"), list):
return web.json_response({"error": "expected {dashboards: [...]}"}, status=400)
data.pop("role", None)
try:
record_history(load_store(), data, (who(request) or {}).get("display", ""))
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))
@admin_only
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"]), "by": e.get("by", "")}
for e in reversed(entries)
], headers={"Cache-Control": "no-store"})
@admin_only
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)
@admin_only
async def activity(request):
"""Who changed which dashboard, newest first, from the version history."""
names = {d.get("id"): d.get("name", "") for d in load_store().get("dashboards", []) if isinstance(d, dict)}
rows = []
for f in HISTORY_DIR.glob("*.json") if HISTORY_DIR.is_dir() else []:
for e in load_json(f, [])[1:]: # the first entry is how it was before history started
rows.append({"at": e["at"], "dashboard": f.stem, "name": names.get(f.stem) or e["data"].get("name", ""),
"by": e.get("by", ""), "deleted": f.stem not in names})
rows.sort(key=lambda r: r["at"], reverse=True)
return web.json_response(rows[:60], headers={"Cache-Control": "no-store"})
async def camera(request):
entity_id = request.match_info["entity_id"]
if not entity_id.startswith("camera.") or "/" in entity_id:
return web.Response(status=400)
if MOCK:
return web.Response(body=request.app["mock"].camera_svg(entity_id), content_type="image/svg+xml")
session = request.app["http"]
async with session.get(
f"{HA_API_URL}/camera_proxy/{entity_id}",
headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"},
) as resp:
body = await resp.read()
return web.Response(
status=resp.status,
body=body,
content_type=resp.content_type or "image/jpeg",
headers={"Cache-Control": "no-store"},
)
# What people who aren't administrators may ask Home Assistant through this app: reading states and
# controlling devices, as they could in Home Assistant itself, but nothing that changes its setup.
BASIC_WS = {"get_states", "get_config", "get_services", "unsubscribe_events", "persistent_notification/subscribe",
"config/area_registry/list", "config/entity_registry/list", "config/device_registry/list", "todo/item/list",
"history/history_during_period", "recorder/statistics_during_period", "energy/solar_forecast", "ping"}
BASIC_EVENTS = {"state_changed", "dashboard_studio_announce"}
BLOCKED_DOMAINS = {"hassio", "recorder", "logger", "system_log", "backup", "lovelace", "frontend", "cloud", "auth", "zone", "person"}
def allowed_for_basic(req):
t = req.get("type")
if t == "subscribe_events":
return req.get("event_type") in BASIC_EVENTS
if t == "call_service":
domain, service = req.get("domain"), req.get("service")
if domain in BLOCKED_DOMAINS:
return False
return service in {"toggle", "turn_on", "turn_off", "update_entity"} if domain == "homeassistant" else True
return t in BASIC_WS
def denied(req):
return {"id": req.get("id"), "type": "result", "success": False,
"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)
allow = None if await role_of(request) == "admin" else allowed_for_basic
if MOCK:
await request.app["mock"].serve(client, allow)
return client
if not SUPERVISOR_TOKEN:
await client.send_json({"type": "error", "message": "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it."})
await client.close()
return client
session = request.app["http"]
try:
upstream = await session.ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0)
except aiohttp.ClientError as err:
LOG.error("Cannot reach Home Assistant websocket: %s", err)
await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"})
await client.close()
return client
try:
msg = await upstream.receive_json() # auth_required
await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN})
msg = await upstream.receive_json()
if msg.get("type") != "auth_ok":
LOG.error("Home Assistant rejected the Supervisor token: %s", msg)
await client.send_json({"type": "error", "message": "Home Assistant auth failed"})
return client
await client.send_json({"type": "ready", "ha_version": msg.get("ha_version")})
async def pump(src, dst, check=None):
async for m in src:
if m.type == aiohttp.WSMsgType.TEXT:
if check:
try:
req = json.loads(m.data)
except json.JSONDecodeError:
continue
if not isinstance(req, dict) or not check(req):
await client.send_json(denied(req if isinstance(req, dict) else {}))
continue
await dst.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))]
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
finally:
await upstream.close()
await client.close()
return client
async def on_startup(app):
app["http"] = aiohttp.ClientSession()
await app["immich"].start()
if MOCK:
app["mock"] = MockHA()
app["mock_task"] = asyncio.create_task(app["mock"].simulate())
LOG.warning("No SUPERVISOR_TOKEN: running against mock Home Assistant")
async def on_cleanup(app):
await app["http"].close()
await app["immich"].close()
if MOCK:
app["mock_task"].cancel()
def make_app():
app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024)
app.router.add_get("/", index)
app.router.add_get("/api/version", version)
app.router.add_get("/manifest.webmanifest", manifest)
app.router.add_get("/sw.js", service_worker)
app.router.add_get("/haven.apk", haven_app)
app.router.add_get("/api/changelog", changelog)
app.router.add_get("/api/whoami", whoami)
app.router.add_get("/api/people", people)
app.router.add_post("/api/devices/seen", device_seen)
app.router.add_get("/api/devices", get_devices)
app.router.add_delete("/api/devices/{device}", forget_device)
app.router.add_get("/api/activity", activity)
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)
app.router.add_routes(app["immich"].routes())
app.router.add_static("/static", WEB_DIR)
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
return app
if __name__ == "__main__":
if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN:
LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant")
LOG.info("Dashboard Studio %s listening on port %s (data in %s)", VERSION, PORT, DATA_DIR)
web.run_app(make_app(), port=PORT, access_log=None)