diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index 0b710f68ee..533f6c2efc 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -52,6 +52,13 @@ export class HaStatisticPicker extends LitElement { @property({ attribute: "include-unit-class" }) public includeUnitClass?: string | string[]; + /** + * Show only statistics with these device classes. + * @attr include-device-class + */ + @property({ attribute: "include-device-class" }) + public includeDeviceClass?: string | string[]; + /** * Show only statistics on entities. * @type {Boolean} @@ -94,6 +101,7 @@ export class HaStatisticPicker extends LitElement { statisticIds: StatisticsMetaData[], includeStatisticsUnitOfMeasurement?: string | string[], includeUnitClass?: string | string[], + includeDeviceClass?: string | string[], entitiesOnly?: boolean ): Array<{ id: string; name: string; state?: HassEntity }> => { if (!statisticIds.length) { @@ -122,6 +130,19 @@ export class HaStatisticPicker extends LitElement { includeUnitClasses.includes(meta.unit_class) ); } + if (includeDeviceClass) { + const includeDeviceClasses: (string | null)[] = + ensureArray(includeDeviceClass); + statisticIds = statisticIds.filter((meta) => { + const stateObj = this.hass.states[meta.statistic_id]; + if (!stateObj) { + return true; + } + return includeDeviceClasses.includes( + stateObj.attributes.device_class || "" + ); + }); + } const output: Array<{ id: string; @@ -195,6 +216,7 @@ export class HaStatisticPicker extends LitElement { this.statisticIds!, this.includeStatisticsUnitOfMeasurement, this.includeUnitClass, + this.includeDeviceClass, this.entitiesOnly ); } else { @@ -203,6 +225,7 @@ export class HaStatisticPicker extends LitElement { this.statisticIds!, this.includeStatisticsUnitOfMeasurement, this.includeUnitClass, + this.includeDeviceClass, this.entitiesOnly ); }); diff --git a/src/components/entity/ha-statistics-picker.ts b/src/components/entity/ha-statistics-picker.ts index 62d7c0d2b7..31266721c4 100644 --- a/src/components/entity/ha-statistics-picker.ts +++ b/src/components/entity/ha-statistics-picker.ts @@ -38,6 +38,13 @@ class HaStatisticsPicker extends LitElement { @property({ attribute: "include-unit-class" }) public includeUnitClass?: string | string[]; + /** + * Show only statistics with these device classes. + * @attr include-device-class + */ + @property({ attribute: "include-device-class" }) + public includeDeviceClass?: string | string[]; + /** * Ignore filtering of statistics type and units when only a single statistic is selected. * @type {boolean} @@ -92,6 +99,7 @@ class HaStatisticsPicker extends LitElement { .includeStatisticsUnitOfMeasurement=${this .includeStatisticsUnitOfMeasurement} .includeUnitClass=${this.includeUnitClass} + .includeDeviceClass=${this.includeDeviceClass} .statisticTypes=${this.statisticTypes} .statisticIds=${this.statisticIds} .label=${this.pickStatisticLabel} diff --git a/src/data/energy.ts b/src/data/energy.ts index be4dafffe0..9b5c012dca 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -62,6 +62,7 @@ export const emptyBatteryEnergyPreference = stat_energy_from: "", stat_energy_to: "", }); + export const emptyGasEnergyPreference = (): GasSourceTypeEnergyPreference => ({ type: "gas", stat_energy_from: "", @@ -70,6 +71,15 @@ export const emptyGasEnergyPreference = (): GasSourceTypeEnergyPreference => ({ number_energy_price: null, }); +export const emptyWaterEnergyPreference = + (): WaterSourceTypeEnergyPreference => ({ + type: "water", + stat_energy_from: "", + stat_cost: null, + entity_energy_price: null, + number_energy_price: null, + }); + interface EnergySolarForecast { wh_hours: Record; } @@ -130,7 +140,22 @@ export interface BatterySourceTypeEnergyPreference { export interface GasSourceTypeEnergyPreference { type: "gas"; - // kWh meter + // kWh/volume meter + stat_energy_from: string; + + // $ meter + stat_cost: string | null; + + // Can be used to generate costs if stat_cost omitted + entity_energy_price: string | null; + number_energy_price: number | null; + unit_of_measurement?: string | null; +} + +export interface WaterSourceTypeEnergyPreference { + type: "water"; + + // volume meter stat_energy_from: string; // $ meter @@ -146,7 +171,8 @@ type EnergySource = | SolarSourceTypeEnergyPreference | GridSourceTypeEnergyPreference | BatterySourceTypeEnergyPreference - | GasSourceTypeEnergyPreference; + | GasSourceTypeEnergyPreference + | WaterSourceTypeEnergyPreference; export interface EnergyPreferences { energy_sources: EnergySource[]; @@ -222,6 +248,7 @@ interface EnergySourceByType { solar?: SolarSourceTypeEnergyPreference[]; battery?: BatterySourceTypeEnergyPreference[]; gas?: GasSourceTypeEnergyPreference[]; + water?: WaterSourceTypeEnergyPreference[]; } export const energySourcesByType = (prefs: EnergyPreferences) => @@ -255,7 +282,7 @@ export const getReferencedStatisticIds = ( continue; } - if (source.type === "gas") { + if (source.type === "gas" || source.type === "water") { statIDs.push(source.stat_energy_from); if (source.stat_cost) { statIDs.push(source.stat_cost); @@ -642,3 +669,6 @@ export const getEnergyGasUnit = ( ? "m³" : "ft³"; }; + +export const getEnergyWaterUnit = (hass: HomeAssistant): string | undefined => + hass.config.unit_system.length === "km" ? "m³" : "ft³"; diff --git a/src/panels/config/energy/components/ha-energy-water-settings.ts b/src/panels/config/energy/components/ha-energy-water-settings.ts new file mode 100644 index 0000000000..a997629695 --- /dev/null +++ b/src/panels/config/energy/components/ha-energy-water-settings.ts @@ -0,0 +1,204 @@ +import "@material/mwc-button/mwc-button"; +import { mdiDelete, mdiWater, mdiPencil } from "@mdi/js"; +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-card"; +import "../../../../components/ha-icon-button"; +import { + EnergyPreferences, + EnergyPreferencesValidation, + EnergyValidationIssue, + WaterSourceTypeEnergyPreference, + saveEnergyPreferences, +} from "../../../../data/energy"; +import { + StatisticsMetaData, + getStatisticLabel, +} from "../../../../data/recorder"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { documentationUrl } from "../../../../util/documentation-url"; +import { showEnergySettingsWaterDialog } from "../dialogs/show-dialogs-energy"; +import "./ha-energy-validation-result"; +import { energyCardStyles } from "./styles"; + +@customElement("ha-energy-water-settings") +export class EnergyWaterSettings extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public preferences!: EnergyPreferences; + + @property({ attribute: false }) + public statsMetadata?: Record; + + @property({ attribute: false }) + public validationResult?: EnergyPreferencesValidation; + + protected render(): TemplateResult { + const waterSources: WaterSourceTypeEnergyPreference[] = []; + const waterValidation: EnergyValidationIssue[][] = []; + + this.preferences.energy_sources.forEach((source, idx) => { + if (source.type !== "water") { + return; + } + waterSources.push(source); + + if (this.validationResult) { + waterValidation.push(this.validationResult.energy_sources[idx]); + } + }); + + return html` + +

+ + ${this.hass.localize("ui.panel.config.energy.water.title")} +

+ +
+

+ ${this.hass.localize("ui.panel.config.energy.water.sub")} + ${this.hass.localize( + "ui.panel.config.energy.water.learn_more" + )} +

+ ${waterValidation.map( + (result) => + html` + + ` + )} +

+ ${this.hass.localize( + "ui.panel.config.energy.water.water_consumption" + )} +

+ ${waterSources.map((source) => { + const entityState = this.hass.states[source.stat_energy_from]; + return html` +
+ ${entityState?.attributes.icon + ? html`` + : html``} + ${getStatisticLabel( + this.hass, + source.stat_energy_from, + this.statsMetadata?.[source.stat_energy_from] + )} + + +
+ `; + })} +
+ + ${this.hass.localize( + "ui.panel.config.energy.water.add_water_source" + )} +
+
+
+ `; + } + + private _addSource() { + showEnergySettingsWaterDialog(this, { + saveCallback: async (source) => { + delete source.unit_of_measurement; + await this._savePreferences({ + ...this.preferences, + energy_sources: this.preferences.energy_sources.concat(source), + }); + }, + }); + } + + private _editSource(ev) { + const origSource: WaterSourceTypeEnergyPreference = + ev.currentTarget.closest(".row").source; + showEnergySettingsWaterDialog(this, { + source: { ...origSource }, + metadata: this.statsMetadata?.[origSource.stat_energy_from], + saveCallback: async (newSource) => { + await this._savePreferences({ + ...this.preferences, + energy_sources: this.preferences.energy_sources.map((src) => + src === origSource ? newSource : src + ), + }); + }, + }); + } + + private async _deleteSource(ev) { + const sourceToDelete: WaterSourceTypeEnergyPreference = + ev.currentTarget.closest(".row").source; + + if ( + !(await showConfirmationDialog(this, { + title: this.hass.localize("ui.panel.config.energy.delete_source"), + })) + ) { + return; + } + + try { + await this._savePreferences({ + ...this.preferences, + energy_sources: this.preferences.energy_sources.filter( + (source) => source !== sourceToDelete + ), + }); + } catch (err: any) { + showAlertDialog(this, { title: `Failed to save config: ${err.message}` }); + } + } + + private async _savePreferences(preferences: EnergyPreferences) { + const result = await saveEnergyPreferences(this.hass, preferences); + fireEvent(this, "value-changed", { value: result }); + } + + static get styles(): CSSResultGroup { + return [haStyle, energyCardStyles]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-energy-water-settings": EnergyWaterSettings; + } +} diff --git a/src/panels/config/energy/dialogs/dialog-energy-gas-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-gas-settings.ts index 7135f538fa..ab9c5e1a8e 100644 --- a/src/panels/config/energy/dialogs/dialog-energy-gas-settings.ts +++ b/src/panels/config/energy/dialogs/dialog-energy-gas-settings.ts @@ -107,6 +107,7 @@ export class DialogEnergyGasSettings "volume", "energy", ]} + include-device-class="gas" .value=${this._source.stat_energy_from} .label=${`${this.hass.localize( "ui.panel.config.energy.gas.dialog.gas_usage" diff --git a/src/panels/config/energy/dialogs/dialog-energy-water-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-water-settings.ts new file mode 100644 index 0000000000..c9f7cd4614 --- /dev/null +++ b/src/panels/config/energy/dialogs/dialog-energy-water-settings.ts @@ -0,0 +1,281 @@ +import "@material/mwc-button/mwc-button"; +import { mdiWater } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/entity/ha-entity-picker"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-formfield"; +import "../../../../components/ha-radio"; +import type { HaRadio } from "../../../../components/ha-radio"; +import "../../../../components/ha-textfield"; +import { + emptyWaterEnergyPreference, + WaterSourceTypeEnergyPreference, +} from "../../../../data/energy"; +import { isExternalStatistic } from "../../../../data/recorder"; +import { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { EnergySettingsWaterDialogParams } from "./show-dialogs-energy"; + +@customElement("dialog-energy-water-settings") +export class DialogEnergyWaterSettings + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: EnergySettingsWaterDialogParams; + + @state() private _source?: WaterSourceTypeEnergyPreference; + + @state() private _costs?: "no-costs" | "number" | "entity" | "statistic"; + + @state() private _error?: string; + + public async showDialog( + params: EnergySettingsWaterDialogParams + ): Promise { + this._params = params; + this._source = params.source + ? { ...params.source } + : emptyWaterEnergyPreference(); + this._costs = this._source.entity_energy_price + ? "entity" + : this._source.number_energy_price + ? "number" + : this._source.stat_cost + ? "statistic" + : "no-costs"; + } + + public closeDialog(): void { + this._params = undefined; + this._source = undefined; + this._error = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._params || !this._source) { + return html``; + } + + const externalSource = + this._source.stat_cost && isExternalStatistic(this._source.stat_cost); + + return html` + + ${this.hass.localize("ui.panel.config.energy.water.dialog.header")}`} + @closed=${this.closeDialog} + > + ${this._error ? html`

${this._error}

` : ""} + + + +

+ ${this.hass.localize(`ui.panel.config.energy.water.dialog.cost_para`)} +

+ + + + + + + + ${this._costs === "statistic" + ? html`` + : ""} + + + + ${this._costs === "entity" + ? html`` + : ""} + + + + ${this._costs === "number" + ? html` + ` + : ""} + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + +
+ `; + } + + private _handleCostChanged(ev: CustomEvent) { + const input = ev.currentTarget as HaRadio; + this._costs = input.value as any; + } + + private _numberPriceChanged(ev) { + this._source = { + ...this._source!, + number_energy_price: Number(ev.target.value), + entity_energy_price: null, + stat_cost: null, + }; + } + + private _priceStatChanged(ev: CustomEvent) { + this._source = { + ...this._source!, + entity_energy_price: null, + number_energy_price: null, + stat_cost: ev.detail.value, + }; + } + + private _priceEntityChanged(ev: CustomEvent) { + this._source = { + ...this._source!, + entity_energy_price: ev.detail.value, + number_energy_price: null, + stat_cost: null, + }; + } + + private async _statisticChanged(ev: CustomEvent<{ value: string }>) { + if (isExternalStatistic(ev.detail.value) && this._costs !== "statistic") { + this._costs = "no-costs"; + } + this._source = { + ...this._source!, + stat_energy_from: ev.detail.value, + }; + } + + private async _save() { + try { + if (this._costs === "no-costs") { + this._source!.entity_energy_price = null; + this._source!.number_energy_price = null; + this._source!.stat_cost = null; + } + await this._params!.saveCallback(this._source!); + this.closeDialog(); + } catch (err: any) { + this._error = err.message; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-dialog { + --mdc-dialog-max-width: 430px; + } + ha-formfield { + display: block; + } + .price-options { + display: block; + padding-left: 52px; + margin-top: -8px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-energy-water-settings": DialogEnergyWaterSettings; + } +} diff --git a/src/panels/config/energy/dialogs/show-dialogs-energy.ts b/src/panels/config/energy/dialogs/show-dialogs-energy.ts index abb935f28d..06eccf7b9c 100644 --- a/src/panels/config/energy/dialogs/show-dialogs-energy.ts +++ b/src/panels/config/energy/dialogs/show-dialogs-energy.ts @@ -8,6 +8,7 @@ import { FlowToGridSourceEnergyPreference, GasSourceTypeEnergyPreference, SolarSourceTypeEnergyPreference, + WaterSourceTypeEnergyPreference, } from "../../../../data/energy"; import { StatisticsMetaData } from "../../../../data/recorder"; @@ -51,6 +52,12 @@ export interface EnergySettingsGasDialogParams { saveCallback: (source: GasSourceTypeEnergyPreference) => Promise; } +export interface EnergySettingsWaterDialogParams { + source?: WaterSourceTypeEnergyPreference; + metadata?: StatisticsMetaData; + saveCallback: (source: WaterSourceTypeEnergyPreference) => Promise; +} + export interface EnergySettingsDeviceDialogParams { saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise; } @@ -99,6 +106,17 @@ export const showEnergySettingsGasDialog = ( }); }; +export const showEnergySettingsWaterDialog = ( + element: HTMLElement, + dialogParams: EnergySettingsWaterDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-energy-water-settings", + dialogImport: () => import("./dialog-energy-water-settings"), + dialogParams: dialogParams, + }); +}; + export const showEnergySettingsGridFlowFromDialog = ( element: HTMLElement, dialogParams: EnergySettingsGridFlowFromDialogParams diff --git a/src/panels/config/energy/ha-config-energy.ts b/src/panels/config/energy/ha-config-energy.ts index 22006e511a..c31cbd1351 100644 --- a/src/panels/config/energy/ha-config-energy.ts +++ b/src/panels/config/energy/ha-config-energy.ts @@ -24,6 +24,7 @@ import "./components/ha-energy-grid-settings"; import "./components/ha-energy-solar-settings"; import "./components/ha-energy-battery-settings"; import "./components/ha-energy-gas-settings"; +import "./components/ha-energy-water-settings"; const INITIAL_CONFIG: EnergyPreferences = { energy_sources: [], @@ -116,6 +117,13 @@ class HaConfigEnergy extends LitElement { .validationResult=${this._validationResult} @value-changed=${this._prefsChanged} > + ${this.hass.localize("ui.panel.energy.setup.step", { step: this._step + 1, - steps: 5, + steps: 6, })}

${this._step === 0 @@ -82,6 +83,12 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard { .preferences=${this._preferences} @value-changed=${this._prefsChanged} >` + : this._step === 4 + ? html`` : html` source.type === "gas"); + const hasWater = prefs.energy_sources.some( + (source) => source.type === "water" + ); + if (info.narrow) { view.cards!.push({ type: "energy-date-selection", @@ -92,6 +96,15 @@ export class EnergyStrategy { }); } + // Only include if we have a water source. + if (hasWater) { + view.cards!.push({ + title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"), + type: "energy-water-graph", + collection_key: "energy_dashboard", + }); + } + // Only include if we have a grid. if (hasGrid) { view.cards!.push({ 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 c8318e800d..9535c4a6c2 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts @@ -1,3 +1,4 @@ +import "@material/mwc-button"; import { mdiArrowDown, mdiArrowLeft, @@ -9,12 +10,12 @@ import { mdiLeaf, mdiSolarPower, mdiTransmissionTower, + mdiWater, } from "@mdi/js"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, html, LitElement, svg } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; -import "@material/mwc-button"; import { formatNumber } from "../../../../common/number/format_number"; import "../../../../components/ha-card"; import "../../../../components/ha-svg-icon"; @@ -23,6 +24,7 @@ import { energySourcesByType, getEnergyDataCollection, getEnergyGasUnit, + getEnergyWaterUnit, } from "../../../../data/energy"; import { calculateStatisticsSumGrowth } from "../../../../data/recorder"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; @@ -83,6 +85,7 @@ class HuiEnergyDistrubutionCard const hasSolarProduction = types.solar !== undefined; const hasBattery = types.battery !== undefined; const hasGas = types.gas !== undefined; + const hasWater = types.water !== undefined; const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0; const totalFromGrid = @@ -91,6 +94,15 @@ class HuiEnergyDistrubutionCard types.grid![0].flow_from.map((flow) => flow.stat_energy_from) ) ?? 0; + let waterUsage: number | null = null; + if (hasWater) { + waterUsage = + calculateStatisticsSumGrowth( + this._data.stats, + types.water!.map((source) => source.stat_energy_from) + ) ?? 0; + } + let gasUsage: number | null = null; if (hasGas) { gasUsage = @@ -255,7 +267,10 @@ class HuiEnergyDistrubutionCard return html`
- ${lowCarbonEnergy !== undefined || hasSolarProduction || hasGas + ${lowCarbonEnergy !== undefined || + hasSolarProduction || + hasGas || + hasWater ? html`
${lowCarbonEnergy === undefined ? html`
` @@ -298,7 +313,7 @@ class HuiEnergyDistrubutionCard kWh
` - : hasGas + : hasGas || hasWater ? html`
` : ""} ${hasGas @@ -338,6 +353,39 @@ class HuiEnergyDistrubutionCard : ""} ` + : hasWater + ? html`
+ ${this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_distribution.water" + )} +
+ + ${formatNumber(waterUsage || 0, this.hass.locale, { + maximumFractionDigits: 1, + })} + ${getEnergyWaterUnit(this.hass) || "m³"} +
+ + + ${waterUsage + ? svg` + + + + ` + : ""} + +
` : html`
`} ` : ""} @@ -460,50 +508,99 @@ class HuiEnergyDistrubutionCard ` : ""} - ${this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_distribution.home" - )} + ${hasGas && hasWater + ? "" + : html`${this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_distribution.home" + )}`} - ${hasBattery + ${hasBattery || (hasGas && hasWater) ? html`
-
-
- - - ${formatNumber(totalBatteryIn || 0, this.hass.locale, { - maximumFractionDigits: 1, - })} - kWh - - ${formatNumber(totalBatteryOut || 0, this.hass.locale, { - maximumFractionDigits: 1, - })} - kWh -
- ${this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_distribution.battery" - )} +
+ + + ${formatNumber( + totalBatteryIn || 0, + this.hass.locale, + { + maximumFractionDigits: 1, + } + )} + kWh + + ${formatNumber( + totalBatteryOut || 0, + this.hass.locale, + { + maximumFractionDigits: 1, + } + )} + kWh +
+ ${this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_distribution.battery" + )} +
` + : html`
`} + ${hasGas && hasWater + ? html`
+ + + ${waterUsage + ? svg` -
-
+ + + + ` + : ""} + +
+ + ${formatNumber(waterUsage || 0, this.hass.locale, { + maximumFractionDigits: 1, + })} + ${getEnergyWaterUnit(this.hass) || "m³"} +
+ ${this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_distribution.water" + )} +
` + : html`
`} ` : ""} -
+
flow.stat_cost || flow.entity_energy_price || flow.number_energy_price + ) || + types.water?.some( + (flow) => + flow.stat_cost || flow.entity_energy_price || flow.number_energy_price ); const gasUnit = getEnergyGasUnit(this.hass, this._data.prefs, this._data.statsMetadata) || ""; + const waterUnit = getEnergyWaterUnit(this.hass) || "m³"; + const compare = this._data.statsCompare !== undefined; return html` @@ -851,7 +865,157 @@ export class HuiEnergySourcesTableCard : ""} ` : ""} - ${totalGasCost && totalGridCost + ${types.water?.map((source, idx) => { + const energy = + calculateStatisticSumGrowth( + this._data!.stats[source.stat_energy_from] + ) || 0; + totalWater += energy; + + const energyCompare = + (compare && + calculateStatisticSumGrowth( + this._data!.statsCompare[source.stat_energy_from] + )) || + 0; + totalWaterCompare += energyCompare; + + const cost_stat = + source.stat_cost || + this._data!.info.cost_sensors[source.stat_energy_from]; + const cost = cost_stat + ? calculateStatisticSumGrowth(this._data!.stats[cost_stat]) || + 0 + : null; + if (cost !== null) { + totalWaterCost += cost; + } + + const costCompare = + compare && cost_stat + ? calculateStatisticSumGrowth( + this._data!.statsCompare[cost_stat] + ) || 0 + : null; + if (costCompare !== null) { + totalWaterCostCompare += costCompare; + } + + const modifiedColor = + idx > 0 + ? this.hass.themes.darkMode + ? labBrighten(rgb2lab(hex2rgb(waterColor)), idx) + : labDarken(rgb2lab(hex2rgb(waterColor)), idx) + : undefined; + const color = modifiedColor + ? rgb2hex(lab2rgb(modifiedColor)) + : waterColor; + + return html`
+ +
+ + + ${getStatisticLabel( + this.hass, + source.stat_energy_from, + this._data?.statsMetadata[source.stat_energy_from] + )} + + ${compare + ? html` + ${formatNumber(energyCompare, this.hass.locale)} + ${waterUnit} + + ${showCosts + ? html` + ${costCompare !== null + ? formatNumber(costCompare, this.hass.locale, { + style: "currency", + currency: this.hass.config.currency!, + }) + : ""} + ` + : ""}` + : ""} + + ${formatNumber(energy, this.hass.locale)} ${waterUnit} + + ${showCosts + ? html` + ${cost !== null + ? formatNumber(cost, this.hass.locale, { + style: "currency", + currency: this.hass.config.currency!, + }) + : ""} + ` + : ""} + `; + })} + ${types.water + ? html` + + + ${this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_sources_table.water_total" + )} + + ${compare + ? html` + ${formatNumber(totalWaterCompare, this.hass.locale)} + ${gasUnit} + + ${showCosts + ? html` + ${formatNumber( + totalWaterCostCompare, + this.hass.locale, + { + style: "currency", + currency: this.hass.config.currency!, + } + )} + ` + : ""}` + : ""} + + ${formatNumber(totalWater, this.hass.locale)} ${waterUnit} + + ${showCosts + ? html` + ${formatNumber(totalWaterCost, this.hass.locale, { + style: "currency", + currency: this.hass.config.currency!, + })} + ` + : ""} + ` + : ""} + ${[totalGasCost, totalWaterCost, totalGridCost].filter(Boolean) + .length > 1 ? html` @@ -867,7 +1031,9 @@ export class HuiEnergySourcesTableCard class="mdc-data-table__cell mdc-data-table__cell--numeric" > ${formatNumber( - totalGasCostCompare + totalGridCostCompare, + totalGasCostCompare + + totalGridCostCompare + + totalWaterCostCompare, this.hass.locale, { style: "currency", @@ -881,7 +1047,7 @@ export class HuiEnergySourcesTableCard class="mdc-data-table__cell mdc-data-table__cell--numeric" > ${formatNumber( - totalGasCost + totalGridCost, + totalGasCost + totalGridCost + totalWaterCost, this.hass.locale, { style: "currency", @@ -922,6 +1088,7 @@ export class HuiEnergySourcesTableCard } ha-card { height: 100%; + overflow: hidden; } .card-header { padding-bottom: 0; diff --git a/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts new file mode 100644 index 0000000000..17cdb690aa --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts @@ -0,0 +1,431 @@ +import { + ChartData, + ChartDataset, + ChartOptions, + ScatterDataPoint, +} from "chart.js"; +import { + addHours, + differenceInDays, + differenceInHours, + endOfToday, + isToday, + startOfToday, +} 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 { labBrighten, labDarken } from "../../../../common/color/lab"; +import { formatDateShort } from "../../../../common/datetime/format_date"; +import { formatTime } from "../../../../common/datetime/format_time"; +import { + formatNumber, + numberFormatToLocale, +} from "../../../../common/number/format_number"; +import "../../../../components/chart/ha-chart-base"; +import "../../../../components/ha-card"; +import { + EnergyData, + WaterSourceTypeEnergyPreference, + getEnergyDataCollection, + getEnergyWaterUnit, +} from "../../../../data/energy"; +import { + Statistics, + StatisticsMetaData, + getStatisticLabel, +} from "../../../../data/recorder"; +import { FrontendLocaleData } from "../../../../data/translation"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import { HomeAssistant } from "../../../../types"; +import { LovelaceCard } from "../../types"; +import { EnergyWaterGraphCardConfig } from "../types"; + +@customElement("hui-energy-water-graph-card") +export class HuiEnergyWaterGraphCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergyWaterGraphCardConfig; + + @state() private _chartData: ChartData = { + datasets: [], + }; + + @state() private _start = startOfToday(); + + @state() private _end = endOfToday(); + + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + + @state() private _unit?: string; + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config?.collection_key, + }).subscribe((data) => this._getStatistics(data)), + ]; + } + + public getCardSize(): Promise | number { + return 3; + } + + public setConfig(config: EnergyWaterGraphCardConfig): void { + this._config = config; + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + return html` + + ${this._config.title + ? html`

${this._config.title}

` + : ""} +
+ + ${!this._chartData.datasets.length + ? html`
+ ${isToday(this._start) + ? this.hass.localize("ui.panel.lovelace.cards.energy.no_data") + : this.hass.localize( + "ui.panel.lovelace.cards.energy.no_data_period" + )} +
` + : ""} +
+
+ `; + } + + private _createOptions = memoizeOne( + ( + start: Date, + end: Date, + locale: FrontendLocaleData, + unit?: string, + compareStart?: Date, + compareEnd?: Date + ): ChartOptions => { + const dayDifference = differenceInDays(end, start); + const compare = compareStart !== undefined && compareEnd !== undefined; + if (compare) { + const difference = differenceInHours(end, start); + const differenceCompare = differenceInHours(compareEnd!, compareStart!); + // If the compare period doesn't match the main period, adjust them to match + if (differenceCompare > difference) { + end = addHours(end, differenceCompare - difference); + } else if (difference > differenceCompare) { + compareEnd = addHours(compareEnd!, difference - differenceCompare); + } + } + + const options: ChartOptions = { + 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: unit, + }, + ticks: { + beginAtZero: true, + }, + }, + }, + plugins: { + tooltip: { + mode: "nearest", + callbacks: { + title: (datasets) => { + if (dayDifference > 0) { + return datasets[0].label; + } + const date = new Date(datasets[0].parsed.x); + return `${ + compare ? `${formatDateShort(date, locale)}: ` : "" + }${formatTime(date, locale)} – ${formatTime( + addHours(date, 1), + locale + )}`; + }, + label: (context) => + `${context.dataset.label}: ${formatNumber( + context.parsed.y, + locale + )} ${unit}`, + }, + }, + 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), + }; + if (compare) { + options.scales!.xAxisCompare = { + ...(options.scales!.x as Record), + suggestedMin: compareStart!.getTime(), + suggestedMax: compareEnd!.getTime(), + display: false, + }; + } + return options; + } + ); + + private async _getStatistics(energyData: EnergyData): Promise { + const waterSources: WaterSourceTypeEnergyPreference[] = + energyData.prefs.energy_sources.filter( + (source) => source.type === "water" + ) as WaterSourceTypeEnergyPreference[]; + + this._unit = getEnergyWaterUnit(this.hass); + + const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + + const computedStyles = getComputedStyle(this); + const waterColor = computedStyles + .getPropertyValue("--energy-water-color") + .trim(); + + datasets.push( + ...this._processDataSet( + energyData.stats, + energyData.statsMetadata, + waterSources, + waterColor + ) + ); + + if (energyData.statsCompare) { + // Add empty dataset to align the bars + datasets.push({ + order: 0, + data: [], + }); + datasets.push({ + order: 999, + data: [], + xAxisID: "xAxisCompare", + }); + + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + energyData.statsMetadata, + waterSources, + waterColor, + true + ) + ); + } + + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + + this._chartData = { + datasets, + }; + } + + private _processDataSet( + statistics: Statistics, + statisticsMetaData: Record, + waterSources: WaterSourceTypeEnergyPreference[], + waterColor: string, + compare = false + ) { + const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + + waterSources.forEach((source, idx) => { + const modifiedColor = + idx > 0 + ? this.hass.themes.darkMode + ? labBrighten(rgb2lab(hex2rgb(waterColor)), idx) + : labDarken(rgb2lab(hex2rgb(waterColor)), idx) + : undefined; + const borderColor = modifiedColor + ? rgb2hex(lab2rgb(modifiedColor)) + : waterColor; + + let prevValue: number | null = null; + let prevStart: string | null = null; + + const waterConsumptionData: ScatterDataPoint[] = []; + + // Process water consumption data. + if (source.stat_energy_from in statistics) { + const stats = statistics[source.stat_energy_from]; + + for (const point of stats) { + if (point.sum === null) { + continue; + } + if (prevValue === null) { + prevValue = point.sum; + continue; + } + if (prevStart === point.start) { + continue; + } + const value = point.sum - prevValue; + const date = new Date(point.start); + waterConsumptionData.push({ + x: date.getTime(), + y: value, + }); + prevStart = point.start; + prevValue = point.sum; + } + } + + data.push({ + label: getStatisticLabel( + this.hass, + source.stat_energy_from, + statisticsMetaData[source.stat_energy_from] + ), + borderColor: compare ? borderColor + "7F" : borderColor, + backgroundColor: compare ? borderColor + "32" : borderColor + "7F", + data: waterConsumptionData, + order: 1, + stack: "water", + xAxisID: compare ? "xAxisCompare" : undefined, + }); + }); + return data; + } + + 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-water-graph-card": HuiEnergyWaterGraphCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 29fd841fb7..8d7fe21412 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -133,6 +133,12 @@ export interface EnergyGasGraphCardConfig extends LovelaceCardConfig { collection_key?: string; } +export interface EnergyWaterGraphCardConfig extends LovelaceCardConfig { + type: "energy-water-graph"; + title?: string; + collection_key?: string; +} + export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig { type: "energy-devices-graph"; 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 aaa8de1829..115de4157b 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -47,6 +47,8 @@ const LAZY_LOAD_TYPES = { "energy-distribution": () => import("../cards/energy/hui-energy-distribution-card"), "energy-gas-graph": () => import("../cards/energy/hui-energy-gas-graph-card"), + "energy-water-graph": () => + import("../cards/energy/hui-energy-water-graph-card"), "energy-grid-neutrality-gauge": () => import("../cards/energy/hui-energy-grid-neutrality-gauge-card"), "energy-solar-consumed-gauge": () => diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts index fbfcf3f040..96c99e47b3 100644 --- a/src/resources/ha-style.ts +++ b/src/resources/ha-style.ts @@ -91,6 +91,7 @@ documentContainer.innerHTML = ` --energy-battery-out-color: #4db6ac; --energy-battery-in-color: #f06292; --energy-gas-color: #8E021B; + --energy-water-color: #00bcd4; /* opacity for dark text on a light background */ --dark-divider-opacity: 0.12; diff --git a/src/translations/en.json b/src/translations/en.json index 9952c30b12..4bac5ab7bb 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1500,6 +1500,29 @@ "m3_or_kWh": "ft³, m³, Wh, kWh, MWh or GJ" } }, + "water": { + "title": "Water Consumption", + "sub": "Let Home Assistant monitor your water usage.", + "learn_more": "More information on how to get started.", + "water_consumption": "Water consumption", + "edit_water_source": "Edit water source", + "delete_water_source": "Delete water source", + "add_water_source": "Add water source", + "dialog": { + "header": "Configure water consumption", + "paragraph": "Gas consumption is the volume of water that flows to your home.", + "energy_stat": "Consumed water (m³/ft³)", + "cost_para": "Select how Home Assistant should keep track of the costs of the consumed water.", + "no_cost": "Do not track costs", + "cost_stat": "Use an entity tracking the total costs", + "cost_stat_input": "Total Costs Entity", + "cost_entity": "Use an entity with current price", + "cost_entity_input": "Entity with the current price per m³", + "cost_number": "Use a static price", + "cost_number_input": "Price per m³", + "water_usage": "Water usage (m³/ft³)" + } + }, "device_consumption": { "title": "Individual devices", "sub": "Tracking the energy usage of individual devices allows Home Assistant to break down your energy usage by device.", @@ -1544,6 +1567,10 @@ "title": "Unexpected unit of measurement", "description": "The following entities do not have the expected units of measurement 'Wh', 'kWh', 'MWh' or 'GJ' for an energy sensor or 'm³' or 'ft³' for a gas sensor:" }, + "entity_unexpected_unit_water": { + "title": "Unexpected unit of measurement", + "description": "The following entities do not have the expected units of measurement 'm³' or 'ft³' for a water sensor:" + }, "entity_unexpected_unit_energy_price": { "title": "Unexpected unit of measurement", "description": "The following entities do not have the expected units of measurement ''{currency}/kWh'', ''{currency}/Wh'', ''{currency}/MWh'' or ''{currency}/GJ'':" @@ -1552,6 +1579,10 @@ "title": "Unexpected unit of measurement", "description": "The following entities do not have the expected units of measurement ''{currency}/kWh'', ''{currency}/Wh'', ''{currency}/MWh'', ''{currency}/GJ'', ''{currency}/m³'' or ''{currency}/ft³'':" }, + "entity_unexpected_unit_water_price": { + "title": "Unexpected unit of measurement", + "description": "The following entities do not have the expected units of measurement ''{currency}/m³'' or ''{currency}/ft³'':" + }, "entity_unexpected_state_class": { "title": "Unexpected state class", "description": "The following entities do not have the expected state class:" @@ -3681,6 +3712,7 @@ "energy_sources_table": { "grid_total": "Grid total", "gas_total": "Gas total", + "water_total": "Water total", "source": "Source", "energy": "Energy", "cost": "Cost", @@ -3711,6 +3743,7 @@ "title_today": "Energy distribution today", "grid": "Grid", "gas": "Gas", + "water": "Water", "solar": "Solar", "low_carbon": "Low-carbon", "home": "Home", @@ -4773,6 +4806,7 @@ "energy_usage_graph_title": "Energy usage", "energy_solar_graph_title": "Solar production", "energy_gas_graph_title": "Gas consumption", + "energy_water_graph_title": "Water consumption", "energy_distribution_title": "Energy distribution", "energy_sources_table_title": "Sources", "energy_devices_graph_title": "Monitor individual devices"