From a5823b47268451239d214a5b92b370f621cf12b8 Mon Sep 17 00:00:00 2001 From: veenm Date: Tue, 27 Oct 2026 00:31:50 +0100 Subject: [PATCH] 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 --- dashboard_studio/CHANGELOG.md | 9 + dashboard_studio/DOCS.md | 26 +- dashboard_studio/config.yaml | 6 +- dashboard_studio/server/main.py | 331 ++++++++++++++++-- dashboard_studio/server/mock_ha.py | 2 + dashboard_studio/web/app.js | 126 ++++++- dashboard_studio/web/haven.apk | Bin 70879 -> 91778 bytes dashboard_studio/web/haven.json | 1 + dashboard_studio/web/i18n.js | 52 +++ dashboard_studio/web/style.css | 200 ++++++----- dashboard_studio/web/widgets.js | 98 ++++-- haven/AndroidManifest.xml | 40 ++- haven/assets/setup.html | 26 ++ haven/build.sh | 4 + haven/res/drawable/ic_tile.xml | 6 + haven/res/drawable/w_bg.xml | 5 + haven/res/drawable/w_bg_on.xml | 5 + haven/res/drawable/w_step.xml | 4 + haven/res/layout/widget_entity.xml | 64 ++++ haven/res/values/values.xml | 2 + haven/res/xml/widget_info.xml | 14 + .../src/nl/melvanveen/haven/EntityWidget.java | 123 +++++++ haven/src/nl/melvanveen/haven/Hass.java | 180 ++++++++++ haven/src/nl/melvanveen/haven/HavenTile.java | 78 +++++ .../src/nl/melvanveen/haven/MainActivity.java | 137 +++++++- .../src/nl/melvanveen/haven/PickActivity.java | 139 ++++++++ 26 files changed, 1514 insertions(+), 164 deletions(-) create mode 100644 dashboard_studio/web/haven.json create mode 100644 haven/res/drawable/ic_tile.xml create mode 100644 haven/res/drawable/w_bg.xml create mode 100644 haven/res/drawable/w_bg_on.xml create mode 100644 haven/res/drawable/w_step.xml create mode 100644 haven/res/layout/widget_entity.xml create mode 100644 haven/res/xml/widget_info.xml create mode 100644 haven/src/nl/melvanveen/haven/EntityWidget.java create mode 100644 haven/src/nl/melvanveen/haven/Hass.java create mode 100644 haven/src/nl/melvanveen/haven/HavenTile.java create mode 100644 haven/src/nl/melvanveen/haven/PickActivity.java diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index 8825456..18c62e5 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -1,5 +1,14 @@ # 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 - **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. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index c4b99f2..cc2681c 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -19,14 +19,25 @@ Everyone with a login sees Dashboard Studio in the Home Assistant sidebar. ## The Admin screen - **Dashboards:** every dashboard as a card, with who sees it. **Open** shows it, **Build** opens it in the builder, and **⋯** renames, duplicates, shows the version history, shares, exports or deletes it. At the top: a new dashboard, one made from your devices, importing a file, and importing one of Home Assistant's own dashboards. -- **People:** everyone with a Home Assistant login. Admins see everything. For everyone else, choose **All dashboards** or **Only these** and tick the dashboards, plus the one that **Opens first**. **Everyone else** at the top sets this for people without their own choice, and for anyone added to Home Assistant later. **Preview** shows the dashboards exactly as that person gets them; **Stop preview** brings you back. +- **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. +- **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. - **Activity:** who changed which dashboard, and when, with a shortcut to its versions. - **About:** the version, what's new and the source code. Screens pick up what an admin changes, a dashboard or who sees what, within a minute, without reloading. +## 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 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. +- **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 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. - **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. -- **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**. - **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. @@ -172,11 +188,13 @@ For the TeamTracker integration: the next match with both teams, their logos, th ### 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 -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 diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 1c7f646..16fe5ae 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "2.1.0" +version: "2.2.0" slug: dashboard_studio description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. url: https://git.melvanveen.nl/veenm/ha-dashboard-studio @@ -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. panel_admin: false homeassistant_api: true +ports: + 8199/tcp: null +ports_description: + 8199/tcp: Guest invite links (answers the local network only) options: immich_url: "" immich_api_key: "" diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py index 68cabea..e105778 100644 --- a/dashboard_studio/server/main.py +++ b/dashboard_studio/server/main.py @@ -9,10 +9,12 @@ Assistant with a handful of fake entities. """ import asyncio +import ipaddress import json import logging import os import re +import secrets import time from pathlib import Path @@ -187,11 +189,12 @@ async def ha_call(app, msg): USERS_TTL = 60 +USERS_CACHE = {} async def ha_users(app): """Home Assistant's people (not its system users), with whether each is an administrator. Cached for a minute.""" - cached = app.get("users_cache") + cached = USERS_CACHE.get("users") if cached and time.time() - cached[0] < USERS_TTL: return cached[1] rows = MockHA.USERS_LIST() if MOCK else await ha_call(app, {"type": "config/auth/list"}) @@ -199,7 +202,7 @@ async def ha_users(app): "admin": bool(u.get("is_owner")) or "system-admin" in (u.get("group_ids") or []), "active": u.get("is_active", True)} for u in rows if not u.get("system_generated")} - app["users_cache"] = (time.time(), users) + USERS_CACHE["users"] = (time.time(), 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"}) +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): """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={ @@ -384,6 +392,7 @@ async def get_dashboards(request): "dashboards": [d for d in store.get("dashboards", []) if d.get("id") in ids], "assign": {"devices": {request.query["device"]: dev} if dev else {}, "users": {}}, "access": {"me": access_of(store, me.get("id"))}, + "look": (store.get("looks") or {}).get(me.get("id")) or {}, "role": "basic", }, headers={"Cache-Control": "no-store"}) @@ -450,12 +459,15 @@ async def activity(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: return web.Response(status=400) if MOCK: - return web.Response(body=request.app["mock"].camera_svg(entity_id), content_type="image/svg+xml") - session = request.app["http"] + return web.Response(body=app["mock"].camera_svg(entity_id), content_type="image/svg+xml") + session = app["http"] async with session.get( f"{HA_API_URL}/camera_proxy/{entity_id}", headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"}, @@ -495,28 +507,39 @@ def denied(req): "error": {"code": "unauthorized", "message": "Only Home Assistant administrators can do this"}} -async def websocket(request): - client = web.WebSocketResponse(heartbeat=30, max_msg_size=0) - await client.prepare(request) +class GuardedSocket: + """The browser's socket as the mock Home Assistant sees it, with an outbound filter in between.""" + 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: - await request.app["mock"].serve(client, allow) - return client + await app["mock"].serve(GuardedSocket(client, shape) if shape else client, allow) + return 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.close() - return client - - session = request.app["http"] + return 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: LOG.error("Cannot reach Home Assistant websocket: %s", err) await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"}) await client.close() - return client - + return try: msg = await upstream.receive_json() # auth_required await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN}) @@ -524,34 +547,275 @@ async def websocket(request): if msg.get("type") != "auth_ok": LOG.error("Home Assistant rejected the Supervisor token: %s", msg) 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")}) - async def pump(src, dst, check=None): - async for m in src: + async def inbound(): + async for m in client: if m.type == aiohttp.WSMsgType.TEXT: - if check: + if allow: try: req = json.loads(m.data) except json.JSONDecodeError: 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 {})) continue - await dst.send_str(m.data) + await upstream.send_str(m.data) elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE): 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) for t in pending: t.cancel() finally: await upstream.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 +# ---------------------------------------------------------------- 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 = """ +Invite ended +

This invite has ended

Ask your host for a new one.

""" + + +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): app["http"] = aiohttp.ClientSession() await app["immich"].start() @@ -575,6 +839,7 @@ def make_app(): app.router.add_get("/manifest.webmanifest", manifest) app.router.add_get("/sw.js", service_worker) 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/whoami", whoami) 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_delete("/api/devices/{device}", forget_device) 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_put("/api/dashboards", put_dashboards) app.router.add_get("/api/history/{dash_id}", get_history) @@ -599,5 +867,16 @@ def make_app(): if __name__ == "__main__": if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN: 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) - web.run_app(make_app(), port=PORT, access_log=None) + LOG.info("Dashboard Studio %s listening on port %s, guest links on %s (data in %s)", VERSION, PORT, GUEST_PORT, DATA_DIR) + + 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()) diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py index e1b881b..dffc67c 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -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_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_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("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"), diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index 7db3874..533419b 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -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 }, Dark: { bg: "#0f0f11", tile: "#1c1c1e", text: "#f5f5f7", text2: "#a1a1a6", accent: "#0a84ff", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", shadow: false, blur: 0 }, Nord: { bg: "#2e3440", tile: "#3b4252", text: "#eceff4", text2: "#c2c9d6", accent: "#88c0d0", separator: "rgba(216,222,233,0.14)", fill: "rgba(216,222,233,0.12)", lit: "#f3e3bf", shadow: false, blur: 0 }, - Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 }, + Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 }, // taste-skill-ignore: the Dusk theme's own background, chosen by the user Forest: { bg: "#0e1a14", tile: "#17271e", text: "#e6f2ea", text2: "#a9c2b2", accent: "#5fd38d", onAccent: "#0b1f14", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 }, // Text on tiles is 5.3:1 or better in every theme; bright accents get dark text (onAccent). Graphite: { bg: "#18181b", tile: "#27272a", text: "#fafafa", text2: "#a1a1aa", accent: "#f59e0b", onAccent: "#1c1204", separator: "rgba(250,250,250,0.1)", fill: "rgba(250,250,250,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Indigo: { bg: "#0b1020", tile: "#151c33", text: "#eef1ff", text2: "#a7b0d6", accent: "#8aa4ff", onAccent: "#0b1020", separator: "rgba(238,241,255,0.1)", fill: "rgba(238,241,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Rose: { bg: "#1d1216", tile: "#2c1d23", text: "#fdf2f5", text2: "#d4b3bf", accent: "#ff7aa6", onAccent: "#2a0a15", separator: "rgba(253,242,245,0.1)", fill: "rgba(253,242,245,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, - Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 }, - 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 }, + Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 }, // taste-skill-ignore: the Ocean theme's own background, chosen by the user + Sunrise: { bg: "linear-gradient(160deg, #fff4e6 0%, #ffe1e8 100%)", tile: "#ffffff", text: "#2b1d14", text2: "#7a5640", accent: "#c2410c", separator: "rgba(43,29,20,0.1)", fill: "rgba(43,29,20,0.07)", lit: "#ffe0a3", shadow: true, blur: 0 }, // taste-skill-ignore: the Sunrise theme's own background, chosen by the user Lavender: { bg: "#f3f0fa", tile: "#ffffff", text: "#231a3a", text2: "#675b84", accent: "#5b3fc4", separator: "rgba(35,26,58,0.1)", fill: "rgba(35,26,58,0.07)", lit: "#ffe9c2", shadow: true, blur: 0 }, Mint: { bg: "#eaf6f0", tile: "#ffffff", text: "#10291d", text2: "#4a6657", accent: "#0b7a53", separator: "rgba(16,41,29,0.1)", fill: "rgba(16,41,29,0.07)", lit: "#ffeec2", shadow: true, blur: 0 }, Sand: { bg: "#f3ebdf", tile: "#fffaf2", text: "#2d261d", text2: "#6b5e4c", accent: "#9a5416", separator: "rgba(45,38,29,0.1)", fill: "rgba(45,38,29,0.07)", lit: "#ffe2a8", shadow: true, blur: 0 }, + Midnight: { bg: "#000000", tile: "#0f0f10", text: "#f2f2f2", text2: "#a3a3a8", accent: "#4da3ff", onAccent: "#001b33", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, + Espresso: { bg: "#1a1411", tile: "#261d18", text: "#f3ebe4", text2: "#c0ada0", accent: "#e0a35a", onAccent: "#24170b", separator: "rgba(243,235,228,0.12)", fill: "rgba(243,235,228,0.1)", lit: "#ffd9a0", shadow: false, blur: 0 }, + Ember: { bg: "#151515", tile: "#212121", text: "#f5f5f5", text2: "#aaaaaa", accent: "#ff7a45", onAccent: "#2a0e02", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffd9a8", shadow: false, blur: 0 }, + Lagoon: { bg: "#0c1a1c", tile: "#142729", text: "#e8f5f4", text2: "#9fbcb9", accent: "#3cc9b8", onAccent: "#04201d", separator: "rgba(232,245,244,0.12)", fill: "rgba(232,245,244,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, + Paper: { bg: "#f4f1ea", tile: "#fbfaf6", text: "#22201c", text2: "#5f5a52", accent: "#1f5f8b", separator: "rgba(34,32,28,0.12)", fill: "rgba(34,32,28,0.07)", lit: "#ffe6b0", shadow: false, blur: 0 }, + Arctic: { bg: "#eef3f8", tile: "#ffffff", text: "#10202e", text2: "#4a5d6e", accent: "#0b6bcb", separator: "rgba(16,32,46,0.1)", fill: "rgba(16,32,46,0.06)", lit: "#ffecc4", shadow: true, blur: 0 }, + Moss: { bg: "#e9ede4", tile: "#f7f9f4", text: "#1d2619", text2: "#4f5c48", accent: "#3f6f2a", separator: "rgba(29,38,25,0.12)", fill: "rgba(29,38,25,0.07)", lit: "#f6e7b4", shadow: false, blur: 0 }, + Sakura: { bg: "#fbf0f2", tile: "#ffffff", text: "#2a1a1f", text2: "#6e5560", accent: "#c2185b", separator: "rgba(42,26,31,0.1)", fill: "rgba(42,26,31,0.06)", lit: "#ffe5c4", shadow: true, blur: 0 }, + Concrete: { bg: "#e4e4e2", tile: "#f2f2f0", text: "#1c1c1b", text2: "#555553", accent: "#2b2b2a", onAccent: "#ffffff", separator: "rgba(28,28,27,0.12)", fill: "rgba(28,28,27,0.07)", lit: "#ffe9b8", shadow: false, blur: 0 }, "High contrast": { bg: "#000000", tile: "#141414", text: "#ffffff", text2: "#ffffff", accent: "#ffd60a", onAccent: "#000000", separator: "rgba(255,255,255,0.7)", fill: "rgba(255,255,255,0.2)", lit: "#ffd60a", shadow: false, blur: 0, outline: true }, }; @@ -676,6 +685,8 @@ let previewAs = null; // Admin → People → Preview: { id, name, ids } let lockedKiosk = false; // people who aren't admins can't leave kiosk mode /** What a person may see: { all: true } or { dashboards: [...] }, plus the one that opens first (home). */ + /** A person's own look, chosen by an admin: a theme, a text size, a language. */ + const lookOf = () => (previewAs ? store.looks?.[previewAs.id] : isAdmin() ? store.looks?.[me?.id] : store.look) || {}; const accessFor = (uid) => store.access?.users?.[uid] || store.access?.everyone || { all: true }; const visibleFor = (uid) => { const a = accessFor(uid); return store.dashboards.filter((d) => a.all || (a.dashboards || []).includes(d.id)).map((d) => d.id); }; let current = null; @@ -821,7 +832,10 @@ } 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.add(`surface-${t.surface || "cards"}`); for (const k of COLOR_KEYS) { @@ -1318,7 +1332,7 @@
${tr("Appearance")}
${Object.entries(PRESETS).map(([n, p]) => { - const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg; + const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg; // taste-skill-ignore: the System swatch: half light, half dark, with a hard edge const fg = p.swatch ? "#6e6e73" : p.text; return ``; }).join("")}
@@ -2519,8 +2533,8 @@ // People who aren't admins stay in kiosk mode: the corner switches between the dashboards they may see. const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : []; const choice = await openModal(lockedKiosk - ? `

${tr("Dashboards")}

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

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

`} - ` + ? `

${tr("Dashboards")}

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

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

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

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

`} + ` : `

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

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

`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); }); @@ -2977,6 +2991,14 @@ scheduleSave(); } + /** A language an admin chose for this person; this device's own choice still comes first. */ + function syncPersonLang() { + const want = lookOf().lang || ""; + if ((lsGet("ds.personLang") || "") === want) return; + lsSet("ds.personLang", want); + if (window.I18n.preference() === "auto" && (want || window.I18n.lang) !== window.I18n.lang) location.reload(); + } + /** People who aren't admins, with no dashboard chosen for them yet. */ function showEmpty() { const el = $("#empty-note"); @@ -3283,10 +3305,10 @@ // Dashboards, the people who use them and what each may see, devices, Haven, and who changed what. const ADMIN_SECTIONS = [ ["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"], - ["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"], + ["guests", "qr", "Guests"], ["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"], ]; let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards"; - let peopleList = null, devicesSeen = null; + let peopleList = null, devicesSeen = null, lastInvite = null; const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never")); const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase()); async function loadPeople(force) { @@ -3339,6 +3361,14 @@ ${(own || key === "everyone") && !entry.all ? `
${dashChecks(key, entry)}
` : ""} ${own || key === "everyone" ? `` : ""}
`; + const lookBlock = (uid) => { + const l = store.looks?.[uid] || {}; + const sel = (k, opts, label) => ``; + return `
+ ${sel("theme", [["", "The dashboard’s theme"], ...Object.keys(PRESETS).filter((n) => n !== "System").map((n) => [n, n])], "Theme")} + ${sel("scale", [["", "Normal"], ["large", "Large"], ["xl", "Extra large"]], "Text")} + ${sel("lang", [["", "Automatic"], ["en", "English"], ["nl", "Nederlands"]], "Language")}
`; + }; const rows = list.map((p) => { const own = acc.users[p.id]; const entry = own || acc.everyone || { all: true }; @@ -3348,6 +3378,7 @@
${p.admin ? tr("Home Assistant administrator · sees and builds everything") : tr("Sees {0} · always in kiosk mode", visibleFor(p.id).length === store.dashboards.length ? tr("all dashboards") : tr("{0} of {1} dashboards", visibleFor(p.id).length, store.dashboards.length))} · ${esc(tr("last opened {0}", ago(p.seen)))}
${p.admin ? "" : ``} ${p.admin ? "" : accessBlock(p.id, entry, !!own)} + ${lookBlock(p.id)} `; }).join(""); return `

${tr("People")}

@@ -3388,6 +3419,36 @@
`; }, + async guests() { + let data; + try { data = await api("api/invites"); } catch (e) { return `

${esc(e.message)}

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

${tr("Guests")}

+

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

+ ${data.port ? "" : `

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

+

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

`} +

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

+
+ + + +
+ +

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

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

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

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

${tr("Haven app")}

@@ -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 === "history") { setCurrent(b.closest("[data-dash]").dataset.dash); await historyDialog(); return; } if (act === "whatsnew") whatsNew(); + if (act === "inv-new") { + const v = $("#inv-for").value, now = new Date(); + let until; + if (v === "tonight") { until = new Date(now); until.setHours(23, 59, 0, 0); } + else if (v === "weekend") { until = new Date(now); until.setDate(now.getDate() + ((8 - now.getDay()) % 7 || 7)); until.setHours(10, 0, 0, 0); } + else until = new Date(now.getTime() + Number(v) * 3600e3); + try { + const r = await fetch(new URL("api/invites", BASE), { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: $("#inv-name").value.trim(), dashboard: $("#inv-dash").value, until: until.getTime() / 1000 }) }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status); + lastInvite = (await r.json()).token; + } catch (err) { toast(tr("Couldn’t make the invite: {0}", err.message), true); return; } + renderAdmin("guests"); + return; + } + if (act === "inv-show") { lastInvite = b.closest("[data-token]").dataset.token; renderAdmin("guests"); $("#admin .adm-body").scrollTop = 0; return; } + if (act === "inv-end") { + const tok = b.closest("[data-token]").dataset.token; + if (!(await confirmDestructive(tr("End this invite?"), tr("The link stops working at once, also on phones that have it open."), tr("End")))) return; + await fetch(new URL(`api/invites/${encodeURIComponent(tok)}`, BASE), { method: "DELETE" }).catch(() => {}); + if (lastInvite === tok) lastInvite = null; + renderAdmin("guests"); + return; + } + if (act === "inv-copy") { navigator.clipboard?.writeText($("#inv-link").value).then(() => toast(tr("Copied."))).catch(() => $("#inv-link").select()); return; } }); $("#admin").addEventListener("change", (e) => { const f = e.target, k = f.dataset.k; + if (f.id === "inv-host") { lsSet("ds.guestHost", f.value.trim()); renderAdmin("guests"); return; } if (f.closest("[data-dev]")) { const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {}); d[k] = f.type === "checkbox" ? f.checked : f.value; @@ -3532,6 +3619,16 @@ scheduleSave(); return; } + if (k?.startsWith("look-")) { + const uid = f.dataset.who, key = k.slice(5); + store.looks ||= {}; + const l = { ...(store.looks[uid] || {}) }; + if (f.value) l[key] = f.value; else delete l[key]; + if (Object.keys(l).length) store.looks[uid] = l; else delete store.looks[uid]; + scheduleSave(); + if (uid === me?.id) { applyTheme(); syncPersonLang(); } + return; + } if (k === "dash") { const boxes = [...f.closest(".adm-chips").querySelectorAll("input:checked")].map((x) => x.value); setAccess(f.dataset.who, (cur) => ({ ...cur, all: false, dashboards: boxes, home: boxes.includes(cur.home) ? cur.home : undefined })); @@ -3588,7 +3685,7 @@ // Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload. let storeKey = ""; - const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices]); + const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices, s.look]); async function refreshStore() { if (editMode || mode === "admin" || savePending || previewAs || modalClose) return; let next; @@ -3603,6 +3700,7 @@ const home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home; const keep = [current?.id, store.assign.devices[DEVICE_ID]?.dashboard, home].find((id) => id && store.dashboards.some((d) => d.id === id)); setCurrent(keep); + syncPersonLang(); } setInterval(refreshStore, 60 * 1000); ha.onChange = onStateChange; @@ -3623,9 +3721,10 @@ // In Haven, a session that couldn't be renewed means signing in again on Home Assistant's page. if (IN_APP && (await fetch(new URL("api/version", BASE)).then((r) => r.status === 401).catch(() => false))) { window.HavenApp.reconnect?.(); return; } loadCachedStates(); - if ("serviceWorker" in navigator && APP_VERSION !== "dev") 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); - 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 { const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE)); store = await r.json(); @@ -3634,7 +3733,7 @@ store.assign = { devices: {}, users: {}, ...(store.assign || {}) }; } catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); } // Let admins see this device on the Admin screen, to give it a dashboard. - 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(() => {}); const firstReady = new Promise((res) => { @@ -3645,6 +3744,7 @@ ha.connect(); if (isAdmin()) migrateAccess(); + syncPersonLang(); storeKey = keyOf(store); if (!store.dashboards?.length) { if (!isAdmin()) { showEmpty(); return; } diff --git a/dashboard_studio/web/haven.apk b/dashboard_studio/web/haven.apk index 32fe1184eea9af7f054e99f4c507cb1c7483aec6..260bad6d8466864d21ef7a46959af8de3f1007d4 100644 GIT binary patch delta 40101 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 ItdQ*RC%I-o_5c6? diff --git a/dashboard_studio/web/haven.json b/dashboard_studio/web/haven.json new file mode 100644 index 0000000..7fdabfb --- /dev/null +++ b/dashboard_studio/web/haven.json @@ -0,0 +1 @@ +{"version": "2.2.0", "code": 2} diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index a938a14..64720d8 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -723,6 +723,56 @@ "Energy now": "Energie nu", "No power is flowing right now.": "Er stroomt nu geen vermogen.", "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", }; @@ -745,6 +795,8 @@ function detect() { const pref = read("ds.lang"); 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 try { ha = ha && JSON.parse(ha); } catch { /* plain string */ } return String(ha || navigator.language || "en").toLowerCase().startsWith("nl") ? "nl" : "en"; diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index eaccb14..f2b13c2 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -908,47 +908,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } @container (max-height: 200px) { .room-devices { display: none; } } /* 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 */ -@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 { 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; } /* 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 */ #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); } /* 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 */ .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); } /* 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 */ .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; } } .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; } } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index 21f0ec9..5f1a431 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -897,6 +897,24 @@ // ---------------------------------------------------------------- vacuum // 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). + /** 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 ? `` : ""; + return ` + + + + ${trail} + + + + + + `; + } + W.vacuum = { label: "Vacuum", icon: "vacuum", size: [4, 5], fields: [ @@ -920,31 +938,32 @@ const hasBatt = isFinite(batt); 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 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 speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : []; - const stat = (label, value, ico) => `
${Icons.svg(ico)}${esc(value)}${esc(label)}
`; - const stats = [ - hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "", - c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "", - c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "", - ...(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")), - ].filter(Boolean).join(""); + // The robot's own sensors: life left in percent goes under Maintenance, the rest with the last clean. + const S = (x) => ha.states[x]; + const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"); + const label = (x) => cap(friendly(S(x)).replace(prefix, "").replace(/\s+(left|life|remaining)$/i, "")); + const extra = (c.entities || []).filter((x) => S(x)); + const maint = extra.filter((x) => S(x).attributes.unit_of_measurement === "%" && isFinite(Number(S(x).state))); + 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) => ``; + const btn = (svc, label, ico, primary, disabled) => ``; - return `
-
${Icons.svg("vacuum")} - ${esc(name)}${esc(headline)}${detail ? ` · ${esc(detail)}` : ""} - ${hasBatt ? `${Icons.svg(charging ? "bolt" : "battery")}${Math.round(batt)}%` : ""}
-
- ${mapUrl ? `${t(` - : `
${esc(headline)} - ${hasBatt ? ` - ${charging ? t("Battery {0}%, charging", Math.round(batt)) : t("Battery {0}%", Math.round(batt))}` : ""}
`} -
- ${stats ? `
${stats}
` : ""} + return `
+
${esc(name)}${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}
+ ${hasBatt ? `
${Math.round(batt)}% +
` : ""}
+
${mapUrl ? `${t(` : vacScene(s.state, charging)}
+ ${facts.length ? `
${facts.map(([l, v]) => `
${esc(v)}${esc(l)}
`).join("")}
` : ""} + ${maint.length ? `
${maint.map((x) => { const v = Math.max(0, Math.min(100, Number(S(x).state))); return `
${esc(label(x))}${Math.round(v)}%
`; }).join("")}
` : ""} ${speeds.length ? `
${speeds.map((f) => ``).join("")}
` : ""}
${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 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 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 = { label: "Formula One", icon: "flag", size: [4, 5], @@ -1559,6 +1584,8 @@ { k: "results", t: "entity", l: "Last race results", domains: ["sensor"] }, { k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" }, { 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: { 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); 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 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(" · "); @@ -1592,17 +1620,31 @@ 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"); rows = list.slice(0, top).map((r) => { - const name = cur[0] === "teams" ? f1Team(r) : f1Driver(r); - const sub = cur[0] === "teams" ? (r.wins != null ? t("{0} wins", r.wins) : "") : f1Team(r); - const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""} ${t("pts")}`; - return `
${esc(r.position ?? r.positionText ?? "")}${esc(name)}${esc(sub)}${esc(val)}
`; + const team = f1Team(r), isTeam = cur[0] === "teams"; + const name = isTeam ? team : f1Driver(r); + const sub = isTeam ? (r.wins != null ? t("{0} wins", r.wins) : "") : team; + const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""}`; + return `
${esc(r.position ?? r.positionText ?? "")} + ${isTeam ? `${esc(name)}` : `${esc(f1Code(r))} ${esc(name)}`}${esc(sub)} + ${esc(val)}${cur[0] === "results" ? "" : `${t("pts")}`}
`; }).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]) => `${String(v).padStart(2, "0")}${esc(l)}`).join(""); + } const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : ""); - return `
- ${next ? `
${Icons.svg("flag")}${esc(a.season ? `${a.season} · ${t("Round {0}", a.round)}` : t("Next race"))}${countdown ? `${esc(countdown)}` : ""}
+ const official = c.look !== "theme"; + return `
+
${c.logo ? `` : ``} + ${esc(a.season ? t("Round {0} · {1}", a.round, a.season) : t("Next race"))}
+ ${next ? `
${esc(raceName || t("Next race"))}
${place ? `
${esc(place)}
` : ""} - ${sessions.length ? `
${sessions.map(([l, v]) => `
${esc(t(l))}${esc(when(new Date(v)))}
`).join("")}
` : ""}
` : ""} + ${clock ? `
${clock}
` : ""} + ${sessions.length ? `
${sessions.map(([l, v]) => { const d = new Date(v); return `
${esc(day(d))}${esc(t(l))}${esc(time(d))}
`; }).join("")}
` : ""}
` : ""} ${tabs.length ? `
${tabs.map(([k, l]) => ``).join("")}
${rows || `
${t("No standings yet.")}
`}
` : ""}
`; }, diff --git a/haven/AndroidManifest.xml b/haven/AndroidManifest.xml index bd16e3d..2fd8c03 100644 --- a/haven/AndroidManifest.xml +++ b/haven/AndroidManifest.xml @@ -1,13 +1,15 @@ + android:versionCode="2" + android:versionName="2.2.0"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/haven/assets/setup.html b/haven/assets/setup.html index 611656d..5b329f4 100644 --- a/haven/assets/setup.html +++ b/haven/assets/setup.html @@ -19,6 +19,15 @@ .hint { font-size: 13px; margin: 8px 0 22px; } button { width: 100%; font: inherit; font-weight: 700; color: #2b1a02; background: #f4a531; border: 0; border-radius: 12px; padding: 13px; } button:active { opacity: .85; } + .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; } @@ -28,6 +37,8 @@

Your home’s dashboards

Enter the address you use to open Home Assistant, then sign in with your own login. You only do this once.

+
+
@@ -42,6 +53,7 @@ label: "Adres van Home Assistant", hint: "Vraag het aan wie Home Assistant heeft ingesteld: het staat in Dashboard Studio op het beheerscherm, onder Haven-app.", go: "Verbinden", + add: "Nog een huis toevoegen", offline: "Home Assistant is niet bereikbaar op dit adres. Controleer het adres en je verbinding.", notfound: "Dashboard Studio staat niet in Home Assistant. Vraag wie Home Assistant beheert om het te installeren.", noaddress: "Dashboard Studio is gevonden, maar het adres kon niet worden opgevraagd. Tik in Dashboard Studio op Openen in Haven, of vraag wie Home Assistant beheert.", @@ -61,6 +73,20 @@ const addr = document.getElementById("addr"); addr.value = app ? app.address() : ""; 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + const removeLabel = T === NL ? "Verwijderen" : "Remove"; + box.innerHTML = homes.map((h) => `
+
`).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(); diff --git a/haven/build.sh b/haven/build.sh index b53a639..8bf66dd 100755 --- a/haven/build.sh +++ b/haven/build.sh @@ -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" if [ -z "$DEBUG" ]; then 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" else echo "Built $OUT/haven.apk (debug)" diff --git a/haven/res/drawable/ic_tile.xml b/haven/res/drawable/ic_tile.xml new file mode 100644 index 0000000..8d75fdf --- /dev/null +++ b/haven/res/drawable/ic_tile.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/haven/res/drawable/w_bg.xml b/haven/res/drawable/w_bg.xml new file mode 100644 index 0000000..119007f --- /dev/null +++ b/haven/res/drawable/w_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/haven/res/drawable/w_bg_on.xml b/haven/res/drawable/w_bg_on.xml new file mode 100644 index 0000000..0b396e6 --- /dev/null +++ b/haven/res/drawable/w_bg_on.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/haven/res/drawable/w_step.xml b/haven/res/drawable/w_step.xml new file mode 100644 index 0000000..f0a757a --- /dev/null +++ b/haven/res/drawable/w_step.xml @@ -0,0 +1,4 @@ + + + + diff --git a/haven/res/layout/widget_entity.xml b/haven/res/layout/widget_entity.xml new file mode 100644 index 0000000..dbd1026 --- /dev/null +++ b/haven/res/layout/widget_entity.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + diff --git a/haven/res/values/values.xml b/haven/res/values/values.xml index 7ec7702..c220d59 100644 --- a/haven/res/values/values.xml +++ b/haven/res/values/values.xml @@ -1,6 +1,8 @@ Haven + A light, scene or thermostat from Home Assistant + Haven