+ if (shared) classes.push("toggle-shared");
+ const title = shared ? ` title="${escapeHtml(t("grid.shared_switch"))}"` : "";
+ return `
${isOn ? t("grid.on") : t("grid.off")}
`;
diff --git a/src/core/dom-updater.ts b/src/core/dom-updater.ts
index a6c2b34..dcb9d1c 100644
--- a/src/core/dom-updater.ts
+++ b/src/core/dom-updater.ts
@@ -14,6 +14,7 @@ import { getHistoryDurationMs, getHorizonDurationMs } from "../helpers/history.j
import { updateChart } from "../chart/chart-update.js";
import { attrSelectorValue } from "../helpers/selector.js";
import { measuresOutsidePanel } from "../helpers/layout.js";
+import { meterSlots } from "../helpers/shared-meters.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";
@@ -152,9 +153,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.
+ // A meter outside the panel is no load on it, so it is no term at all; circuits that share a meter are one term.
+ const slots = meterSlots(topology.circuits);
const consumption: (number | null)[] = [];
- for (const circuit of Object.values(topology.circuits)) {
+ for (const { circuit } of slots) {
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));
@@ -165,7 +167,7 @@ export function updateCircuitDOM(
const chartMetric: ChartMetricDef = getChartMetric(config);
const showCurrent = chartMetric.entityRole === "current";
- for (const [uuid, circuit] of Object.entries(topology.circuits)) {
+ for (const { uuid, circuit } of slots) {
const slot = root.querySelector(`.circuit-slot[data-uuid="${attrSelectorValue(uuid)}"]`);
if (!slot) continue;
diff --git a/src/core/grid-renderer.ts b/src/core/grid-renderer.ts
index 942821d..41bddb1 100644
--- a/src/core/grid-renderer.ts
+++ b/src/core/grid-renderer.ts
@@ -3,6 +3,7 @@ import { formatCircuitCurrentHTML, formatCircuitPowerHTML } from "../helpers/for
import { t } from "../i18n.js";
import { tabToRow, tabToCol, classifyDualTab, measuresOutsidePanel, type PositionRange } from "../helpers/layout.js";
import { getChartMetric } from "../helpers/chart.js";
+import { meterSlots } from "../helpers/shared-meters.js";
import { getCircuitMonitoringInfo } from "./monitoring-status.js";
import {
circuitCurrentA,
@@ -22,6 +23,7 @@ type SlotLayout = "single" | "row-span" | "col-span";
interface TabMapEntry {
uuid: string;
circuit: Circuit;
+ members: readonly Circuit[];
layout: SlotLayout;
}
@@ -39,14 +41,15 @@ export function buildGridHTML(
const tabMap = new Map
();
const occupiedTabs = new Set();
- for (const [uuid, circuit] of Object.entries(topology.circuits)) {
+ // Circuits that share a meter share a space, and are drawn there as one slot.
+ for (const { uuid, circuit, members } of meterSlots(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);
const layout: SlotLayout = tabs.length === 1 ? "single" : (classifyDualTab(tabs) ?? "single");
- tabMap.set(primaryTab, { uuid, circuit, layout });
+ tabMap.set(primaryTab, { uuid, circuit, members, layout });
for (const tab of tabs) occupiedTabs.add(tab);
}
@@ -93,7 +96,18 @@ export function buildGridHTML(
if (leftEntry && leftEntry.layout === "row-span") {
const { monInfo, sheddingPriority } = lookupMonitoring(leftEntry);
- gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, gridRow, "2 / 5", "row-span", hass, config, monInfo, sheddingPriority);
+ gridHTML += renderCircuitSlot(
+ leftEntry.uuid,
+ leftEntry.circuit,
+ gridRow,
+ "2 / 5",
+ "row-span",
+ hass,
+ config,
+ monInfo,
+ sheddingPriority,
+ leftEntry.members
+ );
gridHTML += rightLabel;
continue;
}
@@ -101,7 +115,18 @@ export function buildGridHTML(
if (!rowsToSkipLeft.has(row)) {
if (leftEntry && (leftEntry.layout === "col-span" || leftEntry.layout === "single")) {
const { monInfo, sheddingPriority } = lookupMonitoring(leftEntry);
- gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, gridRow, "2", leftEntry.layout, hass, config, monInfo, sheddingPriority);
+ gridHTML += renderCircuitSlot(
+ leftEntry.uuid,
+ leftEntry.circuit,
+ gridRow,
+ "2",
+ leftEntry.layout,
+ hass,
+ config,
+ monInfo,
+ sheddingPriority,
+ leftEntry.members
+ );
} else if (!occupiedTabs.has(leftTab) && inRange(leftTab)) {
gridHTML += renderEmptySlot(gridRow, "2");
}
@@ -110,7 +135,18 @@ export function buildGridHTML(
if (!rowsToSkipRight.has(row)) {
if (rightEntry && (rightEntry.layout === "col-span" || rightEntry.layout === "single")) {
const { monInfo, sheddingPriority } = lookupMonitoring(rightEntry);
- gridHTML += renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, gridRow, "4", rightEntry.layout, hass, config, monInfo, sheddingPriority);
+ gridHTML += renderCircuitSlot(
+ rightEntry.uuid,
+ rightEntry.circuit,
+ gridRow,
+ "4",
+ rightEntry.layout,
+ hass,
+ config,
+ monInfo,
+ sheddingPriority,
+ rightEntry.members
+ );
} else if (!occupiedTabs.has(rightTab) && inRange(rightTab)) {
gridHTML += renderEmptySlot(gridRow, "4");
}
@@ -122,7 +158,9 @@ export function buildGridHTML(
}
/**
- * Render a single circuit breaker slot.
+ * Render a single circuit breaker slot. With more than one member it is the slot
+ * of circuits that share a meter: `circuit` is the one it reads and switches
+ * through, and each member gives its name and rating.
*/
export function renderCircuitSlot(
uuid: string,
@@ -134,6 +172,7 @@ export function renderCircuitSlot(
config: CardConfig,
monitoringInfo: MonitoringPointInfo | null,
sheddingPriority: string,
+ members: readonly Circuit[] = [circuit],
inline = false
): string {
const powerW = circuitPowerW(circuit, hass);
@@ -142,9 +181,9 @@ export function renderCircuitSlot(
const isOn = relayClosed(circuit, hass);
const presence = switchPresence(circuit, hass);
- const breakerAmps = circuit.breaker_rating_a;
- const breakerLabel = breakerAmps ? `${Math.round(breakerAmps)}A` : "";
- const name = escapeHtml(circuit.name || t("grid.unknown"));
+ const shared = members.length > 1;
+ const breakerLabel = shared ? sharedRatingsLabel(members) : singleRatingLabel(circuit.breaker_rating_a);
+ const name = escapeHtml(members.map(member => member.name || t("grid.unknown")).join(" · "));
const showCurrent = getChartMetric(config).entityRole === "current";
const valueHTML = showCurrent ? formatCircuitCurrentHTML(circuitCurrentA(circuit, hass)) : formatCircuitPowerHTML(powerW);
@@ -186,7 +225,7 @@ export function renderCircuitSlot(
${valueHTML}
- ${buildTogglePillHTML(isOn, presence)}
+ ${buildTogglePillHTML(isOn, presence, shared && sharesRelay(members))}
@@ -198,6 +237,22 @@ export function renderCircuitSlot(
`;
}
+function singleRatingLabel(amps: number | null | undefined): string {
+ return amps ? `${Math.round(amps)}A` : "";
+}
+
+/** Each member's rating, as "15·10 A"; empty while any is unknown. */
+function sharedRatingsLabel(members: readonly Circuit[]): string {
+ if (members.some(member => !member.breaker_rating_a)) return "";
+ return `${members.map(member => Math.round(member.breaker_rating_a!)).join("·")} A`;
+}
+
+/** Whether every member switches with the same relay, so one toggle switches them all. */
+function sharesRelay(members: readonly Circuit[]): boolean {
+ const relay = members[0]?.shared_relay_group;
+ return !!relay && members.every(member => member.shared_relay_group === relay);
+}
+
/**
* Render an empty breaker slot.
*/
diff --git a/src/helpers/shared-meters.ts b/src/helpers/shared-meters.ts
new file mode 100644
index 0000000..39b7cf9
--- /dev/null
+++ b/src/helpers/shared-meters.ts
@@ -0,0 +1,52 @@
+import type { Circuit } from "../types.js";
+
+/**
+ * What one breaker slot draws: a circuit of its own, or the circuits that share
+ * one meter. Members of a group read identical meters, so the group is read,
+ * switched and counted through one of them and drawn once.
+ */
+export interface MeterSlot {
+ /** The circuit the slot reads and switches through: the group's key when that is a member, else its first. */
+ readonly uuid: string;
+ /** That circuit, carrying the group's combined rating, which is what the group is judged against. */
+ readonly circuit: Circuit;
+ /** Every circuit drawn in the slot, in topology order. */
+ readonly members: readonly Circuit[];
+}
+
+/**
+ * One slot per meter, in topology order: each group of circuits that share a
+ * meter once, at its first member's place, and every other circuit by itself.
+ * A circuit with no group is its own slot, unchanged.
+ */
+export function meterSlots(circuits: Record
): MeterSlot[] {
+ const groups = new Map();
+ for (const [uuid, circuit] of Object.entries(circuits)) {
+ const key = circuit.shared_meter_group;
+ if (key) groups.set(key, [...(groups.get(key) ?? []), [uuid, circuit]]);
+ }
+
+ const slots: MeterSlot[] = [];
+ for (const [uuid, circuit] of Object.entries(circuits)) {
+ const key = circuit.shared_meter_group;
+ const group = key ? groups.get(key) : undefined;
+ if (!key || !group || group.length === 1) {
+ slots.push({ uuid, circuit, members: [circuit] });
+ } else if (group[0]![0] === uuid) {
+ const [throughId, through] = group.find(([id]) => id === key) ?? group[0]!;
+ const members = group.map(([, member]) => member);
+ slots.push({ uuid: throughId, circuit: { ...through, breaker_rating_a: combinedRating(members) }, members });
+ }
+ }
+ return slots;
+}
+
+/** The members' ratings summed, or null while any member's is unknown. */
+function combinedRating(members: readonly Circuit[]): number | null {
+ let total = 0;
+ for (const member of members) {
+ if (!member.breaker_rating_a) return null;
+ total += member.breaker_rating_a;
+ }
+ return total;
+}
diff --git a/src/i18n.ts b/src/i18n.ts
index f9ea2ef..f27406d 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -185,6 +185,7 @@ const translations: Record> = {
"grid.configure_subdevice": "Configure device",
"grid.on": "On",
"grid.off": "Off",
+ "grid.shared_switch": "Switches every circuit in this space",
// Meters outside the panel
"meters.title": "Meters",
@@ -441,6 +442,7 @@ const translations: Record> = {
"grid.configure_subdevice": "Configurar dispositivo",
"grid.on": "Enc",
"grid.off": "Apag",
+ "grid.shared_switch": "Conmuta todos los circuitos de este espacio",
"meters.title": "Medidores",
"meters.importing": "importando",
"meters.exporting": "exportando",
@@ -682,6 +684,7 @@ const translations: Record> = {
"grid.configure_subdevice": "Configurer l'appareil",
"grid.on": "On",
"grid.off": "Off",
+ "grid.shared_switch": "Commute tous les circuits de cet emplacement",
"meters.title": "Compteurs",
"meters.importing": "en import",
"meters.exporting": "en export",
@@ -933,6 +936,7 @@ const translations: Record> = {
"grid.configure_subdevice": "\u30c7\u30d0\u30a4\u30b9\u3092\u8a2d\u5b9a",
"grid.on": "\u30aa\u30f3",
"grid.off": "\u30aa\u30d5",
+ "grid.shared_switch": "\u3053\u306e\u30b9\u30da\u30fc\u30b9\u306e\u3059\u3079\u3066\u306e\u56de\u8def\u3092\u5207\u308a\u66ff\u3048\u307e\u3059",
"meters.title": "\u30e1\u30fc\u30bf\u30fc",
"meters.importing": "\u53d7\u96fb\u4e2d",
"meters.exporting": "\u9001\u96fb\u4e2d",
@@ -1177,6 +1181,7 @@ const translations: Record> = {
"grid.configure_subdevice": "Configurar dispositivo",
"grid.on": "Lig",
"grid.off": "Des",
+ "grid.shared_switch": "Liga e desliga todos os circuitos deste espa\u00e7o",
"meters.title": "Medidores",
"meters.importing": "importando",
"meters.exporting": "exportando",
diff --git a/src/types.ts b/src/types.ts
index 5361898..a5c8ac2 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -53,6 +53,10 @@ export interface Circuit {
* power is import-positive, positive while power flows into the panel.
*/
outside_panel?: boolean;
+ /** The group of circuits that share this circuit's meter, by its key; null when it shares none. */
+ shared_meter_group?: string | null;
+ /** The group of circuits that share this circuit's relay, by its key; null when it shares none. */
+ shared_relay_group?: string | null;
}
/**
diff --git a/tests/__snapshots__/capture-topologies.test.ts.snap b/tests/__snapshots__/capture-topologies.test.ts.snap
index abb03c8..b583a2f 100644
--- a/tests/__snapshots__/capture-topologies.test.ts.snap
+++ b/tests/__snapshots__/capture-topologies.test.ts.snap
@@ -3409,16 +3409,16 @@ exports[`the card over capture 'r202639-e' > renders as before > grid 1`] = `
-