diff --git a/src/common/string/format_number.ts b/src/common/string/format_number.ts index 2cfa22458b..a921ee50c4 100644 --- a/src/common/string/format_number.ts +++ b/src/common/string/format_number.ts @@ -1,5 +1,22 @@ import { FrontendLocaleData, NumberFormat } from "../../data/translation"; +export const numberFormatToLocale = ( + localeOptions: FrontendLocaleData +): string | string[] | undefined => { + switch (localeOptions.number_format) { + case NumberFormat.comma_decimal: + return ["en-US", "en"]; // Use United States with fallback to English formatting 1,234,567.89 + case NumberFormat.decimal_comma: + return ["de", "es", "it"]; // Use German with fallback to Spanish then Italian formatting 1.234.567,89 + case NumberFormat.space_comma: + return ["fr", "sv", "cs"]; // Use French with fallback to Swedish and Czech formatting 1 234 567,89 + case NumberFormat.system: + return undefined; + default: + return localeOptions.language; + } +}; + /** * Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility. * @@ -9,27 +26,12 @@ import { FrontendLocaleData, NumberFormat } from "../../data/translation"; */ export const formatNumber = ( num: string | number, - locale?: FrontendLocaleData, + localeOptions?: FrontendLocaleData, options?: Intl.NumberFormatOptions ): string => { - let format: string | string[] | undefined; - - switch (locale?.number_format) { - case NumberFormat.comma_decimal: - format = ["en-US", "en"]; // Use United States with fallback to English formatting 1,234,567.89 - break; - case NumberFormat.decimal_comma: - format = ["de", "es", "it"]; // Use German with fallback to Spanish then Italian formatting 1.234.567,89 - break; - case NumberFormat.space_comma: - format = ["fr", "sv", "cs"]; // Use French with fallback to Swedish and Czech formatting 1 234 567,89 - break; - case NumberFormat.system: - format = undefined; - break; - default: - format = locale?.language; - } + const locale = localeOptions + ? numberFormatToLocale(localeOptions) + : undefined; // Polyfill for Number.isNaN, which is more reliable than the global isNaN() Number.isNaN = @@ -39,13 +41,13 @@ export const formatNumber = ( }; if ( + localeOptions?.number_format !== NumberFormat.none && !Number.isNaN(Number(num)) && - Intl && - locale?.number_format !== NumberFormat.none + Intl ) { try { return new Intl.NumberFormat( - format, + locale, getDefaultFormatOptions(num, options) ).format(Number(num)); } catch (error) { diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index c662823f58..8c4c3cc16e 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -152,7 +152,17 @@ export default class HaChartBase extends LitElement { .querySelector("canvas")! .getContext("2d")!; - this.chart = new (await import("../../resources/chartjs")).Chart(ctx, { + const ChartConstructor = (await import("../../resources/chartjs")).Chart; + + const computedStyles = getComputedStyle(this); + + ChartConstructor.defaults.borderColor = + computedStyles.getPropertyValue("--divider-color"); + ChartConstructor.defaults.color = computedStyles.getPropertyValue( + "--secondary-text-color" + ); + + this.chart = new ChartConstructor(ctx, { type: this.chartType, data: this.data, options: this._createOptions(), diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index 30e6dff80f..797fd55316 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -109,6 +109,8 @@ class StateHistoryChartLine extends LitElement { hitRadius: 5, }, }, + // @ts-expect-error + locale: numberFormatToLocale(this.hass.locale), }; } if (changedProps.has("data")) { diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index 2ce6277871..db81411b6a 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators"; import { getColorByIndex } from "../../common/color/colors"; import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; import { computeDomain } from "../../common/entity/compute_domain"; +import { numberFormatToLocale } from "../../common/string/format_number"; import { computeRTL } from "../../common/util/compute_rtl"; import { TimelineEntity } from "../../data/history"; import { HomeAssistant } from "../../types"; @@ -186,6 +187,8 @@ export class StateHistoryChartTimeline extends LitElement { propagate: true, }, }, + // @ts-expect-error + locale: numberFormatToLocale(this.hass.locale), }; } if (changedProps.has("data")) { diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index 6d4db48c70..0ac2abb66a 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -16,6 +16,7 @@ import { customElement, property, state } from "lit/decorators"; import { getColorByIndex } from "../../common/color/colors"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { computeStateName } from "../../common/entity/compute_state_name"; +import { numberFormatToLocale } from "../../common/string/format_number"; import { Statistics, statisticsHaveType, @@ -119,7 +120,7 @@ class StatisticsChart extends LitElement { : {}, }, time: { - tooltipFormat: "datetimeseconds", + tooltipFormat: "datetime", }, }, y: { @@ -157,6 +158,8 @@ class StatisticsChart extends LitElement { hitRadius: 5, }, }, + // @ts-expect-error + locale: numberFormatToLocale(this.hass.locale), }; } diff --git a/src/panels/config/energy/dialogs/dialog-energy-grid-flow-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-grid-flow-settings.ts index 3869ae6dfe..0963c6beed 100644 --- a/src/panels/config/energy/dialogs/dialog-energy-grid-flow-settings.ts +++ b/src/panels/config/energy/dialogs/dialog-energy-grid-flow-settings.ts @@ -212,7 +212,15 @@ export class DialogEnergyGridFlowSettings ${this.hass.localize("ui.common.cancel")} - + ${this.hass.localize("ui.common.save")} @@ -231,32 +239,42 @@ export class DialogEnergyGridFlowSettings } private _numberPriceChanged(ev: CustomEvent) { - this._source!.number_energy_price = Number(ev.detail.value); - this._source!.entity_energy_price = null; this._costStat = null; + this._source = { + ...this._source!, + number_energy_price: Number(ev.detail.value), + entity_energy_price: null, + }; } private _priceStatChanged(ev: CustomEvent) { this._costStat = ev.detail.value; - this._source!.entity_energy_price = null; - this._source!.number_energy_price = null; + this._source = { + ...this._source!, + entity_energy_price: null, + number_energy_price: null, + }; } private _priceEntityChanged(ev: CustomEvent) { - this._source!.entity_energy_price = ev.detail.value; - this._source!.number_energy_price = null; this._costStat = null; + this._source = { + ...this._source!, + entity_energy_price: ev.detail.value, + number_energy_price: null, + }; } private _statisticChanged(ev: CustomEvent<{ value: string }>) { - this._source![ - this._params!.direction === "from" ? "stat_energy_from" : "stat_energy_to" - ] = ev.detail.value; - this._source![ - this._params!.direction === "from" + this._source = { + ...this._source!, + [this._params!.direction === "from" + ? "stat_energy_from" + : "stat_energy_to"]: ev.detail.value, + [this._params!.direction === "from" ? "entity_energy_from" - : "entity_energy_to" - ] = ev.detail.value; + : "entity_energy_to"]: ev.detail.value, + }; } private async _save() { diff --git a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts index ba65d67349..2e55ba598d 100644 --- a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts +++ b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts @@ -140,7 +140,11 @@ export class DialogEnergySolarSettings ${this.hass.localize("ui.common.cancel")} - + ${this.hass.localize("ui.common.save")} @@ -191,7 +195,7 @@ export class DialogEnergySolarSettings } private _statisticChanged(ev: CustomEvent<{ value: string }>) { - this._source!.stat_energy_from = ev.detail.value; + this._source = { ...this._source!, stat_energy_from: ev.detail.value }; } private async _save() { diff --git a/src/panels/energy/cards/energy-setup-wizard-card.ts b/src/panels/energy/cards/energy-setup-wizard-card.ts index 69c6b42b1e..aa5178ac5f 100644 --- a/src/panels/energy/cards/energy-setup-wizard-card.ts +++ b/src/panels/energy/cards/energy-setup-wizard-card.ts @@ -61,15 +61,15 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard { >`}
${this._step > 0 - ? html`${this.hass.localize("ui.panel.energy.setup.back")}` : html`
`} ${this._step < 2 - ? html`${this.hass.localize("ui.panel.energy.setup.next")}` - : html` + : html` ${this.hass.localize("ui.panel.energy.setup.done")} `}
diff --git a/src/panels/energy/strategies/energy-strategy.ts b/src/panels/energy/strategies/energy-strategy.ts index 787d1de38b..7177e6d9a7 100644 --- a/src/panels/energy/strategies/energy-strategy.ts +++ b/src/panels/energy/strategies/energy-strategy.ts @@ -50,7 +50,7 @@ export class EnergyStrategy { if (hasGrid) { view.cards!.push({ title: "Energy usage", - type: "energy-summary-graph", + type: "energy-usage-graph", prefs: energyPrefs, }); } @@ -64,11 +64,10 @@ export class EnergyStrategy { }); } - // Only include if we have a grid. - if (hasGrid) { + if (hasGrid || hasSolar) { view.cards!.push({ - title: "Costs", - type: "energy-costs-table", + title: "Sources", + type: "energy-sources-table", prefs: energyPrefs, }); } diff --git a/src/panels/lovelace/cards/energy/hui-energy-carbon-consumed-gauge-card.ts b/src/panels/lovelace/cards/energy/hui-energy-carbon-consumed-gauge-card.ts index a54f12c88c..f682895585 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-carbon-consumed-gauge-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-carbon-consumed-gauge-card.ts @@ -105,7 +105,7 @@ class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard { (totalSolarProduction || 0) - (totalGridReturned || 0); - value = round((1 - highCarbonEnergy / totalEnergyConsumed) * 100); + value = round((highCarbonEnergy / totalEnergyConsumed) * 100); } return html` diff --git a/src/panels/lovelace/cards/energy/hui-energy-costs-table-card.ts b/src/panels/lovelace/cards/energy/hui-energy-costs-table-card.ts deleted file mode 100644 index 413288bc59..0000000000 --- a/src/panels/lovelace/cards/energy/hui-energy-costs-table-card.ts +++ /dev/null @@ -1,272 +0,0 @@ -// @ts-ignore -import dataTableStyles from "@material/data-table/dist/mdc.data-table.min.css"; -import { - css, - CSSResultGroup, - html, - LitElement, - TemplateResult, - unsafeCSS, -} from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { computeStateName } from "../../../../common/entity/compute_state_name"; -import { round } from "../../../../common/number/round"; -import { formatNumber } from "../../../../common/string/format_number"; -import "../../../../components/chart/statistics-chart"; -import "../../../../components/ha-card"; -import { - EnergyInfo, - getEnergyInfo, - GridSourceTypeEnergyPreference, -} from "../../../../data/energy"; -import { - calculateStatisticSumGrowth, - fetchStatistics, - Statistics, -} from "../../../../data/history"; -import { HomeAssistant } from "../../../../types"; -import { LovelaceCard } from "../../types"; -import { EnergyDevicesGraphCardConfig } from "../types"; - -@customElement("hui-energy-costs-table-card") -export class HuiEnergyCostsTableCard - extends LitElement - implements LovelaceCard -{ - @property({ attribute: false }) public hass!: HomeAssistant; - - @state() private _config?: EnergyDevicesGraphCardConfig; - - @state() private _stats?: Statistics; - - @state() private _energyInfo?: EnergyInfo; - - public getCardSize(): Promise | number { - return 3; - } - - public setConfig(config: EnergyDevicesGraphCardConfig): void { - this._config = config; - } - - public willUpdate() { - if (!this.hasUpdated) { - this._getEnergyInfo().then(() => this._getStatistics()); - } - } - - protected render(): TemplateResult { - if (!this.hass || !this._config) { - return html``; - } - - if (!this._stats) { - return html`Loading...`; - } - - const source = this._config.prefs.energy_sources?.find( - (src) => src.type === "grid" - ) as GridSourceTypeEnergyPreference | undefined; - - if (!source) { - return html`No grid source found.`; - } - - let totalEnergy = 0; - let totalCost = 0; - - return html` -
-
- - - - - - - - - - ${source.flow_from.map((flow) => { - const entity = this.hass.states[flow.stat_energy_from]; - const energy = - calculateStatisticSumGrowth( - this._stats![flow.stat_energy_from] - ) || 0; - totalEnergy += energy; - const cost_stat = - flow.stat_cost || - this._energyInfo!.cost_sensors[flow.stat_energy_from]; - const cost = - (cost_stat && - calculateStatisticSumGrowth(this._stats![cost_stat])) || - 0; - totalCost += cost; - return html` - - - - `; - })} - ${source.flow_to.map((flow) => { - const entity = this.hass.states[flow.stat_energy_to]; - const energy = - (calculateStatisticSumGrowth( - this._stats![flow.stat_energy_to] - ) || 0) * -1; - totalEnergy += energy; - const cost_stat = - flow.stat_compensation || - this._energyInfo!.cost_sensors[flow.stat_energy_to]; - const cost = - ((cost_stat && - calculateStatisticSumGrowth(this._stats![cost_stat])) || - 0) * -1; - totalCost += cost; - return html` - - - - `; - })} - - - - - - -
- Grid source - - Energy - - Cost -
- ${entity ? computeStateName(entity) : flow.stat_energy_from} - - ${round(energy)} kWh - - ${formatNumber(cost, this.hass.locale, { - style: "currency", - currency: this.hass.config.currency!, - })} -
- ${entity ? computeStateName(entity) : flow.stat_energy_to} - - ${round(energy)} kWh - - ${formatNumber(cost, this.hass.locale, { - style: "currency", - currency: this.hass.config.currency!, - })} -
Total - ${round(totalEnergy)} kWh - - ${formatNumber(totalCost, this.hass.locale, { - style: "currency", - currency: this.hass.config.currency!, - })} -
-
-
-
`; - } - - private async _getEnergyInfo() { - this._energyInfo = await getEnergyInfo(this.hass); - } - - private async _getStatistics(): Promise { - const startDate = new Date(); - startDate.setHours(0, 0, 0, 0); - startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint - - const statistics: string[] = Object.values(this._energyInfo!.cost_sensors); - const prefs = this._config!.prefs; - for (const source of prefs.energy_sources) { - if (source.type === "solar") { - continue; - } - - // grid source - for (const flowFrom of source.flow_from) { - statistics.push(flowFrom.stat_energy_from); - if (flowFrom.stat_cost) { - statistics.push(flowFrom.stat_cost); - } - } - for (const flowTo of source.flow_to) { - statistics.push(flowTo.stat_energy_to); - if (flowTo.stat_compensation) { - statistics.push(flowTo.stat_compensation); - } - } - } - - this._stats = await fetchStatistics( - this.hass!, - startDate, - undefined, - statistics - ); - } - - static get styles(): CSSResultGroup { - return css` - ${unsafeCSS(dataTableStyles)} - .mdc-data-table { - width: 100%; - border: 0; - } - .mdc-data-table__header-cell, - .mdc-data-table__cell { - color: var(--primary-text-color); - border-bottom-color: var(--divider-color); - } - .mdc-data-table__row:not(.mdc-data-table__row--selected):hover { - background-color: rgba(var(--rgb-primary-text-color), 0.04); - } - .total { - --mdc-typography-body2-font-weight: 500; - } - .total .mdc-data-table__cell { - border-top: 1px solid var(--divider-color); - } - ha-card { - height: 100%; - } - .content { - padding: 16px; - } - .has-header { - padding-top: 0; - } - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "hui-energy-costs-table-card": HuiEnergyCostsTableCard; - } -} diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts index 758a6c23b9..f9d8c1cb5d 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts @@ -16,6 +16,10 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { getColorByIndex } from "../../../../common/color/colors"; import { computeStateName } from "../../../../common/entity/compute_state_name"; +import { + formatNumber, + numberFormatToLocale, +} from "../../../../common/string/format_number"; import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import { @@ -106,7 +110,10 @@ export class HuiEnergyDevicesGraphCard } return html` - + + ${this._config.title + ? html`

${this._config.title}

` + : ""}
- `${context.dataset.label}: ${ - Math.round(context.parsed.x * 100) / 100 - } kWh`, + `${context.dataset.label}: ${formatNumber( + context.parsed.x, + this.hass.locale + )} kWh`, }, }, }, + // @ts-expect-error + locale: numberFormatToLocale(this.hass.locale), }; } @@ -236,6 +246,9 @@ export class HuiEnergyDevicesGraphCard ha-card { height: 100%; } + .card-header { + padding-bottom: 0; + } .content { padding: 16px; } diff --git a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts index 4546c15c33..286940fd58 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts @@ -10,7 +10,7 @@ import { css, html, LitElement, svg } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; -import { round } from "../../../../common/number/round"; +import { formatNumber } from "../../../../common/string/format_number"; import { subscribeOne } from "../../../../common/util/subscribe-one"; import "../../../../components/ha-card"; import "../../../../components/ha-svg-icon"; @@ -140,13 +140,9 @@ class HuiEnergyDistrubutionCard extends LitElement implements LovelaceCard { } if (highCarbonConsumption !== null) { - const gridPctHighCarbon = highCarbonConsumption / totalConsumption; + lowCarbonConsumption = totalGridConsumption - highCarbonConsumption; - lowCarbonConsumption = - totalGridConsumption - totalGridConsumption * gridPctHighCarbon; - - const homePctGridHighCarbon = - (gridPctHighCarbon * totalGridConsumption) / totalConsumption; + const homePctGridHighCarbon = highCarbonConsumption / totalConsumption; homeHighCarbonCircumference = CIRCLE_CIRCUMFERENCE * homePctGridHighCarbon; @@ -158,6 +154,15 @@ class HuiEnergyDistrubutionCard extends LitElement implements LovelaceCard { } } + homeSolarCircumference = CIRCLE_CIRCUMFERENCE * 0.1; + + homeHighCarbonCircumference = CIRCLE_CIRCUMFERENCE * 0.8; + + homeLowCarbonCircumference = + CIRCLE_CIRCUMFERENCE - + (homeSolarCircumference || 0) - + homeHighCarbonCircumference; + return html`
@@ -165,29 +170,39 @@ class HuiEnergyDistrubutionCard extends LitElement implements LovelaceCard { ? html`
${lowCarbonConsumption === undefined ? html`
` - : html` - - `} + : html``} ${hasSolarProduction ? html`
Solar
- ${round(totalSolarProduction || 0, 1)} kWh + ${formatNumber( + totalSolarProduction || 0, + this.hass.locale, + { maximumFractionDigits: 1 } + )} + kWh
` : ""} @@ -204,7 +219,11 @@ class HuiEnergyDistrubutionCard extends LitElement implements LovelaceCard { class="small" .path=${mdiArrowRight} >` - : ""}${round(totalGridConsumption, 1)} + : ""}${formatNumber( + totalGridConsumption, + this.hass.locale, + { maximumFractionDigits: 1 } + )} kWh ${productionReturnedToGrid !== null @@ -213,7 +232,12 @@ class HuiEnergyDistrubutionCard extends LitElement implements LovelaceCard { class="small" .path=${mdiArrowLeft} >${round(productionReturnedToGrid, 1)} kWh + >${formatNumber( + productionReturnedToGrid, + this.hass.locale, + { maximumFractionDigits: 1 } + )} + kWh ` : ""}
@@ -228,41 +252,44 @@ class HuiEnergyDistrubutionCard extends LitElement implements LovelaceCard { })}" > - ${round(totalConsumption, 1)} kWh + ${formatNumber(totalConsumption, this.hass.locale, { + maximumFractionDigits: 1, + })} + kWh ${homeSolarCircumference !== undefined || homeLowCarbonCircumference !== undefined ? html` ${homeSolarCircumference !== undefined - ? svg` - ` + shape-rendering="geometricPrecision" + stroke-dashoffset="-${ + CIRCLE_CIRCUMFERENCE - homeSolarCircumference + }" + />` : ""} - ${homeHighCarbonCircumference - ? svg` - ` + stroke-dashoffset="-${ + CIRCLE_CIRCUMFERENCE - + homeLowCarbonCircumference - + (homeSolarCircumference || 0) + }" + shape-rendering="geometricPrecision" + />` : ""} ` @@ -315,7 +342,11 @@ class HuiEnergyDistrubutionCard extends LitElement implements LovelaceCard { vector-effect="non-scaling-stroke" > ${productionReturnedToGrid && hasSolarProduction - ? svg` + ? svg` + ? svg` + ? svg` ${value !== undefined @@ -81,7 +84,9 @@ class HuiEnergySolarGaugeCard extends LitElement implements LovelaceCard { })} >
Self consumed solar energy
` - : html`Self consumed solar energy couldn't be calculated`} + : totalSolarProduction === 0 + ? "You have not produced any solar energy" + : "Self consumed solar energy couldn't be calculated"} `; } diff --git a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts index 47a8ffa69d..391585329b 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts @@ -31,8 +31,10 @@ import { computeStateName } from "../../../../common/entity/compute_state_name"; import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-switch"; import "../../../../components/ha-formfield"; - -const SOLAR_COLOR = "#FF9800"; +import { + formatNumber, + numberFormatToLocale, +} from "../../../../common/string/format_number"; @customElement("hui-energy-solar-graph-card") export class HuiEnergySolarGraphCard @@ -119,7 +121,10 @@ export class HuiEnergySolarGraphCard } return html` - + + ${this._config.title + ? html`

${this._config.title}

` + : ""}
- `${context.dataset.label}: ${context.parsed.y} kWh`, + `${context.dataset.label}: ${formatNumber( + context.parsed.y, + this.hass.locale + )} kWh`, }, }, filler: { @@ -212,6 +220,8 @@ export class HuiEnergySolarGraphCard hitRadius: 5, }, }, + // @ts-expect-error + locale: numberFormatToLocale(this.hass.locale), }; } @@ -219,6 +229,7 @@ export class HuiEnergySolarGraphCard if (this._fetching) { return; } + const startDate = new Date(); startDate.setHours(0, 0, 0, 0); startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint @@ -273,20 +284,25 @@ export class HuiEnergySolarGraphCard endTime = new Date(); } + const computedStyles = getComputedStyle(this); + const solarColor = computedStyles + .getPropertyValue("--energy-solar-color") + .trim(); + solarSources.forEach((source, idx) => { const data: ChartDataset<"bar" | "line">[] = []; const entity = this.hass.states[source.stat_energy_from]; const borderColor = idx > 0 - ? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(SOLAR_COLOR)), idx))) - : SOLAR_COLOR; + ? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(solarColor)), idx))) + : solarColor; data.push({ label: `Production ${ entity ? computeStateName(entity) : source.stat_energy_from }`, - borderColor: borderColor, + borderColor, backgroundColor: borderColor + "7F", data: [], }); @@ -307,7 +323,7 @@ export class HuiEnergySolarGraphCard if (prevStart === point.start) { continue; } - const value = Math.round((point.sum - prevValue) * 100) / 100; + const value = point.sum - prevValue; const date = new Date(point.start); data[0].data.push({ x: date.getTime(), @@ -347,7 +363,9 @@ export class HuiEnergySolarGraphCard }`, fill: false, stepped: false, - borderColor: "#000", + borderColor: computedStyles.getPropertyValue( + "--primary-text-color" + ), borderDash: [7, 5], pointRadius: 0, data: [], @@ -386,6 +404,9 @@ export class HuiEnergySolarGraphCard ha-card { height: 100%; } + .card-header { + padding-bottom: 0; + } .content { padding: 16px; } diff --git a/src/panels/lovelace/cards/energy/hui-energy-sources-table-card.ts b/src/panels/lovelace/cards/energy/hui-energy-sources-table-card.ts new file mode 100644 index 0000000000..44f4336609 --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-energy-sources-table-card.ts @@ -0,0 +1,426 @@ +// @ts-ignore +import dataTableStyles from "@material/data-table/dist/mdc.data-table.min.css"; +import { + css, + CSSResultGroup, + html, + LitElement, + TemplateResult, + unsafeCSS, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { + rgb2hex, + lab2rgb, + rgb2lab, + hex2rgb, +} from "../../../../common/color/convert-color"; +import { labDarken } from "../../../../common/color/lab"; +import { computeStateName } from "../../../../common/entity/compute_state_name"; +import { formatNumber } from "../../../../common/string/format_number"; +import "../../../../components/chart/statistics-chart"; +import "../../../../components/ha-card"; +import { + EnergyInfo, + energySourcesByType, + getEnergyInfo, +} from "../../../../data/energy"; +import { + calculateStatisticSumGrowth, + fetchStatistics, + Statistics, +} from "../../../../data/history"; +import { HomeAssistant } from "../../../../types"; +import { LovelaceCard } from "../../types"; +import { EnergySourcesTableCardConfig } from "../types"; + +@customElement("hui-energy-sources-table-card") +export class HuiEnergySourcesTableCard + extends LitElement + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergySourcesTableCardConfig; + + @state() private _stats?: Statistics; + + @state() private _energyInfo?: EnergyInfo; + + public getCardSize(): Promise | number { + return 3; + } + + public setConfig(config: EnergySourcesTableCardConfig): void { + this._config = config; + } + + public willUpdate() { + if (!this.hasUpdated) { + this._getEnergyInfo().then(() => this._getStatistics()); + } + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + if (!this._stats) { + return html`Loading...`; + } + + let totalGrid = 0; + let totalSolar = 0; + let totalCost = 0; + + const types = energySourcesByType(this._config.prefs); + + const computedStyles = getComputedStyle(this); + const solarColor = computedStyles + .getPropertyValue("--energy-solar-color") + .trim(); + const returnColor = computedStyles + .getPropertyValue("--energy-grid-return-color") + .trim(); + const consumptionColor = computedStyles + .getPropertyValue("--energy-grid-consumption-color") + .trim(); + + const showCosts = + types.grid?.[0].flow_from.some( + (flow) => + flow.stat_cost || flow.entity_energy_price || flow.number_energy_price + ) || + types.grid?.[0].flow_to.some( + (flow) => + flow.stat_compensation || + flow.entity_energy_price || + flow.number_energy_price + ); + + return html` + ${this._config.title + ? html`

${this._config.title}

` + : ""} +
+
+ + + + + + + ${showCosts + ? html` ` + : ""} + + + + ${types.solar?.map((source, idx) => { + const entity = this.hass.states[source.stat_energy_from]; + const energy = + calculateStatisticSumGrowth( + this._stats![source.stat_energy_from] + ) || 0; + totalSolar += energy; + const color = + idx > 0 + ? rgb2hex( + lab2rgb(labDarken(rgb2lab(hex2rgb(solarColor)), idx)) + ) + : solarColor; + return html` + + + + ${showCosts + ? html`` + : ""} + `; + })} + ${types.solar + ? html` + + + + ${showCosts + ? html`` + : ""} + ` + : ""} + ${types.grid?.map( + (source) => html`${source.flow_from.map((flow, idx) => { + const entity = this.hass.states[flow.stat_energy_from]; + const energy = + calculateStatisticSumGrowth( + this._stats![flow.stat_energy_from] + ) || 0; + totalGrid += energy; + const cost_stat = + flow.stat_cost || + this._energyInfo!.cost_sensors[flow.stat_energy_from]; + const cost = cost_stat + ? calculateStatisticSumGrowth(this._stats![cost_stat]) + : null; + if (cost !== null) { + totalCost += cost; + } + const color = + idx > 0 + ? rgb2hex( + lab2rgb( + labDarken(rgb2lab(hex2rgb(consumptionColor)), idx) + ) + ) + : consumptionColor; + return html` + + + + ${showCosts + ? html` ` + : ""} + `; + })} + ${source.flow_to.map((flow, idx) => { + const entity = this.hass.states[flow.stat_energy_to]; + const energy = + (calculateStatisticSumGrowth( + this._stats![flow.stat_energy_to] + ) || 0) * -1; + totalGrid += energy; + const cost_stat = + flow.stat_compensation || + this._energyInfo!.cost_sensors[flow.stat_energy_to]; + const cost = cost_stat + ? calculateStatisticSumGrowth(this._stats![cost_stat]) + : null; + if (cost !== null) { + totalCost += cost; + } + const color = + idx > 0 + ? rgb2hex( + lab2rgb(labDarken(rgb2lab(hex2rgb(returnColor)), idx)) + ) + : returnColor; + return html` + + + + ${showCosts + ? html` ` + : ""} + `; + })}` + )} + ${types.grid + ? html` + + + + ${showCosts + ? html`` + : ""} + ` + : ""} + +
+ Source + + Energy + + Cost +
+
+
+ ${entity + ? computeStateName(entity) + : source.stat_energy_from} + + ${formatNumber(energy, this.hass.locale)} kWh +
+ Solar total + + ${formatNumber(totalSolar, this.hass.locale)} kWh +
+
+
+ ${entity + ? computeStateName(entity) + : flow.stat_energy_from} + + ${formatNumber(energy, this.hass.locale)} kWh + + ${cost !== null + ? formatNumber(cost, this.hass.locale, { + style: "currency", + currency: this.hass.config.currency!, + }) + : ""} +
+
+
+ ${entity ? computeStateName(entity) : flow.stat_energy_to} + + ${formatNumber(energy, this.hass.locale)} kWh + + ${cost !== null + ? formatNumber(cost, this.hass.locale, { + style: "currency", + currency: this.hass.config.currency!, + }) + : ""} +
Grid total + ${formatNumber(totalGrid, this.hass.locale)} kWh + + ${formatNumber(totalCost, this.hass.locale, { + style: "currency", + currency: this.hass.config.currency!, + })} +
+
+
+
`; + } + + private async _getEnergyInfo() { + this._energyInfo = await getEnergyInfo(this.hass); + } + + private async _getStatistics(): Promise { + const startDate = new Date(); + startDate.setHours(0, 0, 0, 0); + startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint + + const statistics: string[] = Object.values(this._energyInfo!.cost_sensors); + const prefs = this._config!.prefs; + for (const source of prefs.energy_sources) { + if (source.type === "solar") { + statistics.push(source.stat_energy_from); + } else { + // grid source + for (const flowFrom of source.flow_from) { + statistics.push(flowFrom.stat_energy_from); + if (flowFrom.stat_cost) { + statistics.push(flowFrom.stat_cost); + } + } + for (const flowTo of source.flow_to) { + statistics.push(flowTo.stat_energy_to); + if (flowTo.stat_compensation) { + statistics.push(flowTo.stat_compensation); + } + } + } + } + + this._stats = await fetchStatistics( + this.hass!, + startDate, + undefined, + statistics + ); + } + + static get styles(): CSSResultGroup { + return css` + ${unsafeCSS(dataTableStyles)} + .mdc-data-table { + width: 100%; + border: 0; + } + .mdc-data-table__header-cell, + .mdc-data-table__cell { + color: var(--primary-text-color); + border-bottom-color: var(--divider-color); + } + .mdc-data-table__row:not(.mdc-data-table__row--selected):hover { + background-color: rgba(var(--rgb-primary-text-color), 0.04); + } + .total { + --mdc-typography-body2-font-weight: 500; + } + .total .mdc-data-table__cell { + border-top: 1px solid var(--divider-color); + } + ha-card { + height: 100%; + } + .card-header { + padding-bottom: 0; + } + .content { + padding: 16px; + } + .has-header { + padding-top: 0; + } + .cell-bullet { + width: 32px; + padding-right: 0; + } + .bullet { + border-width: 1px; + border-style: solid; + border-radius: 4px; + height: 16px; + width: 32px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-sources-table-card": HuiEnergySourcesTableCard; + } +} diff --git a/src/panels/lovelace/cards/energy/hui-energy-summary-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts similarity index 74% rename from src/panels/lovelace/cards/energy/hui-energy-summary-graph-card.ts rename to src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts index 2e3eb96354..5c455a4865 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-summary-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts @@ -9,39 +9,34 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; -import { styleMap } from "lit/directives/style-map"; 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 { round } from "../../../../common/number/round"; -import { formatNumber } from "../../../../common/string/format_number"; +import { + formatNumber, + numberFormatToLocale, +} from "../../../../common/string/format_number"; import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import { fetchStatistics, Statistics } from "../../../../data/history"; import { HomeAssistant } from "../../../../types"; import { LovelaceCard } from "../../types"; -import { EnergySummaryGraphCardConfig } from "../types"; +import { EnergyUsageGraphCardConfig } from "../types"; -const NEGATIVE = ["to_grid"]; -const COLORS = { - to_grid: { border: "#673ab7", background: "#b39bdb" }, - from_grid: { border: "#126A9A", background: "#8ab5cd" }, - used_solar: { border: "#FF9800", background: "#fecc8e" }, -}; - -@customElement("hui-energy-summary-graph-card") -export class HuiEnergySummaryGraphCard +@customElement("hui-energy-usage-graph-card") +export class HuiEnergyUsageGraphCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private _config?: EnergySummaryGraphCardConfig; + @state() private _config?: EnergyUsageGraphCardConfig; @state() private _data?: Statistics; @@ -81,7 +76,7 @@ export class HuiEnergySummaryGraphCard return 3; } - public setConfig(config: EnergySummaryGraphCardConfig): void { + public setConfig(config: EnergyUsageGraphCardConfig): void { this._config = config; } @@ -95,7 +90,7 @@ export class HuiEnergySummaryGraphCard } const oldConfig = changedProps.get("_config") as - | EnergySummaryGraphCardConfig + | EnergyUsageGraphCardConfig | undefined; if (oldConfig !== this._config) { @@ -116,42 +111,14 @@ export class HuiEnergySummaryGraphCard return html` -

${this._config.title}

+ ${this._config.title + ? html`

${this._config.title}

` + : ""}
-
-
    - ${this._chartData.datasets.map( - (dataset) => html`
  • -
    -
    - ${dataset.label} -
    - ${formatNumber( - Math.abs( - dataset.data.reduce( - (total, point) => total + (point as any).y, - 0 - ) as number - ), - this.hass.locale - )} - kWh -
  • ` - )} -
-
Math.abs(round(value)), + callback: (value) => + formatNumber(Math.abs(value), this.hass.locale), }, }, }, @@ -218,7 +186,10 @@ export class HuiEnergySummaryGraphCard filter: (val) => val.formattedValue !== "0", callbacks: { label: (context) => - `${context.dataset.label}: ${Math.abs(context.parsed.y)} kWh`, + `${context.dataset.label}: ${formatNumber( + Math.abs(context.parsed.y), + this.hass.locale + )} kWh`, footer: (contexts) => { let totalConsumed = 0; let totalReturned = 0; @@ -233,10 +204,16 @@ export class HuiEnergySummaryGraphCard } return [ totalConsumed - ? `Total consumed: ${totalConsumed.toFixed(2)} kWh` + ? `Total consumed: ${formatNumber( + totalConsumed, + this.hass.locale + )} kWh` : "", totalReturned - ? `Total returned: ${totalReturned.toFixed(2)} kWh` + ? `Total returned: ${formatNumber( + totalReturned, + this.hass.locale + )} kWh` : "", ].filter(Boolean); }, @@ -261,6 +238,8 @@ export class HuiEnergySummaryGraphCard hitRadius: 5, }, }, + // @ts-expect-error + locale: numberFormatToLocale(this.hass.locale), }; } @@ -344,6 +323,23 @@ export class HuiEnergySummaryGraphCard } = {}; const summedData: { [key: string]: { [start: string]: number } } = {}; + const computedStyles = getComputedStyle(this); + const colors = { + to_grid: computedStyles + .getPropertyValue("--energy-grid-return-color") + .trim(), + from_grid: computedStyles + .getPropertyValue("--energy-grid-consumption-color") + .trim(), + used_solar: computedStyles + .getPropertyValue("--energy-solar-color") + .trim(), + }; + + const backgroundColor = computedStyles + .getPropertyValue("--card-background-color") + .trim(); + Object.entries(statistics).forEach(([key, statIds]) => { const sum = ["solar", "to_grid"].includes(key); const add = key !== "solar"; @@ -407,12 +403,13 @@ export class HuiEnergySummaryGraphCard const uniqueKeys = Array.from(new Set(allKeys)); Object.entries(combinedData).forEach(([type, sources]) => { - const negative = NEGATIVE.includes(type); - Object.entries(sources).forEach(([statId, source], idx) => { const data: ChartDataset<"bar">[] = []; const entity = this.hass.states[statId]; - const color = COLORS[type]; + const borderColor = + idx > 0 + ? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(colors[type])), idx))) + : colors[type]; data.push({ label: @@ -421,28 +418,20 @@ export class HuiEnergySummaryGraphCard : entity ? computeStateName(entity) : statId, - borderColor: - idx > 0 - ? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(color.border)), idx))) - : color.border, - backgroundColor: - idx > 0 - ? rgb2hex( - lab2rgb(labDarken(rgb2lab(hex2rgb(color.background)), idx)) - ) - : color.background, + borderColor, + backgroundColor: hexBlend(borderColor, backgroundColor, 50), stack: "stack", data: [], }); // Process chart data. for (const key of uniqueKeys) { - const value = key in source ? Math.round(source[key] * 100) / 100 : 0; + const value = source[key] || 0; const date = new Date(key); // @ts-expect-error data[0].data.push({ x: date.getTime(), - y: value && negative ? -1 * value : value, + y: value && type === "to_grid" ? -1 * value : value, }); } @@ -470,43 +459,12 @@ export class HuiEnergySummaryGraphCard .has-header { padding-top: 0; } - .chartLegend ul { - padding-left: 20px; - } - .chartLegend li { - padding: 2px 8px; - display: flex; - justify-content: space-between; - align-items: center; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - box-sizing: border-box; - color: var(--secondary-text-color); - } - .chartLegend li > div { - display: flex; - align-items: center; - } - .chartLegend .bullet { - border-width: 1px; - border-style: solid; - border-radius: 4px; - display: inline-block; - height: 16px; - margin-right: 6px; - width: 32px; - box-sizing: border-box; - } - .value { - font-weight: 300; - } `; } } declare global { interface HTMLElementTagNameMap { - "hui-energy-summary-graph-card": HuiEnergySummaryGraphCard; + "hui-energy-usage-graph-card": HuiEnergyUsageGraphCard; } } diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index dcbf69054a..88684adfa8 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -101,7 +101,7 @@ export interface EnergyDistributionCardConfig extends LovelaceCardConfig { title?: string; prefs: EnergyPreferences; } -export interface EnergySummaryGraphCardConfig extends LovelaceCardConfig { +export interface EnergyUsageGraphCardConfig extends LovelaceCardConfig { type: "energy-summary-graph"; title?: string; prefs: EnergyPreferences; @@ -119,6 +119,12 @@ export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig { prefs: EnergyPreferences; } +export interface EnergySourcesTableCardConfig extends LovelaceCardConfig { + type: "energy-sources-table"; + title?: string; + prefs: EnergyPreferences; +} + export interface EnergySolarGaugeCardConfig extends LovelaceCardConfig { type: "energy-solar-consumed-gauge"; 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 71cee8f6fd..f6a489c098 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -35,14 +35,14 @@ const LAZY_LOAD_TYPES = { "alarm-panel": () => import("../cards/hui-alarm-panel-card"), error: () => import("../cards/hui-error-card"), "empty-state": () => import("../cards/hui-empty-state-card"), - "energy-summary-graph": () => - import("../cards/energy/hui-energy-summary-graph-card"), + "energy-usage-graph": () => + import("../cards/energy/hui-energy-usage-graph-card"), "energy-solar-graph": () => import("../cards/energy/hui-energy-solar-graph-card"), "energy-devices-graph": () => import("../cards/energy/hui-energy-devices-graph-card"), - "energy-costs-table": () => - import("../cards/energy/hui-energy-costs-table-card"), + "energy-sources-table": () => + import("../cards/energy/hui-energy-sources-table-card"), "energy-distribution": () => import("../cards/energy/hui-energy-distribution-card"), "energy-solar-consumed-gauge": () => diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts index a4ff54ff8f..9416b15f99 100644 --- a/src/resources/ha-style.ts +++ b/src/resources/ha-style.ts @@ -82,6 +82,14 @@ documentContainer.innerHTML = ` --state-climate-dry-color: #efbd07; --state-climate-idle-color: #8a8a8a; + /* energy */ + --energy-grid-consumption-color: #126a9a; + --energy-grid-return-color: #673ab7; + --energy-solar-color: #ff9800; + --energy-non-fossil-color: #0f9d58; + + --rgb-energy-solar-color: 255, 152, 0; + /* Paper-styles color.html dependency is stripped on build. When a default paper-style color is used, it needs to be copied diff --git a/src/resources/styles.ts b/src/resources/styles.ts index f2b2494416..716de34179 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -31,6 +31,7 @@ export const darkStyles = { "codemirror-property": "#C792EA", "codemirror-qualifier": "#DECB6B", "codemirror-type": "#DECB6B", + "energy-grid-return-color": "#b39bdb", }; export const derivedStyles = {