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>
+18 -1
View File
@@ -285,6 +285,22 @@
"What’s new": "Wat is er nieuw", "Source code": "Broncode", "this version": "deze versie",
"The changelog couldn’t be loaded.": "De lijst met wijzigingen kon niet worden geladen.",
"Updated to {0}. See what’s new under About in the settings.": "Bijgewerkt naar {0}. Bekijk wat er nieuw is onder Over in de instellingen.",
"Card width": "Kaartbreedte", "pixels": "Pixels", "per-view": "Aantal zichtbaar", "Cards visible at once": "Aantal kaarten tegelijk zichtbaar",
"e.g. 1, 2 or 2.5": "bijv. 1, 2 of 2,5", "Width of this card (px)": "Breedte van deze kaart (px)", "Same as the others": "Zelfde als de andere",
"Target": "Doel", "Lower": "Lager", "Raise": "Hoger", "Preset": "Voorinstelling", "Fan": "Ventilator",
"Charge limit": "Laadlimiet",
"Energy": "Energie", "Solar power": "Zonnevermogen", "Grid power (positive = from the grid)": "Netvermogen (positief = van het net)",
"My grid sensor is positive when sending back": "Mijn netsensor is positief bij teruglevering", "Battery power (positive = charging)": "Accuvermogen (positief = laden)",
"My battery sensor is positive when discharging": "Mijn accusensor is positief bij ontladen", "Battery level (%)": "Accuniveau (%)",
"Home use (optional, otherwise calculated)": "Verbruik in huis (optioneel, anders berekend)", "Totals for today, e.g. kWh produced and used": "Totalen van vandaag, bijv. opgewekte en verbruikte kWh",
"Choose your solar, grid or battery power sensors in the inspector.": "Kies in de inspector je sensoren voor zonnepanelen, het net of de thuisaccu.",
"Solar {0}, grid {1}, home {2}": "Zon {0}, net {1}, huis {2}", "Solar": "Zon", "To the grid": "Naar het net", "From the grid": "Van het net", "Discharging": "Ontladen",
"Door (optional)": "Deur (optioneel)", "Energy or water use (optional)": "Energie- of waterverbruik (optioneel)", "Door open": "Deur open", "Door closed": "Deur dicht",
"Status (with Home Connect: Operation state — the rest fills in by itself)": "Status (bij Home Connect: Bedieningsstatus — de rest vult zichzelf in)",
"Time remaining or finish time": "Resterende tijd of eindtijd", "Pause button": "Pauzeknop", "Resume button": "Hervatknop", "Stop button": "Stopknop",
"Power switch": "Aan/uit-schakelaar", "Alerts, e.g. salt or rinse aid low": "Meldingen, bijv. zout of glansspoelmiddel bijna op",
"Starts later": "Start later", "Action needed": "Actie nodig", "Error": "Fout", "Stopping": "Stoppen", "Door locked": "Deur vergrendeld",
"Ready at {0}": "Klaar om {0}", "Check the appliance": "Kijk even bij het apparaat", "Something went wrong": "Er ging iets mis", "Stop": "Stop",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
@@ -295,7 +311,8 @@
locking: "Vergrendelen", unlocking: "Ontgrendelen", jammed: "Vastgelopen", playing: "Speelt", paused: "Gepauzeerd",
idle: "Inactief", standby: "Stand-by", buffering: "Bufferen", heat: "Verwarmen", cool: "Koelen", auto: "Automatisch",
heat_cool: "Verwarmen/koelen", dry: "Drogen", fan_only: "Alleen ventilator", cleaning: "Reinigen", docked: "Gedockt",
returning: "Keert terug", error: "Fout", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
returning: "Keert terug", error: "Fout", eco: "Eco", comfort: "Comfort", away: "Afwezig", sleep: "Slapen", boost: "Boost", none: "Geen",
low: "Laag", medium: "Midden", high: "Hoog", heating: "Verwarmt", cooling: "Koelt", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
"clear-night": "Helder", cloudy: "Bewolkt", exceptional: "Uitzonderlijk", fog: "Mist", hail: "Hagel", lightning: "Onweer",
"lightning-rainy": "Onweer en regen", partlycloudy: "Halfbewolkt", pouring: "Stortregen", rainy: "Regen", snowy: "Sneeuw",
"snowy-rainy": "Natte sneeuw", sunny: "Zonnig", windy: "Winderig", "windy-variant": "Winderig", armed_away: "Ingeschakeld (afwezig)",
+1
View File
@@ -48,6 +48,7 @@
hscroll: "M3 6h5v12H3zM10 6h5v12h-5zM17 6h4M17 18h4M21 6v12",
swipe: "M6 4h12a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM9 21h.01M12 21h.01M15 21h.01",
sofa: "M5 11V8a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3M3 12a2 2 0 0 1 4 0v2h10v-2a2 2 0 0 1 4 0v5H3zM5 17v2M19 17v2",
stop: "M7 7h10v10H7z",
calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
chart: "M3 3v18h18M7 15l4-4 3 3 6-6",
+139 -24
View File
@@ -218,6 +218,25 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
.assign-kiosk { display: flex; align-items: center; gap: 6px; font-size: 13px; white-space: nowrap; }
.assign-kiosk input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
@media (max-width: 560px) { .assign-row[data-dev] { flex-wrap: wrap; } .assign-row[data-dev] .input { flex-basis: 100%; } }
/* climate popup */
.cl-dial { position: relative; width: min(240px, 70vw); margin: 8px auto 0; aspect-ratio: 1; }
.cl-dial svg { width: 100%; height: 100%; }
.cl-dial path { fill: none; stroke-width: 14; stroke-linecap: round; }
.cl-track { stroke: var(--fill-strong); }
.cl-fill { stroke: var(--cl); transition: d .3s; }
.cl-knob { fill: #fff; stroke: var(--cl); stroke-width: 4; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .3)); }
.cl-cur { fill: var(--text); }
.cl-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.cl-target { font-size: 52px; font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
.cl-mode { font-size: 14px; font-weight: 600; color: var(--cl); margin-bottom: 4px; }
.cl-dial.is-off .cl-target { color: var(--text-2); }
.cl-steps { display: flex; justify-content: center; gap: 28px; margin-top: -18px; }
.round-btn { width: 52px; height: 52px; border-radius: 50%; border: 0; background: var(--fill); font-size: 22px; display: grid; place-items: center; cursor: pointer; }
.round-btn:hover { background: var(--fill-strong); }
.cl-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 99px; border: 0; background: var(--fill); cursor: pointer; font-weight: 500; }
.chip-btn .i { font-size: 16px; }
.chip-btn[aria-pressed="true"] { background: var(--text); color: var(--surface); font-weight: 600; }
.changelog { margin-top: 8px; }
.changelog h4 { margin: 16px 0 4px; font-size: 15px; }
.changelog ul { margin: 0; padding-left: 20px; color: var(--text); }
@@ -506,15 +525,64 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
@keyframes eq { 0%, 100% { transform: scaleY(0.4); transform-origin: bottom; } 50% { transform: scaleY(1); transform-origin: bottom; } }
@container (max-height: 170px) { .mp-art { display: none; } }
/* appliance */
.appl { display: flex; flex-direction: column; height: 100%; gap: 8px; justify-content: space-between; }
.appl-status { margin-left: auto; font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
/* appliance: ring on the left at two rows high, stacked when taller */
.appl { display: flex; align-items: center; gap: 16px; height: 100%; min-width: 0; }
.appl-ring { position: relative; flex: none; height: min(100%, 132px); aspect-ratio: 1; display: grid; place-items: center; }
.appl-ring > svg { position: absolute; inset: 0; transform: rotate(-90deg); }
.appl-ring circle { fill: none; stroke-width: 7; }
.appl-ring .track { stroke: var(--d-fill); }
.appl-ring .fill { stroke: var(--d-accent); stroke-linecap: round; transition: stroke-dasharray .6s; }
.phase-done .appl-ring .fill { stroke: var(--ok); }
.appl-ico { width: 64%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(22px, 22cqh, 40px); background: var(--d-fill); }
.phase-running .appl-ico { background: color-mix(in srgb, var(--d-accent) 18%, transparent); color: var(--d-accent); }
.phase-done .appl-ico { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
.appl-pct { position: absolute; bottom: -2px; font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 99px; background: var(--w-bg, var(--d-tile)); color: var(--d-text-2); }
.appl-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.appl-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
.appl-top .name { flex: 1; min-width: 0; }
.appl-status { font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; flex: none; }
.appl.phase-running .appl-status { background: var(--d-accent); color: var(--d-on-accent, #fff); }
.theme-outline .w:not(.transparent) { border-color: var(--d-separator); }
.appl.phase-done .appl-status { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
.appl-big { font-size: 22px; font-weight: 700; }
.appl-big .wt { font-size: 14px; font-weight: 400; }
.appl .car-fill { background: var(--d-accent); }
.appl-program { font-size: 13px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.appl-main { margin-top: 2px; }
.appl-big { font-size: clamp(20px, 15cqh, 30px); font-weight: 700; line-height: 1.1; }
.appl-off { color: var(--d-text-2); }
.appl-sub { font-size: 13px; color: var(--d-text-2); }
.appl-facts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.appl-main { display: flex; align-items: center; gap: 8px; }
.appl-main-text { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; }
.appl-ctrls { display: flex; gap: 6px; flex: none; }
.appl-btn { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-size: 18px; }
.appl-btn:hover { background: color-mix(in srgb, var(--d-text) 16%, transparent); }
.appl.phase-attention .appl-status { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.appl.phase-error .appl-status { background: color-mix(in srgb, var(--crit) 18%, transparent); color: var(--crit); }
.appl.phase-scheduled .appl-status, .appl.phase-stopping .appl-status { background: var(--d-fill); }
.phase-attention .appl-ico { color: var(--warn); } .phase-error .appl-ico { color: var(--crit); }
.appl-fact { white-space: nowrap; }
.appl-fact span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* two rows high: one line of facts, tighter spacing */
@container (max-height: 180px) {
.appl-text { gap: 0; }
.appl-facts { flex-wrap: nowrap; overflow: hidden; margin-top: 3px; }
.appl-fact { min-width: 0; flex-shrink: 1; }
.appl-big { font-size: 22px; }
.appl-btn { width: 36px; height: 36px; font-size: 16px; }
}
.appl-fact { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); }
.appl-fact .i { font-size: 14px; }
.appl-fact.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 16%, transparent); }
.theme-outline .w:not(.transparent) { border-color: var(--d-separator); }
@container (max-width: 260px) { .appl-ring { display: none; } }
@container (min-height: 260px) {
.appl { flex-direction: column; justify-content: center; text-align: center; }
.appl-ring { height: min(55%, 170px); }
.appl-text { align-items: center; flex: none; width: 100%; }
.appl-top { justify-content: center; }
.appl-top .name { flex: none; }
.appl-main, .appl-main-text, .appl-facts { justify-content: center; }
.appl-main { flex-direction: column; }
.appl-main-text { flex: none; }
}
/* containers: horizontal scroll and swipe */
.w.child { position: relative; flex: none; height: 100%; }
@@ -525,7 +593,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
scroll-snap-type: x mandatory; scroll-padding: 0 2px; scrollbar-width: none; overscroll-behavior-x: contain;
}
.hs-track::-webkit-scrollbar { display: none; }
.hs-track > .w.child { width: var(--item-w, 260px); scroll-snap-align: start; }
.hs-track > .w.child { width: var(--own-w, var(--item-w, 260px)); scroll-snap-align: start; }
.hs-track:focus-visible { box-shadow: var(--focus); border-radius: var(--d-radius, 20px); }
.hs-nav {
position: absolute; top: 50%; z-index: 4; width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
@@ -542,6 +610,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.sw-track > .w.child { flex: 0 0 100%; width: 100%; }
.sw.dragging .sw-track { cursor: grabbing; }
.sw.dragging .w.child { pointer-events: none; }
.sw img { -webkit-user-drag: none; user-select: none; }
.sw-track:focus-visible { box-shadow: inset var(--focus); }
/* Dots float over the cards and only show while swiping, hovering or focused. */
.sw-dots {
@@ -588,6 +657,26 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
.light:has(.light-members) { justify-content: flex-start; }
/* energy flow */
.energy { display: flex; flex-direction: column; height: 100%; gap: 10px; }
.ef-diagram { position: relative; flex: 1; min-height: 0; }
.ef-diagram > svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.ef-line, .ef-flow { fill: none; vector-effect: non-scaling-stroke; }
.ef-line { stroke: var(--d-fill); stroke-width: 2; }
.ef-flow { stroke-dasharray: 2 12; stroke-linecap: round; animation: ef-flow linear infinite; }
.ef-flow.solar { stroke: #f5a524; } .ef-flow.grid { stroke: var(--d-text-2); } .ef-flow.battery { stroke: var(--ok); }
@keyframes ef-flow { to { stroke-dashoffset: -28; } }
.ef-node { position: absolute; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; text-align: center; gap: 1px; width: 33%; }
.ef-solar { left: 50%; top: 14%; } .ef-grid { left: 14%; top: 50%; } .ef-home { left: 86%; top: 50%; } .ef-battery { left: 50%; top: 86%; }
.ef-ico { width: clamp(34px, 13cqmin, 52px); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(17px, 6cqmin, 24px); background: var(--w-bg, var(--d-tile)); border: 2px solid; }
.ef-solar .ef-ico { border-color: #f5a524; color: #f5a524; } .ef-grid .ef-ico { border-color: var(--d-text-2); } .ef-home .ef-ico { border-color: var(--d-accent); color: var(--d-accent); } .ef-battery .ef-ico { border-color: var(--ok); color: var(--ok); }
.ef-val { font-size: clamp(13px, 5cqmin, 17px); font-weight: 700; white-space: nowrap; }
.ef-label { font-size: 11px; color: var(--d-text-2); white-space: nowrap; }
.ef-val, .ef-label { position: relative; padding: 0 5px; border-radius: 5px; background: var(--w-bg, var(--d-tile)); }
.surface-plain .ef-val, .surface-plain .ef-label, .w.transparent .ef-val, .w.transparent .ef-label { background: var(--d-bg); }
.ef-totals { flex: none; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--d-text-2); justify-content: center; }
.ef-total b { color: var(--d-text); font-size: 13px; }
/* room */
.room { position: relative; display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; }
.room.on-image { margin: -16px; padding: 16px; border-radius: inherit; color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); --d-fill: rgba(255, 255, 255, 0.18);
@@ -680,28 +769,54 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.cal-now { font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 99px; color: var(--d-accent); background: color-mix(in srgb, currentColor 16%, transparent); align-self: center; }
@container (max-width: 260px) { .cal-ev { flex-wrap: wrap; } .cal-when { width: auto; order: 2; flex-basis: 100%; padding-left: 14px; } }
/* car */
.car { position: relative; display: flex; flex-direction: column; height: 100%; gap: 8px; }
.car-img { position: absolute; right: -4px; top: -6px; width: 46%; height: 58%; object-fit: contain; object-position: right top; pointer-events: none; }
.car.has-img .car-head, .car.has-img .car-main { max-width: 56%; }
.car-head { display: flex; flex-direction: column; min-width: 0; }
.car-head .name { font-size: 17px; }
.car-main { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-top: auto; }
.car-range { font-size: 17px; font-weight: 600; }
.car-range .wt { font-size: 14px; font-weight: 400; }
/* pill button with icon and label (media "Turn on") */
.car-act { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 12px; background: var(--d-fill); font-size: 14px; font-weight: 500; }
.car-act .i { font-size: 18px; }
/* car: built around 3×4; compact when short, side by side when wide */
.car2 { display: grid; grid-template-areas: "top" "visual" "info"; grid-template-rows: auto 1fr auto; gap: 10px; height: 100%; min-height: 0; }
.car-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; }
.car-top .name { font-size: 17px; flex: 1; min-width: 0; }
.car-status { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
.car-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
.car-visual { grid-area: visual; position: relative; min-height: 0; display: grid; place-items: center; }
.car-photo { width: 100%; height: 100%; object-fit: contain; }
.car-art { width: 100%; max-height: 100%; }
.car-body { fill: var(--d-fill); stroke: var(--d-text-2); stroke-width: 1.5; }
.car-glass { fill: color-mix(in srgb, var(--d-text) 12%, transparent); }
.car-wheel { fill: var(--d-text); opacity: .85; }
.car-hub { fill: var(--d-tile); }
.car-visual.is-charging::before { content: ""; position: absolute; inset: 15% 10%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 35%, transparent), transparent); animation: glow 2.4s ease-in-out infinite; }
@keyframes glow { 50% { opacity: .45; } }
.car-bolt { position: absolute; right: 8%; top: 4%; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: 17px; }
.car-info { grid-area: info; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.car-nums { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.car-pct { font-size: clamp(32px, 14cqh, 52px); font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
.car-range { font-size: 17px; font-weight: 600; color: var(--d-text-2); }
.car2 .level { color: var(--crit); align-self: center; }
.car-bar { position: relative; height: 12px; border-radius: 6px; background: var(--d-fill); overflow: visible; flex: none; }
.car-fill { position: absolute; inset: 0 auto 0 0; border-radius: 6px; background: var(--d-text); transition: width .4s; }
.car-bar.charging .car-fill { background: var(--ok); background-image: linear-gradient(110deg, transparent 40%, rgba(255, 255, 255, 0.45) 50%, transparent 60%); background-size: 200% 100%; animation: charge 2.4s linear infinite; }
.car-bar.low .car-fill { background: var(--crit); }
.car-limit { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); }
@keyframes charge { from { background-position: 100% 0; } to { background-position: -100% 0; } }
.car-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.car-act { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 12px; background: var(--d-fill); font-size: 14px; font-weight: 500; }
.car-act .i { font-size: 18px; }
.car-act[aria-pressed="true"] { background: var(--d-text); color: var(--d-tile); }
.car-odo { margin-left: auto; }
@container (max-width: 340px) { .car-img { display: none; } .car.has-img .car-head, .car.has-img .car-main { max-width: none; } .car-odo { display: none; } }
@container (max-height: 200px) { .car-actions { display: none; } }
.car-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; }
.car-stat { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); min-width: 0; }
.car-stat-v { font-size: 15px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.car-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.car-actions2 { display: flex; gap: 6px; }
.car-btn { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); }
.car-btn-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; }
.car-btn[aria-pressed="true"] .car-btn-ico { background: var(--d-text); color: var(--d-tile); }
.car-btn-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* sizes */
@container (max-height: 380px) { .car-stats { display: none; } }
@container (max-height: 250px) and (max-width: 479px) { .car2 { grid-template-areas: "top" "info"; grid-template-rows: auto 1fr; } .car-visual { display: none; } .car-info { justify-content: flex-end; } }
@container (max-height: 160px) { .car-actions2 { display: none; } }
@container (min-width: 480px) and (min-height: 150px) {
.car2 { grid-template-areas: "top top" "visual info"; grid-template-columns: 1.1fr 1fr; grid-template-rows: auto 1fr; column-gap: 18px; }
.car-info { justify-content: center; }
}
@container (max-width: 230px) { .car-btn-label { display: none; } .car-range { font-size: 15px; } }
/* ============================================================ phone dashboards */
body.phone-ui:not(.peek) #topbar { display: none; }
+248 -56
View File
@@ -477,6 +477,73 @@
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
};
// ---------------------------------------------------------------- energy
// Live power flow between solar, grid, battery and home. Lines are as thick and as fast as the power.
function watts(s) {
const v = Number(s?.state);
if (!isFinite(v)) return null;
const u = String(s.attributes.unit_of_measurement || "W").toLowerCase();
return u === "kw" ? v * 1000 : u === "mw" ? v * 1e6 : v;
}
const fmtW = (w) => (Math.abs(w) >= 1000 ? `${(w / 1000).toLocaleString(LOCALE, { maximumFractionDigits: 1 })} kW` : `${Math.round(w).toLocaleString(LOCALE)} W`);
W.energy = {
label: "Energy", icon: "bolt", size: [4, 4],
fields: [
{ k: "solar", t: "entity", l: "Solar power", domains: ["sensor"] },
{ k: "grid", t: "entity", l: "Grid power (positive = from the grid)", domains: ["sensor"] },
{ k: "invertGrid", t: "check", l: "My grid sensor is positive when sending back", d: false },
{ k: "battery", t: "entity", l: "Battery power (positive = charging)", domains: ["sensor"] },
{ k: "invertBattery", t: "check", l: "My battery sensor is positive when discharging", d: false },
{ k: "batteryLevel", t: "entity", l: "Battery level (%)", domains: ["sensor"] },
{ k: "home", t: "entity", l: "Home use (optional, otherwise calculated)", domains: ["sensor"] },
{ k: "today", t: "entities", l: "Totals for today, e.g. kWh produced and used", domains: ["sensor"] },
],
entities: (c) => [c.solar, c.grid, c.battery, c.batteryLevel, c.home, ...(c.today || [])].filter(Boolean),
render(c, { ha }) {
if (!c.solar && !c.grid && !c.battery) return `<div class="empty">${t("Choose your solar, grid or battery power sensors in the inspector.")}</div>`;
const S = (k) => (c[k] ? ha.states[c[k]] : null);
const solar = Math.max(0, watts(S("solar")) ?? 0);
let grid = watts(S("grid")) ?? 0; if (c.invertGrid) grid = -grid;
let batt = watts(S("battery")) ?? 0; if (c.invertBattery) batt = -batt;
const imp = Math.max(0, grid), exp = Math.max(0, -grid), charge = Math.max(0, batt), discharge = Math.max(0, -batt);
const home = Math.max(0, watts(S("home")) ?? solar + imp + discharge - exp - charge);
// Who feeds what: solar goes to the home first, then the battery, the rest to the grid.
const sToH = Math.min(solar, home), sRest = solar - sToH, sToB = Math.min(sRest, charge), sToG = Math.max(0, sRest - sToB);
const gToH = Math.min(imp, Math.max(0, home - sToH - discharge)), gToB = Math.max(0, charge - sToB), bToH = Math.min(discharge, Math.max(0, home - sToH));
const has = { solar: !!c.solar, grid: !!c.grid, battery: !!c.battery };
const lvl = Number(S("batteryLevel")?.state);
const flow = (on, d, w, cls) => {
if (!on) return "";
const speed = Math.max(0.5, Math.min(5, 2400 / (w + 150))); // seconds per cycle: more power, faster dots
const width = w > 1 ? Math.max(1.5, Math.min(5, 1.2 + Math.log10(w + 1))) : 1;
return `<path d="${d}" class="ef-line ${cls}"/>${w > 1 ? `<path d="${d}" class="ef-flow ${cls}" style="stroke-width:${width.toFixed(1)};animation-duration:${speed.toFixed(2)}s"/>` : ""}`;
};
const lines = [
flow(has.solar, "M50 14 Q86 14 86 50", sToH, "solar"),
flow(has.solar && has.grid, "M50 14 Q14 14 14 50", sToG, "solar"),
flow(has.solar && has.battery, "M50 14 L50 86", sToB, "solar"),
flow(has.grid, "M14 50 L86 50", gToH, "grid"),
flow(has.grid && has.battery, "M14 50 Q14 86 50 86", gToB, "grid"),
flow(has.battery, "M50 86 Q86 86 86 50", bToH, "battery"),
].join("");
const node = (key, ico, label, value, extra = "") => `<div class="ef-node ef-${key}"><span class="ef-ico">${Icons.svg(ico)}</span>
<span class="ef-val num">${esc(value)}</span><span class="ef-label">${esc(label)}${extra}</span></div>`;
const totals = (c.today || []).map((e) => ha.states[e] && `<span class="ef-total"><b class="num">${esc(fmtState(ha.states[e]))}</b> ${esc(friendly(ha.states[e]))}</span>`).filter(Boolean).join("");
return `<div class="energy ${has.battery ? "" : "no-battery"} ${has.solar ? "" : "no-solar"}">
<div class="ef-diagram" role="img" aria-label="${esc(t("Solar {0}, grid {1}, home {2}", fmtW(solar), grid >= 0 ? fmtW(imp) : "−" + fmtW(exp), fmtW(home)))}">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>
${has.solar ? node("solar", "sun", t("Solar"), fmtW(solar)) : ""}
${has.grid ? node("grid", "plug", exp > imp ? t("To the grid") : t("From the grid"), fmtW(exp > imp ? exp : imp)) : ""}
${node("home", "home", t("Home"), fmtW(home))}
${has.battery ? node("battery", "battery", charge > 1 ? t("Charging") : discharge > 1 ? t("Discharging") : t("Battery"), isFinite(lvl) ? `${Math.round(lvl)}%` : fmtW(Math.abs(batt)), isFinite(lvl) && Math.abs(batt) > 1 ? ` · ${fmtW(Math.abs(batt))}` : "") : ""}
</div>
${totals ? `<div class="ef-totals">${totals}</div>` : ""}</div>`;
},
};
// ---------------------------------------------------------------- room
// Everything in one Home Assistant area at a glance: climate, what's on or open, and quick controls.
function roomEntities(c, ha) {
@@ -614,12 +681,19 @@
// 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/;
const DONE_RE = /finish|end|done|complete|klaar|gereed|beëindigd/;
const PAUSE_RE = /pause|gepauzeerd/;
// Home Connect's operation states, matched exactly first (https://www.home-assistant.io/integrations/home_connect/).
// "ready" means ready to start and "inactive" switched off: both count as off.
const HC_STATES = { run: "running", pause: "paused", finished: "done", delayedstart: "scheduled", actionrequired: "attention", error: "error", aborting: "stopping", inactive: "off", ready: "off" };
function appliancePhase(c, ha) {
const status = String(ha.states[c.status]?.state || "").toLowerCase();
if (status && !["unavailable", "unknown"].includes(status)) {
if (HC_STATES[status]) return HC_STATES[status];
if (/^(off|idle|standby|none)$/.test(status)) return "off";
if (/delayed|uitgesteld|scheduled/.test(status)) return "scheduled";
if (/error|fault|fout/.test(status)) return "error";
if (DONE_RE.test(status)) return "done";
if (PAUSE_RE.test(status)) return "paused";
if (RUNNING_RE.test(status)) return "running";
@@ -642,40 +716,122 @@
}
const fmtDuration = (m) => (m >= 60 ? t("{0} h {1} min", Math.floor(m / 60), m % 60) : t("{0} min", m));
// "dishcare_dishwasher_program_eco_50" → "Eco 50" (Home Connect program keys); other values are shown as they are.
const prettyProgram = (v) => {
const s = String(v);
return s.includes("_program_") ? cap(s.split("_program_").pop().replace(/_/g, " ")) : st(s);
};
const ALERT_ON = new Set(["present", "confirmed", "on", "true"]);
W.appliance = {
label: "Appliance", icon: "washer", size: [4, 2],
fields: [
{ k: "name", t: "text", l: "Name", d: "Washing machine" },
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" },
{ k: "status", t: "entity", l: "Status", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
{ k: "status", t: "entity", l: "Status (with Home Connect: Operation state — the rest fills in by itself)", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
{ k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"] },
{ k: "threshold", t: "number", l: "Running above (W)", d: 5 },
{ k: "remaining", t: "entity", l: "Time remaining or end time", domains: ["sensor"] },
{ k: "remaining", t: "entity", l: "Time remaining or finish time", domains: ["sensor"] },
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
{ k: "door", t: "entity", l: "Door (optional)", domains: ["binary_sensor", "sensor"] },
{ k: "energy", t: "entity", l: "Energy or water use (optional)", domains: ["sensor"] },
{ k: "pauseBtn", t: "entity", l: "Pause button", domains: ["button"] },
{ k: "resumeBtn", t: "entity", l: "Resume button", domains: ["button"] },
{ k: "stopBtn", t: "entity", l: "Stop button", domains: ["button"] },
{ k: "powerSwitch", t: "entity", l: "Power switch", domains: ["switch"] },
{ k: "alerts", t: "entities", l: "Alerts, e.g. salt or rinse aid low", domains: ["sensor", "binary_sensor", "event"] },
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
],
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 }) {
// Picking the status fills in the other entities of the same device (Home Connect names them predictably).
autofill: {
from: "status",
fill(eid, ha) {
const dev = ha.reg?.deviceOf?.[eid];
const base = eid.split(".")[1].replace(/_(operation_state|status|state)$/, "");
const sibs = dev ? ha.reg.byDevice[dev] : Object.keys(ha.states).filter((e) => e.split(".")[1].startsWith(base));
const find = (dom, re) => sibs.find((e) => domainOf(e) === dom && re.test(e)) || "";
return {
progress: find("sensor", /progress/), remaining: find("sensor", /finish_time|remaining|end_time/),
door: find("sensor", /door/) || find("binary_sensor", /door/), program: find("select", /active_program/) || find("sensor", /program$/),
pauseBtn: find("button", /pause/), resumeBtn: find("button", /resume/), stopBtn: find("button", /stop|abort/),
powerSwitch: find("switch", /_power$/), energy: find("sensor", /energy|water/),
alerts: sibs.filter((e) => /salt|rinse_aid|i_dos|poor_|machine_care/.test(e)),
kind: /dish/.test(eid) ? "dishwasher" : /dryer|droger/.test(eid) && !/washer_dryer/.test(eid) ? "dryer" : undefined,
};
},
},
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program, c.door, c.energy, c.pauseBtn, c.resumeBtn, c.stopBtn, c.powerSwitch, ...(c.alerts || [])].filter(Boolean),
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off"
&& !(c.alerts || []).some((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase())),
// Two rows high: a progress ring around the appliance on the left, the story on the right.
// Taller cards stack the ring on top (see the container queries in style.css).
render(c, { ha, cache }) {
if (!c.status && !c.power && !c.remaining) return `<div class="empty">${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}</div>`;
const phase = appliancePhase(c, ha);
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off") }[phase];
const left = phase === "running" || phase === "paused" ? remainingMinutes(c, ha) : null;
const prog = Number(ha.states[c.progress]?.state);
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off"), scheduled: t("Starts later"), attention: t("Action needed"), error: t("Error"), stopping: t("Stopping") }[phase];
const active = ["running", "paused", "scheduled", "attention"].includes(phase);
const left = active ? remainingMinutes(c, ha) : null;
// Progress from the integration, or worked out from the time left (relative to the most seen this run).
if (phase === "off") cache.maxLeft = 0;
if (left != null && phase !== "scheduled") cache.maxLeft = Math.max(cache.maxLeft || 0, left);
let prog = Number(ha.states[c.progress]?.state);
if (!isFinite(prog) || !c.progress) prog = left != null && cache.maxLeft > 0 && phase !== "scheduled" ? 100 * (1 - left / cache.maxLeft) : NaN;
if (phase === "done") prog = 100;
const program = ha.states[c.program]?.state;
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
const ends = left != null ? new Date(Date.now() + left * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "";
const ico = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer";
const 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>`;
const ringIco = phase === "done" ? "check" : phase === "attention" || phase === "error" ? "shield" : ico;
const R = 44, C = 2 * Math.PI * R, frac = isFinite(prog) ? Math.max(0, Math.min(1, prog / 100)) : 0;
const showRing = isFinite(prog) && phase !== "off";
const ring = `<div class="appl-ring" ${showRing ? `role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"` : ""}>
<svg viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/>
${showRing ? `<circle cx="50" cy="50" r="${R}" class="fill" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}"/>` : ""}</svg>
<span class="appl-ico">${Icons.svg(ringIco)}</span>
${isFinite(prog) && phase === "running" ? `<span class="appl-pct num">${Math.round(prog)}%</span>` : ""}</div>`;
// Door: Home Connect's door state is open / closed / locked; a binary sensor is on when open.
const door = ha.states[c.door], energy = ha.states[c.energy], power = ha.states[c.power];
const doorState = door ? (domainOf(c.door) === "binary_sensor" ? (isOn(door) ? "open" : "closed") : String(door.state).toLowerCase()) : null;
const alerts = (c.alerts || []).filter((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase()));
// Alert names start with the device's name ("Dishwasher Rinse aid nearly empty"); take that from the status entity.
const device = friendly(ha.states[c.status]).replace(/\s*(operation state|bedieningsstatus|status|state)$/i, "");
const strip = (txt) => (device && txt.toLowerCase().startsWith(device.toLowerCase() + " ") ? txt.slice(device.length + 1) : txt);
const facts = [
...alerts.map((e) => `<span class="appl-fact warn">${Icons.svg("shield")}<span>${esc(cap(strip(friendly(ha.states[e]))))}</span></span>`),
doorState && ["open", "closed", "locked"].includes(doorState) && `<span class="appl-fact ${doorState === "open" ? "warn" : ""}">${Icons.svg(doorState === "open" ? "door-open" : doorState === "locked" ? "lock" : "door")}${esc({ open: t("Door open"), closed: t("Door closed"), locked: t("Door locked") }[doorState])}</span>`,
power && phase === "running" && `<span class="appl-fact">${Icons.svg("bolt")}${esc(fmtState(power))}</span>`,
energy && `<span class="appl-fact">${Icons.svg("pulse")}${esc(fmtState(energy))}</span>`,
].filter(Boolean).join("");
// Controls: Home Connect offers pause, resume and stop as buttons, and power as a switch.
const ctl = (eid, ico, lbl, act = "svc", v = "button.press") => (eid && ha.states[eid] ? `<button class="appl-btn wbtn" data-act="${act}" data-e="${esc(eid)}" ${act === "svc" ? `data-v="${v}"` : ""} aria-label="${esc(lbl)}" title="${esc(lbl)}">${Icons.svg(ico)}</button>` : "");
const powerOff = c.powerSwitch && ha.states[c.powerSwitch]?.state === "off";
const ctrls = [
phase === "running" && ctl(c.pauseBtn, "pause", t("Pause")),
phase === "paused" && ctl(c.resumeBtn, "play", t("Resume")),
["running", "paused", "scheduled"].includes(phase) && ctl(c.stopBtn, "stop", t("Stop")),
phase === "off" && powerOff && ctl(c.powerSwitch, "power", t("Turn on"), "toggle"),
].filter(Boolean).join("");
const main = phase === "done" ? `<span class="appl-big">${t("Ready to unload")}</span>`
: phase === "scheduled" ? `<span class="appl-big">${left != null ? t("Ready at {0}", ends) : t("Starts later")}</span>`
: phase === "attention" ? `<span class="appl-big">${t("Check the appliance")}</span>`
: phase === "error" ? `<span class="appl-big">${t("Something went wrong")}</span>`
: left != null ? `<span class="appl-big num">${esc(fmtDuration(left))}</span><span class="appl-sub">${t("left · ready at {0}", ends)}</span>`
: phase === "running" ? `<span class="appl-big">${t("In progress")}</span>` : `<span class="appl-big appl-off">${esc(label)}</span>`;
return `<div class="appl phase-${phase}">${ring}
<div class="appl-text">
<div class="appl-top"><span class="name">${esc(c.name || t("Appliance"))}</span><span class="appl-status">${esc(label)}</span></div>
${showProgram ? `<span class="appl-program">${esc(prettyProgram(program))}</span>` : ""}
<div class="appl-main"><div class="appl-main-text">${main}</div>${ctrls ? `<div class="appl-ctrls">${ctrls}</div>` : ""}</div>
${facts ? `<div class="appl-facts">${facts}</div>` : ""}
</div></div>`;
},
lit: (c, { ha }) => (["running", "done"].includes(appliancePhase(c, ha)) ? "on" : false),
lit: (c, { ha }) => (["running", "done", "attention"].includes(appliancePhase(c, ha)) ? "on" : false),
};
W.camera = {
@@ -693,7 +849,7 @@
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
W.car = {
label: "Car", icon: "car", size: [6, 3],
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" },
@@ -710,15 +866,18 @@
{ 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.
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 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 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 plugged = charging || truthy(plug);
const low = pct != null && pct <= 20;
const fuel = c.kind === "fuel";
let status;
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
@@ -728,34 +887,45 @@
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");
// 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>`;
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>`;
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 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 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>`;
},
};
@@ -1000,20 +1170,25 @@
// Scroll and swipe cards hold other cards in cfg.children. They draw placeholders;
// ctx.renderChildren fills them using the normal widget renderer.
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}"></div>`;
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>`;
W.hscroll = {
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
fields: [
{ k: "title", t: "text", l: "Title", d: "" },
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260 },
{ 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" },
],
render(c, ctx) {
const kids = slots(c, ctx);
if (!kids.length) return noKids(c);
return `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
<div class="hs-track" style="--item-w:${Math.max(120, Number(c.itemWidth) || 260)}px" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
<div class="hs-track" style="--item-w:${c.widthMode === "per-view"
// N cards per view, gaps included; a half card peeking out hints that the row scrolls
? `calc((100% - var(--d-gap, 14px) * ${Math.max(1, Number(c.perView) || 2.5) - 1}) / ${Math.max(1, Number(c.perView) || 2.5)})`
: `${Math.max(120, Number(c.itemWidth) || 260)}px`}" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
},
@@ -1109,12 +1284,29 @@
else if (d.dx > sw.clientWidth / 4 || speed > 0.35) p--;
go(p);
showDots();
cache.swallowClick = true; // the release after a swipe must not press a button underneath
// The release after a swipe must not press a button underneath. Only for a moment: touch screens
// send no click after a swipe, and a lasting flag would eat the next real tap.
cache.swallowUntil = Date.now() + 350;
if (cache.dirty) { cache.dirty = false; setTimeout(() => ctx.rerender(), 400); }
};
track.addEventListener("pointerup", end);
track.addEventListener("pointercancel", end);
track.addEventListener("click", (e) => { if (cache.swallowClick) { cache.swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
track.addEventListener("lostpointercapture", end);
track.addEventListener("dragstart", (e) => e.preventDefault()); // images would start a native drag and cancel the swipe
track.addEventListener("click", (e) => { if (Date.now() < (cache.swallowUntil || 0)) { e.stopPropagation(); e.preventDefault(); } }, true);
// Two-finger swipe on a trackpad: horizontal wheel movement turns one page at a time.
let wheelSum = 0, wheelLock = 0;
sw.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || n < 2) return;
e.preventDefault();
if (Date.now() < wheelLock) return;
wheelSum += e.deltaX;
if (Math.abs(wheelSum) > 50) {
cache.pausedUntil = Date.now() + 20000;
cache.go(page + (wheelSum > 0 ? 1 : -1));
wheelSum = 0; wheelLock = Date.now() + 450;
}
}, { passive: false });
track.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") { e.preventDefault(); cache.go(page + 1); }
if (e.key === "ArrowLeft") { e.preventDefault(); cache.go(page - 1); }