Dashboard Studio 1.10.0: phone dashboards, room card, changelog

- Phone dashboards: a 4-column layout designed at phone width, with
  conversion both ways; on phones the top bar is hidden and comes back
  with a touch-and-hold on an empty spot
- Room card built from Home Assistant areas (area, device and entity
  registries): climate, lights on with an all-lights toggle, thermostat,
  open doors and windows, motion, media, and device buttons
- CHANGELOG.md for Home Assistant's Changelog tab, served at
  /api/changelog and shown under About > What's new

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 00:06:01 +01:00
parent aa75a572c5
commit 78aa85a82f
11 changed files with 348 additions and 7 deletions
+73
View File
@@ -0,0 +1,73 @@
# Changelog
## 1.10.0
- **Phone dashboards.** Turn on *Designed for phones* in a dashboard's grid settings to design it in a 4-column layout at phone size. Existing widgets are converted. On a phone the dashboard fills the screen without the top bar; touch and hold an empty spot to bring the bar back. Other dashboards still reflow on phones as before.
- **Room card.** Pick a Home Assistant room (area) and the card shows its temperature and humidity, what's on, open or playing, a button for all its lights, and its devices to switch. You can add extra entities, or build a room from entities alone.
- **Changelog.** This list, in Home Assistant's Changelog tab and under *About → What's new* in the app.
## 1.9.0
- The swipe card really swipes now, with a finger or the mouse: it follows along, resists at the ends and reacts to a quick flick. The arrow keys work too.
- Cards in a swipe card fill the whole card. The page dots float on top and hide after a few seconds.
- Tap a clock with a photo background, or a Photos card, to go to the next photo.
- Fixed: removing a player from a media card didn't work inside a scroll or swipe card, or on media cards from before 1.5.
- The vacuum card grows with its size: a robot with a battery ring, stats, suction power and labelled buttons.
## 1.8.0
- New vacuum card: status, battery, start or pause, dock, locate, suction power and an optional live map.
- Clock: choose where the time sits in the card, and add a background photo from links or Immich albums.
- Light card: a light group lists each of its lights with its own switch.
## 1.7.0
- New horizontal scroll card and swipe card, which hold other cards.
- Nine new themes, including High contrast, for 15 in total.
- Choose which dashboard opens on which device (optionally in kiosk mode) and for which person.
## 1.6.1
- The version is shown under *About* in the dashboard settings.
- After an update, browsers load the new version instead of an old stored copy, and open screens reload by themselves.
## 1.6.0
- New app icon.
- The clock can show the weather with today's high and low.
- The tab menu can be a slim rail with icons only (the default), with names on hover.
## 1.5.0
- Any widget can be shown only under conditions, such as "only when the washer is running".
- New appliance card for washers, dryers and dishwashers.
- The media card can control several players.
## 1.4.0
- New waste collection card for the Afvalwijzer integration.
- Dashboards can have tabs, with a menu on the side (a tab bar on phones).
## 1.3.1
- Fixed: on Home Assistant the app showed test data instead of your own devices.
- New menu item: *New dashboard from my devices*.
## 1.3.0
- Dutch translation, following your Home Assistant language, with a per-device choice.
## 1.2.0
- New Photos card with Immich albums, and a calendar card.
- Kiosk mode has no visible exit button any more: tap a corner five times to leave.
## 1.1.0
- New design following Apple's design guidelines, with a System theme that follows light and dark mode.
- Tile styles: cards, outlined or plain.
- New car card and kiosk mode.
## 1.0.0
- First version: a drag-and-drop dashboard editor in its own Home Assistant sidebar tab.
+17 -1
View File
@@ -69,6 +69,16 @@ When the light card points at a light group, it also lists every light in the gr
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**. 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**.
### Room
Pick one of your Home Assistant rooms (areas) and the card gathers everything in it:
- the room's temperature and humidity (the sensors set on the area in Home Assistant, or any temperature and humidity sensor in the room; you can also choose them yourself)
- chips for what's going on: how many lights are on (tap to switch them all off, or all on), the thermostat, open doors and windows, motion, and music that's playing
- buttons for the lamps, switches, fans, covers and locks in the room
Light groups are left out so each lamp counts once. Add extra entities, or skip the room and list the entities yourself. The card glows warm while lights are on, and can have a background photo. Rooms are read from Home Assistant's area, device and entity registries.
### Media players ### 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. 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.
@@ -109,6 +119,12 @@ Any widget can be shown only under certain conditions. Select it in edit mode, t
In edit mode, hidden widgets stay visible but faded, with a **Hidden now** label, so you can still edit them. On the dashboard they disappear, and rows that only held hidden widgets close up so no gaps are left. Changes happen live as the entities change. In edit mode, hidden widgets stay visible but faded, with a **Hidden now** label, so you can still edit them. On the dashboard they disappear, and rows that only held hidden widgets close up so no gaps are left. Changes happen live as the entities change.
## Phone dashboards
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.
## Tabs and side menu ## Tabs and side menu
A dashboard can have several tabs, each with its own widgets, for example *Overview*, *Household* and *Cameras*. To set them up, deselect all widgets in edit mode and use the **Tabs** section of the dashboard settings: A dashboard can have several tabs, each with its own widgets, for example *Overview*, *Household* and *Cameras*. To set them up, deselect all widgets in edit mode and use the **Tabs** section of the dashboard settings:
@@ -153,7 +169,7 @@ A device's choice comes before a person's. Without either, the last dashboard us
## Version and updates ## Version and updates
The version you're running is shown at the bottom of the dashboard settings, under **About** (deselect all widgets in edit mode). After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted. The version you're running is shown at the bottom of the dashboard settings, under **About** (deselect all widgets in edit mode). **What's new** there shows the changelog, which is also on the app's **Changelog** tab in Home Assistant. After an update, a short message points you to it. After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted.
## Data ## Data
+1 -1
View File
@@ -6,7 +6,7 @@ RUN apk add --no-cache python3 py3-aiohttp
WORKDIR /app WORKDIR /app
COPY server/ /app/server/ COPY server/ /app/server/
COPY web/ /app/web/ COPY web/ /app/web/
COPY config.yaml /app/config.yaml COPY config.yaml CHANGELOG.md /app/
COPY run.sh /run.sh COPY run.sh /run.sh
RUN chmod a+x /run.sh RUN chmod a+x /run.sh
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "1.9.0" version: "1.10.0"
slug: dashboard_studio slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. 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 url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
+9
View File
@@ -141,6 +141,14 @@ async def users(request):
return web.json_response([{"id": k, **v} for k, v in load_users().items()], headers={"Cache-Control": "no-store"}) return web.json_response([{"id": k, **v} for k, v in load_users().items()], headers={"Cache-Control": "no-store"})
async def changelog(request):
try:
text = (APP_DIR / "CHANGELOG.md").read_text()
except OSError:
text = ""
return web.Response(text=text, content_type="text/markdown", headers={"Cache-Control": "no-store"})
async def version(request): async def version(request):
return web.json_response({"version": VERSION}, headers={"Cache-Control": "no-store"}) return web.json_response({"version": VERSION}, headers={"Cache-Control": "no-store"})
@@ -248,6 +256,7 @@ def make_app():
app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024) app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024)
app.router.add_get("/", index) app.router.add_get("/", index)
app.router.add_get("/api/version", version) app.router.add_get("/api/version", version)
app.router.add_get("/api/changelog", changelog)
app.router.add_get("/api/whoami", whoami) app.router.add_get("/api/whoami", whoami)
app.router.add_get("/api/users", users) app.router.add_get("/api/users", users)
app.router.add_get("/api/dashboards", get_dashboards) app.router.add_get("/api/dashboards", get_dashboards)
+20
View File
@@ -312,6 +312,26 @@ class MockHA:
elif typ == "history/history_during_period": elif typ == "history/history_during_period":
start = req.get("start_time") or (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() start = req.get("start_time") or (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.history(req.get("entity_ids", []), start)}) await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.history(req.get("entity_ids", []), start)})
elif typ == "config/area_registry/list":
await ws.send_json({"id": mid, "type": "result", "success": True, "result": [
{"area_id": "living_room", "name": "Living Room", "icon": None, "temperature_entity_id": "sensor.living_room_temperature", "humidity_entity_id": "sensor.living_room_humidity"},
{"area_id": "kitchen", "name": "Kitchen", "icon": None},
{"area_id": "bedroom", "name": "Bedroom", "icon": None},
{"area_id": "office", "name": "Office", "icon": None},
{"area_id": "garage", "name": "Garage", "icon": None},
]})
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"],
"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"],
"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]
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 == "ping": elif typ == "ping":
await ws.send_json({"id": mid, "type": "pong"}) await ws.send_json({"id": mid, "type": "pong"})
else: else:
+112 -4
View File
@@ -30,6 +30,7 @@
this.connected = false; this.connected = false;
this.onChange = () => {}; this.onChange = () => {};
this.onStatus = () => {}; this.onStatus = () => {};
this.onRegistry = () => {};
this.ready = new Promise((r) => (this._markReady = r)); this.ready = new Promise((r) => (this._markReady = r));
} }
connect() { connect() {
@@ -38,6 +39,7 @@
const ws = (this.ws = new WebSocket(url)); const ws = (this.ws = new WebSocket(url));
ws.onmessage = (ev) => this.handle(JSON.parse(ev.data)); ws.onmessage = (ev) => this.handle(JSON.parse(ev.data));
ws.onclose = () => { ws.onclose = () => {
this._reg = null;
if (this.connected) this.ready = new Promise((r) => (this._markReady = r)); if (this.connected) this.ready = new Promise((r) => (this._markReady = r));
this.connected = false; this.connected = false;
this.onStatus(false); this.onStatus(false);
@@ -65,6 +67,7 @@
}); });
this.connected = true; this.connected = true;
this._markReady(); this._markReady();
this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card
this.onStatus(true); this.onStatus(true);
this.onChange(null); this.onChange(null);
return; return;
@@ -101,6 +104,25 @@
}); });
return res?.response || {}; return res?.response || {};
} }
/** Areas (rooms) and which entities are in them, from the area, entity and device registries. */
registry() {
this._reg ||= Promise.all([
this.send({ type: "config/area_registry/list" }),
this.send({ type: "config/entity_registry/list" }),
this.send({ type: "config/device_registry/list" }),
]).then(([areas, ents, devs]) => {
const devArea = Object.fromEntries((devs || []).map((d) => [d.id, d.area_id]));
const byArea = {};
for (const e of ents || []) {
if (e.disabled_by || e.hidden_by || e.entity_category) continue; // skip config/diagnostic and hidden entities
const area = e.area_id || devArea[e.device_id];
if (area) (byArea[area] ||= []).push(e.entity_id);
}
this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea };
return this.reg;
}).catch((err) => { this._reg = null; throw err; });
return this._reg;
}
/** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */ /** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */
async forecast(entityId, type = "daily") { async forecast(entityId, type = "daily") {
await this.ready; await this.ready;
@@ -205,7 +227,7 @@
const lights = first("light", 3); const lights = first("light", 3);
if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] }); if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] });
const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress/.test(e)).slice(0, 2); const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress|vacuum|robot|filter|cleaning|cleaned/.test(e)).slice(0, 2);
sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e })); sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e }));
const tiles = [...lights.slice(1, 2), ...first("switch", 1)]; const tiles = [...lights.slice(1, 2), ...first("switch", 1)];
tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e })); tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e }));
@@ -358,7 +380,8 @@
const pad = window.innerWidth < 560 ? 12 : 20; const pad = window.innerWidth < 560 ? 12 : 20;
const avail = canvasEl.clientWidth - pad * 2; const avail = canvasEl.clientWidth - pad * 2;
const width = g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail; const width = g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail;
const compact = !editMode && width < 560; // Phone dashboards are drawn exactly as designed; others reflow into two columns on narrow screens.
const compact = !editMode && width < 560 && !current.mobile;
const cols = compact ? 2 : Math.max(1, Number(g.cols) || 12); const cols = compact ? 2 : Math.max(1, Number(g.cols) || 12);
const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0; const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0;
const rh = Number(g.rowHeight) || 72; const rh = Number(g.rowHeight) || 72;
@@ -483,8 +506,10 @@
} }
} }
const phoneScreen = matchMedia("(max-width: 600px)");
function renderGrid() { function renderGrid() {
if (!current) return; if (!current) return;
document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && phoneScreen.matches);
renderNav(); renderNav();
applyTheme(); applyTheme();
computeMetrics(); computeMetrics();
@@ -633,6 +658,11 @@
${list.map((e, i) => `<div class="ent-item"><input class="input" type="text" list="${lid}" ${data} data-idx="${i}" value="${esc(e)}" autocomplete="off" spellcheck="false" aria-label="${esc(f.l)} ${i + 1}"><button class="btn icon" data-ent-del="${i}" ${data} aria-label="${esc(tr("Remove {0}", e))}">${Icons.svg("close")}</button></div>`).join("")} ${list.map((e, i) => `<div class="ent-item"><input class="input" type="text" list="${lid}" ${data} data-idx="${i}" value="${esc(e)}" autocomplete="off" spellcheck="false" aria-label="${esc(f.l)} ${i + 1}"><button class="btn icon" data-ent-del="${i}" ${data} aria-label="${esc(tr("Remove {0}", e))}">${Icons.svg("close")}</button></div>`).join("")}
<input class="input" type="text" list="${lid}" ${data} data-idx="new" placeholder="${tr("Add an entity…")}" autocomplete="off" spellcheck="false" aria-label="Add to ${esc(f.l)}"></div>`; <input class="input" type="text" list="${lid}" ${data} data-idx="new" placeholder="${tr("Add an entity…")}" autocomplete="off" spellcheck="false" aria-label="Add to ${esc(f.l)}"></div>`;
} }
case "area": {
const areas = ha.reg?.areas;
if (!areas) return `<div class="field"><span class="field-label">${esc(f.l)}</span><div class="muted small">${tr("Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.")}</div></div>`;
return `<div class="field">${label}<select class="input" id="${id}" ${data}><option value="">${tr("Choose a room")}</option>${areas.map((a) => `<option value="${esc(a.area_id)}" ${a.area_id === value ? "selected" : ""}>${esc(a.name)}</option>`).join("")}</select></div>`;
}
case "pos9": // a 3×3 grid for placing content in the card case "pos9": // a 3×3 grid for placing content in the card
return `<div class="field"><span class="field-label" id="${id}">${esc(f.l)}</span><div class="pos9" role="group" aria-labelledby="${id}"> return `<div class="field"><span class="field-label" id="${id}">${esc(f.l)}</span><div class="pos9" role="group" aria-labelledby="${id}">
${f.o.map((o) => `<button type="button" ${data} data-v="${o}" aria-pressed="${o === value}" aria-label="${esc(optLabel(o))}" title="${esc(optLabel(o))}"><i></i></button>`).join("")}</div></div>`; ${f.o.map((o) => `<button type="button" ${data} data-v="${o}" aria-pressed="${o === value}" aria-label="${esc(optLabel(o))}" title="${esc(optLabel(o))}"><i></i></button>`).join("")}</div></div>`;
@@ -731,6 +761,8 @@
${fieldHTML({ k: "blur", t: "number", l: "Blur behind tiles (px)", ph: "For photo backgrounds" }, t.blur, "theme")} ${fieldHTML({ k: "blur", t: "number", l: "Blur behind tiles (px)", ph: "For photo backgrounds" }, t.blur, "theme")}
</div> </div>
<div class="section"><div class="section-title">${tr("Grid")}</div> <div class="section"><div class="section-title">${tr("Grid")}</div>
${fieldHTML({ k: "mobile", t: "check", l: "Designed for phones" }, !!current.mobile, "layout")}
<p class="muted small" style="margin:-4px 2px 12px">${tr("A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.")}</p>
<div class="row">${GRID_FIELDS.slice(0, 2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}</div> <div class="row">${GRID_FIELDS.slice(0, 2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}</div>
<div class="row">${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}</div></div> <div class="row">${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}</div></div>
<div class="section"><div class="section-title">${tr("Kiosk")}</div> <div class="section"><div class="section-title">${tr("Kiosk")}</div>
@@ -746,7 +778,8 @@
<div class="section"><div class="section-title">${tr("About")}</div> <div class="section"><div class="section-title">${tr("About")}</div>
<div class="about"><img src="static/favicon.png?v=${esc(APP_VERSION)}" alt="" width="40" height="40"> <div class="about"><img src="static/favicon.png?v=${esc(APP_VERSION)}" alt="" width="40" height="40">
<div><div style="font-weight:600">Dashboard Studio</div><div class="muted small">${tr("Version {0}", esc(APP_VERSION))}</div></div></div> <div><div style="font-weight:600">Dashboard Studio</div><div class="muted small">${tr("Version {0}", esc(APP_VERSION))}</div></div></div>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener" style="width:100%;margin-top:10px;text-decoration:none">${Icons.svg("globe")}${tr("Source code and changes")}</a> <div class="insp-actions"><button class="btn" data-whatsnew>${Icons.svg("sparkles")}${tr("What’s new")}</button>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener" style="text-decoration:none">${Icons.svg("globe")}${tr("Source code")}</a></div>
</div> </div>
<p class="hint">${tr("Select a widget to edit it.")}</p>`; <p class="hint">${tr("Select a widget to edit it.")}</p>`;
} }
@@ -846,6 +879,7 @@
if (isChange || t.tagName === "BUTTON") renderInspector(); if (isChange || t.tagName === "BUTTON") renderInspector();
return; return;
} }
if (path === "layout") { convertLayout(!!v); renderInspector(); return; }
if (path === "nav") { if (path === "nav") {
mutate(() => { current.nav[k] = v; }); mutate(() => { current.nav[k] = v; });
return; return;
@@ -946,6 +980,31 @@
} }
} }
/** Switch a dashboard between the 12-column layout and the 4-column phone layout, converting every tab. */
function convertLayout(toMobile) {
if (!!current.mobile === toMobile) return;
mutate(() => {
const from = Number(current.grid.cols) || 12, to = toMobile ? 4 : 12, f = to / from;
current.mobile = toMobile;
current.grid.cols = to;
current.grid.maxWidth = toMobile ? 440 : 1440;
for (const t of current.tabs) {
const ws = [...t.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
for (const w of ws) {
if (toMobile) { // phone: small tiles become half width, anything a third wide or more full width
w.w = w.w >= from / 3 ? 4 : 2;
w.x = w.w === 4 ? 0 : w.x * f >= 1.5 ? 2 : 0;
} else {
w.w = Math.max(1, Math.min(to, Math.round(w.w * f)));
w.x = Math.min(to - w.w, Math.round(w.x * f));
}
}
const placed = []; // re-pack top-down so nothing overlaps
for (const w of ws) { while (placed.some((o) => overlap(w, o))) w.y++; placed.push(w); }
}
});
}
function findSpot(w, h, widgets = tab().widgets) { function findSpot(w, h, widgets = tab().widgets) {
const cols = current.grid.cols; const cols = current.grid.cols;
for (let y = 0; y < 500; y++) for (let y = 0; y < 500; y++)
@@ -956,7 +1015,7 @@
function addWidget(type) { function addWidget(type) {
const wd = makeWidget(type); const wd = makeWidget(type);
wd.w = Math.min(wd.w, current.grid.cols); wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols);
Object.assign(wd, findSpot(wd.w, wd.h)); Object.assign(wd, findSpot(wd.w, wd.h));
mutate(() => tab().widgets.push(wd)); mutate(() => tab().widgets.push(wd));
select(wd.id); select(wd.id);
@@ -1086,6 +1145,7 @@
const track = a.closest(".hs").querySelector(".hs-track"); const track = a.closest(".hs").querySelector(".hs-track");
track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" }); track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" });
} }
else if (act === "svc-many") { const [d, sv] = a.dataset.v.split("."); ha.callService(d, sv, {}, a.dataset.ids.split(",")); }
else if (act === "photo-next") { else if (act === "photo-next") {
const c = caches[a.closest(".w").dataset.id]; const c = caches[a.closest(".w").dataset.id];
(c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg (c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg
@@ -1217,6 +1277,28 @@
card.removeEventListener("change", update); card.removeEventListener("change", update);
} }
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
async function whatsNew() {
let md = "";
try { md = await fetch(new URL("api/changelog", BASE)).then((r) => r.text()); } catch { /* shown as empty below */ }
const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/\*(.+?)\*/g, "<i>$1</i>");
let html = "", inList = false;
for (const line of md.split("\n")) {
if (/^- /.test(line)) { if (!inList) { html += "<ul>"; inList = true; } html += `<li>${inline(line.slice(2))}</li>`; continue; }
if (inList) { html += "</ul>"; inList = false; }
if (/^## /.test(line)) html += `<h4>${inline(line.slice(3))}${line.slice(3).trim() === APP_VERSION ? ` <span class="muted small">· ${tr("this version")}</span>` : ""}</h4>`;
}
if (inList) html += "</ul>";
const card = $("#modal .modal-card");
const done = openModal(`<h3>${tr("What’s new")}</h3><div class="changelog">${html || `<p>${tr("The changelog couldn’t be loaded.")}</p>`}</div>
<div class="modal-actions"><button class="btn primary" data-x="close">${tr("Close")}</button></div>`, (e, close) => { if (e.target.closest("[data-x]")) close(); });
card.classList.add("wide");
card.querySelector(".changelog").scrollIntoView({ block: "start" }); // start at the newest version, not at the Close button
card.scrollTop = 0;
await done;
card.classList.remove("wide");
}
function toast(msg, err) { function toast(msg, err) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "toast" + (err ? " err" : ""); el.className = "toast" + (err ? " err" : "");
@@ -1324,6 +1406,25 @@
document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true); document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
document.addEventListener("visibilitychange", () => { if (kiosk && document.visibilityState === "visible") requestWakeLock(); }); document.addEventListener("visibilitychange", () => { if (kiosk && document.visibilityState === "visible") requestWakeLock(); });
// ------------------------------------------------------------ phone dashboards
// The top bar is hidden; touching and holding an empty spot brings it back for a while.
let peekTimer = 0, holdTimer2 = 0;
canvasEl.addEventListener("pointerdown", (e) => {
if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return;
holdTimer2 = setTimeout(() => {
document.body.classList.add("peek");
clearTimeout(peekTimer);
peekTimer = setTimeout(() => document.body.classList.remove("peek"), 10000);
navigator.vibrate?.(10);
}, 600);
});
for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); });
function phoneHint() {
if (!document.body.classList.contains("phone-ui") || lsGet("ds.peekHint")) return;
lsSet("ds.peekHint", "1");
toast(tr("Touch and hold an empty spot to show the menu."));
}
// ------------------------------------------------------------ chrome wiring // ------------------------------------------------------------ chrome wiring
function setCurrent(id) { function setCurrent(id) {
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0]; current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
@@ -1453,6 +1554,7 @@
if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; } if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; }
if (childBtn) { childOp(childBtn.dataset.childOp, Number(childBtn.dataset.i)); return; } if (childBtn) { childOp(childBtn.dataset.childOp, Number(childBtn.dataset.i)); return; }
if (e.target.closest("[data-child-back]")) { childIndex = null; renderInspector(); return; } if (e.target.closest("[data-child-back]")) { childIndex = null; renderInspector(); return; }
if (e.target.closest("[data-whatsnew]")) { whatsNew(); return; }
const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]"); const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]");
if (ruleAdd || ruleDel) { if (ruleAdd || ruleDel) {
const w = tab().widgets.find((x) => x.id === selectedId); const w = tab().widgets.find((x) => x.id === selectedId);
@@ -1560,6 +1662,7 @@
} }
setInterval(checkVersion, 5 * 60 * 1000); setInterval(checkVersion, 5 * 60 * 1000);
ha.onChange = onStateChange; ha.onChange = onStateChange;
ha.onRegistry = () => { renderGrid(); if (editMode) renderInspector(); };
/** Translate the static page: elements marked data-i18n, plus every aria-label and title. */ /** Translate the static page: elements marked data-i18n, plus every aria-label and title. */
function translateStatic() { function translateStatic() {
@@ -1597,6 +1700,11 @@
const exists = (id) => id && store.dashboards.some((d) => d.id === id); const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists)); setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
if (location.hash.includes("kiosk") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false }); if (location.hash.includes("kiosk") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
setTimeout(phoneHint, 1500);
// After an update, point at what changed (once per device).
const seen = lsGet("ds.seenVersion");
if (APP_VERSION !== "dev" && seen && seen !== APP_VERSION) setTimeout(() => toast(tr("Updated to {0}. See what’s new under About in the settings.", APP_VERSION)), 2000);
if (APP_VERSION !== "dev") lsSet("ds.seenVersion", APP_VERSION);
} }
boot(); boot();
+13
View File
@@ -272,6 +272,19 @@
"Battery": "Accu", "Cleaned area": "Schoongemaakt oppervlak", "Cleaning time": "Schoonmaaktijd", "Battery": "Accu", "Cleaned area": "Schoongemaakt oppervlak", "Cleaning time": "Schoonmaaktijd",
"More sensors, e.g. filter or brush life": "Meer sensoren, bijv. levensduur van filter of borstel", "More sensors, e.g. filter or brush life": "Meer sensoren, bijv. levensduur van filter of borstel",
"Resume": "Hervat", "Start": "Start", "Dock": "Dock", "Locate": "Zoek", "Resume": "Hervat", "Start": "Start", "Dock": "Dock", "Locate": "Zoek",
"Designed for phones": "Ontworpen voor telefoons",
"A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.": "Een indeling met 4 kolommen die je op telefoongrootte ontwerpt. Op een telefoon vult hij het scherm zonder de bovenbalk; houd een lege plek ingedrukt om de balk terug te halen.",
"Touch and hold an empty spot to show the menu.": "Houd een lege plek ingedrukt om het menu te tonen.",
"Room": "Kamer", "Room name": "Naam van de kamer", "Temperature sensor": "Temperatuursensor", "Humidity sensor": "Vochtigheidssensor",
"Extra entities (or all of them, without a room)": "Extra entiteiten (of allemaal, zonder kamer)", "Show devices": "Toon apparaten",
"Background image URL (optional)": "URL van achtergrondafbeelding (optioneel)", "Automatic": "Automatisch",
"Choose a room or add entities in the inspector.": "Kies een kamer of voeg entiteiten toe in de inspector.",
"Turn off all lights in {0}": "Zet alle lampen in {0} uit", "Turn on all lights in {0}": "Zet alle lampen in {0} aan",
"{0} on": "{0} aan", "Lights off": "Lampen uit", "open": "open", "Playing": "Speelt",
"Choose a room": "Kies een kamer", "Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.": "Kamers konden niet uit Home Assistant worden geladen. Voeg hieronder de entiteiten toe.",
"What’s new": "Wat is er nieuw", "Source code": "Broncode", "this version": "deze versie",
"The changelog couldn’t be loaded.": "De lijst met wijzigingen kon niet worden geladen.",
"Updated to {0}. See what’s new under About in the settings.": "Bijgewerkt naar {0}. Bekijk wat er nieuw is onder Over in de instellingen.",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
+1
View File
@@ -47,6 +47,7 @@
"eye-off": "M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.7 17.7 0 0 1-3.2 4.1M6.6 6.6A17.6 17.6 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2", "eye-off": "M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.7 17.7 0 0 1-3.2 4.1M6.6 6.6A17.6 17.6 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2",
hscroll: "M3 6h5v12H3zM10 6h5v12h-5zM17 6h4M17 18h4M21 6v12", hscroll: "M3 6h5v12H3zM10 6h5v12h-5zM17 6h4M17 18h4M21 6v12",
swipe: "M6 4h12a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM9 21h.01M12 21h.01M15 21h.01", swipe: "M6 4h12a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM9 21h.01M12 21h.01M15 21h.01",
sofa: "M5 11V8a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3M3 12a2 2 0 0 1 4 0v2h10v-2a2 2 0 0 1 4 0v5H3zM5 17v2M19 17v2",
calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01", calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2", clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
chart: "M3 3v18h18M7 15l4-4 3 3 6-6", chart: "M3 3v18h18M7 15l4-4 3 3 6-6",
+34
View File
@@ -218,6 +218,10 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
.assign-kiosk { display: flex; align-items: center; gap: 6px; font-size: 13px; white-space: nowrap; } .assign-kiosk { display: flex; align-items: center; gap: 6px; font-size: 13px; white-space: nowrap; }
.assign-kiosk input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; } .assign-kiosk input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
@media (max-width: 560px) { .assign-row[data-dev] { flex-wrap: wrap; } .assign-row[data-dev] .input { flex-basis: 100%; } } @media (max-width: 560px) { .assign-row[data-dev] { flex-wrap: wrap; } .assign-row[data-dev] .input { flex-basis: 100%; } }
.changelog { margin-top: 8px; }
.changelog h4 { margin: 16px 0 4px; font-size: 15px; }
.changelog ul { margin: 0; padding-left: 20px; color: var(--text); }
.changelog li { margin: 4px 0; line-height: 1.45; }
.attr-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 14px; } .attr-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 14px; }
.attr-table td { border-top: 1px solid var(--separator); padding: 7px 4px; vertical-align: top; word-break: break-word; } .attr-table td { border-top: 1px solid var(--separator); padding: 7px 4px; vertical-align: top; word-break: break-word; }
.attr-table td:first-child { color: var(--text-2); width: 40%; } .attr-table td:first-child { color: var(--text-2); width: 40%; }
@@ -584,6 +588,29 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.light-members { flex: 1; min-height: 0; margin: 0 -6px; } .light-members { flex: 1; min-height: 0; margin: 0 -6px; }
.light:has(.light-members) { justify-content: flex-start; } .light:has(.light-members) { justify-content: flex-start; }
/* room */
.room { position: relative; display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; }
.room.on-image { margin: -16px; padding: 16px; border-radius: inherit; color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); --d-fill: rgba(255, 255, 255, 0.18);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.15) 45%, rgba(0, 0, 0, 0.6)), var(--room-img) center / cover; }
.room-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
.room-name { font-size: 17px; }
.room-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.room-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 12px; border-radius: 99px; background: var(--d-fill); font-size: 13px; font-weight: 600; max-width: 100%; }
.room-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.room-chip .i { font-size: 16px; flex: none; }
.room-chip.on { background: var(--d-text); color: var(--d-tile); }
.room-chip.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 16%, transparent); }
.room-devices { flex: 1; min-height: 0; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); align-content: start; gap: 8px; }
.room-dev { display: flex; align-items: center; gap: 8px; padding: 8px 10px; min-height: 48px; border-radius: 12px; background: var(--d-fill); min-width: 0; }
.room-dev.on { background: var(--d-text); color: var(--d-tile); --d-text-2: color-mix(in srgb, var(--d-tile) 70%, transparent); }
.room-dev-ico { font-size: 18px; display: flex; flex: none; }
.room-dev-text { display: flex; flex-direction: column; min-width: 0; }
.room-dev-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.room-dev-state { font-size: 12px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.w.lit-lamp .room-dev.on { background: rgba(255, 255, 255, 0.7); color: var(--lit-text); --d-text-2: var(--lit-text-2); }
.w.lit-lamp .room-chip.on { background: rgba(61, 44, 10, 0.85); color: #fff6e6; }
@container (max-height: 200px) { .room-devices { display: none; } }
/* vacuum: compact on small cards, a robot with stats on bigger ones */ /* vacuum: compact on small cards, a robot with stats on bigger ones */
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; } .vac { display: flex; flex-direction: column; height: 100%; gap: 12px; }
.vac-head { display: flex; align-items: center; gap: 12px; min-width: 0; } .vac-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
@@ -676,6 +703,13 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
@container (max-width: 340px) { .car-img { display: none; } .car.has-img .car-head, .car.has-img .car-main { max-width: none; } .car-odo { display: none; } } @container (max-width: 340px) { .car-img { display: none; } .car.has-img .car-head, .car.has-img .car-main { max-width: none; } .car-odo { display: none; } }
@container (max-height: 200px) { .car-actions { display: none; } } @container (max-height: 200px) { .car-actions { display: none; } }
/* ============================================================ phone dashboards */
body.phone-ui:not(.peek) #topbar { display: none; }
body.phone-ui #stage { padding-top: calc(env(safe-area-inset-top) + 6px); }
body.phone-ui.peek #stage { padding-top: 72px; }
body.phone-ui.peek #topbar { animation: peek-in .2s ease-out; }
@keyframes peek-in { from { opacity: 0; transform: translateY(-8px); } }
/* ============================================================ kiosk */ /* ============================================================ kiosk */
body.kiosk #topbar, body.kiosk #palette, body.kiosk #inspector { display: none !important; } body.kiosk #topbar, body.kiosk #palette, body.kiosk #inspector { display: none !important; }
body.kiosk #stage { padding-top: 6px; } body.kiosk #stage { padding-top: 6px; }
+67
View File
@@ -477,6 +477,73 @@
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false), lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
}; };
// ---------------------------------------------------------------- room
// Everything in one Home Assistant area at a glance: climate, what's on or open, and quick controls.
function roomEntities(c, ha) {
const ids = [...(ha?.reg?.byArea?.[c.area] || []), ...(c.entities || [])];
return [...new Set(ids)].filter((e) => ha?.states?.[e]);
}
const TOGGLES = ["light", "switch", "fan", "input_boolean", "cover", "lock"];
W.room = {
label: "Room", icon: "sofa", size: [4, 4],
fields: [
{ k: "area", t: "area", l: "Room" },
{ k: "name", t: "text", l: "Name", ph: "Room name" },
{ k: "icon", t: "icon", l: "Icon", d: "sofa" },
{ k: "temperature", t: "entity", l: "Temperature sensor", domains: ["sensor"], ph: "Automatic" },
{ k: "humidity", t: "entity", l: "Humidity sensor", domains: ["sensor"] },
{ k: "entities", t: "entities", l: "Extra entities (or all of them, without a room)" },
{ k: "devices", t: "check", l: "Show devices", d: true },
{ k: "image", t: "text", l: "Background image URL (optional)", ph: "/local/living-room.jpg" },
],
entities: (c, ha) => [...roomEntities(c, ha), c.temperature, c.humidity].filter(Boolean),
render(c, { ha }) {
const area = ha.reg?.areas?.find((a) => a.area_id === c.area);
const ids = roomEntities(c, ha);
if (!area && !ids.length) return `<div class="empty">${t("Choose a room or add entities in the inspector.")}</div>`;
const S = (e) => ha.states[e], dom = (e) => domainOf(e), dc = (e) => S(e).attributes.device_class;
const name = c.name || area?.name || t("Room");
const pick = (explicit, fromArea, cls) => (explicit && S(explicit) ? explicit : fromArea && S(fromArea) ? fromArea : ids.find((e) => dom(e) === "sensor" && dc(e) === cls));
const temp = pick(c.temperature, area?.temperature_entity_id, "temperature");
const hum = pick(c.humidity, area?.humidity_entity_id, "humidity");
// Count and show the actual lamps: leave out light groups, whose lamps are already listed.
const isGroup = (e) => Array.isArray(S(e).attributes.entity_id);
const lights = ids.filter((e) => dom(e) === "light" && !isGroup(e));
const lightsOn = lights.filter((e) => S(e).state === "on");
const open = ids.filter((e) => dom(e) === "binary_sensor" && ["door", "window", "opening", "garage_door"].includes(dc(e)) && S(e).state === "on");
const motion = ids.filter((e) => dom(e) === "binary_sensor" && ["motion", "occupancy", "presence"].includes(dc(e)) && S(e).state === "on");
const playing = ids.filter((e) => dom(e) === "media_player" && S(e).state === "playing");
const climate = ids.find((e) => dom(e) === "climate");
// "Living Room Lamp" → "Lamp" inside the Living Room card
const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i");
const short = (e) => friendly(S(e)).replace(prefix, "") || friendly(S(e));
const chips = [];
if (lights.length) chips.push(`<button class="room-chip wbtn ${lightsOn.length ? "on" : ""}" data-act="svc-many" data-v="light.${lightsOn.length ? "turn_off" : "turn_on"}" data-ids="${esc((lightsOn.length ? lightsOn : lights).join(","))}" aria-label="${esc(lightsOn.length ? t("Turn off all lights in {0}", name) : t("Turn on all lights in {0}", name))}">${Icons.svg("bulb")}<span>${lightsOn.length ? t("{0} on", lightsOn.length) : t("Lights off")}</span></button>`);
if (climate) { const cs = S(climate); chips.push(`<button class="room-chip wbtn" data-act="more" data-e="${esc(climate)}">${Icons.svg("thermometer")}<span>${esc(st(cs.state))}${cs.attributes.temperature != null ? ` · ${esc(cs.attributes.temperature)}°` : ""}</span></button>`); }
for (const e of open) chips.push(`<button class="room-chip wbtn warn" data-act="more" data-e="${esc(e)}">${icon(S(e))}<span>${esc(short(e))} ${t("open")}</span></button>`);
if (motion.length) chips.push(`<span class="room-chip">${Icons.svg("person")}<span>${t("Motion")}</span></span>`);
for (const e of playing) chips.push(`<button class="room-chip wbtn" data-act="more" data-e="${esc(e)}">${Icons.svg("music")}<span>${esc(S(e).attributes.media_title || t("Playing"))}</span></button>`);
const devices = c.devices !== false ? ids.filter((e) => TOGGLES.includes(dom(e)) && !isGroup(e)).slice(0, 12) : [];
const climateText = [temp && fmtState(S(temp)), hum && fmtState(S(hum))].filter(Boolean).join(" · ");
return `<div class="room ${c.image ? "on-image" : ""}" ${c.image ? `style="--room-img:url('${esc(c.image).replace(/'/g, "%27")}')"` : ""}>
<div class="room-head"><span class="badge">${icon(null, c.icon || "sofa")}</span>
<span class="tile-text"><span class="name room-name">${esc(name)}</span>${climateText ? `<span class="state num">${esc(climateText)}</span>` : ""}</span></div>
${chips.length ? `<div class="room-chips">${chips.join("")}</div>` : ""}
${devices.length ? `<div class="room-devices">${devices.map((e) => `<button class="room-dev wbtn ${isOn(S(e)) ? "on" : ""}" data-act="toggle" data-e="${esc(e)}" aria-pressed="${isOn(S(e))}">
<span class="room-dev-ico">${icon(S(e))}</span><span class="room-dev-text"><span class="room-dev-name">${esc(short(e))}</span><span class="room-dev-state">${esc(fmtState(S(e)))}</span></span></button>`).join("")}</div>` : ""}
</div>`;
},
lit: (c, { ha }) => {
const ids = roomEntities(c, ha);
return ids.some((e) => domainOf(e) === "light" && ha.states[e].state === "on") ? "lamp" : false;
},
};
// ---------------------------------------------------------------- vacuum // ---------------------------------------------------------------- vacuum
// One layout that grows with the card: compact on small cards; a robot with a battery ring, // One layout that grows with the card: compact on small cards; a robot with a battery ring,
// stats and labelled buttons on bigger ones (see the container queries in style.css). // stats and labelled buttons on bigger ones (see the container queries in style.css).