diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md new file mode 100644 index 0000000..9111b95 --- /dev/null +++ b/dashboard_studio/CHANGELOG.md @@ -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. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 3c03948..7ac4851 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -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**. +### 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 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. +## 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 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 -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 diff --git a/dashboard_studio/Dockerfile b/dashboard_studio/Dockerfile index 1c4281a..6212a39 100644 --- a/dashboard_studio/Dockerfile +++ b/dashboard_studio/Dockerfile @@ -6,7 +6,7 @@ RUN apk add --no-cache python3 py3-aiohttp WORKDIR /app COPY server/ /app/server/ COPY web/ /app/web/ -COPY config.yaml /app/config.yaml +COPY config.yaml CHANGELOG.md /app/ COPY run.sh /run.sh RUN chmod a+x /run.sh diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index fbcf145..1455a62 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.9.0" +version: "1.10.0" slug: dashboard_studio description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. url: https://git.melvanveen.nl/veenm/ha-dashboard-studio diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py index 7c5b643..f5e1a58 100644 --- a/dashboard_studio/server/main.py +++ b/dashboard_studio/server/main.py @@ -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"}) +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): 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.router.add_get("/", index) 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/users", users) app.router.add_get("/api/dashboards", get_dashboards) diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py index 8006d1a..bcffff2 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -312,6 +312,26 @@ class MockHA: elif typ == "history/history_during_period": 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)}) + 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": await ws.send_json({"id": mid, "type": "pong"}) else: diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index e06a216..8bf1f8d 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -30,6 +30,7 @@ this.connected = false; this.onChange = () => {}; this.onStatus = () => {}; + this.onRegistry = () => {}; this.ready = new Promise((r) => (this._markReady = r)); } connect() { @@ -38,6 +39,7 @@ const ws = (this.ws = new WebSocket(url)); ws.onmessage = (ev) => this.handle(JSON.parse(ev.data)); ws.onclose = () => { + this._reg = null; if (this.connected) this.ready = new Promise((r) => (this._markReady = r)); this.connected = false; this.onStatus(false); @@ -65,6 +67,7 @@ }); this.connected = true; this._markReady(); + this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card this.onStatus(true); this.onChange(null); return; @@ -101,6 +104,25 @@ }); 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. */ async forecast(entityId, type = "daily") { await this.ready; @@ -205,7 +227,7 @@ const lights = first("light", 3); 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 })); const tiles = [...lights.slice(1, 2), ...first("switch", 1)]; 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 avail = canvasEl.clientWidth - pad * 2; 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 gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0; const rh = Number(g.rowHeight) || 72; @@ -483,8 +506,10 @@ } } + const phoneScreen = matchMedia("(max-width: 600px)"); function renderGrid() { if (!current) return; + document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && phoneScreen.matches); renderNav(); applyTheme(); computeMetrics(); @@ -633,6 +658,11 @@ ${list.map((e, i) => `
`).join("")} `; } + case "area": { + const areas = ha.reg?.areas; + if (!areas) return `
${esc(f.l)}
${tr("Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.")}
`; + return `
${label}
`; + } case "pos9": // a 3×3 grid for placing content in the card return `
${esc(f.l)}
${f.o.map((o) => ``).join("")}
`; @@ -731,6 +761,8 @@ ${fieldHTML({ k: "blur", t: "number", l: "Blur behind tiles (px)", ph: "For photo backgrounds" }, t.blur, "theme")}
${tr("Grid")}
+ ${fieldHTML({ k: "mobile", t: "check", l: "Designed for phones" }, !!current.mobile, "layout")} +

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

${GRID_FIELDS.slice(0, 2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
${tr("Kiosk")}
@@ -746,7 +778,8 @@
${tr("About")}
Dashboard Studio
${tr("Version {0}", esc(APP_VERSION))}
- ${Icons.svg("globe")}${tr("Source code and changes")} +
+ ${Icons.svg("globe")}${tr("Source code")}

${tr("Select a widget to edit it.")}

`; } @@ -846,6 +879,7 @@ if (isChange || t.tagName === "BUTTON") renderInspector(); return; } + if (path === "layout") { convertLayout(!!v); renderInspector(); return; } if (path === "nav") { mutate(() => { current.nav[k] = v; }); 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) { const cols = current.grid.cols; for (let y = 0; y < 500; y++) @@ -956,7 +1015,7 @@ function addWidget(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)); mutate(() => tab().widgets.push(wd)); select(wd.id); @@ -1086,6 +1145,7 @@ const track = a.closest(".hs").querySelector(".hs-track"); 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") { const c = caches[a.closest(".w").dataset.id]; (c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg @@ -1217,6 +1277,28 @@ 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, "$1").replace(/\*(.+?)\*/g, "$1"); + let html = "", inList = false; + for (const line of md.split("\n")) { + if (/^- /.test(line)) { if (!inList) { html += ""; inList = false; } + if (/^## /.test(line)) html += `

${inline(line.slice(3))}${line.slice(3).trim() === APP_VERSION ? ` · ${tr("this version")}` : ""}

`; + } + if (inList) html += ""; + const card = $("#modal .modal-card"); + const done = openModal(`

${tr("What’s new")}

${html || `

${tr("The changelog couldn’t be loaded.")}

`}
+ `, (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) { const el = document.createElement("div"); el.className = "toast" + (err ? " err" : ""); @@ -1324,6 +1406,25 @@ document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true); 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 function setCurrent(id) { 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 (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-whatsnew]")) { whatsNew(); return; } const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]"); if (ruleAdd || ruleDel) { const w = tab().widgets.find((x) => x.id === selectedId); @@ -1560,6 +1662,7 @@ } setInterval(checkVersion, 5 * 60 * 1000); ha.onChange = onStateChange; + ha.onRegistry = () => { renderGrid(); if (editMode) renderInspector(); }; /** Translate the static page: elements marked data-i18n, plus every aria-label and title. */ function translateStatic() { @@ -1597,6 +1700,11 @@ const exists = (id) => id && store.dashboards.some((d) => d.id === id); setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists)); if (location.hash.includes("kiosk") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false }); + 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(); diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index 445b28f..4d1b8a6 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -272,6 +272,19 @@ "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", "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", }; diff --git a/dashboard_studio/web/icons.js b/dashboard_studio/web/icons.js index 4b909ba..917b0e4 100644 --- a/dashboard_studio/web/icons.js +++ b/dashboard_studio/web/icons.js @@ -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", 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", + 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", 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", diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index 04dac52..79303d5 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -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 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%; } } +.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 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%; } @@ -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: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 */ .vac { display: flex; flex-direction: column; height: 100%; gap: 12px; } .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-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 */ body.kiosk #topbar, body.kiosk #palette, body.kiosk #inspector { display: none !important; } body.kiosk #stage { padding-top: 6px; } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index cf45f17..5169a0b 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -477,6 +477,73 @@ 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 `
${t("Choose a room or add entities in the inspector.")}
`; + 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(``); + if (climate) { const cs = S(climate); chips.push(``); } + for (const e of open) chips.push(``); + if (motion.length) chips.push(`${Icons.svg("person")}${t("Motion")}`); + for (const e of playing) chips.push(``); + + 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 `
+
${icon(null, c.icon || "sofa")} + ${esc(name)}${climateText ? `${esc(climateText)}` : ""}
+ ${chips.length ? `
${chips.join("")}
` : ""} + ${devices.length ? `
${devices.map((e) => ``).join("")}
` : ""} +
`; + }, + lit: (c, { ha }) => { + const ids = roomEntities(c, ha); + return ids.some((e) => domainOf(e) === "light" && ha.states[e].state === "on") ? "lamp" : false; + }, + }; + // ---------------------------------------------------------------- vacuum // 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).