Files
ha-dashboard-studio/dashboard_studio/server/main.py
T
veenm 297fb2a7a0 Dashboard Studio 1.14.1: Android app, phone menu button, install fix
- Android app (android/, built with build.sh): signs in to Home Assistant once, opens Dashboard Studio full screen, renews the ingress session; downloadable from Install as an app
- Phone dashboards: a round menu button brings back the top bar with Edit, Kiosk and Install as an app
- Install as an app explains what works per device (Android app, https for browser install, iPhone limits)
- Fixed: browser install failed because the manifest was fetched without the Home Assistant sign-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:34:52 +01:00

405 lines
15 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):
"""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()
# 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"
def who(request):
"""The Home Assistant user, from the headers the Supervisor's ingress proxy adds."""
if MOCK:
return {"id": os.environ.get("MOCK_USER_ID", "mock-user-alex"), "name": "alex", "display": "Alex"}
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
def load_users():
try:
return json.loads(USERS_FILE.read_text())
except (OSError, json.JSONDecodeError):
return {}
async def whoami(request):
me = who(request)
if me: # remember who has used the app, so dashboards can be assigned to them
users = load_users()
if users.get(me["id"], {}).get("display") != me["display"] or me["id"] not in users:
users[me["id"]] = {"name": me["name"], "display": me["display"]}
DATA_DIR.mkdir(parents=True, exist_ok=True)
USERS_FILE.write_text(json.dumps(users, indent=2))
return web.json_response(me or {}, headers={"Cache-Control": "no-store"})
async def users(request):
return web.json_response([{"id": k, **v} for k, v in load_users().items()], headers={"Cache-Control": "no-store"})
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 android_app(request):
"""The Android app (built from android/ in the repository), to download on a phone."""
return web.FileResponse(WEB_DIR / "dashboard-studio.apk", headers={
"Content-Type": "application/vnd.android.package-archive",
"Content-Disposition": 'attachment; filename="Dashboard Studio.apk"', "Cache-Control": "no-store"})
async def get_dashboards(request):
return web.json_response(load_store())
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)
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:
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"},
)
async def websocket(request):
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
await client.prepare(request)
if MOCK:
await request.app["mock"].serve(client)
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):
async for m in src:
if m.type == aiohttp.WSMsgType.TEXT:
await dst.send_str(m.data)
elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
break
tasks = [asyncio.create_task(pump(client, upstream)), 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("/android.apk", android_app)
app.router.add_get("/api/changelog", changelog)
app.router.add_get("/api/whoami", whoami)
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)
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)