Compare commits

...

6 Commits

Author SHA1 Message Date
Paul Bottein 824ab4fbe0 Suggest area from device name when adding devices 2026-07-24 11:52:27 +02:00
Paul Bottein 5cf7fb3a3d Fix inverted and missing feature checks in lock and lawn mower (#53222) 2026-07-23 15:58:55 +03:00
dependabot[bot] b6813ce060 Bump fast-uri from 3.1.3 to 3.1.4 (#53248)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 10:30:41 +01:00
Paul Bottein 3a060feb82 Unify number slider threshold across rows and state cards (#53224)
* Unify number slider threshold across rows and state cards

* Add showNumberSlider test
2026-07-23 09:37:12 +02:00
Petar Petrov ab39c4dd4a Group small device flows into "Other" in sankey cards and share logic (#53235)
The water and energy sankey cards skipped device flows below a hardcoded
0.01 while still counting them in the home total, so small-but-real
devices (e.g. 0.004 m³) were misattributed to "Untracked consumption".
Adopt the power/water-flow relative-threshold + "Other"-grouping strategy
and extract the logic shared by all four cards into energy/common/sankey.
2026-07-23 08:52:54 +02:00
Petar Petrov 82167a81f7 Keep ha-qr-code legible when theme colors collide (#53246)
Ensure ha-qr-code stays legible when theme colors collide
2026-07-23 09:43:31 +03:00
21 changed files with 1563 additions and 1312 deletions
@@ -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;
};
+42
View File
@@ -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;
};
+35 -18
View File
@@ -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"),
+14
View File
@@ -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
+1 -1
View File
@@ -29,7 +29,7 @@ class StateCardLock extends LitElement {
.inDialog=${this.inDialog}
></state-info>
${
!supportsOpen
supportsOpen
? html`<ha-button
appearance="plain"
size="s"
+2 -4
View File
@@ -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("");
});
});
+52
View File
@@ -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,
});
});
});
+3 -3
View File
@@ -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