Skip to content

Instantly share code, notes, and snippets.

@ohadlevy
Created March 22, 2026 15:01
Show Gist options
  • Select an option

  • Save ohadlevy/998929000ceb958d1e9eb1f872512394 to your computer and use it in GitHub Desktop.

Select an option

Save ohadlevy/998929000ceb958d1e9eb1f872512394 to your computer and use it in GitHub Desktop.
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
_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