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
+6 -1
View File
@@ -660,6 +660,11 @@
["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);
// a Tuya litter box: a status sensor next to a cat weight sensor
for (const w of ids.filter((e) => /^sensor\..+_cat_weight$/.test(e))) {
const status = w.replace(/_cat_weight$/, "_status");
if (ha.states[status]) appliances.push({ kind: "litterbox", name: "Litter box", status, fill: W_AUTOFILL("appliance", 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; }
@@ -667,7 +672,7 @@
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") });
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"), ...a.fill });
x2 += 4;
}
}
+7 -2
View File
@@ -295,9 +295,14 @@
"Choose your solar, grid or battery power sensors in the inspector.": "Kies in de inspector je sensoren voor zonnepanelen, het net of de thuisaccu.",
"Solar {0}, grid {1}, home {2}": "Zon {0}, net {1}, huis {2}", "Solar": "Zon", "To the grid": "Naar het net", "From the grid": "Van het net", "Discharging": "Ontladen",
"Door (optional)": "Deur (optioneel)", "Energy or water use (optional)": "Energie- of waterverbruik (optioneel)", "Door open": "Deur open", "Door closed": "Deur dicht",
"Status (with Home Connect: Operation state — the rest fills in by itself)": "Status (bij Home Connect: Bedieningsstatus — de rest vult zichzelf in)",
"Status (with Home Connect: Operation state, with a Tuya litter box: Status — the rest fills in by itself)": "Status (bij Home Connect: Bedieningsstatus, bij een Tuya-kattenbak: Status — de rest vult zichzelf in)",
"Time remaining or finish time": "Resterende tijd of eindtijd", "Pause button": "Pauzeknop", "Resume button": "Hervatknop", "Stop button": "Stopknop",
"Power switch": "Aan/uit-schakelaar", "Alerts, e.g. salt or rinse aid low": "Meldingen, bijv. zout of glansspoelmiddel bijna op",
"Power switch": "Aan/uit-schakelaar", "Alerts, e.g. salt or rinse aid low, or a full waste bin": "Meldingen, bijv. zout of glansspoelmiddel bijna op, of een volle afvalbak",
"litterbox": "Kattenbak", "Litter box": "Kattenbak", "Visits today": "Bezoekjes vandaag", "Time in the box today": "Tijd in de bak vandaag", "Cat’s weight": "Gewicht van de kat",
"Clean button": "Schoonmaakknop", "Auto clean switch": "Schakelaar automatisch schoonmaken", "Choose the litter box’s status entity in the inspector.": "Kies de status-entiteit van de kattenbak in de inspector.",
"Emptying": "Aan het legen", "Levelling": "Aan het egaliseren", "Sanitizing": "Aan het ontsmetten", "Sleeping": "Slaapt", "Ready": "Klaar voor gebruik",
"Bin full": "Afvalbak vol", "Check the litter box": "Kijk even bij de kattenbak", "Cat inside": "Kat in de bak", "Needs attention": "Heeft aandacht nodig",
"visits today": "bezoekjes vandaag", "visits": "bezoekjes", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken",
"Starts later": "Start later", "Action needed": "Actie nodig", "Error": "Fout", "Stopping": "Stoppen", "Door locked": "Deur vergrendeld",
"Ready at {0}": "Klaar om {0}", "Check the appliance": "Kijk even bij het apparaat", "Something went wrong": "Er ging iets mis", "Stop": "Stop",
"Hide this card when none of its cards are showing": "Verberg deze kaart als geen van de kaarten erin zichtbaar is",
+27
View File
@@ -1575,6 +1575,32 @@ body.in-haven #btn-new { display: none; }
.appl-fact { font-size: var(--c-fs-meta); font-weight: 500; }
.appl-big { font-size: clamp(22px, 17cqh, 34px); }
/* litter box: what it's doing in words, today's figures, clean now and auto clean */
.lb .appl-text { gap: 4px; }
.lb .appl-main-text { flex-wrap: nowrap; overflow: hidden; }
.lb .appl-big { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lb.phase-cleaning .appl-big { color: var(--d-accent); }
.lb.phase-attention .appl-big { color: var(--warn); }
.lb.phase-cleaning .appl-ico { color: var(--d-accent); }
.lb.phase-attention .appl-ico { color: var(--warn); }
.lb-ring > svg { transform: none; }
.lb-spin { transform-origin: 50px 50px; animation: lb-spin 1.6s linear infinite; }
@keyframes lb-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .lb-spin { animation: none; } }
.lb-figs { display: flex; gap: 18px; min-width: 0; }
.lb-fig { display: flex; flex-direction: column; min-width: 0; }
.lb-fig b { font-size: var(--c-fs-mid); font-weight: 400; line-height: 1.15; white-space: nowrap; }
.lb-s { display: none; }
.lb-fig span { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lb-clean { flex: none; display: inline-flex; align-items: center; gap: 6px; height: var(--c-ctl); padding: 0 14px; border-radius: 99px; background: var(--d-fill); font-size: var(--c-fs-body); font-weight: 600; }
.lb-clean:disabled { opacity: .5; cursor: default; }
@media (hover: hover) { .lb-clean:not(:disabled):hover { background: color-mix(in srgb, var(--d-text) 14%, transparent); } }
.lb-auto { flex: none; display: inline-flex; align-items: center; gap: 8px; font-size: var(--c-fs-meta); color: var(--d-text-2); }
.lb-fig .unit { font-size: var(--c-fs-meta); }
@container (max-width: 240px) { .lb-fig:nth-child(3) { display: none; } }
@container (max-width: 400px) { .lb-figs { gap: 12px; } .lb-auto-l { display: none; } .lb-clean span { display: none; } .lb-clean { width: var(--c-ctl); padding: 0; justify-content: center; } }
@container (max-height: 180px) { .lb .appl-text { gap: 2px; } .lb-fig { flex-direction: row; align-items: baseline; gap: 5px; } .lb-fig b { font-size: var(--c-fs-body); } .lb-l { display: none; } .lb-s { display: inline; } }
/* camera and photos: square label plates (above) */
/* car */
@@ -2006,3 +2032,4 @@ button.fader-input { cursor: pointer; }
.lt-colors { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.lt-colors .swatches { flex: 1; min-width: 0; margin: 0; justify-content: flex-start; }
.lt-colors .swatch { width: 28px; height: 28px; }
@container (min-height: 260px) { .lb-figs { justify-content: center; } .lb-fig { align-items: center; } .lb .appl-main { flex-direction: column; } }
+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 = {