ha-frontend-cdce8p/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts

580 lines
17 KiB
TypeScript

import { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { startOfToday, endOfToday, isToday, differenceInDays } from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import {
hex2rgb,
lab2rgb,
rgb2hex,
rgb2lab,
} from "../../../../common/color/convert-color";
import { hexBlend } from "../../../../common/color/hex";
import { labDarken } from "../../../../common/color/lab";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import {
formatNumber,
numberFormatToLocale,
} from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import { EnergyData, getEnergyDataCollection } from "../../../../data/energy";
import {
reduceSumStatisticsByDay,
reduceSumStatisticsByMonth,
} from "../../../../data/history";
import { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../../types";
import { EnergyUsageGraphCardConfig } from "../types";
@customElement("hui-energy-usage-graph-card")
export class HuiEnergyUsageGraphCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyUsageGraphCardConfig;
@state() private _chartData: ChartData = {
datasets: [],
};
@state() private _start = startOfToday();
@state() private _end = endOfToday();
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => this._getStatistics(data)),
];
}
public getCardSize(): Promise<number> | number {
return 3;
}
public setConfig(config: EnergyUsageGraphCardConfig): void {
this._config = config;
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
return html`
<ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div
class="content ${classMap({
"has-header": !!this._config.title,
})}"
>
<ha-chart-base
.data=${this._chartData}
.options=${this._createOptions(
this._start,
this._end,
this.hass.locale
)}
chart-type="bar"
></ha-chart-base>
${!this._chartData.datasets.length
? html`<div class="no-data">
${isToday(this._start)
? "There is no data to show. It can take up to 2 hours for new data to arrive after you configure your energy dashboard."
: "There is no data for this period."}
</div>`
: ""}
</div>
</ha-card>
`;
}
private _createOptions = memoizeOne(
(start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => {
const dayDifference = differenceInDays(end, start);
return {
parsing: false,
animation: false,
scales: {
x: {
type: "time",
suggestedMin: start.getTime(),
suggestedMax: end.getTime(),
adapters: {
date: {
locale: locale,
},
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat:
dayDifference > 35
? "monthyear"
: dayDifference > 7
? "date"
: dayDifference > 2
? "weekday"
: dayDifference > 0
? "datetime"
: "hour",
minUnit:
dayDifference > 35
? "month"
: dayDifference > 2
? "day"
: "hour",
},
offset: true,
},
y: {
stacked: true,
type: "linear",
title: {
display: true,
text: "kWh",
},
ticks: {
beginAtZero: true,
callback: (value) => formatNumber(Math.abs(value), locale),
},
},
},
plugins: {
tooltip: {
mode: "x",
intersect: true,
position: "nearest",
filter: (val) => val.formattedValue !== "0",
callbacks: {
label: (context) =>
`${context.dataset.label}: ${formatNumber(
Math.abs(context.parsed.y),
locale
)} kWh`,
footer: (contexts) => {
let totalConsumed = 0;
let totalReturned = 0;
for (const context of contexts) {
const value = (context.dataset.data[context.dataIndex] as any)
.y;
if (value > 0) {
totalConsumed += value;
} else {
totalReturned += Math.abs(value);
}
}
return [
totalConsumed
? `Total consumed: ${formatNumber(
totalConsumed,
locale
)} kWh`
: "",
totalReturned
? `Total returned: ${formatNumber(
totalReturned,
locale
)} kWh`
: "",
].filter(Boolean);
},
},
},
filler: {
propagate: false,
},
legend: {
display: false,
labels: {
usePointStyle: true,
},
},
},
hover: {
mode: "nearest",
},
elements: {
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 5,
},
},
// @ts-expect-error
locale: numberFormatToLocale(locale),
};
}
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
const statistics: {
to_grid?: string[];
from_grid?: string[];
solar?: string[];
to_battery?: string[];
from_battery?: string[];
} = {};
for (const source of energyData.prefs.energy_sources) {
if (source.type === "solar") {
if (statistics.solar) {
statistics.solar.push(source.stat_energy_from);
} else {
statistics.solar = [source.stat_energy_from];
}
continue;
}
if (source.type === "battery") {
if (statistics.to_battery) {
statistics.to_battery.push(source.stat_energy_to);
statistics.from_battery!.push(source.stat_energy_from);
} else {
statistics.to_battery = [source.stat_energy_to];
statistics.from_battery = [source.stat_energy_from];
}
continue;
}
if (source.type !== "grid") {
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
if (statistics.from_grid) {
statistics.from_grid.push(flowFrom.stat_energy_from);
} else {
statistics.from_grid = [flowFrom.stat_energy_from];
}
}
for (const flowTo of source.flow_to) {
if (statistics.to_grid) {
statistics.to_grid.push(flowTo.stat_energy_to);
} else {
statistics.to_grid = [flowTo.stat_energy_to];
}
}
}
const dayDifference = differenceInDays(
energyData.end || new Date(),
energyData.start
);
const statisticsData = Object.values(energyData.stats);
const datasets: ChartDataset<"bar">[] = [];
let endTime: Date;
this._start = energyData.start;
this._end = energyData.end || endOfToday();
if (statisticsData.length === 0) {
this._chartData = {
datasets,
};
return;
}
endTime = new Date(
Math.max(
...statisticsData.map((stats) =>
stats.length ? new Date(stats[stats.length - 1].start).getTime() : 0
)
)
);
if (endTime > new Date()) {
endTime = new Date();
}
const combinedData: {
to_grid?: { [statId: string]: { [start: string]: number } };
to_battery?: { [statId: string]: { [start: string]: number } };
from_grid?: { [statId: string]: { [start: string]: number } };
used_grid?: { [statId: string]: { [start: string]: number } };
used_solar?: { [statId: string]: { [start: string]: number } };
used_battery?: { [statId: string]: { [start: string]: number } };
} = {};
const summedData: {
to_grid?: { [start: string]: number };
from_grid?: { [start: string]: number };
to_battery?: { [start: string]: number };
from_battery?: { [start: string]: number };
solar?: { [start: string]: number };
} = {};
const computedStyles = getComputedStyle(this);
const colors = {
to_grid: computedStyles
.getPropertyValue("--energy-grid-return-color")
.trim(),
to_battery: computedStyles
.getPropertyValue("--energy-battery-in-color")
.trim(),
from_grid: computedStyles
.getPropertyValue("--energy-grid-consumption-color")
.trim(),
used_grid: computedStyles
.getPropertyValue("--energy-grid-consumption-color")
.trim(),
used_solar: computedStyles
.getPropertyValue("--energy-solar-color")
.trim(),
used_battery: computedStyles
.getPropertyValue("--energy-battery-out-color")
.trim(),
};
const labels = {
used_grid: "Combined from grid",
used_solar: "Consumed solar",
used_battery: "Consumed battery",
};
const backgroundColor = computedStyles
.getPropertyValue("--card-background-color")
.trim();
Object.entries(statistics).forEach(([key, statIds]) => {
const sum = [
"solar",
"to_grid",
"from_grid",
"to_battery",
"from_battery",
].includes(key);
const add = !["solar", "from_battery"].includes(key);
const totalStats: { [start: string]: number } = {};
const sets: { [statId: string]: { [start: string]: number } } = {};
statIds!.forEach((id) => {
const stats =
dayDifference > 35
? reduceSumStatisticsByMonth(energyData.stats[id])
: dayDifference > 2
? reduceSumStatisticsByDay(energyData.stats[id])
: energyData.stats[id];
if (!stats) {
return;
}
const set = {};
let prevValue: number;
stats.forEach((stat) => {
if (stat.sum === null) {
return;
}
if (prevValue === undefined) {
prevValue = stat.sum;
return;
}
const val = stat.sum - prevValue;
// Get total of solar and to grid to calculate the solar energy used
if (sum) {
totalStats[stat.start] =
stat.start in totalStats ? totalStats[stat.start] + val : val;
}
if (add && !(stat.start in set)) {
set[stat.start] = val;
}
prevValue = stat.sum;
});
sets[id] = set;
});
if (sum) {
summedData[key] = totalStats;
}
if (add) {
combinedData[key] = sets;
}
});
const grid_to_battery = {};
const battery_to_grid = {};
if ((summedData.to_grid || summedData.to_battery) && summedData.solar) {
const used_solar = {};
for (const start of Object.keys(summedData.solar)) {
used_solar[start] =
(summedData.solar[start] || 0) -
(summedData.to_grid?.[start] || 0) -
(summedData.to_battery?.[start] || 0);
if (used_solar[start] < 0) {
if (summedData.to_battery) {
grid_to_battery[start] = used_solar[start] * -1;
if (grid_to_battery[start] > (summedData.from_grid?.[start] || 0)) {
battery_to_grid[start] = Math.min(
0,
grid_to_battery[start] - (summedData.from_grid?.[start] || 0)
);
grid_to_battery[start] = summedData.from_grid?.[start];
}
}
used_solar[start] = 0;
}
}
combinedData.used_solar = { used_solar };
}
if (summedData.from_battery) {
if (summedData.to_grid) {
const used_battery = {};
for (const start of Object.keys(summedData.from_battery)) {
used_battery[start] =
(summedData.from_battery![start] || 0) -
(battery_to_grid[start] || 0);
}
combinedData.used_battery = { used_battery };
} else {
combinedData.used_battery = { used_battery: summedData.from_battery };
}
}
if (combinedData.from_grid && summedData.to_battery) {
const used_grid = {};
for (const start of Object.keys(grid_to_battery)) {
let noOfSources = 0;
let source: string;
for (const [key, stats] of Object.entries(combinedData.from_grid)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1) {
combinedData.from_grid[source!][start] -= grid_to_battery[start] || 0;
} else {
let total_from_grid = 0;
Object.values(combinedData.from_grid).forEach((stats) => {
total_from_grid += stats[start] || 0;
delete stats[start];
});
used_grid[start] = total_from_grid - (grid_to_battery[start] || 0);
}
}
combinedData.used_grid = { used_grid };
}
let allKeys: string[] = [];
Object.values(combinedData).forEach((sources) => {
Object.values(sources).forEach((source) => {
allKeys = allKeys.concat(Object.keys(source));
});
});
const uniqueKeys = Array.from(new Set(allKeys));
Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(sources).forEach(([statId, source], idx) => {
const data: ChartDataset<"bar">[] = [];
const entity = this.hass.states[statId];
const borderColor =
idx > 0
? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(colors[type])), idx)))
: colors[type];
data.push({
label:
type in labels
? labels[type]
: entity
? computeStateName(entity)
: statId,
order:
type === "used_solar"
? 0
: type === "to_battery"
? Object.keys(combinedData).length
: idx + 1,
borderColor,
backgroundColor: hexBlend(borderColor, backgroundColor, 50),
stack: "stack",
data: [],
});
// Process chart data.
for (const key of uniqueKeys) {
const value = source[key] || 0;
const date = new Date(key);
// @ts-expect-error
data[0].data.push({
x: date.getTime(),
y:
value && ["to_grid", "to_battery"].includes(type)
? -1 * value
: value,
});
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
});
this._chartData = {
datasets,
};
}
static get styles(): CSSResultGroup {
return css`
ha-card {
height: 100%;
}
.card-header {
padding-bottom: 0;
}
.content {
padding: 16px;
}
.has-header {
padding-top: 0;
}
.no-data {
position: absolute;
height: 100%;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
padding: 20%;
margin-left: 32px;
box-sizing: border-box;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-usage-graph-card": HuiEnergyUsageGraphCard;
}
}