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:
@@ -1,5 +1,20 @@
|
||||
# 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
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
- **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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Dashboard Studio
|
||||
version: "2.5.0"
|
||||
version: "2.6.0"
|
||||
slug: dashboard_studio
|
||||
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
|
||||
|
||||
+88
-17
@@ -301,8 +301,8 @@
|
||||
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
|
||||
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
|
||||
if (IN_APP) {
|
||||
body = p(tr("This is Haven, connected to {0}.", location.host));
|
||||
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
|
||||
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("Homes and addresses")}</button>`;
|
||||
} 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."))
|
||||
@@ -325,7 +325,7 @@
|
||||
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");
|
||||
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")}
|
||||
<div class="modal-actions">${actions}</div>`,
|
||||
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
|
||||
@@ -2238,15 +2238,34 @@
|
||||
|
||||
// ------------------------------------------------------------ dialogs
|
||||
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) {
|
||||
const card = $("#modal .modal-card");
|
||||
card.innerHTML = html;
|
||||
$("#modal").hidden = false;
|
||||
if (!modalInHistory) { history.pushState({ dsSheet: true }, "", location.href); modalInHistory = true; }
|
||||
const prevFocus = document.activeElement;
|
||||
return new Promise((resolve) => {
|
||||
modalClose = (result) => {
|
||||
$("#modal").hidden = true;
|
||||
modalClose = null;
|
||||
if (modalInHistory && !closingByBack) { modalInHistory = false; ownBacks++; history.back(); }
|
||||
prevFocus?.focus?.();
|
||||
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) {
|
||||
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(`<h3>${esc(friendly(s))}</h3><div class="muted small">${esc(eid)}</div>
|
||||
<div class="num" style="font-size:34px;font-weight:700;margin:14px 0 2px">${esc(fmtState(s))}</div>
|
||||
const d = domainOf(eid);
|
||||
if (d === "light") { lightPopup(eid); return; }
|
||||
if (d === "climate") { climatePopup(eid); return; }
|
||||
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(d) && !["climate", "vacuum", "media_player", "cover"].includes(d);
|
||||
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");
|
||||
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>
|
||||
<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>`,
|
||||
(e, close) => {
|
||||
const x = e.target.closest("[data-x]")?.dataset.x;
|
||||
if (x === "toggle") defaultAction(eid);
|
||||
if (x) close();
|
||||
const b = e.target.closest("[data-x], [data-svc]");
|
||||
if (!b) return;
|
||||
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*. */
|
||||
@@ -2599,7 +2651,7 @@
|
||||
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
|
||||
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>`}
|
||||
<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>
|
||||
<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); });
|
||||
@@ -3003,11 +3055,23 @@
|
||||
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
|
||||
}
|
||||
$("#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
|
||||
function setCurrent(id) {
|
||||
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
|
||||
lsSet("ds.current", current.id);
|
||||
if (phoneScreen.matches) lsSet("ds.currentPhone", current.id);
|
||||
activeTabId = lsGet(`ds.tab.${current.id}`);
|
||||
guestOn = false; guestCache = null; awayState = null; awayFrom = null;
|
||||
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)));
|
||||
}
|
||||
|
||||
let everConnected = false;
|
||||
ha.onStatus = (ok) => {
|
||||
const c = $("#conn");
|
||||
if (ok) everConnected = true;
|
||||
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);
|
||||
// An update restarts the app, which drops the connection: check right after reconnecting.
|
||||
if (ok) checkVersion();
|
||||
@@ -3824,9 +3892,12 @@
|
||||
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";
|
||||
// the builder is for bigger screens: a phone always opens on the dashboard
|
||||
mode = isAdmin() && !IN_APP && !phoneScreen.matches ? lastMode : "dashboard";
|
||||
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 }); }
|
||||
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
|
||||
else setMode(mode);
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
{"version": "2.2.0", "code": 2}
|
||||
{"version": "2.3.0", "code": 3}
|
||||
|
||||
@@ -303,6 +303,10 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -811,6 +815,12 @@
|
||||
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; } };
|
||||
function detect() {
|
||||
const pref = read("ds.lang");
|
||||
@@ -837,7 +847,7 @@
|
||||
return args.length ? s.replace(/\{(\d)\}/g, (_, i) => args[i] ?? "") : s;
|
||||
},
|
||||
/** 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;
|
||||
})();
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<button id="btn-new" class="btn icon" aria-label="New dashboard" title="New dashboard"></button>
|
||||
</div>
|
||||
<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 class="edit-only tb-group">
|
||||
<button id="btn-undo" class="btn icon" aria-label="Undo" title="Undo (⌘Z)"></button>
|
||||
|
||||
@@ -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::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.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; }
|
||||
|
||||
.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); }
|
||||
#phone-menu svg { width: 20px; height: 20px; stroke-width: 2.6; }
|
||||
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 */
|
||||
@media (pointer: fine) { #btn-app { 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)) .phc-name { font-size: 20px; }
|
||||
.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; }
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="nl.melvanveen.haven"
|
||||
android:versionCode="2"
|
||||
android:versionName="2.2.0">
|
||||
android:versionCode="3"
|
||||
android:versionName="2.3.0">
|
||||
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<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 -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
|
||||
+53
-3
@@ -28,6 +28,21 @@
|
||||
.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; }
|
||||
#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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -39,6 +54,7 @@
|
||||
<p class="msg" id="msg"></p>
|
||||
<div class="homes" id="homes"></div>
|
||||
<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">
|
||||
<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>
|
||||
@@ -58,12 +74,19 @@
|
||||
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.",
|
||||
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 = {
|
||||
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.",
|
||||
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}",
|
||||
notFound: "None found on this network.",
|
||||
remove: "Remove", keep: "Keep", removeQ: "Remove {0} from Haven?",
|
||||
};
|
||||
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; });
|
||||
@@ -80,13 +103,40 @@
|
||||
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>
|
||||
<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) => {
|
||||
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 (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();
|
||||
|
||||
// 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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,10 +12,15 @@ import android.content.SharedPreferences;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.graphics.Color;
|
||||
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.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.provider.Settings;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.CookieManager;
|
||||
@@ -28,11 +33,18 @@ import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
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
|
||||
* 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 SharedPreferences prefs;
|
||||
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 ValueCallback<Uri[]> fileCallback;
|
||||
|
||||
@@ -101,7 +116,28 @@ public class MainActivity extends Activity {
|
||||
|
||||
web = new WebView(this);
|
||||
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();
|
||||
s.setJavaScriptEnabled(true);
|
||||
s.setDomStorageEnabled(true);
|
||||
@@ -140,7 +176,8 @@ public class MainActivity extends Activity {
|
||||
private void save(String ha, String app) {
|
||||
try {
|
||||
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);
|
||||
Hass.setCurrent(this, Hass.save(this, h).optString("id"));
|
||||
} 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. */
|
||||
private void hidePage() {
|
||||
web.setAlpha(0f);
|
||||
showStatus(true);
|
||||
reveal.removeCallbacks(showPage);
|
||||
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 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 void showSetup(String why, String 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);
|
||||
}
|
||||
|
||||
@@ -185,12 +242,12 @@ public class MainActivity extends Activity {
|
||||
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());
|
||||
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
|
||||
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;
|
||||
String page = app().replaceAll("[?#].*", "");
|
||||
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;
|
||||
// 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()); }
|
||||
else { reveal.removeCallbacks(showPage); web.setAlpha(1f); showSetup("offline", ""); }
|
||||
else { reveal.removeCallbacks(showPage); showStatus(false); web.setAlpha(1f); showSetup("offline", ""); }
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -261,8 +318,12 @@ public class MainActivity extends Activity {
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (web.canGoBack()) web.goBack();
|
||||
else super.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();
|
||||
else moveTaskToBack(true);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -340,6 +401,90 @@ public class MainActivity extends Activity {
|
||||
} 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
|
||||
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. */
|
||||
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 message() { return message; }
|
||||
@JavascriptInterface public String detail() { return detail; }
|
||||
@@ -364,6 +509,7 @@ public class MainActivity extends Activity {
|
||||
if (!text.matches("(?i)https?://.*")) text = "http://" + text;
|
||||
Uri u = Uri.parse(text);
|
||||
if (u.getHost() == null || u.getHost().isEmpty()) { showSetup("offline", ""); return; }
|
||||
stopFinding();
|
||||
save(origin(u), isIngress(text) ? text : "");
|
||||
start();
|
||||
});
|
||||
@@ -373,6 +519,9 @@ public class MainActivity extends Activity {
|
||||
runOnUiThread(() -> {
|
||||
if (url == null || !url.startsWith(ha())) return;
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -401,12 +550,17 @@ public class MainActivity extends Activity {
|
||||
try {
|
||||
for (int i = 0; i < list.length(); 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"))));
|
||||
}
|
||||
} catch (JSONException ignored) { /* an empty list */ }
|
||||
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 addHome() { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, ""); showSetup("", ""); }); }
|
||||
|
||||
Reference in New Issue
Block a user