Dashboard Studio 2.6.0 and Haven 2.3.0: easier to use on a phone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:48:32 +01:00
parent 3b2cbbc96c
commit 1bca71ff7e
12 changed files with 365 additions and 37 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog # Changelog
## 2.6.0
Easier to use on a phone, in the browser and in Haven (now version 2.3.0, offered as an update in the app).
- **Back closes what's open:** Android's back gesture in Haven, and the browser's back button, now close an open sheet or the top bar instead of leaving the app.
- **Haven finds your Home Assistant:** when you set it up or add a home, Haven lists the Home Assistant it finds on your home network. Tap it instead of typing the address.
- **Haven keeps only homes that work:** an address that doesn't connect is no longer saved as a home; it is filled in again so you can correct it. Removing a home asks first, in the app itself.
- **Haven says what it's doing:** the loading screen says which home it is connecting to, and after a while offers to choose another home or address.
- **A phone opens a phone dashboard:** on a phone, Dashboard Studio opens the dashboard you last used on that phone, or else the first one designed for phones; and always on the dashboard, not the builder.
- **Connecting, not offline:** while it first connects, the top bar says *Connecting…* instead of a red *Offline*.
- **The ⋯ button steps aside:** on a phone dashboard, the round button in the corner moves out of the way while you scroll down, so it doesn't cover a card, and comes back when you scroll up.
- **Friendlier details:** tapping a sensor or device shows what it is now in words, a line of the last 24 hours for sensors, and buttons for blinds, media players and vacuums. The entity ID and attributes are folded away under *Technical details*.
- Weather reads *Partly cloudy* instead of *Partlycloudy* (and the same for a few other states).
- Fixed: on a lit lamp tile in the phone sizes, the text was white on a pale lamp colour.
## 2.5.0 ## 2.5.0
- **Phone sizes:** on a phone dashboard every card now comes in four sizes, like widgets on a phone: **Small** (half width, square), **Medium** (full width, two rows), **Large** (full width, four rows) and **Row** (full width, one line). Pick one under **Size on the phone** in the inspector, or drag the corner: it snaps to the nearest size. - **Phone sizes:** on a phone dashboard every card now comes in four sizes, like widgets on a phone: **Small** (half width, square), **Medium** (full width, two rows), **Large** (full width, four rows) and **Row** (full width, one line). Pick one under **Size on the phone** in the inspector, or drag the corner: it snaps to the nearest size.
+5 -2
View File
@@ -43,10 +43,10 @@ Under **Address for the link**, use the address of Home Assistant on your home n
Haven shows the dashboards and nothing else: no builder and no Admin screen, for admins too. Haven shows the dashboards and nothing else: no builder and no Admin screen, for admins too.
1. On the phone, scan the QR code under **Admin → Haven app** (or tap **Download Haven** on the phone itself), open the file and allow installing apps from that source when Android asks (once). 1. On the phone, scan the QR code under **Admin → Haven app** (or tap **Download Haven** on the phone itself), open the file and allow installing apps from that source when Android asks (once).
2. Open Haven and enter the address you use for Home Assistant. On the phone itself, **Open in Haven** fills it in. 2. Open Haven and tap your Home Assistant under **On this network** (Haven finds it on your home Wi-Fi), or enter the address you use for it. On the phone itself, **Open in Haven** fills it in.
3. Sign in with your own Home Assistant login, once. 3. Sign in with your own Home Assistant login, once.
From then on Haven opens straight on your dashboard, full screen with its own icon, and keeps you signed in. It works with plain http addresses too. Kiosk mode keeps the screen on and hides Android's bars. To connect to another Home Assistant: tap the phone button in the top bar (admins) or the kiosk corner five times (everyone else), then **Change address**. Haven needs Android 7 or newer. The earlier *Dashboard Studio* Android app is replaced by Haven; you can uninstall it. From then on Haven opens straight on your dashboard, full screen with its own icon, and keeps you signed in. It works with plain http addresses too. Kiosk mode keeps the screen on and hides Android's bars. To add another home, such as a holiday home, or switch between them: tap the phone button in the top bar (admins) or the kiosk corner five times (everyone else), then **Homes and addresses**. An address that doesn't connect isn't kept. If connecting takes long, the loading screen offers to choose another home or address. Android's back gesture closes an open sheet first; with nothing open, it puts Haven in the background. Haven needs Android 7 or newer. The earlier *Dashboard Studio* Android app is replaced by Haven; you can uninstall it.
- **Several homes:** under **Change address**, **Add another home** connects Haven to another Home Assistant, such as a holiday home or your parents'. Each home keeps its own sign-in; tap a home to open it, × removes it. - **Several homes:** under **Change address**, **Add another home** connects Haven to another Home Assistant, such as a holiday home or your parents'. Each home keeps its own sign-in; tap a home to open it, × removes it.
- **Home screen widgets:** long-press your home screen, choose **Widgets → Haven**, and pick a light, switch, scene or thermostat. A tap does what it does on the dashboard; a thermostat gets − and + for its temperature. - **Home screen widgets:** long-press your home screen, choose **Widgets → Haven**, and pick a light, switch, scene or thermostat. A tap does what it does on the dashboard; a thermostat gets − and + for its temperature.
@@ -297,6 +297,9 @@ On a phone, such a dashboard fills the whole screen, without the top bar with th
### On phones ### On phones
- **Which dashboard opens:** on a phone, Dashboard Studio opens the dashboard last used on that phone, or else the first one designed for phones (a link, the device's own dashboard or the person's dashboard still come first). It always opens on the dashboard, not the builder.
- **Details:** tap a sensor or device for its details: what it is now, a line of the last 24 hours for sensors, and its buttons. The entity ID and attributes are under *Technical details*. Back (the gesture, or the browser's button) closes the sheet.
- **Install as an app:** on Android, use **Haven** (see above). The phone button in the top bar explains the other options: browsers can install Dashboard Studio as a web app when Home Assistant is opened over **https** (open Dashboard Studio in its own tab first); on iPhone and iPad, apps on the home screen can't use Home Assistant's sign-in, so use the sidebar in the Home Assistant app. - **Install as an app:** on Android, use **Haven** (see above). The phone button in the top bar explains the other options: browsers can install Dashboard Studio as a web app when Home Assistant is opened over **https** (open Dashboard Studio in its own tab first); on iPhone and iPad, apps on the home screen can't use Home Assistant's sign-in, so use the sidebar in the Home Assistant app.
- **Shortcuts:** tick **Shortcut on the app icon** in the settings of a button, tile, light, thermostat or garage card. When Dashboard Studio is installed from the browser on Android, long-press its icon to run one straight away (up to four shortcuts; Haven and iPhones don't support this). - **Shortcuts:** tick **Shortcut on the app icon** in the settings of a button, tile, light, thermostat or garage card. When Dashboard Studio is installed from the browser on Android, long-press its icon to run one straight away (up to four shortcuts; Haven and iPhones don't support this).
- **Offline:** each device keeps the last known states of its dashboard. When the connection drops, the dashboard keeps showing them with a small note saying from when, and catches up when it reconnects. - **Offline:** each device keeps the last known states of its dashboard. When the connection drops, the dashboard keeps showing them with a small note saying from when, and catches up when it reconnects.
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "2.5.0" version: "2.6.0"
slug: dashboard_studio slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
+88 -17
View File
@@ -301,8 +301,8 @@
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`; const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`; let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
if (IN_APP) { if (IN_APP) {
body = p(tr("This is Haven, connected to {0}.", location.host)); body = p(tr("This is Haven, connected to {0}. You can add another home, such as a holiday home, or switch between them.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`; actions += `<button class="btn" data-x="reset">${tr("Homes and addresses")}</button>`;
} else if (android) { } 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. // 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.")) body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in."))
@@ -325,7 +325,7 @@
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`; if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
} }
if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small"); if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small");
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body} const choice = await openModal(`<h3>${IN_APP ? "Haven" : tr("Install as an app")}</h3>${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")} ${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")}
<div class="modal-actions">${actions}</div>`, <div class="modal-actions">${actions}</div>`,
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); }); (e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
@@ -2238,15 +2238,34 @@
// ------------------------------------------------------------ dialogs // ------------------------------------------------------------ dialogs
let modalClose = null; let modalClose = null;
// An open sheet is a step in the history, so going back (Android's back gesture in Haven, the browser's
// back button) closes it instead of leaving the app.
let modalInHistory = false, closingByBack = false, ownBacks = 0;
window.addEventListener("popstate", () => {
if (ownBacks) { ownBacks--; return; } // the step back a closing sheet took itself (another may have opened since)
if (!modalInHistory) return;
modalInHistory = false;
closingByBack = true;
modalClose?.(null);
closingByBack = false;
});
/** Haven's back gesture asks first: close what is open here (a sheet, the top bar) and say so. */
window.havenBack = () => {
if (modalClose) { modalClose(null); return true; }
if (document.body.classList.contains("peek")) { document.body.classList.remove("peek"); return true; }
return false;
};
function openModal(html, onClick) { function openModal(html, onClick) {
const card = $("#modal .modal-card"); const card = $("#modal .modal-card");
card.innerHTML = html; card.innerHTML = html;
$("#modal").hidden = false; $("#modal").hidden = false;
if (!modalInHistory) { history.pushState({ dsSheet: true }, "", location.href); modalInHistory = true; }
const prevFocus = document.activeElement; const prevFocus = document.activeElement;
return new Promise((resolve) => { return new Promise((resolve) => {
modalClose = (result) => { modalClose = (result) => {
$("#modal").hidden = true; $("#modal").hidden = true;
modalClose = null; modalClose = null;
if (modalInHistory && !closingByBack) { modalInHistory = false; ownBacks++; history.back(); }
prevFocus?.focus?.(); prevFocus?.focus?.();
resolve(result); resolve(result);
}; };
@@ -2467,23 +2486,56 @@
}); });
} }
/** The details of one entity: what it is now in words, a day's history for a sensor, the controls,
* and the technical details (its ID and attributes) folded away for whoever needs them. */
function moreInfo(eid) { function moreInfo(eid) {
const s = ha.states[eid]; const s = ha.states[eid];
if (!s) return; if (!s) return;
if (domainOf(eid) === "light") { lightPopup(eid); return; } const d = domainOf(eid);
if (domainOf(eid) === "climate") { climatePopup(eid); return; } if (d === "light") { lightPopup(eid); return; }
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid)); if (d === "climate") { climatePopup(eid); return; }
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"); const toggle = window.HAFmt.TOGGLE_DOMAINS.has(d) && !["climate", "vacuum", "media_player", "cover"].includes(d);
openModal(`<h3>${esc(friendly(s))}</h3><div class="muted small">${esc(eid)}</div> const actionLabel = d === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(d) ? tr("Run") : ["button", "input_button"].includes(d) ? tr("Press") : isOn(s) ? tr("Turn off") : tr("Turn on");
<div class="num" style="font-size:34px;font-weight:700;margin:14px 0 2px">${esc(fmtState(s))}</div> const numeric = ["sensor", "input_number", "number", "counter"].includes(d) && s.state !== "" && isFinite(Number(s.state));
// a few domains get their own buttons
const svc = (service, ico, label) => `<button class="btn" data-svc="${service}">${Icons.svg(ico)}${esc(label)}</button>`;
const extra = d === "cover" ? svc("cover.open_cover", "arrow-up", tr("Open")) + svc("cover.stop_cover", "stop", tr("Stop")) + svc("cover.close_cover", "arrow-down", tr("Close"))
: d === "media_player" ? svc("media_player.media_previous_track", "prev", tr("Previous track")) + svc("media_player.media_play_pause", s.state === "playing" ? "pause" : "play", s.state === "playing" ? tr("Pause") : tr("Play")) + svc("media_player.media_next_track", "next", tr("Next track"))
: d === "vacuum" ? svc(s.state === "cleaning" ? "vacuum.pause" : "vacuum.start", s.state === "cleaning" ? "pause" : "play", s.state === "cleaning" ? tr("Pause") : tr("Start")) + svc("vacuum.return_to_base", "home", tr("Dock")) : "";
const attrs = Object.entries(s.attributes).filter(([k]) => !["friendly_name", "icon", "entity_picture", "supported_features"].includes(k));
const done = openModal(`<div class="mi-head"><span class="mi-ico">${window.HAFmt.icon(s)}</span><h3>${esc(friendly(s))}</h3></div>
<div class="mi-state num">${esc(fmtState(s))}</div>
<div class="muted small">${esc(tr("Changed {0}", relTime(s.last_changed)))}</div> <div class="muted small">${esc(tr("Changed {0}", relTime(s.last_changed)))}</div>
<table class="attr-table">${Object.entries(s.attributes).map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table> ${numeric ? `<div class="mi-graph" aria-label="${esc(tr("The last 24 hours"))}"><div class="muted small">${tr("Loading history…")}</div></div>` : ""}
${extra ? `<div class="mi-actions">${extra}</div>` : ""}
<details class="mi-more"><summary>${tr("Technical details")}</summary>
<table class="attr-table"><tr><td>${tr("Entity ID")}</td><td>${esc(eid)}</td></tr>${attrs.map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table></details>
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button>${toggle ? `<button class="btn primary" data-x="toggle">${actionLabel}</button>` : ""}</div>`, <div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button>${toggle ? `<button class="btn primary" data-x="toggle">${actionLabel}</button>` : ""}</div>`,
(e, close) => { (e, close) => {
const x = e.target.closest("[data-x]")?.dataset.x; const b = e.target.closest("[data-x], [data-svc]");
if (x === "toggle") defaultAction(eid); if (!b) return;
if (x) close(); if (b.dataset.svc) { const [dom, service] = b.dataset.svc.split("."); ha.callService(dom, service, {}, eid); haptic(); return; }
if (b.dataset.x === "toggle") defaultAction(eid);
close();
}); });
if (numeric) drawDetailGraph(eid, done);
}
/** A day of a sensor's history as a line, with its lowest and highest value. */
async function drawDetailGraph(eid, done) {
let pts = [];
try { pts = ((await ha.history([eid], 24))[eid] || []).filter(([, v]) => isFinite(v)); } catch { /* shown as unavailable */ }
const box = $("#modal:not([hidden]) .mi-graph");
if (!box) return;
if (pts.length < 2) { box.innerHTML = `<div class="muted small">${tr("No history for the last 24 hours.")}</div>`; return; }
const W_ = 320, H = 90, t0 = Date.now() - 24 * 3600e3, t1 = Date.now();
const vs = pts.map(([, v]) => v), lo = Math.min(...vs), hi = Math.max(...vs), span = hi - lo || 1;
const x = (t) => ((Math.max(t, t0) - t0) / (t1 - t0)) * W_, y = (v) => H - 6 - ((v - lo) / span) * (H - 12);
const line = pts.map(([t, v], i) => `${i ? "L" : "M"}${x(t).toFixed(1)} ${y(v).toFixed(1)}`).join("") + `L${W_} ${y(vs[vs.length - 1]).toFixed(1)}`;
const unit = ha.states[eid]?.attributes.unit_of_measurement || "";
const f = (v) => `${Number(v).toLocaleString(LOCALE, { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ""}`;
box.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${line}" fill="none" stroke="var(--accent)" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>
<div class="mi-graph-legend muted small"><span>${tr("24 hours ago")}</span><span>${esc(tr("Low {0} · high {1}", f(lo), f(hi)))}</span><span>${tr("Now")}</span></div>`;
} }
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */ /** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
@@ -2599,7 +2651,7 @@
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : []; const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
const choice = await openModal(lockedKiosk const choice = await openModal(lockedKiosk
? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`} ? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>` <div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Homes and addresses")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p> : `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`, <div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); }); (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
@@ -3003,11 +3055,23 @@
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000); if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
} }
$("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek")); $("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
// Scrolling down, the ⋯ button steps aside so it doesn't sit on a card; scrolling up brings it back.
let lastScrollY = 0;
document.addEventListener("scroll", (e) => {
if (!document.body.classList.contains("phone-ui")) return;
const el = e.target === document ? document.scrollingElement : e.target;
if (!el || el.closest?.(".w, #modal")) return; // a list inside a card, or a sheet: not the page
const y = el.scrollTop || 0;
if (Math.abs(y - lastScrollY) < 6) return;
document.body.classList.toggle("menu-away", y > lastScrollY && y > 40);
lastScrollY = y;
}, { capture: true, passive: true });
// ------------------------------------------------------------ chrome wiring // ------------------------------------------------------------ chrome wiring
function setCurrent(id) { function setCurrent(id) {
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0]; current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
lsSet("ds.current", current.id); lsSet("ds.current", current.id);
if (phoneScreen.matches) lsSet("ds.currentPhone", current.id);
activeTabId = lsGet(`ds.tab.${current.id}`); activeTabId = lsGet(`ds.tab.${current.id}`);
guestOn = false; guestCache = null; awayState = null; awayFrom = null; guestOn = false; guestCache = null; awayState = null; awayFrom = null;
undoStack.length = redoStack.length = 0; undoStack.length = redoStack.length = 0;
@@ -3721,10 +3785,14 @@
document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon))); document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
} }
let everConnected = false;
ha.onStatus = (ok) => { ha.onStatus = (ok) => {
const c = $("#conn"); const c = $("#conn");
if (ok) everConnected = true;
c.classList.toggle("ok", ok); c.classList.toggle("ok", ok);
c.textContent = ok ? tr("Connected") : offlineText(); // the first moments after opening are connecting, not offline
c.classList.toggle("connecting", !ok && !everConnected && !lastLive);
c.textContent = ok ? tr("Connected") : !everConnected && !lastLive ? tr("Connecting…") : offlineText();
showOffline(!ok); showOffline(!ok);
// An update restarts the app, which drops the connection: check right after reconnecting. // An update restarts the app, which drops the connection: check right after reconnecting.
if (ok) checkVersion(); if (ok) checkVersion();
@@ -3824,9 +3892,12 @@
const exists = (id) => id && store.dashboards.some((d) => d.id === id); 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. // 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"); const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
mode = isAdmin() && !IN_APP ? lastMode : "dashboard"; // the builder is for bigger screens: a phone always opens on the dashboard
mode = isAdmin() && !IN_APP && !phoneScreen.matches ? lastMode : "dashboard";
editMode = mode === "builder"; editMode = mode === "builder";
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists)); // On a phone: the one last used on this phone, else the first dashboard designed for phones.
const phone = phoneScreen.matches ? [lsGet("ds.currentPhone"), store.dashboards.find((d) => d.mobile)?.id] : [];
setCurrent([hashId, dev?.dashboard, home, ...phone, lsGet("ds.current")].find(exists));
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); } 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 if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
else setMode(mode); else setMode(mode);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"version": "2.2.0", "code": 2} {"version": "2.3.0", "code": 3}
+11 -1
View File
@@ -303,6 +303,10 @@
"Emptying": "Aan het legen", "Levelling": "Aan het egaliseren", "Sanitizing": "Aan het ontsmetten", "Sleeping": "Slaapt", "Ready": "Klaar voor gebruik", "Emptying": "Aan het legen", "Levelling": "Aan het egaliseren", "Sanitizing": "Aan het ontsmetten", "Sleeping": "Slaapt", "Ready": "Klaar voor gebruik",
"Bin full": "Afvalbak vol", "Check the litter box": "Kijk even bij de kattenbak", "Cat inside": "Kat in de bak", "Needs attention": "Heeft aandacht nodig", "Bin full": "Afvalbak vol", "Check the litter box": "Kijk even bij de kattenbak", "Cat inside": "Kat in de bak", "Needs attention": "Heeft aandacht nodig",
"visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje", "visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje",
"Connecting…": "Verbinden…", "Technical details": "Technische details", "Entity ID": "Entiteit-ID", "The last 24 hours": "De afgelopen 24 uur",
"No history for the last 24 hours.": "Geen geschiedenis van de afgelopen 24 uur.", "24 hours ago": "24 uur geleden", "Low {0} · high {1}": "Laagst {0} · hoogst {1}",
"Homes and addresses": "Huizen en adressen",
"This is Haven, connected to {0}. You can add another home, such as a holiday home, or switch between them.": "Dit is Haven, verbonden met {0}. Je kunt nog een huis toevoegen, zoals een vakantiehuis, of tussen huizen wisselen.",
"Size on the phone": "Formaat op de telefoon", "Small": "Klein", "Medium": "Middel", "Now {0}°": "Nu {0}°", "Nobody home": "Niemand thuis", "Size on the phone": "Formaat op de telefoon", "Small": "Klein", "Medium": "Middel", "Now {0}°": "Nu {0}°", "Nobody home": "Niemand thuis",
"Tap to watch": "Tik om te kijken", "Returning": "Terug naar het dock", "Sun {0}": "Zon {0}", "Grid {0}": "Net {0}", "Sending back {0}": "Teruglevering {0}", "Tap to watch": "Tik om te kijken", "Returning": "Terug naar het dock", "Sun {0}": "Zon {0}", "Grid {0}": "Net {0}", "Sending back {0}": "Teruglevering {0}",
"{0} open": "{0} open", "Nothing on the way": "Niets onderweg", "Partly open": "Half open", "{0} of {1} open": "{0} van {1} open", "Nothing planned": "Niets gepland", "Only show when the bin is full or something is wrong": "Alleen tonen als de afvalbak vol is of er iets mis is", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken", "{0} open": "{0} open", "Nothing on the way": "Niets onderweg", "Partly open": "Half open", "{0} of {1} open": "{0} van {1} open", "Nothing planned": "Niets gepland", "Only show when the bin is full or something is wrong": "Alleen tonen als de afvalbak vol is of er iets mis is", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken",
@@ -811,6 +815,12 @@
armed_home: "Ingeschakeld (thuis)", armed_night: "Ingeschakeld (nacht)", disarmed: "Uitgeschakeld", triggered: "Geactiveerd", armed_home: "Ingeschakeld (thuis)", armed_night: "Ingeschakeld (nacht)", disarmed: "Uitgeschakeld", triggered: "Geactiveerd",
}; };
// States whose names don't read well as they are ("partlycloudy", "not_home").
const STATES_EN = {
not_home: "Away", partlycloudy: "Partly cloudy", "clear-night": "Clear night", "lightning-rainy": "Thunder and rain", "snowy-rainy": "Sleet",
"windy-variant": "Windy", heat_cool: "Heat and cool", fan_only: "Fan only", above_horizon: "Above the horizon", below_horizon: "Below the horizon",
armed_away: "Armed (away)", armed_home: "Armed (home)", armed_night: "Armed (night)", armed_vacation: "Armed (vacation)", disarmed: "Disarmed",
};
const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
function detect() { function detect() {
const pref = read("ds.lang"); const pref = read("ds.lang");
@@ -837,7 +847,7 @@
return args.length ? s.replace(/\{(\d)\}/g, (_, i) => args[i] ?? "") : s; return args.length ? s.replace(/\{(\d)\}/g, (_, i) => args[i] ?? "") : s;
}, },
/** A Home Assistant state as readable text. */ /** A Home Assistant state as readable text. */
state: (st) => (lang === "nl" && STATES_NL[st]) || cap(st), state: (st) => (lang === "nl" ? STATES_NL[st] : STATES_EN[st]) || cap(st),
}; };
document.documentElement.lang = lang; document.documentElement.lang = lang;
})(); })();
+1 -1
View File
@@ -30,7 +30,7 @@
<button id="btn-new" class="btn icon" aria-label="New dashboard" title="New dashboard"></button> <button id="btn-new" class="btn icon" aria-label="New dashboard" title="New dashboard"></button>
</div> </div>
<div class="tb-group"> <div class="tb-group">
<span id="conn" class="conn" role="status" data-i18n>Offline</span> <span id="conn" class="conn connecting" role="status" data-i18n>Connecting…</span>
<span id="save-status" class="save-status edit-only" role="status"></span> <span id="save-status" class="save-status edit-only" role="status"></span>
<span class="edit-only tb-group"> <span class="edit-only tb-group">
<button id="btn-undo" class="btn icon" aria-label="Undo" title="Undo (⌘Z)"></button> <button id="btn-undo" class="btn icon" aria-label="Undo" title="Undo (⌘Z)"></button>
+22
View File
@@ -125,6 +125,10 @@ h2, h3 { margin: 0; }
.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--crit); padding: 0 6px; } .conn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--crit); padding: 0 6px; }
.conn::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } .conn::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.conn.ok { display: none; } .conn.ok { display: none; }
.conn.connecting { color: var(--text-2); }
.conn.connecting::before { animation: conn-pulse 1.2s ease-in-out infinite; }
@keyframes conn-pulse { 50% { opacity: .3; } }
@media (prefers-reduced-motion: reduce) { .conn.connecting::before { animation: none; } }
.save-status { font-size: 13px; color: var(--text-2); padding: 0 6px; white-space: nowrap; } .save-status { font-size: 13px; color: var(--text-2); padding: 0 6px; white-space: nowrap; }
.menu { .menu {
@@ -1215,6 +1219,8 @@ body.phone-ui:not(.peek) #topbar { display: none; }
background: rgba(120, 120, 128, .28); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, .15); } background: rgba(120, 120, 128, .28); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, .15); }
#phone-menu svg { width: 20px; height: 20px; stroke-width: 2.6; } #phone-menu svg { width: 20px; height: 20px; stroke-width: 2.6; }
body.phone-ui:not(.peek):not(.kiosk) #phone-menu { display: grid; } body.phone-ui:not(.peek):not(.kiosk) #phone-menu { display: grid; }
#phone-menu { transition: opacity .2s, transform .2s; }
body.menu-away #phone-menu { opacity: 0; transform: translateY(-12px); pointer-events: none; }
/* the install button in the top bar is for phones and tablets; computers have it in the menu */ /* the install button in the top bar is for phones and tablets; computers have it in the menu */
@media (pointer: fine) { #btn-app { display: none; } } @media (pointer: fine) { #btn-app { display: none; } }
#btn-app[hidden] { display: none; } #btn-app[hidden] { display: none; }
@@ -2142,3 +2148,19 @@ button.fader-input { cursor: pointer; }
.phc-m:not(:has(.phc-bottom)) { justify-content: center; } .phc-m:not(:has(.phc-bottom)) { justify-content: center; }
.phc-m:not(:has(.phc-bottom)) .phc-name { font-size: 20px; } .phc-m:not(:has(.phc-bottom)) .phc-name { font-size: 20px; }
.phc-l:not(:has(.phc-val)) .phc-name { font-size: 24px; } .phc-l:not(:has(.phc-val)) .phc-name { font-size: 24px; }
/* the details sheet: the state in words, a day's history, the controls; the technical details folded away */
.mi-head { display: flex; align-items: center; gap: 10px; }
.mi-head h3 { margin: 0; }
.mi-ico { display: flex; font-size: 22px; color: var(--text-2); }
.mi-state { font-size: 34px; font-weight: 400; letter-spacing: -0.02em; margin: 14px 0 2px; }
.mi-graph { margin-top: 14px; }
.mi-graph svg { display: block; width: 100%; height: 90px; }
.mi-graph-legend { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; }
.mi-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.mi-actions .btn { flex: 1; min-width: 0; justify-content: center; }
.mi-more { margin-top: 16px; }
.mi-more summary { cursor: pointer; font-size: 13px; color: var(--text-2); padding: 6px 0; }
.mi-more .attr-table { margin-top: 4px; }
/* lit tiles keep the lamp's dark text */
.phc { color: inherit; }
+5 -2
View File
@@ -1,13 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="nl.melvanveen.haven" package="nl.melvanveen.haven"
android:versionCode="2" android:versionCode="3"
android:versionName="2.2.0"> android:versionName="2.3.0">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<!-- finding Home Assistant on the home network -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- installing a newer Haven that Dashboard Studio offers --> <!-- installing a newer Haven that Dashboard Studio offers -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
+53 -3
View File
@@ -28,6 +28,21 @@
.home .x { width: 44px; flex: none; background: transparent; color: #a9b8c2; font-size: 20px; padding: 12px 0; } .home .x { width: 44px; flex: none; background: transparent; color: #a9b8c2; font-size: 20px; padding: 12px 0; }
.add { background: transparent; color: #f4a531; border: 1.5px solid rgba(244, 165, 49, .5); margin-bottom: 22px; } .add { background: transparent; color: #f4a531; border: 1.5px solid rgba(244, 165, 49, .5); margin-bottom: 22px; }
#f.hide { display: none; } #f.hide { display: none; }
.found { margin: 0 0 22px; }
.found h2 { font-size: 14px; font-weight: 600; margin: 0 0 8px; }
.found .go { display: flex; flex-direction: column; text-align: left; background: rgba(255, 255, 255, .08); color: #f4eee4; padding: 12px 14px; margin-bottom: 8px; }
.found .go small { font-weight: 400; color: #a9b8c2; font-size: 13px; }
.found .look { display: flex; align-items: center; gap: 10px; font-size: 14px; color: #a9b8c2; margin: 0 0 6px; }
.found .look::before { content: ""; width: 14px; height: 14px; border-radius: 50%; border: 2px solid #f4a531; border-right-color: transparent; animation: spin .9s linear infinite; flex: none; }
.found .look.done::before { display: none; }
.found .look[hidden] { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.or { font-size: 14px; color: #a9b8c2; margin: 0 0 12px; }
.home.ask { flex-wrap: wrap; background: rgba(255, 69, 58, .12); border-radius: 12px; padding: 12px 14px; }
.home.ask p { flex-basis: 100%; margin: 0 0 10px; color: #f4eee4; }
.home.ask button { flex: 1; padding: 10px; }
.home.ask .keep { background: rgba(255, 255, 255, .1); color: #f4eee4; }
.home.ask .rm { background: #ff453a; color: #fff; }
</style> </style>
</head> </head>
<body> <body>
@@ -39,6 +54,7 @@
<p class="msg" id="msg"></p> <p class="msg" id="msg"></p>
<div class="homes" id="homes"></div> <div class="homes" id="homes"></div>
<button type="button" class="add" id="add" hidden data-t="add">Add another home</button> <button type="button" class="add" id="add" hidden data-t="add">Add another home</button>
<div class="found" id="found" hidden><h2 data-t="foundTitle">On this network</h2><p class="look" id="look" data-t="looking">Looking for Home Assistant…</p><div id="foundList"></div><p class="or" data-t="or">Or enter the address yourself:</p></div>
<form id="f"> <form id="f">
<label for="addr" data-t="label">Home Assistant address</label> <label for="addr" data-t="label">Home Assistant address</label>
<input id="addr" type="text" inputmode="url" autocomplete="url" autocapitalize="off" spellcheck="false" placeholder="http://homeassistant.local:8123" required> <input id="addr" type="text" inputmode="url" autocomplete="url" autocapitalize="off" spellcheck="false" placeholder="http://homeassistant.local:8123" required>
@@ -58,12 +74,19 @@
notfound: "Dashboard Studio staat niet in Home Assistant. Vraag wie Home Assistant beheert om het te installeren.", notfound: "Dashboard Studio staat niet in Home Assistant. Vraag wie Home Assistant beheert om het te installeren.",
noaddress: "Dashboard Studio is gevonden, maar het adres kon niet worden opgevraagd. Tik in Dashboard Studio op Openen in Haven, of vraag wie Home Assistant beheert.", noaddress: "Dashboard Studio is gevonden, maar het adres kon niet worden opgevraagd. Tik in Dashboard Studio op Openen in Haven, of vraag wie Home Assistant beheert.",
error: "Verbinden lukte niet: {0}", error: "Verbinden lukte niet: {0}",
foundTitle: "Op dit netwerk",
looking: "Home Assistant zoeken…",
notFound: "Niet gevonden op dit netwerk.",
or: "Of vul het adres zelf in:",
remove: "Verwijderen", keep: "Bewaren", removeQ: "{0} uit Haven verwijderen?",
}; };
const EN = { const EN = {
offline: "Home Assistant can't be reached at this address. Check the address and your connection.", offline: "Home Assistant can't be reached at this address. Check the address and your connection.",
notfound: "Dashboard Studio isn't installed in Home Assistant. Ask whoever manages Home Assistant to install it.", notfound: "Dashboard Studio isn't installed in Home Assistant. Ask whoever manages Home Assistant to install it.",
noaddress: "Dashboard Studio was found, but its address couldn't be looked up. Tap Open in Haven in Dashboard Studio, or ask whoever manages Home Assistant.", noaddress: "Dashboard Studio was found, but its address couldn't be looked up. Tap Open in Haven in Dashboard Studio, or ask whoever manages Home Assistant.",
error: "Couldn't connect: {0}", error: "Couldn't connect: {0}",
notFound: "None found on this network.",
remove: "Remove", keep: "Keep", removeQ: "Remove {0} from Haven?",
}; };
const T = navigator.language.startsWith("nl") ? NL : EN; const T = navigator.language.startsWith("nl") ? NL : EN;
if (T === NL) document.querySelectorAll("[data-t]").forEach((el) => { el.textContent = NL[el.dataset.t] || el.textContent; }); if (T === NL) document.querySelectorAll("[data-t]").forEach((el) => { el.textContent = NL[el.dataset.t] || el.textContent; });
@@ -80,13 +103,40 @@
const removeLabel = T === NL ? "Verwijderen" : "Remove"; const removeLabel = T === NL ? "Verwijderen" : "Remove";
box.innerHTML = homes.map((h) => `<div class="home"><button type="button" class="go ${h.current ? "on" : ""}" data-id="${esc(h.id)}">${esc(h.name)}<small>${esc(h.ha)}</small></button> box.innerHTML = homes.map((h) => `<div class="home"><button type="button" class="go ${h.current ? "on" : ""}" data-id="${esc(h.id)}">${esc(h.name)}<small>${esc(h.ha)}</small></button>
<button type="button" class="x" data-x="${esc(h.id)}" aria-label="${removeLabel} ${esc(h.name)}">×</button></div>`).join(""); <button type="button" class="x" data-x="${esc(h.id)}" aria-label="${removeLabel} ${esc(h.name)}">×</button></div>`).join("");
const words = (k) => (T[k] || EN[k] || k);
box.onclick = (e) => { box.onclick = (e) => {
const go = e.target.closest("[data-id]"), x = e.target.closest("[data-x]"); const go = e.target.closest("[data-id]"), x = e.target.closest("[data-x]"), b = e.target.closest("[data-rm], [data-keep]");
if (go) app.switchHome(go.dataset.id); if (go) app.switchHome(go.dataset.id);
if (x && confirm((T === NL ? "Verwijderen: " : "Remove ") + x.closest(".home").querySelector(".go").firstChild.textContent + "?")) app.removeHome(x.dataset.x); // removing asks first, right there in the list
if (x) {
const row = x.closest(".home"), name = row.querySelector(".go").firstChild.textContent;
row.classList.add("ask");
row.innerHTML = `<p>${esc(words("removeQ").replace("{0}", name))}</p><button type="button" class="keep" data-keep>${words("keep")}</button><button type="button" class="rm" data-rm="${esc(x.dataset.x)}">${words("remove")}</button>`;
}
if (b?.dataset.rm) app.removeHome(b.dataset.rm);
if (b && "keep" in b.dataset) location.reload();
}; };
if (homes.length && homes.some((h) => h.current) && !why) { form.classList.add("hide"); add.hidden = false; } const adding = !(homes.length && homes.some((h) => h.current) && !why);
if (!adding) { form.classList.add("hide"); add.hidden = false; }
add.onclick = () => app.addHome(); add.onclick = () => app.addHome();
// Home Assistant announces itself on the home network: list what is found, to tap instead of typing.
if (adding && app && app.findHomes) {
const wrap = document.getElementById("found"), list = document.getElementById("foundList"), look = document.getElementById("look");
const known = new Set(homes.map((h) => h.ha.toLowerCase()));
app.findHomes();
wrap.hidden = false;
let n = 0;
const tick = () => {
const found = JSON.parse(app.found()).filter((f) => !known.has(f.url.toLowerCase()));
list.innerHTML = found.map((f) => `<button type="button" class="go" data-url="${esc(f.url)}">${esc(f.name)}<small>${esc(f.url)}</small></button>`).join("");
if (++n < 30) setTimeout(tick, 1000);
else { look.classList.add("done"); if (!found.length) look.textContent = words("notFound"); else look.hidden = true; }
if (found.length) look.hidden = true;
};
tick();
list.onclick = (e) => { const b = e.target.closest("[data-url]"); if (b) app.setup(b.dataset.url); };
}
</script> </script>
</body> </body>
</html> </html>
+162 -8
View File
@@ -12,10 +12,15 @@ import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
import android.graphics.Color; import android.graphics.Color;
import android.net.Uri; import android.net.Uri;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.net.wifi.WifiManager;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import android.provider.Settings; import android.provider.Settings;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View; import android.view.View;
import android.view.WindowManager; import android.view.WindowManager;
import android.webkit.CookieManager; import android.webkit.CookieManager;
@@ -28,11 +33,18 @@ import android.webkit.WebResourceResponse;
import android.webkit.WebSettings; import android.webkit.WebSettings;
import android.webkit.WebView; import android.webkit.WebView;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import java.util.ArrayDeque;
import java.util.Locale;
import java.util.Map;
/** /**
* Haven: the dashboards of Dashboard Studio as an Android app, for everyone in the home. Full screen, and only * Haven: the dashboards of Dashboard Studio as an Android app, for everyone in the home. Full screen, and only
* the dashboards: Dashboard Studio leaves out its builder and admin screen when window.HavenApp is there. * the dashboards: Dashboard Studio leaves out its builder and admin screen when window.HavenApp is there.
@@ -49,6 +61,9 @@ public class MainActivity extends Activity {
private WebView web; private WebView web;
private SharedPreferences prefs; private SharedPreferences prefs;
private String message = "", detail = ""; private String message = "", detail = "";
private String tried = ""; // the address last tried, to fill in again when it didn't work
private TextView status, another; // on the loading screen: what Haven is doing, and a way out
private static final boolean NL = Locale.getDefault().getLanguage().equals("nl");
private long connectedAt = 0; private long connectedAt = 0;
private ValueCallback<Uri[]> fileCallback; private ValueCallback<Uri[]> fileCallback;
@@ -101,7 +116,28 @@ public class MainActivity extends Activity {
web = new WebView(this); web = new WebView(this);
web.setBackgroundColor(Color.TRANSPARENT); // the loading screen shows through until a page is ready web.setBackgroundColor(Color.TRANSPARENT); // the loading screen shows through until a page is ready
setContentView(web); FrameLayout root = new FrameLayout(this);
root.addView(web);
// Under the icon of the loading screen: what Haven is doing, and after a while a way to choose another home.
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setGravity(Gravity.CENTER_HORIZONTAL);
box.setPadding(dp(24), dp(130), dp(24), 0);
status = new TextView(this);
status.setTextColor(Color.parseColor("#a9b8c2"));
status.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
status.setGravity(Gravity.CENTER);
another = new TextView(this);
another.setText(NL ? "Ander huis of adres kiezen" : "Choose another home or address");
another.setTextColor(Color.parseColor("#f4a531"));
another.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
another.setPadding(dp(16), dp(20), dp(16), dp(12));
another.setOnClickListener((v) -> showSetup("", ""));
box.addView(status);
box.addView(another);
root.addView(box, new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER));
showStatus(false);
setContentView(root);
WebSettings s = web.getSettings(); WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true); s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true); s.setDomStorageEnabled(true);
@@ -140,7 +176,8 @@ public class MainActivity extends Activity {
private void save(String ha, String app) { private void save(String ha, String app) {
try { try {
JSONObject h = Hass.current(this); JSONObject h = Hass.current(this);
if (h == null || !h.optString("ha").equalsIgnoreCase(ha)) h = new JSONObject().put("ha", ha); // a new address is only kept for good once it has connected (see Bridge.open)
if (h == null || !h.optString("ha").equalsIgnoreCase(ha)) h = new JSONObject().put("ha", ha).put("pending", "true");
h.put("app", app); h.put("app", app);
Hass.setCurrent(this, Hass.save(this, h).optString("id")); Hass.setCurrent(this, Hass.save(this, h).optString("id"));
} catch (JSONException ignored) { /* nothing to save */ } } catch (JSONException ignored) { /* nothing to save */ }
@@ -168,15 +205,35 @@ public class MainActivity extends Activity {
/** While Home Assistant's page does its work, the loading screen (the window background) shows instead. */ /** While Home Assistant's page does its work, the loading screen (the window background) shows instead. */
private void hidePage() { private void hidePage() {
web.setAlpha(0f); web.setAlpha(0f);
showStatus(true);
reveal.removeCallbacks(showPage); reveal.removeCallbacks(showPage);
reveal.postDelayed(showPage, 6000); // still there after a while: probably something to read or answer reveal.postDelayed(showPage, 6000); // still there after a while: probably something to read or answer
} }
private final android.os.Handler reveal = new android.os.Handler(android.os.Looper.getMainLooper()); private final android.os.Handler reveal = new android.os.Handler(android.os.Looper.getMainLooper());
private final Runnable showPage = () -> web.animate().alpha(1f).setDuration(150); private final Runnable showPage = () -> { showStatus(false); web.animate().alpha(1f).setDuration(150); };
private final Runnable offerAnother = () -> another.setVisibility(View.VISIBLE);
/** "Connecting to Home…" on the loading screen; after a while also "Choose another home or address". */
private void showStatus(boolean on) {
reveal.removeCallbacks(offerAnother);
another.setVisibility(View.GONE);
if (!on) { status.setVisibility(View.GONE); return; }
JSONObject h = Hass.current(this);
String name = h == null || h.optString("pending").equals("true") ? "" : h.optString("name");
status.setText(name.isEmpty() ? (NL ? "Verbinden met Home Assistant…" : "Connecting to Home Assistant…") : (NL ? "Verbinden met " : "Connecting to ") + name + "…");
status.setVisibility(View.VISIBLE);
reveal.postDelayed(offerAnother, 12000);
}
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
private boolean offlineTried = false; private boolean offlineTried = false;
private void showSetup(String why, String more) { private void showSetup(String why, String more) {
message = why; detail = more == null ? "" : more; message = why; detail = more == null ? "" : more;
showStatus(false);
// An address that never connected isn't kept as a home; it is filled in again to correct it.
JSONObject h = Hass.current(this);
if (h != null && h.optString("pending").equals("true")) { tried = h.optString("ha"); Hass.remove(this, h.optString("id")); }
web.loadUrl(SETUP); web.loadUrl(SETUP);
} }
@@ -185,12 +242,12 @@ public class MainActivity extends Activity {
public void onPageStarted(WebView view, String url, android.graphics.Bitmap icon) { public void onPageStarted(WebView view, String url, android.graphics.Bitmap icon) {
boolean haPage = url != null && !ha().isEmpty() && url.startsWith(ha()) && !isIngress(url) && !url.equals(app().isEmpty() ? "-" : app()); boolean haPage = url != null && !ha().isEmpty() && url.startsWith(ha()) && !isIngress(url) && !url.equals(app().isEmpty() ? "-" : app());
if (haPage && !url.contains("/auth/")) hidePage(); if (haPage && !url.contains("/auth/")) hidePage();
else if (url != null && url.contains("/auth/")) { reveal.removeCallbacks(showPage); web.setAlpha(1f); } // signing in else if (url != null && url.contains("/auth/")) { reveal.removeCallbacks(showPage); showStatus(false); web.setAlpha(1f); } // signing in
} }
@Override @Override
public void onPageFinished(WebView view, String url) { public void onPageFinished(WebView view, String url) {
if (url != null && (isIngress(url) || url.startsWith("file:"))) { reveal.removeCallbacks(showPage); web.animate().alpha(1f).setDuration(150); } if (url != null && (isIngress(url) || url.startsWith("file:"))) { reveal.removeCallbacks(showPage); showStatus(false); web.animate().alpha(1f).setDuration(150); }
if (url == null || ha().isEmpty() || !url.startsWith(ha())) return; if (url == null || ha().isEmpty() || !url.startsWith(ha())) return;
String page = app().replaceAll("[?#].*", ""); String page = app().replaceAll("[?#].*", "");
if (isIngress(url) || (!page.isEmpty() && url.startsWith(page))) { if (isIngress(url) || (!page.isEmpty() && url.startsWith(page))) {
@@ -216,7 +273,7 @@ public class MainActivity extends Activity {
if (!req.isForMainFrame() || req.getUrl().toString().startsWith("file:")) return; if (!req.isForMainFrame() || req.getUrl().toString().startsWith("file:")) return;
// Home Assistant can't be reached: Dashboard Studio's offline copy may still open, with the last known states. // Home Assistant can't be reached: Dashboard Studio's offline copy may still open, with the last known states.
if (!isIngress(req.getUrl().toString()) && !app().isEmpty() && !offlineTried) { offlineTried = true; web.loadUrl(app()); } if (!isIngress(req.getUrl().toString()) && !app().isEmpty() && !offlineTried) { offlineTried = true; web.loadUrl(app()); }
else { reveal.removeCallbacks(showPage); web.setAlpha(1f); showSetup("offline", ""); } else { reveal.removeCallbacks(showPage); showStatus(false); web.setAlpha(1f); showSetup("offline", ""); }
} }
@Override @Override
@@ -261,8 +318,12 @@ public class MainActivity extends Activity {
@Override @Override
public void onBackPressed() { public void onBackPressed() {
// The page first: back closes an open sheet or menu there. Then its history; then Haven goes to the background.
web.evaluateJavascript("(window.havenBack && window.havenBack()) ? 1 : 0", (v) -> {
if ("1".equals(v)) return;
if (web.canGoBack()) web.goBack(); if (web.canGoBack()) web.goBack();
else super.onBackPressed(); else moveTaskToBack(true);
});
} }
@Override @Override
@@ -340,6 +401,90 @@ public class MainActivity extends Activity {
} catch (ActivityNotFoundException ignored) { /* no installer */ } } catch (ActivityNotFoundException ignored) { /* no installer */ }
} }
// ---------------------------------------------------------------- finding Home Assistant
// Home Assistant announces itself on the home network (zeroconf, "_home-assistant._tcp"), with its name and
// address. The setup page lists what is found, so nobody has to know the address.
private NsdManager nsd;
private NsdManager.DiscoveryListener finder;
private WifiManager.MulticastLock multicast;
private final JSONArray found = new JSONArray(); // {name, url}
private final ArrayDeque<NsdServiceInfo> toResolve = new ArrayDeque<>();
private boolean resolving = false;
private final Runnable stopFinding = this::stopFinding;
private void startFinding() {
if (finder != null) return;
nsd = (NsdManager) getSystemService(NSD_SERVICE);
if (nsd == null) return;
try {
multicast = ((WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE)).createMulticastLock("haven");
multicast.setReferenceCounted(false);
multicast.acquire();
} catch (Exception ignored) { multicast = null; /* finding may still work */ }
finder = new NsdManager.DiscoveryListener() {
@Override public void onServiceFound(NsdServiceInfo info) { synchronized (toResolve) { toResolve.add(info); } resolveNext(); }
@Override public void onServiceLost(NsdServiceInfo info) {}
@Override public void onDiscoveryStarted(String type) {}
@Override public void onDiscoveryStopped(String type) {}
@Override public void onStartDiscoveryFailed(String type, int code) { finder = null; }
@Override public void onStopDiscoveryFailed(String type, int code) {}
};
try { nsd.discoverServices("_home-assistant._tcp", NsdManager.PROTOCOL_DNS_SD, finder); } catch (Exception e) { finder = null; }
reveal.postDelayed(stopFinding, 60000);
}
private void stopFinding() {
reveal.removeCallbacks(stopFinding);
if (finder != null) try { nsd.stopServiceDiscovery(finder); } catch (Exception ignored) { /* already stopped */ }
finder = null;
if (multicast != null) try { multicast.release(); } catch (Exception ignored) { /* not held */ }
multicast = null;
}
/** One at a time: older Android resolves only one service at once. */
private void resolveNext() {
NsdServiceInfo next;
synchronized (toResolve) {
if (resolving || toResolve.isEmpty()) return;
resolving = true;
next = toResolve.poll();
}
nsd.resolveService(next, new NsdManager.ResolveListener() {
@Override public void onResolveFailed(NsdServiceInfo info, int code) { synchronized (toResolve) { resolving = false; } resolveNext(); }
@Override public void onServiceResolved(NsdServiceInfo info) { addFound(info); synchronized (toResolve) { resolving = false; } resolveNext(); }
});
}
private void addFound(NsdServiceInfo info) {
Map<String, byte[]> txt = info.getAttributes();
String url = text(txt, "internal_url");
if (url.isEmpty()) url = text(txt, "base_url");
if (url.isEmpty() && info.getHost() != null) {
String host = info.getHost().getHostAddress();
if (host == null) return;
if (host.contains(":")) host = "[" + host.replaceAll("%.*", "") + "]"; // IPv6
url = "http://" + host + ":" + info.getPort();
}
if (!url.matches("(?i)https?://.+")) return;
url = url.replaceAll("/+$", "");
String name = text(txt, "location_name");
if (name.isEmpty()) name = info.getServiceName();
try {
synchronized (found) {
for (int i = 0; i < found.length(); i++) if (found.getJSONObject(i).optString("url").equalsIgnoreCase(url)) return;
found.put(new JSONObject().put("name", name).put("url", url));
}
} catch (JSONException ignored) { /* skip this one */ }
}
private static String text(Map<String, byte[]> txt, String key) {
byte[] v = txt == null ? null : txt.get(key);
return v == null ? "" : new String(v, java.nio.charset.StandardCharsets.UTF_8).trim();
}
@Override
protected void onDestroy() { stopFinding(); super.onDestroy(); }
@Override @Override
protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); web.saveState(out); } protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); web.saveState(out); }
@@ -351,7 +496,7 @@ public class MainActivity extends Activity {
/** What the pages can call, as window.HavenApp. */ /** What the pages can call, as window.HavenApp. */
private class Bridge { private class Bridge {
@JavascriptInterface public String address() { return ha(); } @JavascriptInterface public String address() { return tried.isEmpty() ? ha() : tried; }
@JavascriptInterface public String appUrl() { return app(); } @JavascriptInterface public String appUrl() { return app(); }
@JavascriptInterface public String message() { return message; } @JavascriptInterface public String message() { return message; }
@JavascriptInterface public String detail() { return detail; } @JavascriptInterface public String detail() { return detail; }
@@ -364,6 +509,7 @@ public class MainActivity extends Activity {
if (!text.matches("(?i)https?://.*")) text = "http://" + text; if (!text.matches("(?i)https?://.*")) text = "http://" + text;
Uri u = Uri.parse(text); Uri u = Uri.parse(text);
if (u.getHost() == null || u.getHost().isEmpty()) { showSetup("offline", ""); return; } if (u.getHost() == null || u.getHost().isEmpty()) { showSetup("offline", ""); return; }
stopFinding();
save(origin(u), isIngress(text) ? text : ""); save(origin(u), isIngress(text) ? text : "");
start(); start();
}); });
@@ -373,6 +519,9 @@ public class MainActivity extends Activity {
runOnUiThread(() -> { runOnUiThread(() -> {
if (url == null || !url.startsWith(ha())) return; if (url == null || !url.startsWith(ha())) return;
save(ha(), url); save(ha(), url);
JSONObject h = Hass.current(MainActivity.this);
if (h != null && h.optString("pending").equals("true")) { try { Hass.save(MainActivity.this, h.put("pending", "false")); } catch (JSONException ignored) { /* kept as it was */ } } // it connected: keep it
tried = "";
web.loadUrl(url); web.loadUrl(url);
}); });
} }
@@ -401,12 +550,17 @@ public class MainActivity extends Activity {
try { try {
for (int i = 0; i < list.length(); i++) { for (int i = 0; i < list.length(); i++) {
JSONObject h = list.getJSONObject(i); JSONObject h = list.getJSONObject(i);
if (h.optString("pending").equals("true")) continue;
out.put(new JSONObject().put("id", h.optString("id")).put("name", h.optString("name")).put("ha", h.optString("ha")).put("current", cur.equals(h.optString("id")))); out.put(new JSONObject().put("id", h.optString("id")).put("name", h.optString("name")).put("ha", h.optString("ha")).put("current", cur.equals(h.optString("id"))));
} }
} catch (JSONException ignored) { /* an empty list */ } } catch (JSONException ignored) { /* an empty list */ }
return out.toString(); return out.toString();
} }
/** The setup page: look for Home Assistant on this network, and what was found so far. */
@JavascriptInterface public void findHomes() { runOnUiThread(MainActivity.this::startFinding); }
@JavascriptInterface public String found() { synchronized (found) { return found.toString(); } }
@JavascriptInterface public void switchHome(String id) { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, id); start(); }); } @JavascriptInterface public void switchHome(String id) { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, id); start(); }); }
@JavascriptInterface public void addHome() { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, ""); showSetup("", ""); }); } @JavascriptInterface public void addHome() { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, ""); showSetup("", ""); }); }