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:
2026-10-26 21:08:42 +01:00
parent 93bb42dcbb
commit 79d4a0f011
17 changed files with 2256 additions and 58 deletions
+39 -1
View File
@@ -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)
+104 -3
View File
@@ -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: