Dashboard Studio 2.4.0: litter box on the appliance card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 13:48:15 +01:00
parent 095b23481e
commit 0b2027083a
8 changed files with 153 additions and 23 deletions
+87 -18
View File
@@ -1055,26 +1055,83 @@
return s.includes("_program_") ? cap(s.split("_program_").pop().replace(/_/g, " ")) : st(s);
};
const ALERT_ON = new Set(["present", "confirmed", "on", "true"]);
const litter = (c) => c.kind === "litterbox", notLitter = (c) => c.kind !== "litterbox";
// A litter box isn't a machine you start and empty: it waits, cleans itself after a visit, and now and then
// needs its bin emptied. Tuya reports clean / empty / level / sleep / standby / uv; other boxes use similar words.
function litterPhase(c, ha) {
const s = String(ha.states[c.status]?.state || "").toLowerCase();
const alerts = (c.alerts || []).filter((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase()));
const busy = { clean: t("Cleaning…"), cleaning: t("Cleaning…"), empty: t("Emptying"), emptying: t("Emptying"), level: t("Levelling"), leveling: t("Levelling"), uv: t("Sanitizing"), sanitizing: t("Sanitizing") }[s];
if (!c.status || !ha.states[c.status] || ["unavailable", "unknown"].includes(s)) return { phase: "off", word: t("Unavailable"), alerts };
if (/fault|error|fout|full|vol/.test(s)) return { phase: "attention", word: s.includes("full") || s.includes("vol") ? t("Bin full") : t("Check the litter box"), alerts };
if (busy) return { phase: "cleaning", word: busy, alerts };
if (/sleep|slaap/.test(s)) return { phase: "sleep", word: t("Sleeping"), alerts };
if (/cat|occupied|in_use|using/.test(s)) return { phase: "visit", word: t("Cat inside"), alerts };
if (alerts.length) return { phase: "attention", word: t("Needs attention"), alerts };
return { phase: "ready", word: /^(standby|idle|ready|on|off|none|)$/.test(s) ? t("Ready") : st(s), alerts };
}
function litterBox(c, ha) {
if (!c.status) return `<div class="empty">${t("Choose the litter box’s status entity in the inspector.")}</div>`;
const { phase, word, alerts } = litterPhase(c, ha);
// figures: visits and time today, and the cat's weight; the unit in grey
const fig = (eid, lbl, fmt, short = "") => {
const s = ha.states[eid];
if (!s || ["unknown", "unavailable"].includes(s.state)) return "";
const n = Number(s.state), unit = s.attributes.unit_of_measurement || "";
const v = fmt ? fmt(n, unit) : `${isFinite(n) ? n.toLocaleString(LOCALE, { maximumFractionDigits: 1 }) : esc(s.state)}${unit ? `<small class="unit"> ${esc(unit)}</small>` : ""}`;
return `<div class="lb-fig"><b class="num">${v}</b><span class="lb-l">${esc(lbl)}</span>${short ? `<span class="lb-s">${esc(short)}</span>` : ""}</div>`;
};
// time today: Tuya reports it in seconds or minutes, shown as minutes
const mins = (n, unit) => {
if (!isFinite(n)) return "–";
const m = Math.round(/^s/.test(unit) ? n / 60 : /^h/.test(unit) ? n * 60 : n);
return `${m}<small class="unit"> min</small>`;
};
const figs = [fig(c.visits, t("visits today"), (n) => (isFinite(n) ? String(Math.round(n)) : "–"), t("visits")), fig(c.timeToday, t("in the box today"), mins), fig(c.catWeight, t("cat’s weight"))].join("");
const device = friendly(ha.states[c.status]).replace(/\s*(status|state)$/i, "");
const strip = (txt) => (device && txt.toLowerCase().startsWith(device.toLowerCase() + " ") ? txt.slice(device.length + 1) : txt);
const warn = alerts.map((e) => `<span class="appl-fact warn">${Icons.svg("shield")}<span>${esc(cap(strip(friendly(ha.states[e]))))}</span></span>`).join("");
const clean = c.cleanBtn && ha.states[c.cleanBtn]
? `<button class="lb-clean wbtn" data-act="svc" data-e="${esc(c.cleanBtn)}" data-v="${domainOf(c.cleanBtn) === "script" ? "script.turn_on" : "button.press"}" ${phase === "cleaning" ? "disabled" : ""}>${Icons.svg("sparkles")}<span>${t("Clean now")}</span></button>` : "";
const auto = ha.states[c.autoClean];
const autoSw = auto ? `<span class="lb-auto"><span class="lb-auto-l">${t("Auto clean")}</span><button class="light-switch wbtn" role="switch" aria-checked="${isOn(auto)}" data-act="toggle" data-e="${esc(c.autoClean)}" aria-label="${t("Auto clean")}"><span class="switch"></span></button></span>` : "";
return `<div class="appl lb phase-${phase}">
<div class="appl-ring lb-ring" aria-hidden="true"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="44" class="track"/>${phase === "cleaning" ? `<circle cx="50" cy="50" r="44" class="fill lb-spin" stroke-dasharray="70 207"/>` : ""}</svg>
<span class="appl-ico">${Icons.svg(phase === "attention" ? "shield" : "paw")}</span></div>
<div class="appl-text">
<div class="appl-top"><span class="name">${esc(c.name || t("Litter box"))}</span>${autoSw}</div>
<div class="appl-main"><div class="appl-main-text"><span class="appl-big ${phase === "off" ? "appl-off" : ""}">${esc(word)}</span></div>${clean}</div>
${figs ? `<div class="lb-figs">${figs}</div>` : ""}
${warn ? `<div class="appl-facts">${warn}</div>` : ""}
</div></div>`;
}
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 (with Home Connect: Operation state — the rest fills in by itself)", 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 finish time", domains: ["sensor"] },
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
{ k: "door", t: "entity", l: "Door (optional)", domains: ["binary_sensor", "sensor"] },
{ k: "energy", t: "entity", l: "Energy or water use (optional)", domains: ["sensor"] },
{ k: "pauseBtn", t: "entity", l: "Pause button", domains: ["button"] },
{ k: "resumeBtn", t: "entity", l: "Resume button", domains: ["button"] },
{ k: "stopBtn", t: "entity", l: "Stop button", domains: ["button"] },
{ k: "powerSwitch", t: "entity", l: "Power switch", domains: ["switch"] },
{ k: "alerts", t: "entities", l: "Alerts, e.g. salt or rinse aid low", domains: ["sensor", "binary_sensor", "event"] },
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "litterbox", "other"], d: "washer" },
{ k: "status", t: "entity", l: "Status (with Home Connect: Operation state, with a Tuya litter box: Status — the rest fills in by itself)", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
{ k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"], when: notLitter },
{ k: "threshold", t: "number", l: "Running above (W)", d: 5, when: notLitter },
{ k: "remaining", t: "entity", l: "Time remaining or finish time", domains: ["sensor"], when: notLitter },
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"], when: notLitter },
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"], when: notLitter },
{ k: "door", t: "entity", l: "Door (optional)", domains: ["binary_sensor", "sensor"], when: notLitter },
{ k: "energy", t: "entity", l: "Energy or water use (optional)", domains: ["sensor"], when: notLitter },
{ k: "pauseBtn", t: "entity", l: "Pause button", domains: ["button"], when: notLitter },
{ k: "resumeBtn", t: "entity", l: "Resume button", domains: ["button"], when: notLitter },
{ k: "stopBtn", t: "entity", l: "Stop button", domains: ["button"], when: notLitter },
{ k: "powerSwitch", t: "entity", l: "Power switch", domains: ["switch"], when: notLitter },
{ k: "visits", t: "entity", l: "Visits today", domains: ["sensor"], when: litter },
{ k: "timeToday", t: "entity", l: "Time in the box today", domains: ["sensor"], when: litter },
{ k: "catWeight", t: "entity", l: "Cat’s weight", domains: ["sensor"], when: litter },
{ k: "cleanBtn", t: "entity", l: "Clean button", domains: ["button", "script"], when: litter },
{ k: "autoClean", t: "entity", l: "Auto clean switch", domains: ["switch", "input_boolean"], when: litter },
{ k: "alerts", t: "entities", l: "Alerts, e.g. salt or rinse aid low, or a full waste bin", domains: ["sensor", "binary_sensor", "event"] },
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true, when: notLitter },
],
// Picking the status fills in the other entities of the same device (Home Connect names them predictably).
autofill: {
@@ -1084,6 +1141,14 @@
const base = eid.split(".")[1].replace(/_(operation_state|status|state)$/, "");
const sibs = dev ? ha.reg.byDevice[dev] : Object.keys(ha.states).filter((e) => e.split(".")[1].startsWith(base));
const find = (dom, re) => sibs.find((e) => domainOf(e) === dom && re.test(e)) || "";
// A Tuya litter box (cat weight, visits, manual clean) gets its own set.
if (/litter|kattenbak|cat_?box/.test(eid) || find("sensor", /cat_weight|excretion/)) {
return {
kind: "litterbox", visits: find("sensor", /excretion_times|times_day|visits/), timeToday: find("sensor", /excretion_(time_day|duration)|duration/),
catWeight: find("sensor", /cat_weight|weight/), cleanBtn: find("button", /manual_clean|clean/), autoClean: find("switch", /auto_clean/),
alerts: sibs.filter((e) => ["binary_sensor", "sensor"].includes(domainOf(e)) && /full|fault|error|bin|waste|litter_level/.test(e) && e !== eid),
};
}
return {
progress: find("sensor", /progress/), remaining: find("sensor", /finish_time|remaining|end_time/),
door: find("sensor", /door/) || find("binary_sensor", /door/), program: find("select", /active_program/) || find("sensor", /program$/),
@@ -1094,12 +1159,15 @@
};
},
},
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program, c.door, c.energy, c.pauseBtn, c.resumeBtn, c.stopBtn, c.powerSwitch, ...(c.alerts || [])].filter(Boolean),
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off"
entities: (c) => (c.kind === "litterbox" ? [c.status, c.visits, c.timeToday, c.catWeight, c.cleanBtn, c.autoClean, ...(c.alerts || [])]
: [c.status, c.power, c.remaining, c.progress, c.program, c.door, c.energy, c.pauseBtn, c.resumeBtn, c.stopBtn, c.powerSwitch, ...(c.alerts || [])]).filter(Boolean),
// a litter box is always there to look at; it never hides itself
autoHide: (c, { ha }) => c.kind !== "litterbox" && !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off"
&& !(c.alerts || []).some((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase())),
// Two rows high: a progress ring around the appliance on the left, the story on the right.
// Taller cards stack the ring on top (see the container queries in style.css).
render(c, { ha, cache }) {
if (c.kind === "litterbox") return litterBox(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"), scheduled: t("Starts later"), attention: t("Action needed"), error: t("Error"), stopping: t("Stopping") }[phase];
@@ -1164,7 +1232,8 @@
${facts ? `<div class="appl-facts">${facts}</div>` : ""}
</div></div>`;
},
lit: (c, { ha }) => (["running", "done", "attention"].includes(appliancePhase(c, ha)) ? "on" : false),
lit: (c, { ha }) => (c.kind === "litterbox" ? (["cleaning", "attention"].includes(litterPhase(c, ha).phase) ? "on" : false)
: ["running", "done", "attention"].includes(appliancePhase(c, ha)) ? "on" : false),
};
W.camera = {