Dashboard Studio 2.2.0: guest invites, personal looks, more themes, Haven widgets and tiles

- Vacuum card: the robot drawn beside its dock by state, last clean, maintenance
- Formula One card: the official F1 look with a user-supplied logo
- Nine more themes; per-person theme, text size and language on Admin, People
- Guest invites: an expiring link and QR code for one dashboard, served on a
  LAN-only guest port, with the Home Assistant connection filtered both ways
- Haven 2.2: home screen widgets, three quick settings tiles, several homes,
  and self-updates from the Haven that Dashboard Studio serves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 00:31:50 +01:00
parent f4c98e27af
commit a5823b4726
26 changed files with 1514 additions and 164 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## 2.2.0
- **Robot vacuum:** the robot drawn from above beside its dock, placed by what it does (charging on the dock, cleaning with its path behind it, on its way back), the last clean, and maintenance bars for the filter and brushes.
- **Formula One:** Formula 1's own look, with the race in large type, a countdown, the weekend's sessions with the next one in red, and standings set like the timing tower with the team colours. Add the official logo yourself (it's a trademark, so it doesn't come with the app).
- **Nine more themes:** Midnight, Espresso, Ember, Lagoon, Paper, Arctic, Moss, Sakura and Concrete.
- **Each person's own look:** on **Admin → People**, give someone their own theme, a larger text size or their own language.
- **Guest invites:** a link and a QR code that opens one dashboard for guests without a Home Assistant login, until the time you choose, on your home Wi-Fi. Turn guest links on in the app's Network settings first.
- **Haven 2.2:** home screen widgets for a light, scene or thermostat; three quick settings tiles; several homes (a holiday home, your parents'); and it offers to update itself when Dashboard Studio brings a newer Haven.
## 2.1.0 ## 2.1.0
- **Every card redesigned,** with a desktop, a tablet and a phone design that apply by themselves: compact with hover states on a computer; larger type and 44px buttons on tablets and wall panels; touch sizes in a tighter frame on phones, with rows that scroll sideways instead of wrapping. Narrow cards put their figures under the names. - **Every card redesigned,** with a desktop, a tablet and a phone design that apply by themselves: compact with hover states on a computer; larger type and 44px buttons on tablets and wall panels; touch sizes in a tighter frame on phones, with rows that scroll sideways instead of wrapping. Narrow cards put their figures under the names.
+22 -4
View File
@@ -19,14 +19,25 @@ Everyone with a login sees Dashboard Studio in the Home Assistant sidebar.
## The Admin screen ## 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. - **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. - **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. Each person can also get their own **Theme** (it replaces the dashboard's colours on their devices), a **Text** size (*Large* or *Extra large*, handy for grandparents) and a **Language**. A language chosen on a device itself (under **This device**) still comes first.
- **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. - **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.
- **Guests:** invites for people without a Home Assistant login (see below).
- **Haven app:** a QR code to download Haven on a phone, the address to enter in it, and how signing in works. - **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. - **Activity:** who changed which dashboard, and when, with a shortcut to its versions.
- **About:** the version, what's new and the source code. - **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. Screens pick up what an admin changes, a dashboard or who sees what, within a minute, without reloading.
## Guest invites
An invite is a link and a QR code that opens one dashboard on a guest's phone, without a Home Assistant login, until the time you choose: four hours, tonight, 24 hours, until after the weekend, or a week. Guests can only see and control what is on that dashboard; Dashboard Studio filters everything else out. Invites only work on your home Wi-Fi.
1. Turn guest links on once: in Home Assistant, open **Settings → Apps → Dashboard Studio → Configuration**, and under **Network** give *Guest invite links* a port, for example 8199. Save.
2. Make a dashboard for guests (lights, the thermostat, the TV), or use one you have.
3. Open **Admin → Guests**, fill in who it's for, the dashboard and until when, and choose **Make invite**. Let your guests scan the QR code.
Under **Address for the link**, use the address of Home Assistant on your home network, such as `homeassistant.local` or `192.168.1.20`. **End** stops an invite at once, also on phones that have it open.
## Haven, the Android app ## Haven, the Android app
Haven shows the dashboards and nothing else: no builder and no Admin screen, for admins too. Haven shows the dashboards and nothing else: no builder and no Admin screen, for admins too.
@@ -37,6 +48,11 @@ Haven shows the dashboards and nothing else: no builder and no Admin screen, for
From then on Haven opens straight on your dashboard, full screen with its own icon, and keeps you signed in. It works with plain http addresses too. Kiosk mode keeps the screen on and hides Android's bars. To connect to another Home Assistant: tap the phone button in the top bar (admins) or the kiosk corner five times (everyone else), then **Change address**. Haven needs Android 7 or newer. The earlier *Dashboard Studio* Android app is replaced by Haven; you can uninstall it. From then on Haven opens straight on your dashboard, full screen with its own icon, and keeps you signed in. It works with plain http addresses too. Kiosk mode keeps the screen on and hides Android's bars. To 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.
- **Several homes:** under **Change address**, **Add another home** connects Haven to another Home Assistant, such as a holiday home or your parents'. Each home keeps its own sign-in; tap a home to open it, × removes it.
- **Home screen widgets:** long-press your home screen, choose **Widgets → Haven**, and pick a light, switch, scene or thermostat. A tap does what it does on the dashboard; a thermostat gets − and + for its temperature.
- **Quick settings:** Haven offers three tiles for Android's pull-down menu. Edit the menu, drag a Haven tile in, and tap it to choose what it controls. Long-press a tile to choose again.
- **Updates:** when Dashboard Studio is updated with a newer Haven, Haven offers to install it the next time it opens. Android asks once to allow installs from Haven.
## Building ## Building
1. Open **Dashboard Studio** in the Home Assistant sidebar. 1. Open **Dashboard Studio** in the Home Assistant sidebar.
@@ -65,7 +81,7 @@ More editing tools:
- **Cards for every screen:** each card has a desktop, a tablet and a phone design, applied by itself. On a computer with a mouse, cards are compact with hover states. On a tablet or wall panel (any touch screen, or a screen 641-1100px wide) the type is larger and every button, switch and key is at least 44px, so it can be read and used from across the room. On a phone the same touch sizes sit in a tighter frame, and rows such as a light's colours scroll sideways instead of wrapping. Within that, each card still adapts to its own size: a narrow card puts its figures under the names instead of beside them. - **Cards for every screen:** each card has a desktop, a tablet and a phone design, applied by itself. On a computer with a mouse, cards are compact with hover states. On a tablet or wall panel (any touch screen, or a screen 641-1100px wide) the type is larger and every button, switch and key is at least 44px, so it can be read and used from across the room. On a phone the same touch sizes sit in a tighter frame, and rows such as a light's colours scroll sideways instead of wrapping. Within that, each card still adapts to its own size: a narrow card puts its figures under the names instead of beside them.
- **How cards look:** the answer is the largest thing on each card (the temperature, the battery, *Closed*), in thin, even figures with the unit in grey. Icons are plain outlines that take colour only when something is on or needs attention; status shows as a pale pill; buttons are flat. - **How cards look:** the answer is the largest thing on each card (the temperature, the battery, *Closed*), in thin, even figures with the unit in grey. Icons are plain outlines that take colour only when something is on or needs attention; status shows as a pale pill; buttons are flat.
- **Light and dark versions:** turn on **Switch to a dark or light version with the device** under a theme to use a matching dark version of a light theme when the device is in dark mode, and the other way round. *System* always does this. - **Light and dark versions:** turn on **Switch to a dark or light version with the device** under a theme to use a matching dark version of a light theme when the device is in dark mode, and the other way round. *System* always does this.
- **Theme:** *System* follows your device's light or dark appearance. The other 14 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, and High contrast for maximum readability. Text meets accessibility contrast levels in every theme. Changing any color switches the theme to *Custom*. - **Theme:** *System* follows your device's light or dark appearance. The other 23 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, Midnight (true black, for OLED screens), Espresso, Ember, Lagoon, Paper, Arctic, Moss, Sakura, Concrete, and High contrast for maximum readability. Text meets accessibility contrast levels in every theme. Changing any color switches the theme to *Custom*.
- **Tile style:** *Cards* puts every widget on a tile. *Outlined* draws a thin border instead. *Plain* removes the cards completely, so widgets sit straight on the background. You can also turn off the background for a single widget under **Style → No background**. - **Tile style:** *Cards* puts every widget on a tile. *Outlined* draws a thin border instead. *Plain* removes the cards completely, so widgets sit straight on the background. You can also turn off the background for a single widget under **Style → No background**.
- **Lights that are on** glow: the tile turns warm, or takes on the bulb's color for color lights. In Plain style, the light's icon and brightness bar glow instead. - **Lights that are on** glow: the tile turns warm, or takes on the bulb's color for color lights. In Plain style, the light's icon and brightness bar glow instead.
- **Icons:** widgets pick an icon automatically. To change one, click the icon next to the field to open the icon picker (over 130 icons for rooms, appliances, tools and devices, searchable in English and Dutch), type an icon name, or paste an emoji. - **Icons:** widgets pick an icon automatically. To change one, click the icon next to the field to open the icon picker (over 130 icons for rooms, appliances, tools and devices, searchable in English and Dutch), type an icon name, or paste an emoji.
@@ -172,11 +188,13 @@ For the TeamTracker integration: the next match with both teams, their logos, th
### Formula One ### Formula One
For the F1 Sensor integration. Pick the *next race* sensor and the card finds the standings and results sensors itself. It shows the next race with a countdown and the times of every session in your own time zone, and tabs for the driver standings, the constructor standings and the last race's results. For the F1 Sensor integration. Pick the *next race* sensor and the card finds the standings and results sensors itself. It has Formula 1's own look: carbon black, F1 red and white, whatever your dashboard's theme (choose **Look → Theme** to use your theme instead). It shows the round, the race in large type, a countdown in days, hours and minutes, the weekend's sessions in your own time zone with the next one marked in red, and tabs for the drivers, the teams and the last race, set like the timing tower: position, the team's colour, the driver's three-letter code and the points.
**The logo:** the official Formula 1 logo is a trademark, so it doesn't come with Dashboard Studio. To show it, put the logo file in Home Assistant's `www` folder (for example as `f1-logo.svg`) and enter `/local/f1-logo.svg` under **Logo image**. Without it, a red mark takes its place.
### Vacuum ### Vacuum
The card grows with its size. On a small card it's a compact row with the status, battery and buttons. On a bigger card, the robot sits in the middle with a battery ring around it, a pulse while it cleans and a bolt while it charges. Below it are tiles for battery, cleaned area, cleaning time and any extra sensors you add (such as filter or brush life), the suction power levels, and labelled buttons to start or pause, send it back to the dock and locate it. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**. The card shows the robot from above next to its dock, placed by what it is doing: on the dock with a green light while it charges, out on the floor with a dotted path behind it and a turning brush while it cleans, on its way back while it returns, and outlined in red when it is stuck. Above it are the state in words and the battery with a bar. Below it: the last clean (area and time), **Maintenance** with a bar for every sensor in percent you add under **More sensors** (filter, main brush, side brush; amber below 15%), the suction power levels, and buttons to start or pause, send it back to the dock and locate it. The smaller the card, the more it leaves out, from the bottom up. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
### Energy ### Energy
+5 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "2.1.0" version: "2.2.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
@@ -18,6 +18,10 @@ panel_title: Dashboard Studio
# Everyone with a login gets it in the sidebar; Dashboard Studio itself decides what each person sees. # Everyone with a login gets it in the sidebar; Dashboard Studio itself decides what each person sees.
panel_admin: false panel_admin: false
homeassistant_api: true homeassistant_api: true
ports:
8199/tcp: null
ports_description:
8199/tcp: Guest invite links (answers the local network only)
options: options:
immich_url: "" immich_url: ""
immich_api_key: "" immich_api_key: ""
+305 -26
View File
@@ -9,10 +9,12 @@ Assistant with a handful of fake entities.
""" """
import asyncio import asyncio
import ipaddress
import json import json
import logging import logging
import os import os
import re import re
import secrets
import time import time
from pathlib import Path from pathlib import Path
@@ -187,11 +189,12 @@ async def ha_call(app, msg):
USERS_TTL = 60 USERS_TTL = 60
USERS_CACHE = {}
async def ha_users(app): async def ha_users(app):
"""Home Assistant's people (not its system users), with whether each is an administrator. Cached for a minute.""" """Home Assistant's people (not its system users), with whether each is an administrator. Cached for a minute."""
cached = app.get("users_cache") cached = USERS_CACHE.get("users")
if cached and time.time() - cached[0] < USERS_TTL: if cached and time.time() - cached[0] < USERS_TTL:
return cached[1] return cached[1]
rows = MockHA.USERS_LIST() if MOCK else await ha_call(app, {"type": "config/auth/list"}) rows = MockHA.USERS_LIST() if MOCK else await ha_call(app, {"type": "config/auth/list"})
@@ -199,7 +202,7 @@ async def ha_users(app):
"admin": bool(u.get("is_owner")) or "system-admin" in (u.get("group_ids") or []), "admin": bool(u.get("is_owner")) or "system-admin" in (u.get("group_ids") or []),
"active": u.get("is_active", True)} "active": u.get("is_active", True)}
for u in rows if not u.get("system_generated")} for u in rows if not u.get("system_generated")}
app["users_cache"] = (time.time(), users) USERS_CACHE["users"] = (time.time(), users)
return users return users
@@ -349,6 +352,11 @@ 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 haven_version(request):
"""Which Haven this Dashboard Studio serves, so Haven can offer itself the update."""
return web.FileResponse(WEB_DIR / "haven.json", headers={"Content-Type": "application/json", "Cache-Control": "no-store"})
async def haven_app(request): async def haven_app(request):
"""Haven, the Android app for dashboards (built from haven/ 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 / "haven.apk", headers={ return web.FileResponse(WEB_DIR / "haven.apk", headers={
@@ -384,6 +392,7 @@ async def get_dashboards(request):
"dashboards": [d for d in store.get("dashboards", []) if d.get("id") in ids], "dashboards": [d for d in store.get("dashboards", []) if d.get("id") in ids],
"assign": {"devices": {request.query["device"]: dev} if dev else {}, "users": {}}, "assign": {"devices": {request.query["device"]: dev} if dev else {}, "users": {}},
"access": {"me": access_of(store, me.get("id"))}, "access": {"me": access_of(store, me.get("id"))},
"look": (store.get("looks") or {}).get(me.get("id")) or {},
"role": "basic", "role": "basic",
}, headers={"Cache-Control": "no-store"}) }, headers={"Cache-Control": "no-store"})
@@ -450,12 +459,15 @@ async def activity(request):
async def camera(request): async def camera(request):
entity_id = request.match_info["entity_id"] return await camera_image(request.app, request.match_info["entity_id"])
async def camera_image(app, entity_id):
if not entity_id.startswith("camera.") or "/" in entity_id: if not entity_id.startswith("camera.") or "/" in entity_id:
return web.Response(status=400) return web.Response(status=400)
if MOCK: if MOCK:
return web.Response(body=request.app["mock"].camera_svg(entity_id), content_type="image/svg+xml") return web.Response(body=app["mock"].camera_svg(entity_id), content_type="image/svg+xml")
session = request.app["http"] session = app["http"]
async with session.get( async with session.get(
f"{HA_API_URL}/camera_proxy/{entity_id}", f"{HA_API_URL}/camera_proxy/{entity_id}",
headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"}, headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"},
@@ -495,28 +507,39 @@ def denied(req):
"error": {"code": "unauthorized", "message": "Only Home Assistant administrators can do this"}} "error": {"code": "unauthorized", "message": "Only Home Assistant administrators can do this"}}
async def websocket(request): class GuardedSocket:
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0) """The browser's socket as the mock Home Assistant sees it, with an outbound filter in between."""
await client.prepare(request) def __init__(self, ws, shape):
self.ws, self.shape = ws, shape
allow = None if await role_of(request) == "admin" else allowed_for_basic def __aiter__(self):
return self.ws.__aiter__()
async def send_json(self, data):
data = self.shape(data)
if data is not None:
await self.ws.send_json(data)
def __getattr__(self, name):
return getattr(self.ws, name)
async def serve_ha(app, client, allow=None, shape=None):
"""Connect a browser socket to Home Assistant. `allow` vets what the browser asks; `shape` filters what comes back."""
if MOCK: if MOCK:
await request.app["mock"].serve(client, allow) await app["mock"].serve(GuardedSocket(client, shape) if shape else client, allow)
return client return
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."})
await client.close() await client.close()
return client return
session = request.app["http"]
try: try:
upstream = await session.ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0) upstream = await app["http"].ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0)
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
LOG.error("Cannot reach Home Assistant websocket: %s", err) LOG.error("Cannot reach Home Assistant websocket: %s", err)
await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"}) await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"})
await client.close() await client.close()
return client return
try: try:
msg = await upstream.receive_json() # auth_required msg = await upstream.receive_json() # auth_required
await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN}) await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN})
@@ -524,34 +547,275 @@ async def websocket(request):
if msg.get("type") != "auth_ok": if msg.get("type") != "auth_ok":
LOG.error("Home Assistant rejected the Supervisor token: %s", msg) LOG.error("Home Assistant rejected the Supervisor token: %s", msg)
await client.send_json({"type": "error", "message": "Home Assistant auth failed"}) await client.send_json({"type": "error", "message": "Home Assistant auth failed"})
return client return
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, check=None): async def inbound():
async for m in src: async for m in client:
if m.type == aiohttp.WSMsgType.TEXT: if m.type == aiohttp.WSMsgType.TEXT:
if check: if allow:
try: try:
req = json.loads(m.data) req = json.loads(m.data)
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
if not isinstance(req, dict) or not check(req): if not isinstance(req, dict) or not allow(req):
await client.send_json(denied(req if isinstance(req, dict) else {})) await client.send_json(denied(req if isinstance(req, dict) else {}))
continue continue
await dst.send_str(m.data) await upstream.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, allow)), asyncio.create_task(pump(upstream, client))] async def outbound():
async for m in upstream:
if m.type == aiohttp.WSMsgType.TEXT:
if shape:
out = shape(json.loads(m.data))
if out is not None:
await client.send_json(out)
else:
await client.send_str(m.data)
elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
break
tasks = [asyncio.create_task(inbound()), asyncio.create_task(outbound())]
_, 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()
finally: finally:
await upstream.close() await upstream.close()
await client.close() await client.close()
async def websocket(request):
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
await client.prepare(request)
await serve_ha(request.app, client, None if await role_of(request) == "admin" else allowed_for_basic)
return client return client
# ---------------------------------------------------------------- guest links
# An admin makes an invite: a link with a code to one dashboard, until a set time. It is served on a second
# port that only answers the local network, and it shows that dashboard in kiosk mode. A guest can read and
# control only the entities on that dashboard: everything else is filtered out in both directions.
INVITES_FILE = DATA_DIR / "invites.json"
GUEST_PORT = int(os.environ.get("GUEST_PORT", "8199"))
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
def load_invites():
now = time.time()
return {k: v for k, v in load_json(INVITES_FILE, {}).items() if v.get("until", 0) > now - 7 * 86400} # ended ones linger a week
def entities_in(obj, found=None):
"""Every entity ID (and service name) mentioned anywhere in a dashboard."""
found = set() if found is None else found
if isinstance(obj, dict):
for v in obj.values():
entities_in(v, found)
elif isinstance(obj, list):
for v in obj:
entities_in(v, found)
elif isinstance(obj, str) and ENTITY_ID.match(obj):
found.add(obj)
return found
def guest_policy(allowed):
"""What a guest may ask Home Assistant, and what it may hear back."""
asked = {}
def ids_of(req):
out = []
for src in (req.get("target") or {}, req.get("service_data") or {}):
v = src.get("entity_id")
out += [v] if isinstance(v, str) else list(v or [])
return out
def allow(req):
t = req.get("type")
asked[req.get("id")] = t
if t in ("get_states", "get_config", "ping", "unsubscribe_events", "config/area_registry/list", "config/entity_registry/list", "config/device_registry/list"):
return True
if t == "subscribe_events":
return req.get("event_type") in ("state_changed", "dashboard_studio_announce")
if t == "call_service":
ids = ids_of(req)
if req.get("domain") in BLOCKED_DOMAINS or req.get("domain") == "homeassistant" and req.get("service") not in ("toggle", "turn_on", "turn_off"):
return False
return all(i in allowed for i in ids) if ids else f"{req.get('domain')}.{req.get('service')}" in allowed
if t == "todo/item/list":
return req.get("entity_id") in allowed
if t == "history/history_during_period":
return all(i in allowed for i in req.get("entity_ids") or [])
if t == "recorder/statistics_during_period":
return all(i in allowed for i in req.get("statistic_ids") or [])
return False
def shape(msg):
if not isinstance(msg, dict):
return msg
kind = asked.get(msg.get("id"))
if msg.get("type") == "result" and isinstance(msg.get("result"), list):
if kind == "get_states":
msg["result"] = [x for x in msg["result"] if x.get("entity_id") in allowed]
elif kind == "config/entity_registry/list":
msg["result"] = [x for x in msg["result"] if x.get("entity_id") in allowed]
if msg.get("type") == "event":
ev = msg.get("event") or {}
if ev.get("event_type") == "state_changed" and (ev.get("data") or {}).get("entity_id") not in allowed:
return None
return msg
return allow, shape
def lan_only(handler):
"""Guest links answer the local network only."""
async def wrapped(request):
try:
ip = ipaddress.ip_address((request.remote or "").split("%")[0])
except ValueError:
return web.Response(status=403, text="Guest links work on the local network only")
if not (ip.is_private or ip.is_loopback or ip.is_link_local):
return web.Response(status=403, text="Guest links work on the local network only")
return await handler(request)
return wrapped
ENDED_PAGE = """<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Invite ended</title><body style="margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee;font:17px/1.5 system-ui,sans-serif;text-align:center;padding:24px">
<div><h1 style="font-size:24px;margin:0 0 8px">This invite has ended</h1><p style="margin:0;color:#aaa">Ask your host for a new one.</p></div>"""
def invite_of(request):
inv = load_invites().get(request.match_info.get("token", ""))
if not inv or inv.get("until", 0) < time.time():
return None, None
dash = next((d for d in load_store().get("dashboards", []) if d.get("id") == inv.get("dashboard")), None)
return (inv, dash) if dash else (None, None)
@lan_only
async def guest_page(request):
inv, _ = invite_of(request)
if not inv:
return web.Response(text=ENDED_PAGE, content_type="text/html", status=410)
if not request.path.endswith("/"):
raise web.HTTPFound(request.path + "/")
return await index(request)
@lan_only
async def guest_api(request):
inv, dash = invite_of(request)
if not inv:
return web.json_response({"error": "This invite has ended"}, status=410)
what = request.match_info["what"]
if what == "whoami":
return web.json_response({"id": "guest", "name": "guest", "display": inv.get("name", ""), "admin": False, "guest": True, "until": inv["until"]})
if what == "dashboards":
return web.json_response({"version": 1, "dashboards": [dash], "assign": {"devices": {}, "users": {}},
"access": {"me": {"all": False, "dashboards": [dash["id"]], "home": dash["id"]}}, "role": "guest", "look": {}},
headers={"Cache-Control": "no-store"})
if what == "version":
return web.json_response({"version": VERSION})
if what == "devices/seen":
return web.json_response({"ok": True})
return web.Response(status=404)
@lan_only
async def guest_camera(request):
inv, dash = invite_of(request)
if not inv or request.match_info["entity_id"] not in entities_in(dash):
return web.Response(status=403)
return await camera_image(request.app["main"], request.match_info["entity_id"])
@lan_only
async def guest_static(request):
inv, _ = invite_of(request)
path = (WEB_DIR / request.match_info["path"]).resolve()
if not inv or WEB_DIR.resolve() not in path.parents or not path.is_file():
return web.Response(status=404)
return web.FileResponse(path, headers={"Cache-Control": "no-cache"})
@lan_only
async def guest_socket(request):
inv, dash = invite_of(request)
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
await client.prepare(request)
if not inv:
await client.close()
return client
allow, shape = guest_policy(entities_in(dash))
# the connection ends when the invite does
ender = asyncio.get_event_loop().call_later(max(1, inv["until"] - time.time()), lambda: asyncio.ensure_future(client.close()))
try:
await serve_ha(request.app["main"], client, allow, shape)
finally:
ender.cancel()
return client
def make_guest_app(main):
app = web.Application()
app["main"] = main
app.router.add_get("/g/{token}", guest_page)
app.router.add_get("/g/{token}/", guest_page)
app.router.add_get("/g/{token}/static/{path:.+}", guest_static)
app.router.add_get("/g/{token}/api/camera/{entity_id}", guest_camera)
app.router.add_get("/g/{token}/ws", guest_socket)
app.router.add_route("*", "/g/{token}/api/{what:.+}", guest_api)
return app
@admin_only
async def list_invites(request):
names = {d.get("id"): d.get("name", "") for d in load_store().get("dashboards", [])}
port = await guest_port(request.app)
rows = [{"token": k, **v, "dashboardName": names.get(v.get("dashboard"), ""), "ended": v.get("until", 0) < time.time()}
for k, v in sorted(load_invites().items(), key=lambda kv: -kv[1].get("created", 0))]
return web.json_response({"invites": rows, "port": port}, headers={"Cache-Control": "no-store"})
@admin_only
async def create_invite(request):
body = await request.json()
hours = float(body.get("hours") or 0)
until = float(body.get("until") or 0) or time.time() + hours * 3600
if not body.get("dashboard") or until <= time.time() or until > time.time() + 31 * 86400:
return web.json_response({"error": "Choose a dashboard and an end within a month"}, status=400)
invites = load_invites()
token = secrets.token_urlsafe(12)
invites[token] = {"dashboard": str(body["dashboard"]), "name": str(body.get("name") or "")[:60], "until": until,
"created": time.time(), "by": (who(request) or {}).get("display", "")}
save_json(INVITES_FILE, invites)
return web.json_response({"token": token, **invites[token]})
@admin_only
async def delete_invite(request):
invites = load_invites()
invites.pop(request.match_info["token"], None)
save_json(INVITES_FILE, invites)
return web.json_response({"ok": True})
async def guest_port(app):
"""The host port the guest links are reachable on, from the app's Network settings (None while off)."""
if MOCK:
return GUEST_PORT
try:
async with app["http"].get("http://supervisor/addons/self/info", headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"}) as r:
net = ((await r.json()).get("data") or {}).get("network") or {}
return net.get(f"{GUEST_PORT}/tcp")
except Exception: # noqa: BLE001
return None
async def on_startup(app): async def on_startup(app):
app["http"] = aiohttp.ClientSession() app["http"] = aiohttp.ClientSession()
await app["immich"].start() await app["immich"].start()
@@ -575,6 +839,7 @@ def make_app():
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("/haven.apk", haven_app) app.router.add_get("/haven.apk", haven_app)
app.router.add_get("/haven.json", haven_version)
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/people", people) app.router.add_get("/api/people", people)
@@ -582,6 +847,9 @@ def make_app():
app.router.add_get("/api/devices", get_devices) app.router.add_get("/api/devices", get_devices)
app.router.add_delete("/api/devices/{device}", forget_device) app.router.add_delete("/api/devices/{device}", forget_device)
app.router.add_get("/api/activity", activity) app.router.add_get("/api/activity", activity)
app.router.add_get("/api/invites", list_invites)
app.router.add_post("/api/invites", create_invite)
app.router.add_delete("/api/invites/{token}", delete_invite)
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)
@@ -599,5 +867,16 @@ def make_app():
if __name__ == "__main__": if __name__ == "__main__":
if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN: if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN:
LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant") LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant")
LOG.info("Dashboard Studio %s listening on port %s (data in %s)", VERSION, PORT, DATA_DIR) LOG.info("Dashboard Studio %s listening on port %s, guest links on %s (data in %s)", VERSION, PORT, GUEST_PORT, DATA_DIR)
web.run_app(make_app(), port=PORT, access_log=None)
async def main():
app = make_app()
runner = web.AppRunner(app, access_log=None)
await runner.setup()
await web.TCPSite(runner, port=PORT).start()
guest = web.AppRunner(make_guest_app(app), access_log=None)
await guest.setup()
await web.TCPSite(guest, port=GUEST_PORT).start()
await asyncio.Event().wait()
asyncio.run(main())
+2
View File
@@ -56,6 +56,8 @@ class MockHA:
entity("sensor.robot_vacuum_cleaned_area", "23", friendly_name="Robot Vacuum Cleaned area", unit_of_measurement="m²"), entity("sensor.robot_vacuum_cleaned_area", "23", friendly_name="Robot Vacuum Cleaned area", unit_of_measurement="m²"),
entity("sensor.robot_vacuum_cleaning_time", "34", friendly_name="Robot Vacuum Cleaning time", unit_of_measurement="min", device_class="duration"), entity("sensor.robot_vacuum_cleaning_time", "34", friendly_name="Robot Vacuum Cleaning time", unit_of_measurement="min", device_class="duration"),
entity("sensor.robot_vacuum_filter_left", "72", friendly_name="Robot Vacuum Filter left", unit_of_measurement="%"), entity("sensor.robot_vacuum_filter_left", "72", friendly_name="Robot Vacuum Filter left", unit_of_measurement="%"),
entity("sensor.robot_vacuum_main_brush_left", "41", friendly_name="Robot Vacuum Main brush left", unit_of_measurement="%"),
entity("sensor.robot_vacuum_side_brush_left", "9", friendly_name="Robot Vacuum Side brush left", unit_of_measurement="%"),
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"), entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
entity("fan.office", "off", friendly_name="Office Fan"), entity("fan.office", "off", friendly_name="Office Fan"),
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"), entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
+113 -13
View File
@@ -187,17 +187,26 @@
Light: { bg: "#f2f2f7", tile: "#ffffff", text: "#1d1d1f", text2: "#6e6e73", accent: "#0066cc", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", shadow: true, blur: 0 }, Light: { bg: "#f2f2f7", tile: "#ffffff", text: "#1d1d1f", text2: "#6e6e73", accent: "#0066cc", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", shadow: true, blur: 0 },
Dark: { bg: "#0f0f11", tile: "#1c1c1e", text: "#f5f5f7", text2: "#a1a1a6", accent: "#0a84ff", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", shadow: false, blur: 0 }, Dark: { bg: "#0f0f11", tile: "#1c1c1e", text: "#f5f5f7", text2: "#a1a1a6", accent: "#0a84ff", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", shadow: false, blur: 0 },
Nord: { bg: "#2e3440", tile: "#3b4252", text: "#eceff4", text2: "#c2c9d6", accent: "#88c0d0", separator: "rgba(216,222,233,0.14)", fill: "rgba(216,222,233,0.12)", lit: "#f3e3bf", shadow: false, blur: 0 }, Nord: { bg: "#2e3440", tile: "#3b4252", text: "#eceff4", text2: "#c2c9d6", accent: "#88c0d0", separator: "rgba(216,222,233,0.14)", fill: "rgba(216,222,233,0.12)", lit: "#f3e3bf", shadow: false, blur: 0 },
Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 }, Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 }, // taste-skill-ignore: the Dusk theme's own background, chosen by the user
Forest: { bg: "#0e1a14", tile: "#17271e", text: "#e6f2ea", text2: "#a9c2b2", accent: "#5fd38d", onAccent: "#0b1f14", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 }, Forest: { bg: "#0e1a14", tile: "#17271e", text: "#e6f2ea", text2: "#a9c2b2", accent: "#5fd38d", onAccent: "#0b1f14", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 },
// Text on tiles is 5.3:1 or better in every theme; bright accents get dark text (onAccent). // Text on tiles is 5.3:1 or better in every theme; bright accents get dark text (onAccent).
Graphite: { bg: "#18181b", tile: "#27272a", text: "#fafafa", text2: "#a1a1aa", accent: "#f59e0b", onAccent: "#1c1204", separator: "rgba(250,250,250,0.1)", fill: "rgba(250,250,250,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Graphite: { bg: "#18181b", tile: "#27272a", text: "#fafafa", text2: "#a1a1aa", accent: "#f59e0b", onAccent: "#1c1204", separator: "rgba(250,250,250,0.1)", fill: "rgba(250,250,250,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Indigo: { bg: "#0b1020", tile: "#151c33", text: "#eef1ff", text2: "#a7b0d6", accent: "#8aa4ff", onAccent: "#0b1020", separator: "rgba(238,241,255,0.1)", fill: "rgba(238,241,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Indigo: { bg: "#0b1020", tile: "#151c33", text: "#eef1ff", text2: "#a7b0d6", accent: "#8aa4ff", onAccent: "#0b1020", separator: "rgba(238,241,255,0.1)", fill: "rgba(238,241,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Rose: { bg: "#1d1216", tile: "#2c1d23", text: "#fdf2f5", text2: "#d4b3bf", accent: "#ff7aa6", onAccent: "#2a0a15", separator: "rgba(253,242,245,0.1)", fill: "rgba(253,242,245,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Rose: { bg: "#1d1216", tile: "#2c1d23", text: "#fdf2f5", text2: "#d4b3bf", accent: "#ff7aa6", onAccent: "#2a0a15", separator: "rgba(253,242,245,0.1)", fill: "rgba(253,242,245,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 }, Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 }, // taste-skill-ignore: the Ocean theme's own background, chosen by the user
Sunrise: { bg: "linear-gradient(160deg, #fff4e6 0%, #ffe1e8 100%)", tile: "#ffffff", text: "#2b1d14", text2: "#7a5640", accent: "#c2410c", separator: "rgba(43,29,20,0.1)", fill: "rgba(43,29,20,0.07)", lit: "#ffe0a3", shadow: true, blur: 0 }, Sunrise: { bg: "linear-gradient(160deg, #fff4e6 0%, #ffe1e8 100%)", tile: "#ffffff", text: "#2b1d14", text2: "#7a5640", accent: "#c2410c", separator: "rgba(43,29,20,0.1)", fill: "rgba(43,29,20,0.07)", lit: "#ffe0a3", shadow: true, blur: 0 }, // taste-skill-ignore: the Sunrise theme's own background, chosen by the user
Lavender: { bg: "#f3f0fa", tile: "#ffffff", text: "#231a3a", text2: "#675b84", accent: "#5b3fc4", separator: "rgba(35,26,58,0.1)", fill: "rgba(35,26,58,0.07)", lit: "#ffe9c2", shadow: true, blur: 0 }, Lavender: { bg: "#f3f0fa", tile: "#ffffff", text: "#231a3a", text2: "#675b84", accent: "#5b3fc4", separator: "rgba(35,26,58,0.1)", fill: "rgba(35,26,58,0.07)", lit: "#ffe9c2", shadow: true, blur: 0 },
Mint: { bg: "#eaf6f0", tile: "#ffffff", text: "#10291d", text2: "#4a6657", accent: "#0b7a53", separator: "rgba(16,41,29,0.1)", fill: "rgba(16,41,29,0.07)", lit: "#ffeec2", shadow: true, blur: 0 }, Mint: { bg: "#eaf6f0", tile: "#ffffff", text: "#10291d", text2: "#4a6657", accent: "#0b7a53", separator: "rgba(16,41,29,0.1)", fill: "rgba(16,41,29,0.07)", lit: "#ffeec2", shadow: true, blur: 0 },
Sand: { bg: "#f3ebdf", tile: "#fffaf2", text: "#2d261d", text2: "#6b5e4c", accent: "#9a5416", separator: "rgba(45,38,29,0.1)", fill: "rgba(45,38,29,0.07)", lit: "#ffe2a8", shadow: true, blur: 0 }, Sand: { bg: "#f3ebdf", tile: "#fffaf2", text: "#2d261d", text2: "#6b5e4c", accent: "#9a5416", separator: "rgba(45,38,29,0.1)", fill: "rgba(45,38,29,0.07)", lit: "#ffe2a8", shadow: true, blur: 0 },
Midnight: { bg: "#000000", tile: "#0f0f10", text: "#f2f2f2", text2: "#a3a3a8", accent: "#4da3ff", onAccent: "#001b33", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Espresso: { bg: "#1a1411", tile: "#261d18", text: "#f3ebe4", text2: "#c0ada0", accent: "#e0a35a", onAccent: "#24170b", separator: "rgba(243,235,228,0.12)", fill: "rgba(243,235,228,0.1)", lit: "#ffd9a0", shadow: false, blur: 0 },
Ember: { bg: "#151515", tile: "#212121", text: "#f5f5f5", text2: "#aaaaaa", accent: "#ff7a45", onAccent: "#2a0e02", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffd9a8", shadow: false, blur: 0 },
Lagoon: { bg: "#0c1a1c", tile: "#142729", text: "#e8f5f4", text2: "#9fbcb9", accent: "#3cc9b8", onAccent: "#04201d", separator: "rgba(232,245,244,0.12)", fill: "rgba(232,245,244,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Paper: { bg: "#f4f1ea", tile: "#fbfaf6", text: "#22201c", text2: "#5f5a52", accent: "#1f5f8b", separator: "rgba(34,32,28,0.12)", fill: "rgba(34,32,28,0.07)", lit: "#ffe6b0", shadow: false, blur: 0 },
Arctic: { bg: "#eef3f8", tile: "#ffffff", text: "#10202e", text2: "#4a5d6e", accent: "#0b6bcb", separator: "rgba(16,32,46,0.1)", fill: "rgba(16,32,46,0.06)", lit: "#ffecc4", shadow: true, blur: 0 },
Moss: { bg: "#e9ede4", tile: "#f7f9f4", text: "#1d2619", text2: "#4f5c48", accent: "#3f6f2a", separator: "rgba(29,38,25,0.12)", fill: "rgba(29,38,25,0.07)", lit: "#f6e7b4", shadow: false, blur: 0 },
Sakura: { bg: "#fbf0f2", tile: "#ffffff", text: "#2a1a1f", text2: "#6e5560", accent: "#c2185b", separator: "rgba(42,26,31,0.1)", fill: "rgba(42,26,31,0.06)", lit: "#ffe5c4", shadow: true, blur: 0 },
Concrete: { bg: "#e4e4e2", tile: "#f2f2f0", text: "#1c1c1b", text2: "#555553", accent: "#2b2b2a", onAccent: "#ffffff", separator: "rgba(28,28,27,0.12)", fill: "rgba(28,28,27,0.07)", lit: "#ffe9b8", shadow: false, blur: 0 },
"High contrast": { bg: "#000000", tile: "#141414", text: "#ffffff", text2: "#ffffff", accent: "#ffd60a", onAccent: "#000000", separator: "rgba(255,255,255,0.7)", fill: "rgba(255,255,255,0.2)", lit: "#ffd60a", shadow: false, blur: 0, outline: true }, "High contrast": { bg: "#000000", tile: "#141414", text: "#ffffff", text2: "#ffffff", accent: "#ffd60a", onAccent: "#000000", separator: "rgba(255,255,255,0.7)", fill: "rgba(255,255,255,0.2)", lit: "#ffd60a", shadow: false, blur: 0, outline: true },
}; };
@@ -676,6 +685,8 @@
let previewAs = null; // Admin → People → Preview: { id, name, ids } let previewAs = null; // Admin → People → Preview: { id, name, ids }
let lockedKiosk = false; // people who aren't admins can't leave kiosk mode 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). */ /** What a person may see: { all: true } or { dashboards: [...] }, plus the one that opens first (home). */
/** A person's own look, chosen by an admin: a theme, a text size, a language. */
const lookOf = () => (previewAs ? store.looks?.[previewAs.id] : isAdmin() ? store.looks?.[me?.id] : store.look) || {};
const accessFor = (uid) => store.access?.users?.[uid] || store.access?.everyone || { all: true }; const 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); }; 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;
@@ -821,7 +832,10 @@
} }
function applyTheme() { function applyTheme() {
const t = themeVariant(nightDark ? { ...current.theme, ...PRESETS.Dark, preset: "Dark" } : current.theme), s = stageEl.style; const own = lookOf().theme, base = own && PRESETS[own] ? { ...current.theme, ...PRESETS[own], preset: own, bgImage: "" } : current.theme;
const t = themeVariant(nightDark ? { ...base, ...PRESETS.Dark, preset: "Dark" } : base), s = stageEl.style;
document.body.classList.toggle("text-large", lookOf().scale === "large");
document.body.classList.toggle("text-xl", lookOf().scale === "xl");
stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain"); stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain");
stageEl.classList.add(`surface-${t.surface || "cards"}`); stageEl.classList.add(`surface-${t.surface || "cards"}`);
for (const k of COLOR_KEYS) { for (const k of COLOR_KEYS) {
@@ -1318,7 +1332,7 @@
</div> </div>
<div class="section"><div class="section-title">${tr("Appearance")}</div> <div class="section"><div class="section-title">${tr("Appearance")}</div>
<div class="presets" role="group" aria-label="${tr("Theme")}">${Object.entries(PRESETS).map(([n, p]) => { <div class="presets" role="group" aria-label="${tr("Theme")}">${Object.entries(PRESETS).map(([n, p]) => {
const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg; const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg; // taste-skill-ignore: the System swatch: half light, half dark, with a hard edge
const fg = p.swatch ? "#6e6e73" : p.text; const fg = p.swatch ? "#6e6e73" : p.text;
return `<button class="preset" data-preset="${n}" aria-pressed="${t.preset === n}" style="background:${bg};color:${fg}"><i style="background:${p.accent || "#0a84ff"}"></i><span>${tr(n)}</span></button>`; return `<button class="preset" data-preset="${n}" aria-pressed="${t.preset === n}" style="background:${bg};color:${fg}"><i style="background:${p.accent || "#0a84ff"}"></i><span>${tr(n)}</span></button>`;
}).join("")}</div> }).join("")}</div>
@@ -2519,8 +2533,8 @@
// People who aren't admins stay in kiosk mode: the corner switches between the dashboards they may see. // 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 others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
const choice = await openModal(lockedKiosk 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>`} ? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>` <div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p> : `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`, <div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); }); (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
@@ -2977,6 +2991,14 @@
scheduleSave(); scheduleSave();
} }
/** A language an admin chose for this person; this device's own choice still comes first. */
function syncPersonLang() {
const want = lookOf().lang || "";
if ((lsGet("ds.personLang") || "") === want) return;
lsSet("ds.personLang", want);
if (window.I18n.preference() === "auto" && (want || window.I18n.lang) !== window.I18n.lang) location.reload();
}
/** People who aren't admins, with no dashboard chosen for them yet. */ /** People who aren't admins, with no dashboard chosen for them yet. */
function showEmpty() { function showEmpty() {
const el = $("#empty-note"); const el = $("#empty-note");
@@ -3283,10 +3305,10 @@
// Dashboards, the people who use them and what each may see, devices, Haven, and who changed what. // Dashboards, the people who use them and what each may see, devices, Haven, and who changed what.
const ADMIN_SECTIONS = [ const ADMIN_SECTIONS = [
["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"], ["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"],
["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"], ["guests", "qr", "Guests"], ["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"],
]; ];
let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards"; let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards";
let peopleList = null, devicesSeen = null; let peopleList = null, devicesSeen = null, lastInvite = null;
const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never")); const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never"));
const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase()); const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase());
async function loadPeople(force) { async function loadPeople(force) {
@@ -3339,6 +3361,14 @@
${(own || key === "everyone") && !entry.all ? `<div class="adm-chips">${dashChecks(key, entry)}</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>` : ""} ${own || key === "everyone" ? `<label class="field"><span>${tr("Opens first")}</span>${homeSel(key, entry)}</label>` : ""}
</div>`; </div>`;
const lookBlock = (uid) => {
const l = store.looks?.[uid] || {};
const sel = (k, opts, label) => `<label class="field"><span>${tr(label)}</span><select class="input" data-k="look-${k}" data-who="${esc(uid)}">${opts.map(([v, n]) => `<option value="${v}" ${(l[k] || "") === v ? "selected" : ""}>${esc(tr(n))}</option>`).join("")}</select></label>`;
return `<div class="p-look">
${sel("theme", [["", "The dashboard’s theme"], ...Object.keys(PRESETS).filter((n) => n !== "System").map((n) => [n, n])], "Theme")}
${sel("scale", [["", "Normal"], ["large", "Large"], ["xl", "Extra large"]], "Text")}
${sel("lang", [["", "Automatic"], ["en", "English"], ["nl", "Nederlands"]], "Language")}</div>`;
};
const rows = list.map((p) => { const rows = list.map((p) => {
const own = acc.users[p.id]; const own = acc.users[p.id];
const entry = own || acc.everyone || { all: true }; const entry = own || acc.everyone || { all: true };
@@ -3348,6 +3378,7 @@
<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> <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 ? "" : `<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)} ${p.admin ? "" : accessBlock(p.id, entry, !!own)}
${lookBlock(p.id)}
</article>`; </article>`;
}).join(""); }).join("");
return `<header class="adm-head"><div><h2>${tr("People")}</h2> return `<header class="adm-head"><div><h2>${tr("People")}</h2>
@@ -3388,6 +3419,36 @@
<button class="btn primary" data-a="announce">${tr("Send")}</button></div></section>`; <button class="btn primary" data-a="announce">${tr("Send")}</button></div></section>`;
}, },
async guests() {
let data;
try { data = await api("api/invites"); } catch (e) { return `<p class="muted">${esc(e.message)}</p>`; }
const host = lsGet("ds.guestHost") || location.hostname;
const link = (tok) => (data.port ? `http://${host}:${data.port}/g/${tok}/` : "");
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
const fresh = data.invites.find((i) => i.token === lastInvite && !i.ended);
const rows = data.invites.map((i) => `<div class="act-row inv-row ${i.ended ? "ended" : ""}" data-token="${esc(i.token)}"><span class="adm-avatar">${Icons.svg("qr")}</span>
<div class="grow"><div><b>${esc(i.name || tr("Guests"))}</b> · ${esc(i.dashboardName)}</div>
<div class="muted small">${i.ended ? esc(tr("Ended {0}", when(i.until))) : esc(tr("Until {0}", when(i.until)))}${i.by ? ` · ${esc(tr("by {0}", i.by))}` : ""}</div></div>
${i.ended ? "" : `<button class="btn" data-a="inv-show">${tr("QR code")}</button><button class="btn danger" data-a="inv-end">${tr("End")}</button>`}</div>`).join("");
return `<header class="adm-head"><div><h2>${tr("Guests")}</h2>
<p class="muted">${tr("An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.")}</p></div></header>
${data.port ? "" : `<section class="adm-panel notice"><h3>${Icons.svg("wifi")}${tr("Turn on guest links first")}</h3>
<p class="muted small">${tr("In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.")}</p></section>`}
<section class="adm-panel"><h3>${Icons.svg("plus")}${tr("New invite")}</h3>
<div class="inv-form">
<label class="field"><span>${tr("For")}</span><input class="input" id="inv-name" placeholder="${tr("e.g. Babysitter, Grandma and Grandpa")}"></label>
<label class="field"><span>${tr("Dashboard")}</span><select class="input" id="inv-dash">${store.dashboards.map((d) => `<option value="${d.id}">${esc(d.name)}</option>`).join("")}</select></label>
<label class="field"><span>${tr("Works until")}</span><select class="input" id="inv-for">
<option value="4">${tr("4 hours from now")}</option><option value="tonight">${tr("Tonight, 23:59")}</option><option value="24" selected>${tr("24 hours from now")}</option>
<option value="weekend">${tr("After the weekend (Monday 10:00)")}</option><option value="168">${tr("A week from now")}</option></select></label>
<button class="btn primary" data-a="inv-new" ${data.port ? "" : "disabled"}>${tr("Make invite")}</button></div>
<label class="field inv-host"><span>${tr("Address for the link")}</span><input class="input" id="inv-host" value="${esc(host)}" aria-describedby="inv-host-hint"></label>
<p class="muted small" id="inv-host-hint">${tr("The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.")}</p>
${fresh ? `<div class="inv-fresh"><div class="share-qr">${window.QR.svg(link(fresh.token), { label: tr("QR code for the invite") })}</div>
<div class="share-row"><input class="input" readonly value="${esc(link(fresh.token))}" id="inv-link"><button class="btn" data-a="inv-copy">${Icons.svg("copy")}${tr("Copy")}</button></div></div>` : ""}</section>
<div class="adm-list" style="margin-top:18px">${rows || `<p class="muted">${tr("No invites yet.")}</p>`}</div>`;
},
async haven() { async haven() {
const addr = location.origin; const addr = location.origin;
return `<header class="adm-head haven-head"><img src="static/haven-icon.svg" alt=""><div><h2>${tr("Haven app")}</h2> return `<header class="adm-head haven-head"><img src="static/haven-icon.svg" alt=""><div><h2>${tr("Haven app")}</h2>
@@ -3521,10 +3582,36 @@
if (act === "copy-addr") { navigator.clipboard?.writeText($("#haven-addr").value).then(() => toast(tr("Copied."))).catch(() => $("#haven-addr").select()); 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 === "history") { setCurrent(b.closest("[data-dash]").dataset.dash); await historyDialog(); return; }
if (act === "whatsnew") whatsNew(); if (act === "whatsnew") whatsNew();
if (act === "inv-new") {
const v = $("#inv-for").value, now = new Date();
let until;
if (v === "tonight") { until = new Date(now); until.setHours(23, 59, 0, 0); }
else if (v === "weekend") { until = new Date(now); until.setDate(now.getDate() + ((8 - now.getDay()) % 7 || 7)); until.setHours(10, 0, 0, 0); }
else until = new Date(now.getTime() + Number(v) * 3600e3);
try {
const r = await fetch(new URL("api/invites", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: $("#inv-name").value.trim(), dashboard: $("#inv-dash").value, until: until.getTime() / 1000 }) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status);
lastInvite = (await r.json()).token;
} catch (err) { toast(tr("Couldn’t make the invite: {0}", err.message), true); return; }
renderAdmin("guests");
return;
}
if (act === "inv-show") { lastInvite = b.closest("[data-token]").dataset.token; renderAdmin("guests"); $("#admin .adm-body").scrollTop = 0; return; }
if (act === "inv-end") {
const tok = b.closest("[data-token]").dataset.token;
if (!(await confirmDestructive(tr("End this invite?"), tr("The link stops working at once, also on phones that have it open."), tr("End")))) return;
await fetch(new URL(`api/invites/${encodeURIComponent(tok)}`, BASE), { method: "DELETE" }).catch(() => {});
if (lastInvite === tok) lastInvite = null;
renderAdmin("guests");
return;
}
if (act === "inv-copy") { navigator.clipboard?.writeText($("#inv-link").value).then(() => toast(tr("Copied."))).catch(() => $("#inv-link").select()); return; }
}); });
$("#admin").addEventListener("change", (e) => { $("#admin").addEventListener("change", (e) => {
const f = e.target, k = f.dataset.k; const f = e.target, k = f.dataset.k;
if (f.id === "inv-host") { lsSet("ds.guestHost", f.value.trim()); renderAdmin("guests"); return; }
if (f.closest("[data-dev]")) { if (f.closest("[data-dev]")) {
const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {}); const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {});
d[k] = f.type === "checkbox" ? f.checked : f.value; d[k] = f.type === "checkbox" ? f.checked : f.value;
@@ -3532,6 +3619,16 @@
scheduleSave(); scheduleSave();
return; return;
} }
if (k?.startsWith("look-")) {
const uid = f.dataset.who, key = k.slice(5);
store.looks ||= {};
const l = { ...(store.looks[uid] || {}) };
if (f.value) l[key] = f.value; else delete l[key];
if (Object.keys(l).length) store.looks[uid] = l; else delete store.looks[uid];
scheduleSave();
if (uid === me?.id) { applyTheme(); syncPersonLang(); }
return;
}
if (k === "dash") { if (k === "dash") {
const boxes = [...f.closest(".adm-chips").querySelectorAll("input:checked")].map((x) => x.value); 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 })); setAccess(f.dataset.who, (cur) => ({ ...cur, all: false, dashboards: boxes, home: boxes.includes(cur.home) ? cur.home : undefined }));
@@ -3588,7 +3685,7 @@
// Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload. // Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload.
let storeKey = ""; let storeKey = "";
const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices]); const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices, s.look]);
async function refreshStore() { async function refreshStore() {
if (editMode || mode === "admin" || savePending || previewAs || modalClose) return; if (editMode || mode === "admin" || savePending || previewAs || modalClose) return;
let next; let next;
@@ -3603,6 +3700,7 @@
const home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home; 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)); const keep = [current?.id, store.assign.devices[DEVICE_ID]?.dashboard, home].find((id) => id && store.dashboards.some((d) => d.id === id));
setCurrent(keep); setCurrent(keep);
syncPersonLang();
} }
setInterval(refreshStore, 60 * 1000); setInterval(refreshStore, 60 * 1000);
ha.onChange = onStateChange; ha.onChange = onStateChange;
@@ -3623,9 +3721,10 @@
// In Haven, a session that couldn't be renewed means signing in again on Home Assistant's page. // 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; } 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" && !location.pathname.startsWith("/g/")) navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
me = await api("api/whoami").catch(() => null); me = await api("api/whoami").catch(() => null);
if (me) { role = me.admin ? "admin" : "basic"; lsSet("ds.role", role); } if (me) { role = me.admin ? "admin" : "basic"; if (!me.guest) lsSet("ds.role", role); }
document.body.classList.toggle("guest", !!me?.guest);
try { try {
const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE)); const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE));
store = await r.json(); store = await r.json();
@@ -3634,7 +3733,7 @@
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. // 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" }, if (!me?.guest) fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device: DEVICE_ID, app: IN_APP ? "haven" : "browser", kind: phoneScreen.matches ? "phone" : matchMedia("(pointer: coarse)").matches ? "tablet" : "computer" }) }).catch(() => {}); 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) => {
@@ -3645,6 +3744,7 @@
ha.connect(); ha.connect();
if (isAdmin()) migrateAccess(); if (isAdmin()) migrateAccess();
syncPersonLang();
storeKey = keyOf(store); storeKey = keyOf(store);
if (!store.dashboards?.length) { if (!store.dashboards?.length) {
if (!isAdmin()) { showEmpty(); return; } if (!isAdmin()) { showEmpty(); return; }
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"version": "2.2.0", "code": 2}
+52
View File
@@ -723,6 +723,56 @@
"Energy now": "Energie nu", "Energy now": "Energie nu",
"No power is flowing right now.": "Er stroomt nu geen vermogen.", "No power is flowing right now.": "Er stroomt nu geen vermogen.",
"Battery {0}%, charging": "Batterij {0}%, laadt", "Battery {0}%, charging": "Batterij {0}%, laadt",
"This invite ends {0}.": "Deze uitnodiging eindigt {0}.",
"Guests": "Gasten",
"Ended {0}": "Afgelopen {0}",
"Until {0}": "Tot {0}",
"QR code": "QR-code",
"End": "Beëindigen",
"An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.": "Een uitnodiging opent één dashboard op de telefoon van een gast, zonder Home Assistant-account, tot het moment dat je kiest. Gasten zien en bedienen alleen wat op dat dashboard staat, en alleen via je wifi thuis.",
"Turn on guest links first": "Zet eerst gastlinks aan",
"In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.": "Open in Home Assistant Instellingen, Apps, Dashboard Studio, Configuratie. Geef onder Netwerk “Guest invite links” een poort, bijvoorbeeld 8199, en sla op. Kom daarna hier terug.",
"New invite": "Nieuwe uitnodiging",
"For": "Voor",
"e.g. Babysitter, Grandma and Grandpa": "bijv. Oppas, Opa en oma",
"Works until": "Werkt tot",
"4 hours from now": "Over 4 uur",
"Tonight, 23:59": "Vanavond, 23:59",
"24 hours from now": "Over 24 uur",
"After the weekend (Monday 10:00)": "Na het weekend (maandag 10:00)",
"A week from now": "Over een week",
"Make invite": "Uitnodiging maken",
"Address for the link": "Adres voor de link",
"The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.": "Het adres van Home Assistant in je thuisnetwerk, zoals homeassistant.local of 192.168.1.20.",
"QR code for the invite": "QR-code voor de uitnodiging",
"No invites yet.": "Nog geen uitnodigingen.",
"Couldn’t make the invite: {0}": "Uitnodiging maken lukte niet: {0}",
"End this invite?": "Deze uitnodiging beëindigen?",
"The link stops working at once, also on phones that have it open.": "De link werkt direct niet meer, ook op telefoons waar hij open staat.",
"The dashboard’s theme": "Thema van het dashboard",
"Normal": "Normaal",
"Large": "Groot",
"Extra large": "Extra groot",
"English": "English",
"Nederlands": "Nederlands",
"Cleaned": "Schoongemaakt",
"Time": "Tijd",
"Last clean": "Laatste schoonmaak",
"days": "dagen",
"hrs": "uur",
"mins": "min",
"Round {0} · {1}": "Ronde {0} · {1}",
"Look": "Uiterlijk",
"Official": "Officieel",
"Logo image (put the official logo in Home Assistant's www folder)": "Logo-afbeelding (zet het officiële logo in de www-map van Home Assistant)",
"Midnight": "Middernacht",
"Espresso": "Espresso",
"Ember": "Gloed",
"Lagoon": "Lagune",
"Arctic": "Arctisch",
"Moss": "Mos",
"Sakura": "Sakura",
"Concrete": "Beton",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
@@ -745,6 +795,8 @@
function detect() { function detect() {
const pref = read("ds.lang"); const pref = read("ds.lang");
if (pref && pref !== "auto") return pref; if (pref && pref !== "auto") return pref;
const person = read("ds.personLang"); // chosen for this person by an admin
if (person) return person;
let ha = read("selectedLanguage"); // Home Assistant stores it as a JSON string let ha = read("selectedLanguage"); // Home Assistant stores it as a JSON string
try { ha = ha && JSON.parse(ha); } catch { /* plain string */ } try { ha = ha && JSON.parse(ha); } catch { /* plain string */ }
return String(ha || navigator.language || "en").toLowerCase().startsWith("nl") ? "nl" : "en"; return String(ha || navigator.language || "en").toLowerCase().startsWith("nl") ? "nl" : "en";
+116 -84
View File
@@ -908,47 +908,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
@container (max-height: 200px) { .room-devices { display: none; } } @container (max-height: 200px) { .room-devices { display: none; } }
/* vacuum: compact on small cards, a robot with stats on bigger ones */ /* vacuum: compact on small cards, a robot with stats on bigger ones */
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; }
.vac-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; }
.vac-batt .i { font-size: 18px; }
.vac-hero { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; gap: 18px; }
.vac-map { width: 100%; height: 100%; object-fit: contain; border-radius: 14px; background: var(--d-fill); }
.vac-robot { position: relative; width: clamp(84px, 32cqh, 150px); aspect-ratio: 1; flex: none; display: grid; place-items: center; }
.vac-ring { position: absolute; inset: 0; transform: rotate(-90deg); }
.vac-ring circle { fill: none; stroke-width: 5; }
.vac-ring .track { stroke: var(--d-fill); }
.vac-ring .fill { stroke: var(--ok); stroke-linecap: round; transition: stroke-dasharray .6s; }
.vac-ring .fill.low { stroke: var(--crit); }
.vac-robot-ico { position: relative; z-index: 1; width: 62%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(30px, 12cqh, 54px); background: var(--d-fill); }
.state-cleaning .vac-robot-ico, .state-returning .vac-robot-ico { background: var(--accent-w); color: var(--d-on-accent, #fff); }
.vac-pulse { position: absolute; inset: 19%; border-radius: 50%; }
.vac-robot.is-cleaning .vac-pulse { animation: vac-pulse 1.8s ease-out infinite; border: 2px solid var(--accent-w); }
@keyframes vac-pulse { from { transform: scale(1); opacity: .7; } to { transform: scale(1.45); opacity: 0; } }
.vac-charge { position: absolute; z-index: 2; right: 8%; bottom: 8%; width: 28%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: clamp(14px, 4cqh, 20px); box-shadow: 0 0 0 3px var(--w-bg, var(--d-tile)); }
.vac-hero-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.vac-headline { font-size: clamp(18px, 6cqh, 26px); font-weight: 700; line-height: 1.15; }
.vac-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 8px; flex: none; }
.vac-stat { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 8px; align-items: center; padding: 10px 12px; border-radius: 14px; background: var(--d-fill); min-width: 0; }
.vac-stat .i { grid-row: span 2; font-size: 18px; color: var(--d-text-2); }
.vac-stat-v { font-size: 17px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-fan { flex: none; background: var(--d-fill); }
.vac-fan button { color: inherit; display: grid; place-items: center; }
.vac-fan button[aria-pressed="true"] { background: var(--d-tile); color: var(--d-text); }
.vac-actions { display: flex; gap: 8px; flex: none; }
.vac-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 12px; border-radius: 14px; background: var(--d-fill); font-size: 14px; font-weight: 600; }
.vac-btn .i { font-size: 18px; flex: none; }
.vac-btn.primary { background: var(--d-text); color: var(--d-tile); }
.vac-btn:disabled { opacity: .4; cursor: default; }
.vac.state-error .state { color: var(--crit); font-weight: 600; }
/* the more room, the more it shows */ /* the more room, the more it shows */
@container (max-width: 330px) { .vac-btn span { display: none; } .vac-hero-text { display: none; } }
@container (max-height: 370px) { .vac-stats { display: none; } }
@container (max-height: 330px) { .vac-fan { display: none; } }
@container (max-height: 250px) { .vac-hero { display: none; } }
@container (min-height: 251px) { .vac-head .vac-batt, .vac-head .state { display: none; } }
@container (min-height: 251px) and (max-width: 330px) { .vac-head .vac-batt { display: inline-flex; } }
/* photos */ /* photos */
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; } .photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
@@ -1210,30 +1170,6 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.sp-extra { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sp-extra { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Formula One */ /* Formula One */
.f1 { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }
.f1-next { padding: 12px; border-radius: 14px; }
.f1-top { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--d-text-2); }
.f1-top .i { color: #e10600; font-size: 15px; }
.f1-top b { margin-left: auto; color: var(--d-text); font-weight: 600; }
.f1-race { font-size: 18px; font-weight: 700; margin-top: 4px; letter-spacing: -0.01em; }
.f1-place { font-size: 12px; color: var(--d-text-2); }
.f1-sessions { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; font-size: 12px; }
.f1-sessions > div { display: flex; justify-content: space-between; gap: 8px; }
.f1-sessions .past { opacity: .5; }
.f1-sessions .next { font-weight: 700; color: var(--d-text); }
.f1-tabs { display: flex; gap: 4px; padding: 3px; border-radius: 10px; background: var(--d-fill); }
.f1-tabs button { flex: 1; padding: 5px 4px; border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--d-text-2); }
.f1-tabs button[aria-selected="true"] { background: var(--d-tile); color: var(--d-text); box-shadow: 0 1px 3px rgba(0,0,0,.12); }
.f1-list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; }
.f1-row { display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--d-separator); }
.f1-row:last-child { border-bottom: 0; }
.f1-pos { font-weight: 700; color: var(--d-text-2); text-align: center; }
.f1-row:first-child .f1-pos { color: #e10600; }
.f1-name { display: flex; flex-direction: column; min-width: 0; }
.f1-name b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.f1-name span { font-size: 11px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.f1-val { font-size: 13px; font-weight: 600; }
@container (max-height: 380px) { .f1-sessions { display: none; } }
/* announcements */ /* announcements */
#announce { position: fixed; inset: 0; z-index: 95; display: grid; place-items: center; padding: 24px; background: rgba(0, 0, 0, .72); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); } #announce { position: fixed; inset: 0; z-index: 95; display: grid; place-items: center; padding: 24px; background: rgba(0, 0, 0, .72); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
@@ -1697,18 +1633,6 @@ body.in-haven #btn-new { display: none; }
.room-dev.on { background: color-mix(in srgb, var(--d-text) 88%, transparent); } .room-dev.on { background: color-mix(in srgb, var(--d-text) 88%, transparent); }
/* vacuum: the state in words, the battery as a bar */ /* vacuum: the state in words, the battery as a bar */
.vac-hero { justify-content: flex-start; align-items: center; }
.vac-hero-text { gap: 8px; width: 100%; }
.vac-headline { font-size: clamp(26px, 9cqh, 40px); line-height: 1.1; }
.vac-meter { display: block; height: 6px; border-radius: 3px; background: var(--d-fill); overflow: hidden; max-width: 320px; }
.vac-meter i { display: block; height: 100%; border-radius: 3px; background: var(--ok); }
.vac-meter.low i { background: var(--crit); }
.vac-stat { padding: 10px 12px; border-radius: var(--c-r); grid-template-columns: 1fr; }
.vac-stat .i { display: none; }
.vac-stat-v { font-size: var(--c-fs-mid); font-weight: 400; }
.vac-stat-l { font-size: var(--c-fs-meta); }
.vac-btn { min-height: var(--c-ctl); border-radius: var(--c-r); font-size: var(--c-fs-body); }
@container (max-width: 330px) { .vac-hero-text { display: flex; } }
/* appliance: a thin progress ring with a plain icon inside */ /* appliance: a thin progress ring with a plain icon inside */
.appl-ring circle { stroke-width: 4; } .appl-ring circle { stroke-width: 4; }
@@ -1783,14 +1707,6 @@ body.in-haven #btn-new { display: none; }
.sp-sub, .sp-extra { font-size: var(--c-fs-meta); } .sp-sub, .sp-extra { font-size: var(--c-fs-meta); }
/* Formula One: the next race on one flat step, no gradient */ /* Formula One: the next race on one flat step, no gradient */
.f1-next { background: var(--d-fill); border-radius: var(--c-r); }
.f1-race { font-weight: 600; font-size: var(--c-fs-mid); }
.f1-place, .f1-top, .f1-sessions { font-size: var(--c-fs-meta); }
.f1-name span { font-size: 12px; }
.f1-name b { font-size: var(--c-fs-body); font-weight: 600; }
.f1-tabs button { font-size: var(--c-fs-meta); min-height: 30px; }
.f1-tabs button[aria-selected="true"] { box-shadow: none; }
.f1-pos { font-weight: 500; }
/* calendar */ /* calendar */
.cal-sum { font-size: var(--c-fs-body); } .cal-sum { font-size: var(--c-fs-body); }
@@ -1928,3 +1844,119 @@ body:not([style*="--night-dim: 0."]):not([style*="--night-dim: 1"]) #night-dim {
.climate .round { width: 38px; height: 38px; } .climate .round { width: 38px; height: 38px; }
} }
.tm-time { font-size: clamp(20px, min(14cqh, 15cqw), 44px); } .tm-time { font-size: clamp(20px, min(14cqh, 15cqw), 44px); }
/* vacuum: the robot beside its dock, the last clean, maintenance */
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; min-height: 0; }
.vac-head { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
.vac-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.vac-title .name { font-size: var(--c-fs-title); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-status { font-size: clamp(22px, 8cqw, 30px); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.vac.state-error .vac-status { color: var(--crit); }
.vac-batt { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; padding-top: 2px; }
.vac-batt-v { font-size: var(--c-fs-mid); line-height: 1; }
.vac-batt-v small { font-size: .6em; color: var(--d-text-2); margin-left: 1px; }
.vac-meter { display: block; width: 64px; height: 5px; border-radius: 3px; background: var(--d-fill); overflow: hidden; }
.vac-meter i { display: block; height: 100%; border-radius: 3px; background: var(--d-text); }
.charging .vac-meter i { background: var(--ok); }
.vac-meter.low i { background: var(--crit); }
.vac-art { flex: 1; min-height: 64px; display: grid; place-items: center; }
.vac-map { width: 100%; height: 100%; object-fit: contain; border-radius: var(--c-r); background: var(--d-fill); }
.vac-scene { width: 100%; height: 100%; max-height: 132px; overflow: visible; }
.vs-dock { fill: color-mix(in srgb, var(--d-text) 12%, var(--w-bg, var(--d-tile))); }
.vs-plate { fill: color-mix(in srgb, var(--d-text) 30%, transparent); }
.vs-led { fill: color-mix(in srgb, var(--d-text) 30%, transparent); }
.vs-led.on { fill: var(--ok); }
.vs-trail { fill: none; stroke: var(--d-accent); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 1 8; opacity: .8; }
.vs-robot { transition: transform .8s cubic-bezier(.3, .7, .3, 1); }
.vs-body { fill: color-mix(in srgb, var(--d-text) 7%, var(--w-bg, var(--d-tile))); stroke: color-mix(in srgb, var(--d-text) 26%, transparent); stroke-width: 2; }
.vs-bumper { fill: none; stroke: color-mix(in srgb, var(--d-text) 45%, transparent); stroke-width: 3; stroke-linecap: round; }
.vs-lidar { fill: color-mix(in srgb, var(--d-text) 20%, var(--w-bg, var(--d-tile))); stroke: color-mix(in srgb, var(--d-text) 35%, transparent); stroke-width: 1.5; }
.vs-brush line { stroke: color-mix(in srgb, var(--d-text) 40%, transparent); stroke-width: 1.5; stroke-linecap: round; }
.vs-robot.busy .vs-brush { animation: vs-spin .5s linear infinite; transform-box: fill-box; transform-origin: center; }
.vs-robot.busy .vs-lidar { stroke: var(--d-accent); }
.vs-robot.err .vs-body { stroke: var(--crit); }
@keyframes vs-spin { to { rotate: 360deg; } }
@media (prefers-reduced-motion: reduce) { .vs-robot.busy .vs-brush { animation: none; } .vs-robot { transition: none; } }
.vac-facts { display: flex; gap: 24px; flex-wrap: wrap; }
.vac-fact { display: flex; flex-direction: column; gap: 1px; }
.vac-fv { font-size: var(--c-fs-mid); line-height: 1.1; }
.vac-fl { font-size: var(--c-fs-meta); color: var(--d-text-2); }
.vac-maint { display: flex; flex-direction: column; gap: 6px; }
.vac-mrow { display: grid; grid-template-columns: minmax(0, 7em) minmax(0, 1fr) 3em; align-items: center; gap: 10px; font-size: var(--c-fs-meta); }
.vac-ml { color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-mbar { height: 4px; border-radius: 2px; background: var(--d-fill); overflow: hidden; }
.vac-mbar i { display: block; height: 100%; background: color-mix(in srgb, var(--d-text) 55%, transparent); }
.vac-mrow.low .vac-mbar i { background: var(--warn); }
.vac-mrow.low .vac-mv { color: var(--warn); }
.vac-mv { text-align: right; }
.vac-fan { flex: none; }
.vac-actions { display: flex; gap: 8px; flex: none; }
.vac-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: var(--c-ctl); padding: 0 12px; border-radius: var(--c-r); background: var(--d-fill); font-size: var(--c-fs-body); font-weight: 600; }
.vac-btn .i { font-size: 18px; flex: none; }
.vac-btn.primary { background: var(--d-text); color: var(--d-tile); }
.vac-btn:disabled { opacity: .4; cursor: default; }
@media (hover: hover) { .vac-btn:not(.primary):not(:disabled):hover { background: color-mix(in srgb, var(--d-text) 14%, transparent); } }
/* the more room, the more it shows */
@container (max-width: 330px) { .vac-btn span { display: none; } }
@container (max-height: 460px) { .vac-maint { display: none; } }
@container (max-height: 380px) { .vac-facts { display: none; } }
@container (max-height: 320px) { .vac-fan { display: none; } }
@container (max-height: 240px) { .vac-art { display: none; } }
@media (max-width: 640px) { .vac-btn span { display: none; } }
/* Formula One: the official look (carbon black, F1 red, white), or the dashboard's theme */
.f1 { display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; --f1-red: #e10600; --f1-ink: var(--d-text); --f1-mute: var(--d-text-2); --f1-step: var(--d-fill); }
.f1.f1-official { margin: calc(-1 * var(--c-pad)); padding: var(--c-pad); height: calc(100% + 2 * var(--c-pad)); border-radius: inherit;
background: #15151e; color: #fff; --f1-ink: #fff; --f1-mute: #b5b5c0; --f1-step: #26262f; --d-separator: #2c2c36; }
.f1-brand { display: flex; align-items: center; gap: 10px; min-height: 22px; }
.f1-logo { height: 20px; width: auto; max-width: 96px; object-fit: contain; }
.f1-mark { width: 30px; height: 8px; border-radius: 2px; background: var(--f1-red); transform: skewX(-24deg); }
.f1-round { font-size: var(--c-fs-meta); color: var(--f1-mute); margin-left: auto; }
.f1-next { display: flex; flex-direction: column; gap: 4px; }
.f1-race { font-size: clamp(20px, 7.5cqw, 30px); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; }
.f1-place { font-size: var(--c-fs-meta); color: var(--f1-mute); }
.f1-clock { display: flex; gap: 6px; margin-top: 8px; }
.f1-cd { display: flex; flex-direction: column; align-items: center; min-width: 52px; padding: 6px 8px; border-radius: 6px; background: var(--f1-step); }
.f1-cd b { font-size: var(--c-fs-mid); font-weight: 700; line-height: 1.1; }
.f1-cd small { font-size: 12px; color: var(--f1-mute); }
.f1-sessions { display: flex; flex-direction: column; margin-top: 8px; }
.f1-s { display: grid; grid-template-columns: 4.6em minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 28px; padding: 0 8px; font-size: var(--c-fs-meta); border-left: 3px solid transparent; }
.f1-sd { color: var(--f1-mute); }
.f1-sn { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.f1-s.past { opacity: .45; }
.f1-s.next { border-left-color: var(--f1-red); background: var(--f1-step); font-weight: 700; }
.f1-tabs { display: flex; gap: 16px; border-bottom: 1px solid var(--d-separator); flex: none; }
.f1-tabs button { padding: 8px 0; font-size: var(--c-fs-meta); font-weight: 600; color: var(--f1-mute); border-bottom: 3px solid transparent; margin-bottom: -1px; }
.f1-tabs button[aria-selected="true"] { color: var(--f1-ink); border-bottom-color: var(--f1-red); }
.f1-list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; }
.f1-row { display: grid; grid-template-columns: 22px 4px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; border-bottom: 1px solid var(--d-separator); }
.f1-row:last-child { border-bottom: 0; }
.f1-pos { font-weight: 700; text-align: right; }
.f1-stripe { display: block; width: 4px; height: 22px; border-radius: 1px; background: var(--team); }
.f1-name { display: flex; flex-direction: column; min-width: 0; }
.f1-name b { font-size: var(--c-fs-body); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.f1-code { font-weight: 800; }
.f1-name > span { font-size: 12px; color: var(--f1-mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.f1-val { font-size: var(--c-fs-body); font-weight: 700; text-align: right; }
.f1-val small { font-size: 12px; font-weight: 400; color: var(--f1-mute); margin-left: 3px; }
@container (max-height: 520px) { .f1-sessions { display: none; } }
@container (max-height: 380px) { .f1-clock { display: none; } }
/* a person's text size, chosen by an admin */
body.text-large #grid { --c-fs-title: 17px; --c-fs-body: 17px; --c-fs-name: 18px; --c-fs-meta: 15px; --c-fs-mid: 25px; --c-ctl: 48px; --c-row: 52px; }
body.text-xl #grid { --c-fs-title: 19px; --c-fs-body: 19px; --c-fs-name: 21px; --c-fs-meta: 17px; --c-fs-mid: 28px; --c-ctl: 54px; --c-row: 58px; }
.p-look { margin: 12px 0 0 52px; display: flex; flex-wrap: wrap; gap: 8px 20px; }
.p-look .field { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; color: var(--text-2); }
.p-look .input { width: auto; min-width: 120px; }
@media (max-width: 760px) { .p-look { margin-left: 0; } }
/* guests */
body.guest .guest-hide { display: none; }
.inv-form { display: grid; grid-template-columns: 1.2fr 1fr 1fr auto; gap: 10px; align-items: end; margin-top: 12px; }
.inv-form .field { margin: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-2); }
.inv-host { margin: 14px 0 4px; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-2); max-width: 360px; }
.inv-fresh { margin-top: 16px; max-width: 420px; }
.inv-fresh .share-qr { margin-left: 0; }
.inv-row.ended { opacity: .6; }
.adm-panel.notice { background: color-mix(in srgb, var(--warn) 10%, var(--surface)); }
@media (max-width: 760px) { .inv-form { grid-template-columns: 1fr; } }
+70 -28
View File
@@ -897,6 +897,24 @@
// ---------------------------------------------------------------- vacuum // ---------------------------------------------------------------- vacuum
// One layout that grows with the card: compact on small cards; a robot with a battery ring, // One layout that grows with the card: compact on small cards; a robot with a battery ring,
// stats and labelled buttons on bigger ones (see the container queries in style.css). // stats and labelled buttons on bigger ones (see the container queries in style.css).
/** A robot vacuum seen from above beside its dock, placed and drawn by its state. */
function vacScene(state, charging) {
const docked = state === "docked", cleaning = state === "cleaning", returning = state === "returning", error = state === "error";
const x = docked ? 196 : returning ? 150 : cleaning ? 92 : 124;
const trail = cleaning || returning ? `<path class="vs-trail" d="M224 62 C 196 104, 150 18, ${x + 30} 60"/>` : "";
return `<svg class="vac-scene" viewBox="0 0 300 120" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${esc(st(state))}">
<rect class="vs-dock" x="232" y="18" width="52" height="84" rx="10"/>
<rect class="vs-plate" x="226" y="44" width="10" height="32" rx="3"/>
<circle class="vs-led ${charging ? "on" : ""}" cx="266" cy="34" r="4"/>
${trail}
<g class="vs-robot ${cleaning ? "busy" : ""} ${error ? "err" : ""}" transform="translate(${x} 60)">
<circle class="vs-body" r="36"/>
<path class="vs-bumper" d="M -14 -32.4 A 35 35 0 0 0 -14 32.4"/>
<circle class="vs-lidar" cx="12" r="11"/>
<g class="vs-brush" transform="translate(-21 -21)"><line x1="0" y1="-6" x2="0" y2="6"/><line x1="-5.2" y1="3" x2="5.2" y2="-3"/><line x1="-5.2" y1="-3" x2="5.2" y2="3"/></g>
</g></svg>`;
}
W.vacuum = { W.vacuum = {
label: "Vacuum", icon: "vacuum", size: [4, 5], label: "Vacuum", icon: "vacuum", size: [4, 5],
fields: [ fields: [
@@ -920,31 +938,32 @@
const hasBatt = isFinite(batt); const hasBatt = isFinite(batt);
const charging = s.state === "docked" && hasBatt && batt < 100; const charging = s.state === "docked" && hasBatt && batt < 100;
const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state); const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state);
const detail = a.status && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : ""; // the robot's own status only when it adds something (an error, a paused job), not "Returning home" twice
const detail = a.status && !["cleaning", "returning", "docked"].includes(s.state) && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : ""; const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : []; const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
const stat = (label, value, ico) => `<div class="vac-stat">${Icons.svg(ico)}<span class="vac-stat-v num">${esc(value)}</span><span class="vac-stat-l">${esc(label)}</span></div>`; // The robot's own sensors: life left in percent goes under Maintenance, the rest with the last clean.
const stats = [ const S = (x) => ha.states[x];
hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "", const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i");
c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "", const label = (x) => cap(friendly(S(x)).replace(prefix, "").replace(/\s+(left|life|remaining)$/i, ""));
c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "", const extra = (c.entities || []).filter((x) => S(x));
...(c.entities || []).map((x) => ha.states[x] && stat(friendly(ha.states[x]).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), ""), fmtState(ha.states[x]), "pulse")), const maint = extra.filter((x) => S(x).attributes.unit_of_measurement === "%" && isFinite(Number(S(x).state)));
].filter(Boolean).join(""); const facts = [
c.area && S(c.area) ? [t("Cleaned"), fmtState(S(c.area))] : null,
c.duration && S(c.duration) ? [t("Time"), fmtState(S(c.duration))] : null,
...extra.filter((x) => !maint.includes(x)).map((x) => [label(x), fmtState(S(x))]),
].filter(Boolean);
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""}>${Icons.svg(ico)}<span>${esc(label)}</span></button>`; const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""} aria-label="${esc(label)}">${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
return `<div class="vac state-${esc(s.state)}"> return `<div class="vac state-${esc(s.state)} ${charging ? "charging" : ""}">
<div class="vac-head"><span class="badge">${Icons.svg("vacuum")}</span> <div class="vac-head"><div class="vac-title"><span class="name">${esc(name)}</span><span class="vac-status">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></div>
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></span> ${hasBatt ? `<div class="vac-batt"><span class="vac-batt-v num">${Math.round(batt)}<small>%</small></span>
${hasBatt ? `<span class="vac-batt">${Icons.svg(charging ? "bolt" : "battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div> <span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span></div>` : ""}</div>
<div class="vac-hero"> <div class="vac-art">${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` : vacScene(s.state, charging)}</div>
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` ${facts.length ? `<div class="vac-facts" aria-label="${t("Last clean")}">${facts.map(([l, v]) => `<div class="vac-fact"><span class="vac-fv num">${esc(v)}</span><span class="vac-fl">${esc(l)}</span></div>`).join("")}</div>` : ""}
: `<div class="vac-hero-text"><span class="vac-headline">${esc(headline)}</span> ${maint.length ? `<div class="vac-maint">${maint.map((x) => { const v = Math.max(0, Math.min(100, Number(S(x).state))); return `<div class="vac-mrow ${v < 15 ? "low" : ""}"><span class="vac-ml">${esc(label(x))}</span><span class="vac-mbar"><i style="width:${v}%"></i></span><span class="vac-mv num">${Math.round(v)}%</span></div>`; }).join("")}</div>` : ""}
${hasBatt ? `<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span>
<span class="wt num">${charging ? t("Battery {0}%, charging", Math.round(batt)) : t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
</div>
${stats ? `<div class="vac-stats">${stats}</div>` : ""}
${speeds.length ? `<div class="seg vac-fan" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""} ${speeds.length ? `<div class="seg vac-fan" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
<div class="vac-actions"> <div class="vac-actions">
${btn(cleaning ? "pause" : "start", cleaning ? t("Pause") : returning ? t("Resume") : t("Start"), cleaning ? "pause" : "play", true)} ${btn(cleaning ? "pause" : "start", cleaning ? t("Pause") : returning ? t("Resume") : t("Start"), cleaning ? "pause" : "play", true)}
@@ -1549,6 +1568,12 @@
const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; }; const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; };
const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; }; const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; };
const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; }; const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; };
const f1Code = (r) => { const d = r.Driver || r.driver || {}; return (typeof d === "object" && d.code) || f1Driver(r).split(" ").pop().slice(0, 3).toUpperCase(); };
/** The teams' own colours, as on the timing tower. */
const F1_TEAMS = [["red bull", "#3671c6"], ["mclaren", "#ff8000"], ["ferrari", "#e80020"], ["mercedes", "#27f4d2"], ["aston", "#229971"],
["alpine", "#0093cc"], ["williams", "#64c4ff"], ["racing bulls", "#6692ff"], ["rb f1", "#6692ff"], ["visa", "#6692ff"], ["sauber", "#52e252"],
["kick", "#52e252"], ["audi", "#bb0a30"], ["haas", "#b6babd"], ["cadillac", "#c0a062"]];
const f1Colour = (team) => (F1_TEAMS.find(([k]) => String(team).toLowerCase().includes(k)) || [, "#8e8e93"])[1];
W.f1 = { W.f1 = {
label: "Formula One", icon: "flag", size: [4, 5], label: "Formula One", icon: "flag", size: [4, 5],
@@ -1559,6 +1584,8 @@
{ k: "results", t: "entity", l: "Last race results", domains: ["sensor"] }, { k: "results", t: "entity", l: "Last race results", domains: ["sensor"] },
{ k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" }, { k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" },
{ k: "top", t: "number", l: "Positions to show", d: 5 }, { k: "top", t: "number", l: "Positions to show", d: 5 },
{ k: "look", t: "seg", l: "Look", o: ["official", "theme"], d: "official" },
{ k: "logo", t: "text", l: "Logo image (put the official logo in Home Assistant's www folder)", ph: "/local/f1-logo.svg" },
], ],
autofill: { autofill: {
from: "next", from: "next",
@@ -1579,7 +1606,8 @@
const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60); const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60);
countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60); countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60);
} }
const when = (d) => `${d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" })} ${d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })}`; const day = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric" });
const time = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v))); const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v)));
const nextSession = sessions.find(([, v]) => new Date(v) > Date.now()); const nextSession = sessions.find(([, v]) => new Date(v) > Date.now());
const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · "); const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · ");
@@ -1592,17 +1620,31 @@
const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings") const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings")
: cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results"); : cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results");
rows = list.slice(0, top).map((r) => { rows = list.slice(0, top).map((r) => {
const name = cur[0] === "teams" ? f1Team(r) : f1Driver(r); const team = f1Team(r), isTeam = cur[0] === "teams";
const sub = cur[0] === "teams" ? (r.wins != null ? t("{0} wins", r.wins) : "") : f1Team(r); const name = isTeam ? team : f1Driver(r);
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""} ${t("pts")}`; const sub = isTeam ? (r.wins != null ? t("{0} wins", r.wins) : "") : team;
return `<div class="f1-row"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><span class="f1-name"><b>${esc(name)}</b><span>${esc(sub)}</span></span><span class="f1-val num">${esc(val)}</span></div>`; const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""}`;
return `<div class="f1-row" style="--team:${f1Colour(team)}"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><i class="f1-stripe" aria-hidden="true"></i>
<span class="f1-name">${isTeam ? `<b>${esc(name)}</b>` : `<b><span class="f1-code">${esc(f1Code(r))}</span> ${esc(name)}</b>`}<span>${esc(sub)}</span></span>
<span class="f1-val num">${esc(val)}${cur[0] === "results" ? "" : `<small>${t("pts")}</small>`}</span></div>`;
}).join(""); }).join("");
} }
// The countdown as the timing screens show it: days, hours, minutes.
let clock = "";
if (raceAt && raceAt > Date.now()) {
const m = Math.round((raceAt - Date.now()) / 60000);
clock = [[Math.floor(m / 1440), t("days")], [Math.floor((m % 1440) / 60), t("hrs")], [m % 60, t("mins")]]
.map(([v, l]) => `<span class="f1-cd"><b class="num">${String(v).padStart(2, "0")}</b><small>${esc(l)}</small></span>`).join("");
}
const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : ""); const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : "");
return `<div class="f1"> const official = c.look !== "theme";
${next ? `<div class="f1-next"><div class="f1-top">${Icons.svg("flag")}<span>${esc(a.season ? `${a.season} · ${t("Round {0}", a.round)}` : t("Next race"))}</span>${countdown ? `<b>${esc(countdown)}</b>` : ""}</div> return `<div class="f1 ${official ? "f1-official" : ""}">
<div class="f1-brand">${c.logo ? `<img class="f1-logo" src="${esc(c.logo)}" alt="Formula 1">` : `<span class="f1-mark" aria-hidden="true"></span>`}
<span class="f1-round">${esc(a.season ? t("Round {0} · {1}", a.round, a.season) : t("Next race"))}</span></div>
${next ? `<div class="f1-next">
<div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""} <div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""}
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => `<div class="${nextSession && nextSession[1] === v ? "next" : ""} ${new Date(v) < Date.now() ? "past" : ""}"><span>${esc(t(l))}</span><span class="num">${esc(when(new Date(v)))}</span></div>`).join("")}</div>` : ""}</div>` : ""} ${clock ? `<div class="f1-clock" aria-label="${esc(countdown)}">${clock}</div>` : ""}
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => { const d = new Date(v); return `<div class="f1-s ${nextSession && nextSession[1] === v ? "next" : ""} ${d < Date.now() ? "past" : ""}"><span class="f1-sd num">${esc(day(d))}</span><span class="f1-sn">${esc(t(l))}</span><span class="f1-st num">${esc(time(d))}</span></div>`; }).join("")}</div>` : ""}</div>` : ""}
${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div> ${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div>
<div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`; <div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`;
}, },
+38 -2
View File
@@ -1,13 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="nl.melvanveen.haven" package="nl.melvanveen.haven"
android:versionCode="1" android:versionCode="2"
android:versionName="2.0.0"> android:versionName="2.2.0">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<!-- installing a newer Haven that Dashboard Studio offers -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:label="@string/app_name" android:label="@string/app_name"
@@ -33,5 +35,39 @@
<data android:scheme="haven" android:host="open" /> <data android:scheme="haven" android:host="open" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- choosing what a widget or a quick settings tile controls (long-press a tile to choose again) -->
<activity
android:name=".PickActivity"
android:exported="true"
android:label="Choose"
android:theme="@android:style/Theme.Material.NoActionBar">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<receiver android:name=".EntityWidget" android:exported="true" android:label="Haven">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_info" />
</receiver>
<service android:name=".HavenTile$One" android:exported="true" android:icon="@drawable/ic_tile" android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter><action android:name="android.service.quicksettings.action.QS_TILE" /></intent-filter>
</service>
<service android:name=".HavenTile$Two" android:exported="true" android:icon="@drawable/ic_tile" android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter><action android:name="android.service.quicksettings.action.QS_TILE" /></intent-filter>
</service>
<service android:name=".HavenTile$Three" android:exported="true" android:icon="@drawable/ic_tile" android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter><action android:name="android.service.quicksettings.action.QS_TILE" /></intent-filter>
</service>
</application> </application>
</manifest> </manifest>
+26
View File
@@ -19,6 +19,15 @@
.hint { font-size: 13px; margin: 8px 0 22px; } .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 { width: 100%; font: inherit; font-weight: 700; color: #2b1a02; background: #f4a531; border: 0; border-radius: 12px; padding: 13px; }
button:active { opacity: .85; } button:active { opacity: .85; }
.homes { display: flex; flex-direction: column; gap: 8px; margin: 0 0 22px; }
.homes:empty { display: none; }
.home { display: flex; align-items: center; gap: 8px; }
.home .go { flex: 1; text-align: left; background: rgba(255, 255, 255, .08); color: #f4eee4; font-weight: 600; padding: 12px 14px; display: flex; flex-direction: column; }
.home .go small { font-weight: 400; color: #a9b8c2; font-size: 13px; }
.home .go.on { outline: 2px solid #f4a531; }
.home .x { width: 44px; flex: none; background: transparent; color: #a9b8c2; font-size: 20px; padding: 12px 0; }
.add { background: transparent; color: #f4a531; border: 1.5px solid rgba(244, 165, 49, .5); margin-bottom: 22px; }
#f.hide { display: none; }
</style> </style>
</head> </head>
<body> <body>
@@ -28,6 +37,8 @@
<h1 data-t="title">Your home’s dashboards</h1> <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 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> <p class="msg" id="msg"></p>
<div class="homes" id="homes"></div>
<button type="button" class="add" id="add" hidden data-t="add">Add another home</button>
<form id="f"> <form id="f">
<label for="addr" data-t="label">Home Assistant address</label> <label for="addr" data-t="label">Home Assistant address</label>
<input id="addr" type="text" inputmode="url" autocomplete="url" autocapitalize="off" spellcheck="false" placeholder="http://homeassistant.local:8123" required> <input id="addr" type="text" inputmode="url" autocomplete="url" autocapitalize="off" spellcheck="false" placeholder="http://homeassistant.local:8123" required>
@@ -42,6 +53,7 @@
label: "Adres van Home Assistant", 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.", hint: "Vraag het aan wie Home Assistant heeft ingesteld: het staat in Dashboard Studio op het beheerscherm, onder Haven-app.",
go: "Verbinden", go: "Verbinden",
add: "Nog een huis toevoegen",
offline: "Home Assistant is niet bereikbaar op dit adres. Controleer het adres en je verbinding.", 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.", notfound: "Dashboard Studio staat niet in Home Assistant. Vraag wie Home Assistant beheert om het te installeren.",
noaddress: "Dashboard Studio is gevonden, maar het adres kon niet worden opgevraagd. Tik in Dashboard Studio op Openen in Haven, of vraag wie Home Assistant beheert.", noaddress: "Dashboard Studio is gevonden, maar het adres kon niet worden opgevraagd. Tik in Dashboard Studio op Openen in Haven, of vraag wie Home Assistant beheert.",
@@ -61,6 +73,20 @@
const addr = document.getElementById("addr"); const addr = document.getElementById("addr");
addr.value = app ? app.address() : ""; addr.value = app ? app.address() : "";
document.getElementById("f").onsubmit = (e) => { e.preventDefault(); app.setup(addr.value.trim()); }; document.getElementById("f").onsubmit = (e) => { e.preventDefault(); app.setup(addr.value.trim()); };
// Several homes (a holiday home, your parents'): open one, remove one, or add another.
const homes = app ? JSON.parse(app.homes()) : [];
const box = document.getElementById("homes"), form = document.getElementById("f"), add = document.getElementById("add");
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const removeLabel = T === NL ? "Verwijderen" : "Remove";
box.innerHTML = homes.map((h) => `<div class="home"><button type="button" class="go ${h.current ? "on" : ""}" data-id="${esc(h.id)}">${esc(h.name)}<small>${esc(h.ha)}</small></button>
<button type="button" class="x" data-x="${esc(h.id)}" aria-label="${removeLabel} ${esc(h.name)}">×</button></div>`).join("");
box.onclick = (e) => {
const go = e.target.closest("[data-id]"), x = e.target.closest("[data-x]");
if (go) app.switchHome(go.dataset.id);
if (x && confirm((T === NL ? "Verwijderen: " : "Remove ") + x.closest(".home").querySelector(".go").firstChild.textContent + "?")) app.removeHome(x.dataset.x);
};
if (homes.length && homes.some((h) => h.current) && !why) { form.classList.add("hide"); add.hidden = false; }
add.onclick = () => app.addHome();
</script> </script>
</body> </body>
</html> </html>
+4
View File
@@ -32,6 +32,10 @@ fi
"$BT/apksigner" sign --ks "$KEY" --ks-pass pass:dashboard-studio --ks-key-alias app --out "$OUT/haven.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/haven.apk" ../dashboard_studio/web/haven.apk cp "$OUT/haven.apk" ../dashboard_studio/web/haven.apk
# Haven asks Dashboard Studio for this, to offer itself an update
CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml)
NAME=$(sed -n 's/.*android:versionName="\([^"]*\)".*/\1/p' AndroidManifest.xml)
printf '{"version": "%s", "code": %s}\n' "$NAME" "$CODE" > ../dashboard_studio/web/haven.json
echo "Built and copied to dashboard_studio/web/haven.apk" echo "Built and copied to dashboard_studio/web/haven.apk"
else else
echo "Built $OUT/haven.apk (debug)" echo "Built $OUT/haven.apk (debug)"
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Haven's house, as a single-colour mark for quick settings tiles -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#ffffff" android:pathData="M12,3.2 L20.6,10.4 L19.3,11.9 L18,10.8 L18,19 C18,19.6 17.6,20 17,20 L14.4,20 L14.4,15.4 C14.4,14.1 13.3,13 12,13 C10.7,13 9.6,14.1 9.6,15.4 L9.6,20 L7,20 C6.4,20 6,19.6 6,19 L6,10.8 L4.7,11.9 L3.4,10.4 Z" />
</vector>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#e6142a38" />
<corners android:radius="22dp" />
</shape>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#ffffd98a" />
<corners android:radius="22dp" />
</shape>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<stroke android:width="1.5dp" android:color="#55f4eee4" />
</shape>
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/w_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/w_bg"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/w_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Haven"
android:textColor="#f4eee4"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/w_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Choose what it controls"
android:textColor="#a9b8c2"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/w_steps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:id="@+id/w_minus"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/w_step"
android:gravity="center"
android:text="−"
android:contentDescription="Lower"
android:textColor="#f4eee4"
android:textSize="20sp" />
<TextView
android:id="@+id/w_plus"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:background="@drawable/w_step"
android:gravity="center"
android:text="+"
android:contentDescription="Higher"
android:textColor="#f4eee4"
android:textSize="20sp" />
</LinearLayout>
</LinearLayout>
+2
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Haven</string> <string name="app_name">Haven</string>
<string name="widget_desc">A light, scene or thermostat from Home Assistant</string>
<string name="tile_label">Haven</string>
<style name="AppTheme" parent="android:Theme.Material.NoActionBar"> <style name="AppTheme" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@drawable/loading</item> <item name="android:windowBackground">@drawable/loading</item>
<item name="android:statusBarColor">@color/bg</item> <item name="android:statusBarColor">@color/bg</item>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="nl.melvanveen.haven.PickActivity"
android:initialLayout="@layout/widget_entity"
android:minWidth="180dp"
android:minHeight="56dp"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="1"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:description="@string/widget_desc" />
@@ -0,0 +1,123 @@
package nl.melvanveen.haven;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.view.View;
import android.widget.RemoteViews;
import org.json.JSONObject;
import java.util.Locale;
/**
* A light, switch, scene or thermostat on the home screen. A tap does what it does on the dashboard
* (toggle, run, press); a thermostat gets − and + for its target. States refresh after every tap,
* when the widget is added, and every half hour.
*/
public class EntityWidget extends AppWidgetProvider {
private static final String TAP = "nl.melvanveen.haven.TAP", STEP = "nl.melvanveen.haven.STEP";
@Override
public void onUpdate(Context c, AppWidgetManager mgr, int[] ids) {
for (int id : ids) refresh(c, mgr, id);
}
@Override
public void onDeleted(Context c, int[] ids) {
for (int id : ids) Hass.prefs(c).edit().remove("widget_" + id).apply();
}
@Override
public void onReceive(Context c, Intent intent) {
super.onReceive(c, intent);
String action = intent.getAction();
if (!TAP.equals(action) && !STEP.equals(action)) return;
int id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 0);
String[] cfg = config(c, id);
if (cfg == null) return;
PendingResult done = goAsync();
Hass.BG.execute(() -> {
try {
JSONObject home = Hass.home(c, cfg[0]);
JSONObject s = Hass.state(home, cfg[1]);
if (STEP.equals(action)) {
JSONObject a = s.getJSONObject("attributes");
double step = a.optDouble("target_temp_step", 0.5), target = a.optDouble("temperature", 20) + intent.getIntExtra("dir", 1) * step;
Hass.call(home, "climate", "set_temperature", new JSONObject().put("entity_id", cfg[1]).put("temperature", target));
} else Hass.act(home, cfg[1], s.optString("state"));
Thread.sleep(700); // let Home Assistant settle before reading the new state
} catch (Exception ignored) { /* the refresh below shows what is true */ }
draw(c, AppWidgetManager.getInstance(c), id);
done.finish();
});
}
/** {home id, entity, name}, or null for a widget that was never set up. */
static String[] config(Context c, int id) {
String v = Hass.prefs(c).getString("widget_" + id, null);
return v == null ? null : v.split("\\|", 3);
}
static void refresh(Context c, AppWidgetManager mgr, int id) { Hass.BG.execute(() -> draw(c, mgr, id)); }
/** Fetch the state and draw the widget. Runs off the main thread. */
static void draw(Context c, AppWidgetManager mgr, int id) {
String[] cfg = config(c, id);
RemoteViews v = new RemoteViews(c.getPackageName(), R.layout.widget_entity);
if (cfg == null) { mgr.updateAppWidget(id, v); return; }
v.setTextViewText(R.id.w_name, cfg[2]);
String state = "", line = "Can't reach Home Assistant";
boolean climate = cfg[1].startsWith("climate.");
try {
JSONObject s = Hass.state(Hass.home(c, cfg[0]), cfg[1]), a = s.getJSONObject("attributes");
state = s.optString("state");
if (climate) {
line = String.format(Locale.getDefault(), "%s · %s° → %s°", cap(state), num(a.opt("current_temperature")), num(a.opt("temperature")));
} else if (cfg[1].startsWith("light.") && "on".equals(state) && a.has("brightness") && !a.isNull("brightness")) {
line = "On · " + Math.round(a.optDouble("brightness") / 2.55) + "%";
} else if (cfg[1].matches("^(scene|script|button|input_button)\\..+")) {
line = "Tap to run";
} else line = cap(state);
} catch (Exception e) {
if ("signin".equals(e.getMessage())) line = "Open Haven to sign in";
}
boolean on = Hass.isOn(state) && !climate;
v.setTextViewText(R.id.w_state, line);
v.setInt(R.id.w_root, "setBackgroundResource", on ? R.drawable.w_bg_on : R.drawable.w_bg);
v.setTextColor(R.id.w_name, on ? 0xff2b1a02 : 0xfff4eee4);
v.setTextColor(R.id.w_state, on ? 0xff6b4a12 : 0xffa9b8c2);
v.setViewVisibility(R.id.w_steps, climate ? View.VISIBLE : View.GONE);
v.setOnClickPendingIntent(R.id.w_root, climate ? openApp(c) : pending(c, id, TAP, 0));
if (climate) {
v.setOnClickPendingIntent(R.id.w_minus, pending(c, id, STEP, -1));
v.setOnClickPendingIntent(R.id.w_plus, pending(c, id, STEP, 1));
}
mgr.updateAppWidget(id, v);
}
private static PendingIntent pending(Context c, int id, String action, int dir) {
Intent i = new Intent(c, EntityWidget.class).setAction(action).putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id).putExtra("dir", dir);
return PendingIntent.getBroadcast(c, id * 4 + dir + 1, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private static PendingIntent openApp(Context c) {
return PendingIntent.getActivity(c, 0, new Intent(c, MainActivity.class), PendingIntent.FLAG_IMMUTABLE);
}
private static String cap(String s) { return s == null || s.isEmpty() ? "" : Character.toUpperCase(s.charAt(0)) + s.substring(1).replace('_', ' '); }
private static String num(Object o) {
if (!(o instanceof Number)) return "–";
double d = ((Number) o).doubleValue();
return d == Math.rint(d) ? String.valueOf((long) d) : String.format(Locale.getDefault(), "%.1f", d);
}
static void refreshAll(Context c) {
AppWidgetManager mgr = AppWidgetManager.getInstance(c);
for (int id : mgr.getAppWidgetIds(new ComponentName(c, EntityWidget.class))) refresh(c, mgr, id);
}
}
+180
View File
@@ -0,0 +1,180 @@
package nl.melvanveen.haven;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Haven's homes and its own line to Home Assistant, for the widgets and tiles that work without the app open.
*
* Each home keeps its address, Dashboard Studio's address and the sign-in Home Assistant's page made in the app
* (a refresh token). From that Haven gets a short-lived access token and uses Home Assistant's REST API, which
* everyone with a login may use to read states and control devices.
*/
final class Hass {
private Hass() {}
static final ExecutorService BG = Executors.newSingleThreadExecutor();
private static final Map<String, String[]> ACCESS = new HashMap<>(); // home id -> {token, expires at}
static SharedPreferences prefs(Context c) { return c.getSharedPreferences("app", Context.MODE_PRIVATE); }
// ---------------------------------------------------------------- homes
/** Every home, oldest first. Haven 2.0 kept one home as "ha" and "app": that becomes the first. */
static JSONArray homes(Context c) {
SharedPreferences p = prefs(c);
try {
if (!p.contains("homes")) {
JSONArray list = new JSONArray();
if (!p.getString("ha", "").isEmpty()) {
list.put(new JSONObject().put("id", "h1").put("name", Uri.parse(p.getString("ha", "")).getHost())
.put("ha", p.getString("ha", "")).put("app", p.getString("app", "")));
p.edit().putString("current", "h1").apply();
}
p.edit().putString("homes", list.toString()).apply();
return list;
}
return new JSONArray(p.getString("homes", "[]"));
} catch (Exception e) {
return new JSONArray();
}
}
static JSONObject home(Context c, String id) {
JSONArray list = homes(c);
if (id == null) id = prefs(c).getString("current", "");
for (int i = 0; i < list.length(); i++) {
JSONObject h = list.optJSONObject(i);
if (h != null && id.equals(h.optString("id"))) return h;
}
return null;
}
static JSONObject current(Context c) { return home(c, null); }
/** Add or change a home (matched by id, or else by address). */
static JSONObject save(Context c, JSONObject h) {
try {
JSONArray list = homes(c), out = new JSONArray();
boolean found = false;
for (int i = 0; i < list.length(); i++) {
JSONObject x = list.getJSONObject(i);
if (x.optString("id").equals(h.optString("id")) || (!h.has("id") && x.optString("ha").equalsIgnoreCase(h.optString("ha")))) {
for (java.util.Iterator<String> k = h.keys(); k.hasNext(); ) { String key = k.next(); x.put(key, h.get(key)); }
h = x; found = true;
}
out.put(x);
}
if (!found) {
if (!h.has("id")) h.put("id", "h" + System.currentTimeMillis());
if (!h.has("name")) h.put("name", Uri.parse(h.optString("ha")).getHost());
out.put(h);
}
prefs(c).edit().putString("homes", out.toString()).apply();
return h;
} catch (Exception e) {
return h;
}
}
static void remove(Context c, String id) {
JSONArray list = homes(c), out = new JSONArray();
for (int i = 0; i < list.length(); i++) if (!id.equals(list.optJSONObject(i).optString("id"))) out.put(list.optJSONObject(i));
SharedPreferences.Editor e = prefs(c).edit().putString("homes", out.toString());
if (id.equals(prefs(c).getString("current", ""))) e.putString("current", out.length() > 0 ? out.optJSONObject(0).optString("id") : "");
e.apply();
ACCESS.remove(id);
}
static void setCurrent(Context c, String id) { prefs(c).edit().putString("current", id).apply(); }
// ---------------------------------------------------------------- Home Assistant
/** An access token for the home, renewed with its refresh token when it runs out. Runs off the main thread. */
static String token(JSONObject home) throws Exception {
String[] cached = ACCESS.get(home.optString("id"));
if (cached != null && Long.parseLong(cached[1]) > System.currentTimeMillis() + 60_000) return cached[0];
String refresh = home.optString("refresh");
if (refresh.isEmpty()) throw new IllegalStateException("signin");
String body = "grant_type=refresh_token&refresh_token=" + URLEncoder.encode(refresh, "UTF-8")
+ "&client_id=" + URLEncoder.encode(home.optString("client", home.optString("ha") + "/"), "UTF-8");
JSONObject r = new JSONObject(http("POST", home.optString("ha") + "/auth/token", body, "application/x-www-form-urlencoded", null));
String access = r.getString("access_token");
ACCESS.put(home.optString("id"), new String[] { access, String.valueOf(System.currentTimeMillis() + r.optLong("expires_in", 1800) * 1000) });
return access;
}
static String api(JSONObject home, String method, String path, String json) throws Exception {
return http(method, home.optString("ha") + "/api/" + path, json, "application/json", token(home));
}
static JSONArray states(JSONObject home) throws Exception { return new JSONArray(api(home, "GET", "states", null)); }
static JSONObject state(JSONObject home, String entity) throws Exception { return new JSONObject(api(home, "GET", "states/" + entity, null)); }
static void call(JSONObject home, String domain, String service, JSONObject data) throws Exception {
api(home, "POST", "services/" + domain + "/" + service, data.toString());
}
static String domain(String entity) { return entity.substring(0, entity.indexOf('.')); }
/** What a tap does, as on the dashboard: toggle what can be toggled, run scenes and scripts, press buttons. */
static void act(JSONObject home, String entity, String stateNow) throws Exception {
String d = domain(entity);
JSONObject data = new JSONObject().put("entity_id", entity);
switch (d) {
case "scene": case "script": call(home, d, "turn_on", data); break;
case "button": case "input_button": call(home, d, "press", data); break;
case "lock": call(home, "lock", "locked".equals(stateNow) ? "unlock" : "lock", data); break;
case "cover": call(home, "cover", "toggle", data); break;
case "climate": call(home, "climate", "off".equals(stateNow) ? "turn_on" : "turn_off", data); break;
default: call(home, "homeassistant", "toggle", data);
}
}
static boolean isOn(String state) {
return state != null && (state.equals("on") || state.equals("open") || state.equals("unlocked") || state.equals("playing")
|| state.equals("heat") || state.equals("cool") || state.equals("auto") || state.equals("heat_cool"));
}
/** Entities a widget or tile can control. */
static boolean pickable(String entity) {
return entity.matches("^(light|switch|fan|input_boolean|scene|script|button|input_button|lock|cover|climate|media_player)\\..+");
}
static String http(String method, String url, String body, String type, String bearer) throws Exception {
HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection();
con.setRequestMethod(method);
con.setConnectTimeout(8000);
con.setReadTimeout(12000);
if (bearer != null) con.setRequestProperty("Authorization", "Bearer " + bearer);
if (body != null) {
con.setDoOutput(true);
con.setRequestProperty("Content-Type", type);
try (OutputStream o = con.getOutputStream()) { o.write(body.getBytes(StandardCharsets.UTF_8)); }
}
int code = con.getResponseCode();
InputStream in = code < 400 ? con.getInputStream() : con.getErrorStream();
ByteArrayOutputStream out = new ByteArrayOutputStream();
if (in != null) { byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) > 0) out.write(buf, 0, n); in.close(); }
con.disconnect();
if (code >= 400) throw new IllegalStateException("HTTP " + code);
return out.toString("UTF-8");
}
}
@@ -0,0 +1,78 @@
package nl.melvanveen.haven;
import android.app.PendingIntent;
import android.content.Intent;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
import org.json.JSONObject;
/**
* Favourite actions in Android's pull-down menu. Android shows a fixed set of tiles per app, so Haven offers
* three; each asks what it controls the first time it is tapped.
*/
public abstract class HavenTile extends TileService {
abstract String key();
public static class One extends HavenTile { String key() { return "1"; } }
public static class Two extends HavenTile { String key() { return "2"; } }
public static class Three extends HavenTile { String key() { return "3"; } }
static Class<?> forKey(String key) { return "1".equals(key) ? One.class : "2".equals(key) ? Two.class : Three.class; }
private String[] config() {
String v = Hass.prefs(this).getString("tile_" + key(), null);
return v == null ? null : v.split("\\|", 3);
}
@Override
public void onStartListening() {
String[] cfg = config();
Tile t = getQsTile();
if (t == null) return;
t.setIcon(Icon.createWithResource(this, R.drawable.ic_tile));
if (cfg == null) {
t.setLabel("Haven");
if (Build.VERSION.SDK_INT >= 29) t.setSubtitle("Tap to choose");
t.setState(Tile.STATE_INACTIVE);
t.updateTile();
return;
}
t.setLabel(cfg[2]);
t.updateTile();
Hass.BG.execute(() -> show(cfg));
}
private void show(String[] cfg) {
String state = "";
try { state = Hass.state(Hass.home(this, cfg[0]), cfg[1]).optString("state"); } catch (Exception ignored) { /* shown as unavailable */ }
Tile t = getQsTile();
if (t == null) return;
t.setLabel(cfg[2]);
boolean run = cfg[1].matches("^(scene|script|button|input_button)\\..+");
t.setState(state.isEmpty() ? Tile.STATE_UNAVAILABLE : run ? Tile.STATE_INACTIVE : Hass.isOn(state) ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
if (Build.VERSION.SDK_INT >= 29) t.setSubtitle(state.isEmpty() ? "Can't reach" : run ? "Tap to run" : Hass.isOn(state) ? "On" : "Off");
t.updateTile();
}
@Override
public void onClick() {
String[] cfg = config();
if (cfg == null) {
Intent pick = new Intent(this, PickActivity.class).putExtra("tile", key()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (Build.VERSION.SDK_INT >= 34) startActivityAndCollapse(PendingIntent.getActivity(this, 10 + key().hashCode(), pick, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
else startActivityAndCollapse(pick);
return;
}
Hass.BG.execute(() -> {
try {
JSONObject home = Hass.home(this, cfg[0]);
Hass.act(home, cfg[1], Hass.state(home, cfg[1]).optString("state"));
Thread.sleep(600);
} catch (Exception ignored) { /* the tile shows what is true */ }
show(cfg);
});
}
}
+131 -6
View File
@@ -1,13 +1,21 @@
package nl.melvanveen.haven; package nl.melvanveen.haven;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog;
import android.app.DownloadManager;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
import android.graphics.Color; import android.graphics.Color;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.view.View; import android.view.View;
import android.view.WindowManager; import android.view.WindowManager;
import android.webkit.CookieManager; import android.webkit.CookieManager;
@@ -21,6 +29,10 @@ import android.webkit.WebSettings;
import android.webkit.WebView; import android.webkit.WebView;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/** /**
* Haven: the dashboards of Dashboard Studio as an Android app, for everyone in the home. Full screen, and only * Haven: the dashboards of Dashboard Studio as an Android app, for everyone in the home. Full screen, and only
* the dashboards: Dashboard Studio leaves out its builder and admin screen when window.HavenApp is there. * the dashboards: Dashboard Studio leaves out its builder and admin screen when window.HavenApp is there.
@@ -71,6 +83,7 @@ public class MainActivity extends Activity {
try { session = (await hass.callWS({ type: "supervisor/api", endpoint: "/ingress/session", method: "post" }))?.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; } 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" : ""}`;
HavenApp.saveTokens(JSON.stringify(hass.auth?.data || {}), hass.config?.location_name || "");
HavenApp.open(url); HavenApp.open(url);
} catch (e) { } catch (e) {
HavenApp.fail("error", String(e?.message || e?.body?.message || e?.code || e)); HavenApp.fail("error", String(e?.message || e?.body?.message || e?.code || e));
@@ -121,9 +134,17 @@ public class MainActivity extends Activity {
return true; return true;
} }
private String ha() { return prefs.getString("ha", ""); } // The home Haven shows now; the others wait in the list (see Hass).
private String app() { return prefs.getString("app", ""); } private String ha() { JSONObject h = Hass.current(this); return h == null ? "" : h.optString("ha"); }
private void save(String ha, String app) { prefs.edit().putString("ha", ha).putString("app", app).apply(); } private String app() { JSONObject h = Hass.current(this); return h == null ? "" : h.optString("app"); }
private void save(String ha, String app) {
try {
JSONObject h = Hass.current(this);
if (h == null || !h.optString("ha").equalsIgnoreCase(ha)) h = new JSONObject().put("ha", ha);
h.put("app", app);
Hass.setCurrent(this, Hass.save(this, h).optString("id"));
} catch (JSONException ignored) { /* nothing to save */ }
}
private static String origin(Uri u) { private static String origin(Uri u) {
return u.getScheme() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : ""); return u.getScheme() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
@@ -172,8 +193,12 @@ public class MainActivity extends Activity {
if (url != null && (isIngress(url) || url.startsWith("file:"))) { reveal.removeCallbacks(showPage); web.animate().alpha(1f).setDuration(150); } if (url != null && (isIngress(url) || url.startsWith("file:"))) { reveal.removeCallbacks(showPage); 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))) {
else view.evaluateJavascript(CONNECT_JS, null); view.clearHistory(); // back leaves the app instead of going to Home Assistant
// keep the sign-in fresh for the widgets and tiles, and see whether Dashboard Studio has a newer Haven
view.evaluateJavascript("try { HavenApp.saveTokens(localStorage.getItem('hassTokens') || '', '') } catch (e) {}", null);
checkUpdate();
} else view.evaluateJavascript(CONNECT_JS, null);
} }
@Override @Override
@@ -244,7 +269,76 @@ public class MainActivity extends Activity {
protected void onPause() { super.onPause(); web.onPause(); CookieManager.getInstance().flush(); } protected void onPause() { super.onPause(); web.onPause(); CookieManager.getInstance().flush(); }
@Override @Override
protected void onResume() { super.onResume(); web.onResume(); } protected void onResume() {
super.onResume();
web.onResume();
if (pendingApk >= 0 && canInstall()) install(pendingApk);
}
// ---------------------------------------------------------------- updates
// Dashboard Studio serves the Haven that belongs to it (haven.json and haven.apk next to its page). When that
// is newer than this one, Haven offers to install it. At most twice a day.
private long pendingApk = -1;
private void checkUpdate() {
if (System.currentTimeMillis() - prefs.getLong("updateCheck", 0) < 12 * 3600_000L) return;
prefs.edit().putLong("updateCheck", System.currentTimeMillis()).apply();
String base = app().replaceAll("[?#].*", "").replaceAll("[^/]*$", "");
if (base.isEmpty()) return;
String cookie = CookieManager.getInstance().getCookie(base);
Hass.BG.execute(() -> {
try {
java.net.HttpURLConnection con = (java.net.HttpURLConnection) new java.net.URL(base + "haven.json").openConnection();
if (cookie != null) con.setRequestProperty("Cookie", cookie);
if (con.getResponseCode() != 200) return;
JSONObject v = new JSONObject(new String(con.getInputStream().readAllBytes(), "UTF-8"));
long mine = Build.VERSION.SDK_INT >= 28 ? getPackageManager().getPackageInfo(getPackageName(), 0).getLongVersionCode()
: getPackageManager().getPackageInfo(getPackageName(), 0).versionCode;
if (v.optLong("code") <= mine) return;
runOnUiThread(() -> new AlertDialog.Builder(this)
.setTitle("Haven " + v.optString("version") + " is ready")
.setMessage("A newer Haven came with the latest Dashboard Studio. Install it now?")
.setNegativeButton("Later", null)
.setPositiveButton("Install", (d, w) -> download(base + "haven.apk", cookie, v.optString("version")))
.show());
} catch (Exception ignored) { /* try again later */ }
});
}
private void download(String url, String cookie, String version) {
DownloadManager dm = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
DownloadManager.Request r = new DownloadManager.Request(Uri.parse(url))
.setTitle("Haven " + version).setMimeType("application/vnd.android.package-archive")
.setDestinationInExternalFilesDir(this, Environment.DIRECTORY_DOWNLOADS, "haven-" + version + ".apk");
if (cookie != null) r.addRequestHeader("Cookie", cookie);
long id = dm.enqueue(r);
BroadcastReceiver done = new BroadcastReceiver() {
@Override public void onReceive(Context c, Intent i) {
if (i.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != id) return;
unregisterReceiver(this);
if (canInstall()) install(id);
else { // Android asks once to allow installing apps from Haven
pendingApk = id;
startActivity(new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())));
}
}
};
IntentFilter f = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= 33) registerReceiver(done, f, Context.RECEIVER_EXPORTED);
else registerReceiver(done, f);
}
private boolean canInstall() { return Build.VERSION.SDK_INT < 26 || getPackageManager().canRequestPackageInstalls(); }
private void install(long id) {
pendingApk = -1;
Uri apk = ((DownloadManager) getSystemService(DOWNLOAD_SERVICE)).getUriForDownloadedFile(id);
if (apk == null) return;
try {
startActivity(new Intent(Intent.ACTION_VIEW).setDataAndType(apk, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK));
} catch (ActivityNotFoundException ignored) { /* no installer */ }
}
@Override @Override
protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); web.saveState(out); } protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); web.saveState(out); }
@@ -288,6 +382,37 @@ public class MainActivity extends Activity {
/** Dashboard Studio couldn't renew its session: sign in again on Home Assistant's page. */ /** Dashboard Studio couldn't renew its session: sign in again on Home Assistant's page. */
@JavascriptInterface public void reconnect() { runOnUiThread(MainActivity.this::connect); } @JavascriptInterface public void reconnect() { runOnUiThread(MainActivity.this::connect); }
/** The sign-in Home Assistant's page made, kept for the widgets and tiles; and the home's own name. */
@JavascriptInterface public void saveTokens(String json, String name) {
try {
JSONObject t = new JSONObject(json == null || json.isEmpty() ? "{}" : json), h = Hass.current(MainActivity.this);
if (h == null) return;
if (!t.optString("refresh_token").isEmpty()) h.put("refresh", t.optString("refresh_token")).put("client", t.optString("clientId", h.optString("ha") + "/"));
if (name != null && !name.isEmpty()) h.put("name", name);
Hass.save(MainActivity.this, h);
EntityWidget.refreshAll(MainActivity.this);
} catch (JSONException ignored) { /* not a sign-in */ }
}
/** The homes for the setup page: id, name, address, and which one is open. No sign-ins. */
@JavascriptInterface public String homes() {
JSONArray out = new JSONArray(), list = Hass.homes(MainActivity.this);
String cur = Hass.prefs(MainActivity.this).getString("current", "");
try {
for (int i = 0; i < list.length(); i++) {
JSONObject h = list.getJSONObject(i);
out.put(new JSONObject().put("id", h.optString("id")).put("name", h.optString("name")).put("ha", h.optString("ha")).put("current", cur.equals(h.optString("id"))));
}
} catch (JSONException ignored) { /* an empty list */ }
return out.toString();
}
@JavascriptInterface public void switchHome(String id) { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, id); start(); }); }
@JavascriptInterface public void addHome() { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, ""); showSetup("", ""); }); }
@JavascriptInterface public void removeHome(String id) { runOnUiThread(() -> { Hass.remove(MainActivity.this, id); showSetup("", ""); }); }
/** Dashboard Studio, in Haven: Install as an app, Change address. */ /** 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); }); }
@@ -0,0 +1,139 @@
package nl.melvanveen.haven;
import android.app.Activity;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.service.quicksettings.TileService;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
/** Choose what a home screen widget or a quick settings tile controls: a light, scene, thermostat and so on. */
public class PickActivity extends Activity {
private final List<String[]> all = new ArrayList<>(); // {entity, name}
private final List<String[]> shown = new ArrayList<>();
private ArrayAdapter<String> adapter;
private TextView note;
private int widgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
private String tile;
private JSONObject home;
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
setResult(RESULT_CANCELED); // backing out leaves no empty widget behind
widgetId = getIntent().getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
tile = getIntent().getStringExtra("tile");
// a long press on a tile: choose again for that tile
ComponentName from = getIntent().getParcelableExtra(Intent.EXTRA_COMPONENT_NAME);
if (tile == null && from != null && from.getClassName().contains("HavenTile$")) {
String n = from.getClassName();
tile = n.endsWith("One") ? "1" : n.endsWith("Two") ? "2" : "3";
}
home = Hass.current(this);
int pad = dp(20);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setPadding(pad, pad, pad, 0);
root.setBackgroundColor(Color.parseColor("#0d1d29"));
TextView title = text(tile != null ? "Choose a quick setting" : "Choose what this widget controls", 22, "#f4eee4");
root.addView(title);
note = text(home == null ? "" : home.optString("name"), 14, "#a9b8c2");
note.setPadding(0, dp(4), 0, dp(12));
root.addView(note);
EditText search = new EditText(this);
search.setHint("Search");
search.setTextColor(Color.parseColor("#f4eee4"));
search.setHintTextColor(Color.parseColor("#7f909b"));
search.setSingleLine();
root.addView(search);
ListView list = new ListView(this);
adapter = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_2, android.R.id.text1, new ArrayList<>()) {
@Override public View getView(int pos, View convert, ViewGroup parent) {
View v = super.getView(pos, convert, parent);
TextView a = v.findViewById(android.R.id.text1), b = v.findViewById(android.R.id.text2);
a.setText(shown.get(pos)[1]); a.setTextColor(Color.parseColor("#f4eee4"));
b.setText(shown.get(pos)[0]); b.setTextColor(Color.parseColor("#a9b8c2"));
return v;
}
};
list.setAdapter(adapter);
list.setOnItemClickListener((p, v, pos, id) -> choose(shown.get(pos)));
root.addView(list, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
search.addTextChangedListener(new TextWatcher() {
public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
public void onTextChanged(CharSequence s, int a, int b, int c) { filter(s.toString()); }
public void afterTextChanged(Editable s) {}
});
if (home == null || home.optString("refresh").isEmpty()) { note.setText("Open Haven and sign in first, then add this again."); return; }
Hass.BG.execute(() -> {
try {
JSONArray states = Hass.states(home);
for (int i = 0; i < states.length(); i++) {
JSONObject s = states.getJSONObject(i);
String e = s.getString("entity_id");
if (Hass.pickable(e)) all.add(new String[] { e, s.getJSONObject("attributes").optString("friendly_name", e) });
}
all.sort((x, y) -> x[1].compareToIgnoreCase(y[1]));
runOnUiThread(() -> filter(""));
} catch (Exception err) {
runOnUiThread(() -> note.setText("Home Assistant can't be reached. Check your connection and try again."));
}
});
}
private void filter(String q) {
String f = q.trim().toLowerCase();
shown.clear();
for (String[] x : all) if (f.isEmpty() || x[1].toLowerCase().contains(f) || x[0].contains(f)) shown.add(x);
adapter.clear();
for (String[] x : shown) adapter.add(x[1]);
adapter.notifyDataSetChanged();
}
private void choose(String[] pick) {
String value = home.optString("id") + "|" + pick[0] + "|" + pick[1];
if (tile != null) {
Hass.prefs(this).edit().putString("tile_" + tile, value).apply();
TileService.requestListeningState(this, new ComponentName(this, HavenTile.forKey(tile)));
finish();
return;
}
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { finish(); return; }
Hass.prefs(this).edit().putString("widget_" + widgetId, value).apply();
EntityWidget.refresh(this, AppWidgetManager.getInstance(this), widgetId);
setResult(RESULT_OK, new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId));
finish();
}
private TextView text(String s, int sp, String color) {
TextView t = new TextView(this);
t.setText(s);
t.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp);
t.setTextColor(Color.parseColor(color));
t.setGravity(Gravity.START);
return t;
}
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
}