From 5fb0eca2f2d32ab222ec41bbbb221c72416fa674 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:17:53 -0700 Subject: [PATCH 1/6] fix(discovery): take a panel size only when it is a positive integer A panel that does not know its size reports 0, which the card took as the size and then refused to load. A size of 0, below zero, fractional or not a number is now treated as absent, and the size comes from the circuits. Part of the spanos3/r202639/03 batch 2 work. --- src/card/card-discovery.ts | 5 +-- src/helpers/layout.ts | 9 +++++ tests/card-discovery-fallback.test.ts | 17 +++++++-- tests/positions.test.ts | 52 +++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/positions.test.ts diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts index 849504d..6294040 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 } from "../helpers/layout.js"; import { t } from "../i18n.js"; import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js"; @@ -34,7 +35,7 @@ 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); + const panelSize = isPositiveInteger(topology.panel_size) ? topology.panel_size : panelSizeFromCircuits(topology.circuits); if (!panelSize) { throw new Error(t("card.topology_error")); } @@ -196,7 +197,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/helpers/layout.ts b/src/helpers/layout.ts index 0004bf8..4f906af 100644 --- a/src/helpers/layout.ts +++ b/src/helpers/layout.ts @@ -1,5 +1,14 @@ import type { DualTabLayout } 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; +} + export function tabToRow(tab: number): number { return Math.ceil(tab / 2); } 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/positions.test.ts b/tests/positions.test.ts new file mode 100644 index 0000000..962dc60 --- /dev/null +++ b/tests/positions.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Circuit, HomeAssistant, PanelTopology } from "../src/types.js"; +import { discoverTopology } from "../src/card/card-discovery.js"; +import { t } from "../src/i18n.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")); + }); +}); From 30b4dd4fe13232dc2a08f0fff4a3d179e3533ce7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:19:44 -0700 Subject: [PATCH 2/6] feat(grid): draw the breaker positions the panel reports Topology may report the panel's first and last breaker positions. The grid draws rows over that range, labelled with the positions' own numbers, instead of 1 to the panel size, so a breaker above the size is drawn and no empty slot appears below the first position. A panel that reports size 0 with its positions loads. Without reported positions the range is 1 to the size, as before, and it always widens to every occupied tab. Part of the spanos3/r202639/03 batch 2 work. --- src/card/card-discovery.ts | 5 +- src/card/span-panel-card.ts | 16 ++-- src/core/grid-renderer.ts | 37 ++++++---- src/helpers/layout.ts | 40 +++++++++- src/panel/tab-dashboard.ts | 11 ++- src/types.ts | 6 ++ tests/grid-renderer.test.ts | 2 +- tests/positions.test.ts | 141 +++++++++++++++++++++++++++++++++++- 8 files changed, 227 insertions(+), 31 deletions(-) diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts index 6294040..2ac4263 100644 --- a/src/card/card-discovery.ts +++ b/src/card/card-discovery.ts @@ -1,7 +1,7 @@ import { INTEGRATION_DOMAIN } from "../constants.js"; import { resolveAndAssignAreas } from "../core/area-resolver.js"; import { RetryManager } from "../core/retry-manager.js"; -import { isPositiveInteger } from "../helpers/layout.js"; +import { isPositiveInteger, positionRange } from "../helpers/layout.js"; import { t } from "../i18n.js"; import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js"; @@ -36,7 +36,8 @@ export async function discoverTopology(hass: HomeAssistant, deviceId: string | u const topology = retry ? await retry.callWS(hass, topologyMsg, { errorId: "fetch:topology" }) : await hass.callWS(topologyMsg); const panelSize = isPositiveInteger(topology.panel_size) ? topology.panel_size : panelSizeFromCircuits(topology.circuits); - if (!panelSize) { + // 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")); } diff --git a/src/card/span-panel-card.ts b/src/card/span-panel-card.ts index 6fb13cf..c93f41a 100644 --- a/src/card/span-panel-card.ts +++ b/src/card/span-panel-card.ts @@ -19,6 +19,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 +74,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 +148,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 +156,7 @@ export class SpanPanelCard extends LitElement { } getCardSize(): number { - return Math.ceil(this._panelSize / 2) + 3; + return (this._positions ? positionRowCount(this._positions) : 0) + 3; } static getConfigElement(): HTMLElement { @@ -276,7 +278,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 +426,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,11 +456,11 @@ 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 }); container.innerHTML = ` diff --git a/src/core/grid-renderer.ts b/src/core/grid-renderer.ts index fb596fc..80c0f32 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, 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 @@ -71,41 +72,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/helpers/layout.ts b/src/helpers/layout.ts index 4f906af..d745d54 100644 --- a/src/helpers/layout.ts +++ b/src/helpers/layout.ts @@ -1,4 +1,4 @@ -import type { DualTabLayout } from "../types.js"; +import type { DualTabLayout, PanelTopology } from "../types.js"; /** * Whether a size or position the topology reports can be laid out. A panel that @@ -9,6 +9,44 @@ export function isPositiveInteger(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value > 0; } +/** 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 circuit occupies so that no circuit is + * dropped. 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)) { + 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/panel/tab-dashboard.ts b/src/panel/tab-dashboard.ts index 3205d75..1ff4e2a 100644 --- a/src/panel/tab-dashboard.ts +++ b/src/panel/tab-dashboard.ts @@ -1,6 +1,8 @@ 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 { buildSubDevicesHTML } from "../core/sub-device-renderer.js"; @@ -67,11 +69,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,12 +84,12 @@ 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 }); container.innerHTML = ` diff --git a/src/types.ts b/src/types.ts index 670d899..443029e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,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/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/positions.test.ts b/tests/positions.test.ts index 962dc60..0ac3d59 100644 --- a/tests/positions.test.ts +++ b/tests/positions.test.ts @@ -1,7 +1,11 @@ -import { describe, it, expect, vi } from "vitest"; -import type { Circuit, HomeAssistant, PanelTopology } from "../src/types.js"; +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: {} }; @@ -50,3 +54,136 @@ describe("the panel size topology reports", () => { 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)"); + }); + }); +}); From 5a0fedc5d6a70ca77726b21e2a02b29f8ef9eaed Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:25:21 -0700 Subject: [PATCH 3/6] feat(meters): draw meters without a breaker space in a strip above the grid A topology row marked outside_panel is a meter with no breaker space and no relay. It is never a grid slot: it is drawn in a Meters strip above the grid with its name, its power in the import-positive sign with the direction it states (importing or exporting), and the energy it has imported and exported, with no toggle, rating or gear. An unknown reading shows "--" and states no direction. Such a meter is no load on the panel, so the Site stat's fallback sum leaves it out. In the list views it is never drawn or sorted as off, and it gets no on/off control. Part of the spanos3/r202639/03 batch 2 work. --- src/card/card-styles.ts | 34 ++++ src/card/span-panel-card.ts | 3 + src/core/circuit-state.ts | 13 ++ src/core/dashboard-controller.ts | 2 + src/core/dom-updater.ts | 5 +- src/core/grid-renderer.ts | 3 + src/core/list-renderer.ts | 13 +- src/core/list-view-controller.ts | 4 +- src/core/meters-strip.ts | 65 ++++++++ src/helpers/format.ts | 12 ++ src/i18n.ts | 27 ++++ src/panel/tab-dashboard.ts | 3 + src/types.ts | 5 + tests/meters-strip.test.ts | 267 +++++++++++++++++++++++++++++++ 14 files changed, 447 insertions(+), 9 deletions(-) create mode 100644 src/core/meters-strip.ts create mode 100644 tests/meters-strip.test.ts diff --git a/src/card/card-styles.ts b/src/card/card-styles.ts index 02b1ae9..c4bdb34 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-power { font-size: 1.05em; color: var(--primary-text-color, #fff); white-space: nowrap; } + .meter-power 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 c93f41a..21ce8ca 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 } 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"; @@ -462,11 +463,13 @@ export class SpanPanelCard extends LitElement { const monitoringSummaryHTML = buildMonitoringSummaryHTML(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); 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..8d05f83 100644 --- a/src/core/circuit-state.ts +++ b/src/core/circuit-state.ts @@ -17,6 +17,19 @@ export function getCircuitStateClasses(_circuit: Circuit, monitoringInfo: Monito return classes.join(" "); } +/** Whether the circuit is a meter outside the panel rather than a breaker in it. */ +export function measuresOutsidePanel(c: Pick): boolean { + return c.outside_panel === true; +} + +/** + * 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..82cdb3f 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); } async onGraphSettingsChanged(root: DOMRoot): Promise { diff --git a/src/core/dom-updater.ts b/src/core/dom-updater.ts index 8e07ad9..ea313c9 100644 --- a/src/core/dom-updater.ts +++ b/src/core/dom-updater.ts @@ -13,7 +13,7 @@ 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 { circuitCurrentA, circuitPowerW, measuresOutsidePanel, readsAsProducer, relayClosed, 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 +151,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)); } diff --git a/src/core/grid-renderer.ts b/src/core/grid-renderer.ts index 80c0f32..c43e713 100644 --- a/src/core/grid-renderer.ts +++ b/src/core/grid-renderer.ts @@ -9,6 +9,7 @@ import { circuitPowerW, circuitUtilizationPct, getCircuitStateClasses, + measuresOutsidePanel, readsAsProducer, relayClosed, shedPriorityKey, @@ -40,6 +41,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); diff --git a/src/core/list-renderer.ts b/src/core/list-renderer.ts index b69efd5..89b5c65 100644 --- a/src/core/list-renderer.ts +++ b/src/core/list-renderer.ts @@ -6,9 +6,10 @@ import { circuitCurrentA, circuitPowerW, circuitUtilizationPct, + drawsAsOn, getCircuitStateClasses, + measuresOutsidePanel, readsAsProducer, - relayClosed, switchPresence, } from "./circuit-state.js"; import { buildSheddingIconHTML, buildTogglePillHTML } from "./circuit-controls.js"; @@ -55,7 +56,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 +93,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 +135,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..8730c9f --- /dev/null +++ b/src/core/meters-strip.ts @@ -0,0 +1,65 @@ +import { escapeHtml } from "../helpers/sanitize.js"; +import { attrSelectorValue } from "../helpers/selector.js"; +import { formatCircuitPowerHTML, formatEnergyReading } from "../helpers/format.js"; +import { t } from "../i18n.js"; +import { circuitPowerW, measuresOutsidePanel } from "./circuit-state.js"; +import type { Circuit, HomeAssistant, PanelTopology } from "../types.js"; + +/** 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)); +} + +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 with the direction + * it 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): string { + const powerW = circuitPowerW(circuit, hass); + const direction = powerW === null || powerW === 0 ? "" : powerW > 0 ? t("meters.importing") : t("meters.exporting"); + return ` + ${formatCircuitPowerHTML(powerW)} + ${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. 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): string { + const meters = metersOf(topology); + if (meters.length === 0) return ""; + const tiles = meters + .map( + ([uuid, circuit]) => ` +
+ ${escapeHtml(circuit.name || t("grid.unknown"))} + ${meterReadingsHTML(circuit, hass)} +
` + ) + .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): 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); + } +} 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/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 1ff4e2a..9d8c2af 100644 --- a/src/panel/tab-dashboard.ts +++ b/src/panel/tab-dashboard.ts @@ -5,6 +5,7 @@ import { positionRange, positionRowCount, type PositionRange } from "../helpers/ 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"; @@ -91,12 +92,14 @@ export class DashboardTab { const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus); const gridHTML = buildGridHTML(topology!, positions, hass, config, monitoringStatus); const subDevHTML = buildSubDevicesHTML(topology!, hass, config, { showFavorites: this._ctrl.showFavorites }); + const metersHTML = buildMetersStripHTML(topology!, hass); container.innerHTML = ` ${headerHTML} ${monitoringSummaryHTML} ${subDevHTML ? `
${subDevHTML}
` : ""} + ${metersHTML} ${ config.show_panel !== false ? ` diff --git a/src/types.ts b/src/types.ts index 443029e..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; } /** diff --git a/tests/meters-strip.test.ts b/tests/meters-strip.test.ts new file mode 100644 index 0000000..12f58d6 --- /dev/null +++ b/tests/meters-strip.test.ts @@ -0,0 +1,267 @@ +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 { 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 "../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): HTMLElement { + const div = document.createElement("div"); + div.innerHTML = buildMetersStripHTML(topology, hass); + 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 Meters strip", () => { + it("draws nothing when no meter is outside the panel", () => { + expect(buildMetersStripHTML(topologyOf({ kitchen: hosted("kitchen", 1) }), IMPORTING)).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-power"))).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-power"))).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-power"))).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-power"))).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); + + expect(el.querySelector(".meter-tile")).toBe(tile); + expect(text(el.querySelector(".meter-power"))).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"); + }); +}); + +describe("the card", () => { + const mounted: HTMLElement[] = []; + afterEach(() => { + for (const el of mounted.splice(0)) el.remove(); + }); + + it("draws the Meters strip above the grid and keeps it current", async () => { + const topology = { circuits: { kitchen: hosted("kitchen", 1), "meter-a": meter("meter_a") }, panel_size: 32 }; + const states = (power: string) => + 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: "" }, + ]) + ); + const connection = new FakeConnection(); + const 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 = hassOn(connection, { callWS, states: states("850") }); + + 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-power"))).toBe("-60W"); + }); +}); From 74c5d5eb975553c3c99d4dfd5332837a8c40ccdb Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:35:08 -0700 Subject: [PATCH 4/6] fix(meters): never dim a meter's list chart as off on a live update The list's expanded chart drew a meter outside the panel as on, but the live update re-read its relay, which a meter does not have, and dimmed the chart as off. The update now reads the same on-state the list draws. Part of the spanos3/r202639/03 batch 2 work. --- src/core/dom-updater.ts | 5 +++-- tests/meters-strip.test.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/core/dom-updater.ts b/src/core/dom-updater.ts index ea313c9..c90c5bc 100644 --- a/src/core/dom-updater.ts +++ b/src/core/dom-updater.ts @@ -13,7 +13,7 @@ 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, measuresOutsidePanel, readsAsProducer, relayClosed, shedPriorityKey, switchPresence } from "./circuit-state.js"; +import { circuitCurrentA, circuitPowerW, drawsAsOn, measuresOutsidePanel, readsAsProducer, shedPriorityKey, switchPresence } from "./circuit-state.js"; import { applySheddingIcon, applyTogglePill } from "./circuit-controls.js"; import type { HomeAssistant, PanelTopology, CardConfig, HistoryMap, ChartMetricDef } from "../types.js"; @@ -171,7 +171,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/tests/meters-strip.test.ts b/tests/meters-strip.test.ts index 12f58d6..157ae10 100644 --- a/tests/meters-strip.test.ts +++ b/tests/meters-strip.test.ts @@ -3,7 +3,7 @@ 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 { buildListRowHTML } from "../src/core/list-renderer.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"; @@ -226,6 +226,18 @@ describe("a meter outside the panel in the list views", () => { 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", () => { From 55e3d18f7ebfbd9c61c8f482684aac697510db00 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:35:49 -0700 Subject: [PATCH 5/6] fix(meters): keep meters out of the breaker range and the derived panel size A meter outside the panel occupies no breaker space, so a tab its row might name no longer widens the drawn position range or sets the size derived from the circuits. The predicate moves beside the layout helpers that now read it. Part of the spanos3/r202639/03 batch 2 work. --- src/card/card-discovery.ts | 4 ++-- src/core/circuit-state.ts | 6 +----- src/core/dom-updater.ts | 3 ++- src/core/grid-renderer.ts | 3 +-- src/core/list-renderer.ts | 12 ++---------- src/core/meters-strip.ts | 3 ++- src/helpers/layout.ts | 15 +++++++++++---- tests/meters-strip.test.ts | 18 ++++++++++++++++++ 8 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts index 2ac4263..8936aee 100644 --- a/src/card/card-discovery.ts +++ b/src/card/card-discovery.ts @@ -1,7 +1,7 @@ import { INTEGRATION_DOMAIN } from "../constants.js"; import { resolveAndAssignAreas } from "../core/area-resolver.js"; import { RetryManager } from "../core/retry-manager.js"; -import { isPositiveInteger, positionRange } from "../helpers/layout.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"; @@ -61,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; } diff --git a/src/core/circuit-state.ts b/src/core/circuit-state.ts index 8d05f83..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,11 +18,6 @@ export function getCircuitStateClasses(_circuit: Circuit, monitoringInfo: Monito return classes.join(" "); } -/** Whether the circuit is a meter outside the panel rather than a breaker in it. */ -export function measuresOutsidePanel(c: Pick): boolean { - return c.outside_panel === true; -} - /** * 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. diff --git a/src/core/dom-updater.ts b/src/core/dom-updater.ts index c90c5bc..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, drawsAsOn, measuresOutsidePanel, readsAsProducer, 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"; diff --git a/src/core/grid-renderer.ts b/src/core/grid-renderer.ts index c43e713..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, type PositionRange } 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 { @@ -9,7 +9,6 @@ import { circuitPowerW, circuitUtilizationPct, getCircuitStateClasses, - measuresOutsidePanel, readsAsProducer, relayClosed, shedPriorityKey, diff --git a/src/core/list-renderer.ts b/src/core/list-renderer.ts index 89b5c65..9e28251 100644 --- a/src/core/list-renderer.ts +++ b/src/core/list-renderer.ts @@ -2,16 +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, - drawsAsOn, - getCircuitStateClasses, - measuresOutsidePanel, - readsAsProducer, - 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"; diff --git a/src/core/meters-strip.ts b/src/core/meters-strip.ts index 8730c9f..3b94424 100644 --- a/src/core/meters-strip.ts +++ b/src/core/meters-strip.ts @@ -2,7 +2,8 @@ import { escapeHtml } from "../helpers/sanitize.js"; import { attrSelectorValue } from "../helpers/selector.js"; import { formatCircuitPowerHTML, formatEnergyReading } from "../helpers/format.js"; import { t } from "../i18n.js"; -import { circuitPowerW, measuresOutsidePanel } from "./circuit-state.js"; +import { measuresOutsidePanel } from "../helpers/layout.js"; +import { circuitPowerW } from "./circuit-state.js"; import type { Circuit, HomeAssistant, PanelTopology } from "../types.js"; /** The topology's meters outside the panel, in topology order. */ diff --git a/src/helpers/layout.ts b/src/helpers/layout.ts index d745d54..5a61129 100644 --- a/src/helpers/layout.ts +++ b/src/helpers/layout.ts @@ -1,4 +1,4 @@ -import type { DualTabLayout, PanelTopology } 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 @@ -9,6 +9,11 @@ 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; @@ -17,8 +22,9 @@ export interface PositionRange { /** * The breaker positions the grid draws: the range the panel reports, else 1 to - * `panelSize`, widened to every tab a circuit occupies so that no circuit is - * dropped. Null when nothing places a breaker. + * `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; @@ -33,7 +39,8 @@ export function positionRange(topology: PanelTopology | null, panelSize: number) last = panelSize; } for (const circuit of Object.values(topology.circuits)) { - for (const tab of circuit?.tabs ?? []) { + 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); diff --git a/tests/meters-strip.test.ts b/tests/meters-strip.test.ts index 157ae10..cd5d286 100644 --- a/tests/meters-strip.test.ts +++ b/tests/meters-strip.test.ts @@ -7,6 +7,7 @@ import { buildExpandedChartHTML, buildListRowHTML } from "../src/core/list-rende 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"; @@ -88,6 +89,23 @@ describe("a meter outside the panel on the grid", () => { }); }); +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)).toBe(""); From 3af85b278855bba542bc7fa140a06a898e37ebfb Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:36:59 -0700 Subject: [PATCH 6/6] fix(meters): follow the card's panel and unit settings in the Meters strip The strip is hidden with the panel, as the grid is, and shows current in amps mode, as the grid does, with an unknown current as "--" and the direction still read from power. The card's size counts the strip when it is drawn. Part of the spanos3/r202639/03 batch 2 work. --- src/card/card-styles.ts | 4 +- src/card/span-panel-card.ts | 7 +-- src/core/dashboard-controller.ts | 2 +- src/core/meters-strip.ts | 50 ++++++++++++------- src/panel/tab-dashboard.ts | 2 +- tests/meters-strip.test.ts | 85 +++++++++++++++++++++++--------- 6 files changed, 103 insertions(+), 47 deletions(-) diff --git a/src/card/card-styles.ts b/src/card/card-styles.ts index c4bdb34..f5813b9 100644 --- a/src/card/card-styles.ts +++ b/src/card/card-styles.ts @@ -554,8 +554,8 @@ export const CARD_STYLES: string = ` 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-power { font-size: 1.05em; color: var(--primary-text-color, #fff); white-space: nowrap; } - .meter-power strong { font-weight: 700; font-size: 1.1em; } + .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 { diff --git a/src/card/span-panel-card.ts b/src/card/span-panel-card.ts index 21ce8ca..9d07b1e 100644 --- a/src/card/span-panel-card.ts +++ b/src/card/span-panel-card.ts @@ -6,7 +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 } from "../core/meters-strip.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"; @@ -157,7 +157,8 @@ export class SpanPanelCard extends LitElement { } getCardSize(): number { - return (this._positions ? positionRowCount(this._positions) : 0) + 3; + const meters = this._topology ? metersStripCardSize(this._topology, this._config) : 0; + return (this._positions ? positionRowCount(this._positions) : 0) + 3 + meters; } static getConfigElement(): HTMLElement { @@ -463,7 +464,7 @@ export class SpanPanelCard extends LitElement { const monitoringSummaryHTML = buildMonitoringSummaryHTML(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); + const metersHTML = buildMetersStripHTML(this._topology, this.hass, this._config); container.innerHTML = ` ${headerHTML} diff --git a/src/core/dashboard-controller.ts b/src/core/dashboard-controller.ts index 82cdb3f..035d121 100644 --- a/src/core/dashboard-controller.ts +++ b/src/core/dashboard-controller.ts @@ -377,7 +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); + updateMetersDOM(root, this._hass, this._topology, this._config); } async onGraphSettingsChanged(root: DOMRoot): Promise { diff --git a/src/core/meters-strip.ts b/src/core/meters-strip.ts index 3b94424..737b565 100644 --- a/src/core/meters-strip.ts +++ b/src/core/meters-strip.ts @@ -1,31 +1,48 @@ import { escapeHtml } from "../helpers/sanitize.js"; import { attrSelectorValue } from "../helpers/selector.js"; -import { formatCircuitPowerHTML, formatEnergyReading } from "../helpers/format.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 { circuitPowerW } from "./circuit-state.js"; -import type { Circuit, HomeAssistant, PanelTopology } from "../types.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 with the direction - * it states, then the energy it has imported and exported. An unknown power - * states no direction, and neither does zero. + * 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): string { +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 ` - ${formatCircuitPowerHTML(powerW)} + ${valueHTML} ${escapeHtml(direction)} ${energyHTML(hass, circuit.entities?.consumed_energy, t("meters.imported"))} ${energyHTML(hass, circuit.entities?.produced_energy, t("meters.exported"))} @@ -34,18 +51,17 @@ function meterReadingsHTML(circuit: Circuit, hass: HomeAssistant): string { /** * The strip of meters outside the panel, drawn above the breaker grid; empty - * when there are none. A meter has no breaker space, relay or rating, so its - * tile carries no toggle, badge or gear. + * 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): string { - const meters = metersOf(topology); - if (meters.length === 0) return ""; - const tiles = meters +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)} + ${meterReadingsHTML(circuit, hass, config)}
` ) .join(""); @@ -58,9 +74,9 @@ export function buildMetersStripHTML(topology: PanelTopology, hass: HomeAssistan } /** Refresh each drawn meter tile's readings in place. */ -export function updateMetersDOM(root: Element | ShadowRoot, hass: HomeAssistant, topology: PanelTopology): void { +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); + if (readings) readings.innerHTML = meterReadingsHTML(circuit, hass, config); } } diff --git a/src/panel/tab-dashboard.ts b/src/panel/tab-dashboard.ts index 9d8c2af..9ce33f1 100644 --- a/src/panel/tab-dashboard.ts +++ b/src/panel/tab-dashboard.ts @@ -92,7 +92,7 @@ export class DashboardTab { const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus); const gridHTML = buildGridHTML(topology!, positions, hass, config, monitoringStatus); const subDevHTML = buildSubDevicesHTML(topology!, hass, config, { showFavorites: this._ctrl.showFavorites }); - const metersHTML = buildMetersStripHTML(topology!, hass); + const metersHTML = buildMetersStripHTML(topology!, hass, config); container.innerHTML = ` diff --git a/tests/meters-strip.test.ts b/tests/meters-strip.test.ts index cd5d286..697281c 100644 --- a/tests/meters-strip.test.ts +++ b/tests/meters-strip.test.ts @@ -56,9 +56,9 @@ function wh(state: string): { state: string; attributes: Record return { state, attributes: { unit_of_measurement: "Wh" } }; } -function strip(topology: PanelTopology, hass: HomeAssistant): HTMLElement { +function strip(topology: PanelTopology, hass: HomeAssistant, config: CardConfig = CONFIG): HTMLElement { const div = document.createElement("div"); - div.innerHTML = buildMetersStripHTML(topology, hass); + div.innerHTML = buildMetersStripHTML(topology, hass, config); return div; } @@ -108,7 +108,23 @@ describe("the breaker positions", () => { describe("the Meters strip", () => { it("draws nothing when no meter is outside the panel", () => { - expect(buildMetersStripHTML(topologyOf({ kitchen: hosted("kitchen", 1) }), IMPORTING)).toBe(""); + 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", () => { @@ -119,7 +135,7 @@ describe("the Meters strip", () => { 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-power"))).toBe("850W"); + 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"); @@ -127,13 +143,13 @@ describe("the Meters strip", () => { 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-power"))).toBe("-340W"); + 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-power"))).toBe("0W"); + expect(text(el.querySelector(".meter-value"))).toBe("0W"); expect(text(el.querySelector(".meter-direction"))).toBe(""); }); @@ -142,7 +158,7 @@ describe("the Meters strip", () => { topologyOf({ "meter-a": meter("meter_a") }), hassWith({ "sensor.meter_a_power": "unknown", "sensor.meter_a_consumed_energy": "unavailable" }) ); - expect(text(el.querySelector(".meter-power"))).toBe("--"); + 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"); @@ -175,10 +191,10 @@ describe("the Meters strip", () => { 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); + 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-power"))).toBe("-75W"); + 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"); @@ -264,22 +280,32 @@ describe("the card", () => { for (const el of mounted.splice(0)) el.remove(); }); - it("draws the Meters strip above the grid and keeps it current", async () => { - const topology = { circuits: { kitchen: hosted("kitchen", 1), "meter-a": meter("meter_a") }, panel_size: 32 }; - const states = (power: string) => - 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: "" }, - ]) - ); - const connection = new FakeConnection(); - const 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" }); + 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")!; @@ -292,6 +318,19 @@ describe("the card", () => { card.hass = hassOn(connection, { callWS, states: states("-60") }); await vi.waitFor(() => expect(text(content.querySelector(".meter-direction"))).toBe("exporting")); - expect(text(content.querySelector(".meter-power"))).toBe("-60W"); + 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); }); });