Compare commits

..

9 Commits

Author SHA1 Message Date
Petar Petrov
8b3b7e701a Apply suggestions from code review
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-10-14 16:07:22 +03:00
Petar Petrov
299c08c0ea update data format 2025-10-14 16:04:17 +03:00
Petar Petrov
2fd33069c1 Merge branch 'power' into power-chart 2025-10-14 16:00:41 +03:00
Petar Petrov
8dde4c9a21 remove unused code 2025-10-14 10:45:42 +03:00
Petar Petrov
7a2826a580 tweak 2025-10-14 10:28:33 +03:00
Petar Petrov
c32e5a049a remove debug code 2025-10-14 10:21:31 +03:00
Petar Petrov
f294c1bae6 Rename power graph to power sources graph 2025-10-14 10:18:43 +03:00
Petar Petrov
4aadd7ec71 Merge branch 'power' into power-chart 2025-10-13 09:42:11 +03:00
Petar Petrov
f68d885dd8 Power graph card 2025-10-11 15:16:31 +03:00
7 changed files with 397 additions and 7 deletions

View File

@@ -360,6 +360,35 @@ export const getReferencedStatisticIds = (
return statIDs;
};
export const getReferencedStatisticIdsPower = (
prefs: EnergyPreferences
): string[] => {
const statIDs: (string | undefined)[] = [];
for (const source of prefs.energy_sources) {
if (source.type === "gas" || source.type === "water") {
continue;
}
if (source.type === "solar") {
statIDs.push(source.stat_power);
continue;
}
if (source.type === "battery") {
statIDs.push(source.stat_power);
continue;
}
if (source.power) {
statIDs.push(...source.power.map((p) => p.stat_power));
}
}
statIDs.push(...prefs.device_consumption.map((d) => d.stat_power));
return statIDs.filter(Boolean) as string[];
};
export const enum CompareMode {
NONE = "",
PREVIOUS = "previous",
@@ -407,9 +436,10 @@ const getEnergyData = async (
"gas",
"device",
]);
const powerStatIds = getReferencedStatisticIdsPower(prefs);
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
const allStatIDs = [...energyStatIds, ...waterStatIds];
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
const dayDifference = differenceInDays(end || new Date(), start);
const period =
@@ -420,6 +450,8 @@ const getEnergyData = async (
: dayDifference > 2
? "day"
: "hour";
const finePeriod =
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
const statsMetadata: Record<string, StatisticsMetaData> = {};
const statsMetadataArray = allStatIDs.length
@@ -441,6 +473,9 @@ const getEnergyData = async (
? (gasUnit as (typeof VOLUME_UNITS)[number])
: undefined,
};
const powerUnits: StatisticsUnitConfiguration = {
power: "kW",
};
const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata);
const waterUnits: StatisticsUnitConfiguration = {
volume: waterUnit,
@@ -451,6 +486,12 @@ const getEnergyData = async (
"change",
])
: {};
const _powerStats: Statistics | Promise<Statistics> = powerStatIds.length
? fetchStatistics(hass!, start, end, powerStatIds, finePeriod, powerUnits, [
"mean",
])
: {};
const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length
? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [
"change",
@@ -557,6 +598,7 @@ const getEnergyData = async (
const [
energyStats,
powerStats,
waterStats,
energyStatsCompare,
waterStatsCompare,
@@ -564,13 +606,14 @@ const getEnergyData = async (
fossilEnergyConsumptionCompare,
] = await Promise.all([
_energyStats,
_powerStats,
_waterStats,
_energyStatsCompare,
_waterStatsCompare,
_fossilEnergyConsumption,
_fossilEnergyConsumptionCompare,
]);
const stats = { ...energyStats, ...waterStats };
const stats = { ...energyStats, ...waterStats, ...powerStats };
if (compare) {
statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
}

View File

@@ -16,6 +16,7 @@ import {
import type {
BarSeriesOption,
CallbackDataParams,
LineSeriesOption,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../../../../data/translation";
@@ -170,11 +171,10 @@ function formatTooltip(
compare
? `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}: `
: ""
}${formatTime(date, locale, config)} ${formatTime(
addHours(date, 1),
locale,
config
)}`;
}${formatTime(date, locale, config)}`;
if (params[0].componentSubType === "bar") {
period += ` ${formatTime(addHours(date, 1), locale, config)}`;
}
}
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
@@ -281,6 +281,35 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
});
}
export function fillLineGaps(datasets: LineSeriesOption[]) {
const buckets = Array.from(
new Set(
datasets
.map((dataset) =>
dataset.data!.map((datapoint) => Number(datapoint![0]))
)
.flat()
)
).sort((a, b) => a - b);
buckets.forEach((bucket, index) => {
for (let i = datasets.length - 1; i >= 0; i--) {
const dataPoint = datasets[i].data![index];
const item: any =
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
? dataPoint
: { value: dataPoint };
const x = item.value?.[0];
if (x === undefined) {
continue;
}
if (Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, [bucket, 0]);
}
}
});
return datasets;
}
export function getCompareTransform(start: Date, compareStart?: Date) {
if (!compareStart) {
return (ts: Date) => ts;

View File

@@ -0,0 +1,305 @@
import { endOfToday, isToday, startOfToday } from "date-fns";
import type { HassConfig, 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 memoizeOne from "memoize-one";
import type { LineSeriesOption } from "echarts/charts";
import { graphic } from "echarts";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import { getEnergyDataCollection } from "../../../../data/energy";
import type { StatisticValue } from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { PowerSourcesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
import { hex2rgb } from "../../../../common/color/convert-color";
@customElement("hui-power-sources-graph-card")
export class HuiPowerSourcesGraphCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: PowerSourcesGraphCardConfig;
@state() private _chartData: LineSeriesOption[] = [];
@state() private _start = startOfToday();
@state() private _end = endOfToday();
@state() private _compareStart?: Date;
@state() private _compareEnd?: Date;
protected hassSubscribeRequiredHostProps = ["_config"];
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: PowerSourcesGraphCardConfig): void {
this._config = config;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
return (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
);
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
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
.hass=${this.hass}
.data=${this._chartData}
.options=${this._createOptions(
this._start,
this._end,
this.hass.locale,
this.hass.config,
this._compareStart,
this._compareEnd
)}
></ha-chart-base>
${!this._chartData.some((dataset) => dataset.data!.length)
? html`<div class="no-data">
${isToday(this._start)
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
: this.hass.localize(
"ui.panel.lovelace.cards.energy.no_data_period"
)}
</div>`
: nothing}
</div>
</ha-card>
`;
}
private _createOptions = memoizeOne(
(
start: Date,
end: Date,
locale: FrontendLocaleData,
config: HassConfig,
compareStart?: Date,
compareEnd?: Date
): ECOption =>
getCommonOptions(
start,
end,
locale,
config,
"kW",
compareStart,
compareEnd
)
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
const datasets: LineSeriesOption[] = [];
const statIds = {
solar: {
stats: [] as string[],
color: "--energy-solar-color",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.solar"
),
},
grid: {
stats: [] as string[],
color: "--energy-grid-consumption-color",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.grid"
),
},
battery: {
stats: [] as string[],
color: "--energy-battery-out-color",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.battery"
),
},
};
const computedStyles = getComputedStyle(this);
for (const source of energyData.prefs.energy_sources) {
if (source.type === "solar") {
if (source.stat_power) {
statIds.solar.stats.push(source.stat_power);
}
continue;
}
if (source.type === "battery") {
if (source.stat_power) {
statIds.battery.stats.push(source.stat_power);
}
continue;
}
if (source.type === "grid" && source.power) {
statIds.grid.stats.push(...source.power.map((p) => p.stat_power));
}
}
const commonSeriesOptions: LineSeriesOption = {
type: "line",
smooth: 0.4,
smoothMonotone: "x",
lineStyle: {
width: 1,
},
};
Object.keys(statIds).forEach((key, keyIndex) => {
if (statIds[key].stats.length) {
const colorHex = computedStyles.getPropertyValue(statIds[key].color);
const rgb = hex2rgb(colorHex);
const { positive, negative } = this._processData(
statIds[key].stats.map((id: string) => energyData.stats[id] ?? [])
);
datasets.push({
...commonSeriesOptions,
id: key,
name: statIds[key].name,
color: colorHex,
stack: "positive",
areaStyle: {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
},
{
offset: 1,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
},
]),
},
data: positive,
z: 3 - keyIndex, // draw in reverse order so 0 value lines are overwritten
});
if (key !== "solar") {
datasets.push({
...commonSeriesOptions,
id: `${key}-negative`,
name: statIds[key].name,
color: colorHex,
stack: "negative",
areaStyle: {
color: new graphic.LinearGradient(0, 1, 0, 0, [
{
offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
},
{
offset: 1,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
},
]),
},
data: negative,
z: 4 - keyIndex, // draw in reverse order but above positive series
});
}
}
});
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._chartData = fillLineGaps(datasets);
}
private _processData(stats: StatisticValue[][]) {
const data: Record<number, number[]> = {};
stats.forEach((statSet) => {
statSet.forEach((point) => {
if (point.mean == null) {
return;
}
const x = (point.start + point.end) / 2;
data[x] = [...(data[x] ?? []), point.mean];
});
});
const positive: [number, number][] = [];
const negative: [number, number][] = [];
Object.entries(data).forEach(([x, y]) => {
const ts = Number(x);
const meanY = y.reduce((a, b) => a + b, 0) / y.length;
positive.push([ts, Math.max(0, meanY)]);
negative.push([ts, Math.min(0, meanY)]);
});
return { positive, negative };
}
static styles = css`
ha-card {
height: 100%;
}
.card-header {
padding-bottom: 0;
}
.content {
padding: var(--ha-space-4);
}
.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: var(--ha-space-8);
margin-inline-start: var(--ha-space-8);
margin-inline-end: initial;
box-sizing: border-box;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-power-sources-graph-card": HuiPowerSourcesGraphCard;
}
}

View File

@@ -227,6 +227,11 @@ export interface EnergySankeyCardConfig extends EnergyCardBaseConfig {
group_by_area?: boolean;
}
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
type: "power-sources-graph";
title?: string;
}
export interface EntityFilterCardConfig extends LovelaceCardConfig {
type: "entity-filter";
entities: (EntityFilterEntityConfig | string)[];

View File

@@ -66,6 +66,8 @@ const LAZY_LOAD_TYPES = {
"energy-usage-graph": () =>
import("../cards/energy/hui-energy-usage-graph-card"),
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
"power-sources-graph": () =>
import("../cards/energy/hui-power-sources-graph-card"),
"entity-filter": () => import("../cards/hui-entity-filter-card"),
error: () => import("../cards/hui-error-card"),
"home-summary": () => import("../cards/hui-home-summary-card"),

View File

@@ -22,6 +22,7 @@ const NON_STANDARD_URLS = {
"energy-devices-graph": "energy/#devices-energy-graph",
"energy-devices-detail-graph": "energy/#detail-devices-energy-graph",
"energy-sankey": "energy/#sankey-energy-graph",
"power-sources-graph": "energy/#power-sources-graph",
};
export const getCardDocumentationURL = (

View File

@@ -7053,6 +7053,11 @@
"card_indicates_energy_used": "This card indicates how much of the electricity consumed by your home was generated using non-fossil fuels like solar, wind, and nuclear. The higher, the better!",
"low_carbon_energy_consumed": "Low-carbon electricity consumed",
"low_carbon_energy_not_calculated": "Consumed low-carbon electricity couldn't be calculated"
},
"power_graph": {
"grid": "Grid",
"solar": "Solar",
"battery": "Battery"
}
},
"heading": {