diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index 26d4e1d..b121c76 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 - **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. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 8bd77cb..d0a9f68 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -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. -### 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: @@ -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. +#### 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 Point the car widget at whichever entities your car integration provides. Each one is optional: diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 7e21cc2..a4e3d44 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "2.3.1" +version: "2.4.0" slug: dashboard_studio 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 diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py index 51b13d6..02f4cab 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -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_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="%"), + # 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("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"), @@ -326,6 +334,11 @@ class MockHA: self.todo.remove(it) self.set_state(eid, str(sum(1 for it in self.todo if it["status"] == "needs_action"))) 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": pos = self.states[eid]["attributes"].get("current_position") if service == "set_cover_position": diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index e5877d1..0a34012 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -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; } } diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index 5cfa435..20b8afd 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -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", diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index d8635de..bdf3c92 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -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; } } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index bc779dd..046a5ac 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -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 `