diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts index 849504d..8936aee 100644 --- a/src/card/card-discovery.ts +++ b/src/card/card-discovery.ts @@ -1,6 +1,7 @@ import { INTEGRATION_DOMAIN } from "../constants.js"; import { resolveAndAssignAreas } from "../core/area-resolver.js"; import { RetryManager } from "../core/retry-manager.js"; +import { isPositiveInteger, measuresOutsidePanel, positionRange } from "../helpers/layout.js"; import { t } from "../i18n.js"; import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js"; @@ -34,8 +35,9 @@ export async function discoverTopology(hass: HomeAssistant, deviceId: string | u const topologyMsg = { type: `${INTEGRATION_DOMAIN}/panel_topology`, device_id: deviceId }; const topology = retry ? await retry.callWS(hass, topologyMsg, { errorId: "fetch:topology" }) : await hass.callWS(topologyMsg); - const panelSize = topology.panel_size ?? panelSizeFromCircuits(topology.circuits); - if (!panelSize) { + const panelSize = isPositiveInteger(topology.panel_size) ? topology.panel_size : panelSizeFromCircuits(topology.circuits); + // A panel that does not know its size still loads when it reports its positions. + if (!positionRange(topology, panelSize)) { throw new Error(t("card.topology_error")); } @@ -59,7 +61,7 @@ export async function discoverTopology(hass: HomeAssistant, deviceId: string | u function panelSizeFromCircuits(circuits: Record): number { let maxTab = 0; for (const circuit of Object.values(circuits)) { - if (!circuit) continue; + if (!circuit || measuresOutsidePanel(circuit)) continue; for (const tab of circuit.tabs) { if (tab > maxTab) maxTab = tab; } @@ -196,7 +198,7 @@ export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: st let panelSize = 0; for (const ent of allEntities) { const state = hass.states[ent.entity_id]; - if (state && typeof state.attributes.panel_size === "number") { + if (state && isPositiveInteger(state.attributes.panel_size)) { panelSize = state.attributes.panel_size; break; } diff --git a/src/card/card-styles.ts b/src/card/card-styles.ts index 02b1ae9..f5813b9 100644 --- a/src/card/card-styles.ts +++ b/src/card/card-styles.ts @@ -524,6 +524,40 @@ export const CARD_STYLES: string = ` border-bottom: 1px solid var(--divider-color, #333); } + /* Meters outside the panel: no breaker space, so a strip above the grid. */ + .meters-strip { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--divider-color, #333); + } + .meters-title { flex-basis: 100%; font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); } + .meter-tile { + flex: 1 1 220px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + background: var(--secondary-background-color, var(--card-background-color, #2a2a2a)); + border: 1px solid var(--divider-color, #333); + border-radius: 12px; + padding: 12px 16px; + } + .meter-name { + font-size: 0.9em; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--primary-text-color, #e0e0e0); + } + .meter-readings { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 0.85em; color: var(--secondary-text-color, #999); } + .meter-value { font-size: 1.05em; color: var(--primary-text-color, #fff); white-space: nowrap; } + .meter-value strong { font-weight: 700; font-size: 1.1em; } + .meter-direction:empty { display: none; } + .sub-device { background: var(--secondary-background-color, var(--card-background-color, #2a2a2a)); border: 1px solid var(--divider-color, #333); diff --git a/src/card/span-panel-card.ts b/src/card/span-panel-card.ts index 6fb13cf..9d07b1e 100644 --- a/src/card/span-panel-card.ts +++ b/src/card/span-panel-card.ts @@ -6,6 +6,7 @@ import { escapeHtml } from "../helpers/sanitize.js"; import { loadListColumns } from "../helpers/list-columns.js"; import { buildHeaderHTML } from "../core/header-renderer.js"; import { buildGridHTML } from "../core/grid-renderer.js"; +import { buildMetersStripHTML, metersStripCardSize } from "../core/meters-strip.js"; import { buildSubDevicesHTML } from "../core/sub-device-renderer.js"; import { buildMonitoringSummaryHTML } from "../core/monitoring-status.js"; import { DashboardController } from "../core/dashboard-controller.js"; @@ -19,6 +20,7 @@ import { discoverTopology, discoverEntitiesFallback, panelConfigEntryId } from " import { RetryManager } from "../core/retry-manager.js"; import { errorText } from "../helpers/error-text.js"; import { deepEqual } from "../helpers/deep-equal.js"; +import { positionRange, positionRowCount, type PositionRange } from "../helpers/layout.js"; import { sameTopologyStructure } from "../core/topology-structure.js"; import { CARD_STYLES } from "./card-styles.js"; import "../core/side-panel.js"; @@ -73,7 +75,8 @@ export class SpanPanelCard extends LitElement { @state() private _activeTab: "panel" | "activity" | "area" = "panel"; private _panelDevice: PanelDevice | null = null; - private _panelSize = 0; + /** The breaker positions the grid draws; null until a topology places one. */ + private _positions: PositionRange | null = null; private _historyLoaded = false; private readonly _ctrl = new DashboardController(); private readonly _listCtrl = new ListViewController(this._ctrl); @@ -146,7 +149,7 @@ export class SpanPanelCard extends LitElement { this._topology = null; this._topologySource = null; this._panelDevice = null; - this._panelSize = 0; + this._positions = null; this._activeTab = "panel"; this._ctrl.reset(); this._ctrl.setConfig(config); @@ -154,7 +157,8 @@ export class SpanPanelCard extends LitElement { } getCardSize(): number { - return Math.ceil(this._panelSize / 2) + 3; + const meters = this._topology ? metersStripCardSize(this._topology, this._config) : 0; + return (this._positions ? positionRowCount(this._positions) : 0) + 3 + meters; } static getConfigElement(): HTMLElement { @@ -276,7 +280,7 @@ export class SpanPanelCard extends LitElement { private _adoptTopology(result: DiscoveryResult): void { this._topology = result.topology; this._panelDevice = result.panelDevice; - this._panelSize = result.panelSize; + this._positions = positionRange(result.topology, result.panelSize); } /** @@ -424,7 +428,7 @@ export class SpanPanelCard extends LitElement { private _populateCardContent(): void { const container = this._root.querySelector("#card-content"); - if (!container || !this.hass || !this._topology || !this._panelSize) return; + if (!container || !this.hass || !this._topology || !this._positions) return; // Populate tab bar const tabsContainer = this._root.querySelector("#card-tabs"); @@ -454,17 +458,19 @@ export class SpanPanelCard extends LitElement { } if (this._activeTab === "panel") { - const totalRows = Math.ceil(this._panelSize / 2); + const totalRows = positionRowCount(this._positions); const headerHTML = buildHeaderHTML(this._topology, this._config); const monitoringStatus = this._ctrl.monitoringCache.status; const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus); - const gridHTML = buildGridHTML(this._topology, totalRows, this.hass, this._config, monitoringStatus); + const gridHTML = buildGridHTML(this._topology, this._positions, this.hass, this._config, monitoringStatus); const subDevHTML = buildSubDevicesHTML(this._topology, this.hass, this._config, { showFavorites: this._ctrl.showFavorites }); + const metersHTML = buildMetersStripHTML(this._topology, this.hass, this._config); container.innerHTML = ` ${headerHTML} ${monitoringSummaryHTML} ${subDevHTML ? `
${subDevHTML}
` : ""} + ${metersHTML} ${this._config.show_panel !== false ? `
${gridHTML}
` : ""} `; diff --git a/src/core/circuit-state.ts b/src/core/circuit-state.ts index 6f3a81a..96793db 100644 --- a/src/core/circuit-state.ts +++ b/src/core/circuit-state.ts @@ -1,4 +1,5 @@ import { DEVICE_TYPE_PV, RELAY_STATE_CLOSED, SELECTABLE_PRIORITY_KEYS } from "../constants.js"; +import { measuresOutsidePanel } from "../helpers/layout.js"; import { readNumber } from "../helpers/read-number.js"; import { isAlertActive } from "./monitoring-status.js"; import type { Circuit, HomeAssistant, MonitoringPointInfo } from "../types.js"; @@ -17,6 +18,14 @@ export function getCircuitStateClasses(_circuit: Circuit, monitoringInfo: Monito return classes.join(" "); } +/** + * Whether a circuit is drawn and sorted as on: its relay is closed, or it is a + * meter outside the panel, which has no relay to open. + */ +export function drawsAsOn(c: CircuitControlFields & Pick, hass: HomeAssistant): boolean { + return measuresOutsidePanel(c) || relayClosed(c, hass); +} + /** A circuit's power reading in watts, or null where its sensor has none. */ export function circuitPowerW(c: Pick, hass: HomeAssistant): number | null { const entityId = c.entities?.power; diff --git a/src/core/dashboard-controller.ts b/src/core/dashboard-controller.ts index f0f2322..035d121 100644 --- a/src/core/dashboard-controller.ts +++ b/src/core/dashboard-controller.ts @@ -5,6 +5,7 @@ import { readNumber } from "../helpers/read-number.js"; import { subDeviceCharts } from "../helpers/sub-device-power.js"; import { loadHistory, collectSubDeviceEntityIds } from "./history-loader.js"; import { updateCircuitDOM, updateSubDeviceDOM } from "./dom-updater.js"; +import { updateMetersDOM } from "./meters-strip.js"; import { getEffectiveHorizon, getEffectiveSubDeviceHorizon } from "./graph-settings.js"; import { MonitoringStatusCache, MonitoringStatusMultiCache, mergeMonitoringStatuses } from "./monitoring-status.js"; import { GraphSettingsCache } from "./graph-settings.js"; @@ -376,6 +377,7 @@ export class DashboardController { if (!this._hass || !this._topology || !this._config) return; updateCircuitDOM(root, this._hass, this._topology, this._config, this.powerHistory, this.horizonMap); updateSubDeviceDOM(root, this._hass, this._topology, this._config, this.powerHistory, this.subDeviceHorizonMap); + updateMetersDOM(root, this._hass, this._topology, this._config); } async onGraphSettingsChanged(root: DOMRoot): Promise { diff --git a/src/core/dom-updater.ts b/src/core/dom-updater.ts index 8e07ad9..a6c2b34 100644 --- a/src/core/dom-updater.ts +++ b/src/core/dom-updater.ts @@ -13,7 +13,8 @@ import { resolveSubDevicePower, stateWatts } from "../helpers/sub-device-power.j import { getHistoryDurationMs, getHorizonDurationMs } from "../helpers/history.js"; import { updateChart } from "../chart/chart-update.js"; import { attrSelectorValue } from "../helpers/selector.js"; -import { circuitCurrentA, circuitPowerW, readsAsProducer, relayClosed, shedPriorityKey, switchPresence } from "./circuit-state.js"; +import { measuresOutsidePanel } from "../helpers/layout.js"; +import { circuitCurrentA, circuitPowerW, drawsAsOn, readsAsProducer, shedPriorityKey, switchPresence } from "./circuit-state.js"; import { applySheddingIcon, applyTogglePill } from "./circuit-controls.js"; import type { HomeAssistant, PanelTopology, CardConfig, HistoryMap, ChartMetricDef } from "../types.js"; @@ -151,9 +152,10 @@ export function updateCircuitDOM( const defaultDurationMs = getHistoryDurationMs(config); // The circuits' consumption: every non-solar circuit with a power sensor, an unknown reading skipped. + // A meter outside the panel is no load on it, so it is no term at all. const consumption: (number | null)[] = []; for (const circuit of Object.values(topology.circuits)) { - if (!circuit.entities?.power || circuit.device_type === DEVICE_TYPE_PV) continue; + if (!circuit.entities?.power || circuit.device_type === DEVICE_TYPE_PV || measuresOutsidePanel(circuit)) continue; const power = circuitPowerW(circuit, hass); consumption.push(power === null ? null : Math.abs(power)); } @@ -170,7 +172,8 @@ export function updateCircuitDOM( const powerW = circuitPowerW(circuit, hass); const isProducer = readsAsProducer(circuit, powerW); - const isOn = relayClosed(circuit, hass); + // A meter outside the panel has no relay, so its list chart is never dimmed as off. + const isOn = drawsAsOn(circuit, hass); const powerVal = slot.querySelector(".power-value"); if (powerVal) { diff --git a/src/core/grid-renderer.ts b/src/core/grid-renderer.ts index fb596fc..942821d 100644 --- a/src/core/grid-renderer.ts +++ b/src/core/grid-renderer.ts @@ -1,7 +1,7 @@ import { escapeHtml } from "../helpers/sanitize.js"; import { formatCircuitCurrentHTML, formatCircuitPowerHTML } from "../helpers/format.js"; import { t } from "../i18n.js"; -import { tabToRow, tabToCol, classifyDualTab } from "../helpers/layout.js"; +import { tabToRow, tabToCol, classifyDualTab, measuresOutsidePanel, type PositionRange } from "../helpers/layout.js"; import { getChartMetric } from "../helpers/chart.js"; import { getCircuitMonitoringInfo } from "./monitoring-status.js"; import { @@ -26,11 +26,12 @@ interface TabMapEntry { } /** - * Build the full grid HTML for the panel breaker grid. + * Build the full grid HTML for the panel breaker grid: one row per two positions + * across `positions`, labelled with the positions' own numbers. */ export function buildGridHTML( topology: PanelTopology, - totalRows: number, + positions: PositionRange, hass: HomeAssistant, config: CardConfig, monitoringStatus: MonitoringStatus | null @@ -39,6 +40,8 @@ export function buildGridHTML( const occupiedTabs = new Set(); for (const [uuid, circuit] of Object.entries(topology.circuits)) { + // A meter outside the panel is drawn in the Meters strip, never in a breaker space. + if (measuresOutsidePanel(circuit)) continue; const tabs = circuit.tabs; if (!tabs || tabs.length === 0) continue; const primaryTab = Math.min(...tabs); @@ -71,41 +74,49 @@ export function buildGridHTML( return { monInfo, sheddingPriority }; } + // A row the range starts or ends inside draws only its in-range side. + const inRange = (tab: number): boolean => tab >= positions.first && tab <= positions.last; + const firstRow = tabToRow(positions.first); + const lastRow = tabToRow(positions.last); + let gridHTML = ""; - for (let row = 1; row <= totalRows; row++) { + for (let row = firstRow; row <= lastRow; row++) { + const gridRow = row - firstRow + 1; const leftTab = row * 2 - 1; const rightTab = row * 2; const leftEntry = tabMap.get(leftTab); const rightEntry = tabMap.get(rightTab); + const leftLabel = inRange(leftTab) ? `
${leftTab}
` : ""; + const rightLabel = inRange(rightTab) ? `
${rightTab}
` : ""; - gridHTML += `
${leftTab}
`; + gridHTML += leftLabel; if (leftEntry && leftEntry.layout === "row-span") { const { monInfo, sheddingPriority } = lookupMonitoring(leftEntry); - gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2 / 5", "row-span", hass, config, monInfo, sheddingPriority); - gridHTML += `
${rightTab}
`; + gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, gridRow, "2 / 5", "row-span", hass, config, monInfo, sheddingPriority); + gridHTML += rightLabel; continue; } if (!rowsToSkipLeft.has(row)) { if (leftEntry && (leftEntry.layout === "col-span" || leftEntry.layout === "single")) { const { monInfo, sheddingPriority } = lookupMonitoring(leftEntry); - gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2", leftEntry.layout, hass, config, monInfo, sheddingPriority); - } else if (!occupiedTabs.has(leftTab)) { - gridHTML += renderEmptySlot(row, "2"); + gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, gridRow, "2", leftEntry.layout, hass, config, monInfo, sheddingPriority); + } else if (!occupiedTabs.has(leftTab) && inRange(leftTab)) { + gridHTML += renderEmptySlot(gridRow, "2"); } } if (!rowsToSkipRight.has(row)) { if (rightEntry && (rightEntry.layout === "col-span" || rightEntry.layout === "single")) { const { monInfo, sheddingPriority } = lookupMonitoring(rightEntry); - gridHTML += renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, row, "4", rightEntry.layout, hass, config, monInfo, sheddingPriority); - } else if (!occupiedTabs.has(rightTab)) { - gridHTML += renderEmptySlot(row, "4"); + gridHTML += renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, gridRow, "4", rightEntry.layout, hass, config, monInfo, sheddingPriority); + } else if (!occupiedTabs.has(rightTab) && inRange(rightTab)) { + gridHTML += renderEmptySlot(gridRow, "4"); } } - gridHTML += `
${rightTab}
`; + gridHTML += rightLabel; } return gridHTML; } diff --git a/src/core/list-renderer.ts b/src/core/list-renderer.ts index b69efd5..9e28251 100644 --- a/src/core/list-renderer.ts +++ b/src/core/list-renderer.ts @@ -2,15 +2,8 @@ import { escapeHtml } from "../helpers/sanitize.js"; import { formatCircuitCurrentHTML, formatCircuitPowerHTML } from "../helpers/format.js"; import { t } from "../i18n.js"; import { getChartMetric } from "../helpers/chart.js"; -import { - circuitCurrentA, - circuitPowerW, - circuitUtilizationPct, - getCircuitStateClasses, - readsAsProducer, - relayClosed, - switchPresence, -} from "./circuit-state.js"; +import { measuresOutsidePanel } from "../helpers/layout.js"; +import { circuitCurrentA, circuitPowerW, circuitUtilizationPct, drawsAsOn, getCircuitStateClasses, readsAsProducer, switchPresence } from "./circuit-state.js"; import { buildSheddingIconHTML, buildTogglePillHTML } from "./circuit-controls.js"; import type { Circuit, HomeAssistant, CardConfig, MonitoringPointInfo } from "../types.js"; @@ -55,7 +48,7 @@ export function buildListRowHTML( sheddingPriority: string, isExpanded: boolean ): string { - const isOn = relayClosed(circuit, hass); + const isOn = drawsAsOn(circuit, hass); const presence = switchPresence(circuit, hass); const breakerAmps = circuit.breaker_rating_a; @@ -92,8 +85,10 @@ export function buildListRowHTML( // A switch -- operable, or dimmed and inert while unavailable -- gets the pill, // armed by the header's slide-confirm; a circuit with none keeps a static badge. - const statusControl = - presence === "none" + // A meter outside the panel has no relay to be on or off, so it gets neither. + const statusControl = measuresOutsidePanel(circuit) + ? "" + : presence === "none" ? `${isOn ? "ON" : "OFF"}` : buildTogglePillHTML(isOn, presence); @@ -132,7 +127,7 @@ export function buildExpandedChartHTML( ): string { const isProducer = readsAsProducer(circuit, circuitPowerW(circuit, hass)); - const isOn = relayClosed(circuit, hass); + const isOn = drawsAsOn(circuit, hass); const stateClasses = getCircuitStateClasses(circuit, monitoringInfo, isOn, isProducer); const safeUuid = escapeHtml(uuid); diff --git a/src/core/list-view-controller.ts b/src/core/list-view-controller.ts index 8cc91e9..33316a0 100644 --- a/src/core/list-view-controller.ts +++ b/src/core/list-view-controller.ts @@ -4,7 +4,7 @@ import { formatCircuitCurrentHTML, formatCircuitPowerHTML } from "../helpers/for import { getChartMetric } from "../helpers/chart.js"; import { t } from "../i18n.js"; import { getCircuitMonitoringInfo } from "./monitoring-status.js"; -import { circuitCurrentA, circuitPowerW, relayClosed, shedPriorityKey, switchPresence } from "./circuit-state.js"; +import { circuitCurrentA, circuitPowerW, drawsAsOn, shedPriorityKey, switchPresence } from "./circuit-state.js"; import { applySheddingIcon, applyTogglePill } from "./circuit-controls.js"; import { buildSearchBarHTML, buildListRowHTML, buildExpandedChartHTML, buildAreaHeaderHTML } from "./list-renderer.js"; import { observeFold } from "./truncation-fold.js"; @@ -24,7 +24,7 @@ interface CircuitSortInfo { } function getCircuitSortInfo(circuit: Circuit, hass: HomeAssistant, config: CardConfig): CircuitSortInfo { - const isOn = relayClosed(circuit, hass); + const isOn = drawsAsOn(circuit, hass); const isCurrentMode = (config.chart_metric || "power") === "current"; const value = isCurrentMode ? circuitCurrentA(circuit, hass) : circuitPowerW(circuit, hass); return { isOn, value }; diff --git a/src/core/meters-strip.ts b/src/core/meters-strip.ts new file mode 100644 index 0000000..737b565 --- /dev/null +++ b/src/core/meters-strip.ts @@ -0,0 +1,82 @@ +import { escapeHtml } from "../helpers/sanitize.js"; +import { attrSelectorValue } from "../helpers/selector.js"; +import { formatCircuitCurrentHTML, formatCircuitPowerHTML, formatEnergyReading } from "../helpers/format.js"; +import { getChartMetric } from "../helpers/chart.js"; +import { t } from "../i18n.js"; +import { measuresOutsidePanel } from "../helpers/layout.js"; +import { circuitCurrentA, circuitPowerW } from "./circuit-state.js"; +import type { CardConfig, Circuit, HomeAssistant, PanelTopology } from "../types.js"; + +/** The rows the strip takes in the card's size, one for its title and one for its tiles. */ +const METERS_STRIP_CARD_SIZE = 2; + +/** The topology's meters outside the panel, in topology order. */ +function metersOf(topology: PanelTopology): [string, Circuit][] { + return Object.entries(topology.circuits).filter(([, circuit]) => measuresOutsidePanel(circuit)); +} + +/** Whether the strip is drawn: there is a meter, and the panel is shown, as the grid is. */ +function stripShown(topology: PanelTopology, config: CardConfig): boolean { + return config.show_panel !== false && metersOf(topology).length > 0; +} + +/** What the strip adds to the card's size: nothing when it is not drawn. */ +export function metersStripCardSize(topology: PanelTopology, config: CardConfig): number { + return stripShown(topology, config) ? METERS_STRIP_CARD_SIZE : 0; +} + +function energyHTML(hass: HomeAssistant, entityId: string | undefined, label: string): string { + const reading = formatEnergyReading(entityId ? hass.states[entityId] : undefined); + return `${escapeHtml(reading)} ${escapeHtml(label)}`; +} + +/** + * A meter's readings: its power in the import-positive sign, or its current in + * amps mode as the grid shows it, with the direction its power states; then + * the energy it has imported and exported. An unknown power states no + * direction, and neither does zero. + */ +function meterReadingsHTML(circuit: Circuit, hass: HomeAssistant, config: CardConfig): string { + const powerW = circuitPowerW(circuit, hass); + const direction = powerW === null || powerW === 0 ? "" : powerW > 0 ? t("meters.importing") : t("meters.exporting"); + const showCurrent = getChartMetric(config).entityRole === "current"; + const valueHTML = showCurrent ? formatCircuitCurrentHTML(circuitCurrentA(circuit, hass)) : formatCircuitPowerHTML(powerW); + return ` + ${valueHTML} + ${escapeHtml(direction)} + ${energyHTML(hass, circuit.entities?.consumed_energy, t("meters.imported"))} + ${energyHTML(hass, circuit.entities?.produced_energy, t("meters.exported"))} + `; +} + +/** + * The strip of meters outside the panel, drawn above the breaker grid; empty + * when there are none or the panel is hidden. A meter has no breaker space, + * relay or rating, so its tile carries no toggle, badge or gear. + */ +export function buildMetersStripHTML(topology: PanelTopology, hass: HomeAssistant, config: CardConfig): string { + if (!stripShown(topology, config)) return ""; + const tiles = metersOf(topology) + .map( + ([uuid, circuit]) => ` +
+ ${escapeHtml(circuit.name || t("grid.unknown"))} + ${meterReadingsHTML(circuit, hass, config)} +
` + ) + .join(""); + return ` +
+
${escapeHtml(t("meters.title"))}
+ ${tiles} +
+ `; +} + +/** Refresh each drawn meter tile's readings in place. */ +export function updateMetersDOM(root: Element | ShadowRoot, hass: HomeAssistant, topology: PanelTopology, config: CardConfig): void { + for (const [uuid, circuit] of metersOf(topology)) { + const readings = root.querySelector(`.meter-tile[data-meter-uuid="${attrSelectorValue(uuid)}"] .meter-readings`); + if (readings) readings.innerHTML = meterReadingsHTML(circuit, hass, config); + } +} diff --git a/src/helpers/format.ts b/src/helpers/format.ts index 5012d90..8c21a44 100644 --- a/src/helpers/format.ts +++ b/src/helpers/format.ts @@ -1,4 +1,6 @@ import { CHART_METRICS } from "../constants.js"; +import { readNumber } from "./read-number.js"; +import type { HassEntity } from "../types.js"; const powerMetric = CHART_METRICS.power!; const currentMetric = CHART_METRICS.current!; @@ -35,6 +37,16 @@ export function formatCircuitCurrentHTML(amps: number | null): string { return `${currentMetric.format(amps)}A`; } +/** An energy reading, Wh shown as kWh, or the unknown mark when there is none. */ +export function formatEnergyReading(state: HassEntity | undefined): string { + const value = readNumber(state); + if (value === null) return UNKNOWN_READING; + const unit = state?.attributes.unit_of_measurement; + if (unit === "Wh") return `${(value / 1000).toFixed(1)} kWh`; + if (unit === "kWh") return `${value.toFixed(1)} kWh`; + return typeof unit === "string" && unit ? `${value} ${unit}` : String(value); +} + export function formatKw(watts: number): string { return (Math.abs(watts) / 1000).toFixed(1); } diff --git a/src/helpers/layout.ts b/src/helpers/layout.ts index 0004bf8..5a61129 100644 --- a/src/helpers/layout.ts +++ b/src/helpers/layout.ts @@ -1,4 +1,58 @@ -import type { DualTabLayout } from "../types.js"; +import type { Circuit, DualTabLayout, PanelTopology } from "../types.js"; + +/** + * Whether a size or position the topology reports can be laid out. A panel that + * does not know its size reports 0, and anything that is not a whole number above + * zero is no size at all. + */ +export function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +/** Whether the circuit is a meter outside the panel, occupying no breaker space, rather than a breaker in it. */ +export function measuresOutsidePanel(c: Pick): boolean { + return c.outside_panel === true; +} + +/** The first and last breaker positions the grid draws, inclusive. */ +export interface PositionRange { + readonly first: number; + readonly last: number; +} + +/** + * The breaker positions the grid draws: the range the panel reports, else 1 to + * `panelSize`, widened to every tab a breaker occupies so that no breaker is + * dropped. A meter outside the panel occupies none. Null when nothing places + * a breaker. + */ +export function positionRange(topology: PanelTopology | null, panelSize: number): PositionRange | null { + if (!topology) return null; + let first: number | null = null; + let last: number | null = null; + const { first_position: reportedFirst, last_position: reportedLast } = topology; + if (isPositiveInteger(reportedFirst) && isPositiveInteger(reportedLast) && reportedFirst <= reportedLast) { + first = reportedFirst; + last = reportedLast; + } else if (isPositiveInteger(panelSize)) { + first = 1; + last = panelSize; + } + for (const circuit of Object.values(topology.circuits)) { + if (!circuit || measuresOutsidePanel(circuit)) continue; + for (const tab of circuit.tabs ?? []) { + if (!isPositiveInteger(tab)) continue; + first = first === null ? tab : Math.min(first, tab); + last = last === null ? tab : Math.max(last, tab); + } + } + return first === null || last === null ? null : { first, last }; +} + +/** The grid rows a range spans: two positions to a row, odd on the left. */ +export function positionRowCount(positions: PositionRange): number { + return tabToRow(positions.last) - tabToRow(positions.first) + 1; +} export function tabToRow(tab: number): number { return Math.ceil(tab / 2); diff --git a/src/i18n.ts b/src/i18n.ts index 29158ab..f9ea2ef 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -186,6 +186,13 @@ const translations: Record> = { "grid.on": "On", "grid.off": "Off", + // Meters outside the panel + "meters.title": "Meters", + "meters.importing": "importing", + "meters.exporting": "exporting", + "meters.imported": "imported", + "meters.exported": "exported", + // Sub-devices "subdevice.ev_charger": "EV Charger", "subdevice.battery": "Battery", @@ -434,6 +441,11 @@ const translations: Record> = { "grid.configure_subdevice": "Configurar dispositivo", "grid.on": "Enc", "grid.off": "Apag", + "meters.title": "Medidores", + "meters.importing": "importando", + "meters.exporting": "exportando", + "meters.imported": "importados", + "meters.exported": "exportados", "subdevice.ev_charger": "Cargador EV", "subdevice.battery": "Bater\u00eda", "subdevice.fallback": "Sub-dispositivo", @@ -670,6 +682,11 @@ const translations: Record> = { "grid.configure_subdevice": "Configurer l'appareil", "grid.on": "On", "grid.off": "Off", + "meters.title": "Compteurs", + "meters.importing": "en import", + "meters.exporting": "en export", + "meters.imported": "import\u00e9s", + "meters.exported": "export\u00e9s", "subdevice.ev_charger": "Chargeur VE", "subdevice.battery": "Batterie", "subdevice.fallback": "Sous-appareil", @@ -916,6 +933,11 @@ const translations: Record> = { "grid.configure_subdevice": "\u30c7\u30d0\u30a4\u30b9\u3092\u8a2d\u5b9a", "grid.on": "\u30aa\u30f3", "grid.off": "\u30aa\u30d5", + "meters.title": "\u30e1\u30fc\u30bf\u30fc", + "meters.importing": "\u53d7\u96fb\u4e2d", + "meters.exporting": "\u9001\u96fb\u4e2d", + "meters.imported": "\u53d7\u96fb", + "meters.exported": "\u9001\u96fb", "subdevice.ev_charger": "EV\u5145\u96fb\u5668", "subdevice.battery": "\u30d0\u30c3\u30c6\u30ea\u30fc", "subdevice.fallback": "\u30b5\u30d6\u30c7\u30d0\u30a4\u30b9", @@ -1155,6 +1177,11 @@ const translations: Record> = { "grid.configure_subdevice": "Configurar dispositivo", "grid.on": "Lig", "grid.off": "Des", + "meters.title": "Medidores", + "meters.importing": "importando", + "meters.exporting": "exportando", + "meters.imported": "importados", + "meters.exported": "exportados", "subdevice.ev_charger": "Carregador VE", "subdevice.battery": "Bateria", "subdevice.fallback": "Sub-dispositivo", diff --git a/src/panel/tab-dashboard.ts b/src/panel/tab-dashboard.ts index 3205d75..9ce33f1 100644 --- a/src/panel/tab-dashboard.ts +++ b/src/panel/tab-dashboard.ts @@ -1,8 +1,11 @@ import { discoverTopology } from "../card/card-discovery.js"; import { escapeHtml } from "../helpers/sanitize.js"; import { errorText } from "../helpers/error-text.js"; +import { positionRange, positionRowCount, type PositionRange } from "../helpers/layout.js"; +import { t } from "../i18n.js"; import { buildHeaderHTML } from "../core/header-renderer.js"; import { buildGridHTML } from "../core/grid-renderer.js"; +import { buildMetersStripHTML } from "../core/meters-strip.js"; import { buildSubDevicesHTML } from "../core/sub-device-renderer.js"; import { buildMonitoringSummaryHTML } from "../core/monitoring-status.js"; import { DashboardController } from "../core/dashboard-controller.js"; @@ -67,11 +70,12 @@ export class DashboardTab { this._container = container; this._ctrl.hass = hass; - let topology, panelSize; + let topology, positions: PositionRange | null; try { const result = await discoverTopology(hass, deviceId); topology = result.topology; - panelSize = result.panelSize; + positions = positionRange(topology, result.panelSize); + if (!positions) throw new Error(t("card.topology_error")); } catch (err) { container.innerHTML = `

${escapeHtml(errorText(err))}

`; return null; @@ -81,19 +85,21 @@ export class DashboardTab { await this._ctrl.monitoringCache.fetch(hass, configEntryId ?? null); await this._ctrl.fetchAndBuildHorizonMaps(); - const totalRows = Math.ceil(panelSize / 2); + const totalRows = positionRowCount(positions); const monitoringStatus = this._ctrl.monitoringCache.status; const headerHTML = buildHeaderHTML(topology!, config); const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus); - const gridHTML = buildGridHTML(topology!, totalRows, hass, config, monitoringStatus); + const gridHTML = buildGridHTML(topology!, positions, hass, config, monitoringStatus); const subDevHTML = buildSubDevicesHTML(topology!, hass, config, { showFavorites: this._ctrl.showFavorites }); + const metersHTML = buildMetersStripHTML(topology!, hass, config); container.innerHTML = ` ${headerHTML} ${monitoringSummaryHTML} ${subDevHTML ? `
${subDevHTML}
` : ""} + ${metersHTML} ${ config.show_panel !== false ? ` diff --git a/src/types.ts b/src/types.ts index 670d899..5361898 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,11 @@ export interface Circuit { always_on?: boolean; voltage?: number; area?: string; + /** + * A meter outside the panel: it occupies no breaker space and has no relay. Its + * power is import-positive, positive while power flows into the panel. + */ + outside_panel?: boolean; } /** @@ -102,6 +107,12 @@ export interface PanelTopology { serial?: string; firmware?: string; panel_size?: number; + /** + * The panel's first and last breaker positions, which need not start at 1. Null + * where the panel reports none; absent from an integration that predates them. + */ + first_position?: number | null; + last_position?: number | null; /** The device id the request carried, echoed as sent. */ device_id?: string; /** diff --git a/tests/card-discovery-fallback.test.ts b/tests/card-discovery-fallback.test.ts index 0bebc3d..b7f0d6a 100644 --- a/tests/card-discovery-fallback.test.ts +++ b/tests/card-discovery-fallback.test.ts @@ -38,8 +38,9 @@ function sensorsFor(serial: string): Sensor[] { /** * A registry holding the panel and its circuits' sensors, whose unique ids carry * `uidSerial`. The panel device names `deviceSerial`, or no serial when it is null. + * The site power sensor carries `siteAttributes`. */ -function hassWithRegistry(uidSerial: string = SERIAL, deviceSerial: string | null = uidSerial): HomeAssistant { +function hassWithRegistry(uidSerial: string = SERIAL, deviceSerial: string | null = uidSerial, siteAttributes: Record = {}): HomeAssistant { const sensors = sensorsFor(uidSerial); const sitePower = { entity_id: "sensor.span_panel_site_power", unique_id: `span_${uidSerial.toLowerCase()}_site_power` }; const callWS = vi.fn(async (msg: { type: string }) => { @@ -59,7 +60,7 @@ function hassWithRegistry(uidSerial: string = SERIAL, deviceSerial: string | nul s.entity_id, { entity_id: s.entity_id, state: "1", attributes: { tabs: s.tabs, friendly_name: s.friendly_name }, last_changed: "", last_updated: "" }, ]), - [sitePower.entity_id, { entity_id: sitePower.entity_id, state: "1", attributes: {}, last_changed: "", last_updated: "" }], + [sitePower.entity_id, { entity_id: sitePower.entity_id, state: "1", attributes: siteAttributes, last_changed: "", last_updated: "" }], ]); return { callWS, states, services: {}, language: "en" } as unknown as HomeAssistant; } @@ -98,3 +99,15 @@ describe("discoverEntitiesFallback", () => { expect(Object.keys(topology!.circuits).sort()).toEqual(CIRCUIT_IDS); }); }); + +describe("the panel size the fallback reads", () => { + it("is the panel_size attribute when it is a positive integer", async () => { + const { panelSize } = await discoverEntitiesFallback(hassWithRegistry(SERIAL, SERIAL, { panel_size: 32 }), PANEL_ID); + expect(panelSize).toBe(32); + }); + + it.each([0, -2, 12.5, "32"])("falls back to the circuits when the attribute is %s", async size => { + const { panelSize } = await discoverEntitiesFallback(hassWithRegistry(SERIAL, SERIAL, { panel_size: size }), PANEL_ID); + expect(panelSize).toBe(14); + }); +}); diff --git a/tests/grid-renderer.test.ts b/tests/grid-renderer.test.ts index 9626656..b77d7ae 100644 --- a/tests/grid-renderer.test.ts +++ b/tests/grid-renderer.test.ts @@ -66,7 +66,7 @@ function hassWith(states: Record[0]): HTMLElement { const topology = { circuits: { kitchen: circuit } } as unknown as PanelTopology; const div = document.createElement("div"); - div.innerHTML = buildGridHTML(topology, 1, hassWith(states), config, null); + div.innerHTML = buildGridHTML(topology, { first: 1, last: 2 }, hassWith(states), config, null); return div; } diff --git a/tests/meters-strip.test.ts b/tests/meters-strip.test.ts new file mode 100644 index 0000000..697281c --- /dev/null +++ b/tests/meters-strip.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { buildGridHTML } from "../src/core/grid-renderer.js"; +import { buildMetersStripHTML, updateMetersDOM } from "../src/core/meters-strip.js"; +import { buildPanelStatsHTML } from "../src/core/header-renderer.js"; +import { updateCircuitDOM } from "../src/core/dom-updater.js"; +import { buildExpandedChartHTML, buildListRowHTML } from "../src/core/list-renderer.js"; +import { DashboardController } from "../src/core/dashboard-controller.js"; +import { ListViewController } from "../src/core/list-view-controller.js"; +import { positionRange } from "../src/helpers/layout.js"; +import { discoverTopology } from "../src/card/card-discovery.js"; +import "../src/card/span-panel-card.js"; +import { FakeConnection, hassWith as hassOn } from "./fake-connection.js"; +import type { CardConfig, Circuit, HomeAssistant, PanelTopology } from "../src/types.js"; + +const CONFIG = { chart_metric: "power" } as CardConfig; + +/** A meter outside the panel, as topology reports one: no breaker space, no switch, no select, no rating. */ +function meter(slug: string, name: string | null = "Meter A"): Circuit { + return { + name, + tabs: [], + outside_panel: true, + entities: { + power: `sensor.${slug}_power`, + consumed_energy: `sensor.${slug}_consumed_energy`, + produced_energy: `sensor.${slug}_produced_energy`, + current: `sensor.${slug}_current`, + }, + is_user_controllable: false, + breaker_rating_a: null, + relay_state: "UNKNOWN", + } as unknown as Circuit; +} + +function hosted(slug: string, tab: number, relay = "CLOSED"): Circuit { + return { name: slug, tabs: [tab], outside_panel: false, entities: { power: `sensor.${slug}_power` }, relay_state: relay } as Circuit; +} + +function topologyOf(circuits: Record): PanelTopology { + return { circuits, panel_size: 32 } as PanelTopology; +} + +type States = Record }>; + +function hassWith(states: States): HomeAssistant { + const full = Object.fromEntries( + Object.entries(states).map(([entity_id, s]) => { + const { state, attributes } = typeof s === "string" ? { state: s, attributes: {} } : s; + return [entity_id, { entity_id, state, attributes, last_changed: "", last_updated: "" }]; + }) + ); + return { states: full, services: {}, language: "en" } as unknown as HomeAssistant; +} + +function wh(state: string): { state: string; attributes: Record } { + return { state, attributes: { unit_of_measurement: "Wh" } }; +} + +function strip(topology: PanelTopology, hass: HomeAssistant, config: CardConfig = CONFIG): HTMLElement { + const div = document.createElement("div"); + div.innerHTML = buildMetersStripHTML(topology, hass, config); + return div; +} + +function text(el: Element | null): string { + return (el?.textContent ?? "").replace(/\s+/g, " ").trim(); +} + +const IMPORTING = hassWith({ + "sensor.meter_a_power": "850", + "sensor.meter_a_consumed_energy": wh("52340"), + "sensor.meter_a_produced_energy": wh("812"), +}); + +describe("a meter outside the panel on the grid", () => { + it("is never a grid slot", () => { + const topology = topologyOf({ kitchen: hosted("kitchen", 1), "meter-a": meter("meter_a") }); + const div = document.createElement("div"); + div.innerHTML = buildGridHTML(topology, positionRange(topology, 32)!, IMPORTING, CONFIG, null); + expect(div.querySelector('[data-uuid="kitchen"]')).not.toBeNull(); + expect(div.querySelector('[data-uuid="meter-a"]')).toBeNull(); + }); + + it("is never a grid slot, even when it names a tab", () => { + const topology = topologyOf({ "meter-a": { ...meter("meter_a"), tabs: [3] } }); + const div = document.createElement("div"); + div.innerHTML = buildGridHTML(topology, { first: 1, last: 32 }, IMPORTING, CONFIG, null); + expect(div.querySelector('[data-uuid="meter-a"]')).toBeNull(); + }); +}); + +describe("the breaker positions", () => { + // A meter outside the panel occupies no breaker space, even if its row names a tab. + const WITH_TABBED_METER = { kitchen: hosted("kitchen", 9), "meter-a": { ...meter("meter_a"), tabs: [50] } }; + + it("are never widened by a meter outside the panel", () => { + const topology = { circuits: { ...WITH_TABBED_METER, office: hosted("office", 24) }, first_position: 9, last_position: 24 } as PanelTopology; + expect(positionRange(topology, 16)).toEqual({ first: 9, last: 24 }); + }); + + it("never size the panel from a meter outside it", async () => { + const circuits = { ...WITH_TABBED_METER, office: hosted("office", 14) }; + const callWS = vi.fn(async (msg: { type: string }) => (msg.type === "span_panel/panel_topology" ? { circuits } : [])); + const { panelSize } = await discoverTopology({ callWS } as unknown as HomeAssistant, "panel-1"); + expect(panelSize).toBe(14); + }); +}); + +describe("the Meters strip", () => { + it("draws nothing when no meter is outside the panel", () => { + expect(buildMetersStripHTML(topologyOf({ kitchen: hosted("kitchen", 1) }), IMPORTING, CONFIG)).toBe(""); + }); + + it("draws nothing while the panel is hidden, as the grid", () => { + expect(buildMetersStripHTML(topologyOf({ "meter-a": meter("meter_a") }), IMPORTING, { ...CONFIG, show_panel: false })).toBe(""); + }); + + it("shows current in amps mode, as the grid does, with the direction its power states", () => { + const hass = hassWith({ "sensor.meter_a_power": "850", "sensor.meter_a_current": "3.4" }); + const el = strip(topologyOf({ "meter-a": meter("meter_a") }), hass, { chart_metric: "current" }); + expect(text(el.querySelector(".meter-value"))).toBe("3.4A"); + expect(text(el.querySelector(".meter-direction"))).toBe("importing"); + }); + + it("reads an unknown current in amps mode as unknown, never 0", () => { + const hass = hassWith({ "sensor.meter_a_power": "850", "sensor.meter_a_current": "unavailable" }); + expect(text(strip(topologyOf({ "meter-a": meter("meter_a") }), hass, { chart_metric: "current" }).querySelector(".meter-value"))).toBe("--"); + }); + + it("shows each meter's name, power with its direction, and energy", () => { + const el = strip(topologyOf({ kitchen: hosted("kitchen", 1), "meter-a": meter("meter_a") }), IMPORTING); + expect(text(el.querySelector(".meters-title"))).toBe("Meters"); + const tiles = el.querySelectorAll(".meter-tile"); + expect(tiles).toHaveLength(1); + const tile = tiles[0]!; + expect(tile.getAttribute("data-meter-uuid")).toBe("meter-a"); + expect(text(tile.querySelector(".meter-name"))).toBe("Meter A"); + expect(text(tile.querySelector(".meter-value"))).toBe("850W"); + expect(text(tile.querySelector(".meter-direction"))).toBe("importing"); + expect(text(tile.querySelector(".meter-imported"))).toBe("52.3 kWh imported"); + expect(text(tile.querySelector(".meter-exported"))).toBe("0.8 kWh exported"); + }); + + it("keeps the import-positive sign: negative power is exporting", () => { + const el = strip(topologyOf({ "meter-a": meter("meter_a") }), hassWith({ "sensor.meter_a_power": "-340" })); + expect(text(el.querySelector(".meter-value"))).toBe("-340W"); + expect(text(el.querySelector(".meter-direction"))).toBe("exporting"); + }); + + it("states no direction at zero", () => { + const el = strip(topologyOf({ "meter-a": meter("meter_a") }), hassWith({ "sensor.meter_a_power": "0" })); + expect(text(el.querySelector(".meter-value"))).toBe("0W"); + expect(text(el.querySelector(".meter-direction"))).toBe(""); + }); + + it("reads unknown power and energy as unknown, never 0, with no direction", () => { + const el = strip( + topologyOf({ "meter-a": meter("meter_a") }), + hassWith({ "sensor.meter_a_power": "unknown", "sensor.meter_a_consumed_energy": "unavailable" }) + ); + expect(text(el.querySelector(".meter-value"))).toBe("--"); + expect(text(el.querySelector(".meter-direction"))).toBe(""); + expect(text(el.querySelector(".meter-imported"))).toBe("-- imported"); + expect(text(el.querySelector(".meter-exported"))).toBe("-- exported"); + }); + + it("shows energy reported in kWh as it is", () => { + const hass = hassWith({ "sensor.meter_a_consumed_energy": { state: "52.34", attributes: { unit_of_measurement: "kWh" } } }); + expect(text(strip(topologyOf({ "meter-a": meter("meter_a") }), hass).querySelector(".meter-imported"))).toBe("52.3 kWh imported"); + }); + + it("draws no toggle pill, no breaker rating and no gear", () => { + const el = strip(topologyOf({ "meter-a": meter("meter_a") }), IMPORTING); + expect(el.querySelector(".toggle-pill")).toBeNull(); + expect(el.querySelector(".breaker-badge")).toBeNull(); + expect(el.querySelector(".gear-icon")).toBeNull(); + }); + + it("falls back to the unknown label for a meter with no name", () => { + expect(text(strip(topologyOf({ "meter-a": meter("meter_a", null) }), IMPORTING).querySelector(".meter-name"))).toBe("Unknown"); + }); + + it("escapes a meter's name and id", () => { + const el = strip(topologyOf({ 'm"x': meter("meter_a", "bold") }), IMPORTING); + expect(el.querySelector("b")).toBeNull(); + expect(el.querySelector(".meter-tile")!.getAttribute("data-meter-uuid")).toBe('m"x'); + }); + + it("updates its readings in place", () => { + const topology = topologyOf({ "meter-a": meter("meter_a") }); + const el = strip(topology, IMPORTING); + const tile = el.querySelector(".meter-tile"); + + updateMetersDOM(el, hassWith({ "sensor.meter_a_power": "-75", "sensor.meter_a_produced_energy": wh("900") }), topology, CONFIG); + + expect(el.querySelector(".meter-tile")).toBe(tile); + expect(text(el.querySelector(".meter-value"))).toBe("-75W"); + expect(text(el.querySelector(".meter-direction"))).toBe("exporting"); + expect(text(el.querySelector(".meter-imported"))).toBe("-- imported"); + expect(text(el.querySelector(".meter-exported"))).toBe("0.9 kWh exported"); + }); +}); + +describe("the fallback site sum", () => { + it("leaves out a meter outside the panel, measured or not", () => { + // The site sensor has no state, so the Site stat falls back to the circuits' sum. + const topology = { ...topologyOf({ kitchen: hosted("kitchen", 1), "meter-a": meter("meter_a") }), panel_entities: { site_power: "sensor.site_power" } }; + for (const meterPower of ["850", "unknown"]) { + const hass = hassWith({ "sensor.kitchen_power": "300", "sensor.meter_a_power": meterPower }); + const root = document.createElement("div"); + root.innerHTML = buildPanelStatsHTML(topology, CONFIG); + + updateCircuitDOM(root, hass, topology, CONFIG, new Map(), undefined); + + const consumption = root.querySelector(".stat-consumption .stat-value")!; + expect(consumption.textContent).toBe("0.3"); + expect(consumption.classList.contains("stat-partial")).toBe(false); + } + }); +}); + +describe("a meter outside the panel in the list views", () => { + const CIRCUITS = { on: hosted("on", 1), off: hosted("off", 2, "OPEN"), "meter-a": meter("meter_a") }; + const TOPOLOGY = topologyOf(CIRCUITS); + + function listOf(hass: HomeAssistant, order: string[]): HTMLElement { + const root = document.createElement("div"); + const cells = order + .map( + uuid => + `
${buildListRowHTML(uuid, CIRCUITS[uuid as keyof typeof CIRCUITS], hass, CONFIG, null, "unknown", false)}
` + ) + .join(""); + root.innerHTML = `
${cells}
`; + return root; + } + + function order(root: HTMLElement): string[] { + return [...root.querySelectorAll(".list-cell")].map(cell => cell.dataset.cellUuid!); + } + + const HASS = hassWith({ "sensor.on_power": "30", "sensor.off_power": "0", "sensor.meter_a_power": "50" }); + + it("is never sorted as off: it sorts by its reading among the circuits that are on", () => { + const root = listOf(HASS, ["off", "meter-a", "on"]); + new ListViewController(new DashboardController()).updateCollapsedRows(root, HASS, TOPOLOGY, CONFIG); + // Sorted as off, it would follow the circuit that is on, whatever its reading. + expect(order(root)).toEqual(["meter-a", "on", "off"]); + }); + + it("is drawn with its reading, never as off, and with no on/off control", () => { + const root = listOf(HASS, ["meter-a"]); + const row = root.querySelector(".list-row")!; + expect(row.classList.contains("circuit-off")).toBe(false); + expect(row.querySelector(".toggle-pill")).toBeNull(); + expect(row.querySelector(".list-status-badge")).toBeNull(); + expect(text(row.querySelector(".list-power-value"))).toBe("50W"); + + new ListViewController(new DashboardController()).updateCollapsedRows(root, HASS, TOPOLOGY, CONFIG); + expect(row.classList.contains("circuit-off")).toBe(false); + expect(text(row.querySelector(".list-power-value"))).toBe("50W"); + }); + + it("keeps its expanded chart undimmed through a live update", () => { + const root = document.createElement("div"); + root.innerHTML = buildExpandedChartHTML("meter-a", CIRCUITS["meter-a"], HASS, CONFIG, null); + for (const chart of root.querySelectorAll(".chart-container")) chart.remove(); + const slot = root.querySelector(".circuit-slot")!; + expect(slot.classList.contains("circuit-off")).toBe(false); + + updateCircuitDOM(root, HASS, TOPOLOGY, CONFIG, new Map(), undefined); + + expect(slot.classList.contains("circuit-off")).toBe(false); + }); +}); + +describe("the card", () => { + const mounted: HTMLElement[] = []; + afterEach(() => { + for (const el of mounted.splice(0)) el.remove(); + }); + + type Card = HTMLElement & { setConfig(c: CardConfig): void; hass: HomeAssistant; getCardSize(): number }; + + const TOPOLOGY = { circuits: { kitchen: hosted("kitchen", 1), "meter-a": meter("meter_a") }, panel_size: 32 }; + const callWS = (async (msg: Record) => (msg.type === "span_panel/panel_topology" ? TOPOLOGY : [])) as HomeAssistant["callWS"]; + const connection = new FakeConnection(); + + function states(power: string): HomeAssistant["states"] { + return Object.fromEntries( + Object.entries({ "sensor.kitchen_power": "300", "sensor.meter_a_power": power }).map(([entity_id, state]) => [ + entity_id, + { entity_id, state, attributes: {}, last_changed: "", last_updated: "" }, + ]) + ); + } + + function mount(config: CardConfig): Card { + const card = document.createElement("span-panel-card") as Card; + card.setConfig(config); + document.body.appendChild(card); + mounted.push(card); + card.hass = hassOn(connection, { callWS, states: states("850") }); + return card; + } + + it("draws the Meters strip above the grid and keeps it current", async () => { + const card = mount({ device_id: "panel-1" }); + + await vi.waitFor(() => expect(card.shadowRoot!.querySelector(".meters-strip")).not.toBeNull()); + const content = card.shadowRoot!.querySelector("#card-content")!; + const children = [...content.children]; + const stripIndex = children.findIndex(el => el.classList.contains("meters-strip")); + const gridIndex = children.findIndex(el => el.classList.contains("panel-grid")); + expect(stripIndex).toBeGreaterThanOrEqual(0); + expect(stripIndex).toBeLessThan(gridIndex); + expect(content.querySelector('.panel-grid [data-uuid="meter-a"]')).toBeNull(); + + card.hass = hassOn(connection, { callWS, states: states("-60") }); + await vi.waitFor(() => expect(text(content.querySelector(".meter-direction"))).toBe("exporting")); + expect(text(content.querySelector(".meter-value"))).toBe("-60W"); + }); + + it("counts the strip in its size: 16 grid rows, the header's 3 and the strip's 2", async () => { + const card = mount({ device_id: "panel-1" }); + await vi.waitFor(() => expect(card.shadowRoot!.querySelector(".meters-strip")).not.toBeNull()); + expect(card.getCardSize()).toBe(21); + }); + + it("hides the strip with the panel, and does not count it", async () => { + const card = mount({ device_id: "panel-1", show_panel: false }); + await vi.waitFor(() => expect(card.shadowRoot!.querySelector(".panel-stats")).not.toBeNull()); + expect(card.shadowRoot!.querySelector(".meters-strip")).toBeNull(); + expect(card.getCardSize()).toBe(19); + }); +}); diff --git a/tests/positions.test.ts b/tests/positions.test.ts new file mode 100644 index 0000000..0ac3d59 --- /dev/null +++ b/tests/positions.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { CardConfig, Circuit, HomeAssistant, PanelTopology } from "../src/types.js"; +import { discoverTopology } from "../src/card/card-discovery.js"; +import { buildGridHTML } from "../src/core/grid-renderer.js"; +import { positionRange, positionRowCount, type PositionRange } from "../src/helpers/layout.js"; +import { t } from "../src/i18n.js"; +import "../src/card/span-panel-card.js"; +import { FakeConnection, hassWith } from "./fake-connection.js"; + +function circuit(name: string, tabs: number[]): Circuit { + return { name, tabs, entities: {} }; +} + +/** Circuits on tabs 1…14, so the size they imply is 14. */ +const CIRCUITS: Record = { + "c-1": circuit("Kitchen", [1]), + "c-2": circuit("Dryer", [11, 13]), + "c-3": circuit("Office", [14]), +}; + +function hassAnswering(topology: Partial & Record): HomeAssistant { + const callWS = vi.fn(async (msg: { type: string }) => { + switch (msg.type) { + case "span_panel/panel_topology": + return { circuits: {}, ...topology }; + case "config/device_registry/list": + case "config/area_registry/list": + case "config/entity_registry/list": + return []; + default: + throw new Error(`unexpected websocket call ${msg.type}`); + } + }); + return { callWS } as unknown as HomeAssistant; +} + +describe("the panel size topology reports", () => { + it("is taken when it is a positive integer", async () => { + const { panelSize } = await discoverTopology(hassAnswering({ circuits: CIRCUITS, panel_size: 32 }), "panel-1"); + expect(panelSize).toBe(32); + }); + + it("falls back to the circuits when absent", async () => { + const { panelSize } = await discoverTopology(hassAnswering({ circuits: CIRCUITS }), "panel-1"); + expect(panelSize).toBe(14); + }); + + it.each([0, -2, 12.5, Number.NaN, "32", null])("falls back to the circuits when it is %s", async size => { + const { panelSize } = await discoverTopology(hassAnswering({ circuits: CIRCUITS, panel_size: size }), "panel-1"); + expect(panelSize).toBe(14); + }); + + it("fails to load when it is 0 and nothing else places a breaker", async () => { + await expect(discoverTopology(hassAnswering({ panel_size: 0 }), "panel-1")).rejects.toThrow(t("card.topology_error")); + }); +}); + +const HASS = { states: {}, services: {}, language: "en" } as unknown as HomeAssistant; + +function topologyWith(fields: Partial): PanelTopology { + return { circuits: {}, ...fields }; +} + +function grid(topology: PanelTopology, positions: PositionRange): HTMLElement { + const div = document.createElement("div"); + div.innerHTML = buildGridHTML(topology, positions, HASS, {}, null); + return div; +} + +function labels(el: ParentNode): number[] { + return [...el.querySelectorAll(".tab-label")].map(label => Number(label.textContent)).sort((a, b) => a - b); +} + +function range(first: number, last: number): number[] { + return Array.from({ length: last - first + 1 }, (_, i) => first + i); +} + +describe("positionRange", () => { + it("is the range the panel reports, over its size", () => { + const topology = topologyWith({ circuits: { "c-9": circuit("Kitchen", [9, 11]) }, panel_size: 16, first_position: 9, last_position: 24 }); + expect(positionRange(topology, 16)).toEqual({ first: 9, last: 24 }); + }); + + it("is 1 to the panel size when no range is reported", () => { + expect(positionRange(topologyWith({ circuits: CIRCUITS, panel_size: 32 }), 32)).toEqual({ first: 1, last: 32 }); + }); + + it("is the reported range when the panel size is 0", () => { + expect(positionRange(topologyWith({ panel_size: 0, first_position: 1, last_position: 14 }), 0)).toEqual({ first: 1, last: 14 }); + }); + + it.each([ + [0, 24], + [9, 0], + [24, 9], + [9.5, 24], + [null, 24], + ])("ignores a reported range of %s to %s", (first, last) => { + const topology = topologyWith({ circuits: CIRCUITS, first_position: first, last_position: last }); + expect(positionRange(topology, 14)).toEqual({ first: 1, last: 14 }); + }); + + it("widens to every occupied tab, so no circuit is dropped", () => { + const topology = topologyWith({ circuits: { ...CIRCUITS, "c-9": circuit("Shop", [50]) }, first_position: 9, last_position: 48 }); + expect(positionRange(topology, 40)).toEqual({ first: 1, last: 50 }); + }); + + it("is null when nothing places a breaker", () => { + expect(positionRange(topologyWith({ panel_size: 0 }), 0)).toBeNull(); + expect(positionRange(null, 32)).toBeNull(); + }); + + it("counts the rows the range spans", () => { + expect(positionRowCount({ first: 1, last: 32 })).toBe(16); + expect(positionRowCount({ first: 9, last: 24 })).toBe(8); + expect(positionRowCount({ first: 10, last: 25 })).toBe(9); + }); +}); + +describe("the grid", () => { + const NINE_TO_24 = topologyWith({ + circuits: { "c-9": circuit("Kitchen", [9]), "c-12": circuit("Dryer", [12, 14]), "c-24": circuit("Office", [24]) }, + panel_size: 16, + first_position: 9, + last_position: 24, + }); + + it("draws rows labelled with the reported positions, and no slot below the first", () => { + const el = grid(NINE_TO_24, positionRange(NINE_TO_24, 16)!); + expect(labels(el)).toEqual(range(9, 24)); + // 16 positions, 4 of them occupied by three circuits. + expect(el.querySelectorAll(".circuit-empty")).toHaveLength(12); + expect(el.querySelector('.tab-label[style*="grid-row: 1;"]')!.textContent).toBe("9"); + }); + + it("draws a breaker above the panel size when the range reaches it", () => { + const topology = topologyWith({ circuits: { "c-43": circuit("Range", [43, 45]) }, panel_size: 40, first_position: 9, last_position: 48 }); + const slot = grid(topology, positionRange(topology, 40)!).querySelector('[data-uuid="c-43"]'); + expect(slot).not.toBeNull(); + // Position 43 is the 18th row from position 9; the slot spans two rows. + expect(slot!.getAttribute("style")).toContain("grid-row: 18 / span 2"); + }); + + it("labels only the positions in range on a row the range starts or ends inside", () => { + const topology = topologyWith({ first_position: 10, last_position: 25 }); + expect(labels(grid(topology, positionRange(topology, 0)!))).toEqual(range(10, 25)); + }); + + it("draws a 1 to 32 range exactly as a 32-space panel that reports none", () => { + const circuits = { "c-1": circuit("Kitchen", [1]), "c-2": circuit("Dryer", [2, 4]), "c-3": circuit("Range", [29, 31]), "c-4": circuit("Office", [32]) }; + const reported = topologyWith({ circuits, panel_size: 32, first_position: 1, last_position: 32 }); + const unreported = topologyWith({ circuits, panel_size: 32 }); + const html = grid(reported, positionRange(reported, 32)!).innerHTML; + expect(html).toBe(grid(unreported, positionRange(unreported, 32)!).innerHTML); + expect(labels(grid(reported, positionRange(reported, 32)!))).toEqual(range(1, 32)); + }); +}); + +describe("a panel that reports size 0 with positions", () => { + it("loads, rather than failing on its size", async () => { + const result = await discoverTopology(hassAnswering({ panel_size: 0, first_position: 1, last_position: 14 }), "panel-1"); + expect(result.panelSize).toBe(0); + expect(positionRange(result.topology, result.panelSize)).toEqual({ first: 1, last: 14 }); + }); + + describe("on the card", () => { + const mounted: HTMLElement[] = []; + afterEach(() => { + for (const el of mounted.splice(0)) el.remove(); + }); + + it("draws the grid over the reported positions", async () => { + const topology = { circuits: { "c-9": circuit("Kitchen", [9]) }, panel_size: 0, first_position: 9, last_position: 24 }; + const hass = hassWith(new FakeConnection(), { + callWS: (async (msg: Record) => (msg.type === "span_panel/panel_topology" ? topology : [])) as HomeAssistant["callWS"], + }); + const card = document.createElement("span-panel-card") as HTMLElement & { setConfig(c: CardConfig): void; hass: HomeAssistant }; + card.setConfig({ device_id: "panel-1" }); + document.body.appendChild(card); + mounted.push(card); + card.hass = hass; + + await vi.waitFor(() => expect(card.shadowRoot!.querySelector(".panel-grid")).not.toBeNull()); + const panelGrid = card.shadowRoot!.querySelector(".panel-grid")!; + expect(labels(panelGrid)).toEqual(range(9, 24)); + expect(panelGrid.getAttribute("style")).toContain("repeat(8, auto)"); + }); + }); +});