diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 6fbc25d..3c03948 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -25,7 +25,7 @@ Heading, entity tile, big value (with warning and critical levels), gauge, light ### Photos and Immich -The Photos widget shows one picture or a slideshow that fades between photos. Pick a source: +The Photos widget shows one picture or a slideshow that fades between photos. Tap it to skip to the next photo. Pick a source: - **Links:** paste one or more image URLs, one per line. - **Immich:** tick one or more albums. Both your own albums and albums shared with you appear. @@ -50,14 +50,14 @@ Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and s Pick a weather entity in the clock's settings to show the current conditions and temperature under the time. Today's high and low come from Home Assistant's forecast, refreshed every half hour. Tap the weather to see all its details. - **Text position:** place the time anywhere in the card with the 3×3 grid: top left, top, top right, left, center, right, bottom left, bottom or bottom right. -- **Background image:** choose image links (one per line) or Immich albums. With more than one photo the clock fades between them; set how often. **Darken the image** keeps the time readable on bright photos (30% by default), and the text turns white with a soft shadow. +- **Background image:** choose image links (one per line) or Immich albums. With more than one photo the clock fades between them; set how often. Tap the clock to skip to the next photo right away. **Darken the image** keeps the time readable on bright photos (30% by default), and the text turns white with a soft shadow. ### Horizontal scroll and swipe cards These cards hold other cards. Select one in edit mode, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it. - **Horizontal scroll** puts its cards in a row you can scroll sideways, by touch, trackpad or the arrow buttons that appear on hover. Set the width of the cards and an optional title. The row keeps its scroll position when something inside it changes. -- **Swipe cards** shows one card at a time: swipe, or tap the dots underneath. It can move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on. +- **Swipe cards** shows one card at a time, filling the whole card. Swipe with a finger or drag with the mouse: the card follows along, resists at the first and last card, and a quick flick is enough. The left and right arrow keys work too. Small dots appear at the bottom while you swipe or hover, and hide again after a few seconds; tap one to jump to that card. It can also move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on. Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off. @@ -67,7 +67,7 @@ When the light card points at a light group, it also lists every light in the gr ### Vacuum -Shows the robot's status and battery, buttons to start or pause, send it back to the dock and locate it, and the suction power levels it supports. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**. +The card grows with its size. On a small card it's a compact row with the status, battery and buttons. On a bigger card, the robot sits in the middle with a battery ring around it, a pulse while it cleans and a bolt while it charges. Below it are tiles for battery, cleaned area, cleaning time and any extra sensors you add (such as filter or brush life), the suction power levels, and labelled buttons to start or pause, send it back to the dock and locate it. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**. ### Media players diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 3a643b0..fbcf145 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.8.0" +version: "1.9.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 6e2b0d6..8006d1a 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -51,6 +51,9 @@ class MockHA: entity_id=["light.living_room", "light.kitchen", "light.reading_lamp"]), entity("vacuum.robot", "docked", friendly_name="Robot Vacuum", battery_level=82, fan_speed="standard", fan_speed_list=["quiet", "standard", "turbo", "max"], status="Charging"), + entity("sensor.robot_vacuum_cleaned_area", "23", friendly_name="Robot Vacuum Cleaned area", unit_of_measurement="m²"), + entity("sensor.robot_vacuum_cleaning_time", "34", friendly_name="Robot Vacuum Cleaning time", unit_of_measurement="min", device_class="duration"), + entity("sensor.robot_vacuum_filter_left", "72", friendly_name="Robot Vacuum Filter left", unit_of_measurement="%"), 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"), diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index 16c424c..e06a216 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -173,6 +173,12 @@ d.nav = { show: false, style: "icons", ...(d.nav || {}) }; if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }]; delete d.widgets; + // Media cards from before 1.5 kept one player in cfg.entity; move it into the list so it can be edited. + const fix = (w) => { + if (w.type === "media" && w.cfg.entity) { w.cfg.entities = [...new Set([w.cfg.entity, ...(w.cfg.entities || [])])]; delete w.cfg.entity; } + (w.cfg.children || []).forEach(fix); + }; + d.tabs.forEach((t) => t.widgets.forEach(fix)); return d; } @@ -505,6 +511,7 @@ } const ctxFor = (w) => ({ ha, w, api, cache: (caches[w.id] ||= {}), + rerender: () => renderWidget(w), isVisible: (child) => editMode || isVisible(child), // Containers draw placeholders (.w.child); this fills each with its card. renderChildren: (el) => el.querySelectorAll(".w.child[data-id]").forEach((ph) => { @@ -556,6 +563,7 @@ for (const w of affected) { const el = gridEl.querySelector(`.w[data-id="${w.id}"]`); if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag + if (caches[w.id]?.busy) { caches[w.id].dirty = true; continue; } // mid-swipe: redraw when the swipe ends const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null; renderWidget(w, el); if (focused) el.querySelector(`[data-act="${focused}"]`)?.focus(); @@ -1078,10 +1086,11 @@ const track = a.closest(".hs").querySelector(".hs-track"); track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" }); } - else if (act === "sw-go") { - const track = a.closest(".sw").querySelector(".sw-track"); - track.scrollTo({ left: Number(a.dataset.v) * track.clientWidth, behavior: reducedMotion() ? "auto" : "smooth" }); + else if (act === "photo-next") { + const c = caches[a.closest(".w").dataset.id]; + (c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg } + else if (act === "sw-go") caches[a.closest(".w:not(.child)").dataset.id]?.go?.(Number(a.dataset.v)); else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); } else if (act === "button") { const c = findWidget(a.closest(".w").dataset.id).cfg; @@ -1436,9 +1445,9 @@ } const del = e.target.closest("[data-ent-del]"); if (del) { - const w = tab().widgets.find((x) => x.id === selectedId); - mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false }); - renderWidget(w); renderInspector(); return; + const w = editTarget(); // may be a card inside a scroll or swipe card + mutate(() => (w.cfg[del.dataset.k] || []).splice(Number(del.dataset.entDel), 1), null, { rerender: false }); + renderWidget(selectedWidget()); renderInspector(); return; } const childEdit = e.target.closest("[data-child-edit]"), childBtn = e.target.closest("[data-child-op]"); if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; } diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index 0edc520..445b28f 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -268,6 +268,10 @@ "Show suction power": "Toon zuigkracht", "Battery {0}%": "Accu {0}%", "Cleaning map": "Schoonmaakkaart", "Suction power": "Zuigkracht", "Start cleaning": "Begin met schoonmaken", "Send back to the dock": "Terug naar het dock", "Locate (makes a sound)": "Zoeken (maakt geluid)", "Cleaning…": "Aan het schoonmaken…", "On its way to the dock": "Onderweg naar het dock", + "Next photo": "Volgende foto", "Show page dots while swiping": "Toon paginapuntjes tijdens het vegen", + "Battery": "Accu", "Cleaned area": "Schoongemaakt oppervlak", "Cleaning time": "Schoonmaaktijd", + "More sensors, e.g. filter or brush life": "Meer sensoren, bijv. levensduur van filter of borstel", + "Resume": "Hervat", "Start": "Start", "Dock": "Dock", "Locate": "Zoek", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", }; diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index 08456b1..04dac52 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -260,6 +260,7 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace, .w-body { position: absolute; inset: 0; padding: 16px; display: flex; flex-direction: column; min-height: 0; } .w-body.flush { padding: 0; } .w.transparent .w-body, .surface-plain .w .w-body { padding: 6px 4px; } +.w.transparent .w-body.flush, .surface-plain .w .w-body.flush { padding: 0; } .pad { padding: 16px; height: 100%; display: flex; } /* Lit: a light that is on turns its tile into lamplight — the signature moment. */ @@ -532,14 +533,23 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } .hs.at-start .hs-nav.prev, .hs.at-end .hs-nav.next { opacity: 0; pointer-events: none; } @media (hover: none) { .hs-nav { display: none; } } .i.back { transform: rotate(90deg); } .i.fwd { transform: rotate(-90deg); } -.sw { height: 100%; display: flex; flex-direction: column; gap: 8px; } -.sw-track { flex: 1; min-height: 0; display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; overscroll-behavior-x: contain; border-radius: var(--d-radius, 20px); } -.sw-track::-webkit-scrollbar { display: none; } -.sw-track > .w.child { width: 100%; scroll-snap-align: center; scroll-snap-stop: always; } -.sw-dots { flex: none; display: flex; justify-content: center; gap: 2px; } +.sw { position: relative; height: 100%; overflow: hidden; border-radius: var(--d-radius, 20px); touch-action: pan-y; } +.sw-track { display: flex; height: 100%; will-change: transform; user-select: none; -webkit-user-select: none; outline: none; } +.sw-track > .w.child { flex: 0 0 100%; width: 100%; } +.sw.dragging .sw-track { cursor: grabbing; } +.sw.dragging .w.child { pointer-events: none; } +.sw-track:focus-visible { box-shadow: inset var(--focus); } +/* Dots float over the cards and only show while swiping, hovering or focused. */ +.sw-dots { + position: absolute; left: 50%; bottom: 10px; z-index: 4; transform: translate(-50%, 6px); + display: flex; gap: 0; padding: 2px 6px; border-radius: 99px; + background: var(--glass); backdrop-filter: blur(16px) saturate(1.3); -webkit-backdrop-filter: blur(16px) saturate(1.3); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); opacity: 0; pointer-events: none; transition: opacity .25s, transform .25s; +} +.sw.dots-on .sw-dots, .sw:has(:focus-visible) .sw-dots { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; } .sw-dot { width: 22px; height: 22px; display: grid; place-items: center; } -.sw-dot::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--d-text-2); opacity: .45; transition: width .2s, opacity .2s; } -.sw-dot[aria-selected="true"]::after { width: 18px; border-radius: 4px; opacity: 1; background: var(--d-text); } +.sw-dot::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--text); opacity: .35; transition: width .2s, opacity .2s; } +.sw-dot[aria-selected="true"]::after { width: 18px; border-radius: 4px; opacity: 1; } .btn.back { margin: 0 0 10px; padding-left: 8px; } .child-list { display: flex; flex-direction: column; gap: 6px; } .child-item { display: flex; gap: 4px; align-items: center; } @@ -560,6 +570,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } .clock.pos-top-right, .clock.pos-right, .clock.pos-bottom-right { align-items: flex-end; text-align: right; } .clock-bg { position: absolute; inset: 0; margin: -16px; z-index: 0; border-radius: inherit; } .w.transparent .clock-bg, .surface-plain .clock-bg { margin: -6px -4px; } +.clock.on-image, .photos[data-act] { cursor: pointer; } .clock-dim { position: absolute; inset: 0; margin: -16px; z-index: 0; background: linear-gradient(to bottom, rgba(0, 0, 0, calc(var(--dim) * 0.8)), rgba(0, 0, 0, var(--dim))); } .clock.on-image { color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); text-shadow: 0 1px 12px rgba(0, 0, 0, 0.45); } .clock.on-image .clock-wx { background: rgba(0, 0, 0, 0.32); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } @@ -573,17 +584,48 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } .light-members { flex: 1; min-height: 0; margin: 0 -6px; } .light:has(.light-members) { justify-content: flex-start; } -/* vacuum */ -.vac { display: flex; flex-direction: column; height: 100%; gap: 10px; } -.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); } +/* vacuum: compact on small cards, a robot with stats on bigger ones */ +.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; } +.vac-head { display: flex; align-items: center; gap: 12px; min-width: 0; } +.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; } .vac-batt .i { font-size: 18px; } -.vac-map { flex: 1; min-height: 0; width: 100%; object-fit: contain; border-radius: 12px; background: var(--d-fill); } -.vac-spacer { flex: 1; } -.vac .ctrls { display: flex; gap: 8px; align-items: center; } -.vac .round:disabled { opacity: .35; cursor: default; } -.vac-hint { margin-left: 4px; } -.vac.is-error .state { color: var(--crit); font-weight: 600; } -@container (max-height: 220px) { .vac-map { display: none; } .vac-spacer { display: none; } } +.vac-hero { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; gap: 18px; } +.vac-map { width: 100%; height: 100%; object-fit: contain; border-radius: 14px; background: var(--d-fill); } +.vac-robot { position: relative; width: clamp(84px, 32cqh, 150px); aspect-ratio: 1; flex: none; display: grid; place-items: center; } +.vac-ring { position: absolute; inset: 0; transform: rotate(-90deg); } +.vac-ring circle { fill: none; stroke-width: 5; } +.vac-ring .track { stroke: var(--d-fill); } +.vac-ring .fill { stroke: var(--ok); stroke-linecap: round; transition: stroke-dasharray .6s; } +.vac-ring .fill.low { stroke: var(--crit); } +.vac-robot-ico { position: relative; z-index: 1; width: 62%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(30px, 12cqh, 54px); background: var(--d-fill); } +.state-cleaning .vac-robot-ico, .state-returning .vac-robot-ico { background: var(--accent-w); color: var(--d-on-accent, #fff); } +.vac-pulse { position: absolute; inset: 19%; border-radius: 50%; } +.vac-robot.is-cleaning .vac-pulse { animation: vac-pulse 1.8s ease-out infinite; border: 2px solid var(--accent-w); } +@keyframes vac-pulse { from { transform: scale(1); opacity: .7; } to { transform: scale(1.45); opacity: 0; } } +.vac-charge { position: absolute; z-index: 2; right: 8%; bottom: 8%; width: 28%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: clamp(14px, 4cqh, 20px); box-shadow: 0 0 0 3px var(--w-bg, var(--d-tile)); } +.vac-hero-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.vac-headline { font-size: clamp(18px, 6cqh, 26px); font-weight: 700; line-height: 1.15; } +.vac-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 8px; flex: none; } +.vac-stat { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 8px; align-items: center; padding: 10px 12px; border-radius: 14px; background: var(--d-fill); min-width: 0; } +.vac-stat .i { grid-row: span 2; font-size: 18px; color: var(--d-text-2); } +.vac-stat-v { font-size: 17px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vac-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vac-fan { flex: none; background: var(--d-fill); } +.vac-fan button { color: inherit; display: grid; place-items: center; } +.vac-fan button[aria-pressed="true"] { background: var(--d-tile); color: var(--d-text); } +.vac-actions { display: flex; gap: 8px; flex: none; } +.vac-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 12px; border-radius: 14px; background: var(--d-fill); font-size: 14px; font-weight: 600; } +.vac-btn .i { font-size: 18px; flex: none; } +.vac-btn.primary { background: var(--d-text); color: var(--d-tile); } +.vac-btn:disabled { opacity: .4; cursor: default; } +.vac.state-error .state { color: var(--crit); font-weight: 600; } +/* the more room, the more it shows */ +@container (max-width: 330px) { .vac-btn span { display: none; } .vac-hero-text { display: none; } } +@container (max-height: 370px) { .vac-stats { display: none; } } +@container (max-height: 330px) { .vac-fan { display: none; } } +@container (max-height: 250px) { .vac-hero { display: none; } } +@container (min-height: 251px) { .vac-head .vac-batt, .vac-head .state { display: none; } } +@container (min-height: 251px) and (max-width: 330px) { .vac-head .vac-batt { display: inline-flex; } } /* photos */ .photos { position: absolute; inset: 0; background: #000; overflow: hidden; } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index fee4cd3..cf45f17 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -358,7 +358,8 @@ const wx = s ? `` : ""; - return `
+ return `
+ ${bg ? `` : ""} ${bg ? `
` : ""}
${clockHTML(c.h24, c.seconds, c.date)}
${wx}
`; }, @@ -477,37 +478,66 @@ }; // ---------------------------------------------------------------- vacuum + // One layout that grows with the card: compact on small cards; a robot with a battery ring, + // stats and labelled buttons on bigger ones (see the container queries in style.css). W.vacuum = { - label: "Vacuum", icon: "vacuum", size: [4, 3], + label: "Vacuum", icon: "vacuum", size: [4, 5], 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: "area", t: "entity", l: "Cleaned area", domains: ["sensor"] }, + { k: "duration", t: "entity", l: "Cleaning time", domains: ["sensor"] }, + { k: "entities", t: "entities", l: "More sensors, e.g. filter or brush life", domains: ["sensor", "binary_sensor"] }, { k: "fan", t: "check", l: "Show suction power", d: true }, ], - entities: (c) => [c.entity, c.battery].filter(Boolean), + entities: (c) => [c.entity, c.battery, c.area, c.duration, ...(c.entities || [])].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 a = s.attributes, e = esc(c.entity), name = friendly(s, c.name); + const cleaning = s.state === "cleaning", returning = 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 hasBatt = isFinite(batt); + const charging = s.state === "docked" && hasBatt && batt < 100; + const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state); + const detail = a.status && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : ""; 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 `
-
${Icons.svg("vacuum")} - ${esc(friendly(s, c.name))}${esc(status)} - ${isFinite(batt) ? `${Icons.svg("battery")}${Math.round(batt)}%` : ""}
- ${mapUrl ? `${t(` : `
`} - ${speeds.length ? `
${speeds.map((f) => ``).join("")}
` : ""} -
- - - - ${busy ? `${cleaning ? t("Cleaning…") : t("On its way to the dock")}` : ""} + + // battery ring around the robot + const R = 46, C = 2 * Math.PI * R, frac = hasBatt ? Math.max(0, Math.min(1, batt / 100)) : 0; + const ring = ``; + + const stat = (label, value, ico) => `
${Icons.svg(ico)}${esc(value)}${esc(label)}
`; + const stats = [ + hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "", + c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "", + c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "", + ...(c.entities || []).map((x) => ha.states[x] && stat(friendly(ha.states[x]).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), ""), fmtState(ha.states[x]), "pulse")), + ].filter(Boolean).join(""); + + const btn = (svc, label, ico, primary, disabled) => ``; + + return `
+
${Icons.svg("vacuum")} + ${esc(name)}${esc(headline)}${detail ? ` · ${esc(detail)}` : ""} + ${hasBatt ? `${Icons.svg(charging ? "bolt" : "battery")}${Math.round(batt)}%` : ""}
+
+ ${mapUrl ? `${t(` + : `
${ring}${Icons.svg("vacuum")} + ${charging ? `` : ""}
+
${esc(headline)}${hasBatt ? `${t("Battery {0}%", Math.round(batt))}` : ""}
`} +
+ ${stats ? `
${stats}
` : ""} + ${speeds.length ? `
${speeds.map((f) => ``).join("")}
` : ""} +
+ ${btn(cleaning ? "pause" : "start", cleaning ? t("Pause") : returning ? t("Resume") : t("Start"), cleaning ? "pause" : "play", true)} + ${btn("return_to_base", t("Dock"), "home", false, s.state === "docked")} + ${btn("locate", t("Locate"), "pin")}
`; }, lit: (c, { ha }) => (["cleaning", "returning"].includes(ha.states[c.entity]?.state) ? "on" : false), @@ -694,7 +724,8 @@ render(c) { const empty = c.source === "immich" ? (c.albums?.length ? "" : t("Choose one or more Immich albums in the inspector.")) : urlList(c).length ? "" : t("Add image URLs in the inspector."); if (empty) return `
${empty}
`; - return `