7abf7379d8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1646 lines
119 KiB
JavaScript
1646 lines
119 KiB
JavaScript
/* 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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="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 || []; }
|
||
if (el.isConnected) ctx.rerender();
|
||
return;
|
||
}
|
||
// Swipe a row to the left to delete it (touch); the ✕ does the same with a mouse.
|
||
el.querySelectorAll(".todo-row").forEach((r) => {
|
||
let x0 = null, dx = 0;
|
||
r.addEventListener("pointerdown", (e) => { if (e.pointerType !== "mouse" && !e.target.closest("button")) { x0 = e.clientX; dx = 0; } });
|
||
r.addEventListener("pointermove", (e) => { if (x0 == null) return; dx = Math.min(0, e.clientX - x0); r.style.transform = `translateX(${dx}px)`; r.style.opacity = String(1 + dx / 300); });
|
||
const end = () => { if (x0 == null) return; x0 = null; if (dx < -90) r.querySelector(".todo-del")?.click(); else { r.style.transform = ""; r.style.opacity = ""; } };
|
||
r.addEventListener("pointerup", end); r.addEventListener("pointercancel", end);
|
||
});
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------- who's home
|
||
W.people = {
|
||
label: "Who's home", icon: "people", size: [4, 2],
|
||
fields: [{ k: "entities", t: "entities", l: "People (leave empty for everyone)", domains: ["person"] }, { k: "layout", t: "seg", l: "Layout", o: ["grid", "list"], d: "grid" }],
|
||
entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("person."))),
|
||
render(c, { ha }) {
|
||
const ids = (c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => e.startsWith("person."))).filter((e) => ha.states[e]);
|
||
if (!ids.length) return `<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;
|
||
if (el.isConnected) ctx.rerender();
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------- timer
|
||
// Counts down in the browser from finishes_at (ticked by app.js every second); presets start it with a duration.
|
||
const hms = (secs) => { secs = Math.max(0, Math.round(secs)); const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), x = secs % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(x).padStart(2, "0")}` : `${m}:${String(x).padStart(2, "0")}`; };
|
||
const toSecs = (v) => { const p = String(v || "0").split(":").map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p.length === 2 ? p[0] * 60 + p[1] : p[0]; };
|
||
|
||
W.timer = {
|
||
label: "Timer", icon: "timer", size: [3, 3],
|
||
fields: [
|
||
{ k: "entity", t: "entity", l: "Timer", domains: ["timer"] },
|
||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||
{ k: "presets", t: "text", l: "Quick starts in minutes", d: "5, 10, 15, 30" },
|
||
{ k: "sound", t: "check", l: "Beep when done", d: true },
|
||
],
|
||
render(c, { ha, cache }) {
|
||
const s = ha.states[c.entity];
|
||
if (!s) return missing(c.entity, "a timer");
|
||
const a = s.attributes, e = esc(c.entity);
|
||
const dur = toSecs(a.duration);
|
||
// Spot the moment it runs out: active → idle around the finish time.
|
||
if (cache.prev === "active" && s.state === "idle" && cache.finish && Date.now() >= cache.finish - 3000) {
|
||
cache.doneUntil = Date.now() + 60000;
|
||
if (c.sound !== false) window.HAFmt.beep?.();
|
||
}
|
||
cache.prev = s.state;
|
||
cache.finish = a.finishes_at ? new Date(a.finishes_at).getTime() : null;
|
||
const done = cache.doneUntil && Date.now() < cache.doneUntil && s.state === "idle";
|
||
const left = s.state === "active" && a.finishes_at ? (new Date(a.finishes_at) - Date.now()) / 1000 : s.state === "paused" ? toSecs(a.remaining) : dur;
|
||
const R = 44, C = 2 * Math.PI * R, frac = dur ? Math.max(0, Math.min(1, left / dur)) : 0;
|
||
const presets = String(c.presets || "").split(/[,\s]+/).map(Number).filter((m) => m > 0).slice(0, 6);
|
||
const ctl = (svc, ico, lbl, data) => `<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);
|
||
|
||
const flow = (on, d, w, cls) => {
|
||
if (!on) return "";
|
||
const speed = Math.max(0.5, Math.min(5, 2400 / (w + 150))); // seconds per cycle: more power, faster dots
|
||
const width = w > 1 ? Math.max(1.5, Math.min(5, 1.2 + Math.log10(w + 1))) : 1;
|
||
return `<path d="${d}" class="ef-line ${cls}"/>${w > 1 ? `<path d="${d}" class="ef-flow ${cls}" style="stroke-width:${width.toFixed(1)};animation-duration:${speed.toFixed(2)}s"/>` : ""}`;
|
||
};
|
||
const lines = [
|
||
flow(has.solar, "M50 14 Q86 14 86 50", sToH, "solar"),
|
||
flow(has.solar && has.grid, "M50 14 Q14 14 14 50", sToG, "solar"),
|
||
flow(has.solar && has.battery, "M50 14 L50 86", sToB, "solar"),
|
||
flow(has.grid, "M14 50 L86 50", gToH, "grid"),
|
||
flow(has.grid && has.battery, "M14 50 Q14 86 50 86", gToB, "grid"),
|
||
flow(has.battery, "M50 86 Q86 86 86 50", bToH, "battery"),
|
||
].join("");
|
||
const node = (key, ico, label, value, extra = "") => `<div class="ef-node ef-${key}"><span class="ef-ico">${Icons.svg(ico)}</span>
|
||
<span class="ef-val num">${esc(value)}</span><span class="ef-label">${esc(label)}${extra}</span></div>`;
|
||
const totals = (c.today || []).map((e) => ha.states[e] && `<span class="ef-total"><b class="num">${esc(fmtState(ha.states[e]))}</b> ${esc(friendly(ha.states[e]))}</span>`).filter(Boolean).join("");
|
||
|
||
return `<div class="energy ${has.battery ? "" : "no-battery"} ${has.solar ? "" : "no-solar"}">
|
||
<div class="ef-diagram" role="img" aria-label="${esc(t("Solar {0}, grid {1}, home {2}", fmtW(solar), grid >= 0 ? fmtW(imp) : "−" + fmtW(exp), fmtW(home)))}">
|
||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>
|
||
${has.solar ? node("solar", "sun", t("Solar"), fmtW(solar)) : ""}
|
||
${has.grid ? node("grid", "plug", exp > imp ? t("To the grid") : t("From the grid"), fmtW(exp > imp ? exp : imp)) : ""}
|
||
${node("home", "home", t("Home"), fmtW(home))}
|
||
${has.battery ? node("battery", "battery", charge > 1 ? t("Charging") : discharge > 1 ? t("Discharging") : t("Battery"), isFinite(lvl) ? `${Math.round(lvl)}%` : fmtW(Math.abs(batt)), isFinite(lvl) && Math.abs(batt) > 1 ? ` · ${fmtW(Math.abs(batt))}` : "") : ""}
|
||
</div>
|
||
${totals ? `<div class="ef-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).
|
||
W.vacuum = {
|
||
label: "Vacuum", icon: "vacuum", size: [4, 5],
|
||
fields: [
|
||
{ k: "entity", t: "entity", l: "Vacuum", domains: ["vacuum"] },
|
||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||
{ k: "map", t: "entity", l: "Map camera (optional)", domains: ["camera", "image"] },
|
||
{ k: "refresh", t: "number", l: "Refresh the map every (seconds)", d: 5, when: (c) => !!c.map },
|
||
{ k: "battery", t: "entity", l: "Battery sensor (if not on the vacuum)", domains: ["sensor"] },
|
||
{ k: "area", t: "entity", l: "Cleaned area", domains: ["sensor"] },
|
||
{ k: "duration", t: "entity", l: "Cleaning time", domains: ["sensor"] },
|
||
{ k: "entities", t: "entities", l: "More sensors, e.g. filter or brush life", domains: ["sensor", "binary_sensor"] },
|
||
{ k: "fan", t: "check", l: "Show suction power", d: true },
|
||
],
|
||
entities: (c) => [c.entity, c.battery, c.area, c.duration, ...(c.entities || [])].filter(Boolean),
|
||
render(c, { ha }) {
|
||
const s = ha.states[c.entity];
|
||
if (!s) return missing(c.entity, "a vacuum");
|
||
const a = s.attributes, e = esc(c.entity), name = friendly(s, c.name);
|
||
const cleaning = s.state === "cleaning", returning = s.state === "returning";
|
||
const batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level);
|
||
const hasBatt = isFinite(batt);
|
||
const charging = s.state === "docked" && hasBatt && batt < 100;
|
||
const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state);
|
||
const detail = a.status && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
|
||
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
|
||
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
|
||
|
||
// battery ring around the robot
|
||
const R = 46, C = 2 * Math.PI * R, frac = hasBatt ? Math.max(0, Math.min(1, batt / 100)) : 0;
|
||
const ring = `<svg class="vac-ring" viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/>
|
||
${hasBatt ? `<circle cx="50" cy="50" r="${R}" class="fill ${batt <= 20 ? "low" : ""}" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}"/>` : ""}</svg>`;
|
||
|
||
const stat = (label, value, ico) => `<div class="vac-stat">${Icons.svg(ico)}<span class="vac-stat-v num">${esc(value)}</span><span class="vac-stat-l">${esc(label)}</span></div>`;
|
||
const stats = [
|
||
hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "",
|
||
c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "",
|
||
c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "",
|
||
...(c.entities || []).map((x) => ha.states[x] && stat(friendly(ha.states[x]).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), ""), fmtState(ha.states[x]), "pulse")),
|
||
].filter(Boolean).join("");
|
||
|
||
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""}>${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
|
||
|
||
return `<div class="vac state-${esc(s.state)}">
|
||
<div class="vac-head"><span class="badge">${Icons.svg("vacuum")}</span>
|
||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></span>
|
||
${hasBatt ? `<span class="vac-batt">${Icons.svg(charging ? "bolt" : "battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
|
||
<div class="vac-hero">
|
||
${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")}">`
|
||
: `<div class="vac-robot ${cleaning ? "is-cleaning" : ""}">${ring}<span class="vac-pulse" aria-hidden="true"></span><span class="vac-robot-ico">${Icons.svg("vacuum")}</span>
|
||
${charging ? `<span class="vac-charge" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>
|
||
<div class="vac-hero-text"><span class="vac-headline">${esc(headline)}</span>${hasBatt ? `<span class="wt">${t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
|
||
</div>
|
||
${stats ? `<div class="vac-stats">${stats}</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()));
|
||
|
||
// Built around a round gauge, like an instrument cluster: charge as an arc with the charge limit marked,
|
||
// the range large in the middle, and the car in front. Sizes adapt through container queries.
|
||
const CAR_ART = `<svg class="car-art2" viewBox="0 0 200 78" aria-hidden="true">
|
||
<defs><linearGradient id="carBody" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--car-top)"/><stop offset="1" stop-color="var(--car-bottom)"/></linearGradient>
|
||
<linearGradient id="carGlass" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#cfe3ff" stop-opacity=".55"/><stop offset="1" stop-color="#7a8cab" stop-opacity=".35"/></linearGradient></defs>
|
||
<ellipse cx="100" cy="70" rx="86" ry="5" class="car-shadow"/>
|
||
<path class="car-body2" d="M14 56c0-6 4-9 12-11l30-6c10-10 22-15 40-16h24c14 0 26 5 36 13l14 5c12 2 18 6 19 12v5c0 3-2 4-5 4H16c-1.5 0-2-2-2-6z"/>
|
||
<path class="car-glass2" d="M64 39c8-8 18-12 32-12h12v12zM112 27h8c11 0 20 4 28 11l-36 1z"/>
|
||
<path class="car-line" d="M30 50h150"/><circle cx="182" cy="52" r="2.2" class="car-light"/>
|
||
<circle cx="52" cy="62" r="12" class="car-tire"/><circle cx="52" cy="62" r="6" class="car-rim"/>
|
||
<circle cx="154" cy="62" r="12" class="car-tire"/><circle cx="154" cy="62" r="6" class="car-rim"/></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: "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 }) {
|
||
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 charging = truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on");
|
||
const plugged = charging || truthy(plug);
|
||
const fuel = c.kind === "fuel", low = pct != null && pct <= 20;
|
||
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
|
||
|
||
// status line and the "full at" time
|
||
let status = "";
|
||
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
|
||
else if (plugged) status = t("Plugged in");
|
||
else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state);
|
||
let 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) fullAt = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }));
|
||
}
|
||
|
||
// the gauge: 240° arc, level filled, charge limit as a tick
|
||
const A0 = Math.PI * (150 / 180), SPAN = Math.PI * (240 / 180), R = 80;
|
||
const ang = (p) => A0 + SPAN * Math.max(0, Math.min(1, p / 100));
|
||
const pt = (p, r = R) => [100 + r * Math.cos(ang(p)), 100 + r * Math.sin(ang(p))];
|
||
const arc = (p0, p1) => { const [x0, y0] = pt(p0), [x1, y1] = pt(p1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(p1) - ang(p0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
|
||
const tick = limPct != null && !fuel ? (() => { const [x0, y0] = pt(limPct, R - 11), [x1, y1] = pt(limPct, R + 11); return `<line x1="${x0.toFixed(1)}" y1="${y0.toFixed(1)}" x2="${x1.toFixed(1)}" y2="${y1.toFixed(1)}" class="cg-limit"/>`; })() : "";
|
||
const gauge = `<div class="cg ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct ?? 0}" aria-label="${esc(fuel ? t("Fuel level") : t("Battery level"))}">
|
||
<svg viewBox="0 0 200 200" aria-hidden="true"><path d="${arc(0, 100)}" class="cg-track"/>${pct ? `<path d="${arc(0, pct)}" class="cg-fill"/>` : ""}${tick}</svg>
|
||
<div class="cg-center">${range ? `<span class="cg-range num">${esc(fmtNum(range.state))}<small>${esc(range.attributes.unit_of_measurement || "km")}</small></span>` : ""}
|
||
${pct != null ? `<span class="cg-pct num">${charging ? Icons.svg("bolt", "inline") : ""}${pct}%${low ? ` · ${t("Low")}` : ""}</span>` : ""}</div>
|
||
<div class="cg-car">${c.image ? `<img src="${esc(c.image)}" alt="">` : CAR_ART}</div></div>`;
|
||
|
||
const btn = (act, eid, on, ico, label, sub) => `<button class="cb wbtn" data-act="${act}" data-e="${esc(eid)}" aria-label="${esc(sub ? `${label}, ${sub}` : label)}" title="${esc(label)}" ${act === "toggle" ? `aria-pressed="${on}"` : ""}>
|
||
<span class="cb-ico">${Icons.svg(ico)}</span><span class="cb-label">${esc(label)}</span>${sub ? `<span class="cb-sub num">${esc(sub)}</span>` : ""}</button>`;
|
||
const buttons = [];
|
||
if (lock) { const locked = lock.state === "locked"; buttons.push(btn("toggle", c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
|
||
if (clim) {
|
||
const on = isOn(clim) || clim.state === "on", inside = clim.attributes.current_temperature;
|
||
buttons.push(btn("toggle", c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"), inside != null ? `${inside}° ${t("inside")}` : ""));
|
||
}
|
||
if (chg && !fuel) {
|
||
const isBtn = domainOf(c.chargeSwitch) === "button";
|
||
buttons.push(isBtn ? `<button class="cb wbtn" data-act="svc" data-e="${esc(c.chargeSwitch)}" data-v="button.press"><span class="cb-ico">${Icons.svg("bolt")}</span><span class="cb-label">${esc(friendly(chg))}</span></button>`
|
||
: btn("toggle", c.chargeSwitch, charging, "bolt", charging ? t("Stop charging") : t("Start charging")));
|
||
}
|
||
if (loc) buttons.push(btn("more", c.location, false, "pin", fmtState(loc)));
|
||
|
||
const chips = [fullAt && `<span class="cchip">${Icons.svg("clock")}${esc(fullAt)}</span>`, odo && `<span class="cchip">${Icons.svg("road")}${esc(fmtState(odo))}</span>`].filter(Boolean).join("");
|
||
|
||
return `<div class="car3 ${charging ? "is-charging" : ""}">
|
||
<div class="car3-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car3-status ${charging ? "charging" : ""}">${esc(status)}</span>` : ""}</div>
|
||
${gauge}
|
||
<div class="car3-info">${chips ? `<div class="cchips">${chips}</div>` : ""}${buttons.length ? `<div class="cbs">${buttons.join("")}</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 };
|
||
})();
|