Dashboard Studio 1.5.0: conditional widgets, appliance card, multi-player media
- Visibility conditions on every widget (is / is not / above / below, all or any); rows that only held hidden widgets close up in view mode - Appliance card for washer, dryer and dishwasher: status or power based, time left, ready time, progress and program; hides when off - Media card controls several players with a switcher and album art - Dutch translations and docs for the new features Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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
|
### Car
|
||||||
|
|
||||||
Point the car widget at whichever entities your car integration provides. Each one is optional:
|
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/…`.
|
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
|
## 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:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Dashboard Studio
|
name: Dashboard Studio
|
||||||
version: "1.4.0"
|
version: "1.5.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
|
||||||
|
|||||||
@@ -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("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("cover.garage_door", "closed", friendly_name="Garage Door"),
|
||||||
entity("lock.front_door", "locked", friendly_name="Front Door Lock"),
|
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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%23234'/><stop offset='1' stop-color='%23c58'/></linearGradient></defs><rect width='100' height='100' fill='url(%23g)'/><circle cx='50' cy='50' r='22' fill='none' stroke='%23fff' stroke-width='3' opacity='.7'/></svg>"),
|
||||||
|
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("scene.movie_night", "2026-10-24T20:00:00+00:00", friendly_name="Movie Night"),
|
||||||
entity("script.good_morning", "off", friendly_name="Good Morning"),
|
entity("script.good_morning", "off", friendly_name="Good Morning"),
|
||||||
entity("weather.home", "partlycloudy", friendly_name="Home", temperature=13, humidity=71, wind_speed=14),
|
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"])
|
lvl = float(self.states["sensor.model_y_battery"]["state"])
|
||||||
if lvl < 80:
|
if lvl < 80:
|
||||||
self.set_state("sensor.model_y_battery", f"{lvl + 0.2:.1f}")
|
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:
|
if random.random() < 0.15:
|
||||||
eid = "binary_sensor.hallway_motion"
|
eid = "binary_sensor.hallway_motion"
|
||||||
self.set_state(eid, "off" if self.states[eid]["state"] == "on" else "on")
|
self.set_state(eid, "off" if self.states[eid]["state"] == "on" else "on")
|
||||||
|
|||||||
+110
-5
@@ -175,7 +175,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/.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 }));
|
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 }));
|
||||||
@@ -197,6 +197,22 @@
|
|||||||
if (sw.length) put("list", carLock ? 6 : 8, y, carLock ? 6 : 4, 3, { title: tr("Quick controls"), entities: sw });
|
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);
|
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") });
|
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)) {
|
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));
|
const bottom = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h));
|
||||||
put("waste", 0, bottom, 4, 4, {});
|
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.
|
// 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() {
|
function positions() {
|
||||||
const map = {};
|
const map = {};
|
||||||
|
const shown = editMode ? tab().widgets : tab().widgets.filter(isVisible);
|
||||||
if (!metrics.compact) {
|
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;
|
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";
|
const small = (w) => w.w <= Math.floor(current.grid.cols / 4) && w.type !== "heading";
|
||||||
let y = 0, halfOpen = null;
|
let y = 0, halfOpen = null;
|
||||||
for (const w of sorted) {
|
for (const w of sorted) {
|
||||||
@@ -395,7 +438,10 @@
|
|||||||
computeMetrics();
|
computeMetrics();
|
||||||
const pos = positions();
|
const pos = positions();
|
||||||
gridEl.innerHTML = "";
|
gridEl.innerHTML = "";
|
||||||
|
lastVisible = {};
|
||||||
for (const w of tab().widgets) {
|
for (const w of tab().widgets) {
|
||||||
|
lastVisible[w.id] = isVisible(w);
|
||||||
|
if (!pos[w.id]) continue;
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "w";
|
el.className = "w";
|
||||||
el.dataset.id = w.id;
|
el.dataset.id = w.id;
|
||||||
@@ -430,7 +476,10 @@
|
|||||||
}
|
}
|
||||||
let html;
|
let html;
|
||||||
try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
|
try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
|
||||||
|
const hiddenNow = editMode && !isVisible(w);
|
||||||
|
el.classList.toggle("cond-hidden", hiddenNow);
|
||||||
el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` +
|
el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` +
|
||||||
|
(hiddenNow ? `<div class="w-hidden">${Icons.svg("eye-off")}${tr("Hidden now")}</div>` : "") +
|
||||||
(editMode ? `<div class="w-tools"><button data-tool="dup" aria-label="${tr("Duplicate")}" title="${tr("Duplicate")}">${Icons.svg("copy")}</button><button data-tool="del" class="del" aria-label="${tr("Delete")}" title="${tr("Delete")}">${Icons.svg("trash")}</button></div><div class="w-resize" aria-hidden="true"></div>` : "");
|
(editMode ? `<div class="w-tools"><button data-tool="dup" aria-label="${tr("Duplicate")}" title="${tr("Duplicate")}">${Icons.svg("copy")}</button><button data-tool="del" class="del" aria-label="${tr("Delete")}" title="${tr("Delete")}">${Icons.svg("trash")}</button></div><div class="w-resize" aria-hidden="true"></div>` : "");
|
||||||
if (editMode) el.querySelectorAll(".w-body button, .w-body input, .w-body iframe").forEach((n) => n.setAttribute("tabindex", "-1"));
|
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);
|
if (def.after) Promise.resolve(def.after(el, w.cfg, ctx)).catch(console.error);
|
||||||
@@ -447,8 +496,9 @@
|
|||||||
function onStateChange(entityId) {
|
function onStateChange(entityId) {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); 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) {
|
const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId));
|
||||||
if (!entitiesOf(w).includes(entityId)) continue;
|
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}"]`);
|
const el = gridEl.querySelector(`.w[data-id="${w.id}"]`);
|
||||||
if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag
|
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;
|
const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null;
|
||||||
@@ -565,6 +615,7 @@
|
|||||||
<div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
|
<div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
|
||||||
${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") : ""}
|
${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") : ""}
|
||||||
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}</div>
|
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}</div>
|
||||||
|
${visibilityHTML(w)}
|
||||||
<div class="insp-actions"><button class="btn" data-tool="dup">${Icons.svg("copy")}${tr("Duplicate")}</button><button class="btn danger" data-tool="del">${Icons.svg("trash")}${tr("Delete")}</button></div>`;
|
<div class="insp-actions"><button class="btn" data-tool="dup">${Icons.svg("copy")}${tr("Duplicate")}</button><button class="btn danger" data-tool="del">${Icons.svg("trash")}${tr("Delete")}</button></div>`;
|
||||||
fillAlbums(el);
|
fillAlbums(el);
|
||||||
return;
|
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) => `<option value="${esc(o)}">`).join("");
|
||||||
|
return `<div class="rule">
|
||||||
|
<input class="input" type="text" list="dl-all" data-path="rule" data-idx="${i}" data-k="entity" value="${esc(r.entity || "")}" placeholder="${tr("Entity")}" aria-label="${tr("Entity")}" autocomplete="off" spellcheck="false">
|
||||||
|
<div class="rule-row">
|
||||||
|
<select class="input" data-path="rule" data-idx="${i}" data-k="op" aria-label="${tr("Condition")}">${["is", "is not", "above", "below"].map((o) => `<option value="${o}" ${o === (r.op || "is") ? "selected" : ""}>${esc(optLabel(o))}</option>`).join("")}</select>
|
||||||
|
<input class="input" type="text" list="dl-rule-${i}" data-path="rule" data-idx="${i}" data-k="value" value="${esc(r.value ?? "")}" placeholder="${tr("Value")}" aria-label="${tr("Value")}" autocomplete="off">
|
||||||
|
<datalist id="dl-rule-${i}">${opts}</datalist>
|
||||||
|
<button class="btn icon" data-rule-del="${i}" aria-label="${tr("Remove condition")}">${Icons.svg("close")}</button>
|
||||||
|
</div>
|
||||||
|
${s ? `<div class="ent-hint">${tr("Now")}: ${esc(s.state)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
const status = vis.rules.some((r) => r.entity) || Widgets[w.type]?.autoHide
|
||||||
|
? `<p class="small vis-status">${isVisible(w) ? Icons.svg("eye") + tr("Visible right now") : Icons.svg("eye-off") + tr("Hidden right now")}</p>` : "";
|
||||||
|
return `<div class="section"><div class="section-title">${tr("Visibility")}</div>
|
||||||
|
<p class="muted small" style="margin:0 2px 10px">${tr("Show this widget only when the conditions below are met. Leave empty to always show it.")}</p>
|
||||||
|
${vis.rules.length > 1 ? fieldHTML({ k: "mode", t: "seg", l: "Show when", o: ["all", "any"] }, vis.mode || "all", "vis") : ""}
|
||||||
|
${rows}${status}
|
||||||
|
<button class="btn" data-rule-add style="width:100%">${Icons.svg("plus")}${tr("Add condition")}</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function applyField(t, v, isChange) {
|
function applyField(t, v, isChange) {
|
||||||
const path = t.dataset.path, k = t.dataset.k, idx = t.dataset.idx;
|
const path = t.dataset.path, k = t.dataset.k, idx = t.dataset.idx;
|
||||||
if (path === "tab") {
|
if (path === "tab") {
|
||||||
@@ -642,6 +719,16 @@
|
|||||||
if (isChange && k === "icon") renderInspector();
|
if (isChange && k === "icon") renderInspector();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (path === "vis" || path === "rule") {
|
||||||
|
const w = tab().widgets.find((x) => x.id === selectedId);
|
||||||
|
mutate(() => {
|
||||||
|
w.visibility ||= { mode: "all", rules: [] };
|
||||||
|
if (path === "vis") w.visibility[k] = v;
|
||||||
|
else w.visibility.rules[Number(t.dataset.idx)][k] = v;
|
||||||
|
}, `${w.id}|${path}|${t.dataset.idx}|${k}`);
|
||||||
|
if (isChange || t.tagName === "BUTTON") renderInspector();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (path === "nav") {
|
if (path === "nav") {
|
||||||
mutate(() => { current.nav[k] = v; });
|
mutate(() => { current.nav[k] = v; });
|
||||||
return;
|
return;
|
||||||
@@ -867,6 +954,12 @@
|
|||||||
const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10;
|
const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10;
|
||||||
ha.callService("climate", "set_temperature", { temperature: t }, eid);
|
ha.callService("climate", "set_temperature", { temperature: t }, eid);
|
||||||
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
|
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
|
||||||
|
else if (act === "mp-select") {
|
||||||
|
const w = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id);
|
||||||
|
caches[w.id].sel = a.dataset.v;
|
||||||
|
renderWidget(w);
|
||||||
|
gridEl.querySelector(`.w[data-id="${w.id}"] [data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus();
|
||||||
|
}
|
||||||
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
|
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
|
||||||
else if (act === "button") {
|
else if (act === "button") {
|
||||||
const c = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id).cfg;
|
const c = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id).cfg;
|
||||||
@@ -1179,6 +1272,18 @@
|
|||||||
mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
|
mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
|
||||||
renderWidget(w); renderInspector(); return;
|
renderWidget(w); renderInspector(); 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);
|
||||||
|
mutate(() => {
|
||||||
|
w.visibility ||= { mode: "all", rules: [] };
|
||||||
|
if (ruleAdd) w.visibility.rules.push({ entity: "", op: "is", value: "" });
|
||||||
|
else w.visibility.rules.splice(Number(ruleDel.dataset.ruleDel), 1);
|
||||||
|
});
|
||||||
|
renderInspector();
|
||||||
|
if (ruleAdd) $(`#inspector [data-path="rule"][data-idx="${w.visibility.rules.length - 1}"][data-k="entity"]`)?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const op = e.target.closest("[data-tabop]");
|
const op = e.target.closest("[data-tabop]");
|
||||||
if (op) { tabOp(op.dataset.tabop, op.dataset.tabId); return; }
|
if (op) { tabOp(op.dataset.tabop, op.dataset.tabId); return; }
|
||||||
const k = e.target.closest("[data-kiosk]");
|
const k = e.target.closest("[data-kiosk]");
|
||||||
|
|||||||
@@ -221,6 +221,24 @@
|
|||||||
"Textile": "Textiel", "Bulky waste": "Grofvuil", "Hazardous waste": "KCA", "Nappies": "Luiers", "Glass": "Glas",
|
"Textile": "Textiel", "Bulky waste": "Grofvuil", "Hazardous waste": "KCA", "Nappies": "Luiers", "Glass": "Glas",
|
||||||
"Pruning waste": "Snoeiafval", "Garden waste": "Tuinafval", "Christmas trees": "Kerstbomen", "BEST bag": "BEST-tas",
|
"Pruning waste": "Snoeiafval", "Garden waste": "Tuinafval", "Christmas trees": "Kerstbomen", "BEST bag": "BEST-tas",
|
||||||
"Hazardous waste van": "Milieubus",
|
"Hazardous waste van": "Milieubus",
|
||||||
|
// visibility
|
||||||
|
"Hidden now": "Nu verborgen", "Visibility": "Zichtbaarheid",
|
||||||
|
"Show this widget only when the conditions below are met. Leave empty to always show it.": "Toon deze widget alleen als aan de voorwaarden hieronder is voldaan. Laat leeg om hem altijd te tonen.",
|
||||||
|
"Show when": "Toon als", "all": "Alle voorwaarden", "any": "Eén voorwaarde", "Add condition": "Voorwaarde toevoegen",
|
||||||
|
"Remove condition": "Verwijder voorwaarde", "Condition": "Voorwaarde", "Value": "Waarde",
|
||||||
|
"is": "Is", "is not": "Is niet", "above": "Hoger dan", "below": "Lager dan",
|
||||||
|
"Visible right now": "Nu zichtbaar", "Hidden right now": "Nu verborgen",
|
||||||
|
// media
|
||||||
|
"Media players": "Mediaspelers", "Show album art": "Toon albumhoes", "Hide players that are off from the switcher": "Verberg uitgeschakelde spelers in de keuzelijst",
|
||||||
|
// appliance
|
||||||
|
"Appliance": "Apparaat", "Washing machine": "Wasmachine", "Dryer": "Droger", "Dishwasher": "Vaatwasser",
|
||||||
|
"washer": "Wasmachine", "dryer": "Droger", "dishwasher": "Vaatwasser", "other": "Anders",
|
||||||
|
"Status": "Status", "Power (W), if there is no status": "Vermogen (W), als er geen status is", "Running above (W)": "Aan boven (W)",
|
||||||
|
"Time remaining or end time": "Resterende tijd of eindtijd", "Progress (%)": "Voortgang (%)", "Program": "Programma",
|
||||||
|
"Only show while running or finished": "Alleen tonen als hij draait of klaar is",
|
||||||
|
"Choose the appliance’s status, power or remaining-time entity in the inspector.": "Kies in de inspector de status-, vermogens- of resterende-tijdentiteit van het apparaat.",
|
||||||
|
"In progress": "Bezig", "Finished": "Klaar", "Paused": "Gepauzeerd", "Ready to unload": "Klaar om uit te ruimen",
|
||||||
|
"{0} h {1} min": "{0} u {1} min", "{0} min": "{0} min", "left · ready at {0}": "te gaan · klaar om {0}", "Progress": "Voortgang",
|
||||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
shield: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z",
|
shield: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z",
|
||||||
bin: "M4 6h16M6 6l1.2 12.2a1 1 0 0 0 1 .8h7.6a1 1 0 0 0 1-.8L18 6M9 6V3.5h6V6M8.5 21.5a1 1 0 1 0 0-.01M15.5 21.5a1 1 0 1 0 0-.01M10 10v5M14 10v5",
|
bin: "M4 6h16M6 6l1.2 12.2a1 1 0 0 0 1 .8h7.6a1 1 0 0 0 1-.8L18 6M9 6V3.5h6V6M8.5 21.5a1 1 0 1 0 0-.01M15.5 21.5a1 1 0 1 0 0-.01M10 10v5M14 10v5",
|
||||||
tabs: "M3 5h7v14H3zM13 6h8M13 10h8M13 14h5",
|
tabs: "M3 5h7v14H3zM13 6h8M13 10h8M13 14h5",
|
||||||
|
washer: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 7.5h16M7 5.3h.01M10 5.3h.01M12 18.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zM9.6 14.6c1-.8 1.9-.8 2.9 0s1.9.8 2.9 0",
|
||||||
|
dryer: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 7.5h16M7 5.3h.01M10 5.3h.01M12 18.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zM10.5 12c.8.8.8 1.6 0 2.4M13.5 12c.8.8.8 1.6 0 2.4",
|
||||||
|
dishwasher: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 8h16M8 5.5h.01M11 5.5h.01M8 12v5M12 12v5M16 12v5",
|
||||||
|
eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
|
||||||
|
"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",
|
||||||
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",
|
||||||
|
|||||||
@@ -432,6 +432,41 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
|||||||
.waste-day.soon { color: var(--d-accent); }
|
.waste-day.soon { color: var(--d-accent); }
|
||||||
@container (max-width: 300px) { .waste-row .st { display: none; } }
|
@container (max-width: 300px) { .waste-row .st { display: none; } }
|
||||||
|
|
||||||
|
/* visibility */
|
||||||
|
.w.cond-hidden { opacity: 0.5; }
|
||||||
|
.w-hidden { position: absolute; left: 8px; bottom: 8px; z-index: 3; display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 99px; font-size: 12px; font-weight: 600; background: var(--surface); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
|
||||||
|
.rule { padding: 8px; border-radius: 10px; background: var(--fill); margin-bottom: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.rule .input { background: var(--surface); }
|
||||||
|
.rule-row { display: flex; gap: 6px; }
|
||||||
|
.rule-row select { flex: 1.1; }
|
||||||
|
.rule-row input { flex: 1; min-width: 0; }
|
||||||
|
.rule-row .btn { flex: none; }
|
||||||
|
.vis-status { display: flex; align-items: center; gap: 6px; margin: 0 2px 10px; color: var(--text-2); }
|
||||||
|
|
||||||
|
/* media players */
|
||||||
|
.mp { gap: 8px; }
|
||||||
|
.mp-switch { display: flex; gap: 6px; overflow-x: auto; flex: none; scrollbar-width: none; }
|
||||||
|
.mp-switch .chip { flex: none; gap: 6px; }
|
||||||
|
.mp-switch .chip[aria-selected="true"] { background: var(--d-text); color: var(--d-tile); font-weight: 600; }
|
||||||
|
.mp-now { display: flex; align-items: center; gap: 12px; min-height: 0; flex: 1; }
|
||||||
|
.mp-art { width: clamp(48px, 32cqh, 96px); aspect-ratio: 1; border-radius: 10px; object-fit: cover; flex: none; background: var(--d-fill); }
|
||||||
|
.mp-art.ph { display: grid; place-items: center; font-size: 28px; color: var(--d-text-2); }
|
||||||
|
.mp-meta { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 11px; }
|
||||||
|
.eq i { width: 3px; background: currentColor; border-radius: 1px; animation: eq 0.9s ease-in-out infinite; }
|
||||||
|
.eq i:nth-child(1) { height: 50%; } .eq i:nth-child(2) { height: 100%; animation-delay: -0.3s; } .eq i:nth-child(3) { height: 70%; animation-delay: -0.6s; }
|
||||||
|
@keyframes eq { 0%, 100% { transform: scaleY(0.4); transform-origin: bottom; } 50% { transform: scaleY(1); transform-origin: bottom; } }
|
||||||
|
@container (max-height: 170px) { .mp-art { display: none; } }
|
||||||
|
|
||||||
|
/* appliance */
|
||||||
|
.appl { display: flex; flex-direction: column; height: 100%; gap: 8px; justify-content: space-between; }
|
||||||
|
.appl-status { margin-left: auto; font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
|
||||||
|
.appl.phase-running .appl-status { background: var(--d-accent); color: #fff; }
|
||||||
|
.appl.phase-done .appl-status { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
|
||||||
|
.appl-big { font-size: 22px; font-weight: 700; }
|
||||||
|
.appl-big .wt { font-size: 14px; font-weight: 400; }
|
||||||
|
.appl .car-fill { background: var(--d-accent); }
|
||||||
|
|
||||||
/* photos */
|
/* photos */
|
||||||
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
|
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
|
||||||
.ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; }
|
.ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; }
|
||||||
|
|||||||
+110
-13
@@ -384,23 +384,120 @@
|
|||||||
render: (c) => `<button class="btn-widget wbtn" data-act="button"><span class="badge">${icon(null, c.icon || "sparkles")}</span><span class="name">${esc(c.label)}</span></button>`,
|
render: (c) => `<button class="btn-widget wbtn" data-act="button"><span class="badge">${icon(null, c.icon || "sparkles")}</span><span class="name">${esc(c.label)}</span></button>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- media (one or more players)
|
||||||
|
const mediaIds = (c) => (c.entities?.length ? c.entities : c.entity ? [c.entity] : []);
|
||||||
|
const ACTIVE = ["playing", "paused", "buffering", "on", "idle"];
|
||||||
|
|
||||||
|
/** Which player the card shows: the one picked, else whatever is playing, else the first that's on. */
|
||||||
|
function mediaSelected(ids, ha, cache) {
|
||||||
|
if (cache.sel && ids.includes(cache.sel)) return cache.sel;
|
||||||
|
return ids.find((e) => ha.states[e]?.state === "playing") || ids.find((e) => ha.states[e]?.state === "paused") || ids.find((e) => ACTIVE.includes(ha.states[e]?.state)) || ids[0];
|
||||||
|
}
|
||||||
|
|
||||||
W.media = {
|
W.media = {
|
||||||
label: "Media player", icon: "music", size: [4, 2],
|
label: "Media players", icon: "music", size: [4, 3],
|
||||||
fields: [{ k: "entity", t: "entity", l: "Media player", domains: ["media_player"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }],
|
fields: [
|
||||||
render(c, { ha }) {
|
{ k: "entities", t: "entities", l: "Media players", domains: ["media_player"] },
|
||||||
const s = ha.states[c.entity];
|
{ k: "art", t: "check", l: "Show album art", d: true },
|
||||||
if (!s) return missing(c.entity, "a media player");
|
{ k: "hideOff", t: "check", l: "Hide players that are off from the switcher", d: false },
|
||||||
const a = s.attributes, e = esc(c.entity), playing = s.state === "playing";
|
],
|
||||||
return `<div class="media"><div class="wt">${esc(friendly(s, c.name))}</div>
|
entities: mediaIds,
|
||||||
<div><div class="title">${esc(a.media_title || st(s.state))}</div><div class="wt">${esc(a.media_artist || "")}</div></div>
|
render(c, { ha, cache }) {
|
||||||
<div class="ctrls">
|
let ids = mediaIds(c).filter((e) => ha.states[e]);
|
||||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
|
if (!ids.length) return missing(mediaIds(c)[0], "a media player");
|
||||||
|
const sel = mediaSelected(ids, ha, cache);
|
||||||
|
if (c.hideOff) ids = ids.filter((e) => e === sel || ha.states[e].state !== "off");
|
||||||
|
const s = ha.states[sel], a = s.attributes, e = esc(sel);
|
||||||
|
const playing = s.state === "playing", off = ["off", "standby", "unavailable"].includes(s.state);
|
||||||
|
const bars = `<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>`;
|
||||||
|
const switcher = ids.length > 1 ? `<div class="mp-switch" role="tablist" aria-label="${t("Media players")}">${ids.map((id) => {
|
||||||
|
const p = ha.states[id], on = p.state === "playing";
|
||||||
|
return `<button class="chip wbtn ${on ? "is-playing" : ""}" role="tab" aria-selected="${id === sel}" data-act="mp-select" data-v="${esc(id)}" aria-label="${esc(friendly(p))}, ${esc(st(p.state))}">${on ? bars : ""}${esc(friendly(p))}</button>`;
|
||||||
|
}).join("")}</div>` : "";
|
||||||
|
const pic = c.art !== false && a.entity_picture && !off ? `<img class="mp-art" src="${esc(a.entity_picture)}" alt="">` : `<span class="mp-art ph" aria-hidden="true">${icon(s)}</span>`;
|
||||||
|
const title = off ? st(s.state) : a.media_title || a.app_name || a.source || st(s.state);
|
||||||
|
const sub = off ? "" : [a.media_artist, a.media_album_name].filter(Boolean).join(" · ") || (a.media_title ? "" : st(s.state));
|
||||||
|
const controls = off
|
||||||
|
? `<button class="car-act wbtn" data-act="svc" data-e="${e}" data-v="media_player.turn_on">${Icons.svg("power")}<span>${t("Turn on")}</span></button>`
|
||||||
|
: `<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
|
||||||
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="media_player.media_play_pause" aria-label="${playing ? t("Pause") : t("Play")}">${Icons.svg(playing ? "pause" : "play")}</button>
|
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="media_player.media_play_pause" aria-label="${playing ? t("Pause") : t("Play")}">${Icons.svg(playing ? "pause" : "play")}</button>
|
||||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_next_track" aria-label="${t("Next track")}">${Icons.svg("next")}</button>
|
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_next_track" aria-label="${t("Next track")}">${Icons.svg("next")}</button>
|
||||||
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${t("Volume")}">` : ""}
|
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${esc(t("Volume"))} ${esc(friendly(s))}">` : ""}`;
|
||||||
</div></div>`;
|
return `<div class="media mp">${switcher}
|
||||||
|
<div class="mp-now">${pic}<div class="mp-meta">
|
||||||
|
${ids.length === 1 ? `<div class="wt">${esc(friendly(s))}</div>` : ""}
|
||||||
|
<div class="title">${esc(title)}</div>${sub ? `<div class="wt">${esc(sub)}</div>` : ""}</div></div>
|
||||||
|
<div class="ctrls">${controls}</div></div>`;
|
||||||
},
|
},
|
||||||
lit: (c, { ha }) => (ha.states[c.entity]?.state === "playing" ? "on" : false),
|
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
|
||||||
|
// Works with any integration: a status entity, and/or a power sensor above a threshold,
|
||||||
|
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
|
||||||
|
const RUNNING_RE = /run|wash|dry|rins|spin|heat|clean|activ|program|bezig|draait|centrifug|spoel|droog|^on$|^true$/;
|
||||||
|
const DONE_RE = /finish|end|done|complete|ready|klaar|gereed|beëindigd/;
|
||||||
|
const PAUSE_RE = /pause|gepauzeerd|delayed|uitgesteld/;
|
||||||
|
|
||||||
|
function appliancePhase(c, ha) {
|
||||||
|
const status = String(ha.states[c.status]?.state || "").toLowerCase();
|
||||||
|
if (status && !["unavailable", "unknown"].includes(status)) {
|
||||||
|
if (DONE_RE.test(status)) return "done";
|
||||||
|
if (PAUSE_RE.test(status)) return "paused";
|
||||||
|
if (RUNNING_RE.test(status)) return "running";
|
||||||
|
}
|
||||||
|
const power = Number(ha.states[c.power]?.state);
|
||||||
|
if (c.power && isFinite(power) && power > (Number(c.threshold) || 5)) return "running";
|
||||||
|
const left = remainingMinutes(c, ha);
|
||||||
|
if (!c.status && !c.power && left > 0) return "running";
|
||||||
|
return "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingMinutes(c, ha) {
|
||||||
|
const s = ha.states[c.remaining];
|
||||||
|
if (!s) return null;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T/.test(s.state)) return Math.max(0, Math.round((new Date(s.state) - Date.now()) / 60000));
|
||||||
|
const n = Number(s.state);
|
||||||
|
if (!isFinite(n)) return null;
|
||||||
|
const unit = String(s.attributes.unit_of_measurement || "min").toLowerCase();
|
||||||
|
return Math.round(unit.startsWith("h") ? n * 60 : unit === "s" ? n / 60 : n);
|
||||||
|
}
|
||||||
|
const fmtDuration = (m) => (m >= 60 ? t("{0} h {1} min", Math.floor(m / 60), m % 60) : t("{0} min", m));
|
||||||
|
|
||||||
|
W.appliance = {
|
||||||
|
label: "Appliance", icon: "washer", size: [4, 2],
|
||||||
|
fields: [
|
||||||
|
{ k: "name", t: "text", l: "Name", d: "Washing machine" },
|
||||||
|
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" },
|
||||||
|
{ k: "status", t: "entity", l: "Status", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
|
||||||
|
{ k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"] },
|
||||||
|
{ k: "threshold", t: "number", l: "Running above (W)", d: 5 },
|
||||||
|
{ k: "remaining", t: "entity", l: "Time remaining or end time", domains: ["sensor"] },
|
||||||
|
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
|
||||||
|
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
|
||||||
|
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
|
||||||
|
],
|
||||||
|
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program].filter(Boolean),
|
||||||
|
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off",
|
||||||
|
render(c, { ha }) {
|
||||||
|
if (!c.status && !c.power && !c.remaining) return `<div class="empty">${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}</div>`;
|
||||||
|
const phase = appliancePhase(c, ha);
|
||||||
|
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off") }[phase];
|
||||||
|
const left = phase === "running" || phase === "paused" ? remainingMinutes(c, ha) : null;
|
||||||
|
const prog = Number(ha.states[c.progress]?.state);
|
||||||
|
const program = ha.states[c.program]?.state;
|
||||||
|
const ends = left != null ? new Date(Date.now() + left * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "";
|
||||||
|
const ico = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer";
|
||||||
|
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
|
||||||
|
return `<div class="appl phase-${phase}">
|
||||||
|
<div class="tile-row"><span class="badge">${Icons.svg(ico)}</span>
|
||||||
|
<span class="tile-text"><span class="name">${esc(c.name || t("Appliance"))}</span>${showProgram ? `<span class="state">${esc(st(program))}</span>` : ""}</span>
|
||||||
|
<span class="appl-status">${esc(label)}</span></div>
|
||||||
|
${phase === "done" ? `<div class="appl-big">${t("Ready to unload")}</div>` : left != null ? `<div class="appl-big"><span class="num">${esc(fmtDuration(left))}</span> <span class="wt">${t("left · ready at {0}", ends)}</span></div>` : ""}
|
||||||
|
${isFinite(prog) && c.progress && phase !== "off" ? `<div class="car-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"><span class="car-fill" style="width:${Math.max(0, Math.min(100, prog))}%"></span></div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
lit: (c, { ha }) => (["running", "done"].includes(appliancePhase(c, ha)) ? "on" : false),
|
||||||
};
|
};
|
||||||
|
|
||||||
W.camera = {
|
W.camera = {
|
||||||
|
|||||||
Reference in New Issue
Block a user