Dashboard Studio 1.12.0: new cards, kiosk doorbell, night mode, editor tools, version history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:12:30 +01:00
parent f9822a9c49
commit 7abf7379d8
11 changed files with 1728 additions and 182 deletions
+376 -63
View File
@@ -202,11 +202,36 @@
},
};
// ---------------------------------------------------------------- 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.
@@ -214,20 +239,33 @@
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a light");
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id : [];
const 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);
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">` : ""}
${members.length ? `<div class="list light-members" role="group" aria-label="${t("Lights in {0}", esc(name))}">${members.map((eid) => {
const m = ha.states[eid];
if (!m) return "";
return `<button class="list-row wbtn" role="switch" aria-checked="${isOn(m)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(m)}</span><span class="grow">${esc(friendly(m))}</span><span class="st">${esc(fmtState(m))}</span><span class="switch"></span></button>`;
}).join("")}</div>` : ""}</div>`;
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]),
@@ -477,6 +515,254 @@
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) {
@@ -848,11 +1134,23 @@
// ---------------------------------------------------------------- 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)", ph: "https://… or /local/car.png" },
{ 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"] },
@@ -860,72 +1158,70 @@
{ 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", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
// Layout follows the card's size (container queries in style.css): designed around 3×4,
// a compact strip when short, and the car beside the numbers when wide.
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "toFull", "chargeSwitch", "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");
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 = st_("power"), plug = st_("plug"), limit = st_("limit"), odo = st_("odometer");
const charging = truthy(st_("charging")) || (!c.charging && power && Number(power.state) > 0);
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 low = pct != null && pct <= 20;
const fuel = c.kind === "fuel";
const fuel = c.kind === "fuel", low = pct != null && pct <= 20;
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
let status;
// 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);
else if (lock) status = lock.state === "locked" ? t("Locked") : t("Unlocked");
else status = "";
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" }));
}
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
// Drawn car for when there's no photo: a side view, with a glow and a bolt while charging.
const drawn = `<svg class="car-art" viewBox="0 0 200 84" aria-hidden="true">
<path class="car-body" d="M10 60c1-10 9-15 22-17l26-15c7-4 15-6 24-6h40c10 0 18 3 25 9l15 12c13 1 24 4 28 11l2 8c0 4-2 6-6 6H12c-3 0-4-3-2-8z"/>
<path class="car-glass" d="M64 31c6-3 12-4 19-4h16v18H52zM105 27h17c8 0 14 2 20 7l9 11h-46z"/>
<circle class="car-wheel" cx="46" cy="68" r="13"/><circle class="car-wheel" cx="154" cy="68" r="13"/>
<circle class="car-hub" cx="46" cy="68" r="5"/><circle class="car-hub" cx="154" cy="68" r="5"/></svg>`;
const visual = `<div class="car-visual ${charging ? "is-charging" : ""}">${c.image ? `<img class="car-photo" src="${esc(c.image)}" alt="">` : drawn}
${charging ? `<span class="car-bolt" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>`;
// 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 bar = pct == null ? "" : `
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${fuel ? t("Fuel level") : t("Battery level")}">
<span class="car-fill" style="width:${pct}%"></span>
${limPct != null && !fuel ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
</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 action = (eid, on, ico, label) => `<button class="car-btn wbtn" data-act="${eid === c.location ? "more" : "toggle"}" data-e="${esc(eid)}" ${eid === c.location ? "" : `aria-pressed="${on}"`}>
<span class="car-btn-ico">${Icons.svg(ico)}</span><span class="car-btn-label">${esc(label)}</span></button>`;
const actions = [];
if (lock) { const locked = lock.state === "locked"; actions.push(action(c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
if (clim) { const on = isOn(clim) || clim.state === "on"; actions.push(action(c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"))); }
if (loc) actions.push(action(c.location, false, "pin", fmtState(loc)));
const 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("");
const stat = (label, value) => `<div class="car-stat"><span class="car-stat-v num">${esc(value)}</span><span class="car-stat-l">${esc(label)}</span></div>`;
const stats = [
odo && stat(t("Odometer"), fmtState(odo)),
limPct != null && !fuel && stat(t("Charge limit"), `${limPct}%`),
charging && power && stat(t("Charging power"), fmtState(power)),
].filter(Boolean).join("");
return `<div class="car2 ${c.image ? "has-photo" : ""}">
<div class="car-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car-status ${charging ? "charging" : ""}">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</span>` : ""}</div>
${visual}
<div class="car-info">
<div class="car-nums">${pct != null ? `<span class="car-pct num">${pct}<span class="unit">%</span></span>` : ""}
${range ? `<span class="car-range num">${esc(fmtState(range))}</span>` : ""}${low ? `<span class="level">${t("Low")}</span>` : ""}</div>
${bar}
${stats ? `<div class="car-stats">${stats}</div>` : ""}
${actions.length ? `<div class="car-actions2">${actions.join("")}</div>` : ""}
</div></div>`;
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>`;
},
};
@@ -1171,7 +1467,13 @@
// 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 ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></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,
@@ -1180,7 +1482,9 @@
{ 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);
@@ -1212,7 +1516,9 @@
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);
@@ -1328,5 +1634,12 @@
};
window.Widgets = W;
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
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 };
})();