Files
ha-dashboard-studio/dashboard_studio/web/widgets.js
T
veenm a5823b4726 Dashboard Studio 2.2.0: guest invites, personal looks, more themes, Haven widgets and tiles
- Vacuum card: the robot drawn beside its dock by state, last clean, maintenance
- Formula One card: the official F1 look with a user-supplied logo
- Nine more themes; per-person theme, text size and language on Admin, People
- Guest invites: an expiring link and QR code for one dashboard, served on a
  LAN-only guest port, with the Home Assistant connection filtered both ways
- Haven 2.2: home screen widgets, three quick settings tiles, several homes,
  and self-updates from the Haven that Dashboard Studio serves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:31:50 +01:00

2071 lines
153 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 <button>s carrying data-act / data-e, handled by app.js.
*/
(function () {
const Icons = window.Icons;
const { t, state: st, locale: LOCALE } = window.I18n;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const domainOf = (eid) => (eid || "").split(".")[0];
const ON_STATES = new Set(["on", "open", "opening", "unlocked", "playing", "home", "heat", "cool", "auto", "heat_cool", "dry", "fan_only", "cleaning"]);
const TOGGLE_DOMAINS = new Set(["light", "switch", "fan", "input_boolean", "automation", "cover", "lock", "media_player", "scene", "script", "button", "input_button", "siren", "humidifier", "vacuum", "climate"]);
const STATELESS = new Set(["scene", "script", "button", "input_button"]);
const isOn = (s) => !!s && ON_STATES.has(s.state);
const friendly = (s, override) => override || s?.attributes?.friendly_name || s?.entity_id || "";
// ------------------------------------------------------------ icons
const DOMAIN_ICON = {
light: "bulb", switch: "plug", fan: "fan", input_boolean: "toggle", automation: "robot", cover: "window",
media_player: "speaker", climate: "thermometer", scene: "sparkles", script: "script", camera: "camera",
person: "person", device_tracker: "pin", vacuum: "vacuum", button: "button", input_button: "button",
sun: "sun", alarm_control_panel: "shield", weather: "cloud-sun", input_number: "number", number: "number", counter: "number",
};
const CLASS_ICON = {
temperature: "thermometer", humidity: "droplet", moisture: "droplet", power: "bolt", energy: "bolt", voltage: "bolt",
current: "bolt", battery: "battery", illuminance: "sun", pressure: "gauge", gas: "flame", water: "droplet",
distance: "road", speed: "wind", wind_speed: "wind", co2: "pulse", pm25: "fog", aqi: "fog",
};
const BINARY_ICON = { door: ["door", "door-open"], garage_door: ["garage", "garage"], window: ["window", "window"], opening: ["door", "door-open"], motion: ["person", "person"], occupancy: ["person", "person"], presence: ["home", "home"], smoke: ["flame", "flame"], moisture: ["droplet", "droplet"], plug: ["cable", "cable"], battery_charging: ["battery", "bolt"], lock: ["lock", "unlock"] };
const WX = { "clear-night": "moon", cloudy: "cloud", exceptional: "shield", fog: "fog", hail: "snow", lightning: "storm", "lightning-rainy": "storm", partlycloudy: "cloud-sun", pouring: "rain", rainy: "rain", snowy: "snow", "snowy-rainy": "snow", sunny: "sun", windy: "wind", "windy-variant": "wind" };
function iconName(s) {
if (!s) return "pulse";
const d = domainOf(s.entity_id), dc = s.attributes.device_class;
if (d === "binary_sensor") return (BINARY_ICON[dc] || ["pulse", "pulse"])[isOn(s) ? 1 : 0];
if (d === "sensor") return CLASS_ICON[dc] || "pulse";
if (d === "lock") return s.state === "locked" ? "lock" : "unlock";
if (d === "cover" && dc === "garage") return "garage";
if (d === "weather") return WX[s.state] || "cloud";
if (d === "media_player" && s.state === "playing") return "music";
return DOMAIN_ICON[d] || "pulse";
}
/** An override can be an icon name from the set or any text, such as an emoji. */
function icon(s, override) {
if (override) return Icons.has(override) ? Icons.svg(override) : `<span class="glyph">${esc(override)}</span>`;
return Icons.svg(iconName(s));
}
// ------------------------------------------------------------ formatting
const cap = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : "").replace(/_/g, " ");
const BINARY_LABELS = { door: ["Closed", "Open"], window: ["Closed", "Open"], garage_door: ["Closed", "Open"], opening: ["Closed", "Open"], motion: ["Clear", "Motion"], occupancy: ["Clear", "Occupied"], moisture: ["Dry", "Wet"], smoke: ["Clear", "Smoke"], presence: ["Away", "Home"], plug: ["Unplugged", "Plugged in"], battery_charging: ["Not charging", "Charging"] };
function fmtNum(v, decimals) {
const n = Number(v);
if (!isFinite(n)) return v;
if (decimals !== "" && decimals != null && !isNaN(decimals)) {
const d = Math.max(0, Math.min(6, Number(decimals)));
return n.toLocaleString(LOCALE, { minimumFractionDigits: d, maximumFractionDigits: d });
}
return n.toLocaleString(LOCALE, { maximumFractionDigits: Math.abs(n) >= 100 ? 0 : 1 });
}
function fmtState(s) {
if (!s) return t("Unavailable");
const d = domainOf(s.entity_id), a = s.attributes;
if (s.state === "unavailable" || s.state === "unknown") return st(s.state);
if (d === "binary_sensor") return t((BINARY_LABELS[a.device_class] || ["Off", "On"])[isOn(s) ? 1 : 0]);
if (d === "light") return s.state === "on" ? (a.brightness ? `${Math.round(a.brightness / 2.55)}%` : t("On")) : t("Off");
if (d === "climate") return `${st(s.state)}${a.temperature != null ? " · " + a.temperature + "°" : ""}`;
if (d === "scene") return t("Scene");
if (d === "script") return s.state === "on" ? t("Running") : t("Script");
if (d === "media_player" && a.media_title) return `${st(s.state)} · ${a.media_title}`;
if (d === "weather") return `${st(s.state)} · ${a.temperature}°`;
if (d === "device_tracker" || d === "person") return s.state === "home" ? t("Home") : s.state === "not_home" ? t("Away") : st(s.state);
if (a.unit_of_measurement && !isNaN(Number(s.state))) return `${fmtNum(s.state)} ${a.unit_of_measurement}`;
return st(s.state);
}
const relTime = (iso) => {
const sec = (Date.now() - new Date(iso).getTime()) / 1000;
if (sec < 60) return t("just now");
if (sec < 3600) return t("{0} min ago", Math.floor(sec / 60));
if (sec < 86400) return t("{0} h ago", Math.floor(sec / 3600));
return t("{0} d ago", Math.floor(sec / 86400));
};
const missing = (c, what = "an entity") => `<div class="empty">${c ? t("Can’t find {0}.", esc(c)) + "<br>" + t("Check the entity ID in the inspector.") : t("Choose {0} in the inspector.", t(what))}</div>`;
const title = (txt) => (txt ? `<div class="wt">${esc(txt)}</div>` : "");
const has = (x) => x !== "" && x != null && isFinite(Number(x));
function lampColor(s) {
const rgb = s?.attributes?.rgb_color;
return s?.state === "on" && Array.isArray(rgb) ? `rgb(${rgb.join(",")})` : null;
}
const litKind = (s) => (!isOn(s) ? false : domainOf(s.entity_id) === "light" ? "lamp" : "on");
// ------------------------------------------------------------ widgets
const W = {};
W.heading = {
label: "Heading", icon: "heading", size: [6, 1], transparent: true,
fields: [
{ k: "text", t: "text", l: "Text", d: "Welcome home" },
{ k: "subtitle", t: "text", l: "Subtitle", d: "" },
{ k: "size", t: "number", l: "Font size (px)", d: 30 },
{ k: "align", t: "seg", l: "Alignment", o: ["left", "center", "right"], d: "left" },
],
render: (c) => `<div class="heading" style="text-align:${esc(c.align)}">
<h2 class="h-main" style="--h-size:${Number(c.size) || 30}px;font-size:${Number(c.size) || 30}px">${esc(c.text)}</h2>
${c.subtitle ? `<div class="wt wrap">${esc(c.subtitle)}</div>` : ""}</div>`,
};
W.entity = {
label: "Entity tile", icon: "tile", size: [3, 2],
fields: [
{ k: "entity", t: "entity", l: "Entity" },
{ k: "name", t: "text", l: "Name" , ph: "Entity name" },
{ k: "icon", t: "icon", l: "Icon" },
{ k: "tap", t: "seg", l: "Tap action", o: ["auto", "toggle", "more-info", "none"], d: "auto" },
{ k: "showChanged", t: "check", l: "Show when it last changed", d: false },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity);
const d = domainOf(c.entity);
const tap = c.tap === "auto" ? (TOGGLE_DOMAINS.has(d) ? "toggle" : "more-info") : c.tap;
const name = friendly(s, c.name), state = fmtState(s) + (c.showChanged ? " · " + relTime(s.last_changed) : "");
const inner = `<span class="badge">${icon(s, c.icon)}</span>
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(state)}</span></span>`;
if (tap === "none") return `<div class="tile">${inner}</div>`;
const pressed = tap === "toggle" && !STATELESS.has(d) ? ` aria-pressed="${isOn(s)}"` : "";
return `<button class="tile wbtn" data-act="${tap === "toggle" ? "toggle" : "more"}" data-e="${esc(c.entity)}"${pressed}>${inner}</button>`;
},
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 `<button class="value wbtn ${lvl}" data-act="more" data-e="${esc(c.entity)}">
<span class="value-top"><span class="wt">${esc(friendly(s, c.name))}</span><span class="value-ico">${icon(s, c.icon)}</span></span>
<span class="big num">${esc(isFinite(v) ? fmtNum(s.state, c.decimals) : cap(s.state))}<span class="unit">${esc(unit)}</span></span>
${lvl ? `<span class="level">${lvl === "crit" ? t("Critical") : t("High")}</span>` : ""}</button>`;
},
};
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 `<button class="gauge wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(friendly(s, c.name))}, ${esc(fmtState(s))}">
<svg viewBox="0 0 100 82" aria-hidden="true">
<path d="${arc(50, 50, 40, a0, a0 + span)}" stroke="var(--d-fill)" stroke-width="8" fill="none" stroke-linecap="round"/>
${frac > 0.001 ? `<path d="${arc(50, 50, 40, a0, a0 + span * frac)}" stroke="${color}" stroke-width="8" fill="none" stroke-linecap="round"/>` : ""}
<text x="50" y="53" text-anchor="middle" class="num" font-size="20" font-weight="700" fill="currentColor">${esc(isFinite(v) ? fmtNum(v) : "–")}</text>
<text x="50" y="67" text-anchor="middle" font-size="9" fill="var(--d-text-2)">${esc(unit)}</text>
</svg><span class="wt">${esc(friendly(s, c.name))}</span></button>`;
},
};
// ---------------------------------------------------------------- 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 `<div class="swatches ${compact ? "compact" : ""}" role="group" aria-label="${t("Colors")}">${(compact ? list.slice(0, 7) : list).map(([v, col, lbl]) =>
`<button class="swatch wbtn" style="--sw:${col}" data-act="light-color" data-e="${esc(eid)}" data-v="${v}" aria-label="${esc(t(lbl))}" title="${esc(t(lbl))}"></button>`).join("")}
<button class="swatch more wbtn" data-act="light-popup" data-e="${esc(eid)}" aria-label="${t("More colors and settings")}" title="${t("More colors and settings")}">${Icons.svg("palette")}</button></div>`;
}
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 = `<div class="light-head">
<button class="badge light-badge wbtn" data-act="light-popup" data-e="${esc(c.entity)}" aria-label="${esc(t("Settings for {0}", name))}">${icon(s)}</button>
<button class="tile-text light-toggle wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}"><span class="name">${esc(name)}</span><span class="state">${esc(state)}${pct && members.length ? ` · ${pct}%` : ""}</span></button>
<button class="light-switch wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(c.entity)}" aria-label="${esc(name)}"><span class="switch"></span></button></div>`;
const slider = dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(t("{0} brightness", name))}">` : "";
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 `<div class="lm-row ${on ? "on" : ""}">
<button class="lm-dot wbtn" style="${tint ? `--tint:${tint}` : ""}" data-act="light-popup" data-e="${esc(eid)}" aria-label="${esc(t("Settings for {0}", friendly(m)))}">${icon(m)}</button>
<div class="lm-main"><span class="lm-name">${esc(short)}</span>
${mdim ? `<input class="slider lm-slider" type="range" min="1" max="100" value="${mp || 1}" style="--pct:${mp}%" data-act="brightness" data-e="${esc(eid)}" aria-label="${esc(t("{0} brightness", friendly(m)))}">` : `<span class="lm-state">${esc(fmtState(m))}</span>`}</div>
<span class="lm-pct num">${on && mdim ? `${mp}%` : ""}</span>
<button class="lm-switch wbtn" role="switch" aria-checked="${on}" data-act="toggle" data-e="${esc(eid)}" aria-label="${esc(friendly(m))}"><span class="switch"></span></button>
</div>`;
}).join("");
return `<div class="light ${members.length ? "is-group" : ""}">${head}${slider}${colors}${rows ? `<div class="light-members2" role="group" aria-label="${t("Lights in {0}", esc(name))}">${rows}</div>` : ""}</div>`;
},
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 `<div class="climate">
<div class="wt">${esc(friendly(s, c.name))} · ${t("Now")} ${esc(a.current_temperature ?? "–")}°</div>
<div class="temps">
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${-step}" aria-label="${t("Lower to {0}°", esc((a.temperature ?? 0) - step))}">${Icons.svg("minus")}</button>
<div class="target num" aria-live="polite">${esc(a.temperature ?? "–")}°</div>
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${step}" aria-label="${t("Raise to {0}°", esc((a.temperature ?? 0) + step))}">${Icons.svg("plus")}</button>
</div>
<div class="chips" role="group" aria-label="${t("Mode")}">${(a.hvac_modes || []).map((m) => `<button class="chip wbtn" aria-pressed="${m === s.state}" data-act="hvac" data-e="${e}" data-v="${esc(m)}">${esc(st(m))}</button>`).join("")}</div></div>`;
},
};
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 `<div class="empty">${t("Add one or more sensors in the inspector.")}</div>`;
return `<div class="graph">${title(c.title)}<div class="legend"></div><div class="plot"><div class="empty">${t("Loading history…")}</div></div></div>`;
},
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 `<span><svg width="18" height="8" aria-hidden="true"><line x1="1" y1="4" x2="17" y2="4" stroke="${col}" stroke-width="2.5" stroke-dasharray="${dash}" stroke-linecap="round"/></svg>${esc(friendly(s))} <b class="num">${esc(s ? fmtState(s) : "–")}</b></span>`;
}).join("");
if (!series.length || W_ < 10 || H < 10) { plot.innerHTML = `<div class="empty">${t("No numeric history for this period.")}</div>`; 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 = `<div class="empty">${t("No numeric history for this period.")}</div>`; 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 ? `<path d="${d}L${x(t1).toFixed(1)} ${H - B}L${x(Math.max(pts[0][0], t0)).toFixed(1)} ${H - B}Z" fill="${col}" opacity=".12"/>` : "";
return `${area}<path d="${d}" fill="none" stroke="${col}" stroke-width="2" stroke-dasharray="${dash}" stroke-linejoin="round" stroke-linecap="round"/>`;
}).join("");
const ticks = [0, 0.5, 1].map((f) => lo + (hi - lo) * f);
plot.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" role="img" aria-label="${t("History for the last {0} hours", hours)}">
${ticks.map((v) => `<line x1="${L}" x2="${W_}" y1="${y(v)}" y2="${y(v)}" stroke="var(--d-separator)"/><text class="axis" x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${fmtNum(v)}</text>`).join("")}
${paths}
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
}
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 ? `<button class="clock-wx wbtn" data-act="more" data-e="${esc(c.weather)}" aria-label="${esc(friendly(s))}: ${esc(st(s.state))}, ${esc(s.attributes.temperature ?? "–")}°">
<span class="clock-wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span><span class="clock-wx-temp num">${esc(s.attributes.temperature ?? "–")}°</span>
<span class="clock-wx-text">${esc(st(s.state))}</span><span class="clock-hl num" data-hl></span></button>` : "";
return `<div class="clock pos-${pos} ${bg ? "on-image" : ""}" ${bg ? 'data-act="photo-next"' : ""} style="--dim:${Math.min(90, Math.max(0, Number(c.dim ?? 30))) / 100}">
${bg ? `<button class="sr-only" data-act="photo-next">${t("Next photo")}</button>` : ""}
${bg ? `<div class="photos clock-bg" aria-hidden="true"><img class="ph-layer" alt=""><img class="ph-layer" alt=""></div><div class="clock-dim"></div>` : ""}
<div class="clock-content"><div class="clock-time" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>${wx}</div></div>`;
},
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 `<div class="time num">${time}</div>${date ? `<div class="date">${d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" })}</div>` : ""}`;
}
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 `<button class="weather wbtn" data-act="more" data-e="${esc(c.entity)}"><span class="wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span>
<span class="wx-text"><span class="wx-temp num">${esc(a.temperature ?? "–")}°</span><span class="wt">${esc(friendly(s, c.name))} · ${esc(st(s.state))}</span>
<span class="wt">${t("Humidity")} ${esc(a.humidity ?? "–")}% · ${t("Wind")} ${esc(a.wind_speed ?? "–")} ${esc(a.wind_speed_unit || "km/h")}</span></span></button>`;
},
};
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)}<div class="empty">${t("Add entities in the inspector.")}</div>`;
const rows = c.entities.map((eid) => {
const s = ha.states[eid];
if (!s) return `<div class="list-row"><span class="row-ico">${Icons.svg("pulse")}</span><span class="grow muted">${esc(eid)}</span></div>`;
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
? `<button class="list-row wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="switch"></span></button>`
: `<button class="list-row wbtn" data-act="more" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="st">${esc(fmtState(s))}</span></button>`;
}).join("");
return `${title(c.title)}<div class="list">${rows}</div>`;
},
};
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) => `<button class="btn-widget wbtn" data-act="button"><span class="badge">${icon(null, c.icon || "sparkles")}</span><span class="name">${esc(c.label)}</span></button>`,
};
// ---------------------------------------------------------------- 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 = `<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>`;
const switcher = ids.length > 1 ? `<div class="mp-switch" role="tablist" aria-label="${t("Media players")}">${ids.map((id) => {
const p = ha.states[id], on = p.state === "playing";
return `<button class="chip wbtn ${on ? "is-playing" : ""}" role="tab" aria-selected="${id === sel}" data-act="mp-select" data-v="${esc(id)}" aria-label="${esc(friendly(p))}, ${esc(st(p.state))}">${on ? bars : ""}${esc(friendly(p))}</button>`;
}).join("")}</div>` : "";
const pic = c.art !== false && a.entity_picture && !off ? `<img class="mp-art" src="${esc(a.entity_picture)}" alt="">` : `<span class="mp-art ph" aria-hidden="true">${icon(s)}</span>`;
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
? `<button class="car-act wbtn" data-act="svc" data-e="${e}" data-v="media_player.turn_on">${Icons.svg("power")}<span>${t("Turn on")}</span></button>`
: `<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="media_player.media_play_pause" aria-label="${playing ? t("Pause") : t("Play")}">${Icons.svg(playing ? "pause" : "play")}</button>
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_next_track" aria-label="${t("Next track")}">${Icons.svg("next")}</button>
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${esc(t("Volume"))} ${esc(friendly(s))}">` : ""}`;
return `<div class="media mp">${switcher}
<div class="mp-now">${pic}<div class="mp-meta">
${ids.length === 1 ? `<div class="wt">${esc(friendly(s))}</div>` : ""}
<div class="title">${esc(title)}</div>${sub ? `<div class="wt">${esc(sub)}</div>` : ""}</div></div>
<div class="ctrls">${controls}</div></div>`;
},
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) => `<div class="todo-row ${i.status === "completed" ? "done" : ""}" data-uid="${esc(i.uid)}">
<button class="todo-check wbtn" role="checkbox" aria-checked="${i.status === "completed"}" data-act="todo-toggle" data-e="${esc(c.entity)}" data-v="${esc(i.uid)}" aria-label="${esc(i.summary)}">${Icons.svg("check")}</button>
<span class="todo-text">${esc(i.summary)}${i.due ? `<small>${esc(new Date(i.due).toLocaleDateString(LOCALE, { day: "numeric", month: "short" }))}</small>` : ""}</span>
<button class="todo-del wbtn" data-act="todo-del" data-e="${esc(c.entity)}" data-v="${esc(i.uid)}" aria-label="${esc(t("Delete {0}", i.summary))}">${Icons.svg("close")}</button></div>`;
const showDone = cache.showDone ?? c.showDone;
return `<div class="todo">
<div class="todo-head"><span class="name">${esc(c.title || friendly(s))}</span><span class="todo-count num">${open.length}</span></div>
<form class="todo-add" data-e="${esc(c.entity)}"><input class="todo-input" type="text" placeholder="${t("Add an item…")}" aria-label="${t("Add an item")}" enterkeyhint="done">
<button type="button" class="todo-plus wbtn" data-act="todo-add" data-e="${esc(c.entity)}" aria-label="${t("Add")}">${Icons.svg("plus")}</button></form>
<div class="todo-list">${cache.items ? open.map(row).join("") || `<div class="todo-empty">${t("Nothing on the list.")}</div>` : `<div class="todo-empty">${t("Loading…")}</div>`}
${done.length ? `<button class="todo-more wbtn" data-act="todo-showdone">${showDone ? t("Hide completed ({0})", done.length) : t("Show completed ({0})", done.length)}</button>${showDone ? done.map(row).join("") : ""}` : ""}</div></div>`;
},
async after(el, c, ctx) {
const s = ctx.ha.states[c.entity];
if (!s) return;
// The list's state is the number of open items; refetch when it (or the card) changes.
const key = `${c.entity}|${s.last_updated}|${ctx.cache.bump || 0}`;
if (ctx.cache.key !== key) {
ctx.cache.key = key;
try { ctx.cache.items = (await ctx.ha.send({ type: "todo/item/list", entity_id: c.entity }))?.items || []; } catch { ctx.cache.items = ctx.cache.items || []; }
ctx.rerender();
return;
}
// Swipe a row to the left to delete it (touch); the ✕ does the same with a mouse.
el.querySelectorAll(".todo-row").forEach((r) => {
let x0 = null, dx = 0;
r.addEventListener("pointerdown", (e) => { if (e.pointerType !== "mouse" && !e.target.closest("button")) { x0 = e.clientX; dx = 0; } });
r.addEventListener("pointermove", (e) => { if (x0 == null) return; dx = Math.min(0, e.clientX - x0); r.style.transform = `translateX(${dx}px)`; r.style.opacity = String(1 + dx / 300); });
const end = () => { if (x0 == null) return; x0 = null; if (dx < -90) r.querySelector(".todo-del")?.click(); else { r.style.transform = ""; r.style.opacity = ""; } };
r.addEventListener("pointerup", end); r.addEventListener("pointercancel", end);
});
},
};
// ---------------------------------------------------------------- who's home
W.people = {
label: "Who's home", icon: "people", size: [4, 2],
fields: [{ k: "entities", t: "entities", l: "People (leave empty for everyone)", domains: ["person"] }, { k: "layout", t: "seg", l: "Layout", o: ["grid", "list"], d: "grid" }],
entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("person."))),
render(c, { ha }) {
const ids = (c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => e.startsWith("person."))).filter((e) => ha.states[e]);
if (!ids.length) return `<div class="empty">${t("No people found. Add person entities in Home Assistant.")}</div>`;
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 ? `<img src="${esc(s.attributes.entity_picture)}" alt="">` : `<span class="pp-init">${esc(initials)}</span>`;
return `<button class="pp ${home ? "home" : ""} wbtn" data-act="more" data-e="${esc(e)}" aria-label="${esc(`${name}: ${where}`)}">
<span class="pp-av">${pic}<span class="pp-dot" aria-hidden="true"></span></span>
<span class="pp-text"><span class="pp-name">${esc(name.split(" ")[0])}</span><span class="pp-where">${esc(where)} · ${esc(relTime(s.last_changed).replace(/ geleden| ago/, ""))}</span></span></button>`;
}).join("");
return `<div class="people ${c.layout === "list" ? "as-list" : ""}">${ppl}</div>`;
},
};
// ---------------------------------------------------------------- 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 ? `<div class="fc-hours">${cache.hourly.slice(0, Math.max(3, Number(c.hours) || 12)).map((h) =>
`<div class="fc-hour"><span class="fc-t">${esc(new Date(h.datetime).toLocaleTimeString(LOCALE, { hour: "2-digit" }))}</span>${Icons.svg(WX[h.condition] || "cloud")}<b class="num">${deg(h.temperature)}</b>${h.precipitation_probability ? `<span class="fc-rain">${Math.round(h.precipitation_probability)}%</span>` : `<span class="fc-rain"></span>`}</div>`).join("")}</div>` : "";
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 = `<div class="fc-days">${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 `<div class="fc-day"><span class="fc-dn">${i === 0 ? t("Today") : esc(new Date(d.datetime).toLocaleDateString(LOCALE, { weekday: "short" }))}</span>${Icons.svg(WX[d.condition] || "cloud")}
<span class="fc-rain">${d.precipitation_probability ? `${Math.round(d.precipitation_probability)}%` : ""}</span><span class="fc-lo num">${deg(l)}</span>
<span class="fc-bar"><i style="left:${left}%;right:${100 - right}%"></i></span><span class="fc-hi num">${deg(d.temperature)}</span></div>`;
}).join("")}</div>`;
}
return `<div class="fc">
<button class="fc-now wbtn" data-act="more" data-e="${esc(c.entity)}"><span class="fc-ico">${Icons.svg(WX[s.state] || "cloud")}</span>
<span class="fc-temp num">${esc(a.temperature ?? "–")}°</span>
<span class="fc-cond"><b>${esc(st(s.state))}</b>${today ? `<span>${t("High {0}° · Low {1}°", Math.round(today.temperature), Math.round(today.templow ?? today.temperature))}</span>` : ""}</span>
${c.sun !== false && sun ? `<span class="fc-sun"><span>${Icons.svg("sunrise")}${esc(hm(sun.next_rising))}</span><span>${Icons.svg("sunset")}${esc(hm(sun.next_setting))}</span></span>` : ""}</button>
${hourly}${daily}${!cache.daily && !cache.hourly ? `<div class="empty">${t("Loading the forecast…")}</div>` : ""}</div>`;
},
async after(el, c, ctx) {
if (!ctx.ha.states[c.entity]) return;
const cache = ctx.cache;
if (cache.key === c.entity && Date.now() - cache.at < 30 * 60 * 1000) return;
cache.key = c.entity; cache.at = Date.now();
const [daily, hourly] = await Promise.all([ctx.ha.forecast(c.entity, "daily").catch(() => null), ctx.ha.forecast(c.entity, "hourly").catch(() => null)]);
cache.daily = daily; cache.hourly = hourly;
ctx.rerender();
},
};
// ---------------------------------------------------------------- timer
// Counts down in the browser from finishes_at (ticked by app.js every second); presets start it with a duration.
const hms = (secs) => { secs = Math.max(0, Math.round(secs)); const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), x = secs % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(x).padStart(2, "0")}` : `${m}:${String(x).padStart(2, "0")}`; };
const toSecs = (v) => { const p = String(v || "0").split(":").map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p.length === 2 ? p[0] * 60 + p[1] : p[0]; };
W.timer = {
label: "Timer", icon: "timer", size: [3, 3],
fields: [
{ k: "entity", t: "entity", l: "Timer", domains: ["timer"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "presets", t: "text", l: "Quick starts in minutes", d: "5, 10, 15, 30" },
{ k: "sound", t: "check", l: "Beep when done", d: true },
],
render(c, { ha, cache }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a timer");
const a = s.attributes, e = esc(c.entity);
const dur = toSecs(a.duration);
// Spot the moment it runs out: active → idle around the finish time.
if (cache.prev === "active" && s.state === "idle" && cache.finish && Date.now() >= cache.finish - 3000) {
cache.doneUntil = Date.now() + 60000;
if (c.sound !== false) window.HAFmt.beep?.();
}
cache.prev = s.state;
cache.finish = a.finishes_at ? new Date(a.finishes_at).getTime() : null;
const done = cache.doneUntil && Date.now() < cache.doneUntil && s.state === "idle";
const left = s.state === "active" && a.finishes_at ? (new Date(a.finishes_at) - Date.now()) / 1000 : s.state === "paused" ? toSecs(a.remaining) : dur;
const R = 44, C = 2 * Math.PI * R, frac = dur ? Math.max(0, Math.min(1, left / dur)) : 0;
const presets = String(c.presets || "").split(/[,\s]+/).map(Number).filter((m) => m > 0).slice(0, 6);
const ctl = (svc, ico, lbl, data) => `<button class="round wbtn" data-act="timer" data-e="${e}" data-v="${svc}" ${data ? `data-d="${data}"` : ""} aria-label="${esc(lbl)}" title="${esc(lbl)}">${Icons.svg(ico)}</button>`;
return `<div class="tm ${s.state} ${done ? "done" : ""}">
<div class="tm-name">${esc(friendly(s, c.name))}</div>
<div class="tm-ring"><svg viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/><circle cx="50" cy="50" r="${R}" class="fill" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}" data-ring="${C.toFixed(1)}"/></svg>
<span class="tm-time num" ${s.state === "active" && a.finishes_at ? `data-countdown="${esc(a.finishes_at)}" data-dur="${dur}"` : ""} aria-live="off">${done ? t("Done!") : hms(left)}</span></div>
<div class="tm-ctrls">${done ? `<button class="chip wbtn" data-act="timer-ok" aria-pressed="true">${t("OK")}</button>`
: 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) => `<button class="chip wbtn" data-act="timer" data-e="${e}" data-v="start" data-d="${m}">${m} ${t("min")}</button>`).join("") + (dur ? ctl("start", "play", t("Start")) : "")}</div></div>`;
},
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 `<div class="empty">${t("Add one or more blinds in the inspector.")}</div>`;
const btns = (e, s) => `<div class="cv-btns">
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.open_cover" aria-label="${t("Open")}" ${coverPos(s) >= 100 ? "disabled" : ""}>${Icons.svg("arrow-up")}</button>
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.stop_cover" aria-label="${t("Stop")}">${Icons.svg("stop")}</button>
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.close_cover" aria-label="${t("Close")}" ${coverPos(s) <= 0 ? "disabled" : ""}>${Icons.svg("arrow-down")}</button></div>`;
const slider = (e, s, kind = "pos") => {
const v = kind === "pos" ? coverPos(s) : Number(s.attributes.current_tilt_position ?? 0);
return `<input class="slider thin cv-slider" type="range" min="0" max="100" value="${v}" style="--pct:${v}%" data-act="cover-${kind}" data-e="${esc(e)}" aria-label="${esc(`${friendly(s)} ${kind === "pos" ? t("position") : t("tilt")}`)}">`;
};
// a window whose blind comes down as the cover closes
const win = (s) => { const closed = 100 - coverPos(s); return `<div class="cv-win" aria-hidden="true"><div class="cv-blind" style="height:${closed}%"></div></div>`; };
const moving = (s) => (["opening", "closing"].includes(s.state) ? ` · ${st(s.state)}` : "");
if (ids.length === 1) {
const e = ids[0], s = ha.states[e];
return `<div class="cv one">${c.title ? `<div class="wt">${esc(c.title)}</div>` : ""}
<div class="cv-main">${win(s)}<div class="cv-side"><span class="name">${esc(friendly(s))}</span><span class="cv-pct num">${coverPos(s)}%${esc(moving(s))}</span>${btns(e, s)}</div></div>
${slider(e, s)}${s.attributes.current_tilt_position != null ? `<div class="cv-tilt"><span class="wt">${t("Tilt")}</span>${slider(e, s, "tilt")}</div>` : ""}</div>`;
}
return `<div class="cv">
<div class="cv-head"><span class="name">${esc(c.title || t("Blinds"))}</span>
<button class="chip wbtn" data-act="svc-many" data-v="cover.open_cover" data-ids="${esc(ids.join(","))}">${t("Open all")}</button>
<button class="chip wbtn" data-act="svc-many" data-v="cover.close_cover" data-ids="${esc(ids.join(","))}">${t("Close all")}</button></div>
<div class="cv-list">${ids.map((e) => { const s = ha.states[e]; return `<div class="cv-row">${win(s)}<div class="cv-row-main"><span class="cv-name">${esc(friendly(s))} <b class="num">${coverPos(s)}%</b>${esc(moving(s))}</span>${slider(e, s)}</div>${btns(e, s)}</div>`; }).join("")}</div></div>`;
},
};
// ---------------------------------------------------------------- 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]) => `<button class="al-mode wbtn ${s.state === st_ ? "on" : ""}" data-act="alarm" data-e="${esc(c.entity)}" data-v="${svc}" aria-pressed="${s.state === st_}">${esc(t(lbl))}</button>`).join("");
const keys = needsCode ? `<div class="al-code" aria-live="polite">${code ? "•".repeat(code.length) : `<span class="wt">${t("Enter the code")}</span>`}</div>
<div class="al-keys">${["1", "2", "3", "4", "5", "6", "7", "8", "9", "C", "0", "⌫"].map((k) => `<button class="al-key wbtn" data-act="alarm-key" data-v="${k}" aria-label="${k === "C" ? t("Clear") : k === "⌫" ? t("Delete last digit") : k}">${k}</button>`).join("")}</div>` : "";
return `<div class="al state-${esc(s.state)}">
<div class="al-head"><span class="al-shield">${Icons.svg("shield")}</span><span class="tile-text"><span class="name">${esc(friendly(s))}</span><span class="al-state">${esc(label)}</span></span></div>
${open.length && !armed ? `<button class="al-open wbtn" data-act="more" data-e="${esc(open[0].entity_id)}">${Icons.svg("door-open")}<span>${esc(t("{0} still open: {1}", open.length, open.map((o) => friendly(o)).join(", ")))}</span></button>` : ""}
${keys}
<div class="al-modes">${armed || alarm || s.state === "arming" || s.state === "pending" ? `<button class="al-mode off wbtn" data-act="alarm" data-e="${esc(c.entity)}" data-v="alarm_disarm">${t("Turn off")}</button>` : modes}</div></div>`;
},
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, "<b>$1</b>").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]*)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>').replace(/\n/g, "<br>");
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) => `<div class="nt-item update"><span class="nt-ico">${Icons.svg("sparkles")}</span><div class="nt-body"><b>${esc(u.attributes.title || friendly(u))}</b>
<span class="wt">${esc(t("{0} → {1}", u.attributes.installed_version || "?", u.attributes.latest_version || "?"))}</span>
${u.attributes.release_url ? `<a href="${esc(u.attributes.release_url)}" target="_blank" rel="noopener" class="nt-link">${t("What’s new")}</a>` : ""}</div>
<button class="chip wbtn" data-act="update-install" data-e="${esc(u.entity_id)}">${t("Install")}</button></div>`),
...notes.map((n) => `<div class="nt-item"><span class="nt-ico">${Icons.svg("bell")}</span><div class="nt-body">${n.title ? `<b>${esc(n.title)}</b>` : ""}<span class="nt-msg">${mdLite(n.message || "")}</span>
<span class="nt-time">${esc(relTime(n.created_at))}</span></div>
<button class="nt-x wbtn" data-act="note-dismiss" data-v="${esc(n.notification_id)}" aria-label="${t("Dismiss")}">${Icons.svg("close")}</button></div>`),
].join("");
return `<div class="nt"><div class="nt-head"><span class="name">${t("Notifications")}</span>${count ? `<span class="todo-count num">${count}</span>` : ""}
${notes.length > 1 ? `<button class="chip wbtn" data-act="note-dismiss-all">${t("Dismiss all")}</button>` : ""}</div>
<div class="nt-list">${items || `<div class="nt-empty">${Icons.svg("check")}<span>${t("All caught up")}</span></div>`}</div></div>`;
},
};
// ---------------------------------------------------------------- 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 `<div class="empty">${t("Choose your solar, grid or battery power sensors in the inspector.")}</div>`;
const S = (k) => (c[k] ? ha.states[c[k]] : null);
const solar = Math.max(0, watts(S("solar")) ?? 0);
let grid = watts(S("grid")) ?? 0; if (c.invertGrid) grid = -grid;
let batt = watts(S("battery")) ?? 0; if (c.invertBattery) batt = -batt;
const imp = Math.max(0, grid), exp = Math.max(0, -grid), charge = Math.max(0, batt), discharge = Math.max(0, -batt);
const home = Math.max(0, watts(S("home")) ?? solar + imp + discharge - exp - charge);
// Who feeds what: solar goes to the home first, then the battery, the rest to the grid.
const sToH = Math.min(solar, home), sRest = solar - sToH, sToB = Math.min(sRest, charge), sToG = Math.max(0, sRest - sToB);
const gToH = Math.min(imp, Math.max(0, home - sToH - discharge)), gToB = Math.max(0, charge - sToB), bToH = Math.min(discharge, Math.max(0, home - sToH));
const has = { solar: !!c.solar, grid: !!c.grid, battery: !!c.battery };
const lvl = Number(S("batteryLevel")?.state);
// Where the power is going now: the four places in one row, then each flow that runs, largest first.
// They balance: sun + grid in + battery out = house + battery in + grid out.
const fig = (w) => { const [n, u] = fmtW(w).split(" "); return `<span class="pf-fig num">${esc(n)}<small>${esc(u || "")}</small></span>`; };
const node = (key, ico, label, figure, sub) => `<div class="pf-node pf-${key}"><span class="pf-lbl">${Icons.svg(ico)}${esc(label)}</span>${figure}<span class="pf-sub">${esc(sub)}</span></div>`;
const nodes = [
has.solar ? node("sun", "sun", t("Sun"), fig(solar), solar > 1 ? t("Producing") : t("Not producing")) : "",
node("home", "home", t("House"), fig(home), t("In use")),
has.battery ? node("battery", "battery", t("Battery"), isFinite(lvl) ? `<span class="pf-fig num">${Math.round(lvl)}<small>%</small></span>` : fig(Math.abs(batt)),
charge > 1 ? t("Charging {0}", fmtW(charge)) : discharge > 1 ? t("Supplying {0}", fmtW(discharge)) : t("Idle")) : "",
has.grid ? node("grid", "plug", t("Grid"), fig(exp > imp ? exp : imp), exp > imp ? t("Sending back") : imp > 1 ? t("Drawing") : t("Idle")) : "",
].filter(Boolean);
const flows = [
["sun", t("Sun to the house"), sToH], ["sun", t("Sun to the battery"), sToB], ["sun", t("Sun to the grid"), sToG],
["grid", t("Grid to the house"), gToH], ["grid", t("Grid to the battery"), gToB], ["battery", t("Battery to the house"), bToH],
].filter(([, , w]) => w > 1).sort((a, b) => b[2] - a[2]);
const top = Math.max(1, ...flows.map(([, , w]) => w));
const totals = (c.today || []).map((e) => ha.states[e] && `<span class="pf-total"><b class="num">${esc(fmtState(ha.states[e]))}</b> ${esc(friendly(ha.states[e]))}</span>`).filter(Boolean).join("");
return `<div class="pf">
<div class="pf-head"><span class="wt">${t("Energy now")}</span></div>
<div class="pf-nodes n${nodes.length}">${nodes.join("")}</div>
<div class="pf-flows">${flows.length ? flows.map(([k, label, w]) => `<div class="pf-flow pf-${k}"><span class="pf-flow-lbl">${esc(label)}</span><span class="pf-flow-v num">${esc(fmtW(w))}</span><i style="width:${((w / top) * 100).toFixed(1)}%"></i></div>`).join("")
: `<div class="pf-none">${t("No power is flowing right now.")}</div>`}</div>
${totals ? `<div class="pf-totals">${totals}</div>` : ""}</div>`;
},
};
// ---------------------------------------------------------------- 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 `<div class="empty">${t("Choose a room or add entities in the inspector.")}</div>`;
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(`<button class="room-chip wbtn ${lightsOn.length ? "on" : ""}" data-act="svc-many" data-v="light.${lightsOn.length ? "turn_off" : "turn_on"}" data-ids="${esc((lightsOn.length ? lightsOn : lights).join(","))}" aria-label="${esc(lightsOn.length ? t("Turn off all lights in {0}", name) : t("Turn on all lights in {0}", name))}">${Icons.svg("bulb")}<span>${lightsOn.length ? t("{0} on", lightsOn.length) : t("Lights off")}</span></button>`);
if (climate) { const cs = S(climate); chips.push(`<button class="room-chip wbtn" data-act="more" data-e="${esc(climate)}">${Icons.svg("thermometer")}<span>${esc(st(cs.state))}${cs.attributes.temperature != null ? ` · ${esc(cs.attributes.temperature)}°` : ""}</span></button>`); }
for (const e of open) chips.push(`<button class="room-chip wbtn warn" data-act="more" data-e="${esc(e)}">${icon(S(e))}<span>${esc(short(e))} ${t("open")}</span></button>`);
if (motion.length) chips.push(`<span class="room-chip">${Icons.svg("person")}<span>${t("Motion")}</span></span>`);
for (const e of playing) chips.push(`<button class="room-chip wbtn" data-act="more" data-e="${esc(e)}">${Icons.svg("music")}<span>${esc(S(e).attributes.media_title || t("Playing"))}</span></button>`);
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 `<div class="room ${c.image ? "on-image" : ""}" ${c.image ? `style="--room-img:url('${esc(c.image).replace(/'/g, "%27")}')"` : ""}>
<div class="room-head"><span class="badge">${icon(null, c.icon || "sofa")}</span>
<span class="tile-text"><span class="name room-name">${esc(name)}</span>${climateText ? `<span class="state num">${esc(climateText)}</span>` : ""}</span></div>
${chips.length ? `<div class="room-chips">${chips.join("")}</div>` : ""}
${devices.length ? `<div class="room-devices">${devices.map((e) => `<button class="room-dev wbtn ${isOn(S(e)) ? "on" : ""}" data-act="toggle" data-e="${esc(e)}" aria-pressed="${isOn(S(e))}">
<span class="room-dev-ico">${icon(S(e))}</span><span class="room-dev-text"><span class="room-dev-name">${esc(short(e))}</span><span class="room-dev-state">${esc(fmtState(S(e)))}</span></span></button>`).join("")}</div>` : ""}
</div>`;
},
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).
/** A robot vacuum seen from above beside its dock, placed and drawn by its state. */
function vacScene(state, charging) {
const docked = state === "docked", cleaning = state === "cleaning", returning = state === "returning", error = state === "error";
const x = docked ? 196 : returning ? 150 : cleaning ? 92 : 124;
const trail = cleaning || returning ? `<path class="vs-trail" d="M224 62 C 196 104, 150 18, ${x + 30} 60"/>` : "";
return `<svg class="vac-scene" viewBox="0 0 300 120" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${esc(st(state))}">
<rect class="vs-dock" x="232" y="18" width="52" height="84" rx="10"/>
<rect class="vs-plate" x="226" y="44" width="10" height="32" rx="3"/>
<circle class="vs-led ${charging ? "on" : ""}" cx="266" cy="34" r="4"/>
${trail}
<g class="vs-robot ${cleaning ? "busy" : ""} ${error ? "err" : ""}" transform="translate(${x} 60)">
<circle class="vs-body" r="36"/>
<path class="vs-bumper" d="M -14 -32.4 A 35 35 0 0 0 -14 32.4"/>
<circle class="vs-lidar" cx="12" r="11"/>
<g class="vs-brush" transform="translate(-21 -21)"><line x1="0" y1="-6" x2="0" y2="6"/><line x1="-5.2" y1="3" x2="5.2" y2="-3"/><line x1="-5.2" y1="-3" x2="5.2" y2="3"/></g>
</g></svg>`;
}
W.vacuum = {
label: "Vacuum", icon: "vacuum", size: [4, 5],
fields: [
{ k: "entity", t: "entity", l: "Vacuum", domains: ["vacuum"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "map", t: "entity", l: "Map camera (optional)", domains: ["camera", "image"] },
{ k: "refresh", t: "number", l: "Refresh the map every (seconds)", d: 5, when: (c) => !!c.map },
{ k: "battery", t: "entity", l: "Battery sensor (if not on the vacuum)", domains: ["sensor"] },
{ k: "area", t: "entity", l: "Cleaned area", domains: ["sensor"] },
{ k: "duration", t: "entity", l: "Cleaning time", domains: ["sensor"] },
{ k: "entities", t: "entities", l: "More sensors, e.g. filter or brush life", domains: ["sensor", "binary_sensor"] },
{ k: "fan", t: "check", l: "Show suction power", d: true },
],
entities: (c) => [c.entity, c.battery, c.area, c.duration, ...(c.entities || [])].filter(Boolean),
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a vacuum");
const a = s.attributes, e = esc(c.entity), name = friendly(s, c.name);
const cleaning = s.state === "cleaning", returning = s.state === "returning";
const batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level);
const hasBatt = isFinite(batt);
const charging = s.state === "docked" && hasBatt && batt < 100;
const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state);
// the robot's own status only when it adds something (an error, a paused job), not "Returning home" twice
const detail = a.status && !["cleaning", "returning", "docked"].includes(s.state) && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
// The robot's own sensors: life left in percent goes under Maintenance, the rest with the last clean.
const S = (x) => ha.states[x];
const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i");
const label = (x) => cap(friendly(S(x)).replace(prefix, "").replace(/\s+(left|life|remaining)$/i, ""));
const extra = (c.entities || []).filter((x) => S(x));
const maint = extra.filter((x) => S(x).attributes.unit_of_measurement === "%" && isFinite(Number(S(x).state)));
const facts = [
c.area && S(c.area) ? [t("Cleaned"), fmtState(S(c.area))] : null,
c.duration && S(c.duration) ? [t("Time"), fmtState(S(c.duration))] : null,
...extra.filter((x) => !maint.includes(x)).map((x) => [label(x), fmtState(S(x))]),
].filter(Boolean);
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""} aria-label="${esc(label)}">${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
return `<div class="vac state-${esc(s.state)} ${charging ? "charging" : ""}">
<div class="vac-head"><div class="vac-title"><span class="name">${esc(name)}</span><span class="vac-status">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></div>
${hasBatt ? `<div class="vac-batt"><span class="vac-batt-v num">${Math.round(batt)}<small>%</small></span>
<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span></div>` : ""}</div>
<div class="vac-art">${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` : vacScene(s.state, charging)}</div>
${facts.length ? `<div class="vac-facts" aria-label="${t("Last clean")}">${facts.map(([l, v]) => `<div class="vac-fact"><span class="vac-fv num">${esc(v)}</span><span class="vac-fl">${esc(l)}</span></div>`).join("")}</div>` : ""}
${maint.length ? `<div class="vac-maint">${maint.map((x) => { const v = Math.max(0, Math.min(100, Number(S(x).state))); return `<div class="vac-mrow ${v < 15 ? "low" : ""}"><span class="vac-ml">${esc(label(x))}</span><span class="vac-mbar"><i style="width:${v}%"></i></span><span class="vac-mv num">${Math.round(v)}%</span></div>`; }).join("")}</div>` : ""}
${speeds.length ? `<div class="seg vac-fan" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
<div class="vac-actions">
${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")}
</div></div>`;
},
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 `<div class="empty">${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}</div>`;
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 = `<div class="appl-ring" ${showRing ? `role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"` : ""}>
<svg viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/>
${showRing ? `<circle cx="50" cy="50" r="${R}" class="fill" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}"/>` : ""}</svg>
<span class="appl-ico">${Icons.svg(ringIco)}</span>
${isFinite(prog) && phase === "running" ? `<span class="appl-pct num">${Math.round(prog)}%</span>` : ""}</div>`;
// 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) => `<span class="appl-fact warn">${Icons.svg("shield")}<span>${esc(cap(strip(friendly(ha.states[e]))))}</span></span>`),
doorState && ["open", "closed", "locked"].includes(doorState) && `<span class="appl-fact ${doorState === "open" ? "warn" : ""}">${Icons.svg(doorState === "open" ? "door-open" : doorState === "locked" ? "lock" : "door")}${esc({ open: t("Door open"), closed: t("Door closed"), locked: t("Door locked") }[doorState])}</span>`,
power && phase === "running" && `<span class="appl-fact">${Icons.svg("bolt")}${esc(fmtState(power))}</span>`,
energy && `<span class="appl-fact">${Icons.svg("pulse")}${esc(fmtState(energy))}</span>`,
].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] ? `<button class="appl-btn wbtn" data-act="${act}" data-e="${esc(eid)}" ${act === "svc" ? `data-v="${v}"` : ""} aria-label="${esc(lbl)}" title="${esc(lbl)}">${Icons.svg(ico)}</button>` : "");
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" ? `<span class="appl-big">${t("Ready to unload")}</span>`
: phase === "scheduled" ? `<span class="appl-big">${left != null ? t("Ready at {0}", ends) : t("Starts later")}</span>`
: phase === "attention" ? `<span class="appl-big">${t("Check the appliance")}</span>`
: phase === "error" ? `<span class="appl-big">${t("Something went wrong")}</span>`
: left != null ? `<span class="appl-big num">${esc(fmtDuration(left))}</span><span class="appl-sub">${t("left · ready at {0}", ends)}</span>`
: phase === "running" ? `<span class="appl-big">${t("In progress")}</span>` : `<span class="appl-big appl-off">${esc(label)}</span>`;
return `<div class="appl phase-${phase}">${ring}
<div class="appl-text">
<div class="appl-top"><span class="name">${esc(c.name || t("Appliance"))}</span><span class="appl-status">${esc(label)}</span></div>
${showProgram ? `<span class="appl-program">${esc(prettyProgram(program))}</span>` : ""}
<div class="appl-main"><div class="appl-main-text">${main}</div>${ctrls ? `<div class="appl-ctrls">${ctrls}</div>` : ""}</div>
${facts ? `<div class="appl-facts">${facts}</div>` : ""}
</div></div>`;
},
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 `<div class="pad">${missing(c.entity, "a camera")}</div>`;
return `<button class="cam-btn wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(t("{0} camera", friendly(s)))}"><img class="cam" data-cam="${esc(c.entity)}" data-refresh="${Number(c.refresh) || 10}" src="api/camera/${encodeURIComponent(c.entity)}?t=${Date.now()}" alt=""></button>
${c.showName ? `<div class="cam-label">${esc(friendly(s))}</div>` : ""}`;
},
};
// ---------------------------------------------------------------- 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 `<svg class="tz-front" viewBox="0 0 200 116" aria-hidden="true">
<defs><linearGradient id="tzs-${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset=".45" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient>
<radialGradient id="tzg-${id}" cx=".5" cy=".2" r=".9"><stop offset="0" stop-color="#8fa3bf" stop-opacity=".55"/><stop offset="1" stop-color="#0d1117" stop-opacity=".95"/></radialGradient></defs>
<ellipse cx="100" cy="108" rx="90" ry="5" class="tz-shadow"/>
<rect x="19" y="78" width="22" height="30" rx="6" class="tz-tyre"/><rect x="159" y="78" width="22" height="30" rx="6" class="tz-tyre"/>
<path d="M50 54C54 36 64 24 80 21C92 19 108 19 120 21C136 24 146 36 150 54Z" class="tz-paint"/>
<path d="${FRONT_BODY}" class="tz-paint"/>
<path d="M50 54C54 36 64 24 80 21C92 19 108 19 120 21C136 24 146 36 150 54Z" fill="url(#tzs-${id})"/>
<path d="${FRONT_BODY}" fill="url(#tzs-${id})"/>
<path d="M57 52C61 38 68 29 81 26C93 24 107 24 119 26C132 29 139 38 143 52C128 49.5 72 49.5 57 52Z" fill="url(#tzg-${id})"/>
<path d="M50 55L38 52C35 51.5 34 54 36 55.5L48 59ZM150 55L162 52C165 51.5 166 54 164 55.5L152 59Z" class="tz-paint"/>
<path d="M70 61C82 58 118 58 130 61" class="tz-crease"/>
<path d="M26 69C34 66 46 65 58 67L56 71C46 71 36 72 28 73ZM174 69C166 66 154 65 142 67L144 71C154 71 164 72 172 73Z" class="tz-lamp"/>
<rect x="64" y="84" width="72" height="8" rx="4" class="tz-intake"/>
<path d="M24 100H176" class="tz-crease"/></svg>`;
}
function carSide(id, o) {
const wheel = (cx) => `<circle cx="${cx}" cy="70" r="15" class="tz-tyre"/><circle cx="${cx}" cy="70" r="10" class="tz-rim"/>
<path d="M${cx} 61V79M${cx - 9} 70H${cx + 9}M${cx - 6.4} 63.6L${cx + 6.4} 76.4M${cx - 6.4} 76.4L${cx + 6.4} 63.6" class="tz-spokes"/><circle cx="${cx}" cy="70" r="2.6" class="tz-hub"/>`;
return `<svg class="tz-side" viewBox="0 0 240 92" aria-hidden="true">
<defs><linearGradient id="tzd-${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".4"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient>
<linearGradient id="tzw-${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#8fa3bf" stop-opacity=".6"/><stop offset="1" stop-color="#0d1117" stop-opacity=".95"/></linearGradient></defs>
<ellipse cx="121" cy="86" rx="112" ry="3.5" class="tz-shadow"/>
<path d="${SIDE_BODY}" class="tz-paint"/><path d="${SIDE_BODY}" fill="url(#tzd-${id})"/>
<path d="M164 40C154 32 144 26 132 24C120 22 104 22 92 23L94 40ZM88 23C74 25 60 31 48 40H90Z" fill="url(#tzw-${id})"/>
<path d="M34 52L212 49M93 41L95 66M150 42L152 64" class="tz-crease"/>
<path d="M118 46H128M66 47H76" class="tz-handle"/>
<path d="M220 49.5L230 53" class="tz-lamp-s"/><path d="M8.5 49L17 47" class="tz-tail"/>
${o.plugged ? `<circle cx="24" cy="51" r="3.4" class="tz-port"/>` : ""}
${wheel(58)}${wheel(184)}</svg>`;
}
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 `<div class="empty">${t("Set the car’s level, range or lock entity in the inspector.")}</div>`;
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 ? `<svg class="tz-bicon" viewBox="0 0 28 14" aria-hidden="true"><rect x=".75" y=".75" width="23.5" height="12.5" rx="3.2" class="tz-bshell"/><rect x="2.5" y="2.5" width="${(20 * p / 100).toFixed(1)}" height="9" rx="1.6" class="tz-blevel"/><path d="M26 4.8v4.4" class="tz-bnub"/>${charging ? `<path d="M13.6 2.6 9.8 7.6h3.4l-.8 3.8 3.8-5h-3.4z" class="tz-bbolt"/>` : ""}</svg>` : "";
const batt = `<div class="tz-batt">${battIcon}${pct != null ? `<span class="num">${pct}%</span>` : ""}${rangeTxt ? `<span class="tz-brange num">${esc(rangeTxt)}</span>` : ""}</div>`;
const big = `<div class="tz-big">${pct != null ? `<span class="tz-pct num">${pct}<small>%</small></span>` : ""}${rangeTxt ? `<span class="tz-rng num">${esc(rangeTxt)}</span>` : ""}</div>`;
// the charging bar: green while charging, with the charge limit marked
const bar = pct != null ? `<div class="tz-bar"><i style="width:${p}%"></i>${limPct != null ? `<b style="left:${Math.max(0, Math.min(100, limPct))}%"></b>` : ""}</div>` : "";
const chargeInfo = plugged
? `<div class="tz-charge"><div class="tz-cl"><span class="tz-ct">${charging ? t("Charging") : t("Plugged in")}${charging && power && Number(power.state) > 0 ? ` <span class="num">${esc(fmtState(power))}</span>` : ""}</span><span class="tz-cr num">${esc(left || (limPct != null ? t("Charges to {0}%", Math.round(limPct)) : ""))}</span></div>${bar}${fullAt || limPct != null ? `<div class="tz-cs"><span>${esc(fullAt)}</span><span>${limPct != null ? esc(t("Limit {0}%", Math.round(limPct))) : ""}</span></div>` : ""}</div>`
: bar ? `<div class="tz-charge tz-idle">${bar}</div>` : "";
// icon buttons
const ctrl = (act, eid, ico, label, cls = "", extra = "") => `<button class="tz-btn wbtn ${cls}" data-act="${act}" data-e="${esc(eid)}" ${extra} aria-label="${esc(label)}" title="${esc(label)}">${Icons.svg(ico)}</button>`;
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)}<span class="tz-rt">${esc(title)}</span><span class="tz-rs num">${esc(sub)}</span>${act ? Icons.svg("chevron", "fwd") : ""}</${act ? "button" : "div"}>`;
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 ? `<img class="tz-img" src="${esc(c.image)}" alt="">` : `${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 `<div class="tz ${cls}"${paint}>
<div class="tz-head"><div class="tz-title"><div class="tz-name">${esc(c.name || t("Car"))}</div>${status ? `<div class="tz-status">${esc(status)}</div>` : ""}</div>${batt}</div>
${big}
<div class="tz-art">${art}</div>
${ctrls.length ? `<div class="tz-ctrls">${ctrls.join("")}</div>` : ""}
${chargeInfo}
${rows ? `<div class="tz-rows">${rows}</div>` : ""}
</div>`;
},
};
// ---------------------------------------------------------------- parcels
// Parcel integrations (PostNL, DHL, …) keep their parcels as lists in a sensor's attributes;
// field names differ, so each parcel is read from whichever names it has.
const pick = (o, ...keys) => { for (const k of keys) if (o?.[k] != null && o[k] !== "") return o[k]; return null; };
const dayLabel = (d) => {
const today = new Date(); today.setHours(0, 0, 0, 0);
const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400000);
return diff === 0 ? t("Today") : diff === 1 ? t("Tomorrow") : diff === -1 ? t("Yesterday") : new Date(d).toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" });
};
function parcelsOf(s) {
const out = [];
for (const [k, v] of Object.entries(s.attributes)) {
if (!Array.isArray(v) || !v.length || typeof v[0] !== "object") continue;
const listDelivered = /deliver|bezorgd|afgeleverd/i.test(k) && !/route|onderweg|transit/i.test(k);
for (const p of v) {
const status = String(pick(p, "status_message", "status", "state", "statusText") || "");
const when = pick(p, "expected_datetime", "planned_date", "delivery_date", "expected_delivery", "eta", "date", "planned_from");
const date = when && !isNaN(new Date(when)) ? new Date(when) : null;
out.push({
name: String(pick(p, "name", "title", "description", "sender", "shop", "product", "key", "tracking_number", "barcode") || t("Parcel")),
status, date, until: pick(p, "planned_to", "expected_to"),
done: listDelivered || p.delivered === true || /delivered|bezorgd|afgeleverd/i.test(status),
url: pick(p, "url", "track_url", "tracking_url"),
});
}
}
return out;
}
W.parcels = {
label: "Parcels", icon: "package", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Parcels" },
{ k: "entities", t: "entities", l: "Parcel sensors (PostNL, DHL, …)", domains: ["sensor"] },
{ k: "showDelivered", t: "check", l: "Show parcels delivered in the last two days", d: true },
],
entities: (c) => c.entities || [],
render(c, { ha }) {
const list = (c.entities || []).map((e) => ha.states[e]).filter(Boolean);
if (!list.length) return `${title(c.title)}${missing((c.entities || [])[0], "a parcel sensor")}`;
let items = list.flatMap((s) => {
const ps = parcelsOf(s);
return ps.length || !isFinite(Number(s.state)) ? ps : []; // a count with no list means nothing on the way
});
const recent = Date.now() - 2 * 86400000;
if (c.showDelivered === false) items = items.filter((p) => !p.done);
else items = items.filter((p) => !p.done || !p.date || p.date.getTime() > recent);
items.sort((a, b) => a.done - b.done || (a.date?.getTime() ?? 9e15) - (b.date?.getTime() ?? 9e15));
const coming = items.filter((p) => !p.done), today = coming.filter((p) => p.date && dayLabel(p.date) === t("Today")).length;
const hm2 = (d) => new Date(d).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
const when = (p) => {
if (!p.date) return "";
const hasTime = p.date.getHours() || p.date.getMinutes();
return `${dayLabel(p.date)}${hasTime ? ` ${hm2(p.date)}${p.until && !isNaN(new Date(p.until)) ? `–${hm2(p.until)}` : ""}` : ""}`;
};
const rows = items.map((p) => `<${p.url ? `a href="${esc(p.url)}" target="_blank" rel="noopener"` : "div"} class="pc-row ${p.done ? "done" : ""} ${p.date && dayLabel(p.date) === t("Today") && !p.done ? "today" : ""}">
<span class="pc-ico">${Icons.svg(p.done ? "check" : "package")}</span>
<span class="pc-text"><span class="pc-name">${esc(p.name)}</span><span class="pc-status">${esc(p.done ? t("Delivered") : p.status || t("On the way"))}</span></span>
<span class="pc-when">${esc(when(p))}</span></${p.url ? "a" : "div"}>`).join("");
return `${title(c.title)}<div class="pc">
<div class="pc-sum"><b class="num">${coming.length}</b> ${esc(coming.length === 1 ? t("parcel on the way") : t("parcels on the way"))}${today ? ` · <span class="pc-today">${esc(t("{0} today", today))}</span>` : ""}</div>
${rows ? `<div class="pc-list">${rows}</div>` : `<div class="empty">${t("No parcels on the way.")}</div>`}</div>`;
},
};
// ---------------------------------------------------------------- air quality
const AIR = {
carbon_dioxide: { k: "co2", l: "CO₂", lv: (v) => (v <= 800 ? 0 : v <= 1200 ? 1 : 2) },
pm25: { k: "pm25", l: "PM2.5", lv: (v) => (v <= 12 ? 0 : v <= 35 ? 1 : 2) },
pm10: { k: "pm10", l: "PM10", lv: (v) => (v <= 20 ? 0 : v <= 50 ? 1 : 2) },
volatile_organic_compounds: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) },
volatile_organic_compounds_parts: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) },
humidity: { k: "hum", l: "Humidity", lv: (v) => (v >= 40 && v <= 60 ? 0 : v >= 30 && v <= 70 ? 1 : 2) },
};
const AIR_WORDS = /\b(co2|carbon dioxide|pm2[.,]?5|pm10|voc|humidity|luchtvochtigheid|vochtigheid|air quality|luchtkwaliteit)\b/gi;
W.air = {
label: "Air quality", icon: "air", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Air quality" },
{ k: "entities", t: "entities", l: "Sensors (leave empty to find CO₂, fine dust and humidity per room)", domains: ["sensor"] },
],
entities: (c, ha) => (c.entities?.length ? c.entities : Object.values(ha?.states || {}).filter((s) => AIR[s.attributes.device_class]).map((s) => s.entity_id)),
render(c, { ha }) {
const reg = ha.reg;
const areaOf = {};
if (reg) for (const [a, ids] of Object.entries(reg.byArea)) for (const id of ids) areaOf[id] = reg.areas.find((x) => x.area_id === a)?.name;
const sensors = (c.entities?.length ? c.entities : W.air.entities(c, ha)).map((e) => ha.states[e]).filter((s) => s && AIR[s.attributes.device_class] && isFinite(Number(s.state)));
// Without chosen sensors, only rooms count; with chosen ones, a sensor's name is its room when it has none.
const rooms = new Map();
for (const s of sensors) {
const room = areaOf[s.entity_id] || (c.entities?.length ? friendly(s).replace(AIR_WORDS, "").replace(/\s+/g, " ").trim() || friendly(s) : null);
if (!room) continue;
(rooms.get(room) || rooms.set(room, []).get(room)).push(s);
}
if (!rooms.size) return `${title(c.title)}<div class="empty">${t("No air quality sensors found. Add CO₂, fine dust or humidity sensors in the inspector.")}</div>`;
const names = ["Good", "Fair", "Poor"];
const rows = [...rooms.entries()].map(([room, ss]) => {
const m = ss.map((s) => { const d = AIR[s.attributes.device_class], v = Number(s.state); return { ...d, v, lvl: d.lv(v), s }; });
const worst = Math.max(...m.map((x) => x.lvl));
const co2 = m.find((x) => x.k === "co2")?.v, hum = m.find((x) => x.k === "hum")?.v, pm = m.find((x) => (x.k === "pm25" || x.k === "pm10") && x.lvl === 2);
const hint = co2 >= 1000 ? t("Open a window") : pm ? t("Air the room or turn on the purifier") : hum > 70 ? t("Too humid, air the room") : hum < 30 ? t("The air is dry") : "";
return `<div class="aq-row lvl${worst}"><span class="aq-dot" aria-hidden="true"></span>
<span class="aq-room"><b>${esc(room)}</b><span>${esc(hint || t(names[worst]))}</span></span>
<span class="aq-vals">${m.map((x) => `<button class="aq-val lvl${x.lvl} wbtn" data-act="more" data-e="${esc(x.s.entity_id)}" title="${esc(t(x.l))}: ${esc(t(names[x.lvl]))}"><small>${esc(t(x.l))}</small><span class="num">${esc(fmtNum(x.v))}${x.k === "hum" ? "%" : ""}</span></button>`).join("")}</span></div>`;
}).join("");
return `${title(c.title)}<div class="aq">${rows}</div>`;
},
};
// ---------------------------------------------------------------- water and gas
// Daily use from Home Assistant's long-term statistics of a meter (the same numbers the energy dashboard uses).
W.usage = {
label: "Water and gas", icon: "droplet", size: [4, 3],
fields: [
{ k: "entity", t: "entity", l: "Meter (water, gas or energy)", domains: ["sensor"] },
{ k: "title", t: "text", l: "Title", ph: "Entity name" },
{ k: "kind", t: "seg", l: "Type", o: ["water", "gas", "electricity"], d: "water" },
{ k: "days", t: "number", l: "Days to show", d: 7 },
],
render(c, { ha, cache }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a meter");
let unit = s.attributes.unit_of_measurement || "";
// Water reads better in litres than in thousandths of a cubic metre.
const liters = (c.kind || "water") === "water" && unit === "m³";
if (liters) unit = "L";
const ico = { water: "droplet", gas: "flame", electricity: "bolt" }[c.kind] || "droplet";
const head = `<div class="us-head"><span class="us-ico ${esc(c.kind || "water")}">${Icons.svg(ico)}</span><span class="name">${esc(c.title || friendly(s))}</span></div>`;
if (cache.error) return `${head}<div class="empty">${t("No statistics for this meter. It needs a state class (total or total_increasing).")}</div>`;
if (!cache.days) return `${head}<div class="empty">${t("Loading…")}</div>`;
const days = liters ? cache.days.map((d) => ({ ...d, v: Math.round(d.v * 1000) })) : cache.days;
const today = days[days.length - 1]?.v ?? 0, yest = days[days.length - 2]?.v;
const max = Math.max(...days.map((d) => d.v), 0.0001), avg = days.slice(0, -1).reduce((a, d) => a + d.v, 0) / Math.max(1, days.length - 1);
return `${head}<div class="us ${esc(c.kind || "water")}">
<div class="us-now"><span class="us-big num">${esc(fmtNum(today))}<small>${esc(unit)}</small></span>
<span class="us-cmp">${t("Today so far")}${yest != null ? ` · ${t("yesterday {0}", `${fmtNum(yest)} ${unit}`)}` : ""}</span></div>
<div class="us-bars" role="img" aria-label="${esc(days.map((d) => `${d.label}: ${fmtNum(d.v)} ${unit}`).join(", "))}">
${avg ? `<i class="us-avg" style="bottom:${(avg / max) * 100}%" title="${esc(t("Average"))}"></i>` : ""}
${days.map((d, i) => `<div class="us-day ${i === days.length - 1 ? "today" : ""}"><span class="us-bar" style="height:${Math.max(2, (d.v / max) * 100)}%"></span><span class="us-lbl">${esc(d.label)}</span></div>`).join("")}</div>
<div class="us-foot"><span>${t("{0} days", days.length)}: <b class="num">${esc(fmtNum(days.reduce((a, d) => a + d.v, 0)))} ${esc(unit)}</b></span><span>${t("Average {0}", `${fmtNum(avg)} ${unit}`)}</span></div>
</div>`;
},
async after(el, c, ctx) {
if (!ctx.ha.states[c.entity]) return;
const cache = ctx.cache, n = Math.max(3, Math.min(31, Number(c.days) || 7));
const key = `${c.entity}|${n}`;
if (cache.key === key && Date.now() - cache.at < 10 * 60 * 1000) return;
cache.key = key; cache.at = Date.now();
try {
const rows = await ctx.ha.statistics(c.entity, n);
cache.days = rows.map((r) => ({ v: Math.max(0, Number(r.change) || 0), label: new Date(r.start).toLocaleDateString(LOCALE, { weekday: "short" }) }));
cache.error = !rows.length;
} catch { cache.error = true; }
ctx.rerender(); // the card may have been redrawn while loading: draw whichever one is on screen now
},
};
// ---------------------------------------------------------------- solar forecast
// Forecast.Solar: the day totals come from its sensors, the hour by hour forecast from the energy API.
W.solar = {
label: "Solar forecast", icon: "solar", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Solar forecast" },
{ k: "today", t: "entity", l: "Production today (kWh)", domains: ["sensor"] },
{ k: "tomorrow", t: "entity", l: "Production tomorrow (kWh)", domains: ["sensor"] },
{ k: "now", t: "entity", l: "Power now (W)", domains: ["sensor"] },
{ k: "hours", t: "number", l: "Hours for the washer or dishwasher", d: 3 },
],
autofill: {
from: "today",
fill: (eid, ha) => {
const has = (e) => (ha.states[e] ? e : "");
return { tomorrow: has(eid.replace("today", "tomorrow")), now: has(eid.replace("energy_production_today", "power_production_now")) };
},
},
entities: (c) => [c.today, c.tomorrow, c.now].filter(Boolean),
render(c, { ha, cache }) {
const today = ha.states[c.today], tomorrow = ha.states[c.tomorrow], now = ha.states[c.now];
if (!today && !tomorrow) return `${title(c.title)}${missing(c.today, "the production today sensor")}`;
const kwh = (s) => (s ? `${fmtNum(s.state)}<small>${esc(s.attributes.unit_of_measurement || "kWh")}</small>` : "–");
const hours = cache.hours || [];
const day0 = new Date(); day0.setHours(0, 0, 0, 0);
const todays = hours.filter((h) => h.t >= day0.getTime() && h.t < day0.getTime() + 86400000);
const shown = todays.filter((h) => h.wh > 0 || (new Date(h.t).getHours() >= 7 && new Date(h.t).getHours() <= 20));
const max = Math.max(...shown.map((h) => h.wh), 1), nowH = new Date().getHours();
// the best window to run an appliance: the most sun in N hours in a row, from now on
const n = Math.max(1, Math.min(6, Number(c.hours) || 3));
const ahead = hours.filter((h) => h.t >= Date.now() - 3600000);
let best = null;
for (let i = 0; i + n <= ahead.length; i++) {
const sum = ahead.slice(i, i + n).reduce((a, h) => a + h.wh, 0);
if (ahead[i + n - 1].t - ahead[i].t === (n - 1) * 3600000 && (!best || sum > best.sum)) best = { sum, from: ahead[i].t, to: ahead[i].t + n * 3600000 };
}
const hmT = (ms) => new Date(ms).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
return `${title(c.title)}<div class="sf">
<div class="sf-top"><div class="sf-main"><span class="sf-lbl">${t("Today")}</span><span class="sf-big num">${kwh(today)}</span></div>
${tomorrow ? `<div><span class="sf-lbl">${t("Tomorrow")}</span><span class="sf-mid num">${kwh(tomorrow)}</span></div>` : ""}
${now ? `<div><span class="sf-lbl">${t("Now")}</span><span class="sf-mid num">${esc(fmtState(now))}</span></div>` : ""}</div>
${shown.length ? `<div class="sf-bars">${shown.map((h) => { const hr = new Date(h.t).getHours(); return `<div class="sf-h ${hr === nowH ? "now" : ""} ${hr < nowH ? "past" : ""} ${best && h.t >= best.from && h.t < best.to ? "best" : ""}" title="${esc(`${hmT(h.t)}: ${fmtNum(h.wh / 1000)} kWh`)}"><i style="height:${Math.max(2, (h.wh / max) * 100)}%"></i>${hr % 3 === 0 ? `<span>${hr}</span>` : ""}</div>`; }).join("")}</div>`
: cache.loaded ? `<div class="muted small">${t("The hour by hour forecast isn’t available.")}</div>` : ""}
${best ? `<div class="sf-best">${Icons.svg("washer")}<span>${esc(t("Best time for the washer: {0}", `${dayLabel(best.from) === t("Today") ? "" : dayLabel(best.from) + " "}${hmT(best.from)}–${hmT(best.to)}`))}</span></div>` : ""}
</div>`;
},
async after(el, c, ctx) {
const cache = ctx.cache;
if (cache.at && Date.now() - cache.at < 30 * 60 * 1000) return;
cache.at = Date.now();
try {
const res = await ctx.ha.solarForecast();
const sum = {};
for (const entry of Object.values(res || {})) for (const [iso, wh] of Object.entries(entry.wh_hours || {})) { const tm = new Date(iso).getTime(); sum[tm] = (sum[tm] || 0) + Number(wh || 0); }
cache.hours = Object.entries(sum).map(([tm, wh]) => ({ t: Number(tm), wh })).sort((a, b) => a.t - b.t);
} catch { cache.hours = []; }
cache.loaded = true;
ctx.rerender();
},
};
// ---------------------------------------------------------------- garage and gate
/** Who changed an entity last, from the user on its context (matched to a person). */
function changedBy(s, ha) {
const uid = s?.context?.user_id;
if (!uid) return "";
const p = Object.values(ha.states).find((x) => x.entity_id.startsWith("person.") && x.attributes.user_id === uid);
return p ? friendly(p) : "";
}
W.gate = {
label: "Garage and gate", icon: "garage", size: [3, 3],
fields: [
{ k: "entity", t: "entity", l: "Garage door, gate or lock", domains: ["cover", "lock", "switch", "button", "input_boolean"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "kind", t: "seg", l: "Icon", o: ["garage", "gate", "door"], d: "garage" },
{ k: "confirm", t: "check", l: "Ask before opening or closing", d: true },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a garage door or gate");
const d = domainOf(c.entity), st0 = s.state;
const open = ["open", "opening", "unlocked", "unlocking", "on"].includes(st0), moving = ["opening", "closing", "locking", "unlocking"].includes(st0);
const name = c.name || friendly(s);
const label = d === "button" ? t("Press") : moving ? st(st0) : open ? (d === "lock" ? t("Lock") : t("Close")) : d === "lock" ? t("Unlock") : t("Open");
const by = changedBy(s, ha);
const since = `${open ? t("Open") : st(st0)} · ${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`;
const ico = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage";
const when = `${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`;
return `<div class="gt ${open ? "open" : ""} ${moving ? "moving" : ""}">
<div class="gt-head"><span class="gt-ico">${Icons.svg(open && c.kind === "door" ? "door-open" : ico)}</span><span class="name">${esc(name)}</span></div>
<div class="gt-now"><span class="gt-big">${esc(d === "button" ? name : open ? t("Open") : st(st0))}</span><span class="gt-since" title="${esc(since)}">${esc(when)}</span></div>
<button class="gt-btn wbtn" data-act="gate" data-e="${esc(c.entity)}" data-v="${c.confirm === false ? "" : "1"}" aria-label="${esc(`${label}: ${name}`)}"><span class="gt-lbl">${esc(label)}</span></button></div>`;
},
};
// ---------------------------------------------------------------- sports (TeamTracker)
const isMatchDay = (s) => s && (s.state === "IN" || (s.attributes.date && dayLabel(s.attributes.date) === t("Today")));
W.sports = {
label: "Sports", icon: "trophy", size: [4, 3],
fields: [
{ k: "entity", t: "entity", l: "Team (TeamTracker sensor)", domains: ["sensor"] },
{ k: "matchDay", t: "check", l: "Only show on match days", d: false },
{ k: "venue", t: "check", l: "Show the venue and TV channel", d: true },
],
autoHide: (c, { ha }) => !!c.matchDay && !isMatchDay(ha.states[c.entity]),
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a TeamTracker sensor");
const a = s.attributes, state = String(s.state).toUpperCase();
if (state === "NOT_FOUND" || state === "BYE" || !a.opponent_name) return `<div class="sp"><div class="sp-league">${esc(a.league || friendly(s))}</div><div class="empty">${state === "BYE" ? t("No match this round.") : t("No match planned.")}</div></div>`;
const team = { name: a.team_name, short: a.team_abbr, logo: a.team_logo, score: a.team_score, win: a.team_winner },
opp = { name: a.opponent_name, short: a.opponent_abbr, logo: a.opponent_logo, score: a.opponent_score, win: a.opponent_winner };
const [home, away] = a.team_homeaway === "away" ? [opp, team] : [team, opp];
const side = (x) => `<div class="sp-team ${x.win ? "win" : ""}">${x.logo ? `<img src="${esc(x.logo)}" alt="">` : `<span class="sp-badge">${esc((x.short || x.name || "?").slice(0, 3))}</span>`}<span>${esc(x.name || x.short || "")}</span></div>`;
const kick = a.date ? new Date(a.date) : null;
let mid;
if (state === "PRE") mid = `<span class="sp-time num">${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"}</span><span class="sp-sub">${esc(kick ? dayLabel(kick) : "")}${a.kickoff_in ? ` · ${esc(a.kickoff_in)}` : ""}</span>`;
else mid = `<span class="sp-score num">${esc(home.score ?? 0)}<i>–</i>${esc(away.score ?? 0)}</span><span class="sp-sub">${state === "IN" ? `<b class="sp-live">${t("Live")}</b> ${esc(a.clock || "")}` : t("Full time")}</span>`;
const extra = c.venue !== false ? [a.venue, a.tv_network].filter(Boolean).join(" · ") : "";
return `<div class="sp ${state === "IN" ? "live" : ""}"><div class="sp-league">${esc(a.league || a.sport || "")}${a.event_name && state !== "PRE" ? "" : ""}</div>
<div class="sp-match">${side(home)}<div class="sp-mid">${mid}</div>${side(away)}</div>
${extra ? `<div class="sp-extra">${esc(extra)}</div>` : ""}${state === "IN" && a.last_play ? `<div class="sp-extra">${esc(a.last_play)}</div>` : ""}</div>`;
},
};
// ---------------------------------------------------------------- Formula One (F1 Sensor integration)
// Its sensors carry the season data from the Jolpica (Ergast) API in their attributes.
const F1_SESSIONS = [["first_practice", "Practice 1"], ["second_practice", "Practice 2"], ["third_practice", "Practice 3"], ["sprint_qualifying", "Sprint qualifying"], ["sprint", "Sprint"], ["qualifying", "Qualifying"], ["race", "Race"]];
const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; };
const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; };
const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; };
const f1Code = (r) => { const d = r.Driver || r.driver || {}; return (typeof d === "object" && d.code) || f1Driver(r).split(" ").pop().slice(0, 3).toUpperCase(); };
/** The teams' own colours, as on the timing tower. */
const F1_TEAMS = [["red bull", "#3671c6"], ["mclaren", "#ff8000"], ["ferrari", "#e80020"], ["mercedes", "#27f4d2"], ["aston", "#229971"],
["alpine", "#0093cc"], ["williams", "#64c4ff"], ["racing bulls", "#6692ff"], ["rb f1", "#6692ff"], ["visa", "#6692ff"], ["sauber", "#52e252"],
["kick", "#52e252"], ["audi", "#bb0a30"], ["haas", "#b6babd"], ["cadillac", "#c0a062"]];
const f1Colour = (team) => (F1_TEAMS.find(([k]) => String(team).toLowerCase().includes(k)) || [, "#8e8e93"])[1];
W.f1 = {
label: "Formula One", icon: "flag", size: [4, 5],
fields: [
{ k: "next", t: "entity", l: "Next race", domains: ["sensor"] },
{ k: "drivers", t: "entity", l: "Driver standings", domains: ["sensor"] },
{ k: "teams", t: "entity", l: "Constructor standings", domains: ["sensor"] },
{ k: "results", t: "entity", l: "Last race results", domains: ["sensor"] },
{ k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" },
{ k: "top", t: "number", l: "Positions to show", d: 5 },
{ k: "look", t: "seg", l: "Look", o: ["official", "theme"], d: "official" },
{ k: "logo", t: "text", l: "Logo image (put the official logo in Home Assistant's www folder)", ph: "/local/f1-logo.svg" },
],
autofill: {
from: "next",
fill: (eid, ha) => {
const pre = eid.replace(/next_race.*$/, ""), has = (e) => (ha.states[e] ? e : "");
return { drivers: has(`${pre}driver_standings`), teams: has(`${pre}constructor_standings`), results: has(`${pre}last_race_results`) };
},
},
entities: (c) => [c.next, c.drivers, c.teams, c.results].filter(Boolean),
render(c, { ha, cache }) {
const next = ha.states[c.next];
if (!next && !c.drivers) return missing(c.next, "the next race sensor");
const a = next?.attributes || {};
const iso = (k) => a[`${k}_start`] || a[`${k}_date`] || a[`${k}_start_local`];
const raceAt = iso("race") ? new Date(iso("race")) : null;
let countdown = "";
if (raceAt && raceAt > Date.now()) {
const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60);
countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60);
}
const day = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric" });
const time = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v)));
const nextSession = sessions.find(([, v]) => new Date(v) > Date.now());
const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · ");
const tab = cache.tab || c.show || "drivers", top = Math.max(1, Math.min(20, Number(c.top) || 5));
const tabs = [["drivers", "Drivers", c.drivers], ["teams", "Teams", c.teams], ["results", "Last race", c.results]].filter(([, , e]) => e && ha.states[e]);
const cur = tabs.find(([k]) => k === tab) || tabs[0];
let rows = "";
if (cur) {
const s = ha.states[cur[2]];
const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings")
: cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results");
rows = list.slice(0, top).map((r) => {
const team = f1Team(r), isTeam = cur[0] === "teams";
const name = isTeam ? team : f1Driver(r);
const sub = isTeam ? (r.wins != null ? t("{0} wins", r.wins) : "") : team;
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""}`;
return `<div class="f1-row" style="--team:${f1Colour(team)}"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><i class="f1-stripe" aria-hidden="true"></i>
<span class="f1-name">${isTeam ? `<b>${esc(name)}</b>` : `<b><span class="f1-code">${esc(f1Code(r))}</span> ${esc(name)}</b>`}<span>${esc(sub)}</span></span>
<span class="f1-val num">${esc(val)}${cur[0] === "results" ? "" : `<small>${t("pts")}</small>`}</span></div>`;
}).join("");
}
// The countdown as the timing screens show it: days, hours, minutes.
let clock = "";
if (raceAt && raceAt > Date.now()) {
const m = Math.round((raceAt - Date.now()) / 60000);
clock = [[Math.floor(m / 1440), t("days")], [Math.floor((m % 1440) / 60), t("hrs")], [m % 60, t("mins")]]
.map(([v, l]) => `<span class="f1-cd"><b class="num">${String(v).padStart(2, "0")}</b><small>${esc(l)}</small></span>`).join("");
}
const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : "");
const official = c.look !== "theme";
return `<div class="f1 ${official ? "f1-official" : ""}">
<div class="f1-brand">${c.logo ? `<img class="f1-logo" src="${esc(c.logo)}" alt="Formula 1">` : `<span class="f1-mark" aria-hidden="true"></span>`}
<span class="f1-round">${esc(a.season ? t("Round {0} · {1}", a.round, a.season) : t("Next race"))}</span></div>
${next ? `<div class="f1-next">
<div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""}
${clock ? `<div class="f1-clock" aria-label="${esc(countdown)}">${clock}</div>` : ""}
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => { const d = new Date(v); return `<div class="f1-s ${nextSession && nextSession[1] === v ? "next" : ""} ${d < Date.now() ? "past" : ""}"><span class="f1-sd num">${esc(day(d))}</span><span class="f1-sn">${esc(t(l))}</span><span class="f1-st num">${esc(time(d))}</span></div>`; }).join("")}</div>` : ""}</div>` : ""}
${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div>
<div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`;
},
};
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) => `<b>${esc(fmtState(ha.states[eid]))}</b>`);
return `<div class="text-w" style="font-size:${Math.max(13, Number(c.size) || 17)}px">${html}</div>`;
},
};
// ---------------------------------------------------------------- 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 `<div class="pad"><div class="empty">${empty}</div></div>`;
return `<div class="photos" data-act="photo-next" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
<button class="sr-only" data-act="photo-next">${t("Next photo")}</button>
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
},
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 = `<div class="empty">${text}</div>`; };
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)}<div class="empty">${t("Add one or more calendars in the inspector.")}</div>`;
return `${title(c.title)}<div class="cal"><div class="empty">${t("Loading events…")}</div></div>`;
},
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 = `<div class="empty">${t("Couldn’t load events. {0}", esc(cache.error))}</div>`; 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 = `<div class="empty">${t("Nothing planned for the next {0} days.", days)}</div>`; 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 += `<div class="cal-day">${esc(label(day))}</div>`; }
const when = ev.allDay ? t("All day") : `${hm(ev.s)}–${hm(ev.e)}`;
const live = !ev.allDay && ev.s <= now && ev.e > now;
html += `<div class="cal-ev ${live ? "live" : ""}"><span class="cal-bar" style="background:${ev.color}"></span>
<span class="cal-when num">${esc(when)}</span>
<span class="cal-what"><span class="cal-sum">${esc(ev.summary || t("Busy"))}</span>${ev.location || multi ? `<span class="cal-sub">${esc([multi ? ev.cal : "", ev.location].filter(Boolean).join(" · "))}</span>` : ""}</span>
${live ? `<span class="cal-now">${t("Now")}</span>` : ""}</div>`;
}
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 ? `<iframe class="frame" src="${esc(c.url)}" loading="lazy" title="${t("Embedded page")}"></iframe>` : `<div class="pad"><div class="empty">${t("Add a URL in the inspector.")}</div></div>`),
};
// ---------------------------------------------------------------- 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)}<div class="empty">${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.")}</div>`;
}
const dot = (i) => `<span class="bin" style="--bin:${i.color}" aria-hidden="true">${Icons.svg("bin")}</span>`;
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 = `<div class="waste-next ${soon ? "soon" : ""}">
<div class="waste-bins">${first.map(dot).join("")}</div>
<div class="waste-next-text"><span class="wt">${t("Next pickup")}</span>
<span class="waste-when">${esc(whenLabel(first[0].days))}</span>
<span class="waste-types">${esc(first.map((i) => i.name).join(" · "))}</span>
${first[0].days === 1 ? `<span class="level">${t("Put the bin out tonight")}</span>` : ""}</div></div>`;
}
const rows = rest.map((i) => `<button class="list-row waste-row wbtn" data-act="more" data-e="${esc(i.eid)}">${dot(i)}
<span class="grow">${esc(i.name)}</span><span class="waste-day ${i.days <= 1 ? "soon" : ""}">${esc(whenLabel(i.days))}</span><span class="st">${esc(shortDate(i.date))}</span></button>`).join("");
return `${title(c.title)}${head}${rows ? `<div class="list waste-list">${rows}</div>` : ""}`;
},
};
// ---------------------------------------------------------------- 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) => `<div class="w child" data-id="${esc(ch.id)}" ${Number(ch.cfg?._width) > 0 ? `style="--own-w:${Number(ch.cfg._width)}px"` : ""}></div>`;
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? esc(c.emptyText || t("None of these cards are showing right now.")) : t("Add cards in the inspector.")}</div></div>`;
// 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 `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
<div class="hs-track" style="--item-w:${c.widthMode === "per-view"
// N cards per view, gaps included; a half card peeking out hints that the row scrolls
? `calc((100% - var(--d-gap, 14px) * ${Math.max(1, Number(c.perView) || 2.5) - 1}) / ${Math.max(1, Number(c.perView) || 2.5)})`
: `${Math.max(120, Number(c.itemWidth) || 260)}px`}" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
},
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
? `<div class="sw-dots" role="tablist" aria-label="${t("Cards")}">${kids.map((_, i) => `<button class="sw-dot wbtn" role="tab" data-act="sw-go" data-v="${i}" aria-label="${t("Card {0} of {1}", i + 1, kids.length)}" aria-selected="false"></button>`).join("")}</div>` : "";
return `<div class="sw" role="group" aria-roledescription="carousel" aria-label="${t("Swipe cards")}">
<div class="sw-track" tabindex="0">${kids.map(slot).join("")}</div>${dots}</div>`;
},
// 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 };
})();