diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md
index d30966f..84ef097 100644
--- a/dashboard_studio/CHANGELOG.md
+++ b/dashboard_studio/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## 1.14.0
+
+- **Seven new cards:** parcels on the way (PostNL, DHL), air quality per room with a hint to open a window, water and gas use per day, the solar forecast with the best time to run the washer, a garage and gate button that asks first, sports scores (TeamTracker, optionally only on match days) and Formula One (next race, sessions, standings and results).
+- **Announcements:** an automation can put a full-screen message on the screens, such as "Dinner is ready".
+- **Kiosk:** tabs can take turns, pausing when someone touches the screen.
+- **Guest mode:** a simple screen with only lights and thermostats, turned on with a button, an entity or a schedule.
+- **New dashboards from templates:** wall tablet, phone, energy and family, filled with your own devices.
+- **Import from Home Assistant:** turn an existing Home Assistant dashboard into a Dashboard Studio dashboard.
+- **Share** a dashboard as a link or QR code, optionally in kiosk mode.
+- **Editor:** save widgets as presets, see and jump back through the undo history, and alignment guides while dragging.
+- **Devices not on a dashboard yet** are pointed out in the widget list, ready to add.
+- **Touch and hold** a widget for its details, or give it a second action of its own.
+- **Kiosk:** an optional soft click when something is pressed, for wall tablets that can't vibrate.
+- **Themes** can switch to a matching dark or light version with the device.
+- **Phones:** install Dashboard Studio as an app with its own icon and home screen shortcuts, keep showing the last known states when offline, swipe between tabs, pull down to refresh, popups as bottom sheets, a short vibration on presses, widgets only on phones or only on larger screens, a landscape layout, editing on the phone itself, and a tab that opens when you're away from home.
+- Fixed: the weather forecast and to-do list could stay on "Loading…" when the page had just connected.
+
## 1.13.1
- Car card: the battery bar is always shown now, along the bottom of the card, with the charge limit marked. It turns green with the charging details while the car charges. On the wide card it runs the full width.
diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md
index f15421f..5c91510 100644
--- a/dashboard_studio/DOCS.md
+++ b/dashboard_studio/DOCS.md
@@ -19,9 +19,15 @@ More editing tools:
- **Several widgets at once:** Shift-click or ⌘-click (Ctrl-click) widgets to select them together. Drag one to move them all, or use the arrow keys. The right panel can line up their left, right, top or bottom edges, give them the same width or height (that of the widget you clicked last), duplicate them, copy them elsewhere or delete them.
- **Copy to…** (in the right panel) copies the selected widgets to another tab or another dashboard.
- **Into a scroll or swipe card:** drag a widget onto a horizontal scroll or swipe card; it lights up, and when you let go the widget becomes one of its cards. To take a card out again, open it in the container's settings and choose **Take out of this card**.
+- **Alignment guides:** while you drag or resize a widget, pink lines show where its edges line up with other widgets.
+- **Presets:** **Save as preset** (in the right panel) keeps a set-up widget under a name. Presets appear at the top of the widget list, on every dashboard; hover one to delete it.
+- **Devices not on a dashboard:** the top of the widget list says how many of your devices (lights, thermostats, covers, players, cameras, locks, switches and more) aren't on any dashboard yet. Open it to add one to the current tab with the widget that suits it. Also in the **⋯** menu.
+- **Touch and hold:** holding a widget for half a second shows the details of its entity. Under **Touch and hold** in a widget's settings, you can make it switch the entity on or off, run an action of its own, or do nothing.
+- **Undo history…** in the **⋯** menu lists the changes made since you opened the editor, such as *Added Light* or *Moved 3 widgets*. Pick one to go back to just after it; redo brings the later ones back.
## Look and feel
+- **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*.
- **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.
@@ -103,6 +109,34 @@ An alarm panel (`alarm_control_panel.*`) with its state, a keypad for the code (
Home Assistant's notifications, with a button to dismiss each one or all, plus available updates (`update.*`) with a button to install them (after confirmation).
+### Parcels
+
+Shows the parcels on their way from parcel integrations such as PostNL or DHL, whose sensors keep a list of parcels in their attributes. Each parcel shows its sender or description, its status and when it's expected; parcels coming today are highlighted. Tap a parcel to open its tracking page, when the integration provides one. Parcels delivered in the last two days can be shown too, dimmed.
+
+### Air quality
+
+Groups CO₂, fine dust (PM2.5, PM10), VOC and humidity sensors per room, with a green, orange or red dot for each room and each value. A hint says what to do: *Open a window* above 1000 ppm CO₂, *Too humid* above 70% humidity, or *The air is dry* below 30%. Leave the list empty to find every such sensor that is in a room (area) in Home Assistant, or pick sensors yourself.
+
+### Water and gas
+
+Pick a water, gas or energy meter (a sensor with a state class *total* or *total_increasing*, the same kind the energy dashboard uses). The card shows today's use so far, yesterday's total, and a bar for each of the last days (7 by default) with the average as a dashed line. Water in m³ is shown in litres.
+
+### Solar forecast
+
+For the Forecast.Solar integration. Pick the *production today* sensor and the card fills in tomorrow's and the current power by itself. It shows today's expected production hour by hour (the current hour marked) and the best time to run the washer or dishwasher: the hours in a row with the most sun from now on (3 by default).
+
+### Garage and gate
+
+One big button for a garage door, gate or lock, which opens or closes it (or stops it while moving). It asks first, unless you turn that off. Underneath: whether it's open, for how long, and who last opened or closed it, when Home Assistant knows (a person with a user account).
+
+### Sports
+
+For the TeamTracker integration: the next match with both teams, their logos, the kick-off time and the venue and TV channel; the live score with the match clock during the match; the final score afterwards. Turn on **Only show on match days** to hide the card on other days.
+
+### 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.
+
### 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**.
@@ -180,7 +214,20 @@ In edit mode, hidden widgets stay visible but faded, with a **Hidden now** label
Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns.
-On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and **Edit**. To bring the bar back, touch and hold an empty spot for a moment; it hides again after 10 seconds. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar.
+On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and **Edit**. To bring the bar back, touch and hold an empty spot for a moment; it hides again after 10 seconds. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other.
+
+### On phones
+
+- **Install as an app:** **⋯ → Install as an app…** explains how. Home Assistant's own page can't be installed, so open Dashboard Studio in its own tab first (the dialog has a button for it). Installed, it opens full screen with its own icon and the dashboard's colors in the status bar. Dashboard Studio keeps Home Assistant's sign-in fresh in the background while it's open, using the login Home Assistant saved in that browser (with *Keep me logged in*). If it was closed for a long time and shows an error, open Home Assistant once.
+- **Shortcuts:** tick **Shortcut on the app icon** in the settings of a button, tile, light, thermostat or garage card. On Android, long-press the installed app's icon to run it straight away (up to four shortcuts; iPhones don't support this).
+- **Offline:** each device keeps the last known states of its dashboard. When the connection drops, the dashboard keeps showing them with a small note saying from when, and catches up when it reconnects.
+- **Swipe** left or right over the dashboard to go to the next or previous tab (not over cards that swipe or scroll themselves).
+- **Pull down** at the top of the dashboard to reload every state.
+- **Popups** open as sheets from the bottom; drag one down to close it.
+- **Vibration:** buttons, switches and sliders give a short tick on phones that support it (not iPhones). Turn this off under **This device**. Wall tablets can play a soft click instead: turn on **A soft click when something is pressed** in the **Kiosk** settings.
+- **Show on:** under **Visibility**, a widget can be shown on all screens, only on phones, or only on larger screens.
+- **Editing on the phone:** **Edit** works on a phone too. The bar at the bottom opens the widget list (**Add**) and the settings as sheets. Tap a widget to select it, then drag it or its corner; tap it again for its settings.
+- **Away from home:** in the **Tabs** settings, **When you're away from home, open** picks a tab, such as cameras, the garage or the alarm, that opens while you're away, and the dashboard goes back when you're home. It follows the person signed in on the device, or a person you choose. Not used in kiosk mode.
## Tabs and side menu
@@ -207,6 +254,26 @@ Press **Kiosk** in the toolbar, or open the dashboard with `#kiosk` at the end o
**Doorbell:** in the **Kiosk** settings, pick the doorbell (a binary sensor that turns on, an `event.*` doorbell entity, or a switch or helper) and the camera to show. When it rings while kiosk mode is on, the camera fills the screen with a live picture, the screen saver goes away and a short chime plays (you can turn that off). It closes by itself after a minute, or after the number of seconds you set, or when you tap **Close**.
+**Tabs that take turns:** with several tabs, set **Go to the next tab every (seconds)** to show them in turn. Touching the screen pauses this for a minute.
+
+**Announcements:** an automation can put a message on the screens, for example *Dinner is ready*. Fire the event `dashboard_studio_announce`:
+
+```yaml
+action:
+ - event: dashboard_studio_announce
+ event_data:
+ message: Dinner is ready!
+ title: Kitchen # optional
+ icon: dining # optional, any icon name from the icon picker
+ seconds: 60 # optional, closes by itself after this
+ device: Kitchen tablet # optional, only this named device (see Which dashboard opens where)
+ sound: true # optional, a short chime
+```
+
+The message fills the screen, wakes it from the screen saver, and closes after the seconds you give (a minute by default) or when someone taps **OK**. It shows on open dashboards that aren't being edited; turn it off per dashboard in the **Kiosk** settings.
+
+**Guest mode** shows a simple screen with only lights and thermostats, for guests or a babysitter. Set it up under **Guest mode** in the dashboard settings: it turns on with the button there (on that device only), with an entity such as an `input_boolean` (on every screen), or between two times. You can pick which lights and thermostats it shows and the welcome text. A **Guest mode** button in the top bar ends it; in kiosk mode, use the exit corner, where the prompt has a **Guest mode** button too. Editing always shows the real dashboard.
+
On a wall tablet, use **New tab** in the kiosk settings. It opens the dashboard on its own, without the Home Assistant sidebar, and full screen works there.
## Language and night mode
@@ -217,7 +284,13 @@ Dashboard Studio is available in English and Dutch (Nederlands). By default it f
## Several dashboards
-Use **+** for a new dashboard. The **⋯** menu (in edit mode) can rename, duplicate, export, import or delete a dashboard. Exported JSON files can be imported on another Home Assistant install.
+Use **+** for a new dashboard. You can start empty, from your devices, or from a template that fills itself with your devices: *Wall tablet* (clock, weather, calendar, rooms and cameras), *Phone* (people, lights, climate and media in the phone layout), *Energy* (power flow, solar forecast, water and gas, graphs) or *Family* (who's home, calendar, shopping list, parcels and weather).
+
+The **⋯** menu (in edit mode) can rename, duplicate, export, import or delete a dashboard. Exported JSON files can be imported on another Home Assistant install.
+
+**Import from Home Assistant…** turns one of your existing Home Assistant dashboards into a new dashboard here, with a tab for each view. Entities, tile, button, light, thermostat, sensor, gauge, graph, weather, media, camera, calendar, to-do, alarm, area and markdown cards become matching widgets, and stacks and grids are taken apart; other cards are skipped and counted. The original stays as it is. Dashboards that Home Assistant generates itself (without cards of their own) can't be imported.
+
+**Share…** shows a link and a QR code for the dashboard, optionally opening in kiosk mode. It opens on any device that is signed in to Home Assistant.
**Version history…** in the **⋯** menu lists the earlier saves of the dashboard, up to 30, with the time and the number of widgets. Changes made within five minutes of each other count as one version. **Restore** brings a version back; ⌘Z undoes that.
@@ -229,7 +302,7 @@ Use **⋯ → Who sees which dashboard…** in edit mode to choose the dashboard
- **Other devices** you have named appear in the list too, so you can change them from anywhere.
- **People:** pick a dashboard per Home Assistant user. People appear once they've opened Dashboard Studio, or when they have a person entity.
-A device's choice comes before a person's. Without either, the last dashboard used on that device opens. A link with `#view=…` always wins. You can still switch dashboards at any time.
+A device's choice comes before a person's. Without either, the last dashboard used on that device opens. A link with `#view=…` or a shared link always wins. You can still switch dashboards at any time.
## Version and updates
@@ -237,4 +310,4 @@ The version you're running is shown at the bottom of the dashboard settings, und
## Data
-Dashboards are stored in the app's `/data/dashboards.json`, and their version history in `/data/history/`, so both are included in Home Assistant backups.
+Dashboards and presets are stored in the app's `/data/dashboards.json`, and their version history in `/data/history/`, so both are included in Home Assistant backups. Night mode, guest mode started with the button, vibration and the last known states are kept on each device.
diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml
index b8486eb..243b6cd 100644
--- a/dashboard_studio/config.yaml
+++ b/dashboard_studio/config.yaml
@@ -1,5 +1,5 @@
name: Dashboard Studio
-version: "1.13.1"
+version: "1.14.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
diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py
index 4d0ed37..6f425b0 100644
--- a/dashboard_studio/server/main.py
+++ b/dashboard_studio/server/main.py
@@ -147,7 +147,9 @@ def record_history(old, new):
async def index(request):
# Stamp the version on every asset URL, so an update can never be served from a stale cache.
html = (WEB_DIR / "index.html").read_text()
- html = re.sub(r'(static/[\w.-]+\.(?:js|css|png))"', rf'\1?v={VERSION}"', html)
+ # Locally, files change without a new version number: stamp them with their last change instead.
+ stamp = VERSION if not MOCK else f"{VERSION}-{int(max(f.stat().st_mtime for f in WEB_DIR.iterdir()))}"
+ html = re.sub(r'(static/[\w.-]+\.(?:js|css|png))"', rf'\1?v={stamp}"', html)
html = html.replace("__VERSION__", VERSION)
return web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store"})
@@ -198,6 +200,40 @@ async def version(request):
return web.json_response({"version": VERSION}, headers={"Cache-Control": "no-store"})
+def all_widgets(widgets):
+ for w in widgets or []:
+ if isinstance(w, dict):
+ yield w
+ yield from all_widgets((w.get("cfg") or {}).get("children"))
+
+
+async def manifest(request):
+ """The web app manifest, so the dashboard can be installed on a phone's home screen.
+ Widgets marked as a shortcut appear when you long-press the app icon (Android)."""
+ shortcuts = []
+ for d in load_store().get("dashboards", []):
+ for t in d.get("tabs", []):
+ for w in all_widgets(t.get("widgets")):
+ if w.get("shortcut") and len(shortcuts) < 4:
+ shortcuts.append({"name": str(w["shortcut"])[:40], "url": f"./?ds_action={w.get('id')}",
+ "icons": [{"src": "static/icon-192.png", "sizes": "192x192", "type": "image/png"}]})
+ body = {
+ "name": "Dashboard Studio", "short_name": "Dashboards", "id": "./", "start_url": "./", "scope": "./",
+ "display": "standalone", "background_color": "#0f1526", "theme_color": "#0f1526",
+ "icons": [{"src": "static/icon-192.png", "sizes": "192x192", "type": "image/png"},
+ {"src": "static/icon-512.png", "sizes": "512x512", "type": "image/png"},
+ {"src": "static/app-icon.svg", "sizes": "any", "type": "image/svg+xml"}],
+ "shortcuts": shortcuts,
+ }
+ return web.json_response(body, content_type="application/manifest+json", headers={"Cache-Control": "no-store"})
+
+
+async def service_worker(request):
+ """Network first; the last good copy of the page, its files and the dashboards when the network fails."""
+ js = (WEB_DIR / "sw.js").read_text().replace("__VERSION__", VERSION)
+ return web.Response(text=js, content_type="application/javascript", headers={"Cache-Control": "no-store"})
+
+
async def get_dashboards(request):
return web.json_response(load_store())
@@ -334,6 +370,8 @@ def make_app():
app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024)
app.router.add_get("/", index)
app.router.add_get("/api/version", version)
+ app.router.add_get("/manifest.webmanifest", manifest)
+ app.router.add_get("/sw.js", service_worker)
app.router.add_get("/api/changelog", changelog)
app.router.add_get("/api/whoami", whoami)
app.router.add_get("/api/users", users)
diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py
index 8f67334..d0f2846 100644
--- a/dashboard_studio/server/mock_ha.py
+++ b/dashboard_studio/server/mock_ha.py
@@ -133,6 +133,8 @@ class MockHA:
entity("sensor.model_y_odometer", "24816", friendly_name="Model Y Odometer", unit_of_measurement="km", device_class="distance"),
]
}
+ self.states.update({e["entity_id"]: e for e in self._extra_entities()})
+ self.states["cover.garage_door"]["context"] = {"id": "mock", "user_id": "mock-user-alex"}
self.clients = set()
self.todo = [{"uid": "1", "summary": "Milk", "status": "needs_action"}, {"uid": "2", "summary": "Bread", "status": "needs_action"},
{"uid": "3", "summary": "Coffee beans", "status": "needs_action"}, {"uid": "4", "summary": "Batteries", "status": "completed"}]
@@ -143,6 +145,94 @@ class MockHA:
}
self.note_subs = set()
+ def _extra_entities(self):
+ """Parcels, air quality, meters, solar forecast, sports and Formula One."""
+ now = datetime.now().astimezone()
+ day = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ at = lambda d, h, m=0: (day + timedelta(days=d, hours=h, minutes=m)).isoformat()
+ drivers = [("Max", "Verstappen", "VER", "Red Bull", 395), ("Lando", "Norris", "NOR", "McLaren", 372), ("Oscar", "Piastri", "PIA", "McLaren", 341),
+ ("Charles", "Leclerc", "LEC", "Ferrari", 290), ("George", "Russell", "RUS", "Mercedes", 245), ("Lewis", "Hamilton", "HAM", "Ferrari", 198)]
+ standings = [{"position": str(i + 1), "points": str(p), "wins": str(max(0, 6 - i * 2)), "Driver": {"givenName": g, "familyName": f, "code": c}, "Constructors": [{"name": team}]}
+ for i, (g, f, c, team, p) in enumerate(drivers)]
+ teams = [{"position": str(i + 1), "points": str(p), "wins": str(w), "Constructor": {"name": n}} for i, (n, p, w) in enumerate([("McLaren", 713, 9), ("Ferrari", 488, 3), ("Red Bull", 470, 7), ("Mercedes", 402, 2)])]
+ results = [{"position": str(i + 1), "points": str([25, 18, 15, 12, 10, 8][i]), "Driver": {"givenName": g, "familyName": f, "code": c}, "Constructor": {"name": team},
+ "status": "Finished", "Time": {"time": ["1:32:10.553", "+2.411", "+8.902", "+15.300", "+21.774", "+30.105"][i]}}
+ for i, (g, f, c, team, _) in enumerate(drivers)]
+ return [
+ entity("sensor.postnl_delivery", "2", friendly_name="PostNL Delivery",
+ enroute=[{"key": "3SABCD123", "name": "Bol.com", "status_message": "De bezorger is onderweg", "planned_date": at(0, 14), "planned_to": at(0, 16), "url": "https://jouw.postnl.nl/"},
+ {"key": "3SXYZ987", "name": "Coolblue", "status_message": "Wordt gesorteerd", "planned_date": at(1, 0)}],
+ delivered=[{"key": "3SQQQ555", "name": "Zalando", "status_message": "Bezorgd", "delivery_date": at(-1, 11, 20)}]),
+ entity("sensor.living_room_co2", "940", friendly_name="Living Room CO2", unit_of_measurement="ppm", device_class="carbon_dioxide"),
+ entity("sensor.living_room_pm25", "8", friendly_name="Living Room PM2.5", unit_of_measurement="µg/m³", device_class="pm25"),
+ entity("sensor.bedroom_co2", "1340", friendly_name="Bedroom CO2", unit_of_measurement="ppm", device_class="carbon_dioxide"),
+ entity("sensor.bedroom_humidity", "72", friendly_name="Bedroom Humidity", unit_of_measurement="%", device_class="humidity"),
+ entity("sensor.office_co2", "610", friendly_name="Office CO2", unit_of_measurement="ppm", device_class="carbon_dioxide"),
+ entity("sensor.office_humidity", "46", friendly_name="Office Humidity", unit_of_measurement="%", device_class="humidity"),
+ entity("sensor.water_meter", "1234.567", friendly_name="Water Meter", unit_of_measurement="m³", device_class="water", state_class="total_increasing"),
+ entity("sensor.gas_meter", "4321.12", friendly_name="Gas Meter", unit_of_measurement="m³", device_class="gas", state_class="total_increasing"),
+ entity("sensor.energy_production_today", "18.4", friendly_name="Estimated energy production - today", unit_of_measurement="kWh", device_class="energy"),
+ entity("sensor.energy_production_tomorrow", "12.1", friendly_name="Estimated energy production - tomorrow", unit_of_measurement="kWh", device_class="energy"),
+ entity("sensor.power_production_now", "1240", friendly_name="Estimated power production - now", unit_of_measurement="W", device_class="power"),
+ entity("sensor.team_tracker_ajax", "PRE", friendly_name="Ajax", sport="soccer", league="Eredivisie", team_name="Ajax", team_abbr="AJA", team_homeaway="home",
+ opponent_name="PSV", opponent_abbr="PSV", date=at(0, 20), kickoff_in="in a few hours", venue="Johan Cruijff ArenA", tv_network="ESPN"),
+ entity("sensor.f1_next_race", at(5, 18), friendly_name="F1 Next Race", season="2026", round="21", race_name="São Paulo Grand Prix",
+ circuit_name="Autódromo José Carlos Pace", circuit_locality="São Paulo", circuit_country="Brazil",
+ first_practice_start=at(3, 15, 30), sprint_qualifying_start=at(3, 19, 30), sprint_start=at(4, 15), qualifying_start=at(4, 19), race_start=at(5, 18)),
+ entity("sensor.f1_driver_standings", "Max Verstappen", friendly_name="F1 Driver Standings", driver_standings=standings),
+ entity("sensor.f1_constructor_standings", "McLaren", friendly_name="F1 Constructor Standings", constructor_standings=teams),
+ entity("sensor.f1_last_race_results", "Mexico City Grand Prix", friendly_name="F1 Last Race Results", race_name="Mexico City Grand Prix", results=results),
+ ]
+
+ def statistics(self, ids, start):
+ start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")).astimezone()
+ now = datetime.now().astimezone()
+ out = {}
+ for eid in ids:
+ base = {"sensor.gas_meter": 3.2}.get(eid, 0.18)
+ rows, d = [], start_dt
+ while d <= now:
+ seed = (d.toordinal() * 7 + len(eid)) % 10
+ change = base * (0.6 + seed / 12)
+ if d.date() == now.date():
+ change *= now.hour / 24
+ rows.append({"start": int(d.timestamp() * 1000), "end": int((d + timedelta(days=1)).timestamp() * 1000), "change": round(change, 3)})
+ d += timedelta(days=1)
+ out[eid] = rows
+ return out
+
+ def solar_forecast(self):
+ day = datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0)
+ hours = {}
+ for d, peak in ((0, 2600), (1, 1700)):
+ for h in range(6, 21):
+ wh = max(0, peak * math.sin(math.pi * (h - 6) / 14))
+ hours[(day + timedelta(days=d, hours=h)).isoformat()] = round(wh)
+ return {"mock_entry": {"wh_hours": hours}}
+
+ def broadcast_event(self, event_type, data):
+ event = {"event_type": event_type, "data": data or {}, "time_fired": now_iso()}
+ for ws, sub_ids in list(self.clients):
+ for sid in sub_ids:
+ asyncio.ensure_future(self._safe_send(ws, {"id": sid, "type": "event", "event": event}))
+
+ LOVELACE = {"views": [
+ {"title": "Home", "path": "home", "icon": "mdi:home", "cards": [
+ {"type": "entities", "title": "Living room", "entities": ["light.living_room", "light.reading_lamp", {"entity": "sensor.living_room_temperature", "name": "Temperature"}]},
+ {"type": "thermostat", "entity": "climate.thermostat"},
+ {"type": "weather-forecast", "entity": "weather.home"},
+ {"type": "horizontal-stack", "cards": [{"type": "button", "entity": "switch.coffee_machine"}, {"type": "light", "entity": "light.kitchen"}]},
+ {"type": "media-control", "entity": "media_player.living_room_speaker"},
+ {"type": "markdown", "content": "## Welcome home"},
+ ]},
+ {"title": "Energy", "path": "energy", "cards": [
+ {"type": "gauge", "entity": "sensor.power_usage", "min": 0, "max": 5000},
+ {"type": "history-graph", "entities": ["sensor.power_usage", "sensor.solar_production"], "hours_to_show": 24},
+ {"type": "tile", "entity": "sensor.solar_production"},
+ {"type": "picture-entity", "entity": "camera.front_yard"},
+ ]},
+ ]}
+
# -- state handling ---------------------------------------------------
def set_state(self, entity_id, state=None, **attrs):
@@ -454,10 +544,10 @@ class MockHA:
]})
elif typ == "config/entity_registry/list":
areas = {
- "living_room": ["light.living_room", "light.reading_lamp", "light.downstairs", "media_player.living_room_speaker", "media_player.tv", "sensor.living_room_temperature", "sensor.living_room_humidity", "binary_sensor.hallway_motion", "climate.thermostat"],
+ "living_room": ["sensor.living_room_co2", "sensor.living_room_pm25", "light.living_room", "light.reading_lamp", "light.downstairs", "media_player.living_room_speaker", "media_player.tv", "sensor.living_room_temperature", "sensor.living_room_humidity", "binary_sensor.hallway_motion", "climate.thermostat"],
"kitchen": ["light.kitchen", "switch.coffee_machine", "media_player.kitchen_speaker", "sensor.washer_status"],
- "bedroom": ["light.bedroom", "binary_sensor.front_door"],
- "office": ["fan.office", "input_boolean.guest_mode"],
+ "bedroom": ["light.bedroom", "binary_sensor.front_door", "sensor.bedroom_co2", "sensor.bedroom_humidity"],
+ "office": ["fan.office", "input_boolean.guest_mode", "sensor.office_co2", "sensor.office_humidity"],
"garage": ["cover.garage_door", "lock.front_door"],
}
rows = [{"entity_id": e, "area_id": a, "device_id": None, "disabled_by": None, "hidden_by": None, "entity_category": None} for a, es in areas.items() for e in es]
@@ -466,6 +556,17 @@ class MockHA:
await ws.send_json({"id": mid, "type": "result", "success": True, "result": rows})
elif typ == "config/device_registry/list":
await ws.send_json({"id": mid, "type": "result", "success": True, "result": []})
+ elif typ == "recorder/statistics_during_period":
+ await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.statistics(req.get("statistic_ids") or [], req.get("start_time"))})
+ elif typ == "energy/solar_forecast":
+ await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.solar_forecast()})
+ elif typ == "fire_event":
+ self.broadcast_event(req.get("event_type"), req.get("event_data"))
+ await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}}})
+ elif typ == "lovelace/dashboards/list":
+ await ws.send_json({"id": mid, "type": "result", "success": True, "result": [{"url_path": "wall-tablet", "title": "Wall tablet", "mode": "storage", "icon": "mdi:tablet"}]})
+ elif typ == "lovelace/config":
+ await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.LOVELACE})
elif typ == "ping":
await ws.send_json({"id": mid, "type": "pong"})
else:
diff --git a/dashboard_studio/web/app-icon.svg b/dashboard_studio/web/app-icon.svg
new file mode 100644
index 0000000..7c776b7
--- /dev/null
+++ b/dashboard_studio/web/app-icon.svg
@@ -0,0 +1,18 @@
+
diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js
index a5e99f0..1336b55 100644
--- a/dashboard_studio/web/app.js
+++ b/dashboard_studio/web/app.js
@@ -60,6 +60,7 @@
const states = await this.send({ type: "get_states" });
this.states = Object.fromEntries(states.map((s) => [s.entity_id, s]));
this.send({ type: "subscribe_events", event_type: "state_changed" }, (ev) => {
+ if (ev.event_type && ev.event_type !== "state_changed") return;
const { entity_id, new_state } = ev.data;
if (new_state) this.states[entity_id] = new_state;
else delete this.states[entity_id];
@@ -75,6 +76,10 @@
else this.notifications = { ...(this.notifications || {}), ...ev.notifications };
this.onChange("__notifications");
}).catch(() => {});
+ // Announcements: an automation fires this event to put a message on the screens.
+ this.send({ type: "subscribe_events", event_type: "dashboard_studio_announce" }, (ev) => {
+ if (ev.event_type === "dashboard_studio_announce") this.onAnnounce?.(ev.data || {});
+ }).catch(() => {});
this.onStatus(true);
this.onChange(null);
return;
@@ -142,6 +147,18 @@
});
return res?.response?.[entityId]?.forecast || [];
}
+ /** Daily totals of a meter from the long-term statistics, oldest first, today last. */
+ async statistics(entityId, days) {
+ await this.ready;
+ const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() - (days - 1));
+ const res = await this.send({ type: "recorder/statistics_during_period", start_time: start.toISOString(), statistic_ids: [entityId], period: "day", types: ["change"] });
+ return (res?.[entityId] || []).map((r) => ({ start: typeof r.start === "number" ? r.start : new Date(r.start).getTime(), change: r.change }));
+ }
+ /** Forecast.Solar's expected production per hour, per configured plane. */
+ async solarForecast() {
+ await this.ready;
+ return this.send({ type: "energy/solar_forecast" });
+ }
async history(entityIds, hours) {
await this.ready;
const res = await this.send({
@@ -186,10 +203,11 @@
const DEFAULT_THEME = { preset: "System", surface: "cards", radius: 20, bgImage: "", blur: 0, shadow: true };
const DEFAULT_GRID = { cols: 12, rowHeight: 72, gap: 14, maxWidth: 1440 };
- const DEFAULT_KIOSK = { saver: 0, saverStyle: "clock", saverSource: "links", saverUrls: "", saverAlbums: [], saverInterval: 30, exitCorner: "top-right", hideCursor: true, doorbell: "", doorbellCamera: "", doorbellSecs: 60, doorbellSound: true };
+ const DEFAULT_KIOSK = { saver: 0, saverStyle: "clock", saverSource: "links", saverUrls: "", saverAlbums: [], saverInterval: 30, exitCorner: "top-right", hideCursor: true, doorbell: "", doorbellCamera: "", doorbellSecs: 60, doorbellSound: true, rotate: 0, announce: true };
+ const DEFAULT_GUEST = { when: "manual", entity: "", from: "18:00", to: "23:00", entities: [], title: "" };
const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] });
- const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, nav: { show: false, style: "icons" }, tabs: [newTab(tr("Overview"))] });
+ const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, guest: { ...DEFAULT_GUEST }, nav: { show: false, style: "icons" }, tabs: [newTab(tr("Overview"))] });
// Bring dashboards saved by older versions up to the current shape.
function migrate(d) {
@@ -202,6 +220,7 @@
d.theme = { ...DEFAULT_THEME, ...t };
d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) };
d.kiosk = { ...DEFAULT_KIOSK, ...(d.kiosk || {}) };
+ d.guest = { ...DEFAULT_GUEST, ...(d.guest || {}) };
d.nav = { show: false, style: "icons", ...(d.nav || {}) };
if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }];
delete d.widgets;
@@ -223,6 +242,318 @@
return { id: uid(), type, x, y, w: def.size[0], h: def.size[1], cfg, style: { transparent: !!def.transparent, bg: "", color: "", accent: "" } };
}
+ // ------------------------------------------------------------ app on the home screen
+ let installPrompt = null;
+ window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; });
+ const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
+ async function installDialog() {
+ let inFrame = false;
+ try { inFrame = window.top !== window; } catch { inFrame = true; }
+ const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
+ const how = inFrame
+ ? tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there.")
+ : ios ? tr("In Safari, tap the Share button, then Add to Home Screen.")
+ : installPrompt ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.")
+ : tr("In your browser’s menu, choose Install app or Add to Home screen.");
+ const choice = await openModal(`
${tr("Install as an app")}
${esc(how)}
+
${tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon.")}
` : ""}
${n.mode !== "off" ? `${fieldHTML({ k: "dim", t: "number", l: "Dim the screen (%)" }, n.dim, "night")}${fieldHTML({ k: "dark", t: "check", l: "Switch to the dark theme" }, n.dark, "night")}` : ""}
-
${tr("Night mode is saved on this device only, and is off while editing.")}
`;
+
${tr("Night mode is saved on this device only, and is off while editing.")}
+ `;
}
/** Album pickers load their list from the server after the inspector is drawn. */
@@ -892,6 +1386,15 @@
renderInspector();
}
+ /** What touching and holding a widget does: its details by default, or a second action. */
+ function holdHTML(w) {
+ if (Widgets[w.type]?.container) return "";
+ const h = w.hold || {};
+ return `
- `,
+ `,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
clearTimeout(timer);
promptOpen = false;
if (choice === "exit") exitKiosk();
else if (choice === "full") toggleFullscreen(false);
+ else if (choice === "guest") guestOn ? endGuest() : setGuestManual(true);
}
function inExitCorner(e) {
@@ -1988,6 +2591,129 @@
}
$("#doorbell").addEventListener("click", (e) => { if (e.target.closest(".db-close")) closeDoorbell(); });
+ // ------------------------------------------------------------ announcements
+ // An automation fires the event dashboard_studio_announce with a message; open screens show it full screen.
+ let announceTimer = 0;
+ ha.onAnnounce = (d) => {
+ if (!current || editMode || current.kiosk.announce === false || !d.message) return;
+ const me2 = (store.assign?.devices?.[DEVICE_ID]?.name || "").toLowerCase();
+ const targets = [].concat(d.device || d.devices || []).map((x) => String(x).toLowerCase());
+ if (targets.length && !targets.includes(me2) && !targets.includes(DEVICE_ID.toLowerCase())) return;
+ const el = $("#announce");
+ hideSaver();
+ if (modalClose) modalClose(null);
+ el.querySelector(".an-title").textContent = d.title || tr("Announcement");
+ el.querySelector(".an-msg").textContent = d.message;
+ el.querySelector(".an-ico").innerHTML = Icons.svg(Icons.has(d.icon) ? d.icon : "megaphone");
+ el.querySelector(".an-ok").textContent = tr("OK");
+ el.hidden = false;
+ el.querySelector(".an-ok").focus();
+ if (d.sound !== false) { window.HAFmt.beep?.(); setTimeout(() => window.HAFmt.beep?.(), 350); }
+ clearTimeout(announceTimer);
+ announceTimer = setTimeout(closeAnnounce, Math.max(5, Number(d.seconds) || 60) * 1000);
+ };
+ function closeAnnounce() {
+ clearTimeout(announceTimer);
+ $("#announce").hidden = true;
+ poke();
+ }
+ $("#announce").addEventListener("click", (e) => { if (e.target.closest(".an-ok") || e.target.id === "announce") closeAnnounce(); });
+
+ // ------------------------------------------------------------ tab rotation
+ // In kiosk mode the tabs can take turns; touching the screen pauses that for a minute.
+ let lastRotate = Date.now(), lastTouch = 0;
+ document.addEventListener("pointerdown", () => { lastTouch = Date.now(); }, { capture: true, passive: true });
+ function rotateTick() {
+ const secs = Number(current?.kiosk.rotate) || 0;
+ if (!kiosk || secs <= 0 || current.tabs.length < 2 || (guestOn && !editMode) || modalClose || !$("#saver").hidden) return;
+ const now = Date.now();
+ if (now - lastTouch < 60000 || now - lastRotate < Math.max(5, secs) * 1000) return;
+ lastRotate = now;
+ const i = current.tabs.findIndex((t) => t.id === tab().id);
+ setTab(current.tabs[(i + 1) % current.tabs.length].id);
+ }
+
+ // ------------------------------------------------------------ away from home
+ // A dashboard can switch to another tab (garage, alarm, cameras) while you are away, and back when you're home.
+ let awayState = null, awayFrom = null;
+ function awayEntity() {
+ if (!current?.nav?.awayTab) return null;
+ if (current.nav.awayPerson) return current.nav.awayPerson;
+ return Object.values(ha.states).find((x) => x.entity_id.startsWith("person.") && me?.id && x.attributes.user_id === me.id)?.entity_id || null;
+ }
+ function checkAway() {
+ const eid = awayEntity(), s = eid && ha.states[eid];
+ if (!s || editMode || kiosk || !current.tabs.some((t) => t.id === current.nav.awayTab)) return;
+ const away = s.state !== "home";
+ if (away === awayState) return;
+ const first = awayState === null;
+ awayState = away;
+ if (away && tab().id !== current.nav.awayTab) { awayFrom = tab().id; setTab(current.nav.awayTab); if (!first) toast(tr("You’re away: showing “{0}”.", tab().name)); }
+ else if (!away && awayFrom && tab().id === current.nav.awayTab) { setTab(awayFrom); awayFrom = null; }
+ }
+
+ // ------------------------------------------------------------ guest mode
+ // A plain screen with lights and thermostats. Turned on per device with a button, or for
+ // everyone by an entity or a time of day. Editing always shows the real dashboard.
+ let guestOn = false, guestCache = null;
+ const guestManual = () => !!current && lsGet(`ds.guest.${current.id}`) === "1";
+ function setGuestManual(on) {
+ lsSet(`ds.guest.${current.id}`, on ? "1" : "0");
+ checkGuest(true);
+ toast(on ? tr("Guest mode is on.") : tr("Guest mode is off."));
+ }
+ function checkGuest(force) {
+ if (!current) return;
+ const g = current.guest || DEFAULT_GUEST;
+ const skipped = Date.now() < Number(lsGet(`ds.guestskip.${current.id}`) || 0); // ended early on this device
+ const on = guestManual() || (g.when === "entity" && !!g.entity && ["on", "home", "true"].includes(ha.states[g.entity]?.state))
+ || (g.when === "times" && !skipped && betweenTimes(g.from, g.to));
+ if (on === guestOn && !force) return;
+ guestOn = on;
+ guestCache = null;
+ document.body.classList.toggle("guest", on && !editMode);
+ $("#btn-guest").hidden = !(on && !editMode);
+ if (!editMode) renderGrid();
+ }
+ function guestTab() {
+ const g = current.guest || DEFAULT_GUEST, cols = Number(current.grid.cols) || 12;
+ const pickIds = g.entities?.length ? g.entities : Object.keys(ha.states).filter((e) => (e.startsWith("light.") || e.startsWith("climate.")) && !ha.states[e].attributes.entity_id);
+ const key = `${pickIds.join(",")}|${cols}|${g.title}`;
+ if (guestCache?.key === key) return guestCache.tab;
+ const lights = pickIds.filter((e) => e.startsWith("light.")).sort((a, b) => friendly(ha.states[a] || { entity_id: a }).localeCompare(friendly(ha.states[b] || { entity_id: b })));
+ const climates = pickIds.filter((e) => e.startsWith("climate."));
+ const widgets = [];
+ const add = (type, cfg, w, h) => {
+ const wd = makeWidget(type);
+ Object.assign(wd.cfg, cfg);
+ wd.id = `guest-${type}-${cfg.entity || "title"}`;
+ wd.w = Math.min(w, cols); wd.h = h;
+ Object.assign(wd, findSpot(wd.w, wd.h, widgets, cols));
+ widgets.push(wd);
+ };
+ add("heading", { text: g.title || tr("Welcome"), subtitle: tr("Lights and temperature"), size: 30 }, cols, 1);
+ const tw = cols >= 12 ? 4 : Math.max(2, Math.floor(cols / 2));
+ for (const e of climates) add("climate", { entity: e }, tw, 3);
+ const lw = cols >= 12 ? 3 : Math.max(2, Math.floor(cols / 2));
+ for (const e of lights) add("light", { entity: e, colors: false }, lw, 2);
+ guestCache = { key, tab: { id: "guest", name: tr("Guest mode"), icon: "person", widgets } };
+ return guestCache.tab;
+ }
+ /** Ending guest mode undoes whatever turned it on: the button, the entity, or the rest of today's time window. */
+ async function endGuest() {
+ if (!(await confirmAction(tr("End guest mode?"), "", tr("End guest mode")))) return;
+ const g = current.guest;
+ if (g.when === "entity" && g.entity && ["input_boolean", "switch"].includes(domainOf(g.entity))) ha.callService(domainOf(g.entity), "turn_off", {}, g.entity);
+ if (g.when === "times" && betweenTimes(g.from, g.to)) {
+ const [h, m] = String(g.to || "0:0").split(":").map(Number), end = new Date();
+ end.setHours(h || 0, m || 0, 0, 0);
+ if (end < Date.now()) end.setDate(end.getDate() + 1);
+ lsSet(`ds.guestskip.${current.id}`, String(end.getTime()));
+ }
+ setGuestManual(false);
+ }
+ $("#btn-guest").onclick = endGuest;
+
// ------------------------------------------------------------ night mode
// Per device: dim the screen and use the dark theme after sunset or between two times.
const DEFAULT_NIGHT = { mode: "off", from: "22:00", to: "07:00", dim: 40, dark: true };
@@ -1995,12 +2721,15 @@
function nightCfg() {
try { return { ...DEFAULT_NIGHT, ...JSON.parse(lsGet("ds.night") || "{}") }; } catch { return { ...DEFAULT_NIGHT }; }
}
+ /** Whether the time of day is between two "HH:MM" times, also across midnight. */
+ function betweenTimes(fromV, toV) {
+ const mins = (v) => { const [h, m] = String(v || "0:0").split(":").map(Number); return (h || 0) * 60 + (m || 0); };
+ const d = new Date(), now = d.getHours() * 60 + d.getMinutes(), from = mins(fromV), to = mins(toV);
+ return from <= to ? now >= from && now < to : now >= from || now < to;
+ }
function isNight(n) {
if (n.mode === "sunset") return ha.states["sun.sun"]?.state === "below_horizon";
- if (n.mode !== "times") return false;
- const mins = (v) => { const [h, m] = String(v || "0:0").split(":").map(Number); return (h || 0) * 60 + (m || 0); };
- const d = new Date(), now = d.getHours() * 60 + d.getMinutes(), from = mins(n.from), to = mins(n.to);
- return from <= to ? now >= from && now < to : now >= from || now < to;
+ return n.mode === "times" && betweenTimes(n.from, n.to);
}
function applyNight() {
if (!current) return;
@@ -2011,6 +2740,140 @@
if (dark !== nightDark) { nightDark = dark; applyTheme(); }
}
+ // ------------------------------------------------------------ touch: haptics, swiping tabs, pull to refresh, sheets
+ const coarse = matchMedia("(pointer: coarse)");
+ function haptic() {
+ if (coarse.matches && lsGet("ds.haptics") !== "0") navigator.vibrate?.(8);
+ if (kiosk && current?.kiosk.clickSound) clickSound();
+ }
+ /** A short, quiet tick, made on the spot (no sound file), for wall tablets that can't vibrate. */
+ let audioCtx = null;
+ function clickSound() {
+ try {
+ audioCtx ||= new (window.AudioContext || window.webkitAudioContext)();
+ const t0 = audioCtx.currentTime, o = audioCtx.createOscillator(), g = audioCtx.createGain();
+ o.type = "triangle"; o.frequency.setValueAtTime(1800, t0); o.frequency.exponentialRampToValueAtTime(600, t0 + 0.04);
+ g.gain.setValueAtTime(0.12, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.05);
+ o.connect(g).connect(audioCtx.destination);
+ o.start(t0); o.stop(t0 + 0.06);
+ } catch { /* no audio */ }
+ }
+
+ // ------------------------------------------------------------ touch and hold
+ // Holding a widget for half a second runs its second action; the tap that follows is swallowed.
+ let holdTimer = 0, holdStart = null;
+ gridEl.addEventListener("pointerdown", (e) => {
+ clearTimeout(holdTimer);
+ if (editMode || e.button !== 0) return;
+ const el = e.target.closest(".w[data-id]");
+ if (!el || e.target.closest("input, textarea, select, .hs-nav, .sw-dot")) return;
+ holdStart = { x: e.clientX, y: e.clientY };
+ holdTimer = setTimeout(() => {
+ const w = findWidget(el.dataset.id);
+ if (!w || !runHold(w)) return;
+ swallowClick = true;
+ haptic();
+ el.animate?.([{ transform: "scale(.97)" }, { transform: "scale(1)" }], 180);
+ }, 500);
+ });
+ gridEl.addEventListener("pointermove", (e) => { if (holdStart && Math.hypot(e.clientX - holdStart.x, e.clientY - holdStart.y) > 10) { clearTimeout(holdTimer); holdStart = null; } });
+ for (const ev of ["pointerup", "pointercancel", "pointerleave"]) gridEl.addEventListener(ev, () => { clearTimeout(holdTimer); holdStart = null; });
+ gridEl.addEventListener("contextmenu", (e) => { if (!editMode && coarse.matches && e.target.closest(".w")) e.preventDefault(); });
+
+ function runHold(w) {
+ const h = w.hold || {}, eid = w.cfg.entity || (w.cfg.entities || [])[0] || h.entity;
+ switch (h.action || "details") {
+ case "none": return false;
+ case "toggle": if (!eid) return false; defaultAction(eid); return true;
+ case "service": {
+ if (!h.service?.includes(".")) return false;
+ let data = {};
+ if (h.data) { try { data = JSON.parse(h.data); } catch { return false; } }
+ const [d, sv] = h.service.split(".");
+ ha.callService(d, sv, data, h.entity || undefined);
+ return true;
+ }
+ default: if (!eid || !ha.states[eid]) return false; moreInfo(eid); return true;
+ }
+ }
+
+ // Swipe left or right over the dashboard to go to the next or previous tab (not over cards that swipe themselves).
+ let swipeStart = null;
+ const SWIPE_OWN = ".hs, .sw, input, textarea, select, .swatches, .todo-list, .f1-tabs, .fc-hours, .sf-bars, .us-bars";
+ canvasEl.addEventListener("touchstart", (e) => {
+ const tch = e.touches[0];
+ swipeStart = !editMode && e.touches.length === 1 && current.tabs.length > 1 && !(guestOn) && !e.target.closest(SWIPE_OWN)
+ ? { x: tch.clientX, y: tch.clientY, t: Date.now() } : null;
+ }, { passive: true });
+ canvasEl.addEventListener("touchend", (e) => {
+ if (!swipeStart) return;
+ const tch = e.changedTouches[0], dx = tch.clientX - swipeStart.x, dy = tch.clientY - swipeStart.y, dt = Date.now() - swipeStart.t;
+ swipeStart = null;
+ if (Math.abs(dx) < 70 || Math.abs(dy) > Math.abs(dx) * 0.6 || dt > 700) return;
+ const i = current.tabs.findIndex((t) => t.id === tab().id), j = i + (dx < 0 ? 1 : -1);
+ if (j < 0 || j >= current.tabs.length) return;
+ haptic();
+ setTab(current.tabs[j].id);
+ if (!reducedMotion()) gridEl.animate([{ transform: `translateX(${dx < 0 ? 40 : -40}px)`, opacity: 0.4 }, { transform: "none", opacity: 1 }], { duration: 220, easing: "cubic-bezier(.2,.8,.2,1)" });
+ }, { passive: true });
+
+ // Pull down at the top to fetch every state again.
+ let pull = null;
+ const ptr = $("#ptr");
+ stageEl.addEventListener("touchstart", (e) => {
+ pull = !editMode && stageEl.scrollTop <= 0 && e.touches.length === 1 && !e.target.closest("input, textarea, select, .hs, .sw") ? { y: e.touches[0].clientY, d: 0 } : null;
+ }, { passive: true });
+ stageEl.addEventListener("touchmove", (e) => {
+ if (!pull) return;
+ pull.d = Math.max(0, e.touches[0].clientY - pull.y);
+ if (stageEl.scrollTop > 0) { pull = null; ptr.hidden = true; return; }
+ if (pull.d > 10) {
+ ptr.hidden = false;
+ const k = Math.min(1, pull.d / 90);
+ ptr.style.transform = `translate(-50%, ${Math.min(70, pull.d * 0.5)}px) rotate(${k * 270}deg)`;
+ ptr.style.opacity = String(k);
+ ptr.classList.toggle("ready", k >= 1);
+ }
+ }, { passive: true });
+ stageEl.addEventListener("touchend", async () => {
+ if (!pull) return;
+ const go = pull.d >= 90;
+ pull = null;
+ if (!go) { ptr.hidden = true; return; }
+ haptic();
+ ptr.classList.add("spin");
+ try {
+ if (ha.connected) {
+ const states = await ha.send({ type: "get_states" });
+ ha.states = Object.fromEntries(states.map((x) => [x.entity_id, x]));
+ onStateChange(null);
+ } else toast(tr("Not connected to Home Assistant. Retrying…"), true);
+ } finally {
+ setTimeout(() => { ptr.classList.remove("spin", "ready"); ptr.hidden = true; }, 400);
+ }
+ });
+
+ // On phones, popups are sheets from the bottom; drag one down to close it.
+ let sheetDrag = null;
+ const sheet = $("#modal .modal-card");
+ sheet.addEventListener("touchstart", (e) => {
+ sheetDrag = matchMedia("(max-width: 600px)").matches && sheet.scrollTop <= 0 && !e.target.closest("input, textarea, select, .lp-slider, input[type=range]") ? { y: e.touches[0].clientY, d: 0 } : null;
+ }, { passive: true });
+ sheet.addEventListener("touchmove", (e) => {
+ if (!sheetDrag) return;
+ sheetDrag.d = Math.max(0, e.touches[0].clientY - sheetDrag.y);
+ sheet.style.transition = "none";
+ sheet.style.transform = `translateY(${sheetDrag.d}px)`;
+ }, { passive: true });
+ sheet.addEventListener("touchend", () => {
+ if (!sheetDrag) return;
+ const close = sheetDrag.d > 110;
+ sheetDrag = null;
+ sheet.style.transition = "";
+ sheet.style.transform = "";
+ if (close) modalClose?.(null);
+ });
+
// ------------------------------------------------------------ phone dashboards
// The top bar is hidden; touching and holding an empty spot brings it back for a while.
let peekTimer = 0, holdTimer2 = 0;
@@ -2035,17 +2898,24 @@
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
lsSet("ds.current", current.id);
activeTabId = lsGet(`ds.tab.${current.id}`);
+ guestOn = false; guestCache = null; awayState = null; awayFrom = null;
undoStack.length = redoStack.length = 0;
{ selectedId = null; multi.clear(); }
updateUndoButtons();
renderAll();
+ checkGuest(true);
+ checkAway();
}
function setEdit(on) {
editMode = on;
+ if (!on) openSheet(null);
+ else renderPalette(); // the suggestions count needs the states
lsSet("ds.edit", on ? "1" : "0");
if (!on) { selectedId = null; multi.clear(); }
renderAll();
+ checkGuest(true);
+ checkAway();
}
/** Earlier saves of this dashboard, from the server. Restoring one can be undone. */
@@ -2088,9 +2958,10 @@
$("#btn-redo").onclick = redo;
$("#dash-select").onchange = (e) => setCurrent(e.target.value);
$("#btn-new").onclick = async () => {
- const name = await askText(tr("New dashboard"), tr("New dashboard"), tr("Create"));
- if (!name) return;
- const d = newDashboard(name);
+ const choice = await templateDialog();
+ if (!choice) return;
+ const d = choice.template === "devices" ? starterDashboard() : TEMPLATES[choice.template]?.build() || newDashboard(choice.name);
+ d.name = choice.name;
store.dashboards.push(d);
scheduleSave();
setCurrent(d.id);
@@ -2110,6 +2981,16 @@
assignDialog();
} else if (act === "history") {
historyDialog();
+ } else if (act === "undo-history") {
+ undoHistoryDialog();
+ } else if (act === "share") {
+ shareDialog();
+ } else if (act === "suggest") {
+ suggestionsDialog();
+ } else if (act === "install") {
+ installDialog();
+ } else if (act === "lovelace") {
+ lovelaceDialog();
} else if (act === "starter") {
if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; }
const d = starterDashboard();
@@ -2159,9 +3040,19 @@
if (b) setTab(b.dataset.tab);
});
- $("#palette-list").addEventListener("click", (e) => {
+ $("#palette-list").addEventListener("click", async (e) => {
const b = e.target.closest("[data-add]");
- if (b) addWidget(b.dataset.add);
+ if (b) { addWidget(b.dataset.add); return; }
+ if (e.target.closest("[data-suggest]")) { suggestionsDialog(); return; }
+ const pa = e.target.closest("[data-preset-add]");
+ if (pa) { addPreset(pa.dataset.presetAdd); return; }
+ const pd = e.target.closest("[data-preset-del]");
+ if (pd) {
+ const p = store.presets.find((x) => x.id === pd.dataset.presetDel);
+ if (!(await confirmDestructive(tr("Delete preset “{0}”", p.name), tr("Widgets you already added from it stay."), tr("Delete")))) return;
+ store.presets = store.presets.filter((x) => x !== p);
+ scheduleSave(); renderPalette();
+ }
});
const insp = $("#inspector");
@@ -2180,6 +3071,11 @@
renderInspector(); return;
}
const del = e.target.closest("[data-ent-del]");
+ if (del && del.dataset.path === "guest") {
+ mutate(() => current.guest[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
+ checkGuest(true); renderInspector(); return;
+ }
+ if (e.target.closest("[data-guest-toggle]")) { setGuestManual(!guestManual()); lsSet(`ds.guestskip.${current.id}`, "0"); renderInspector(); return; }
if (del) {
const w = editTarget(); // may be a card inside a scroll or swipe card
mutate(() => (w.cfg[del.dataset.k] || []).splice(Number(del.dataset.entDel), 1), null, { rerender: false });
@@ -2219,7 +3115,8 @@
});
function toolAction(name, ids) {
- if (name === "copyto") copyTo(ids);
+ if (name === "preset") savePreset(ids[0]);
+ else if (name === "copyto") copyTo(ids);
else if (ids.length > 1) name === "dup" ? duplicateMany(ids) : removeMany(ids);
else name === "dup" ? duplicate(ids[0]) : remove(ids[0]);
}
@@ -2270,11 +3167,14 @@
let resizeRaf = 0;
window.addEventListener("resize", () => { cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(renderGrid); });
matchMedia("(prefers-color-scheme: dark)").addEventListener?.("change", () => renderGrid());
+ phoneLandscape.addEventListener?.("change", () => renderGrid());
+ phoneScreen.addEventListener?.("change", () => renderGrid()); // widgets shown only on phones or only on larger screens
// clocks, screen saver and camera refresh
setInterval(() => {
gridEl.querySelectorAll("[data-clock]").forEach((el) => { el.innerHTML = clockHTML(el.dataset.h24 === "1", el.dataset.sec === "1", el.dataset.date === "1"); });
if (!$("#saver").hidden) updateSaver();
+ rotateTick();
gridEl.querySelectorAll("[data-countdown]").forEach((el) => {
const left = Math.max(0, (new Date(el.dataset.countdown) - Date.now()) / 1000), dur = Number(el.dataset.dur) || 1;
el.textContent = window.HAFmt.hms(left);
@@ -2289,12 +3189,13 @@
});
}, 1000);
// keep "x min ago" labels and graph time windows fresh
- setInterval(() => applyNight(), 60000);
+ setInterval(() => { applyNight(); checkGuest(); }, 60000);
setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000);
// ------------------------------------------------------------ boot
function paintChromeIcons() {
$("#btn-new").innerHTML = Icons.svg("plus");
+ $("#eb-add").insertAdjacentHTML("afterbegin", Icons.svg("plus"));
$("#btn-undo").innerHTML = Icons.svg("undo");
$("#btn-redo").innerHTML = Icons.svg("redo");
$("#btn-menu").innerHTML = Icons.svg("more");
@@ -2305,7 +3206,8 @@
ha.onStatus = (ok) => {
const c = $("#conn");
c.classList.toggle("ok", ok);
- c.textContent = ok ? tr("Connected") : tr("Offline");
+ c.textContent = ok ? tr("Connected") : offlineText();
+ showOffline(!ok);
// An update restarts the app, which drops the connection: check right after reconnecting.
if (ok) checkVersion();
};
@@ -2328,7 +3230,7 @@
}
setInterval(checkVersion, 5 * 60 * 1000);
ha.onChange = onStateChange;
- ha.onRegistry = () => { renderGrid(); if (editMode) renderInspector(); };
+ ha.onRegistry = () => { renderGrid(); if (editMode) { renderInspector(); renderPalette(); } };
/** Translate the static page: elements marked data-i18n, plus every aria-label and title. */
function translateStatic() {
@@ -2340,6 +3242,9 @@
translateStatic();
paintChromeIcons();
renderPalette();
+ await keepIngressSession();
+ loadCachedStates();
+ if ("serviceWorker" in navigator && APP_VERSION !== "dev") navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
try {
const r = await fetch(new URL("api/dashboards", BASE));
store = await r.json();
@@ -2360,13 +3265,16 @@
save();
}
me = await api("api/whoami").catch(() => null);
- const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1];
+ const shared = sharedParams();
+ const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1] || shared.get("ds");
// Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here.
const dev = store.assign.devices[DEVICE_ID], forUser = me?.id && store.assign.users[me.id];
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
- if (location.hash.includes("kiosk") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
+ if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
setTimeout(phoneHint, 1500);
+ const action = shared.get("ds_action");
+ if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); }
// After an update, point at what changed (once per device).
const seen = lsGet("ds.seenVersion");
if (APP_VERSION !== "dev" && seen && seen !== APP_VERSION) setTimeout(() => toast(tr("Updated to {0}. See what’s new under About in the settings.", APP_VERSION)), 2000);
diff --git a/dashboard_studio/web/apple-touch-icon.png b/dashboard_studio/web/apple-touch-icon.png
new file mode 100644
index 0000000..ef5d5ad
Binary files /dev/null and b/dashboard_studio/web/apple-touch-icon.png differ
diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js
index efac4e0..19809cd 100644
--- a/dashboard_studio/web/i18n.js
+++ b/dashboard_studio/web/i18n.js
@@ -398,6 +398,222 @@
"Unlocked, tap to lock": "Open, tik om op slot te doen",
"Location: {0}": "Locatie: {0}",
"Inside {0}°": "Binnen {0}°",
+ "Parcels": "Pakketten",
+ "Parcel sensors (PostNL, DHL, …)": "Pakketsensoren (PostNL, DHL, …)",
+ "Show parcels delivered in the last two days": "Pakketten tonen die de afgelopen twee dagen zijn bezorgd",
+ "a parcel sensor": "een pakketsensor",
+ "Parcel": "Pakket",
+ "Delivered": "Bezorgd",
+ "On the way": "Onderweg",
+ "parcel on the way": "pakket onderweg",
+ "parcels on the way": "pakketten onderweg",
+ "{0} today": "{0} vandaag",
+ "No parcels on the way.": "Geen pakketten onderweg.",
+ "Yesterday": "Gisteren",
+ "Air quality": "Luchtkwaliteit",
+ "Sensors (leave empty to find CO₂, fine dust and humidity per room)": "Sensoren (laat leeg om CO₂, fijnstof en luchtvochtigheid per ruimte te zoeken)",
+ "No air quality sensors found. Add CO₂, fine dust or humidity sensors in the inspector.": "Geen luchtkwaliteitssensoren gevonden. Voeg CO₂-, fijnstof- of vochtigheidssensoren toe in de inspector.",
+ "Good": "Goed",
+ "Fair": "Matig",
+ "Poor": "Slecht",
+ "Open a window": "Zet een raam open",
+ "Air the room or turn on the purifier": "Lucht de ruimte of zet de luchtreiniger aan",
+ "Too humid, air the room": "Te vochtig, lucht de ruimte",
+ "The air is dry": "De lucht is droog",
+ "Water and gas": "Water en gas",
+ "Meter (water, gas or energy)": "Meter (water, gas of energie)",
+ "Type": "Soort",
+ "water": "Water",
+ "gas": "Gas",
+ "electricity": "Stroom",
+ "Days to show": "Aantal dagen",
+ "a meter": "een meter",
+ "No statistics for this meter. It needs a state class (total or total_increasing).": "Geen statistieken voor deze meter. Hij heeft een state class nodig (total of total_increasing).",
+ "Today so far": "Vandaag tot nu",
+ "vs yesterday": "t.o.v. gisteren",
+ "Average": "Gemiddeld",
+ "{0} days": "{0} dagen",
+ "Average {0}": "Gemiddeld {0}",
+ "Solar forecast": "Zonneverwachting",
+ "Production today (kWh)": "Opbrengst vandaag (kWh)",
+ "Production tomorrow (kWh)": "Opbrengst morgen (kWh)",
+ "Power now (W)": "Vermogen nu (W)",
+ "Hours for the washer or dishwasher": "Uren voor de wasmachine of vaatwasser",
+ "the production today sensor": "de sensor voor de opbrengst vandaag",
+ "The hour by hour forecast isn’t available.": "De verwachting per uur is niet beschikbaar.",
+ "Best time for the washer: {0}": "Beste moment voor de was: {0}",
+ "Garage and gate": "Garage en poort",
+ "Garage door, gate or lock": "Garagedeur, poort of slot",
+ "garage": "Garage",
+ "gate": "Poort",
+ "door": "Deur",
+ "Ask before opening or closing": "Vragen voor openen of sluiten",
+ "a garage door or gate": "een garagedeur of poort",
+ "Press": "Indrukken",
+ "by {0}": "door {0}",
+ "Open {0}?": "{0} openen?",
+ "Close {0}?": "{0} sluiten?",
+ "Lock {0}?": "{0} op slot doen?",
+ "Unlock {0}?": "{0} ontgrendelen?",
+ "Stop {0}?": "{0} stoppen?",
+ "Press {0}?": "{0} indrukken?",
+ "Sports": "Sport",
+ "Team (TeamTracker sensor)": "Team (TeamTracker-sensor)",
+ "Only show on match days": "Alleen tonen op wedstrijddagen",
+ "Show the venue and TV channel": "Stadion en tv-zender tonen",
+ "a TeamTracker sensor": "een TeamTracker-sensor",
+ "No match this round.": "Geen wedstrijd deze speelronde.",
+ "No match planned.": "Geen wedstrijd gepland.",
+ "Live": "Live",
+ "Full time": "Afgelopen",
+ "Formula One": "Formule 1",
+ "Next race": "Volgende race",
+ "Driver standings": "Coureursklassement",
+ "Constructor standings": "Constructeursklassement",
+ "Last race results": "Uitslag laatste race",
+ "Show first": "Eerst tonen",
+ "drivers": "Coureurs",
+ "teams": "Teams",
+ "results": "Uitslag",
+ "Positions to show": "Aantal posities",
+ "the next race sensor": "de sensor voor de volgende race",
+ "in {0} d {1} h": "over {0} d {1} u",
+ "in {0} h {1} min": "over {0} u {1} min",
+ "Drivers": "Coureurs",
+ "Teams": "Teams",
+ "Last race": "Laatste race",
+ "{0} wins": "{0} zeges",
+ "pts": "pnt",
+ "Round {0}": "Ronde {0}",
+ "No standings yet.": "Nog geen klassement.",
+ "Practice 1": "Training 1",
+ "Practice 2": "Training 2",
+ "Practice 3": "Training 3",
+ "Sprint qualifying": "Sprintkwalificatie",
+ "Sprint": "Sprint",
+ "Qualifying": "Kwalificatie",
+ "Race": "Race",
+ "yesterday {0}": "gisteren {0}",
+ "Go to the next tab every (seconds)": "Naar het volgende tabblad elke (seconden)",
+ "Show announcements from Home Assistant": "Mededelingen uit Home Assistant tonen",
+ "Guest mode": "Gastmodus",
+ "A simple screen with only lights and thermostats, for guests or a babysitter.": "Een eenvoudig scherm met alleen lampen en thermostaten, voor gasten of de oppas.",
+ "Turns on": "Gaat aan",
+ "With the button": "Met de knop",
+ "With an entity": "Met een entiteit",
+ "On while this is on": "Aan zolang dit aan staat",
+ "Welcome text": "Welkomsttekst",
+ "Welcome": "Welkom",
+ "Lights and thermostats (leave empty for all)": "Lampen en thermostaten (laat leeg voor alle)",
+ "End guest mode on this device": "Gastmodus op dit apparaat beëindigen",
+ "Start guest mode on this device": "Gastmodus op dit apparaat starten",
+ "Announcement": "Mededeling",
+ "Guest mode is on.": "Gastmodus staat aan.",
+ "Guest mode is off.": "Gastmodus staat uit.",
+ "Lights and temperature": "Lampen en temperatuur",
+ "End guest mode?": "Gastmodus beëindigen?",
+ "Guest mode was turned on by a schedule or an entity, and comes back when that says so.": "Gastmodus is aangezet door een tijdschema of entiteit, en komt terug zodra die dat aangeeft.",
+ "End guest mode": "Gastmodus beëindigen",
+ "Moved widgets around": "Widgets verplaatst",
+ "Added {0}": "{0} toegevoegd",
+ "Added {0} widgets": "{0} widgets toegevoegd",
+ "Deleted {0}": "{0} verwijderd",
+ "Deleted {0} widgets": "{0} widgets verwijderd",
+ "Moved {0}": "{0} verplaatst",
+ "Moved {0} widgets": "{0} widgets verplaatst",
+ "Resized {0}": "Formaat van {0} gewijzigd",
+ "Resized {0} widgets": "Formaat van {0} widgets gewijzigd",
+ "Edited {0}": "{0} bewerkt",
+ "Edited {0} widgets": "{0} widgets bewerkt",
+ "Changed the tabs": "Tabbladen gewijzigd",
+ "Changed the look": "Uiterlijk gewijzigd",
+ "Changed the grid": "Raster gewijzigd",
+ "Changed the kiosk settings": "Kioskinstellingen gewijzigd",
+ "Changed guest mode": "Gastmodus gewijzigd",
+ "Renamed the dashboard": "Dashboard hernoemd",
+ "Changed the dashboard": "Dashboard gewijzigd",
+ "No changes yet in this session.": "Nog geen wijzigingen in deze sessie.",
+ "Undo history": "Geschiedenis van wijzigingen",
+ "Undo history…": "Geschiedenis van wijzigingen…",
+ "Pick a change to go back to just after it. Redo brings the later ones back.": "Kies een wijziging om terug te gaan naar net daarna. Opnieuw brengt de latere wijzigingen terug.",
+ "Before your changes": "Voor je wijzigingen",
+ "Save as preset": "Bewaren als voorinstelling",
+ "My presets": "Mijn voorinstellingen",
+ "Delete preset “{0}”": "Voorinstelling “{0}” verwijderen",
+ "Save": "Bewaren",
+ "Saved “{0}”. You’ll find it at the top of the widget list.": "“{0}” bewaard. Je vindt hem bovenaan de lijst met widgets.",
+ "Widgets you already added from it stay.": "Widgets die je er al mee hebt toegevoegd blijven staan.",
+ "Wall tablet": "Wandtablet",
+ "Clock, weather, calendar, rooms and cameras for a screen on the wall.": "Klok, weer, agenda, ruimtes en camera’s voor een scherm aan de muur.",
+ "Phone": "Telefoon",
+ "A 4-column layout for your phone: people, lights, climate and media.": "Een indeling met 4 kolommen voor je telefoon: personen, lampen, klimaat en media.",
+ "Power flow, solar forecast, water and gas use and history graphs.": "Energiestromen, zonneverwachting, water- en gasverbruik en grafieken.",
+ "Power, last 24 hours": "Vermogen, afgelopen 24 uur",
+ "Family": "Gezin",
+ "Who’s home, the family calendar, shopping list, parcels and the weather.": "Wie er thuis is, de gezinsagenda, boodschappenlijst, pakketten en het weer.",
+ "Empty": "Leeg",
+ "Start with a blank grid.": "Begin met een leeg raster.",
+ "From my devices": "Vanuit mijn apparaten",
+ "A mix of your lights, sensors and devices.": "Een mix van je lampen, sensoren en apparaten.",
+ "Start from": "Beginnen met",
+ "Switch to a dark or light version with the device": "Wisselen naar een donkere of lichte versie met het apparaat",
+ "QR code for {0}": "QR-code voor {0}",
+ "Share “{0}”": "“{0}” delen",
+ "Open this link or scan the code on another device that is signed in to Home Assistant.": "Open deze link of scan de code op een ander apparaat dat is ingelogd bij Home Assistant.",
+ "Link": "Link",
+ "Open in kiosk mode": "Openen in kioskmodus",
+ "Link copied.": "Link gekopieerd.",
+ "Share…": "Delen…",
+ "Import from Home Assistant…": "Importeren uit Home Assistant…",
+ "Import from Home Assistant": "Importeren uit Home Assistant",
+ "Choose one of your Home Assistant dashboards. Its cards become widgets in a new dashboard here; the original stays as it is.": "Kies een van je Home Assistant-dashboards. De kaarten worden widgets in een nieuw dashboard hier; het origineel blijft zoals het is.",
+ "Import": "Importeren",
+ "Couldn’t load that dashboard: {0}": "Dat dashboard kon niet worden geladen: {0}",
+ "That dashboard is generated by Home Assistant and has no cards of its own to import.": "Dat dashboard wordt door Home Assistant zelf gemaakt en heeft geen eigen kaarten om te importeren.",
+ "Imported {0} widgets. {1} cards couldn’t be converted.": "{0} widgets geïmporteerd. {1} kaarten konden niet worden omgezet.",
+ "Imported {0} widgets.": "{0} widgets geïmporteerd.",
+ "That shortcut’s widget no longer exists.": "De widget van die snelkoppeling bestaat niet meer.",
+ "Done: {0}": "Gedaan: {0}",
+ "Home screen": "Beginscherm",
+ "Shortcut on the app icon": "Snelkoppeling op het app-pictogram",
+ "When Dashboard Studio is installed as an app on Android, long-press its icon to run this widget straight away. Up to four shortcuts.": "Als Dashboard Studio als app op Android is geïnstalleerd, houd je het pictogram ingedrukt om deze widget meteen te gebruiken. Maximaal vier snelkoppelingen.",
+ "Install as an app…": "Installeren als app…",
+ "Install as an app": "Installeren als app",
+ "Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there.": "De pagina van Home Assistant zelf kan niet worden geïnstalleerd. Open Dashboard Studio eerst in een eigen tabblad en kies daar opnieuw Installeren als app.",
+ "In Safari, tap the Share button, then Add to Home Screen.": "Tik in Safari op de deelknop en daarna op Zet op beginscherm.",
+ "Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.": "Je browser kan Dashboard Studio installeren als app met een eigen pictogram. Hij opent schermvullend, zonder de balken van de browser.",
+ "In your browser’s menu, choose Install app or Add to Home screen.": "Kies in het menu van je browser App installeren of Toevoegen aan startscherm.",
+ "It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon.": "Hij opent het dashboard dat voor dat apparaat is gekozen. Markeer knoppen als snelkoppeling om ze vanaf het app-pictogram te gebruiken.",
+ "Open in its own tab": "Openen in een eigen tabblad",
+ "Offline · {0}": "Offline · {0}",
+ "Offline. Showing the states from {0}.": "Offline. De standen van {0} worden getoond.",
+ "Offline. Reconnecting…": "Offline. Opnieuw verbinden…",
+ "Show on": "Tonen op",
+ "All screens": "Alle schermen",
+ "Phones": "Telefoons",
+ "Larger screens": "Grotere schermen",
+ "Vibrate briefly when you press something (phones that support it)": "Kort trillen als je iets indrukt (telefoons die dat kunnen)",
+ "Edit widget": "Widget bewerken",
+ "Dashboard settings": "Dashboardinstellingen",
+ "When you’re away from home, open": "Als je niet thuis bent, openen",
+ "Nothing special": "Niets bijzonders",
+ "Away means this person is away": "Weg betekent: deze persoon is weg",
+ "Leave the person empty to follow whoever is signed in on the device. Not used in kiosk mode.": "Laat de persoon leeg om te volgen wie er op het apparaat is ingelogd. Niet in kioskmodus.",
+ "You’re away: showing “{0}”.": "Je bent weg: “{0}” wordt getoond.",
+ "Settings": "Instellingen",
+ "Touch and hold": "Ingedrukt houden",
+ "Touch and hold to": "Ingedrukt houden om",
+ "Show the details": "De details te tonen",
+ "Switch on or off": "Aan of uit te zetten",
+ "Run an action": "Een actie uit te voeren",
+ "Do nothing": "Niets te doen",
+ "A soft click when something is pressed": "Een zachte klik als er iets wordt ingedrukt",
+ "Every device is on a dashboard already.": "Elk apparaat staat al op een dashboard.",
+ "Not on a dashboard yet": "Nog niet op een dashboard",
+ "These devices aren’t on any of your dashboards. Add one to this tab with the widget that suits it.": "Deze apparaten staan op geen van je dashboards. Voeg er een toe aan dit tabblad met de widget die erbij past.",
+ "1 device isn’t on a dashboard": "1 apparaat staat niet op een dashboard",
+ "{0} devices aren’t on a dashboard": "{0} apparaten staan niet op een dashboard",
+ "Devices not on a dashboard…": "Apparaten die niet op een dashboard staan…",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
diff --git a/dashboard_studio/web/icon-192.png b/dashboard_studio/web/icon-192.png
new file mode 100644
index 0000000..fa80204
Binary files /dev/null and b/dashboard_studio/web/icon-192.png differ
diff --git a/dashboard_studio/web/icon-512.png b/dashboard_studio/web/icon-512.png
new file mode 100644
index 0000000..ff08f01
Binary files /dev/null and b/dashboard_studio/web/icon-512.png differ
diff --git a/dashboard_studio/web/icons.js b/dashboard_studio/web/icons.js
index 7fdca5c..477cb16 100644
--- a/dashboard_studio/web/icons.js
+++ b/dashboard_studio/web/icons.js
@@ -151,6 +151,18 @@
"arrow-up": "M12 19V5M5 12l7-7 7 7",
"arrow-down": "M12 5v14M19 12l-7 7-7-7",
"copy-to": "M9 9h11v11H9zM5 15H4V4h11v1M13 14.5h4M15 12.5l2 2-2 2",
+ package: "M21 8l-9-5-9 5v8l9 5 9-5zM3 8l9 5 9-5M12 13v8M7.5 5.5l9 5",
+ trophy: "M8 4h8v5a4 4 0 0 1-8 0zM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M12 13v4M8 21h8M10 17h4v4h-4z",
+ flag: "M5 21V4M5 4h11l-2 4 2 4H5",
+ share: "M12 3v12M8 7l4-4 4 4M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7",
+ qr: "M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h2v2h-2zM18 14h2M14 18h2M18 18h2v2M17 17h1",
+ layout: "M4 4h16v6H4zM4 14h7v6H4zM15 14h5v6h-5z",
+ bookmark: "M6 3h12v18l-6-4-6 4z",
+ megaphone: "M3 10v4h4l7 5V5L7 10zM17 9a4 4 0 0 1 0 6M19.5 6.5a8 8 0 0 1 0 11",
+ import: "M12 3v12M8 11l4 4 4-4M5 19h14",
+ refresh: "M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7",
+ air: "M3 8h10a3 3 0 1 0-3-3M3 12h15a3 3 0 1 1-3 3M3 16h7",
+ gate: "M3 21V5M21 21V5M3 9h18M3 15h18M8 9v6M13 9v6M18 9v6",
};
const FILLED = new Set(["play", "pause", "next", "prev"]);
// Dutch (and some English) words for the icon search, so "keuken" or "slaapkamer" find something.
@@ -167,6 +179,8 @@
paw: "huisdier hond kat", bike: "fiets", bulb: "lamp licht", plug: "stopcontact stekker", fan: "ventilator", thermometer: "temperatuur thermostaat",
droplet: "water vocht", washer: "wasmachine", dryer: "droger", dishwasher: "vaatwasser", vacuum: "stofzuiger robot", car: "auto", lock: "slot",
camera: "camera", speaker: "speaker luidspreker", music: "muziek", door: "deur", window: "raam", timer: "timer kookwekker", people: "personen familie",
+ package: "pakket pakje post bezorging doos", trophy: "sport wedstrijd beker voetbal", flag: "vlag formule 1 race", air: "lucht luchtkwaliteit co2",
+ gate: "hek poort oprit", megaphone: "omroep mededeling bericht", share: "delen", qr: "qr code", bookmark: "favoriet bewaren",
};
/** Icon names matching a search, by name or by the words above. */
diff --git a/dashboard_studio/web/index.html b/dashboard_studio/web/index.html
index 7932635..1cb60fb 100644
--- a/dashboard_studio/web/index.html
+++ b/dashboard_studio/web/index.html
@@ -9,6 +9,13 @@
Dashboard Studio
+
+
+
+
+
+
+