diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 3b76ca0..1f2803f 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -45,6 +45,21 @@ This card works with the [Afvalwijzer](https://github.com/xirixiz/homeassistant- Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and so on), and each bin is always named, so the color is never the only clue. Types with no pickup planned are hidden. You can limit the card to a number of pickups, or to pickups within a number of days. +### 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. + +### Appliance (washer, dryer, dishwasher) + +Shows whether an appliance is busy, finished or paused, with the program, the time left, the time it will be ready and a progress bar. It works with any integration, such as Home Connect, Miele, LG ThinQ, SmartThings, or a smart plug: + +- **Status:** a sensor such as `sensor.washer_status`. Values like *running*, *washing*, *drying*, *finished* or *klaar* are recognized. +- **Power:** if there's no status, a power sensor works too. The appliance counts as running above the number of watts you set (5 W by default). +- **Time remaining:** minutes, hours, or an end time. +- **Progress** (%) and **Program** are optional. + +By default the card only shows while the appliance is running or has just finished, and hides itself when it's off. + ### Car Point the car widget at whichever entities your car integration provides. Each one is optional: @@ -64,6 +79,12 @@ Point the car widget at whichever entities your car integration provides. Each o The widget shows the level as a bar with the charge limit marked, the range, and a status line (charging and its power, plugged in, parked at home, or away). It also has buttons to lock or unlock the doors and to start climate. You can add a car image by URL. Images you put in Home Assistant's `www` folder are available at `/local/…`. +## Conditional widgets + +Any widget can be shown only under certain conditions. Select it in edit mode, then under **Visibility** choose **Add condition**. Pick an entity, a comparison (*is*, *is not*, *above*, *below*) and a value. For example: *switch.coffee_machine is on*, or *sensor.washer_power above 5*. With several conditions, choose whether all of them or just one must be true. + +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. + ## 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: diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index fcdd577..b7859fb 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.4.0" +version: "1.5.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 fce0b08..b672d70 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -60,7 +60,16 @@ class MockHA: entity("climate.thermostat", "heat", friendly_name="Thermostat", current_temperature=20.5, temperature=21, min_temp=7, max_temp=30, hvac_modes=["off", "heat", "auto"]), entity("cover.garage_door", "closed", friendly_name="Garage Door"), entity("lock.front_door", "locked", friendly_name="Front Door Lock"), - entity("media_player.living_room_speaker", "playing", friendly_name="Living Room Speaker", media_title="Blue in Green", media_artist="Miles Davis", volume_level=0.35), + entity("media_player.living_room_speaker", "playing", friendly_name="Living Room Speaker", media_title="Blue in Green", media_artist="Miles Davis", media_album_name="Kind of Blue", volume_level=0.35, entity_picture="data:image/svg+xml;utf8,"), + entity("media_player.kitchen_speaker", "paused", friendly_name="Kitchen Speaker", media_title="Bohemian Rhapsody", media_artist="Queen", volume_level=0.2), + entity("media_player.tv", "off", friendly_name="TV"), + entity("sensor.washer_status", "running", friendly_name="Washer Status"), + entity("sensor.washer_remaining_time", "42", friendly_name="Washer Remaining Time", unit_of_measurement="min", device_class="duration"), + entity("sensor.washer_progress", "64", friendly_name="Washer Progress", unit_of_measurement="%"), + entity("sensor.washer_program", "cotton_40", friendly_name="Washer Program"), + entity("sensor.washer_power", "450", friendly_name="Washer Power", unit_of_measurement="W", device_class="power"), + entity("sensor.dryer_status", "off", friendly_name="Dryer Status"), + entity("sensor.dryer_remaining_time", "0", friendly_name="Dryer Remaining Time", unit_of_measurement="min", device_class="duration"), entity("scene.movie_night", "2026-10-24T20:00:00+00:00", friendly_name="Movie Night"), entity("script.good_morning", "off", friendly_name="Good Morning"), entity("weather.home", "partlycloudy", friendly_name="Home", temperature=13, humidity=71, wind_speed=14), @@ -215,6 +224,13 @@ class MockHA: lvl = float(self.states["sensor.model_y_battery"]["state"]) if lvl < 80: self.set_state("sensor.model_y_battery", f"{lvl + 0.2:.1f}") + if self.states["sensor.washer_status"]["state"] == "running": + left = max(0, int(self.states["sensor.washer_remaining_time"]["state"]) - 1) + self.set_state("sensor.washer_remaining_time", str(left)) + self.set_state("sensor.washer_progress", str(min(100, 100 - round(left * 36 / 42)))) + if left == 0: + self.set_state("sensor.washer_status", "finished") + self.set_state("sensor.washer_power", "1") if random.random() < 0.15: eid = "binary_sensor.hallway_motion" self.set_state(eid, "off" if self.states[eid]["state"] == "on" else "on") diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index eff72aa..8038306 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -175,7 +175,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/.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/.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 })); @@ -197,6 +197,22 @@ if (sw.length) put("list", carLock ? 6 : 8, y, carLock ? 6 : 4, 3, { title: tr("Quick controls"), entities: sw }); const graphSensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && ha.states[e].attributes.device_class === "temperature").slice(0, 2); if (graphSensors.length) put("graph", 0, carLock ? y + 3 : y, carLock ? 12 : 8, 3, { entities: graphSensors, title: tr("Temperature, last 24 hours") }); + const players = first("media_player", 4); + const appliances = [["washer", /(washer|washing_machine|wasmachine)_(status|state|operation_state|program_state)$/, "Washing machine"], + ["dryer", /(dryer|droger|wasdroger)_(status|state|operation_state|program_state)$/, "Dryer"], + ["dishwasher", /(dishwasher|vaatwasser|vaatwasmachine)_(status|state|operation_state|program_state)$/, "Dishwasher"]] + .map(([kind, re, name]) => ({ kind, name, status: ids.find((e) => domainOf(e) === "sensor" && re.test(e)) })).filter((a) => a.status); + if (players.length || appliances.length) { + let y2 = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)), x2 = 0; + if (players.length) { put("media", 0, y2, 4, 3, { entities: players }); x2 = 4; } + for (const a of appliances) { + const base = a.status.replace(/_(status|state|operation_state|program_state)$/, ""); + const pick = (...suffixes) => suffixes.map((sfx) => `${base}_${sfx}`).find((e) => ha.states[e]) || ""; + if (x2 + 4 > 12) { x2 = 0; y2 += 3; } + put("appliance", x2, y2, 4, 2, { name: tr(a.name), kind: a.kind, status: a.status, remaining: pick("remaining_time", "time_remaining", "remaining", "end_time", "finish_time"), progress: pick("progress", "program_progress"), program: pick("program", "selected_program", "active_program"), power: pick("power") }); + x2 += 4; + } + } if (ids.some((e) => e.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in ha.states[e].attributes)) { const bottom = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)); put("waste", 0, bottom, 4, 4, {}); @@ -302,13 +318,40 @@ } // Compact widths: two columns in reading order. Small widgets take one column, the rest the full width. + // ------------------------------------------------------------ visibility + // A widget can carry conditions ("show only when the washer is running"); widgets may + // also hide themselves (autoHide). Editing always shows everything. + function ruleMatches(r) { + const st = String(ha.states[r.entity]?.state ?? "unavailable").toLowerCase(); + const v = String(r.value ?? "").trim().toLowerCase(); + switch (r.op) { + case "is not": return st !== v; + case "above": return Number(st) > Number(v); + case "below": return Number(st) < Number(v); + default: return st === v; + } + } + function isVisible(w) { + if (Widgets[w.type]?.autoHide?.(w.cfg, ctxFor(w))) return false; + const rules = (w.visibility?.rules || []).filter((r) => r.entity); + if (!rules.length) return true; + return w.visibility.mode === "any" ? rules.some(ruleMatches) : rules.every(ruleMatches); + } + const ruleEntities = (w) => (w.visibility?.rules || []).map((r) => r.entity).filter(Boolean); + let lastVisible = {}; + function positions() { const map = {}; + const shown = editMode ? tab().widgets : tab().widgets.filter(isVisible); if (!metrics.compact) { - for (const w of tab().widgets) map[w.id] = w; + if (editMode) { for (const w of shown) map[w.id] = w; return map; } + // Rows that only hidden widgets used close up, so nothing leaves a hole. + const rows = (list) => { const set = new Set(); for (const w of list) for (let r = w.y; r < w.y + w.h; r++) set.add(r); return set; }; + const vis = rows(shown), gone = [...rows(tab().widgets.filter((w) => !shown.includes(w)))].filter((r) => !vis.has(r)); + for (const w of shown) map[w.id] = { x: w.x, y: w.y - gone.filter((r) => r < w.y).length, w: w.w, h: w.h }; return map; } - const sorted = [...tab().widgets].sort((a, b) => a.y - b.y || a.x - b.x); + const sorted = [...shown].sort((a, b) => a.y - b.y || a.x - b.x); const small = (w) => w.w <= Math.floor(current.grid.cols / 4) && w.type !== "heading"; let y = 0, halfOpen = null; for (const w of sorted) { @@ -395,7 +438,10 @@ computeMetrics(); const pos = positions(); gridEl.innerHTML = ""; + lastVisible = {}; for (const w of tab().widgets) { + lastVisible[w.id] = isVisible(w); + if (!pos[w.id]) continue; const el = document.createElement("div"); el.className = "w"; el.dataset.id = w.id; @@ -430,7 +476,10 @@ } let html; try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `
${tr("This widget couldn’t be drawn.")}
`; } + const hiddenNow = editMode && !isVisible(w); + el.classList.toggle("cond-hidden", hiddenNow); el.innerHTML = `
${html}
` + + (hiddenNow ? `
${Icons.svg("eye-off")}${tr("Hidden now")}
` : "") + (editMode ? `
` : ""); if (editMode) el.querySelectorAll(".w-body button, .w-body input, .w-body iframe").forEach((n) => n.setAttribute("tabindex", "-1")); if (def.after) Promise.resolve(def.after(el, w.cfg, ctx)).catch(console.error); @@ -447,8 +496,9 @@ function onStateChange(entityId) { if (!current) return; if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; } - for (const w of tab().widgets) { - if (!entitiesOf(w).includes(entityId)) continue; + const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId)); + if (affected.some((w) => isVisible(w) !== lastVisible[w.id])) { renderGrid(); return; } + for (const w of affected) { const el = gridEl.querySelector(`.w[data-id="${w.id}"]`); if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null; @@ -565,6 +615,7 @@
${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `
`).join("")}
${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}
+ ${visibilityHTML(w)}
`; fillAlbums(el); return; @@ -633,6 +684,32 @@ } } + function visibilityHTML(w) { + const vis = w.visibility || { mode: "all", rules: [] }; + const common = ["on", "off", "running", "idle", "playing", "paused", "home", "not_home", "unavailable"]; + const rows = vis.rules.map((r, i) => { + const s = ha.states[r.entity]; + const opts = [...new Set([s?.state, ...common].filter(Boolean))].map((o) => `