/* 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 ``; }, }; 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: "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 : []; 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); return `
${dimmable ? `` : ""} ${members.length ? `
${members.map((eid) => { const m = ha.states[eid]; if (!m) return ""; return ``; }).join("")}
` : ""}
`; }, 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), }; // ---------------------------------------------------------------- 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())); 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)", ph: "https://… or /local/car.png" }, { 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: "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", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean), // Layout follows the card's size (container queries in style.css): designed around 3×4, // a compact strip when short, and the car beside the numbers when wide. render(c, { ha }) { const st_ = (k) => (c[k] ? ha.states[c[k]] : null); const level = st_("level"), range = st_("range"), lock = st_("lock"), clim = st_("climate"), loc = st_("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 = st_("power"), plug = st_("plug"), limit = st_("limit"), odo = st_("odometer"); const charging = truthy(st_("charging")) || (!c.charging && power && Number(power.state) > 0); const plugged = charging || truthy(plug); const low = pct != null && pct <= 20; const fuel = c.kind === "fuel"; let status; if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`; else if (plugged) status = t("Plugged in"); else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state); else if (lock) status = lock.state === "locked" ? t("Locked") : t("Unlocked"); else status = ""; const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null; // Drawn car for when there's no photo: a side view, with a glow and a bolt while charging. const drawn = ``; const visual = `
${c.image ? `` : drawn} ${charging ? `` : ""}
`; const bar = pct == null ? "" : `
${limPct != null && !fuel ? `` : ""}
`; const action = (eid, on, ico, label) => ``; const actions = []; if (lock) { const locked = lock.state === "locked"; actions.push(action(c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); } if (clim) { const on = isOn(clim) || clim.state === "on"; actions.push(action(c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"))); } if (loc) actions.push(action(c.location, false, "pin", fmtState(loc))); const stat = (label, value) => `
${esc(value)}${esc(label)}
`; const stats = [ odo && stat(t("Odometer"), fmtState(odo)), limPct != null && !fuel && stat(t("Charge limit"), `${limPct}%`), charging && power && stat(t("Charging power"), fmtState(power)), ].filter(Boolean).join(""); return `
${esc(c.name || t("Car"))}${status ? `${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}` : ""}
${visual}
${pct != null ? `${pct}%` : ""} ${range ? `${esc(fmtState(range))}` : ""}${low ? `${t("Low")}` : ""}
${bar} ${stats ? `
${stats}
` : ""} ${actions.length ? `
${actions.join("")}
` : ""}
`; }, }; 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 ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}
`; 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" }, ], 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 }, ], 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; window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap }; })();