Dashboard Studio 1.8.0: vacuum card, clock position and background, light groups
- Vacuum card: status, battery, start/pause, dock, locate, suction power and an optional live map from a camera or image entity - Clock: 3x3 text position picker and a background image from links or Immich albums (shared slideshow code with the Photos card), dimmable - Light card lists the lights of a light group with their own switches; picking a group grows the card to fit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+107
-42
@@ -207,10 +207,14 @@
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Light", domains: ["light"] },
|
||||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||
{ k: "members", t: "check", l: "For a light group, show each light", d: true },
|
||||
],
|
||||
// A light group lists its lights in the entity_id attribute; follow them too.
|
||||
entities: (c, ha) => [c.entity, ...(ha?.states[c.entity]?.attributes.entity_id || [])].filter(Boolean),
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a light");
|
||||
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id : [];
|
||||
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
|
||||
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
|
||||
const name = friendly(s, c.name);
|
||||
@@ -218,7 +222,12 @@
|
||||
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
|
||||
<span class="badge">${icon(s)}</span>
|
||||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
|
||||
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}</div>`;
|
||||
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}
|
||||
${members.length ? `<div class="list light-members" role="group" aria-label="${t("Lights in {0}", esc(name))}">${members.map((eid) => {
|
||||
const m = ha.states[eid];
|
||||
if (!m) return "";
|
||||
return `<button class="list-row wbtn" role="switch" aria-checked="${isOn(m)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(m)}</span><span class="grow">${esc(friendly(m))}</span><span class="st">${esc(fmtState(m))}</span><span class="switch"></span></button>`;
|
||||
}).join("")}</div>` : ""}</div>`;
|
||||
},
|
||||
lit: (c, { ha }) => litKind(ha.states[c.entity]),
|
||||
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
|
||||
@@ -324,24 +333,38 @@
|
||||
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
|
||||
}
|
||||
|
||||
const POSITIONS = ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"];
|
||||
|
||||
W.clock = {
|
||||
label: "Clock", icon: "clock", size: [3, 2],
|
||||
fields: [
|
||||
{ k: "h24", t: "check", l: "24-hour time", d: true },
|
||||
{ k: "seconds", t: "check", l: "Show seconds", d: false },
|
||||
{ k: "date", t: "check", l: "Show date", d: true },
|
||||
{ k: "pos", t: "pos9", l: "Text position", o: POSITIONS, d: "center" },
|
||||
{ k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] },
|
||||
{ k: "forecast", t: "check", l: "Show today’s high and low", d: true },
|
||||
{ k: "bgSource", t: "seg", l: "Background image", o: ["none", "links", "immich"], d: "none" },
|
||||
{ k: "bgUrls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.bgSource === "links" },
|
||||
{ k: "bgAlbums", t: "albums", l: "Immich albums", when: (c) => c.bgSource === "immich" },
|
||||
{ k: "bgInterval", t: "number", l: "Change photo every (seconds)", d: 60, when: (c) => c.bgSource && c.bgSource !== "none" },
|
||||
{ k: "dim", t: "number", l: "Darken the image (%)", d: 30, when: (c) => c.bgSource && c.bgSource !== "none" },
|
||||
],
|
||||
entities: (c) => (c.weather ? [c.weather] : []),
|
||||
render(c, { ha }) {
|
||||
const s = c.weather && ha.states[c.weather];
|
||||
const bg = c.bgSource === "links" ? urlList({ urls: c.bgUrls }).length > 0 : c.bgSource === "immich" && c.bgAlbums?.length > 0;
|
||||
const pos = POSITIONS.includes(c.pos) ? c.pos : "center";
|
||||
const wx = s ? `<button class="clock-wx wbtn" data-act="more" data-e="${esc(c.weather)}" aria-label="${esc(friendly(s))}: ${esc(st(s.state))}, ${esc(s.attributes.temperature ?? "–")}°">
|
||||
<span class="clock-wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span><span class="clock-wx-temp num">${esc(s.attributes.temperature ?? "–")}°</span>
|
||||
<span class="clock-wx-text">${esc(st(s.state))}</span><span class="clock-hl num" data-hl></span></button>` : "";
|
||||
return `<div class="clock"><div class="clock-time" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>${wx}</div>`;
|
||||
return `<div class="clock pos-${pos} ${bg ? "on-image" : ""}" style="--dim:${Math.min(90, Math.max(0, Number(c.dim ?? 30))) / 100}">
|
||||
${bg ? `<div class="photos clock-bg" aria-hidden="true"><img class="ph-layer" alt=""><img class="ph-layer" alt=""></div><div class="clock-dim"></div>` : ""}
|
||||
<div class="clock-content"><div class="clock-time" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>${wx}</div></div>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
const box = el.querySelector(".clock-bg");
|
||||
if (box) slideshow(el, box, ctx, (ctx.cache.bg ||= {}), { source: c.bgSource, urls: c.bgUrls, albums: c.bgAlbums, shuffle: true, interval: c.bgInterval || 60 });
|
||||
if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return;
|
||||
const cache = ctx.cache;
|
||||
if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) {
|
||||
@@ -453,6 +476,43 @@
|
||||
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- vacuum
|
||||
W.vacuum = {
|
||||
label: "Vacuum", icon: "vacuum", size: [4, 3],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Vacuum", domains: ["vacuum"] },
|
||||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||
{ k: "map", t: "entity", l: "Map camera (optional)", domains: ["camera", "image"] },
|
||||
{ k: "refresh", t: "number", l: "Refresh the map every (seconds)", d: 5, when: (c) => !!c.map },
|
||||
{ k: "battery", t: "entity", l: "Battery sensor (if not on the vacuum)", domains: ["sensor"] },
|
||||
{ k: "fan", t: "check", l: "Show suction power", d: true },
|
||||
],
|
||||
entities: (c) => [c.entity, c.battery].filter(Boolean),
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a vacuum");
|
||||
const a = s.attributes, e = esc(c.entity);
|
||||
const cleaning = s.state === "cleaning", busy = cleaning || s.state === "returning";
|
||||
const batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level);
|
||||
const status = a.status && String(a.status).toLowerCase() !== s.state ? `${st(s.state)} · ${a.status}` : st(s.state);
|
||||
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
|
||||
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
|
||||
return `<div class="vac ${s.state === "error" ? "is-error" : ""}">
|
||||
<div class="tile-row"><span class="badge">${Icons.svg("vacuum")}</span>
|
||||
<span class="tile-text"><span class="name">${esc(friendly(s, c.name))}</span><span class="state">${esc(status)}</span></span>
|
||||
${isFinite(batt) ? `<span class="vac-batt" aria-label="${t("Battery {0}%", Math.round(batt))}">${Icons.svg("battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
|
||||
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` : `<div class="vac-spacer"></div>`}
|
||||
${speeds.length ? `<div class="chips" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="chip wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
|
||||
<div class="ctrls">
|
||||
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="vacuum.${cleaning ? "pause" : "start"}" aria-label="${cleaning ? t("Pause") : t("Start cleaning")}">${Icons.svg(cleaning ? "pause" : "play")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="vacuum.return_to_base" aria-label="${t("Send back to the dock")}" ${s.state === "docked" ? "disabled" : ""}>${Icons.svg("home")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="vacuum.locate" aria-label="${t("Locate (makes a sound)")}">${Icons.svg("pin")}</button>
|
||||
${busy ? `<span class="wt vac-hint">${cleaning ? t("Cleaning…") : t("On its way to the dock")}</span>` : ""}
|
||||
</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (["cleaning", "returning"].includes(ha.states[c.entity]?.state) ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
|
||||
// Works with any integration: a status entity, and/or a power sensor above a threshold,
|
||||
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
|
||||
@@ -637,49 +697,54 @@
|
||||
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
||||
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
const cache = ctx.cache;
|
||||
clearTimeout(cache.timer);
|
||||
const box = el.querySelector(".photos");
|
||||
if (!box) return;
|
||||
const msg = box.querySelector(".ph-msg");
|
||||
const key = JSON.stringify([c.source, c.urls ?? c.url, c.albums, c.shuffle]);
|
||||
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
|
||||
cache.key = key; cache.at = Date.now(); cache.pos = 0;
|
||||
cache.loading = (async () => {
|
||||
if (c.source !== "immich") return urlList(c).map((src) => ({ src }));
|
||||
const lists = await Promise.all((c.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
|
||||
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
|
||||
})().then((items) => {
|
||||
if (c.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
|
||||
cache.items = items; cache.error = null;
|
||||
}).catch((e) => { cache.items = []; cache.error = e.message; });
|
||||
}
|
||||
await cache.loading;
|
||||
if (!el.isConnected) return;
|
||||
if (cache.error) { msg.innerHTML = `<div class="empty">${t("Couldn’t load photos. {0}", esc(cache.error))}</div>`; return; }
|
||||
if (!cache.items.length) { msg.innerHTML = `<div class="empty">${t("These albums have no photos yet.")}</div>`; return; }
|
||||
|
||||
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
|
||||
let front = 0;
|
||||
const show = () => {
|
||||
if (!el.isConnected) return;
|
||||
const item = cache.items[cache.pos % cache.items.length];
|
||||
cache.pos++;
|
||||
const next = layers[1 - front];
|
||||
next.onload = () => {
|
||||
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
|
||||
msg.innerHTML = "";
|
||||
dateEl.textContent = c.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
|
||||
};
|
||||
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
|
||||
next.src = item.src;
|
||||
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(c.interval) || 20) * 1000);
|
||||
};
|
||||
show();
|
||||
after(el, c, ctx) {
|
||||
return slideshow(el, el.querySelector(".photos"), ctx, ctx.cache, { source: c.source, urls: c.urls ?? c.url, albums: c.albums, shuffle: c.shuffle, interval: c.interval, showDate: c.showDate });
|
||||
},
|
||||
};
|
||||
|
||||
/** Load photos from links or Immich albums and cross-fade through them in `box` (.photos with two .ph-layer images).
|
||||
* Used by the Photos card and as a clock background; `cache` keeps the list and position across redraws. */
|
||||
async function slideshow(el, box, ctx, cache, o) {
|
||||
clearTimeout(cache.timer);
|
||||
if (!box) return;
|
||||
const msg = box.querySelector(".ph-msg");
|
||||
const key = JSON.stringify([o.source, o.urls, o.albums, o.shuffle]);
|
||||
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
|
||||
cache.key = key; cache.at = Date.now(); cache.pos = 0;
|
||||
cache.loading = (async () => {
|
||||
if (o.source !== "immich") return urlList({ urls: o.urls }).map((src) => ({ src }));
|
||||
const lists = await Promise.all((o.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
|
||||
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
|
||||
})().then((items) => {
|
||||
if (o.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
|
||||
cache.items = items; cache.error = null;
|
||||
}).catch((e) => { cache.items = []; cache.error = e.message; });
|
||||
}
|
||||
await cache.loading;
|
||||
if (!el.isConnected) return;
|
||||
const say = (text) => { if (msg) msg.innerHTML = `<div class="empty">${text}</div>`; };
|
||||
if (cache.error) { say(t("Couldn’t load photos. {0}", esc(cache.error))); return; }
|
||||
if (!cache.items.length) { say(t("These albums have no photos yet.")); return; }
|
||||
|
||||
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
|
||||
let front = 0;
|
||||
const show = () => {
|
||||
if (!el.isConnected) return;
|
||||
const item = cache.items[cache.pos % cache.items.length];
|
||||
cache.pos++;
|
||||
const next = layers[1 - front];
|
||||
next.onload = () => {
|
||||
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
|
||||
if (msg) msg.innerHTML = "";
|
||||
if (dateEl) dateEl.textContent = o.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
|
||||
};
|
||||
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
|
||||
next.src = item.src;
|
||||
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(o.interval) || 20) * 1000);
|
||||
};
|
||||
show();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- calendar
|
||||
const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"];
|
||||
const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
|
||||
Reference in New Issue
Block a user