diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md
index 742c2e8..6fbc25d 100644
--- a/dashboard_studio/DOCS.md
+++ b/dashboard_studio/DOCS.md
@@ -49,6 +49,9 @@ Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and s
Pick a weather entity in the clock's settings to show the current conditions and temperature under the time. Today's high and low come from Home Assistant's forecast, refreshed every half hour. Tap the weather to see all its details.
+- **Text position:** place the time anywhere in the card with the 3×3 grid: top left, top, top right, left, center, right, bottom left, bottom or bottom right.
+- **Background image:** choose image links (one per line) or Immich albums. With more than one photo the clock fades between them; set how often. **Darken the image** keeps the time readable on bright photos (30% by default), and the text turns white with a soft shadow.
+
### Horizontal scroll and swipe cards
These cards hold other cards. Select one in edit mode, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it.
@@ -58,6 +61,14 @@ These cards hold other cards. Select one in edit mode, pick a card type under **
Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off.
+### Light groups
+
+When the light card points at a light group, it also lists every light in the group, each with its own switch and brightness. The group's own switch and slider still control all of them at once. When you pick a group, the card grows to four rows so the list fits. Turn off **For a light group, show each light** to hide the list.
+
+### Vacuum
+
+Shows the robot's status and battery, buttons to start or pause, send it back to the dock and locate it, and the suction power levels it supports. 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**.
+
### Media players
One card can control several players. With more than one, a row of buttons at the top switches between them, and a small animated equalizer marks the ones that are playing. The card starts on whatever is playing. It shows the album art, title and artist, with previous, play/pause and next buttons and a volume slider. A player that is off gets a **Turn on** button instead. You can hide players that are off from the switcher.
diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml
index 3271767..3a643b0 100644
--- a/dashboard_studio/config.yaml
+++ b/dashboard_studio/config.yaml
@@ -1,5 +1,5 @@
name: Dashboard Studio
-version: "1.7.0"
+version: "1.8.0"
slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py
index baad578..6e2b0d6 100644
--- a/dashboard_studio/server/mock_ha.py
+++ b/dashboard_studio/server/mock_ha.py
@@ -13,10 +13,10 @@ def now_iso():
return datetime.now(timezone.utc).isoformat()
-def entity(entity_id, state, **attrs):
+def entity(eid, state, /, **attrs):
ts = now_iso()
return {
- "entity_id": entity_id,
+ "entity_id": eid,
"state": state,
"attributes": attrs,
"last_changed": ts,
@@ -47,6 +47,10 @@ class MockHA:
entity("light.reading_lamp", "on", friendly_name="Reading Lamp", brightness=140, rgb_color=[255, 140, 60], supported_color_modes=["rgb"]),
entity("light.kitchen", "off", friendly_name="Kitchen", brightness=None, supported_color_modes=["brightness"]),
entity("light.bedroom", "on", friendly_name="Bedroom", brightness=90, supported_color_modes=["brightness"]),
+ entity("light.downstairs", "on", friendly_name="Downstairs", brightness=160, supported_color_modes=["brightness"],
+ entity_id=["light.living_room", "light.kitchen", "light.reading_lamp"]),
+ entity("vacuum.robot", "docked", friendly_name="Robot Vacuum", battery_level=82, fan_speed="standard",
+ fan_speed_list=["quiet", "standard", "turbo", "max"], status="Charging"),
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
entity("fan.office", "off", friendly_name="Office Fan"),
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
@@ -104,6 +108,8 @@ class MockHA:
new["attributes"].update(attrs)
new["last_updated"] = now_iso()
self.states[entity_id] = new
+ if entity_id.startswith("light.") and "entity_id" not in new["attributes"]:
+ self._sync_groups(entity_id)
event = {"event_type": "state_changed", "data": {"entity_id": entity_id, "old_state": old, "new_state": new}, "time_fired": now_iso()}
for ws, sub_ids in list(self.clients):
for sid in sub_ids:
@@ -115,13 +121,40 @@ class MockHA:
except Exception: # client went away
pass
+ def _vacuum(self, eid, service, data):
+ if service == "start":
+ self.set_state(eid, "cleaning", status="Cleaning")
+ elif service == "pause":
+ self.set_state(eid, "paused", status="Paused")
+ elif service in ("return_to_base", "stop"):
+ self.set_state(eid, "returning", status="Returning home")
+ asyncio.get_event_loop().call_later(6, lambda: self.set_state(eid, "docked", status="Charging"))
+ elif service == "set_fan_speed":
+ self.set_state(eid, None, fan_speed=data.get("fan_speed"))
+
+ def _sync_groups(self, changed):
+ for gid, g in list(self.states.items()):
+ members = g["attributes"].get("entity_id")
+ if not gid.startswith("light.") or not isinstance(members, list) or changed not in members:
+ continue
+ on = [self.states[m] for m in members if m in self.states and self.states[m]["state"] == "on"]
+ bright = round(sum(m["attributes"].get("brightness") or 0 for m in on) / len(on)) if on else None
+ self.set_state(gid, "on" if on else "off", brightness=bright)
+
def call_service(self, domain, service, data):
targets = data.get("entity_id") or (data.get("target") or {}).get("entity_id") or []
if isinstance(targets, str):
targets = [targets]
+ expanded = []
for eid in targets:
+ members = (self.states.get(eid) or {}).get("attributes", {}).get("entity_id")
+ expanded += members if eid.startswith("light.") and isinstance(members, list) else [eid]
+ for eid in expanded:
if eid not in self.states:
continue
+ if domain == "vacuum" or eid.startswith("vacuum."):
+ self._vacuum(eid, service, data)
+ continue
st = self.states[eid]["state"]
if eid.startswith("climate.") and service in ("turn_on", "turn_off", "toggle"):
on = service == "turn_on" or (service == "toggle" and st == "off")
diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js
index 82303ce..16c424c 100644
--- a/dashboard_studio/web/app.js
+++ b/dashboard_studio/web/app.js
@@ -625,6 +625,9 @@
${list.map((e, i) => `
`).join("")}
`;
}
+ case "pos9": // a 3×3 grid for placing content in the card
+ return `
+
+
+
+ ${busy ? `${cleaning ? t("Cleaning…") : t("On its way to the dock")}` : ""}
+
`;
+ },
+ lit: (c, { ha }) => (["cleaning", "returning"].includes(ha.states[c.entity]?.state) ? "on" : false),
+ };
+
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
// Works with any integration: a status entity, and/or a power sensor above a threshold,
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
@@ -637,49 +697,54 @@
return `