9e7e7beaa0
- Visibility conditions on every widget (is / is not / above / below, all or any); rows that only held hidden widgets close up in view mode - Appliance card for washer, dryer and dishwasher: status or power based, time left, ready time, progress and program; hides when off - Media card controls several players with a switcher and album art - Dutch translations and docs for the new features Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
816 lines
52 KiB
JavaScript
816 lines
52 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>`;
|
||
},
|
||
};
|
||
|
||
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" },
|
||
],
|
||
render(c, { ha }) {
|
||
const s = ha.states[c.entity];
|
||
if (!s) return missing(c.entity, "a light");
|
||
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
|
||
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
|
||
const name = friendly(s, c.name);
|
||
return `<div class="light">
|
||
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
|
||
<span class="badge">${icon(s)}</span>
|
||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
|
||
${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(name)} brightness">` : ""}</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>`;
|
||
}
|
||
|
||
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 },
|
||
],
|
||
render: (c) => `<div class="clock" 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>`,
|
||
};
|
||
|
||
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),
|
||
};
|
||
|
||
// ---------------------------------------------------------------- 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|ready|klaar|gereed|beëindigd/;
|
||
const PAUSE_RE = /pause|gepauzeerd|delayed|uitgesteld/;
|
||
|
||
function appliancePhase(c, ha) {
|
||
const status = String(ha.states[c.status]?.state || "").toLowerCase();
|
||
if (status && !["unavailable", "unknown"].includes(status)) {
|
||
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));
|
||
|
||
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", 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 end time", domains: ["sensor"] },
|
||
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
|
||
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
|
||
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
|
||
],
|
||
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program].filter(Boolean),
|
||
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off",
|
||
render(c, { ha }) {
|
||
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") }[phase];
|
||
const left = phase === "running" || phase === "paused" ? remainingMinutes(c, ha) : null;
|
||
const prog = Number(ha.states[c.progress]?.state);
|
||
const program = ha.states[c.program]?.state;
|
||
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 showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
|
||
return `<div class="appl phase-${phase}">
|
||
<div class="tile-row"><span class="badge">${Icons.svg(ico)}</span>
|
||
<span class="tile-text"><span class="name">${esc(c.name || t("Appliance"))}</span>${showProgram ? `<span class="state">${esc(st(program))}</span>` : ""}</span>
|
||
<span class="appl-status">${esc(label)}</span></div>
|
||
${phase === "done" ? `<div class="appl-big">${t("Ready to unload")}</div>` : left != null ? `<div class="appl-big"><span class="num">${esc(fmtDuration(left))}</span> <span class="wt">${t("left · ready at {0}", ends)}</span></div>` : ""}
|
||
${isFinite(prog) && c.progress && phase !== "off" ? `<div class="car-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"><span class="car-fill" style="width:${Math.max(0, Math.min(100, prog))}%"></span></div>` : ""}
|
||
</div>`;
|
||
},
|
||
lit: (c, { ha }) => (["running", "done"].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()));
|
||
|
||
W.car = {
|
||
label: "Car", icon: "car", size: [6, 3],
|
||
fields: [
|
||
{ k: "name", t: "text", l: "Name", d: "My car" },
|
||
{ k: "image", t: "text", l: "Car image URL (optional)", ph: "https://… or /local/car.png" },
|
||
{ k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" },
|
||
{ k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] },
|
||
{ k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] },
|
||
{ k: "charging", t: "entity", l: "Charging", domains: ["binary_sensor", "sensor", "switch"] },
|
||
{ k: "power", t: "entity", l: "Charging power", domains: ["sensor"] },
|
||
{ k: "plug", t: "entity", l: "Plugged in", domains: ["binary_sensor", "sensor"] },
|
||
{ k: "limit", t: "entity", l: "Charge limit (%)", domains: ["number", "sensor", "input_number"] },
|
||
{ k: "lock", t: "entity", l: "Doors", domains: ["lock"] },
|
||
{ k: "climate", t: "entity", l: "Climate", domains: ["climate", "switch", "input_boolean"] },
|
||
{ k: "location", t: "entity", l: "Location", domains: ["device_tracker", "person", "sensor"] },
|
||
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
|
||
],
|
||
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
|
||
render(c, { ha }) {
|
||
const st = (k) => (c[k] ? ha.states[c[k]] : null);
|
||
const level = st("level"), range = st("range"), lock = st("lock"), clim = st("climate"), loc = st("location");
|
||
if (!c.level && !c.range && !c.lock) return `<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 = st("power"), plug = st("plug"), limit = st("limit");
|
||
const charging = truthy(st("charging")) || (!c.charging && power && Number(power.state) > 0);
|
||
const plugged = charging || truthy(plug);
|
||
const low = pct != null && pct <= 20;
|
||
|
||
let status;
|
||
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
|
||
else if (plugged) status = t("Plugged in");
|
||
else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state);
|
||
else if (lock) status = lock.state === "locked" ? t("Locked") : t("Unlocked");
|
||
else status = "";
|
||
|
||
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
|
||
const bar = pct == null ? "" : `
|
||
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${c.kind === "fuel" ? t("Fuel level") : t("Battery level")}">
|
||
<span class="car-fill" style="width:${pct}%"></span>
|
||
${limPct != null && c.kind !== "fuel" ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
|
||
</div>`;
|
||
const actions = [];
|
||
if (lock) {
|
||
const locked = lock.state === "locked";
|
||
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.lock)}" aria-pressed="${!locked}">${Icons.svg(locked ? "lock" : "unlock")}<span>${locked ? t("Locked") : t("Unlocked")}</span></button>`);
|
||
}
|
||
if (clim) {
|
||
const on = isOn(clim) || clim.state === "on";
|
||
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.climate)}" aria-pressed="${on}">${Icons.svg(on ? "fan" : "snowflake")}<span>${on ? t("Climate on") : t("Climate")}</span></button>`);
|
||
}
|
||
if (loc) actions.push(`<button class="car-act wbtn" data-act="more" data-e="${esc(c.location)}">${Icons.svg("pin")}<span>${esc(fmtState(loc))}</span></button>`);
|
||
const odo = st("odometer");
|
||
|
||
return `<div class="car ${c.image ? "has-img" : ""}">
|
||
${c.image ? `<img class="car-img" src="${esc(c.image)}" alt="">` : ""}
|
||
<div class="car-head"><div class="name">${esc(c.name || t("Car"))}</div><div class="wt">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</div></div>
|
||
<div class="car-main">
|
||
${pct != null ? `<span class="big num">${pct}<span class="unit">%</span></span>` : ""}
|
||
${range ? `<span class="car-range">${esc(fmtState(range))}<span class="wt"> ${t("range")}</span></span>` : ""}
|
||
${low ? `<span class="level">${t("Low")}</span>` : ""}
|
||
</div>
|
||
${bar}
|
||
${actions.length || odo ? `<div class="car-actions">${actions.join("")}${odo ? `<span class="wt car-odo">${esc(fmtState(odo))}</span>` : ""}</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" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
||
<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>`;
|
||
},
|
||
async after(el, c, ctx) {
|
||
const cache = ctx.cache;
|
||
clearTimeout(cache.timer);
|
||
const box = el.querySelector(".photos");
|
||
if (!box) return;
|
||
const msg = box.querySelector(".ph-msg");
|
||
const key = JSON.stringify([c.source, c.urls ?? c.url, c.albums, c.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 (c.source !== "immich") return urlList(c).map((src) => ({ src }));
|
||
const lists = await Promise.all((c.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 (c.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;
|
||
if (cache.error) { msg.innerHTML = `<div class="empty">${t("Couldn’t load photos. {0}", esc(cache.error))}</div>`; return; }
|
||
if (!cache.items.length) { msg.innerHTML = `<div class="empty">${t("These albums have no photos yet.")}</div>`; 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;
|
||
msg.innerHTML = "";
|
||
dateEl.textContent = c.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(c.interval) || 20) * 1000);
|
||
};
|
||
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>` : ""}`;
|
||
},
|
||
};
|
||
|
||
window.Widgets = W;
|
||
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
|
||
})();
|