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
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ dist/
# Dependencies
node_modules/

# Fixtures copied byte for byte from the integration's test fixtures
tests/fixtures/

# IDE files
.vscode/
.idea/
Expand Down
11 changes: 8 additions & 3 deletions src/core/circuit-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ import { escapeHtml } from "../helpers/sanitize.js";
import type { SwitchPresence } from "./circuit-state.js";
import type { SheddingPriorityDef } from "../types.js";

/** The pill for a breaker's switch: none without one, dimmed and inert while it is unavailable. */
export function buildTogglePillHTML(isOn: boolean, presence: SwitchPresence): string {
/**
* The pill for a breaker's switch: none without one, dimmed and inert while it is
* unavailable, and titled as switching the group when circuits share its relay.
*/
export function buildTogglePillHTML(isOn: boolean, presence: SwitchPresence, shared = false): string {
if (presence === "none") return "";
const classes = ["toggle-pill", isOn ? "toggle-on" : "toggle-off"];
if (presence === "inert") classes.push("toggle-unavailable");
return `<div class="${classes.join(" ")}">
if (shared) classes.push("toggle-shared");
const title = shared ? ` title="${escapeHtml(t("grid.shared_switch"))}"` : "";
return `<div class="${classes.join(" ")}"${title}>
<span class="toggle-label">${isOn ? t("grid.on") : t("grid.off")}</span>
<span class="toggle-knob"></span>
</div>`;
Expand Down
14 changes: 11 additions & 3 deletions src/core/circuit-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,17 @@ export function circuitCurrentA(c: Pick<Circuit, "entities">, hass: HomeAssistan
return readNumber(entityId ? hass.states[entityId] : undefined);
}

/** Whether a circuit draws as production: a solar circuit, or one measured feeding power back. An unknown reading is not production. */
export function readsAsProducer(c: Pick<Circuit, "device_type">, powerW: number | null): boolean {
return c.device_type === DEVICE_TYPE_PV || (powerW !== null && powerW < 0);
/**
* Whether a circuit is a source of generation: as the topology flags it, else, for
* a topology that carries no flag, by its device type.
*/
export function isGeneration(c: Pick<Circuit, "device_type" | "is_generation">): boolean {
return c.is_generation ?? c.device_type === DEVICE_TYPE_PV;
}

/** Whether a circuit draws as production: a source of generation, or one measured feeding power back. An unknown reading is not production. */
export function readsAsProducer(c: Pick<Circuit, "device_type" | "is_generation">, powerW: number | null): boolean {
return isGeneration(c) || (powerW !== null && powerW < 0);
}

/**
Expand Down
6 changes: 2 additions & 4 deletions src/core/dashboard-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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 { MonitoringStatusCache, MonitoringStatusMultiCache, mergeMonitoringStatuses, monitoringStatusFrom } from "./monitoring-status.js";
import { GraphSettingsCache } from "./graph-settings.js";
import { groupFavoritesByPanel } from "./favorites-sections.js";
import { switchPresence } from "./circuit-state.js";
Expand Down Expand Up @@ -632,9 +632,7 @@ export class DashboardController {
errorMessage: t("error.monitoring_failed"),
})
: await this._hass.callWS<{ response?: MonitoringStatusResponse }>(msg);
const response = resp?.response;
if (!response) return null;
return { circuits: response.circuits, mains: response.mains };
return monitoringStatusFrom(resp?.response);
} catch (err) {
console.warn("SPAN Panel: fresh monitoring status fetch failed", err);
return null;
Expand Down
31 changes: 15 additions & 16 deletions src/core/dom-updater.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import {
BESS_CHART_METRICS,
DEVICE_TYPE_PV,
CIRCUIT_CHART_HEIGHT,
CIRCUIT_COL_SPAN_CHART_HEIGHT,
BESS_CHART_COL_HEIGHT,
EVSE_CHART_HEIGHT,
} from "../constants.js";
import { BESS_CHART_METRICS, CIRCUIT_CHART_HEIGHT, CIRCUIT_COL_SPAN_CHART_HEIGHT, BESS_CHART_COL_HEIGHT, EVSE_CHART_HEIGHT } from "../constants.js";
import { formatCircuitCurrentHTML, formatCircuitPowerHTML, formatPowerHTML, formatKw, UNKNOWN_READING } from "../helpers/format.js";
import { readNumber, sumReadings, type ReadingSum } from "../helpers/read-number.js";
import { getChartMetric } from "../helpers/chart.js";
Expand All @@ -14,7 +7,8 @@ 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 { circuitCurrentA, circuitPowerW, drawsAsOn, readsAsProducer, shedPriorityKey, switchPresence } from "./circuit-state.js";
import { meterSlots } from "../helpers/shared-meters.js";
import { circuitCurrentA, circuitPowerW, drawsAsOn, isGeneration, 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,11 +145,12 @@ 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.
// The circuits' consumption: every circuit with a power sensor that is no source of generation, an unknown reading skipped.
// 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)) {
if (!circuit.entities?.power || circuit.device_type === DEVICE_TYPE_PV || measuresOutsidePanel(circuit)) continue;
for (const { circuit } of slots) {
if (!circuit.entities?.power || isGeneration(circuit) || measuresOutsidePanel(circuit)) continue;
const power = circuitPowerW(circuit, hass);
consumption.push(power === null ? null : Math.abs(power));
}
Expand All @@ -165,7 +160,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;

Expand All @@ -192,7 +187,7 @@ export function updateCircuitDOM(
const history = powerHistory.get(uuid) || [];
const h = slot.classList.contains("circuit-col-span") ? CIRCUIT_COL_SPAN_CHART_HEIGHT : CIRCUIT_CHART_HEIGHT;
const circuitDuration = horizonMap?.has(uuid) ? getHorizonDurationMs(horizonMap.get(uuid)!) : defaultDurationMs;
const useLinear = circuit.device_type === DEVICE_TYPE_PV;
const useLinear = isGeneration(circuit);
updateChart(chartContainer, hass, history, circuitDuration, chartMetric, isProducer, h, circuit.breaker_rating_a ?? undefined, useLinear);
}
}
Expand All @@ -219,8 +214,12 @@ export function updateSubDeviceDOM(
if (powerEl) powerEl.innerHTML = formatPowerHTML(stateWatts(hass, power.headlineEid));
}
if (power.siteTotalEid) {
const watts = stateWatts(hass, power.siteTotalEid);
const totalEl = section.querySelector(".sub-site-total-value");
if (totalEl) totalEl.innerHTML = formatPowerHTML(stateWatts(hass, power.siteTotalEid));
if (totalEl) totalEl.innerHTML = formatPowerHTML(watts);
// An unknown site total is no row, never one reading 0.
const rowEl = totalEl?.closest<HTMLElement>(".sub-entity");
if (rowEl) rowEl.hidden = watts === null;
}

const chartContainers = section.querySelectorAll("[data-chart-key]");
Expand Down
75 changes: 65 additions & 10 deletions src/core/grid-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +23,7 @@ type SlotLayout = "single" | "row-span" | "col-span";
interface TabMapEntry {
uuid: string;
circuit: Circuit;
members: readonly Circuit[];
layout: SlotLayout;
}

Expand All @@ -39,14 +41,15 @@ export function buildGridHTML(
const tabMap = new Map<number, TabMapEntry>();
const occupiedTabs = new Set<number>();

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);
}

Expand Down Expand Up @@ -93,15 +96,37 @@ 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;
}

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");
}
Expand All @@ -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");
}
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -186,7 +225,7 @@ export function renderCircuitSlot(
<span class="power-value">
${valueHTML}
</span>
${buildTogglePillHTML(isOn, presence)}
${buildTogglePillHTML(isOn, presence, shared && sharesRelay(members))}
</div>
</div>
<div class="circuit-status">
Expand All @@ -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.
*/
Expand Down
16 changes: 13 additions & 3 deletions src/core/monitoring-status.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { INTEGRATION_DOMAIN } from "../constants.js";
import { t } from "../i18n.js";
import { RetryManager } from "./retry-manager.js";
import type { HomeAssistant, MonitoringPointInfo, MonitoringStatus } from "../types.js";
import type { HomeAssistant, MonitoringPointInfo, MonitoringStatus, MonitoringStatusResponse } from "../types.js";
import type { ErrorStore } from "./error-store.js";

const MONITORING_POLL_INTERVAL_MS = 30_000;

interface CallServiceResponse {
response?: MonitoringStatus;
response?: MonitoringStatusResponse;
}

/**
* The status a `get_monitoring_status` answer gives, or null when it gives none.
* With monitoring off the integration answers `enabled: false` and no points:
* that is no monitoring, not monitoring of zero circuits and zero mains.
*/
export function monitoringStatusFrom(response: MonitoringStatusResponse | null | undefined): MonitoringStatus | null {
if (!response || response.enabled === false) return null;
return { circuits: response.circuits, mains: response.mains };
}

/**
Expand Down Expand Up @@ -67,7 +77,7 @@ export class MonitoringStatusCache {
errorMessage: t("error.monitoring_failed"),
})
: await hass.callWS<CallServiceResponse>(msg);
const next = resp?.response ?? null;
const next = monitoringStatusFrom(resp?.response);
if (requestGen === this._generation) {
this._status = next;
this._lastFetch = Date.now();
Expand Down
9 changes: 6 additions & 3 deletions src/core/sub-device-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,16 @@ function tileLabel(sub: SubDevice): string {
*/
export function buildSolarDetailHTML(solar: SubDeviceSolar, power: SubDevicePower, hass: HomeAssistant): string {
let html = "";
const identity: string = [solar.vendor, solar.model].filter((part): part is string => !!part).join(" · ");
// eBus connection/feeds-role SOLAR: a source known only by a circuit's role has no vendor or model to show.
const identity: string = solar.identity === "role" ? "" : [solar.vendor, solar.model].filter((part): part is string => !!part).join(" · ");
if (identity) html += `<div class="sub-identity">${escapeHtml(identity)}</div>`;
if (power.siteTotalEid) {
// Held hidden while the site total is unknown, so it never reads 0; the live update shows it once it reads.
const watts = stateWatts(hass, power.siteTotalEid);
html += `
<div class="sub-entity">
<div class="sub-entity"${watts === null ? " hidden" : ""}>
<span class="sub-entity-name">${escapeHtml(t("subdevice.site_total"))}:</span>
<span class="sub-site-total-value" data-site-total-eid="${escapeHtml(power.siteTotalEid)}">${formatPowerHTML(stateWatts(hass, power.siteTotalEid))}</span>
<span class="sub-site-total-value" data-site-total-eid="${escapeHtml(power.siteTotalEid)}">${formatPowerHTML(watts)}</span>
</div>`;
}
if (solar.role === "inverter" && !power.headlineEid) html += `<div class="sub-note">${escapeHtml(t("subdevice.in_site_total"))}</div>`;
Expand Down
Loading
Loading