Dashboard Studio 1.14.0: new cards, kiosk announcements, guest mode, templates, sharing and phone app
- Seven new cards: parcels, air quality, water and gas usage, solar forecast, garage and gate, sports, Formula One - Kiosk: announcements from automations, tab rotation, guest mode, click sound - Editor: templates, widget presets, undo history, alignment guides, Lovelace import, share link and QR code, theme variants, suggestions, touch and hold actions - Phones: installable app with shortcuts, offline states, tab swipe, pull to refresh, bottom sheets, haptics, phone/large visibility, landscape layout, editing on the phone, away tab - Fixed: the weather forecast and to-do list could stay on Loading… Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#24304f"/><stop offset="1" stop-color="#0f1526"/></linearGradient>
|
||||
<linearGradient id="lamp" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffe3a3"/><stop offset="1" stop-color="#f6b955"/></linearGradient>
|
||||
<radialGradient id="glow" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#ffcf70" stop-opacity=".45"/><stop offset="1" stop-color="#ffcf70" stop-opacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<ellipse cx="160" cy="260" rx="150" ry="210" fill="url(#glow)"/>
|
||||
<rect x="76" y="76" width="172" height="360" rx="36" fill="url(#lamp)"/>
|
||||
<circle cx="124" cy="124" r="22" fill="#fff6e4"/><circle cx="124" cy="124" r="10" fill="#7a4b14"/>
|
||||
<rect x="100" y="364" width="124" height="36" rx="18" fill="#c48a3a" opacity=".55"/>
|
||||
<rect x="100" y="364" width="72" height="36" rx="18" fill="#7a4b14"/>
|
||||
<rect x="276" y="76" width="160" height="172" rx="36" fill="#2c3754" stroke="#3d4a6b" stroke-width="3"/>
|
||||
<path d="M300 196l44-36 30 22 40-62" fill="none" stroke="#5aa2ff" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="276" y="264" width="160" height="172" rx="36" fill="#2c3754" stroke="#3d4a6b" stroke-width="3"/>
|
||||
<rect x="300" y="326" width="112" height="52" rx="26" fill="#34c759"/>
|
||||
<circle cx="386" cy="352" r="21" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
+951
-43
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
@@ -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. */
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<title>Dashboard Studio</title>
|
||||
<link rel="icon" type="image/png" href="static/favicon.png">
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="static/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#0f1526">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Dashboards">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -29,6 +36,7 @@
|
||||
<button id="btn-redo" class="btn icon" aria-label="Redo" title="Redo (⇧⌘Z)"></button>
|
||||
<button id="btn-menu" class="btn icon" aria-label="More actions" aria-haspopup="menu" aria-expanded="false" title="More actions"></button>
|
||||
</span>
|
||||
<button id="btn-guest" class="btn view-only" hidden><span data-i18n>Guest mode</span></button>
|
||||
<button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button>
|
||||
<button id="btn-edit" class="btn primary" data-i18n>Edit</button>
|
||||
</div>
|
||||
@@ -36,11 +44,16 @@
|
||||
<button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button>
|
||||
<button role="menuitem" data-menu="duplicate" data-icon="copy" data-i18n>Duplicate</button>
|
||||
<button role="menuitem" data-menu="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
|
||||
<button role="menuitem" data-menu="suggest" data-icon="search" data-i18n>Devices not on a dashboard…</button>
|
||||
<button role="menuitem" data-menu="assign" data-icon="person" data-i18n>Who sees which dashboard…</button>
|
||||
<button role="menuitem" data-menu="history" data-icon="history" data-i18n>Version history…</button>
|
||||
<button role="menuitem" data-menu="undo-history" data-icon="undo" data-i18n>Undo history…</button>
|
||||
<button role="menuitem" data-menu="share" data-icon="share" data-i18n>Share…</button>
|
||||
<button role="menuitem" data-menu="install" data-icon="phone" data-i18n>Install as an app…</button>
|
||||
<hr>
|
||||
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
|
||||
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
|
||||
<button role="menuitem" data-menu="lovelace" data-icon="import" data-i18n>Import from Home Assistant…</button>
|
||||
<hr>
|
||||
<button role="menuitem" data-menu="delete" data-icon="trash" class="danger" data-i18n>Delete dashboard…</button>
|
||||
</div>
|
||||
@@ -48,6 +61,7 @@
|
||||
|
||||
<div id="workspace">
|
||||
<aside id="palette" class="edit-only" aria-label="Widgets">
|
||||
<button class="sheet-close btn icon" aria-label="Close" title="Close">×</button>
|
||||
<div class="panel-title" data-i18n>Add widget</div>
|
||||
<input id="palette-search" class="input" type="search" aria-label="Search widgets" autocomplete="off" spellcheck="false">
|
||||
<div id="palette-list"></div>
|
||||
@@ -63,9 +77,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-bar" class="edit-only"><button class="btn" id="eb-add"><span data-i18n>Add</span></button><button class="btn primary" id="eb-insp"></button></div>
|
||||
<div id="sheet-backdrop" hidden></div>
|
||||
<div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div>
|
||||
<div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div>
|
||||
<div id="night-dim" aria-hidden="true"></div>
|
||||
<div id="offline-note" hidden role="status"></div>
|
||||
<div id="ptr" hidden aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg></div>
|
||||
<div id="announce" hidden role="alertdialog" aria-modal="true" aria-labelledby="an-title"><div class="an-card"><div class="an-ico"></div><div class="an-title" id="an-title"></div><div class="an-msg"></div><button class="btn primary an-ok">OK</button></div></div>
|
||||
|
||||
<div id="modal" class="modal" hidden><div class="modal-card" role="dialog" aria-modal="true"></div></div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
@@ -73,6 +92,7 @@
|
||||
|
||||
<script src="static/i18n.js"></script>
|
||||
<script src="static/icons.js"></script>
|
||||
<script src="static/qr.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" shape-rendering="crispEdges" role="img" aria-label="${label}"><rect width="${n}" height="${n}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
|
||||
}
|
||||
|
||||
const api = { matrix, svg };
|
||||
if (typeof window !== "undefined") window.QR = api;
|
||||
if (typeof module !== "undefined") module.exports = api;
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})());
|
||||
});
|
||||
@@ -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" : ""}">
|
||||
<span class="pc-ico">${Icons.svg(p.done ? "check" : "package")}</span>
|
||||
<span class="pc-text"><span class="pc-name">${esc(p.name)}</span><span class="pc-status">${esc(p.done ? t("Delivered") : p.status || t("On the way"))}</span></span>
|
||||
<span class="pc-when">${esc(when(p))}</span></${p.url ? "a" : "div"}>`).join("");
|
||||
return `${title(c.title)}<div class="pc">
|
||||
<div class="pc-sum"><b class="num">${coming.length}</b> ${esc(coming.length === 1 ? t("parcel on the way") : t("parcels on the way"))}${today ? ` · <span class="pc-today">${esc(t("{0} today", today))}</span>` : ""}</div>
|
||||
${rows ? `<div class="pc-list">${rows}</div>` : `<div class="empty">${t("No parcels on the way.")}</div>`}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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)}<div class="empty">${t("No air quality sensors found. Add CO₂, fine dust or humidity sensors in the inspector.")}</div>`;
|
||||
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 `<div class="aq-row lvl${worst}"><span class="aq-dot" aria-hidden="true"></span>
|
||||
<span class="aq-room"><b>${esc(room)}</b><span>${esc(hint || t(names[worst]))}</span></span>
|
||||
<span class="aq-vals">${m.map((x) => `<button class="aq-val lvl${x.lvl} wbtn" data-act="more" data-e="${esc(x.s.entity_id)}" title="${esc(t(x.l))}: ${esc(t(names[x.lvl]))}"><small>${esc(t(x.l))}</small><span class="num">${esc(fmtNum(x.v))}${x.k === "hum" ? "%" : ""}</span></button>`).join("")}</span></div>`;
|
||||
}).join("");
|
||||
return `${title(c.title)}<div class="aq">${rows}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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 = `<div class="us-head"><span class="us-ico ${esc(c.kind || "water")}">${Icons.svg(ico)}</span><span class="name">${esc(c.title || friendly(s))}</span></div>`;
|
||||
if (cache.error) return `${head}<div class="empty">${t("No statistics for this meter. It needs a state class (total or total_increasing).")}</div>`;
|
||||
if (!cache.days) return `${head}<div class="empty">${t("Loading…")}</div>`;
|
||||
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}<div class="us ${esc(c.kind || "water")}">
|
||||
<div class="us-now"><span class="us-big num">${esc(fmtNum(today))}<small>${esc(unit)}</small></span>
|
||||
<span class="us-cmp">${t("Today so far")}${yest != null ? ` · ${t("yesterday {0}", `${fmtNum(yest)} ${unit}`)}` : ""}</span></div>
|
||||
<div class="us-bars" role="img" aria-label="${esc(days.map((d) => `${d.label}: ${fmtNum(d.v)} ${unit}`).join(", "))}">
|
||||
${avg ? `<i class="us-avg" style="bottom:${(avg / max) * 100}%" title="${esc(t("Average"))}"></i>` : ""}
|
||||
${days.map((d, i) => `<div class="us-day ${i === days.length - 1 ? "today" : ""}"><span class="us-bar" style="height:${Math.max(2, (d.v / max) * 100)}%"></span><span class="us-lbl">${esc(d.label)}</span></div>`).join("")}</div>
|
||||
<div class="us-foot"><span>${t("{0} days", days.length)}: <b class="num">${esc(fmtNum(days.reduce((a, d) => a + d.v, 0)))} ${esc(unit)}</b></span><span>${t("Average {0}", `${fmtNum(avg)} ${unit}`)}</span></div>
|
||||
</div>`;
|
||||
},
|
||||
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)}<small>${esc(s.attributes.unit_of_measurement || "kWh")}</small>` : "–");
|
||||
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)}<div class="sf">
|
||||
<div class="sf-top"><div class="sf-main"><span class="sf-lbl">${t("Today")}</span><span class="sf-big num">${kwh(today)}</span></div>
|
||||
${tomorrow ? `<div><span class="sf-lbl">${t("Tomorrow")}</span><span class="sf-mid num">${kwh(tomorrow)}</span></div>` : ""}
|
||||
${now ? `<div><span class="sf-lbl">${t("Now")}</span><span class="sf-mid num">${esc(fmtState(now))}</span></div>` : ""}</div>
|
||||
${shown.length ? `<div class="sf-bars">${shown.map((h) => { const hr = new Date(h.t).getHours(); return `<div class="sf-h ${hr === nowH ? "now" : ""} ${hr < nowH ? "past" : ""} ${best && h.t >= best.from && h.t < best.to ? "best" : ""}" title="${esc(`${hmT(h.t)}: ${fmtNum(h.wh / 1000)} kWh`)}"><i style="height:${Math.max(2, (h.wh / max) * 100)}%"></i>${hr % 3 === 0 ? `<span>${hr}</span>` : ""}</div>`; }).join("")}</div>`
|
||||
: cache.loaded ? `<div class="muted small">${t("The hour by hour forecast isn’t available.")}</div>` : ""}
|
||||
${best ? `<div class="sf-best">${Icons.svg("washer")}<span>${esc(t("Best time for the washer: {0}", `${dayLabel(best.from) === t("Today") ? "" : dayLabel(best.from) + " "}${hmT(best.from)}–${hmT(best.to)}`))}</span></div>` : ""}
|
||||
</div>`;
|
||||
},
|
||||
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 `<div class="gt ${open ? "open" : ""} ${moving ? "moving" : ""}">
|
||||
<div class="gt-head"><span class="name">${esc(name)}</span><span class="gt-state">${esc(open ? t("Open") : d === "button" ? "" : st(st0))}</span></div>
|
||||
<button class="gt-btn wbtn" data-act="gate" data-e="${esc(c.entity)}" data-v="${c.confirm === false ? "" : "1"}" aria-label="${esc(`${label}: ${name}`)}">
|
||||
<span class="gt-ring">${Icons.svg(open && c.kind === "door" ? "door-open" : ico)}</span><span class="gt-lbl">${esc(label)}</span></button>
|
||||
<div class="gt-since">${esc(since)}</div></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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 `<div class="sp"><div class="sp-league">${esc(a.league || friendly(s))}</div><div class="empty">${state === "BYE" ? t("No match this round.") : t("No match planned.")}</div></div>`;
|
||||
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) => `<div class="sp-team ${x.win ? "win" : ""}">${x.logo ? `<img src="${esc(x.logo)}" alt="">` : `<span class="sp-badge">${esc((x.short || x.name || "?").slice(0, 3))}</span>`}<span>${esc(x.name || x.short || "")}</span></div>`;
|
||||
const kick = a.date ? new Date(a.date) : null;
|
||||
let mid;
|
||||
if (state === "PRE") mid = `<span class="sp-time num">${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"}</span><span class="sp-sub">${esc(kick ? dayLabel(kick) : "")}${a.kickoff_in ? ` · ${esc(a.kickoff_in)}` : ""}</span>`;
|
||||
else mid = `<span class="sp-score num">${esc(home.score ?? 0)}<i>–</i>${esc(away.score ?? 0)}</span><span class="sp-sub">${state === "IN" ? `<b class="sp-live">${t("Live")}</b> ${esc(a.clock || "")}` : t("Full time")}</span>`;
|
||||
const extra = c.venue !== false ? [a.venue, a.tv_network].filter(Boolean).join(" · ") : "";
|
||||
return `<div class="sp ${state === "IN" ? "live" : ""}"><div class="sp-league">${esc(a.league || a.sport || "")}${a.event_name && state !== "PRE" ? "" : ""}</div>
|
||||
<div class="sp-match">${side(home)}<div class="sp-mid">${mid}</div>${side(away)}</div>
|
||||
${extra ? `<div class="sp-extra">${esc(extra)}</div>` : ""}${state === "IN" && a.last_play ? `<div class="sp-extra">${esc(a.last_play)}</div>` : ""}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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 `<div class="f1-row"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><span class="f1-name"><b>${esc(name)}</b><span>${esc(sub)}</span></span><span class="f1-val num">${esc(val)}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : "");
|
||||
return `<div class="f1">
|
||||
${next ? `<div class="f1-next"><div class="f1-top">${Icons.svg("flag")}<span>${esc(a.season ? `${a.season} · ${t("Round {0}", a.round)}` : t("Next race"))}</span>${countdown ? `<b>${esc(countdown)}</b>` : ""}</div>
|
||||
<div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""}
|
||||
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => `<div class="${nextSession && nextSession[1] === v ? "next" : ""} ${new Date(v) < Date.now() ? "past" : ""}"><span>${esc(t(l))}</span><span class="num">${esc(when(new Date(v)))}</span></div>`).join("")}</div>` : ""}</div>` : ""}
|
||||
${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div>
|
||||
<div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
W.text = {
|
||||
label: "Text", icon: "text", size: [4, 2],
|
||||
fields: [
|
||||
|
||||
Reference in New Issue
Block a user