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
10 changes: 6 additions & 4 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, measuresOutsidePanel, 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 All @@ -59,7 +61,7 @@ export async function discoverTopology(hass: HomeAssistant, deviceId: string | u
function panelSizeFromCircuits(circuits: Record<string, Circuit>): 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;
}
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
34 changes: 34 additions & 0 deletions src/card/card-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,40 @@ export const CARD_STYLES: string = `
border-bottom: 1px solid var(--divider-color, #333);
}

/* Meters outside the panel: no breaker space, so a strip above the grid. */
.meters-strip {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--divider-color, #333);
}
.meters-title { flex-basis: 100%; font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }
.meter-tile {
flex: 1 1 220px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));
border: 1px solid var(--divider-color, #333);
border-radius: 12px;
padding: 12px 16px;
}
.meter-name {
font-size: 0.9em;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--primary-text-color, #e0e0e0);
}
.meter-readings { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 0.85em; color: var(--secondary-text-color, #999); }
.meter-value { font-size: 1.05em; color: var(--primary-text-color, #fff); white-space: nowrap; }
.meter-value strong { font-weight: 700; font-size: 1.1em; }
.meter-direction:empty { display: none; }

.sub-device {
background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));
border: 1px solid var(--divider-color, #333);
Expand Down
20 changes: 13 additions & 7 deletions src/card/span-panel-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { escapeHtml } from "../helpers/sanitize.js";
import { loadListColumns } from "../helpers/list-columns.js";
import { buildHeaderHTML } from "../core/header-renderer.js";
import { buildGridHTML } from "../core/grid-renderer.js";
import { buildMetersStripHTML, metersStripCardSize } from "../core/meters-strip.js";
import { buildSubDevicesHTML } from "../core/sub-device-renderer.js";
import { buildMonitoringSummaryHTML } from "../core/monitoring-status.js";
import { DashboardController } from "../core/dashboard-controller.js";
Expand All @@ -19,6 +20,7 @@ import { discoverTopology, discoverEntitiesFallback, panelConfigEntryId } from "
import { RetryManager } from "../core/retry-manager.js";
import { errorText } from "../helpers/error-text.js";
import { deepEqual } from "../helpers/deep-equal.js";
import { positionRange, positionRowCount, type PositionRange } from "../helpers/layout.js";
import { sameTopologyStructure } from "../core/topology-structure.js";
import { CARD_STYLES } from "./card-styles.js";
import "../core/side-panel.js";
Expand Down Expand Up @@ -73,7 +75,8 @@ export class SpanPanelCard extends LitElement {
@state() private _activeTab: "panel" | "activity" | "area" = "panel";

private _panelDevice: PanelDevice | null = null;
private _panelSize = 0;
/** The breaker positions the grid draws; null until a topology places one. */
private _positions: PositionRange | null = null;
private _historyLoaded = false;
private readonly _ctrl = new DashboardController();
private readonly _listCtrl = new ListViewController(this._ctrl);
Expand Down Expand Up @@ -146,15 +149,16 @@ 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;
const meters = this._topology ? metersStripCardSize(this._topology, this._config) : 0;
return (this._positions ? positionRowCount(this._positions) : 0) + 3 + meters;
}

static getConfigElement(): HTMLElement {
Expand Down Expand Up @@ -276,7 +280,7 @@ export class SpanPanelCard extends LitElement {
private _adoptTopology(result: DiscoveryResult): void {
this._topology = result.topology;
this._panelDevice = result.panelDevice;
this._panelSize = result.panelSize;
this._positions = positionRange(result.topology, result.panelSize);
}

/**
Expand Down Expand Up @@ -424,7 +428,7 @@ export class SpanPanelCard extends LitElement {

private _populateCardContent(): void {
const container = this._root.querySelector("#card-content");
if (!container || !this.hass || !this._topology || !this._panelSize) return;
if (!container || !this.hass || !this._topology || !this._positions) return;

// Populate tab bar
const tabsContainer = this._root.querySelector("#card-tabs");
Expand Down Expand Up @@ -454,17 +458,19 @@ export class SpanPanelCard extends LitElement {
}

if (this._activeTab === "panel") {
const totalRows = Math.ceil(this._panelSize / 2);
const totalRows = positionRowCount(this._positions);
const headerHTML = buildHeaderHTML(this._topology, this._config);
const monitoringStatus = this._ctrl.monitoringCache.status;
const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus);
const gridHTML = buildGridHTML(this._topology, totalRows, this.hass, this._config, monitoringStatus);
const gridHTML = buildGridHTML(this._topology, this._positions, this.hass, this._config, monitoringStatus);
const subDevHTML = buildSubDevicesHTML(this._topology, this.hass, this._config, { showFavorites: this._ctrl.showFavorites });
const metersHTML = buildMetersStripHTML(this._topology, this.hass, this._config);

container.innerHTML = `
${headerHTML}
${monitoringSummaryHTML}
${subDevHTML ? `<div class="sub-devices">${subDevHTML}</div>` : ""}
${metersHTML}
${this._config.show_panel !== false ? `<div class="panel-grid" style="grid-template-rows: repeat(${totalRows}, auto);">${gridHTML}</div>` : ""}
`;

Expand Down
9 changes: 9 additions & 0 deletions src/core/circuit-state.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,6 +18,14 @@ export function getCircuitStateClasses(_circuit: Circuit, monitoringInfo: Monito
return classes.join(" ");
}

/**
* Whether a circuit is drawn and sorted as on: its relay is closed, or it is a
* meter outside the panel, which has no relay to open.
*/
export function drawsAsOn(c: CircuitControlFields & Pick<Circuit, "outside_panel">, 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<Circuit, "entities">, hass: HomeAssistant): number | null {
const entityId = c.entities?.power;
Expand Down
2 changes: 2 additions & 0 deletions src/core/dashboard-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -376,6 +377,7 @@ export class DashboardController {
if (!this._hass || !this._topology || !this._config) return;
updateCircuitDOM(root, this._hass, this._topology, this._config, this.powerHistory, this.horizonMap);
updateSubDeviceDOM(root, this._hass, this._topology, this._config, this.powerHistory, this.subDeviceHorizonMap);
updateMetersDOM(root, this._hass, this._topology, this._config);
}

async onGraphSettingsChanged(root: DOMRoot): Promise<void> {
Expand Down
9 changes: 6 additions & 3 deletions src/core/dom-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { resolveSubDevicePower, stateWatts } from "../helpers/sub-device-power.j
import { getHistoryDurationMs, getHorizonDurationMs } from "../helpers/history.js";
import { updateChart } from "../chart/chart-update.js";
import { attrSelectorValue } from "../helpers/selector.js";
import { circuitCurrentA, circuitPowerW, readsAsProducer, relayClosed, shedPriorityKey, switchPresence } from "./circuit-state.js";
import { measuresOutsidePanel } from "../helpers/layout.js";
import { circuitCurrentA, circuitPowerW, drawsAsOn, readsAsProducer, shedPriorityKey, switchPresence } from "./circuit-state.js";
import { applySheddingIcon, applyTogglePill } from "./circuit-controls.js";
import type { HomeAssistant, PanelTopology, CardConfig, HistoryMap, ChartMetricDef } from "../types.js";

Expand Down Expand Up @@ -151,9 +152,10 @@ export function updateCircuitDOM(
const defaultDurationMs = getHistoryDurationMs(config);

// The circuits' consumption: every non-solar circuit with a power sensor, an unknown reading skipped.
// A meter outside the panel is no load on it, so it is no term at all.
const consumption: (number | null)[] = [];
for (const circuit of Object.values(topology.circuits)) {
if (!circuit.entities?.power || circuit.device_type === DEVICE_TYPE_PV) continue;
if (!circuit.entities?.power || circuit.device_type === DEVICE_TYPE_PV || measuresOutsidePanel(circuit)) continue;
const power = circuitPowerW(circuit, hass);
consumption.push(power === null ? null : Math.abs(power));
}
Expand All @@ -170,7 +172,8 @@ export function updateCircuitDOM(
const powerW = circuitPowerW(circuit, hass);
const isProducer = readsAsProducer(circuit, powerW);

const isOn = relayClosed(circuit, hass);
// A meter outside the panel has no relay, so its list chart is never dimmed as off.
const isOn = drawsAsOn(circuit, hass);

const powerVal = slot.querySelector(".power-value");
if (powerVal) {
Expand Down
39 changes: 25 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, measuresOutsidePanel, 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 All @@ -39,6 +40,8 @@ export function buildGridHTML(
const occupiedTabs = new Set<number>();

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);
Expand Down Expand Up @@ -71,41 +74,49 @@ export function buildGridHTML(
return { monInfo, sheddingPriority };
}

// A row the range starts or ends inside draws only its in-range side.
const inRange = (tab: number): boolean => tab >= positions.first && tab <= positions.last;
const firstRow = tabToRow(positions.first);
const lastRow = tabToRow(positions.last);

let gridHTML = "";
for (let row = 1; row <= totalRows; row++) {
for (let row = firstRow; row <= lastRow; row++) {
const gridRow = row - firstRow + 1;
const leftTab = row * 2 - 1;
const rightTab = row * 2;
const leftEntry = tabMap.get(leftTab);
const rightEntry = tabMap.get(rightTab);
const leftLabel = inRange(leftTab) ? `<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
21 changes: 8 additions & 13 deletions src/core/list-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,8 @@ import { escapeHtml } from "../helpers/sanitize.js";
import { formatCircuitCurrentHTML, formatCircuitPowerHTML } from "../helpers/format.js";
import { t } from "../i18n.js";
import { getChartMetric } from "../helpers/chart.js";
import {
circuitCurrentA,
circuitPowerW,
circuitUtilizationPct,
getCircuitStateClasses,
readsAsProducer,
relayClosed,
switchPresence,
} from "./circuit-state.js";
import { measuresOutsidePanel } from "../helpers/layout.js";
import { circuitCurrentA, circuitPowerW, circuitUtilizationPct, drawsAsOn, getCircuitStateClasses, readsAsProducer, switchPresence } from "./circuit-state.js";
import { buildSheddingIconHTML, buildTogglePillHTML } from "./circuit-controls.js";
import type { Circuit, HomeAssistant, CardConfig, MonitoringPointInfo } from "../types.js";

Expand Down Expand Up @@ -55,7 +48,7 @@ export function buildListRowHTML(
sheddingPriority: string,
isExpanded: boolean
): string {
const isOn = relayClosed(circuit, hass);
const isOn = drawsAsOn(circuit, hass);
const presence = switchPresence(circuit, hass);

const breakerAmps = circuit.breaker_rating_a;
Expand Down Expand Up @@ -92,8 +85,10 @@ export function buildListRowHTML(

// A switch -- operable, or dimmed and inert while unavailable -- gets the pill,
// armed by the header's slide-confirm; a circuit with none keeps a static badge.
const statusControl =
presence === "none"
// A meter outside the panel has no relay to be on or off, so it gets neither.
const statusControl = measuresOutsidePanel(circuit)
? ""
: presence === "none"
? `<span class="list-status-badge ${isOn ? "list-status-on" : "list-status-off"}">${isOn ? "ON" : "OFF"}</span>`
: buildTogglePillHTML(isOn, presence);

Expand Down Expand Up @@ -132,7 +127,7 @@ export function buildExpandedChartHTML(
): string {
const isProducer = readsAsProducer(circuit, circuitPowerW(circuit, hass));

const isOn = relayClosed(circuit, hass);
const isOn = drawsAsOn(circuit, hass);

const stateClasses = getCircuitStateClasses(circuit, monitoringInfo, isOn, isProducer);
const safeUuid = escapeHtml(uuid);
Expand Down
4 changes: 2 additions & 2 deletions src/core/list-view-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 };
Expand Down
Loading
Loading