diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts
index edccd2d22cbd..7bf2acc7392c 100644
--- a/src/components/chart/ha-chart-base.ts
+++ b/src/components/chart/ha-chart-base.ts
@@ -75,6 +75,8 @@ export class HaChartBase extends LitElement {
private _paddingYAxisInternal = 0;
+ private _datasetOrder: number[] = [];
+
public disconnectedCallback() {
super.disconnectedCallback();
this._releaseCanvas();
@@ -165,7 +167,17 @@ export class HaChartBase extends LitElement {
}
}
+ // put the legend labels in sorted order if provided
if (changedProps.has("data")) {
+ this._datasetOrder = this.data.datasets.map((_, index) => index);
+ if (this.data?.datasets.some((dataset) => dataset.order)) {
+ this._datasetOrder.sort(
+ (a, b) =>
+ (this.data.datasets[a].order || 0) -
+ (this.data.datasets[b].order || 0)
+ );
+ }
+
if (this.externalHidden) {
this._hiddenDatasets = new Set();
if (this.data?.datasets) {
@@ -205,8 +217,9 @@ export class HaChartBase extends LitElement {
${this.options?.plugins?.legend?.display === true
? html`
- ${this.data.datasets.map((dataset, index) =>
- this.extraData?.[index]?.show_legend === false
+ ${this._datasetOrder.map((index) => {
+ const dataset = this.data.datasets[index];
+ return this.extraData?.[index]?.show_legend === false
? nothing
: html`-
-
`
- )}
+ `;
+ })}
`
: ""}
diff --git a/src/panels/energy/strategies/energy-view-strategy.ts b/src/panels/energy/strategies/energy-view-strategy.ts
index 27a0a9137184..d41ea668ba59 100644
--- a/src/panels/energy/strategies/energy-view-strategy.ts
+++ b/src/panels/energy/strategies/energy-view-strategy.ts
@@ -154,6 +154,13 @@ export class EnergyViewStrategy extends ReactiveElement {
// Only include if we have at least 1 device in the config.
if (prefs.device_consumption.length) {
+ view.cards!.push({
+ title: hass.localize(
+ "ui.panel.energy.cards.energy_devices_detail_graph_title"
+ ),
+ type: "energy-devices-detail-graph",
+ collection_key: "energy_dashboard",
+ });
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts
new file mode 100644
index 000000000000..c13f2dac8eb6
--- /dev/null
+++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts
@@ -0,0 +1,412 @@
+import {
+ ChartData,
+ ChartDataset,
+ ChartOptions,
+ ScatterDataPoint,
+} from "chart.js";
+import { differenceInDays, endOfToday, startOfToday } from "date-fns/esm";
+import { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
+import {
+ css,
+ CSSResultGroup,
+ html,
+ LitElement,
+ nothing,
+ PropertyValues,
+} from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import memoizeOne from "memoize-one";
+import { getColorByIndex } from "../../../../common/color/colors";
+import { ChartDatasetExtra } from "../../../../components/chart/ha-chart-base";
+import "../../../../components/ha-card";
+import {
+ DeviceConsumptionEnergyPreference,
+ EnergyData,
+ getEnergyDataCollection,
+} from "../../../../data/energy";
+import {
+ calculateStatisticSumGrowth,
+ fetchStatistics,
+ getStatisticLabel,
+ Statistics,
+ StatisticsMetaData,
+ StatisticsUnitConfiguration,
+} from "../../../../data/recorder";
+import { FrontendLocaleData } from "../../../../data/translation";
+import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
+import { HomeAssistant } from "../../../../types";
+import { LovelaceCard } from "../../types";
+import { EnergyDevicesDetailGraphCardConfig } from "../types";
+import { hasConfigChanged } from "../../common/has-changed";
+import { getCommonOptions } from "./common/energy-chart-options";
+
+@customElement("hui-energy-devices-detail-graph-card")
+export class HuiEnergyDevicesDetailGraphCard
+ extends SubscribeMixin(LitElement)
+ implements LovelaceCard
+{
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @state() private _config?: EnergyDevicesDetailGraphCardConfig;
+
+ @state() private _chartData: ChartData = { datasets: [] };
+
+ @state() private _chartDatasetExtra: ChartDatasetExtra[] = [];
+
+ @state() private _data?: EnergyData;
+
+ @state() private _statistics?: Statistics;
+
+ @state() private _compareStatistics?: Statistics;
+
+ @state() private _start = startOfToday();
+
+ @state() private _end = endOfToday();
+
+ @state() private _compareStart?: Date;
+
+ @state() private _compareEnd?: Date;
+
+ @state() private _unit?: string;
+
+ @state() private _hiddenStats = new Set();
+
+ protected hassSubscribeRequiredHostProps = ["_config"];
+
+ public hassSubscribe(): UnsubscribeFunc[] {
+ return [
+ getEnergyDataCollection(this.hass, {
+ key: this._config?.collection_key,
+ }).subscribe(async (data) => {
+ this._data = data;
+ await this._getStatistics(this._data);
+ this._processStatistics();
+ }),
+ ];
+ }
+
+ public getCardSize(): Promise | number {
+ return 3;
+ }
+
+ public setConfig(config: EnergyDevicesDetailGraphCardConfig): void {
+ this._config = config;
+ }
+
+ protected shouldUpdate(changedProps: PropertyValues): boolean {
+ return (
+ hasConfigChanged(this, changedProps) ||
+ changedProps.size > 1 ||
+ !changedProps.has("hass")
+ );
+ }
+
+ protected willUpdate(changedProps: PropertyValues) {
+ if (changedProps.has("_hiddenStats") && this._statistics) {
+ this._processStatistics();
+ }
+ }
+
+ protected render() {
+ if (!this.hass || !this._config) {
+ return nothing;
+ }
+
+ return html`
+
+ ${this._config.title
+ ? html``
+ : ""}
+
+
+
+
+ `;
+ }
+
+ private _datasetHidden(ev) {
+ ev.stopPropagation();
+ this._hiddenStats.add(
+ this._data!.prefs.device_consumption[ev.detail.index].stat_consumption
+ );
+ this.requestUpdate("_hiddenStats");
+ }
+
+ private _datasetUnhidden(ev) {
+ ev.stopPropagation();
+ this._hiddenStats.delete(
+ this._data!.prefs.device_consumption[ev.detail.index].stat_consumption
+ );
+ this.requestUpdate("_hiddenStats");
+ }
+
+ private _createOptions = memoizeOne(
+ (
+ start: Date,
+ end: Date,
+ locale: FrontendLocaleData,
+ config: HassConfig,
+ unit?: string,
+ compareStart?: Date,
+ compareEnd?: Date
+ ): ChartOptions => {
+ const commonOptions = getCommonOptions(
+ start,
+ end,
+ locale,
+ config,
+ unit,
+ compareStart,
+ compareEnd
+ );
+
+ const options: ChartOptions = {
+ ...commonOptions,
+ interaction: {
+ mode: "nearest",
+ },
+ plugins: {
+ ...commonOptions.plugins!,
+ legend: {
+ display: true,
+ labels: {
+ usePointStyle: true,
+ },
+ },
+ },
+ };
+ return options;
+ }
+ );
+
+ private async _getStatistics(energyData: EnergyData): Promise {
+ const dayDifference = differenceInDays(
+ energyData.end || new Date(),
+ energyData.start
+ );
+
+ const devices = energyData.prefs.device_consumption.map(
+ (device) => device.stat_consumption
+ );
+
+ const period =
+ dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";
+
+ const lengthUnit = this.hass.config.unit_system.length || "";
+ const units: StatisticsUnitConfiguration = {
+ energy: "kWh",
+ volume: lengthUnit === "km" ? "m³" : "ft³",
+ };
+ this._unit = "kWh";
+
+ const statistics = await fetchStatistics(
+ this.hass,
+ energyData.start,
+ energyData.end,
+ devices,
+ period,
+ units,
+ ["change"]
+ );
+
+ let compareStatistics: Statistics | undefined;
+
+ if (energyData.startCompare && energyData.endCompare) {
+ compareStatistics = await fetchStatistics(
+ this.hass,
+ energyData.startCompare,
+ energyData.endCompare,
+ devices,
+ period,
+ units,
+ ["change"]
+ );
+ }
+ this._statistics = statistics;
+ this._compareStatistics = compareStatistics;
+ }
+
+ private async _processStatistics() {
+ const energyData = this._data!;
+ const data = this._statistics!;
+ const compareData = this._compareStatistics;
+
+ const growthValues = {};
+ energyData.prefs.device_consumption.forEach((device) => {
+ const value =
+ device.stat_consumption in data
+ ? calculateStatisticSumGrowth(data[device.stat_consumption]) || 0
+ : 0;
+
+ growthValues[device.stat_consumption] = value;
+ });
+
+ const sorted_devices = energyData.prefs.device_consumption.map(
+ (device) => device.stat_consumption
+ );
+ sorted_devices.sort((a, b) => growthValues[b] - growthValues[a]);
+
+ const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
+ const datasetExtras: ChartDatasetExtra[] = [];
+
+ datasets.push(
+ ...this._processDataSet(
+ data,
+ energyData.statsMetadata,
+ energyData.prefs.device_consumption,
+ sorted_devices
+ )
+ );
+
+ const items = datasets.length;
+ datasetExtras.push(...Array(items).fill({}));
+
+ if (compareData) {
+ // Add empty dataset to align the bars
+ datasets.push({
+ order: 0,
+ data: [],
+ });
+ datasetExtras.push({
+ show_legend: false,
+ });
+ datasets.push({
+ order: 999,
+ data: [],
+ xAxisID: "xAxisCompare",
+ });
+ datasetExtras.push({
+ show_legend: false,
+ });
+
+ datasets.push(
+ ...this._processDataSet(
+ compareData,
+ energyData.statsMetadata,
+ energyData.prefs.device_consumption,
+ sorted_devices,
+ true
+ )
+ );
+ datasetExtras.push(
+ ...Array(items).fill({ show_legend: false })
+ );
+ }
+
+ this._start = energyData.start;
+ this._end = energyData.end || endOfToday();
+
+ this._compareStart = energyData.startCompare;
+ this._compareEnd = energyData.endCompare;
+
+ this._chartData = {
+ datasets,
+ };
+ this._chartDatasetExtra = datasetExtras;
+ }
+
+ private _processDataSet(
+ statistics: Statistics,
+ statisticsMetaData: Record,
+ devices: DeviceConsumptionEnergyPreference[],
+ sorted_devices: string[],
+ compare = false
+ ) {
+ const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
+
+ devices.forEach((source, idx) => {
+ const color = getColorByIndex(idx);
+
+ let prevStart: number | null = null;
+
+ const consumptionData: ScatterDataPoint[] = [];
+
+ // Process gas consumption data.
+ if (source.stat_consumption in statistics) {
+ const stats = statistics[source.stat_consumption];
+ let end;
+
+ for (const point of stats) {
+ if (point.change === null || point.change === undefined) {
+ continue;
+ }
+ if (prevStart === point.start) {
+ continue;
+ }
+ const date = new Date(point.start);
+ consumptionData.push({
+ x: date.getTime(),
+ y: point.change,
+ });
+ prevStart = point.start;
+ end = point.end;
+ }
+ if (consumptionData.length === 1) {
+ consumptionData.push({
+ x: end,
+ y: 0,
+ });
+ }
+ }
+
+ data.push({
+ label: getStatisticLabel(
+ this.hass,
+ source.stat_consumption,
+ statisticsMetaData[source.stat_consumption]
+ ),
+ hidden: this._hiddenStats.has(source.stat_consumption),
+ borderColor: compare ? color + "7F" : color,
+ backgroundColor: compare ? color + "32" : color + "7F",
+ data: consumptionData,
+ order: 1 + sorted_devices.indexOf(source.stat_consumption),
+ stack: "devices",
+ pointStyle: compare ? false : "circle",
+ xAxisID: compare ? "xAxisCompare" : undefined,
+ });
+ });
+ return data;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ .card-header {
+ padding-bottom: 0;
+ }
+ .content {
+ padding: 16px;
+ }
+ .has-header {
+ padding-top: 0;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "hui-energy-devices-detail-graph-card": HuiEnergyDevicesDetailGraphCard;
+ }
+}
diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts
index d9a26be158b7..f87dde26cd4a 100644
--- a/src/panels/lovelace/cards/types.ts
+++ b/src/panels/lovelace/cards/types.ts
@@ -159,6 +159,13 @@ export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig {
max_devices?: number;
}
+export interface EnergyDevicesDetailGraphCardConfig extends LovelaceCardConfig {
+ type: "energy-devices-detail-graph";
+ title?: string;
+ collection_key?: string;
+ max_devices?: number;
+}
+
export interface EnergySourcesTableCardConfig extends LovelaceCardConfig {
type: "energy-sources-table";
title?: string;
diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts
index 1884c79cc18c..3619a13c8a0e 100644
--- a/src/panels/lovelace/create-element/create-card-element.ts
+++ b/src/panels/lovelace/create-element/create-card-element.ts
@@ -44,6 +44,8 @@ const LAZY_LOAD_TYPES = {
import("../cards/energy/hui-energy-date-selection-card"),
"energy-devices-graph": () =>
import("../cards/energy/hui-energy-devices-graph-card"),
+ "energy-devices-detail-graph": () =>
+ import("../cards/energy/hui-energy-devices-detail-graph-card"),
"energy-distribution": () =>
import("../cards/energy/hui-energy-distribution-card"),
"energy-gas-graph": () => import("../cards/energy/hui-energy-gas-graph-card"),
diff --git a/src/translations/en.json b/src/translations/en.json
index d0cda35a1675..1195ceb346c7 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -6535,7 +6535,8 @@
"energy_water_graph_title": "Water consumption",
"energy_distribution_title": "Energy distribution",
"energy_sources_table_title": "Sources",
- "energy_devices_graph_title": "Monitor individual devices"
+ "energy_devices_graph_title": "Individual devices total usage",
+ "energy_devices_detail_graph_title": "Individual devices detail usage"
}
},
"history": {