/* Widget library. Each widget type declares: * label, icon, size [w, h], fields (inspector schema), render(cfg, ctx) -> HTML * optional: entities(cfg) -> ids it depends on, after(el, cfg, ctx), flush, transparent, * lit(cfg, ctx) -> "lamp" | "on" | false, lamp(cfg, ctx) -> CSS color for a lit light * Interactive elements are real `; }, lit: (c, { ha }) => litKind(ha.states[c.entity]), lamp: (c, { ha }) => lampColor(ha.states[c.entity]), }; W.value = { label: "Big value", icon: "number", size: [3, 2], fields: [ { k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number", "counter"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }, { k: "unit", t: "text", l: "Unit", ph: "From entity" }, { k: "decimals", t: "number", l: "Decimals", d: "" }, { k: "warn", t: "number", l: "Warn above", d: "" }, { k: "crit", t: "number", l: "Critical above", d: "" }, { k: "icon", t: "icon", l: "Icon" }, ], render(c, { ha }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity); const v = Number(s.state); const lvl = isFinite(v) && has(c.crit) && v > Number(c.crit) ? "crit" : isFinite(v) && has(c.warn) && v > Number(c.warn) ? "warn" : ""; const unit = c.unit || s.attributes.unit_of_measurement || ""; return ``; }, }; function arc(cx, cy, r, a0, a1) { const p = (a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)]; const [x0, y0] = p(a0), [x1, y1] = p(a1); return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; } W.gauge = { label: "Gauge", icon: "gauge", size: [3, 3], fields: [ { k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }, { k: "min", t: "number", l: "Minimum", d: 0 }, { k: "max", t: "number", l: "Maximum", d: 100 }, { k: "green", t: "number", l: "Green from", d: "" }, { k: "yellow", t: "number", l: "Yellow from", d: "" }, { k: "red", t: "number", l: "Red from", d: "" }, ], render(c, { ha }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity); const v = Number(s.state), min = Number(c.min) || 0, max = Number(c.max) || 100; const frac = isFinite(v) ? Math.max(0, Math.min(1, (v - min) / (max - min || 1))) : 0; let color = "var(--d-text)"; if (isFinite(v)) { if (has(c.red) && v >= Number(c.red)) color = "var(--crit)"; else if (has(c.yellow) && v >= Number(c.yellow)) color = "var(--warn)"; else if (has(c.green) && v >= Number(c.green)) color = "var(--ok)"; } const a0 = Math.PI * 0.75, span = Math.PI * 1.5; const unit = s.attributes.unit_of_measurement || ""; return ``; }, }; // ---------------------------------------------------------------- light (and light groups) const COLOR_MODES = ["hs", "xy", "rgb", "rgbw", "rgbww"]; const canColor = (s) => (s?.attributes.supported_color_modes || []).some((m) => COLOR_MODES.includes(m)); const canTemp = (s) => (s?.attributes.supported_color_modes || []).includes("color_temp"); // Swatches: three whites by color temperature, then colors. Values are what light.turn_on takes. const WHITES = [["k:2700", "#ffc46b", "Warm white"], ["k:4000", "#ffe9cf", "Neutral white"], ["k:6500", "#dbe9ff", "Cool white"]]; const COLORS = [["rgb:255,59,48", "#ff3b30", "Red"], ["rgb:255,149,0", "#ff9500", "Orange"], ["rgb:255,214,10", "#ffd60a", "Yellow"], ["rgb:52,199,89", "#34c759", "Green"], ["rgb:90,200,250", "#5ac8fa", "Light blue"], ["rgb:0,122,255", "#007aff", "Blue"], ["rgb:175,82,222", "#af52de", "Purple"], ["rgb:255,45,85", "#ff2d55", "Pink"]]; /** The color a light shows as: its RGB color, or a white from its color temperature. */ function lightTint(s) { const a = s?.attributes || {}; if (s?.state !== "on") return ""; if (Array.isArray(a.rgb_color) && a.color_mode !== "color_temp") return `rgb(${a.rgb_color.join(",")})`; const k = Number(a.color_temp_kelvin); return isFinite(k) && k > 0 ? (k < 3300 ? "#ffc46b" : k < 5000 ? "#ffe9cf" : "#dbe9ff") : "#ffd58a"; } const dimmable = (s) => (s?.attributes?.supported_color_modes || []).some((m) => m !== "onoff"); const pctOf = (s) => (s?.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0); /** The large figure: the brightness, or On / Off for a light that only switches. */ const bigText = (s, short) => (s.state !== "on" ? t("Off") : dimmable(s) ? `${pctOf(s)}%` : short ? "" : t("On")); /** What colour it is, in words: a colour temperature, "Colour", or Off. */ function lightNote(s) { const a = s.attributes; if (s.state !== "on") return t("Off"); if (Array.isArray(a.rgb_color) && a.color_mode && a.color_mode !== "color_temp") return t("Colour"); const k = Number(a.color_temp_kelvin); return isFinite(k) && k > 0 ? (k < 3300 ? t("Warm white") : k < 5000 ? t("Neutral white") : t("Cool white")) : t("On"); } /** A light as a fader: the brightness fills it in the lamp's colour; drag across it to dim, or tap to switch a light that can't dim. */ function fader(eid, s, name, inner, cls) { const on = s.state === "on", pct = pctOf(s); const control = dimmable(s) ? `` : ``; return `
${control}${inner}
`; } function swatches(eid, s, { compact = false } = {}) { const list = [...(canTemp(s) ? WHITES : []), ...(canColor(s) ? COLORS : [])]; if (!list.length) return ""; return `
${(compact ? list.slice(0, 7) : list).map(([v, col, lbl]) => ``).join("")}
`; } W.light = { label: "Light", icon: "bulb", size: [3, 2], fields: [ { k: "entity", t: "entity", l: "Light", domains: ["light"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }, { k: "colors", t: "check", l: "Show colors", d: true }, { k: "members", t: "check", l: "For a light group, show each light", d: true }, ], // A light group lists its lights in the entity_id attribute; follow them too. entities: (c, ha) => [c.entity, ...(ha?.states[c.entity]?.attributes.entity_id || [])].filter(Boolean), // The card is the lamp: when it's on, the brightness fills the card from the left in the lamp's colour, // and dragging anywhere across it dims. A group adds a row per light, each its own little fader. render(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a light"); const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id.filter((e) => ha.states[e]) : []; const name = friendly(s, c.name); const onCount = members.filter((e) => ha.states[e].state === "on").length; const sub = members.length ? (onCount ? t("{0} of {1} on", onCount, members.length) : t("All off")) : lightNote(s); // The colours wait behind a palette button; opened, they take the figure's place until one is picked. const hasColors = c.colors !== false && (canTemp(s) || canColor(s)); const open = hasColors && cache.colors; const foot = open ? `
${swatches(c.entity, s)}
` : `${esc(bigText(s))}${hasColors ? `` : ""}`; const rows = members.map((eid) => { const m = ha.states[eid]; const short = friendly(m).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "") || friendly(m); return fader(eid, m, short, `${esc(short)}${esc(bigText(m, true))} `, "lf-row"); }).join(""); const main = fader(c.entity, s, name, `
${esc(name)}${esc(sub)}
${foot}
`, "lt-main"); return `
${main}${rows ? `
${rows}
` : ""}
`; }, lit: (c, { ha }) => litKind(ha.states[c.entity]), lamp: (c, { ha }) => lampColor(ha.states[c.entity]), }; W.climate = { label: "Thermostat", icon: "thermometer", size: [3, 3], fields: [ { k: "entity", t: "entity", l: "Thermostat", domains: ["climate"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }, { k: "step", t: "number", l: "Step", d: 0.5 }, ], render(c, { ha }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a thermostat"); const a = s.attributes, step = Number(c.step) || 0.5, e = esc(c.entity); return `
${esc(friendly(s, c.name))} · ${t("Now")} ${esc(a.current_temperature ?? "–")}°
${esc(a.temperature ?? "–")}°
${(a.hvac_modes || []).map((m) => ``).join("")}
`; }, }; W.graph = { label: "History graph", icon: "chart", size: [6, 3], fields: [ { k: "title", t: "text", l: "Title", d: "" }, { k: "entities", t: "entities", l: "Sensors", domains: ["sensor", "input_number", "number"] }, { k: "hours", t: "number", l: "Hours to show", d: 24 }, { k: "fill", t: "check", l: "Fill under the line", d: true }, ], entities: (c) => c.entities || [], render(c) { if (!c.entities?.length) return `
${t("Add one or more sensors in the inspector.")}
`; return `
${title(c.title)}
${t("Loading history…")}
`; }, async after(el, c, ctx) { if (!c.entities?.length) return; const hours = Number(c.hours) || 24; const key = c.entities.join(",") + "|" + hours; const cache = ctx.cache; if (cache.key !== key || Date.now() - cache.at > 10 * 60 * 1000) { cache.key = key; cache.at = Date.now(); cache.loading = ctx.ha.history(c.entities, hours).catch(() => ({})).then((s) => (cache.series = s)); } // a re-render while the fetch is in flight must wait for the same request await cache.loading; if (!el.isConnected) return; for (const eid of c.entities) { const s = ctx.ha.states[eid]; if (!s || !cache.series[eid]) continue; const pts = cache.series[eid], last = pts[pts.length - 1]; const t = new Date(s.last_updated).getTime(); if (!last || t > last[0]) pts.push([t, Number(s.state)]); const cutoff = Date.now() - hours * 3600e3; while (pts.length > 2 && pts[0][0] < cutoff) pts.shift(); } drawGraph(el, c, ctx); }, }; // Series differ by dash pattern as well as color, so color is never the only cue. const SERIES = [["var(--d-accent)", ""], ["var(--warn)", "6 4"], ["var(--ok)", "2 3"], ["var(--crit)", "10 3 2 3"]]; function drawGraph(el, c, ctx) { const plot = el.querySelector(".plot"), legend = el.querySelector(".legend"); if (!plot) return; const W_ = plot.clientWidth, H = plot.clientHeight; const series = c.entities.map((eid) => ({ eid, pts: (ctx.cache.series || {})[eid] || [] })).filter((s) => s.pts.length); legend.innerHTML = c.entities.map((eid, i) => { const s = ctx.ha.states[eid], [col, dash] = SERIES[i % SERIES.length]; return `${esc(friendly(s))} ${esc(s ? fmtState(s) : "–")}`; }).join(""); if (!series.length || W_ < 10 || H < 10) { plot.innerHTML = `
${t("No numeric history for this period.")}
`; return; } const hours = Number(c.hours) || 24; const t1 = Date.now(), t0 = t1 - hours * 3600e3; let lo = Infinity, hi = -Infinity; series.forEach((s) => s.pts.forEach(([, v]) => { if (isFinite(v)) { lo = Math.min(lo, v); hi = Math.max(hi, v); } })); if (!isFinite(lo)) { plot.innerHTML = `
${t("No numeric history for this period.")}
`; return; } if (hi - lo < 1e-6) { hi += 1; lo -= 1; } const pad = (hi - lo) * 0.1; lo -= pad; hi += pad; const L = 36, B = 16; const x = (t) => L + ((t - t0) / (t1 - t0)) * (W_ - L); const y = (v) => (1 - (v - lo) / (hi - lo)) * (H - B); const paths = series.map((s) => { const [col, dash] = SERIES[c.entities.indexOf(s.eid) % SERIES.length]; const pts = s.pts.filter(([, v]) => isFinite(v)); if (!pts.length) return ""; let d = ""; pts.forEach(([t, v], j) => { d += `${j ? "L" : "M"}${x(Math.max(t, t0)).toFixed(1)} ${y(v).toFixed(1)}`; }); d += `L${x(t1).toFixed(1)} ${y(pts[pts.length - 1][1]).toFixed(1)}`; const area = c.fill && series.length === 1 ? `` : ""; return `${area}`; }).join(""); const ticks = [0, 0.5, 1].map((f) => lo + (hi - lo) * f); plot.innerHTML = ` ${ticks.map((v) => `${fmtNum(v)}`).join("")} ${paths} ${t("−{0} h", hours)}${t("Now")}`; } const POSITIONS = ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"]; W.clock = { label: "Clock", icon: "clock", size: [3, 2], fields: [ { k: "h24", t: "check", l: "24-hour time", d: true }, { k: "seconds", t: "check", l: "Show seconds", d: false }, { k: "date", t: "check", l: "Show date", d: true }, { k: "pos", t: "pos9", l: "Text position", o: POSITIONS, d: "center" }, { k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] }, { k: "forecast", t: "check", l: "Show today’s high and low", d: true }, { k: "bgSource", t: "seg", l: "Background image", o: ["none", "links", "immich"], d: "none" }, { k: "bgUrls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.bgSource === "links" }, { k: "bgAlbums", t: "albums", l: "Immich albums", when: (c) => c.bgSource === "immich" }, { k: "bgInterval", t: "number", l: "Change photo every (seconds)", d: 60, when: (c) => c.bgSource && c.bgSource !== "none" }, { k: "dim", t: "number", l: "Darken the image (%)", d: 30, when: (c) => c.bgSource && c.bgSource !== "none" }, ], entities: (c) => (c.weather ? [c.weather] : []), render(c, { ha }) { const s = c.weather && ha.states[c.weather]; const bg = c.bgSource === "links" ? urlList({ urls: c.bgUrls }).length > 0 : c.bgSource === "immich" && c.bgAlbums?.length > 0; const pos = POSITIONS.includes(c.pos) ? c.pos : "center"; const wx = s ? `` : ""; return `
${bg ? `` : ""} ${bg ? `
` : ""}
${clockHTML(c.h24, c.seconds, c.date)}
${wx}
`; }, async after(el, c, ctx) { const box = el.querySelector(".clock-bg"); if (box) slideshow(el, box, ctx, (ctx.cache.bg ||= {}), { source: c.bgSource, urls: c.bgUrls, albums: c.bgAlbums, shuffle: true, interval: c.bgInterval || 60 }); if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return; const cache = ctx.cache; if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) { cache.key = c.weather; cache.at = Date.now(); cache.loading = ctx.ha.forecast(c.weather, "daily").then((f) => (cache.today = f[0])).catch(() => (cache.today = null)); } await cache.loading; const hl = el.querySelector("[data-hl]"); const f = cache.today; if (hl && f && f.temperature != null) hl.textContent = f.templow != null ? t("High {0}° · Low {1}°", Math.round(f.temperature), Math.round(f.templow)) : t("High {0}°", Math.round(f.temperature)); }, }; function clockHTML(h24, sec, date) { const d = new Date(); const time = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit", second: sec ? "2-digit" : undefined, hour12: !h24 }); return `
${time}
${date ? `
${d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" })}
` : ""}`; } W.weather = { label: "Weather", icon: "cloud-sun", size: [4, 2], fields: [{ k: "entity", t: "entity", l: "Weather", domains: ["weather"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }], render(c, { ha }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a weather entity"); const a = s.attributes; return ``; }, }; W.list = { label: "Entity list", icon: "list", size: [4, 4], fields: [{ k: "title", t: "text", l: "Title", d: "Devices" }, { k: "entities", t: "entities", l: "Entities" }], entities: (c) => c.entities || [], render(c, { ha }) { if (!c.entities?.length) return `${title(c.title)}
${t("Add entities in the inspector.")}
`; const rows = c.entities.map((eid) => { const s = ha.states[eid]; if (!s) return `
${Icons.svg("pulse")}${esc(eid)}
`; const sw = ["light", "switch", "fan", "input_boolean", "automation"].includes(domainOf(eid)); // Switches belong in list rows, so this is the one place we draw them. return sw ? `` : ``; }).join(""); return `${title(c.title)}
${rows}
`; }, }; W.button = { label: "Button", icon: "button", size: [2, 2], fields: [ { k: "label", t: "text", l: "Label", d: "Run" }, { k: "icon", t: "icon", l: "Icon", d: "sparkles" }, { k: "entity", t: "entity", l: "Target entity (optional)" }, { k: "service", t: "text", l: "Action (e.g. scene.turn_on)", d: "" }, { k: "data", t: "json", l: "Action data (JSON)", d: "" }, ], render: (c) => ``, }; // ---------------------------------------------------------------- media (one or more players) const mediaIds = (c) => (c.entities?.length ? c.entities : c.entity ? [c.entity] : []); const ACTIVE = ["playing", "paused", "buffering", "on", "idle"]; /** Which player the card shows: the one picked, else whatever is playing, else the first that's on. */ function mediaSelected(ids, ha, cache) { if (cache.sel && ids.includes(cache.sel)) return cache.sel; return ids.find((e) => ha.states[e]?.state === "playing") || ids.find((e) => ha.states[e]?.state === "paused") || ids.find((e) => ACTIVE.includes(ha.states[e]?.state)) || ids[0]; } W.media = { label: "Media players", icon: "music", size: [4, 3], fields: [ { k: "entities", t: "entities", l: "Media players", domains: ["media_player"] }, { k: "art", t: "check", l: "Show album art", d: true }, { k: "hideOff", t: "check", l: "Hide players that are off from the switcher", d: false }, ], entities: mediaIds, render(c, { ha, cache }) { let ids = mediaIds(c).filter((e) => ha.states[e]); if (!ids.length) return missing(mediaIds(c)[0], "a media player"); const sel = mediaSelected(ids, ha, cache); if (c.hideOff) ids = ids.filter((e) => e === sel || ha.states[e].state !== "off"); const s = ha.states[sel], a = s.attributes, e = esc(sel); const playing = s.state === "playing", off = ["off", "standby", "unavailable"].includes(s.state); const bars = ``; const switcher = ids.length > 1 ? `
${ids.map((id) => { const p = ha.states[id], on = p.state === "playing"; return ``; }).join("")}
` : ""; const pic = c.art !== false && a.entity_picture && !off ? `` : ``; const title = off ? st(s.state) : a.media_title || a.app_name || a.source || st(s.state); const sub = off ? "" : [a.media_artist, a.media_album_name].filter(Boolean).join(" · ") || (a.media_title ? "" : st(s.state)); const controls = off ? `` : ` ${a.volume_level != null ? `` : ""}`; return `
${switcher}
${pic}
${ids.length === 1 ? `
${esc(friendly(s))}
` : ""}
${esc(title)}
${sub ? `
${esc(sub)}
` : ""}
${controls}
`; }, lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false), }; // ---------------------------------------------------------------- to-do / shopping list // Items come from todo/item/list; adding, ticking off and deleting use the todo actions (handled in app.js). W.todo = { label: "To-do list", icon: "checkbox", size: [4, 4], fields: [ { k: "entity", t: "entity", l: "List", domains: ["todo"] }, { k: "title", t: "text", l: "Title", ph: "List name" }, { k: "showDone", t: "check", l: "Show completed items", d: false }, ], render(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a to-do list"); const items = cache.items || []; const open = items.filter((i) => i.status !== "completed"), done = items.filter((i) => i.status === "completed"); const row = (i) => `
${esc(i.summary)}${i.due ? `${esc(new Date(i.due).toLocaleDateString(LOCALE, { day: "numeric", month: "short" }))}` : ""}
`; const showDone = cache.showDone ?? c.showDone; return `
${esc(c.title || friendly(s))}${open.length}
${cache.items ? open.map(row).join("") || `
${t("Nothing on the list.")}
` : `
${t("Loading…")}
`} ${done.length ? `${showDone ? done.map(row).join("") : ""}` : ""}
`; }, async after(el, c, ctx) { const s = ctx.ha.states[c.entity]; if (!s) return; // The list's state is the number of open items; refetch when it (or the card) changes. const key = `${c.entity}|${s.last_updated}|${ctx.cache.bump || 0}`; if (ctx.cache.key !== key) { ctx.cache.key = key; try { ctx.cache.items = (await ctx.ha.send({ type: "todo/item/list", entity_id: c.entity }))?.items || []; } catch { ctx.cache.items = ctx.cache.items || []; } ctx.rerender(); return; } // Swipe a row to the left to delete it (touch); the ✕ does the same with a mouse. el.querySelectorAll(".todo-row").forEach((r) => { let x0 = null, dx = 0; r.addEventListener("pointerdown", (e) => { if (e.pointerType !== "mouse" && !e.target.closest("button")) { x0 = e.clientX; dx = 0; } }); r.addEventListener("pointermove", (e) => { if (x0 == null) return; dx = Math.min(0, e.clientX - x0); r.style.transform = `translateX(${dx}px)`; r.style.opacity = String(1 + dx / 300); }); const end = () => { if (x0 == null) return; x0 = null; if (dx < -90) r.querySelector(".todo-del")?.click(); else { r.style.transform = ""; r.style.opacity = ""; } }; r.addEventListener("pointerup", end); r.addEventListener("pointercancel", end); }); }, }; // ---------------------------------------------------------------- who's home W.people = { label: "Who's home", icon: "people", size: [4, 2], fields: [{ k: "entities", t: "entities", l: "People (leave empty for everyone)", domains: ["person"] }, { k: "layout", t: "seg", l: "Layout", o: ["grid", "list"], d: "grid" }], entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("person."))), render(c, { ha }) { const ids = (c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => e.startsWith("person."))).filter((e) => ha.states[e]); if (!ids.length) return `
${t("No people found. Add person entities in Home Assistant.")}
`; const ppl = ids.map((e) => { const s = ha.states[e], home = s.state === "home", away = s.state === "not_home"; const where = home ? t("Home") : away ? t("Away") : st(s.state); const name = friendly(s), initials = name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase(); const pic = s.attributes.entity_picture ? `` : `${esc(initials)}`; return ``; }).join(""); return `
${ppl}
`; }, }; // ---------------------------------------------------------------- weather forecast W.forecast = { label: "Weather forecast", icon: "umbrella", size: [6, 4], fields: [ { k: "entity", t: "entity", l: "Weather", domains: ["weather"] }, { k: "show", t: "seg", l: "Show", o: ["both", "hourly", "daily"], d: "both" }, { k: "hours", t: "number", l: "Hours ahead", d: 12 }, { k: "days", t: "number", l: "Days ahead", d: 5 }, { k: "sun", t: "check", l: "Show sunrise and sunset", d: true }, ], entities: (c) => [c.entity, "sun.sun"].filter(Boolean), render(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a weather entity"); const a = s.attributes, unit = a.temperature_unit || "°"; const deg = (v) => (v == null ? "–" : `${Math.round(v)}°`); const today = cache.daily?.[0]; const sun = ha.states["sun.sun"]?.attributes; const hm = (iso) => new Date(iso).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); const hourly = c.show !== "daily" && cache.hourly ? `
${cache.hourly.slice(0, Math.max(3, Number(c.hours) || 12)).map((h) => `
${esc(new Date(h.datetime).toLocaleTimeString(LOCALE, { hour: "2-digit" }))}${Icons.svg(WX[h.condition] || "cloud")}${deg(h.temperature)}${h.precipitation_probability ? `${Math.round(h.precipitation_probability)}%` : ``}
`).join("")}
` : ""; let daily = ""; if (c.show !== "hourly" && cache.daily) { const days = cache.daily.slice(0, Math.max(2, Number(c.days) || 5)); const lo = Math.min(...days.map((d) => d.templow ?? d.temperature)), hi = Math.max(...days.map((d) => d.temperature)); daily = `
${days.map((d, i) => { const l = d.templow ?? d.temperature, left = ((l - lo) / (hi - lo || 1)) * 100, right = ((d.temperature - lo) / (hi - lo || 1)) * 100; return `
${i === 0 ? t("Today") : esc(new Date(d.datetime).toLocaleDateString(LOCALE, { weekday: "short" }))}${Icons.svg(WX[d.condition] || "cloud")} ${d.precipitation_probability ? `${Math.round(d.precipitation_probability)}%` : ""}${deg(l)} ${deg(d.temperature)}
`; }).join("")}
`; } return `
${hourly}${daily}${!cache.daily && !cache.hourly ? `
${t("Loading the forecast…")}
` : ""}
`; }, async after(el, c, ctx) { if (!ctx.ha.states[c.entity]) return; const cache = ctx.cache; if (cache.key === c.entity && Date.now() - cache.at < 30 * 60 * 1000) return; cache.key = c.entity; cache.at = Date.now(); const [daily, hourly] = await Promise.all([ctx.ha.forecast(c.entity, "daily").catch(() => null), ctx.ha.forecast(c.entity, "hourly").catch(() => null)]); cache.daily = daily; cache.hourly = hourly; ctx.rerender(); }, }; // ---------------------------------------------------------------- timer // Counts down in the browser from finishes_at (ticked by app.js every second); presets start it with a duration. const hms = (secs) => { secs = Math.max(0, Math.round(secs)); const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), x = secs % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(x).padStart(2, "0")}` : `${m}:${String(x).padStart(2, "0")}`; }; const toSecs = (v) => { const p = String(v || "0").split(":").map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p.length === 2 ? p[0] * 60 + p[1] : p[0]; }; W.timer = { label: "Timer", icon: "timer", size: [3, 3], fields: [ { k: "entity", t: "entity", l: "Timer", domains: ["timer"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }, { k: "presets", t: "text", l: "Quick starts in minutes", d: "5, 10, 15, 30" }, { k: "sound", t: "check", l: "Beep when done", d: true }, ], render(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a timer"); const a = s.attributes, e = esc(c.entity); const dur = toSecs(a.duration); // Spot the moment it runs out: active → idle around the finish time. if (cache.prev === "active" && s.state === "idle" && cache.finish && Date.now() >= cache.finish - 3000) { cache.doneUntil = Date.now() + 60000; if (c.sound !== false) window.HAFmt.beep?.(); } cache.prev = s.state; cache.finish = a.finishes_at ? new Date(a.finishes_at).getTime() : null; const done = cache.doneUntil && Date.now() < cache.doneUntil && s.state === "idle"; const left = s.state === "active" && a.finishes_at ? (new Date(a.finishes_at) - Date.now()) / 1000 : s.state === "paused" ? toSecs(a.remaining) : dur; const R = 44, C = 2 * Math.PI * R, frac = dur ? Math.max(0, Math.min(1, left / dur)) : 0; const presets = String(c.presets || "").split(/[,\s]+/).map(Number).filter((m) => m > 0).slice(0, 6); const ctl = (svc, ico, lbl, data) => ``; return `
${esc(friendly(s, c.name))}
${done ? t("Done!") : hms(left)}
${done ? `` : s.state === "active" ? ctl("pause", "pause", t("Pause")) + ctl("cancel", "stop", t("Stop")) : s.state === "paused" ? ctl("start", "play", t("Resume")) + ctl("cancel", "stop", t("Stop")) : presets.map((m) => ``).join("") + (dur ? ctl("start", "play", t("Start")) : "")}
`; }, lit: (c, { ha }) => (ha.states[c.entity]?.state === "active" ? "on" : false), }; // ---------------------------------------------------------------- blinds / covers const coverPos = (s) => (s.attributes.current_position != null ? Number(s.attributes.current_position) : s.state === "open" ? 100 : s.state === "closed" ? 0 : 50); W.covers = { label: "Blinds", icon: "blinds", size: [4, 3], fields: [{ k: "title", t: "text", l: "Title", ph: "e.g. Living room" }, { k: "entities", t: "entities", l: "Blinds, curtains or shutters", domains: ["cover"] }], entities: (c) => c.entities || [], render(c, { ha }) { const ids = (c.entities || []).filter((e) => ha.states[e]); if (!ids.length) return `
${t("Add one or more blinds in the inspector.")}
`; const btns = (e, s) => `
`; const slider = (e, s, kind = "pos") => { const v = kind === "pos" ? coverPos(s) : Number(s.attributes.current_tilt_position ?? 0); return ``; }; // a window whose blind comes down as the cover closes const win = (s) => { const closed = 100 - coverPos(s); return ``; }; const moving = (s) => (["opening", "closing"].includes(s.state) ? ` · ${st(s.state)}` : ""); if (ids.length === 1) { const e = ids[0], s = ha.states[e]; return `
${c.title ? `
${esc(c.title)}
` : ""}
${win(s)}
${esc(friendly(s))}${coverPos(s)}%${esc(moving(s))}${btns(e, s)}
${slider(e, s)}${s.attributes.current_tilt_position != null ? `
${t("Tilt")}${slider(e, s, "tilt")}
` : ""}
`; } return `
${esc(c.title || t("Blinds"))}
${ids.map((e) => { const s = ha.states[e]; return `
${win(s)}
${esc(friendly(s))} ${coverPos(s)}%${esc(moving(s))}${slider(e, s)}
${btns(e, s)}
`; }).join("")}
`; }, }; // ---------------------------------------------------------------- alarm const ALARM_FEATURES = [["armed_home", 1, "alarm_arm_home", "Home"], ["armed_away", 2, "alarm_arm_away", "Away"], ["armed_night", 4, "alarm_arm_night", "Night"], ["armed_vacation", 32, "alarm_arm_vacation", "Vacation"]]; const isOpening = (s) => s.entity_id.startsWith("binary_sensor.") && ["door", "window", "opening", "garage_door"].includes(s.attributes.device_class); W.alarm = { label: "Alarm", icon: "shield", size: [4, 5], fields: [{ k: "entity", t: "entity", l: "Alarm panel", domains: ["alarm_control_panel"] }, { k: "openings", t: "entities", l: "Doors and windows to check (leave empty for all)", domains: ["binary_sensor"] }], entities: (c, ha) => [c.entity, ...(c.openings?.length ? c.openings : Object.values(ha?.states || {}).filter(isOpening).map((s) => s.entity_id))].filter(Boolean), render(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "an alarm panel"); const a = s.attributes, f = Number(a.supported_features) || 7, armed = s.state.startsWith("armed"), alarm = s.state === "triggered"; const open = (c.openings?.length ? c.openings.map((e) => ha.states[e]).filter(Boolean) : Object.values(ha.states).filter(isOpening)).filter((o) => o.state === "on"); const label = { disarmed: t("Off"), armed_home: t("On · home"), armed_away: t("On · away"), armed_night: t("On · night"), armed_vacation: t("On · vacation"), arming: t("Turning on…"), pending: t("Entry delay…"), triggered: t("Alarm!") }[s.state] || st(s.state); const code = cache.code || ""; const needsCode = !!a.code_format; const modes = ALARM_FEATURES.filter(([, bit]) => f & bit).map(([st_, , svc, lbl]) => ``).join(""); const keys = needsCode ? `
${code ? "•".repeat(code.length) : `${t("Enter the code")}`}
${["1", "2", "3", "4", "5", "6", "7", "8", "9", "C", "0", "⌫"].map((k) => ``).join("")}
` : ""; return `
${Icons.svg("shield")}${esc(friendly(s))}${esc(label)}
${open.length && !armed ? `` : ""} ${keys}
${armed || alarm || s.state === "arming" || s.state === "pending" ? `` : modes}
`; }, lit: (c, { ha }) => (ha.states[c.entity]?.state === "triggered" ? "on" : false), }; // ---------------------------------------------------------------- notifications // Home Assistant's notifications (kept up to date by a subscription in app.js) and available updates. const mdLite = (txt) => esc(txt).replace(/\*\*(.+?)\*\*/g, "$1").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]*)\)/g, '$1').replace(/\n/g, "
"); W.notifications = { label: "Notifications", icon: "bell", size: [4, 4], fields: [{ k: "notes", t: "check", l: "Show notifications", d: true }, { k: "updates", t: "check", l: "Show available updates", d: true }], entities: (c, ha) => ["__notifications", ...Object.keys(ha?.states || {}).filter((e) => e.startsWith("update."))], render(c, { ha }) { const notes = c.notes !== false ? Object.values(ha.notifications || {}).sort((x, y) => String(y.created_at).localeCompare(String(x.created_at))) : []; const updates = c.updates !== false ? Object.values(ha.states).filter((s) => s.entity_id.startsWith("update.") && s.state === "on") : []; const count = notes.length + updates.length; const items = [ ...updates.map((u) => `
${Icons.svg("sparkles")}
${esc(u.attributes.title || friendly(u))} ${esc(t("{0} → {1}", u.attributes.installed_version || "?", u.attributes.latest_version || "?"))} ${u.attributes.release_url ? `${t("What’s new")}` : ""}
`), ...notes.map((n) => `
${Icons.svg("bell")}
${n.title ? `${esc(n.title)}` : ""}${mdLite(n.message || "")} ${esc(relTime(n.created_at))}
`), ].join(""); return `
${t("Notifications")}${count ? `${count}` : ""} ${notes.length > 1 ? `` : ""}
${items || `
${Icons.svg("check")}${t("All caught up")}
`}
`; }, }; // ---------------------------------------------------------------- energy // Live power flow between solar, grid, battery and home. Lines are as thick and as fast as the power. function watts(s) { const v = Number(s?.state); if (!isFinite(v)) return null; const u = String(s.attributes.unit_of_measurement || "W").toLowerCase(); return u === "kw" ? v * 1000 : u === "mw" ? v * 1e6 : v; } const fmtW = (w) => (Math.abs(w) >= 1000 ? `${(w / 1000).toLocaleString(LOCALE, { maximumFractionDigits: 1 })} kW` : `${Math.round(w).toLocaleString(LOCALE)} W`); W.energy = { label: "Energy", icon: "bolt", size: [4, 4], fields: [ { k: "solar", t: "entity", l: "Solar power", domains: ["sensor"] }, { k: "grid", t: "entity", l: "Grid power (positive = from the grid)", domains: ["sensor"] }, { k: "invertGrid", t: "check", l: "My grid sensor is positive when sending back", d: false }, { k: "battery", t: "entity", l: "Battery power (positive = charging)", domains: ["sensor"] }, { k: "invertBattery", t: "check", l: "My battery sensor is positive when discharging", d: false }, { k: "batteryLevel", t: "entity", l: "Battery level (%)", domains: ["sensor"] }, { k: "home", t: "entity", l: "Home use (optional, otherwise calculated)", domains: ["sensor"] }, { k: "today", t: "entities", l: "Totals for today, e.g. kWh produced and used", domains: ["sensor"] }, ], entities: (c) => [c.solar, c.grid, c.battery, c.batteryLevel, c.home, ...(c.today || [])].filter(Boolean), render(c, { ha }) { if (!c.solar && !c.grid && !c.battery) return `
${t("Choose your solar, grid or battery power sensors in the inspector.")}
`; const S = (k) => (c[k] ? ha.states[c[k]] : null); const solar = Math.max(0, watts(S("solar")) ?? 0); let grid = watts(S("grid")) ?? 0; if (c.invertGrid) grid = -grid; let batt = watts(S("battery")) ?? 0; if (c.invertBattery) batt = -batt; const imp = Math.max(0, grid), exp = Math.max(0, -grid), charge = Math.max(0, batt), discharge = Math.max(0, -batt); const home = Math.max(0, watts(S("home")) ?? solar + imp + discharge - exp - charge); // Who feeds what: solar goes to the home first, then the battery, the rest to the grid. const sToH = Math.min(solar, home), sRest = solar - sToH, sToB = Math.min(sRest, charge), sToG = Math.max(0, sRest - sToB); const gToH = Math.min(imp, Math.max(0, home - sToH - discharge)), gToB = Math.max(0, charge - sToB), bToH = Math.min(discharge, Math.max(0, home - sToH)); const has = { solar: !!c.solar, grid: !!c.grid, battery: !!c.battery }; const lvl = Number(S("batteryLevel")?.state); // Where the power is going now: the four places in one row, then each flow that runs, largest first. // They balance: sun + grid in + battery out = house + battery in + grid out. const fig = (w) => { const [n, u] = fmtW(w).split(" "); return `${esc(n)}${esc(u || "")}`; }; const node = (key, ico, label, figure, sub) => `
${Icons.svg(ico)}${esc(label)}${figure}${esc(sub)}
`; const nodes = [ has.solar ? node("sun", "sun", t("Sun"), fig(solar), solar > 1 ? t("Producing") : t("Not producing")) : "", node("home", "home", t("House"), fig(home), t("In use")), has.battery ? node("battery", "battery", t("Battery"), isFinite(lvl) ? `${Math.round(lvl)}%` : fig(Math.abs(batt)), charge > 1 ? t("Charging {0}", fmtW(charge)) : discharge > 1 ? t("Supplying {0}", fmtW(discharge)) : t("Idle")) : "", has.grid ? node("grid", "plug", t("Grid"), fig(exp > imp ? exp : imp), exp > imp ? t("Sending back") : imp > 1 ? t("Drawing") : t("Idle")) : "", ].filter(Boolean); const flows = [ ["sun", t("Sun to the house"), sToH], ["sun", t("Sun to the battery"), sToB], ["sun", t("Sun to the grid"), sToG], ["grid", t("Grid to the house"), gToH], ["grid", t("Grid to the battery"), gToB], ["battery", t("Battery to the house"), bToH], ].filter(([, , w]) => w > 1).sort((a, b) => b[2] - a[2]); const top = Math.max(1, ...flows.map(([, , w]) => w)); const totals = (c.today || []).map((e) => ha.states[e] && `${esc(fmtState(ha.states[e]))} ${esc(friendly(ha.states[e]))}`).filter(Boolean).join(""); return `
${t("Energy now")}
${nodes.join("")}
${flows.length ? flows.map(([k, label, w]) => `
${esc(label)}${esc(fmtW(w))}
`).join("") : `
${t("No power is flowing right now.")}
`}
${totals ? `
${totals}
` : ""}
`; }, }; // ---------------------------------------------------------------- room // Everything in one Home Assistant area at a glance: climate, what's on or open, and quick controls. function roomEntities(c, ha) { const ids = [...(ha?.reg?.byArea?.[c.area] || []), ...(c.entities || [])], hidden = new Set(c.hidden || []); return [...new Set(ids)].filter((e) => ha?.states?.[e] && !hidden.has(e)); } const TOGGLES = ["light", "switch", "fan", "input_boolean", "cover", "lock"]; W.room = { label: "Room", icon: "sofa", size: [4, 4], fields: [ { k: "area", t: "area", l: "Room" }, { k: "name", t: "text", l: "Name", ph: "Room name" }, { k: "icon", t: "icon", l: "Icon", d: "sofa" }, { k: "temperature", t: "entity", l: "Temperature sensor", domains: ["sensor"], ph: "Automatic" }, { k: "humidity", t: "entity", l: "Humidity sensor", domains: ["sensor"] }, { k: "entities", t: "entities", l: "Extra entities (or all of them, without a room)" }, { k: "devices", t: "check", l: "Show devices", d: true }, { k: "hidden", t: "entities", l: "Hide these entities" }, { k: "image", t: "text", l: "Background image URL (optional)", ph: "/local/living-room.jpg" }, ], entities: (c, ha) => [...roomEntities(c, ha), c.temperature, c.humidity].filter(Boolean), render(c, { ha }) { const area = ha.reg?.areas?.find((a) => a.area_id === c.area); const ids = roomEntities(c, ha); if (!area && !ids.length) return `
${t("Choose a room or add entities in the inspector.")}
`; const S = (e) => ha.states[e], dom = (e) => domainOf(e), dc = (e) => S(e).attributes.device_class; const name = c.name || area?.name || t("Room"); const pick = (explicit, fromArea, cls) => (explicit && S(explicit) ? explicit : fromArea && S(fromArea) ? fromArea : ids.find((e) => dom(e) === "sensor" && dc(e) === cls)); const temp = pick(c.temperature, area?.temperature_entity_id, "temperature"); const hum = pick(c.humidity, area?.humidity_entity_id, "humidity"); // Count and show the actual lamps: leave out light groups, whose lamps are already listed. const isGroup = (e) => Array.isArray(S(e).attributes.entity_id); const lights = ids.filter((e) => dom(e) === "light" && !isGroup(e)); const lightsOn = lights.filter((e) => S(e).state === "on"); const open = ids.filter((e) => dom(e) === "binary_sensor" && ["door", "window", "opening", "garage_door"].includes(dc(e)) && S(e).state === "on"); const motion = ids.filter((e) => dom(e) === "binary_sensor" && ["motion", "occupancy", "presence"].includes(dc(e)) && S(e).state === "on"); const playing = ids.filter((e) => dom(e) === "media_player" && S(e).state === "playing"); const climate = ids.find((e) => dom(e) === "climate"); // "Living Room Lamp" → "Lamp" inside the Living Room card const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"); const short = (e) => friendly(S(e)).replace(prefix, "") || friendly(S(e)); // What is going on, in one line: lights, motion, what's playing. Open doors and windows get their own amber line. const said = [ lights.length ? (lightsOn.length ? t("{0} of {1} lights on", lightsOn.length, lights.length) : t("Lights off")) : "", motion.length ? t("Motion") : "", ...playing.map((e) => S(e).attributes.media_title ? t("Playing {0}", S(e).attributes.media_title) : t("Playing")), ].filter(Boolean).join(" · "); const cs = climate && S(climate); const fig = (e, unitFallback) => { const v = S(e); const n = Number(v.state); return isFinite(n) ? `${Math.round(n * 10) / 10}${esc(v.attributes.unit_of_measurement || unitFallback)}` : esc(fmtState(v)); }; const devices = c.devices !== false ? ids.filter((e) => TOGGLES.includes(dom(e)) && !isGroup(e)).slice(0, 12) : []; const tiles = devices.map((e) => { const v = S(e), on = isOn(v), light = dom(e) === "light", pct = light ? pctOf(v) : 0; const state = light ? bigText(v) : fmtState(v); return ``; }).join(""); return `
${icon(null, c.icon || "sofa")} ${esc(name)}${said ? `${esc(said)}` : ""} ${lights.length ? `` : ""}
${temp || hum || cs ? `
${temp ? `${fig(temp, "°")}` : ""} ${hum ? `${Icons.svg("droplet")}${fig(hum, "%")}` : ""} ${cs ? `` : ""}
` : ""} ${open.length ? `
${Icons.svg("door-open")}${esc(open.map((e) => short(e)).join(", "))} ${open.length > 1 ? t("are open") : t("is open")}
` : ""} ${tiles ? `
${tiles}
` : ""}
`; }, // The room keeps the theme's surface: its lamps glow on their own tiles instead. lit: () => false, }; // ---------------------------------------------------------------- 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). /** A robot vacuum seen from above beside its dock, placed and drawn by its state. */ function vacScene(state, charging) { const docked = state === "docked", cleaning = state === "cleaning", returning = state === "returning", error = state === "error"; const x = docked ? 196 : returning ? 150 : cleaning ? 92 : 124; const trail = cleaning || returning ? `` : ""; return ` ${trail} `; } W.vacuum = { 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, 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), 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 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); // the robot's own status only when it adds something (an error, a paused job), not "Returning home" twice const detail = a.status && !["cleaning", "returning", "docked"].includes(s.state) && !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 : []; // The robot's own sensors: life left in percent goes under Maintenance, the rest with the last clean. const S = (x) => ha.states[x]; const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"); const label = (x) => cap(friendly(S(x)).replace(prefix, "").replace(/\s+(left|life|remaining)$/i, "")); const extra = (c.entities || []).filter((x) => S(x)); const maint = extra.filter((x) => S(x).attributes.unit_of_measurement === "%" && isFinite(Number(S(x).state))); const facts = [ c.area && S(c.area) ? [t("Cleaned"), fmtState(S(c.area))] : null, c.duration && S(c.duration) ? [t("Time"), fmtState(S(c.duration))] : null, ...extra.filter((x) => !maint.includes(x)).map((x) => [label(x), fmtState(S(x))]), ].filter(Boolean); const btn = (svc, label, ico, primary, disabled) => ``; return `
${esc(name)}${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}
${hasBatt ? `
${Math.round(batt)}%
` : ""}
${mapUrl ? `${t(` : vacScene(s.state, charging)}
${facts.length ? `
${facts.map(([l, v]) => `
${esc(v)}${esc(l)}
`).join("")}
` : ""} ${maint.length ? `
${maint.map((x) => { const v = Math.max(0, Math.min(100, Number(S(x).state))); return `
${esc(label(x))}${Math.round(v)}%
`; }).join("")}
` : ""} ${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), }; // ---------------------------------------------------------------- appliance (washer, dryer, dishwasher) // Works with any integration: a status entity, and/or a power sensor above a threshold, // plus optional remaining time (minutes, hours or an end timestamp), progress and program. const RUNNING_RE = /run|wash|dry|rins|spin|heat|clean|activ|program|bezig|draait|centrifug|spoel|droog|^on$|^true$/; const DONE_RE = /finish|end|done|complete|klaar|gereed|beëindigd/; const PAUSE_RE = /pause|gepauzeerd/; // Home Connect's operation states, matched exactly first (https://www.home-assistant.io/integrations/home_connect/). // "ready" means ready to start and "inactive" switched off: both count as off. const HC_STATES = { run: "running", pause: "paused", finished: "done", delayedstart: "scheduled", actionrequired: "attention", error: "error", aborting: "stopping", inactive: "off", ready: "off" }; function appliancePhase(c, ha) { const status = String(ha.states[c.status]?.state || "").toLowerCase(); if (status && !["unavailable", "unknown"].includes(status)) { if (HC_STATES[status]) return HC_STATES[status]; if (/^(off|idle|standby|none)$/.test(status)) return "off"; if (/delayed|uitgesteld|scheduled/.test(status)) return "scheduled"; if (/error|fault|fout/.test(status)) return "error"; if (DONE_RE.test(status)) return "done"; if (PAUSE_RE.test(status)) return "paused"; if (RUNNING_RE.test(status)) return "running"; } const power = Number(ha.states[c.power]?.state); if (c.power && isFinite(power) && power > (Number(c.threshold) || 5)) return "running"; const left = remainingMinutes(c, ha); if (!c.status && !c.power && left > 0) return "running"; return "off"; } function remainingMinutes(c, ha) { const s = ha.states[c.remaining]; if (!s) return null; if (/^\d{4}-\d{2}-\d{2}T/.test(s.state)) return Math.max(0, Math.round((new Date(s.state) - Date.now()) / 60000)); const n = Number(s.state); if (!isFinite(n)) return null; const unit = String(s.attributes.unit_of_measurement || "min").toLowerCase(); return Math.round(unit.startsWith("h") ? n * 60 : unit === "s" ? n / 60 : n); } const fmtDuration = (m) => (m >= 60 ? t("{0} h {1} min", Math.floor(m / 60), m % 60) : t("{0} min", m)); // "dishcare_dishwasher_program_eco_50" → "Eco 50" (Home Connect program keys); other values are shown as they are. const prettyProgram = (v) => { const s = String(v); 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. // An alert is on when it says so ("on", "true") or names the trouble ("full", "fault"), but not "not_full". const litterAlert = (v) => { const s = String(v ?? "").toLowerCase(); return ALERT_ON.has(s) || (/full|vol|fault|error|fout|alarm|problem/.test(s) && !/^(not|no|niet)[_ ]/.test(s)); }; function litterPhase(c, ha) { const s = String(ha.states[c.status]?.state || "").toLowerCase(); const alerts = (c.alerts || []).filter((e) => litterAlert(ha.states[e]?.state)); 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 }; // name the trouble when an alert says what it is (a full bin), else a general word if (alerts.length) { const full = alerts.some((e) => /full|vol|bag|bin|waste|afval/.test(`${e} ${friendly(ha.states[e])}`.toLowerCase())); return { phase: "attention", word: full ? t("Bin full") : t("Check the litter box"), alerts }; } return { phase: "ready", word: /^(standby|idle|ready|on|off|none|)$/.test(s) ? t("Ready") : st(s), alerts }; } // Compact: icon, name and controls on top, what it's doing below, today's figures as one short line. // One row high it all goes on one line; tall cards put a ring on top (see the container queries in style.css). function litterBox(c, ha) { if (!c.status) return `
${t("Choose the litter box’s status entity in the inspector.")}
`; const { phase, word, alerts } = litterPhase(c, ha); const val = (eid) => { const s = ha.states[eid]; return s && !["unknown", "unavailable"].includes(s.state) ? s : null; }; const fig = (v, unit, title) => `${v} ${esc(unit)}`; const visits = val(c.visits), time = val(c.timeToday), weight = val(c.catWeight); const figs = [ visits && isFinite(Number(visits.state)) && fig(Math.round(Number(visits.state)), Number(visits.state) === 1 ? t("visit") : t("visits"), t("Visits today")), // time today: Tuya reports it in seconds or minutes, shown as minutes time && isFinite(Number(time.state)) && (() => { const n = Number(time.state), unit = String(time.attributes.unit_of_measurement || "min").toLowerCase(); return fig(Math.round(unit.startsWith("s") ? n / 60 : unit.startsWith("h") ? n * 60 : n), t("min"), t("Time in the box today")); })(), weight && fig(isFinite(Number(weight.state)) ? Number(weight.state).toLocaleString(LOCALE, { maximumFractionDigits: 1 }) : esc(weight.state), weight.attributes.unit_of_measurement || "", t("Cat’s weight")), ].filter(Boolean).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) => `${Icons.svg("shield")}${esc(cap(strip(friendly(ha.states[e]))))}`).join(""); const clean = c.cleanBtn && ha.states[c.cleanBtn] ? `` : ""; const auto = ha.states[c.autoClean]; const autoSw = auto ? `${t("Auto")}` : ""; const ico = phase === "attention" ? "shield" : "paw"; return `
${esc(c.name || t("Litter box"))} ${clean || autoSw ? `
${autoSw}${clean}
` : ""} ${esc(word)} ${figs ? `
${figs}
` : ""} ${warn ? `
${warn}
` : ""}
`; } 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", "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 }, { k: "onlyAttention", t: "check", l: "Only show when the bin is full or something is wrong", d: true, when: litter }, ], // Picking the status fills in the other entities of the same device (Home Connect names them predictably). autofill: { from: "status", fill(eid, ha) { const dev = ha.reg?.deviceOf?.[eid]; 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|bag|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$/), pauseBtn: find("button", /pause/), resumeBtn: find("button", /resume/), stopBtn: find("button", /stop|abort/), powerSwitch: find("switch", /_power$/), energy: find("sensor", /energy|water/), alerts: sibs.filter((e) => /salt|rinse_aid|i_dos|poor_|machine_care/.test(e)), kind: /dish/.test(eid) ? "dishwasher" : /dryer|droger/.test(eid) && !/washer_dryer/.test(eid) ? "dryer" : undefined, }; }, }, 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 // A litter box only shows when its bin is full or something is wrong (unless that option is off); // other appliances while running or finished, or while an alert is on. autoHide: (c, { ha }) => (c.kind === "litterbox" ? c.onlyAttention !== false && !!c.status && litterPhase(c, ha).phase !== "attention" : !!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 `
${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}
`; 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 active = ["running", "paused", "scheduled", "attention"].includes(phase); const left = active ? remainingMinutes(c, ha) : null; // Progress from the integration, or worked out from the time left (relative to the most seen this run). if (phase === "off") cache.maxLeft = 0; if (left != null && phase !== "scheduled") cache.maxLeft = Math.max(cache.maxLeft || 0, left); let prog = Number(ha.states[c.progress]?.state); if (!isFinite(prog) || !c.progress) prog = left != null && cache.maxLeft > 0 && phase !== "scheduled" ? 100 * (1 - left / cache.maxLeft) : NaN; if (phase === "done") prog = 100; const program = ha.states[c.program]?.state; const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase()); const ends = left != null ? new Date(Date.now() + left * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : ""; const ico = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer"; const ringIco = phase === "done" ? "check" : phase === "attention" || phase === "error" ? "shield" : ico; const R = 44, C = 2 * Math.PI * R, frac = isFinite(prog) ? Math.max(0, Math.min(1, prog / 100)) : 0; const showRing = isFinite(prog) && phase !== "off"; const ring = `
${Icons.svg(ringIco)} ${isFinite(prog) && phase === "running" ? `${Math.round(prog)}%` : ""}
`; // Door: Home Connect's door state is open / closed / locked; a binary sensor is on when open. const door = ha.states[c.door], energy = ha.states[c.energy], power = ha.states[c.power]; const doorState = door ? (domainOf(c.door) === "binary_sensor" ? (isOn(door) ? "open" : "closed") : String(door.state).toLowerCase()) : null; const alerts = (c.alerts || []).filter((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase())); // Alert names start with the device's name ("Dishwasher Rinse aid nearly empty"); take that from the status entity. const device = friendly(ha.states[c.status]).replace(/\s*(operation state|bedieningsstatus|status|state)$/i, ""); const strip = (txt) => (device && txt.toLowerCase().startsWith(device.toLowerCase() + " ") ? txt.slice(device.length + 1) : txt); const facts = [ ...alerts.map((e) => `${Icons.svg("shield")}${esc(cap(strip(friendly(ha.states[e]))))}`), doorState && ["open", "closed", "locked"].includes(doorState) && `${Icons.svg(doorState === "open" ? "door-open" : doorState === "locked" ? "lock" : "door")}${esc({ open: t("Door open"), closed: t("Door closed"), locked: t("Door locked") }[doorState])}`, power && phase === "running" && `${Icons.svg("bolt")}${esc(fmtState(power))}`, energy && `${Icons.svg("pulse")}${esc(fmtState(energy))}`, ].filter(Boolean).join(""); // Controls: Home Connect offers pause, resume and stop as buttons, and power as a switch. const ctl = (eid, ico, lbl, act = "svc", v = "button.press") => (eid && ha.states[eid] ? `` : ""); const powerOff = c.powerSwitch && ha.states[c.powerSwitch]?.state === "off"; const ctrls = [ phase === "running" && ctl(c.pauseBtn, "pause", t("Pause")), phase === "paused" && ctl(c.resumeBtn, "play", t("Resume")), ["running", "paused", "scheduled"].includes(phase) && ctl(c.stopBtn, "stop", t("Stop")), phase === "off" && powerOff && ctl(c.powerSwitch, "power", t("Turn on"), "toggle"), ].filter(Boolean).join(""); const main = phase === "done" ? `${t("Ready to unload")}` : phase === "scheduled" ? `${left != null ? t("Ready at {0}", ends) : t("Starts later")}` : phase === "attention" ? `${t("Check the appliance")}` : phase === "error" ? `${t("Something went wrong")}` : left != null ? `${esc(fmtDuration(left))}${t("left · ready at {0}", ends)}` : phase === "running" ? `${t("In progress")}` : `${esc(label)}`; return `
${ring}
${esc(c.name || t("Appliance"))}${esc(label)}
${showProgram ? `${esc(prettyProgram(program))}` : ""}
${main}
${ctrls ? `
${ctrls}
` : ""}
${facts ? `
${facts}
` : ""}
`; }, 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 = { label: "Camera", icon: "camera", size: [6, 4], flush: true, fields: [{ k: "entity", t: "entity", l: "Camera", domains: ["camera"] }, { k: "refresh", t: "number", l: "Refresh every (seconds)", d: 10 }, { k: "showName", t: "check", l: "Show name", d: true }], render(c, { ha }) { const s = ha.states[c.entity]; if (!s) return `
${missing(c.entity, "a camera")}
`; return ` ${c.showName ? `
${esc(friendly(s))}
` : ""}`; }, }; // ---------------------------------------------------------------- car const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase())); // Inspired by the Tesla app: the car in its own color as the centerpiece, the battery top right, // a green bar while charging and plain icon buttons. A square card shows the car from the front, // a wide card from the side with the details on the left. Container queries pick one (or the Layout field does). const SIDE_BODY = "M16 68H40A18 18 0 0 1 76 68H166A18 18 0 0 1 202 68H224C230 68 233 65 233 60L232 54C231 49 226 46 218 45L172 40C160 32 148 24 134 21C118 18 98 18 84 20C66 23 46 32 30 40L16 43C10 44 7 48 7 54V61C7 65 10 68 16 68Z"; const FRONT_BODY = "M16 90C14 76 18 66 30 61L52 52C68 47 132 47 148 52L170 61C182 66 186 76 184 90V96C184 101 180 104 175 104H25C20 104 16 101 16 96Z"; function carFront(id) { return ``; } function carSide(id, o) { const wheel = (cx) => ` `; return ``; } W.car = { label: "Car", icon: "car", size: [3, 4], fields: [ { k: "name", t: "text", l: "Name", d: "My car" }, { k: "image", t: "text", l: "Car image URL (optional, a side view with a transparent background looks best)", ph: "https://… or /local/car.png" }, { k: "layout", t: "seg", l: "Layout", o: ["auto", "square", "wide"], d: "auto" }, { k: "paint", t: "color", l: "Car color", ph: "Silver" }, { k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" }, { k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] }, { k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] }, { k: "charging", t: "entity", l: "Charging", domains: ["binary_sensor", "sensor", "switch"] }, { k: "power", t: "entity", l: "Charging power", domains: ["sensor"] }, { k: "plug", t: "entity", l: "Plugged in", domains: ["binary_sensor", "sensor"] }, { k: "limit", t: "entity", l: "Charge limit (%)", domains: ["number", "sensor", "input_number"] }, { k: "toFull", t: "entity", l: "Time until full (minutes or end time)", domains: ["sensor"] }, { k: "chargeSwitch", t: "entity", l: "Start/stop charging (switch)", domains: ["switch", "button"] }, { k: "lock", t: "entity", l: "Doors", domains: ["lock"] }, { k: "climate", t: "entity", l: "Climate", domains: ["climate", "switch", "input_boolean"] }, { k: "location", t: "entity", l: "Location", domains: ["device_tracker", "person", "sensor"] }, { k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] }, ], entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "toFull", "chargeSwitch", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean), render(c, { ha, w }) { const S = (k) => (c[k] ? ha.states[c[k]] : null); const level = S("level"), range = S("range"), lock = S("lock"), clim = S("climate"), loc = S("location"); if (!c.level && !c.range && !c.lock) return `
${t("Set the car’s level, range or lock entity in the inspector.")}
`; const pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null; const power = S("power"), plug = S("plug"), limit = S("limit"), odo = S("odometer"), toFull = S("toFull"), chg = S("chargeSwitch"); const fuel = c.kind === "fuel"; const charging = !fuel && (truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on")); const plugged = !fuel && (charging || truthy(plug)); const low = pct != null && pct <= 20; const limPct = !fuel && limit && isFinite(Number(limit.state)) ? Number(limit.state) : null; const unit = range ? range.attributes.unit_of_measurement || "km" : ""; const rangeTxt = range ? `${fmtNum(range.state)} ${unit}` : ""; const climOn = clim && (isOn(clim) || clim.state === "on"); const inside = clim?.attributes.current_temperature; const id = esc(w?.id || "x"); // status under the name, the way the Tesla app says it const place = loc ? (loc.state === "home" ? t("Home") : loc.state === "not_home" ? t("Away") : st(loc.state)) : ""; const status = [charging ? t("Charging") : plugged ? t("Plugged in") : loc ? t("Parked") : "", place, climOn && t("Climate on")].filter(Boolean).join(" · "); // time left on the charge let left = "", fullAt = ""; if (charging && toFull) { const v = toFull.state, mins = /^\d{4}-\d{2}-\d{2}T/.test(v) ? (new Date(v) - Date.now()) / 60000 : Number(v) * (String(toFull.attributes.unit_of_measurement || "min").toLowerCase().startsWith("h") ? 60 : 1); if (isFinite(mins) && mins > 0) { left = mins >= 60 ? t("{0} h {1} min left", Math.floor(mins / 60), Math.round(mins % 60)) : t("{0} min left", Math.round(mins)); fullAt = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })); } } const p = Math.max(0, Math.min(100, pct ?? 0)); const battIcon = pct != null ? `` : ""; const batt = `
${battIcon}${pct != null ? `${pct}%` : ""}${rangeTxt ? `${esc(rangeTxt)}` : ""}
`; const big = `
${pct != null ? `${pct}%` : ""}${rangeTxt ? `${esc(rangeTxt)}` : ""}
`; // the charging bar: green while charging, with the charge limit marked const bar = pct != null ? `
${limPct != null ? `` : ""}
` : ""; const chargeInfo = plugged ? `
${charging ? t("Charging") : t("Plugged in")}${charging && power && Number(power.state) > 0 ? ` ${esc(fmtState(power))}` : ""}${esc(left || (limPct != null ? t("Charges to {0}%", Math.round(limPct)) : ""))}
${bar}${fullAt || limPct != null ? `
${esc(fullAt)}${limPct != null ? esc(t("Limit {0}%", Math.round(limPct))) : ""}
` : ""}
` : bar ? `
${bar}
` : ""; // icon buttons const ctrl = (act, eid, ico, label, cls = "", extra = "") => ``; const ctrls = []; if (lock) { const locked = lock.state === "locked"; ctrls.push(ctrl("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked, tap to unlock") : t("Unlocked, tap to lock"), locked ? "" : "warn", `aria-pressed="${!locked}"`)); } if (clim) ctrls.push(ctrl("toggle", c.climate, "fan", climOn ? t("Climate on") : t("Climate off"), climOn ? "on" : "", `aria-pressed="${climOn}"`)); if (chg && !fuel) ctrls.push(domainOf(c.chargeSwitch) === "button" ? ctrl("svc", c.chargeSwitch, "bolt", t("Start charging"), "", `data-v="button.press"`) : ctrl("toggle", c.chargeSwitch, "bolt", charging ? t("Stop charging") : t("Start charging"), charging ? "ok" : "", `aria-pressed="${charging}"`)); if (loc) ctrls.push(ctrl("more", c.location, "pin", t("Location: {0}", place))); // detail rows, for cards with room to spare const row = (ico, title, sub, act, eid) => `<${act ? `button class="tz-row wbtn" data-act="${act}" data-e="${esc(eid)}"` : `div class="tz-row"`}>${Icons.svg(ico)}${esc(title)}${esc(sub)}${act ? Icons.svg("chevron", "fwd") : ""}`; const rows = [clim && row("thermometer", t("Climate"), [inside != null && t("Inside {0}°", fmtNum(inside)), climOn ? t("On") : t("Off")].filter(Boolean).join(" · "), "more", c.climate), loc && row("pin", t("Location"), place, "more", c.location), odo && row("road", t("Odometer"), fmtState(odo))].filter(Boolean).join(""); const art = c.image ? `` : `${carFront(id)}${carSide(id, { plugged })}`; const lay = c.layout === "square" ? "tz-sq" : c.layout === "wide" ? "tz-wide" : "tz-auto"; const cls = [lay, charging && "charging", plugged && "plugged", low && "low", fuel && "fuel"].filter(Boolean).join(" "); const paint = /^#[0-9a-f]{3,8}$|^rgb|^hsl|^[a-z]+$/i.test(c.paint || "") ? ` style="--tz-paint:${esc(c.paint)}"` : ""; return `
${esc(c.name || t("Car"))}
${status ? `
${esc(status)}
` : ""}
${batt}
${big}
${art}
${ctrls.length ? `
${ctrls.join("")}
` : ""} ${chargeInfo} ${rows ? `
${rows}
` : ""}
`; }, }; // ---------------------------------------------------------------- parcels // Parcel integrations (PostNL, DHL, …) keep their parcels as lists in a sensor's attributes; // field names differ, so each parcel is read from whichever names it has. const pick = (o, ...keys) => { for (const k of keys) if (o?.[k] != null && o[k] !== "") return o[k]; return null; }; const dayLabel = (d) => { const today = new Date(); today.setHours(0, 0, 0, 0); const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400000); return diff === 0 ? t("Today") : diff === 1 ? t("Tomorrow") : diff === -1 ? t("Yesterday") : new Date(d).toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" }); }; function parcelsOf(s) { const out = []; for (const [k, v] of Object.entries(s.attributes)) { if (!Array.isArray(v) || !v.length || typeof v[0] !== "object") continue; const listDelivered = /deliver|bezorgd|afgeleverd/i.test(k) && !/route|onderweg|transit/i.test(k); for (const p of v) { const status = String(pick(p, "status_message", "status", "state", "statusText") || ""); const when = pick(p, "expected_datetime", "planned_date", "delivery_date", "expected_delivery", "eta", "date", "planned_from"); const date = when && !isNaN(new Date(when)) ? new Date(when) : null; out.push({ name: String(pick(p, "name", "title", "description", "sender", "shop", "product", "key", "tracking_number", "barcode") || t("Parcel")), status, date, until: pick(p, "planned_to", "expected_to"), done: listDelivered || p.delivered === true || /delivered|bezorgd|afgeleverd/i.test(status), url: pick(p, "url", "track_url", "tracking_url"), }); } } return out; } W.parcels = { label: "Parcels", icon: "package", size: [4, 4], fields: [ { k: "title", t: "text", l: "Title", d: "Parcels" }, { k: "entities", t: "entities", l: "Parcel sensors (PostNL, DHL, …)", domains: ["sensor"] }, { k: "showDelivered", t: "check", l: "Show parcels delivered in the last two days", d: true }, ], entities: (c) => c.entities || [], render(c, { ha }) { const list = (c.entities || []).map((e) => ha.states[e]).filter(Boolean); if (!list.length) return `${title(c.title)}${missing((c.entities || [])[0], "a parcel sensor")}`; let items = list.flatMap((s) => { const ps = parcelsOf(s); return ps.length || !isFinite(Number(s.state)) ? ps : []; // a count with no list means nothing on the way }); const recent = Date.now() - 2 * 86400000; if (c.showDelivered === false) items = items.filter((p) => !p.done); else items = items.filter((p) => !p.done || !p.date || p.date.getTime() > recent); items.sort((a, b) => a.done - b.done || (a.date?.getTime() ?? 9e15) - (b.date?.getTime() ?? 9e15)); const coming = items.filter((p) => !p.done), today = coming.filter((p) => p.date && dayLabel(p.date) === t("Today")).length; const hm2 = (d) => new Date(d).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); const when = (p) => { if (!p.date) return ""; const hasTime = p.date.getHours() || p.date.getMinutes(); return `${dayLabel(p.date)}${hasTime ? ` ${hm2(p.date)}${p.until && !isNaN(new Date(p.until)) ? `–${hm2(p.until)}` : ""}` : ""}`; }; const rows = items.map((p) => `<${p.url ? `a href="${esc(p.url)}" target="_blank" rel="noopener"` : "div"} class="pc-row ${p.done ? "done" : ""} ${p.date && dayLabel(p.date) === t("Today") && !p.done ? "today" : ""}"> ${Icons.svg(p.done ? "check" : "package")} ${esc(p.name)}${esc(p.done ? t("Delivered") : p.status || t("On the way"))} ${esc(when(p))}`).join(""); return `${title(c.title)}
${coming.length} ${esc(coming.length === 1 ? t("parcel on the way") : t("parcels on the way"))}${today ? ` · ${esc(t("{0} today", today))}` : ""}
${rows ? `
${rows}
` : `
${t("No parcels on the way.")}
`}
`; }, }; // ---------------------------------------------------------------- air quality const AIR = { carbon_dioxide: { k: "co2", l: "CO₂", lv: (v) => (v <= 800 ? 0 : v <= 1200 ? 1 : 2) }, pm25: { k: "pm25", l: "PM2.5", lv: (v) => (v <= 12 ? 0 : v <= 35 ? 1 : 2) }, pm10: { k: "pm10", l: "PM10", lv: (v) => (v <= 20 ? 0 : v <= 50 ? 1 : 2) }, volatile_organic_compounds: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) }, volatile_organic_compounds_parts: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) }, humidity: { k: "hum", l: "Humidity", lv: (v) => (v >= 40 && v <= 60 ? 0 : v >= 30 && v <= 70 ? 1 : 2) }, }; const AIR_WORDS = /\b(co2|carbon dioxide|pm2[.,]?5|pm10|voc|humidity|luchtvochtigheid|vochtigheid|air quality|luchtkwaliteit)\b/gi; W.air = { label: "Air quality", icon: "air", size: [4, 4], fields: [ { k: "title", t: "text", l: "Title", d: "Air quality" }, { k: "entities", t: "entities", l: "Sensors (leave empty to find CO₂, fine dust and humidity per room)", domains: ["sensor"] }, ], entities: (c, ha) => (c.entities?.length ? c.entities : Object.values(ha?.states || {}).filter((s) => AIR[s.attributes.device_class]).map((s) => s.entity_id)), render(c, { ha }) { const reg = ha.reg; const areaOf = {}; if (reg) for (const [a, ids] of Object.entries(reg.byArea)) for (const id of ids) areaOf[id] = reg.areas.find((x) => x.area_id === a)?.name; const sensors = (c.entities?.length ? c.entities : W.air.entities(c, ha)).map((e) => ha.states[e]).filter((s) => s && AIR[s.attributes.device_class] && isFinite(Number(s.state))); // Without chosen sensors, only rooms count; with chosen ones, a sensor's name is its room when it has none. const rooms = new Map(); for (const s of sensors) { const room = areaOf[s.entity_id] || (c.entities?.length ? friendly(s).replace(AIR_WORDS, "").replace(/\s+/g, " ").trim() || friendly(s) : null); if (!room) continue; (rooms.get(room) || rooms.set(room, []).get(room)).push(s); } if (!rooms.size) return `${title(c.title)}
${t("No air quality sensors found. Add CO₂, fine dust or humidity sensors in the inspector.")}
`; const names = ["Good", "Fair", "Poor"]; const rows = [...rooms.entries()].map(([room, ss]) => { const m = ss.map((s) => { const d = AIR[s.attributes.device_class], v = Number(s.state); return { ...d, v, lvl: d.lv(v), s }; }); const worst = Math.max(...m.map((x) => x.lvl)); const co2 = m.find((x) => x.k === "co2")?.v, hum = m.find((x) => x.k === "hum")?.v, pm = m.find((x) => (x.k === "pm25" || x.k === "pm10") && x.lvl === 2); const hint = co2 >= 1000 ? t("Open a window") : pm ? t("Air the room or turn on the purifier") : hum > 70 ? t("Too humid, air the room") : hum < 30 ? t("The air is dry") : ""; return `
${esc(room)}${esc(hint || t(names[worst]))} ${m.map((x) => ``).join("")}
`; }).join(""); return `${title(c.title)}
${rows}
`; }, }; // ---------------------------------------------------------------- water and gas // Daily use from Home Assistant's long-term statistics of a meter (the same numbers the energy dashboard uses). W.usage = { label: "Water and gas", icon: "droplet", size: [4, 3], fields: [ { k: "entity", t: "entity", l: "Meter (water, gas or energy)", domains: ["sensor"] }, { k: "title", t: "text", l: "Title", ph: "Entity name" }, { k: "kind", t: "seg", l: "Type", o: ["water", "gas", "electricity"], d: "water" }, { k: "days", t: "number", l: "Days to show", d: 7 }, ], render(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a meter"); let unit = s.attributes.unit_of_measurement || ""; // Water reads better in litres than in thousandths of a cubic metre. const liters = (c.kind || "water") === "water" && unit === "m³"; if (liters) unit = "L"; const ico = { water: "droplet", gas: "flame", electricity: "bolt" }[c.kind] || "droplet"; const head = `
${Icons.svg(ico)}${esc(c.title || friendly(s))}
`; if (cache.error) return `${head}
${t("No statistics for this meter. It needs a state class (total or total_increasing).")}
`; if (!cache.days) return `${head}
${t("Loading…")}
`; const days = liters ? cache.days.map((d) => ({ ...d, v: Math.round(d.v * 1000) })) : cache.days; const today = days[days.length - 1]?.v ?? 0, yest = days[days.length - 2]?.v; const max = Math.max(...days.map((d) => d.v), 0.0001), avg = days.slice(0, -1).reduce((a, d) => a + d.v, 0) / Math.max(1, days.length - 1); return `${head}
${esc(fmtNum(today))}${esc(unit)} ${t("Today so far")}${yest != null ? ` · ${t("yesterday {0}", `${fmtNum(yest)} ${unit}`)}` : ""}
${t("{0} days", days.length)}: ${esc(fmtNum(days.reduce((a, d) => a + d.v, 0)))} ${esc(unit)}${t("Average {0}", `${fmtNum(avg)} ${unit}`)}
`; }, async after(el, c, ctx) { if (!ctx.ha.states[c.entity]) return; const cache = ctx.cache, n = Math.max(3, Math.min(31, Number(c.days) || 7)); const key = `${c.entity}|${n}`; if (cache.key === key && Date.now() - cache.at < 10 * 60 * 1000) return; cache.key = key; cache.at = Date.now(); try { const rows = await ctx.ha.statistics(c.entity, n); cache.days = rows.map((r) => ({ v: Math.max(0, Number(r.change) || 0), label: new Date(r.start).toLocaleDateString(LOCALE, { weekday: "short" }) })); cache.error = !rows.length; } catch { cache.error = true; } ctx.rerender(); // the card may have been redrawn while loading: draw whichever one is on screen now }, }; // ---------------------------------------------------------------- solar forecast // Forecast.Solar: the day totals come from its sensors, the hour by hour forecast from the energy API. W.solar = { label: "Solar forecast", icon: "solar", size: [4, 4], fields: [ { k: "title", t: "text", l: "Title", d: "Solar forecast" }, { k: "today", t: "entity", l: "Production today (kWh)", domains: ["sensor"] }, { k: "tomorrow", t: "entity", l: "Production tomorrow (kWh)", domains: ["sensor"] }, { k: "now", t: "entity", l: "Power now (W)", domains: ["sensor"] }, { k: "hours", t: "number", l: "Hours for the washer or dishwasher", d: 3 }, ], autofill: { from: "today", fill: (eid, ha) => { const has = (e) => (ha.states[e] ? e : ""); return { tomorrow: has(eid.replace("today", "tomorrow")), now: has(eid.replace("energy_production_today", "power_production_now")) }; }, }, entities: (c) => [c.today, c.tomorrow, c.now].filter(Boolean), render(c, { ha, cache }) { const today = ha.states[c.today], tomorrow = ha.states[c.tomorrow], now = ha.states[c.now]; if (!today && !tomorrow) return `${title(c.title)}${missing(c.today, "the production today sensor")}`; const kwh = (s) => (s ? `${fmtNum(s.state)}${esc(s.attributes.unit_of_measurement || "kWh")}` : "–"); const hours = cache.hours || []; const day0 = new Date(); day0.setHours(0, 0, 0, 0); const todays = hours.filter((h) => h.t >= day0.getTime() && h.t < day0.getTime() + 86400000); const shown = todays.filter((h) => h.wh > 0 || (new Date(h.t).getHours() >= 7 && new Date(h.t).getHours() <= 20)); const max = Math.max(...shown.map((h) => h.wh), 1), nowH = new Date().getHours(); // the best window to run an appliance: the most sun in N hours in a row, from now on const n = Math.max(1, Math.min(6, Number(c.hours) || 3)); const ahead = hours.filter((h) => h.t >= Date.now() - 3600000); let best = null; for (let i = 0; i + n <= ahead.length; i++) { const sum = ahead.slice(i, i + n).reduce((a, h) => a + h.wh, 0); if (ahead[i + n - 1].t - ahead[i].t === (n - 1) * 3600000 && (!best || sum > best.sum)) best = { sum, from: ahead[i].t, to: ahead[i].t + n * 3600000 }; } const hmT = (ms) => new Date(ms).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); return `${title(c.title)}
${t("Today")}${kwh(today)}
${tomorrow ? `
${t("Tomorrow")}${kwh(tomorrow)}
` : ""} ${now ? `
${t("Now")}${esc(fmtState(now))}
` : ""}
${shown.length ? `
${shown.map((h) => { const hr = new Date(h.t).getHours(); return `
= best.from && h.t < best.to ? "best" : ""}" title="${esc(`${hmT(h.t)}: ${fmtNum(h.wh / 1000)} kWh`)}">${hr % 3 === 0 ? `${hr}` : ""}
`; }).join("")}
` : cache.loaded ? `
${t("The hour by hour forecast isn’t available.")}
` : ""} ${best ? `
${Icons.svg("washer")}${esc(t("Best time for the washer: {0}", `${dayLabel(best.from) === t("Today") ? "" : dayLabel(best.from) + " "}${hmT(best.from)}–${hmT(best.to)}`))}
` : ""}
`; }, async after(el, c, ctx) { const cache = ctx.cache; if (cache.at && Date.now() - cache.at < 30 * 60 * 1000) return; cache.at = Date.now(); try { const res = await ctx.ha.solarForecast(); const sum = {}; for (const entry of Object.values(res || {})) for (const [iso, wh] of Object.entries(entry.wh_hours || {})) { const tm = new Date(iso).getTime(); sum[tm] = (sum[tm] || 0) + Number(wh || 0); } cache.hours = Object.entries(sum).map(([tm, wh]) => ({ t: Number(tm), wh })).sort((a, b) => a.t - b.t); } catch { cache.hours = []; } cache.loaded = true; ctx.rerender(); }, }; // ---------------------------------------------------------------- garage and gate /** Who changed an entity last, from the user on its context (matched to a person). */ function changedBy(s, ha) { const uid = s?.context?.user_id; if (!uid) return ""; const p = Object.values(ha.states).find((x) => x.entity_id.startsWith("person.") && x.attributes.user_id === uid); return p ? friendly(p) : ""; } W.gate = { label: "Garage and gate", icon: "garage", size: [3, 3], fields: [ { k: "entity", t: "entity", l: "Garage door, gate or lock", domains: ["cover", "lock", "switch", "button", "input_boolean"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }, { k: "kind", t: "seg", l: "Icon", o: ["garage", "gate", "door"], d: "garage" }, { k: "confirm", t: "check", l: "Ask before opening or closing", d: true }, ], render(c, { ha }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a garage door or gate"); const d = domainOf(c.entity), st0 = s.state; const open = ["open", "opening", "unlocked", "unlocking", "on"].includes(st0), moving = ["opening", "closing", "locking", "unlocking"].includes(st0); const name = c.name || friendly(s); const label = d === "button" ? t("Press") : moving ? st(st0) : open ? (d === "lock" ? t("Lock") : t("Close")) : d === "lock" ? t("Unlock") : t("Open"); const by = changedBy(s, ha); const since = `${open ? t("Open") : st(st0)} · ${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`; const ico = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage"; const when = `${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`; return `
${Icons.svg(open && c.kind === "door" ? "door-open" : ico)}${esc(name)}
${esc(d === "button" ? name : open ? t("Open") : st(st0))}${esc(when)}
`; }, }; // ---------------------------------------------------------------- sports (TeamTracker) const isMatchDay = (s) => s && (s.state === "IN" || (s.attributes.date && dayLabel(s.attributes.date) === t("Today"))); W.sports = { label: "Sports", icon: "trophy", size: [4, 3], fields: [ { k: "entity", t: "entity", l: "Team (TeamTracker sensor)", domains: ["sensor"] }, { k: "matchDay", t: "check", l: "Only show on match days", d: false }, { k: "venue", t: "check", l: "Show the venue and TV channel", d: true }, ], autoHide: (c, { ha }) => !!c.matchDay && !isMatchDay(ha.states[c.entity]), render(c, { ha }) { const s = ha.states[c.entity]; if (!s) return missing(c.entity, "a TeamTracker sensor"); const a = s.attributes, state = String(s.state).toUpperCase(); if (state === "NOT_FOUND" || state === "BYE" || !a.opponent_name) return `
${esc(a.league || friendly(s))}
${state === "BYE" ? t("No match this round.") : t("No match planned.")}
`; // TeamTracker gives each side its colours, record, league position and, often, its chance to win. const hues = (v) => (Array.isArray(v) ? v : String(v || "").split(",")).map((x) => String(x).trim()).map((x) => (x.startsWith("#") ? x : `#${x}`)).filter((x) => /^#[0-9a-f]{6}$/i.test(x)); const side = (p) => ({ name: a[`${p}_name`], short: a[`${p}_abbr`], logo: a[`${p}_logo`], score: a[`${p}_score`], win: a[`${p}_winner`], colors: hues(a[`${p}_colors`]), record: a[`${p}_record`], rank: a[`${p}_rank`], prob: Number(a[`${p}_win_probability`]) }); const team = side("team"), opp = side("opponent"); const [home, away] = a.team_homeaway === "away" ? [opp, team] : [team, opp]; const light = (hex) => { const n = parseInt(hex.slice(1), 16); return 0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255) > 160; }; const crest = (x) => { if (x.logo) return ``; const c1 = x.colors[0] || "#8e8e93", c2 = x.colors[1] && x.colors[1].toLowerCase() !== c1.toLowerCase() ? x.colors[1] : light(c1) ? "#1c1c1e" : "#ffffff"; return ``; }; const meta = (x) => [x.rank ? `#${x.rank}` : "", x.record].filter(Boolean).join(" · "); const sideHTML = (x) => `
${crest(x)}${esc(x.name || x.short || "")}${meta(x) ? `${esc(meta(x))}` : ""}
`; const kick = a.date ? new Date(a.date) : null; let mid; if (state === "PRE") { let until = ""; if (kick && kick > Date.now()) { const m = Math.round((kick - Date.now()) / 60000), d = Math.floor(m / 1440); until = d ? t("in {0} d {1} h", d, Math.floor((m % 1440) / 60)) : m >= 60 ? t("in {0} h {1} min", Math.floor(m / 60), m % 60) : t("in {0} min", m); } mid = `${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"} ${esc(kick ? dayLabel(kick) : "")}${until ? `${esc(until)}` : ""}`; } else mid = `${esc(home.score ?? 0)}–${esc(away.score ?? 0)} ${state === "IN" ? `${t("Live")} ${esc(a.clock || "")}` : t("Full time")}`; // The chance of each result, as one bar in the teams' colours. let odds = ""; if (isFinite(home.prob) && isFinite(away.prob) && home.prob + away.prob > 0) { const draw = Math.max(0, 1 - home.prob - away.prob), pc = (v) => Math.round(v * 100); const seg = (v, col, cls) => (v > 0.005 ? `` : ""); odds = `
${seg(home.prob, home.colors[0], "h")}${draw > 0.02 ? seg(draw, "", "d") : ""}${seg(away.prob, away.colors[0], "a")}
${pc(home.prob)}%${draw > 0.02 ? `${esc(t("Draw"))} ${pc(draw)}%` : ""}${pc(away.prob)}%
`; } const place = c.venue !== false ? [a.venue, a.location].filter(Boolean).join(", ") : ""; return `
${esc([a.league || a.sport, a.event_name && a.event_name !== a.league ? a.event_name : ""].filter(Boolean).join(" · "))}${c.venue !== false && a.tv_network ? `${esc(a.tv_network)}` : ""}
${sideHTML(home)}
${mid}
${sideHTML(away)}
${odds} ${state === "IN" && a.last_play ? `
${esc(a.last_play)}
` : place ? `
${Icons.svg("pin")}${esc(place)}
` : ""}
`; }, }; // ---------------------------------------------------------------- Formula One (F1 Sensor integration) // Its sensors carry the season data from the Jolpica (Ergast) API in their attributes. const F1_SESSIONS = [["first_practice", "Practice 1"], ["second_practice", "Practice 2"], ["third_practice", "Practice 3"], ["sprint_qualifying", "Sprint qualifying"], ["sprint", "Sprint"], ["qualifying", "Qualifying"], ["race", "Race"]]; const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; }; const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; }; const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; }; const f1Code = (r) => { const d = r.Driver || r.driver || {}; return (typeof d === "object" && d.code) || f1Driver(r).split(" ").pop().slice(0, 3).toUpperCase(); }; /** The teams' own colours, as on the timing tower. */ const F1_TEAMS = [["red bull", "#3671c6"], ["mclaren", "#ff8000"], ["ferrari", "#e80020"], ["mercedes", "#27f4d2"], ["aston", "#229971"], ["alpine", "#0093cc"], ["williams", "#64c4ff"], ["racing bulls", "#6692ff"], ["rb f1", "#6692ff"], ["visa", "#6692ff"], ["sauber", "#52e252"], ["kick", "#52e252"], ["audi", "#bb0a30"], ["haas", "#b6babd"], ["cadillac", "#c0a062"]]; const f1Colour = (team) => (F1_TEAMS.find(([k]) => String(team).toLowerCase().includes(k)) || [, "#8e8e93"])[1]; W.f1 = { label: "Formula One", icon: "flag", size: [4, 5], fields: [ { k: "next", t: "entity", l: "Next race", domains: ["sensor"] }, { k: "drivers", t: "entity", l: "Driver standings", domains: ["sensor"] }, { k: "teams", t: "entity", l: "Constructor standings", domains: ["sensor"] }, { k: "results", t: "entity", l: "Last race results", domains: ["sensor"] }, { k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" }, { k: "top", t: "number", l: "Positions to show", d: 5 }, { k: "look", t: "seg", l: "Look", o: ["official", "theme"], d: "official" }, { k: "logo", t: "text", l: "Logo image (put the official logo in Home Assistant's www folder)", ph: "/local/f1-logo.svg" }, ], autofill: { from: "next", fill: (eid, ha) => { const pre = eid.replace(/next_race.*$/, ""), has = (e) => (ha.states[e] ? e : ""); return { drivers: has(`${pre}driver_standings`), teams: has(`${pre}constructor_standings`), results: has(`${pre}last_race_results`) }; }, }, entities: (c) => [c.next, c.drivers, c.teams, c.results].filter(Boolean), render(c, { ha, cache }) { const next = ha.states[c.next]; if (!next && !c.drivers) return missing(c.next, "the next race sensor"); const a = next?.attributes || {}; const iso = (k) => a[`${k}_start`] || a[`${k}_date`] || a[`${k}_start_local`]; const raceAt = iso("race") ? new Date(iso("race")) : null; let countdown = ""; if (raceAt && raceAt > Date.now()) { const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60); countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60); } const day = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric" }); const time = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v))); const nextSession = sessions.find(([, v]) => new Date(v) > Date.now()); const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · "); const tab = cache.tab || c.show || "drivers", top = Math.max(1, Math.min(20, Number(c.top) || 5)); const tabs = [["drivers", "Drivers", c.drivers], ["teams", "Teams", c.teams], ["results", "Last race", c.results]].filter(([, , e]) => e && ha.states[e]); const cur = tabs.find(([k]) => k === tab) || tabs[0]; let rows = ""; if (cur) { const s = ha.states[cur[2]]; const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings") : cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results"); rows = list.slice(0, top).map((r) => { const team = f1Team(r), isTeam = cur[0] === "teams"; const name = isTeam ? team : f1Driver(r); const sub = isTeam ? (r.wins != null ? t("{0} wins", r.wins) : "") : team; const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""}`; return `
${esc(r.position ?? r.positionText ?? "")} ${isTeam ? `${esc(name)}` : `${esc(f1Code(r))} ${esc(name)}`}${esc(sub)} ${esc(val)}${cur[0] === "results" ? "" : `${t("pts")}`}
`; }).join(""); } // The countdown as the timing screens show it: days, hours, minutes. let clock = ""; if (raceAt && raceAt > Date.now()) { const m = Math.round((raceAt - Date.now()) / 60000); clock = [[Math.floor(m / 1440), t("days")], [Math.floor((m % 1440) / 60), t("hrs")], [m % 60, t("mins")]] .map(([v, l]) => `${String(v).padStart(2, "0")}${esc(l)}`).join(""); } const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : ""); const official = c.look !== "theme"; return `
${c.logo ? `` : ``} ${esc(a.season ? t("Round {0} · {1}", a.round, a.season) : t("Next race"))}
${next ? `
${esc(raceName || t("Next race"))}
${place ? `
${esc(place)}
` : ""} ${clock ? `
${clock}
` : ""} ${sessions.length ? `
${sessions.map(([l, v]) => { const d = new Date(v); return `
${esc(day(d))}${esc(t(l))}${esc(time(d))}
`; }).join("")}
` : ""}
` : ""} ${tabs.length ? `
${tabs.map(([k, l]) => ``).join("")}
${rows || `
${t("No standings yet.")}
`}
` : ""}
`; }, }; W.text = { label: "Text", icon: "text", size: [4, 2], fields: [ { k: "text", t: "textarea", l: "Text — use {{ sensor.x }} for live values", d: "Inside it’s {{ sensor.living_room_temperature }}." }, { k: "size", t: "number", l: "Font size (px)", d: 17 }, ], entities: (c) => [...String(c.text || "").matchAll(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g)].map((m) => m[1]), render(c, { ha }) { const html = esc(c.text).replace(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g, (_, eid) => `${esc(fmtState(ha.states[eid]))}`); return `
${html}
`; }, }; // ---------------------------------------------------------------- photos // One image, a slideshow of URLs, or a slideshow of Immich albums (proxied by the server). const urlList = (c) => String(c.urls ?? c.url ?? "").split(/\s*\n\s*/).map((u) => u.trim()).filter(Boolean); W.image = { label: "Photos", icon: "image", size: [4, 3], flush: true, fields: [ { k: "source", t: "seg", l: "Source", o: ["links", "immich"], d: "links" }, { k: "urls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.source !== "immich" }, { k: "albums", t: "albums", l: "Immich albums", when: (c) => c.source === "immich" }, { k: "interval", t: "number", l: "Change photo every (seconds)", d: 20 }, { k: "shuffle", t: "check", l: "Shuffle", d: true }, { k: "fit", t: "seg", l: "Fit", o: ["cover", "contain"], d: "cover" }, { k: "showDate", t: "check", l: "Show when the photo was taken", d: false, when: (c) => c.source === "immich" }, { k: "alt", t: "text", l: "Description (for screen readers)" }, ], 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 ``; }, after(el, c, ctx) { return slideshow(el, el.querySelector(".photos"), ctx, ctx.cache, { source: c.source, urls: c.urls ?? c.url, albums: c.albums, shuffle: c.shuffle, interval: c.interval, showDate: c.showDate }); }, }; /** Load photos from links or Immich albums and cross-fade through them in `box` (.photos with two .ph-layer images). * Used by the Photos card and as a clock background; `cache` keeps the list and position across redraws. */ async function slideshow(el, box, ctx, cache, o) { clearTimeout(cache.timer); if (!box) return; const msg = box.querySelector(".ph-msg"); const key = JSON.stringify([o.source, o.urls, o.albums, o.shuffle]); if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) { cache.key = key; cache.at = Date.now(); cache.pos = 0; cache.loading = (async () => { if (o.source !== "immich") return urlList({ urls: o.urls }).map((src) => ({ src })); const lists = await Promise.all((o.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`))); return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date })); })().then((items) => { if (o.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; } cache.items = items; cache.error = null; }).catch((e) => { cache.items = []; cache.error = e.message; }); } await cache.loading; if (!el.isConnected) return; const say = (text) => { if (msg) msg.innerHTML = `
${text}
`; }; if (cache.error) { say(t("Couldn’t load photos. {0}", esc(cache.error))); return; } if (!cache.items.length) { say(t("These albums have no photos yet.")); return; } const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date"); let front = 0; const show = () => { if (!el.isConnected) return; const item = cache.items[cache.pos % cache.items.length]; cache.pos++; const next = layers[1 - front]; next.onload = () => { next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front; if (msg) msg.innerHTML = ""; if (dateEl) dateEl.textContent = o.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : ""; }; next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); }; next.src = item.src; if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(o.interval) || 20) * 1000); }; // Tapping the card skips ahead; the automatic timer restarts from there. cache.next = cache.items.length > 1 ? () => { clearTimeout(cache.timer); show(); } : null; show(); } // ---------------------------------------------------------------- calendar const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"]; const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; const parseWhen = (v) => (typeof v === "string" && !v.includes("T") ? new Date(`${v}T00:00:00`) : new Date(v?.dateTime || v?.date || v)); const hm = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); W.calendar = { label: "Calendar", icon: "calendar", size: [4, 4], fields: [ { k: "title", t: "text", l: "Title", d: "Upcoming" }, { k: "entities", t: "entities", l: "Calendars", domains: ["calendar"] }, { k: "days", t: "number", l: "Days ahead", d: 7 }, { k: "max", t: "number", l: "Most events to show", d: 12 }, { k: "showCalendar", t: "check", l: "Show which calendar", d: true }, ], entities: (c) => c.entities || [], render(c) { if (!c.entities?.length) return `${title(c.title)}
${t("Add one or more calendars in the inspector.")}
`; return `${title(c.title)}
${t("Loading events…")}
`; }, async after(el, c, ctx) { if (!c.entities?.length) return; const days = Math.max(1, Number(c.days) || 7); const key = c.entities.join(",") + "|" + days; const cache = ctx.cache; if (cache.key !== key || Date.now() - cache.at > 5 * 60 * 1000) { cache.key = key; cache.at = Date.now(); const start = new Date(); start.setHours(0, 0, 0, 0); const end = new Date(start.getTime() + days * 86400e3); cache.loading = ctx.ha.calendarEvents(c.entities, start, end) .then((res) => { cache.res = res || {}; cache.error = null; }) .catch((e) => { cache.res = {}; cache.error = e.message; }); } await cache.loading; const list = el.querySelector(".cal"); // the phone's small versions show the next event from the cache: draw them again once it's there if (el.isConnected && !list && cache.drawnAt !== cache.at) { cache.drawnAt = cache.at; ctx.rerender(); } if (!el.isConnected || !list) return; if (cache.error) { list.innerHTML = `
${t("Couldn’t load events. {0}", esc(cache.error))}
`; return; } const now = new Date(); const events = []; c.entities.forEach((eid, i) => { for (const ev of cache.res[eid]?.events || []) { const allDay = typeof ev.start === "string" ? !ev.start.includes("T") : !!ev.start?.date; const s = parseWhen(ev.start), e = parseWhen(ev.end); if (e < now) continue; events.push({ ...ev, s, e, allDay, cal: friendly(ctx.ha.states[eid]), color: CAL_COLORS[i % CAL_COLORS.length] }); } }); events.sort((a, b) => a.s - b.s || b.allDay - a.allDay); const shown = events.slice(0, Math.max(1, Number(c.max) || 12)); if (!shown.length) { list.innerHTML = `
${t("Nothing planned for the next {0} days.", days)}
`; return; } const today = new Date(); today.setHours(0, 0, 0, 0); const label = (d) => { const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400e3); return diff <= 0 ? t("Today") : diff === 1 ? t("Tomorrow") : d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" }); }; let html = "", lastDay = ""; const multi = c.entities.length > 1 && c.showCalendar; for (const ev of shown) { const day = ev.s < today ? today : ev.s; if (dayKey(day) !== lastDay) { lastDay = dayKey(day); html += `
${esc(label(day))}
`; } const when = ev.allDay ? t("All day") : `${hm(ev.s)}–${hm(ev.e)}`; const live = !ev.allDay && ev.s <= now && ev.e > now; html += `
${esc(when)} ${esc(ev.summary || t("Busy"))}${ev.location || multi ? `${esc([multi ? ev.cal : "", ev.location].filter(Boolean).join(" · "))}` : ""} ${live ? `${t("Now")}` : ""}
`; } list.innerHTML = html; }, }; W.iframe = { label: "Web page", icon: "globe", size: [6, 4], flush: true, fields: [{ k: "url", t: "text", l: "URL" }], render: (c) => (c.url ? `` : `
${t("Add a URL in the inspector.")}
`), }; // ---------------------------------------------------------------- waste (Afvalwijzer) // Reads the per-type sensors of the Afvalwijzer integration: state is the next pickup // date (ISO, or a timestamp), with days_until_collection_date and is_collection_date_*. const WASTE_TYPES = { gft: ["Organic", "#4CAF50"], papier: ["Paper", "#2196F3"], pmd: ["PMD", "#800000"], plastic: ["Plastic", "#FF9800"], restafval: ["Residual waste", "#9E9E9E"], restafvalzakken: ["Residual waste bags", "#9E9E9E"], restwagen: ["Residual waste", "#9E9E9E"], textiel: ["Textile", "#00695C"], grofvuil: ["Bulky waste", "#795548"], kca: ["Hazardous waste", "#F44336"], luiers: ["Nappies", "#FFA000"], glas: ["Glass", "#009688"], snoeiafval: ["Pruning waste", "#2E7D32"], tuinafval: ["Garden waste", "#2E7D32"], kerstbomen: ["Christmas trees", "#1B5E20"], best_tas: ["BEST bag", "#6D4C41"], milieubus: ["Hazardous waste van", "#F44336"], grip: ["GRIP", "#607D8B"], maas: ["Maas", "#607D8B"], }; const WASTE_KEYS = Object.keys(WASTE_TYPES).sort((a, b) => b.length - a.length); const isWasteSensor = (s) => !!s && s.entity_id.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in s.attributes; const wasteKey = (eid) => WASTE_KEYS.find((k) => eid.endsWith(`_${k}`)) || eid.split("_").pop(); function wasteItems(c, ha) { const ids = c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => isWasteSensor(ha.states[e])); const today = new Date(); today.setHours(0, 0, 0, 0); const out = []; for (const eid of ids) { const s = ha.states[eid]; if (!s) continue; const raw = s.state; const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw); if (isNaN(date)) continue; // "geen" or unavailable: no pickup planned date.setHours(0, 0, 0, 0); const days = s.attributes.days_until_collection_date ?? Math.round((date - today) / 86400e3); if (days < 0) continue; const key = wasteKey(eid), known = WASTE_TYPES[key]; out.push({ eid, key, days, date, name: known ? t(known[0]) : friendly(s).replace(/^afvalwijzer\s*/i, "") || cap(key), color: known?.[1] || "var(--d-text-2)" }); } return out.sort((a, b) => a.days - b.days || a.name.localeCompare(b.name)); } const whenLabel = (d) => (d === 0 ? t("Today") : d === 1 ? t("Tomorrow") : d === 2 ? t("Day after tomorrow") : t("In {0} days", d)); const shortDate = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" }); W.waste = { label: "Waste collection", icon: "bin", size: [4, 4], fields: [ { k: "title", t: "text", l: "Title", d: "Waste collection" }, { k: "layout", t: "seg", l: "Layout", o: ["next", "list"], d: "next" }, { k: "entities", t: "entities", l: "Waste sensors (leave empty to find them automatically)", domains: ["sensor"] }, { k: "max", t: "number", l: "Most pickups to show", d: 6 }, { k: "within", t: "number", l: "Only pickups within (days)", d: 31 }, ], // With automatic discovery every Afvalwijzer sensor matters, so listen to all of them. entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("sensor.afvalwijzer_"))), render(c, { ha }) { const within = Number(c.within) || 31; const items = wasteItems(c, ha).filter((i) => i.days <= within).slice(0, Math.max(1, Number(c.max) || 6)); if (!items.length) { const any = c.entities?.length || Object.values(ha.states).some(isWasteSensor); return `${title(c.title)}
${any ? t("No pickups in the next {0} days.", within) : t("No Afvalwijzer sensors found. Install the Afvalwijzer integration, or pick the sensors in the inspector.")}
`; } const dot = (i) => ``; let head = "", rest = items; if (c.layout !== "list") { const first = items.filter((i) => i.days === items[0].days); rest = items.slice(first.length); const soon = first[0].days <= 1; head = `
${first.map(dot).join("")}
${t("Next pickup")} ${esc(whenLabel(first[0].days))} ${esc(first.map((i) => i.name).join(" · "))} ${first[0].days === 1 ? `${t("Put the bin out tonight")}` : ""}
`; } const rows = rest.map((i) => ``).join(""); return `${title(c.title)}${head}${rows ? `
${rows}
` : ""}`; }, }; // ---------------------------------------------------------------- containers // Scroll and swipe cards hold other cards in cfg.children. They draw placeholders; // ctx.renderChildren fills them using the normal widget renderer. const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch)); const slot = (ch) => `
0 ? `style="--own-w:${Number(ch.cfg._width)}px"` : ""}>
`; const noKids = (c) => `
${(c.children || []).length ? esc(c.emptyText || t("None of these cards are showing right now.")) : t("Add cards in the inspector.")}
`; // What a container shows when all its cards are hidden by their conditions: a text of your own, or nothing at all. const EMPTY_FIELDS = [ { k: "hideEmpty", t: "check", l: "Hide this card when none of its cards are showing", d: false }, { k: "emptyText", t: "text", l: "Text when none of its cards are showing", ph: "None of these cards are showing right now.", when: (c) => !c.hideEmpty }, ]; const containerHidden = (c, ctx) => !!c.hideEmpty && (c.children || []).length > 0 && !(c.children || []).some((ch) => ctx.isVisible(ch)); W.hscroll = { label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true, fields: [ { k: "title", t: "text", l: "Title", d: "" }, { k: "widthMode", t: "seg", l: "Card width", o: ["pixels", "per-view"], d: "pixels" }, { k: "itemWidth", t: "number", l: "Card width (px)", d: 260, when: (c) => c.widthMode !== "per-view" }, { k: "perView", t: "number", l: "Cards visible at once", d: 2.5, ph: "e.g. 1, 2 or 2.5", when: (c) => c.widthMode === "per-view" }, ...EMPTY_FIELDS, ], autoHide: containerHidden, render(c, ctx) { const kids = slots(c, ctx); if (!kids.length) return noKids(c); return `
${c.title ? `
${esc(c.title)}
` : ""}
${kids.map(slot).join("")}
`; }, after(el, c, ctx) { ctx.renderChildren(el); const track = el.querySelector(".hs-track"), box = el.querySelector(".hs"); if (!track) return; // Keep the scroll position when a card inside updates and everything is redrawn. track.scrollLeft = ctx.cache.scroll || 0; const edges = () => { box.classList.toggle("at-start", track.scrollLeft < 4); box.classList.toggle("at-end", track.scrollLeft + track.clientWidth > track.scrollWidth - 4); }; track.addEventListener("scroll", () => { ctx.cache.scroll = track.scrollLeft; edges(); }, { passive: true }); edges(); }, }; W.swipe = { label: "Swipe cards", icon: "swipe", size: [4, 3], container: true, transparent: true, flush: true, fields: [ { k: "autoplay", t: "number", l: "Next card every (seconds)", d: 0, ph: "0 = off" }, { k: "dots", t: "check", l: "Show page dots while swiping", d: true }, ...EMPTY_FIELDS, ], autoHide: containerHidden, render(c, ctx) { const kids = slots(c, ctx); if (!kids.length) return noKids(c); const dots = c.dots !== false && kids.length > 1 ? `
${kids.map((_, i) => ``).join("")}
` : ""; return `
${kids.map(slot).join("")}
${dots}
`; }, // Swiping is done by hand (pointer events + transform), so it works with a mouse as well as touch, // follows the finger, resists at the ends and settles on the nearest card or in the flick direction. after(el, c, ctx) { ctx.renderChildren(el); const sw = el.querySelector(".sw"), track = el.querySelector(".sw-track"); if (!track) return; const cache = ctx.cache, n = track.children.length; const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches; let page = Math.min(cache.page || 0, n - 1); let hideTimer; const showDots = () => { sw.classList.add("dots-on"); clearTimeout(hideTimer); hideTimer = setTimeout(() => { if (!sw.querySelector(":focus-visible")) sw.classList.remove("dots-on"); }, 2500); }; const mark = () => el.querySelectorAll(".sw-dot").forEach((d, i) => d.setAttribute("aria-selected", String(i === page))); const go = (p, animate = true) => { page = Math.max(0, Math.min(n - 1, p)); cache.page = page; track.style.transition = animate && !reduce() ? "transform .38s cubic-bezier(.2,.8,.2,1)" : "none"; track.style.transform = `translateX(${-page * 100}%)`; mark(); }; cache.go = (p) => { go(p); showDots(); }; cache.n = n; go(page, false); let drag = null; track.addEventListener("pointerdown", (e) => { if ((e.pointerType === "mouse" && e.button !== 0) || e.target.closest("input, select, textarea") || n < 2) return; drag = { x: e.clientX, y: e.clientY, t: performance.now(), dx: 0, id: e.pointerId, on: false }; cache.pausedUntil = Date.now() + 20000; }); track.addEventListener("pointermove", (e) => { if (!drag || e.pointerId !== drag.id) return; const dx = e.clientX - drag.x, dy = e.clientY - drag.y; if (!drag.on) { if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) { // horizontal: it's a swipe drag.on = true; cache.busy = true; track.setPointerCapture(e.pointerId); track.style.transition = "none"; sw.classList.add("dragging"); showDots(); } else if (Math.abs(dy) > 8) { drag = null; return; } // vertical: let the page scroll else return; } drag.dx = dx; const atEdge = (page === 0 && dx > 0) || (page === n - 1 && dx < 0); track.style.transform = `translateX(calc(${-page * 100}% + ${atEdge ? dx / 3 : dx}px))`; }); const end = () => { const d = drag; drag = null; if (!d?.on) return; sw.classList.remove("dragging"); cache.busy = false; const speed = d.dx / Math.max(1, performance.now() - d.t); // px per ms let p = page; if (d.dx < -sw.clientWidth / 4 || speed < -0.35) p++; else if (d.dx > sw.clientWidth / 4 || speed > 0.35) p--; go(p); showDots(); // The release after a swipe must not press a button underneath. Only for a moment: touch screens // send no click after a swipe, and a lasting flag would eat the next real tap. cache.swallowUntil = Date.now() + 350; if (cache.dirty) { cache.dirty = false; setTimeout(() => ctx.rerender(), 400); } }; track.addEventListener("pointerup", end); track.addEventListener("pointercancel", end); track.addEventListener("lostpointercapture", end); track.addEventListener("dragstart", (e) => e.preventDefault()); // images would start a native drag and cancel the swipe track.addEventListener("click", (e) => { if (Date.now() < (cache.swallowUntil || 0)) { e.stopPropagation(); e.preventDefault(); } }, true); // Two-finger swipe on a trackpad: horizontal wheel movement turns one page at a time. let wheelSum = 0, wheelLock = 0; sw.addEventListener("wheel", (e) => { if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || n < 2) return; e.preventDefault(); if (Date.now() < wheelLock) return; wheelSum += e.deltaX; if (Math.abs(wheelSum) > 50) { cache.pausedUntil = Date.now() + 20000; cache.go(page + (wheelSum > 0 ? 1 : -1)); wheelSum = 0; wheelLock = Date.now() + 450; } }, { passive: false }); track.addEventListener("keydown", (e) => { if (e.key === "ArrowRight") { e.preventDefault(); cache.go(page + 1); } if (e.key === "ArrowLeft") { e.preventDefault(); cache.go(page - 1); } }); sw.addEventListener("pointermove", (e) => { if (e.pointerType === "mouse") showDots(); }); sw.addEventListener("focusin", () => { if (sw.querySelector(":focus-visible")) showDots(); }); // One autoplay timer per card that survives redraws; it always drives the latest card. const every = Math.max(3, Number(c.autoplay) || 0) * 1000; if (!(Number(c.autoplay) > 0) || n < 2) { clearInterval(cache.timer); cache.timer = null; return; } if (cache.timer && cache.every === every) return; clearInterval(cache.timer); cache.every = every; cache.timer = setInterval(() => { if (Date.now() < (cache.pausedUntil || 0) || cache.busy || document.body.classList.contains("editing") || reduce()) return; cache.go(((cache.page || 0) + 1) % cache.n); // cache.go always drives the latest drawing of this card }, every); }, }; // ================================================================ phone versions // Phone dashboards give every card four sizes, like widgets on a phone: Small (half width, square), // Medium and Large (full width, two and four rows high) and Row (full width, one line). // Medium and Large draw the card itself (tuned for phones in style.css). Small and Row draw the card's // summary: an icon, a name, the one figure that matters and at most one control. A card can ask for its // own drawing at a size instead ("card"), or leave a size out when it has no sensible form there. // // A summary is { ico, name, value, unit, word, sub, tone, on, tint, meter, ctl, tap }: // value + unit a figure, drawn large (word: the same, but words, such as "Open" or "Cleaning…") // tone "ok" | "warn" | "crit" | "accent" colours the figure; on lights the card like a lit tile // meter 0–100, a thin bar under the figure (brightness, battery, a gauge) // ctl HTML for one control (a switch, a round button, two at most) // tap { act, e, v } for a tap anywhere else on the card const ico = (name) => Icons.svg(name); const numOf = (s, decimals) => (s && s.state !== "" && isFinite(Number(s.state)) ? fmtNum(s.state, decimals) : null); const sw = (eid, on, label) => ``; const rnd = (act, eid, icn, label, v, extra = "") => ``; const more = (eid) => (eid ? { act: "more", e: eid } : null); const SUMMARY = { entity(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const d = domainOf(c.entity), tap = c.tap === "auto" || !c.tap ? (TOGGLE_DOMAINS.has(d) ? "toggle" : "more-info") : c.tap; const n = numOf(s); return { ico: icon(s, c.icon), name: friendly(s, c.name), value: n, unit: n != null ? s.attributes.unit_of_measurement || "" : "", word: n == null ? fmtState(s) : "", sub: c.showChanged ? relTime(s.last_changed) : "", on: isOn(s), tint: domainOf(c.entity) === "light" ? lightTint(s) : "", tap: tap === "none" ? null : { act: tap === "toggle" ? "toggle" : "more", e: c.entity } }; }, value(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const v = Number(s.state), lvl = isFinite(v) && has(c.crit) && v > Number(c.crit) ? "crit" : isFinite(v) && has(c.warn) && v > Number(c.warn) ? "warn" : ""; const n = numOf(s, c.decimals); return { ico: icon(s, c.icon), name: friendly(s, c.name), value: n, word: n == null ? cap(s.state) : "", unit: c.unit || s.attributes.unit_of_measurement || "", tone: lvl, sub: lvl ? (lvl === "crit" ? t("Critical") : t("High")) : "", tap: more(c.entity) }; }, gauge(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const v = Number(s.state), min = Number(c.min) || 0, max = Number(c.max) || 100; const tone = !isFinite(v) ? "" : has(c.red) && v >= Number(c.red) ? "crit" : has(c.yellow) && v >= Number(c.yellow) ? "warn" : has(c.green) && v >= Number(c.green) ? "ok" : ""; return { ico: icon(s), name: friendly(s, c.name), value: numOf(s) ?? "–", unit: s.attributes.unit_of_measurement || "", tone, meter: isFinite(v) ? Math.max(0, Math.min(100, ((v - min) / (max - min || 1)) * 100)) : null, tap: more(c.entity) }; }, climate(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const a = s.attributes, step = Number(c.step) || 0.5; return { ico: icon(s), name: friendly(s, c.name), value: a.temperature != null ? fmtNum(a.temperature) : "–", unit: "°", sub: [a.current_temperature != null && t("Now {0}°", fmtNum(a.current_temperature)), st(s.state)].filter(Boolean).join(" · "), on: s.state !== "off", tap: more(c.entity), ctl: rnd("temp", c.entity, "minus", t("Lower to {0}°", (a.temperature ?? 0) - step), -step) + rnd("temp", c.entity, "plus", t("Raise to {0}°", (a.temperature ?? 0) + step), step) }; }, graph(c, { ha }) { const s = ha.states[(c.entities || [])[0]]; if (!s) return null; const n = numOf(s); return { ico: ico("chart"), name: c.title || friendly(s), value: n, word: n == null ? fmtState(s) : "", unit: n != null ? s.attributes.unit_of_measurement || "" : "", sub: (c.entities || []).length > 1 ? (c.entities.slice(1).map((e) => ha.states[e] && fmtState(ha.states[e])).filter(Boolean).join(" · ")) : "", tap: more(c.entities[0]) }; }, clock(c, { ha }) { const s = c.weather && ha.states[c.weather]; return { ico: ico("clock"), name: s ? `${st(s.state)} · ${s.attributes.temperature ?? "–"}°` : "", value: `${clockHTML(c.h24, false, c.date)}`, tap: more(c.weather) }; }, weather(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; return { ico: ico(WX[s.state] || "cloud"), name: friendly(s, c.name), value: s.attributes.temperature ?? "–", unit: "°", sub: st(s.state), tap: more(c.entity) }; }, forecast(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return null; const d = cache.daily?.[0]; return { ico: ico(WX[s.state] || "cloud"), name: friendly(s), value: s.attributes.temperature ?? "–", unit: "°", sub: d ? t("High {0}° · Low {1}°", Math.round(d.temperature), Math.round(d.templow ?? d.temperature)) : st(s.state), tap: more(c.entity) }; }, list(c, { ha }) { const ids = (c.entities || []).filter((e) => ha.states[e]); if (!ids.length) return null; const on = ids.filter((e) => isOn(ha.states[e])); return { ico: ico("list"), name: c.title || t("Devices"), value: on.length, unit: `/ ${ids.length}`, sub: on.length ? on.map((e) => friendly(ha.states[e])).join(", ") : t("All off"), on: on.length > 0 }; }, button: (c) => ({ ico: icon(null, c.icon || "sparkles"), name: c.label || t("Run"), tap: { act: "button" } }), media(c, { ha, cache }) { const ids = mediaIds(c).filter((e) => ha.states[e]); if (!ids.length) return null; const sel = mediaSelected(ids, ha, cache), s = ha.states[sel], a = s.attributes; const off = ["off", "standby", "unavailable"].includes(s.state), playing = s.state === "playing"; return { ico: icon(s), name: friendly(s), sub: off ? st(s.state) : [a.media_title, a.media_artist].filter(Boolean).join(" · ") || st(s.state), on: playing, tap: more(sel), art: c.art !== false && !off ? a.entity_picture : "", ctl: off ? rnd("svc", sel, "power", t("Turn on"), "media_player.turn_on") : rnd("svc", sel, playing ? "pause" : "play", playing ? t("Pause") : t("Play"), "media_player.media_play_pause") }; }, todo(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return null; const open = (cache.items || []).filter((i) => i.status !== "completed"); return { ico: ico("checkbox"), name: c.title || friendly(s), value: numOf(s) ?? open.length, sub: open.slice(0, 3).map((i) => i.summary).join(", ") || t("Nothing on the list."), tap: more(c.entity) }; }, people(c, { ha }) { const ids = W.people.entities(c, ha).filter((e) => ha.states[e]); if (!ids.length) return null; const home = ids.filter((e) => ha.states[e].state === "home"); return { ico: ico("people"), name: t("Home"), value: home.length, unit: `/ ${ids.length}`, on: home.length > 0, sub: home.length ? home.map((e) => friendly(ha.states[e]).split(" ")[0]).join(", ") : t("Nobody home") }; }, timer(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const a = s.attributes, dur = toSecs(a.duration), active = s.state === "active"; const left = active && a.finishes_at ? (new Date(a.finishes_at) - Date.now()) / 1000 : s.state === "paused" ? toSecs(a.remaining) : dur; const first = String(c.presets || "").split(/[,\s]+/).map(Number).find((m) => m > 0); return { ico: ico("timer"), name: friendly(s, c.name), on: active, sub: st(s.state), value: `${hms(left)}`, ctl: active ? rnd("timer", c.entity, "pause", t("Pause"), "pause") : s.state === "paused" ? rnd("timer", c.entity, "play", t("Resume"), "start") : dur ? rnd("timer", c.entity, "play", t("Start"), "start") : first ? rnd("timer", c.entity, "play", `${first} ${t("min")}`, "start", `data-d="${first}"`) : "" }; }, covers(c, { ha }) { const ids = (c.entities || []).filter((e) => ha.states[e]); if (!ids.length) return null; const pos = ids.map((e) => coverPos(ha.states[e])), avg = Math.round(pos.reduce((x, y) => x + y, 0) / pos.length); const one = ids.length === 1, s = ha.states[ids[0]], all = esc(ids.join(",")); const moving = ids.find((e) => ["opening", "closing"].includes(ha.states[e].state)); return { ico: ico("blinds"), name: c.title || (one ? friendly(s) : t("Blinds")), value: avg, unit: "%", meter: avg, on: avg > 0, sub: moving ? st(ha.states[moving].state) : one ? (avg >= 100 ? t("Open") : avg <= 0 ? t("Closed") : t("Partly open")) : t("{0} of {1} open", pos.filter((p) => p > 0).length, ids.length), tap: one ? more(ids[0]) : null, ctl: `` }; }, alarm(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const armed = s.state.startsWith("armed"), word = { disarmed: t("Off"), armed_home: t("On · home"), armed_away: t("On · away"), armed_night: t("On · night"), armed_vacation: t("On · vacation"), arming: t("Turning on…"), pending: t("Entry delay…"), triggered: t("Alarm!") }[s.state] || st(s.state); return { ico: ico("shield"), name: friendly(s), word, tone: s.state === "triggered" ? "crit" : armed ? "ok" : s.state === "pending" ? "warn" : "", on: armed, tap: more(c.entity) }; }, notifications(c, { ha }) { const notes = c.notes !== false ? Object.values(ha.notifications || {}) : []; const ups = c.updates !== false ? Object.values(ha.states).filter((s) => s.entity_id.startsWith("update.") && s.state === "on") : []; const n = notes.length + ups.length; return { ico: ico("bell"), name: t("Notifications"), value: n, tone: n ? "accent" : "", sub: n ? [...ups.map((u) => u.attributes.title || friendly(u)), ...notes.map((x) => x.title || x.message)].slice(0, 2).join(" · ") : t("All caught up") }; }, energy(c, { ha }) { if (!c.solar && !c.grid && !c.battery) return null; const S = (k) => (c[k] ? ha.states[c[k]] : null); const solar = Math.max(0, watts(S("solar")) ?? 0); let grid = watts(S("grid")) ?? 0; if (c.invertGrid) grid = -grid; let batt = watts(S("battery")) ?? 0; if (c.invertBattery) batt = -batt; const home = Math.max(0, watts(S("home")) ?? solar + Math.max(0, grid) + Math.max(0, -batt) - Math.max(0, -grid) - Math.max(0, batt)); const [v, u] = fmtW(home).split(" "); return { ico: ico("bolt"), name: t("House"), value: v, unit: u, sub: [c.solar && t("Sun {0}", fmtW(solar)), c.grid && (grid < -1 ? t("Sending back {0}", fmtW(-grid)) : t("Grid {0}", fmtW(Math.max(0, grid))))].filter(Boolean).join(" · ") }; }, room(c, { ha }) { const area = ha.reg?.areas?.find((a) => a.area_id === c.area), ids = roomEntities(c, ha); if (!area && !ids.length) return null; const S = (e) => ha.states[e], name = c.name || area?.name || t("Room"); const temp = [c.temperature, area?.temperature_entity_id].find((e) => e && S(e)) || ids.find((e) => domainOf(e) === "sensor" && S(e).attributes.device_class === "temperature"); const lights = ids.filter((e) => domainOf(e) === "light" && !Array.isArray(S(e).attributes.entity_id)), on = lights.filter((e) => S(e).state === "on"); const open = ids.filter((e) => domainOf(e) === "binary_sensor" && ["door", "window", "opening", "garage_door"].includes(S(e).attributes.device_class) && S(e).state === "on"); return { ico: icon(null, c.icon || "sofa"), name, value: temp ? numOf(S(temp)) : null, unit: temp ? "°" : "", on: on.length > 0, tone: open.length ? "warn" : "", sub: open.length ? t("{0} open", open.length) : lights.length ? (on.length ? t("{0} of {1} lights on", on.length, lights.length) : t("Lights off")) : "", ctl: lights.length ? `` : "" }; }, vacuum(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const a = s.attributes, cleaning = s.state === "cleaning", batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level); return { ico: ico("vacuum"), name: friendly(s, c.name), word: cleaning ? t("Cleaning…") : s.state === "returning" ? t("Returning") : st(s.state), sub: isFinite(batt) ? t("Battery {0}%", Math.round(batt)) : "", meter: isFinite(batt) ? batt : null, on: cleaning, tone: s.state === "error" ? "crit" : "", tap: more(c.entity), ctl: rnd("svc", c.entity, cleaning ? "pause" : "play", cleaning ? t("Pause") : t("Start"), `vacuum.${cleaning ? "pause" : "start"}`) }; }, appliance(c, { ha }) { if (c.kind === "litterbox") { if (!c.status) return null; const { phase, word } = litterPhase(c, ha), v = ha.states[c.visits]; return { ico: ico(phase === "attention" ? "shield" : "paw"), name: c.name || t("Litter box"), word, tone: phase === "attention" ? "warn" : phase === "cleaning" ? "accent" : "", sub: v && isFinite(Number(v.state)) ? `${Math.round(Number(v.state))} ${Number(v.state) === 1 ? t("visit") : t("visits")}` : "", ctl: c.cleanBtn && ha.states[c.cleanBtn] ? rnd("svc", c.cleanBtn, "sparkles", t("Clean now"), domainOf(c.cleanBtn) === "script" ? "script.turn_on" : "button.press", phase === "cleaning" ? "disabled" : "") : "" }; } if (!c.status && !c.power && !c.remaining) return null; const phase = appliancePhase(c, ha), left = ["running", "paused"].includes(phase) ? remainingMinutes(c, ha) : null; 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 ii = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer"; return { ico: ico(phase === "done" ? "check" : ii), name: c.name || t("Appliance"), word: left != null ? fmtDuration(left) : label, sub: left != null ? label : "", on: ["running", "done"].includes(phase), tone: phase === "done" ? "ok" : phase === "attention" ? "warn" : phase === "error" ? "crit" : phase === "running" ? "accent" : "", meter: Number.isFinite(Number(ha.states[c.progress]?.state)) && phase === "running" ? Number(ha.states[c.progress].state) : null }; }, camera(c, { ha }) { const s = ha.states[c.entity]; return s ? { ico: ico("camera"), name: friendly(s), sub: t("Tap to watch"), tap: more(c.entity) } : null; }, car(c, { ha }) { const S = (k) => (c[k] ? ha.states[c[k]] : null), level = S("level"), range = S("range"), lock = S("lock"); if (!level && !range && !lock) return null; const pct = numOf(level), charging = c.kind !== "fuel" && (truthy(S("charging")) || (S("chargeSwitch")?.state === "on" && domainOf(c.chargeSwitch) === "switch")); const locked = lock?.state === "locked"; return { ico: ico("car"), name: c.name || t("Car"), value: pct != null ? Math.round(Number(level.state)) : null, unit: pct != null ? "%" : "", meter: pct != null ? Number(level.state) : null, tone: pct != null && Number(level.state) <= 20 ? "warn" : charging ? "ok" : "", on: charging, sub: [charging && t("Charging"), range && `${fmtNum(range.state)} ${range.attributes.unit_of_measurement || "km"}`].filter(Boolean).join(" · "), ctl: lock ? rnd("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked, tap to unlock") : t("Unlocked, tap to lock")) : "" }; }, parcels(c, { ha }) { const list = (c.entities || []).map((e) => ha.states[e]).filter(Boolean); if (!list.length) return null; const coming = list.flatMap((s) => parcelsOf(s)).filter((p) => !p.done).sort((a, b) => (a.date?.getTime() ?? 9e15) - (b.date?.getTime() ?? 9e15)); const today = coming.filter((p) => p.date && dayLabel(p.date) === t("Today")).length; return { ico: ico("package"), name: c.title || t("Parcels"), value: coming.length, tone: today ? "accent" : "", sub: today ? t("{0} today", today) : coming[0] ? `${coming[0].name}${coming[0].date ? ` · ${dayLabel(coming[0].date)}` : ""}` : t("Nothing on the way"), tap: more(c.entities[0]) }; }, air(c, { ha }) { const sensors = (c.entities?.length ? c.entities : W.air.entities(c, ha)).map((e) => ha.states[e]).filter((s) => s && AIR[s.attributes.device_class] && isFinite(Number(s.state))); if (!sensors.length) return null; const lv = sensors.map((s) => ({ s, lvl: AIR[s.attributes.device_class].lv(Number(s.state)) })).sort((a, b) => b.lvl - a.lvl), worst = lv[0]; const co2 = sensors.find((s) => AIR[s.attributes.device_class].k === "co2"); return { ico: ico("air"), name: c.title || t("Air quality"), word: t(["Good", "Fair", "Poor"][worst.lvl]), tone: ["ok", "warn", "crit"][worst.lvl], sub: worst.lvl ? friendly(worst.s) : co2 ? `CO₂ ${fmtState(co2)}` : "", tap: more(worst.s.entity_id) }; }, usage(c, { ha, cache }) { const s = ha.states[c.entity]; if (!s) return null; const liters = (c.kind || "water") === "water" && s.attributes.unit_of_measurement === "m³"; const days = (cache.days || []).map((d) => (liters ? d.v * 1000 : d.v)), today = days[days.length - 1], yest = days[days.length - 2]; const unit = liters ? "L" : s.attributes.unit_of_measurement || ""; return { ico: ico({ water: "droplet", gas: "flame", electricity: "bolt" }[c.kind] || "droplet"), name: c.title || friendly(s), value: today != null ? fmtNum(today) : "–", unit, sub: yest != null ? t("yesterday {0}", `${fmtNum(yest)} ${unit}`) : t("Today so far"), tap: more(c.entity) }; }, solar(c, { ha }) { const today = ha.states[c.today], tomorrow = ha.states[c.tomorrow]; if (!today && !tomorrow) return null; const s = today || tomorrow; return { ico: ico("solar"), name: c.title || t("Solar forecast"), value: numOf(s) ?? "–", unit: s.attributes.unit_of_measurement || "kWh", sub: today && tomorrow ? `${t("Tomorrow")} ${fmtNum(tomorrow.state)} ${tomorrow.attributes.unit_of_measurement || "kWh"}` : today ? t("Today") : t("Tomorrow"), tap: more(c.today || c.tomorrow) }; }, gate(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const d = domainOf(c.entity), open = ["open", "opening", "unlocked", "unlocking", "on"].includes(s.state), moving = ["opening", "closing", "locking", "unlocking"].includes(s.state); const name = c.name || friendly(s), label = d === "button" ? t("Press") : moving ? st(s.state) : open ? (d === "lock" ? t("Lock") : t("Close")) : d === "lock" ? t("Unlock") : t("Open"); const k = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage"; return { ico: ico(open && c.kind === "door" ? "door-open" : k), name, word: d === "button" ? "" : open ? t("Open") : st(s.state), tone: open ? "warn" : "", on: open, sub: relTime(s.last_changed), // a round button with an arrow on a Small card, the word beside it in a Row ctl: `` }; }, sports(c, { ha }) { const s = ha.states[c.entity]; if (!s) return null; const a = s.attributes, state = String(s.state).toUpperCase(); if (!a.opponent_name) return { ico: ico("trophy"), name: a.team_name || friendly(s), sub: t("No match planned.") }; const home = a.team_homeaway === "away" ? "opponent" : "team", away = home === "team" ? "opponent" : "team"; const ab = (p) => a[`${p}_abbr`] || String(a[`${p}_name`] || "").slice(0, 3).toUpperCase(); const kick = a.date ? new Date(a.date) : null; return { ico: ico("trophy"), name: `${ab(home)} – ${ab(away)}`, on: state === "IN", tone: state === "IN" ? "accent" : "", value: state === "PRE" ? (kick ? kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "–") : `${a[`${home}_score`] ?? 0}–${a[`${away}_score`] ?? 0}`, sub: state === "PRE" ? (kick ? dayLabel(kick) : "") : state === "IN" ? a.clock || t("Live") : t("Full time"), tap: more(c.entity) }; }, f1(c, { ha }) { const next = ha.states[c.next]; if (!next) return null; const a = next.attributes, iso = a.race_start || a.race_date || a.race_start_local, at = iso ? new Date(iso) : null; let when = at ? dayLabel(at) : ""; if (at && at > Date.now()) { const m = Math.round((at - Date.now()) / 60000), dd = Math.floor(m / 1440); when = dd ? t("in {0} d {1} h", dd, Math.floor((m % 1440) / 60)) : t("in {0} h {1} min", Math.floor(m / 60), m % 60); } return { ico: ico("flag"), name: a.race_name || a.raceName || t("Next race"), word: when, sub: [a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · "), tap: more(c.next) }; }, calendar(c, { ha, cache }) { if (!c.entities?.length) return null; const now = new Date(), events = []; for (const eid of c.entities) for (const ev of cache.res?.[eid]?.events || []) { const s = parseWhen(ev.start), e = parseWhen(ev.end); if (e >= now) events.push({ ...ev, s, allDay: typeof ev.start === "string" ? !ev.start.includes("T") : !!ev.start?.date }); } events.sort((x, y) => x.s - y.s); const ev = events[0]; // the next event as the name; its time today as the figure, else the day const today = ev && dayLabel(ev.s) === t("Today"); return { ico: ico("calendar"), name: ev ? ev.summary : c.title || t("Upcoming"), tap: more(c.entities[0]), value: ev && today && !ev.allDay ? ev.s.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : null, word: ev ? (today && !ev.allDay ? "" : dayLabel(ev.s)) : cache.res ? t("Nothing planned") : t("Loading…"), sub: c.title || "" }; }, waste(c, { ha }) { const items = wasteItems(c, ha).filter((i) => i.days <= (Number(c.within) || 31)); if (!items.length) return null; const first = items.filter((i) => i.days === items[0].days); return { ico: `${Icons.svg("bin")}`, name: c.title || t("Waste collection"), word: whenLabel(first[0].days), sub: first.map((i) => i.name).join(" · "), tone: first[0].days <= 1 ? "warn" : "", tap: more(first[0].eid) }; }, }; /** Draw a summary as a Small, Medium or Large card, or a Row. */ function phoneCard(sum, size) { const tap = sum.tap ? `` : ""; const fig = sum.value != null && sum.value !== "" ? `${typeof sum.value === "string" && sum.value.startsWith("<") ? sum.value : esc(sum.value)}${sum.unit === "°" ? "°" : sum.unit ? `${esc(sum.unit)}` : ""}` : sum.word ? `${esc(sum.word)}` : ""; const meter = sum.meter != null ? `` : ""; const art = sum.art ? `` : `${sum.ico || ""}`; const cls = `phc phc-${size} ${sum.on ? "on" : ""} ${sum.tone ? `tone-${sum.tone}` : ""}`; const style = sum.tint ? ` style="--phc-tint:${esc(sum.tint)}"` : ""; if (size === "row") { return `
${tap}${art}${esc(sum.name)}${sum.sub ? `${esc(sum.sub)}` : ""} ${fig}${sum.ctl ? `${sum.ctl}` : ""}
`; } if (size === "m") { return `
${tap}
${art}${esc(sum.name)}${sum.sub ? `${esc(sum.sub)}` : ""}${sum.ctl ? `${sum.ctl}` : ""}
${fig || meter ? `
${fig}${meter}
` : ""}
`; } return `
${tap}
${art}${sum.ctl ? `${sum.ctl}` : ""}
${fig}${meter}${esc(sum.name)}${sum.sub ? `${esc(sum.sub)}` : ""}
`; } // Which sizes each card has on a phone, and which it draws itself instead of as a summary. const PHONE = { heading: { sizes: ["row", "m"], row: "card", s: "card" }, entity: { m: "sum", l: "sum" }, button: { m: "sum", l: "sum" }, timer: { m: "sum" }, light: { s: "card" }, text: { s: "card", row: "card" }, image: { sizes: ["s", "m", "l"], s: "card" }, iframe: { sizes: ["s", "m", "l"], s: "card" }, camera: { s: "card" }, hscroll: { sizes: ["m", "l"] }, swipe: { sizes: ["s", "m", "l"], s: "card" }, }; for (const [k, def] of Object.entries(W)) def.phone = { sizes: ["s", "m", "l", "row"], ...(PHONE[k] || {}) }; // A light as one row: the fader the group card uses for each lamp, so you can still dim it. function lightRow(c, ha) { const s = ha.states[c.entity]; if (!s) return null; const name = friendly(s, c.name); return `
${fader(c.entity, s, name, ` ${esc(name)}${esc(bigText(s, true))}${sw(c.entity, isOn(s), name)}`, "lf-row")}
`; } /** The phone version of a card at a size, or null to draw the card as it is. */ W.__phoneRender = (type, c, ctx, size) => { const def = W[type]; if (!def) return null; if ((size === "m" || size === "l") ? def.phone[size] !== "sum" : def.phone[size] === "card") return null; if (type === "light" && size === "row") return lightRow(c, ctx.ha); const sum = SUMMARY[type]?.(c, ctx); return sum ? phoneCard(sum, size) : null; }; window.Widgets = W; function beep() { try { const ac = new (window.AudioContext || window.webkitAudioContext)(); [0, 0.35, 0.7].forEach((t0) => { const o = ac.createOscillator(), g = ac.createGain(); o.frequency.value = 880; g.gain.setValueAtTime(0.0001, ac.currentTime + t0); g.gain.exponentialRampToValueAtTime(0.3, ac.currentTime + t0 + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + t0 + 0.25); o.connect(g).connect(ac.destination); o.start(ac.currentTime + t0); o.stop(ac.currentTime + t0 + 0.3); }); } catch { /* no audio */ } } window.HAFmt = { slideshow, beep, hms, esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap, lightTint, canColor, canTemp, WHITES, COLORS }; })();