/* Dashboard Studio editor + runtime. */ (function () { const { esc, domainOf, isOn, friendly, fmtState, relTime, clockHTML, cap } = window.HAFmt; const Widgets = window.Widgets; const Icons = window.Icons; const tr = window.I18n.t, LOCALE = window.I18n.locale; // Filled in by the server; "dev" when index.html is opened some other way. const APP_VERSION = (document.querySelector('meta[name="ds-version"]')?.content || "dev").replace("__VERSION__", "dev"); const REPO_URL = "https://git.melvanveen.nl/veenm/ha-dashboard-studio"; /** Option labels: a direct translation when there is one, else the capitalised value. */ const optLabel = (o) => (tr(o) !== o ? tr(o) : tr(cap(String(o).replace("-", " ")))); const $ = (s, el = document) => el.querySelector(s); const uid = () => Math.random().toString(36).slice(2, 10); const clone = (o) => JSON.parse(JSON.stringify(o)); // Ingress serves us under /api/hassio_ingress// — keep every URL relative to that. const BASE = new URL(location.pathname.endsWith("/") ? location.pathname : location.pathname + "/", location.origin); const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } }; const prefersDark = () => matchMedia("(prefers-color-scheme: dark)").matches; const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches; // ------------------------------------------------------------ HA connection class HA { constructor() { this.states = {}; this.nextId = 1; this.pending = new Map(); this.connected = false; this.onChange = () => {}; this.onStatus = () => {}; this.onRegistry = () => {}; this.ready = new Promise((r) => (this._markReady = r)); } connect() { const url = new URL("ws", BASE); url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; const ws = (this.ws = new WebSocket(url)); ws.onmessage = (ev) => this.handle(JSON.parse(ev.data)); ws.onclose = () => { this._reg = null; if (this.connected) this.ready = new Promise((r) => (this._markReady = r)); this.connected = false; this.onStatus(false); for (const p of this.pending.values()) p.reject?.(new Error("disconnected")); this.pending.clear(); setTimeout(() => this.connect(), 3000); }; } send(msg, onEvent) { const id = this.nextId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject, onEvent }); this.ws.send(JSON.stringify({ ...msg, id })); }); } async handle(m) { if (m.type === "ready") { const states = await this.send({ type: "get_states" }); this.states = Object.fromEntries(states.map((s) => [s.entity_id, s])); this.send({ type: "subscribe_events", event_type: "state_changed" }, (ev) => { if (ev.event_type && ev.event_type !== "state_changed") return; const { entity_id, new_state } = ev.data; if (new_state) this.states[entity_id] = new_state; else delete this.states[entity_id]; this.onChange(entity_id); }); this.connected = true; this._markReady(); this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card // Home Assistant's notifications, kept current for the notifications card. this.send({ type: "persistent_notification/subscribe" }, (ev) => { if (ev.type === "current") this.notifications = { ...ev.notifications }; else if (ev.type === "removed") for (const id of Object.keys(ev.notifications || {})) delete this.notifications[id]; else this.notifications = { ...(this.notifications || {}), ...ev.notifications }; this.onChange("__notifications"); }).catch(() => {}); // Announcements: an automation fires this event to put a message on the screens. this.send({ type: "subscribe_events", event_type: "dashboard_studio_announce" }, (ev) => { if (ev.event_type === "dashboard_studio_announce") this.onAnnounce?.(ev.data || {}); }).catch(() => {}); this.onStatus(true); this.onChange(null); return; } if (m.type === "error" && m.id == null) { // The server closes and we reconnect every few seconds; don't repeat the same message. if (m.message !== this.lastError || Date.now() - this.lastErrorAt > 60000) toast(m.message, true); this.lastError = m.message; this.lastErrorAt = Date.now(); return; } const p = this.pending.get(m.id); if (!p) return; if (m.type === "event") { p.onEvent?.(m.event); return; } if (m.type === "result") { if (!p.onEvent) this.pending.delete(m.id); if (m.success) p.resolve(m.result); else p.reject(new Error(m.error?.message || "Request failed")); } } callService(domain, service, data = {}, entityId) { if (!this.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return Promise.resolve(); } const msg = { type: "call_service", domain, service, service_data: data }; if (entityId) msg.target = { entity_id: entityId }; return this.send(msg).catch((e) => toast(tr("{0} failed: {1}", `${domain}.${service}`, e.message), true)); } /** Events from calendar entities, via calendar.get_events with return_response. */ async calendarEvents(entityIds, start, end) { await this.ready; const res = await this.send({ type: "call_service", domain: "calendar", service: "get_events", target: { entity_id: entityIds }, service_data: { start_date_time: start.toISOString(), end_date_time: end.toISOString() }, return_response: true, }); return res?.response || {}; } /** Areas (rooms) and which entities are in them, from the area, entity and device registries. */ registry() { this._reg ||= Promise.all([ this.send({ type: "config/area_registry/list" }), this.send({ type: "config/entity_registry/list" }), this.send({ type: "config/device_registry/list" }), ]).then(([areas, ents, devs]) => { const devArea = Object.fromEntries((devs || []).map((d) => [d.id, d.area_id])); const byArea = {}; const deviceOf = {}, byDevice = {}; for (const e of ents || []) { if (e.disabled_by) continue; if (e.device_id) { deviceOf[e.entity_id] = e.device_id; (byDevice[e.device_id] ||= []).push(e.entity_id); } if (e.hidden_by || e.entity_category) continue; // rooms skip config/diagnostic and hidden entities const area = e.area_id || devArea[e.device_id]; if (area) (byArea[area] ||= []).push(e.entity_id); } this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea, deviceOf, byDevice }; return this.reg; }).catch((err) => { this._reg = null; throw err; }); return this._reg; } /** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */ async forecast(entityId, type = "daily") { await this.ready; const res = await this.send({ type: "call_service", domain: "weather", service: "get_forecasts", target: { entity_id: entityId }, service_data: { type }, return_response: true, }); return res?.response?.[entityId]?.forecast || []; } /** Daily totals of a meter from the long-term statistics, oldest first, today last. */ async statistics(entityId, days) { await this.ready; const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() - (days - 1)); const res = await this.send({ type: "recorder/statistics_during_period", start_time: start.toISOString(), statistic_ids: [entityId], period: "day", types: ["change"] }); return (res?.[entityId] || []).map((r) => ({ start: typeof r.start === "number" ? r.start : new Date(r.start).getTime(), change: r.change })); } /** Forecast.Solar's expected production per hour, per configured plane. */ async solarForecast() { await this.ready; return this.send({ type: "energy/solar_forecast" }); } async history(entityIds, hours) { await this.ready; const res = await this.send({ type: "history/history_during_period", start_time: new Date(Date.now() - hours * 3600e3).toISOString(), entity_ids: entityIds, minimal_response: true, no_attributes: true, significant_changes_only: false, }); const out = {}; for (const [eid, rows] of Object.entries(res || {})) { out[eid] = rows.map((r) => [(r.lu ?? r.lc ?? 0) * 1000, Number(r.s)]); } return out; } } const ha = new HA(); // ------------------------------------------------------------ themes // "System" keeps the adaptive light/dark tokens from style.css. const COLOR_KEYS = ["bg", "tile", "text", "text2", "accent", "onAccent", "separator", "fill", "lit"]; const PRESETS = { System: { swatch: ["#f2f2f7", "#1c1c1e"] }, Light: { bg: "#f2f2f7", tile: "#ffffff", text: "#1d1d1f", text2: "#6e6e73", accent: "#0066cc", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", shadow: true, blur: 0 }, Dark: { bg: "#0f0f11", tile: "#1c1c1e", text: "#f5f5f7", text2: "#a1a1a6", accent: "#0a84ff", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", shadow: false, blur: 0 }, Nord: { bg: "#2e3440", tile: "#3b4252", text: "#eceff4", text2: "#c2c9d6", accent: "#88c0d0", separator: "rgba(216,222,233,0.14)", fill: "rgba(216,222,233,0.12)", lit: "#f3e3bf", shadow: false, blur: 0 }, Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 }, // taste-skill-ignore: the Dusk theme's own background, chosen by the user Forest: { bg: "#0e1a14", tile: "#17271e", text: "#e6f2ea", text2: "#a9c2b2", accent: "#5fd38d", onAccent: "#0b1f14", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 }, // Text on tiles is 5.3:1 or better in every theme; bright accents get dark text (onAccent). Graphite: { bg: "#18181b", tile: "#27272a", text: "#fafafa", text2: "#a1a1aa", accent: "#f59e0b", onAccent: "#1c1204", separator: "rgba(250,250,250,0.1)", fill: "rgba(250,250,250,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Indigo: { bg: "#0b1020", tile: "#151c33", text: "#eef1ff", text2: "#a7b0d6", accent: "#8aa4ff", onAccent: "#0b1020", separator: "rgba(238,241,255,0.1)", fill: "rgba(238,241,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Rose: { bg: "#1d1216", tile: "#2c1d23", text: "#fdf2f5", text2: "#d4b3bf", accent: "#ff7aa6", onAccent: "#2a0a15", separator: "rgba(253,242,245,0.1)", fill: "rgba(253,242,245,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 }, // taste-skill-ignore: the Ocean theme's own background, chosen by the user Sunrise: { bg: "linear-gradient(160deg, #fff4e6 0%, #ffe1e8 100%)", tile: "#ffffff", text: "#2b1d14", text2: "#7a5640", accent: "#c2410c", separator: "rgba(43,29,20,0.1)", fill: "rgba(43,29,20,0.07)", lit: "#ffe0a3", shadow: true, blur: 0 }, // taste-skill-ignore: the Sunrise theme's own background, chosen by the user Lavender: { bg: "#f3f0fa", tile: "#ffffff", text: "#231a3a", text2: "#675b84", accent: "#5b3fc4", separator: "rgba(35,26,58,0.1)", fill: "rgba(35,26,58,0.07)", lit: "#ffe9c2", shadow: true, blur: 0 }, Mint: { bg: "#eaf6f0", tile: "#ffffff", text: "#10291d", text2: "#4a6657", accent: "#0b7a53", separator: "rgba(16,41,29,0.1)", fill: "rgba(16,41,29,0.07)", lit: "#ffeec2", shadow: true, blur: 0 }, Sand: { bg: "#f3ebdf", tile: "#fffaf2", text: "#2d261d", text2: "#6b5e4c", accent: "#9a5416", separator: "rgba(45,38,29,0.1)", fill: "rgba(45,38,29,0.07)", lit: "#ffe2a8", shadow: true, blur: 0 }, Midnight: { bg: "#000000", tile: "#0f0f10", text: "#f2f2f2", text2: "#a3a3a8", accent: "#4da3ff", onAccent: "#001b33", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Espresso: { bg: "#1a1411", tile: "#261d18", text: "#f3ebe4", text2: "#c0ada0", accent: "#e0a35a", onAccent: "#24170b", separator: "rgba(243,235,228,0.12)", fill: "rgba(243,235,228,0.1)", lit: "#ffd9a0", shadow: false, blur: 0 }, Ember: { bg: "#151515", tile: "#212121", text: "#f5f5f5", text2: "#aaaaaa", accent: "#ff7a45", onAccent: "#2a0e02", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffd9a8", shadow: false, blur: 0 }, Lagoon: { bg: "#0c1a1c", tile: "#142729", text: "#e8f5f4", text2: "#9fbcb9", accent: "#3cc9b8", onAccent: "#04201d", separator: "rgba(232,245,244,0.12)", fill: "rgba(232,245,244,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Paper: { bg: "#f4f1ea", tile: "#fbfaf6", text: "#22201c", text2: "#5f5a52", accent: "#1f5f8b", separator: "rgba(34,32,28,0.12)", fill: "rgba(34,32,28,0.07)", lit: "#ffe6b0", shadow: false, blur: 0 }, Arctic: { bg: "#eef3f8", tile: "#ffffff", text: "#10202e", text2: "#4a5d6e", accent: "#0b6bcb", separator: "rgba(16,32,46,0.1)", fill: "rgba(16,32,46,0.06)", lit: "#ffecc4", shadow: true, blur: 0 }, Moss: { bg: "#e9ede4", tile: "#f7f9f4", text: "#1d2619", text2: "#4f5c48", accent: "#3f6f2a", separator: "rgba(29,38,25,0.12)", fill: "rgba(29,38,25,0.07)", lit: "#f6e7b4", shadow: false, blur: 0 }, Sakura: { bg: "#fbf0f2", tile: "#ffffff", text: "#2a1a1f", text2: "#6e5560", accent: "#c2185b", separator: "rgba(42,26,31,0.1)", fill: "rgba(42,26,31,0.06)", lit: "#ffe5c4", shadow: true, blur: 0 }, Concrete: { bg: "#e4e4e2", tile: "#f2f2f0", text: "#1c1c1b", text2: "#555553", accent: "#2b2b2a", onAccent: "#ffffff", separator: "rgba(28,28,27,0.12)", fill: "rgba(28,28,27,0.07)", lit: "#ffe9b8", shadow: false, blur: 0 }, "High contrast": { bg: "#000000", tile: "#141414", text: "#ffffff", text2: "#ffffff", accent: "#ffd60a", onAccent: "#000000", separator: "rgba(255,255,255,0.7)", fill: "rgba(255,255,255,0.2)", lit: "#ffd60a", shadow: false, blur: 0, outline: true }, }; const DEFAULT_THEME = { preset: "System", surface: "cards", radius: 20, bgImage: "", blur: 0, shadow: true }; const DEFAULT_GRID = { cols: 12, rowHeight: 72, gap: 14, maxWidth: 1440 }; const DEFAULT_KIOSK = { saver: 0, saverStyle: "clock", saverSource: "links", saverUrls: "", saverAlbums: [], saverInterval: 30, exitCorner: "top-right", hideCursor: true, doorbell: "", doorbellCamera: "", doorbellSecs: 60, doorbellSound: true, rotate: 0, announce: true }; const DEFAULT_GUEST = { when: "manual", entity: "", from: "18:00", to: "23:00", entities: [], title: "" }; const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] }); const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, guest: { ...DEFAULT_GUEST }, nav: { show: false, style: "icons" }, tabs: [newTab(tr("Overview"))] }); // Bring dashboards saved by older versions up to the current shape. function migrate(d) { // Before 2.2.1, picking an entity with the keyboard could add it twice: keep each entity once per list. const dedupe = (ws) => (ws || []).forEach((w) => { for (const [k, v] of Object.entries(w.cfg || {})) if (Array.isArray(v) && v.every((x) => typeof x === "string")) w.cfg[k] = [...new Set(v)]; dedupe(w.cfg?.children); }); (d.tabs || []).forEach((tb) => dedupe(tb.widgets)); const t = d.theme || {}; if (!t.preset) { t.preset = "Custom"; if (t.card && !t.tile) t.tile = t.card; if (t.border && !t.separator) t.separator = t.border; } d.theme = { ...DEFAULT_THEME, ...t }; d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) }; d.kiosk = { ...DEFAULT_KIOSK, ...(d.kiosk || {}) }; d.guest = { ...DEFAULT_GUEST, ...(d.guest || {}) }; d.nav = { show: false, style: "icons", ...(d.nav || {}) }; if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }]; delete d.widgets; // Media cards from before 1.5 kept one player in cfg.entity; move it into the list so it can be edited. const fix = (w) => { if (w.type === "media" && w.cfg.entity) { w.cfg.entities = [...new Set([w.cfg.entity, ...(w.cfg.entities || [])])]; delete w.cfg.entity; } (w.cfg.children || []).forEach(fix); }; d.tabs.forEach((t) => t.widgets.forEach(fix)); return d; } function makeWidget(type, x = 0, y = 0) { const def = Widgets[type]; const cfg = {}; for (const f of def.fields) if ("d" in f) cfg[f.k] = typeof f.d === "string" && f.t !== "seg" ? tr(f.d) : f.d; if (def.fields.some((f) => f.t === "entities")) cfg.entities = []; if (def.container) cfg.children = []; return { id: uid(), type, x, y, w: def.size[0], h: def.size[1], cfg, style: { transparent: !!def.transparent, bg: "", color: "", accent: "" } }; } // ------------------------------------------------------------ app on the home screen let installPrompt = null; window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; }); const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; // Haven, the Android app (haven/ in the repository), shows this page in its own window and adds window.HavenApp. // It only ever shows dashboards: no builder and no admin screen, for admins too. const IN_APP = !!window.HavenApp; const HAVEN_ID = "nl.melvanveen.haven"; // The public download in the repository, so a phone can get Haven before it is signed in to Home Assistant. const HAVEN_DOWNLOAD = `${REPO_URL}/raw/branch/main/dashboard_studio/web/haven.apk`; const havenLink = () => new URL("./", BASE).href; const havenIntent = () => `intent://open?url=${encodeURIComponent(havenLink())}#Intent;scheme=haven;package=${HAVEN_ID};end`; async function installDialog() { let inFrame = false; try { inFrame = window.top !== window; } catch { inFrame = true; } const ua = navigator.userAgent; const ios = /iphone|ipad|ipod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const android = /android/i.test(ua); const link = new URL(`?ds=${encodeURIComponent(current.id)}`, BASE).href; const browserInstall = !inFrame && window.isSecureContext && installPrompt && !standalone(); const p = (t, cls = "") => `${esc(t)}

`; let body, actions = ``; if (IN_APP) { body = p(tr("This is Haven, connected to {0}.", location.host)); actions += ``; } else if (android) { // A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: Haven always works. body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in.")) + `
  1. ${esc(tr("Download Haven and open the file to install it. Android asks once to allow apps from this source."))}
  2. ${esc(tr("Come back here and tap Open in Haven. Or open Haven and enter the address of Home Assistant."))}
` + (browserInstall ? p(tr("Or install it from the browser, without Haven."), "muted small") : ""); actions += (browserInstall ? `` : "") + `${tr("Download Haven")}` + `${tr("Open in Haven")}`; } else if (ios) { // Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant. body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead.")); } else if (inFrame) { body = p(tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there.")); actions += ``; } else if (!window.isSecureContext) { body = p(tr("Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again.")); } else { body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen.")); if (browserInstall) actions += ``; } if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small"); const choice = await openModal(`

${tr("Install as an app")}

${body} ${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")} `, (e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); }); if (choice === "tab") window.open(link, "_blank", "noopener"); if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; } if (choice === "reset") window.HavenApp.reset(); } /** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so * after a while requests would fail. Renew it the way Home Assistant's own page does, with the * login that page saved in this browser. Inside Home Assistant this isn't needed. */ function wsSession(token) { return new Promise((resolve) => { const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/websocket`); const done = (v) => { clearTimeout(t); try { ws.close(); } catch { /* closed */ } resolve(v); }; const t = setTimeout(() => done(null), 10000); ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.type === "auth_required") ws.send(JSON.stringify({ type: "auth", access_token: token })); else if (m.type === "auth_ok") ws.send(JSON.stringify({ id: 1, type: "supervisor/api", endpoint: "/ingress/session", method: "post" })); else if (m.id === 1) done(m.success ? m.result?.session : null); else if (m.type === "auth_invalid") done(null); }; ws.onerror = () => done(null); }); } async function keepIngressSession() { let top = true; try { top = window.top === window; } catch { top = false; } if (!top || !location.pathname.startsWith("/api/hassio_ingress/")) return; const renew = async () => { let tokens; try { tokens = JSON.parse(localStorage.getItem("hassTokens") || "null"); } catch { tokens = null; } if (!tokens?.refresh_token) return; if (!tokens.access_token || Date.now() > (tokens.expires || 0) - 60000) { const r = await fetch("/auth/token", { method: "POST", body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: tokens.clientId || `${location.origin}/` }) }); if (!r.ok) return; const n = await r.json(); tokens = { ...tokens, access_token: n.access_token, expires_in: n.expires_in, expires: Date.now() + n.expires_in * 1000 }; localStorage.setItem("hassTokens", JSON.stringify(tokens)); } const r = await fetch("/api/hassio/ingress/session", { method: "POST", headers: { Authorization: `Bearer ${tokens.access_token}` } }); // Only administrators may use that address; Home Assistant's websocket gives everyone a session. const session = r.ok ? (await r.json())?.data?.session : await wsSession(tokens.access_token); if (session) document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`; }; try { await renew(); } catch { /* offline: the cached page carries on */ } setInterval(() => renew().catch(() => {}), 5 * 60 * 1000); } // ------------------------------------------------------------ last known states // The states the open dashboard uses are kept on this device, so it can show them while offline. let stateCacheTimer = 0, lastLive = 0; function cacheStates() { if (stateCacheTimer) return; // at most every few seconds, however busy the states are stateCacheTimer = setTimeout(() => { stateCacheTimer = 0; if (!current || !ha.connected) return; const ids = new Set(["sun.sun"]); for (const t of current.tabs) for (const w of t.widgets) entitiesOf(w).forEach((e) => ids.add(e)); const states = {}; for (const id of ids) if (ha.states[id]) states[id] = ha.states[id]; const json = JSON.stringify({ at: Date.now(), states }); if (json.length < 1.5e6) lsSet("ds.states", json); }, 5000); } function loadCachedStates() { try { const c = JSON.parse(lsGet("ds.states") || "null"); if (c?.states && !Object.keys(ha.states).length) { ha.states = c.states; lastLive = c.at; } } catch { /* nothing kept */ } } const offlineText = () => (lastLive ? tr("Offline · {0}", new Date(lastLive).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : tr("Offline")); let offlineTimer = 0; function showOffline(on) { const el = $("#offline-note"); if (!on) { clearTimeout(offlineTimer); offlineTimer = 0; el.hidden = true; return; } if (offlineTimer || !el.hidden) return; // already counting, or showing (each reconnect attempt reports again) // a short blip (an update, a reconnect) shouldn't flash a note offlineTimer = setTimeout(() => { offlineTimer = 0; el.textContent = lastLive ? tr("Offline. Showing the states from {0}.", new Date(lastLive).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : tr("Offline. Reconnecting…"); el.hidden = false; }, 4000); } // ------------------------------------------------------------ sharing // A link to Home Assistant's own page for this app, with the dashboard in it, so it opens // through the sidebar on any device that is signed in to Home Assistant. function sharedParams() { const out = new URLSearchParams(location.search); try { if (window.top !== window) for (const [k, v] of new URLSearchParams(window.top.location.search)) out.set(k, v); } catch { /* another origin */ } return out; } function shareURL(id, inKiosk) { let u; try { u = new URL(window.top.location.href); } catch { u = new URL(location.href); } u.hash = ""; u.search = ""; u.searchParams.set("ds", id); if (inKiosk) u.searchParams.set("ds_kiosk", "1"); return u.href; } async function shareDialog() { const draw = (inKiosk) => { const url = shareURL(current.id, inKiosk); $("#share-url").value = url; $("#share-qr").innerHTML = window.QR.svg(url, { label: tr("QR code for {0}", current.name) }); }; const done = openModal(`

${tr("Share “{0}”", esc(current.name))}

${tr("Open this link or scan the code on another device that is signed in to Home Assistant.")}

`, async (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x === "done") close(null); if (x === "copy") { try { await navigator.clipboard.writeText($("#share-url").value); toast(tr("Link copied.")); } catch { $("#share-url").select(); document.execCommand?.("copy"); toast(tr("Link copied.")); } } }); draw(false); $("#share-kiosk").onchange = (e) => draw(e.target.checked); await done; } // ------------------------------------------------------------ import from Home Assistant // Turns the cards of an existing Home Assistant dashboard into widgets, one tab per view. const ent = (x) => (typeof x === "string" ? x : x?.entity) || ""; function convertCard(card, out) { const t = String(card.type || "").replace(/^custom:/, ""); const e = card.entity || ""; const ents = (card.entities || []).map(ent).filter(Boolean); const add = (type, cfg, w, h) => out.push({ type, cfg, w, h }); switch (t) { case "vertical-stack": case "horizontal-stack": case "grid": (card.cards || []).forEach((c) => convertCard(c, out)); return; case "entities": case "glance": if (ents.length) add("list", { title: card.title || "", entities: ents }, 4, Math.min(6, 1 + ents.length)); return; case "light": add("light", { entity: e, name: card.name || "" }, 3, 2); return; case "thermostat": case "climate": add("climate", { entity: e, name: card.name || "" }, 3, 3); return; case "weather-forecast": add("forecast", { entity: e }, 6, 4); return; case "media-control": add("media", { entities: [e] }, 4, 3); return; case "markdown": add("text", { text: String(card.content || "").replace(/^#+\s*/gm, "").replace(/\*\*/g, "") }, 4, 2); return; case "gauge": add("gauge", { entity: e, name: card.name || "", min: card.min ?? 0, max: card.max ?? 100 }, 3, 3); return; case "history-graph": case "statistics-graph": case "sensor-graph": if (ents.length || e) add("graph", { title: card.title || "", entities: ents.length ? ents : [e], hours: card.hours_to_show || 24 }, 6, 3); return; case "sensor": add("value", { entity: e, name: card.name || "" }, 3, 2); return; case "picture-entity": case "picture-glance": { const cam = card.camera_image || (domainOf(e) === "camera" ? e : ""); if (cam) add("camera", { entity: cam }, 4, 3); return; } case "calendar": if (ents.length) add("calendar", { entities: ents }, 4, 5); return; case "todo-list": case "shopping-list": add("todo", { entity: e || "todo.shopping_list" }, 4, 4); return; case "alarm-panel": add("alarm", { entity: e }, 4, 5); return; case "area": add("room", { area: card.area || "" }, 4, 3); return; case "tile": case "button": case "entity": case "mushroom-entity-card": case "mushroom-light-card": if (domainOf(e) === "light") add("light", { entity: e, name: card.name || "" }, 3, 2); else if (domainOf(e) === "climate") add("climate", { entity: e, name: card.name || "" }, 3, 3); else if (e) add("entity", { entity: e, name: card.name || "" }, 3, 2); return; default: if (e) add("entity", { entity: e, name: card.name || "" }, 3, 2); else if (ents.length) add("list", { title: card.title || "", entities: ents }, 4, Math.min(6, 1 + ents.length)); else out.skipped = (out.skipped || 0) + 1; } } function convertLovelace(config, name) { const d = newDashboard(name); d.tabs = []; let skipped = 0; for (const view of config.views || []) { const tabW = { ...newTab(view.title || tr("Tab {0}", d.tabs.length + 1), String(view.icon || "").replace(/^mdi:/, "")), widgets: [] }; if (!Icons.has(tabW.icon)) tabW.icon = "tile"; const cards = [...(view.cards || []), ...(view.sections || []).flatMap((sec) => sec.cards || [])]; const out = []; cards.forEach((c) => convertCard(c, out)); skipped += out.skipped || 0; for (const o of out) { if (!Widgets[o.type]) continue; const wd = makeWidget(o.type); Object.assign(wd.cfg, o.cfg); wd.w = o.w; wd.h = o.h; Object.assign(wd, findSpot(wd.w, wd.h, tabW.widgets, d.grid.cols)); tabW.widgets.push(wd); } d.tabs.push(tabW); } if (!d.tabs.length) d.tabs.push(newTab(tr("Overview"))); d.nav.show = d.tabs.length > 1; return { d, skipped }; } async function lovelaceDialog() { if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } const list = await ha.send({ type: "lovelace/dashboards/list" }).catch(() => []); const opts = [{ url_path: "", title: tr("Overview") }, ...(list || [])]; const pick = await openModal(`

${tr("Import from Home Assistant")}

${tr("Choose one of your Home Assistant dashboards. Its cards become widgets in a new dashboard here; the original stays as it is.")}

`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok" ? { path: $("#ll-pick").value, title: $("#ll-pick").selectedOptions[0].text } : null); }); if (!pick) return; let config; try { config = await ha.send({ type: "lovelace/config", url_path: pick.path || null }); } catch (e) { toast(tr("Couldn’t load that dashboard: {0}", e.message), true); return; } if (config?.strategy && !config.views) { toast(tr("That dashboard is generated by Home Assistant and has no cards of its own to import."), true); return; } const { d, skipped } = convertLovelace(config || {}, pick.title); store.dashboards.push(d); scheduleSave(); setCurrent(d.id); setEdit(true); const n = d.tabs.reduce((a, t) => a + t.widgets.length, 0); toast(skipped ? tr("Imported {0} widgets. {1} cards couldn’t be converted.", n, skipped) : tr("Imported {0} widgets.", n)); } // ------------------------------------------------------------ templates // Ready-made layouts, filled with whatever matching devices this Home Assistant has. function templateBuilder(name, { mobile = false } = {}) { const d = newDashboard(name); if (mobile) Object.assign(d, { mobile: true, grid: { ...d.grid, cols: 4, maxWidth: 440 } }); const cols = d.grid.cols, ws = d.tabs[0].widgets; const ids = Object.keys(ha.states); const of = (dom, test = () => true) => ids.filter((e) => domainOf(e) === dom && test(ha.states[e])); const add = (type, w, h, cfg = {}) => { const wd = makeWidget(type); Object.assign(wd.cfg, cfg); wd.w = Math.min(w, cols); wd.h = h; Object.assign(wd, findSpot(wd.w, wd.h, ws, cols)); ws.push(wd); return wd; }; const dc = (s, ...c) => c.includes(s.attributes.device_class); return { d, add, of, dc, ids }; } const TEMPLATES = { wall: { icon: "kiosk", name: "Wall tablet", desc: "Clock, weather, calendar, rooms and cameras for a screen on the wall.", build() { const { d, add, of } = templateBuilder(tr("Wall tablet")); Object.assign(d.theme, PRESETS.Dark, { preset: "Dark" }); d.kiosk.saver = 10; const weather = of("weather")[0]; add("clock", 4, 3, { weather: weather || "", date: true }); if (weather) add("forecast", 8, 3, { entity: weather, show: "both" }); const cals = of("calendar"); if (cals.length) add("calendar", 4, 6, { entities: cals }); for (const a of (ha.reg?.areas || []).slice(0, 4)) add("room", 4, 3, { area: a.area_id }); const cam = of("camera")[0]; if (cam) add("camera", 4, 3, { entity: cam }); const players = of("media_player").slice(0, 4); if (players.length) add("media", 4, 3, { entities: players }); return d; } }, phone: { icon: "phone", name: "Phone", desc: "A 4-column layout for your phone: people, lights, climate and media.", build() { const { d, add, of } = templateBuilder(tr("Phone"), { mobile: true }); add("heading", 4, 1, { text: tr("Home"), subtitle: "", size: 26 }); const people = of("person"); if (people.length) add("people", 4, 2, { entities: people.slice(0, 4) }); for (const e of of("light", (s) => !s.attributes.entity_id).slice(0, 4)) add("entity", 2, 2, { entity: e }); const clim = of("climate")[0]; if (clim) add("climate", 4, 3, { entity: clim }); const weather = of("weather")[0]; if (weather) add("weather", 4, 2, { entity: weather }); const garage = of("cover", (s) => /garage|gate|poort|hek/i.test(s.entity_id + friendly(s)))[0]; if (garage) add("gate", 2, 3, { entity: garage }); const players = of("media_player").slice(0, 3); if (players.length) add("media", 4, 3, { entities: players }); return d; } }, energy: { icon: "bolt", name: "Energy", desc: "Power flow, solar forecast, water and gas use and history graphs.", build() { const { d, add, of, dc } = templateBuilder(tr("Energy")); add("heading", 12, 1, { text: tr("Energy"), subtitle: "" }); const power = of("sensor", (s) => dc(s, "power")); const find = (re) => power.find((e) => re.test(e)); const solar = find(/solar|pv|zon/i), grid = find(/grid|net|p1/i), battery = find(/battery|accu/i); if (solar || grid) add("energy", 6, 5, { solar: solar || "", grid: grid || "", battery: battery || "" }); const today = of("sensor").find((e) => /energy_production_today/.test(e)); if (today) add("solar", 6, 5, { today, ...W_AUTOFILL("solar", today) }); for (const e of of("sensor", (s) => dc(s, "water", "gas") && /total/.test(s.attributes.state_class || "")).slice(0, 2)) add("usage", 4, 3, { entity: e, kind: ha.states[e].attributes.device_class === "gas" ? "gas" : "water" }); if (power.length) add("graph", 12, 3, { entities: power.slice(0, 3), title: tr("Power, last 24 hours") }); return d; } }, family: { icon: "people", name: "Family", desc: "Who’s home, the family calendar, shopping list, parcels and the weather.", build() { const { d, add, of } = templateBuilder(tr("Family")); add("clock", 4, 2, { date: true, weather: of("weather")[0] || "" }); const people = of("person"); if (people.length) add("people", 8, 2, { entities: people }); const cals = of("calendar"); if (cals.length) add("calendar", 4, 6, { entities: cals }); const todo = of("todo")[0]; if (todo) add("todo", 4, 5, { entity: todo }); const weather = of("weather")[0]; if (weather) add("forecast", 4, 4, { entity: weather, show: "daily" }); const parcels = of("sensor", (s) => Object.values(s.attributes).some((v) => Array.isArray(v) && typeof v[0] === "object" && /parcel|delivery|postnl|dhl|pakket/i.test(s.entity_id))); if (parcels.length) add("parcels", 4, 4, { entities: parcels }); const waste = of("sensor", (s) => /afvalwijzer/i.test(s.entity_id)); if (waste.length) add("waste", 4, 3, {}); add("notifications", 4, 3, {}); return d; } }, }; const W_AUTOFILL = (type, eid) => Object.fromEntries(Object.entries(Widgets[type].autofill?.fill(eid, ha) || {}).filter(([, v]) => v)); /** New dashboard: a name and where to start from. */ async function templateDialog() { const opts = [["empty", "tile", "Empty", "Start with a blank grid."], ...Object.entries(TEMPLATES).map(([k, t]) => [k, t.icon, t.name, t.desc]), ["devices", "sparkles", "From my devices", "A mix of your lights, sensors and devices."]]; return openModal(`

${tr("New dashboard")}

${opts.map(([k, ic, n, desc], i) => ``).join("")}
`, (e, close) => { if (e.target.closest("[data-x='cancel']")) close(null); }); } document.addEventListener("submit", (e) => { if (e.target.id !== "tpl-form") return; e.preventDefault(); const name = $("#tpl-name").value.trim(), template = e.target.querySelector("input[name=tpl]:checked")?.value || "empty"; if (name && template !== "devices" && template !== "empty" && !ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } if (name) modalClose?.({ name, template }); }); function starterDashboard() { const d = newDashboard(tr("Home")); const ids = Object.keys(ha.states); const first = (dom, n = 1) => ids.filter((e) => domainOf(e) === dom).slice(0, n); const put = (type, x, y, w, h, cfg) => { const wd = makeWidget(type, x, y); Object.assign(wd, { w, h }); Object.assign(wd.cfg, cfg); d.tabs[0].widgets.push(wd); return wd; }; const numeric = (e) => !isNaN(Number(ha.states[e].state)); put("heading", 0, 0, 8, 1, { text: tr("Home"), subtitle: tr("Everything at a glance") }); const weather = first("weather")[0]; put("clock", 8, 0, 4, 3, { weather: weather || "" }); if (weather) put("weather", 0, 1, 4, 2, { entity: weather }); const lights = first("light", 3); if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] }); const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress|vacuum|robot|filter|cleaning|cleaned/.test(e)).slice(0, 2); sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e })); const tiles = [...lights.slice(1, 2), ...first("switch", 1)]; tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e })); // A car is recognisable as a lock with a sibling "_battery" sensor. const carLock = first("lock", 20).find((l) => ha.states[`sensor.${l.split(".")[1].replace(/_[^_]+$/, "")}_battery`]); let y = 5; if (carLock) { const base = carLock.split(".")[1].replace(/_[^_]+$/, ""); const pick = (dom, suffix) => (ha.states[`${dom}.${base}_${suffix}`] ? `${dom}.${base}_${suffix}` : ""); put("car", 0, y, 6, 3, { name: cap(base.replace(/_/g, " ")).replace(/\b\w/g, (m) => m.toUpperCase()), level: pick("sensor", "battery"), range: pick("sensor", "range"), charging: pick("binary_sensor", "charging"), power: pick("sensor", "charging_power"), plug: pick("binary_sensor", "plug"), limit: pick("number", "charge_limit"), lock: carLock, climate: pick("climate", "climate"), location: pick("device_tracker", "location"), odometer: pick("sensor", "odometer"), }); } const sw = [...first("switch", 3).slice(1), ...first("input_boolean", 1), ...first("fan", 1), ...lights.slice(2)]; if (sw.length) put("list", carLock ? 6 : 8, y, carLock ? 6 : 4, 3, { title: tr("Quick controls"), entities: sw }); const graphSensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && ha.states[e].attributes.device_class === "temperature").slice(0, 2); if (graphSensors.length) put("graph", 0, carLock ? y + 3 : y, carLock ? 12 : 8, 3, { entities: graphSensors, title: tr("Temperature, last 24 hours") }); const players = first("media_player", 4); const appliances = [["washer", /(washer|washing_machine|wasmachine)_(status|state|operation_state|program_state)$/, "Washing machine"], ["dryer", /(dryer|droger|wasdroger)_(status|state|operation_state|program_state)$/, "Dryer"], ["dishwasher", /(dishwasher|vaatwasser|vaatwasmachine)_(status|state|operation_state|program_state)$/, "Dishwasher"]] .map(([kind, re, name]) => ({ kind, name, status: ids.find((e) => domainOf(e) === "sensor" && re.test(e)) })).filter((a) => a.status); // a Tuya litter box: a status sensor next to a cat weight sensor for (const w of ids.filter((e) => /^sensor\..+_cat_weight$/.test(e))) { const status = w.replace(/_cat_weight$/, "_status"); if (ha.states[status]) appliances.push({ kind: "litterbox", name: "Litter box", status, fill: W_AUTOFILL("appliance", status) }); } if (players.length || appliances.length) { let y2 = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)), x2 = 0; if (players.length) { put("media", 0, y2, 4, 3, { entities: players }); x2 = 4; } for (const a of appliances) { const base = a.status.replace(/_(status|state|operation_state|program_state)$/, ""); const pick = (...suffixes) => suffixes.map((sfx) => `${base}_${sfx}`).find((e) => ha.states[e]) || ""; if (x2 + 4 > 12) { x2 = 0; y2 += 3; } put("appliance", x2, y2, 4, 2, { name: tr(a.name), kind: a.kind, status: a.status, remaining: pick("remaining_time", "time_remaining", "remaining", "end_time", "finish_time"), progress: pick("progress", "program_progress"), program: pick("program", "selected_program", "active_program"), power: pick("power"), ...a.fill }); x2 += 4; } } if (ids.some((e) => e.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in ha.states[e].attributes)) { const bottom = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)); put("waste", 0, bottom, 4, 4, {}); } return d; } // ------------------------------------------------------------ app state let store = { version: 1, dashboards: [], assign: { devices: {}, users: {} } }; // Each browser gets its own id, so a wall tablet can be told which dashboard to open. const DEVICE_ID = lsGet("ds.device") || (() => { const id = uid() + uid(); lsSet("ds.device", id); return id; })(); let me = null; // the signed-in Home Assistant user, from the server // Admins (Home Assistant's administrators) get three screens: Dashboard, Builder and Admin. Everyone else // only sees the dashboards an admin chose for them, always in kiosk mode. The server enforces the same. let role = lsGet("ds.role") === "basic" ? "basic" : "admin"; const isAdmin = () => role === "admin"; let mode = "dashboard"; // dashboard, builder or admin let previewAs = null; // Admin → People → Preview: { id, name, ids } let lockedKiosk = false; // people who aren't admins can't leave kiosk mode /** What a person may see: { all: true } or { dashboards: [...] }, plus the one that opens first (home). */ /** A person's own look, chosen by an admin: a theme, a text size, a language. */ const lookOf = () => (previewAs ? store.looks?.[previewAs.id] : isAdmin() ? store.looks?.[me?.id] : store.look) || {}; const accessFor = (uid) => store.access?.users?.[uid] || store.access?.everyone || { all: true }; const visibleFor = (uid) => { const a = accessFor(uid); return store.dashboards.filter((d) => a.all || (a.dashboards || []).includes(d.id)).map((d) => d.id); }; let current = null; let editMode = lsGet("ds.edit") === "1"; let selectedId = null; /** Shift- or ⌘-click selects several widgets; `multi` then holds them all, including selectedId. */ const multi = new Set(); const isSel = (id) => id === selectedId || multi.has(id); let activeTabId = null; /** The tab whose widgets are on screen. */ const tab = () => (guestOn && !editMode ? guestTab() : current.tabs.find((t) => t.id === activeTabId) || current.tabs[0]); /** Cards inside a container card (scroll, swipe) live in its cfg.children. */ let childIndex = null; function findWidget(id, list = tab().widgets) { for (const w of list) { if (w.id === id) return w; const inner = w.cfg?.children && findWidget(id, w.cfg.children); if (inner) return inner; } return null; } const selectedWidget = () => tab().widgets.find((x) => x.id === selectedId); const selection = () => tab().widgets.filter((w) => isSel(w.id)); /** What the inspector edits: a card inside the selected container, or the selected widget. */ const editTarget = () => { const w = selectedWidget(); return (childIndex != null && w?.cfg.children?.[childIndex]) || w; }; const undoStack = [], redoStack = []; const undoAt = [], redoAt = []; // when each step was made, for the undo history list let lastMutKey = null, lastMutAt = 0; const caches = {}; // per-widget runtime cache (graph history) const gridEl = $("#grid"), stageEl = $("#stage"), canvasEl = $("#canvas"), navEl = $("#tabnav"); let metrics = { colW: 0, rh: 72, gap: 14, cols: 12, compact: false }; // ------------------------------------------------------------ persistence let saveTimer = null, savePending = false; function scheduleSave() { if (!isAdmin()) return; // the server refuses it anyway savePending = true; $("#save-status").textContent = tr("Saving…"); clearTimeout(saveTimer); saveTimer = setTimeout(save, 700); } async function save() { savePending = false; try { const r = await fetch(new URL("api/dashboards", BASE), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(store) }); if (!r.ok) throw new Error(r.status); $("#save-status").textContent = tr("Saved"); } catch { $("#save-status").textContent = tr("Not saved"); toast(tr("Couldn’t save. Your changes are kept here and will save on the next edit."), true); } } /** Apply a change to the current dashboard with undo support. * `key` coalesces rapid edits (typing) into one undo step. */ function mutate(fn, key, { rerender = true } = {}) { const now = Date.now(); if (!(key && key === lastMutKey && now - lastMutAt < 1200)) { undoStack.push(JSON.stringify(current)); undoAt.push(now); if (undoStack.length > 100) { undoStack.shift(); undoAt.shift(); } redoStack.length = redoAt.length = 0; } lastMutKey = key; lastMutAt = now; fn(); scheduleSave(); updateUndoButtons(); if (rerender) renderGrid(); } function restore(snapshot) { const idx = store.dashboards.findIndex((d) => d.id === current.id); current = JSON.parse(snapshot); store.dashboards[idx] = current; if (!tab().widgets.some((w) => w.id === selectedId)) { selectedId = null; multi.clear(); } lastMutKey = null; scheduleSave(); updateUndoButtons(); renderAll(); } function undo() { if (undoStack.length) { redoStack.push(JSON.stringify(current)); redoAt.push(undoAt.pop()); restore(undoStack.pop()); } } function redo() { if (redoStack.length) { undoStack.push(JSON.stringify(current)); undoAt.push(redoAt.pop()); restore(redoStack.pop()); } } /** What changed between two versions of a dashboard, in a few words. */ function describeChange(a, b) { const all = (d) => { const m = new Map(); const walk = (ws) => ws.forEach((w) => { m.set(w.id, w); walk(w.cfg?.children || []); }); d.tabs.forEach((t) => walk(t.widgets)); return m; }; const wa = all(a), wb = all(b), name = (w) => tr(Widgets[w.type]?.label || w.type); const added = [...wb.values()].filter((w) => !wa.has(w.id)), gone = [...wa.values()].filter((w) => !wb.has(w.id)); const several = (list, one, many) => (list.length === 1 ? tr(one, name(list[0])) : tr(many, list.length)); if (added.length && gone.length) return tr("Moved widgets around"); if (added.length) return several(added, "Added {0}", "Added {0} widgets"); if (gone.length) return several(gone, "Deleted {0}", "Deleted {0} widgets"); const same = (x, y) => JSON.stringify(x) === JSON.stringify(y); const changed = [...wb.values()].filter((w) => !same(w, wa.get(w.id))); if (changed.length) { const kind = (w) => { const o = wa.get(w.id); return o.x !== w.x || o.y !== w.y ? (o.w !== w.w || o.h !== w.h ? "size" : "move") : o.w !== w.w || o.h !== w.h ? "size" : "edit"; }; const k = kind(changed[0]); if (k === "move") return several(changed, "Moved {0}", "Moved {0} widgets"); if (k === "size") return several(changed, "Resized {0}", "Resized {0} widgets"); return several(changed, "Edited {0}", "Edited {0} widgets"); } if (!same(a.tabs.map((t) => [t.id, t.name, t.icon]), b.tabs.map((t) => [t.id, t.name, t.icon])) || !same(a.nav, b.nav)) return tr("Changed the tabs"); if (!same(a.theme, b.theme)) return tr("Changed the look"); if (!same(a.grid, b.grid) || a.mobile !== b.mobile) return tr("Changed the grid"); if (!same(a.kiosk, b.kiosk)) return tr("Changed the kiosk settings"); if (!same(a.guest, b.guest)) return tr("Changed guest mode"); if (a.name !== b.name) return tr("Renamed the dashboard"); return tr("Changed the dashboard"); } /** The changes made in this session, newest first; picking one goes back to just after it. */ async function undoHistoryDialog() { if (!undoStack.length) { toast(tr("No changes yet in this session.")); return; } const states = [...undoStack, JSON.stringify(current)]; const rows = undoStack.map((snap, i) => ({ i, label: describeChange(JSON.parse(snap), JSON.parse(states[i + 1])), at: undoAt[i] })).reverse(); const pick = await openModal(`

${tr("Undo history")}

${tr("Pick a change to go back to just after it. Redo brings the later ones back.")}

${rows.map((r, n) => ``).join("")}
`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "cancel" ? null : x); }); if (pick == null) return; const steps = undoStack.length - (Number(pick) + 1); for (let i = 0; i < steps; i++) undo(); } function updateUndoButtons() { $("#btn-undo").disabled = !undoStack.length; $("#btn-redo").disabled = !redoStack.length; } // ------------------------------------------------------------ rendering const VAR_FOR = { bg: "--d-bg", tile: "--d-tile", text: "--d-text", text2: "--d-text-2", accent: "--d-accent", onAccent: "--d-on-accent", separator: "--d-separator", fill: "--d-fill", lit: "--d-lit" }; /** A theme's light or dark twin, built from its accent, for themes set to follow the device. */ function themeVariant(t) { if (!t.follow || t.preset === "System" || t.preset === "Custom" || !t.bg || !/^#[0-9a-f]{6}$/i.test(t.bg)) return t; const lum = (hex) => { const n = parseInt(hex.slice(1), 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; }; const isDark = lum(t.bg) < 0.45, wantDark = prefersDark(); if (isDark === wantDark) return t; const a = t.accent || "#0a84ff"; return wantDark ? { ...t, bg: `color-mix(in srgb, ${a} 7%, #0f0f11)`, tile: `color-mix(in srgb, ${a} 9%, #1c1c1e)`, text: "#f5f5f7", text2: "#a1a1a6", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", onAccent: "#ffffff", shadow: false } : { ...t, bg: `color-mix(in srgb, ${a} 7%, #f2f2f7)`, tile: `color-mix(in srgb, ${a} 3%, #ffffff)`, text: "#1d1d1f", text2: "#6e6e73", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", onAccent: "#ffffff", shadow: true }; } function applyTheme() { const own = lookOf().theme, base = own && PRESETS[own] ? { ...current.theme, ...PRESETS[own], preset: own, bgImage: "" } : current.theme; const t = themeVariant(nightDark ? { ...base, ...PRESETS.Dark, preset: "Dark" } : base), s = stageEl.style; document.body.classList.toggle("text-large", lookOf().scale === "large"); document.body.classList.toggle("text-xl", lookOf().scale === "xl"); stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain"); stageEl.classList.add(`surface-${t.surface || "cards"}`); for (const k of COLOR_KEYS) { if (t.preset !== "System" && t[k]) s.setProperty(VAR_FOR[k], t[k]); else s.removeProperty(VAR_FOR[k]); } const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)"; if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`); s.setProperty("--d-radius", `${t.radius ?? 20}px`); const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7"; document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar); window.HavenApp?.setBarColor(bar); s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`); stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System"); s.setProperty("--d-blur", `${t.blur || 0}px`); if (t.preset === "System") s.removeProperty("--d-shadow"); else s.setProperty("--d-shadow", t.shadow ? "0 1px 2px rgba(0,0,0,.04), 0 4px 16px rgba(0,0,0,.06)" : "none"); } function computeMetrics() { const g = current.grid; const pad = window.innerWidth < 560 ? 12 : 20; const avail = canvasEl.clientWidth - pad * 2; const landscape = !editMode && !!current.mobile && phoneLandscape.matches; const width = landscape ? Math.min(avail, 900) : g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail; // Phone dashboards are drawn exactly as designed; others reflow into two columns on narrow screens. const compact = !editMode && width < 560 && !current.mobile; const cols = compact ? 2 : landscape ? (Number(g.cols) || 4) * 2 : Math.max(1, Number(g.cols) || 12); const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0; const rh = Number(g.rowHeight) || 72; metrics = { width, cols, gap, rh, colW: (width - gap * (cols - 1)) / cols, compact, landscape }; gridEl.style.width = `${width}px`; gridEl.style.margin = `${editMode ? 4 : 8}px auto ${pad}px`; } // Compact widths: two columns in reading order. Small widgets take one column, the rest the full width. // ------------------------------------------------------------ visibility // A widget can carry conditions ("show only when the washer is running"); widgets may // also hide themselves (autoHide). Editing always shows everything. function ruleMatches(r) { const st = String(ha.states[r.entity]?.state ?? "unavailable").toLowerCase(); const v = String(r.value ?? "").trim().toLowerCase(); switch (r.op) { case "is not": return st !== v; case "above": return Number(st) > Number(v); case "below": return Number(st) < Number(v); default: return st === v; } } function isVisible(w) { const scr = w.visibility?.screen, phone = matchMedia("(max-width: 600px)").matches; if ((scr === "phone" && !phone) || (scr === "large" && phone)) return false; if (Widgets[w.type]?.autoHide?.(w.cfg, ctxFor(w))) return false; const rules = (w.visibility?.rules || []).filter((r) => r.entity); if (!rules.length) return true; return w.visibility.mode === "any" ? rules.some(ruleMatches) : rules.every(ruleMatches); } const ruleEntities = (w) => (w.visibility?.rules || []).map((r) => r.entity).filter(Boolean); let lastVisible = {}; function positions() { const map = {}; const shown = editMode ? tab().widgets : tab().widgets.filter(isVisible); if (metrics.landscape) { // reading order, packed into the double-width grid const placed = []; for (const w of [...shown].sort((a, b) => a.y - b.y || a.x - b.x)) { const p = { ...findSpot(w.w, w.h, placed, metrics.cols), w: w.w, h: w.h }; map[w.id] = p; placed.push(p); } return map; } if (!metrics.compact) { if (editMode) { for (const w of shown) map[w.id] = w; return map; } // Rows that only hidden widgets used close up, so nothing leaves a hole. const rows = (list) => { const set = new Set(); for (const w of list) for (let r = w.y; r < w.y + w.h; r++) set.add(r); return set; }; const vis = rows(shown), gone = [...rows(tab().widgets.filter((w) => !shown.includes(w)))].filter((r) => !vis.has(r)); for (const w of shown) map[w.id] = { x: w.x, y: w.y - gone.filter((r) => r < w.y).length, w: w.w, h: w.h }; return map; } const sorted = [...shown].sort((a, b) => a.y - b.y || a.x - b.x); const small = (w) => w.w <= Math.floor(current.grid.cols / 4) && w.type !== "heading"; let y = 0, halfOpen = null; for (const w of sorted) { if (small(w)) { if (halfOpen) { map[w.id] = { x: 1, y: halfOpen.y, w: 1, h: halfOpen.h }; y = halfOpen.y + halfOpen.h; halfOpen = null; } else { const h = Math.max(2, w.h); map[w.id] = { x: 0, y, w: 1, h }; halfOpen = { y, h }; } } else { if (halfOpen) { y = halfOpen.y + halfOpen.h; halfOpen = null; } const h = w.type === "clock" ? Math.min(w.h, 2) : w.h; map[w.id] = { x: 0, y, w: 2, h }; y += h; } } return map; } function rectFor(p) { const { colW, gap, rh } = metrics; return { left: p.x * (colW + gap), top: p.y * (rh + gap), width: p.w * colW + (p.w - 1) * gap, height: p.h * rh + (p.h - 1) * gap }; } function placeEl(el, p) { const r = rectFor(p); Object.assign(el.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` }); } function updateGridHeight(pos) { const rows = Object.values(pos).reduce((m, p) => Math.max(m, p.y + p.h), 0) + (editMode ? 4 : 0); gridEl.style.height = `${Math.max(rows * (metrics.rh + metrics.gap) - metrics.gap, 0)}px`; const cw = metrics.colW + metrics.gap, ch = metrics.rh + metrics.gap; const svg = ``; gridEl.style.setProperty("--cell-bg", `url("data:image/svg+xml,${encodeURIComponent(svg)}")`); gridEl.style.backgroundSize = `${cw}px ${ch}px`; } /** Side menu with the dashboard's tabs (a bottom tab bar on phones). */ function renderNav() { const show = !(guestOn && !editMode) && (current.nav.show || (editMode && current.tabs.length > 1)); document.body.classList.toggle("has-tabs", show); navEl.hidden = !show; if (!show) return; const active = tab().id; // "icons": a slim rail; names appear as a tooltip on hover or keyboard focus. navEl.classList.toggle("icons", current.nav.style !== "labels"); navEl.innerHTML = current.tabs.map((t) => ``).join("") + (editMode ? `` : ""); } function setTab(id) { activeTabId = id; lsSet(`ds.tab.${current.id}`, id); { selectedId = null; multi.clear(); } renderGrid(); if (editMode) renderInspector(); stageEl.scrollTop = 0; } function addTab() { const t = newTab(tr("Tab {0}", current.tabs.length + 1), "tile"); mutate(() => { current.tabs.push(t); current.nav.show = true; }, null, { rerender: false }); setTab(t.id); } async function tabOp(op, id) { const i = current.tabs.findIndex((t) => t.id === id); if (op === "add") return addTab(); if (op === "up" || op === "down") { const j = op === "up" ? i - 1 : i + 1; if (j < 0 || j >= current.tabs.length) return; mutate(() => { [current.tabs[i], current.tabs[j]] = [current.tabs[j], current.tabs[i]]; }); renderInspector(); } else if (op === "del") { if (current.tabs.length === 1) { toast(tr("A dashboard needs at least one tab."), true); return; } const t = current.tabs[i]; if (t.widgets.length && !(await confirmDestructive(tr("Delete tab “{0}”?", t.name), tr("Its {0} widgets will be deleted too. You can undo this with ⌘Z.", t.widgets.length), tr("Delete")))) return; mutate(() => { current.tabs.splice(i, 1); }, null, { rerender: false }); setTab(current.tabs[Math.max(0, i - 1)].id); } } const phoneScreen = matchMedia("(max-width: 600px)"); // A phone on its side: wide but low. Phone dashboards then use two columns of their layout side by side. const phoneLandscape = matchMedia("(orientation: landscape) and (max-height: 500px)"); function renderGrid() { if (!current) return; document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && (phoneScreen.matches || phoneLandscape.matches)); renderNav(); applyTheme(); computeMetrics(); const pos = positions(); gridEl.innerHTML = ""; lastVisible = {}; for (const w of tab().widgets) { lastVisible[w.id] = isVisible(w); if (!pos[w.id]) continue; const el = document.createElement("div"); el.className = "w"; el.dataset.id = w.id; gridEl.appendChild(el); placeEl(el, pos[w.id]); renderWidget(w, el); } // The first heading on the tab is the screen's title. const h = gridEl.querySelector("h2.h-main"); if (h) { const h1 = document.createElement("h1"); h1.className = h.className; h1.style.cssText = h.style.cssText; h1.textContent = h.textContent; h.replaceWith(h1); } updateGridHeight(pos); } async function api(path) { const r = await fetch(new URL(path, BASE)); if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`); return r.json(); } const ctxFor = (w) => ({ ha, w, api, cache: (caches[w.id] ||= {}), rerender: () => renderWidget(w), isVisible: (child) => editMode || isVisible(child), // Containers draw placeholders (.w.child); this fills each with its card. renderChildren: (el) => el.querySelectorAll(".w.child[data-id]").forEach((ph) => { const ch = (w.cfg.children || []).find((c) => c.id === ph.dataset.id); if (ch) renderWidget(ch, ph, { child: true }); }), }); function renderWidget(w, el = gridEl.querySelector(`.w[data-id="${w.id}"]`), { child = false } = {}) { if (!el) return; const def = Widgets[w.type]; if (!def) { el.innerHTML = `
${tr("Unknown widget “{0}”.", esc(w.type))}
`; return; } const ctx = ctxFor(w); const st = w.style || {}; const lit = def.lit?.(w.cfg, ctx) || false; el.classList.toggle("selected", editMode && isSel(w.id)); el.classList.toggle("transparent", !!st.transparent); el.classList.toggle("lit-lamp", lit === "lamp"); el.classList.toggle("lit-on", lit === "on"); const lamp = lit === "lamp" ? def.lamp?.(w.cfg, ctx) : null; for (const [prop, val] of [["--lamp", lamp], ["--w-bg", st.bg], ["--w-color", st.color], ["--w-accent", st.accent]]) { if (val) el.style.setProperty(prop, val); else el.style.removeProperty(prop); } let html; try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `
${tr("This widget couldn’t be drawn.")}
`; } const hiddenNow = editMode && !child && !isVisible(w); el.classList.toggle("cond-hidden", hiddenNow); el.innerHTML = `
${html}
` + (hiddenNow ? `
${Icons.svg("eye-off")}${tr("Hidden now")}
` : "") + (editMode && !child ? `
` : ""); if (editMode) el.querySelectorAll(".w-body button, .w-body input, .w-body iframe").forEach((n) => n.setAttribute("tabindex", "-1")); if (def.after) Promise.resolve(def.after(el, w.cfg, ctx)).catch(console.error); } function entitiesOf(w) { const def = Widgets[w.type]; if (!def) return []; if (def.container) return (w.cfg.children || []).flatMap(entitiesOf); if (def.entities) return def.entities(w.cfg, ha); return w.cfg.entity ? [w.cfg.entity] : []; } let interacting = false; function onStateChange(entityId) { if (ha.connected) { lastLive = Date.now(); cacheStates(); } if (livePopup && livePopup.eid === entityId) livePopup.draw(); // an open climate popup follows the device if (!current) return; if (entityId === current.kiosk.doorbell || entityId == null) checkDoorbell(); if (entityId === "sun.sun" || entityId == null) applyNight(); if (entityId === current.guest?.entity || entityId == null) checkGuest(); if (entityId == null || entityId === awayEntity()) checkAway(); if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; } const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId)); if (affected.some((w) => isVisible(w) !== lastVisible[w.id])) { renderGrid(); return; } for (const w of affected) { const el = gridEl.querySelector(`.w[data-id="${w.id}"]`); if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag if (caches[w.id]?.busy) { caches[w.id].dirty = true; continue; } // mid-swipe: redraw when the swipe ends if (el?.contains(document.activeElement) && document.activeElement.matches("input[type=text]")) { (caches[w.id] ||= {}).typing = true; continue; } const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null; renderWidget(w, el); if (focused) el.querySelector(`[data-act="${focused}"]`)?.focus(); } } function renderDashSelect() { $("#dash-select").innerHTML = store.dashboards.filter((d) => !previewAs || previewAs.ids.includes(d.id)).map((d) => ``).join(""); } // ------------------------------------------------------------ suggestions // Devices that aren't on any dashboard yet, each with the widget that suits it. const SUGGEST = { light: "light", climate: "climate", cover: "covers", media_player: "media", camera: "camera", lock: "entity", switch: "entity", fan: "entity", vacuum: "vacuum", alarm_control_panel: "alarm", todo: "todo", input_boolean: "entity", scene: "entity", weather: "forecast" }; function suggestions() { const used = new Set(); for (const d of store.dashboards) for (const t of d.tabs) for (const w of t.widgets) { entitiesOf(w).forEach((e) => used.add(e)); ruleEntities(w).forEach((e) => used.add(e)); } const hidden = new Set(); // entities Home Assistant hides, and lights that are part of a group already on a dashboard for (const st0 of Object.values(ha.states)) if (Array.isArray(st0.attributes.entity_id) && used.has(st0.entity_id)) st0.attributes.entity_id.forEach((e) => hidden.add(e)); return Object.values(ha.states).filter((x) => SUGGEST[domainOf(x.entity_id)] && !used.has(x.entity_id) && !hidden.has(x.entity_id) && x.state !== "unavailable") .sort((a, b) => domainOf(a.entity_id).localeCompare(domainOf(b.entity_id)) || friendly(a).localeCompare(friendly(b))); } async function suggestionsDialog() { const list = suggestions(); if (!list.length) { toast(tr("Every device is on a dashboard already.")); return; } const draw = () => suggestions().map((x) => `
${window.HAFmt.icon(x)}${esc(friendly(x))}${esc(x.entity_id)} · ${esc(tr(Widgets[SUGGEST[domainOf(x.entity_id)]].label))}
`).join("") || `
${tr("Every device is on a dashboard already.")}
`; await openModal(`

${tr("Not on a dashboard yet")}

${tr("These devices aren’t on any of your dashboards. Add one to this tab with the widget that suits it.")}

${draw()}
`, (e, close) => { const b = e.target.closest("[data-x]"); if (!b) return; if (b.dataset.x === "done") return close(null); const eid = b.dataset.e, type = SUGGEST[domainOf(eid)]; const wd = makeWidget(type); if (Widgets[type].fields.some((f) => f.k === "entities")) wd.cfg.entities = [eid]; else wd.cfg.entity = eid; wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols); Object.assign(wd, findSpot(wd.w, wd.h)); mutate(() => tab().widgets.push(wd)); $(".sg-list").innerHTML = draw(); renderPalette(); }); } function renderPalette() { const search = $("#palette-search"), q = search.value.trim().toLowerCase(); search.placeholder = tr("Search widgets"); const items = Object.entries(Widgets).filter(([type, d]) => !q || [type, d.label, tr(d.label)].some((x) => x.toLowerCase().includes(q))); const presets = (store.presets || []).filter((p) => !q || p.name.toLowerCase().includes(q)); const presetHTML = presets.length ? `
${tr("My presets")}
${presets.map((p) => `
`).join("")}
${tr("Widgets")}
` : ""; const nSug = ha.connected && store.dashboards?.length ? suggestions().length : 0; const sugHTML = nSug && !q ? `` : ""; $("#palette-list").innerHTML = items.length || presets.length ? sugHTML + presetHTML + items.map(([type, d]) => ``).join("") : `

${tr("No widgets match “{0}”.", esc(search.value.trim()))}

`; } /** A set-up widget saved under a name, to add again on any dashboard. Kept with the dashboards. */ async function savePreset(id) { const w = findWidget(id); if (!w) return; const name = await askText(tr("Save as preset"), w.cfg.name || w.cfg.title || tr(Widgets[w.type].label), tr("Save")); if (!name) return; const { x, y, ...rest } = clone(w); (store.presets ||= []).push({ id: uid(), name, widget: rest }); scheduleSave(); renderPalette(); toast(tr("Saved “{0}”. You’ll find it at the top of the widget list.", name)); } function addPreset(pid) { const p = (store.presets || []).find((x) => x.id === pid); if (!p) return; const wd = { ...clone(p.widget), id: uid() }; (wd.cfg.children || []).forEach((ch) => (ch.id = uid())); wd.w = Math.min(wd.w, current.grid.cols); Object.assign(wd, findSpot(wd.w, wd.h)); mutate(() => tab().widgets.push(wd)); select(wd.id); } $("#palette-search").addEventListener("input", renderPalette); $("#palette-search").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#palette-list .pal-item")?.click(); }); function renderAll() { document.body.classList.toggle("editing", editMode); renderModeSeg(); renderDashSelect(); renderGrid(); if (editMode) renderInspector(); applyNight(); } // ------------------------------------------------------------ inspector const entPickAttrs = (domains) => `data-ent-pick="${(domains || []).join(",")}" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="ent-pop"`; function fieldHTML(f, value, path) { f = { ...f, l: tr(f.l), ph: f.ph ? tr(f.ph) : f.ph }; const id = `f-${path}-${f.k}`; const data = `data-path="${path}" data-k="${f.k}"`; const label = ``; switch (f.t) { case "check": return `
`; case "seg": return `
${esc(f.l)}
${f.o.map((o) => ``).join("")}
`; case "select": return `
${label}
`; case "time": return `
${label}
`; case "number": return `
${label}
`; case "textarea": case "json": return `
${label}
`; case "color": return `
${label}
`; case "icon": return `
${label}
`; case "entity": { const s = ha.states[value]; return `
${label} ${s ? `
${esc(friendly(s))} · ${esc(fmtState(s))}
` : value ? `
${tr("No entity with this ID")}
` : ""}
`; } case "entities": { const lid = entPickAttrs(f.domains); const list = Array.isArray(value) ? value : []; return `
${esc(f.l)} ${list.map((e, i) => `
`).join("")}
`; } case "area": { const areas = ha.reg?.areas; if (!areas) return `
${esc(f.l)}
${tr("Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.")}
`; return `
${label}
`; } case "pos9": // a 3×3 grid for placing content in the card return `
${esc(f.l)}
${f.o.map((o) => ``).join("")}
`; case "albums": return `
${esc(f.l)}
${tr("Loading albums…")}
`; default: return `
${label}
`; } } function datalists() { return `${Icons.names.map((n) => ``; } const STYLE_FIELDS = [ { k: "transparent", t: "check", l: "No background" }, { k: "bg", t: "color", l: "Background", ph: "Theme" }, { k: "color", t: "color", l: "Text color", ph: "Theme" }, { k: "accent", t: "color", l: "Accent color", ph: "Theme" }, ]; const COLOR_FIELDS = [ { k: "bg", t: "color", l: "Background (color or CSS gradient)" }, { k: "tile", t: "color", l: "Tile color" }, { k: "text", t: "color", l: "Text color" }, { k: "text2", t: "color", l: "Secondary text color" }, { k: "accent", t: "color", l: "Accent color" }, ]; const GRID_FIELDS = [ { k: "cols", t: "number", l: "Columns" }, { k: "rowHeight", t: "number", l: "Row height (px)" }, { k: "gap", t: "number", l: "Spacing (px)" }, { k: "maxWidth", t: "number", l: "Maximum width (px)", ph: "0 = full width" }, ]; function renderInspector() { const el = $("#inspector"); updateEditBar(); if (multi.size > 1) { const btn = (k, l) => ``; el.innerHTML = `
${tr("{0} widgets selected", multi.size)}
${tr("Align")}
${btn("left", "Left edges")}${btn("right", "Right edges")}${btn("top", "Top edges")}${btn("bottom", "Bottom edges")}${btn("width", "Same width")}${btn("height", "Same height")}

${tr("Same width and height follow the widget you clicked last.")}

${tr("Shift-click or ⌘-click widgets to add or remove them. Drag one to move them all; the arrow keys move them too.")}

`; return; } const w = tab().widgets.find((x) => x.id === selectedId); if (w && childIndex != null && w.cfg.children?.[childIndex]) { // Editing one card inside a container: its own fields and style, with a way back. const ch = w.cfg.children[childIndex], cdef = Widgets[ch.type]; el.innerHTML = `${datalists()}
${Icons.svg(cdef.icon)} ${esc(tr(cdef.label))}
${cdef.fields.filter((f) => !f.when || f.when(ch.cfg)).map((f) => fieldHTML(f, ch.cfg[f.k], "cfg")).join("")} ${w.type === "hscroll" ? fieldHTML({ k: "_width", t: "number", l: "Width of this card (px)", ph: "Same as the others" }, ch.cfg._width, "cfg") : ""}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, ch.style?.[f.k], "style")).join("")}
`; return; } if (w) { const def = Widgets[w.type]; el.innerHTML = `${datalists()}
${Icons.svg(def.icon)} ${esc(tr(def.label))}
${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")} ${def.container ? childrenHTML(w) : ""}
${tr("Size and position")}
${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `
`).join("")}
${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}
${visibilityHTML(w)} ${holdHTML(w)} ${["button", "entity", "light", "gate", "climate"].includes(w.type) ? `
${tr("Home screen")}

${tr("When Dashboard Studio is installed as an app on Android, long-press its icon to run this widget straight away. Up to four shortcuts.")}

` : ""}

${tr("Drag a widget onto a scroll or swipe card to put it inside.")}

`; fillAlbums(el); return; } const t = current.theme, k = current.kiosk; el.innerHTML = `
${tr("Dashboard")}
${fieldHTML({ k: "name", t: "text", l: "Name" }, current.name, "dash")} ${Icons.names.map((n) => `
${tr("Tabs")}
${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")} ${fieldHTML({ k: "style", t: "seg", l: "Menu style", o: ["icons", "labels"] }, current.nav.style || "icons", "nav")} ${current.tabs.length > 1 ? `${fieldHTML({ k: "awayTab", t: "select", l: "When you’re away from home, open", o: ["", ...current.tabs.map((t) => t.id)], labels: { "": tr("Nothing special"), ...Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) } }, current.nav.awayTab || "", "nav")} ${current.nav.awayTab ? fieldHTML({ k: "awayPerson", t: "entity", l: "Away means this person is away", domains: ["person", "device_tracker"], ph: "person." }, current.nav.awayPerson || "", "nav") : ""} ${current.nav.awayTab ? `

${tr("Leave the person empty to follow whoever is signed in on the device. Not used in kiosk mode.")}

` : ""}` : ""}
${current.tabs.map((t, i) => `
`).join("")}
${tr("Appearance")}
${Object.entries(PRESETS).map(([n, p]) => { const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg; // taste-skill-ignore: the System swatch: half light, half dark, with a hard edge const fg = p.swatch ? "#6e6e73" : p.text; return ``; }).join("")}

${tr("System follows your device’s light or dark appearance.")}

${t.preset !== "System" && t.preset !== "Custom" ? fieldHTML({ k: "follow", t: "check", l: "Switch to a dark or light version with the device" }, t.follow, "theme") : ""} ${fieldHTML({ k: "surface", t: "seg", l: "Tile style", o: ["cards", "outline", "plain"] }, t.surface, "theme")} ${fieldHTML({ k: "radius", t: "number", l: "Corner radius (px)" }, t.radius, "theme")}
${tr("Colors")}${t.preset !== "Custom" ? ` · ${tr("editing switches to Custom")}` : ""}
${COLOR_FIELDS.map((f) => fieldHTML(f, t.preset === "System" ? "" : t[f.k], "theme")).join("")} ${fieldHTML({ k: "bgImage", t: "text", l: "Background image URL", ph: "/local/wallpaper.jpg" }, t.bgImage, "theme")} ${fieldHTML({ k: "blur", t: "number", l: "Blur behind tiles (px)", ph: "For photo backgrounds" }, t.blur, "theme")}
${tr("Grid")}
${fieldHTML({ k: "mobile", t: "check", l: "Designed for phones" }, !!current.mobile, "layout")}

${tr("A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.")}

${GRID_FIELDS.slice(0, 2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
${tr("Kiosk")}
${fieldHTML({ k: "saver", t: "number", l: "Screen saver after (minutes)", ph: "0 = off" }, k.saver, "kiosk")} ${Number(k.saver) > 0 ? fieldHTML({ k: "saverStyle", t: "seg", l: "Screen saver", o: ["clock", "photos"] }, k.saverStyle, "kiosk") : ""} ${Number(k.saver) > 0 && k.saverStyle === "photos" ? `${fieldHTML({ k: "saverSource", t: "seg", l: "Photos from", o: ["links", "immich"] }, k.saverSource, "kiosk")} ${k.saverSource === "immich" ? fieldHTML({ k: "saverAlbums", t: "albums", l: "Immich albums" }, k.saverAlbums, "kiosk") : fieldHTML({ k: "saverUrls", t: "textarea", l: "Image URLs, one per line" }, k.saverUrls, "kiosk")} ${fieldHTML({ k: "saverInterval", t: "number", l: "Change photo every (seconds)" }, k.saverInterval, "kiosk")}` : ""} ${fieldHTML({ k: "exitCorner", t: "select", l: "Exit corner (tap it 5 times to leave)", o: ["top-right", "top-left", "bottom-right", "bottom-left"] }, k.exitCorner, "kiosk")} ${fieldHTML({ k: "hideCursor", t: "check", l: "Hide the pointer when idle" }, k.hideCursor, "kiosk")} ${current.tabs.length > 1 ? fieldHTML({ k: "rotate", t: "number", l: "Go to the next tab every (seconds)", ph: "0 = off" }, k.rotate, "kiosk") : ""} ${fieldHTML({ k: "announce", t: "check", l: "Show announcements from Home Assistant" }, k.announce, "kiosk")} ${fieldHTML({ k: "clickSound", t: "check", l: "A soft click when something is pressed" }, k.clickSound, "kiosk")} ${fieldHTML({ k: "doorbell", t: "entity", l: "Doorbell", domains: ["binary_sensor", "event", "input_boolean", "switch"] }, k.doorbell, "kiosk")} ${k.doorbell ? `${fieldHTML({ k: "doorbellCamera", t: "entity", l: "Camera to show", domains: ["camera"] }, k.doorbellCamera, "kiosk")}
${fieldHTML({ k: "doorbellSecs", t: "number", l: "Close after (seconds)" }, k.doorbellSecs, "kiosk")}
${fieldHTML({ k: "doorbellSound", t: "check", l: "Play a sound" }, k.doorbellSound, "kiosk")}` : ""}

${tr("When the doorbell rings in kiosk mode, the camera fills the screen.")}

${tr("On a wall tablet, open kiosk in a new tab to hide the Home Assistant sidebar too.")}

${guestHTML()}
${tr("This device")}
${fieldHTML({ k: "lang", t: "select", l: "Language on this device", o: Object.keys(window.I18n.languages), labels: Object.fromEntries(Object.entries(window.I18n.languages).map(([c, n]) => [c, c === "auto" ? tr(n) : n])) }, window.I18n.preference(), "device")} ${nightHTML()}
${tr("About")}
Dashboard Studio
${tr("Version {0}", esc(APP_VERSION))}
${Icons.svg("globe")}${tr("Source code")}

${tr("Select a widget to edit it.")}

`; fillAlbums(el); } function guestHTML() { const g = (current.guest ||= { ...DEFAULT_GUEST }); return `
${tr("Guest mode")}

${tr("A simple screen with only lights and thermostats, for guests or a babysitter.")}

${fieldHTML({ k: "when", t: "seg", l: "Turns on", o: ["manual", "entity", "times"], labels: { manual: tr("With the button"), entity: tr("With an entity"), times: tr("Between times") } }, g.when, "guest")} ${g.when === "entity" ? fieldHTML({ k: "entity", t: "entity", l: "On while this is on", domains: ["input_boolean", "switch", "schedule", "binary_sensor"] }, g.entity, "guest") : ""} ${g.when === "times" ? `
${fieldHTML({ k: "from", t: "time", l: "From" }, g.from, "guest")}${fieldHTML({ k: "to", t: "time", l: "Until" }, g.to, "guest")}
` : ""} ${fieldHTML({ k: "title", t: "text", l: "Welcome text", ph: tr("Welcome") }, g.title, "guest")} ${fieldHTML({ k: "entities", t: "entities", l: "Lights and thermostats (leave empty for all)", domains: ["light", "climate"] }, g.entities, "guest")}
`; } function nightHTML() { const n = nightCfg(); return `${fieldHTML({ k: "mode", t: "seg", l: "Night mode", o: ["off", "sunset", "times"], labels: { off: tr("Off"), sunset: tr("After sunset"), times: tr("Between times") } }, n.mode, "night")} ${n.mode === "times" ? `
${fieldHTML({ k: "from", t: "time", l: "From" }, n.from, "night")}${fieldHTML({ k: "to", t: "time", l: "Until" }, n.to, "night")}
` : ""} ${n.mode !== "off" ? `${fieldHTML({ k: "dim", t: "number", l: "Dim the screen (%)" }, n.dim, "night")}${fieldHTML({ k: "dark", t: "check", l: "Switch to the dark theme" }, n.dark, "night")}` : ""}

${tr("Night mode is saved on this device only, and is off while editing.")}

`; } /** Album pickers load their list from the server after the inspector is drawn. */ async function fillAlbums(root) { for (const box of root.querySelectorAll(".albums")) { const selected = new Set(JSON.parse(box.dataset.selected || "[]")); try { const albums = await api("api/immich/albums"); if (!box.isConnected) return; box.innerHTML = albums.length ? albums.map((a) => ``).join("") : `
${tr("No albums found in Immich.")}
`; } catch (e) { box.innerHTML = `
${esc(e.message)}
`; } } } /** The list of cards inside a container, with add / edit / reorder / remove. */ function childrenHTML(w) { const kids = w.cfg.children || []; const summary = (c) => c.cfg.name || c.cfg.title || c.cfg.text || c.cfg.label || c.cfg.entity || (c.cfg.entities || [])[0] || ""; const types = Object.entries(Widgets).filter(([, d]) => !d.container); return `
${tr("Cards")}
${kids.length ? "" : `

${tr("Add the cards this container should hold.")}

`}
${kids.map((c, i) => `
`).join("")}
`; } function childOp(op, i) { const w = selectedWidget(); const kids = (w.cfg.children ||= []); if (op === "add") { const type = $("#child-type").value, c = makeWidget(type); mutate(() => kids.push({ id: c.id, type, cfg: c.cfg, style: { transparent: false, bg: "", color: "", accent: "" } })); childIndex = kids.length - 1; // go straight to setting it up } else if (op === "up" || op === "down") { const j = op === "up" ? i - 1 : i + 1; if (j < 0 || j >= kids.length) return; mutate(() => { [kids[i], kids[j]] = [kids[j], kids[i]]; }); } else if (op === "del") { mutate(() => kids.splice(i, 1)); childIndex = null; } else if (op === "out") { // back onto the dashboard as a widget of its own const c = kids[i], def = Widgets[c.type]; const nw = { ...c, w: Math.min(c.w || def.size[0], current.grid.cols), h: c.h || def.size[1] }; delete nw.cfg._width; Object.assign(nw, findSpot(nw.w, nw.h)); mutate(() => { kids.splice(i, 1); tab().widgets.push(nw); }); childIndex = null; select(nw.id); return; } renderInspector(); } /** What touching and holding a widget does: its details by default, or a second action. */ function holdHTML(w) { if (Widgets[w.type]?.container) return ""; const h = w.hold || {}; return `
${tr("Touch and hold")}
${fieldHTML({ k: "action", t: "select", l: "Touch and hold to", o: ["details", "toggle", "service", "none"], labels: { details: tr("Show the details"), toggle: tr("Switch on or off"), service: tr("Run an action"), none: tr("Do nothing") } }, h.action || "details", "hold")} ${h.action === "service" ? `${fieldHTML({ k: "service", t: "text", l: "Action (e.g. scene.turn_on)", ph: "script.good_night" }, h.service, "hold")}${fieldHTML({ k: "entity", t: "entity", l: "Target entity (optional)" }, h.entity, "hold")}${fieldHTML({ k: "data", t: "json", l: "Action data (JSON)" }, h.data, "hold")}` : ""}
`; } function visibilityHTML(w) { const vis = w.visibility || { mode: "all", rules: [] }; const common = ["on", "off", "running", "idle", "playing", "paused", "home", "not_home", "unavailable"]; const rows = vis.rules.map((r, i) => { const s = ha.states[r.entity]; const opts = [...new Set([s?.state, ...common].filter(Boolean))].map((o) => `${d.tabs.map((t) => ``).join("")}`).join(""); const v = await openModal(`

${src.length > 1 ? tr("Copy {0} widgets to…", src.length) : tr("Copy to…")}

`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok" ? $("#copy-target").value : null); }); if (!v) return; const [did, tid] = v.split("|"); const d = store.dashboards.find((x) => x.id === did), t = d?.tabs.find((x) => x.id === tid); if (!t) return; const cols = Number(d.grid?.cols) || 12; const put = () => { for (const w of [...src].sort((a, b) => a.y - b.y || a.x - b.x)) { const c = freshCopy(w); c.w = Math.min(c.w, cols); Object.assign(c, findSpot(c.w, c.h, t.widgets, cols)); t.widgets.push(c); } }; if (d === current) mutate(put); else { put(); scheduleSave(); } toast(tr("Copied to “{0}”.", d.tabs.length > 1 ? `${d.name} · ${t.name}` : d.name)); } function select(id, toggle = false) { if (toggle && id) { if (selectedId && !multi.size) multi.add(selectedId); multi.has(id) ? multi.delete(id) : multi.add(id); selectedId = multi.has(id) ? id : [...multi].pop() ?? null; if (multi.size < 2) multi.clear(); childIndex = null; } else { if (id !== selectedId) childIndex = null; selectedId = id; multi.clear(); } gridEl.querySelectorAll(".w").forEach((el) => el.classList.toggle("selected", isSel(el.dataset.id))); renderInspector(); } // ------------------------------------------------------------ entity picker // Entity fields get a list with each entity's icon, name and id, filtered as you type. const entPop = document.createElement("div"); entPop.id = "ent-pop"; entPop.className = "ent-pop"; entPop.setAttribute("role", "listbox"); entPop.hidden = true; document.body.appendChild(entPop); let entFor = null, entItems = [], entActive = 0; function entOpen(input, typed) { entFor = input; const doms = input.dataset.entPick ? input.dataset.entPick.split(",") : null; const q = (typed || !ha.states[input.value] ? input.value : "").trim().toLowerCase(); const rank = (s) => { const n = friendly(s).toLowerCase(), id = s.entity_id.toLowerCase(); return !q ? 0 : n.startsWith(q) || id.split(".")[1]?.startsWith(q) ? 0 : 1; }; entItems = Object.values(ha.states) .filter((s) => (!doms || doms.includes(domainOf(s.entity_id))) && (!q || s.entity_id.toLowerCase().includes(q) || friendly(s).toLowerCase().includes(q))) .sort((a, b) => rank(a) - rank(b) || friendly(a).localeCompare(friendly(b))) .slice(0, 80); entActive = 0; entDraw(); } function entDraw() { if (!entFor) return; entPop.innerHTML = entItems.length ? entItems.map((s, i) => `
${window.HAFmt.icon(s)}${esc(friendly(s))}${esc(s.entity_id)}${esc(fmtState(s))}
`).join("") : `
${tr("No matching entities")}
`; const r = entFor.getBoundingClientRect(), below = window.innerHeight - r.bottom; entPop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - Math.max(r.width, 300) - 8))}px`; entPop.style.width = `${Math.max(r.width, 300)}px`; entPop.style.maxHeight = `${Math.max(160, Math.min(320, below > 200 ? below - 12 : r.top - 12))}px`; entPop.hidden = false; if (below > 200) { entPop.style.top = `${r.bottom + 4}px`; entPop.style.bottom = ""; } else { entPop.style.top = ""; entPop.style.bottom = `${window.innerHeight - r.top + 4}px`; } entFor.setAttribute("aria-expanded", "true"); entFor.setAttribute("aria-activedescendant", entItems.length ? `ep-${entActive}` : ""); entPop.querySelector(".ep-item.on")?.scrollIntoView({ block: "nearest" }); } function entClose() { entPop.hidden = true; entFor?.setAttribute("aria-expanded", "false"); entFor = null; } function entPick(eid) { const input = entFor; entClose(); if (!input || !eid) return; input.value = eid; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); } document.addEventListener("focusin", (e) => { if (e.target.matches?.("[data-ent-pick]")) entOpen(e.target, false); else if (entFor && !entPop.contains(e.target)) entClose(); }); document.addEventListener("input", (e) => { if (e.target === entFor && e.isTrusted) entOpen(entFor, true); }); document.addEventListener("focusout", (e) => { if (e.target === entFor) setTimeout(() => { if (entFor && document.activeElement !== entFor) entClose(); }, 0); }); document.addEventListener("keydown", (e) => { if (!entFor || e.target !== entFor) return; if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (entPop.hidden) { entOpen(entFor, false); return; } entActive = (entActive + (e.key === "ArrowDown" ? 1 : -1) + entItems.length) % Math.max(1, entItems.length); entDraw(); } else if (e.key === "Enter" && !entPop.hidden && entItems[entActive]) { e.preventDefault(); entPick(entItems[entActive].entity_id); } else if (e.key === "Escape" && !entPop.hidden) { e.preventDefault(); e.stopPropagation(); entPop.hidden = true; entFor.setAttribute("aria-expanded", "false"); } }, true); entPop.addEventListener("pointerdown", (e) => e.preventDefault()); // keep the focus in the field entPop.addEventListener("click", (e) => { const it = e.target.closest("[data-eid]"); if (it) entPick(it.dataset.eid); }); document.addEventListener("scroll", (e) => { if (entFor && !entPop.contains(e.target)) entDraw(); }, true); // ------------------------------------------------------------ drag / resize let drag = null; gridEl.addEventListener("pointerdown", (e) => { if (!editMode || e.button !== 0) return; const el = e.target.closest(".w"); if (!el) { select(null); return; } if (e.target.closest("[data-tool]")) return; const w = tab().widgets.find((x) => x.id === el.dataset.id); if (e.shiftKey || e.metaKey || e.ctrlKey) { select(w.id, true); e.preventDefault(); return; } // By touch, the first tap selects (so the page can still scroll); a selected widget drags. if (e.pointerType === "touch" && w.id !== selectedId && !multi.has(w.id)) { select(w.id); return; } const mode = e.target.closest(".w-resize") ? "resize" : "move"; if (multi.has(w.id) && mode === "move") { startGroupDrag(e, el); return; } if (selectedId !== w.id) select(w.id); const ph = document.createElement("div"); ph.className = "placeholder"; gridEl.appendChild(ph); placeEl(ph, w); drag = { w, el, ph, mode, touch: e.pointerType === "touch", sx: e.clientX, sy: e.clientY, orig: { x: w.x, y: w.y, w: w.w, h: w.h }, next: { x: w.x, y: w.y, w: w.w, h: w.h }, r0: rectFor(w) }; el.classList.add("dragging"); el.setPointerCapture(e.pointerId); e.preventDefault(); }); /** Dragging one of several selected widgets moves them all, keeping their layout. */ function startGroupDrag(e, el) { const items = selection().map((w) => { const wel = gridEl.querySelector(`.w[data-id="${w.id}"]`), ph = document.createElement("div"); ph.className = "placeholder"; gridEl.appendChild(ph); placeEl(ph, w); wel?.classList.add("dragging"); return { w, el: wel, ph, r0: rectFor(w) }; }); drag = { group: items, sx: e.clientX, sy: e.clientY, cx: 0, cy: 0 }; el.setPointerCapture(e.pointerId); e.preventDefault(); } function moveGroup(e) { const { colW, gap, rh, cols } = metrics, ws = drag.group.map((g) => g.w); const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy; const minX = Math.min(...ws.map((w) => w.x)), maxR = Math.max(...ws.map((w) => w.x + w.w)), minY = Math.min(...ws.map((w) => w.y)); drag.cx = Math.max(-minX, Math.min(cols - maxR, Math.round(dx / (colW + gap)))); drag.cy = Math.max(-minY, Math.round(dy / (rh + gap))); const pos = positions(); for (const g of drag.group) { if (g.el) { g.el.style.left = `${g.r0.left + dx}px`; g.el.style.top = `${g.r0.top + dy}px`; } const n = { x: g.w.x + drag.cx, y: g.w.y + drag.cy, w: g.w.w, h: g.w.h }; placeEl(g.ph, n); pos[g.w.id] = n; } updateGridHeight(pos); } /** The scroll or swipe card under the pointer, if a dragged widget could be dropped into it. */ function containerAt(x, y) { for (const n of document.elementsFromPoint(x, y)) { for (let c = n.closest?.(".w"); c; c = c.parentElement?.closest(".w")) { if (c === drag.el) continue; const t = tab().widgets.find((o) => o.id === c.dataset.id); if (t && Widgets[t.type]?.container) return c; } } return null; } gridEl.addEventListener("pointermove", (e) => { if (!drag) return; if (drag.group) { moveGroup(e); return; } const { colW, gap, rh, cols } = metrics; const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy; const o = drag.orig, n = drag.next; if (drag.mode === "move") { drag.el.style.left = `${drag.r0.left + dx}px`; drag.el.style.top = `${drag.r0.top + dy}px`; n.x = Math.max(0, Math.min(cols - o.w, o.x + Math.round(dx / (colW + gap)))); n.y = Math.max(0, o.y + Math.round(dy / (rh + gap))); } else { drag.el.style.width = `${Math.max(colW, drag.r0.width + dx)}px`; drag.el.style.height = `${Math.max(rh, drag.r0.height + dy)}px`; n.w = Math.max(1, Math.min(cols - o.x, o.w + Math.round(dx / (colW + gap)))); n.h = Math.max(1, o.h + Math.round(dy / (rh + gap))); } placeEl(drag.ph, n); showGuides(n, drag.w); if (drag.mode === "move" && !Widgets[drag.w.type]?.container) { const into = containerAt(e.clientX, e.clientY); if (into !== drag.into) { drag.into?.classList.remove("drop-into"); into?.classList.add("drop-into"); drag.into = into; } drag.ph.hidden = !!into; } const pos = positions(); pos[drag.w.id] = n; updateGridHeight(pos); }); /** Lines where the dragged widget's edges line up with other widgets, like in design apps. */ function showGuides(n, w) { gridEl.querySelectorAll(".guide").forEach((g) => g.remove()); const { colW, gap, rh } = metrics; const others = tab().widgets.filter((o) => o !== w); const xs = new Set(others.flatMap((o) => [o.x, o.x + o.w])), ys = new Set(others.flatMap((o) => [o.y, o.y + o.h])); const line = (cls, px) => { const g = document.createElement("div"); g.className = `guide ${cls}`; g.style[cls === "v" ? "left" : "top"] = `${Math.max(0, px)}px`; gridEl.appendChild(g); }; for (const x of new Set([n.x, n.x + n.w])) if (xs.has(x)) line("v", x * (colW + gap) - gap / 2); for (const y of new Set([n.y, n.y + n.h])) if (ys.has(y)) line("h", y * (rh + gap) - gap / 2); } function endDrag() { gridEl.querySelectorAll(".guide").forEach((g) => g.remove()); if (!drag) return; if (drag.group) { const { group, cx, cy } = drag; drag = null; group.forEach((g) => { g.ph.remove(); g.el?.classList.remove("dragging"); }); if (!cx && !cy) { group.forEach((g) => g.el && placeEl(g.el, g.w)); return; } mutate(() => { group.forEach((g) => { g.w.x += cx; g.w.y += cy; }); group.map((g) => g.w).sort((a, b) => a.y - b.y).forEach(resolveCollisions); }); return; } const { w, ph, el, orig, next, into, touch: touchEnd } = drag; ph.remove(); el.classList.remove("dragging"); into?.classList.remove("drop-into"); drag = null; if (into) { // dropped onto a scroll or swipe card: it becomes one of its cards const box = tab().widgets.find((o) => o.id === into.dataset.id); mutate(() => { tab().widgets = tab().widgets.filter((o) => o !== w); (box.cfg.children ||= []).push({ id: w.id, type: w.type, cfg: w.cfg, style: w.style, w: w.w, h: w.h, ...(w.visibility ? { visibility: w.visibility } : {}) }); }); select(box.id); toast(tr("Moved into “{0}”.", tr(Widgets[box.type].label))); return; } if (orig.x === next.x && orig.y === next.y && orig.w === next.w && orig.h === next.h) { placeEl(el, w); if (touchEnd && phoneEditing()) openSheet("inspector"); // tapping the selected widget opens its settings return; } mutate(() => { Object.assign(w, next); resolveCollisions(w); }); if (!multi.has(w.id)) select(w.id); } gridEl.addEventListener("pointerup", endDrag); gridEl.addEventListener("pointercancel", endDrag); // ------------------------------------------------------------ actions (view mode) function defaultAction(eid) { const d = domainOf(eid), s = ha.states[eid]; switch (d) { case "scene": case "script": return ha.callService(d, "turn_on", {}, eid); case "button": case "input_button": return ha.callService(d, "press", {}, eid); case "lock": return ha.callService("lock", s?.state === "locked" ? "unlock" : "lock", {}, eid); case "cover": return ha.callService("cover", "toggle", {}, eid); case "media_player": return ha.callService("media_player", "media_play_pause", {}, eid); case "climate": return ha.callService("climate", s?.state === "off" ? "turn_on" : "turn_off", {}, eid); default: return ha.callService("homeassistant", "toggle", {}, eid); } } gridEl.addEventListener("click", (e) => { if (!editMode && e.target.closest("[data-act]")) haptic(); if (editMode) { const tool = e.target.closest("[data-tool]"); if (tool) { const id = tool.closest(".w").dataset.id; toolAction(tool.dataset.tool, multi.has(id) ? [...multi] : [id]); } return; } const a = e.target.closest("[data-act]"); if (!a || a.tagName === "INPUT") return; const eid = a.dataset.e, act = a.dataset.act; if (act === "toggle") defaultAction(eid); else if (act === "more") moreInfo(eid); else if (act === "temp") { const s = ha.states[eid]; const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10; ha.callService("climate", "set_temperature", { temperature: t }, eid); } else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid); else if (act === "gate") gateAction(eid, a.dataset.v === "1"); else if (act === "card-tab") { // a card's own tabs (Formula One): kept in its cache, not saved const id = a.closest(".w[data-id]")?.dataset.id; (caches[id] ||= {}).tab = a.dataset.v; const top = tab().widgets.find((w) => w.id === id || findWidget(id, w.cfg.children || [])); if (top) renderWidget(top); } else if (act === "light-color") { const [kind, val] = a.dataset.v.split(":"); ha.callService("light", "turn_on", kind === "k" ? { color_temp_kelvin: Number(val) } : { rgb_color: val.split(",").map(Number) }, eid); if (caches[a.closest(".w[data-id]")?.dataset.id]?.colors) toggleLightColors(a); // a colour picked: the row closes again } else if (act === "light-colors") toggleLightColors(a); else if (act === "light-popup") lightPopup(eid); else if (act === "todo-add") todoAdd(a.closest(".todo-add")); else if (act === "alarm-key") { const box = a.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id], k = a.dataset.v; cache.code = k === "C" ? "" : k === "⌫" ? (cache.code || "").slice(0, -1) : ((cache.code || "") + k).slice(0, 12); renderWidget(w, box, { child: box.classList.contains("child") }); } else if (act === "alarm") alarmAction(a, eid); else if (act === "note-dismiss") ha.callService("persistent_notification", "dismiss", { notification_id: a.dataset.v }); else if (act === "note-dismiss-all") ha.callService("persistent_notification", "dismiss_all", {}); else if (act === "update-install") { const s = ha.states[eid]; confirmAction(tr("Install {0}?", s?.attributes.title || friendly(s)), tr("Version {0} will be installed. Home Assistant may restart.", s?.attributes.latest_version || ""), tr("Install")) .then((ok) => { if (ok) ha.callService("update", "install", {}, eid); }); } else if (act === "todo-toggle" || act === "todo-del") { const box = a.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id], item = (cache.items || []).find((i) => i.uid === a.dataset.v); if (!item) return; const call = act === "todo-del" ? ha.callService("todo", "remove_item", { item: item.uid }, eid) : ha.callService("todo", "update_item", { item: item.uid, status: item.status === "completed" ? "needs_action" : "completed" }, eid); item.status = act === "todo-del" ? "deleted" : item.status === "completed" ? "needs_action" : "completed"; // show it right away if (act === "todo-del") cache.items = cache.items.filter((i) => i !== item); renderWidget(w, box, { child: box.classList.contains("child") }); call.then(() => { cache.bump = (cache.bump || 0) + 1; renderWidget(w, gridEl.querySelector(`.w[data-id="${w.id}"]`), { child: box.classList.contains("child") }); }); } else if (act === "todo-showdone") { const box = a.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id]; cache.showDone = !(cache.showDone ?? w.cfg.showDone); renderWidget(w, box, { child: box.classList.contains("child") }); } else if (act === "timer") { const mins = Number(a.dataset.d); const data = mins > 0 ? { duration: `${String(Math.floor(mins / 60)).padStart(2, "0")}:${String(mins % 60).padStart(2, "0")}:00` } : {}; ha.callService("timer", a.dataset.v, data, eid); } else if (act === "timer-ok") { const box = a.closest(".w"), w = findWidget(box.dataset.id); caches[w.id].doneUntil = 0; renderWidget(w, box, { child: box.classList.contains("child") }); } else if (act === "vac-fan") ha.callService("vacuum", "set_fan_speed", { fan_speed: a.dataset.v }, eid); else if (act === "mp-select") { const box = a.closest(".w"), w = findWidget(box.dataset.id); caches[w.id].sel = a.dataset.v; renderWidget(w, box, { child: box.classList.contains("child") }); box.querySelector(`[data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus(); } else if (act === "hs-prev" || act === "hs-next") { const track = a.closest(".hs").querySelector(".hs-track"); track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" }); } else if (act === "svc-many") { const [d, sv] = a.dataset.v.split("."); ha.callService(d, sv, {}, a.dataset.ids.split(",")); } else if (act === "photo-next") { const c = caches[a.closest(".w").dataset.id]; (c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg } else if (act === "sw-go") caches[a.closest(".w:not(.child)").dataset.id]?.go?.(Number(a.dataset.v)); else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); } else if (act === "button") { if (runButton(findWidget(a.closest(".w").dataset.id).cfg)) a.animate?.([{ transform: "scale(.96)" }, { transform: "scale(1)" }], 160); } }); gridEl.addEventListener("focusout", (e) => { const box = e.target.closest?.(".w"); if (!box || !e.target.matches("input[type=text]")) return; const w = findWidget(box.dataset.id), cache = w && caches[w.id]; if (cache?.typing) { cache.typing = false; setTimeout(() => renderWidget(w, gridEl.querySelector(`.w[data-id="${w.id}"]`), { child: box.classList.contains("child") }), 150); } }); gridEl.addEventListener("input", (e) => { const t = e.target; if (!t.dataset.act) return; interacting = true; t.style.setProperty("--pct", `${t.value}%`); // a light's fader: the fill and the figure follow the finger const fd = t.closest(".fader"); if (fd) { fd.style.setProperty("--pct", `${t.value}%`); fd.classList.add("on"); const big = fd.querySelector(".lt-big, .lf-pct, .rd-pct"); if (big) big.textContent = `${t.value}%`; } }); gridEl.addEventListener("change", (e) => { const t = e.target, eid = t.dataset.e; if (!editMode) haptic(); interacting = false; if (t.dataset.act === "brightness") ha.callService("light", "turn_on", { brightness_pct: Number(t.value) }, eid); if (t.dataset.act === "volume") ha.callService("media_player", "volume_set", { volume_level: Number(t.value) / 100 }, eid); if (t.dataset.act === "cover-pos") ha.callService("cover", "set_cover_position", { position: Number(t.value) }, eid); if (t.dataset.act === "cover-tilt") ha.callService("cover", "set_cover_tilt_position", { tilt_position: Number(t.value) }, eid); }); /** The light card's colours, behind their button: open or closed, kept on this device and not saved. */ function toggleLightColors(btn) { const box = btn.closest(".w[data-id]"), id = box?.dataset.id, cache = (caches[id] ||= {}); cache.colors = !cache.colors; const w = findWidget(id); // also finds a light card inside a scroll or swipe card if (w) renderWidget(w, box, { child: box.classList.contains("child") }); } // ------------------------------------------------------------ dialogs let modalClose = null; function openModal(html, onClick) { const card = $("#modal .modal-card"); card.innerHTML = html; $("#modal").hidden = false; const prevFocus = document.activeElement; return new Promise((resolve) => { modalClose = (result) => { $("#modal").hidden = true; modalClose = null; prevFocus?.focus?.(); resolve(result); }; card.onclick = (e) => onClick?.(e, modalClose); (card.querySelector("[autofocus]") || card.querySelector("button"))?.focus(); }); } $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") modalClose?.(null); }); /** A short sheet with a text field. Resolves to the text, or null when cancelled. */ function askText(title, value, confirmLabel) { return openModal(`

${esc(title)}

`, (e, close) => { if (e.target.dataset.x === "cancel") close(null); }); } document.addEventListener("submit", (e) => { if (e.target.id !== "ask-form") return; e.preventDefault(); const v = $("#ask-input").value.trim(); if (v) modalClose?.(v); }); /** Like confirmDestructive, for actions that aren't destructive. */ /** A button widget's action: its own action with data, or the default for its entity. */ function runButton(c) { let data = {}; if (c.data) { try { data = JSON.parse(c.data); } catch { toast(tr("The button’s action data isn’t valid JSON. Fix it in the inspector."), true); return false; } } if (c.service && c.service.includes(".")) { const [d, sv] = c.service.split("."); ha.callService(d, sv, data, c.entity || undefined); } else if (c.entity) defaultAction(c.entity); else { toast(tr("This button has no action yet. Set one in the inspector."), true); return false; } return true; } /** A home screen shortcut (long-press the app icon): runs a widget's main action, wherever it is. */ async function runShortcut(id) { let w = null; for (const d of store.dashboards) for (const t of d.tabs) w ||= findWidget(id, t.widgets); if (!w) { toast(tr("That shortcut’s widget no longer exists."), true); return; } await ha.ready; const eid = w.cfg.entity || (w.cfg.entities || [])[0]; if (w.type === "button") { if (!runButton(w.cfg)) return; } else if (w.type === "gate") await gateAction(eid, w.cfg.confirm !== false); else if (eid) defaultAction(eid); else return; toast(tr("Done: {0}", w.shortcut || w.cfg.name || w.cfg.label || friendly(ha.states[eid] || { entity_id: eid }))); } /** The garage and gate card: open, close or stop, after a check when the card asks for one. */ async function gateAction(eid, ask) { const s = ha.states[eid]; if (!s) return; const d = domainOf(eid), open = ["open", "opening", "unlocked", "unlocking", "on"].includes(s.state), moving = ["opening", "closing"].includes(s.state); const verb = d === "button" ? "Press" : moving ? "Stop" : open ? (d === "lock" ? "Lock" : "Close") : d === "lock" ? "Unlock" : "Open"; if (ask && !(await confirmAction(tr(`${verb} {0}?`, friendly(s)), "", tr(verb)))) return; if (d === "cover") ha.callService("cover", moving ? "stop_cover" : open ? "close_cover" : "open_cover", {}, eid); else if (d === "lock") ha.callService("lock", open ? "lock" : "unlock", {}, eid); else if (d === "button") ha.callService("button", "press", {}, eid); else ha.callService(d, "toggle", {}, eid); } function confirmAction(title, message, label) { return openModal(`

${esc(title)}

${esc(message)}

`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok"); }); } function confirmDestructive(title, message, label) { return openModal(`

${esc(title)}

${esc(message)}

`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok"); }); } // ------------------------------------------------------------ climate popup let livePopup = null; const HVAC_ICON = { off: "power", heat: "flame", cool: "snowflake", heat_cool: "thermometer", auto: "sparkles", dry: "droplet", fan_only: "fan" }; /** A thermostat dial with −/+, modes, presets and fan modes. Taps on −/+ add up and are sent after a short pause. */ function climatePopup(eid) { let pending = null, sendTimer = 0; const step = () => Number(ha.states[eid]?.attributes.target_temp_step) || 0.5; const draw = () => { const s = ha.states[eid]; if (!s) return; const a = s.attributes, min = Number(a.min_temp ?? 7), max = Number(a.max_temp ?? 30); const target = pending ?? a.temperature; const off = s.state === "off"; const color = { heat: "var(--warn)", cool: "var(--accent)", heat_cool: "var(--ok)", auto: "var(--ok)" }[s.state] || "var(--text-2)"; // 270° dial: track, the target as an arc and a knob, the current temperature as a small dot const A0 = Math.PI * 0.75, SPAN = Math.PI * 1.5, R = 86; const ang = (v) => A0 + SPAN * Math.max(0, Math.min(1, (v - min) / (max - min || 1))); const pt = (v, r = R) => [100 + r * Math.cos(ang(v)), 100 + r * Math.sin(ang(v))]; const arc = (v0, v1) => { const [x0, y0] = pt(v0), [x1, y1] = pt(v1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(v1) - ang(v0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; }; const hasTarget = target != null && isFinite(Number(target)); const cur = Number(a.current_temperature); const chips = (list, current, kind) => (list || []).map((m) => ``).join(""); const card = $("#modal .modal-card"); card.innerHTML = `

${esc(friendly(s))}

${tr("Now")} ${isFinite(cur) ? cur + "°" : "–"}${a.current_humidity != null ? ` · ${tr("Humidity")} ${esc(a.current_humidity)}%` : ""}${a.hvac_action ? ` · ${esc(window.I18n.state(a.hvac_action))}` : ""}

${esc(window.I18n.state(s.state))} ${hasTarget ? `${Number(target).toLocaleString(LOCALE, { minimumFractionDigits: step() < 1 ? 1 : 0, maximumFractionDigits: 1 })}°` : "–"} ${tr("Target")}
${hasTarget ? `
` : ""} ${a.hvac_modes?.length ? `
${tr("Mode")}
${chips(a.hvac_modes, s.state, "hvac")}
` : ""} ${a.preset_modes?.length ? `
${tr("Preset")}
${chips(a.preset_modes, a.preset_mode, "preset")}
` : ""} ${a.fan_modes?.length ? `
${tr("Fan")}
${chips(a.fan_modes, a.fan_mode, "fan")}
` : ""} `; }; livePopup = { eid, draw }; const done = openModal("", (e, close) => { const b = e.target.closest("[data-cl], [data-x]"); if (!b) return; if (b.dataset.x) { close(); return; } const s = ha.states[eid], kind = b.dataset.cl; if (kind === "temp") { const a = s.attributes, base = pending ?? Number(a.temperature); pending = Math.round(Math.max(Number(a.min_temp ?? 7), Math.min(Number(a.max_temp ?? 30), base + Number(b.dataset.v) * step())) * 10) / 10; draw(); clearTimeout(sendTimer); sendTimer = setTimeout(() => { const v = pending; ha.callService("climate", "set_temperature", { temperature: v }, eid).then(() => { if (pending === v) pending = null; }); }, 700); } else if (kind === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: b.dataset.v }, eid); else if (kind === "preset") ha.callService("climate", "set_preset_mode", { preset_mode: b.dataset.v }, eid); else if (kind === "fan") ha.callService("climate", "set_fan_mode", { fan_mode: b.dataset.v }, eid); }); draw(); $("#modal .modal-card [data-x]")?.focus(); done.then(() => { livePopup = null; }); } /** Arm or disarm, with the code typed on the card's keypad. Arming with doors or windows open asks first. */ async function alarmAction(a, eid) { const box = a.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id], svc = a.dataset.v; if (svc !== "alarm_disarm") { const sel = w.cfg.openings?.length ? w.cfg.openings.map((e) => ha.states[e]).filter(Boolean) : Object.values(ha.states).filter((s) => s.entity_id.startsWith("binary_sensor.") && ["door", "window", "opening", "garage_door"].includes(s.attributes.device_class)); const open = sel.filter((s) => s.state === "on"); if (open.length && !(await confirmAction(tr("Turn on the alarm anyway?"), tr("{0} still open: {1}", open.length, open.map((s) => friendly(s)).join(", ")), tr("Turn on")))) return; } const data = cache.code ? { code: cache.code } : {}; cache.code = ""; renderWidget(w, gridEl.querySelector(`.w[data-id="${w.id}"]`), { child: box.classList.contains("child") }); ha.callService("alarm_control_panel", svc, data, eid); } /** Add what was typed to a to-do list; the list refreshes once Home Assistant has it. */ function todoAdd(form) { const input = form?.querySelector(".todo-input"), item = input?.value.trim(); if (!item) return; const box = form.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id]; input.value = ""; (cache.items ||= []).unshift({ uid: `pending-${Date.now()}`, summary: item, status: "needs_action" }); renderWidget(w, box, { child: box.classList.contains("child") }); box.querySelector(".todo-input")?.focus(); ha.callService("todo", "add_item", { item }, form.dataset.e).then(() => { cache.bump = (cache.bump || 0) + 1; renderWidget(w, gridEl.querySelector(`.w[data-id="${w.id}"]`), { child: box.classList.contains("child") }); }); } gridEl.addEventListener("submit", (e) => { if (e.target.matches(".todo-add")) { e.preventDefault(); todoAdd(e.target); } }); /** One lamp (or group): on/off, a big brightness slider, color temperature, swatches and any color. Follows the light live. */ function lightPopup(eid) { const H = window.HAFmt; let holding = false; // don't redraw under a finger that is dragging a slider const draw = () => { const s = ha.states[eid]; if (!s || holding) return; const a = s.attributes, on = s.state === "on"; const pct = on ? Math.round((a.brightness || 255) / 2.55) : 0; const dimmable = (a.supported_color_modes || []).some((m) => m !== "onoff"); const kMin = Number(a.min_color_temp_kelvin) || 2000, kMax = Number(a.max_color_temp_kelvin) || 6500, k = Number(a.color_temp_kelvin) || Math.round((kMin + kMax) / 2); const rgb = Array.isArray(a.rgb_color) ? "#" + a.rgb_color.map((v) => Number(v).toString(16).padStart(2, "0")).join("") : "#ffd58a"; const sw = (list) => list.map(([v, col, lbl]) => ``).join(""); $("#modal .modal-card").innerHTML = `
${H.icon(s)}

${esc(friendly(s))}

${esc(on ? (pct ? `${pct}%` : tr("On")) : tr("Off"))}

${dimmable ? `
${tr("Brightness")}
` : ""} ${H.canTemp(s) ? `
${tr("Color temperature")}
${sw(H.WHITES)}
` : ""} ${H.canColor(s) ? `
${tr("Color")}
${sw(H.COLORS)}
` : ""} `; }; livePopup = { eid, draw }; const card = $("#modal .modal-card"); const done = openModal("", (e, close) => { const b = e.target.closest("[data-lp], [data-x]"); if (!b) return; if (b.dataset.x) { close(); return; } if (b.dataset.lp === "toggle") ha.callService("light", ha.states[eid]?.state === "on" ? "turn_off" : "turn_on", {}, eid); if (b.dataset.lp === "color") { const [kind, val] = b.dataset.v.split(":"); ha.callService("light", "turn_on", kind === "k" ? { color_temp_kelvin: Number(val) } : { rgb_color: val.split(",").map(Number) }, eid); } }); const onInput = (e) => { if (e.target.dataset.lp === "brightness") e.target.style.setProperty("--pct", `${e.target.value}%`); }; const onChange = (e) => { const f = e.target, kind = f.dataset.lp; holding = false; if (kind === "brightness") ha.callService("light", "turn_on", { brightness_pct: Number(f.value) }, eid); if (kind === "kelvin") ha.callService("light", "turn_on", { color_temp_kelvin: Number(f.value) }, eid); if (kind === "rgb") ha.callService("light", "turn_on", { rgb_color: [1, 3, 5].map((i) => parseInt(f.value.slice(i, i + 2), 16)) }, eid); }; const onDown = (e) => { if (e.target.matches("input[type=range]")) holding = true; }; card.addEventListener("input", onInput); card.addEventListener("change", onChange); card.addEventListener("pointerdown", onDown); draw(); card.querySelector("[data-x]")?.focus(); done.then(() => { livePopup = null; card.removeEventListener("input", onInput); card.removeEventListener("change", onChange); card.removeEventListener("pointerdown", onDown); }); } function moreInfo(eid) { const s = ha.states[eid]; if (!s) return; if (domainOf(eid) === "light") { lightPopup(eid); return; } if (domainOf(eid) === "climate") { climatePopup(eid); return; } const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid)); const actionLabel = domainOf(eid) === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(domainOf(eid)) ? tr("Run") : isOn(s) ? tr("Turn off") : tr("Turn on"); openModal(`

${esc(friendly(s))}

${esc(eid)}
${esc(fmtState(s))}
${esc(tr("Changed {0}", relTime(s.last_changed)))}
${Object.entries(s.attributes).map(([k, v]) => ``).join("")}
${esc(cap(k))}${esc(typeof v === "object" ? JSON.stringify(v) : v)}
`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x === "toggle") defaultAction(eid); if (x) close(); }); } /** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */ async function whatsNew() { let md = ""; try { md = await fetch(new URL("api/changelog", BASE)).then((r) => r.text()); } catch { /* shown as empty below */ } const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1"); let html = "", inList = false; for (const line of md.split("\n")) { if (/^- /.test(line)) { if (!inList) { html += "
    "; inList = true; } html += `
  • ${inline(line.slice(2))}
  • `; continue; } if (inList) { html += "
"; inList = false; } if (/^## /.test(line)) html += `

${inline(line.slice(3))}${line.slice(3).trim() === APP_VERSION ? ` · ${tr("this version")}` : ""}

`; } if (inList) html += ""; const card = $("#modal .modal-card"); const done = openModal(`

${tr("What’s new")}

${html || `

${tr("The changelog couldn’t be loaded.")}

`}
`, (e, close) => { if (e.target.closest("[data-x]")) close(); }); card.classList.add("wide"); card.querySelector(".changelog").scrollIntoView({ block: "start" }); // start at the newest version, not at the Close button card.scrollTop = 0; await done; card.classList.remove("wide"); } /** A searchable grid of every icon; choosing one fills the given field as if it was typed. */ async function iconPicker(input) { if (!input) return; const grid = (f) => Icons.search(f).map((n) => ``).join("") || `

${tr("No icons match.")}

`; const card = $("#modal .modal-card"); const done = openModal(`

${tr("Choose an icon")}

${grid("")}

${tr("You can also type an emoji in the field instead.")}

`, (e, close) => { const b = e.target.closest("[data-name], [data-x]"); if (!b) return; if (b.dataset.name) close(b.dataset.name); else close(b.dataset.x === "none" ? "" : null); }); card.classList.add("wide"); const search = card.querySelector(".ip-search"); search.addEventListener("input", () => { card.querySelector(".ip-grid").innerHTML = grid(search.value); }); search.focus(); const name = await done; card.classList.remove("wide"); if (name == null) return; input.value = name; input.dispatchEvent(new Event("change", { bubbles: true })); } function toast(msg, err) { const el = document.createElement("div"); el.className = "toast" + (err ? " err" : ""); el.textContent = msg; $("#toasts").appendChild(el); setTimeout(() => el.remove(), err ? 6000 : 3500); } // ------------------------------------------------------------ kiosk // No visible controls. Tapping one screen corner 5 times quickly opens a // short-lived exit prompt; Escape does the same on a keyboard. const CORNER_PX = 90, CORNER_TAPS = 5, CORNER_WINDOW_MS = 2500, PROMPT_MS = 8000; let kiosk = false, swallowClick = false, wakeLock = null, idleTimer = 0, saverTimer = 0; let cornerTaps = [], promptOpen = false; async function requestWakeLock() { try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; } window.HavenApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself } const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " "); function enterKiosk({ fullscreen = true } = {}) { if (editMode) setEdit(false); kiosk = true; document.body.classList.add("kiosk"); if (fullscreen) toggleFullscreen(true); requestWakeLock(); doorbellPrev = ha.states[current.kiosk.doorbell]?.state; history.replaceState(null, "", `#kiosk&view=${current.id}`); renderGrid(); poke(); if (!lockedKiosk) toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS)); } function exitKiosk() { if (lockedKiosk) return; kiosk = false; document.body.classList.remove("kiosk", "idle"); hideSaver(); closeDoorbell(); clearTimeout(idleTimer); clearTimeout(saverTimer); wakeLock?.release?.(); wakeLock = null; window.HavenApp?.keepAwake(false); if (document.fullscreenElement) document.exitFullscreen?.(); history.replaceState(null, "", location.pathname); renderGrid(); } function toggleFullscreen(quiet) { if (document.fullscreenElement) { document.exitFullscreen?.(); return; } const p = document.documentElement.requestFullscreen?.(); if (!p) { if (!quiet) toast(tr("Full screen isn’t available in this browser."), true); return; } p.catch(() => { if (!quiet) toast(tr("Home Assistant’s frame blocks full screen. Use “New tab” in the kiosk settings instead."), true); }); } /** The exit prompt closes itself if nobody answers, so the screen goes back to the dashboard. */ async function kioskPrompt() { if (promptOpen || modalClose) return; promptOpen = true; const timer = setTimeout(() => modalClose?.(null), PROMPT_MS * (lockedKiosk ? 2 : 1)); // People who aren't admins stay in kiosk mode: the corner switches between the dashboards they may see. const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : []; const choice = await openModal(lockedKiosk ? `

${tr("Dashboards")}

${me?.guest && me.until ? `

${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}

` : ""}${others.length ? `
${others.map((d) => ``).join("")}
` : `

${tr("This is the only dashboard you can see. An admin can add more for you.")}

`} ` : `

${tr("Leave kiosk mode?")}

${tr("This closes by itself in a few seconds.")}

`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); }); clearTimeout(timer); promptOpen = false; if (choice?.startsWith("dash:")) { setCurrent(choice.slice(5)); lsSet("ds.current", current.id); } else if (choice === "exit") exitKiosk(); else if (choice === "haven") window.HavenApp.reset(); else if (choice === "full") toggleFullscreen(false); else if (choice === "guest") guestOn ? endGuest() : setGuestManual(true); } function inExitCorner(e) { const c = current.kiosk.exitCorner || "top-right"; const x = c.endsWith("left") ? e.clientX : window.innerWidth - e.clientX; const y = c.startsWith("top") ? e.clientY : window.innerHeight - e.clientY; return x < CORNER_PX && y < CORNER_PX; } /** Any activity: wake from the screen saver, count corner taps, reset idle timers. */ function poke(e) { if (!kiosk) return; if (!$("#saver").hidden) { hideSaver(); if (e?.type === "pointerdown") swallowClick = true; // the waking tap shouldn't also press a tile } else if (e?.type === "pointerdown" && !modalClose && inExitCorner(e)) { swallowClick = true; // corner taps never press whatever widget sits there const now = Date.now(); cornerTaps = [...cornerTaps.filter((t) => now - t < CORNER_WINDOW_MS), now]; if (cornerTaps.length >= CORNER_TAPS) { cornerTaps = []; kioskPrompt(); } } document.body.classList.remove("idle"); clearTimeout(idleTimer); if (current.kiosk.hideCursor) idleTimer = setTimeout(() => document.body.classList.add("idle"), 3000); clearTimeout(saverTimer); const mins = Number(current.kiosk.saver) || 0; if (mins > 0) saverTimer = setTimeout(showSaver, mins * 60000); } const saverCache = {}; function showSaver() { if (!kiosk || modalClose || !$("#doorbell").hidden) return; const k = current.kiosk, el = $("#saver"), photos = k.saverStyle === "photos"; el.classList.toggle("photo", photos); updateSaver(); el.hidden = false; if (photos) window.HAFmt.slideshow(el, $("#saver .photos"), { api }, saverCache, { source: k.saverSource, urls: k.saverUrls, albums: k.saverAlbums, shuffle: true, interval: k.saverInterval || 30 }); } function hideSaver() { $("#saver").hidden = true; clearTimeout(saverCache.timer); } function updateSaver() { const d = new Date(); $("#saver .time").textContent = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); $("#saver .date").textContent = d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" }); } for (const ev of ["pointerdown", "pointermove", "keydown", "wheel"]) document.addEventListener(ev, poke, { capture: true, passive: true }); document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true); document.addEventListener("visibilitychange", () => { if (kiosk && document.visibilityState === "visible") requestWakeLock(); }); // ------------------------------------------------------------ doorbell // In kiosk mode a ring (a sensor turning on, or a new doorbell event) shows the camera full screen. let doorbellPrev, doorbellTimer = 0; function checkDoorbell() { const k = current?.kiosk, s = k?.doorbell && ha.states[k.doorbell]; if (!s) return; const prev = doorbellPrev; doorbellPrev = s.state; if (prev === undefined || prev === s.state || !kiosk) return; const rang = domainOf(k.doorbell) === "event" ? !["unavailable", "unknown"].includes(s.state) : s.state === "on"; if (rang) ringDoorbell(); } function ringDoorbell() { const k = current.kiosk, el = $("#doorbell"); hideSaver(); clearTimeout(saverTimer); if (modalClose) modalClose(null); const cam = k.doorbellCamera; el.querySelector(".db-cam").innerHTML = cam ? `${esc(friendly(ha.states[cam] || { entity_id: cam }))}` : `
${Icons.svg("bell")}
`; el.querySelector(".db-title").textContent = tr("Someone’s at the door"); el.querySelector(".db-close").textContent = tr("Close"); el.hidden = false; if (k.doorbellSound !== false) { window.HAFmt.beep?.(); setTimeout(() => window.HAFmt.beep?.(), 450); } clearTimeout(doorbellTimer); doorbellTimer = setTimeout(closeDoorbell, Math.max(5, Number(k.doorbellSecs) || 60) * 1000); el.querySelector(".db-close").focus(); } function closeDoorbell() { clearTimeout(doorbellTimer); const el = $("#doorbell"); if (el.hidden) return; el.hidden = true; el.querySelector(".db-cam").innerHTML = ""; poke(); // the screen saver counts from here } $("#doorbell").addEventListener("click", (e) => { if (e.target.closest(".db-close")) closeDoorbell(); }); // ------------------------------------------------------------ announcements // An automation fires the event dashboard_studio_announce with a message; open screens show it full screen. let announceTimer = 0; ha.onAnnounce = (d) => { if (!current || editMode || current.kiosk.announce === false || !d.message) return; const me2 = (store.assign?.devices?.[DEVICE_ID]?.name || "").toLowerCase(); const targets = [].concat(d.device || d.devices || []).map((x) => String(x).toLowerCase()); if (targets.length && !targets.includes(me2) && !targets.includes(DEVICE_ID.toLowerCase())) return; const el = $("#announce"); hideSaver(); if (modalClose) modalClose(null); el.querySelector(".an-title").textContent = d.title || tr("Announcement"); el.querySelector(".an-msg").textContent = d.message; el.querySelector(".an-ico").innerHTML = Icons.svg(Icons.has(d.icon) ? d.icon : "megaphone"); el.querySelector(".an-ok").textContent = tr("OK"); el.hidden = false; el.querySelector(".an-ok").focus(); if (d.sound !== false) { window.HAFmt.beep?.(); setTimeout(() => window.HAFmt.beep?.(), 350); } clearTimeout(announceTimer); announceTimer = setTimeout(closeAnnounce, Math.max(5, Number(d.seconds) || 60) * 1000); }; function closeAnnounce() { clearTimeout(announceTimer); $("#announce").hidden = true; poke(); } $("#announce").addEventListener("click", (e) => { if (e.target.closest(".an-ok") || e.target.id === "announce") closeAnnounce(); }); // ------------------------------------------------------------ tab rotation // In kiosk mode the tabs can take turns; touching the screen pauses that for a minute. let lastRotate = Date.now(), lastTouch = 0; document.addEventListener("pointerdown", () => { lastTouch = Date.now(); }, { capture: true, passive: true }); function rotateTick() { const secs = Number(current?.kiosk.rotate) || 0; if (!kiosk || secs <= 0 || current.tabs.length < 2 || (guestOn && !editMode) || modalClose || !$("#saver").hidden) return; const now = Date.now(); if (now - lastTouch < 60000 || now - lastRotate < Math.max(5, secs) * 1000) return; lastRotate = now; const i = current.tabs.findIndex((t) => t.id === tab().id); setTab(current.tabs[(i + 1) % current.tabs.length].id); } // ------------------------------------------------------------ away from home // A dashboard can switch to another tab (garage, alarm, cameras) while you are away, and back when you're home. let awayState = null, awayFrom = null; function awayEntity() { if (!current?.nav?.awayTab) return null; if (current.nav.awayPerson) return current.nav.awayPerson; return Object.values(ha.states).find((x) => x.entity_id.startsWith("person.") && me?.id && x.attributes.user_id === me.id)?.entity_id || null; } function checkAway() { const eid = awayEntity(), s = eid && ha.states[eid]; if (!s || editMode || kiosk || !current.tabs.some((t) => t.id === current.nav.awayTab)) return; const away = s.state !== "home"; if (away === awayState) return; const first = awayState === null; awayState = away; if (away && tab().id !== current.nav.awayTab) { awayFrom = tab().id; setTab(current.nav.awayTab); if (!first) toast(tr("You’re away: showing “{0}”.", tab().name)); } else if (!away && awayFrom && tab().id === current.nav.awayTab) { setTab(awayFrom); awayFrom = null; } } // ------------------------------------------------------------ guest mode // A plain screen with lights and thermostats. Turned on per device with a button, or for // everyone by an entity or a time of day. Editing always shows the real dashboard. let guestOn = false, guestCache = null; const guestManual = () => !!current && lsGet(`ds.guest.${current.id}`) === "1"; function setGuestManual(on) { lsSet(`ds.guest.${current.id}`, on ? "1" : "0"); checkGuest(true); toast(on ? tr("Guest mode is on.") : tr("Guest mode is off.")); } function checkGuest(force) { if (!current) return; const g = current.guest || DEFAULT_GUEST; const skipped = Date.now() < Number(lsGet(`ds.guestskip.${current.id}`) || 0); // ended early on this device const on = guestManual() || (g.when === "entity" && !!g.entity && ["on", "home", "true"].includes(ha.states[g.entity]?.state)) || (g.when === "times" && !skipped && betweenTimes(g.from, g.to)); if (on === guestOn && !force) return; guestOn = on; guestCache = null; document.body.classList.toggle("guest", on && !editMode); $("#btn-guest").hidden = !(on && !editMode); if (!editMode) renderGrid(); } function guestTab() { const g = current.guest || DEFAULT_GUEST, cols = Number(current.grid.cols) || 12; const pickIds = g.entities?.length ? g.entities : Object.keys(ha.states).filter((e) => (e.startsWith("light.") || e.startsWith("climate.")) && !ha.states[e].attributes.entity_id); const key = `${pickIds.join(",")}|${cols}|${g.title}`; if (guestCache?.key === key) return guestCache.tab; const lights = pickIds.filter((e) => e.startsWith("light.")).sort((a, b) => friendly(ha.states[a] || { entity_id: a }).localeCompare(friendly(ha.states[b] || { entity_id: b }))); const climates = pickIds.filter((e) => e.startsWith("climate.")); const widgets = []; const add = (type, cfg, w, h) => { const wd = makeWidget(type); Object.assign(wd.cfg, cfg); wd.id = `guest-${type}-${cfg.entity || "title"}`; wd.w = Math.min(w, cols); wd.h = h; Object.assign(wd, findSpot(wd.w, wd.h, widgets, cols)); widgets.push(wd); }; add("heading", { text: g.title || tr("Welcome"), subtitle: tr("Lights and temperature"), size: 30 }, cols, 1); const tw = cols >= 12 ? 4 : Math.max(2, Math.floor(cols / 2)); for (const e of climates) add("climate", { entity: e }, tw, 3); const lw = cols >= 12 ? 3 : Math.max(2, Math.floor(cols / 2)); for (const e of lights) add("light", { entity: e, colors: false }, lw, 2); guestCache = { key, tab: { id: "guest", name: tr("Guest mode"), icon: "person", widgets } }; return guestCache.tab; } /** Ending guest mode undoes whatever turned it on: the button, the entity, or the rest of today's time window. */ async function endGuest() { if (!(await confirmAction(tr("End guest mode?"), "", tr("End guest mode")))) return; const g = current.guest; if (g.when === "entity" && g.entity && ["input_boolean", "switch"].includes(domainOf(g.entity))) ha.callService(domainOf(g.entity), "turn_off", {}, g.entity); if (g.when === "times" && betweenTimes(g.from, g.to)) { const [h, m] = String(g.to || "0:0").split(":").map(Number), end = new Date(); end.setHours(h || 0, m || 0, 0, 0); if (end < Date.now()) end.setDate(end.getDate() + 1); lsSet(`ds.guestskip.${current.id}`, String(end.getTime())); } setGuestManual(false); } $("#btn-guest").onclick = endGuest; // ------------------------------------------------------------ night mode // Per device: dim the screen and use the dark theme after sunset or between two times. const DEFAULT_NIGHT = { mode: "off", from: "22:00", to: "07:00", dim: 40, dark: true }; let nightDark = false; function nightCfg() { try { return { ...DEFAULT_NIGHT, ...JSON.parse(lsGet("ds.night") || "{}") }; } catch { return { ...DEFAULT_NIGHT }; } } /** Whether the time of day is between two "HH:MM" times, also across midnight. */ function betweenTimes(fromV, toV) { const mins = (v) => { const [h, m] = String(v || "0:0").split(":").map(Number); return (h || 0) * 60 + (m || 0); }; const d = new Date(), now = d.getHours() * 60 + d.getMinutes(), from = mins(fromV), to = mins(toV); return from <= to ? now >= from && now < to : now >= from || now < to; } function isNight(n) { if (n.mode === "sunset") return ha.states["sun.sun"]?.state === "below_horizon"; return n.mode === "times" && betweenTimes(n.from, n.to); } function applyNight() { if (!current) return; const n = nightCfg(), on = !editMode && isNight(n); document.body.classList.toggle("night", on); document.body.style.setProperty("--night-dim", on ? String(Math.min(90, Math.max(0, Number(n.dim) || 0)) / 100) : "0"); const dark = on && !!n.dark; if (dark !== nightDark) { nightDark = dark; applyTheme(); } } // ------------------------------------------------------------ touch: haptics, swiping tabs, pull to refresh, sheets const coarse = matchMedia("(pointer: coarse)"); function haptic() { if (coarse.matches && lsGet("ds.haptics") !== "0") navigator.vibrate?.(8); if (kiosk && current?.kiosk.clickSound) clickSound(); } /** A short, quiet tick, made on the spot (no sound file), for wall tablets that can't vibrate. */ let audioCtx = null; function clickSound() { try { audioCtx ||= new (window.AudioContext || window.webkitAudioContext)(); const t0 = audioCtx.currentTime, o = audioCtx.createOscillator(), g = audioCtx.createGain(); o.type = "triangle"; o.frequency.setValueAtTime(1800, t0); o.frequency.exponentialRampToValueAtTime(600, t0 + 0.04); g.gain.setValueAtTime(0.12, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.05); o.connect(g).connect(audioCtx.destination); o.start(t0); o.stop(t0 + 0.06); } catch { /* no audio */ } } // ------------------------------------------------------------ touch and hold // Holding a widget for half a second runs its second action; the tap that follows is swallowed. let holdTimer = 0, holdStart = null; gridEl.addEventListener("pointerdown", (e) => { clearTimeout(holdTimer); if (editMode || e.button !== 0) return; const el = e.target.closest(".w[data-id]"); if (!el || e.target.closest("input, textarea, select, .hs-nav, .sw-dot")) return; holdStart = { x: e.clientX, y: e.clientY }; holdTimer = setTimeout(() => { const w = findWidget(el.dataset.id); if (!w || !runHold(w)) return; swallowClick = true; haptic(); el.animate?.([{ transform: "scale(.97)" }, { transform: "scale(1)" }], 180); }, 500); }); gridEl.addEventListener("pointermove", (e) => { if (holdStart && Math.hypot(e.clientX - holdStart.x, e.clientY - holdStart.y) > 10) { clearTimeout(holdTimer); holdStart = null; } }); for (const ev of ["pointerup", "pointercancel", "pointerleave"]) gridEl.addEventListener(ev, () => { clearTimeout(holdTimer); holdStart = null; }); gridEl.addEventListener("contextmenu", (e) => { if (!editMode && coarse.matches && e.target.closest(".w")) e.preventDefault(); }); function runHold(w) { const h = w.hold || {}, eid = w.cfg.entity || (w.cfg.entities || [])[0] || h.entity; switch (h.action || "details") { case "none": return false; case "toggle": if (!eid) return false; defaultAction(eid); return true; case "service": { if (!h.service?.includes(".")) return false; let data = {}; if (h.data) { try { data = JSON.parse(h.data); } catch { return false; } } const [d, sv] = h.service.split("."); ha.callService(d, sv, data, h.entity || undefined); return true; } default: if (!eid || !ha.states[eid]) return false; moreInfo(eid); return true; } } // Swipe left or right over the dashboard to go to the next or previous tab (not over cards that swipe themselves). let swipeStart = null; const SWIPE_OWN = ".hs, .sw, input, textarea, select, .swatches, .todo-list, .f1-tabs, .fc-hours, .sf-bars, .us-bars"; canvasEl.addEventListener("touchstart", (e) => { const tch = e.touches[0]; swipeStart = !editMode && e.touches.length === 1 && current.tabs.length > 1 && !(guestOn) && !e.target.closest(SWIPE_OWN) ? { x: tch.clientX, y: tch.clientY, t: Date.now() } : null; }, { passive: true }); canvasEl.addEventListener("touchend", (e) => { if (!swipeStart) return; const tch = e.changedTouches[0], dx = tch.clientX - swipeStart.x, dy = tch.clientY - swipeStart.y, dt = Date.now() - swipeStart.t; swipeStart = null; if (Math.abs(dx) < 70 || Math.abs(dy) > Math.abs(dx) * 0.6 || dt > 700) return; const i = current.tabs.findIndex((t) => t.id === tab().id), j = i + (dx < 0 ? 1 : -1); if (j < 0 || j >= current.tabs.length) return; haptic(); setTab(current.tabs[j].id); if (!reducedMotion()) gridEl.animate([{ transform: `translateX(${dx < 0 ? 40 : -40}px)`, opacity: 0.4 }, { transform: "none", opacity: 1 }], { duration: 220, easing: "cubic-bezier(.2,.8,.2,1)" }); }, { passive: true }); // Pull down at the top to fetch every state again. let pull = null; const ptr = $("#ptr"); stageEl.addEventListener("touchstart", (e) => { pull = !editMode && stageEl.scrollTop <= 0 && e.touches.length === 1 && !e.target.closest("input, textarea, select, .hs, .sw") ? { y: e.touches[0].clientY, d: 0 } : null; }, { passive: true }); stageEl.addEventListener("touchmove", (e) => { if (!pull) return; pull.d = Math.max(0, e.touches[0].clientY - pull.y); if (stageEl.scrollTop > 0) { pull = null; ptr.hidden = true; return; } if (pull.d > 10) { ptr.hidden = false; const k = Math.min(1, pull.d / 90); ptr.style.transform = `translate(-50%, ${Math.min(70, pull.d * 0.5)}px) rotate(${k * 270}deg)`; ptr.style.opacity = String(k); ptr.classList.toggle("ready", k >= 1); } }, { passive: true }); stageEl.addEventListener("touchend", async () => { if (!pull) return; const go = pull.d >= 90; pull = null; if (!go) { ptr.hidden = true; return; } haptic(); ptr.classList.add("spin"); try { if (ha.connected) { const states = await ha.send({ type: "get_states" }); ha.states = Object.fromEntries(states.map((x) => [x.entity_id, x])); onStateChange(null); } else toast(tr("Not connected to Home Assistant. Retrying…"), true); } finally { setTimeout(() => { ptr.classList.remove("spin", "ready"); ptr.hidden = true; }, 400); } }); // On phones, popups are sheets from the bottom; drag one down to close it. let sheetDrag = null; const sheet = $("#modal .modal-card"); sheet.addEventListener("touchstart", (e) => { sheetDrag = matchMedia("(max-width: 600px)").matches && sheet.scrollTop <= 0 && !e.target.closest("input, textarea, select, .lp-slider, input[type=range]") ? { y: e.touches[0].clientY, d: 0 } : null; }, { passive: true }); sheet.addEventListener("touchmove", (e) => { if (!sheetDrag) return; sheetDrag.d = Math.max(0, e.touches[0].clientY - sheetDrag.y); sheet.style.transition = "none"; sheet.style.transform = `translateY(${sheetDrag.d}px)`; }, { passive: true }); sheet.addEventListener("touchend", () => { if (!sheetDrag) return; const close = sheetDrag.d > 110; sheetDrag = null; sheet.style.transition = ""; sheet.style.transform = ""; if (close) modalClose?.(null); }); // ------------------------------------------------------------ phone dashboards // The top bar is hidden; touching and holding an empty spot brings it back for a while. let peekTimer = 0, holdTimer2 = 0; canvasEl.addEventListener("pointerdown", (e) => { if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return; holdTimer2 = setTimeout(() => { peek(true); navigator.vibrate?.(10); }, 600); }); for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); }); // The round button in the corner does the same, for dashboards without an empty spot. function peek(on) { document.body.classList.toggle("peek", on); clearTimeout(peekTimer); if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000); } $("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek")); // ------------------------------------------------------------ chrome wiring function setCurrent(id) { current = store.dashboards.find((d) => d.id === id) || store.dashboards[0]; lsSet("ds.current", current.id); activeTabId = lsGet(`ds.tab.${current.id}`); guestOn = false; guestCache = null; awayState = null; awayFrom = null; undoStack.length = redoStack.length = 0; { selectedId = null; multi.clear(); } updateUndoButtons(); renderAll(); checkGuest(true); checkAway(); if (mode === "admin") renderAdmin(); } // ------------------------------------------------------------ screens function renderModeSeg() { const seg = $("#mode-seg"); seg.hidden = !isAdmin() || IN_APP || !!previewAs; seg.querySelectorAll("[data-mode]").forEach((b) => { const on = b.dataset.mode === mode; b.classList.toggle("on", on); b.setAttribute("aria-selected", String(on)); }); } function syncMode() { document.body.classList.toggle("admin-mode", mode === "admin"); $("#admin").hidden = mode !== "admin"; if (!previewAs) lsSet("ds.mode", mode); } function setMode(m) { if (!isAdmin() || IN_APP || previewAs) m = "dashboard"; if (m === "admin") { if (kiosk) exitKiosk(); if (editMode) { editMode = false; openSheet(null); selectedId = null; multi.clear(); } mode = "admin"; syncMode(); renderAll(); renderAdmin(); return; } mode = m; setEdit(m === "builder"); } /** Dashboards from before 2.0 had "opens first" per person in assign.users; that's now part of access. */ function migrateAccess() { store.access ||= { everyone: { all: true }, users: {} }; store.access.users ||= {}; const old = store.assign?.users || {}; if (!Object.keys(old).length) return; for (const [u, id] of Object.entries(old)) store.access.users[u] ||= { all: true, home: id }; store.assign.users = {}; scheduleSave(); } /** A language an admin chose for this person; this device's own choice still comes first. */ function syncPersonLang() { const want = lookOf().lang || ""; if ((lsGet("ds.personLang") || "") === want) return; lsSet("ds.personLang", want); if (window.I18n.preference() === "auto" && (want || window.I18n.lang) !== window.I18n.lang) location.reload(); } /** People who aren't admins, with no dashboard chosen for them yet. */ function showEmpty() { const el = $("#empty-note"); el.querySelector(".en-ico").innerHTML = Icons.svg("layout"); el.querySelector(".en-text").textContent = tr("An admin hasn’t chosen a dashboard for you yet. Ask someone who manages Home Assistant to pick one for you on the Admin screen in Dashboard Studio."); el.hidden = false; document.body.classList.add("empty"); } function setEdit(on) { editMode = on; if (on) mode = "builder"; else if (mode === "builder") mode = "dashboard"; syncMode(); if (!on) openSheet(null); else renderPalette(); // the suggestions count needs the states lsSet("ds.edit", on ? "1" : "0"); if (!on) { selectedId = null; multi.clear(); } renderAll(); checkGuest(true); checkAway(); } /** Earlier saves of this dashboard, from the server. Restoring one can be undone. */ async function historyDialog() { if (savePending) { clearTimeout(saveTimer); await save(); } let list; try { list = await api(`api/history/${encodeURIComponent(current.id)}`); } catch (e) { toast(tr("Couldn’t load the version history. {0}", e.message), true); return; } const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); const rows = list.map((v, i) => `
${esc(when(v.at))}
${esc(relTime(new Date(v.at * 1000).toISOString()))} · ${tr("{0} widgets", v.widgets)}${v.tabs > 1 ? ` · ${tr("{0} tabs", v.tabs)}` : ""}${v.name !== current.name ? ` · “${esc(v.name)}”` : ""}
${i === 0 ? `${tr("Current")}` : ``}
`).join(""); const at = await openModal(`

${tr("Version history")}

${tr("Each save is kept here, up to 30 versions. Changes within five minutes count as one.")}

${rows || `
${tr("No earlier versions yet. They appear after your next changes.")}
`}
`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "cancel" ? null : x); }); if (!at) return; let data; try { data = await api(`api/history/${encodeURIComponent(current.id)}/${at}`); } catch (e) { toast(e.message, true); return; } select(null); const id = current.id; mutate(() => { for (const k of Object.keys(current)) delete current[k]; Object.assign(current, migrate(data), { id }); }); renderAll(); toast(tr("Restored the version from {0}. Press ⌘Z to undo.", when(Number(at)))); } function setMenu(open) { $("#menu").hidden = !open; $("#btn-menu").setAttribute("aria-expanded", String(open)); if (open) $("#menu button")?.focus(); } $("#mode-seg").onclick = (e) => { const b = e.target.closest("[data-mode]"); if (b) setMode(b.dataset.mode); }; $("#btn-kiosk").onclick = () => enterKiosk(); $("#btn-app").onclick = () => installDialog(); $("#btn-app").hidden = standalone() && !IN_APP; // in Haven it holds Change address $("#btn-undo").onclick = undo; $("#btn-redo").onclick = redo; $("#dash-select").onchange = (e) => setCurrent(e.target.value); $("#btn-new").onclick = async () => { const choice = await templateDialog(); if (!choice) return; const d = choice.template === "devices" ? starterDashboard() : TEMPLATES[choice.template]?.build() || newDashboard(choice.name); d.name = choice.name; store.dashboards.push(d); scheduleSave(); setCurrent(d.id); setEdit(true); }; $("#btn-menu").onclick = (e) => { e.stopPropagation(); setMenu($("#menu").hidden); }; document.addEventListener("click", (e) => { if (!e.target.closest("#menu")) setMenu(false); }); $("#menu").addEventListener("click", async (e) => { const act = e.target.closest("[data-menu]")?.dataset.menu; if (!act) return; setMenu(false); dashboardAction(act); }); /** What can be done with the current dashboard, from the builder's ⋯ menu or a card on the Admin screen. */ async function dashboardAction(act) { if (act === "rename") { const n = await askText(tr("Rename dashboard"), current.name, tr("Rename")); if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); } } else if (act === "history") { historyDialog(); } else if (act === "undo-history") { undoHistoryDialog(); } else if (act === "share") { shareDialog(); } else if (act === "suggest") { suggestionsDialog(); } else if (act === "install") { installDialog(); } else if (act === "lovelace") { lovelaceDialog(); } else if (act === "starter") { if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } const d = starterDashboard(); store.dashboards.push(d); scheduleSave(); setCurrent(d.id); toast(tr("Created “{0}” from your devices.", d.name)); } else if (act === "duplicate") { const d = clone(current); d.id = uid(); d.name += ` ${tr("copy")}`; d.tabs.forEach((t) => { t.id = uid(); t.widgets.forEach((w) => (w.id = uid())); }); store.dashboards.push(d); scheduleSave(); setCurrent(d.id); } else if (act === "export") { const blob = new Blob([JSON.stringify(current, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${current.name.replace(/[^\w-]+/g, "_")}.dashboard.json`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } else if (act === "import") { $("#import-file").click(); } else if (act === "delete") { if (store.dashboards.length === 1) { toast(tr("This is your only dashboard. Create another one before deleting it."), true); return; } const ok = await confirmDestructive(tr("Delete “{0}”?", current.name), tr("The dashboard and its widgets will be removed. This can’t be undone."), tr("Delete")); if (!ok) return; store.dashboards = store.dashboards.filter((d) => d.id !== current.id); scheduleSave(); setCurrent(store.dashboards[0].id); } } $("#import-file").onchange = async (e) => { const file = e.target.files[0]; e.target.value = ""; if (!file) return; try { const d = migrate(JSON.parse(await file.text())); if (!d.tabs.every((t) => Array.isArray(t.widgets))) throw new Error(tr("the file has no widgets list")); d.id = uid(); d.tabs.forEach((t) => { t.id = uid(); t.widgets.forEach((w) => (w.id = uid())); }); d.name = d.name || file.name; store.dashboards.push(d); scheduleSave(); setCurrent(d.id); toast(tr("Imported “{0}”.", d.name)); } catch (err) { toast(tr("Couldn’t import: {0}.", err.message), true); } }; navEl.addEventListener("click", (e) => { const op = e.target.closest("[data-tabop]"); if (op) { tabOp(op.dataset.tabop); return; } const b = e.target.closest("[data-tab]"); if (b) setTab(b.dataset.tab); }); $("#palette-list").addEventListener("click", async (e) => { const b = e.target.closest("[data-add]"); if (b) { addWidget(b.dataset.add); return; } if (e.target.closest("[data-suggest]")) { suggestionsDialog(); return; } const pa = e.target.closest("[data-preset-add]"); if (pa) { addPreset(pa.dataset.presetAdd); return; } const pd = e.target.closest("[data-preset-del]"); if (pd) { const p = store.presets.find((x) => x.id === pd.dataset.presetDel); if (!(await confirmDestructive(tr("Delete preset “{0}”", p.name), tr("Widgets you already added from it stay."), tr("Delete")))) return; store.presets = store.presets.filter((x) => x !== p); scheduleSave(); renderPalette(); } }); const insp = $("#inspector"); insp.addEventListener("input", onInspectorInput); insp.addEventListener("change", onInspectorInput); insp.addEventListener("click", (e) => { const seg = e.target.closest(".seg button[data-path], .pos9 button[data-path]"); if (seg) { applyField(seg, seg.dataset.v, true); return; } const p = e.target.closest("[data-preset]"); if (p) { mutate(() => { const keep = { surface: current.theme.surface, radius: current.theme.radius, bgImage: current.theme.bgImage }; current.theme = { ...DEFAULT_THEME, ...PRESETS[p.dataset.preset], ...keep, preset: p.dataset.preset }; delete current.theme.swatch; }); renderInspector(); return; } const del = e.target.closest("[data-ent-del]"); if (del && del.dataset.path === "guest") { mutate(() => current.guest[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false }); checkGuest(true); renderInspector(); return; } if (e.target.closest("[data-guest-toggle]")) { setGuestManual(!guestManual()); lsSet(`ds.guestskip.${current.id}`, "0"); renderInspector(); return; } if (del) { const w = editTarget(); // may be a card inside a scroll or swipe card mutate(() => (w.cfg[del.dataset.k] || []).splice(Number(del.dataset.entDel), 1), null, { rerender: false }); renderWidget(selectedWidget()); renderInspector(); return; } const childEdit = e.target.closest("[data-child-edit]"), childBtn = e.target.closest("[data-child-op]"); if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; } if (childBtn) { childOp(childBtn.dataset.childOp, Number(childBtn.dataset.i)); return; } if (e.target.closest("[data-child-back]")) { childIndex = null; renderInspector(); return; } if (e.target.closest("[data-whatsnew]")) { whatsNew(); return; } const pick = e.target.closest("[data-icon-pick]"); if (pick) { iconPicker(document.getElementById(pick.dataset.iconPick)); return; } const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]"); if (ruleAdd || ruleDel) { const w = tab().widgets.find((x) => x.id === selectedId); mutate(() => { w.visibility ||= { mode: "all", rules: [] }; if (ruleAdd) w.visibility.rules.push({ entity: "", op: "is", value: "" }); else w.visibility.rules.splice(Number(ruleDel.dataset.ruleDel), 1); }); renderInspector(); if (ruleAdd) $(`#inspector [data-path="rule"][data-idx="${w.visibility.rules.length - 1}"][data-k="entity"]`)?.focus(); return; } const op = e.target.closest("[data-tabop]"); if (op) { tabOp(op.dataset.tabop, op.dataset.tabId); return; } const k = e.target.closest("[data-kiosk]"); if (k) { if (k.dataset.kiosk === "here") enterKiosk(); else window.open(new URL(`#kiosk&view=${current.id}`, BASE).href, "_blank", "noopener"); return; } const al = e.target.closest("[data-align]"); if (al) { alignSelection(al.dataset.align); return; } const tool = e.target.closest("[data-tool]"); if (tool && selectedId) toolAction(tool.dataset.tool, multi.size ? [...multi] : [selectedId]); }); function toolAction(name, ids) { if (name === "preset") savePreset(ids[0]); else if (name === "copyto") copyTo(ids); else if (ids.length > 1) name === "dup" ? duplicateMany(ids) : removeMany(ids); else name === "dup" ? duplicate(ids[0]) : remove(ids[0]); } document.addEventListener("keydown", (e) => { if (e.key === "Escape") { if (modalClose) { modalClose(null); return; } if (!$("#menu").hidden) { setMenu(false); $("#btn-menu").focus(); return; } if (kiosk) { kioskPrompt(); return; } } if (!editMode || modalClose) return; const inField = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName); const mod = e.ctrlKey || e.metaKey; if (mod && e.key.toLowerCase() === "z" && !inField) { e.preventDefault(); e.shiftKey ? redo() : undo(); return; } if (mod && e.key.toLowerCase() === "y" && !inField) { e.preventDefault(); redo(); return; } if (inField || !selectedId) return; if (multi.size) { const ws = selection(); if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); removeMany(ws.map((w) => w.id)); } else if (mod && e.key.toLowerCase() === "d") { e.preventDefault(); duplicateMany(ws.map((w) => w.id)); } else if (e.key === "Escape") select(null); else if (e.key.startsWith("Arrow") && !e.shiftKey) { e.preventDefault(); const [dx, dy] = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; const minX = Math.min(...ws.map((w) => w.x)), maxR = Math.max(...ws.map((w) => w.x + w.w)), minY = Math.min(...ws.map((w) => w.y)); if (minX + dx < 0 || maxR + dx > current.grid.cols || minY + dy < 0) return; mutate(() => { ws.forEach((w) => { w.x += dx; w.y += dy; }); [...ws].sort((a, b) => a.y - b.y).forEach(resolveCollisions); }, `kbd-multi`); } return; } const w = tab().widgets.find((x) => x.id === selectedId); if (!w) return; if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); remove(w.id); } else if (mod && e.key.toLowerCase() === "d") { e.preventDefault(); duplicate(w.id); } else if (e.key === "Escape") select(null); else if (e.key.startsWith("Arrow")) { e.preventDefault(); const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; mutate(() => { if (e.shiftKey) { w.w = Math.max(1, Math.min(current.grid.cols - w.x, w.w + d[0])); w.h = Math.max(1, w.h + d[1]); } else { w.x = Math.max(0, Math.min(current.grid.cols - w.w, w.x + d[0])); w.y = Math.max(0, w.y + d[1]); } resolveCollisions(w); }, `kbd-${w.id}`); select(w.id); } }); let resizeRaf = 0; window.addEventListener("resize", () => { cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(renderGrid); }); matchMedia("(prefers-color-scheme: dark)").addEventListener?.("change", () => renderGrid()); phoneLandscape.addEventListener?.("change", () => renderGrid()); phoneScreen.addEventListener?.("change", () => renderGrid()); // widgets shown only on phones or only on larger screens // clocks, screen saver and camera refresh setInterval(() => { gridEl.querySelectorAll("[data-clock]").forEach((el) => { el.innerHTML = clockHTML(el.dataset.h24 === "1", el.dataset.sec === "1", el.dataset.date === "1"); }); if (!$("#saver").hidden) updateSaver(); rotateTick(); gridEl.querySelectorAll("[data-countdown]").forEach((el) => { const left = Math.max(0, (new Date(el.dataset.countdown) - Date.now()) / 1000), dur = Number(el.dataset.dur) || 1; el.textContent = window.HAFmt.hms(left); const ring = el.parentElement.querySelector("[data-ring]"); if (ring) ring.setAttribute("stroke-dasharray", `${(Number(ring.dataset.ring) * Math.min(1, left / dur)).toFixed(1)} ${ring.dataset.ring}`); }); const now = Date.now(); document.querySelectorAll("#grid img[data-cam], #doorbell img[data-cam]").forEach((img) => { const every = Number(img.dataset.refresh) * 1000; if (!img._last) img._last = now; if (now - img._last >= every) { img._last = now; img.src = `api/camera/${encodeURIComponent(img.dataset.cam)}?t=${now}`; } }); }, 1000); // keep "x min ago" labels and graph time windows fresh setInterval(() => { applyNight(); checkGuest(); }, 60000); setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000); // ------------------------------------------------------------ admin screen // Dashboards, the people who use them and what each may see, devices, Haven, and who changed what. const ADMIN_SECTIONS = [ ["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"], ["guests", "qr", "Guests"], ["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"], ]; let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards"; let peopleList = null, devicesSeen = null, lastInvite = null; const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never")); const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase()); async function loadPeople(force) { if (!peopleList || force) peopleList = await api("api/people").catch(() => []); return peopleList; } /** Who may see a dashboard, in a few words. */ function seenByText(d) { const basics = (peopleList || []).filter((p) => !p.admin); const who = basics.filter((p) => visibleFor(p.id).includes(d.id)); if (!basics.length) return tr("Admins"); if (who.length === basics.length) return tr("Everyone"); if (!who.length) return tr("Admins only"); return who.map((p) => p.name).join(", "); } const ADMIN = { async dashboards() { await loadPeople(); const cards = store.dashboards.map((d) => { const widgets = d.tabs.reduce((n, t) => n + t.widgets.length, 0); const bg = /^#|gradient/.test(d.theme?.bg || "") && d.theme.preset !== "System" ? d.theme.bg : "var(--fill)"; return `
${Icons.svg(d.mobile ? "phone" : "layout")}

${esc(d.name)}

${d.tabs.length === 1 ? tr("1 tab") : tr("{0} tabs", d.tabs.length)} · ${widgets === 1 ? tr("1 widget") : tr("{0} widgets", widgets)}${d.mobile ? ` · ${tr("Phone")}` : ""}
${Icons.svg("people")}${esc(seenByText(d))}
`; }).join(""); return `

${tr("Dashboards")}

${tr("Build a dashboard, then choose under People who sees it.")}

${cards}
`; }, async people() { const list = await loadPeople(true); const acc = store.access; const dashChecks = (key, entry) => store.dashboards.map((d) => ``).join(""); const homeSel = (key, entry) => ``; const accessBlock = (key, entry, own) => `
${key !== "everyone" ? `` : ""}
${(own || key === "everyone") && !entry.all ? `
${dashChecks(key, entry)}
` : ""} ${own || key === "everyone" ? `` : ""}
`; const lookBlock = (uid) => { const l = store.looks?.[uid] || {}; const sel = (k, opts, label) => ``; return `
${sel("theme", [["", "The dashboard’s theme"], ...Object.keys(PRESETS).filter((n) => n !== "System").map((n) => [n, n])], "Theme")} ${sel("scale", [["", "Normal"], ["large", "Large"], ["xl", "Extra large"]], "Text")} ${sel("lang", [["", "Automatic"], ["en", "English"], ["nl", "Nederlands"]], "Language")}
`; }; const rows = list.map((p) => { const own = acc.users[p.id]; const entry = own || acc.everyone || { all: true }; return `
${initials(p.name)}
${esc(p.name)} ${p.admin ? `${tr("Admin")}` : ""}${me?.id === p.id ? ` (${tr("you")})` : ""}
${p.admin ? tr("Home Assistant administrator · sees and builds everything") : tr("Sees {0} · always in kiosk mode", visibleFor(p.id).length === store.dashboards.length ? tr("all dashboards") : tr("{0} of {1} dashboards", visibleFor(p.id).length, store.dashboards.length))} · ${esc(tr("last opened {0}", ago(p.seen)))}
${p.admin ? "" : ``}
${p.admin ? "" : accessBlock(p.id, entry, !!own)} ${lookBlock(p.id)}
`; }).join(""); return `

${tr("People")}

${tr("Home Assistant’s administrators are admins here: they see every dashboard and can build. Everyone else only sees the dashboards chosen here, full screen in kiosk mode, on any device.")}

${Icons.svg("people")}
${tr("Everyone else")}
${tr("People without their own choice below, and anyone added to Home Assistant later.")}
${accessBlock("everyone", acc.everyone || { all: true }, true)}
${rows || `

${tr("Couldn’t read Home Assistant’s people.")}

`}

${tr("To add someone, create a person with a login in Home Assistant (Settings, People). Turn off Administrator there to make them a basic user.")}

`; }, async devices() { [devicesSeen] = await Promise.all([api("api/devices").catch(() => ({})), loadPeople()]); const a = store.assign.devices; const ids = [...new Set([DEVICE_ID, ...Object.keys(devicesSeen), ...Object.keys(a)])] .sort((x, y) => (x === DEVICE_ID ? -1 : y === DEVICE_ID ? 1 : (devicesSeen[y]?.seen || 0) - (devicesSeen[x]?.seen || 0))); const opts = (val) => `` + store.dashboards.map((d) => ``).join(""); const rows = ids.map((id) => { const seen = devicesSeen[id] || {}, set = a[id] || {}; const icon = { phone: "phone", tablet: "tv", computer: "laptop" }[seen.kind] || "laptop"; const what = [seen.display || (id === DEVICE_ID ? me?.display : ""), seen.app === "haven" ? "Haven" : seen.app ? tr("Browser") : "", seen.seen ? ago(seen.seen) : ""].filter(Boolean).join(" · "); return `
${Icons.svg(icon)}
${id === DEVICE_ID ? `${tr("This device")} · ` : ""}${esc(what)}
${id === DEVICE_ID ? `` : ``}
`; }).join(""); const named = ids.filter((id) => a[id]?.name); return `

${tr("Devices")}

${tr("Every phone, tablet and computer that opened Dashboard Studio. Give a wall tablet its own dashboard: that comes before the person’s choice.")}

${rows}

${Icons.svg("megaphone")}${tr("Send a message to the screens")}

${tr("It shows full screen on dashboards in kiosk mode, like an announcement from an automation.")}

`; }, async guests() { let data; try { data = await api("api/invites"); } catch (e) { return `

${esc(e.message)}

`; } const host = lsGet("ds.guestHost") || location.hostname; const link = (tok) => (data.port ? `http://${host}:${data.port}/g/${tok}/` : ""); const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); const fresh = data.invites.find((i) => i.token === lastInvite && !i.ended); const rows = data.invites.map((i) => `
${Icons.svg("qr")}
${esc(i.name || tr("Guests"))} · ${esc(i.dashboardName)}
${i.ended ? esc(tr("Ended {0}", when(i.until))) : esc(tr("Until {0}", when(i.until)))}${i.by ? ` · ${esc(tr("by {0}", i.by))}` : ""}
${i.ended ? "" : ``}
`).join(""); return `

${tr("Guests")}

${tr("An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.")}

${data.port ? "" : `

${Icons.svg("wifi")}${tr("Turn on guest links first")}

${tr("In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.")}

`}

${Icons.svg("plus")}${tr("New invite")}

${tr("The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.")}

${fresh ? `
` : ""}
${rows || `

${tr("No invites yet.")}

`}
`; }, async haven() { const addr = location.origin; return `

${tr("Haven app")}

${tr("Haven is the Android app for the people in your home. It only shows dashboards: no builder and no settings. Everyone sees what you chose for them under People.")}

1

${tr("Install Haven on the phone")}

${tr("Scan this code with the phone’s camera, or download it on the phone itself. Android asks once to allow apps from this source.")}

${Icons.svg("arrow-down")}${tr("Download Haven")}
2

${tr("Connect to Home Assistant")}

${tr("Open Haven and enter this address. It is the address you use for Home Assistant; on other networks use its outside address, for example Home Assistant Cloud.")}

${tr("On the phone itself, Open in Haven fills it in.")}

${tr("Open in Haven")}
3

${tr("Sign in")}

${tr("Everyone signs in with their own Home Assistant login, once. Haven then opens straight on their dashboard and stays signed in.")}

${tr("Haven needs Android 7 or newer. On iPhone, use the Home Assistant app instead.")}

`; }, async activity() { const rows = await api("api/activity").catch(() => []); const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); return `

${tr("Activity")}

${tr("Who changed which dashboard. Restore an earlier version from the dashboard’s version history.")}

${rows.map((r) => `
${r.by ? initials(r.by) : Icons.svg("edit")}
${r.by ? tr("{0} changed {1}", `${esc(r.by)}`, `${esc(r.name)}`) : tr("{0} was changed", `${esc(r.name)}`)}${r.deleted ? ` (${tr("deleted")})` : ""}
${esc(when(r.at))} · ${esc(ago(r.at))}
${r.deleted ? "" : ``}
`).join("") || `

${tr("No changes yet.")}

`}
`; }, async about() { return `

${tr("About")}

Dashboard Studio ${esc(APP_VERSION)}

${tr("You’re signed in as {0}, a Home Assistant administrator, so you can use the Dashboard, Builder and Admin screens.", esc(me?.display || me?.name || ""))}

`; }, }; async function renderAdmin(section = adminSection) { adminSection = section; lsSet("ds.admin", section); $("#admin .adm-nav").innerHTML = ADMIN_SECTIONS.map(([k, icon, label]) => ``).join(""); const body = $("#admin .adm-body"); const html = await ADMIN[section](); if (adminSection !== section || mode !== "admin") return; // moved on while it loaded const y = body.scrollTop; body.innerHTML = html; body.scrollTop = y; } /** Change what a person (or "everyone") may see. */ function setAccess(who, fn) { const acc = store.access; if (who === "everyone") acc.everyone = fn({ all: true, ...(acc.everyone || {}) }); else { const v = fn(acc.users[who] ? { ...acc.users[who] } : { ...(acc.everyone || { all: true }) }); if (v) acc.users[who] = v; else delete acc.users[who]; } scheduleSave(); renderAdmin("people"); } /** Admin → People → Preview: the dashboards this person sees, as they see them. */ function startPreview(p) { const ids = visibleFor(p.id); if (!ids.length) { toast(tr("{0} can’t see any dashboard yet.", p.name), true); return; } const home = accessFor(p.id).home; previewAs = { ...p, ids }; mode = "dashboard"; syncMode(); setCurrent(ids.includes(home) ? home : ids[0]); setEdit(false); const bar = $("#preview-bar"); bar.querySelector(".pv-text").textContent = tr("Previewing as {0}: {1} dashboards. On their devices it’s full screen, in kiosk mode.", p.name, ids.length); bar.hidden = false; document.body.classList.add("previewing"); } $("#pv-stop").onclick = () => { previewAs = null; $("#preview-bar").hidden = true; document.body.classList.remove("previewing"); setMode("admin"); renderAdmin("people"); }; $("#admin").addEventListener("click", async (e) => { const sec = e.target.closest("[data-sec]"); if (sec) { renderAdmin(sec.dataset.sec); $("#admin .adm-body").scrollTop = 0; return; } const k = e.target.closest("button[data-k]"); if (k?.dataset.k === "mode") { const v = k.dataset.v; setAccess(k.dataset.who, (cur) => (v === "same" ? null : v === "all" ? { all: true, home: cur.home } : { all: false, dashboards: cur.all ? store.dashboards.map((d) => d.id) : cur.dashboards || [], home: cur.home })); return; } const b = e.target.closest("[data-a]"); if (!b) return; const act = b.dataset.a, card = b.closest("[data-dash]"), dash = card?.dataset.dash; if (act === "open" || act === "edit") { setCurrent(dash); setMode(act === "edit" ? "builder" : "dashboard"); return; } if (act === "more") { const d = store.dashboards.find((x) => x.id === dash); const pick = await openModal(`

${esc(d.name)}

${[["rename", "edit", "Rename…"], ["duplicate", "copy", "Duplicate"], ["history", "history", "Version history…"], ["share", "share", "Share…"], ["export", "exit", "Export as JSON"], ["delete", "trash", "Delete dashboard…"]] .map(([a, i, l]) => ``).join("")}
`, (ev, close) => { const x = ev.target.closest("[data-x]")?.dataset.x; if (x) close(x === "close" ? null : x); }); if (!pick) return; setCurrent(dash); await dashboardAction(pick); if (mode === "admin") renderAdmin(); return; } if (act === "new") { $("#btn-new").click(); return; } if (["starter", "import", "lovelace"].includes(act)) { await dashboardAction(act); if (act === "starter") renderAdmin(); return; } if (act === "preview") { const p = peopleList.find((x) => x.id === b.closest("[data-user]").dataset.user); if (p) startPreview(p); return; } if (act === "forget") { const id = b.closest("[data-dev]").dataset.dev; delete store.assign.devices[id]; scheduleSave(); await fetch(new URL(`api/devices/${encodeURIComponent(id)}`, BASE), { method: "DELETE" }).catch(() => {}); renderAdmin("devices"); return; } if (act === "announce") { const message = $("#an-msg").value.trim(); if (!message) { $("#an-msg").focus(); return; } const to = $("#an-to").value; await ha.send({ type: "fire_event", event_type: "dashboard_studio_announce", event_data: { title: $("#an-title").value.trim() || undefined, message, ...(to ? { device: to } : {}) } }) .then(() => { toast(tr("Sent.")); $("#an-msg").value = ""; }) .catch((err) => toast(tr("Couldn’t send it: {0}", err.message), true)); return; } if (act === "copy-addr") { navigator.clipboard?.writeText($("#haven-addr").value).then(() => toast(tr("Copied."))).catch(() => $("#haven-addr").select()); return; } if (act === "history") { setCurrent(b.closest("[data-dash]").dataset.dash); await historyDialog(); return; } if (act === "whatsnew") whatsNew(); if (act === "inv-new") { const v = $("#inv-for").value, now = new Date(); let until; if (v === "tonight") { until = new Date(now); until.setHours(23, 59, 0, 0); } else if (v === "weekend") { until = new Date(now); until.setDate(now.getDate() + ((8 - now.getDay()) % 7 || 7)); until.setHours(10, 0, 0, 0); } else until = new Date(now.getTime() + Number(v) * 3600e3); try { const r = await fetch(new URL("api/invites", BASE), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: $("#inv-name").value.trim(), dashboard: $("#inv-dash").value, until: until.getTime() / 1000 }) }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status); lastInvite = (await r.json()).token; } catch (err) { toast(tr("Couldn’t make the invite: {0}", err.message), true); return; } renderAdmin("guests"); return; } if (act === "inv-show") { lastInvite = b.closest("[data-token]").dataset.token; renderAdmin("guests"); $("#admin .adm-body").scrollTop = 0; return; } if (act === "inv-end") { const tok = b.closest("[data-token]").dataset.token; if (!(await confirmDestructive(tr("End this invite?"), tr("The link stops working at once, also on phones that have it open."), tr("End")))) return; await fetch(new URL(`api/invites/${encodeURIComponent(tok)}`, BASE), { method: "DELETE" }).catch(() => {}); if (lastInvite === tok) lastInvite = null; renderAdmin("guests"); return; } if (act === "inv-copy") { navigator.clipboard?.writeText($("#inv-link").value).then(() => toast(tr("Copied."))).catch(() => $("#inv-link").select()); return; } }); $("#admin").addEventListener("change", (e) => { const f = e.target, k = f.dataset.k; if (f.id === "inv-host") { lsSet("ds.guestHost", f.value.trim()); renderAdmin("guests"); return; } if (f.closest("[data-dev]")) { const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {}); d[k] = f.type === "checkbox" ? f.checked : f.value; if (!d.name && !d.dashboard && !d.kiosk) delete store.assign.devices[id]; scheduleSave(); return; } if (k?.startsWith("look-")) { const uid = f.dataset.who, key = k.slice(5); store.looks ||= {}; const l = { ...(store.looks[uid] || {}) }; if (f.value) l[key] = f.value; else delete l[key]; if (Object.keys(l).length) store.looks[uid] = l; else delete store.looks[uid]; scheduleSave(); if (uid === me?.id) { applyTheme(); syncPersonLang(); } return; } if (k === "dash") { const boxes = [...f.closest(".adm-chips").querySelectorAll("input:checked")].map((x) => x.value); setAccess(f.dataset.who, (cur) => ({ ...cur, all: false, dashboards: boxes, home: boxes.includes(cur.home) ? cur.home : undefined })); } else if (k === "home") setAccess(f.dataset.who, (cur) => ({ ...cur, home: f.value || undefined })); }); // device names save while typing $("#admin").addEventListener("input", (e) => { const f = e.target; if (f.dataset.k !== "name" || !f.closest("[data-dev]")) return; const id = f.closest("[data-dev]").dataset.dev; (store.assign.devices[id] ||= {}).name = f.value; scheduleSave(); }); // ------------------------------------------------------------ boot function paintChromeIcons() { $("#btn-new").innerHTML = Icons.svg("plus"); $("#eb-add").insertAdjacentHTML("afterbegin", Icons.svg("plus")); $("#btn-undo").innerHTML = Icons.svg("undo"); $("#btn-redo").innerHTML = Icons.svg("redo"); $("#btn-menu").innerHTML = Icons.svg("more"); $("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk")); $("#btn-app").innerHTML = Icons.svg("phone"); $("#phone-menu").innerHTML = Icons.svg("more"); document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon))); } ha.onStatus = (ok) => { const c = $("#conn"); c.classList.toggle("ok", ok); c.textContent = ok ? tr("Connected") : offlineText(); showOffline(!ok); // An update restarts the app, which drops the connection: check right after reconnecting. if (ok) checkVersion(); }; /** If the app was updated while this page was open, load the new version. */ let updateNoticeShown = false; async function checkVersion() { if (APP_VERSION === "dev") return; let latest; try { latest = (await api("api/version")).version; } catch { return; } if (!latest || latest === APP_VERSION) return; if (editMode) { // never yank the editor away; just say so once if (!updateNoticeShown) toast(tr("Dashboard Studio was updated to {0}. Reload the page to use it.", latest)); updateNoticeShown = true; return; } toast(tr("Dashboard Studio was updated to {0}. Reloading…", latest)); if (savePending) { clearTimeout(saveTimer); await save(); } // keep edits still waiting to be saved setTimeout(() => location.reload(), 2500); } setInterval(checkVersion, 5 * 60 * 1000); // Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload. let storeKey = ""; const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices, s.look]); async function refreshStore() { if (editMode || mode === "admin" || savePending || previewAs || modalClose) return; let next; try { next = await api(`api/dashboards?device=${DEVICE_ID}`); } catch { return; } const key = keyOf(next); if (key === storeKey) return; storeKey = key; if (!$("#empty-note").hidden || (!isAdmin() && !next.dashboards?.length)) { location.reload(); return; } next.dashboards = next.dashboards.map(migrate); next.assign = { devices: {}, users: {}, ...(next.assign || {}) }; store = next; const home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home; const keep = [current?.id, store.assign.devices[DEVICE_ID]?.dashboard, home].find((id) => id && store.dashboards.some((d) => d.id === id)); setCurrent(keep); syncPersonLang(); } setInterval(refreshStore, 60 * 1000); ha.onChange = onStateChange; ha.onRegistry = () => { renderGrid(); if (editMode) { renderInspector(); renderPalette(); } }; /** Translate the static page: elements marked data-i18n, plus every aria-label and title. */ function translateStatic() { document.querySelectorAll("[data-i18n]").forEach((el) => { el.textContent = tr(el.textContent.trim()); }); for (const attr of ["aria-label", "title"]) document.querySelectorAll(`[${attr}]`).forEach((el) => el.setAttribute(attr, tr(el.getAttribute(attr)))); } async function boot() { document.body.classList.toggle("in-haven", IN_APP); translateStatic(); paintChromeIcons(); renderPalette(); await keepIngressSession(); // In Haven, a session that couldn't be renewed means signing in again on Home Assistant's page. if (IN_APP && (await fetch(new URL("api/version", BASE)).then((r) => r.status === 401).catch(() => false))) { window.HavenApp.reconnect?.(); return; } loadCachedStates(); if ("serviceWorker" in navigator && APP_VERSION !== "dev" && !location.pathname.startsWith("/g/")) navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {}); me = await api("api/whoami").catch(() => null); if (me) { role = me.admin ? "admin" : "basic"; if (!me.guest) lsSet("ds.role", role); } document.body.classList.toggle("guest", !!me?.guest); try { const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE)); store = await r.json(); if (store.role) role = store.role; store.dashboards = (store.dashboards || []).map(migrate); store.assign = { devices: {}, users: {}, ...(store.assign || {}) }; } catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); } // Let admins see this device on the Admin screen, to give it a dashboard. if (!me?.guest) fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device: DEVICE_ID, app: IN_APP ? "haven" : "browser", kind: phoneScreen.matches ? "phone" : matchMedia("(pointer: coarse)").matches ? "tablet" : "computer" }) }).catch(() => {}); const firstReady = new Promise((res) => { const prev = ha.onStatus; ha.onStatus = (ok) => { prev(ok); if (ok) res(); }; setTimeout(res, 4000); }); ha.connect(); if (isAdmin()) migrateAccess(); syncPersonLang(); storeKey = keyOf(store); if (!store.dashboards?.length) { if (!isAdmin()) { showEmpty(); return; } await firstReady; store = { ...store, version: 1, dashboards: [starterDashboard()] }; save(); } const shared = sharedParams(); const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1] || shared.get("ds"); // Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here. const dev = store.assign.devices[DEVICE_ID], home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home; const exists = (id) => id && store.dashboards.some((d) => d.id === id); // The screen it was on last time; people who aren't admins, and Haven, only get the dashboard. const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard"); mode = isAdmin() && !IN_APP ? lastMode : "dashboard"; editMode = mode === "builder"; setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists)); if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); } else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false }); else setMode(mode); const action = shared.get("ds_action"); if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); } // After an update, point at what changed (once per device). const seen = lsGet("ds.seenVersion"); if (APP_VERSION !== "dev" && seen && seen !== APP_VERSION) setTimeout(() => toast(tr("Updated to {0}. See what’s new under About in the settings.", APP_VERSION)), 2000); if (APP_VERSION !== "dev") lsSet("ds.seenVersion", APP_VERSION); } boot(); })();