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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.9.6

### Fixed

- **When the integration cannot report the panel's layout, the card still finds every circuit from its power sensor.**
- **A reading the panel has not published shows as "--", never 0.**

## 0.9.5

### Added
Expand Down
20 changes: 10 additions & 10 deletions dist/span-panel-card.js

Large diffs are not rendered by default.

36 changes: 18 additions & 18 deletions dist/span-panel.js

Large diffs are not rendered by default.

60 changes: 32 additions & 28 deletions src/card/card-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ function deviceToPanelDevice(entry: DeviceRegistryEntry | undefined): PanelDevic

// ── Fallback discovery from entity registry ──────────────────────────────────

const POWER_SUFFIX = "_power";

/**
* The circuit id in a circuit power sensor's unique id, null for any other
* sensor. The unique id is `span_{serial}_{circuit id}_power`, the serial
* lower-cased, and the circuit id is opaque: everything between that prefix
* and the suffix, `_` included. Where the unique id does not start with the
* panel's serial, the serial is taken to be its second `_` segment.
*/
function circuitIdOfPowerSensor(uniqueId: string, serial: string): string | null {
if (!uniqueId.endsWith(POWER_SUFFIX)) return null;
const prefix = `span_${serial.toLowerCase()}_`;
const circuitId = serial && uniqueId.startsWith(prefix) ? uniqueId.slice(prefix.length, -POWER_SUFFIX.length) : uniqueId.split("_").slice(2, -1).join("_");
return circuitId || null;
}

export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: string | undefined, retry?: RetryManager | null): Promise<DiscoveryResult> {
const devicesMsg = { type: "config/device_registry/list" };
const entitiesMsg = { type: "config/entity_registry/list" };
Expand All @@ -113,6 +129,19 @@ export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: st
const subDeviceIds = new Set(subDevices.map(d => d.id));
const subEntities = entities.filter(e => e.device_id !== undefined && subDeviceIds.has(e.device_id));

let serial = "";
if (panelDevice.identifiers) {
for (const pair of panelDevice.identifiers) {
// Identifier pairs are [domain, value]. Skip malformed shapes rather
// than silently indexing past the end.
if (!Array.isArray(pair) || pair.length < 2) continue;
const [domain, value] = pair;
if (domain === INTEGRATION_DOMAIN && typeof value === "string") {
serial = value;
}
}
}

const circuits: Record<string, Circuit> = {};
const devName = panelDevice.name_by_user ?? panelDevice.name ?? "";

Expand All @@ -132,24 +161,12 @@ export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: st
}
if (!tabs.every(Number.isFinite)) continue;

const uidParts = ent.unique_id.split("_");
let circuitUuid: string | null = null;
for (let i = 2; i < uidParts.length - 1; i++) {
const part = uidParts[i];
if (part !== undefined && part.length >= 16 && /^[a-f0-9]+$/i.test(part)) {
circuitUuid = part;
break;
}
}
// A circuit's sensors all carry its tabs; its power sensor alone stands for it.
const circuitUuid = circuitIdOfPowerSensor(ent.unique_id, serial);
if (!circuitUuid) continue;

let displayName = (typeof attrs.friendly_name === "string" ? attrs.friendly_name : undefined) ?? ent.entity_id;
for (const suffix of [" Power", " Consumed Energy", " Produced Energy"]) {
if (displayName.endsWith(suffix)) {
displayName = displayName.slice(0, -suffix.length);
break;
}
}
if (displayName.endsWith(" Power")) displayName = displayName.slice(0, -" Power".length);
if (devName && displayName.startsWith(devName + " ")) {
displayName = displayName.slice(devName.length + 1);
}
Expand All @@ -176,19 +193,6 @@ export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: st
};
}

let serial = "";
if (panelDevice.identifiers) {
for (const pair of panelDevice.identifiers) {
// Identifier pairs are [domain, value]. Skip malformed shapes rather
// than silently indexing past the end.
if (!Array.isArray(pair) || pair.length < 2) continue;
const [domain, value] = pair;
if (domain === INTEGRATION_DOMAIN && typeof value === "string") {
serial = value;
}
}
}

let panelSize = 0;
for (const ent of allEntities) {
const state = hass.states[ent.entity_id];
Expand Down
3 changes: 3 additions & 0 deletions src/card/card-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ export const CARD_STYLES: string = `
.stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }
.stat-row { display: flex; align-items: baseline; gap: 2px; }
.stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }
/* A sum that left out a term with no reading: dimmed, and marked as at least this much. */
.stat-value.stat-partial { opacity: 0.7; }
.stat-value.stat-partial::after { content: "+"; }
.stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }

.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }
Expand Down
40 changes: 39 additions & 1 deletion src/core/circuit-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RELAY_STATE_CLOSED, SELECTABLE_PRIORITY_KEYS } from "../constants.js";
import { DEVICE_TYPE_PV, RELAY_STATE_CLOSED, SELECTABLE_PRIORITY_KEYS } from "../constants.js";
import { readNumber } from "../helpers/read-number.js";
import { isAlertActive } from "./monitoring-status.js";
import type { Circuit, HomeAssistant, MonitoringPointInfo } from "../types.js";

Expand All @@ -16,6 +17,43 @@ export function getCircuitStateClasses(_circuit: Circuit, monitoringInfo: Monito
return classes.join(" ");
}

/** 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;
return readNumber(entityId ? hass.states[entityId] : undefined);
}

/** A circuit's current reading in amps, or null where its sensor has none. */
export function circuitCurrentA(c: Pick<Circuit, "entities">, hass: HomeAssistant): number | null {
const entityId = c.entities?.current;
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);
}

/**
* A circuit's breaker utilization in percent: the monitor's figure, else its
* measured current over its breaker rating. Null when neither is known, never 0 %.
*
* A circuit whose current sensor has no reading has no utilization, whatever
* the monitor says: the monitor can report 0 % before its first reading, and
* keeps its last figure after the sensor goes unavailable.
*/
export function circuitUtilizationPct(
c: Pick<Circuit, "entities" | "breaker_rating_a">,
hass: HomeAssistant,
monitoringInfo: MonitoringPointInfo | null
): number | null {
const amps = circuitCurrentA(c, hass);
if (c.entities?.current && amps === null) return null;
const monitored = monitoringInfo?.utilization_pct ?? null;
if (monitored !== null || amps === null || !c.breaker_rating_a) return monitored;
return Math.round((Math.abs(amps) / c.breaker_rating_a) * 1000) / 10;
}

/**
* The fields every answer below reads. Structural, so both `Circuit` and the
* side panel's circuit config satisfy it.
Expand Down
13 changes: 5 additions & 8 deletions src/core/dashboard-controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DEFAULT_GRAPH_HORIZON, GRAPH_HORIZONS, INTEGRATION_DOMAIN, LIVE_SAMPLE_INTERVAL_MS } from "../constants.js";
import { getCircuitChartEntity } from "../helpers/chart.js";
import { getHorizonDurationMs, getMaxHistoryPoints, getMinGapMs, recordSample } from "../helpers/history.js";
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";
Expand Down Expand Up @@ -279,10 +280,8 @@ export class DashboardController {

const entityId = getCircuitChartEntity(circuit, this._config);
if (!entityId) continue;
const state = this._hass.states[entityId];
if (!state) continue;
const val = parseFloat(state.state);
if (isNaN(val)) continue;
const val = readNumber(this._hass.states[entityId]);
if (val === null) continue;

const durationMs = getHorizonDurationMs(horizon);
const maxPoints = getMaxHistoryPoints(durationMs);
Expand All @@ -299,10 +298,8 @@ export class DashboardController {
const horizon = this.subDeviceHorizonMap.get(devId) ?? DEFAULT_GRAPH_HORIZON;
if (!GRAPH_HORIZONS[horizon]?.useRealtime) continue;

const state = this._hass.states[entityId];
if (!state) continue;
const val = parseFloat(state.state);
if (isNaN(val)) continue;
const val = readNumber(this._hass.states[entityId]);
if (val === null) continue;

const durationMs = getHorizonDurationMs(horizon);
const maxPoints = getMaxHistoryPoints(durationMs);
Expand Down
Loading
Loading