Dashboard Studio 1.11.0: energy card, climate popup, car and appliance redesigns, Home Connect

- Energy card: live power flow between solar, grid, battery and home,
  with line width and speed following the power, battery level and
  today's totals; sign of grid and battery sensors can be flipped
- Climate popup (room card thermostat and any climate entity): dial,
  -/+ that batch quick taps, modes, presets and fan modes, live updates
- Car card redesigned around 3x4 with a drawn car or photo, compact,
  wide and tall layouts
- Appliance card redesigned with a progress ring; Home Connect support:
  all operation states, auto-fill from the operation state sensor via
  the device registry, readable program names, door state, pause/
  resume/stop buttons, power switch and alerts. Fixes ready/inactive
  being shown as finished/running
- Horizontal scroll card: width in pixels or cards per view, and a
  width per card
- Swipe card: no lost tap after a touch swipe, no broken swipes over
  images, lost pointer capture ends the swipe, trackpad swiping

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 14:06:35 +01:00
parent 78aa85a82f
commit f9822a9c49
9 changed files with 551 additions and 90 deletions
+83 -3
View File
@@ -113,12 +113,15 @@
]).then(([areas, ents, devs]) => {
const devArea = Object.fromEntries((devs || []).map((d) => [d.id, d.area_id]));
const byArea = {};
const deviceOf = {}, byDevice = {};
for (const e of ents || []) {
if (e.disabled_by || e.hidden_by || e.entity_category) continue; // skip config/diagnostic and hidden entities
if (e.disabled_by) continue;
if (e.device_id) { deviceOf[e.entity_id] = e.device_id; (byDevice[e.device_id] ||= []).push(e.entity_id); }
if (e.hidden_by || e.entity_category) continue; // rooms skip config/diagnostic and hidden entities
const area = e.area_id || devArea[e.device_id];
if (area) (byArea[area] ||= []).push(e.entity_id);
}
this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea };
this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea, deviceOf, byDevice };
return this.reg;
}).catch((err) => { this._reg = null; throw err; });
return this._reg;
@@ -581,6 +584,7 @@
let interacting = false;
function onStateChange(entityId) {
if (livePopup && livePopup.eid === entityId) livePopup.draw(); // an open climate popup follows the device
if (!current) return;
if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; }
const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId));
@@ -709,6 +713,7 @@
el.innerHTML = `${datalists()}<button class="btn back" data-child-back>${Icons.svg("chevron", "back")}${esc(tr(Widgets[w.type].label))}</button>
<div class="panel-title">${Icons.svg(cdef.icon)} ${esc(tr(cdef.label))}</div>
${cdef.fields.filter((f) => !f.when || f.when(ch.cfg)).map((f) => fieldHTML(f, ch.cfg[f.k], "cfg")).join("")}
${w.type === "hscroll" ? fieldHTML({ k: "_width", t: "number", l: "Width of this card (px)", ph: "Same as the others" }, ch.cfg._width, "cfg") : ""}
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, ch.style?.[f.k], "style")).join("")}</div>
<div class="insp-actions"><button class="btn danger" data-child-op="del" data-i="${childIndex}">${Icons.svg("trash")}${tr("Remove from container")}</button></div>`;
return;
@@ -919,6 +924,17 @@
if (path === "cfg") {
if (idx != null) w.cfg[k][Number(idx)] = v;
else w.cfg[k] = v;
const def = Widgets[w.type];
if (def.autofill && k === def.autofill.from && v && idx == null) {
const fill = def.autofill.fill(v, ha);
for (const [fk, fv] of Object.entries(fill)) if (fv && (w.cfg[fk] == null || w.cfg[fk] === "" || (Array.isArray(w.cfg[fk]) && !w.cfg[fk].length))) w.cfg[fk] = fv;
// The type may change from its default, and a default name follows it (washer → dishwasher).
if (fill.kind && w.cfg.kind !== fill.kind) {
const names = { washer: "Washing machine", dryer: "Dryer", dishwasher: "Dishwasher" };
if (!w.cfg.name || w.cfg.name === tr(names[w.cfg.kind] || "")) w.cfg.name = tr(names[fill.kind]);
w.cfg.kind = fill.kind;
}
}
// A light group lists its lights underneath, which needs room.
if (w.type === "light" && k === "entity" && Array.isArray(ha.states[v]?.attributes.entity_id) && w.h && w.h < 4) { w.h = 4; resolveCollisions(w); }
} else if (path === "style") { (w.style ||= {})[k] = v; }
@@ -940,7 +956,7 @@
if (path === "cfg" || path === "style") renderWidget(selectedWidget());
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
if (isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON")) renderInspector();
if (isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON" || Widgets[w.type].autofill?.from === k)) renderInspector();
if (path === "theme" && (t.tagName === "BUTTON" || (isChange && COLOR_KEYS.includes(k)))) renderInspector();
}
@@ -1216,9 +1232,73 @@
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok"); });
}
// ------------------------------------------------------------ climate popup
let livePopup = null;
const HVAC_ICON = { off: "power", heat: "flame", cool: "snowflake", heat_cool: "thermometer", auto: "sparkles", dry: "droplet", fan_only: "fan" };
/** A thermostat dial with −/+, modes, presets and fan modes. Taps on −/+ add up and are sent after a short pause. */
function climatePopup(eid) {
let pending = null, sendTimer = 0;
const step = () => Number(ha.states[eid]?.attributes.target_temp_step) || 0.5;
const draw = () => {
const s = ha.states[eid];
if (!s) return;
const a = s.attributes, min = Number(a.min_temp ?? 7), max = Number(a.max_temp ?? 30);
const target = pending ?? a.temperature;
const off = s.state === "off";
const color = { heat: "var(--warn)", cool: "var(--accent)", heat_cool: "var(--ok)", auto: "var(--ok)" }[s.state] || "var(--text-2)";
// 270° dial: track, the target as an arc and a knob, the current temperature as a small dot
const A0 = Math.PI * 0.75, SPAN = Math.PI * 1.5, R = 86;
const ang = (v) => A0 + SPAN * Math.max(0, Math.min(1, (v - min) / (max - min || 1)));
const pt = (v, r = R) => [100 + r * Math.cos(ang(v)), 100 + r * Math.sin(ang(v))];
const arc = (v0, v1) => { const [x0, y0] = pt(v0), [x1, y1] = pt(v1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(v1) - ang(v0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
const hasTarget = target != null && isFinite(Number(target));
const cur = Number(a.current_temperature);
const chips = (list, current, kind) => (list || []).map((m) => `<button class="chip-btn" data-cl="${kind}" data-v="${esc(m)}" aria-pressed="${m === current}">${kind === "hvac" && HVAC_ICON[m] ? Icons.svg(HVAC_ICON[m]) : ""}<span>${esc(window.I18n.state(m))}</span></button>`).join("");
const card = $("#modal .modal-card");
card.innerHTML = `<h3>${esc(friendly(s))}</h3>
<p>${tr("Now")} <b class="num">${isFinite(cur) ? cur + "°" : "–"}</b>${a.current_humidity != null ? ` · ${tr("Humidity")} <b class="num">${esc(a.current_humidity)}%</b>` : ""}${a.hvac_action ? ` · ${esc(window.I18n.state(a.hvac_action))}` : ""}</p>
<div class="cl-dial ${off ? "is-off" : ""}" style="--cl:${color}">
<svg viewBox="0 0 200 200" aria-hidden="true">
<path d="${arc(min, max)}" class="cl-track"/>
${hasTarget && !off ? `<path d="${arc(min, Number(target))}" class="cl-fill"/><circle cx="${pt(Number(target))[0]}" cy="${pt(Number(target))[1]}" r="11" class="cl-knob"/>` : ""}
${isFinite(cur) ? `<circle cx="${pt(cur, R - 18)[0]}" cy="${pt(cur, R - 18)[1]}" r="4" class="cl-cur"/>` : ""}
</svg>
<div class="cl-center"><span class="cl-mode">${esc(window.I18n.state(s.state))}</span>
<span class="cl-target num" aria-live="polite">${hasTarget ? `${Number(target).toLocaleString(LOCALE, { minimumFractionDigits: step() < 1 ? 1 : 0, maximumFractionDigits: 1 })}°` : "–"}</span>
<span class="muted small">${tr("Target")}</span></div>
</div>
${hasTarget ? `<div class="cl-steps"><button class="round-btn" data-cl="temp" data-v="-1" aria-label="${tr("Lower")}">${Icons.svg("minus")}</button><button class="round-btn" data-cl="temp" data-v="1" aria-label="${tr("Raise")}">${Icons.svg("plus")}</button></div>` : ""}
${a.hvac_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Mode")}</div><div class="cl-chips">${chips(a.hvac_modes, s.state, "hvac")}</div>` : ""}
${a.preset_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Preset")}</div><div class="cl-chips">${chips(a.preset_modes, a.preset_mode, "preset")}</div>` : ""}
${a.fan_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Fan")}</div><div class="cl-chips">${chips(a.fan_modes, a.fan_mode, "fan")}</div>` : ""}
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button></div>`;
};
livePopup = { eid, draw };
const done = openModal("", (e, close) => {
const b = e.target.closest("[data-cl], [data-x]");
if (!b) return;
if (b.dataset.x) { close(); return; }
const s = ha.states[eid], kind = b.dataset.cl;
if (kind === "temp") {
const a = s.attributes, base = pending ?? Number(a.temperature);
pending = Math.round(Math.max(Number(a.min_temp ?? 7), Math.min(Number(a.max_temp ?? 30), base + Number(b.dataset.v) * step())) * 10) / 10;
draw();
clearTimeout(sendTimer);
sendTimer = setTimeout(() => { const v = pending; ha.callService("climate", "set_temperature", { temperature: v }, eid).then(() => { if (pending === v) pending = null; }); }, 700);
} else if (kind === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: b.dataset.v }, eid);
else if (kind === "preset") ha.callService("climate", "set_preset_mode", { preset_mode: b.dataset.v }, eid);
else if (kind === "fan") ha.callService("climate", "set_fan_mode", { fan_mode: b.dataset.v }, eid);
});
draw();
$("#modal .modal-card [data-x]")?.focus();
done.then(() => { livePopup = null; });
}
function moreInfo(eid) {
const s = ha.states[eid];
if (!s) return;
if (domainOf(eid) === "climate") { climatePopup(eid); return; }
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid));
const actionLabel = domainOf(eid) === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(domainOf(eid)) ? tr("Run") : isOn(s) ? tr("Turn off") : tr("Turn on");
openModal(`<h3>${esc(friendly(s))}</h3><div class="muted small">${esc(eid)}</div>