fa3e58b747
- Version shown under About in the dashboard settings, with a link to the repository; /api/version reports it - index.html is never cached and stamps the version on every asset URL, so browsers can't keep running old files after an update - Open screens check the version after reconnecting and every 5 minutes and reload into the new version (editing only shows a message); pending saves are flushed first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
8.3 KiB
Python
234 lines
8.3 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
|
|
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)
|
|
|
|
|
|
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()
|
|
html = re.sub(r'(static/[\w.-]+\.(?:js|css|png))"', rf'\1?v={VERSION}"', html)
|
|
html = html.replace("__VERSION__", VERSION)
|
|
return web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
async def version(request):
|
|
return web.json_response({"version": VERSION}, headers={"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)
|
|
save_store(data)
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
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("/api/dashboards", get_dashboards)
|
|
app.router.add_put("/api/dashboards", put_dashboards)
|
|
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)
|