Created
March 22, 2026 15:01
-
-
Save ohadlevy/998929000ceb958d1e9eb1f872512394 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Solar Water Heater HMI Card v2 | |
| // Visual schematic of solar thermal system with live data overlay | |
| const { LitElement, html, css } = await import("lit").catch(() => | |
| import("https://unpkg.com/lit-element@2.4.0/lit-element.js?module") | |
| ); | |
| const fireEvent = (node, type, detail = {}) => { | |
| node.dispatchEvent( | |
| new CustomEvent(type, { bubbles: true, composed: true, detail }) | |
| ); | |
| }; | |
| class SolarHeaterCard extends LitElement { | |
| static get properties() { | |
| return { | |
| hass: { type: Object }, | |
| config: { type: Object }, | |
| _tick: { type: Number }, | |
| }; | |
| } | |
| constructor() { | |
| super(); | |
| this._tick = 0; | |
| this._timer = null; | |
| } | |
| setConfig(config) { | |
| this.config = { | |
| collector_temp: "sensor.unknown_temperature_2", | |
| return_temp: "sensor.unknown_temperature", | |
| delta_temp: "sensor.hot_water_temperature_difference", | |
| pump: "switch.hot_water_pump", | |
| del24: "binary_sensor.del24_running", | |
| pump_reason: "sensor.solar_pump_status_reason", | |
| pump_runtime: "sensor.solar_pump_on_today", | |
| del24_runtime: "sensor.del24_on_today", | |
| night_heat_loss: "binary_sensor.night_heat_loss_risk", | |
| flood: "binary_sensor.boiler_flood", | |
| tank_orientation: "horizontal", // "horizontal" or "vertical" | |
| panel_tilt: 0, // degrees to tilt solar panel (e.g. 15 for east-facing) | |
| ...config, | |
| }; | |
| } | |
| static getConfigElement() { | |
| return document.createElement("solar-heater-card-editor"); | |
| } | |
| static getStubConfig() { | |
| return {}; | |
| } | |
| connectedCallback() { | |
| super.connectedCallback(); | |
| this._timer = setInterval(() => { this._tick++; }, 1000); | |
| } | |
| disconnectedCallback() { | |
| super.disconnectedCallback(); | |
| clearInterval(this._timer); | |
| } | |
| _state(entityId) { | |
| const s = this.hass?.states?.[entityId]; | |
| if (!s || s.state === "unavailable" || s.state === "unknown") return null; | |
| return s.state; | |
| } | |
| _numState(entityId) { | |
| const v = this._state(entityId); | |
| return v !== null ? parseFloat(v) : null; | |
| } | |
| _isOn(entityId) { | |
| return this._state(entityId) === "on"; | |
| } | |
| _showMoreInfo(entityId) { | |
| if (entityId) fireEvent(this, "hass-more-info", { entityId }); | |
| } | |
| _tempColor(temp) { | |
| if (temp == null) return "#666"; | |
| if (temp < 20) return "#4fc3f7"; | |
| if (temp < 35) return "#29b6f6"; | |
| if (temp < 45) return "#ffa726"; | |
| if (temp < 55) return "#ff7043"; | |
| return "#ef5350"; | |
| } | |
| _deltaColor(delta) { | |
| if (delta == null) return "#f44336"; | |
| if (delta >= 8) return "#4caf50"; | |
| if (delta >= 3) return "#ff9800"; | |
| return "#f44336"; | |
| } | |
| _waterGradColors(temp) { | |
| if (temp == null) return ["#1565c0", "#0d47a1", "#0a3060"]; | |
| if (temp < 25) return ["#29b6f6", "#0288d1", "#01579b"]; | |
| if (temp < 40) return ["#4fc3f7", "#0097a7", "#006064"]; | |
| if (temp < 50) return ["#ffb74d", "#f57c00", "#e65100"]; | |
| return ["#ff8a65", "#e64a19", "#bf360c"]; | |
| } | |
| _formatHours(val) { | |
| if (val == null) return "—"; | |
| const h = parseFloat(val); | |
| const hrs = Math.floor(h); | |
| const mins = Math.round((h - hrs) * 60); | |
| if (hrs === 0) return `${mins}m`; | |
| return `${hrs}h ${mins}m`; | |
| } | |
| _esc(str) { | |
| if (str == null) return ""; | |
| return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| } | |
| _relativeTime(entityId) { | |
| const s = this.hass?.states?.[entityId]; | |
| if (!s) return ""; | |
| const changed = new Date(s.last_changed); | |
| const secs = Math.floor((Date.now() - changed.getTime()) / 1000); | |
| if (secs < 60) return `${secs}s ago`; | |
| if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; | |
| if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`; | |
| return `${Math.floor(secs / 86400)}d ago`; | |
| } | |
| updated() { | |
| const root = this.shadowRoot; | |
| if (!root) return; | |
| const c = this.config; | |
| const pumpOn = this._isOn(c.pump); | |
| const del24On = this._isOn(c.del24); | |
| const returnTemp = this._numState(c.return_temp); | |
| const wc = this._waterGradColors(returnTemp); | |
| const pumpColor = pumpOn ? "#4caf50" : "#555"; | |
| // Flow dots — animate stroke-dashoffset along the path | |
| const flowHot = root.getElementById("flowHot"); | |
| const flowCold = root.getElementById("flowCold"); | |
| if (flowHot) { | |
| flowHot.style.display = pumpOn ? "inline" : "none"; | |
| if (pumpOn) flowHot.setAttribute("stroke-dashoffset", -(this._tick * 8) % 30); | |
| } | |
| if (flowCold) { | |
| flowCold.style.display = pumpOn ? "inline" : "none"; | |
| if (pumpOn) flowCold.setAttribute("stroke-dashoffset", -(this._tick * 8) % 30); | |
| } | |
| // Pump impeller rotation | |
| const impeller = root.getElementById("impeller"); | |
| if (impeller) { | |
| const angle = pumpOn ? (this._tick * 30) % 360 : 0; | |
| impeller.setAttribute("transform", `rotate(${angle})`); | |
| } | |
| // Pump circle glow | |
| const pumpCircle = root.getElementById("pumpCircle"); | |
| if (pumpCircle) { | |
| pumpCircle.setAttribute("filter", pumpOn ? "url(#glow)" : ""); | |
| pumpCircle.setAttribute("stroke", pumpColor); | |
| } | |
| // Impeller color | |
| root.querySelectorAll("#impeller line").forEach(l => l.setAttribute("stroke", pumpColor)); | |
| const pumpDot = root.getElementById("pumpDot"); | |
| if (pumpDot) pumpDot.setAttribute("fill", pumpColor); | |
| // DEL24 glow | |
| const del24Box = root.getElementById("del24Box"); | |
| if (del24Box) { | |
| del24Box.setAttribute("filter", del24On ? "url(#orangeGlow)" : ""); | |
| del24Box.setAttribute("stroke", del24On ? "#ff9800" : "#555"); | |
| } | |
| const del24Bolt = root.getElementById("del24Bolt"); | |
| if (del24Bolt) { | |
| del24Bolt.setAttribute("fill", del24On ? "#ff9800" : "#555"); | |
| del24Bolt.setAttribute("opacity", del24On ? "1" : "0.4"); | |
| } | |
| // Water gradient colors | |
| const ws0 = root.getElementById("ws0"); | |
| const ws1 = root.getElementById("ws1"); | |
| const ws2 = root.getElementById("ws2"); | |
| if (ws0) ws0.setAttribute("stop-color", wc[0]); | |
| if (ws1) ws1.setAttribute("stop-color", wc[1]); | |
| if (ws2) ws2.setAttribute("stop-color", wc[2]); | |
| } | |
| render() { | |
| if (!this.hass) return html``; | |
| const c = this.config; | |
| const collectorTemp = this._numState(c.collector_temp); | |
| const returnTemp = this._numState(c.return_temp); | |
| const deltaTemp = this._numState(c.delta_temp); | |
| const pumpOn = this._isOn(c.pump); | |
| const del24On = this._isOn(c.del24); | |
| const pumpReason = this._state(c.pump_reason); | |
| const pumpRuntime = this._state(c.pump_runtime); | |
| const del24Runtime = this._state(c.del24_runtime); | |
| const nightRisk = this._isOn(c.night_heat_loss); | |
| const flood = this._isOn(c.flood); | |
| const sunAttrs = this.hass.states?.["sun.sun"]?.attributes; | |
| const sunElev = sunAttrs?.elevation; | |
| const sunAzimuth = sunAttrs?.azimuth; | |
| const sunUp = sunElev != null && sunElev > 0; | |
| const pumpColor = pumpOn ? "#4caf50" : "#555"; | |
| const del24Color = del24On ? "#ff9800" : "#555"; | |
| const collTempStr = collectorTemp != null ? collectorTemp.toFixed(1) + "°C" : "—"; | |
| const retTempStr = returnTemp != null ? returnTemp.toFixed(1) + "°C" : "—"; | |
| const deltaStr = deltaTemp != null ? deltaTemp.toFixed(1) + "°C" : "—"; | |
| const horiz = c.tank_orientation === "horizontal"; | |
| const pumpLastChanged = this._relativeTime(c.pump); | |
| // Sun quality indicator | |
| const lowSun = sunUp && sunElev < 15; | |
| const goodSun = sunUp && sunElev >= 15; | |
| return html` | |
| <ha-card> | |
| <div class="hmi"> | |
| ${flood ? html` | |
| <div class="hmi-alert hmi-alert-critical"> | |
| <ha-icon icon="mdi:water-alert" style="--mdc-icon-size: 18px;"></ha-icon> | |
| Flood sensor triggered! | |
| </div> | |
| ` : ""} | |
| ${nightRisk ? html` | |
| <div class="hmi-alert hmi-alert-warning"> | |
| <ha-icon icon="mdi:weather-night" style="--mdc-icon-size: 18px;"></ha-icon> | |
| Night heat loss risk — reverse thermosiphon possible | |
| </div> | |
| ` : ""} | |
| <div class="hmi-svg-wrap"> | |
| ${this._renderSVG({ | |
| collectorTemp, returnTemp, deltaTemp, pumpOn, del24On, | |
| pumpColor, del24Color, collTempStr, retTempStr, deltaStr, | |
| sunUp, pumpReason, horiz, pumpLastChanged, | |
| lowSun, goodSun, sunElev, sunAzimuth | |
| })} | |
| </div> | |
| <!-- Pump status bar (HTML — handles RTL properly) --> | |
| <div class="hmi-pump-status" @click=${() => this._showMoreInfo(c.pump)}> | |
| <ha-icon icon=${pumpOn ? "mdi:pump" : "mdi:pump-off"} | |
| style="--mdc-icon-size: 18px; color: ${pumpColor};"></ha-icon> | |
| <span class="hmi-pump-state" style="color: ${pumpColor}"> | |
| ${pumpOn ? "Pump ON" : "Pump OFF"} | |
| </span> | |
| <span class="hmi-pump-changed">${pumpLastChanged}</span> | |
| ${pumpReason ? html` | |
| <span class="hmi-pump-reason">${pumpReason}</span> | |
| ` : ""} | |
| </div> | |
| <div class="hmi-stats"> | |
| <div class="hmi-stat" @click=${() => this._showMoreInfo(c.pump_runtime)}> | |
| <ha-icon icon="mdi:pump" style="--mdc-icon-size: 16px; color: ${pumpColor};"></ha-icon> | |
| <span class="hmi-stat-label">Pump today</span> | |
| <span class="hmi-stat-value">${this._formatHours(pumpRuntime)}</span> | |
| </div> | |
| <div class="hmi-stat" @click=${() => this._showMoreInfo(c.del24_runtime)}> | |
| <ha-icon icon="mdi:lightning-bolt" style="--mdc-icon-size: 16px; color: ${del24Color};"></ha-icon> | |
| <span class="hmi-stat-label">DEL 24 today</span> | |
| <span class="hmi-stat-value">${this._formatHours(del24Runtime)}</span> | |
| </div> | |
| <div class="hmi-stat" @click=${() => this._showMoreInfo(c.delta_temp)}> | |
| <ha-icon icon="mdi:thermometer-lines" style="--mdc-icon-size: 16px;"></ha-icon> | |
| <span class="hmi-stat-label">Delta</span> | |
| <span class="hmi-stat-value" style="color: ${this._deltaColor(deltaTemp)}"> | |
| ${deltaStr} | |
| </span> | |
| </div> | |
| <div class="hmi-stat" @click=${() => this._showMoreInfo(c.flood)}> | |
| <ha-icon icon="mdi:water-alert" style="--mdc-icon-size: 16px; color: ${flood ? '#f44336' : '#4caf50'};"></ha-icon> | |
| <span class="hmi-stat-label">Flood</span> | |
| <span class="hmi-stat-value" style="color: ${flood ? '#f44336' : '#4caf50'}"> | |
| ${flood ? "ALERT" : "OK"} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| </ha-card> | |
| `; | |
| } | |
| _renderSVG(d) { | |
| const horiz = d.horiz; | |
| // Tank geometry | |
| const tankX = horiz ? 155 : 185; | |
| const tankY = horiz ? 175 : 130; | |
| const tankW = horiz ? 130 : 70; | |
| const tankH = horiz ? 70 : 140; | |
| const tankRx = horiz ? 35 : 6; | |
| const tankCX = tankX + tankW / 2; | |
| const tankCY = tankY + tankH / 2; | |
| // Water fill (inset from tank) | |
| const wX = tankX + 4; | |
| const wY = tankY + 6; | |
| const wW = tankW - 8; | |
| const wH = tankH - 12; | |
| const wRx = horiz ? 30 : 4; | |
| // Water gradient direction for horizontal | |
| const wgX2 = horiz ? "1" : "0"; | |
| const wgY2 = horiz ? "0" : "1"; | |
| // Pipe connection points | |
| const tankTopMid = { x: tankCX, y: tankY }; | |
| const tankBotMid = { x: tankCX, y: tankY + tankH }; | |
| const tankRight = { x: tankX + tankW, y: tankCY }; | |
| // Hot pipe: panel right → down to tank top | |
| const hotPipe = `M 240 115 L 280 115 L 280 ${tankTopMid.y - 5} L ${tankCX} ${tankTopMid.y - 5}`; | |
| // Cold pipe: tank bottom → down → left → up to panel left | |
| const coldPipe = `M ${tankCX} ${tankBotMid.y + 5} L ${tankCX} ${tankBotMid.y + 30} L 25 ${tankBotMid.y + 30} L 25 115 L 30 115`; | |
| // DEL24 position | |
| const del24X = tankRight.x + 65; | |
| const del24Y = tankCY; | |
| // Output pipe — extended so house icon has room | |
| const outputEndX = del24X + 90; | |
| // Panel dimming for night / low sun | |
| const panelOpacity = d.sunUp ? (d.lowSun ? 0.6 : 1) : 0.35; | |
| const svgContent = ` | |
| <svg class="hmi-svg" viewBox="0 0 ${outputEndX + 55} ${tankBotMid.y + 55}" xmlns="http://www.w3.org/2000/svg"> | |
| <defs> | |
| <linearGradient id="waterGrad" x1="0" y1="0" x2="${wgX2}" y2="${wgY2}"> | |
| <stop id="ws0" offset="0%" stop-color="#1565c0" stop-opacity="0.9"/> | |
| <stop id="ws1" offset="50%" stop-color="#0d47a1" stop-opacity="0.85"/> | |
| <stop id="ws2" offset="100%" stop-color="#0a3060" stop-opacity="0.95"/> | |
| </linearGradient> | |
| <linearGradient id="tankGrad" x1="0" y1="0" x2="${horiz ? '0' : '1'}" y2="${horiz ? '1' : '0'}"> | |
| <stop offset="0%" stop-color="#455a64"/> | |
| <stop offset="20%" stop-color="#78909c"/> | |
| <stop offset="50%" stop-color="#90a4ae"/> | |
| <stop offset="80%" stop-color="#78909c"/> | |
| <stop offset="100%" stop-color="#455a64"/> | |
| </linearGradient> | |
| <linearGradient id="panelGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stop-color="#1e3a5f"/> | |
| <stop offset="100%" stop-color="#0d2137"/> | |
| </linearGradient> | |
| <filter id="glow"> | |
| <feGaussianBlur stdDeviation="3" result="blur"/> | |
| <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge> | |
| </filter> | |
| <filter id="orangeGlow"> | |
| <feGaussianBlur stdDeviation="4" result="blur"/> | |
| <feFlood flood-color="#ff9800" flood-opacity="0.4" result="color"/> | |
| <feComposite in="color" in2="blur" operator="in" result="glow"/> | |
| <feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge> | |
| </filter> | |
| <filter id="textShadow" x="-10%" y="-10%" width="120%" height="120%"> | |
| <feDropShadow dx="0" dy="0" stdDeviation="2" flood-color="#000" flood-opacity="0.7"/> | |
| </filter> | |
| </defs> | |
| <!-- SUN with position arc --> | |
| <g class="clickable" data-entity="sun.sun" transform="translate(135, 50)"> | |
| ${d.sunUp ? (() => { | |
| // Sun position on arc using azimuth (90=east/AM → 180=noon → 270=west/PM) | |
| const arcR = 36; | |
| const az = d.sunAzimuth || 180; | |
| const theta = (az - 90) * Math.PI / 180; // 0=east, π/2=noon, π=west | |
| const sunX = -arcR * Math.cos(theta); | |
| const sunY = -arcR * Math.sin(theta); | |
| return ` | |
| <!-- Horizon line --> | |
| <line x1="-44" y1="0" x2="44" y2="0" stroke="#546e7a" stroke-width="1" opacity="0.4"/> | |
| <!-- Arc track --> | |
| <path d="M -${arcR} 0 A ${arcR} ${arcR} 0 0 1 ${arcR} 0" fill="none" stroke="#546e7a" stroke-width="0.8" opacity="0.25" stroke-dasharray="2 3"/> | |
| <!-- AM / PM labels --> | |
| <text x="-${arcR + 8}" y="4" text-anchor="end" style="font-size:8px;fill:#607d8b;font-family:sans-serif">AM</text> | |
| <text x="${arcR + 8}" y="4" text-anchor="start" style="font-size:8px;fill:#607d8b;font-family:sans-serif">PM</text> | |
| <!-- Sun glow + dot --> | |
| <circle cx="${sunX}" cy="${sunY}" r="10" fill="#fdd835" opacity="0.12" filter="url(#glow)"/> | |
| <circle cx="${sunX}" cy="${sunY}" r="6" fill="#ffeb3b"/> | |
| ${[0,45,90,135,180,225,270,315].map(a => | |
| `<line x1="0" y1="-9" x2="0" y2="-13" stroke="#fdd835" stroke-width="1" stroke-linecap="round" opacity="0.5" transform="translate(${sunX},${sunY}) rotate(${a})"/>` | |
| ).join("")} | |
| `; | |
| })() : ` | |
| <line x1="-44" y1="0" x2="44" y2="0" stroke="#546e7a" stroke-width="1" opacity="0.3"/> | |
| <text x="-44" y="4" text-anchor="end" style="font-size:8px;fill:#546e7a;font-family:sans-serif">AM</text> | |
| <text x="44" y="4" text-anchor="start" style="font-size:8px;fill:#546e7a;font-family:sans-serif">PM</text> | |
| <circle cx="0" cy="-10" r="7" fill="#455a64" opacity="0.4"/> | |
| <circle cx="0" cy="-10" r="5" fill="#546e7a" opacity="0.3"/> | |
| <text x="0" y="14" text-anchor="middle" style="font-size:9px;fill:#607d8b;font-family:sans-serif">night</text> | |
| `} | |
| </g> | |
| <!-- SOLAR PANEL --> | |
| <g class="clickable" data-entity="${this._esc(this.config.collector_temp)}" opacity="${panelOpacity}"> | |
| <rect x="30" y="80" width="210" height="70" rx="4" fill="url(#panelGrad)" stroke="#37526e" stroke-width="2"/> | |
| ${[50,75,100,125,150,175,200,225].map(x => | |
| `<line x1="${x}" y1="85" x2="${x}" y2="145" stroke="#2a4460" stroke-width="1"/>` | |
| ).join("")} | |
| <line x1="35" y1="98" x2="235" y2="98" stroke="#2a4460" stroke-width="0.5"/> | |
| <line x1="35" y1="115" x2="235" y2="115" stroke="#2a4460" stroke-width="0.5"/> | |
| <line x1="35" y1="132" x2="235" y2="132" stroke="#2a4460" stroke-width="0.5"/> | |
| <rect x="34" y="83" width="202" height="6" rx="2" fill="white" opacity="${d.goodSun ? 0.08 : (d.lowSun ? 0.04 : 0.02)}"/> | |
| <text x="135" y="106" text-anchor="middle" class="hmi-label" style="font-size:13px">Solar Collector</text> | |
| <text x="135" y="137" text-anchor="middle" class="hmi-value" style="font-size:20px" fill="${this._tempColor(d.collectorTemp)}">${this._esc(d.collTempStr)}</text> | |
| </g> | |
| <!-- PIPES --> | |
| <!-- Hot pipe: panel right → tank top --> | |
| <path d="${hotPipe}" fill="none" stroke="#546e7a" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path id="flowHot" d="${hotPipe}" fill="none" stroke="#4fc3f7" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="6 24" opacity="0.8" style="display:none"/> | |
| <!-- Cold pipe: tank bottom → panel left --> | |
| <path d="${coldPipe}" fill="none" stroke="#3d5060" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path id="flowCold" d="${coldPipe}" fill="none" stroke="#4fc3f7" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="6 24" opacity="0.8" style="display:none"/> | |
| <!-- PUMP --> | |
| <g class="clickable" data-entity="${this._esc(this.config.pump)}" transform="translate(${tankCX}, ${tankBotMid.y + 30})"> | |
| <circle id="pumpCircle" r="16" fill="#1a2030" stroke="#555" stroke-width="2.5"/> | |
| <g id="impeller"> | |
| <line x1="-8" y1="0" x2="8" y2="0" stroke="#555" stroke-width="2.5" stroke-linecap="round"/> | |
| <line x1="0" y1="-8" x2="0" y2="8" stroke="#555" stroke-width="2.5" stroke-linecap="round"/> | |
| <line x1="-6" y1="-6" x2="6" y2="6" stroke="#555" stroke-width="2" stroke-linecap="round"/> | |
| <line x1="6" y1="-6" x2="-6" y2="6" stroke="#555" stroke-width="2" stroke-linecap="round"/> | |
| </g> | |
| <circle id="pumpDot" r="4" fill="#555"/> | |
| </g> | |
| <!-- HOT WATER TANK --> | |
| <g class="clickable" data-entity="${this._esc(this.config.return_temp)}"> | |
| <!-- Tank body --> | |
| <rect x="${tankX}" y="${tankY}" width="${tankW}" height="${tankH}" rx="${tankRx}" fill="url(#tankGrad)" stroke="#546e7a" stroke-width="2"/> | |
| <!-- Water fill --> | |
| <rect x="${wX}" y="${wY}" width="${wW}" height="${wH}" rx="${wRx}" fill="url(#waterGrad)"/> | |
| <!-- Water surface shimmer --> | |
| ${horiz ? ` | |
| <rect x="${wX}" y="${wY}" width="4" height="${wH}" rx="2" fill="white" opacity="0.1"/> | |
| ` : ` | |
| <rect x="${wX}" y="${wY}" width="${wW}" height="3" rx="1" fill="white" opacity="0.12"/> | |
| `} | |
| <!-- Tank ribs --> | |
| ${horiz ? ` | |
| <line x1="${tankX + 35}" y1="${tankY}" x2="${tankX + 35}" y2="${tankY + tankH}" stroke="#455a64" stroke-width="1" opacity="0.4"/> | |
| <line x1="${tankX + 65}" y1="${tankY}" x2="${tankX + 65}" y2="${tankY + tankH}" stroke="#455a64" stroke-width="1" opacity="0.4"/> | |
| <line x1="${tankX + 95}" y1="${tankY}" x2="${tankX + 95}" y2="${tankY + tankH}" stroke="#455a64" stroke-width="1" opacity="0.4"/> | |
| ` : ` | |
| <line x1="${tankX}" y1="${tankY + 35}" x2="${tankX + tankW}" y2="${tankY + 35}" stroke="#455a64" stroke-width="1" opacity="0.4"/> | |
| <line x1="${tankX}" y1="${tankY + 70}" x2="${tankX + tankW}" y2="${tankY + 70}" stroke="#455a64" stroke-width="1" opacity="0.4"/> | |
| <line x1="${tankX}" y1="${tankY + 105}" x2="${tankX + tankW}" y2="${tankY + 105}" stroke="#455a64" stroke-width="1" opacity="0.4"/> | |
| `} | |
| <!-- Labels --> | |
| <g filter="url(#textShadow)"> | |
| <text x="${tankCX}" y="${tankCY - 20}" text-anchor="middle" class="hmi-label" style="font-size:12px">Hot Water Tank</text> | |
| <text x="${tankCX}" y="${tankCY + 8}" text-anchor="middle" class="hmi-value-lg" style="font-size:24px" fill="${this._tempColor(d.returnTemp)}">${this._esc(d.retTempStr)}</text> | |
| <text x="${tankCX}" y="${tankCY + 26}" text-anchor="middle" style="font-size:12px;font-family:'Roboto Mono',monospace;font-weight:600" fill="${this._deltaColor(d.deltaTemp)}">Δ ${this._esc(d.deltaStr)}</text> | |
| </g> | |
| </g> | |
| <!-- DEL 24 ELECTRIC HEATER --> | |
| <g class="clickable" data-entity="${this._esc(this.config.del24)}" transform="translate(${del24X}, ${del24Y})"> | |
| <rect id="del24Box" x="-32" y="-35" width="64" height="70" rx="5" fill="#1a2030" stroke="#555" stroke-width="2"/> | |
| <path id="del24Bolt" d="M-4,-18 L4,-18 L2,-4 L8,-4 L-4,18 L-1,4 L-8,4 Z" fill="#555" opacity="0.4"/> | |
| <text x="0" y="-22" text-anchor="middle" class="hmi-label" style="font-size:12px">DEL 24</text> | |
| <text x="0" y="26" text-anchor="middle" style="font-size:10px;fill:${d.del24On ? '#ff9800' : '#607d8b'};font-family:sans-serif;font-weight:500">${d.del24On ? "HEATING" : "Standby"}</text> | |
| </g> | |
| <!-- Pipe: tank → DEL24 (colored by tank temp) --> | |
| <line x1="${tankRight.x}" y1="${tankCY}" x2="${del24X - 32}" y2="${del24Y}" stroke="${this._tempColor(d.returnTemp)}" stroke-width="6" stroke-linecap="round" opacity="0.7"/> | |
| <!-- OUTPUT: DEL24 → House (hot water supply — always warm) --> | |
| <path d="M ${del24X + 32} ${del24Y} L ${outputEndX} ${del24Y}" fill="none" stroke="#ff7043" stroke-width="6" stroke-linecap="round" opacity="0.7"/> | |
| <g transform="translate(${outputEndX + 18}, ${del24Y})"> | |
| <!-- House icon --> | |
| <path d="M-12,6 L-12,-4 L0,-12 L12,-4 L12,6 Z" fill="none" stroke="#b0bec5" stroke-width="1.5" stroke-linejoin="round"/> | |
| <rect x="-3" y="-1" width="6" height="7" fill="none" stroke="#ff7043" stroke-width="1" opacity="0.8"/> | |
| <!-- Drop --> | |
| <path d="M0,-18 Q3,-14 3,-11 Q3,-8 0,-8 Q-3,-8 -3,-11 Q-3,-14 0,-18 Z" fill="#ff7043" opacity="0.6"/> | |
| </g> | |
| <text x="${outputEndX + 18}" y="${del24Y + 22}" text-anchor="middle" style="font-size:10px;fill:#b0bec5;font-family:sans-serif">Hot Water</text> | |
| </svg> | |
| `; | |
| const container = document.createElement("div"); | |
| container.style.width = "100%"; | |
| container.innerHTML = svgContent; | |
| container.querySelectorAll(".clickable").forEach((el) => { | |
| const entity = el.getAttribute("data-entity"); | |
| if (entity) { | |
| el.style.cursor = "pointer"; | |
| el.addEventListener("click", () => this._showMoreInfo(entity)); | |
| } | |
| }); | |
| return html`${container}`; | |
| } | |
| static get styles() { | |
| return css` | |
| :host { | |
| display: block; | |
| } | |
| .hmi { | |
| padding: 12px; | |
| max-width: 700px; | |
| margin: 0 auto; | |
| } | |
| .hmi-alert { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 12px; | |
| border-radius: 8px; | |
| margin-bottom: 8px; | |
| font-size: 0.85em; | |
| font-weight: 500; | |
| animation: alertPulse 2s ease-in-out infinite; | |
| } | |
| .hmi-alert-critical { | |
| background: rgba(244, 67, 54, 0.2); | |
| color: #f44336; | |
| border: 1px solid rgba(244, 67, 54, 0.4); | |
| } | |
| .hmi-alert-warning { | |
| background: rgba(255, 152, 0, 0.15); | |
| color: #ff9800; | |
| border: 1px solid rgba(255, 152, 0, 0.3); | |
| } | |
| @keyframes alertPulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.7; } | |
| } | |
| .hmi-svg-wrap { | |
| width: 100%; | |
| display: flex; | |
| justify-content: center; | |
| } | |
| .hmi-svg-wrap svg { | |
| width: 100%; | |
| height: auto; | |
| display: block; | |
| } | |
| /* SVG text classes applied via class attr */ | |
| .hmi-svg-wrap .hmi-label { | |
| font-size: 10px; | |
| fill: #90a4ae; | |
| font-family: sans-serif; | |
| font-weight: 500; | |
| } | |
| .hmi-svg-wrap .hmi-label-sm { | |
| font-size: 8.5px; | |
| fill: #78909c; | |
| font-family: sans-serif; | |
| } | |
| .hmi-svg-wrap .hmi-value { | |
| font-size: 16px; | |
| font-family: 'Roboto Mono', monospace; | |
| font-weight: 700; | |
| } | |
| .hmi-svg-wrap .hmi-value-lg { | |
| font-size: 20px; | |
| font-family: 'Roboto Mono', monospace; | |
| font-weight: 700; | |
| } | |
| .hmi-svg-wrap .clickable:hover { | |
| filter: brightness(1.2); | |
| } | |
| .hmi-pump-status { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 12px; | |
| margin-top: 4px; | |
| border-radius: 8px; | |
| background: rgba(255, 255, 255, 0.04); | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| flex-wrap: wrap; | |
| } | |
| .hmi-pump-status:hover { | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .hmi-pump-state { | |
| font-weight: 600; | |
| font-size: 0.95em; | |
| } | |
| .hmi-pump-changed { | |
| font-size: 0.8em; | |
| color: var(--secondary-text-color, #78909c); | |
| } | |
| .hmi-pump-reason { | |
| font-size: 0.85em; | |
| color: var(--secondary-text-color, #90a4ae); | |
| margin-inline-start: auto; | |
| direction: rtl; | |
| text-align: end; | |
| } | |
| .hmi-stats { | |
| display: flex; | |
| gap: 4px; | |
| margin-top: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .hmi-stat { | |
| flex: 1; | |
| min-width: 80px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 2px; | |
| padding: 8px 4px; | |
| border-radius: 8px; | |
| background: rgba(255, 255, 255, 0.04); | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| } | |
| .hmi-stat:hover { | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .hmi-stat-label { | |
| font-size: 0.8em; | |
| color: var(--secondary-text-color, #78909c); | |
| text-align: center; | |
| } | |
| .hmi-stat-value { | |
| font-size: 1.05em; | |
| font-weight: 600; | |
| font-family: 'Roboto Mono', monospace; | |
| color: var(--primary-text-color, #eceff1); | |
| } | |
| `; | |
| } | |
| getCardSize() { | |
| return 4; | |
| } | |
| } | |
| // Card editor | |
| class SolarHeaterCardEditor extends LitElement { | |
| static get properties() { | |
| return { hass: { type: Object }, _config: { type: Object } }; | |
| } | |
| setConfig(config) { | |
| this._config = config; | |
| } | |
| render() { | |
| return html` | |
| <div style="padding: 16px;"> | |
| <p style="color: var(--secondary-text-color); font-size: 0.9em;"> | |
| Solar water heater HMI diagram. Set <code>tank_orientation: vertical</code> | |
| for a vertical tank. All entity IDs auto-discovered by default. | |
| </p> | |
| </div> | |
| `; | |
| } | |
| } | |
| customElements.define("solar-heater-card", SolarHeaterCard); | |
| customElements.define("solar-heater-card-editor", SolarHeaterCardEditor); | |
| window.customCards = window.customCards || []; | |
| window.customCards.push({ | |
| type: "solar-heater-card", | |
| name: "Solar Water Heater HMI", | |
| description: "Visual schematic of solar thermal system with live data and animations", | |
| preview: true, | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment