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.")}

+ `, + (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); }); + if (choice === "tab") window.open(new URL(`?ds=${current.id}`, BASE).href, "_blank", "noopener"); + if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; } + } + + /** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so + * after a while requests would fail. Renew it the way Home Assistant's own page does, with the + * login that page saved in this browser. Inside Home Assistant this isn't needed. */ + async function keepIngressSession() { + let top = true; + try { top = window.top === window; } catch { top = false; } + if (!top || !location.pathname.startsWith("/api/hassio_ingress/")) return; + const renew = async () => { + let tokens; + try { tokens = JSON.parse(localStorage.getItem("hassTokens") || "null"); } catch { tokens = null; } + if (!tokens?.refresh_token) return; + if (!tokens.access_token || Date.now() > (tokens.expires || 0) - 60000) { + const r = await fetch("/auth/token", { method: "POST", body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: tokens.clientId || `${location.origin}/` }) }); + if (!r.ok) return; + const n = await r.json(); + tokens = { ...tokens, access_token: n.access_token, expires_in: n.expires_in, expires: Date.now() + n.expires_in * 1000 }; + localStorage.setItem("hassTokens", JSON.stringify(tokens)); + } + const r = await fetch("/api/hassio/ingress/session", { method: "POST", headers: { Authorization: `Bearer ${tokens.access_token}` } }); + if (!r.ok) return; + const session = (await r.json())?.data?.session; + if (session) document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`; + }; + try { await renew(); } catch { /* offline: the cached page carries on */ } + setInterval(() => renew().catch(() => {}), 5 * 60 * 1000); + } + + // ------------------------------------------------------------ last known states + // The states the open dashboard uses are kept on this device, so it can show them while offline. + let stateCacheTimer = 0, lastLive = 0; + function cacheStates() { + if (stateCacheTimer) return; // at most every few seconds, however busy the states are + stateCacheTimer = setTimeout(() => { + stateCacheTimer = 0; + if (!current || !ha.connected) return; + const ids = new Set(["sun.sun"]); + for (const t of current.tabs) for (const w of t.widgets) entitiesOf(w).forEach((e) => ids.add(e)); + const states = {}; + for (const id of ids) if (ha.states[id]) states[id] = ha.states[id]; + const json = JSON.stringify({ at: Date.now(), states }); + if (json.length < 1.5e6) lsSet("ds.states", json); + }, 5000); + } + function loadCachedStates() { + try { + const c = JSON.parse(lsGet("ds.states") || "null"); + if (c?.states && !Object.keys(ha.states).length) { ha.states = c.states; lastLive = c.at; } + } catch { /* nothing kept */ } + } + const offlineText = () => (lastLive ? tr("Offline · {0}", new Date(lastLive).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : tr("Offline")); + let offlineTimer = 0; + function showOffline(on) { + const el = $("#offline-note"); + if (!on) { clearTimeout(offlineTimer); offlineTimer = 0; el.hidden = true; return; } + if (offlineTimer || !el.hidden) return; // already counting, or showing (each reconnect attempt reports again) + // a short blip (an update, a reconnect) shouldn't flash a note + offlineTimer = setTimeout(() => { + offlineTimer = 0; + el.textContent = lastLive ? tr("Offline. Showing the states from {0}.", new Date(lastLive).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : tr("Offline. Reconnecting…"); + el.hidden = false; + }, 4000); + } + + // ------------------------------------------------------------ sharing + // A link to Home Assistant's own page for this app, with the dashboard in it, so it opens + // through the sidebar on any device that is signed in to Home Assistant. + function sharedParams() { + const out = new URLSearchParams(location.search); + try { if (window.top !== window) for (const [k, v] of new URLSearchParams(window.top.location.search)) out.set(k, v); } catch { /* another origin */ } + return out; + } + function shareURL(id, inKiosk) { + let u; + try { u = new URL(window.top.location.href); } catch { u = new URL(location.href); } + u.hash = ""; u.search = ""; + u.searchParams.set("ds", id); + if (inKiosk) u.searchParams.set("ds_kiosk", "1"); + return u.href; + } + async function shareDialog() { + const draw = (inKiosk) => { + const url = shareURL(current.id, inKiosk); + $("#share-url").value = url; + $("#share-qr").innerHTML = window.QR.svg(url, { label: tr("QR code for {0}", current.name) }); + }; + const done = openModal(`

${tr("Share “{0}”", esc(current.name))}

+

${tr("Open this link or scan the code on another device that is signed in to Home Assistant.")}

+
+
+ + `, + async (e, close) => { + const x = e.target.closest("[data-x]")?.dataset.x; + if (x === "done") close(null); + if (x === "copy") { + try { await navigator.clipboard.writeText($("#share-url").value); toast(tr("Link copied.")); } + catch { $("#share-url").select(); document.execCommand?.("copy"); toast(tr("Link copied.")); } + } + }); + draw(false); + $("#share-kiosk").onchange = (e) => draw(e.target.checked); + await done; + } + + // ------------------------------------------------------------ import from Home Assistant + // Turns the cards of an existing Home Assistant dashboard into widgets, one tab per view. + const ent = (x) => (typeof x === "string" ? x : x?.entity) || ""; + function convertCard(card, out) { + const t = String(card.type || "").replace(/^custom:/, ""); + const e = card.entity || ""; + const ents = (card.entities || []).map(ent).filter(Boolean); + const add = (type, cfg, w, h) => out.push({ type, cfg, w, h }); + switch (t) { + case "vertical-stack": case "horizontal-stack": case "grid": (card.cards || []).forEach((c) => convertCard(c, out)); return; + case "entities": case "glance": if (ents.length) add("list", { title: card.title || "", entities: ents }, 4, Math.min(6, 1 + ents.length)); return; + case "light": add("light", { entity: e, name: card.name || "" }, 3, 2); return; + case "thermostat": case "climate": add("climate", { entity: e, name: card.name || "" }, 3, 3); return; + case "weather-forecast": add("forecast", { entity: e }, 6, 4); return; + case "media-control": add("media", { entities: [e] }, 4, 3); return; + case "markdown": add("text", { text: String(card.content || "").replace(/^#+\s*/gm, "").replace(/\*\*/g, "") }, 4, 2); return; + case "gauge": add("gauge", { entity: e, name: card.name || "", min: card.min ?? 0, max: card.max ?? 100 }, 3, 3); return; + case "history-graph": case "statistics-graph": case "sensor-graph": if (ents.length || e) add("graph", { title: card.title || "", entities: ents.length ? ents : [e], hours: card.hours_to_show || 24 }, 6, 3); return; + case "sensor": add("value", { entity: e, name: card.name || "" }, 3, 2); return; + case "picture-entity": case "picture-glance": { const cam = card.camera_image || (domainOf(e) === "camera" ? e : ""); if (cam) add("camera", { entity: cam }, 4, 3); return; } + case "calendar": if (ents.length) add("calendar", { entities: ents }, 4, 5); return; + case "todo-list": case "shopping-list": add("todo", { entity: e || "todo.shopping_list" }, 4, 4); return; + case "alarm-panel": add("alarm", { entity: e }, 4, 5); return; + case "area": add("room", { area: card.area || "" }, 4, 3); return; + case "tile": case "button": case "entity": case "mushroom-entity-card": case "mushroom-light-card": + if (domainOf(e) === "light") add("light", { entity: e, name: card.name || "" }, 3, 2); + else if (domainOf(e) === "climate") add("climate", { entity: e, name: card.name || "" }, 3, 3); + else if (e) add("entity", { entity: e, name: card.name || "" }, 3, 2); + return; + default: + if (e) add("entity", { entity: e, name: card.name || "" }, 3, 2); + else if (ents.length) add("list", { title: card.title || "", entities: ents }, 4, Math.min(6, 1 + ents.length)); + else out.skipped = (out.skipped || 0) + 1; + } + } + function convertLovelace(config, name) { + const d = newDashboard(name); + d.tabs = []; + let skipped = 0; + for (const view of config.views || []) { + const tabW = { ...newTab(view.title || tr("Tab {0}", d.tabs.length + 1), String(view.icon || "").replace(/^mdi:/, "")), widgets: [] }; + if (!Icons.has(tabW.icon)) tabW.icon = "tile"; + const cards = [...(view.cards || []), ...(view.sections || []).flatMap((sec) => sec.cards || [])]; + const out = []; + cards.forEach((c) => convertCard(c, out)); + skipped += out.skipped || 0; + for (const o of out) { + if (!Widgets[o.type]) continue; + const wd = makeWidget(o.type); + Object.assign(wd.cfg, o.cfg); + wd.w = o.w; wd.h = o.h; + Object.assign(wd, findSpot(wd.w, wd.h, tabW.widgets, d.grid.cols)); + tabW.widgets.push(wd); + } + d.tabs.push(tabW); + } + if (!d.tabs.length) d.tabs.push(newTab(tr("Overview"))); + d.nav.show = d.tabs.length > 1; + return { d, skipped }; + } + async function lovelaceDialog() { + if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } + const list = await ha.send({ type: "lovelace/dashboards/list" }).catch(() => []); + const opts = [{ url_path: "", title: tr("Overview") }, ...(list || [])]; + const pick = await openModal(`

${tr("Import from Home Assistant")}

+

${tr("Choose one of your Home Assistant dashboards. Its cards become widgets in a new dashboard here; the original stays as it is.")}

+ + `, + (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok" ? { path: $("#ll-pick").value, title: $("#ll-pick").selectedOptions[0].text } : null); }); + if (!pick) return; + let config; + try { config = await ha.send({ type: "lovelace/config", url_path: pick.path || null }); } + catch (e) { toast(tr("Couldn’t load that dashboard: {0}", e.message), true); return; } + if (config?.strategy && !config.views) { toast(tr("That dashboard is generated by Home Assistant and has no cards of its own to import."), true); return; } + const { d, skipped } = convertLovelace(config || {}, pick.title); + store.dashboards.push(d); + scheduleSave(); + setCurrent(d.id); + setEdit(true); + const n = d.tabs.reduce((a, t) => a + t.widgets.length, 0); + toast(skipped ? tr("Imported {0} widgets. {1} cards couldn’t be converted.", n, skipped) : tr("Imported {0} widgets.", n)); + } + + // ------------------------------------------------------------ templates + // Ready-made layouts, filled with whatever matching devices this Home Assistant has. + function templateBuilder(name, { mobile = false } = {}) { + const d = newDashboard(name); + if (mobile) Object.assign(d, { mobile: true, grid: { ...d.grid, cols: 4, maxWidth: 440 } }); + const cols = d.grid.cols, ws = d.tabs[0].widgets; + const ids = Object.keys(ha.states); + const of = (dom, test = () => true) => ids.filter((e) => domainOf(e) === dom && test(ha.states[e])); + const add = (type, w, h, cfg = {}) => { + const wd = makeWidget(type); + Object.assign(wd.cfg, cfg); + wd.w = Math.min(w, cols); wd.h = h; + Object.assign(wd, findSpot(wd.w, wd.h, ws, cols)); + ws.push(wd); + return wd; + }; + const dc = (s, ...c) => c.includes(s.attributes.device_class); + return { d, add, of, dc, ids }; + } + const TEMPLATES = { + wall: { icon: "kiosk", name: "Wall tablet", desc: "Clock, weather, calendar, rooms and cameras for a screen on the wall.", build() { + const { d, add, of } = templateBuilder(tr("Wall tablet")); + Object.assign(d.theme, PRESETS.Dark, { preset: "Dark" }); + d.kiosk.saver = 10; + const weather = of("weather")[0]; + add("clock", 4, 3, { weather: weather || "", date: true }); + if (weather) add("forecast", 8, 3, { entity: weather, show: "both" }); + const cals = of("calendar"); + if (cals.length) add("calendar", 4, 6, { entities: cals }); + for (const a of (ha.reg?.areas || []).slice(0, 4)) add("room", 4, 3, { area: a.area_id }); + const cam = of("camera")[0]; + if (cam) add("camera", 4, 3, { entity: cam }); + const players = of("media_player").slice(0, 4); + if (players.length) add("media", 4, 3, { entities: players }); + return d; + } }, + phone: { icon: "phone", name: "Phone", desc: "A 4-column layout for your phone: people, lights, climate and media.", build() { + const { d, add, of } = templateBuilder(tr("Phone"), { mobile: true }); + add("heading", 4, 1, { text: tr("Home"), subtitle: "", size: 26 }); + const people = of("person"); + if (people.length) add("people", 4, 2, { entities: people.slice(0, 4) }); + for (const e of of("light", (s) => !s.attributes.entity_id).slice(0, 4)) add("entity", 2, 2, { entity: e }); + const clim = of("climate")[0]; + if (clim) add("climate", 4, 3, { entity: clim }); + const weather = of("weather")[0]; + if (weather) add("weather", 4, 2, { entity: weather }); + const garage = of("cover", (s) => /garage|gate|poort|hek/i.test(s.entity_id + friendly(s)))[0]; + if (garage) add("gate", 2, 3, { entity: garage }); + const players = of("media_player").slice(0, 3); + if (players.length) add("media", 4, 3, { entities: players }); + return d; + } }, + energy: { icon: "bolt", name: "Energy", desc: "Power flow, solar forecast, water and gas use and history graphs.", build() { + const { d, add, of, dc } = templateBuilder(tr("Energy")); + add("heading", 12, 1, { text: tr("Energy"), subtitle: "" }); + const power = of("sensor", (s) => dc(s, "power")); + const find = (re) => power.find((e) => re.test(e)); + const solar = find(/solar|pv|zon/i), grid = find(/grid|net|p1/i), battery = find(/battery|accu/i); + if (solar || grid) add("energy", 6, 5, { solar: solar || "", grid: grid || "", battery: battery || "" }); + const today = of("sensor").find((e) => /energy_production_today/.test(e)); + if (today) add("solar", 6, 5, { today, ...W_AUTOFILL("solar", today) }); + for (const e of of("sensor", (s) => dc(s, "water", "gas") && /total/.test(s.attributes.state_class || "")).slice(0, 2)) + add("usage", 4, 3, { entity: e, kind: ha.states[e].attributes.device_class === "gas" ? "gas" : "water" }); + if (power.length) add("graph", 12, 3, { entities: power.slice(0, 3), title: tr("Power, last 24 hours") }); + return d; + } }, + family: { icon: "people", name: "Family", desc: "Who’s home, the family calendar, shopping list, parcels and the weather.", build() { + const { d, add, of } = templateBuilder(tr("Family")); + add("clock", 4, 2, { date: true, weather: of("weather")[0] || "" }); + const people = of("person"); + if (people.length) add("people", 8, 2, { entities: people }); + const cals = of("calendar"); + if (cals.length) add("calendar", 4, 6, { entities: cals }); + const todo = of("todo")[0]; + if (todo) add("todo", 4, 5, { entity: todo }); + const weather = of("weather")[0]; + if (weather) add("forecast", 4, 4, { entity: weather, show: "daily" }); + const parcels = of("sensor", (s) => Object.values(s.attributes).some((v) => Array.isArray(v) && typeof v[0] === "object" && /parcel|delivery|postnl|dhl|pakket/i.test(s.entity_id))); + if (parcels.length) add("parcels", 4, 4, { entities: parcels }); + const waste = of("sensor", (s) => /afvalwijzer/i.test(s.entity_id)); + if (waste.length) add("waste", 4, 3, {}); + add("notifications", 4, 3, {}); + return d; + } }, + }; + const W_AUTOFILL = (type, eid) => Object.fromEntries(Object.entries(Widgets[type].autofill?.fill(eid, ha) || {}).filter(([, v]) => v)); + + /** New dashboard: a name and where to start from. */ + async function templateDialog() { + const opts = [["empty", "tile", "Empty", "Start with a blank grid."], ...Object.entries(TEMPLATES).map(([k, t]) => [k, t.icon, t.name, t.desc]), ["devices", "sparkles", "From my devices", "A mix of your lights, sensors and devices."]]; + return openModal(`

${tr("New dashboard")}

+
+
${opts.map(([k, ic, n, desc], i) => ``).join("")}
+
`, + (e, close) => { if (e.target.closest("[data-x='cancel']")) close(null); }); + } + document.addEventListener("submit", (e) => { + if (e.target.id !== "tpl-form") return; + e.preventDefault(); + const name = $("#tpl-name").value.trim(), template = e.target.querySelector("input[name=tpl]:checked")?.value || "empty"; + if (name && template !== "devices" && template !== "empty" && !ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } + if (name) modalClose?.({ name, template }); + }); + function starterDashboard() { const d = newDashboard(tr("Home")); const ids = Object.keys(ha.states); @@ -295,7 +626,7 @@ const isSel = (id) => id === selectedId || multi.has(id); let activeTabId = null; /** The tab whose widgets are on screen. */ - const tab = () => current.tabs.find((t) => t.id === activeTabId) || current.tabs[0]; + const tab = () => (guestOn && !editMode ? guestTab() : current.tabs.find((t) => t.id === activeTabId) || current.tabs[0]); /** Cards inside a container card (scroll, swipe) live in its cfg.children. */ let childIndex = null; function findWidget(id, list = tab().widgets) { @@ -311,6 +642,7 @@ /** What the inspector edits: a card inside the selected container, or the selected widget. */ const editTarget = () => { const w = selectedWidget(); return (childIndex != null && w?.cfg.children?.[childIndex]) || w; }; const undoStack = [], redoStack = []; + const undoAt = [], redoAt = []; // when each step was made, for the undo history list let lastMutKey = null, lastMutAt = 0; const caches = {}; // per-widget runtime cache (graph history) @@ -343,8 +675,9 @@ const now = Date.now(); if (!(key && key === lastMutKey && now - lastMutAt < 1200)) { undoStack.push(JSON.stringify(current)); - if (undoStack.length > 100) undoStack.shift(); - redoStack.length = 0; + undoAt.push(now); + if (undoStack.length > 100) { undoStack.shift(); undoAt.shift(); } + redoStack.length = redoAt.length = 0; } lastMutKey = key; lastMutAt = now; fn(); @@ -361,8 +694,51 @@ lastMutKey = null; scheduleSave(); updateUndoButtons(); renderAll(); } - function undo() { if (undoStack.length) { redoStack.push(JSON.stringify(current)); restore(undoStack.pop()); } } - function redo() { if (redoStack.length) { undoStack.push(JSON.stringify(current)); restore(redoStack.pop()); } } + function undo() { if (undoStack.length) { redoStack.push(JSON.stringify(current)); redoAt.push(undoAt.pop()); restore(undoStack.pop()); } } + function redo() { if (redoStack.length) { undoStack.push(JSON.stringify(current)); undoAt.push(redoAt.pop()); restore(redoStack.pop()); } } + + /** What changed between two versions of a dashboard, in a few words. */ + function describeChange(a, b) { + const all = (d) => { const m = new Map(); const walk = (ws) => ws.forEach((w) => { m.set(w.id, w); walk(w.cfg?.children || []); }); d.tabs.forEach((t) => walk(t.widgets)); return m; }; + const wa = all(a), wb = all(b), name = (w) => tr(Widgets[w.type]?.label || w.type); + const added = [...wb.values()].filter((w) => !wa.has(w.id)), gone = [...wa.values()].filter((w) => !wb.has(w.id)); + const several = (list, one, many) => (list.length === 1 ? tr(one, name(list[0])) : tr(many, list.length)); + if (added.length && gone.length) return tr("Moved widgets around"); + if (added.length) return several(added, "Added {0}", "Added {0} widgets"); + if (gone.length) return several(gone, "Deleted {0}", "Deleted {0} widgets"); + const same = (x, y) => JSON.stringify(x) === JSON.stringify(y); + const changed = [...wb.values()].filter((w) => !same(w, wa.get(w.id))); + if (changed.length) { + const kind = (w) => { const o = wa.get(w.id); return o.x !== w.x || o.y !== w.y ? (o.w !== w.w || o.h !== w.h ? "size" : "move") : o.w !== w.w || o.h !== w.h ? "size" : "edit"; }; + const k = kind(changed[0]); + if (k === "move") return several(changed, "Moved {0}", "Moved {0} widgets"); + if (k === "size") return several(changed, "Resized {0}", "Resized {0} widgets"); + return several(changed, "Edited {0}", "Edited {0} widgets"); + } + if (!same(a.tabs.map((t) => [t.id, t.name, t.icon]), b.tabs.map((t) => [t.id, t.name, t.icon])) || !same(a.nav, b.nav)) return tr("Changed the tabs"); + if (!same(a.theme, b.theme)) return tr("Changed the look"); + if (!same(a.grid, b.grid) || a.mobile !== b.mobile) return tr("Changed the grid"); + if (!same(a.kiosk, b.kiosk)) return tr("Changed the kiosk settings"); + if (!same(a.guest, b.guest)) return tr("Changed guest mode"); + if (a.name !== b.name) return tr("Renamed the dashboard"); + return tr("Changed the dashboard"); + } + + /** The changes made in this session, newest first; picking one goes back to just after it. */ + async function undoHistoryDialog() { + if (!undoStack.length) { toast(tr("No changes yet in this session.")); return; } + const states = [...undoStack, JSON.stringify(current)]; + const rows = undoStack.map((snap, i) => ({ i, label: describeChange(JSON.parse(snap), JSON.parse(states[i + 1])), at: undoAt[i] })).reverse(); + const pick = await openModal(`

${tr("Undo history")}

${tr("Pick a change to go back to just after it. Redo brings the later ones back.")}

+
${rows.map((r, n) => ``).join("")} +
+ `, + (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "cancel" ? null : x); }); + if (pick == null) return; + const steps = undoStack.length - (Number(pick) + 1); + for (let i = 0; i < steps; i++) undo(); + } function updateUndoButtons() { $("#btn-undo").disabled = !undoStack.length; $("#btn-redo").disabled = !redoStack.length; @@ -371,8 +747,20 @@ // ------------------------------------------------------------ rendering const VAR_FOR = { bg: "--d-bg", tile: "--d-tile", text: "--d-text", text2: "--d-text-2", accent: "--d-accent", onAccent: "--d-on-accent", separator: "--d-separator", fill: "--d-fill", lit: "--d-lit" }; + /** A theme's light or dark twin, built from its accent, for themes set to follow the device. */ + function themeVariant(t) { + if (!t.follow || t.preset === "System" || t.preset === "Custom" || !t.bg || !/^#[0-9a-f]{6}$/i.test(t.bg)) return t; + const lum = (hex) => { const n = parseInt(hex.slice(1), 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; }; + const isDark = lum(t.bg) < 0.45, wantDark = prefersDark(); + if (isDark === wantDark) return t; + const a = t.accent || "#0a84ff"; + return wantDark + ? { ...t, bg: `color-mix(in srgb, ${a} 7%, #0f0f11)`, tile: `color-mix(in srgb, ${a} 9%, #1c1c1e)`, text: "#f5f5f7", text2: "#a1a1a6", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", onAccent: "#ffffff", shadow: false } + : { ...t, bg: `color-mix(in srgb, ${a} 7%, #f2f2f7)`, tile: `color-mix(in srgb, ${a} 3%, #ffffff)`, text: "#1d1d1f", text2: "#6e6e73", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", onAccent: "#ffffff", shadow: true }; + } + function applyTheme() { - const t = nightDark ? { ...current.theme, ...PRESETS.Dark, preset: "Dark" } : current.theme, s = stageEl.style; + const t = themeVariant(nightDark ? { ...current.theme, ...PRESETS.Dark, preset: "Dark" } : current.theme), s = stageEl.style; stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain"); stageEl.classList.add(`surface-${t.surface || "cards"}`); for (const k of COLOR_KEYS) { @@ -382,6 +770,7 @@ const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)"; if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`); s.setProperty("--d-radius", `${t.radius ?? 20}px`); + document.querySelector('meta[name="theme-color"]')?.setAttribute("content", /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7"); s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`); stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System"); s.setProperty("--d-blur", `${t.blur || 0}px`); @@ -393,13 +782,14 @@ const g = current.grid; const pad = window.innerWidth < 560 ? 12 : 20; const avail = canvasEl.clientWidth - pad * 2; - const width = g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail; + const landscape = !editMode && !!current.mobile && phoneLandscape.matches; + const width = landscape ? Math.min(avail, 900) : g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail; // Phone dashboards are drawn exactly as designed; others reflow into two columns on narrow screens. const compact = !editMode && width < 560 && !current.mobile; - const cols = compact ? 2 : Math.max(1, Number(g.cols) || 12); + const cols = compact ? 2 : landscape ? (Number(g.cols) || 4) * 2 : Math.max(1, Number(g.cols) || 12); const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0; const rh = Number(g.rowHeight) || 72; - metrics = { width, cols, gap, rh, colW: (width - gap * (cols - 1)) / cols, compact }; + metrics = { width, cols, gap, rh, colW: (width - gap * (cols - 1)) / cols, compact, landscape }; gridEl.style.width = `${width}px`; gridEl.style.margin = `${editMode ? 4 : 8}px auto ${pad}px`; } @@ -419,6 +809,8 @@ } } function isVisible(w) { + const scr = w.visibility?.screen, phone = matchMedia("(max-width: 600px)").matches; + if ((scr === "phone" && !phone) || (scr === "large" && phone)) return false; if (Widgets[w.type]?.autoHide?.(w.cfg, ctxFor(w))) return false; const rules = (w.visibility?.rules || []).filter((r) => r.entity); if (!rules.length) return true; @@ -430,6 +822,15 @@ function positions() { const map = {}; const shown = editMode ? tab().widgets : tab().widgets.filter(isVisible); + if (metrics.landscape) { // reading order, packed into the double-width grid + const placed = []; + for (const w of [...shown].sort((a, b) => a.y - b.y || a.x - b.x)) { + const p = { ...findSpot(w.w, w.h, placed, metrics.cols), w: w.w, h: w.h }; + map[w.id] = p; + placed.push(p); + } + return map; + } if (!metrics.compact) { if (editMode) { for (const w of shown) map[w.id] = w; return map; } // Rows that only hidden widgets used close up, so nothing leaves a hole. @@ -476,7 +877,7 @@ /** Side menu with the dashboard's tabs (a bottom tab bar on phones). */ function renderNav() { - const show = current.nav.show || (editMode && current.tabs.length > 1); + const show = !(guestOn && !editMode) && (current.nav.show || (editMode && current.tabs.length > 1)); document.body.classList.toggle("has-tabs", show); navEl.hidden = !show; if (!show) return; @@ -521,9 +922,11 @@ } const phoneScreen = matchMedia("(max-width: 600px)"); + // A phone on its side: wide but low. Phone dashboards then use two columns of their layout side by side. + const phoneLandscape = matchMedia("(orientation: landscape) and (max-height: 500px)"); function renderGrid() { if (!current) return; - document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && phoneScreen.matches); + document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && (phoneScreen.matches || phoneLandscape.matches)); renderNav(); applyTheme(); computeMetrics(); @@ -595,10 +998,13 @@ let interacting = false; function onStateChange(entityId) { + if (ha.connected) { lastLive = Date.now(); cacheStates(); } if (livePopup && livePopup.eid === entityId) livePopup.draw(); // an open climate popup follows the device if (!current) return; if (entityId === current.kiosk.doorbell || entityId == null) checkDoorbell(); if (entityId === "sun.sun" || entityId == null) applyNight(); + if (entityId === current.guest?.entity || entityId == null) checkGuest(); + if (entityId == null || entityId === awayEntity()) checkAway(); if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; } const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId)); if (affected.some((w) => isVisible(w) !== lastVisible[w.id])) { renderGrid(); return; } @@ -617,14 +1023,76 @@ $("#dash-select").innerHTML = store.dashboards.map((d) => ``).join(""); } + // ------------------------------------------------------------ suggestions + // Devices that aren't on any dashboard yet, each with the widget that suits it. + const SUGGEST = { light: "light", climate: "climate", cover: "covers", media_player: "media", camera: "camera", lock: "entity", switch: "entity", fan: "entity", + vacuum: "vacuum", alarm_control_panel: "alarm", todo: "todo", input_boolean: "entity", scene: "entity", weather: "forecast" }; + function suggestions() { + const used = new Set(); + for (const d of store.dashboards) for (const t of d.tabs) for (const w of t.widgets) { entitiesOf(w).forEach((e) => used.add(e)); ruleEntities(w).forEach((e) => used.add(e)); } + const hidden = new Set(); + // entities Home Assistant hides, and lights that are part of a group already on a dashboard + for (const st0 of Object.values(ha.states)) if (Array.isArray(st0.attributes.entity_id) && used.has(st0.entity_id)) st0.attributes.entity_id.forEach((e) => hidden.add(e)); + return Object.values(ha.states).filter((x) => SUGGEST[domainOf(x.entity_id)] && !used.has(x.entity_id) && !hidden.has(x.entity_id) && x.state !== "unavailable") + .sort((a, b) => domainOf(a.entity_id).localeCompare(domainOf(b.entity_id)) || friendly(a).localeCompare(friendly(b))); + } + async function suggestionsDialog() { + const list = suggestions(); + if (!list.length) { toast(tr("Every device is on a dashboard already.")); return; } + const draw = () => suggestions().map((x) => `
${window.HAFmt.icon(x)}${esc(friendly(x))}${esc(x.entity_id)} · ${esc(tr(Widgets[SUGGEST[domainOf(x.entity_id)]].label))}
`).join("") || `
${tr("Every device is on a dashboard already.")}
`; + await openModal(`

${tr("Not on a dashboard yet")}

${tr("These devices aren’t on any of your dashboards. Add one to this tab with the widget that suits it.")}

+
${draw()}
+ `, + (e, close) => { + const b = e.target.closest("[data-x]"); + if (!b) return; + if (b.dataset.x === "done") return close(null); + const eid = b.dataset.e, type = SUGGEST[domainOf(eid)]; + const wd = makeWidget(type); + if (Widgets[type].fields.some((f) => f.k === "entities")) wd.cfg.entities = [eid]; else wd.cfg.entity = eid; + wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols); + Object.assign(wd, findSpot(wd.w, wd.h)); + mutate(() => tab().widgets.push(wd)); + $(".sg-list").innerHTML = draw(); + renderPalette(); + }); + } + function renderPalette() { const search = $("#palette-search"), q = search.value.trim().toLowerCase(); search.placeholder = tr("Search widgets"); const items = Object.entries(Widgets).filter(([type, d]) => !q || [type, d.label, tr(d.label)].some((x) => x.toLowerCase().includes(q))); - $("#palette-list").innerHTML = items.length - ? items.map(([type, d]) => ``).join("") + const presets = (store.presets || []).filter((p) => !q || p.name.toLowerCase().includes(q)); + const presetHTML = presets.length ? `
${tr("My presets")}
${presets.map((p) => `
`).join("")}
${tr("Widgets")}
` : ""; + const nSug = ha.connected && store.dashboards?.length ? suggestions().length : 0; + const sugHTML = nSug && !q ? `` : ""; + $("#palette-list").innerHTML = items.length || presets.length + ? sugHTML + presetHTML + items.map(([type, d]) => ``).join("") : `

${tr("No widgets match “{0}”.", esc(search.value.trim()))}

`; } + + /** A set-up widget saved under a name, to add again on any dashboard. Kept with the dashboards. */ + async function savePreset(id) { + const w = findWidget(id); + if (!w) return; + const name = await askText(tr("Save as preset"), w.cfg.name || w.cfg.title || tr(Widgets[w.type].label), tr("Save")); + if (!name) return; + const { x, y, ...rest } = clone(w); + (store.presets ||= []).push({ id: uid(), name, widget: rest }); + scheduleSave(); + renderPalette(); + toast(tr("Saved “{0}”. You’ll find it at the top of the widget list.", name)); + } + function addPreset(pid) { + const p = (store.presets || []).find((x) => x.id === pid); + if (!p) return; + const wd = { ...clone(p.widget), id: uid() }; + (wd.cfg.children || []).forEach((ch) => (ch.id = uid())); + wd.w = Math.min(wd.w, current.grid.cols); + Object.assign(wd, findSpot(wd.w, wd.h)); + mutate(() => tab().widgets.push(wd)); + select(wd.id); + } $("#palette-search").addEventListener("input", renderPalette); $("#palette-search").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#palette-list .pal-item")?.click(); }); @@ -720,6 +1188,7 @@ function renderInspector() { const el = $("#inspector"); + updateEditBar(); if (multi.size > 1) { const btn = (k, l) => ``; el.innerHTML = `
${tr("{0} widgets selected", multi.size)}
@@ -752,7 +1221,11 @@ ${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}
${visibilityHTML(w)} -
+ ${holdHTML(w)} + ${["button", "entity", "light", "gate", "climate"].includes(w.type) ? `
${tr("Home screen")}
+
+

${tr("When Dashboard Studio is installed as an app on Android, long-press its icon to run this widget straight away. Up to four shortcuts.")}

` : ""} +

${tr("Drag a widget onto a scroll or swipe card to put it inside.")}

`; fillAlbums(el); return; @@ -764,6 +1237,9 @@
${tr("Tabs")}
${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")} ${fieldHTML({ k: "style", t: "seg", l: "Menu style", o: ["icons", "labels"] }, current.nav.style || "icons", "nav")} + ${current.tabs.length > 1 ? `${fieldHTML({ k: "awayTab", t: "select", l: "When you’re away from home, open", o: ["", ...current.tabs.map((t) => t.id)], labels: { "": tr("Nothing special"), ...Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) } }, current.nav.awayTab || "", "nav")} + ${current.nav.awayTab ? fieldHTML({ k: "awayPerson", t: "entity", l: "Away means this person is away", domains: ["person", "device_tracker"], ph: "person." }, current.nav.awayPerson || "", "nav") : ""} + ${current.nav.awayTab ? `

${tr("Leave the person empty to follow whoever is signed in on the device. Not used in kiosk mode.")}

` : ""}` : ""}
${current.tabs.map((t, i) => `
@@ -782,6 +1258,7 @@ }).join("")}

${tr("System follows your device’s light or dark appearance.")}

+ ${t.preset !== "System" && t.preset !== "Custom" ? fieldHTML({ k: "follow", t: "check", l: "Switch to a dark or light version with the device" }, t.follow, "theme") : ""} ${fieldHTML({ k: "surface", t: "seg", l: "Tile style", o: ["cards", "outline", "plain"] }, t.surface, "theme")} ${fieldHTML({ k: "radius", t: "number", l: "Corner radius (px)" }, t.radius, "theme")}
@@ -803,6 +1280,9 @@ ${fieldHTML({ k: "saverInterval", t: "number", l: "Change photo every (seconds)" }, k.saverInterval, "kiosk")}` : ""} ${fieldHTML({ k: "exitCorner", t: "select", l: "Exit corner (tap it 5 times to leave)", o: ["top-right", "top-left", "bottom-right", "bottom-left"] }, k.exitCorner, "kiosk")} ${fieldHTML({ k: "hideCursor", t: "check", l: "Hide the pointer when idle" }, k.hideCursor, "kiosk")} + ${current.tabs.length > 1 ? fieldHTML({ k: "rotate", t: "number", l: "Go to the next tab every (seconds)", ph: "0 = off" }, k.rotate, "kiosk") : ""} + ${fieldHTML({ k: "announce", t: "check", l: "Show announcements from Home Assistant" }, k.announce, "kiosk")} + ${fieldHTML({ k: "clickSound", t: "check", l: "A soft click when something is pressed" }, k.clickSound, "kiosk")} ${fieldHTML({ k: "doorbell", t: "entity", l: "Doorbell", domains: ["binary_sensor", "event", "input_boolean", "switch"] }, k.doorbell, "kiosk")} ${k.doorbell ? `${fieldHTML({ k: "doorbellCamera", t: "entity", l: "Camera to show", domains: ["camera"] }, k.doorbellCamera, "kiosk")}
${fieldHTML({ k: "doorbellSecs", t: "number", l: "Close after (seconds)" }, k.doorbellSecs, "kiosk")}
@@ -811,6 +1291,7 @@

${tr("On a wall tablet, open kiosk in a new tab to hide the Home Assistant sidebar too.")}

+ ${guestHTML()}
${tr("This device")}
${fieldHTML({ k: "lang", t: "select", l: "Language on this device", o: Object.keys(window.I18n.languages), labels: Object.fromEntries(Object.entries(window.I18n.languages).map(([c, n]) => [c, c === "auto" ? tr(n) : n])) }, window.I18n.preference(), "device")} ${nightHTML()} @@ -825,12 +1306,25 @@ fillAlbums(el); } + function guestHTML() { + const g = (current.guest ||= { ...DEFAULT_GUEST }); + return `
${tr("Guest mode")}
+

${tr("A simple screen with only lights and thermostats, for guests or a babysitter.")}

+ ${fieldHTML({ k: "when", t: "seg", l: "Turns on", o: ["manual", "entity", "times"], labels: { manual: tr("With the button"), entity: tr("With an entity"), times: tr("Between times") } }, g.when, "guest")} + ${g.when === "entity" ? fieldHTML({ k: "entity", t: "entity", l: "On while this is on", domains: ["input_boolean", "switch", "schedule", "binary_sensor"] }, g.entity, "guest") : ""} + ${g.when === "times" ? `
${fieldHTML({ k: "from", t: "time", l: "From" }, g.from, "guest")}${fieldHTML({ k: "to", t: "time", l: "Until" }, g.to, "guest")}
` : ""} + ${fieldHTML({ k: "title", t: "text", l: "Welcome text", ph: tr("Welcome") }, g.title, "guest")} + ${fieldHTML({ k: "entities", t: "entities", l: "Lights and thermostats (leave empty for all)", domains: ["light", "climate"] }, g.entities, "guest")} +
`; + } + function nightHTML() { const n = nightCfg(); return `${fieldHTML({ k: "mode", t: "seg", l: "Night mode", o: ["off", "sunset", "times"], labels: { off: tr("Off"), sunset: tr("After sunset"), times: tr("Between times") } }, n.mode, "night")} ${n.mode === "times" ? `
${fieldHTML({ k: "from", t: "time", l: "From" }, n.from, "night")}${fieldHTML({ k: "to", t: "time", l: "Until" }, n.to, "night")}
` : ""} ${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 `
${tr("Touch and hold")}
+ ${fieldHTML({ k: "action", t: "select", l: "Touch and hold to", o: ["details", "toggle", "service", "none"], labels: { details: tr("Show the details"), toggle: tr("Switch on or off"), service: tr("Run an action"), none: tr("Do nothing") } }, h.action || "details", "hold")} + ${h.action === "service" ? `${fieldHTML({ k: "service", t: "text", l: "Action (e.g. scene.turn_on)", ph: "script.good_night" }, h.service, "hold")}${fieldHTML({ k: "entity", t: "entity", l: "Target entity (optional)" }, h.entity, "hold")}${fieldHTML({ k: "data", t: "json", l: "Action data (JSON)" }, h.data, "hold")}` : ""}
`; + } + function visibilityHTML(w) { const vis = w.visibility || { mode: "all", rules: [] }; const common = ["on", "off", "running", "idle", "playing", "paused", "home", "not_home", "unavailable"]; @@ -913,6 +1416,7 @@ ? `

${isVisible(w) ? Icons.svg("eye") + tr("Visible right now") : Icons.svg("eye-off") + tr("Hidden right now")}

` : ""; return `
${tr("Visibility")}

${tr("Show this widget only when the conditions below are met. Leave empty to always show it.")}

+ ${fieldHTML({ k: "screen", t: "seg", l: "Show on", o: ["all", "phone", "large"], labels: { all: tr("All screens"), phone: tr("Phones"), large: tr("Larger screens") } }, vis.screen || "all", "vis")} ${vis.rules.length > 1 ? fieldHTML({ k: "mode", t: "seg", l: "Show when", o: ["all", "any"] }, vis.mode || "all", "vis") : ""} ${rows}${status}
`; @@ -927,6 +1431,13 @@ if (isChange && k === "icon") renderInspector(); return; } + if (path === "hold") { + const w = selectedWidget(); + if (t.dataset.json && v) { try { JSON.parse(v); t.classList.remove("invalid"); } catch { t.classList.add("invalid"); return; } } + mutate(() => { (w.hold ||= {})[k] = v; }, `${w.id}|hold|${k}`, { rerender: false }); + if (isChange && k === "action") renderInspector(); + return; + } if (path === "vis" || path === "rule") { const w = tab().widgets.find((x) => x.id === selectedId); mutate(() => { @@ -940,6 +1451,7 @@ if (path === "layout") { convertLayout(!!v); renderInspector(); return; } if (path === "nav") { mutate(() => { current.nav[k] = v; }); + if (k === "awayTab" || k === "awayPerson") { awayState = null; if (isChange) renderInspector(); } return; } if (path === "move") { @@ -954,6 +1466,14 @@ toast(tr("Moved to “{0}”.", target.name)); return; } + if (path === "guest") { + const g = current.guest; + if (idx === "new") { if (!isChange || !v) return; mutate(() => { (g[k] ||= []).push(v); }, null, { rerender: false }); } + else mutate(() => { if (idx != null) g[k][Number(idx)] = v; else g[k] = v; }, `guest|${k}|${idx ?? ""}`, { rerender: false }); + checkGuest(true); + if (isChange || t.tagName === "BUTTON") renderInspector(); + return; + } if (path === "night") { const n = nightCfg(); n[k] = v; @@ -1028,6 +1548,13 @@ function onInspectorInput(e) { const t = e.target; + if (t.matches?.("[data-haptics]")) { if (e.type === "change") { lsSet("ds.haptics", t.checked ? "1" : "0"); haptic(); } return; } + if (t.matches?.("[data-shortcut]")) { + if (e.type !== "change") return; + const w = selectedWidget(), s0 = ha.states[w.cfg.entity]; + mutate(() => { if (t.checked) w.shortcut = w.cfg.name || w.cfg.label || (s0 ? friendly(s0) : tr(Widgets[w.type].label)); else delete w.shortcut; }, null, { rerender: false }); + return; + } const albums = t.closest?.(".albums"); if (albums) { if (e.type !== "change") return; @@ -1094,12 +1621,28 @@ return { x: 0, y: 0 }; } + // On a phone, the widget list and the settings are sheets that slide up from the bottom. + const phoneEditing = () => editMode && matchMedia("(max-width: 640px)").matches; + function openSheet(name) { + document.body.classList.toggle("sheet-palette", name === "palette"); + document.body.classList.toggle("sheet-inspector", name === "inspector"); + $("#sheet-backdrop").hidden = !name; + } + function updateEditBar() { + $("#eb-insp").innerHTML = `${Icons.svg(selectedId ? "edit" : "sliders")}${esc(selectedId ? tr("Edit") : tr("Settings"))}`; + } + $("#eb-add").onclick = () => openSheet("palette"); + $("#eb-insp").onclick = () => openSheet("inspector"); + $("#sheet-backdrop").onclick = () => openSheet(null); + document.querySelectorAll(".sheet-close").forEach((b) => (b.onclick = () => openSheet(null))); + function addWidget(type) { const wd = makeWidget(type); wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols); Object.assign(wd, findSpot(wd.w, wd.h)); mutate(() => tab().widgets.push(wd)); select(wd.id); + if (phoneEditing()) openSheet(null); gridEl.querySelector(`.w[data-id="${wd.id}"]`)?.scrollIntoView({ block: "nearest", behavior: "smooth" }); } @@ -1283,6 +1826,8 @@ if (e.target.closest("[data-tool]")) return; const w = tab().widgets.find((x) => x.id === el.dataset.id); if (e.shiftKey || e.metaKey || e.ctrlKey) { select(w.id, true); e.preventDefault(); return; } + // By touch, the first tap selects (so the page can still scroll); a selected widget drags. + if (e.pointerType === "touch" && w.id !== selectedId && !multi.has(w.id)) { select(w.id); return; } const mode = e.target.closest(".w-resize") ? "resize" : "move"; if (multi.has(w.id) && mode === "move") { startGroupDrag(e, el); return; } if (selectedId !== w.id) select(w.id); @@ -1290,7 +1835,7 @@ ph.className = "placeholder"; gridEl.appendChild(ph); placeEl(ph, w); - drag = { w, el, ph, mode, sx: e.clientX, sy: e.clientY, orig: { x: w.x, y: w.y, w: w.w, h: w.h }, next: { x: w.x, y: w.y, w: w.w, h: w.h }, r0: rectFor(w) }; + drag = { w, el, ph, mode, touch: e.pointerType === "touch", sx: e.clientX, sy: e.clientY, orig: { x: w.x, y: w.y, w: w.w, h: w.h }, next: { x: w.x, y: w.y, w: w.w, h: w.h }, r0: rectFor(w) }; el.classList.add("dragging"); el.setPointerCapture(e.pointerId); e.preventDefault(); @@ -1355,6 +1900,7 @@ n.h = Math.max(1, o.h + Math.round(dy / (rh + gap))); } placeEl(drag.ph, n); + showGuides(n, drag.w); if (drag.mode === "move" && !Widgets[drag.w.type]?.container) { const into = containerAt(e.clientX, e.clientY); if (into !== drag.into) { drag.into?.classList.remove("drop-into"); into?.classList.add("drop-into"); drag.into = into; } @@ -1364,7 +1910,19 @@ updateGridHeight(pos); }); + /** Lines where the dragged widget's edges line up with other widgets, like in design apps. */ + function showGuides(n, w) { + gridEl.querySelectorAll(".guide").forEach((g) => g.remove()); + const { colW, gap, rh } = metrics; + const others = tab().widgets.filter((o) => o !== w); + const xs = new Set(others.flatMap((o) => [o.x, o.x + o.w])), ys = new Set(others.flatMap((o) => [o.y, o.y + o.h])); + const line = (cls, px) => { const g = document.createElement("div"); g.className = `guide ${cls}`; g.style[cls === "v" ? "left" : "top"] = `${Math.max(0, px)}px`; gridEl.appendChild(g); }; + for (const x of new Set([n.x, n.x + n.w])) if (xs.has(x)) line("v", x * (colW + gap) - gap / 2); + for (const y of new Set([n.y, n.y + n.h])) if (ys.has(y)) line("h", y * (rh + gap) - gap / 2); + } + function endDrag() { + gridEl.querySelectorAll(".guide").forEach((g) => g.remove()); if (!drag) return; if (drag.group) { const { group, cx, cy } = drag; @@ -1377,7 +1935,7 @@ }); return; } - const { w, ph, el, orig, next, into } = drag; + const { w, ph, el, orig, next, into, touch: touchEnd } = drag; ph.remove(); el.classList.remove("dragging"); into?.classList.remove("drop-into"); @@ -1392,7 +1950,11 @@ toast(tr("Moved into “{0}”.", tr(Widgets[box.type].label))); return; } - if (orig.x === next.x && orig.y === next.y && orig.w === next.w && orig.h === next.h) { placeEl(el, w); return; } + if (orig.x === next.x && orig.y === next.y && orig.w === next.w && orig.h === next.h) { + placeEl(el, w); + if (touchEnd && phoneEditing()) openSheet("inspector"); // tapping the selected widget opens its settings + return; + } mutate(() => { Object.assign(w, next); resolveCollisions(w); }); if (!multi.has(w.id)) select(w.id); } @@ -1414,6 +1976,7 @@ } gridEl.addEventListener("click", (e) => { + if (!editMode && e.target.closest("[data-act]")) haptic(); if (editMode) { const tool = e.target.closest("[data-tool]"); if (tool) { @@ -1432,6 +1995,13 @@ const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10; ha.callService("climate", "set_temperature", { temperature: t }, eid); } else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid); + else if (act === "gate") gateAction(eid, a.dataset.v === "1"); + else if (act === "card-tab") { // a card's own tabs (Formula One): kept in its cache, not saved + const id = a.closest(".w[data-id]")?.dataset.id; + (caches[id] ||= {}).tab = a.dataset.v; + const top = tab().widgets.find((w) => w.id === id || findWidget(id, w.cfg.children || [])); + if (top) renderWidget(top); + } else if (act === "light-color") { const [kind, val] = a.dataset.v.split(":"); ha.callService("light", "turn_on", kind === "k" ? { color_temp_kelvin: Number(val) } : { rgb_color: val.split(",").map(Number) }, eid); @@ -1495,13 +2065,7 @@ else if (act === "sw-go") caches[a.closest(".w:not(.child)").dataset.id]?.go?.(Number(a.dataset.v)); else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); } else if (act === "button") { - const c = findWidget(a.closest(".w").dataset.id).cfg; - let data = {}; - if (c.data) { try { data = JSON.parse(c.data); } catch { toast(tr("The button’s action data isn’t valid JSON. Fix it in the inspector."), true); return; } } - if (c.service && c.service.includes(".")) { const [d, s] = c.service.split("."); ha.callService(d, s, data, c.entity || undefined); } - else if (c.entity) defaultAction(c.entity); - else { toast(tr("This button has no action yet. Set one in the inspector."), true); return; } - a.animate?.([{ transform: "scale(.96)" }, { transform: "scale(1)" }], 160); + if (runButton(findWidget(a.closest(".w").dataset.id).cfg)) a.animate?.([{ transform: "scale(.96)" }, { transform: "scale(1)" }], 160); } }); @@ -1519,6 +2083,7 @@ }); gridEl.addEventListener("change", (e) => { const t = e.target, eid = t.dataset.e; + if (!editMode) haptic(); interacting = false; if (t.dataset.act === "brightness") ha.callService("light", "turn_on", { brightness_pct: Number(t.value) }, eid); if (t.dataset.act === "volume") ha.callService("media_player", "volume_set", { volume_level: Number(t.value) / 100 }, eid); @@ -1561,6 +2126,43 @@ }); /** Like confirmDestructive, for actions that aren't destructive. */ + /** A button widget's action: its own action with data, or the default for its entity. */ + function runButton(c) { + let data = {}; + if (c.data) { try { data = JSON.parse(c.data); } catch { toast(tr("The button’s action data isn’t valid JSON. Fix it in the inspector."), true); return false; } } + if (c.service && c.service.includes(".")) { const [d, sv] = c.service.split("."); ha.callService(d, sv, data, c.entity || undefined); } + else if (c.entity) defaultAction(c.entity); + else { toast(tr("This button has no action yet. Set one in the inspector."), true); return false; } + return true; + } + + /** A home screen shortcut (long-press the app icon): runs a widget's main action, wherever it is. */ + async function runShortcut(id) { + let w = null; + for (const d of store.dashboards) for (const t of d.tabs) w ||= findWidget(id, t.widgets); + if (!w) { toast(tr("That shortcut’s widget no longer exists."), true); return; } + await ha.ready; + const eid = w.cfg.entity || (w.cfg.entities || [])[0]; + if (w.type === "button") { if (!runButton(w.cfg)) return; } + else if (w.type === "gate") await gateAction(eid, w.cfg.confirm !== false); + else if (eid) defaultAction(eid); + else return; + toast(tr("Done: {0}", w.shortcut || w.cfg.name || w.cfg.label || friendly(ha.states[eid] || { entity_id: eid }))); + } + + /** The garage and gate card: open, close or stop, after a check when the card asks for one. */ + async function gateAction(eid, ask) { + const s = ha.states[eid]; + if (!s) return; + const d = domainOf(eid), open = ["open", "opening", "unlocked", "unlocking", "on"].includes(s.state), moving = ["opening", "closing"].includes(s.state); + const verb = d === "button" ? "Press" : moving ? "Stop" : open ? (d === "lock" ? "Lock" : "Close") : d === "lock" ? "Unlock" : "Open"; + if (ask && !(await confirmAction(tr(`${verb} {0}?`, friendly(s)), "", tr(verb)))) return; + if (d === "cover") ha.callService("cover", moving ? "stop_cover" : open ? "close_cover" : "open_cover", {}, eid); + else if (d === "lock") ha.callService("lock", open ? "lock" : "unlock", {}, eid); + else if (d === "button") ha.callService("button", "press", {}, eid); + else ha.callService(d, "toggle", {}, eid); + } + function confirmAction(title, message, label) { return openModal(`

${esc(title)}

${esc(message)}

`, @@ -1890,12 +2492,13 @@ promptOpen = true; const timer = setTimeout(() => modalClose?.(null), PROMPT_MS); const choice = await openModal(`

${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); }); 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 + + + + + + +
@@ -29,6 +36,7 @@ +
@@ -36,11 +44,16 @@ + + + +
+
@@ -48,6 +61,7 @@
+
+ + + +
@@ -73,6 +92,7 @@ + diff --git a/dashboard_studio/web/qr.js b/dashboard_studio/web/qr.js new file mode 100644 index 0000000..53839b7 --- /dev/null +++ b/dashboard_studio/web/qr.js @@ -0,0 +1,174 @@ +/* A small QR code encoder for share links: byte mode, error correction level M. + * Follows the structure of Project Nayuki's QR Code generator (MIT licence). + * QR.svg(text) returns an SVG string; QR.matrix(text, mask) the modules (true = dark). */ +(function () { + // Per version (index 1 to 40), for level M: error correction codewords per block, and the number of blocks. + const ECC = [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28]; + const BLOCKS = [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49]; + + const rawModules = (v) => { + let r = (16 * v + 128) * v + 64; + if (v >= 2) { const n = Math.floor(v / 7) + 2; r -= (25 * n - 10) * n - 55; if (v >= 7) r -= 36; } + return r; + }; + const dataCodewords = (v) => Math.floor(rawModules(v) / 8) - ECC[v] * BLOCKS[v]; + const bit = (x, i) => ((x >>> i) & 1) !== 0; + + // Reed-Solomon over GF(2^8) with the polynomial 0x11D. + function gfMul(x, y) { + let z = 0; + for (let i = 7; i >= 0; i--) { z = (z << 1) ^ ((z >>> 7) * 0x11d); z ^= ((y >>> i) & 1) * x; } + return z; + } + function rsDivisor(degree) { + const r = new Array(degree).fill(0); + r[degree - 1] = 1; + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < r.length; j++) { r[j] = gfMul(r[j], root); if (j + 1 < r.length) r[j] ^= r[j + 1]; } + root = gfMul(root, 0x02); + } + return r; + } + function rsRemainder(data, divisor) { + const r = divisor.map(() => 0); + for (const b of data) { + const f = b ^ r.shift(); + r.push(0); + divisor.forEach((c, i) => (r[i] ^= gfMul(c, f))); + } + return r; + } + + function encodeData(bytes) { + let ver = 1; + for (; ver <= 40; ver++) if (4 + (ver < 10 ? 8 : 16) + bytes.length * 8 <= dataCodewords(ver) * 8) break; + if (ver > 40) throw new Error("Text too long for a QR code"); + const bits = []; + const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); }; + put(4, 4); // byte mode + put(bytes.length, ver < 10 ? 8 : 16); + for (const b of bytes) put(b, 8); + const cap = dataCodewords(ver) * 8; + put(0, Math.min(4, cap - bits.length)); + put(0, (8 - (bits.length % 8)) % 8); + for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) put(pad, 8); + const data = []; + for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b2) => (a << 1) | b2, 0)); + // split into blocks, add error correction, interleave + const nBlocks = BLOCKS[ver], eccLen = ECC[ver], raw = Math.floor(rawModules(ver) / 8); + const nShort = nBlocks - (raw % nBlocks), shortLen = Math.floor(raw / nBlocks), div = rsDivisor(eccLen); + const blocks = []; + for (let i = 0, k = 0; i < nBlocks; i++) { + const dat = data.slice(k, k + shortLen - eccLen + (i < nShort ? 0 : 1)); + k += dat.length; + const ecc = rsRemainder(dat, div); + if (i < nShort) dat.push(0); + blocks.push(dat.concat(ecc)); + } + const out = []; + for (let i = 0; i < blocks[0].length; i++) blocks.forEach((b, j) => { if (i !== shortLen - eccLen || j >= nShort) out.push(b[i]); }); + return { ver, codewords: out }; + } + + function build(ver, codewords, mask) { + const size = ver * 4 + 17; + const mod = Array.from({ length: size }, () => new Array(size).fill(false)); + const fn = Array.from({ length: size }, () => new Array(size).fill(false)); + const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; }; + for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); } + const finder = (x, y) => { + for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) { + const d = Math.max(Math.abs(dx), Math.abs(dy)), xx = x + dx, yy = y + dy; + if (xx >= 0 && xx < size && yy >= 0 && yy < size) set(xx, yy, d !== 2 && d !== 4); + } + }; + finder(3, 3); finder(size - 4, 3); finder(3, size - 4); + const align = []; + if (ver > 1) { + const n = Math.floor(ver / 7) + 2, step = ver === 32 ? 26 : Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2; + align.push(6); + for (let pos = size - 7; align.length < n; pos -= step) align.splice(1, 0, pos); + } + align.forEach((ax, i) => align.forEach((ay, j) => { + if ((i === 0 && j === 0) || (i === 0 && j === align.length - 1) || (i === align.length - 1 && j === 0)) return; + for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); + })); + const format = (m) => { + const d = (0 << 3) | m; // level M has format bits 00 + let rem = d; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const b = ((d << 10) | rem) ^ 0x5412; + for (let i = 0; i <= 5; i++) set(8, i, bit(b, i)); + set(8, 7, bit(b, 6)); set(8, 8, bit(b, 7)); set(7, 8, bit(b, 8)); + for (let i = 9; i < 15; i++) set(14 - i, 8, bit(b, i)); + for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(b, i)); + for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(b, i)); + set(8, size - 8, true); + }; + format(0); + if (ver >= 7) { + let rem = ver; + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const b = (ver << 12) | rem; + for (let i = 0; i < 18; i++) { const a = size - 11 + (i % 3), c = Math.floor(i / 3); set(a, c, bit(b, i)); set(c, a, bit(b, i)); } + } + // the data, in the zigzag order + let i = 0; + for (let right = size - 1; right >= 1; right -= 2) { + if (right === 6) right = 5; + for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) { + const x = right - j, up = ((right + 1) & 2) === 0, y = up ? size - 1 - vert : vert; + if (!fn[y][x] && i < codewords.length * 8) { mod[y][x] = bit(codewords[i >>> 3], 7 - (i & 7)); i++; } + } + } + const masks = [(x, y) => (x + y) % 2 === 0, (x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0, + (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0, + (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0]; + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && masks[mask](x, y)) mod[y][x] = !mod[y][x]; + format(mask); + return mod; + } + + /** How hard a pattern is to scan (lower is better): runs, blocks, finder look-alikes and balance. */ + function penalty(m) { + const n = m.length; + let p = 0, dark = 0; + const lines = [...m, ...m[0].map((_, x) => m.map((row) => row[x]))]; + for (const line of lines) { + let run = 1; + for (let i = 1; i <= n; i++) { + if (i < n && line[i] === line[i - 1]) run++; + else { if (run >= 5) p += run - 2; run = 1; } + } + const s = line.map((v) => (v ? 1 : 0)).join(""); + for (const pat of ["10111010000", "00001011101"]) for (let k = s.indexOf(pat); k !== -1; k = s.indexOf(pat, k + 1)) p += 40; + } + for (let y = 0; y < n - 1; y++) for (let x = 0; x < n - 1; x++) { + const c = m[y][x]; + if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) p += 3; + } + for (const row of m) for (const v of row) if (v) dark++; + p += Math.floor(Math.abs(dark * 20 - n * n * 10) / (n * n)) * 10; + return p; + } + + function matrix(text, mask) { + const { ver, codewords } = encodeData([...new TextEncoder().encode(text)]); + if (mask != null) return build(ver, codewords, mask); + let best = null, bestScore = Infinity; + for (let m = 0; m < 8; m++) { const mm = build(ver, codewords, m), sc = penalty(mm); if (sc < bestScore) { best = mm; bestScore = sc; } } + return best; + } + + function svg(text, { border = 3, label = "QR code" } = {}) { + const m = matrix(text), n = m.length + border * 2; + let d = ""; + m.forEach((row, y) => row.forEach((v, x) => { if (v) d += `M${x + border} ${y + border}h1v1h-1z`; })); + return ``; + } + + const api = { matrix, svg }; + if (typeof window !== "undefined") window.QR = api; + if (typeof module !== "undefined") module.exports = api; +})(); diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index 57eb5f1..440a48e 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -1093,6 +1093,238 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } } @container (max-height: 120px) { .tz.tz .tz-ctrls { display: none; } } +/* parcels */ +.pc { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; } +.pc-sum { font-size: 14px; color: var(--d-text-2); } +.pc-sum b { font-size: 22px; color: var(--d-text); margin-right: 2px; } +.pc-today { color: var(--d-accent); font-weight: 600; } +.pc-list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; min-height: 0; } +.pc-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); color: inherit; text-decoration: none; min-width: 0; } +.pc-row.today { background: color-mix(in srgb, var(--d-accent) 14%, transparent); } +.pc-row.done { opacity: .6; } +.pc-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--d-tile); font-size: 17px; flex: none; } +.pc-row.today .pc-ico { color: var(--d-accent); } +.pc-row.done .pc-ico { color: var(--ok); } +.pc-text { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.pc-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pc-status { font-size: 12px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pc-when { font-size: 12px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; text-align: right; } +.pc-row.today .pc-when { color: var(--d-accent); } + +/* air quality */ +.aq { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; min-height: 0; } +.aq-row { --aq: var(--ok); display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); } +.aq-row.lvl1 { --aq: var(--warn); } .aq-row.lvl2 { --aq: var(--crit); } +.aq-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--aq); box-shadow: 0 0 0 4px color-mix(in srgb, var(--aq) 22%, transparent); } +.aq-room { display: flex; flex-direction: column; min-width: 0; } +.aq-room b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.aq-room span { font-size: 12px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.aq-row.lvl2 .aq-room span { color: var(--crit); font-weight: 600; } +.aq-vals { display: flex; gap: 4px; } +.aq-val { display: flex; flex-direction: column; align-items: center; min-width: 44px; padding: 3px 6px; border-radius: 9px; background: var(--d-tile); } +.aq-val small { font-size: 10px; color: var(--d-text-2); } +.aq-val span { font-size: 13px; font-weight: 600; } +.aq-val.lvl1 span { color: var(--warn); } .aq-val.lvl2 span { color: var(--crit); } +@container (max-width: 300px) { .aq-val small { display: none; } .aq-val { min-width: 36px; } } + +/* water and gas */ +.us-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; min-width: 0; } +.us-head .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.us-ico { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 16px; color: #0a84ff; background: color-mix(in srgb, #0a84ff 16%, transparent); flex: none; } +.us-ico.gas { color: #ff9f0a; background: color-mix(in srgb, #ff9f0a 16%, transparent); } +.us-ico.electricity { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); } +.us { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; } +.us-now { display: flex; flex-direction: column; gap: 2px; } +.us-big { font-size: clamp(26px, 9cqw, 38px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; } +.us-big small { font-size: .45em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; } +.us-cmp { font-size: 12px; color: var(--d-text-2); } +.us-cmp .up { color: var(--warn); } .us-cmp .down { color: var(--ok); } +.us-bars { position: relative; flex: 1; min-height: 40px; display: flex; align-items: stretch; gap: 4px; } +.us-day { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 4px; min-width: 0; } +.us-bar { width: 100%; max-width: 26px; border-radius: 5px 5px 2px 2px; background: color-mix(in srgb, var(--d-text) 18%, transparent); } +.us-day.today .us-bar { background: #0a84ff; } +.us.gas .us-day.today .us-bar { background: #ff9f0a; } +.us.electricity .us-day.today .us-bar { background: var(--ok); } +.us-lbl { font-size: 10px; color: var(--d-text-2); } +.us-avg { position: absolute; left: 0; right: 0; height: 0; border-top: 1px dashed var(--d-text-2); opacity: .6; margin-bottom: 18px; pointer-events: none; } +.us-foot { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--d-text-2); } +@container (max-height: 200px) { .us-bars, .us-foot { display: none; } } + +/* solar forecast */ +.sf { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; } +.sf-top { display: flex; align-items: flex-end; gap: 18px; flex-wrap: wrap; } +.sf-top > div { display: flex; flex-direction: column; } +.sf-lbl { font-size: 12px; color: var(--d-text-2); } +.sf-big { font-size: clamp(28px, 10cqw, 42px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; } +.sf-mid { font-size: 18px; font-weight: 600; } +.sf-big small, .sf-mid small { font-size: .5em; color: var(--d-text-2); margin-left: 3px; font-weight: 600; } +.sf-bars { flex: 1; min-height: 50px; display: flex; align-items: stretch; gap: 3px; padding-bottom: 14px; } +.sf-h { position: relative; flex: 1; display: flex; align-items: flex-end; } +.sf-h i { width: 100%; border-radius: 4px 4px 1px 1px; background: color-mix(in srgb, #ffcc00 55%, transparent); } +.sf-h.past i { opacity: .45; } +.sf-h.best i { background: #ff9f0a; } +.sf-h.now i { background: #ffcc00; box-shadow: 0 0 0 2px var(--d-tile), 0 0 0 3px #ffcc00; } +.sf-h span { position: absolute; bottom: -15px; left: 50%; transform: translateX(-50%); font-size: 10px; color: var(--d-text-2); } +.sf-best { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; padding: 8px 10px; border-radius: 12px; background: color-mix(in srgb, #ff9f0a 14%, transparent); } +.sf-best .i { color: #ff9f0a; font-size: 18px; flex: none; } +@container (max-height: 220px) { .sf-bars { display: none; } } + +/* garage and gate */ +.gt { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; height: 100%; min-height: 0; } +.gt-head { display: flex; align-items: center; gap: 8px; min-width: 0; } +.gt-head .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gt-state { font-size: 12px; font-weight: 600; color: var(--d-text-2); } +.gt.open .gt-state { color: var(--warn); } +.gt-btn { justify-self: center; align-self: center; display: flex; flex-direction: column; align-items: center; gap: 6px; min-height: 0; } +.gt-ring { width: clamp(64px, 34cqmin, 116px); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(28px, 14cqmin, 50px); + background: var(--d-fill); box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--d-text) 10%, transparent); transition: background-color .25s, color .25s, transform .15s; } +.gt-btn:active .gt-ring { transform: scale(.96); } +.gt.open .gt-ring { background: color-mix(in srgb, var(--warn) 20%, transparent); color: var(--warn); box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--warn) 55%, transparent); } +.gt.moving .gt-ring { animation: gt-pulse 1.2s ease-in-out infinite; } +@keyframes gt-pulse { 50% { opacity: .55; } } +.gt-lbl { font-size: 14px; font-weight: 600; } +.gt-since { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (prefers-reduced-motion: reduce) { .gt.moving .gt-ring { animation: none; } } + +/* sports */ +.sp { display: flex; flex-direction: column; gap: 8px; height: 100%; justify-content: center; min-height: 0; } +.sp-league { font-size: 12px; font-weight: 600; color: var(--d-text-2); text-align: center; text-transform: uppercase; letter-spacing: .04em; } +.sp-match { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; } +.sp-team { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; text-align: center; } +.sp-team img { width: clamp(36px, 16cqmin, 64px); aspect-ratio: 1; object-fit: contain; } +.sp-badge { width: clamp(36px, 16cqmin, 64px); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-weight: 700; font-size: 13px; } +.sp-team > span:last-child { font-size: 13px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sp-team.win > span:last-child { color: var(--ok); } +.sp-mid { display: flex; flex-direction: column; align-items: center; gap: 2px; } +.sp-score { font-size: clamp(26px, 11cqmin, 40px); font-weight: 700; letter-spacing: -0.02em; } +.sp-score i { font-style: normal; color: var(--d-text-2); margin: 0 6px; } +.sp-time { font-size: clamp(22px, 9cqmin, 32px); font-weight: 700; } +.sp-sub { font-size: 12px; color: var(--d-text-2); white-space: nowrap; } +.sp-live { color: var(--crit); } +.sp-live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--crit); margin-right: 4px; vertical-align: 1px; animation: tz-pulse 1.4s infinite; } +.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; background: linear-gradient(135deg, color-mix(in srgb, #e10600 22%, var(--d-fill)), var(--d-fill)); } +.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); } +#announce[hidden] { display: none; } +.an-card { width: min(640px, 100%); display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; color: #fff; } +.an-ico { width: 84px; height: 84px; border-radius: 50%; display: grid; place-items: center; font-size: 40px; background: var(--accent); color: #fff; animation: an-pop .5s cubic-bezier(.2, 1.4, .4, 1); } +@keyframes an-pop { from { transform: scale(.4); opacity: 0; } } +.an-title { font-size: 18px; font-weight: 600; opacity: .8; } +.an-msg { font-size: clamp(30px, 6vw, 56px); font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; white-space: pre-line; } +.an-ok { min-height: 52px; min-width: 160px; font-size: 18px; margin-top: 10px; } +@media (prefers-reduced-motion: reduce) { .an-ico { animation: none; } } +#btn-guest[hidden] { display: none; } +#btn-guest { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); } + +/* editor: alignment guides, presets, templates, undo history */ +.guide { position: absolute; z-index: 6; pointer-events: none; background: #ff2d92; } +.guide.v { top: 0; bottom: 0; width: 1px; } +.guide.h { left: 0; right: 0; height: 1px; } +.pal-sub { grid-column: 1 / -1; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-2); margin: 6px 2px 0; } +.pal-preset { position: relative; display: grid; } +.pal-del { position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; border-radius: 50%; display: none; place-items: center; font-size: 12px; background: var(--surface); color: var(--text-2); } +.pal-preset:hover .pal-del, .pal-del:focus-visible { display: grid; } +.modal-card:has(.tpl-grid) { width: 640px; } +.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; margin-top: 14px; max-height: 52vh; overflow-y: auto; } +.tpl { display: flex; gap: 12px; align-items: flex-start; padding: 12px; border-radius: 14px; border: 1px solid var(--separator); cursor: pointer; } +.tpl input { position: absolute; opacity: 0; pointer-events: none; } +.tpl:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); } +.tpl:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; } +.tpl-ico { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; font-size: 20px; background: var(--fill); flex: none; } +.tpl > span:last-child { display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: var(--text-2); } +.tpl b { color: var(--text); font-size: 14px; } +.undo-row { width: 100%; text-align: left; border-radius: 10px; } +.undo-row:hover { background: var(--fill); } + +/* share */ +.share-qr { width: 220px; max-width: 70%; margin: 14px auto 12px; border-radius: 12px; overflow: hidden; box-shadow: 0 0 0 1px var(--separator); } +.share-qr svg { display: block; width: 100%; height: auto; } +.share-row { display: flex; gap: 8px; } +.share-row .input { flex: 1; min-width: 0; font-size: 13px; } +.share-row .btn { flex: none; } + +/* offline note: visible without the top bar too (phones, kiosk) */ +#offline-note { position: fixed; z-index: 85; left: 50%; top: calc(10px + env(safe-area-inset-top)); transform: translateX(-50%); padding: 6px 14px; border-radius: 99px; + font-size: 13px; font-weight: 600; color: #fff; background: rgba(28, 28, 30, .86); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 16px rgba(0, 0, 0, .25); max-width: calc(100% - 32px); text-align: center; } +#offline-note[hidden] { display: none; } +body.editing #offline-note { display: none; } + +/* pull to refresh */ +#ptr { position: fixed; z-index: 86; left: 50%; top: calc(8px + env(safe-area-inset-top)); width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; + background: var(--surface); color: var(--text-2); box-shadow: 0 4px 16px rgba(0, 0, 0, .2); transform: translate(-50%, 0); pointer-events: none; } +#ptr[hidden] { display: none; } +#ptr svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +#ptr.ready { color: var(--accent); } +#ptr.spin svg { animation: ptr-spin .8s linear infinite; } +@keyframes ptr-spin { to { transform: rotate(360deg); } } + +/* popups as bottom sheets on phones */ +@media (max-width: 600px) { + .modal { align-items: flex-end; padding: 0; } + .modal-card, .modal-card:has(.tpl-grid) { width: 100%; max-height: 90vh; border-radius: 20px 20px 0 0; padding: 26px 18px calc(18px + env(safe-area-inset-bottom)); transition: transform .25s ease; animation: sheet-up .3s cubic-bezier(.2, .9, .3, 1); } + .modal-card::before { content: ""; position: sticky; top: -18px; display: block; width: 38px; height: 5px; border-radius: 3px; background: var(--separator); margin: -14px auto 12px; } + .modal-actions { flex-wrap: wrap; } + .modal-actions .btn { flex: 1 1 auto; min-height: 46px; } +} +@keyframes sheet-up { from { transform: translateY(100%); } } +@media (prefers-reduced-motion: reduce) { .modal-card { animation: none !important; } } + +/* phone editing: the bar with "Add widget" and the settings */ +#edit-bar { display: none; position: fixed; z-index: 63; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); gap: 8px; padding: 6px; + border-radius: 99px; background: var(--glass); border: 1px solid var(--glass-edge); box-shadow: 0 8px 30px rgba(0, 0, 0, .25); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); } +#edit-bar { max-width: calc(100% - 24px); } +#edit-bar .btn { min-height: 44px; border-radius: 99px; padding: 0 18px; white-space: nowrap; } +#sheet-backdrop { position: fixed; inset: 0; z-index: 61; background: rgba(0, 0, 0, .35); } +#sheet-backdrop[hidden] { display: none; } +.sheet-close { display: none; position: sticky; top: 0; float: right; width: 34px; height: 34px; border-radius: 50%; place-items: center; font-size: 20px; z-index: 2; } +body:not(.editing) #edit-bar { display: none !important; } +@media (pointer: coarse) { + body.editing .w.selected { touch-action: none; } + body.editing .w.selected .w-resize { width: 34px; height: 34px; } + body.editing .w-tools button { width: 38px; height: 38px; } +} +@media (prefers-reduced-motion: reduce) { body.editing #palette, body.editing #inspector { transition: none; } } + +/* suggestions */ +.pal-sug { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; padding: 9px 10px; border-radius: 12px; text-align: left; font-size: 13px; font-weight: 500; + color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); } +.pal-sug .i { font-size: 17px; flex: none; } +.sg-row { display: flex; align-items: center; gap: 10px; padding: 8px 2px; border-bottom: 1px solid var(--separator); } +.sg-row:last-child { border-bottom: 0; } +.sg-row .grow { display: flex; flex-direction: column; min-width: 0; } +.sg-row b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sg-row .muted { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sg-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--fill); font-size: 17px; flex: none; } +.sg-row .btn { flex: none; } +body:not(.editing) .w { -webkit-touch-callout: none; } + /* ============================================================ phone dashboards */ body.phone-ui:not(.peek) #topbar { display: none; } body.phone-ui #stage { padding-top: calc(env(safe-area-inset-top) + 6px); } @@ -1160,10 +1392,16 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; } body.editing #stage { flex-direction: column; align-items: stretch; } #workspace { flex-direction: column; } body.editing #stage { order: 1; flex: 1; } - body.editing #palette { order: 2; width: auto; max-height: 120px; padding: 10px; border: 0; border-top: 1px solid var(--separator); } - body.editing #palette .panel-title, body.editing #palette .hint { display: none; } - body.editing #palette-list { grid-template-columns: repeat(4, 1fr); } - body.editing #inspector { order: 3; width: auto; max-height: 40vh; padding: 14px; border: 0; border-top: 1px solid var(--separator); } + /* editing on a phone: the widget list and the settings slide up as sheets */ + body.editing #palette, body.editing #inspector { position: fixed; left: 0; right: 0; bottom: 0; z-index: 62; width: auto; height: 72vh; max-height: none; overflow-y: auto; + padding: 16px 16px calc(84px + env(safe-area-inset-bottom)); border: 0; border-radius: 20px 20px 0 0; background: var(--surface); box-shadow: 0 -10px 40px rgba(0, 0, 0, .3); + transform: translateY(105%); transition: transform .3s cubic-bezier(.2, .9, .3, 1); } + body.editing.sheet-palette #palette, body.editing.sheet-inspector #inspector { transform: none; } + body.editing #palette .hint { display: none; } + body.editing #palette-list { grid-template-columns: repeat(3, 1fr); } + body.editing #edit-bar { display: flex; } + body.editing .sheet-close { display: grid; } + body.editing #canvas { padding-bottom: 90px; } .btn.label-wide span { display: none; } } diff --git a/dashboard_studio/web/sw.js b/dashboard_studio/web/sw.js new file mode 100644 index 0000000..7705392 --- /dev/null +++ b/dashboard_studio/web/sw.js @@ -0,0 +1,31 @@ +/* Dashboard Studio service worker: always the network first, so updates show straight away. + * When the network fails (or Home Assistant's ingress session has run out), it answers with + * the last good copy, so the app can start, show the last known states and reconnect. */ +const CACHE = "ds-__VERSION__"; +const SKIP = /\/(ws|api\/camera|api\/immich|api\/history)/; + +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (e) => { + e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith("ds-") && k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim())); +}); +self.addEventListener("fetch", (e) => { + const req = e.request, url = new URL(req.url); + if (req.method !== "GET" || url.origin !== location.origin || SKIP.test(url.pathname)) return; + const page = req.mode === "navigate"; + e.respondWith((async () => { + const cache = await caches.open(CACHE); + try { + const res = await fetch(req); + if (res.ok) { + cache.put(page ? new Request(new URL("./", self.registration.scope)) : req, res.clone()); + return res; + } + if (!page) return res; + throw new Error(String(res.status)); + } catch (err) { + const hit = page ? await cache.match(new URL("./", self.registration.scope).href) : await cache.match(req); + if (hit) return hit; + throw err; + } + })()); +}); diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index 2f504df..2f7fb58 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -549,7 +549,7 @@ if (ctx.cache.key !== key) { ctx.cache.key = key; try { ctx.cache.items = (await ctx.ha.send({ type: "todo/item/list", entity_id: c.entity }))?.items || []; } catch { ctx.cache.items = ctx.cache.items || []; } - if (el.isConnected) ctx.rerender(); + ctx.rerender(); return; } // Swipe a row to the left to delete it (touch); the ✕ does the same with a mouse. @@ -630,7 +630,7 @@ cache.key = c.entity; cache.at = Date.now(); const [daily, hourly] = await Promise.all([ctx.ha.forecast(c.entity, "daily").catch(() => null), ctx.ha.forecast(c.entity, "hourly").catch(() => null)]); cache.daily = daily; cache.hourly = hourly; - if (el.isConnected) ctx.rerender(); + ctx.rerender(); }, }; @@ -1266,6 +1266,356 @@ }, }; + // ---------------------------------------------------------------- parcels + // Parcel integrations (PostNL, DHL, …) keep their parcels as lists in a sensor's attributes; + // field names differ, so each parcel is read from whichever names it has. + const pick = (o, ...keys) => { for (const k of keys) if (o?.[k] != null && o[k] !== "") return o[k]; return null; }; + const dayLabel = (d) => { + const today = new Date(); today.setHours(0, 0, 0, 0); + const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400000); + return diff === 0 ? t("Today") : diff === 1 ? t("Tomorrow") : diff === -1 ? t("Yesterday") : new Date(d).toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" }); + }; + function parcelsOf(s) { + const out = []; + for (const [k, v] of Object.entries(s.attributes)) { + if (!Array.isArray(v) || !v.length || typeof v[0] !== "object") continue; + const listDelivered = /deliver|bezorgd|afgeleverd/i.test(k) && !/route|onderweg|transit/i.test(k); + for (const p of v) { + const status = String(pick(p, "status_message", "status", "state", "statusText") || ""); + const when = pick(p, "expected_datetime", "planned_date", "delivery_date", "expected_delivery", "eta", "date", "planned_from"); + const date = when && !isNaN(new Date(when)) ? new Date(when) : null; + out.push({ + name: String(pick(p, "name", "title", "description", "sender", "shop", "product", "key", "tracking_number", "barcode") || t("Parcel")), + status, date, until: pick(p, "planned_to", "expected_to"), + done: listDelivered || p.delivered === true || /delivered|bezorgd|afgeleverd/i.test(status), + url: pick(p, "url", "track_url", "tracking_url"), + }); + } + } + return out; + } + + W.parcels = { + label: "Parcels", icon: "package", size: [4, 4], + fields: [ + { k: "title", t: "text", l: "Title", d: "Parcels" }, + { k: "entities", t: "entities", l: "Parcel sensors (PostNL, DHL, …)", domains: ["sensor"] }, + { k: "showDelivered", t: "check", l: "Show parcels delivered in the last two days", d: true }, + ], + entities: (c) => c.entities || [], + render(c, { ha }) { + const list = (c.entities || []).map((e) => ha.states[e]).filter(Boolean); + if (!list.length) return `${title(c.title)}${missing((c.entities || [])[0], "a parcel sensor")}`; + let items = list.flatMap((s) => { + const ps = parcelsOf(s); + return ps.length || !isFinite(Number(s.state)) ? ps : []; // a count with no list means nothing on the way + }); + const recent = Date.now() - 2 * 86400000; + if (c.showDelivered === false) items = items.filter((p) => !p.done); + else items = items.filter((p) => !p.done || !p.date || p.date.getTime() > recent); + items.sort((a, b) => a.done - b.done || (a.date?.getTime() ?? 9e15) - (b.date?.getTime() ?? 9e15)); + const coming = items.filter((p) => !p.done), today = coming.filter((p) => p.date && dayLabel(p.date) === t("Today")).length; + const hm2 = (d) => new Date(d).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); + const when = (p) => { + if (!p.date) return ""; + const hasTime = p.date.getHours() || p.date.getMinutes(); + return `${dayLabel(p.date)}${hasTime ? ` ${hm2(p.date)}${p.until && !isNaN(new Date(p.until)) ? `–${hm2(p.until)}` : ""}` : ""}`; + }; + const rows = items.map((p) => `<${p.url ? `a href="${esc(p.url)}" target="_blank" rel="noopener"` : "div"} class="pc-row ${p.done ? "done" : ""} ${p.date && dayLabel(p.date) === t("Today") && !p.done ? "today" : ""}"> + ${Icons.svg(p.done ? "check" : "package")} + ${esc(p.name)}${esc(p.done ? t("Delivered") : p.status || t("On the way"))} + ${esc(when(p))}`).join(""); + return `${title(c.title)}
+
${coming.length} ${esc(coming.length === 1 ? t("parcel on the way") : t("parcels on the way"))}${today ? ` · ${esc(t("{0} today", today))}` : ""}
+ ${rows ? `
${rows}
` : `
${t("No parcels on the way.")}
`}
`; + }, + }; + + // ---------------------------------------------------------------- air quality + const AIR = { + carbon_dioxide: { k: "co2", l: "CO₂", lv: (v) => (v <= 800 ? 0 : v <= 1200 ? 1 : 2) }, + pm25: { k: "pm25", l: "PM2.5", lv: (v) => (v <= 12 ? 0 : v <= 35 ? 1 : 2) }, + pm10: { k: "pm10", l: "PM10", lv: (v) => (v <= 20 ? 0 : v <= 50 ? 1 : 2) }, + volatile_organic_compounds: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) }, + volatile_organic_compounds_parts: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) }, + humidity: { k: "hum", l: "Humidity", lv: (v) => (v >= 40 && v <= 60 ? 0 : v >= 30 && v <= 70 ? 1 : 2) }, + }; + const AIR_WORDS = /\b(co2|carbon dioxide|pm2[.,]?5|pm10|voc|humidity|luchtvochtigheid|vochtigheid|air quality|luchtkwaliteit)\b/gi; + + W.air = { + label: "Air quality", icon: "air", size: [4, 4], + fields: [ + { k: "title", t: "text", l: "Title", d: "Air quality" }, + { k: "entities", t: "entities", l: "Sensors (leave empty to find CO₂, fine dust and humidity per room)", domains: ["sensor"] }, + ], + entities: (c, ha) => (c.entities?.length ? c.entities : Object.values(ha?.states || {}).filter((s) => AIR[s.attributes.device_class]).map((s) => s.entity_id)), + render(c, { ha }) { + const reg = ha.reg; + const areaOf = {}; + if (reg) for (const [a, ids] of Object.entries(reg.byArea)) for (const id of ids) areaOf[id] = reg.areas.find((x) => x.area_id === a)?.name; + const sensors = (c.entities?.length ? c.entities : W.air.entities(c, ha)).map((e) => ha.states[e]).filter((s) => s && AIR[s.attributes.device_class] && isFinite(Number(s.state))); + // Without chosen sensors, only rooms count; with chosen ones, a sensor's name is its room when it has none. + const rooms = new Map(); + for (const s of sensors) { + const room = areaOf[s.entity_id] || (c.entities?.length ? friendly(s).replace(AIR_WORDS, "").replace(/\s+/g, " ").trim() || friendly(s) : null); + if (!room) continue; + (rooms.get(room) || rooms.set(room, []).get(room)).push(s); + } + if (!rooms.size) return `${title(c.title)}
${t("No air quality sensors found. Add CO₂, fine dust or humidity sensors in the inspector.")}
`; + const names = ["Good", "Fair", "Poor"]; + const rows = [...rooms.entries()].map(([room, ss]) => { + const m = ss.map((s) => { const d = AIR[s.attributes.device_class], v = Number(s.state); return { ...d, v, lvl: d.lv(v), s }; }); + const worst = Math.max(...m.map((x) => x.lvl)); + const co2 = m.find((x) => x.k === "co2")?.v, hum = m.find((x) => x.k === "hum")?.v, pm = m.find((x) => (x.k === "pm25" || x.k === "pm10") && x.lvl === 2); + const hint = co2 >= 1000 ? t("Open a window") : pm ? t("Air the room or turn on the purifier") : hum > 70 ? t("Too humid, air the room") : hum < 30 ? t("The air is dry") : ""; + return `
+ ${esc(room)}${esc(hint || t(names[worst]))} + ${m.map((x) => ``).join("")}
`; + }).join(""); + return `${title(c.title)}
${rows}
`; + }, + }; + + // ---------------------------------------------------------------- water and gas + // Daily use from Home Assistant's long-term statistics of a meter (the same numbers the energy dashboard uses). + W.usage = { + label: "Water and gas", icon: "droplet", size: [4, 3], + fields: [ + { k: "entity", t: "entity", l: "Meter (water, gas or energy)", domains: ["sensor"] }, + { k: "title", t: "text", l: "Title", ph: "Entity name" }, + { k: "kind", t: "seg", l: "Type", o: ["water", "gas", "electricity"], d: "water" }, + { k: "days", t: "number", l: "Days to show", d: 7 }, + ], + render(c, { ha, cache }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity, "a meter"); + let unit = s.attributes.unit_of_measurement || ""; + // Water reads better in litres than in thousandths of a cubic metre. + const liters = (c.kind || "water") === "water" && unit === "m³"; + if (liters) unit = "L"; + const ico = { water: "droplet", gas: "flame", electricity: "bolt" }[c.kind] || "droplet"; + const head = `
${Icons.svg(ico)}${esc(c.title || friendly(s))}
`; + if (cache.error) return `${head}
${t("No statistics for this meter. It needs a state class (total or total_increasing).")}
`; + if (!cache.days) return `${head}
${t("Loading…")}
`; + const days = liters ? cache.days.map((d) => ({ ...d, v: Math.round(d.v * 1000) })) : cache.days; + const today = days[days.length - 1]?.v ?? 0, yest = days[days.length - 2]?.v; + const max = Math.max(...days.map((d) => d.v), 0.0001), avg = days.slice(0, -1).reduce((a, d) => a + d.v, 0) / Math.max(1, days.length - 1); + return `${head}
+
${esc(fmtNum(today))}${esc(unit)} + ${t("Today so far")}${yest != null ? ` · ${t("yesterday {0}", `${fmtNum(yest)} ${unit}`)}` : ""}
+ +
${t("{0} days", days.length)}: ${esc(fmtNum(days.reduce((a, d) => a + d.v, 0)))} ${esc(unit)}${t("Average {0}", `${fmtNum(avg)} ${unit}`)}
+
`; + }, + async after(el, c, ctx) { + if (!ctx.ha.states[c.entity]) return; + const cache = ctx.cache, n = Math.max(3, Math.min(31, Number(c.days) || 7)); + const key = `${c.entity}|${n}`; + if (cache.key === key && Date.now() - cache.at < 10 * 60 * 1000) return; + cache.key = key; cache.at = Date.now(); + try { + const rows = await ctx.ha.statistics(c.entity, n); + cache.days = rows.map((r) => ({ v: Math.max(0, Number(r.change) || 0), label: new Date(r.start).toLocaleDateString(LOCALE, { weekday: "short" }) })); + cache.error = !rows.length; + } catch { cache.error = true; } + ctx.rerender(); // the card may have been redrawn while loading: draw whichever one is on screen now + }, + }; + + // ---------------------------------------------------------------- solar forecast + // Forecast.Solar: the day totals come from its sensors, the hour by hour forecast from the energy API. + W.solar = { + label: "Solar forecast", icon: "solar", size: [4, 4], + fields: [ + { k: "title", t: "text", l: "Title", d: "Solar forecast" }, + { k: "today", t: "entity", l: "Production today (kWh)", domains: ["sensor"] }, + { k: "tomorrow", t: "entity", l: "Production tomorrow (kWh)", domains: ["sensor"] }, + { k: "now", t: "entity", l: "Power now (W)", domains: ["sensor"] }, + { k: "hours", t: "number", l: "Hours for the washer or dishwasher", d: 3 }, + ], + autofill: { + from: "today", + fill: (eid, ha) => { + const has = (e) => (ha.states[e] ? e : ""); + return { tomorrow: has(eid.replace("today", "tomorrow")), now: has(eid.replace("energy_production_today", "power_production_now")) }; + }, + }, + entities: (c) => [c.today, c.tomorrow, c.now].filter(Boolean), + render(c, { ha, cache }) { + const today = ha.states[c.today], tomorrow = ha.states[c.tomorrow], now = ha.states[c.now]; + if (!today && !tomorrow) return `${title(c.title)}${missing(c.today, "the production today sensor")}`; + const kwh = (s) => (s ? `${fmtNum(s.state)}${esc(s.attributes.unit_of_measurement || "kWh")}` : "–"); + const hours = cache.hours || []; + const day0 = new Date(); day0.setHours(0, 0, 0, 0); + const todays = hours.filter((h) => h.t >= day0.getTime() && h.t < day0.getTime() + 86400000); + const shown = todays.filter((h) => h.wh > 0 || (new Date(h.t).getHours() >= 7 && new Date(h.t).getHours() <= 20)); + const max = Math.max(...shown.map((h) => h.wh), 1), nowH = new Date().getHours(); + // the best window to run an appliance: the most sun in N hours in a row, from now on + const n = Math.max(1, Math.min(6, Number(c.hours) || 3)); + const ahead = hours.filter((h) => h.t >= Date.now() - 3600000); + let best = null; + for (let i = 0; i + n <= ahead.length; i++) { + const sum = ahead.slice(i, i + n).reduce((a, h) => a + h.wh, 0); + if (ahead[i + n - 1].t - ahead[i].t === (n - 1) * 3600000 && (!best || sum > best.sum)) best = { sum, from: ahead[i].t, to: ahead[i].t + n * 3600000 }; + } + const hmT = (ms) => new Date(ms).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); + return `${title(c.title)}
+
${t("Today")}${kwh(today)}
+ ${tomorrow ? `
${t("Tomorrow")}${kwh(tomorrow)}
` : ""} + ${now ? `
${t("Now")}${esc(fmtState(now))}
` : ""}
+ ${shown.length ? `
${shown.map((h) => { const hr = new Date(h.t).getHours(); return `
= best.from && h.t < best.to ? "best" : ""}" title="${esc(`${hmT(h.t)}: ${fmtNum(h.wh / 1000)} kWh`)}">${hr % 3 === 0 ? `${hr}` : ""}
`; }).join("")}
` + : cache.loaded ? `
${t("The hour by hour forecast isn’t available.")}
` : ""} + ${best ? `
${Icons.svg("washer")}${esc(t("Best time for the washer: {0}", `${dayLabel(best.from) === t("Today") ? "" : dayLabel(best.from) + " "}${hmT(best.from)}–${hmT(best.to)}`))}
` : ""} +
`; + }, + async after(el, c, ctx) { + const cache = ctx.cache; + if (cache.at && Date.now() - cache.at < 30 * 60 * 1000) return; + cache.at = Date.now(); + try { + const res = await ctx.ha.solarForecast(); + const sum = {}; + for (const entry of Object.values(res || {})) for (const [iso, wh] of Object.entries(entry.wh_hours || {})) { const tm = new Date(iso).getTime(); sum[tm] = (sum[tm] || 0) + Number(wh || 0); } + cache.hours = Object.entries(sum).map(([tm, wh]) => ({ t: Number(tm), wh })).sort((a, b) => a.t - b.t); + } catch { cache.hours = []; } + cache.loaded = true; + ctx.rerender(); + }, + }; + + // ---------------------------------------------------------------- garage and gate + /** Who changed an entity last, from the user on its context (matched to a person). */ + function changedBy(s, ha) { + const uid = s?.context?.user_id; + if (!uid) return ""; + const p = Object.values(ha.states).find((x) => x.entity_id.startsWith("person.") && x.attributes.user_id === uid); + return p ? friendly(p) : ""; + } + + W.gate = { + label: "Garage and gate", icon: "garage", size: [3, 3], + fields: [ + { k: "entity", t: "entity", l: "Garage door, gate or lock", domains: ["cover", "lock", "switch", "button", "input_boolean"] }, + { k: "name", t: "text", l: "Name", ph: "Entity name" }, + { k: "kind", t: "seg", l: "Icon", o: ["garage", "gate", "door"], d: "garage" }, + { k: "confirm", t: "check", l: "Ask before opening or closing", d: true }, + ], + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity, "a garage door or gate"); + const d = domainOf(c.entity), st0 = s.state; + const open = ["open", "opening", "unlocked", "unlocking", "on"].includes(st0), moving = ["opening", "closing", "locking", "unlocking"].includes(st0); + const name = c.name || friendly(s); + const label = d === "button" ? t("Press") : moving ? st(st0) : open ? (d === "lock" ? t("Lock") : t("Close")) : d === "lock" ? t("Unlock") : t("Open"); + const by = changedBy(s, ha); + const since = `${open ? t("Open") : st(st0)} · ${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`; + const ico = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage"; + return `
+
${esc(name)}${esc(open ? t("Open") : d === "button" ? "" : st(st0))}
+ +
${esc(since)}
`; + }, + }; + + // ---------------------------------------------------------------- sports (TeamTracker) + const isMatchDay = (s) => s && (s.state === "IN" || (s.attributes.date && dayLabel(s.attributes.date) === t("Today"))); + + W.sports = { + label: "Sports", icon: "trophy", size: [4, 3], + fields: [ + { k: "entity", t: "entity", l: "Team (TeamTracker sensor)", domains: ["sensor"] }, + { k: "matchDay", t: "check", l: "Only show on match days", d: false }, + { k: "venue", t: "check", l: "Show the venue and TV channel", d: true }, + ], + autoHide: (c, { ha }) => !!c.matchDay && !isMatchDay(ha.states[c.entity]), + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity, "a TeamTracker sensor"); + const a = s.attributes, state = String(s.state).toUpperCase(); + if (state === "NOT_FOUND" || state === "BYE" || !a.opponent_name) return `
${esc(a.league || friendly(s))}
${state === "BYE" ? t("No match this round.") : t("No match planned.")}
`; + const team = { name: a.team_name, short: a.team_abbr, logo: a.team_logo, score: a.team_score, win: a.team_winner }, + opp = { name: a.opponent_name, short: a.opponent_abbr, logo: a.opponent_logo, score: a.opponent_score, win: a.opponent_winner }; + const [home, away] = a.team_homeaway === "away" ? [opp, team] : [team, opp]; + const side = (x) => `
${x.logo ? `` : `${esc((x.short || x.name || "?").slice(0, 3))}`}${esc(x.name || x.short || "")}
`; + const kick = a.date ? new Date(a.date) : null; + let mid; + if (state === "PRE") mid = `${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"}${esc(kick ? dayLabel(kick) : "")}${a.kickoff_in ? ` · ${esc(a.kickoff_in)}` : ""}`; + else mid = `${esc(home.score ?? 0)}–${esc(away.score ?? 0)}${state === "IN" ? `${t("Live")} ${esc(a.clock || "")}` : t("Full time")}`; + const extra = c.venue !== false ? [a.venue, a.tv_network].filter(Boolean).join(" · ") : ""; + return `
${esc(a.league || a.sport || "")}${a.event_name && state !== "PRE" ? "" : ""}
+
${side(home)}
${mid}
${side(away)}
+ ${extra ? `
${esc(extra)}
` : ""}${state === "IN" && a.last_play ? `
${esc(a.last_play)}
` : ""}
`; + }, + }; + + // ---------------------------------------------------------------- Formula One (F1 Sensor integration) + // Its sensors carry the season data from the Jolpica (Ergast) API in their attributes. + const F1_SESSIONS = [["first_practice", "Practice 1"], ["second_practice", "Practice 2"], ["third_practice", "Practice 3"], ["sprint_qualifying", "Sprint qualifying"], ["sprint", "Sprint"], ["qualifying", "Qualifying"], ["race", "Race"]]; + 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 || ""; }; + + W.f1 = { + label: "Formula One", icon: "flag", size: [4, 5], + fields: [ + { k: "next", t: "entity", l: "Next race", domains: ["sensor"] }, + { k: "drivers", t: "entity", l: "Driver standings", domains: ["sensor"] }, + { k: "teams", t: "entity", l: "Constructor standings", domains: ["sensor"] }, + { k: "results", t: "entity", l: "Last race results", domains: ["sensor"] }, + { k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" }, + { k: "top", t: "number", l: "Positions to show", d: 5 }, + ], + autofill: { + from: "next", + fill: (eid, ha) => { + const pre = eid.replace(/next_race.*$/, ""), has = (e) => (ha.states[e] ? e : ""); + return { drivers: has(`${pre}driver_standings`), teams: has(`${pre}constructor_standings`), results: has(`${pre}last_race_results`) }; + }, + }, + entities: (c) => [c.next, c.drivers, c.teams, c.results].filter(Boolean), + render(c, { ha, cache }) { + const next = ha.states[c.next]; + if (!next && !c.drivers) return missing(c.next, "the next race sensor"); + const a = next?.attributes || {}; + const iso = (k) => a[`${k}_start`] || a[`${k}_date`] || a[`${k}_start_local`]; + const raceAt = iso("race") ? new Date(iso("race")) : null; + let countdown = ""; + if (raceAt && raceAt > Date.now()) { + 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 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(" · "); + const tab = cache.tab || c.show || "drivers", top = Math.max(1, Math.min(20, Number(c.top) || 5)); + const tabs = [["drivers", "Drivers", c.drivers], ["teams", "Teams", c.teams], ["results", "Last race", c.results]].filter(([, , e]) => e && ha.states[e]); + const cur = tabs.find(([k]) => k === tab) || tabs[0]; + let rows = ""; + if (cur) { + const s = ha.states[cur[2]]; + 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)}
`; + }).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)}` : ""}
+
${esc(raceName || t("Next race"))}
${place ? `
${esc(place)}
` : ""} + ${sessions.length ? `
${sessions.map(([l, v]) => `
${esc(t(l))}${esc(when(new Date(v)))}
`).join("")}
` : ""}
` : ""} + ${tabs.length ? `
${tabs.map(([k, l]) => ``).join("")}
+
${rows || `
${t("No standings yet.")}
`}
` : ""}
`; + }, + }; + W.text = { label: "Text", icon: "text", size: [4, 2], fields: [