3d958d0411
- 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>
604 lines
24 KiB
Python
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)
|