// WashData - Home Assistant integration for appliance cycle monitoring via smart plugs. // Copyright (C) 2026 Lukas Bandura // SPDX-License-Identifier: AGPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const CARD_TAG = "ha-washdata-card"; const EDITOR_TAG = "ha-washdata-card-editor"; const DOMAIN = "ha_washdata"; // Gesture timing (ms) and movement tolerance (px) for tap / hold / double-tap, // chosen to match Home Assistant's own action handler conventions. const HOLD_MS = 500; const DOUBLE_TAP_MS = 250; const TAP_MOVE_TOLERANCE = 10; // Sibling entities of a WashData device are resolved from the device registry by // their stable translation_key (which never changes even if the user renames the // entity), so pointing the card at any one entity auto-wires all the rest. const ROLE_TKEYS = { state: "washer_state", program: "washer_program", time: "time_remaining", progress: "cycle_progress", power: "current_power", energy: "energy_total", phase: "current_phase", }; const BUTTON_TKEYS = { pause: "pause_cycle", resume: "resume_cycle", terminate: "force_end_cycle", record_start: "record_start", record_stop: "record_stop", }; const BUTTON_ICONS = { pause: "mdi:pause", resume: "mdi:play", terminate: "mdi:stop", record_start: "mdi:record-circle-outline", record_stop: "mdi:stop-circle-outline", open_panel: "mdi:open-in-new", }; const BUTTON_ORDER = ["pause", "resume", "terminate", "record_start", "record_stop", "program", "open_panel"]; const ACTIVE_STATES = ["running", "paused", "user_paused", "ending", "starting", "anti_wrinkle", "rinse"]; const INACTIVE_STATES = ["off", "unknown", "unavailable", "idle"]; const SPARK_MAX_POINTS = 60; // ── Shared, module-level resource caches ──────────────────────────────────── // One WebSocket round-trip per HA connection for the shared display constants // (STATE_COLORS is the single source of truth in const.py), and one fetch per // language for the panel translation dictionaries the card reuses for its own // chrome. Both are shared across every card instance on the page. const _constantsCache = new WeakMap(); // hass.connection -> Promise<{stateColors}> function loadConstants(hass) { const conn = hass && hass.connection; if (!conn) return Promise.resolve({ stateColors: {} }); if (_constantsCache.has(conn)) return _constantsCache.get(conn); const p = hass .callWS({ type: DOMAIN + "/get_constants" }) .then((c) => ({ stateColors: (c && c.state_colors) || {} })) .catch(() => ({ stateColors: {} })); _constantsCache.set(conn, p); return p; } const _panelTrans = {}; // lang -> dict const _panelTransPending = {}; // lang -> Promise function _panelTransUrl(lang) { return "/" + DOMAIN + "/panel-translations/" + encodeURIComponent(lang) + ".json"; } async function _fetchPanelLang(lang) { if (!lang) return null; const candidates = [lang]; const dash = lang.indexOf("-"); if (dash > 0) candidates.push(lang.slice(0, dash)); for (const cand of candidates) { try { const r = await fetch(_panelTransUrl(cand)); if (r.ok) { const j = await r.json(); if (j && typeof j === "object") return j; } } catch (_) { /* try next candidate */ } } return null; } async function loadPanelLang(lang) { if (!lang || _panelTrans[lang]) return; if (_panelTransPending[lang]) { await _panelTransPending[lang]; return; } const p = _fetchPanelLang(lang).then((d) => { if (d) _panelTrans[lang] = d; }); _panelTransPending[lang] = p; await p; } class WashDataCard extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); this._cfg = null; this._hass = null; this._builtSig = null; // structure signature the current shell was built for this._constantsLoaded = false; this._langEnsured = ""; this._constants = null; // Rolling in-memory buffer of {t, w} power samples for the live sparkline. this._spark = []; this._sparkState = null; // Gesture state for tap / hold / double-tap recognition. this._holdTimer = null; this._holdTriggered = false; this._tapTimer = null; this._lastTapTime = 0; this._pointerStart = null; this._pointerCanceled = false; this._onPointerDown = this._onPointerDown.bind(this); this._onPointerMove = this._onPointerMove.bind(this); this._onPointerUp = this._onPointerUp.bind(this); this._onPointerCancel = this._onPointerCancel.bind(this); } static getStubConfig() { return { entity: "sensor.washing_machine_state", layout: "tile", tap_action: { action: "more-info" }, hold_action: { action: "none" }, double_tap_action: { action: "none" }, }; } static getConfigElement() { return document.createElement(EDITOR_TAG); } setConfig(config) { if (!config) throw new Error("Invalid configuration"); const layout = config.layout || "tile"; if (layout !== "glance" && !config.entity) { throw new Error("Please define an entity"); } if (layout === "glance" && !(Array.isArray(config.entities) && config.entities.length) && !config.entity) { throw new Error("Please define entities for the glance layout"); } this._cfg = { ...config, layout }; // Rebuild the shell when the structural signature changes - not only on a // layout switch, but also when the progress bar, sparkline, buttons, or the // glance entity count change (HA reuses the element and re-calls setConfig on // editor edits / Lovelace reloads). Otherwise the _update* helpers early-return // because their elements don't exist and the new options only appear after a // layout switch or page reload (removed entities also leave orphan glance rows). if (this._builtSig !== this._structureSig()) { this._builtSig = null; if (this.shadowRoot) this.shadowRoot.innerHTML = ""; } this._render(); } // Signature of every config field that changes which elements the shell emits // (see _buildTile/_buildDetail/_buildGlance). A change here forces a rebuild. _structureSig() { const c = this._cfg || {}; const f = this._flags(); const layout = c.layout || "tile"; return [ layout, f.showBar ? "bar" : "", f.showSparkline ? "spark" : "", f.buttons.join("+"), layout === "glance" ? String(this._glanceEntities().length) : "", ].join("|"); } set hass(hass) { this._hass = hass; this._ensureResources(); this._render(); } // Reserve enough vertical space for whatever the detail layout is showing, so // the sections-view grid (which clips to the reserved rows) never cuts off the // action buttons or the sparkline at the bottom of the card. _detailRows() { const f = this._flags(); let rows = 2; // header + progress bar + meta chips (content-hugging card) if (f.buttons.length) rows += 1; if (f.showSparkline) rows += 1; return rows; } getCardSize() { const layout = this._cfg && this._cfg.layout; if (layout === "detail") return this._detailRows(); if (layout === "glance") return this._glanceEntities().length || 1; return 1; } getGridOptions() { const layout = this._cfg && this._cfg.layout; if (layout === "detail") { const rows = this._detailRows(); return { rows, min_rows: rows, columns: 12, min_columns: 6 }; } if (layout === "glance") { const n = Math.max(1, this._glanceEntities().length); return { rows: n, min_rows: 1, columns: 12, min_columns: 6 }; } return { rows: 1, min_rows: 1, columns: 6, min_columns: 3 }; } disconnectedCallback() { this._clearHoldTimer(); if (this._tapTimer) { window.clearTimeout(this._tapTimer); this._tapTimer = null; } } // ── Localization ────────────────────────────────────────────────────────── _lang() { const raw = (this._hass && this._hass.locale && this._hass.locale.language) || (this._hass && this._hass.language) || "en"; return typeof raw === "string" && raw ? raw : "en"; } _tLookup(key, lang) { const dict = _panelTrans[lang]; if (!dict) return null; const val = key.split(".").reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), dict); return val && typeof val === "string" ? val : null; } _t(key, vars, fallback) { const lang = this._lang(); let s = (lang && this._tLookup(key, lang)) || this._tLookup(key, "en") || fallback || key; if (vars) { for (const [k, v] of Object.entries(vars)) { s = s.replace(new RegExp("\\{" + k + "\\}", "g"), String(v)); } } return s; } _ensureResources() { if (!this._hass) return; const lang = this._lang(); const needsConstants = !this._constantsLoaded; const needsLang = lang !== this._langEnsured; if (!needsConstants && !needsLang) return; const tasks = []; if (needsConstants) { this._constantsLoaded = true; tasks.push( loadConstants(this._hass).then((c) => { this._constants = c; }), loadPanelLang("en"), ); } if (needsLang) { this._langEnsured = lang; if (lang && lang !== "en") tasks.push(loadPanelLang(lang)); } Promise.all(tasks) .then(() => this._update()) .catch(() => { /* card degrades gracefully to English fallbacks + theme colors */ }); } // ── Entity discovery ────────────────────────────────────────────────────── _resolveRoles(primaryEntity) { const hass = this._hass; const cfg = this._cfg || {}; const reg = (hass && hass.entities) || {}; const primary = primaryEntity || cfg.entity; const primaryReg = reg[primary]; const deviceId = cfg.device_id || (primaryReg && primaryReg.device_id) || null; const roles = { state: primary }; const buttons = {}; let selectEntity = null; if (deviceId) { for (const [eid, ent] of Object.entries(reg)) { if (!ent || ent.device_id !== deviceId) continue; if (ent.platform && ent.platform !== DOMAIN) continue; const tk = ent.translation_key; // Display roles are always sensors; restrict the match so a like-named // entity in another domain can never hijack a display role. if (eid.startsWith("sensor.")) { for (const [role, want] of Object.entries(ROLE_TKEYS)) { if (tk === want && !roles[role]) roles[role] = eid; } } for (const [btn, want] of Object.entries(BUTTON_TKEYS)) { if (tk === want && !buttons[btn]) buttons[btn] = eid; } if (!selectEntity && eid.startsWith("select.")) selectEntity = eid; } } // Legacy explicit overrides always win over auto-discovery. if (cfg.program_entity) roles.program = cfg.program_entity; if (cfg.time_entity) roles.time = cfg.time_entity; if (cfg.pct_entity) roles.progress = cfg.pct_entity; if (cfg.power_entity) roles.power = cfg.power_entity; if (cfg.energy_entity) roles.energy = cfg.energy_entity; if (cfg.phase_entity) roles.phase = cfg.phase_entity; // Suffix fallback for the common case where the primary is the state sensor // but the registry lookup came up empty (e.g. template entity, no device). if (!roles.time || !roles.progress || !roles.program) { const m = /^sensor\.(.+)_state$/.exec(primary); if (m) { const base = "sensor." + m[1] + "_"; const st = hass && hass.states; const guess = (suffix) => (st && st[base + suffix] ? base + suffix : null); roles.time = roles.time || guess("time_remaining"); roles.progress = roles.progress || guess("cycle_progress"); roles.program = roles.program || guess("program"); } } roles._buttons = buttons; roles._select = selectEntity; roles._deviceId = deviceId; return roles; } // ── View model ──────────────────────────────────────────────────────────── _fmtState(stateObj) { if (!stateObj) return ""; const hass = this._hass; if (hass && typeof hass.formatEntityState === "function") { try { return hass.formatEntityState(stateObj); } catch (_) { /* fall through */ } } const s = stateObj.state || ""; return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; } _vm(roles) { const hass = this._hass; const cfg = this._cfg; const states = hass.states; const stateObj = states[roles.state]; if (!stateObj) { return { missing: true, missingEntity: roles.state }; } const sk = String(stateObj.state || "").toLowerCase(); const isInactive = INACTIVE_STATES.includes(sk); const isActive = ACTIVE_STATES.includes(sk); const attr = stateObj.attributes || {}; // Program: prefer the dedicated program entity, then the state sensor's guess. let program = ""; const progObj = roles.program ? states[roles.program] : null; if (progObj) { const pv = String(progObj.state || "").toLowerCase(); if (!["unknown", "none", "off", "unavailable", ""].includes(pv)) program = this._fmtState(progObj); } if (!program && attr.current_program_guess) { const pv = String(attr.current_program_guess).toLowerCase(); if (!["unknown", "none", "off", "unavailable"].includes(pv)) program = attr.current_program_guess; } // Phase: dedicated phase entity, else the program sensor's active_phase attr. let phase = ""; const phaseObj = roles.phase ? states[roles.phase] : null; if (phaseObj) { const pv = String(phaseObj.state || "").toLowerCase(); if (!["unknown", "none", "off", "unavailable", ""].includes(pv)) phase = this._fmtState(phaseObj); } if (!phase && progObj && progObj.attributes && progObj.attributes.active_phase) { phase = progObj.attributes.active_phase; } // Sub-state (e.g. "Running (Rinsing)" -> "Rinsing"). let subState = ""; if (sk === "running" && attr.sub_state) { const m = String(attr.sub_state).match(/Running \((.*)\)/); subState = m && m[1] ? m[1] : attr.sub_state; } // Progress percentage. let pct = null; const pctObj = roles.progress ? states[roles.progress] : null; if (pctObj && !isNaN(parseFloat(pctObj.state))) { pct = Math.max(0, Math.min(100, Math.round(parseFloat(pctObj.state)))); } // Remaining time (respects the entity's Display Precision + unit, #354). let timeText = ""; const timeObj = roles.time ? states[roles.time] : null; if (timeObj && !INACTIVE_STATES.includes(String(timeObj.state).toLowerCase())) { if (hass && typeof hass.formatEntityState === "function") { timeText = this._fmtState(timeObj); } else if (!isNaN(parseFloat(timeObj.state))) { timeText = timeObj.state + " " + this._t("card.min", null, "min"); } else { timeText = timeObj.state; } } // Projected energy + cost (live on the progress entity while running). let energyText = ""; let costText = ""; if (pctObj && pctObj.attributes) { const kwh = pctObj.attributes.projected_energy_kwh; if (kwh !== undefined && kwh !== null) energyText = kwh + " kWh"; const cost = pctObj.attributes.projected_cost; if (cost !== undefined && cost !== null) costText = this._fmtCost(cost); } // Current power. let powerW = null; const powerObj = roles.power ? states[roles.power] : null; if (powerObj && !isNaN(parseFloat(powerObj.state))) powerW = parseFloat(powerObj.state); // Overrun / anomaly (visible-only; never a notification). let anomaly = null; if (attr.cycle_anomaly && attr.cycle_anomaly !== "none") { anomaly = { kind: attr.cycle_anomaly, ratio: attr.overrun_ratio }; } const color = this._stateColor(sk, isActive, cfg.active_color); return { missing: false, sk, stateObj, isInactive, isActive, isRunning: sk === "running", stateLabel: this._fmtState(stateObj), color, program, phase, subState, pct, timeText, energyText, costText, powerW, anomaly, buttons: roles._buttons || {}, select: roles._select || null, }; } _fmtCost(cost) { const hass = this._hass; const currency = hass && hass.config && hass.config.currency; if (currency) { try { return new Intl.NumberFormat(this._lang(), { style: "currency", currency }).format(Number(cost)); } catch (_) { /* fall through */ } } return String(cost); } _stateColor(sk, isActive, override) { // Honour an explicit per-card override colour first (legacy active_color). if (isActive && override) { if (Array.isArray(override)) { const [r, g, b] = override; return { fg: "rgb(" + r + "," + g + "," + b + ")", bg: "rgba(" + r + "," + g + "," + b + ",0.2)" }; } return { fg: override, bg: "rgba(128,128,128,0.15)" }; } const colors = (this._constants && this._constants.stateColors) || {}; const c = colors[sk]; if (c) return { fg: c, bg: this._alpha(c, sk) }; // Fallback to theme semantics when constants have not loaded yet. if (INACTIVE_STATES.includes(sk)) { return { fg: "var(--disabled-text-color, grey)", bg: "rgba(128,128,128,0.1)" }; } return { fg: "var(--primary-color)", bg: "rgba(var(--rgb-primary-color, 33,150,243),0.2)" }; } _alpha(cssColor, sk) { // The STATE_COLORS values are "var(--x, #hex)" strings; a translucent tint is // built from the hex fallback so the icon chip reads on any theme. const hex = /#([0-9a-fA-F]{6})/.exec(cssColor); if (hex) { const n = parseInt(hex[1], 16); const r = (n >> 16) & 255; const g = (n >> 8) & 255; const b = n & 255; return "rgba(" + r + "," + g + "," + b + ",0.18)"; } return INACTIVE_STATES.includes(sk) ? "rgba(128,128,128,0.1)" : "rgba(128,128,128,0.15)"; } _deviceTypeIcon() { const hass = this._hass; const cfg = this._cfg; if (cfg.icon) return cfg.icon; const stateObj = hass && hass.states[cfg.entity]; if (stateObj && stateObj.attributes && stateObj.attributes.icon) return stateObj.attributes.icon; return "mdi:washing-machine"; } _titleText() { const cfg = this._cfg; if (cfg.title) return cfg.title; const hass = this._hass; const reg = (hass && hass.entities) || {}; const devices = (hass && hass.devices) || {}; const ent = reg[cfg.entity]; if (ent && ent.device_id && devices[ent.device_id]) { const dev = devices[ent.device_id]; const name = dev.name_by_user || dev.name; if (name) return name; } const stateObj = hass && hass.states[cfg.entity]; if (stateObj && stateObj.attributes && stateObj.attributes.friendly_name) { return String(stateObj.attributes.friendly_name).replace(/ (State|Status)$/i, ""); } return "WashData"; } _flags() { const c = this._cfg || {}; return { showState: c.show_state !== false, showProgram: c.show_program !== false, showDetails: c.show_details !== false, // tile: show the time / percentage line showBar: c.show_progress_bar !== false, showPhase: c.show_phase !== false, showEnergy: c.show_energy !== false, showAnomaly: c.show_anomaly !== false, showSparkline: !!c.show_sparkline, displayMode: c.display_mode || "time", buttons: Array.isArray(c.buttons) ? c.buttons.filter((b) => BUTTON_ORDER.includes(b)) : [], }; } // ── Rendering ───────────────────────────────────────────────────────────── _render() { if (!this.shadowRoot || !this._cfg) return; const sig = this._structureSig(); if (this._builtSig !== sig) { this._build(this._cfg.layout || "tile"); this._builtSig = sig; } this._update(); } _baseStyle() { return ( ":host{display:block;height:100%}" + "ha-card{padding:0;background:var(--ha-card-background,var(--card-background-color,white));" + "border-radius:var(--ha-card-border-radius,12px);box-shadow:var(--ha-card-box-shadow,none);" + "overflow:hidden;cursor:pointer;height:100%;box-sizing:border-box;" + "border:var(--ha-card-border-width,1px) solid var(--ha-card-border-color,var(--divider-color))}" + ".icon-container{border-radius:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0;" + "transition:background-color .3s,color .3s;position:relative}" + "ha-icon{--mdc-icon-size:24px}" + ".primary{font-weight:500;color:var(--primary-text-color);white-space:nowrap;text-overflow:ellipsis;overflow:hidden;line-height:1.2}" + ".secondary{color:var(--secondary-text-color);white-space:nowrap;text-overflow:ellipsis;overflow:hidden;line-height:1.2}" + ".bar{height:4px;border-radius:2px;background:var(--divider-color,rgba(128,128,128,.25));overflow:hidden}" + ".bar>i{display:block;height:100%;width:0;border-radius:2px;transition:width .5s ease,background-color .3s}" + ".chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:10px;" + "background:rgba(128,128,128,.14);color:var(--secondary-text-color);white-space:nowrap}" + ".chip.warn{background:rgba(255,152,0,.16);color:var(--warning-color,#ff9800)}" + ".acts{display:flex;gap:4px;align-items:center}" + ".wd-act{border:none;background:rgba(128,128,128,.12);color:var(--primary-text-color);border-radius:10px;" + "width:34px;height:34px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}" + ".wd-act:hover{background:rgba(128,128,128,.22)}" + ".wd-act[disabled]{opacity:.35;pointer-events:none}" + ".wd-act ha-icon{--mdc-icon-size:20px}" + "select.wd-prog{max-width:150px;font:inherit;color:var(--primary-text-color);background:var(--secondary-background-color);" + "border:1px solid var(--divider-color);border-radius:8px;padding:4px 6px}" ); } _build(layout) { if (layout === "detail") return this._buildDetail(); if (layout === "glance") return this._buildGlance(); return this._buildTile(); } _attachGestures(cardEl) { cardEl.addEventListener("pointerdown", this._onPointerDown); cardEl.addEventListener("pointermove", this._onPointerMove); cardEl.addEventListener("pointerup", this._onPointerUp); cardEl.addEventListener("pointercancel", this._onPointerCancel); cardEl.addEventListener("pointerleave", this._onPointerCancel); } _buildTile() { const flags = this._flags(); const style = this._baseStyle() + ".tile{display:flex;flex-direction:row;align-items:center;padding:8px 12px;gap:12px;width:100%;box-sizing:border-box}" + ".tile .icon-container{width:40px;height:40px}" + ".tile .info{display:flex;flex-direction:column;justify-content:center;overflow:hidden;flex:1;min-width:0}" + ".tile .primary{font-size:14px}.tile .secondary{font-size:12px;margin-top:2px}" + ".tile .bar{margin-top:6px}"; this.shadowRoot.innerHTML = "" + '
' + '
' + '
' + '
' + (flags.showBar ? '
' : "") + "
"; this._attachGestures(this.shadowRoot.getElementById("card")); } _buildDetail() { const flags = this._flags(); const style = this._baseStyle() + // The detail card hugs its content (height:auto) so a short cycle does not // leave a large empty gap; the sections grid reserves rows via getGridOptions. "ha-card.wd-detail{height:auto}" + ".detail{display:flex;flex-direction:column;gap:8px;padding:12px 14px;width:100%;box-sizing:border-box}" + ".detail .top{display:flex;align-items:center;gap:12px}" + ".detail .icon-container{width:48px;height:48px}.detail .icon-container ha-icon{--mdc-icon-size:30px}" + ".detail .info{flex:1;min-width:0}" + ".detail .primary{font-size:16px}.detail .secondary{font-size:13px;margin-top:2px}" + ".detail .eta{text-align:right;flex-shrink:0}" + ".detail .eta .big{font-size:22px;font-weight:600;color:var(--primary-text-color);line-height:1.1}" + ".detail .eta .lbl{font-size:11px;color:var(--secondary-text-color);text-transform:uppercase;letter-spacing:.04em}" + ".detail .bar{height:6px}" + ".detail .meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center}" + ".detail .meta:empty{display:none}" + ".detail canvas{width:100%;height:34px;display:block}"; this.shadowRoot.innerHTML = "" + '
' + '
' + '
' + '
' + '
' + "
" + (flags.showBar ? '
' : "") + '
' + (flags.showSparkline ? '' : "") + (flags.buttons.length ? '
' : "") + "
"; this._attachGestures(this.shadowRoot.getElementById("card")); if (flags.buttons.length) this._buildButtons(this.shadowRoot.getElementById("acts"), flags.buttons); } _buildButtons(container, buttons) { if (!container) return; container.innerHTML = ""; for (const b of buttons) { if (b === "program") { const sel = document.createElement("select"); sel.className = "wd-prog"; sel.id = "prog-select"; sel.addEventListener("click", (ev) => ev.stopPropagation()); sel.addEventListener("pointerdown", (ev) => ev.stopPropagation()); sel.addEventListener("change", (ev) => { ev.stopPropagation(); this._onProgramChange(ev.target.value); }); container.appendChild(sel); continue; } const btn = document.createElement("button"); btn.className = "wd-act"; btn.dataset.btn = b; btn.title = this._t("card.btn." + b, null, this._defaultBtnLabel(b)); btn.addEventListener("pointerdown", (ev) => ev.stopPropagation()); btn.addEventListener("pointerup", (ev) => ev.stopPropagation()); btn.addEventListener("click", (ev) => { ev.stopPropagation(); this._onActionButton(b); }); const ic = document.createElement("ha-icon"); ic.setAttribute("icon", BUTTON_ICONS[b] || "mdi:gesture-tap-button"); btn.appendChild(ic); container.appendChild(btn); } } _defaultBtnLabel(b) { const map = { pause: "Pause", resume: "Resume", terminate: "End cycle", record_start: "Start recording", record_stop: "Stop recording", open_panel: "Open WashData", }; return map[b] || b; } _buildGlance() { const style = this._baseStyle() + ".glance{display:flex;flex-direction:column;padding:6px 0}" + ".row{display:flex;align-items:center;gap:12px;padding:8px 14px;cursor:pointer}" + ".row:hover{background:rgba(128,128,128,.06)}" + ".dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}" + ".row .info{flex:1;min-width:0}.row .primary{font-size:14px}.row .secondary{font-size:12px;margin-top:1px}" + ".row .rt{font-size:13px;color:var(--secondary-text-color);flex-shrink:0;text-align:right}"; const rows = this._glanceEntities() .map( (_, i) => '
' + '
' + '
' ) .join(""); this.shadowRoot.innerHTML = "" + '
' + rows + "
"; this.shadowRoot.querySelectorAll(".row").forEach((rowEl) => { rowEl.addEventListener("click", () => { const idx = parseInt(rowEl.dataset.idx, 10); const ent = this._glanceEntities()[idx]; if (ent) this._moreInfo(ent); }); }); } _glanceEntities() { const cfg = this._cfg || {}; if (Array.isArray(cfg.entities) && cfg.entities.length) { return cfg.entities.map((e) => (typeof e === "string" ? e : e && e.entity)).filter(Boolean); } return cfg.entity ? [cfg.entity] : []; } _update() { if (!this.shadowRoot || !this._hass || !this._cfg) return; const layout = this._cfg.layout || "tile"; if (layout === "glance") return this._updateGlance(); const roles = this._resolveRoles(); const vm = this._vm(roles); if (layout === "detail") return this._updateDetail(vm); return this._updateTile(vm); } _applyIcon(vm) { const iconEl = this.shadowRoot.getElementById("icon"); const iconc = this.shadowRoot.getElementById("iconc"); if (!iconEl || !iconc) return; iconEl.setAttribute("icon", this._deviceTypeIcon()); iconc.style.color = vm.color.fg; iconc.style.background = vm.color.bg; } _applyBar(vm) { const fill = this.shadowRoot.getElementById("barfill"); const bar = this.shadowRoot.getElementById("bar"); if (!fill || !bar) return; if (!vm.isActive || vm.pct === null) { bar.style.visibility = "hidden"; fill.style.width = "0%"; return; } bar.style.visibility = "visible"; fill.style.width = vm.pct + "%"; fill.style.background = vm.color.fg; } _updateTile(vm) { const titleEl = this.shadowRoot.getElementById("title"); const stateEl = this.shadowRoot.getElementById("state"); if (vm.missing) { if (titleEl) titleEl.textContent = this._t("card.entity_not_found", null, "Entity not found"); if (stateEl) stateEl.textContent = vm.missingEntity || ""; return; } titleEl.textContent = this._titleText(); this._applyIcon(vm); this._applyBar(vm); const flags = this._flags(); const parts = []; if (flags.showState) { if (vm.isRunning) { if (vm.subState) parts.push(vm.subState); } else { parts.push(vm.stateLabel); } } if (flags.showProgram && vm.program) parts.push(vm.program); if (!vm.isInactive && flags.showDetails) { if (flags.displayMode === "percentage" && vm.pct !== null) parts.push(vm.pct + "%"); else if (vm.timeText) parts.push(vm.timeText); else if (vm.pct !== null) parts.push(vm.pct + "%"); } if (flags.showAnomaly && vm.anomaly) parts.push(this._t("card.running_long", null, "running long")); stateEl.textContent = parts.join(" • "); } _updateDetail(vm) { const titleEl = this.shadowRoot.getElementById("title"); const stateEl = this.shadowRoot.getElementById("state"); const etaEl = this.shadowRoot.getElementById("eta"); const etaLbl = this.shadowRoot.getElementById("etalbl"); const etaWrap = this.shadowRoot.getElementById("etawrap"); const meta = this.shadowRoot.getElementById("meta"); if (vm.missing) { if (titleEl) titleEl.textContent = this._t("card.entity_not_found", null, "Entity not found"); if (stateEl) stateEl.textContent = vm.missingEntity || ""; return; } titleEl.textContent = this._titleText(); this._applyIcon(vm); this._applyBar(vm); const flags = this._flags(); // Header secondary line: state + program. const sub = []; if (flags.showState) sub.push(vm.isRunning && vm.subState ? vm.subState : vm.stateLabel); if (flags.showProgram && vm.program) sub.push(vm.program); stateEl.textContent = sub.join(" • "); // Big ETA (or percentage when running with no time). if (etaWrap && etaEl && etaLbl) { if (vm.isActive && vm.timeText) { etaEl.textContent = vm.timeText; etaLbl.textContent = this._t("card.remaining", null, "remaining"); etaWrap.style.visibility = "visible"; } else if (vm.isActive && vm.pct !== null) { etaEl.textContent = vm.pct + "%"; etaLbl.textContent = this._t("card.progress", null, "progress"); etaWrap.style.visibility = "visible"; } else { etaWrap.style.visibility = "hidden"; } } // Meta chips. if (meta) { meta.innerHTML = ""; const addChip = (text, warn) => { if (!text) return; const c = document.createElement("span"); c.className = "chip" + (warn ? " warn" : ""); c.textContent = text; meta.appendChild(c); }; if (flags.showPhase && vm.phase && vm.isActive) addChip(vm.phase); if (flags.showEnergy && vm.energyText) addChip(vm.energyText); if (flags.showEnergy && vm.costText) addChip(vm.costText); if (vm.isRunning && vm.powerW !== null) addChip(Math.round(vm.powerW) + " W"); if (flags.showAnomaly && vm.anomaly) { const ratio = vm.anomaly.ratio ? " (" + Math.round((vm.anomaly.ratio - 1) * 100) + "%)" : ""; addChip(this._t("card.running_long", null, "Running long") + ratio, true); } } this._updateButtons(vm); if (flags.showSparkline) this._updateSparkline(vm); } _updateButtons(vm) { const acts = this.shadowRoot.getElementById("acts"); if (!acts) return; const sk = vm.sk; const enabled = { pause: sk === "running", resume: sk === "paused" || sk === "user_paused", terminate: vm.isActive, record_start: !vm.isActive, record_stop: true, open_panel: true, }; acts.querySelectorAll(".wd-act").forEach((btn) => { const b = btn.dataset.btn; const target = b === "open_panel" ? true : b === "program" ? true : vm.buttons[b]; const ok = enabled[b] !== false && (b === "open_panel" || target); if (ok) btn.removeAttribute("disabled"); else btn.setAttribute("disabled", ""); }); const sel = this.shadowRoot.getElementById("prog-select"); if (sel && vm.select) { const selObj = this._hass.states[vm.select]; const opts = (selObj && selObj.attributes && selObj.attributes.options) || []; const cur = selObj && selObj.state; const sig = opts.join("|"); if (sel._sig !== sig) { sel._sig = sig; sel.innerHTML = ""; for (const o of opts) { const opt = document.createElement("option"); opt.value = o; opt.textContent = o; sel.appendChild(opt); } } if (cur !== undefined) sel.value = cur; } else if (sel) { sel.setAttribute("disabled", ""); } } _updateSparkline(vm) { const canvas = this.shadowRoot.getElementById("spark"); if (!canvas) return; // Reset the buffer when a new cycle starts / the machine goes idle. if (vm.sk !== this._sparkState) { if (!vm.isActive) this._spark = []; this._sparkState = vm.sk; } if (vm.isActive && vm.powerW !== null) { const last = this._spark[this._spark.length - 1]; const now = Date.now(); if (!last || now - last.t > 2000) { this._spark.push({ t: now, w: vm.powerW }); if (this._spark.length > SPARK_MAX_POINTS) this._spark.shift(); } } const pts = this._spark; // Hide the canvas until there is a line to draw, so an early / idle cycle // does not leave a blank 34px band above the buttons. if (pts.length < 2) { canvas.style.display = "none"; return; } canvas.style.display = "block"; const w = canvas.clientWidth || 300; const h = 34; if (canvas.width !== w) canvas.width = w; if (canvas.height !== h) canvas.height = h; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, w, h); const max = Math.max.apply(null, pts.map((p) => p.w)) || 1; const pad = 3; const dx = (w - pad * 2) / (pts.length - 1); ctx.beginPath(); pts.forEach((p, i) => { const x = pad + i * dx; const y = h - pad - (p.w / max) * (h - pad * 2); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = vm.color.fg; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke(); } _updateGlance() { const entities = this._glanceEntities(); const rows = this.shadowRoot.querySelectorAll(".row"); entities.forEach((ent, i) => { const rowEl = rows[i]; if (!rowEl) return; const roles = this._resolveRoles(ent); const vm = this._vm(roles); const dot = rowEl.querySelector("[data-dot]"); const title = rowEl.querySelector("[data-title]"); const sub = rowEl.querySelector("[data-sub]"); const rt = rowEl.querySelector("[data-rt]"); if (vm.missing) { if (title) title.textContent = ent; if (sub) sub.textContent = this._t("card.entity_not_found", null, "Entity not found"); if (dot) dot.style.background = "var(--disabled-text-color, grey)"; if (rt) rt.textContent = ""; return; } if (dot) dot.style.background = vm.color.fg; if (title) title.textContent = this._deviceNameFor(ent); const parts = []; if (vm.isRunning && vm.subState) parts.push(vm.subState); else parts.push(vm.stateLabel); if (vm.program) parts.push(vm.program); if (sub) sub.textContent = parts.join(" • "); if (rt) { if (vm.isActive && vm.timeText) rt.textContent = vm.timeText; else if (vm.isActive && vm.pct !== null) rt.textContent = vm.pct + "%"; else rt.textContent = ""; } }); } _deviceNameFor(entityId) { const hass = this._hass; const reg = (hass && hass.entities) || {}; const devices = (hass && hass.devices) || {}; const ent = reg[entityId]; if (ent && ent.device_id && devices[ent.device_id]) { const dev = devices[ent.device_id]; const name = dev.name_by_user || dev.name; if (name) return name; } const stateObj = hass && hass.states[entityId]; if (stateObj && stateObj.attributes && stateObj.attributes.friendly_name) { return String(stateObj.attributes.friendly_name).replace(/ (State|Status)$/i, ""); } return entityId; } // ── Actions ─────────────────────────────────────────────────────────────── _onActionButton(b) { const hass = this._hass; if (!hass) return; if (b === "open_panel") { this._navigate("/" + "ha-washdata"); return; } const roles = this._resolveRoles(); const entityId = roles._buttons && roles._buttons[b]; if (!entityId) return; this._fireHaptic("light"); hass.callService("button", "press", { entity_id: entityId }); } _onProgramChange(value) { const hass = this._hass; if (!hass || !value) return; const roles = this._resolveRoles(); const sel = roles._select; if (!sel) return; hass.callService("select", "select_option", { entity_id: sel, option: value }); } _moreInfo(entityId) { if (!entityId) return; this.dispatchEvent(new CustomEvent("hass-more-info", { detail: { entityId }, bubbles: true, composed: true })); } _navigate(path) { window.history.pushState(null, "", path); window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } })); } // ── Gesture handling (tap / hold / double-tap) ────────────────────────────── _clearHoldTimer() { if (this._holdTimer) { window.clearTimeout(this._holdTimer); this._holdTimer = null; } } _onPointerDown(ev) { if (ev.button !== undefined && ev.button !== 0) return; this._holdTriggered = false; this._pointerCanceled = false; this._pointerStart = { x: ev.clientX, y: ev.clientY }; const holdCfg = this._cfg && this._cfg.hold_action; if (holdCfg && holdCfg.action && holdCfg.action !== "none") { this._clearHoldTimer(); this._holdTimer = window.setTimeout(() => { this._holdTimer = null; this._holdTriggered = true; this._fireHaptic("success"); this._executeAction(holdCfg); }, HOLD_MS); } } _onPointerMove(ev) { if (!this._pointerStart) return; const dx = ev.clientX - this._pointerStart.x; const dy = ev.clientY - this._pointerStart.y; if (dx * dx + dy * dy > TAP_MOVE_TOLERANCE * TAP_MOVE_TOLERANCE) { this._clearHoldTimer(); this._pointerStart = null; this._pointerCanceled = true; } } _onPointerCancel() { this._clearHoldTimer(); } _onPointerUp() { this._clearHoldTimer(); if (this._pointerCanceled) { this._pointerCanceled = false; return; } if (this._holdTriggered) { this._holdTriggered = false; return; } const tapCfg = (this._cfg && this._cfg.tap_action) || { action: "more-info" }; const doubleCfg = this._cfg && this._cfg.double_tap_action; const hasDouble = doubleCfg && doubleCfg.action && doubleCfg.action !== "none"; if (!hasDouble) { this._executeAction(tapCfg); return; } const now = Date.now(); if (this._tapTimer && now - this._lastTapTime < DOUBLE_TAP_MS) { window.clearTimeout(this._tapTimer); this._tapTimer = null; this._lastTapTime = 0; this._executeAction(doubleCfg); return; } this._lastTapTime = now; this._tapTimer = window.setTimeout(() => { this._tapTimer = null; this._executeAction(tapCfg); }, DOUBLE_TAP_MS); } _fireHaptic(type) { this.dispatchEvent(new CustomEvent("haptic", { detail: type, bubbles: true, composed: true })); } _executeAction(actionCfg) { if (!actionCfg) return; const action = actionCfg.action || "more-info"; const entityId = actionCfg.entity || (this._cfg && this._cfg.entity); switch (action) { case "none": return; case "more-info": this._moreInfo(entityId); return; case "toggle": if (!this._hass || !entityId) return; this._hass.callService("homeassistant", "toggle", { entity_id: entityId }); return; case "call-service": case "perform-action": { const svc = actionCfg.perform_action || actionCfg.service; if (!svc || !this._hass) return; const [svcDomain, svcName] = svc.split("."); if (!svcDomain || !svcName) return; const data = { ...(actionCfg.data || actionCfg.service_data || {}) }; this._hass.callService(svcDomain, svcName, data, actionCfg.target); return; } case "navigate": { const path = actionCfg.navigation_path; if (!path) return; if (actionCfg.navigation_replace) { window.history.replaceState(window.history.state, "", path); window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: true } })); } else { this._navigate(path); } return; } case "url": { const url = actionCfg.url_path; if (!url) return; window.open(url, "_blank", "noopener,noreferrer"); return; } default: return; } } } class WashDataCardEditor extends HTMLElement { _lang() { const raw = (this._hass && this._hass.locale && this._hass.locale.language) || (this._hass && this._hass.language) || "en"; return typeof raw === "string" && raw ? raw : "en"; } _t(key, fallback) { const lang = this._lang(); const dict = _panelTrans[lang] || _panelTrans["en"]; if (dict) { const val = key.split(".").reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), dict); if (val && typeof val === "string") return val; } return fallback || key; } setConfig(config) { this._cfg = { layout: "tile", ...config }; this._render(); } set hass(hass) { this._hass = hass; const lang = this._lang(); const jobs = []; if (!_panelTrans["en"]) jobs.push(loadPanelLang("en")); if (lang && lang !== "en" && !_panelTrans[lang]) jobs.push(loadPanelLang(lang)); if (jobs.length) Promise.all(jobs).then(() => this._render()); if (this._form) this._form.hass = hass; } _render() { if (!this.shadowRoot) this.attachShadow({ mode: "open" }); if (!this._form) { this.shadowRoot.innerHTML = "" + '
'; this._form = document.createElement("ha-form"); this.shadowRoot.getElementById("editor-container").appendChild(this._form); this._form.addEventListener("value-changed", (ev) => this._valueChanged(ev)); this._form.computeLabel = (schema) => this._t("card.editor." + schema.name, this._humanize(schema.name)); } this._form.schema = this._schema(); this._form.data = this._cfg; if (this._hass) this._form.hass = this._hass; } _humanize(name) { return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } _schema() { // Flat schema (no nested/expandable groups) so config keys always stay at the // top level regardless of the running Home Assistant version. const layout = (this._cfg && this._cfg.layout) || "tile"; const layoutSel = { name: "layout", selector: { select: { mode: "dropdown", options: [ { value: "tile", label: this._t("card.layout.tile", "Tile (compact)") }, { value: "detail", label: this._t("card.layout.detail", "Detail (rich)") }, { value: "glance", label: this._t("card.layout.glance", "Glance (multiple devices)") }, ], }, }, }; if (layout === "glance") { return [ layoutSel, { name: "entities", selector: { entity: { domain: "sensor", multiple: true } } }, { name: "title", selector: { text: {} } }, ]; } const schema = [ layoutSel, { name: "entity", selector: { entity: { domain: "sensor" } } }, { name: "title", selector: { text: {} } }, { name: "icon", selector: { icon: {} } }, { name: "show_state", selector: { boolean: {} } }, { name: "show_program", selector: { boolean: {} } }, { name: "show_progress_bar", selector: { boolean: {} } }, { name: "display_mode", selector: { select: { mode: "dropdown", options: [ { value: "time", label: this._t("card.show_time_remaining", "Show Time Remaining") }, { value: "percentage", label: this._t("card.show_percentage", "Show Percentage") }, ], }, }, }, { name: "active_color", selector: { color_rgb: {} } }, ]; if (layout === "detail") { schema.push( { name: "show_phase", selector: { boolean: {} } }, { name: "show_energy", selector: { boolean: {} } }, { name: "show_anomaly", selector: { boolean: {} } }, { name: "show_sparkline", selector: { boolean: {} } }, { name: "buttons", selector: { select: { multiple: true, mode: "list", options: BUTTON_ORDER.map((b) => ({ value: b, label: this._t("card.btn." + b, this._humanize(b)) })), }, }, } ); } schema.push( { name: "program_entity", selector: { entity: { domain: ["sensor", "select", "input_select", "input_text"] } } }, { name: "time_entity", selector: { entity: { domain: "sensor" } } }, { name: "pct_entity", selector: { entity: { domain: "sensor" } } }, { name: "power_entity", selector: { entity: { domain: "sensor" } } }, { name: "tap_action", selector: { ui_action: {} } }, { name: "hold_action", selector: { ui_action: {} } }, { name: "double_tap_action", selector: { ui_action: {} } } ); return schema; } _valueChanged(ev) { if (!this._cfg) return; const val = ev.detail.value; const layoutChanged = val.layout && val.layout !== this._cfg.layout; this._cfg = { ...this._cfg, ...val }; this.dispatchEvent( new CustomEvent("config-changed", { detail: { config: this._cfg }, bubbles: true, composed: true }) ); // Re-render the schema when the layout changes so its layout-specific fields appear. if (layoutChanged) this._render(); } } customElements.define(CARD_TAG, WashDataCard); customElements.define(EDITOR_TAG, WashDataCardEditor); window.customCards = window.customCards || []; window.customCards.push({ type: CARD_TAG, name: "WashData Card", preview: true, description: "Adaptive card for WashData appliances: compact tile, rich detail, or multi-device glance.", documentationURL: "https://github.com/3dg1luk43/ha_washdata", });