"""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/.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)