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
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 2.4.0
- **Litter box:** the Appliance card has a new kind, **Litter box**, for self-cleaning litter boxes such as the ZEDAR K1200 on Tuya. It shows what the box is doing in words (Ready, Cleaning…, Emptying, Levelling, Sanitizing, Sleeping), today's visits, the time in the box today and your cat's weight, with a **Clean now** button and the auto-clean switch. A full waste bin or a fault shows in amber. Choose the box's *Status* sensor and the rest of the device fills in by itself. A litter box card never hides itself.
- Generated dashboards now include a Tuya litter box when one is found.
## 2.3.1 ## 2.3.1
- **Light card:** the colors now wait behind a palette button next to the brightness. Tap it to show them; picking one folds them away again. - **Light card:** the colors now wait behind a palette button next to the brightness. Tap it to show them; picking one folds them away again.
+7 -1
View File
@@ -221,7 +221,7 @@ Light groups are left out so each lamp counts once. Add extra entities, or skip
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. 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) ### Appliance (washer, dryer, dishwasher, litter box)
At two rows high, a progress ring around the appliance sits beside its name, status, program, the time left and when it will be ready, plus the door and its power or energy use. Taller cards stack the ring on top. Without a progress sensor, progress is worked out from the time left. It works with any integration, such as Home Connect, Miele, LG ThinQ, SmartThings, or a smart plug: At two rows high, a progress ring around the appliance sits beside its name, status, program, the time left and when it will be ready, plus the door and its power or energy use. Taller cards stack the ring on top. Without a progress sensor, progress is worked out from the time left. It works with any integration, such as Home Connect, Miele, LG ThinQ, SmartThings, or a smart plug:
@@ -234,6 +234,12 @@ By default the card only shows while the appliance is running or has just finish
**Home Connect** (Bosch, Siemens, Neff, Gaggenau): choose the appliance's *Operation state* sensor as the status, and the card fills in the rest of the same device by itself: program progress, finish time, door state, active program, the pause, resume and stop buttons, the power switch and alerts (for example rinse aid or salt nearly empty, which Home Connect provides as event sensors that are off by default). Fields you already filled in are left alone. The card understands every Home Connect operation state: running, paused, finished, starts later (with the time it will be ready), action needed, error and stopping; *ready* and *inactive* count as off. Program names such as `dishcare_dishwasher_program_eco_50` show as "Eco 50". Depending on the state, round buttons appear to pause, resume or stop the program, or to switch the appliance on. While an alert is active, the card stays visible even when the appliance is off. **Home Connect** (Bosch, Siemens, Neff, Gaggenau): choose the appliance's *Operation state* sensor as the status, and the card fills in the rest of the same device by itself: program progress, finish time, door state, active program, the pause, resume and stop buttons, the power switch and alerts (for example rinse aid or salt nearly empty, which Home Connect provides as event sensors that are off by default). Fields you already filled in are left alone. The card understands every Home Connect operation state: running, paused, finished, starts later (with the time it will be ready), action needed, error and stopping; *ready* and *inactive* count as off. Program names such as `dishcare_dishwasher_program_eco_50` show as "Eco 50". Depending on the state, round buttons appear to pause, resume or stop the program, or to switch the appliance on. While an alert is active, the card stays visible even when the appliance is off.
#### Litter box
Set **Appliance** to **Litter box** for a self-cleaning litter box. Instead of a progress ring, the card says what the box is doing: *Ready*, *Cleaning…*, *Emptying*, *Levelling*, *Sanitizing* or *Sleeping*. Under that are today's visits, the time in the box today and your cat's weight. **Clean now** starts a cleaning cycle and the switch at the top turns auto clean on or off. The card never hides itself.
**Tuya** (for example the ZEDAR K1200): choose the box's *Status* sensor and the card fills in the rest of the same device: *Excretion times (day)* as the visits, *Excretion duration* as the time in the box, *Cat weight*, the *Manual clean* button and the *Auto clean* switch. Sensors for a full waste bin or a fault become alerts, shown in amber. Not every box offers every sensor, and names can differ; you can always pick the entities by hand. Other brands work too, as long as there is a status sensor.
### 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:
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "2.3.1" version: "2.4.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
+13
View File
@@ -58,6 +58,14 @@ class MockHA:
entity("sensor.robot_vacuum_filter_left", "72", friendly_name="Robot Vacuum Filter left", unit_of_measurement="%"), entity("sensor.robot_vacuum_filter_left", "72", friendly_name="Robot Vacuum Filter left", unit_of_measurement="%"),
entity("sensor.robot_vacuum_main_brush_left", "41", friendly_name="Robot Vacuum Main brush left", unit_of_measurement="%"), entity("sensor.robot_vacuum_main_brush_left", "41", friendly_name="Robot Vacuum Main brush left", unit_of_measurement="%"),
entity("sensor.robot_vacuum_side_brush_left", "9", friendly_name="Robot Vacuum Side brush left", unit_of_measurement="%"), entity("sensor.robot_vacuum_side_brush_left", "9", friendly_name="Robot Vacuum Side brush left", unit_of_measurement="%"),
# a Tuya cat litter box (category msp), named as the Tuya integration names its entities
entity("sensor.zedar_k1200_status", "standby", friendly_name="ZEDAR K1200 Status", device_class="enum", options=["clean", "empty", "level", "sleep", "standby", "uv"]),
entity("sensor.zedar_k1200_cat_weight", "4.6", friendly_name="ZEDAR K1200 Cat weight", unit_of_measurement="kg", device_class="weight"),
entity("sensor.zedar_k1200_excretion_times_day", "3", friendly_name="ZEDAR K1200 Excretion times (day)"),
entity("sensor.zedar_k1200_excretion_duration", "7", friendly_name="ZEDAR K1200 Excretion duration", unit_of_measurement="min", device_class="duration"),
entity("button.zedar_k1200_manual_clean", "unknown", friendly_name="ZEDAR K1200 Manual clean"),
entity("button.zedar_k1200_factory_reset", "unknown", friendly_name="ZEDAR K1200 Factory reset"),
entity("switch.zedar_k1200_auto_clean", "on", friendly_name="ZEDAR K1200 Auto clean"),
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"), entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
entity("fan.office", "off", friendly_name="Office Fan"), entity("fan.office", "off", friendly_name="Office Fan"),
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"), entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
@@ -326,6 +334,11 @@ class MockHA:
self.todo.remove(it) self.todo.remove(it)
self.set_state(eid, str(sum(1 for it in self.todo if it["status"] == "needs_action"))) self.set_state(eid, str(sum(1 for it in self.todo if it["status"] == "needs_action")))
continue continue
if eid == "button.zedar_k1200_manual_clean": # the litter box cleans for a little while
self.set_state("sensor.zedar_k1200_status", "clean")
asyncio.get_event_loop().call_later(8, lambda: self.set_state("sensor.zedar_k1200_status", "level"))
asyncio.get_event_loop().call_later(12, lambda: self.set_state("sensor.zedar_k1200_status", "standby"))
continue
if domain == "cover": if domain == "cover":
pos = self.states[eid]["attributes"].get("current_position") pos = self.states[eid]["attributes"].get("current_position")
if service == "set_cover_position": if service == "set_cover_position":
+6 -1
View File
@@ -660,6 +660,11 @@
["dryer", /(dryer|droger|wasdroger)_(status|state|operation_state|program_state)$/, "Dryer"], ["dryer", /(dryer|droger|wasdroger)_(status|state|operation_state|program_state)$/, "Dryer"],
["dishwasher", /(dishwasher|vaatwasser|vaatwasmachine)_(status|state|operation_state|program_state)$/, "Dishwasher"]] ["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); .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) { if (players.length || appliances.length) {
let y2 = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)), x2 = 0; 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; } 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 base = a.status.replace(/_(status|state|operation_state|program_state)$/, "");
const pick = (...suffixes) => suffixes.map((sfx) => `${base}_${sfx}`).find((e) => ha.states[e]) || ""; const pick = (...suffixes) => suffixes.map((sfx) => `${base}_${sfx}`).find((e) => ha.states[e]) || "";
if (x2 + 4 > 12) { x2 = 0; y2 += 3; } 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; 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.", "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", "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", "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", "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", "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", "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", "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-fact { font-size: var(--c-fs-meta); font-weight: 500; }
.appl-big { font-size: clamp(22px, 17cqh, 34px); } .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) */ /* camera and photos: square label plates (above) */
/* car */ /* 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 { 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 .swatches { flex: 1; min-width: 0; margin: 0; justify-content: flex-start; }
.lt-colors .swatch { width: 28px; height: 28px; } .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); return s.includes("_program_") ? cap(s.split("_program_").pop().replace(/_/g, " ")) : st(s);
}; };
const ALERT_ON = new Set(["present", "confirmed", "on", "true"]); 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 = { W.appliance = {
label: "Appliance", icon: "washer", size: [4, 2], label: "Appliance", icon: "washer", size: [4, 2],
fields: [ fields: [
{ k: "name", t: "text", l: "Name", d: "Washing machine" }, { k: "name", t: "text", l: "Name", d: "Washing machine" },
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" }, { 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 — the rest fills in by itself)", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] }, { 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"] }, { 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 }, { k: "threshold", t: "number", l: "Running above (W)", d: 5, when: notLitter },
{ k: "remaining", t: "entity", l: "Time remaining or finish time", domains: ["sensor"] }, { k: "remaining", t: "entity", l: "Time remaining or finish time", domains: ["sensor"], when: notLitter },
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] }, { k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"], when: notLitter },
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] }, { k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"], when: notLitter },
{ k: "door", t: "entity", l: "Door (optional)", domains: ["binary_sensor", "sensor"] }, { 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"] }, { k: "energy", t: "entity", l: "Energy or water use (optional)", domains: ["sensor"], when: notLitter },
{ k: "pauseBtn", t: "entity", l: "Pause button", domains: ["button"] }, { k: "pauseBtn", t: "entity", l: "Pause button", domains: ["button"], when: notLitter },
{ k: "resumeBtn", t: "entity", l: "Resume button", domains: ["button"] }, { k: "resumeBtn", t: "entity", l: "Resume button", domains: ["button"], when: notLitter },
{ k: "stopBtn", t: "entity", l: "Stop button", domains: ["button"] }, { k: "stopBtn", t: "entity", l: "Stop button", domains: ["button"], when: notLitter },
{ k: "powerSwitch", t: "entity", l: "Power switch", domains: ["switch"] }, { k: "powerSwitch", t: "entity", l: "Power switch", domains: ["switch"], when: notLitter },
{ k: "alerts", t: "entities", l: "Alerts, e.g. salt or rinse aid low", domains: ["sensor", "binary_sensor", "event"] }, { k: "visits", t: "entity", l: "Visits today", domains: ["sensor"], when: litter },
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true }, { 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). // Picking the status fills in the other entities of the same device (Home Connect names them predictably).
autofill: { autofill: {
@@ -1084,6 +1141,14 @@
const base = eid.split(".")[1].replace(/_(operation_state|status|state)$/, ""); 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 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)) || ""; 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 { return {
progress: find("sensor", /progress/), remaining: find("sensor", /finish_time|remaining|end_time/), 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$/), 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), entities: (c) => (c.kind === "litterbox" ? [c.status, c.visits, c.timeToday, c.catWeight, c.cleanBtn, c.autoClean, ...(c.alerts || [])]
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off" : [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())), && !(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. // 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). // Taller cards stack the ring on top (see the container queries in style.css).
render(c, { ha, cache }) { 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>`; 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 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]; 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>` : ""} ${facts ? `<div class="appl-facts">${facts}</div>` : ""}
</div></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 = { W.camera = {