Dashboard Studio 2.0.0: Admin, Builder and Dashboard screens, roles, Haven

- Home Assistant administrators are admins: Dashboard, Builder and Admin screens
- Everyone else only sees the dashboards an admin chose, always in kiosk mode;
  the server enforces it (saving, people, devices, history are admin-only, and
  the websocket only allows reading states and controlling devices)
- Admin screen: dashboards, people and what each sees (with preview), devices,
  messages to the screens, the Haven app, activity, about
- Haven: new Android app (haven/) that only shows dashboards; replaces the
  Dashboard Studio Android app and signs in through Home Assistant's page
- Screens pick up dashboard and access changes within a minute
- Version history records who made each change; panel visible to all users

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 23:20:55 +01:00
parent 297fb2a7a0
commit 3d958d0411
37 changed files with 1096 additions and 248 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-63
View File
@@ -1,63 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: dark; }
body { margin: 0; min-height: 100vh; background: #0f1526; color: #f2f2f7; font: 16px/1.45 system-ui, sans-serif; display: flex; align-items: center; justify-content: center; }
main { width: 100%; max-width: 420px; padding: 28px 22px; box-sizing: border-box; }
img { width: 72px; height: 72px; border-radius: 18px; display: block; margin-bottom: 18px; }
h1 { font-size: 24px; margin: 0 0 8px; }
p { margin: 0 0 18px; color: #a1a8bb; }
.msg { background: rgba(255, 69, 58, .15); color: #ffb4ae; padding: 10px 14px; border-radius: 12px; }
.msg:empty { display: none; }
label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
input { width: 100%; box-sizing: border-box; font: inherit; color: inherit; background: #1c2438; border: 1px solid #2f3a57; border-radius: 12px; padding: 12px 14px; }
input:focus { outline: 2px solid #0a84ff; border-color: transparent; }
.hint { font-size: 13px; margin: 8px 0 22px; }
button { width: 100%; font: inherit; font-weight: 600; color: #fff; background: #0a84ff; border: 0; border-radius: 12px; padding: 13px; }
button:active { opacity: .8; }
</style>
</head>
<body>
<main>
<img src="icon.png" alt="">
<h1 data-t="title">Connect to Home Assistant</h1>
<p data-t="intro">Enter the address you use to open Home Assistant. You sign in once; the app remembers it.</p>
<p class="msg" id="msg"></p>
<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>
<p class="hint" data-t="hint">You can also paste the link from Dashboard Studio: Install as an app, Open in the app.</p>
<button type="submit" data-t="go">Connect</button>
</form>
</main>
<script>
const NL = {
title: "Verbinden met Home Assistant",
intro: "Vul het adres in waarmee je Home Assistant opent. Je logt één keer in; de app onthoudt het.",
label: "Adres van Home Assistant",
hint: "Je kunt ook de link uit Dashboard Studio plakken: Installeren als app, Openen in de app.",
go: "Verbinden",
offline: "Home Assistant is niet bereikbaar op dit adres. Controleer het adres en je verbinding.",
notfound: "Dashboard Studio staat niet in Home Assistant. Installeer de app daar eerst, of plak de link uit Dashboard Studio.",
noaddress: "Dashboard Studio is gevonden, maar alleen een beheerder kan het adres opvragen. Plak de link uit Dashboard Studio: Installeren als app, Openen in de app.",
error: "Verbinden lukte niet: {0}",
};
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. Install it there first, or paste the link from Dashboard Studio.",
noaddress: "Dashboard Studio was found, but only an administrator can look up its address. Paste the link from Dashboard Studio: Install as an app, Open in the app.",
error: "Couldn't connect: {0}",
};
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; });
const why = window.DSApp ? DSApp.message() : "", detail = window.DSApp ? DSApp.detail() : "";
if (why) document.getElementById("msg").textContent = (T[why] || T.error).replace("{0}", detail || why);
const addr = document.getElementById("addr");
addr.value = window.DSApp ? DSApp.address() : "";
document.getElementById("f").onsubmit = (e) => { e.preventDefault(); DSApp.setup(addr.value.trim()); };
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2.0.0
- **Admins and everyone else:** Home Assistant's administrators are admins in Dashboard Studio; everyone else only sees the dashboards an admin chose for them, full screen in kiosk mode, and can't change anything. Dashboard Studio enforces this itself: only admins can save, and everyone else can only read states and control devices, as in Home Assistant.
- **Three screens for admins:** Dashboard, Builder and Admin, switched at the top right. **Edit** is now **Builder**.
- **Admin screen:** dashboards as cards with who sees them; people and what each may see, with **Preview** to see it as they do; every device that opened Dashboard Studio, to give it a dashboard; a message to the screens; setting up Haven; and who changed which dashboard.
- **Haven:** a new Android app for everyone in your home. It only shows dashboards, opens full screen and keeps you signed in. Download it with the QR code under **Admin → Haven app**. It replaces the earlier Dashboard Studio Android app.
- **Live updates:** screens pick up a changed dashboard, or a change in who sees what, within a minute, without reloading.
- **Everyone in the sidebar:** Dashboard Studio is now in the sidebar for everyone with a Home Assistant login, not only for administrators.
- The version history now remembers who made each change.
## 1.14.1 ## 1.14.1
- **Android app:** Dashboard Studio as a real app for Android phones and tablets. Download it from **Install as an app**, sign in to Home Assistant once, and it opens full screen on your dashboard from then on, with its own icon. It works with plain http addresses too. - **Android app:** Dashboard Studio as a real app for Android phones and tablets. Download it from **Install as an app**, sign in to Home Assistant once, and it opens full screen on your dashboard from then on, with its own icon. It works with plain http addresses too.
+55 -29
View File
@@ -1,16 +1,51 @@
# Dashboard Studio # Dashboard Studio
A drag-and-drop dashboard builder that lives in its own sidebar tab. A drag-and-drop dashboard builder that lives in its own sidebar tab, with **Haven**, an Android app that shows the dashboards to everyone in your home.
## Using it ## Admins and everyone else
Dashboard Studio follows Home Assistant's own **Administrator** setting (Settings, People):
- **Admins** (Home Assistant administrators) get three screens, switched at the top right:
- **Dashboard:** use your dashboards.
- **Builder:** build and change them.
- **Admin:** manage the dashboards, choose who sees what, name devices, set up Haven and see who changed what.
- **Everyone else** only sees the dashboards an admin chose for them, full screen in kiosk mode, on every device. They can't change anything. Tapping the kiosk corner five times switches between their dashboards (and turns guest mode on or off); there's no way out of kiosk mode. Someone with no dashboard chosen yet sees a short note saying so, which updates by itself once an admin picks one.
This is enforced by Dashboard Studio itself, not just hidden: only administrators can save, see the people and devices or the history, and the connection to Home Assistant only lets everyone else read states and control devices, as Home Assistant itself would. Home Assistant's settings, other apps and its own dashboards stay out of reach.
Everyone with a login sees Dashboard Studio in the Home Assistant sidebar.
## The Admin screen
- **Dashboards:** every dashboard as a card, with who sees it. **Open** shows it, **Build** opens it in the builder, and **⋯** renames, duplicates, shows the version history, shares, exports or deletes it. At the top: a new dashboard, one made from your devices, importing a file, and importing one of Home Assistant's own dashboards.
- **People:** everyone with a Home Assistant login. Admins see everything. For everyone else, choose **All dashboards** or **Only these** and tick the dashboards, plus the one that **Opens first**. **Everyone else** at the top sets this for people without their own choice, and for anyone added to Home Assistant later. **Preview** shows the dashboards exactly as that person gets them; **Stop preview** brings you back.
- **Devices:** every phone, tablet and computer that opened Dashboard Studio, with who used it, whether it was Haven or a browser, and when. Name a device, give it its own dashboard (that comes before the person's choice, handy for a wall tablet), have admins start it in kiosk mode, or forget it. **Send a message to the screens** puts a full-screen message on all screens in kiosk mode, or on one named device.
- **Haven app:** a QR code to download Haven on a phone, the address to enter in it, and how signing in works.
- **Activity:** who changed which dashboard, and when, with a shortcut to its versions.
- **About:** the version, what's new and the source code.
Screens pick up what an admin changes, a dashboard or who sees what, within a minute, without reloading.
## Haven, the Android app
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.
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.
## Building
1. Open **Dashboard Studio** in the Home Assistant sidebar. 1. Open **Dashboard Studio** in the Home Assistant sidebar.
2. Press **Edit**. Pick widgets from the left panel, drag them anywhere on the grid, and resize from the bottom-right corner. Other widgets move out of the way. 2. Switch to **Builder**. Pick widgets from the left panel, drag them anywhere on the grid, and resize from the bottom-right corner. Other widgets move out of the way.
3. Set up the selected widget in the right panel: entity, labels, icon, thresholds, colors. 3. Set up the selected widget in the right panel: entity, labels, icon, thresholds, colors.
4. With nothing selected, the right panel holds dashboard settings: theme, tile style, colors, background image, grid and kiosk options. 4. With nothing selected, the right panel holds dashboard settings: theme, tile style, colors, background image, grid and kiosk options.
5. Press **Done**. Changes save automatically. 5. Switch back to **Dashboard**. Changes save automatically.
Keyboard shortcuts in edit mode: arrow keys move, Shift+arrows resize, ⌘D or Ctrl+D duplicates, Delete removes, ⌘Z or Ctrl+Z undoes, and ⇧⌘Z or Ctrl+Shift+Z redoes. Keyboard shortcuts in the builder: arrow keys move, Shift+arrows resize, ⌘D or Ctrl+D duplicates, Delete removes, ⌘Z or Ctrl+Z undoes, and ⇧⌘Z or Ctrl+Shift+Z redoes.
More editing tools: More editing tools:
@@ -68,7 +103,7 @@ Pick a weather entity in the clock's settings to show the current conditions and
### Horizontal scroll and swipe cards ### Horizontal scroll and swipe cards
These cards hold other cards. Select one in edit mode, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it. These cards hold other cards. Select one in the builder, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it.
- **Horizontal scroll** puts its cards in a row you can scroll sideways, by touch, trackpad or the arrow buttons that appear on hover. Set the card width in pixels, or as the number of cards visible at once (2.5 lets half a card peek out, so it's clear the row scrolls). A single card can get its own width in its settings. Add an optional title. The row keeps its scroll position when something inside it changes. - **Horizontal scroll** puts its cards in a row you can scroll sideways, by touch, trackpad or the arrow buttons that appear on hover. Set the card width in pixels, or as the number of cards visible at once (2.5 lets half a card peek out, so it's clear the row scrolls). A single card can get its own width in its settings. Add an optional title. The row keeps its scroll position when something inside it changes.
- **Swipe cards** shows one card at a time, filling the whole card. Swipe with a finger or drag with the mouse: the card follows along, resists at the first and last card, and a quick flick is enough. The left and right arrow keys work too. Small dots appear at the bottom while you swipe or hover, and hide again after a few seconds; tap one to jump to that card. It can also move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on. - **Swipe cards** shows one card at a time, filling the whole card. Swipe with a finger or drag with the mouse: the card follows along, resists at the first and last card, and a quick flick is enough. The left and right arrow keys work too. Small dots appear at the bottom while you swipe or hover, and hide again after a few seconds; tap one to jump to that card. It can also move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on.
@@ -206,35 +241,32 @@ The card follows the style of the Tesla app, with two designs:
## Conditional widgets ## Conditional widgets
Any widget can be shown only under certain conditions. Select it in edit mode, then under **Visibility** choose **Add condition**. Pick an entity, a comparison (*is*, *is not*, *above*, *below*) and a value. For example: *switch.coffee_machine is on*, or *sensor.washer_power above 5*. With several conditions, choose whether all of them or just one must be true. Any widget can be shown only under certain conditions. Select it in the builder, then under **Visibility** choose **Add condition**. Pick an entity, a comparison (*is*, *is not*, *above*, *below*) and a value. For example: *switch.coffee_machine is on*, or *sensor.washer_power above 5*. With several conditions, choose whether all of them or just one must be true.
In edit mode, hidden widgets stay visible but faded, with a **Hidden now** label, so you can still edit them. On the dashboard they disappear, and rows that only held hidden widgets close up so no gaps are left. Changes happen live as the entities change. In the builder, hidden widgets stay visible but faded, with a **Hidden now** label, so you can still edit them. On the dashboard they disappear, and rows that only held hidden widgets close up so no gaps are left. Changes happen live as the entities change.
## Phone dashboards ## Phone dashboards
Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns. Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns.
On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and **Edit**. To bring the bar back, tap the round **⋯** button in the top right corner (or touch and hold an empty spot); it hides again after 15 seconds or when you tap **⋯** again. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other. On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and the screen switcher. To bring the bar back, tap the round **⋯** button in the top right corner (or touch and hold an empty spot); it hides again after 15 seconds or when you tap **⋯** again. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other.
### On phones ### On phones
- **Install as an app:** tap the phone button in the top bar (or **⋯ → Install as an app…** in edit mode). What it offers depends on the phone: - **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.
- **Android:** the Dashboard Studio app. Tap **Download the app**, open the downloaded file and allow installing apps from that source when Android asks (once). Then come back and tap **Open in the app**, or open the app and enter the address you use for Home Assistant. Sign in to Home Assistant once in the app; after that it opens straight on Dashboard Studio, full screen with its own icon and the dashboard's colors in the status bar, and keeps you signed in. Kiosk mode keeps the screen on and hides Android's bars. To connect to another Home Assistant, choose **Install as an app → Change address** in the app. The app finds Dashboard Studio by itself if you sign in as an administrator; other people can tap **Open in the app** on their phone instead. - **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).
- **From the browser** (Android and computers): the browser can also install Dashboard Studio as a web app, but only when Home Assistant is opened over **https** (for example Home Assistant Cloud or your own certificate). Home Assistant's own page can't be installed, so open Dashboard Studio in its own tab first (the dialog has a button for it).
- **iPhone and iPad:** apps on the home screen get their own storage there, without Home Assistant's sign-in, so they can't open an app that runs inside Home Assistant. Use Dashboard Studio from 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; the Android app 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.
- **Swipe** left or right over the dashboard to go to the next or previous tab (not over cards that swipe or scroll themselves). - **Swipe** left or right over the dashboard to go to the next or previous tab (not over cards that swipe or scroll themselves).
- **Pull down** at the top of the dashboard to reload every state. - **Pull down** at the top of the dashboard to reload every state.
- **Popups** open as sheets from the bottom; drag one down to close it. - **Popups** open as sheets from the bottom; drag one down to close it.
- **Vibration:** buttons, switches and sliders give a short tick on phones that support it (not iPhones). Turn this off under **This device**. Wall tablets can play a soft click instead: turn on **A soft click when something is pressed** in the **Kiosk** settings. - **Vibration:** buttons, switches and sliders give a short tick on phones that support it (not iPhones). Turn this off under **This device**. Wall tablets can play a soft click instead: turn on **A soft click when something is pressed** in the **Kiosk** settings.
- **Show on:** under **Visibility**, a widget can be shown on all screens, only on phones, or only on larger screens. - **Show on:** under **Visibility**, a widget can be shown on all screens, only on phones, or only on larger screens.
- **Editing on the phone:** **Edit** works on a phone too. The bar at the bottom opens the widget list (**Add**) and the settings as sheets. Tap a widget to select it, then drag it or its corner; tap it again for its settings. - **Building on the phone:** the **Builder** works on a phone too. The bar at the bottom opens the widget list (**Add**) and the settings as sheets. Tap a widget to select it, then drag it or its corner; tap it again for its settings.
- **Away from home:** in the **Tabs** settings, **When you're away from home, open** picks a tab, such as cameras, the garage or the alarm, that opens while you're away, and the dashboard goes back when you're home. It follows the person signed in on the device, or a person you choose. Not used in kiosk mode. - **Away from home:** in the **Tabs** settings, **When you're away from home, open** picks a tab, such as cameras, the garage or the alarm, that opens while you're away, and the dashboard goes back when you're home. It follows the person signed in on the device, or a person you choose. Not used in kiosk mode.
## Tabs and side menu ## Tabs and side menu
A dashboard can have several tabs, each with its own widgets, for example *Overview*, *Household* and *Cameras*. To set them up, deselect all widgets in edit mode and use the **Tabs** section of the dashboard settings: A dashboard can have several tabs, each with its own widgets, for example *Overview*, *Household* and *Cameras*. To set them up, deselect all widgets in the builder and use the **Tabs** section of the dashboard settings:
- **Add tab** creates a tab and turns on the menu. - **Add tab** creates a tab and turns on the menu.
- Each tab has a name and an icon (an icon name or an emoji). Use the arrows to reorder tabs and the bin to delete one; ⌘Z undoes this. - Each tab has a name and an icon (an icon name or an emoji). Use the arrows to reorder tabs and the bin to delete one; ⌘Z undoes this.
@@ -287,30 +319,24 @@ Dashboard Studio is available in English and Dutch (Nederlands). By default it f
## Several dashboards ## Several dashboards
Use **+** for a new dashboard. You can start empty, from your devices, or from a template that fills itself with your devices: *Wall tablet* (clock, weather, calendar, rooms and cameras), *Phone* (people, lights, climate and media in the phone layout), *Energy* (power flow, solar forecast, water and gas, graphs) or *Family* (who's home, calendar, shopping list, parcels and weather). Use **+** (or **New dashboard** on the Admin screen) for a new dashboard. You can start empty, from your devices, or from a template that fills itself with your devices: *Wall tablet* (clock, weather, calendar, rooms and cameras), *Phone* (people, lights, climate and media in the phone layout), *Energy* (power flow, solar forecast, water and gas, graphs) or *Family* (who's home, calendar, shopping list, parcels and weather).
The **⋯** menu (in edit mode) can rename, duplicate, export, import or delete a dashboard. Exported JSON files can be imported on another Home Assistant install. On the Admin screen, a dashboard's **⋯** renames, duplicates, exports or deletes it; **Import file** brings an exported one back. Exported JSON files can be imported on another Home Assistant install. In the builder, the **⋯** menu keeps what concerns the dashboard you're building: rename, devices not on a dashboard, version history, undo history, share and export.
**Import from Home Assistant…** turns one of your existing Home Assistant dashboards into a new dashboard here, with a tab for each view. Entities, tile, button, light, thermostat, sensor, gauge, graph, weather, media, camera, calendar, to-do, alarm, area and markdown cards become matching widgets, and stacks and grids are taken apart; other cards are skipped and counted. The original stays as it is. Dashboards that Home Assistant generates itself (without cards of their own) can't be imported. **Import from Home Assistant** (on the Admin screen) turns one of your existing Home Assistant dashboards into a new dashboard here, with a tab for each view. Entities, tile, button, light, thermostat, sensor, gauge, graph, weather, media, camera, calendar, to-do, alarm, area and markdown cards become matching widgets, and stacks and grids are taken apart; other cards are skipped and counted. The original stays as it is. Dashboards that Home Assistant generates itself (without cards of their own) can't be imported.
**Share…** shows a link and a QR code for the dashboard, optionally opening in kiosk mode. It opens on any device that is signed in to Home Assistant. **Share…** shows a link and a QR code for the dashboard, optionally opening in kiosk mode. It opens on any device that is signed in to Home Assistant.
**Version history…** in the **⋯** menu lists the earlier saves of the dashboard, up to 30, with the time and the number of widgets. Changes made within five minutes of each other count as one version. **Restore** brings a version back; ⌘Z undoes that. **Version history…** (in the builder's **⋯** menu, or a dashboard's **⋯** on the Admin screen) lists the earlier saves of the dashboard, up to 30, with the time and the number of widgets. Changes made within five minutes of each other count as one version. **Restore** brings a version back; ⌘Z undoes that.
## Which dashboard opens where ## Which dashboard opens where
Use **⋯ → Who sees which dashboard…** in edit mode to choose the dashboard that opens first: The first dashboard to open on a device is, in this order: one from a link (`#view=…` or a shared link), the device's own dashboard (**Admin → Devices**), the person's **Opens first** (**Admin → People**), and otherwise the last one used on that device. People who aren't admins only ever get dashboards they may see, plus the one an admin gave their device.
- **This device:** give the device a name, for example *Kitchen tablet*, and pick its dashboard. Tick **Kiosk** to have it start in kiosk mode.
- **Other devices** you have named appear in the list too, so you can change them from anywhere.
- **People:** pick a dashboard per Home Assistant user. People appear once they've opened Dashboard Studio, or when they have a person entity.
A device's choice comes before a person's. Without either, the last dashboard used on that device opens. A link with `#view=…` or a shared link always wins. You can still switch dashboards at any time.
## Version and updates ## Version and updates
The version you're running is shown at the bottom of the dashboard settings, under **About** (deselect all widgets in edit mode). **What's new** there shows the changelog, which is also on the app's **Changelog** tab in Home Assistant. After an update, a short message points you to it. After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted. The version you're running is shown under **Admin → About**, and at the bottom of the dashboard settings in the builder. **What's new** there shows the changelog, which is also on the app's **Changelog** tab in Home Assistant. After an update, a short message points you to it. After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted.
## Data ## Data
Dashboards and presets are stored in the app's `/data/dashboards.json`, and their version history in `/data/history/`, so both are included in Home Assistant backups. Night mode, guest mode started with the button, vibration and the last known states are kept on each device. Dashboards, presets and who sees what are stored in the app's `/data/dashboards.json`, their version history (with who made each change) in `/data/history/`, and the devices that opened Dashboard Studio in `/data/devices.json`, so all of it is included in Home Assistant backups. Night mode, guest mode started with the button, vibration and the last known states are kept on each device.
+3 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "1.14.1" version: "2.0.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
@@ -15,6 +15,8 @@ ingress_port: 8099
ingress_stream: true ingress_stream: true
panel_icon: mdi:view-dashboard-edit panel_icon: mdi:view-dashboard-edit
panel_title: Dashboard Studio panel_title: Dashboard Studio
# Everyone with a login gets it in the sidebar; Dashboard Studio itself decides what each person sees.
panel_admin: false
homeassistant_api: true homeassistant_api: true
options: options:
immich_url: "" immich_url: ""
+228 -29
View File
@@ -119,7 +119,7 @@ def load_history(dash_id):
return [] return []
def record_history(old, new): def record_history(old, new, by=""):
"""Add every dashboard that changed in this save to its history.""" """Add every dashboard that changed in this save to its history."""
before = {d.get("id"): d for d in old.get("dashboards", []) if isinstance(d, dict)} before = {d.get("id"): d for d in old.get("dashboards", []) if isinstance(d, dict)}
now = time.time() now = time.time()
@@ -134,10 +134,10 @@ def record_history(old, new):
except OSError: except OSError:
saved_at = now - HISTORY_MERGE_S - 1 saved_at = now - HISTORY_MERGE_S - 1
entries.append({"at": saved_at, "data": before[dash_id]}) entries.append({"at": saved_at, "data": before[dash_id]})
if entries and now - entries[-1]["at"] < HISTORY_MERGE_S: if entries and now - entries[-1]["at"] < HISTORY_MERGE_S and entries[-1].get("by", "") == by:
entries[-1] = {"at": now, "data": d} entries[-1] = {"at": now, "data": d, "by": by}
else: else:
entries.append({"at": now, "data": d}) entries.append({"at": now, "data": d, "by": by})
HISTORY_DIR.mkdir(parents=True, exist_ok=True) HISTORY_DIR.mkdir(parents=True, exist_ok=True)
tmp = history_file(dash_id).with_suffix(".tmp") tmp = history_file(dash_id).with_suffix(".tmp")
tmp.write_text(json.dumps(entries[-HISTORY_MAX:])) tmp.write_text(json.dumps(entries[-HISTORY_MAX:]))
@@ -155,37 +155,152 @@ async def index(request):
USERS_FILE = DATA_DIR / "users.json" USERS_FILE = DATA_DIR / "users.json"
DEVICES_FILE = DATA_DIR / "devices.json"
def who(request): def who(request):
"""The Home Assistant user, from the headers the Supervisor's ingress proxy adds.""" """The Home Assistant user, from the headers the Supervisor's ingress proxy adds."""
if MOCK: if MOCK: # locally, a cookie picks one of the mock users, to try the app as someone else
return {"id": os.environ.get("MOCK_USER_ID", "mock-user-alex"), "name": "alex", "display": "Alex"} uid = request.cookies.get("mock_user") or os.environ.get("MOCK_USER_ID", "mock-user-alex")
u = MockHA.USERS.get(uid) or MockHA.USERS["mock-user-alex"]
return {"id": uid if uid in MockHA.USERS else "mock-user-alex", "name": u["username"], "display": u["name"]}
h = request.headers h = request.headers
uid = h.get("X-Remote-User-Id") uid = h.get("X-Remote-User-Id")
return {"id": uid, "name": h.get("X-Remote-User-Name", ""), "display": h.get("X-Remote-User-Display-Name", "")} if uid else None return {"id": uid, "name": h.get("X-Remote-User-Name", ""), "display": h.get("X-Remote-User-Display-Name", "")} if uid else None
def load_users(): async def ha_call(app, msg):
"""One request to Home Assistant's websocket API with the Supervisor token."""
async with app["http"].ws_connect(HA_WS_URL, max_msg_size=0) as ws:
await ws.receive_json() # auth_required
await ws.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN})
if (await ws.receive_json()).get("type") != "auth_ok":
raise RuntimeError("Home Assistant rejected the Supervisor token")
await ws.send_json({"id": 1, **msg})
while True:
r = await ws.receive_json()
if r.get("id") == 1:
break
if not r.get("success"):
raise RuntimeError((r.get("error") or {}).get("message", "request failed"))
return r["result"]
USERS_TTL = 60
async def ha_users(app):
"""Home Assistant's people (not its system users), with whether each is an administrator. Cached for a minute."""
cached = app.get("users_cache")
if cached and time.time() - cached[0] < USERS_TTL:
return cached[1]
rows = MockHA.USERS_LIST() if MOCK else await ha_call(app, {"type": "config/auth/list"})
users = {u["id"]: {"id": u["id"], "name": u.get("name") or u.get("username") or "", "username": u.get("username") or "",
"admin": bool(u.get("is_owner")) or "system-admin" in (u.get("group_ids") or []),
"active": u.get("is_active", True)}
for u in rows if not u.get("system_generated")}
app["users_cache"] = (time.time(), users)
return users
async def role_of(request):
""""admin" for Home Assistant administrators, "basic" for everyone else."""
if "role" in request:
return request["role"]
me = who(request)
role = "basic"
if me:
try:
role = "admin" if (await ha_users(request.app)).get(me["id"], {}).get("admin") else "basic"
except Exception: # noqa: BLE001 - never let a failed lookup make someone an admin
LOG.exception("Could not look up Home Assistant's users")
request["role"] = role
return role
def admin_only(handler):
async def wrapped(request):
if await role_of(request) != "admin":
return web.json_response({"error": "Only Home Assistant administrators can do this"}, status=403)
return await handler(request)
return wrapped
def load_json(path, default):
try: try:
return json.loads(USERS_FILE.read_text()) return json.loads(path.read_text())
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return {} return default
def save_json(path, data):
DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2))
tmp.replace(path)
def load_users():
return load_json(USERS_FILE, {})
async def whoami(request): async def whoami(request):
me = who(request) me = who(request)
if me: # remember who has used the app, so dashboards can be assigned to them if me: # remember who has used the app and when
users = load_users() users = load_users()
if users.get(me["id"], {}).get("display") != me["display"] or me["id"] not in users: prev = users.get(me["id"], {})
users[me["id"]] = {"name": me["name"], "display": me["display"]} if prev.get("display") != me["display"] or time.time() - prev.get("seen", 0) > 300:
DATA_DIR.mkdir(parents=True, exist_ok=True) users[me["id"]] = {"name": me["name"], "display": me["display"], "seen": time.time()}
USERS_FILE.write_text(json.dumps(users, indent=2)) save_json(USERS_FILE, users)
return web.json_response(me or {}, headers={"Cache-Control": "no-store"}) return web.json_response({**(me or {}), "admin": await role_of(request) == "admin"}, headers={"Cache-Control": "no-store"})
async def users(request): @admin_only
return web.json_response([{"id": k, **v} for k, v in load_users().items()], headers={"Cache-Control": "no-store"}) async def people(request):
"""Everyone with a Home Assistant login, for choosing what they see."""
seen = load_users()
try:
users = await ha_users(request.app)
except Exception as err: # noqa: BLE001
return web.json_response({"error": f"Couldn't read Home Assistant's users: {err}"}, status=502)
out = [{**u, "seen": seen.get(u["id"], {}).get("seen")} for u in users.values() if u["active"]]
return web.json_response(sorted(out, key=lambda u: (not u["admin"], u["name"].lower())), headers={"Cache-Control": "no-store"})
SAFE_DEVICE = re.compile(r"^[\w-]{4,64}$")
async def device_seen(request):
"""Every device reports in when it opens, so an admin can give it a dashboard."""
try:
body = await request.json()
except json.JSONDecodeError:
return web.Response(status=400)
dev = str(body.get("device", ""))
if not SAFE_DEVICE.match(dev):
return web.Response(status=400)
me = who(request) or {}
devices = load_json(DEVICES_FILE, {})
devices[dev] = {"user": me.get("id"), "display": me.get("display", ""), "seen": time.time(),
"app": "haven" if body.get("app") == "haven" else "browser", "kind": str(body.get("kind", ""))[:20]}
if len(devices) > 200: # forget the longest-unseen ones
devices = dict(sorted(devices.items(), key=lambda kv: kv[1].get("seen", 0))[-200:])
save_json(DEVICES_FILE, devices)
return web.json_response({"ok": True})
@admin_only
async def get_devices(request):
return web.json_response(load_json(DEVICES_FILE, {}), headers={"Cache-Control": "no-store"})
@admin_only
async def forget_device(request):
dev = request.match_info["device"]
devices = load_json(DEVICES_FILE, {})
devices.pop(dev, None)
save_json(DEVICES_FILE, devices)
return web.json_response({"ok": True})
async def changelog(request): async def changelog(request):
@@ -234,17 +349,46 @@ async def service_worker(request):
return web.Response(text=js, content_type="application/javascript", headers={"Cache-Control": "no-store"}) return web.Response(text=js, content_type="application/javascript", headers={"Cache-Control": "no-store"})
async def android_app(request): async def haven_app(request):
"""The Android app (built from android/ in the repository), to download on a phone.""" """Haven, the Android app for dashboards (built from haven/ in the repository), to download on a phone."""
return web.FileResponse(WEB_DIR / "dashboard-studio.apk", headers={ return web.FileResponse(WEB_DIR / "haven.apk", headers={
"Content-Type": "application/vnd.android.package-archive", "Content-Type": "application/vnd.android.package-archive",
"Content-Disposition": 'attachment; filename="Dashboard Studio.apk"', "Cache-Control": "no-store"}) "Content-Disposition": 'attachment; filename="Haven.apk"', "Cache-Control": "no-store"})
def access_of(store, uid):
"""What a person may see: {"all": true} or {"dashboards": [...]}, plus the one to open first ("home")."""
acc = store.get("access") or {}
mine = (acc.get("users") or {}).get(uid) if uid else None
return mine if isinstance(mine, dict) else acc.get("everyone") or {"all": True}
def visible_ids(store, uid, device):
ids = [d.get("id") for d in store.get("dashboards", []) if isinstance(d, dict)]
entry = access_of(store, uid)
seen = ids if entry.get("all") else [i for i in ids if i in (entry.get("dashboards") or [])]
dev = ((store.get("assign") or {}).get("devices") or {}).get(device or "") or {}
if dev.get("dashboard") in ids and dev["dashboard"] not in seen: # an admin put it on this screen
seen.append(dev["dashboard"])
return seen, dev
async def get_dashboards(request): async def get_dashboards(request):
return web.json_response(load_store()) store = load_store()
if await role_of(request) == "admin":
return web.json_response({**store, "role": "admin"}, headers={"Cache-Control": "no-store"})
me = who(request) or {}
ids, dev = visible_ids(store, me.get("id"), request.query.get("device"))
return web.json_response({
"version": store.get("version", 1),
"dashboards": [d for d in store.get("dashboards", []) if d.get("id") in ids],
"assign": {"devices": {request.query["device"]: dev} if dev else {}, "users": {}},
"access": {"me": access_of(store, me.get("id"))},
"role": "basic",
}, headers={"Cache-Control": "no-store"})
@admin_only
async def put_dashboards(request): async def put_dashboards(request):
try: try:
data = await request.json() data = await request.json()
@@ -252,8 +396,9 @@ async def put_dashboards(request):
return web.json_response({"error": "invalid json"}, status=400) return web.json_response({"error": "invalid json"}, status=400)
if not isinstance(data, dict) or not isinstance(data.get("dashboards"), list): if not isinstance(data, dict) or not isinstance(data.get("dashboards"), list):
return web.json_response({"error": "expected {dashboards: [...]}"}, status=400) return web.json_response({"error": "expected {dashboards: [...]}"}, status=400)
data.pop("role", None)
try: try:
record_history(load_store(), data) record_history(load_store(), data, (who(request) or {}).get("display", ""))
except OSError: except OSError:
LOG.exception("Could not write the version history") LOG.exception("Could not write the version history")
save_store(data) save_store(data)
@@ -264,17 +409,19 @@ def widget_count(d):
return sum(len(t.get("widgets", [])) for t in d.get("tabs", []) if isinstance(t, dict)) return sum(len(t.get("widgets", [])) for t in d.get("tabs", []) if isinstance(t, dict))
@admin_only
async def get_history(request): async def get_history(request):
dash_id = request.match_info["dash_id"] dash_id = request.match_info["dash_id"]
if not SAFE_ID.match(dash_id): if not SAFE_ID.match(dash_id):
return web.Response(status=400) return web.Response(status=400)
entries = load_history(dash_id) entries = load_history(dash_id)
return web.json_response([ return web.json_response([
{"at": e["at"], "name": e["data"].get("name", ""), "tabs": len(e["data"].get("tabs", [])), "widgets": widget_count(e["data"])} {"at": e["at"], "name": e["data"].get("name", ""), "tabs": len(e["data"].get("tabs", [])), "widgets": widget_count(e["data"]), "by": e.get("by", "")}
for e in reversed(entries) for e in reversed(entries)
], headers={"Cache-Control": "no-store"}) ], headers={"Cache-Control": "no-store"})
@admin_only
async def get_history_version(request): async def get_history_version(request):
dash_id = request.match_info["dash_id"] dash_id = request.match_info["dash_id"]
if not SAFE_ID.match(dash_id): if not SAFE_ID.match(dash_id):
@@ -289,6 +436,19 @@ async def get_history_version(request):
return web.Response(status=404) return web.Response(status=404)
@admin_only
async def activity(request):
"""Who changed which dashboard, newest first, from the version history."""
names = {d.get("id"): d.get("name", "") for d in load_store().get("dashboards", []) if isinstance(d, dict)}
rows = []
for f in HISTORY_DIR.glob("*.json") if HISTORY_DIR.is_dir() else []:
for e in load_json(f, [])[1:]: # the first entry is how it was before history started
rows.append({"at": e["at"], "dashboard": f.stem, "name": names.get(f.stem) or e["data"].get("name", ""),
"by": e.get("by", ""), "deleted": f.stem not in names})
rows.sort(key=lambda r: r["at"], reverse=True)
return web.json_response(rows[:60], headers={"Cache-Control": "no-store"})
async def camera(request): async def camera(request):
entity_id = request.match_info["entity_id"] entity_id = request.match_info["entity_id"]
if not entity_id.startswith("camera.") or "/" in entity_id: if not entity_id.startswith("camera.") or "/" in entity_id:
@@ -309,12 +469,39 @@ async def camera(request):
) )
# What people who aren't administrators may ask Home Assistant through this app: reading states and
# controlling devices, as they could in Home Assistant itself, but nothing that changes its setup.
BASIC_WS = {"get_states", "get_config", "get_services", "unsubscribe_events", "persistent_notification/subscribe",
"config/area_registry/list", "config/entity_registry/list", "config/device_registry/list", "todo/item/list",
"history/history_during_period", "recorder/statistics_during_period", "energy/solar_forecast", "ping"}
BASIC_EVENTS = {"state_changed", "dashboard_studio_announce"}
BLOCKED_DOMAINS = {"hassio", "recorder", "logger", "system_log", "backup", "lovelace", "frontend", "cloud", "auth", "zone", "person"}
def allowed_for_basic(req):
t = req.get("type")
if t == "subscribe_events":
return req.get("event_type") in BASIC_EVENTS
if t == "call_service":
domain, service = req.get("domain"), req.get("service")
if domain in BLOCKED_DOMAINS:
return False
return service in {"toggle", "turn_on", "turn_off", "update_entity"} if domain == "homeassistant" else True
return t in BASIC_WS
def denied(req):
return {"id": req.get("id"), "type": "result", "success": False,
"error": {"code": "unauthorized", "message": "Only Home Assistant administrators can do this"}}
async def websocket(request): async def websocket(request):
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0) client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
await client.prepare(request) await client.prepare(request)
allow = None if await role_of(request) == "admin" else allowed_for_basic
if MOCK: if MOCK:
await request.app["mock"].serve(client) await request.app["mock"].serve(client, allow)
return client return client
if not SUPERVISOR_TOKEN: if not SUPERVISOR_TOKEN:
await client.send_json({"type": "error", "message": "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it."}) await client.send_json({"type": "error", "message": "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it."})
@@ -340,14 +527,22 @@ async def websocket(request):
return client return client
await client.send_json({"type": "ready", "ha_version": msg.get("ha_version")}) await client.send_json({"type": "ready", "ha_version": msg.get("ha_version")})
async def pump(src, dst): async def pump(src, dst, check=None):
async for m in src: async for m in src:
if m.type == aiohttp.WSMsgType.TEXT: if m.type == aiohttp.WSMsgType.TEXT:
if check:
try:
req = json.loads(m.data)
except json.JSONDecodeError:
continue
if not isinstance(req, dict) or not check(req):
await client.send_json(denied(req if isinstance(req, dict) else {}))
continue
await dst.send_str(m.data) await dst.send_str(m.data)
elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE): elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
break break
tasks = [asyncio.create_task(pump(client, upstream)), asyncio.create_task(pump(upstream, client))] tasks = [asyncio.create_task(pump(client, upstream, allow)), asyncio.create_task(pump(upstream, client))]
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) _, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending: for t in pending:
t.cancel() t.cancel()
@@ -379,10 +574,14 @@ def make_app():
app.router.add_get("/api/version", version) app.router.add_get("/api/version", version)
app.router.add_get("/manifest.webmanifest", manifest) app.router.add_get("/manifest.webmanifest", manifest)
app.router.add_get("/sw.js", service_worker) app.router.add_get("/sw.js", service_worker)
app.router.add_get("/android.apk", android_app) app.router.add_get("/haven.apk", haven_app)
app.router.add_get("/api/changelog", changelog) app.router.add_get("/api/changelog", changelog)
app.router.add_get("/api/whoami", whoami) app.router.add_get("/api/whoami", whoami)
app.router.add_get("/api/users", users) app.router.add_get("/api/people", people)
app.router.add_post("/api/devices/seen", device_seen)
app.router.add_get("/api/devices", get_devices)
app.router.add_delete("/api/devices/{device}", forget_device)
app.router.add_get("/api/activity", activity)
app.router.add_get("/api/dashboards", get_dashboards) app.router.add_get("/api/dashboards", get_dashboards)
app.router.add_put("/api/dashboards", put_dashboards) app.router.add_put("/api/dashboards", put_dashboards)
app.router.add_get("/api/history/{dash_id}", get_history) app.router.add_get("/api/history/{dash_id}", get_history)
+16 -2
View File
@@ -469,7 +469,19 @@ class MockHA:
# -- websocket ---------------------------------------------------------- # -- websocket ----------------------------------------------------------
async def serve(self, ws): # Home Assistant's logins, as config/auth/list returns them. Alex is the owner; the others aren't administrators.
USERS = {
"mock-user-alex": {"id": "mock-user-alex", "name": "Alex", "username": "alex", "is_owner": True, "is_active": True, "system_generated": False, "group_ids": ["system-admin"]},
"mock-user-sam": {"id": "mock-user-sam", "name": "Sam", "username": "sam", "is_owner": False, "is_active": True, "system_generated": False, "group_ids": ["system-users"]},
"mock-user-noor": {"id": "mock-user-noor", "name": "Noor", "username": "noor", "is_owner": False, "is_active": True, "system_generated": False, "group_ids": ["system-users"]},
"mock-user-tablet": {"id": "mock-user-tablet", "name": "Kitchen tablet", "username": "tablet", "is_owner": False, "is_active": True, "system_generated": False, "group_ids": ["system-users"]},
}
@staticmethod
def USERS_LIST(): # noqa: N802 - mirrors USERS
return [*MockHA.USERS.values(), {"id": "mock-supervisor", "name": "Supervisor", "is_owner": False, "is_active": True, "system_generated": True, "group_ids": ["system-admin"]}]
async def serve(self, ws, allow=None):
subs = set() subs = set()
entry = (ws, frozenset()) entry = (ws, frozenset())
await ws.send_json({"type": "ready", "ha_version": "mock"}) await ws.send_json({"type": "ready", "ha_version": "mock"})
@@ -479,7 +491,9 @@ class MockHA:
continue continue
req = json.loads(msg.data) req = json.loads(msg.data)
mid, typ = req.get("id"), req.get("type") mid, typ = req.get("id"), req.get("type")
if typ == "get_states": if allow and not allow(req):
await ws.send_json({"id": mid, "type": "result", "success": False, "error": {"code": "unauthorized", "message": "Only Home Assistant administrators can do this"}})
elif typ == "get_states":
await ws.send_json({"id": mid, "type": "result", "success": True, "result": list(self.states.values())}) await ws.send_json({"id": mid, "type": "result", "success": True, "result": list(self.states.values())})
elif typ == "subscribe_events": elif typ == "subscribe_events":
subs.add(mid) subs.add(mid)
+432 -79
View File
@@ -246,9 +246,14 @@
let installPrompt = null; let installPrompt = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; }); window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; });
const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
// The Android app (android/ in the repository) shows this page in its own window and adds window.DSApp. // Haven, the Android app (haven/ in the repository), shows this page in its own window and adds window.HavenApp.
const IN_APP = !!window.DSApp; // It only ever shows dashboards: no builder and no admin screen, for admins too.
const APK_ID = "nl.melvanveen.dashboardstudio"; const IN_APP = !!window.HavenApp;
const HAVEN_ID = "nl.melvanveen.haven";
// The public download in the repository, so a phone can get Haven before it is signed in to Home Assistant.
const HAVEN_DOWNLOAD = `${REPO_URL}/raw/branch/main/dashboard_studio/web/haven.apk`;
const havenLink = () => new URL("./", BASE).href;
const havenIntent = () => `intent://open?url=${encodeURIComponent(havenLink())}#Intent;scheme=haven;package=${HAVEN_ID};end`;
async function installDialog() { async function installDialog() {
let inFrame = false; let inFrame = false;
try { inFrame = window.top !== window; } catch { inFrame = true; } try { inFrame = window.top !== window; } catch { inFrame = true; }
@@ -260,17 +265,17 @@
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 the Dashboard Studio app, connected to {0}.", location.host)); body = p(tr("This is Haven, connected to {0}.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`; actions += `<button class="btn" data-x="reset">${tr("Change address")}</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: the Android app 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 the Dashboard Studio app for Android. 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."))
+ `<ol class="steps"><li>${esc(tr("Download the app and open the file to install it. Android asks once to allow apps from this source."))}</li> + `<ol class="steps"><li>${esc(tr("Download Haven and open the file to install it. Android asks once to allow apps from this source."))}</li>
<li>${esc(tr("Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant."))}</li></ol>` <li>${esc(tr("Come back here and tap Open in Haven. Or open Haven and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without the app."), "muted small") : ""); + (browserInstall ? p(tr("Or install it from the browser, without Haven."), "muted small") : "");
actions += (browserInstall ? `<button class="btn" data-x="install">${tr("Install from the browser")}</button>` : "") actions += (browserInstall ? `<button class="btn" data-x="install">${tr("Install from the browser")}</button>` : "")
+ `<a class="btn" href="${new URL("android.apk", BASE).href}" download="Dashboard Studio.apk">${tr("Download the app")}</a>` + `<a class="btn" href="${new URL("haven.apk", BASE).href}" download="Haven.apk">${tr("Download Haven")}</a>`
+ `<a class="btn primary" target="_top" href="intent://open?url=${encodeURIComponent(link)}#Intent;scheme=dashboardstudio;package=${APK_ID};end">${tr("Open in the app")}</a>`; + `<a class="btn primary" target="_top" href="${havenIntent()}">${tr("Open in Haven")}</a>`;
} else if (ios) { } else if (ios) {
// Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant. // Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant.
body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead.")); body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead."));
@@ -283,18 +288,34 @@
body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen.")); body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen."));
if (browserInstall) actions += `<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");
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body} const choice = await openModal(`<h3>${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"); });
if (choice === "tab") window.open(link, "_blank", "noopener"); if (choice === "tab") window.open(link, "_blank", "noopener");
if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; } if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; }
if (choice === "reset") window.DSApp.reset(); if (choice === "reset") window.HavenApp.reset();
} }
/** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so /** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so
* after a while requests would fail. Renew it the way Home Assistant's own page does, with the * after a while requests would fail. Renew it the way Home Assistant's own page does, with the
* login that page saved in this browser. Inside Home Assistant this isn't needed. */ * login that page saved in this browser. Inside Home Assistant this isn't needed. */
function wsSession(token) {
return new Promise((resolve) => {
const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/websocket`);
const done = (v) => { clearTimeout(t); try { ws.close(); } catch { /* closed */ } resolve(v); };
const t = setTimeout(() => done(null), 10000);
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === "auth_required") ws.send(JSON.stringify({ type: "auth", access_token: token }));
else if (m.type === "auth_ok") ws.send(JSON.stringify({ id: 1, type: "supervisor/api", endpoint: "/ingress/session", method: "post" }));
else if (m.id === 1) done(m.success ? m.result?.session : null);
else if (m.type === "auth_invalid") done(null);
};
ws.onerror = () => done(null);
});
}
async function keepIngressSession() { async function keepIngressSession() {
let top = true; let top = true;
try { top = window.top === window; } catch { top = false; } try { top = window.top === window; } catch { top = false; }
@@ -311,8 +332,8 @@
localStorage.setItem("hassTokens", JSON.stringify(tokens)); localStorage.setItem("hassTokens", JSON.stringify(tokens));
} }
const r = await fetch("/api/hassio/ingress/session", { method: "POST", headers: { Authorization: `Bearer ${tokens.access_token}` } }); const r = await fetch("/api/hassio/ingress/session", { method: "POST", headers: { Authorization: `Bearer ${tokens.access_token}` } });
if (!r.ok) return; // Only administrators may use that address; Home Assistant's websocket gives everyone a session.
const session = (await r.json())?.data?.session; const session = r.ok ? (await r.json())?.data?.session : await wsSession(tokens.access_token);
if (session) document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`; if (session) document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`;
}; };
try { await renew(); } catch { /* offline: the cached page carries on */ } try { await renew(); } catch { /* offline: the cached page carries on */ }
@@ -647,6 +668,16 @@
// Each browser gets its own id, so a wall tablet can be told which dashboard to open. // Each browser gets its own id, so a wall tablet can be told which dashboard to open.
const DEVICE_ID = lsGet("ds.device") || (() => { const id = uid() + uid(); lsSet("ds.device", id); return id; })(); const DEVICE_ID = lsGet("ds.device") || (() => { const id = uid() + uid(); lsSet("ds.device", id); return id; })();
let me = null; // the signed-in Home Assistant user, from the server let me = null; // the signed-in Home Assistant user, from the server
// Admins (Home Assistant's administrators) get three screens: Dashboard, Builder and Admin. Everyone else
// only sees the dashboards an admin chose for them, always in kiosk mode. The server enforces the same.
let role = lsGet("ds.role") === "basic" ? "basic" : "admin";
const isAdmin = () => role === "admin";
let mode = "dashboard"; // dashboard, builder or admin
let previewAs = null; // Admin → People → Preview: { id, name, ids }
let lockedKiosk = false; // people who aren't admins can't leave kiosk mode
/** What a person may see: { all: true } or { dashboards: [...] }, plus the one that opens first (home). */
const accessFor = (uid) => store.access?.users?.[uid] || store.access?.everyone || { all: true };
const visibleFor = (uid) => { const a = accessFor(uid); return store.dashboards.filter((d) => a.all || (a.dashboards || []).includes(d.id)).map((d) => d.id); };
let current = null; let current = null;
let editMode = lsGet("ds.edit") === "1"; let editMode = lsGet("ds.edit") === "1";
let selectedId = null; let selectedId = null;
@@ -681,6 +712,7 @@
// ------------------------------------------------------------ persistence // ------------------------------------------------------------ persistence
let saveTimer = null, savePending = false; let saveTimer = null, savePending = false;
function scheduleSave() { function scheduleSave() {
if (!isAdmin()) return; // the server refuses it anyway
savePending = true; savePending = true;
$("#save-status").textContent = tr("Saving…"); $("#save-status").textContent = tr("Saving…");
clearTimeout(saveTimer); clearTimeout(saveTimer);
@@ -801,7 +833,7 @@
s.setProperty("--d-radius", `${t.radius ?? 20}px`); s.setProperty("--d-radius", `${t.radius ?? 20}px`);
const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7"; const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7";
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar); document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar);
window.DSApp?.setBarColor(bar); window.HavenApp?.setBarColor(bar);
s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`); s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`);
stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System"); stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System");
s.setProperty("--d-blur", `${t.blur || 0}px`); s.setProperty("--d-blur", `${t.blur || 0}px`);
@@ -1051,7 +1083,7 @@
} }
function renderDashSelect() { function renderDashSelect() {
$("#dash-select").innerHTML = store.dashboards.map((d) => `<option value="${d.id}" ${d.id === current?.id ? "selected" : ""}>${esc(d.name)}</option>`).join(""); $("#dash-select").innerHTML = store.dashboards.filter((d) => !previewAs || previewAs.ids.includes(d.id)).map((d) => `<option value="${d.id}" ${d.id === current?.id ? "selected" : ""}>${esc(d.name)}</option>`).join("");
} }
// ------------------------------------------------------------ suggestions // ------------------------------------------------------------ suggestions
@@ -1129,7 +1161,7 @@
function renderAll() { function renderAll() {
document.body.classList.toggle("editing", editMode); document.body.classList.toggle("editing", editMode);
$("#btn-edit").textContent = editMode ? tr("Done") : tr("Edit"); renderModeSeg();
renderDashSelect(); renderDashSelect();
renderGrid(); renderGrid();
if (editMode) renderInspector(); if (editMode) renderInspector();
@@ -2372,50 +2404,6 @@
}); });
} }
/** Who sees which dashboard: this device, other known devices, and people. */
async function assignDialog() {
const users = await api("api/users").catch(() => []);
const people = new Map();
for (const s of Object.values(ha.states)) if (s.entity_id.startsWith("person.") && s.attributes.user_id) people.set(s.attributes.user_id, friendly(s));
for (const u of users) if (!people.has(u.id)) people.set(u.id, u.display || u.name || u.id);
const a = store.assign;
const opts = (val) => `<option value="">${tr("Last used")}</option>` + store.dashboards.map((d) => `<option value="${d.id}" ${d.id === val ? "selected" : ""}>${esc(d.name)}</option>`).join("");
const devRow = (id, d, mine) => `<div class="assign-row" data-dev="${id}">
<input class="input" data-a="name" value="${esc(d?.name || "")}" placeholder="${mine ? tr("Name this device, e.g. Kitchen tablet") : tr("Unnamed device")}" aria-label="${tr("Device name")}">
<select class="input" data-a="dashboard" aria-label="${tr("Dashboard")}">${opts(d?.dashboard)}</select>
<label class="assign-kiosk"><input type="checkbox" data-a="kiosk" ${d?.kiosk ? "checked" : ""}> ${tr("Kiosk")}</label>
${mine ? "" : `<button class="btn icon" data-x="forget" aria-label="${tr("Forget this device")}">${Icons.svg("trash")}</button>`}</div>`;
const others = Object.entries(a.devices).filter(([id]) => id !== DEVICE_ID);
const html = `<h3>${tr("Who sees which dashboard")}</h3>
<p>${tr("Choose the dashboard that opens first. A device’s choice comes before a person’s; otherwise the last dashboard used on that device opens.")}</p>
<div class="section-title" style="margin-top:16px">${tr("This device")}</div>${devRow(DEVICE_ID, a.devices[DEVICE_ID], true)}
${others.length ? `<div class="section-title" style="margin-top:16px">${tr("Other devices")}</div>${others.map(([id, d]) => devRow(id, d, false)).join("")}` : ""}
<div class="section-title" style="margin-top:16px">${tr("People")}</div>
${people.size ? [...people].map(([id, name]) => `<div class="assign-row" data-user="${esc(id)}"><span class="assign-name">${esc(name)}${me?.id === id ? ` <span class="muted small">(${tr("you")})</span>` : ""}</span>
<select class="input" data-a="dashboard" aria-label="${esc(tr("Dashboard for {0}", name))}">${opts(a.users[id])}</select></div>`).join("") : `<p class="muted small">${tr("People appear here once they have opened Dashboard Studio, or when they have a person entity.")}</p>`}
<div class="modal-actions"><button class="btn primary" data-x="done">${tr("Done")}</button></div>`;
const card = $("#modal .modal-card");
const done = openModal(html, (e, close) => {
const x = e.target.closest("[data-x]")?.dataset.x;
if (x === "forget") { delete a.devices[e.target.closest("[data-dev]").dataset.dev]; e.target.closest(".assign-row").remove(); scheduleSave(); }
if (x === "done") close();
});
card.classList.add("wide");
const update = (e) => {
const f = e.target, key = f.dataset.a, row = f.closest(".assign-row");
if (!key || !row) return;
const v = f.type === "checkbox" ? f.checked : f.value;
if (row.dataset.user) { if (v) a.users[row.dataset.user] = v; else delete a.users[row.dataset.user]; }
else { const d = (a.devices[row.dataset.dev] ||= {}); d[key] = v; }
scheduleSave();
};
card.addEventListener("change", update);
card.addEventListener("input", (e) => { if (e.target.dataset.a === "name") update(e); });
await done;
card.classList.remove("wide");
card.removeEventListener("change", update);
}
/** 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*. */
async function whatsNew() { async function whatsNew() {
let md = ""; let md = "";
@@ -2481,7 +2469,7 @@
async function requestWakeLock() { async function requestWakeLock() {
try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; } try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; }
window.DSApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself window.HavenApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
} }
const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " "); const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " ");
@@ -2496,17 +2484,18 @@
history.replaceState(null, "", `#kiosk&view=${current.id}`); history.replaceState(null, "", `#kiosk&view=${current.id}`);
renderGrid(); renderGrid();
poke(); poke();
toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS)); if (!lockedKiosk) toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS));
} }
function exitKiosk() { function exitKiosk() {
if (lockedKiosk) return;
kiosk = false; kiosk = false;
document.body.classList.remove("kiosk", "idle"); document.body.classList.remove("kiosk", "idle");
hideSaver(); hideSaver();
closeDoorbell(); closeDoorbell();
clearTimeout(idleTimer); clearTimeout(saverTimer); clearTimeout(idleTimer); clearTimeout(saverTimer);
wakeLock?.release?.(); wakeLock = null; wakeLock?.release?.(); wakeLock = null;
window.DSApp?.keepAwake(false); window.HavenApp?.keepAwake(false);
if (document.fullscreenElement) document.exitFullscreen?.(); if (document.fullscreenElement) document.exitFullscreen?.();
history.replaceState(null, "", location.pathname); history.replaceState(null, "", location.pathname);
renderGrid(); renderGrid();
@@ -2523,13 +2512,20 @@
async function kioskPrompt() { async function kioskPrompt() {
if (promptOpen || modalClose) return; if (promptOpen || modalClose) return;
promptOpen = true; promptOpen = true;
const timer = setTimeout(() => modalClose?.(null), PROMPT_MS); const timer = setTimeout(() => modalClose?.(null), PROMPT_MS * (lockedKiosk ? 2 : 1));
const choice = await openModal(`<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p> // People who aren't admins stay in kiosk mode: the corner switches between the dashboards they may see.
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
const choice = await openModal(lockedKiosk
? `<h3>${tr("Dashboards")}</h3>${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" 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>`, <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); });
clearTimeout(timer); clearTimeout(timer);
promptOpen = false; promptOpen = false;
if (choice === "exit") exitKiosk(); if (choice?.startsWith("dash:")) { setCurrent(choice.slice(5)); lsSet("ds.current", current.id); }
else if (choice === "exit") exitKiosk();
else if (choice === "haven") window.HavenApp.reset();
else if (choice === "full") toggleFullscreen(false); else if (choice === "full") toggleFullscreen(false);
else if (choice === "guest") guestOn ? endGuest() : setGuestManual(true); else if (choice === "guest") guestOn ? endGuest() : setGuestManual(true);
} }
@@ -2938,10 +2934,60 @@
renderAll(); renderAll();
checkGuest(true); checkGuest(true);
checkAway(); checkAway();
if (mode === "admin") renderAdmin();
}
// ------------------------------------------------------------ screens
function renderModeSeg() {
const seg = $("#mode-seg");
seg.hidden = !isAdmin() || IN_APP || !!previewAs;
seg.querySelectorAll("[data-mode]").forEach((b) => { const on = b.dataset.mode === mode; b.classList.toggle("on", on); b.setAttribute("aria-selected", String(on)); });
}
function syncMode() {
document.body.classList.toggle("admin-mode", mode === "admin");
$("#admin").hidden = mode !== "admin";
if (!previewAs) lsSet("ds.mode", mode);
}
function setMode(m) {
if (!isAdmin() || IN_APP || previewAs) m = "dashboard";
if (m === "admin") {
if (kiosk) exitKiosk();
if (editMode) { editMode = false; openSheet(null); selectedId = null; multi.clear(); }
mode = "admin";
syncMode();
renderAll();
renderAdmin();
return;
}
mode = m;
setEdit(m === "builder");
}
/** Dashboards from before 2.0 had "opens first" per person in assign.users; that's now part of access. */
function migrateAccess() {
store.access ||= { everyone: { all: true }, users: {} };
store.access.users ||= {};
const old = store.assign?.users || {};
if (!Object.keys(old).length) return;
for (const [u, id] of Object.entries(old)) store.access.users[u] ||= { all: true, home: id };
store.assign.users = {};
scheduleSave();
}
/** People who aren't admins, with no dashboard chosen for them yet. */
function showEmpty() {
const el = $("#empty-note");
el.querySelector(".en-ico").innerHTML = Icons.svg("layout");
el.querySelector(".en-text").textContent = tr("An admin hasn’t chosen a dashboard for you yet. Ask someone who manages Home Assistant to pick one for you on the Admin screen in Dashboard Studio.");
el.hidden = false;
document.body.classList.add("empty");
} }
function setEdit(on) { function setEdit(on) {
editMode = on; editMode = on;
if (on) mode = "builder";
else if (mode === "builder") mode = "dashboard";
syncMode();
if (!on) openSheet(null); if (!on) openSheet(null);
else renderPalette(); // the suggestions count needs the states else renderPalette(); // the suggestions count needs the states
lsSet("ds.edit", on ? "1" : "0"); lsSet("ds.edit", on ? "1" : "0");
@@ -2985,10 +3031,10 @@
if (open) $("#menu button")?.focus(); if (open) $("#menu button")?.focus();
} }
$("#btn-edit").onclick = () => setEdit(!editMode); $("#mode-seg").onclick = (e) => { const b = e.target.closest("[data-mode]"); if (b) setMode(b.dataset.mode); };
$("#btn-kiosk").onclick = () => enterKiosk(); $("#btn-kiosk").onclick = () => enterKiosk();
$("#btn-app").onclick = () => installDialog(); $("#btn-app").onclick = () => installDialog();
$("#btn-app").hidden = IN_APP || standalone(); $("#btn-app").hidden = standalone() && !IN_APP; // in Haven it holds Change address
$("#btn-undo").onclick = undo; $("#btn-undo").onclick = undo;
$("#btn-redo").onclick = redo; $("#btn-redo").onclick = redo;
$("#dash-select").onchange = (e) => setCurrent(e.target.value); $("#dash-select").onchange = (e) => setCurrent(e.target.value);
@@ -3009,11 +3055,14 @@
const act = e.target.closest("[data-menu]")?.dataset.menu; const act = e.target.closest("[data-menu]")?.dataset.menu;
if (!act) return; if (!act) return;
setMenu(false); setMenu(false);
dashboardAction(act);
});
/** What can be done with the current dashboard, from the builder's ⋯ menu or a card on the Admin screen. */
async function dashboardAction(act) {
if (act === "rename") { if (act === "rename") {
const n = await askText(tr("Rename dashboard"), current.name, tr("Rename")); const n = await askText(tr("Rename dashboard"), current.name, tr("Rename"));
if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); } if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); }
} else if (act === "assign") {
assignDialog();
} else if (act === "history") { } else if (act === "history") {
historyDialog(); historyDialog();
} else if (act === "undo-history") { } else if (act === "undo-history") {
@@ -3051,7 +3100,7 @@
store.dashboards = store.dashboards.filter((d) => d.id !== current.id); store.dashboards = store.dashboards.filter((d) => d.id !== current.id);
scheduleSave(); setCurrent(store.dashboards[0].id); scheduleSave(); setCurrent(store.dashboards[0].id);
} }
}); }
$("#import-file").onchange = async (e) => { $("#import-file").onchange = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
@@ -3227,6 +3276,273 @@
setInterval(() => { applyNight(); checkGuest(); }, 60000); setInterval(() => { applyNight(); checkGuest(); }, 60000);
setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000); setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000);
// ------------------------------------------------------------ admin screen
// Dashboards, the people who use them and what each may see, devices, Haven, and who changed what.
const ADMIN_SECTIONS = [
["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"],
["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"],
];
let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards";
let peopleList = null, devicesSeen = null;
const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never"));
const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase());
async function loadPeople(force) {
if (!peopleList || force) peopleList = await api("api/people").catch(() => []);
return peopleList;
}
/** Who may see a dashboard, in a few words. */
function seenByText(d) {
const basics = (peopleList || []).filter((p) => !p.admin);
const who = basics.filter((p) => visibleFor(p.id).includes(d.id));
if (!basics.length) return tr("Admins");
if (who.length === basics.length) return tr("Everyone");
if (!who.length) return tr("Admins only");
return who.map((p) => p.name).join(", ");
}
const ADMIN = {
async dashboards() {
await loadPeople();
const cards = store.dashboards.map((d) => {
const widgets = d.tabs.reduce((n, t) => n + t.widgets.length, 0);
const bg = /^#|gradient/.test(d.theme?.bg || "") && d.theme.preset !== "System" ? d.theme.bg : "var(--fill)";
return `<article class="adm-card" data-dash="${d.id}">
<div class="ac-top"><div class="ac-swatch" style="background:${esc(bg)}">${Icons.svg(d.mobile ? "phone" : "layout")}</div>
<div class="grow"><h3>${esc(d.name)}</h3><div class="muted small">${d.tabs.length === 1 ? tr("1 tab") : tr("{0} tabs", d.tabs.length)} · ${widgets === 1 ? tr("1 widget") : tr("{0} widgets", widgets)}${d.mobile ? ` · ${tr("Phone")}` : ""}</div></div></div>
<div class="ac-who">${Icons.svg("people")}<span>${esc(seenByText(d))}</span></div>
<div class="ac-actions"><button class="btn" data-a="open">${tr("Open")}</button><button class="btn primary" data-a="edit">${Icons.svg("edit")}${tr("Build")}</button>
<button class="btn icon" data-a="more" aria-label="${esc(tr("More for {0}", d.name))}">${Icons.svg("more")}</button></div>
</article>`;
}).join("");
return `<header class="adm-head"><div><h2>${tr("Dashboards")}</h2><p class="muted">${tr("Build a dashboard, then choose under People who sees it.")}</p></div>
<div class="adm-actions"><button class="btn primary" data-a="new">${Icons.svg("plus")}${tr("New dashboard")}</button>
<button class="btn" data-a="starter">${Icons.svg("sparkles")}${tr("From my devices")}</button>
<button class="btn" data-a="import">${Icons.svg("plus")}${tr("Import file")}</button>
<button class="btn" data-a="lovelace">${Icons.svg("import")}${tr("Import from Home Assistant")}</button></div></header>
<div class="adm-cards">${cards}</div>`;
},
async people() {
const list = await loadPeople(true);
const acc = store.access;
const dashChecks = (key, entry) => store.dashboards.map((d) => `<label class="chip-check"><input type="checkbox" data-k="dash" data-who="${key}" value="${d.id}" ${entry.all || (entry.dashboards || []).includes(d.id) ? "checked" : ""} ${entry.all ? "disabled" : ""}><span>${esc(d.name)}</span></label>`).join("");
const homeSel = (key, entry) => `<select class="input" data-k="home" data-who="${key}" aria-label="${tr("Opens first")}"><option value="">${tr("The first one")}</option>${store.dashboards.filter((d) => entry.all || (entry.dashboards || []).includes(d.id)).map((d) => `<option value="${d.id}" ${entry.home === d.id ? "selected" : ""}>${esc(d.name)}</option>`).join("")}</select>`;
const accessBlock = (key, entry, own) => `<div class="p-access">
<div class="seg seg-sm" role="radiogroup" aria-label="${tr("Sees")}">
${key !== "everyone" ? `<button data-k="mode" data-who="${key}" data-v="same" aria-pressed="${!own}">${tr("Like everyone else")}</button>` : ""}
<button data-k="mode" data-who="${key}" data-v="all" aria-pressed="${(own || key === "everyone") && !!entry.all}">${tr("All dashboards")}</button>
<button data-k="mode" data-who="${key}" data-v="some" aria-pressed="${(own || key === "everyone") && !entry.all}">${tr("Only these")}</button></div>
${(own || key === "everyone") && !entry.all ? `<div class="adm-chips">${dashChecks(key, entry)}</div>` : ""}
${own || key === "everyone" ? `<label class="field"><span>${tr("Opens first")}</span>${homeSel(key, entry)}</label>` : ""}
</div>`;
const rows = list.map((p) => {
const own = acc.users[p.id];
const entry = own || acc.everyone || { all: true };
return `<article class="person" data-user="${esc(p.id)}">
<div class="p-head"><span class="adm-avatar ${p.admin ? "admin" : ""}">${initials(p.name)}</span>
<div class="grow"><div class="p-name">${esc(p.name)} ${p.admin ? `<span class="p-badge">${tr("Admin")}</span>` : ""}${me?.id === p.id ? ` <span class="muted small">(${tr("you")})</span>` : ""}</div>
<div class="muted small">${p.admin ? tr("Home Assistant administrator · sees and builds everything") : tr("Sees {0} · always in kiosk mode", visibleFor(p.id).length === store.dashboards.length ? tr("all dashboards") : tr("{0} of {1} dashboards", visibleFor(p.id).length, store.dashboards.length))} · ${esc(tr("last opened {0}", ago(p.seen)))}</div></div>
${p.admin ? "" : `<button class="btn" data-a="preview">${Icons.svg("eye")}<span class="label-wide">${tr("Preview")}</span></button>`}</div>
${p.admin ? "" : accessBlock(p.id, entry, !!own)}
</article>`;
}).join("");
return `<header class="adm-head"><div><h2>${tr("People")}</h2>
<p class="muted">${tr("Home Assistant’s administrators are admins here: they see every dashboard and can build. Everyone else only sees the dashboards chosen here, full screen in kiosk mode, on any device.")}</p></div></header>
<article class="person everyone"><div class="p-head"><span class="adm-avatar">${Icons.svg("people")}</span>
<div class="grow"><div class="p-name">${tr("Everyone else")}</div><div class="muted small">${tr("People without their own choice below, and anyone added to Home Assistant later.")}</div></div></div>
${accessBlock("everyone", acc.everyone || { all: true }, true)}</article>
${rows || `<p class="muted">${tr("Couldn’t read Home Assistant’s people.")}</p>`}
<p class="muted small">${tr("To add someone, create a person with a login in Home Assistant (Settings, People). Turn off Administrator there to make them a basic user.")}</p>`;
},
async devices() {
[devicesSeen] = await Promise.all([api("api/devices").catch(() => ({})), loadPeople()]);
const a = store.assign.devices;
const ids = [...new Set([DEVICE_ID, ...Object.keys(devicesSeen), ...Object.keys(a)])]
.sort((x, y) => (x === DEVICE_ID ? -1 : y === DEVICE_ID ? 1 : (devicesSeen[y]?.seen || 0) - (devicesSeen[x]?.seen || 0)));
const opts = (val) => `<option value="">${tr("The person’s choice")}</option>` + store.dashboards.map((d) => `<option value="${d.id}" ${d.id === val ? "selected" : ""}>${esc(d.name)}</option>`).join("");
const rows = ids.map((id) => {
const seen = devicesSeen[id] || {}, set = a[id] || {};
const icon = { phone: "phone", tablet: "tv", computer: "laptop" }[seen.kind] || "laptop";
const what = [seen.display || (id === DEVICE_ID ? me?.display : ""), seen.app === "haven" ? "Haven" : seen.app ? tr("Browser") : "", seen.seen ? ago(seen.seen) : ""].filter(Boolean).join(" · ");
return `<div class="dev-row" data-dev="${id}"><span class="adm-avatar">${Icons.svg(icon)}</span>
<div class="grow"><input class="input" data-k="name" value="${esc(set.name || "")}" placeholder="${id === DEVICE_ID ? tr("Name this device, e.g. Kitchen tablet") : tr("Unnamed device")}" aria-label="${tr("Device name")}">
<div class="muted small">${id === DEVICE_ID ? `<b>${tr("This device")}</b> · ` : ""}${esc(what)}</div></div>
<select class="input" data-k="dashboard" aria-label="${tr("Dashboard")}">${opts(set.dashboard)}</select>
<label class="assign-kiosk" title="${tr("Admins on this device start in kiosk mode too")}"><input type="checkbox" data-k="kiosk" ${set.kiosk ? "checked" : ""}> ${tr("Kiosk")}</label>
${id === DEVICE_ID ? `<span class="dev-gap"></span>` : `<button class="btn icon" data-a="forget" aria-label="${tr("Forget this device")}">${Icons.svg("trash")}</button>`}</div>`;
}).join("");
const named = ids.filter((id) => a[id]?.name);
return `<header class="adm-head"><div><h2>${tr("Devices")}</h2>
<p class="muted">${tr("Every phone, tablet and computer that opened Dashboard Studio. Give a wall tablet its own dashboard: that comes before the person’s choice.")}</p></div></header>
<div class="adm-list">${rows}</div>
<section class="adm-panel"><h3>${Icons.svg("megaphone")}${tr("Send a message to the screens")}</h3>
<p class="muted small">${tr("It shows full screen on dashboards in kiosk mode, like an announcement from an automation.")}</p>
<div class="an-form"><input class="input" id="an-title" placeholder="${tr("Title, e.g. Dinner")}" aria-label="${tr("Title")}">
<input class="input" id="an-msg" placeholder="${tr("Message, e.g. Dinner is ready!")}" aria-label="${tr("Message")}">
<select class="input" id="an-to" aria-label="${tr("To")}"><option value="">${tr("All screens")}</option>${named.map((id) => `<option value="${id}">${esc(a[id].name)}</option>`).join("")}</select>
<button class="btn primary" data-a="announce">${tr("Send")}</button></div></section>`;
},
async haven() {
const addr = location.origin;
return `<header class="adm-head haven-head"><img src="static/haven-icon.svg" alt=""><div><h2>${tr("Haven app")}</h2>
<p class="muted">${tr("Haven is the Android app for the people in your home. It only shows dashboards: no builder and no settings. Everyone sees what you chose for them under People.")}</p></div></header>
<div class="haven-grid">
<section class="adm-panel haven-step"><div class="hs-num">1</div><h3>${tr("Install Haven on the phone")}</h3>
<p class="muted small">${tr("Scan this code with the phone’s camera, or download it on the phone itself. Android asks once to allow apps from this source.")}</p>
<div class="share-qr">${window.QR.svg(HAVEN_DOWNLOAD, { label: tr("QR code to download Haven") })}</div>
<a class="btn" href="${new URL("haven.apk", BASE).href}" download="Haven.apk">${Icons.svg("arrow-down")}${tr("Download Haven")}</a></section>
<section class="adm-panel haven-step"><div class="hs-num">2</div><h3>${tr("Connect to Home Assistant")}</h3>
<p class="muted small">${tr("Open Haven and enter this address. It is the address you use for Home Assistant; on other networks use its outside address, for example Home Assistant Cloud.")}</p>
<div class="share-row"><input class="input" id="haven-addr" readonly value="${esc(addr)}" aria-label="${tr("Address")}"><button class="btn" data-a="copy-addr">${Icons.svg("copy")}${tr("Copy")}</button></div>
<p class="muted small">${tr("On the phone itself, Open in Haven fills it in.")}</p>
<a class="btn" target="_top" href="${havenIntent()}">${tr("Open in Haven")}</a></section>
<section class="adm-panel haven-step"><div class="hs-num">3</div><h3>${tr("Sign in")}</h3>
<p class="muted small">${tr("Everyone signs in with their own Home Assistant login, once. Haven then opens straight on their dashboard and stays signed in.")}</p></section>
</div>
<p class="muted small">${tr("Haven needs Android 7 or newer. On iPhone, use the Home Assistant app instead.")}</p>`;
},
async activity() {
const rows = await api("api/activity").catch(() => []);
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
return `<header class="adm-head"><div><h2>${tr("Activity")}</h2><p class="muted">${tr("Who changed which dashboard. Restore an earlier version from the dashboard’s version history.")}</p></div></header>
<div class="adm-list">${rows.map((r) => `<div class="act-row" ${r.deleted ? "" : `data-dash="${r.dashboard}"`}>
<span class="adm-avatar">${r.by ? initials(r.by) : Icons.svg("edit")}</span>
<div class="grow"><div>${r.by ? tr("{0} changed {1}", `<b>${esc(r.by)}</b>`, `<b>${esc(r.name)}</b>`) : tr("{0} was changed", `<b>${esc(r.name)}</b>`)}${r.deleted ? ` <span class="muted small">(${tr("deleted")})</span>` : ""}</div>
<div class="muted small">${esc(when(r.at))} · ${esc(ago(r.at))}</div></div>
${r.deleted ? "" : `<button class="btn" data-a="history">${tr("Versions")}</button>`}</div>`).join("") || `<p class="muted">${tr("No changes yet.")}</p>`}</div>`;
},
async about() {
return `<header class="adm-head"><div><h2>${tr("About")}</h2></div></header>
<section class="adm-panel"><h3>Dashboard Studio ${esc(APP_VERSION)}</h3>
<p class="muted">${tr("You’re signed in as {0}, a Home Assistant administrator, so you can use the Dashboard, Builder and Admin screens.", esc(me?.display || me?.name || ""))}</p>
<div class="modal-actions" style="justify-content:flex-start"><button class="btn" data-a="whatsnew">${Icons.svg("sparkles")}${tr("What’s new")}</button>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener">${Icons.svg("globe")}${tr("Source code")}</a></div></section>`;
},
};
async function renderAdmin(section = adminSection) {
adminSection = section;
lsSet("ds.admin", section);
$("#admin .adm-nav").innerHTML = ADMIN_SECTIONS.map(([k, icon, label]) => `<button role="tab" data-sec="${k}" aria-selected="${k === section}" class="${k === section ? "on" : ""}">${Icons.svg(icon)}<span>${tr(label)}</span></button>`).join("");
const body = $("#admin .adm-body");
const html = await ADMIN[section]();
if (adminSection !== section || mode !== "admin") return; // moved on while it loaded
const y = body.scrollTop;
body.innerHTML = html;
body.scrollTop = y;
}
/** Change what a person (or "everyone") may see. */
function setAccess(who, fn) {
const acc = store.access;
if (who === "everyone") acc.everyone = fn({ all: true, ...(acc.everyone || {}) });
else { const v = fn(acc.users[who] ? { ...acc.users[who] } : { ...(acc.everyone || { all: true }) }); if (v) acc.users[who] = v; else delete acc.users[who]; }
scheduleSave();
renderAdmin("people");
}
/** Admin → People → Preview: the dashboards this person sees, as they see them. */
function startPreview(p) {
const ids = visibleFor(p.id);
if (!ids.length) { toast(tr("{0} can’t see any dashboard yet.", p.name), true); return; }
const home = accessFor(p.id).home;
previewAs = { ...p, ids };
mode = "dashboard";
syncMode();
setCurrent(ids.includes(home) ? home : ids[0]);
setEdit(false);
const bar = $("#preview-bar");
bar.querySelector(".pv-text").textContent = tr("Previewing as {0}: {1} dashboards. On their devices it’s full screen, in kiosk mode.", p.name, ids.length);
bar.hidden = false;
document.body.classList.add("previewing");
}
$("#pv-stop").onclick = () => {
previewAs = null;
$("#preview-bar").hidden = true;
document.body.classList.remove("previewing");
setMode("admin");
renderAdmin("people");
};
$("#admin").addEventListener("click", async (e) => {
const sec = e.target.closest("[data-sec]");
if (sec) { renderAdmin(sec.dataset.sec); $("#admin .adm-body").scrollTop = 0; return; }
const k = e.target.closest("button[data-k]");
if (k?.dataset.k === "mode") {
const v = k.dataset.v;
setAccess(k.dataset.who, (cur) => (v === "same" ? null : v === "all" ? { all: true, home: cur.home } : { all: false, dashboards: cur.all ? store.dashboards.map((d) => d.id) : cur.dashboards || [], home: cur.home }));
return;
}
const b = e.target.closest("[data-a]");
if (!b) return;
const act = b.dataset.a, card = b.closest("[data-dash]"), dash = card?.dataset.dash;
if (act === "open" || act === "edit") { setCurrent(dash); setMode(act === "edit" ? "builder" : "dashboard"); return; }
if (act === "more") {
const d = store.dashboards.find((x) => x.id === dash);
const pick = await openModal(`<h3>${esc(d.name)}</h3><div class="act-list">
${[["rename", "edit", "Rename…"], ["duplicate", "copy", "Duplicate"], ["history", "history", "Version history…"], ["share", "share", "Share…"], ["export", "exit", "Export as JSON"], ["delete", "trash", "Delete dashboard…"]]
.map(([a, i, l]) => `<button class="btn ${a === "delete" ? "danger" : ""}" data-x="${a}">${Icons.svg(i)}${tr(l)}</button>`).join("")}</div>
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button></div>`,
(ev, close) => { const x = ev.target.closest("[data-x]")?.dataset.x; if (x) close(x === "close" ? null : x); });
if (!pick) return;
setCurrent(dash);
await dashboardAction(pick);
if (mode === "admin") renderAdmin();
return;
}
if (act === "new") { $("#btn-new").click(); return; }
if (["starter", "import", "lovelace"].includes(act)) { await dashboardAction(act); if (act === "starter") renderAdmin(); return; }
if (act === "preview") { const p = peopleList.find((x) => x.id === b.closest("[data-user]").dataset.user); if (p) startPreview(p); return; }
if (act === "forget") {
const id = b.closest("[data-dev]").dataset.dev;
delete store.assign.devices[id];
scheduleSave();
await fetch(new URL(`api/devices/${encodeURIComponent(id)}`, BASE), { method: "DELETE" }).catch(() => {});
renderAdmin("devices");
return;
}
if (act === "announce") {
const message = $("#an-msg").value.trim();
if (!message) { $("#an-msg").focus(); return; }
const to = $("#an-to").value;
await ha.send({ type: "fire_event", event_type: "dashboard_studio_announce", event_data: { title: $("#an-title").value.trim() || undefined, message, ...(to ? { device: to } : {}) } })
.then(() => { toast(tr("Sent.")); $("#an-msg").value = ""; })
.catch((err) => toast(tr("Couldn’t send it: {0}", err.message), true));
return;
}
if (act === "copy-addr") { navigator.clipboard?.writeText($("#haven-addr").value).then(() => toast(tr("Copied."))).catch(() => $("#haven-addr").select()); return; }
if (act === "history") { setCurrent(b.closest("[data-dash]").dataset.dash); await historyDialog(); return; }
if (act === "whatsnew") whatsNew();
});
$("#admin").addEventListener("change", (e) => {
const f = e.target, k = f.dataset.k;
if (f.closest("[data-dev]")) {
const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {});
d[k] = f.type === "checkbox" ? f.checked : f.value;
if (!d.name && !d.dashboard && !d.kiosk) delete store.assign.devices[id];
scheduleSave();
return;
}
if (k === "dash") {
const boxes = [...f.closest(".adm-chips").querySelectorAll("input:checked")].map((x) => x.value);
setAccess(f.dataset.who, (cur) => ({ ...cur, all: false, dashboards: boxes, home: boxes.includes(cur.home) ? cur.home : undefined }));
} else if (k === "home") setAccess(f.dataset.who, (cur) => ({ ...cur, home: f.value || undefined }));
});
// device names save while typing
$("#admin").addEventListener("input", (e) => {
const f = e.target;
if (f.dataset.k !== "name" || !f.closest("[data-dev]")) return;
const id = f.closest("[data-dev]").dataset.dev;
(store.assign.devices[id] ||= {}).name = f.value;
scheduleSave();
});
// ------------------------------------------------------------ boot // ------------------------------------------------------------ boot
function paintChromeIcons() { function paintChromeIcons() {
$("#btn-new").innerHTML = Icons.svg("plus"); $("#btn-new").innerHTML = Icons.svg("plus");
@@ -3237,7 +3553,7 @@
$("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk")); $("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk"));
$("#btn-app").innerHTML = Icons.svg("phone"); $("#btn-app").innerHTML = Icons.svg("phone");
$("#phone-menu").innerHTML = Icons.svg("more"); $("#phone-menu").innerHTML = Icons.svg("more");
document.querySelectorAll("#menu [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)));
} }
ha.onStatus = (ok) => { ha.onStatus = (ok) => {
@@ -3266,6 +3582,26 @@
setTimeout(() => location.reload(), 2500); setTimeout(() => location.reload(), 2500);
} }
setInterval(checkVersion, 5 * 60 * 1000); setInterval(checkVersion, 5 * 60 * 1000);
// Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload.
let storeKey = "";
const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices]);
async function refreshStore() {
if (editMode || mode === "admin" || savePending || previewAs || modalClose) return;
let next;
try { next = await api(`api/dashboards?device=${DEVICE_ID}`); } catch { return; }
const key = keyOf(next);
if (key === storeKey) return;
storeKey = key;
if (!$("#empty-note").hidden || (!isAdmin() && !next.dashboards?.length)) { location.reload(); return; }
next.dashboards = next.dashboards.map(migrate);
next.assign = { devices: {}, users: {}, ...(next.assign || {}) };
store = next;
const home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
const keep = [current?.id, store.assign.devices[DEVICE_ID]?.dashboard, home].find((id) => id && store.dashboards.some((d) => d.id === id));
setCurrent(keep);
}
setInterval(refreshStore, 60 * 1000);
ha.onChange = onStateChange; ha.onChange = onStateChange;
ha.onRegistry = () => { renderGrid(); if (editMode) { renderInspector(); renderPalette(); } }; ha.onRegistry = () => { renderGrid(); if (editMode) { renderInspector(); renderPalette(); } };
@@ -3276,18 +3612,27 @@
} }
async function boot() { async function boot() {
document.body.classList.toggle("in-haven", IN_APP);
translateStatic(); translateStatic();
paintChromeIcons(); paintChromeIcons();
renderPalette(); renderPalette();
await keepIngressSession(); await keepIngressSession();
// In Haven, a session that couldn't be renewed means signing in again on Home Assistant's page.
if (IN_APP && (await fetch(new URL("api/version", BASE)).then((r) => r.status === 401).catch(() => false))) { window.HavenApp.reconnect?.(); return; }
loadCachedStates(); loadCachedStates();
if ("serviceWorker" in navigator && APP_VERSION !== "dev") navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {}); if ("serviceWorker" in navigator && APP_VERSION !== "dev") navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
me = await api("api/whoami").catch(() => null);
if (me) { role = me.admin ? "admin" : "basic"; lsSet("ds.role", role); }
try { try {
const r = await fetch(new URL("api/dashboards", BASE)); const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE));
store = await r.json(); store = await r.json();
if (store.role) role = store.role;
store.dashboards = (store.dashboards || []).map(migrate); store.dashboards = (store.dashboards || []).map(migrate);
store.assign = { devices: {}, users: {}, ...(store.assign || {}) }; store.assign = { devices: {}, users: {}, ...(store.assign || {}) };
} catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); } } catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); }
// Let admins see this device on the Admin screen, to give it a dashboard.
fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device: DEVICE_ID, app: IN_APP ? "haven" : "browser", kind: phoneScreen.matches ? "phone" : matchMedia("(pointer: coarse)").matches ? "tablet" : "computer" }) }).catch(() => {});
const firstReady = new Promise((res) => { const firstReady = new Promise((res) => {
const prev = ha.onStatus; const prev = ha.onStatus;
@@ -3296,19 +3641,27 @@
}); });
ha.connect(); ha.connect();
if (isAdmin()) migrateAccess();
storeKey = keyOf(store);
if (!store.dashboards?.length) { if (!store.dashboards?.length) {
if (!isAdmin()) { showEmpty(); return; }
await firstReady; await firstReady;
store = { version: 1, dashboards: [starterDashboard()], assign: { devices: {}, users: {} } }; store = { ...store, version: 1, dashboards: [starterDashboard()] };
save(); save();
} }
me = await api("api/whoami").catch(() => null);
const shared = sharedParams(); const shared = sharedParams();
const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1] || shared.get("ds"); const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1] || shared.get("ds");
// Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here. // Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here.
const dev = store.assign.devices[DEVICE_ID], forUser = me?.id && store.assign.users[me.id]; const dev = store.assign.devices[DEVICE_ID], home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
const exists = (id) => id && store.dashboards.some((d) => d.id === id); const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists)); // The screen it was on last time; people who aren't admins, and Haven, only get the dashboard.
if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false }); const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
mode = isAdmin() && !IN_APP ? lastMode : "dashboard";
editMode = mode === "builder";
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists));
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); }
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
else setMode(mode);
const action = shared.get("ds_action"); const action = shared.get("ds_action");
if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); } if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); }
// After an update, point at what changed (once per device). // After an update, point at what changed (once per device).
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#245268"/><stop offset="1" stop-color="#0d1d29"/></linearGradient>
<linearGradient id="door" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffe2a0"/><stop offset="1" stop-color="#f4a531"/></linearGradient>
<clipPath id="house"><path d="M136 240 L256 140 L376 240 V388 a22 22 0 0 1 -22 22 H158 a22 22 0 0 1 -22 -22 Z"/></clipPath>
<filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="16"/></filter>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<g id="art">
<path d="M136 240 L256 140 L376 240 V388 a22 22 0 0 1 -22 22 H158 a22 22 0 0 1 -22 -22 Z" fill="#f4eee4"/>
<path d="M100 262 L256 130 L412 262" fill="none" stroke="#f4eee4" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
<g clip-path="url(#house)"><ellipse cx="256" cy="352" rx="62" ry="76" fill="#ffb547" opacity=".55" filter="url(#blur)"/></g>
<path d="M214 410 V322 a42 42 0 0 1 84 0 V410 Z" fill="url(#door)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
+79
View File
@@ -625,6 +625,85 @@
"Open in the app": "Openen in de app", "Open in the app": "Openen in de app",
"On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead.": "Op iPhone en iPad kunnen apps op het beginscherm de inlog van Home Assistant niet gebruiken, dus daar kan Dashboard Studio niet los draaien. Open het in plaats daarvan via de zijbalk in de Home Assistant-app.", "On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead.": "Op iPhone en iPad kunnen apps op het beginscherm de inlog van Home Assistant niet gebruiken, dus daar kan Dashboard Studio niet los draaien. Open het in plaats daarvan via de zijbalk in de Home Assistant-app.",
"Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again.": "Browsers installeren alleen apps vanaf een beveiligd adres (https), en Home Assistant is hier via http geopend. Open Home Assistant via het https-adres, bijvoorbeeld Home Assistant Cloud, en probeer het opnieuw.", "Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again.": "Browsers installeren alleen apps vanaf een beveiligd adres (https), en Home Assistant is hier via http geopend. Open Home Assistant via het https-adres, bijvoorbeeld Home Assistant Cloud, en probeer het opnieuw.",
"This is Haven, connected to {0}.": "Dit is Haven, verbonden met {0}.",
"Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in.": "Gebruik Haven, de Android-app voor je dashboards. Hij opent schermvullend met een eigen pictogram en houdt je ingelogd.",
"Download Haven and open the file to install it. Android asks once to allow apps from this source.": "Download Haven en open het bestand om het te installeren. Android vraagt één keer of apps uit deze bron zijn toegestaan.",
"Come back here and tap Open in Haven. Or open Haven and enter the address of Home Assistant.": "Kom hier terug en tik op Openen in Haven. Of open Haven en vul het adres van Home Assistant in.",
"Or install it from the browser, without Haven.": "Of installeer het vanuit de browser, zonder Haven.",
"Download Haven": "Haven downloaden",
"Open in Haven": "Openen in Haven",
"For Android phones there is Haven: see Admin, Haven app.": "Voor Android-telefoons is er Haven: zie Beheer, Haven-app.",
"Dashboards": "Dashboards",
"This is the only dashboard you can see. An admin can add more for you.": "Dit is het enige dashboard dat je kunt zien. Een beheerder kan er meer voor je toevoegen.",
"An admin hasn’t chosen a dashboard for you yet. Ask someone who manages Home Assistant to pick one for you on the Admin screen in Dashboard Studio.": "Een beheerder heeft nog geen dashboard voor je gekozen. Vraag iemand die Home Assistant beheert om er een voor je te kiezen op het beheerscherm van Dashboard Studio.",
"never": "nooit",
"Admins": "Beheerders",
"Everyone": "Iedereen",
"Admins only": "Alleen beheerders",
"1 tab": "1 tabblad",
"1 widget": "1 widget",
"Build": "Bouwen",
"More for {0}": "Meer voor {0}",
"Build a dashboard, then choose under People who sees it.": "Bouw een dashboard en kies daarna onder Personen wie het ziet.",
"Import file": "Bestand importeren",
"Opens first": "Opent eerst",
"The first one": "Het eerste",
"Sees": "Ziet",
"Like everyone else": "Zoals iedereen",
"All dashboards": "Alle dashboards",
"Only these": "Alleen deze",
"Admin": "Beheer",
"Home Assistant administrator · sees and builds everything": "Beheerder in Home Assistant · ziet en bouwt alles",
"Sees {0} · always in kiosk mode": "Ziet {0} · altijd in kioskmodus",
"all dashboards": "alle dashboards",
"{0} of {1} dashboards": "{0} van {1} dashboards",
"last opened {0}": "laatst geopend {0}",
"Preview": "Voorbeeld",
"Home Assistant’s administrators are admins here: they see every dashboard and can build. Everyone else only sees the dashboards chosen here, full screen in kiosk mode, on any device.": "De beheerders van Home Assistant zijn hier beheerder: ze zien elk dashboard en kunnen bouwen. Alle anderen zien alleen de dashboards die je hier kiest, schermvullend in kioskmodus, op elk apparaat.",
"Everyone else": "Alle anderen",
"People without their own choice below, and anyone added to Home Assistant later.": "Mensen zonder eigen keuze hieronder, en iedereen die later aan Home Assistant wordt toegevoegd.",
"Couldn’t read Home Assistant’s people.": "De mensen in Home Assistant konden niet worden gelezen.",
"To add someone, create a person with a login in Home Assistant (Settings, People). Turn off Administrator there to make them a basic user.": "Om iemand toe te voegen maak je in Home Assistant een persoon met een account (Instellingen, Personen). Zet Beheerder daar uit om er een gewone gebruiker van te maken.",
"The person’s choice": "Keuze van de persoon",
"Browser": "Browser",
"Admins on this device start in kiosk mode too": "Beheerders starten op dit apparaat ook in kioskmodus",
"Every phone, tablet and computer that opened Dashboard Studio. Give a wall tablet its own dashboard: that comes before the person’s choice.": "Elke telefoon, tablet en computer die Dashboard Studio heeft geopend. Geef een wandtablet een eigen dashboard: dat gaat voor de keuze van de persoon.",
"Send a message to the screens": "Een bericht naar de schermen sturen",
"It shows full screen on dashboards in kiosk mode, like an announcement from an automation.": "Het verschijnt schermvullend op dashboards in kioskmodus, net als een aankondiging uit een automatisering.",
"Title, e.g. Dinner": "Titel, bijv. Eten",
"Message, e.g. Dinner is ready!": "Bericht, bijv. Het eten is klaar!",
"Message": "Bericht",
"To": "Aan",
"Send": "Versturen",
"Haven app": "Haven-app",
"Haven is the Android app for the people in your home. It only shows dashboards: no builder and no settings. Everyone sees what you chose for them under People.": "Haven is de Android-app voor de mensen in je huis. Het toont alleen dashboards: geen bouwer en geen instellingen. Iedereen ziet wat je onder Personen voor ze hebt gekozen.",
"Install Haven on the phone": "Haven op de telefoon installeren",
"Scan this code with the phone’s camera, or download it on the phone itself. Android asks once to allow apps from this source.": "Scan deze code met de camera van de telefoon, of download het op de telefoon zelf. Android vraagt één keer of apps uit deze bron zijn toegestaan.",
"QR code to download Haven": "QR-code om Haven te downloaden",
"Connect to Home Assistant": "Verbinden met Home Assistant",
"Open Haven and enter this address. It is the address you use for Home Assistant; on other networks use its outside address, for example Home Assistant Cloud.": "Open Haven en vul dit adres in. Het is het adres waarmee je Home Assistant opent; gebruik buitenshuis het externe adres, bijvoorbeeld Home Assistant Cloud.",
"Address": "Adres",
"On the phone itself, Open in Haven fills it in.": "Op de telefoon zelf vult Openen in Haven het in.",
"Sign in": "Inloggen",
"Everyone signs in with their own Home Assistant login, once. Haven then opens straight on their dashboard and stays signed in.": "Iedereen logt één keer in met zijn eigen Home Assistant-account. Haven opent daarna meteen op het eigen dashboard en blijft ingelogd.",
"Haven needs Android 7 or newer. On iPhone, use the Home Assistant app instead.": "Haven werkt op Android 7 of nieuwer. Gebruik op een iPhone de Home Assistant-app.",
"Activity": "Activiteit",
"Who changed which dashboard. Restore an earlier version from the dashboard’s version history.": "Wie welk dashboard heeft gewijzigd. Zet een eerdere versie terug via de versiegeschiedenis van het dashboard.",
"{0} changed {1}": "{0} wijzigde {1}",
"{0} was changed": "{0} is gewijzigd",
"deleted": "verwijderd",
"Versions": "Versies",
"No changes yet.": "Nog geen wijzigingen.",
"You’re signed in as {0}, a Home Assistant administrator, so you can use the Dashboard, Builder and Admin screens.": "Je bent ingelogd als {0}, beheerder in Home Assistant, dus je kunt de schermen Dashboard, Bouwer en Beheer gebruiken.",
"{0} can’t see any dashboard yet.": "{0} kan nog geen enkel dashboard zien.",
"Previewing as {0}: {1} dashboards. On their devices it’s full screen, in kiosk mode.": "Voorbeeld als {0}: {1} dashboards. Op hun apparaten is het schermvullend, in kioskmodus.",
"Sent.": "Verstuurd.",
"Couldn’t send it: {0}": "Versturen lukte niet: {0}",
"Copied.": "Gekopieerd.",
"Builder": "Bouwer",
"Stop preview": "Voorbeeld stoppen",
"Nothing to show yet": "Nog niets te zien",
"Screen": "Scherm",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
+13 -10
View File
@@ -40,24 +40,19 @@
<button id="btn-guest" class="btn view-only" hidden><span data-i18n>Guest mode</span></button> <button id="btn-guest" class="btn view-only" hidden><span data-i18n>Guest mode</span></button>
<button id="btn-app" class="btn icon view-only" aria-label="Install as an app" title="Install as an app"></button> <button id="btn-app" class="btn icon view-only" aria-label="Install as an app" title="Install as an app"></button>
<button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button> <button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button>
<button id="btn-edit" class="btn primary" data-i18n>Edit</button> <div id="mode-seg" class="mode-seg" role="tablist" aria-label="Screen" hidden>
<button role="tab" data-mode="dashboard" data-icon="layout"><span data-i18n>Dashboard</span></button>
<button role="tab" data-mode="builder" data-icon="edit"><span data-i18n>Builder</span></button>
<button role="tab" data-mode="admin" data-icon="shield"><span data-i18n>Admin</span></button>
</div>
</div> </div>
<div id="menu" class="menu" role="menu" hidden> <div id="menu" class="menu" role="menu" hidden>
<button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button> <button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button>
<button role="menuitem" data-menu="duplicate" data-icon="copy" data-i18n>Duplicate</button>
<button role="menuitem" data-menu="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
<button role="menuitem" data-menu="suggest" data-icon="search" data-i18n>Devices not on a dashboard…</button> <button role="menuitem" data-menu="suggest" data-icon="search" data-i18n>Devices not on a dashboard…</button>
<button role="menuitem" data-menu="assign" data-icon="person" data-i18n>Who sees which dashboard…</button>
<button role="menuitem" data-menu="history" data-icon="history" data-i18n>Version history…</button> <button role="menuitem" data-menu="history" data-icon="history" data-i18n>Version history…</button>
<button role="menuitem" data-menu="undo-history" data-icon="undo" data-i18n>Undo history…</button> <button role="menuitem" data-menu="undo-history" data-icon="undo" data-i18n>Undo history…</button>
<button role="menuitem" data-menu="share" data-icon="share" data-i18n>Share…</button> <button role="menuitem" data-menu="share" data-icon="share" data-i18n>Share…</button>
<button role="menuitem" data-menu="install" data-icon="phone" data-i18n>Install as an app…</button>
<hr>
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button> <button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
<button role="menuitem" data-menu="lovelace" data-icon="import" data-i18n>Import from Home Assistant…</button>
<hr>
<button role="menuitem" data-menu="delete" data-icon="trash" class="danger" data-i18n>Delete dashboard…</button>
</div> </div>
</header> </header>
@@ -77,6 +72,12 @@
<aside id="inspector" class="edit-only" aria-label="Inspector"></aside> <aside id="inspector" class="edit-only" aria-label="Inspector"></aside>
</div> </div>
<!-- Admin: dashboards, people, devices, the Haven app, activity -->
<section id="admin" hidden aria-label="Admin">
<nav class="adm-nav" role="tablist" aria-label="Admin"></nav>
<div class="adm-body"></div>
</section>
</div> </div>
<div id="edit-bar" class="edit-only"><button class="btn" id="eb-add"><span data-i18n>Add</span></button><button class="btn primary" id="eb-insp"></button></div> <div id="edit-bar" class="edit-only"><button class="btn" id="eb-add"><span data-i18n>Add</span></button><button class="btn primary" id="eb-insp"></button></div>
@@ -84,6 +85,8 @@
<div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div> <div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div>
<div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div> <div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div>
<div id="night-dim" aria-hidden="true"></div> <div id="night-dim" aria-hidden="true"></div>
<div id="preview-bar" hidden role="status"><span class="pv-text"></span><button class="btn" id="pv-stop"><span data-i18n>Stop preview</span></button></div>
<div id="empty-note" hidden role="status"><div class="en-card"><div class="en-ico"></div><h2 data-i18n>Nothing to show yet</h2><p class="en-text"></p></div></div>
<button id="phone-menu" class="btn icon" aria-label="Menu" title="Menu"></button> <button id="phone-menu" class="btn icon" aria-label="Menu" title="Menu"></button>
<div id="offline-note" hidden role="status"></div> <div id="offline-note" hidden role="status"></div>
<div id="ptr" hidden aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg></div> <div id="ptr" hidden aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg></div>
+108
View File
@@ -1418,3 +1418,111 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
.hist-row { display: flex; align-items: center; gap: 12px; padding: 8px 4px; border-bottom: 1px solid var(--separator); } .hist-row { display: flex; align-items: center; gap: 12px; padding: 8px 4px; border-bottom: 1px solid var(--separator); }
.hist-row:last-child { border-bottom: 0; } .hist-row:last-child { border-bottom: 0; }
.hist-when { font-weight: 500; } .hist-when { font-weight: 500; }
/* ============================================================ screens: Dashboard, Builder, Admin */
.mode-seg { display: flex; gap: 2px; padding: 2px; border-radius: 11px; background: var(--fill); }
.mode-seg[hidden] { display: none; }
.mode-seg button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; border-radius: 9px; min-height: 32px; padding: 0 12px; cursor: pointer; font-weight: 500; color: var(--text-2); }
.mode-seg button .i { font-size: 17px; }
.mode-seg button:hover { color: var(--text); }
.mode-seg button.on { background: var(--surface); color: var(--text); font-weight: 650; box-shadow: 0 1px 3px rgba(0, 0, 0, .14); }
.mode-seg button[data-mode="builder"].on { background: var(--accent); color: var(--on-accent); }
@media (max-width: 760px) { .mode-seg button span { display: none; } .mode-seg button { padding: 0 9px; } }
body.admin-mode #workspace, body.admin-mode #topbar .picker, body.admin-mode #btn-new, body.admin-mode #topbar .view-only, body.admin-mode #topbar .edit-only { display: none !important; }
body.admin-mode #topbar::before { content: "Dashboard Studio"; font-weight: 650; font-size: 17px; padding-left: 8px; }
/* ============================================================ admin */
#admin { position: absolute; inset: 0; display: flex; padding-top: 72px; background: var(--canvas); }
#admin[hidden] { display: none; }
.adm-nav { flex: none; width: 220px; padding: 14px 10px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--separator); background: var(--surface); overflow-y: auto; }
.adm-nav button { display: flex; align-items: center; gap: 10px; border: 0; background: transparent; border-radius: 10px; padding: 9px 12px; min-height: 40px; cursor: pointer; font-size: 15px; text-align: left; color: var(--text); }
.adm-nav button .i { font-size: 19px; color: var(--text-2); }
.adm-nav button:hover { background: var(--fill); }
.adm-nav button.on { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); font-weight: 650; }
.adm-nav button.on .i { color: var(--accent); }
.adm-body { flex: 1; min-width: 0; overflow-y: auto; padding: 22px 28px 60px; }
.adm-body > * { max-width: 1040px; margin-left: auto; margin-right: auto; }
.adm-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 18px; }
.adm-head h2 { font-size: 28px; font-weight: 700; margin: 0 0 4px; letter-spacing: -.01em; }
.adm-head p { margin: 0; max-width: 640px; }
.adm-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.adm-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.adm-card { background: var(--surface); border-radius: 16px; padding: 16px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow); border: 1px solid var(--separator); }
.ac-top { display: flex; gap: 12px; align-items: center; }
.ac-top h3 { margin: 0 0 2px; font-size: 17px; overflow-wrap: anywhere; }
.ac-swatch { width: 52px; height: 52px; flex: none; border-radius: 12px; display: grid; place-items: center; color: #fff; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .08); }
.ac-swatch .i { font-size: 22px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35)); }
.ac-who { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); }
.ac-who .i { font-size: 16px; flex: none; }
.ac-actions { display: flex; gap: 8px; margin-top: auto; }
.ac-actions .btn:not(.icon) { flex: 1; }
.adm-list { display: flex; flex-direction: column; gap: 10px; }
#admin .grow { flex: 1; min-width: 0; }
.person { background: var(--surface); border-radius: 16px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid var(--separator); }
.person.everyone { border-style: dashed; }
.p-head { display: flex; align-items: center; gap: 12px; }
.p-name { font-weight: 650; font-size: 16px; }
.p-badge { display: inline-block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: 2px 7px; border-radius: 6px; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); vertical-align: 2px; margin-left: 4px; }
.adm-avatar { width: 40px; height: 40px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--fill); font-weight: 700; color: var(--text-2); }
.adm-avatar.admin { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
.adm-avatar .i { font-size: 19px; }
.p-access { margin: 12px 0 0 52px; display: flex; flex-direction: column; gap: 10px; }
.p-access .seg { max-width: 460px; }
.p-access .field { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 13px; color: var(--text-2); }
.p-access .field .input { max-width: 240px; }
.adm-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip-check { position: relative; }
.chip-check input { position: absolute; opacity: 0; pointer-events: none; }
.chip-check span { display: inline-flex; align-items: center; min-height: 32px; padding: 0 13px; border-radius: 99px; background: var(--fill); font-size: 13px; font-weight: 500; cursor: pointer; }
.chip-check input:checked + span { background: var(--accent); color: var(--on-accent); }
.chip-check input:checked + span::before { content: "✓"; margin-right: 6px; font-weight: 700; }
.chip-check input:disabled + span { opacity: .55; cursor: default; }
.chip-check input:focus-visible + span { box-shadow: var(--focus); }
.dev-row, .act-row { display: flex; align-items: center; gap: 12px; background: var(--surface); border-radius: 14px; padding: 10px 14px; border: 1px solid var(--separator); }
.dev-row .grow .input { max-width: 320px; font-weight: 600; }
.dev-row select.input { width: 200px; flex: none; }
.dev-gap { width: 36px; flex: none; }
.adm-panel { background: var(--surface); border-radius: 16px; padding: 18px; border: 1px solid var(--separator); margin-top: 18px; }
.adm-panel h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-size: 17px; }
.adm-panel h3 .i { color: var(--accent); }
.an-form { display: grid; grid-template-columns: 1fr 2fr 180px auto; gap: 8px; margin-top: 12px; }
.haven-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
.haven-grid .adm-panel { margin-top: 0; display: flex; flex-direction: column; gap: 6px; }
.haven-step .btn { align-self: flex-start; }
.hs-num { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; font-weight: 700; }
.act-list { display: flex; flex-direction: column; gap: 6px; }
.act-list .btn { justify-content: flex-start; }
.kp-dashes { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; margin: 6px 0 4px; }
.kp-dashes .btn { min-height: 56px; justify-content: flex-start; font-size: 16px; font-weight: 600; }
@media (max-width: 760px) {
#admin { flex-direction: column; }
.adm-nav { width: auto; flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--separator); padding: 8px; }
.adm-nav button { flex: none; padding: 8px 12px; }
.adm-body { padding: 18px 14px 60px; }
.adm-head h2 { font-size: 24px; }
.p-access { margin-left: 0; }
.dev-row { flex-wrap: wrap; }
.dev-row .grow { flex-basis: calc(100% - 52px); }
.dev-row select.input { flex: 1; width: auto; }
.an-form { grid-template-columns: 1fr; }
}
/* preview as someone else, and nothing to show */
#preview-bar { position: fixed; z-index: 70; left: 50%; bottom: calc(16px + env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; align-items: center; gap: 12px;
padding: 8px 8px 8px 16px; border-radius: 14px; background: rgba(28, 28, 30, .9); color: #fff; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0, 0, 0, .3); max-width: calc(100% - 24px); font-size: 14px; }
#preview-bar[hidden] { display: none; }
#preview-bar .btn { background: rgba(255, 255, 255, .16); color: #fff; flex: none; }
body.previewing #btn-new { display: none; }
#empty-note { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: var(--canvas); }
#empty-note[hidden] { display: none; }
.en-card { max-width: 420px; text-align: center; }
.en-ico { width: 72px; height: 72px; margin: 0 auto 16px; border-radius: 20px; display: grid; place-items: center; background: var(--fill); }
.en-ico .i { font-size: 34px; color: var(--text-2); }
.en-card h2 { margin: 0 0 8px; }
.en-card p { color: var(--text-2); margin: 0; }
body.empty #app { display: none; }
body.in-haven #btn-new { display: none; }
.haven-head { justify-content: flex-start; align-items: center; }
.haven-head img { width: 64px; height: 64px; border-radius: 16px; flex: none; box-shadow: 0 6px 18px rgba(0, 0, 0, .2); }
.haven-head > div { flex: 1; min-width: 240px; }
+3 -1
View File
@@ -21,7 +21,9 @@ self.addEventListener("fetch", (e) => {
return res; return res;
} }
if (!page) return res; if (!page) return res;
throw new Error(String(res.status)); // Signed out of Home Assistant: the last good copy renews the session itself. Without one, pass
// the refusal on, so Haven knows to sign in again.
return (await cache.match(new URL("./", self.registration.scope).href)) || res;
} catch (err) { } catch (err) {
const hit = page ? await cache.match(new URL("./", self.registration.scope).href) : await cache.match(req); const hit = page ? await cache.match(new URL("./", self.registration.scope).href) : await cache.match(req);
if (hit) return hit; if (hit) return hit;
@@ -1,8 +1,8 @@
<?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.dashboardstudio" package="nl.melvanveen.haven"
android:versionCode="1" android:versionCode="1"
android:versionName="1.14.1"> android:versionName="2.0.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" />
@@ -25,12 +25,12 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- "Open in the app" in Dashboard Studio: dashboardstudio://open?url=<the app's address> --> <!-- "Open in Haven" in Dashboard Studio: haven://open?url=<the address of Dashboard Studio> -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="dashboardstudio" android:host="open" /> <data android:scheme="haven" android:host="open" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: dark; }
body { margin: 0; min-height: 100vh; background: linear-gradient(180deg, #17384a 0%, #0d1d29 60%); color: #f4eee4; font: 16px/1.45 system-ui, sans-serif; display: flex; align-items: center; justify-content: center; }
main { width: 100%; max-width: 420px; padding: 28px 22px; box-sizing: border-box; }
img { width: 80px; height: 80px; border-radius: 20px; display: block; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(0, 0, 0, .35); }
.name { font-size: 15px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #f4a531; margin: 0 0 4px; }
h1 { font-size: 26px; margin: 0 0 8px; }
p { margin: 0 0 18px; color: #a9b8c2; }
.msg { background: rgba(255, 69, 58, .15); color: #ffb4ae; padding: 10px 14px; border-radius: 12px; }
.msg:empty { display: none; }
label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
input { width: 100%; box-sizing: border-box; font: inherit; color: inherit; background: rgba(255, 255, 255, .07); border: 1px solid rgba(255, 255, 255, .14); border-radius: 12px; padding: 12px 14px; }
input:focus { outline: 2px solid #f4a531; border-color: transparent; }
.hint { font-size: 13px; margin: 8px 0 22px; }
button { width: 100%; font: inherit; font-weight: 700; color: #2b1a02; background: #f4a531; border: 0; border-radius: 12px; padding: 13px; }
button:active { opacity: .85; }
</style>
</head>
<body>
<main>
<img src="icon.png" alt="">
<p class="name">Haven</p>
<h1 data-t="title">Your home’s dashboards</h1>
<p data-t="intro">Enter the address you use to open Home Assistant, then sign in with your own login. You only do this once.</p>
<p class="msg" id="msg"></p>
<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>
<p class="hint" data-t="hint">Ask whoever set up Home Assistant: it’s on the Admin screen of Dashboard Studio, under Haven app.</p>
<button type="submit" data-t="go">Connect</button>
</form>
</main>
<script>
const NL = {
title: "De dashboards van je huis",
intro: "Vul het adres in waarmee je Home Assistant opent en log in met je eigen account. Dat doe je maar één keer.",
label: "Adres van Home Assistant",
hint: "Vraag het aan wie Home Assistant heeft ingesteld: het staat in Dashboard Studio op het beheerscherm, onder Haven-app.",
go: "Verbinden",
offline: "Home Assistant is niet bereikbaar op dit adres. Controleer het adres en je verbinding.",
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}",
};
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}",
};
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; });
const app = window.HavenApp;
const why = app ? app.message() : "", detail = app ? app.detail() : "";
if (why) document.getElementById("msg").textContent = (T[why] || T.error).replace("{0}", detail || why);
const addr = document.getElementById("addr");
addr.value = app ? app.address() : "";
document.getElementById("f").onsubmit = (e) => { e.preventDefault(); app.setup(addr.value.trim()); };
</script>
</body>
</html>
+6 -6
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# Builds the Android app without Gradle, with the Android SDK's own tools, and copies it into the # Builds Haven, the Android app, without Gradle, with the Android SDK's own tools, and copies it into the
# Home Assistant app, which offers it for download (Install as an app on an Android phone). # Home Assistant app, which offers it for download (Admin, Haven app; and Install as an app on Android).
# ./build.sh the release build, signed with ~/.android/dashboard-studio.jks (made the first time) # ./build.sh the release build, signed with ~/.android/dashboard-studio.jks (made the first time)
# ./build.sh debug a debuggable build, for testing in the emulator (Chrome can inspect its pages) # ./build.sh debug a debuggable build, for testing in the emulator (Chrome can inspect its pages)
# Keep the key: Android only installs an update that is signed with the same key as the installed app. # Keep the key: Android only installs an update that is signed with the same key as the installed app.
@@ -29,10 +29,10 @@ if [ ! -f "$KEY" ]; then
-keyalg RSA -keysize 4096 -validity 36500 -dname "CN=Dashboard Studio" >/dev/null -keyalg RSA -keysize 4096 -validity 36500 -dname "CN=Dashboard Studio" >/dev/null
echo "Made a new signing key: $KEY (keep it, updates need the same key)" echo "Made a new signing key: $KEY (keep it, updates need the same key)"
fi fi
"$BT/apksigner" sign --ks "$KEY" --ks-pass pass:dashboard-studio --ks-key-alias app --out "$OUT/dashboard-studio.apk" "$OUT/app.aligned.apk" "$BT/apksigner" sign --ks "$KEY" --ks-pass pass:dashboard-studio --ks-key-alias app --out "$OUT/haven.apk" "$OUT/app.aligned.apk"
if [ -z "$DEBUG" ]; then if [ -z "$DEBUG" ]; then
cp "$OUT/dashboard-studio.apk" ../dashboard_studio/web/dashboard-studio.apk cp "$OUT/haven.apk" ../dashboard_studio/web/haven.apk
echo "Built and copied to dashboard_studio/web/dashboard-studio.apk" echo "Built and copied to dashboard_studio/web/haven.apk"
else else
echo "Built $OUT/dashboard-studio.apk (debug)" echo "Built $OUT/haven.apk (debug)"
fi fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient android:angle="270" android:startColor="#24304f" android:endColor="#0f1526" /> <gradient android:angle="270" android:startColor="#245268" android:endColor="#0d1d29" />
</shape> </shape>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_bg" />
<item android:gravity="center" android:width="160dp" android:height="160dp" android:drawable="@drawable/ic_fg" />
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Dashboards</string> <string name="app_name">Haven</string>
<style name="AppTheme" parent="android:Theme.Material.NoActionBar"> <style name="AppTheme" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/bg</item> <item name="android:windowBackground">@drawable/loading</item>
<item name="android:statusBarColor">@color/bg</item> <item name="android:statusBarColor">@color/bg</item>
<item name="android:navigationBarColor">@color/bg</item> <item name="android:navigationBarColor">@color/bg</item>
<item name="android:colorAccent">#0a84ff</item> <item name="android:colorAccent">#f4a531</item>
</style> </style>
<color name="bg">#0f1526</color> <color name="bg">#0d1d29</color>
</resources> </resources>
@@ -1,4 +1,4 @@
package nl.melvanveen.dashboardstudio; package nl.melvanveen.haven;
import android.app.Activity; import android.app.Activity;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
@@ -22,7 +22,8 @@ import android.webkit.WebView;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
/** /**
* Dashboard Studio as an Android app: a full-screen window on the Dashboard Studio app inside Home Assistant. * 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.
* *
* Home Assistant only lets a signed-in browser into an app's page (its "ingress"). So the app first opens * Home Assistant only lets a signed-in browser into an app's page (its "ingress"). So the app first opens
* Home Assistant itself, where you sign in once; then, on Home Assistant's page, it asks for an ingress * Home Assistant itself, where you sign in once; then, on Home Assistant's page, it asks for an ingress
@@ -44,7 +45,7 @@ public class MainActivity extends Activity {
(function () { (function () {
if (window.__dsConnect) return; if (window.__dsConnect) return;
window.__dsConnect = true; window.__dsConnect = true;
const known = DSApp.appUrl(); const known = HavenApp.appUrl();
let tries = 0; let tries = 0;
const tick = async () => { const tick = async () => {
const hass = document.querySelector("home-assistant")?.hass; const hass = document.querySelector("home-assistant")?.hass;
@@ -58,19 +59,21 @@ public class MainActivity extends Activity {
let url = known; let url = known;
if (!url) { if (!url) {
const panel = Object.values(hass.panels).find((p) => /(^|_)dashboard_studio$/.test(p.config?.ingress || "")); const panel = Object.values(hass.panels).find((p) => /(^|_)dashboard_studio$/.test(p.config?.ingress || ""));
if (!panel) return DSApp.fail("notfound", ""); if (!panel) return HavenApp.fail("notfound", "");
let info; let info;
try { info = await hass.callWS({ type: "supervisor/api", endpoint: `/addons/${panel.config.ingress}/info`, method: "get" }); } try { info = await hass.callWS({ type: "supervisor/api", endpoint: `/addons/${panel.config.ingress}/info`, method: "get" }); }
catch { info = (await hass.callApi("GET", `hassio/addons/${panel.config.ingress}/info`).catch(() => null))?.data; } catch { info = (await hass.callApi("GET", `hassio/addons/${panel.config.ingress}/info`).catch(() => null))?.data; }
if (!info?.ingress_url) return DSApp.fail("noaddress", ""); if (!info?.ingress_url) return HavenApp.fail("noaddress", "");
url = location.origin + info.ingress_url; url = location.origin + info.ingress_url;
} }
const res = await hass.callApi("POST", "hassio/ingress/session"); // The websocket gives everyone a session; the REST address only works for administrators.
const session = res?.data?.session || res?.session; let session;
try { session = (await hass.callWS({ type: "supervisor/api", endpoint: "/ingress/session", method: "post" }))?.session; }
catch { const res = await hass.callApi("POST", "hassio/ingress/session"); session = res?.data?.session || res?.session; }
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`; document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`;
DSApp.open(url); HavenApp.open(url);
} catch (e) { } catch (e) {
DSApp.fail("error", String(e?.message || e?.body?.message || e?.code || e)); HavenApp.fail("error", String(e?.message || e?.body?.message || e?.code || e));
} }
}; };
tick(); tick();
@@ -84,7 +87,7 @@ public class MainActivity extends Activity {
if ((getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) WebView.setWebContentsDebuggingEnabled(true); if ((getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) WebView.setWebContentsDebuggingEnabled(true);
web = new WebView(this); web = new WebView(this);
web.setBackgroundColor(Color.parseColor("#0f1526")); web.setBackgroundColor(Color.TRANSPARENT); // the loading screen shows through until a page is ready
setContentView(web); setContentView(web);
WebSettings s = web.getSettings(); WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true); s.setJavaScriptEnabled(true);
@@ -92,9 +95,9 @@ public class MainActivity extends Activity {
s.setDatabaseEnabled(true); s.setDatabaseEnabled(true);
s.setMediaPlaybackRequiresUserGesture(false); // camera streams and the doorbell sound s.setMediaPlaybackRequiresUserGesture(false); // camera streams and the doorbell sound
s.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE); s.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
s.setUserAgentString(s.getUserAgentString() + " DashboardStudioApp/1.0"); s.setUserAgentString(s.getUserAgentString() + " HavenApp/2.0");
CookieManager.getInstance().setAcceptCookie(true); CookieManager.getInstance().setAcceptCookie(true);
web.addJavascriptInterface(new Bridge(), "DSApp"); web.addJavascriptInterface(new Bridge(), "HavenApp");
web.setWebViewClient(new Client()); web.setWebViewClient(new Client());
web.setWebChromeClient(new Chrome()); web.setWebChromeClient(new Chrome());
@@ -107,10 +110,10 @@ public class MainActivity extends Activity {
fromLink(intent); fromLink(intent);
} }
/** "Open in the app" in Dashboard Studio: dashboardstudio://open?url=... */ /** "Open in Haven" in Dashboard Studio: haven://open?url=... */
private boolean fromLink(Intent intent) { private boolean fromLink(Intent intent) {
Uri data = intent == null ? null : intent.getData(); Uri data = intent == null ? null : intent.getData();
if (data == null || !"dashboardstudio".equals(data.getScheme())) return false; if (data == null || !"haven".equals(data.getScheme())) return false;
String url = data.getQueryParameter("url"); String url = data.getQueryParameter("url");
if (url == null || !url.matches("(?i)https?://.+")) return false; if (url == null || !url.matches("(?i)https?://.+")) return false;
save(origin(Uri.parse(url)), url); save(origin(Uri.parse(url)), url);
@@ -127,11 +130,13 @@ public class MainActivity extends Activity {
} }
private static boolean isIngress(String url) { return url != null && url.contains("/api/hassio_ingress/"); } private static boolean isIngress(String url) { return url != null && url.contains("/api/hassio_ingress/"); }
/** Always through Home Assistant's own page: it gets a fresh session first, so Dashboard Studio's page
* never opens signed out (its offline copy can't help then, and the WebView wouldn't say it failed). */
private void start() { private void start() {
message = ""; detail = ""; message = ""; detail = "";
offlineTried = false;
if (ha().isEmpty()) { web.loadUrl(SETUP); return; } if (ha().isEmpty()) { web.loadUrl(SETUP); return; }
// With the address known, try it straight away: a session may still be valid, or the page is cached for offline use. connect();
web.loadUrl(app().isEmpty() ? ha() + "/" : app());
} }
private void connect() { private void connect() {
@@ -139,14 +144,32 @@ public class MainActivity extends Activity {
web.loadUrl(ha() + "/"); web.loadUrl(ha() + "/");
} }
/** While Home Assistant's page does its work, the loading screen (the window background) shows instead. */
private void hidePage() {
web.setAlpha(0f);
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 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;
web.loadUrl(SETUP); web.loadUrl(SETUP);
} }
private class Client extends WebViewClient { private class Client extends WebViewClient {
@Override
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
}
@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 || 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))) view.clearHistory(); // back leaves the app instead of going to Home Assistant if (isIngress(url) || (!page.isEmpty() && url.startsWith(page))) view.clearHistory(); // back leaves the app instead of going to Home Assistant
@@ -165,7 +188,10 @@ public class MainActivity extends Activity {
@Override @Override
public void onReceivedError(WebView view, WebResourceRequest req, WebResourceError err) { public void onReceivedError(WebView view, WebResourceRequest req, WebResourceError err) {
if (req.isForMainFrame() && !req.getUrl().toString().startsWith("file:")) showSetup("offline", ""); 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", ""); }
} }
@Override @Override
@@ -229,7 +255,7 @@ public class MainActivity extends Activity {
if (web.restoreState(state) == null) start(); if (web.restoreState(state) == null) start();
} }
/** What the pages can call, as window.DSApp. */ /** 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 ha(); }
@JavascriptInterface public String appUrl() { return app(); } @JavascriptInterface public String appUrl() { return app(); }
@@ -259,7 +285,10 @@ public class MainActivity extends Activity {
@JavascriptInterface public void fail(String why, String more) { runOnUiThread(() -> showSetup(why, more)); } @JavascriptInterface public void fail(String why, String more) { runOnUiThread(() -> showSetup(why, more)); }
/** Dashboard Studio: Install as an app, Change address. */ /** Dashboard Studio couldn't renew its session: sign in again on Home Assistant's page. */
@JavascriptInterface public void reconnect() { runOnUiThread(MainActivity.this::connect); }
/** Dashboard Studio, in Haven: Install as an app, Change address. */
@JavascriptInterface public void reset() { runOnUiThread(() -> { message = ""; detail = ""; web.loadUrl(SETUP); }); } @JavascriptInterface public void reset() { runOnUiThread(() -> { message = ""; detail = ""; web.loadUrl(SETUP); }); }
/** Kiosk mode: keep the screen on and hide the system bars. */ /** Kiosk mode: keep the screen on and hide the system bars. */