/* 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"; } 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), render(c, { ha }) { 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 pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0; const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff"); const name = friendly(s, c.name); const onCount = members.filter((e) => ha.states[e].state === "on").length; const state = members.length ? (onCount ? t("{0} of {1} on", onCount, members.length) : t("Off")) : fmtState(s); const head = `
`; const slider = dimmable ? `` : ""; const colors = c.colors !== false ? swatches(c.entity, s, { compact: !!members.length }) : ""; // In a group, every light gets a row: its color (tap for its own settings), name, brightness and switch. const rows = members.map((eid) => { const m = ha.states[eid], on = m.state === "on", mp = on ? Math.round((m.attributes.brightness || 255) / 2.55) : 0; const tint = lightTint(m), mdim = (m.attributes.supported_color_modes || []).some((x) => x !== "onoff"); const short = friendly(m).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "") || friendly(m); return `
${esc(short)} ${mdim ? `` : `${esc(fmtState(m))}`}
${on && mdim ? `${mp}%` : ""}
`; }).join(""); return `
${head}${slider}${colors}${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 || []; } if (el.isConnected) 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; if (el.isConnected) 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); const flow = (on, d, w, cls) => { if (!on) return ""; const speed = Math.max(0.5, Math.min(5, 2400 / (w + 150))); // seconds per cycle: more power, faster dots const width = w > 1 ? Math.max(1.5, Math.min(5, 1.2 + Math.log10(w + 1))) : 1; return `${w > 1 ? `` : ""}`; }; const lines = [ flow(has.solar, "M50 14 Q86 14 86 50", sToH, "solar"), flow(has.solar && has.grid, "M50 14 Q14 14 14 50", sToG, "solar"), flow(has.solar && has.battery, "M50 14 L50 86", sToB, "solar"), flow(has.grid, "M14 50 L86 50", gToH, "grid"), flow(has.grid && has.battery, "M14 50 Q14 86 50 86", gToB, "grid"), flow(has.battery, "M50 86 Q86 86 86 50", bToH, "battery"), ].join(""); const node = (key, ico, label, value, extra = "") => `
${Icons.svg(ico)} ${esc(value)}${esc(label)}${extra}
`; const totals = (c.today || []).map((e) => ha.states[e] && `${esc(fmtState(ha.states[e]))} ${esc(friendly(ha.states[e]))}`).filter(Boolean).join(""); return `
${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 || [])]; return [...new Set(ids)].filter((e) => ha?.states?.[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: "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)); const chips = []; if (lights.length) chips.push(``); if (climate) { const cs = S(climate); chips.push(``); } for (const e of open) chips.push(``); if (motion.length) chips.push(`${Icons.svg("person")}${t("Motion")}`); for (const e of playing) chips.push(``); const devices = c.devices !== false ? ids.filter((e) => TOGGLES.includes(dom(e)) && !isGroup(e)).slice(0, 12) : []; const climateText = [temp && fmtState(S(temp)), hum && fmtState(S(hum))].filter(Boolean).join(" · "); return `
${icon(null, c.icon || "sofa")} ${esc(name)}${climateText ? `${esc(climateText)}` : ""}
${chips.length ? `
${chips.join("")}
` : ""} ${devices.length ? `
${devices.map((e) => ``).join("")}
` : ""}
`; }, lit: (c, { ha }) => { const ids = roomEntities(c, ha); return ids.some((e) => domainOf(e) === "light" && ha.states[e].state === "on") ? "lamp" : 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). 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); const detail = a.status && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : ""; const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : ""; const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : []; // battery ring around the robot const R = 46, C = 2 * Math.PI * R, frac = hasBatt ? Math.max(0, Math.min(1, batt / 100)) : 0; const ring = ``; const stat = (label, value, ico) => `
${Icons.svg(ico)}${esc(value)}${esc(label)}
`; const stats = [ hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "", c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "", c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "", ...(c.entities || []).map((x) => ha.states[x] && stat(friendly(ha.states[x]).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), ""), fmtState(ha.states[x]), "pulse")), ].filter(Boolean).join(""); const btn = (svc, label, ico, primary, disabled) => ``; return `
${Icons.svg("vacuum")} ${esc(name)}${esc(headline)}${detail ? ` · ${esc(detail)}` : ""} ${hasBatt ? `${Icons.svg(charging ? "bolt" : "battery")}${Math.round(batt)}%` : ""}
${mapUrl ? `${t(` : `
${ring}${Icons.svg("vacuum")} ${charging ? `` : ""}
${esc(headline)}${hasBatt ? `${t("Battery {0}%", Math.round(batt))}` : ""}
`}
${stats ? `
${stats}
` : ""} ${speeds.length ? `
${speeds.map((f) => ``).join("")}
` : ""}
${btn(cleaning ? "pause" : "start", cleaning ? t("Pause") : returning ? t("Resume") : t("Start"), cleaning ? "pause" : "play", true)} ${btn("return_to_base", t("Dock"), "home", false, s.state === "docked")} ${btn("locate", t("Locate"), "pin")}
`; }, lit: (c, { ha }) => (["cleaning", "returning"].includes(ha.states[c.entity]?.state) ? "on" : false), }; // ---------------------------------------------------------------- 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"]); W.appliance = { label: "Appliance", icon: "washer", size: [4, 2], fields: [ { k: "name", t: "text", l: "Name", d: "Washing machine" }, { k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" }, { k: "status", t: "entity", l: "Status (with Home Connect: Operation state — the rest fills in by itself)", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] }, { k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"] }, { k: "threshold", t: "number", l: "Running above (W)", d: 5 }, { k: "remaining", t: "entity", l: "Time remaining or finish time", domains: ["sensor"] }, { k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] }, { k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] }, { k: "door", t: "entity", l: "Door (optional)", domains: ["binary_sensor", "sensor"] }, { k: "energy", t: "entity", l: "Energy or water use (optional)", domains: ["sensor"] }, { k: "pauseBtn", t: "entity", l: "Pause button", domains: ["button"] }, { k: "resumeBtn", t: "entity", l: "Resume button", domains: ["button"] }, { k: "stopBtn", t: "entity", l: "Stop button", domains: ["button"] }, { k: "powerSwitch", t: "entity", l: "Power switch", domains: ["switch"] }, { k: "alerts", t: "entities", l: "Alerts, e.g. salt or rinse aid low", domains: ["sensor", "binary_sensor", "event"] }, { k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true }, ], // 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)) || ""; 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.status, c.power, c.remaining, c.progress, c.program, c.door, c.energy, c.pauseBtn, c.resumeBtn, c.stopBtn, c.powerSwitch, ...(c.alerts || [])].filter(Boolean), autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off" && !(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.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 }) => (["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}
`; // 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}
${chargeInfo} ${ctrls.length ? `
${ctrls.join("")}
` : ""} ${rows ? `
${rows}
` : ""}
`; }, }; 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"); 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); }, }; 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 }; })();