mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-25 22:14:13 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 824ab4fbe0 | |||
| 5cf7fb3a3d | |||
| b6813ce060 | |||
| 3a060feb82 | |||
| ab39c4dd4a | |||
| 82167a81f7 |
@@ -0,0 +1,85 @@
|
||||
import { stripBoundaryLabel } from "../string/strip_boundary_label";
|
||||
|
||||
export interface AreaForNameMatch {
|
||||
area_id: string;
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
export interface DeviceAreaSuggestion {
|
||||
name: string;
|
||||
area?: string;
|
||||
}
|
||||
|
||||
const areaLabels = (area: AreaForNameMatch): string[] =>
|
||||
[area.name, ...(area.aliases ?? [])].filter(Boolean);
|
||||
|
||||
// Longest matching label (name or alias) of a single area, and the device name
|
||||
// once that label is stripped off. `null` when the area does not match.
|
||||
const matchArea = (
|
||||
name: string,
|
||||
area: AreaForNameMatch
|
||||
): { strippedName: string; labelLength: number } | null => {
|
||||
let best: { strippedName: string; labelLength: number } | null = null;
|
||||
for (const label of areaLabels(area)) {
|
||||
const strippedName = stripBoundaryLabel(name, label);
|
||||
if (strippedName === null) {
|
||||
continue;
|
||||
}
|
||||
if (!best || label.length > best.labelLength) {
|
||||
best = { strippedName, labelLength: label.length };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
// The area with the longest matching label, and the stripped device name.
|
||||
const bestAreaMatch = (
|
||||
name: string,
|
||||
areas: AreaForNameMatch[]
|
||||
): { areaId: string; strippedName: string; labelLength: number } | null => {
|
||||
let best: {
|
||||
areaId: string;
|
||||
strippedName: string;
|
||||
labelLength: number;
|
||||
} | null = null;
|
||||
for (const area of areas) {
|
||||
const match = matchArea(name, area);
|
||||
if (match && (!best || match.labelLength > best.labelLength)) {
|
||||
best = { areaId: area.area_id, ...match };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggests a cleaned device name (and an area) when an area name or alias is a
|
||||
* prefix or suffix of the device name.
|
||||
*
|
||||
* - When the device already has an area, only match against that area, so an
|
||||
* assigned area is never overridden — just the name is cleaned.
|
||||
* - Otherwise pick the area with the longest matching name/alias and suggest it.
|
||||
* - The longest match wins with no fallback: if the name is exactly the area,
|
||||
* nothing is left to keep and the result is a no-op (`null`).
|
||||
*/
|
||||
export const computeDeviceAreaSuggestion = (
|
||||
deviceName: string | null | undefined,
|
||||
currentAreaId: string | null | undefined,
|
||||
areas: AreaForNameMatch[]
|
||||
): DeviceAreaSuggestion | null => {
|
||||
const name = deviceName?.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentAreaId) {
|
||||
const area = areas.find((a) => a.area_id === currentAreaId);
|
||||
const match = area ? matchArea(name, area) : null;
|
||||
return match?.strippedName ? { name: match.strippedName } : null;
|
||||
}
|
||||
|
||||
const match = bestAreaMatch(name, areas);
|
||||
return match?.strippedName
|
||||
? { name: match.strippedName, area: match.areaId }
|
||||
: null;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const SEPARATORS = "\\s\\-_.";
|
||||
const LEADING_SEPARATORS = new RegExp(`^[${SEPARATORS}]+`);
|
||||
const TRAILING_SEPARATORS = new RegExp(`[${SEPARATORS}]+$`);
|
||||
const STARTS_WITH_SEPARATOR = new RegExp(`^[${SEPARATORS}]`);
|
||||
const ENDS_WITH_SEPARATOR = new RegExp(`[${SEPARATORS}]$`);
|
||||
|
||||
/**
|
||||
* Strips `label` from the start or end of `text` on a word boundary,
|
||||
* case-insensitively, and trims the surrounding separators.
|
||||
*
|
||||
* Returns:
|
||||
* - `""` when `text` equals `label`,
|
||||
* - the trimmed remainder for a prefix or suffix match,
|
||||
* - `null` when `label` is not a word-boundary prefix or suffix of `text`.
|
||||
*/
|
||||
export const stripBoundaryLabel = (
|
||||
text: string,
|
||||
label: string
|
||||
): string | null => {
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerLabel = label.toLowerCase();
|
||||
|
||||
if (lowerText === lowerLabel) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (lowerText.startsWith(lowerLabel)) {
|
||||
const rest = text.slice(label.length);
|
||||
if (STARTS_WITH_SEPARATOR.test(rest)) {
|
||||
return rest.replace(LEADING_SEPARATORS, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerText.endsWith(lowerLabel)) {
|
||||
const rest = text.slice(0, text.length - label.length);
|
||||
if (ENDS_WITH_SEPARATOR.test(rest)) {
|
||||
return rest.replace(TRAILING_SEPARATORS, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -4,6 +4,23 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import QRCode from "qrcode";
|
||||
import "./ha-alert";
|
||||
import { rgb2hex } from "../common/color/convert-color";
|
||||
import {
|
||||
getContrastedColorHex,
|
||||
getRGBContrastRatio,
|
||||
} from "../common/color/rgb";
|
||||
|
||||
// Minimum contrast ratio (WCAG non-text minimum) below which we substitute a
|
||||
// legible foreground so the QR code never renders unscannable.
|
||||
const MIN_CONTRAST_RATIO = 3;
|
||||
|
||||
const parseRgbVariable = (
|
||||
value: string
|
||||
): [number, number, number] | undefined => {
|
||||
const parts = value.split(",").map((part) => parseInt(part, 10));
|
||||
return parts.length === 3 && parts.every((part) => !Number.isNaN(part))
|
||||
? (parts as [number, number, number])
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@customElement("ha-qr-code")
|
||||
export class HaQrCode extends LitElement {
|
||||
@@ -60,27 +77,27 @@ export class HaQrCode extends LitElement {
|
||||
changedProperties.has("centerImage"))
|
||||
) {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const textRgb = computedStyles.getPropertyValue(
|
||||
"--rgb-primary-text-color"
|
||||
const textRgb = parseRgbVariable(
|
||||
computedStyles.getPropertyValue("--rgb-primary-text-color")
|
||||
);
|
||||
const backgroundRgb = computedStyles.getPropertyValue(
|
||||
"--rgb-card-background-color"
|
||||
);
|
||||
const textHex = rgb2hex(
|
||||
textRgb.split(",").map((a) => parseInt(a, 10)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
);
|
||||
const backgroundHex = rgb2hex(
|
||||
backgroundRgb.split(",").map((a) => parseInt(a, 10)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
const backgroundRgb = parseRgbVariable(
|
||||
computedStyles.getPropertyValue("--rgb-card-background-color")
|
||||
);
|
||||
|
||||
const backgroundHex = backgroundRgb ? rgb2hex(backgroundRgb) : "#ffffff";
|
||||
let textHex = textRgb ? rgb2hex(textRgb) : "#000000";
|
||||
|
||||
// If the theme colors are missing/invalid or don't contrast enough, the
|
||||
// QR code would be unscannable (e.g. white-on-white). Fall back to a
|
||||
// foreground guaranteed to contrast the resolved background.
|
||||
if (
|
||||
!textRgb ||
|
||||
!backgroundRgb ||
|
||||
getRGBContrastRatio(textRgb, backgroundRgb) < MIN_CONTRAST_RATIO
|
||||
) {
|
||||
textHex = getContrastedColorHex(backgroundHex);
|
||||
}
|
||||
|
||||
QRCode.toCanvas(canvas, this.data, {
|
||||
errorCorrectionLevel:
|
||||
this.errorCorrectionLevel || (this.centerImage ? "Q" : "M"),
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface NumberDeviceClassUnits {
|
||||
units: string[];
|
||||
}
|
||||
|
||||
const AUTO_MODE_MAX_STEPS = 256;
|
||||
|
||||
export const showNumberSlider = (stateObj: HassEntity): boolean => {
|
||||
const { mode, min, max, step } = stateObj.attributes;
|
||||
if (mode === "slider") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
mode === "auto" &&
|
||||
(Number(max) - Number(min)) / Number(step) <= AUTO_MODE_MAX_STEPS
|
||||
);
|
||||
};
|
||||
|
||||
export const getNumberDeviceClassConvertibleUnits = (
|
||||
hass: HomeAssistant,
|
||||
deviceClass: string
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isTiltOnly } from "../../../data/cover";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import type { ImageEntity } from "../../../data/image";
|
||||
import { computeImageUrl } from "../../../data/image";
|
||||
import { showNumberSlider } from "../../../data/number";
|
||||
import "../../../panels/lovelace/components/hui-timestamp-display";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
@@ -249,15 +250,9 @@ class EntityPreviewRow extends LitElement {
|
||||
}
|
||||
|
||||
if (domain === "number") {
|
||||
const showNumberSlider =
|
||||
stateObj.attributes.mode === "slider" ||
|
||||
(stateObj.attributes.mode === "auto" &&
|
||||
(Number(stateObj.attributes.max) - Number(stateObj.attributes.min)) /
|
||||
Number(stateObj.attributes.step) <=
|
||||
256);
|
||||
return html`
|
||||
${
|
||||
showNumberSlider
|
||||
showNumberSlider(stateObj)
|
||||
? html`
|
||||
<div class="numberflex">
|
||||
<ha-slider
|
||||
|
||||
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeDeviceAreaSuggestion } from "../../common/entity/compute_device_area_suggestion";
|
||||
import {
|
||||
computeDeviceName,
|
||||
computeDeviceNameDisplay,
|
||||
@@ -72,6 +73,10 @@ class StepFlowCreateEntry extends LitElement {
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("devices")) {
|
||||
this._suggestDeviceUpdates();
|
||||
}
|
||||
|
||||
if (!changedProps.has("devices") && !changedProps.has("hass")) {
|
||||
return;
|
||||
}
|
||||
@@ -103,6 +108,32 @@ class StepFlowCreateEntry extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _suggestDeviceUpdates() {
|
||||
if (!this.hass || !this.devices?.length) {
|
||||
return;
|
||||
}
|
||||
const areas = Object.values(this.hass.areas);
|
||||
const updates = { ...this._deviceUpdate };
|
||||
let changed = false;
|
||||
for (const device of this.devices) {
|
||||
if (device.id in updates || device.name_by_user) {
|
||||
continue;
|
||||
}
|
||||
const suggestion = computeDeviceAreaSuggestion(
|
||||
computeDeviceName(device),
|
||||
device.area_id,
|
||||
areas
|
||||
);
|
||||
if (suggestion) {
|
||||
updates[device.id] = suggestion;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this._deviceUpdate = updates;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const localize = this.hass.localize;
|
||||
const domains = this.step.result
|
||||
|
||||
@@ -78,18 +78,21 @@ class MoreInfoLawnMower extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseIcon(): string {
|
||||
if (!this.stateObj) return mdiPlay;
|
||||
return isMowing(this.stateObj) &&
|
||||
private get _showPause(): boolean {
|
||||
if (!this.stateObj) return false;
|
||||
return (
|
||||
isMowing(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
? mdiPause
|
||||
: mdiPlay;
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseIcon(): string {
|
||||
return this._showPause ? mdiPause : mdiPlay;
|
||||
}
|
||||
|
||||
private get _startPauseLabel(): string {
|
||||
if (!this.stateObj) return "";
|
||||
return isMowing(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
return this._showPause
|
||||
? this._i18n.localize("ui.dialogs.more_info_control.lawn_mower.pause")
|
||||
: this._i18n.localize(
|
||||
"ui.dialogs.more_info_control.lawn_mower.start_mowing"
|
||||
@@ -99,8 +102,11 @@ class MoreInfoLawnMower extends LitElement {
|
||||
private get _startPauseDisabled(): boolean {
|
||||
if (!this.stateObj) return true;
|
||||
if (this.stateObj.state === UNAVAILABLE) return true;
|
||||
if (isMowing(this.stateObj)) return false;
|
||||
return !canStartMowing(this.stateObj);
|
||||
if (this._showPause) return false;
|
||||
return (
|
||||
!supportsFeature(this.stateObj, LawnMowerEntityFeature.START_MOWING) ||
|
||||
!canStartMowing(this.stateObj)
|
||||
);
|
||||
}
|
||||
|
||||
private _renderBattery() {
|
||||
@@ -156,7 +162,7 @@ class MoreInfoLawnMower extends LitElement {
|
||||
private _handleStartPause() {
|
||||
if (!this.stateObj) return;
|
||||
forwardHaptic(this, "light");
|
||||
if (isMowing(this.stateObj)) {
|
||||
if (this._showPause) {
|
||||
this._api.callService("lawn_mower", "pause", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ import "../../../components/ha-svg-icon";
|
||||
import { apiContext } from "../../../data/context";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { LawnMowerEntity } from "../../../data/lawn_mower";
|
||||
import { LawnMowerEntityFeature, canDock } from "../../../data/lawn_mower";
|
||||
import {
|
||||
LawnMowerEntityFeature,
|
||||
canDock,
|
||||
canStartMowing,
|
||||
} from "../../../data/lawn_mower";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../../../types";
|
||||
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
import { cardFeatureStyles } from "./common/card-feature-styles";
|
||||
@@ -72,6 +76,9 @@ export const LAWN_MOWER_COMMANDS_BUTTONS: Record<
|
||||
translationKey: "start",
|
||||
icon: mdiPlay,
|
||||
serviceName: "start_mowing",
|
||||
disabled:
|
||||
!supportsFeature(stateObj, LawnMowerEntityFeature.START_MOWING) ||
|
||||
!canStartMowing(stateObj),
|
||||
};
|
||||
},
|
||||
dock: (stateObj) => ({
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { getGraphColorByIndex } from "../../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../../common/entity/context/get_entity_context";
|
||||
import type {
|
||||
Link,
|
||||
Node,
|
||||
} from "../../../../../components/chart/ha-sankey-chart";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../../data/energy";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
// Devices below this fraction of the home total are grouped into an "Other" node
|
||||
export const MIN_SANKEY_THRESHOLD_FACTOR = 0.001; // 0.1% of home total
|
||||
|
||||
// While the graph is assembled, device nodes carry an ad-hoc `parent` field
|
||||
// that is not part of the chart's Node interface. The chart ignores it.
|
||||
export type SankeyDeviceNode = Node & { parent?: string };
|
||||
|
||||
interface SmallConsumer {
|
||||
id: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
const OTHER_LABEL_KEY =
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other";
|
||||
const UNTRACKED_LABEL_KEY =
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption";
|
||||
|
||||
/** Open more-info for a clicked sankey node that maps to an entity. */
|
||||
export const fireSankeyNodeMoreInfo = (el: HTMLElement, node: Node): void => {
|
||||
if (node.entityId) {
|
||||
fireEvent(el, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
};
|
||||
|
||||
export interface BuildSankeyDeviceNodesOptions {
|
||||
devices: DeviceConsumptionEnergyPreference[];
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
localize: HomeAssistant["localize"];
|
||||
/** Node small consumers and untracked flow attach to (usually "home"). */
|
||||
rootNodeId: string;
|
||||
/** Flows below this value are grouped into an "Other" node. */
|
||||
minThreshold: number;
|
||||
/** Per-parent untracked residual only shown when above this value. */
|
||||
untrackedFloor: number;
|
||||
/** Round the "Other" node value up (instantaneous cards only). */
|
||||
ceilOtherValue: boolean;
|
||||
/** Starting untracked total (the home/root value). */
|
||||
initialUntracked: number;
|
||||
getId: (device: DeviceConsumptionEnergyPreference) => string | undefined;
|
||||
getValue: (id: string) => number;
|
||||
getLabel: (id: string, name: string | undefined) => string;
|
||||
getEntityId: (id: string) => string | undefined;
|
||||
findEffectiveParent: (
|
||||
includedInStat: string | undefined
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the device layer of a sankey card: one node per device, small devices
|
||||
* folded into per-parent "Other" nodes, and per-parent untracked residuals.
|
||||
* Value/label/id/entityId are supplied via callbacks so both cumulative
|
||||
* (statistic growth) and instantaneous (live power/flow) cards can share it.
|
||||
*/
|
||||
export const buildSankeyDeviceNodes = (
|
||||
options: BuildSankeyDeviceNodesOptions
|
||||
): {
|
||||
deviceNodes: SankeyDeviceNode[];
|
||||
parentLinks: Record<string, string>;
|
||||
links: Link[];
|
||||
untrackedConsumption: number;
|
||||
} => {
|
||||
const {
|
||||
devices,
|
||||
computedStyle,
|
||||
localize,
|
||||
rootNodeId,
|
||||
minThreshold,
|
||||
untrackedFloor,
|
||||
ceilOtherValue,
|
||||
initialUntracked,
|
||||
getId,
|
||||
getValue,
|
||||
getLabel,
|
||||
getEntityId,
|
||||
findEffectiveParent,
|
||||
} = options;
|
||||
|
||||
const unavailableColor = computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim();
|
||||
|
||||
const links: Link[] = [];
|
||||
const deviceNodes: SankeyDeviceNode[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
let untrackedConsumption = initialUntracked;
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const value = getValue(id);
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minThreshold) {
|
||||
// Collect small consumers instead of folding them into untracked
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
id,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node: SankeyDeviceNode = {
|
||||
id,
|
||||
label: getLabel(id, device.name),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: getEntityId(id),
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Process small consumers - show a lone one directly, group clusters as "Other"
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumers.length === 1) {
|
||||
const consumer = consumers[0];
|
||||
const node: SankeyDeviceNode = {
|
||||
id: consumer.id,
|
||||
label: getLabel(consumer.id, consumer.name),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: getEntityId(consumer.id),
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: SankeyDeviceNode = {
|
||||
id: otherNodeId,
|
||||
label: localize(OTHER_LABEL_KEY),
|
||||
value: ceilOtherValue ? Math.ceil(totalValue) : totalValue,
|
||||
color: unavailableColor,
|
||||
index: 4,
|
||||
};
|
||||
if (parentKey !== rootNodeId) {
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({ source: parentKey, target: otherNodeId });
|
||||
} else {
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > untrackedFloor) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: localize(UNTRACKED_LABEL_KEY),
|
||||
value: untracked,
|
||||
color: unavailableColor,
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { deviceNodes, parentLinks, links, untrackedConsumption };
|
||||
};
|
||||
|
||||
/** Bucket device nodes by their entity's area and floor. */
|
||||
export const groupSankeyDevicesByFloorAndArea = (
|
||||
hass: HomeAssistant,
|
||||
deviceNodes: Node[]
|
||||
): {
|
||||
areas: Record<string, { value: number; devices: Node[] }>;
|
||||
floors: Record<string, { value: number; areas: string[] }>;
|
||||
} => {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
};
|
||||
|
||||
/**
|
||||
* Organize device nodes into hierarchical sections based on parent-child
|
||||
* relationships (top-level parents first, then their children, recursively).
|
||||
*/
|
||||
export const getSankeyDeviceSections = (
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] => {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: Record<string, string> = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...getSankeyDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
};
|
||||
|
||||
export interface BuildSankeyLayoutOptions {
|
||||
hass: HomeAssistant;
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
localize: HomeAssistant["localize"];
|
||||
deviceNodes: SankeyDeviceNode[];
|
||||
parentLinks: Record<string, string>;
|
||||
rootNodeId: string;
|
||||
groupByFloor: boolean;
|
||||
groupByArea: boolean;
|
||||
untrackedConsumption: number;
|
||||
untrackedFloor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the floor/area grouping layer, emit the device section nodes with
|
||||
* their final section indices, and add the top-level untracked node.
|
||||
*/
|
||||
export const buildSankeyLayout = (
|
||||
options: BuildSankeyLayoutOptions
|
||||
): { nodes: Node[]; links: Link[] } => {
|
||||
const {
|
||||
hass,
|
||||
computedStyle,
|
||||
localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId,
|
||||
groupByFloor,
|
||||
groupByArea,
|
||||
untrackedConsumption,
|
||||
untrackedFloor,
|
||||
} = options;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
const primaryColor = computedStyle.getPropertyValue("--primary-color").trim();
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
|
||||
if (groupByArea || groupByFloor) {
|
||||
const { areas, floors } = groupSankeyDevicesByFloorAndArea(
|
||||
hass,
|
||||
devicesWithoutParent
|
||||
);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(hass.floors[b]?.level ?? -Infinity) -
|
||||
(hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !groupByFloor) {
|
||||
// link "no_floor" areas to the root
|
||||
floorNodeId = rootNodeId;
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !groupByArea) {
|
||||
// If group_by_area is false, link devices to floor or root
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or root)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const deviceSections = getSankeyDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > untrackedFloor) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: localize(UNTRACKED_LABEL_KEY),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle.getPropertyValue("--state-unavailable-color").trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
@@ -25,10 +24,14 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { EnergySankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<EnergySankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
@@ -138,6 +141,8 @@ class HuiEnergySankeyCard
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
|
||||
const minEnergyThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
if (types.battery) {
|
||||
const totalBatteryOut = summedData.total.from_battery ?? 0;
|
||||
const totalBatteryIn = summedData.total.to_battery ?? 0;
|
||||
@@ -262,185 +267,85 @@ class HuiEnergySankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
prefs.device_consumption.forEach((device, idx) => {
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
if (value < 0.01) {
|
||||
return;
|
||||
const deviceValue = (statConsumption: string) =>
|
||||
statConsumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
const node = {
|
||||
id: device.stat_consumption,
|
||||
label:
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this._data!.statsMetadata[device.stat_consumption]
|
||||
),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: device.included_in_stat,
|
||||
entityId: isExternalStatistic(device.stat_consumption)
|
||||
? undefined
|
||||
: device.stat_consumption,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
this._data!.statsMetadata[statConsumption]
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > 0) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minEnergyThreshold,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]!.name,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > 0) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -480,113 +385,7 @@ class HuiEnergySankeyCard
|
||||
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
formatPowerShort,
|
||||
@@ -19,19 +18,19 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { PowerSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
// Minimum power threshold as a fraction of total consumption to display a device node
|
||||
// Devices below this threshold will be grouped into an "Other" node
|
||||
const MIN_POWER_THRESHOLD_FACTOR = 0.001; // 0.1% of used_total
|
||||
|
||||
interface PowerData {
|
||||
solar: number;
|
||||
from_grid: number;
|
||||
@@ -48,14 +47,6 @@ interface PowerData {
|
||||
used_total: number;
|
||||
}
|
||||
|
||||
interface SmallConsumer {
|
||||
statRate: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("hui-power-sankey-card")
|
||||
class HuiPowerSankeyCard
|
||||
extends SubscribeMixin(MobileAwareMixin(LitElement))
|
||||
@@ -163,7 +154,8 @@ class HuiPowerSankeyCard
|
||||
const computedStyle = getComputedStyle(this);
|
||||
|
||||
// Calculate dynamic threshold based on total consumption
|
||||
const minPowerThreshold = powerData.used_total * MIN_POWER_THRESHOLD_FACTOR;
|
||||
const minPowerThreshold =
|
||||
powerData.used_total * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
@@ -286,10 +278,6 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
|
||||
const deviceMap = new Map<
|
||||
@@ -338,252 +326,43 @@ class HuiPowerSankeyCard
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Collect small consumers by their effective parent
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
|
||||
prefs.device_consumption.forEach((device, idx) => {
|
||||
if (!device.stat_rate) {
|
||||
return;
|
||||
}
|
||||
const value = this._getCurrentPower(device.stat_rate);
|
||||
|
||||
// Find the effective parent (may be different from direct parent if parent has no stat_rate)
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minPowerThreshold) {
|
||||
// Collect small consumers instead of skipping them
|
||||
const parentKey = effectiveParent ?? "home";
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
statRate: device.stat_rate,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: device.stat_rate,
|
||||
label: device.name || this._getEntityLabel(device.stat_rate),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: device.stat_rate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minPowerThreshold,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentPower(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
|
||||
// Process small consumers - create "Other" nodes or show single entities
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumers.length === 1) {
|
||||
// Single entity - show it directly instead of grouping
|
||||
const consumer = consumers[0];
|
||||
const node = {
|
||||
id: consumer.statRate,
|
||||
label: consumer.name || this._getEntityLabel(consumer.statRate),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: consumer.statRate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
// Multiple entities - create "Other" group
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: Node = {
|
||||
id: otherNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
|
||||
),
|
||||
value: Math.ceil(totalValue),
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
};
|
||||
|
||||
if (parentKey !== "home") {
|
||||
// Has a parent device
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({
|
||||
source: parentKey,
|
||||
target: otherNodeId,
|
||||
});
|
||||
} else {
|
||||
// Top-level "Other" - will be linked to home/floor/area later
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full power
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
// only show if larger than 1W
|
||||
if (untracked > 1) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
|
||||
// untracked consumption (only show if larger than 1W)
|
||||
if (untrackedConsumption > 1) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -623,10 +402,7 @@ class HuiPowerSankeyCard
|
||||
formatPowerShort(this.hass, value);
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -758,109 +534,6 @@ class HuiPowerSankeyCard
|
||||
};
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current power value from entity state, normalized to watts (W)
|
||||
* @param entityId - The entity ID to get power value from
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
formatFlowRateShort,
|
||||
@@ -18,27 +17,19 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterFlowSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterFlowSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
// Minimum flow threshold as a fraction of total inflow to display a device node.
|
||||
// Devices below this threshold will be grouped into an "Other" node.
|
||||
const MIN_FLOW_THRESHOLD_FACTOR = 0.001; // 0.1% of total inflow
|
||||
|
||||
interface SmallConsumer {
|
||||
statRate: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("hui-water-flow-sankey-card")
|
||||
class HuiWaterFlowSankeyCard
|
||||
extends SubscribeMixin(MobileAwareMixin(LitElement))
|
||||
@@ -177,7 +168,7 @@ class HuiWaterFlowSankeyCard
|
||||
waterSources.length === 0 ? totalDeviceFlow : totalInflow;
|
||||
|
||||
// Calculate dynamic threshold
|
||||
const minFlowThreshold = effectiveTotalInflow * MIN_FLOW_THRESHOLD_FACTOR;
|
||||
const minFlowThreshold = effectiveTotalInflow * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
@@ -292,230 +283,43 @@ class HuiWaterFlowSankeyCard
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let untrackedConsumption = effectiveTotalInflow;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
|
||||
prefs.device_consumption_water.forEach((device, idx) => {
|
||||
if (!device.stat_rate) return;
|
||||
const value = this._getCurrentFlowRate(device.stat_rate);
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minFlowThreshold) {
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
statRate: device.stat_rate,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: device.stat_rate,
|
||||
label: device.name || this._getEntityLabel(device.stat_rate),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: device.stat_rate,
|
||||
};
|
||||
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption_water,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId,
|
||||
minThreshold: minFlowThreshold,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
|
||||
// Process small consumers
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) return;
|
||||
|
||||
if (consumers.length === 1) {
|
||||
const consumer = consumers[0];
|
||||
const node = {
|
||||
id: consumer.statRate,
|
||||
label: consumer.name || this._getEntityLabel(consumer.statRate),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: consumer.statRate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: Node = {
|
||||
id: otherNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
|
||||
),
|
||||
value: Math.ceil(totalValue),
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
};
|
||||
|
||||
if (parentKey !== rootNodeId) {
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({ source: parentKey, target: otherNodeId });
|
||||
} else {
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full flow
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
// only show if larger than 1 L/min
|
||||
if (untracked > 1) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor, layout, title } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
floorNodeId = rootNodeId;
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({ source: rootNodeId, target: floorNodeId });
|
||||
}
|
||||
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const sankeyLayout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId,
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
|
||||
// Untracked consumption (only show if > 1 L/min threshold)
|
||||
if (untrackedConsumption > 1) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...sankeyLayout.nodes);
|
||||
links.push(...sankeyLayout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -557,10 +361,7 @@ class HuiWaterFlowSankeyCard
|
||||
);
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
private _getCurrentFlowRate(entityId: string): number {
|
||||
@@ -574,98 +375,6 @@ class HuiWaterFlowSankeyCard
|
||||
return stateObj.attributes.friendly_name || entityId;
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: { value: 0, devices: [] },
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: { value: 0, areas: ["no_area"] },
|
||||
};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
return [deviceNodes];
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
height: 400px;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
getEnergyDataCollection,
|
||||
@@ -22,10 +21,14 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
@@ -165,6 +168,8 @@ class HuiWaterSankeyCard
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
|
||||
const minWaterThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
// Add water source nodes
|
||||
const waterColor = computedStyle
|
||||
.getPropertyValue("--energy-water-color")
|
||||
@@ -180,7 +185,7 @@ class HuiWaterSankeyCard
|
||||
) || 0
|
||||
: 0;
|
||||
|
||||
if (value < 0.01) {
|
||||
if (value <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,185 +210,85 @@ class HuiWaterSankeyCard
|
||||
});
|
||||
});
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
prefs.device_consumption_water.forEach((device, idx) => {
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
if (value < 0.01) {
|
||||
return;
|
||||
const deviceValue = (statConsumption: string) =>
|
||||
statConsumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
const node = {
|
||||
id: device.stat_consumption,
|
||||
label:
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this._data!.statsMetadata[device.stat_consumption]
|
||||
),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: device.included_in_stat,
|
||||
entityId: isExternalStatistic(device.stat_consumption)
|
||||
? undefined
|
||||
: device.stat_consumption,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
this._data!.statsMetadata[statConsumption]
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > 0) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption_water,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minWaterThreshold,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]!.name,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > 0) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -423,113 +328,7 @@ class HuiWaterSankeyCard
|
||||
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} ${this._data!.waterUnit}`;
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../components/input/ha-input";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import { setValue } from "../../../data/input_text";
|
||||
import { showNumberSlider } from "../../../data/number";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
import "../components/hui-generic-entity-row";
|
||||
@@ -75,12 +76,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
return html`
|
||||
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
|
||||
${
|
||||
stateObj.attributes.mode === "slider" ||
|
||||
(stateObj.attributes.mode === "auto" &&
|
||||
(Number(stateObj.attributes.max) -
|
||||
Number(stateObj.attributes.min)) /
|
||||
Number(stateObj.attributes.step) <=
|
||||
256)
|
||||
showNumberSlider(stateObj)
|
||||
? html`
|
||||
<div class="flex">
|
||||
<ha-slider
|
||||
|
||||
@@ -29,7 +29,7 @@ class StateCardLock extends LitElement {
|
||||
.inDialog=${this.inDialog}
|
||||
></state-info>
|
||||
${
|
||||
!supportsOpen
|
||||
supportsOpen
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../components/entity/state-info";
|
||||
import "../components/ha-slider";
|
||||
import "../components/input/ha-input";
|
||||
import { UNAVAILABLE } from "../data/entity/entity";
|
||||
import { showNumberSlider } from "../data/number";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -46,8 +47,6 @@ class StateCardNumber extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const range = this.stateObj.attributes.max - this.stateObj.attributes.min;
|
||||
|
||||
return html`
|
||||
<state-info
|
||||
.hass=${this.hass}
|
||||
@@ -55,8 +54,7 @@ class StateCardNumber extends LitElement {
|
||||
.inDialog=${this.inDialog}
|
||||
></state-info>
|
||||
${
|
||||
this.stateObj.attributes.mode === "slider" ||
|
||||
(this.stateObj.attributes.mode === "auto" && range <= 256)
|
||||
showNumberSlider(this.stateObj)
|
||||
? html`
|
||||
<div class="flex">
|
||||
<ha-slider
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
computeDeviceAreaSuggestion,
|
||||
type AreaForNameMatch,
|
||||
} from "../../../src/common/entity/compute_device_area_suggestion";
|
||||
|
||||
const AREAS: AreaForNameMatch[] = [
|
||||
{ area_id: "living_room", name: "Living Room", aliases: ["Lounge"] },
|
||||
{ area_id: "kitchen", name: "Kitchen" },
|
||||
{ area_id: "master", name: "Master" },
|
||||
{ area_id: "master_bedroom", name: "Master Bedroom" },
|
||||
];
|
||||
|
||||
describe("computeDeviceAreaSuggestion", () => {
|
||||
describe("device without an area", () => {
|
||||
it("strips a prefix area and suggests it", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Living Room Thermostat", null, AREAS)
|
||||
).toEqual({ name: "Thermostat", area: "living_room" });
|
||||
});
|
||||
|
||||
it("strips a suffix area and suggests it", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Thermostat Living Room", null, AREAS)
|
||||
).toEqual({ name: "Thermostat", area: "living_room" });
|
||||
});
|
||||
|
||||
it("matches an area alias", () => {
|
||||
expect(computeDeviceAreaSuggestion("Lounge Lamp", null, AREAS)).toEqual({
|
||||
name: "Lamp",
|
||||
area: "living_room",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the longest matching area name", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Master Bedroom Lamp", null, AREAS)
|
||||
).toEqual({ name: "Lamp", area: "master_bedroom" });
|
||||
});
|
||||
|
||||
it("is a no-op when the name equals an area and does not fall back to a shorter one", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Master Bedroom", null, AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not match in the middle of a word", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Kitchenette Sensor", null, AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("matches case-insensitively and keeps the remainder's casing", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("living room thermostat", null, AREAS)
|
||||
).toEqual({ name: "thermostat", area: "living_room" });
|
||||
});
|
||||
|
||||
it("trims residual separators after stripping", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Living Room - Thermostat", null, AREAS)
|
||||
).toEqual({ name: "Thermostat", area: "living_room" });
|
||||
});
|
||||
|
||||
it("does nothing when no area matches", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Random Device", null, AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("device with an area already set", () => {
|
||||
it("strips the name but keeps the area when it matches", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Kitchen Sensor", "kitchen", AREAS)
|
||||
).toEqual({ name: "Sensor" });
|
||||
});
|
||||
|
||||
it("never overrides the area when the name does not match it", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Living Room Sensor", "kitchen", AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op when the name equals its own area", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Kitchen", "kitchen", AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing for an empty name", () => {
|
||||
expect(computeDeviceAreaSuggestion(" ", null, AREAS)).toBeNull();
|
||||
expect(computeDeviceAreaSuggestion(undefined, null, AREAS)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { stripBoundaryLabel } from "../../../src/common/string/strip_boundary_label";
|
||||
|
||||
describe("stripBoundaryLabel", () => {
|
||||
it("returns an empty string when the text equals the label", () => {
|
||||
expect(stripBoundaryLabel("Kitchen", "Kitchen")).toBe("");
|
||||
});
|
||||
|
||||
it("strips a prefix on a word boundary", () => {
|
||||
expect(stripBoundaryLabel("Living Room Thermostat", "Living Room")).toBe(
|
||||
"Thermostat"
|
||||
);
|
||||
});
|
||||
|
||||
it("strips a suffix on a word boundary", () => {
|
||||
expect(stripBoundaryLabel("Thermostat Living Room", "Living Room")).toBe(
|
||||
"Thermostat"
|
||||
);
|
||||
});
|
||||
|
||||
it("is case-insensitive and keeps the remainder's casing", () => {
|
||||
expect(stripBoundaryLabel("living room Thermostat", "Living Room")).toBe(
|
||||
"Thermostat"
|
||||
);
|
||||
});
|
||||
|
||||
it("trims different separators", () => {
|
||||
expect(stripBoundaryLabel("Kitchen - Sensor", "Kitchen")).toBe("Sensor");
|
||||
expect(stripBoundaryLabel("Kitchen_Sensor", "Kitchen")).toBe("Sensor");
|
||||
expect(stripBoundaryLabel("Kitchen.Sensor", "Kitchen")).toBe("Sensor");
|
||||
});
|
||||
|
||||
it("does not match in the middle of a word", () => {
|
||||
expect(stripBoundaryLabel("Kitchenette Sensor", "Kitchen")).toBeNull();
|
||||
expect(stripBoundaryLabel("Sub Kitchenette", "Kitchen")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the label is absent", () => {
|
||||
expect(stripBoundaryLabel("Living Room Sensor", "Kitchen")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty string when only separators are left", () => {
|
||||
expect(stripBoundaryLabel("Kitchen -", "Kitchen")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { showNumberSlider } from "../../src/data/number";
|
||||
|
||||
const makeStateObj = (attributes: Record<string, unknown>): HassEntity => ({
|
||||
entity_id: "number.test",
|
||||
state: "0",
|
||||
last_changed: "2024-01-01T00:00:00Z",
|
||||
last_updated: "2024-01-01T00:00:00Z",
|
||||
attributes: { min: 0, max: 100, step: 1, ...attributes },
|
||||
context: { id: "test", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
describe("showNumberSlider", () => {
|
||||
it("shows a slider in slider mode regardless of step count", () => {
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "slider", min: 0, max: 100000, step: 1 })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a slider in auto mode when the step count is at or below the threshold", () => {
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "auto", min: 0, max: 256, step: 1 })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "auto", min: 0, max: 1000, step: 10 })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a box in auto mode when the step count is above the threshold", () => {
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "auto", min: 0, max: 257, step: 1 })
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "auto", min: 0, max: 100, step: 0.1 })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a box for box mode", () => {
|
||||
expect(showNumberSlider(makeStateObj({ mode: "box" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
BuildSankeyDeviceNodesOptions,
|
||||
SankeyDeviceNode,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
getSankeyDeviceSections,
|
||||
groupSankeyDevicesByFloorAndArea,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
|
||||
import type { Node } from "../../../../../../src/components/chart/ha-sankey-chart";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../../../src/data/energy";
|
||||
import type { HomeAssistant } from "../../../../../../src/types";
|
||||
import { createMockComputedStyle } from "../../../../../fixtures/computed-style";
|
||||
import {
|
||||
createMockEntityState,
|
||||
createMockHass,
|
||||
mockLocalize,
|
||||
} from "../../../../../fixtures/hass";
|
||||
|
||||
const computedStyle = createMockComputedStyle();
|
||||
|
||||
const devices = (
|
||||
...items: DeviceConsumptionEnergyPreference[]
|
||||
): DeviceConsumptionEnergyPreference[] => items;
|
||||
|
||||
// Cumulative-style option factory (id === stat_consumption, always clickable).
|
||||
const cumulativeOpts = (
|
||||
overrides: { values: Record<string, number> } & Pick<
|
||||
BuildSankeyDeviceNodesOptions,
|
||||
"devices"
|
||||
> &
|
||||
Partial<BuildSankeyDeviceNodesOptions>
|
||||
): BuildSankeyDeviceNodesOptions => {
|
||||
const { values, ...rest } = overrides;
|
||||
return {
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: 0.01,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: 0,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: (id) => values[id] ?? 0,
|
||||
getLabel: (id, name) => name || id,
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent: () => undefined,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
describe("getSankeyDeviceSections", () => {
|
||||
it("returns a single section when there are no parent links", () => {
|
||||
const nodes: Node[] = [
|
||||
{ id: "a", value: 1, index: 4 },
|
||||
{ id: "b", value: 2, index: 4 },
|
||||
];
|
||||
expect(getSankeyDeviceSections({}, nodes)).toEqual([nodes]);
|
||||
});
|
||||
|
||||
it("splits parents and children into ordered sections", () => {
|
||||
const parent: Node = { id: "parent", value: 10, index: 4 };
|
||||
const child: Node = { id: "child", value: 4, index: 4 };
|
||||
const sections = getSankeyDeviceSections({ child: "parent" }, [
|
||||
parent,
|
||||
child,
|
||||
]);
|
||||
expect(sections).toEqual([[parent], [child]]);
|
||||
});
|
||||
|
||||
it("recurses through multiple parent levels", () => {
|
||||
const grandparent: Node = { id: "grandparent", value: 10, index: 4 };
|
||||
const parent: Node = { id: "parent", value: 6, index: 4 };
|
||||
const child: Node = { id: "child", value: 4, index: 4 };
|
||||
const sections = getSankeyDeviceSections(
|
||||
{ parent: "grandparent", child: "parent" },
|
||||
[grandparent, parent, child]
|
||||
);
|
||||
expect(sections).toEqual([[grandparent], [parent], [child]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSankeyDeviceNodes", () => {
|
||||
it("renders top-level devices and subtracts them from untracked", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices({ stat_consumption: "a" }, { stat_consumption: "b" }),
|
||||
values: { a: 10, b: 5 },
|
||||
initialUntracked: 15,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["a", "b"]);
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
// top-level devices carry no link here (linked in the layout step)
|
||||
expect(result.links).toEqual([]);
|
||||
expect(result.parentLinks).toEqual({});
|
||||
});
|
||||
|
||||
it("shows a lone sub-threshold device directly (no Other node)", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "small" }
|
||||
),
|
||||
values: { a: 10, small: 0.005 },
|
||||
initialUntracked: 10.005,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id).sort()).toEqual(["a", "small"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("groups multiple sub-threshold devices into an Other node", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "s1" },
|
||||
{ stat_consumption: "s2" }
|
||||
),
|
||||
values: { s1: 0.003, s2: 0.004 },
|
||||
initialUntracked: 0.007,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes).toHaveLength(1);
|
||||
const other = result.deviceNodes[0];
|
||||
expect(other.id).toBe("other_home");
|
||||
expect(other.value).toBeCloseTo(0.007, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("ceils the Other node value only when ceilOtherValue is set, but always subtracts the raw total", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "s1" },
|
||||
{ stat_consumption: "s2" }
|
||||
),
|
||||
values: { s1: 0.3, s2: 0.4 },
|
||||
minThreshold: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: 0.7,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes[0];
|
||||
expect(other.value).toBe(1); // Math.ceil(0.7)
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10); // raw 0.7 subtracted
|
||||
});
|
||||
|
||||
it("skips devices whose id resolves to undefined (instantaneous with no stat_rate)", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a", stat_rate: undefined },
|
||||
{ stat_consumption: "b", stat_rate: "sensor.b_rate" }
|
||||
),
|
||||
values: { "sensor.b_rate": 10 },
|
||||
initialUntracked: 10,
|
||||
getId: (device) => device.stat_rate,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["sensor.b_rate"]);
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
|
||||
it("adds a per-parent untracked residual above the floor", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, child: 4 },
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
expect(result.parentLinks.child).toBe("parent");
|
||||
const residual = result.deviceNodes.find((n) =>
|
||||
n.id.startsWith("untracked_")
|
||||
);
|
||||
expect(residual?.id).toBe("untracked_parent");
|
||||
expect(residual?.value).toBe(6); // 10 - 4
|
||||
expect(result.untrackedConsumption).toBe(0); // parent (top-level) subtracted
|
||||
});
|
||||
|
||||
it("suppresses a per-parent residual at or below the floor", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, child: 9.5 },
|
||||
untrackedFloor: 1,
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("untracked_"))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("groups a small-device cluster under its rendered parent, not into untracked", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "s1", included_in_stat: "parent" },
|
||||
{ stat_consumption: "s2", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, s1: 0.003, s2: 0.004 },
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_parent");
|
||||
expect(other?.value).toBeCloseTo(0.007, 10);
|
||||
expect(result.parentLinks.other_parent).toBe("parent");
|
||||
expect(result.links).toContainEqual({
|
||||
source: "parent",
|
||||
target: "other_parent",
|
||||
});
|
||||
// The cluster attaches to its parent, so home-level untracked only loses
|
||||
// the top-level parent (10), never the cluster total.
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSankeyDevicesByFloorAndArea", () => {
|
||||
const hass = {
|
||||
...createMockHass({
|
||||
"sensor.a": createMockEntityState("sensor.a", "1"),
|
||||
"sensor.b": createMockEntityState("sensor.b", "2"),
|
||||
}),
|
||||
entities: {
|
||||
"sensor.a": { entity_id: "sensor.a", area_id: "kitchen" },
|
||||
},
|
||||
areas: {
|
||||
kitchen: { area_id: "kitchen", name: "Kitchen", floor_id: "ground" },
|
||||
},
|
||||
floors: {
|
||||
ground: { floor_id: "ground", name: "Ground", level: 0 },
|
||||
},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
it("buckets devices by their entity's area and floor, unknown ones under no_area", () => {
|
||||
const nodes: Node[] = [
|
||||
{ id: "sensor.a", value: 3, index: 4 },
|
||||
{ id: "sensor.b", value: 2, index: 4 },
|
||||
];
|
||||
const { areas, floors } = groupSankeyDevicesByFloorAndArea(hass, nodes);
|
||||
expect(areas.kitchen.value).toBe(3);
|
||||
expect(areas.kitchen.devices.map((n) => n.id)).toEqual(["sensor.a"]);
|
||||
expect(floors.ground.value).toBe(3);
|
||||
expect(floors.ground.areas).toContain("kitchen");
|
||||
// sensor.b has no registry entry -> no_area
|
||||
expect(areas.no_area.devices.map((n) => n.id)).toEqual(["sensor.b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSankeyLayout", () => {
|
||||
const hass = createMockHass();
|
||||
|
||||
it("links top-level devices to the root and emits the untracked node above the floor", () => {
|
||||
const deviceNodes: SankeyDeviceNode[] = [{ id: "a", value: 10, index: 4 }];
|
||||
const { nodes, links } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes,
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 2,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
expect(links).toContainEqual({ source: "home", target: "a", value: 10 });
|
||||
const untracked = nodes.find((n) => n.id === "untracked");
|
||||
expect(untracked?.value).toBe(2);
|
||||
expect(links).toContainEqual({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses the untracked node at or below the floor", () => {
|
||||
const { nodes } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [{ id: "a", value: 10, index: 4 }],
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 0.5,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
expect(nodes.some((n) => n.id === "untracked")).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a non-home root node id (single-source water-flow case)", () => {
|
||||
const { links } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [{ id: "a", value: 4, index: 4 }],
|
||||
parentLinks: {},
|
||||
rootNodeId: "sensor.source",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 0,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
expect(links).toContainEqual({
|
||||
source: "sensor.source",
|
||||
target: "a",
|
||||
value: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("numbers device sections and the untracked node by section depth", () => {
|
||||
const { nodes } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [
|
||||
{ id: "parent", value: 10, index: 4 },
|
||||
{ id: "child", value: 4, index: 4 },
|
||||
],
|
||||
parentLinks: { child: "parent" },
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 2,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
// section 0 -> index 4, section 1 -> index 5
|
||||
expect(nodes.find((n) => n.id === "parent")?.index).toBe(4);
|
||||
expect(nodes.find((n) => n.id === "child")?.index).toBe(5);
|
||||
// untracked sits at 3 + deviceSections.length (2)
|
||||
expect(nodes.find((n) => n.id === "untracked")?.index).toBe(5);
|
||||
});
|
||||
|
||||
it("builds floor and area nodes and links devices through them", () => {
|
||||
const groupedHass = {
|
||||
...createMockHass({
|
||||
"sensor.a": createMockEntityState("sensor.a", "3"),
|
||||
"sensor.b": createMockEntityState("sensor.b", "2"),
|
||||
}),
|
||||
entities: {
|
||||
"sensor.a": { entity_id: "sensor.a", area_id: "kitchen" },
|
||||
},
|
||||
areas: {
|
||||
kitchen: { area_id: "kitchen", name: "Kitchen", floor_id: "ground" },
|
||||
},
|
||||
floors: {
|
||||
ground: { floor_id: "ground", name: "Ground", level: 0 },
|
||||
},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const { nodes, links } = buildSankeyLayout({
|
||||
hass: groupedHass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [
|
||||
{ id: "sensor.a", value: 3, index: 4 },
|
||||
{ id: "sensor.b", value: 2, index: 4 },
|
||||
],
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: true,
|
||||
groupByArea: true,
|
||||
untrackedConsumption: 0,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
const floor = nodes.find((n) => n.id === "floor_ground");
|
||||
expect(floor?.index).toBe(2);
|
||||
expect(floor?.value).toBe(3);
|
||||
const area = nodes.find((n) => n.id === "area_kitchen");
|
||||
expect(area?.index).toBe(3);
|
||||
expect(area?.value).toBe(3);
|
||||
// root -> floor -> area -> device
|
||||
expect(links).toContainEqual({ source: "home", target: "floor_ground" });
|
||||
expect(links).toContainEqual({
|
||||
source: "floor_ground",
|
||||
target: "area_kitchen",
|
||||
value: 3,
|
||||
});
|
||||
expect(links).toContainEqual({
|
||||
source: "area_kitchen",
|
||||
target: "sensor.a",
|
||||
value: 3,
|
||||
});
|
||||
// sensor.b has no registry entry -> no_area -> linked straight to root
|
||||
expect(links).toContainEqual({
|
||||
source: "home",
|
||||
target: "sensor.b",
|
||||
value: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9148,9 +9148,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"fast-uri@npm:^3.0.1":
|
||||
version: 3.1.3
|
||||
resolution: "fast-uri@npm:3.1.3"
|
||||
checksum: 10/7969a50a327482035d5c8c93faf51b26d7a93ce62bc750c91b3df158550004b5fbb378c95c900fd71f4dded36b5a78d1c666fb8043bb1214efbaebd8518d873e
|
||||
version: 3.1.4
|
||||
resolution: "fast-uri@npm:3.1.4"
|
||||
checksum: 10/1c1ff8e7b6f7b38e997b1528aa2c97e290e0173d51250bfe4e11a303e454fc297a287555b5cb2ded59dbfce7876855fb15994a1a6b439f2497a2b8926513e397
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user