From 6bcc95646d9ed2a3e50b01c05c07da847153e54b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:41:27 -0700 Subject: [PATCH] fix: use dynamic panel_size from topology instead of hardcoded 32 Panel size now comes from the integration's topology API. Falls back to deriving from max circuit tab number for backward compatibility with older integration versions. Removes stale monolithic build artifact. Bump version to 0.8.8. --- package.json | 2 +- span-panel-card.js | 1756 ----------------------------------- src/card/card-discovery.js | 28 +- src/card/span-panel-card.js | 8 +- src/constants.js | 2 +- 5 files changed, 32 insertions(+), 1764 deletions(-) delete mode 100644 span-panel-card.js diff --git a/package.json b/package.json index a8771ef..445226b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "span-panel-card", - "version": "0.8.7", + "version": "0.8.8", "private": true, "type": "module", "scripts": { diff --git a/span-panel-card.js b/span-panel-card.js deleted file mode 100644 index b4162f4..0000000 --- a/span-panel-card.js +++ /dev/null @@ -1,1756 +0,0 @@ -/** - * SPAN Panel Card — Custom Lovelace card for Home Assistant - * - * Renders a physical representation of a SPAN electrical panel matching - * the native SPAN frontend design with live power charts. - * - * Config: - * type: custom:span-panel-card - * device_id: - * history_days: 0 (0-30, default 0) - * history_hours: 0 (0-23, default 0) - * history_minutes: 5 (0-59, default 5) - */ - -const CARD_VERSION = "0.8.5"; - -// ── Constants ──────────────────────────────────────────────────────────────── - -const DEFAULT_HISTORY_DAYS = 0; -const DEFAULT_HISTORY_HOURS = 0; -const DEFAULT_HISTORY_MINUTES = 5; -const DEFAULT_CHART_METRIC = "power"; -const LIVE_SAMPLE_INTERVAL_MS = 1000; - -// Chart metric definitions: maps config value → { entityRole, unit, formatValue, label } -const CHART_METRICS = { - power: { - entityRole: "power", - label: "Power", - unit: v => (Math.abs(v) >= 1000 ? "kW" : "W"), - format: v => (Math.abs(v) >= 1000 ? (Math.abs(v) / 1000).toFixed(1) : String(Math.round(Math.abs(v)))), - }, - current: { - entityRole: "current", - label: "Current", - unit: () => "A", - format: v => Math.abs(v).toFixed(1), - }, -}; - -// Fixed metric definitions for BESS charts (not user-configurable) -const BESS_CHART_METRICS = { - soc: { - label: "State of Charge", - unit: () => "%", - format: v => String(Math.round(v)), - fixedMin: 0, - fixedMax: 100, - }, - soe: { - label: "State of Energy", - unit: () => "kWh", - format: v => v.toFixed(1), - }, - power: CHART_METRICS.power, -}; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function tabToRow(tab) { - return Math.ceil(tab / 2); -} - -function tabToCol(tab) { - return tab % 2 === 0 ? 1 : 0; -} - -function formatPower(watts) { - const abs = Math.abs(watts); - if (abs >= 1000) return (abs / 1000).toFixed(1); - return Math.round(abs).toString(); -} - -function formatPowerUnit(watts) { - return Math.abs(watts) >= 1000 ? "kW" : "W"; -} - -function formatPowerSigned(watts) { - const abs = Math.abs(watts); - const sign = watts < 0 ? "-" : ""; - if (abs >= 1000) return sign + (abs / 1000).toFixed(1); - return sign + Math.round(abs).toString(); -} - -function formatKw(watts) { - return (Math.abs(watts) / 1000).toFixed(1); -} - -function classifyDualTab(tabs) { - if (tabs.length !== 2) return null; - const [a, b] = [Math.min(...tabs), Math.max(...tabs)]; - if (tabToRow(a) === tabToRow(b)) return "row-span"; - if (tabToCol(a) === tabToCol(b)) return "col-span"; - return "row-span"; -} - -function getChartMetric(config) { - return CHART_METRICS[config.chart_metric] || CHART_METRICS[DEFAULT_CHART_METRIC]; -} - -function getChartEntityRole(config) { - return getChartMetric(config).entityRole; -} - -function getCircuitChartEntity(circuit, config) { - const role = getChartEntityRole(config); - return circuit.entities?.[role] || circuit.entities?.power || null; -} - -// Find the power sensor entity_id in a sub-device's flat entity map -function findSubDevicePowerEntity(subDevice) { - if (!subDevice.entities) return null; - for (const [entityId, info] of Object.entries(subDevice.entities)) { - if (info.domain !== "sensor") continue; - const name = (info.original_name || "").toLowerCase(); - if (name === "power" || name === "battery power") return entityId; - if (info.unique_id && info.unique_id.endsWith("_power")) return entityId; - } - return null; -} - -// Find the battery level (SoC %) entity in a BESS sub-device -function findBatteryLevelEntity(subDevice) { - if (!subDevice.entities) return null; - for (const [entityId, info] of Object.entries(subDevice.entities)) { - if (info.domain !== "sensor") continue; - const name = (info.original_name || "").toLowerCase(); - if (name === "battery level" || name === "battery percentage") return entityId; - if (info.unique_id && info.unique_id.endsWith("_battery_level")) return entityId; - if (info.unique_id && info.unique_id.endsWith("_battery_percentage")) return entityId; - } - return null; -} - -// Find the SoE (kWh) entity in a BESS sub-device -function findBatterySoeEntity(subDevice) { - if (!subDevice.entities) return null; - for (const [entityId, info] of Object.entries(subDevice.entities)) { - if (info.domain !== "sensor") continue; - const name = (info.original_name || "").toLowerCase(); - if (name === "state of energy") return entityId; - if (info.unique_id && info.unique_id.endsWith("_soe_kwh")) return entityId; - } - return null; -} - -// Find the nameplate capacity entity in a BESS sub-device -function findBatteryCapacityEntity(subDevice) { - if (!subDevice.entities) return null; - for (const [entityId, info] of Object.entries(subDevice.entities)) { - if (info.domain !== "sensor") continue; - const name = (info.original_name || "").toLowerCase(); - if (name === "nameplate capacity") return entityId; - if (info.unique_id && info.unique_id.endsWith("_nameplate_capacity")) return entityId; - } - return null; -} - -function getHistoryDurationMs(config) { - const d = parseInt(config.history_days) || DEFAULT_HISTORY_DAYS; - const h = parseInt(config.history_hours) || DEFAULT_HISTORY_HOURS; - const hasExplicit = config.history_days !== undefined || config.history_hours !== undefined; - const m = parseInt(config.history_minutes) || (hasExplicit && config.history_minutes === undefined ? 0 : DEFAULT_HISTORY_MINUTES); - const total = ((d * 24 + h) * 60 + m) * 60 * 1000; - return Math.max(total, 60000); // minimum 1 minute -} - -function getMaxHistoryPoints(durationMs) { - // ~1 point per second for durations up to 10 minutes, - // then taper to avoid memory bloat - const seconds = durationMs / 1000; - if (seconds <= 600) return Math.ceil(seconds); - return Math.min(1200, Math.ceil(seconds / 5)); -} - -// ── Chart helpers (ha-chart-base) ───────────────────────────────────────────── - -function buildChartOptions(history, durationMs, metric, isProducer) { - if (!metric) metric = CHART_METRICS[DEFAULT_CHART_METRIC]; - const accentRgb = isProducer ? "140, 160, 220" : "77, 217, 175"; - const accentColor = `rgb(${accentRgb})`; - const now = Date.now(); - const startTime = now - durationMs; - - const hasFixedRange = metric.fixedMin !== undefined && metric.fixedMax !== undefined; - const unit = metric.unit(0); - - // Build data array: [[timestamp, value], ...] - const data = (history || []).filter(p => p.time >= startTime).map(p => [p.time, Math.abs(p.value)]); - - const series = [ - { - type: "line", - data, - showSymbol: false, - smooth: false, - lineStyle: { width: 1.5, color: accentColor }, - areaStyle: { - color: { - type: "linear", - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [ - { offset: 0, color: `rgba(${accentRgb}, 0.35)` }, - { offset: 1, color: `rgba(${accentRgb}, 0.02)` }, - ], - }, - }, - itemStyle: { color: accentColor }, - }, - ]; - - const yAxis = { - type: "value", - splitNumber: 4, - axisLabel: { - fontSize: 10, - formatter: v => metric.format(v), - }, - splitLine: { lineStyle: { opacity: 0.15 } }, - }; - if (hasFixedRange) { - yAxis.min = metric.fixedMin; - yAxis.max = metric.fixedMax; - } - - const options = { - xAxis: { - type: "time", - min: startTime, - max: now, - axisLabel: { fontSize: 10 }, - splitLine: { show: false }, - }, - yAxis, - grid: { top: 8, right: 4, bottom: 0, left: 0, containLabel: true }, - tooltip: { - trigger: "axis", - axisPointer: { type: "line", lineStyle: { type: "dashed" } }, - formatter: params => { - if (!params || !params.length) return ""; - const p = params[0]; - const date = new Date(p.value[0]); - const timeStr = date.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - const val = parseFloat(p.value[1].toFixed(2)); - return `
${timeStr}
${val} ${unit}
`; - }, - }, - animation: false, - }; - - return { options, series }; -} - -// Create or update a ha-chart-base element inside a container -function updateChart(container, hass, history, durationMs, metric, isProducer, heightPx) { - const { options, series } = buildChartOptions(history, durationMs, metric, isProducer); - let chart = container.querySelector("ha-chart-base"); - if (!chart) { - chart = document.createElement("ha-chart-base"); - chart.style.display = "block"; - chart.style.width = "100%"; - chart.height = (heightPx || 120) + "px"; - container.innerHTML = ""; - container.appendChild(chart); - } - chart.hass = hass; - chart.options = options; - chart.data = series; -} - -// ── Card Element ───────────────────────────────────────────────────────────── - -class SpanPanelCard extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: "open" }); - this._hass = null; - this._config = {}; - this._discovered = false; - this._discovering = false; - - this._topology = null; - this._panelDevice = null; - this._panelSize = 32; - - // Power history per circuit (keyed by circuit UUID) - this._powerHistory = new Map(); - this._historyLoaded = false; - - this._updateInterval = null; - this._rendered = false; - } - - connectedCallback() { - this._updateInterval = setInterval(() => { - if (this._discovered && this._hass) { - this._updateData(); - } - }, LIVE_SAMPLE_INTERVAL_MS); - } - - disconnectedCallback() { - if (this._updateInterval) { - clearInterval(this._updateInterval); - this._updateInterval = null; - } - } - - setConfig(config) { - this._config = config; - this._discovered = false; - this._rendered = false; - this._historyLoaded = false; - this._powerHistory.clear(); - } - - get _durationMs() { - return getHistoryDurationMs(this._config); - } - - set hass(hass) { - this._hass = hass; - if (!this._config.device_id) { - this.shadowRoot.innerHTML = ` - -
- Open the card editor and select your SPAN Panel device. -
-
- `; - return; - } - if (!this._discovered && !this._discovering) { - this._discovering = true; - this._discoverTopology().then(() => { - this._discovered = true; - this._discovering = false; - this._render(); - // Load history from HA recorder - this._loadHistory(); - }); - return; - } - if (this._discovered) { - this._updateData(); - } - } - - getCardSize() { - return Math.ceil(this._panelSize / 2) + 3; - } - - static getConfigElement() { - return document.createElement("span-panel-card-editor"); - } - - static getStubConfig() { - return { - device_id: "", - history_days: 0, - history_hours: 0, - history_minutes: 5, - chart_metric: DEFAULT_CHART_METRIC, - show_panel: true, - show_battery: true, - show_evse: true, - }; - } - - // ── History from HA recorder ───────────────────────────────────────────── - - async _loadHistory() { - if (this._historyLoaded || !this._topology || !this._hass) return; - this._historyLoaded = true; - - const durationMs = this._durationMs; - const startTime = new Date(Date.now() - durationMs).toISOString(); - const entityIds = []; - const uuidByEntity = new Map(); - - for (const [uuid, circuit] of Object.entries(this._topology.circuits)) { - const eid = getCircuitChartEntity(circuit, this._config); - if (eid) { - entityIds.push(eid); - uuidByEntity.set(eid, uuid); - } - } - - // Sub-device entities (power for all, plus SoC/SoE for BESS) - if (this._topology.sub_devices) { - for (const [devId, sub] of Object.entries(this._topology.sub_devices)) { - const eidMap = { power: findSubDevicePowerEntity(sub) }; - if (sub.type === "bess") { - eidMap.soc = findBatteryLevelEntity(sub); - eidMap.soe = findBatterySoeEntity(sub); - } - for (const [role, eid] of Object.entries(eidMap)) { - if (eid) { - entityIds.push(eid); - uuidByEntity.set(eid, `sub_${devId}_${role}`); - } - } - } - } - - if (entityIds.length === 0) return; - - try { - const result = await this._hass.callWS({ - type: "history/history_during_period", - start_time: startTime, - entity_ids: entityIds, - minimal_response: true, - significant_changes_only: false, - no_attributes: true, - }); - - for (const [entityId, states] of Object.entries(result)) { - const uuid = uuidByEntity.get(entityId); - if (!uuid || !states) continue; - - const hist = []; - for (const entry of states) { - const val = parseFloat(entry.s); - if (!Number.isFinite(val)) continue; - // Compressed state format: lu=last_updated, lc=last_changed (seconds since epoch) - // lu is only present when last_updated != last_changed; fall back to lc - const tsSec = entry.lu || entry.lc || 0; - const time = tsSec * 1000; - if (time > 0) { - hist.push({ time, value: val }); - } - } - - if (hist.length > 0) { - // Merge with any live samples already collected - const existing = this._powerHistory.get(uuid) || []; - const merged = [...hist, ...existing]; - merged.sort((a, b) => a.time - b.time); - - // Deduplicate by keeping points at least 500ms apart - const deduped = [merged[0]]; - for (let i = 1; i < merged.length; i++) { - if (merged[i].time - deduped[deduped.length - 1].time >= 500) { - deduped.push(merged[i]); - } - } - - const maxPoints = getMaxHistoryPoints(durationMs); - if (deduped.length > maxPoints) { - deduped.splice(0, deduped.length - maxPoints); - } - this._powerHistory.set(uuid, deduped); - } - } - - this._updateDOM(); - } catch (err) { - console.warn("SPAN Panel: history fetch failed, charts will populate live", err); - } - } - - // ── Discovery via WebSocket API ────────────────────────────────────────── - - async _discoverTopology() { - const hass = this._hass; - if (!hass) return; - - try { - this._topology = await hass.callWS({ - type: "span_panel/panel_topology", - device_id: this._config.device_id, - }); - } catch (err) { - console.error("SPAN Panel: topology fetch failed, falling back to entity discovery", err); - await this._discoverEntitiesFallback(); - return; - } - - if (this._topology) { - this._panelSize = this._topology.panel_size || 32; - const devices = await hass.callWS({ type: "config/device_registry/list" }); - this._panelDevice = devices.find(d => d.id === this._config.device_id) || null; - } - } - - async _discoverEntitiesFallback() { - const hass = this._hass; - const [devices, entities] = await Promise.all([hass.callWS({ type: "config/device_registry/list" }), hass.callWS({ type: "config/entity_registry/list" })]); - - this._panelDevice = devices.find(d => d.id === this._config.device_id) || null; - if (!this._panelDevice) return; - - const allEntities = entities.filter(e => e.device_id === this._config.device_id); - const subDevices = devices.filter(d => d.via_device_id === this._config.device_id); - const subDeviceIds = new Set(subDevices.map(d => d.id)); - const subEntities = entities.filter(e => subDeviceIds.has(e.device_id)); - - const circuits = {}; - - for (const ent of [...allEntities, ...subEntities]) { - const state = hass.states[ent.entity_id]; - if (!state || !state.attributes || !state.attributes.tabs) continue; - - const tabsAttr = state.attributes.tabs; - if (!tabsAttr || !tabsAttr.startsWith("tabs [")) continue; - const content = tabsAttr.slice(6, -1); - let tabs; - if (content.includes(":")) { - tabs = content.split(":").map(Number); - } else { - tabs = [Number(content)]; - } - if (!tabs.every(Number.isFinite)) continue; - - const uidParts = ent.unique_id.split("_"); - let circuitUuid = null; - for (let i = 2; i < uidParts.length - 1; i++) { - if (uidParts[i].length >= 16 && /^[a-f0-9]+$/i.test(uidParts[i])) { - circuitUuid = uidParts[i]; - break; - } - } - if (!circuitUuid) continue; - - let displayName = state.attributes.friendly_name || ent.entity_id; - for (const suffix of [" Power", " Consumed Energy", " Produced Energy"]) { - if (displayName.endsWith(suffix)) { - displayName = displayName.slice(0, -suffix.length); - break; - } - } - if (this._panelDevice) { - const devName = this._panelDevice.name_by_user || this._panelDevice.name || ""; - if (displayName.startsWith(devName + " ")) { - displayName = displayName.slice(devName.length + 1); - } - } - - const base = ent.entity_id.replace(/^sensor\./, "").replace(/_power$/, ""); - - circuits[circuitUuid] = { - tabs, - name: displayName, - voltage: state.attributes.voltage || (tabs.length === 2 ? 240 : 120), - device_type: state.attributes.device_type || "circuit", - relay_state: state.attributes.relay_state || "UNKNOWN", - is_user_controllable: true, - breaker_rating_a: null, - entities: { - power: ent.entity_id, - switch: `switch.${base}_breaker`, - breaker_rating: `sensor.${base}_breaker_rating`, - }, - }; - } - - let serial = ""; - if (this._panelDevice.identifiers) { - for (const pair of this._panelDevice.identifiers) { - if (pair[0] === "span_panel") serial = pair[1]; - } - } - - for (const ent of allEntities) { - const state = hass.states[ent.entity_id]; - if (state && state.attributes && state.attributes.panel_size) { - this._panelSize = state.attributes.panel_size; - break; - } - } - - const subDeviceMap = {}; - for (const sub of subDevices) { - const subEnts = entities.filter(e => e.device_id === sub.id); - const isBess = (sub.model || "").toLowerCase().includes("battery") || (sub.identifiers || []).some(p => (p[1] || "").toLowerCase().includes("bess")); - const isEvse = (sub.model || "").toLowerCase().includes("drive") || (sub.identifiers || []).some(p => (p[1] || "").toLowerCase().includes("evse")); - - const entMap = {}; - for (const e of subEnts) { - entMap[e.entity_id] = { - domain: e.entity_id.split(".")[0], - original_name: hass.states[e.entity_id]?.attributes?.friendly_name || e.entity_id, - }; - } - - subDeviceMap[sub.id] = { - name: sub.name_by_user || sub.name || "", - type: isBess ? "bess" : isEvse ? "evse" : "unknown", - entities: entMap, - }; - } - - this._topology = { - serial, - firmware: this._panelDevice.sw_version || "", - panel_size: this._panelSize, - device_id: this._config.device_id, - device_name: this._panelDevice.name_by_user || this._panelDevice.name || "SPAN Panel", - circuits, - sub_devices: subDeviceMap, - }; - } - - // ── Record live power samples ──────────────────────────────────────────── - - _recordPowerHistory() { - if (!this._topology || !this._hass) return; - const now = Date.now(); - const cutoff = now - this._durationMs; - const maxPoints = getMaxHistoryPoints(this._durationMs); - - for (const [uuid, circuit] of Object.entries(this._topology.circuits)) { - const entityId = getCircuitChartEntity(circuit, this._config); - if (!entityId) continue; - - const state = this._hass.states[entityId]; - const rawValue = state ? parseFloat(state.state) || 0 : 0; - - if (!this._powerHistory.has(uuid)) { - this._powerHistory.set(uuid, []); - } - const hist = this._powerHistory.get(uuid); - hist.push({ time: now, value: rawValue }); - - while (hist.length > 0 && hist[0].time < cutoff) { - hist.shift(); - } - if (hist.length > maxPoints) { - hist.splice(0, hist.length - maxPoints); - } - } - - // Sub-device sampling (power for all, plus SoC/SoE for BESS) - if (this._topology.sub_devices) { - for (const [devId, sub] of Object.entries(this._topology.sub_devices)) { - const eidMap = { power: findSubDevicePowerEntity(sub) }; - if (sub.type === "bess") { - eidMap.soc = findBatteryLevelEntity(sub); - eidMap.soe = findBatterySoeEntity(sub); - } - for (const [role, entityId] of Object.entries(eidMap)) { - if (!entityId) continue; - const key = `sub_${devId}_${role}`; - const state = this._hass.states[entityId]; - const rawValue = state ? parseFloat(state.state) || 0 : 0; - if (!this._powerHistory.has(key)) { - this._powerHistory.set(key, []); - } - const hist = this._powerHistory.get(key); - hist.push({ time: now, value: rawValue }); - while (hist.length > 0 && hist[0].time < cutoff) { - hist.shift(); - } - if (hist.length > maxPoints) { - hist.splice(0, hist.length - maxPoints); - } - } - } - } - } - - // ── Data update (lightweight) ──────────────────────────────────────────── - - _updateData() { - this._recordPowerHistory(); - this._updateDOM(); - } - - // ── DOM updates (incremental) ──────────────────────────────────────────── - - _updateDOM() { - const root = this.shadowRoot; - if (!root || !this._topology || !this._hass) return; - - const hass = this._hass; - const topo = this._topology; - const durationMs = this._durationMs; - - // Update summary stats - let totalConsumption = 0; - let solarProduction = 0; - - for (const [, circuit] of Object.entries(topo.circuits)) { - const entityId = circuit.entities?.power; - if (!entityId) continue; - const state = hass.states[entityId]; - const power = state ? parseFloat(state.state) || 0 : 0; - if (circuit.device_type === "pv") { - solarProduction += Math.abs(power); - } else { - totalConsumption += Math.abs(power); - } - } - - const panelPowerEntity = this._findPanelEntity("current_power"); - if (panelPowerEntity) { - const state = hass.states[panelPowerEntity]; - if (state) totalConsumption = Math.abs(parseFloat(state.state) || 0); - } - - const consumptionEl = root.querySelector(".stat-consumption .stat-value"); - if (consumptionEl) consumptionEl.textContent = formatKw(totalConsumption); - const solarEl = root.querySelector(".stat-solar .stat-value"); - if (solarEl) solarEl.textContent = solarProduction > 0 ? formatKw(solarProduction) : "--"; - - // Update each circuit - for (const [uuid, circuit] of Object.entries(topo.circuits)) { - const slot = root.querySelector(`[data-uuid="${uuid}"]`); - if (!slot) continue; - - const entityId = circuit.entities?.power; - const state = entityId ? hass.states[entityId] : null; - const powerW = state ? parseFloat(state.state) || 0 : 0; - const isProducer = circuit.device_type === "pv" || powerW < 0; - - // Determine on/off from the switch entity (authoritative), fallback to relay_state attribute - const switchEntityId = circuit.entities?.switch; - const switchState = switchEntityId ? hass.states[switchEntityId] : null; - const isOn = switchState ? switchState.state === "on" : (state?.attributes?.relay_state || circuit.relay_state) === "CLOSED"; - - const powerVal = slot.querySelector(".power-value"); - if (powerVal) { - powerVal.innerHTML = `${formatPowerSigned(powerW)}${formatPowerUnit(powerW)}`; - } - - const toggle = slot.querySelector(".toggle-pill"); - if (toggle) { - toggle.className = `toggle-pill ${isOn ? "toggle-on" : "toggle-off"}`; - const label = toggle.querySelector(".toggle-label"); - if (label) label.textContent = isOn ? "On" : "Off"; - } - - slot.classList.toggle("circuit-off", !isOn); - slot.classList.toggle("circuit-producer", isProducer); - - const chartContainer = slot.querySelector(".chart-container"); - if (chartContainer) { - const history = this._powerHistory.get(uuid) || []; - const h = slot.classList.contains("circuit-col-span") ? 200 : 100; - updateChart(chartContainer, hass, history, durationMs, getChartMetric(this._config), isProducer, h); - } - } - - // Update sub-device sections - if (topo.sub_devices) { - for (const [devId, sub] of Object.entries(topo.sub_devices)) { - const section = root.querySelector(`[data-subdev="${devId}"]`); - if (!section) continue; - - const powerEid = findSubDevicePowerEntity(sub); - if (powerEid) { - const state = hass.states[powerEid]; - const powerW = state ? parseFloat(state.state) || 0 : 0; - const powerEl = section.querySelector(".sub-power-value"); - if (powerEl) { - powerEl.innerHTML = `${formatPowerSigned(powerW)} ${formatPowerUnit(powerW)}`; - } - } - - // Update all charts by data-chart-key - const chartContainers = section.querySelectorAll("[data-chart-key]"); - for (const cc of chartContainers) { - const chartKey = cc.dataset.chartKey; - const history = this._powerHistory.get(chartKey) || []; - let metric = CHART_METRICS.power; - if (chartKey.endsWith("_soc")) metric = BESS_CHART_METRICS.soc; - else if (chartKey.endsWith("_soe")) metric = BESS_CHART_METRICS.soe; - const isBessCol = !!cc.closest(".bess-chart-col"); - updateChart(cc, hass, history, durationMs, metric, false, isBessCol ? 120 : 150); - } - - // Update all entity values - for (const entityId of Object.keys(sub.entities || {})) { - const valEl = section.querySelector(`[data-eid="${entityId}"]`); - if (!valEl) continue; - const state = hass.states[entityId]; - if (state) { - valEl.textContent = `${state.state}${state.attributes.unit_of_measurement ? " " + state.attributes.unit_of_measurement : ""}`; - } - } - } - } - } - - _findPanelEntity(suffix) { - if (!this._hass) return null; - for (const entityId of Object.keys(this._hass.states)) { - if (entityId.startsWith("sensor.span_panel_") && entityId.endsWith(`_${suffix}`)) { - return entityId; - } - } - return null; - } - - // ── Full render ────────────────────────────────────────────────────────── - - _render() { - const hass = this._hass; - if (!hass || !this._topology) { - this.shadowRoot.innerHTML = ` - -
- ${!this._topology ? "Panel device not found. Check device_id in card config." : "Loading..."} -
-
- `; - return; - } - - const topo = this._topology; - const totalRows = Math.ceil(this._panelSize / 2); - const panelName = topo.device_name || "SPAN Panel"; - const durationMs = this._durationMs; - - const tabMap = new Map(); - const occupiedTabs = new Set(); - - for (const [uuid, circuit] of Object.entries(topo.circuits)) { - const tabs = circuit.tabs; - if (!tabs || tabs.length === 0) continue; - const primaryTab = Math.min(...tabs); - const layout = tabs.length === 1 ? "single" : classifyDualTab(tabs); - tabMap.set(primaryTab, { uuid, circuit, layout }); - for (const t of tabs) occupiedTabs.add(t); - } - - let gridHTML = ""; - const rowsToSkipLeft = new Set(); - const rowsToSkipRight = new Set(); - - for (const [primaryTab, entry] of tabMap) { - if (entry.layout === "col-span") { - const tabs = entry.circuit.tabs; - const secondaryTab = Math.max(...tabs); - const secondaryRow = tabToRow(secondaryTab); - const col = tabToCol(primaryTab); - if (col === 0) rowsToSkipLeft.add(secondaryRow); - else rowsToSkipRight.add(secondaryRow); - } - } - - for (let row = 1; row <= totalRows; row++) { - const leftTab = row * 2 - 1; - const rightTab = row * 2; - - const leftEntry = tabMap.get(leftTab); - const rightEntry = tabMap.get(rightTab); - - gridHTML += `
${leftTab}
`; - - if (leftEntry && leftEntry.layout === "row-span") { - gridHTML += this._renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2 / 4", "row-span", durationMs); - gridHTML += `
${rightTab}
`; - continue; - } - - if (rowsToSkipLeft.has(row)) { - // occupied by col-span above - } else if (leftEntry && leftEntry.layout === "col-span") { - gridHTML += this._renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2", "col-span", durationMs); - } else if (leftEntry && leftEntry.layout === "single") { - gridHTML += this._renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2", "single", durationMs); - } else if (!occupiedTabs.has(leftTab)) { - gridHTML += this._renderEmptySlot(row, "2"); - } - - if (rowsToSkipRight.has(row)) { - // occupied by col-span above - } else if (rightEntry && rightEntry.layout === "col-span") { - gridHTML += this._renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, row, "3", "col-span", durationMs); - } else if (rightEntry && rightEntry.layout === "single") { - gridHTML += this._renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, row, "3", "single", durationMs); - } else if (!occupiedTabs.has(rightTab)) { - gridHTML += this._renderEmptySlot(row, "3"); - } - - gridHTML += `
${rightTab}
`; - } - - // Sub-devices (filtered by config) - const showBattery = this._config.show_battery !== false; - const showEvse = this._config.show_evse !== false; - let subDevHTML = ""; - if (topo.sub_devices) { - for (const [devId, sub] of Object.entries(topo.sub_devices)) { - if (sub.type === "bess" && !showBattery) continue; - if (sub.type === "evse" && !showEvse) continue; - - const label = sub.type === "evse" ? "EV Charger" : sub.type === "bess" ? "Battery" : "Sub-device"; - const powerEid = findSubDevicePowerEntity(sub); - const powerState = powerEid ? hass.states[powerEid] : null; - const powerW = powerState ? parseFloat(powerState.state) || 0 : 0; - - // BESS-specific: SoC and SoE shown prominently - const isBess = sub.type === "bess"; - const battLevelEid = isBess ? findBatteryLevelEntity(sub) : null; - const battSoeEid = isBess ? findBatterySoeEntity(sub) : null; - const battCapEid = isBess ? findBatteryCapacityEntity(sub) : null; - const battLevel = battLevelEid && hass.states[battLevelEid] ? parseFloat(hass.states[battLevelEid].state) : null; - const battSoe = battSoeEid && hass.states[battSoeEid] ? parseFloat(hass.states[battSoeEid].state) : null; - const battCap = battCapEid && hass.states[battCapEid] ? parseFloat(hass.states[battCapEid].state) : null; - - // Entities to hide from the flat list (shown in header/stats) - const hideEids = new Set([powerEid, battLevelEid, battSoeEid, battCapEid].filter(Boolean)); - - const visibleEnts = this._config.visible_sub_entities || {}; - let entHTML = ""; - if (sub.entities) { - for (const [entityId, info] of Object.entries(sub.entities)) { - if (hideEids.has(entityId)) continue; - // Per-entity visibility: default hidden (must be explicitly enabled) - if (visibleEnts[entityId] !== true) continue; - const state = hass.states[entityId]; - if (!state) continue; - let name = info.original_name || state.attributes.friendly_name || entityId; - const devName = sub.name || ""; - if (name.startsWith(devName + " ")) name = name.slice(devName.length + 1); - // Use HA's translated/formatted state when available, else raw - let displayValue; - if (hass.formatEntityState) { - displayValue = hass.formatEntityState(state); - } else { - displayValue = state.state; - const unit = state.attributes.unit_of_measurement || ""; - if (unit) displayValue += " " + unit; - } - // Convert Wh energy to kWh with 1 decimal for readability - const rawUnit = state.attributes.unit_of_measurement || ""; - if (rawUnit === "Wh") { - const wh = parseFloat(state.state); - if (!isNaN(wh)) displayValue = (wh / 1000).toFixed(1) + " kWh"; - } - entHTML += ` -
- ${name}: - ${displayValue} -
- `; - } - } - - let chartsHTML = ""; - if (isBess) { - // Three charts side by side: SoC, SoE, Power - const bessCharts = [ - { key: `sub_${devId}_soc`, metric: BESS_CHART_METRICS.soc, title: "SoC", available: !!battLevelEid }, - { key: `sub_${devId}_soe`, metric: BESS_CHART_METRICS.soe, title: "SoE", available: !!battSoeEid }, - { key: `sub_${devId}_power`, metric: BESS_CHART_METRICS.power, title: "Power", available: !!powerEid }, - ].filter(c => c.available); - - chartsHTML = ` -
- ${bessCharts - .map( - c => ` -
-
${c.title}
-
-
- ` - ) - .join("")} -
- `; - } else if (powerEid) { - chartsHTML = `
`; - } - - subDevHTML += ` -
-
- ${label} - ${sub.name || ""} - ${powerEid ? `${formatPowerSigned(powerW)} ${formatPowerUnit(powerW)}` : ""} -
- ${chartsHTML} - ${entHTML} -
- `; - } - } - - this.shadowRoot.innerHTML = ` - - -
-
-
-

${panelName}

- ${topo.serial || ""} -
-
-
- Panel consumption -
0kW
-
-
- Solar production -
--kW
-
-
- Battery charge/discharge -
-
-
-
-
- Firmware: ${topo.firmware || ""} -
-
- ${ - this._config.show_panel !== false - ? ` -
- ${gridHTML} -
- ` - : "" - } - ${subDevHTML ? `
${subDevHTML}
` : ""} -
- `; - - // Delegate toggle clicks at shadowRoot level (survives DOM updates) - this.shadowRoot.addEventListener("click", ev => { - const pill = ev.target.closest(".toggle-pill"); - if (!pill) return; - ev.stopPropagation(); - ev.preventDefault(); - const slot = pill.closest("[data-uuid]"); - if (!slot || !this._topology || !this._hass) return; - const uuid = slot.dataset.uuid; - const circuit = this._topology.circuits[uuid]; - if (!circuit) return; - const switchEntity = circuit.entities?.switch; - if (!switchEntity) return; - const switchState = this._hass.states[switchEntity]; - if (!switchState) { - console.warn("SPAN Panel: switch entity not found:", switchEntity); - return; - } - const service = switchState.state === "on" ? "turn_off" : "turn_on"; - this._hass.callService("switch", service, {}, { entity_id: switchEntity }).catch(err => { - console.error("SPAN Panel: switch service call failed:", err); - }); - }); - - this._rendered = true; - this._recordPowerHistory(); - this._updateDOM(); - } - - _renderCircuitSlot(uuid, circuit, row, col, layout, durationMs) { - const hass = this._hass; - const entityId = circuit.entities?.power; - const state = entityId ? hass.states[entityId] : null; - const powerW = state ? parseFloat(state.state) || 0 : 0; - const isProducer = circuit.device_type === "pv" || powerW < 0; - - const switchEntityId = circuit.entities?.switch; - const switchState = switchEntityId ? hass.states[switchEntityId] : null; - const isOn = switchState ? switchState.state === "on" : (state?.attributes?.relay_state || circuit.relay_state) === "CLOSED"; - - const breakerAmps = circuit.breaker_rating_a; - const breakerLabel = breakerAmps ? `${Math.round(breakerAmps)}A` : ""; - const name = circuit.name || "Unknown"; - - const rowSpan = layout === "col-span" ? `${row} / span 2` : `${row}`; - const layoutClass = layout === "row-span" ? "circuit-row-span" : layout === "col-span" ? "circuit-col-span" : ""; - - return ` -
-
-
- ${breakerLabel ? `${breakerLabel}` : ""} - ${name} -
-
- - ${formatPowerSigned(powerW)}${formatPowerUnit(powerW)} - - ${ - circuit.is_user_controllable !== false && circuit.entities?.switch - ? ` -
- ${isOn ? "On" : "Off"} - -
- ` - : "" - } -
-
-
-
- `; - } - - _renderEmptySlot(row, col) { - return ` -
- -
- `; - } - - // ── Styles ─────────────────────────────────────────────────────────────── - - static _styles() { - return ` - :host { - --span-accent: var(--primary-color, #4dd9af); - } - - ha-card { - padding: 24px; - background: var(--card-background-color, #1c1c1c); - color: var(--primary-text-color, #e0e0e0); - border-radius: var(--ha-card-border-radius, 12px); - border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333)); - box-shadow: var(--ha-card-box-shadow, none); - } - - .panel-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 20px; - padding-bottom: 16px; - border-bottom: 1px solid var(--divider-color, #333); - } - - .panel-identity { - display: flex; - align-items: baseline; - gap: 12px; - margin-bottom: 12px; - } - - .panel-title { - font-size: 1.8em; - font-weight: 700; - margin: 0; - color: var(--primary-text-color, #fff); - } - - .panel-serial { - font-size: 0.85em; - color: var(--secondary-text-color, #999); - font-family: monospace; - } - - .panel-stats { - display: flex; - gap: 32px; - } - - .stat { display: flex; flex-direction: column; } - .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; } - .stat-row { display: flex; align-items: baseline; gap: 2px; } - .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); } - .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); } - - .header-right { display: flex; gap: 20px; align-items: center; padding-top: 8px; } - .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); } - - .panel-grid { - display: grid; - grid-template-columns: 28px 1fr 1fr 28px; - gap: 8px; - align-items: stretch; - } - - .tab-label { - display: flex; - align-items: center; - font-size: 0.85em; - font-weight: 600; - color: var(--secondary-text-color, #999); - user-select: none; - } - .tab-left { justify-content: flex-start; } - .tab-right { justify-content: flex-end; } - - .circuit-slot { - background: var(--secondary-background-color, var(--card-background-color, #2a2a2a)); - border: 1px solid var(--divider-color, #333); - border-radius: 12px; - padding: 14px 16px 20px; - min-height: 140px; - transition: opacity 0.3s; - position: relative; - overflow: hidden; - } - - .circuit-col-span { min-height: 280px; } - .circuit-row-span { border-left: 3px solid var(--span-accent); } - .circuit-off .circuit-name, - .circuit-off .breaker-badge, - .circuit-off .power-value, - .circuit-off .chart-container { opacity: 0.45; } - - .circuit-empty { - opacity: 0.2; - min-height: 60px; - display: flex; - align-items: center; - justify-content: center; - border-style: dashed; - } - .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; } - - .circuit-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 6px; - gap: 8px; - } - - .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; } - - .breaker-badge { - background: color-mix(in srgb, var(--span-accent) 15%, transparent); - color: var(--span-accent); - font-size: 0.7em; - font-weight: 700; - padding: 2px 7px; - border-radius: 4px; - white-space: nowrap; - border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent); - flex-shrink: 0; - } - - .circuit-name { - font-size: 0.9em; - font-weight: 500; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--primary-text-color, #e0e0e0); - } - - .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } - - .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; } - .power-value strong { font-weight: 700; font-size: 1.1em; } - .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; } - .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); } - - .toggle-pill { - display: flex; - align-items: center; - gap: 3px; - padding: 2px 4px; - border-radius: 10px; - cursor: pointer; - font-size: 0.65em; - font-weight: 600; - transition: background 0.2s; - user-select: none; - min-width: 40px; - } - .toggle-on { - padding-left: 6px; - background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent); - color: var(--state-active-color, var(--span-accent)); - } - .toggle-off { - padding-right: 6px; - background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent); - color: var(--secondary-text-color, #999); - } - .toggle-knob { - width: 14px; - height: 14px; - border-radius: 50%; - transition: background 0.2s, margin 0.2s; - } - .toggle-on .toggle-knob { - background: var(--state-active-color, var(--span-accent)); - margin-left: auto; - } - .toggle-off .toggle-knob { - background: var(--secondary-text-color, #999); - margin-right: auto; - order: -1; - } - - .chart-container { - width: 100%; - margin-top: 4px; - } - - .sub-devices { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid var(--divider-color, #333); - } - - .sub-device { - margin-bottom: 12px; - background: var(--secondary-background-color, var(--card-background-color, #2a2a2a)); - border: 1px solid var(--divider-color, #333); - border-radius: 12px; - padding: 14px 16px; - } - - .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; } - .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); } - .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; } - .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; } - .sub-power-value strong { font-weight: 700; font-size: 1.1em; } - .sub-device .chart-container { margin-bottom: 8px; } - - .bess-charts { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 10px; - } - .bess-chart-col { min-width: 0; } - .bess-chart-title { - font-size: 0.75em; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--secondary-text-color, #999); - margin-bottom: 4px; - } - .bess-chart-col .chart-container { } - .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; } - .sub-entity-name { color: var(--secondary-text-color, #999); } - .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); } - - @media (max-width: 600px) { - ha-card { padding: 12px; } - .panel-header { flex-direction: column; } - .panel-identity { flex-direction: column; gap: 4px; } - .panel-title { font-size: 1.4em; } - .panel-stats { gap: 16px; flex-wrap: wrap; } - .header-right { margin-top: 8px; } - .circuit-slot { min-height: 100px; padding: 10px 12px 16px; } - .circuit-col-span { min-height: 200px; } - .chart-container { height: 60px; } - .circuit-col-span .chart-container { height: 140px; } - } - `; - } -} - -// ── Config Editor ──────────────────────────────────────────────────────────── - -class SpanPanelCardEditor extends HTMLElement { - constructor() { - super(); - this._config = {}; - this._hass = null; - this._panels = null; - this._availableRoles = null; // set of entity roles present in topology - this._built = false; - } - - setConfig(config) { - this._config = { ...config }; - this._updateControls(); - } - - set hass(hass) { - this._hass = hass; - if (!this._panels) { - this._discoverPanels(); - } else if (!this._built) { - this._buildEditor(); - } - } - - async _discoverPanels() { - if (!this._hass) return; - const devices = await this._hass.callWS({ type: "config/device_registry/list" }); - this._panels = devices - .filter(d => (d.identifiers || []).some(pair => pair[0] === "span_panel") && !d.via_device_id) - .map(d => { - const serial = (d.identifiers || []).find(p => p[0] === "span_panel")?.[1] || ""; - const name = d.name_by_user || d.name || "SPAN Panel"; - return { device_id: d.id, label: `${name} (${serial})` }; - }); - this._buildEditor(); - } - - _buildEditor() { - this.innerHTML = ""; - this._built = true; - - const wrapper = document.createElement("div"); - wrapper.style.padding = "16px"; - - const fieldStyle = ` - width: 100%; - padding: 10px 12px; - border-radius: 8px; - border: 1px solid var(--divider-color, #333); - background: var(--card-background-color, var(--secondary-background-color, #1c1c1c)); - color: var(--primary-text-color, #e0e0e0); - font-size: 1em; - cursor: pointer; - appearance: auto; - box-sizing: border-box; - `; - - const labelStyle = "display: block; font-weight: 500; margin-bottom: 8px; color: var(--primary-text-color);"; - const groupStyle = "margin-bottom: 16px;"; - - // Panel selector - const panelGroup = document.createElement("div"); - panelGroup.style.cssText = groupStyle; - const panelLabel = document.createElement("label"); - panelLabel.textContent = "SPAN Panel"; - panelLabel.style.cssText = labelStyle; - const panelSelect = document.createElement("select"); - panelSelect.style.cssText = fieldStyle; - - const emptyOpt = document.createElement("option"); - emptyOpt.value = ""; - emptyOpt.textContent = "Select a panel..."; - panelSelect.appendChild(emptyOpt); - - if (this._panels) { - for (const panel of this._panels) { - const opt = document.createElement("option"); - opt.value = panel.device_id; - opt.textContent = panel.label; - if (panel.device_id === this._config.device_id) opt.selected = true; - panelSelect.appendChild(opt); - } - } - - panelSelect.addEventListener("change", () => { - this._config = { ...this._config, device_id: panelSelect.value }; - this._fireConfigChanged(); - this._discoverAvailableRoles(panelSelect.value); - }); - - panelGroup.appendChild(panelLabel); - panelGroup.appendChild(panelSelect); - wrapper.appendChild(panelGroup); - - // Chart time window - const timeGroup = document.createElement("div"); - timeGroup.style.cssText = groupStyle; - const timeLabel = document.createElement("label"); - timeLabel.textContent = "Chart time window"; - timeLabel.style.cssText = labelStyle; - - const timeRow = document.createElement("div"); - timeRow.style.cssText = "display: flex; gap: 12px; align-items: center; flex-wrap: wrap;"; - - // Days - const daysWrap = document.createElement("div"); - daysWrap.style.cssText = "display: flex; align-items: center; gap: 6px;"; - const daysInput = document.createElement("input"); - daysInput.type = "number"; - daysInput.min = "0"; - daysInput.max = "30"; - daysInput.value = String(parseInt(this._config.history_days) || DEFAULT_HISTORY_DAYS); - daysInput.style.cssText = fieldStyle + "width: 70px; cursor: text;"; - const daysLabel = document.createElement("span"); - daysLabel.textContent = "days"; - daysLabel.style.cssText = "font-size: 0.9em; color: var(--secondary-text-color);"; - daysWrap.appendChild(daysInput); - daysWrap.appendChild(daysLabel); - - // Hours - const hoursWrap = document.createElement("div"); - hoursWrap.style.cssText = "display: flex; align-items: center; gap: 6px;"; - const hoursInput = document.createElement("input"); - hoursInput.type = "number"; - hoursInput.min = "0"; - hoursInput.max = "23"; - hoursInput.value = String(parseInt(this._config.history_hours) || DEFAULT_HISTORY_HOURS); - hoursInput.style.cssText = fieldStyle + "width: 70px; cursor: text;"; - const hoursLabel = document.createElement("span"); - hoursLabel.textContent = "hours"; - hoursLabel.style.cssText = "font-size: 0.9em; color: var(--secondary-text-color);"; - hoursWrap.appendChild(hoursInput); - hoursWrap.appendChild(hoursLabel); - - // Minutes - const minsWrap = document.createElement("div"); - minsWrap.style.cssText = "display: flex; align-items: center; gap: 6px;"; - const minsInput = document.createElement("input"); - minsInput.type = "number"; - minsInput.min = "0"; - minsInput.max = "59"; - minsInput.value = String( - parseInt(this._config.history_minutes) || - (this._config.history_hours !== undefined && this._config.history_minutes === undefined ? 0 : DEFAULT_HISTORY_MINUTES) - ); - minsInput.style.cssText = fieldStyle + "width: 70px; cursor: text;"; - const minsLabel = document.createElement("span"); - minsLabel.textContent = "minutes"; - minsLabel.style.cssText = "font-size: 0.9em; color: var(--secondary-text-color);"; - minsWrap.appendChild(minsInput); - minsWrap.appendChild(minsLabel); - - daysInput.addEventListener("change", () => { - this._config = { ...this._config, history_days: parseInt(daysInput.value) || 0 }; - this._fireConfigChanged(); - }); - hoursInput.addEventListener("change", () => { - this._config = { ...this._config, history_hours: parseInt(hoursInput.value) || 0 }; - this._fireConfigChanged(); - }); - minsInput.addEventListener("change", () => { - this._config = { ...this._config, history_minutes: parseInt(minsInput.value) || 0 }; - this._fireConfigChanged(); - }); - - timeRow.appendChild(daysWrap); - timeRow.appendChild(hoursWrap); - timeRow.appendChild(minsWrap); - timeGroup.appendChild(timeLabel); - timeGroup.appendChild(timeRow); - wrapper.appendChild(timeGroup); - - // Chart metric selector - const metricGroup = document.createElement("div"); - metricGroup.style.cssText = groupStyle; - const metricLabel = document.createElement("label"); - metricLabel.textContent = "Chart metric"; - metricLabel.style.cssText = labelStyle; - const metricSelect = document.createElement("select"); - metricSelect.style.cssText = fieldStyle; - this._metricFieldStyle = fieldStyle; - - metricSelect.addEventListener("change", () => { - this._config = { ...this._config, chart_metric: metricSelect.value }; - this._fireConfigChanged(); - }); - - metricGroup.appendChild(metricLabel); - metricGroup.appendChild(metricSelect); - wrapper.appendChild(metricGroup); - - // Sections checkboxes - const sectionsGroup = document.createElement("div"); - sectionsGroup.style.cssText = groupStyle; - const sectionsLabel = document.createElement("label"); - sectionsLabel.textContent = "Visible sections"; - sectionsLabel.style.cssText = labelStyle; - sectionsGroup.appendChild(sectionsLabel); - - const checkboxStyle = "display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;"; - const cbLabelStyle = "font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;"; - - const sections = [ - { key: "show_panel", label: "Panel circuits", subDeviceType: null }, - { key: "show_battery", label: "Battery (BESS)", subDeviceType: "bess" }, - { key: "show_evse", label: "EV Charger (EVSE)", subDeviceType: "evse" }, - ]; - - this._checkboxes = {}; - this._entityContainers = {}; - for (const sec of sections) { - const row = document.createElement("div"); - row.style.cssText = checkboxStyle; - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.checked = this._config[sec.key] !== false; - cb.style.cssText = "width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color);"; - const lbl = document.createElement("span"); - lbl.textContent = sec.label; - lbl.style.cssText = cbLabelStyle; - row.appendChild(cb); - row.appendChild(lbl); - sectionsGroup.appendChild(row); - this._checkboxes[sec.key] = cb; - - // Container for per-entity checkboxes (populated after topology discovery) - let entityContainer = null; - if (sec.subDeviceType) { - entityContainer = document.createElement("div"); - entityContainer.style.cssText = "padding-left: 26px;"; - entityContainer.style.display = cb.checked ? "block" : "none"; - sectionsGroup.appendChild(entityContainer); - this._entityContainers[sec.subDeviceType] = entityContainer; - } - - cb.addEventListener("change", () => { - this._config = { ...this._config, [sec.key]: cb.checked }; - if (entityContainer) entityContainer.style.display = cb.checked ? "block" : "none"; - this._fireConfigChanged(); - }); - } - - wrapper.appendChild(sectionsGroup); - - this.appendChild(wrapper); - - this._panelSelect = panelSelect; - this._daysInput = daysInput; - this._hoursInput = hoursInput; - this._minsInput = minsInput; - this._metricSelect = metricSelect; - - // Populate metric dropdown — discover roles if a panel is already selected - this._populateMetricSelect(); - if (this._config.device_id) { - this._discoverAvailableRoles(this._config.device_id); - } - } - - _isChartEntity(entityId, info, subDeviceType) { - const name = (info.original_name || "").toLowerCase(); - const uid = info.unique_id || ""; - // Power is always a chart entity - if (name === "power" || name === "battery power" || uid.endsWith("_power")) return true; - if (subDeviceType === "bess") { - if (name === "battery level" || name === "battery percentage" || uid.endsWith("_battery_level") || uid.endsWith("_battery_percentage")) return true; - if (name === "state of energy" || uid.endsWith("_soe_kwh")) return true; - if (name === "nameplate capacity" || uid.endsWith("_nameplate_capacity")) return true; - } - return false; - } - - _populateEntityCheckboxes(subDevices) { - const visibleEnts = this._config.visible_sub_entities || {}; - const checkboxStyle = "display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;"; - const cbLabelStyle = "font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;"; - - for (const [devId, sub] of Object.entries(subDevices)) { - const container = this._entityContainers[sub.type]; - if (!container) continue; - container.innerHTML = ""; - if (!sub.entities) continue; - - for (const [entityId, info] of Object.entries(sub.entities)) { - if (info.domain === "sensor" && this._isChartEntity(entityId, info, sub.type)) continue; - const row = document.createElement("div"); - row.style.cssText = checkboxStyle; - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.checked = visibleEnts[entityId] === true; - cb.style.cssText = "width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);"; - const lbl = document.createElement("span"); - let name = info.original_name || entityId; - const devName = sub.name || ""; - if (name.startsWith(devName + " ")) name = name.slice(devName.length + 1); - lbl.textContent = name; - lbl.style.cssText = cbLabelStyle; - row.appendChild(cb); - row.appendChild(lbl); - container.appendChild(row); - - cb.addEventListener("change", () => { - const updated = { ...(this._config.visible_sub_entities || {}) }; - if (cb.checked) { - updated[entityId] = true; - } else { - delete updated[entityId]; - } - this._config = { ...this._config, visible_sub_entities: updated }; - this._fireConfigChanged(); - }); - } - } - } - - async _discoverAvailableRoles(deviceId) { - if (!this._hass || !deviceId) return; - try { - const topo = await this._hass.callWS({ - type: "span_panel/panel_topology", - device_id: deviceId, - }); - const roles = new Set(); - for (const circuit of Object.values(topo.circuits || {})) { - for (const role of Object.keys(circuit.entities || {})) { - roles.add(role); - } - } - this._availableRoles = roles; - this._populateMetricSelect(); - if (topo.sub_devices) { - this._populateEntityCheckboxes(topo.sub_devices); - } - } catch (err) { - // Topology unavailable — show all metrics as fallback - this._availableRoles = null; - this._populateMetricSelect(); - } - } - - _populateMetricSelect() { - const select = this._metricSelect; - if (!select) return; - const current = this._config.chart_metric || DEFAULT_CHART_METRIC; - select.innerHTML = ""; - for (const [key, def] of Object.entries(CHART_METRICS)) { - if (this._availableRoles && !this._availableRoles.has(def.entityRole)) continue; - const opt = document.createElement("option"); - opt.value = key; - opt.textContent = def.label; - if (key === current) opt.selected = true; - select.appendChild(opt); - } - } - - _updateControls() { - if (this._panelSelect) this._panelSelect.value = this._config.device_id || ""; - if (this._daysInput) this._daysInput.value = String(parseInt(this._config.history_days) || 0); - if (this._hoursInput) this._hoursInput.value = String(parseInt(this._config.history_hours) || 0); - if (this._minsInput) this._minsInput.value = String(parseInt(this._config.history_minutes) || DEFAULT_HISTORY_MINUTES); - if (this._metricSelect) this._metricSelect.value = this._config.chart_metric || DEFAULT_CHART_METRIC; - if (this._checkboxes) { - for (const [key, cb] of Object.entries(this._checkboxes)) { - cb.checked = this._config[key] !== false; - } - } - } - - _fireConfigChanged() { - this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: this._config } })); - } -} - -// ── Registration ───────────────────────────────────────────────────────────── - -customElements.define("span-panel-card", SpanPanelCard); -customElements.define("span-panel-card-editor", SpanPanelCardEditor); - -window.customCards = window.customCards || []; -window.customCards.push({ - type: "span-panel-card", - name: "SPAN Panel", - description: "Physical panel layout with live power charts matching the SPAN frontend", - preview: true, -}); - -console.info( - `%c SPAN-PANEL-CARD %c v${CARD_VERSION} `, - "background: var(--primary-color, #4dd9af); color: var(--text-primary-color, #000); font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;", - "background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;" -); diff --git a/src/card/card-discovery.js b/src/card/card-discovery.js index 6bc36ee..3571d5d 100644 --- a/src/card/card-discovery.js +++ b/src/card/card-discovery.js @@ -8,20 +8,36 @@ export async function discoverTopology(hass, deviceId) { device_id: deviceId, }); + const panelSize = topology.panel_size || panelSizeFromCircuits(topology.circuits); + if (!panelSize) { + throw new Error("Topology response missing panel_size and no circuits found. Update the SPAN Panel integration."); + } + const devices = await hass.callWS({ type: "config/device_registry/list" }); const panelDevice = devices.find(d => d.id === deviceId) || null; - const panelSize = topology.panel_size || 32; return { topology, panelDevice, panelSize }; } +// ── Backward-compatible panel size derivation ──────────────────────────────── + +function panelSizeFromCircuits(circuits) { + let maxTab = 0; + for (const circuit of Object.values(circuits || {})) { + for (const tab of circuit.tabs || []) { + if (tab > maxTab) maxTab = tab; + } + } + return maxTab > 0 ? maxTab + (maxTab % 2) : 0; +} + // ── Fallback discovery from entity registry ────────────────────────────────── export async function discoverEntitiesFallback(hass, deviceId) { const [devices, entities] = await Promise.all([hass.callWS({ type: "config/device_registry/list" }), hass.callWS({ type: "config/entity_registry/list" })]); const panelDevice = devices.find(d => d.id === deviceId) || null; - if (!panelDevice) return { topology: null, panelDevice: null, panelSize: 32 }; + if (!panelDevice) return { topology: null, panelDevice: null, panelSize: 0 }; const allEntities = entities.filter(e => e.device_id === deviceId); const subDevices = devices.filter(d => d.via_device_id === deviceId); @@ -92,7 +108,7 @@ export async function discoverEntitiesFallback(hass, deviceId) { } } - let panelSize = 32; + let panelSize = 0; for (const ent of allEntities) { const state = hass.states[ent.entity_id]; if (state && state.attributes && state.attributes.panel_size) { @@ -100,6 +116,12 @@ export async function discoverEntitiesFallback(hass, deviceId) { break; } } + if (!panelSize) { + panelSize = panelSizeFromCircuits(circuits); + } + if (!panelSize) { + throw new Error("Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration."); + } const subDeviceMap = {}; for (const sub of subDevices) { diff --git a/src/card/span-panel-card.js b/src/card/span-panel-card.js index 6ca3220..f5bd20e 100644 --- a/src/card/span-panel-card.js +++ b/src/card/span-panel-card.js @@ -30,7 +30,7 @@ export class SpanPanelCard extends HTMLElement { this._topology = null; this._panelDevice = null; - this._panelSize = 32; + this._panelSize = 0; this._powerHistory = new Map(); this._historyLoaded = false; @@ -134,6 +134,7 @@ export class SpanPanelCard extends HTMLElement { this._panelSize = result.panelSize; } catch (fallbackErr) { console.error("SPAN Panel: fallback discovery also failed", fallbackErr); + this._discoveryError = fallbackErr.message; } } } @@ -453,11 +454,12 @@ export class SpanPanelCard extends HTMLElement { _render() { const hass = this._hass; - if (!hass || !this._topology) { + if (!hass || !this._topology || !this._panelSize) { + const msg = this._discoveryError || (!this._topology ? "Panel device not found. Check device_id in card config." : "Loading..."); this.shadowRoot.innerHTML = `
- ${!this._topology ? "Panel device not found. Check device_id in card config." : "Loading..."} + ${escapeHtml(msg)}
`; diff --git a/src/constants.js b/src/constants.js index ddcc324..f4fab2b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,4 +1,4 @@ -export const CARD_VERSION = "0.8.7"; +export const CARD_VERSION = "0.8.8"; // ── Defaults ────────────────────────────────────────────────────────────────