From b143421886f051c37dc1e854166c24677658e5dd Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 25 Nov 2025 18:57:10 +0200 Subject: [PATCH] Add water sankey card (#27929) * Add support for downstream water meters in energy dashboard * Add water sankey card --- src/panels/lovelace/cards/types.ts | 8 + .../cards/water/hui-water-sankey-card.ts | 464 ++++++++++++++++++ .../create-element/create-card-element.ts | 1 + src/translations/en.json | 1 + 4 files changed, 474 insertions(+) create mode 100644 src/panels/lovelace/cards/water/hui-water-sankey-card.ts diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 67161e5cfc..a2cae6898d 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -226,6 +226,14 @@ export interface EnergySankeyCardConfig extends EnergyCardBaseConfig { group_by_area?: boolean; } +export interface WaterSankeyCardConfig extends EnergyCardBaseConfig { + type: "water-sankey"; + title?: string; + layout?: "vertical" | "horizontal" | "auto"; + group_by_floor?: boolean; + group_by_area?: boolean; +} + export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig { type: "power-sources-graph"; title?: string; diff --git a/src/panels/lovelace/cards/water/hui-water-sankey-card.ts b/src/panels/lovelace/cards/water/hui-water-sankey-card.ts new file mode 100644 index 0000000000..61bccb87ae --- /dev/null +++ b/src/panels/lovelace/cards/water/hui-water-sankey-card.ts @@ -0,0 +1,464 @@ +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { PropertyValues } from "lit"; +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 "../../../../components/ha-svg-icon"; +import type { EnergyData } from "../../../../data/energy"; +import { getEnergyDataCollection } from "../../../../data/energy"; +import { + calculateStatisticSumGrowth, + getStatisticLabel, +} from "../../../../data/recorder"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../../../../types"; +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"; + +const DEFAULT_CONFIG: Partial = { + group_by_floor: true, + group_by_area: true, +}; + +@customElement("hui-water-sankey-card") +class HuiWaterSankeyCard + extends SubscribeMixin(MobileAwareMixin(LitElement)) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public layout?: string; + + @state() private _config?: WaterSankeyCardConfig; + + @state() private _data?: EnergyData; + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public setConfig(config: WaterSankeyCardConfig): void { + this._config = { ...DEFAULT_CONFIG, ...config }; + } + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config?.collection_key, + }).subscribe((data) => { + this._data = data; + }), + ]; + } + + public getCardSize(): Promise | number { + return 5; + } + + getGridOptions(): LovelaceGridOptions { + return { + columns: 12, + min_columns: 6, + rows: 6, + min_rows: 2, + }; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + return ( + changedProps.has("_config") || + changedProps.has("_data") || + changedProps.has("_isMobileSize") + ); + } + + protected render() { + if (!this._config) { + return nothing; + } + + if (!this._data) { + return html`${this.hass.localize( + "ui.panel.lovelace.cards.energy.loading" + )}`; + } + + const prefs = this._data.prefs; + const waterSources = prefs.energy_sources.filter( + (source) => source.type === "water" + ); + + const computedStyle = getComputedStyle(this); + + const nodes: Node[] = []; + const links: Link[] = []; + + // Calculate total water consumption from all devices + let totalWaterConsumption = 0; + prefs.device_consumption_water.forEach((device) => { + const value = + device.stat_consumption in this._data!.stats + ? calculateStatisticSumGrowth( + this._data!.stats[device.stat_consumption] + ) || 0 + : 0; + totalWaterConsumption += value; + }); + + // Create home/consumption node + const homeNode: Node = { + id: "home", + label: this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_distribution.home" + ), + value: Math.max(0, totalWaterConsumption), + color: computedStyle.getPropertyValue("--primary-color").trim(), + index: 1, + }; + nodes.push(homeNode); + + // Add water source nodes + const waterColor = computedStyle + .getPropertyValue("--energy-water-color") + .trim(); + waterSources.forEach((source) => { + if (source.type !== "water") { + return; + } + const value = + source.stat_energy_from in this._data!.stats + ? calculateStatisticSumGrowth( + this._data!.stats[source.stat_energy_from] + ) || 0 + : 0; + + if (value < 0.01) { + return; + } + + nodes.push({ + id: source.stat_energy_from, + label: getStatisticLabel( + this.hass, + source.stat_energy_from, + this._data!.statsMetadata[source.stat_energy_from] + ), + value, + color: waterColor, + index: 0, + }); + + links.push({ + source: source.stat_energy_from, + target: "home", + value, + }); + }); + + let untrackedConsumption = homeNode.value; + const deviceNodes: Node[] = []; + const parentLinks: Record = {}; + 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 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, + }; + if (node.parent) { + parentLinks[node.id] = node.parent; + links.push({ + source: node.parent, + target: node.id, + }); + } else { + untrackedConsumption -= value; + } + deviceNodes.push(node); + }); + const devicesWithoutParent = deviceNodes.filter( + (node) => !parentLinks[node.id] + ); + + 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 }); + }); + }); + + // 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, + }); + } + + const hasData = nodes.some((node) => node.value > 0); + + const vertical = + this._config.layout === "vertical" || + (this._config.layout !== "horizontal" && this._isMobileSize); + + return html` + +
+ ${hasData + ? html`` + : html`${this.hass.localize( + "ui.panel.lovelace.cards.energy.no_data_period" + )}`} +
+
+ `; + } + + private _valueFormatter = (value: number) => + `${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} ${this._data!.waterUnit}`; + + protected _groupByFloorAndArea(deviceNodes: Node[]) { + const areas: Record = { + no_area: { + value: 0, + devices: [], + }, + }; + const floors: Record = { + 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, + 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]; + } + + static styles = css` + ha-card { + height: 400px; + display: flex; + flex-direction: column; + --chart-max-height: none; + } + ha-card.is-vertical { + height: 500px; + } + ha-card.is-grid, + ha-card.is-panel { + height: 100%; + } + .card-content { + flex: 1; + display: flex; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-water-sankey-card": HuiWaterSankeyCard; + } +} diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index e822580f47..c7e13d772d 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -66,6 +66,7 @@ const LAZY_LOAD_TYPES = { "energy-usage-graph": () => import("../cards/energy/hui-energy-usage-graph-card"), "energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"), + "water-sankey": () => import("../cards/water/hui-water-sankey-card"), "power-sources-graph": () => import("../cards/energy/hui-power-sources-graph-card"), "power-sankey": () => import("../cards/energy/hui-power-sankey-card"), diff --git a/src/translations/en.json b/src/translations/en.json index 22a3582c26..7839169979 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -9551,6 +9551,7 @@ "energy_devices_graph_title": "Individual devices total usage", "energy_devices_detail_graph_title": "Individual devices detail usage", "energy_sankey_title": "Energy flow", + "water_sankey_title": "Water flow", "energy_top_consumers_title": "Top consumers", "power_sankey_title": "Current power flow", "power_sources_graph_title": "Power sources"