Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/card/card-discovery.ts
Original file line number Diff line number Diff line change
@@ -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, positionRange } from "../helpers/layout.js";
import { t } from "../i18n.js";
import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js";

Expand Down Expand Up @@ -34,8 +35,9 @@ export async function discoverTopology(hass: HomeAssistant, deviceId: string | u
const topologyMsg = { type: `${INTEGRATION_DOMAIN}/panel_topology`, device_id: deviceId };
const topology = retry ? await retry.callWS<PanelTopology>(hass, topologyMsg, { errorId: "fetch:topology" }) : await hass.callWS<PanelTopology>(topologyMsg);

const panelSize = topology.panel_size ?? panelSizeFromCircuits(topology.circuits);
if (!panelSize) {
const panelSize = isPositiveInteger(topology.panel_size) ? topology.panel_size : panelSizeFromCircuits(topology.circuits);
// A panel that does not know its size still loads when it reports its positions.
if (!positionRange(topology, panelSize)) {
throw new Error(t("card.topology_error"));
}

Expand Down Expand Up @@ -196,7 +198,7 @@ export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: st
let panelSize = 0;
for (const ent of allEntities) {
const state = hass.states[ent.entity_id];
if (state && typeof state.attributes.panel_size === "number") {
if (state && isPositiveInteger(state.attributes.panel_size)) {
panelSize = state.attributes.panel_size;
break;
}
Expand Down
16 changes: 9 additions & 7 deletions src/card/span-panel-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -146,15 +148,15 @@ 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);
this._ctrl.errorStore = this._errorStore;
}

getCardSize(): number {
return Math.ceil(this._panelSize / 2) + 3;
return (this._positions ? positionRowCount(this._positions) : 0) + 3;
}

static getConfigElement(): HTMLElement {
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 = `
Expand Down
37 changes: 23 additions & 14 deletions src/core/grid-renderer.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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) ? `<div class="tab-label tab-left" style="grid-row: ${gridRow}; grid-column: 1;">${leftTab}</div>` : "";
const rightLabel = inRange(rightTab) ? `<div class="tab-label tab-right" style="grid-row: ${gridRow}; grid-column: 5;">${rightTab}</div>` : "";

gridHTML += `<div class="tab-label tab-left" style="grid-row: ${row}; grid-column: 1;">${leftTab}</div>`;
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 += `<div class="tab-label tab-right" style="grid-row: ${row}; grid-column: 5;">${rightTab}</div>`;
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 += `<div class="tab-label tab-right" style="grid-row: ${row}; grid-column: 5;">${rightTab}</div>`;
gridHTML += rightLabel;
}
return gridHTML;
}
Expand Down
49 changes: 48 additions & 1 deletion src/helpers/layout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,51 @@
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
* 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;
}

/** 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);
Expand Down
11 changes: 7 additions & 4 deletions src/panel/tab-dashboard.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = `<p style="color:var(--error-color);">${escapeHtml(errorText(err))}</p>`;
return null;
Expand All @@ -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 = `
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
17 changes: 15 additions & 2 deletions tests/card-discovery-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}): 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 }) => {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion tests/grid-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function hassWith(states: Record<string, { state: string; attributes?: Record<st
function grid(circuit: Circuit, states: Parameters<typeof hassWith>[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;
}

Expand Down
Loading
Loading