diff --git a/hassio/src/dialogs/network/dialog-hassio-network.ts b/hassio/src/dialogs/network/dialog-hassio-network.ts index 4b0fd9c9dc..28aa6666c8 100644 --- a/hassio/src/dialogs/network/dialog-hassio-network.ts +++ b/hassio/src/dialogs/network/dialog-hassio-network.ts @@ -108,7 +108,7 @@ export class DialogHassioNetwork ${this._interfaces.length > 1 - ? html` ${this._interfaces.map( diff --git a/package.json b/package.json index df2f60af1c..b9628ec2a2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@fullcalendar/list": "5.1.0", "@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.6.0#./.yarn/patches/@lit-labs/virtualizer/0.7.0.patch", "@material/chips": "=12.0.0-canary.1a8d06483.0", + "@material/data-table": "=12.0.0-canary.1a8d06483.0", "@material/mwc-button": "0.22.0-canary.cc04657a.0", "@material/mwc-checkbox": "0.22.0-canary.cc04657a.0", "@material/mwc-circular-progress": "0.22.0-canary.cc04657a.0", diff --git a/src/common/const.ts b/src/common/const.ts index 40374552e0..2bd338c895 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -59,7 +59,7 @@ export const FIXED_DEVICE_CLASS_ICONS = { current: "hass:current-ac", carbon_dioxide: "mdi:molecule-co2", carbon_monoxide: "mdi:molecule-co", - energy: "hass:flash", + energy: "hass:lightning-bolt", humidity: "hass:water-percent", illuminance: "hass:brightness-5", temperature: "hass:thermometer", diff --git a/src/common/entity/state_icon.ts b/src/common/entity/state_icon.ts index c87c7bdd2f..86bfe50812 100644 --- a/src/common/entity/state_icon.ts +++ b/src/common/entity/state_icon.ts @@ -4,7 +4,7 @@ import { DEFAULT_DOMAIN_ICON } from "../const"; import { computeDomain } from "./compute_domain"; import { domainIcon } from "./domain_icon"; -export const stateIcon = (state: HassEntity) => { +export const stateIcon = (state?: HassEntity) => { if (!state) { return DEFAULT_DOMAIN_ICON; } diff --git a/src/common/number/round.ts b/src/common/number/round.ts new file mode 100644 index 0000000000..d0da48ca0e --- /dev/null +++ b/src/common/number/round.ts @@ -0,0 +1,2 @@ +export const round = (value: number, precision = 2): number => + Math.round(value * 10 ** precision) / 10 ** precision; diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 62fc29352b..63b41aef70 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -23,11 +23,11 @@ export default class HaChartBase extends LitElement { @property({ attribute: "chart-type", reflect: true }) public chartType: ChartType = "line"; - @property({ attribute: false }) - public data: ChartData = { datasets: [] }; + @property({ attribute: false }) public data: ChartData = { datasets: [] }; - @property({ attribute: false }) - public options?: ChartOptions; + @property({ attribute: false }) public options?: ChartOptions; + + @property({ attribute: false }) public plugins?: any[]; @state() private _tooltip?: Tooltip; @@ -50,11 +50,14 @@ export default class HaChartBase extends LitElement { if (!this.hasUpdated || !this.chart) { return; } - + if (changedProps.has("plugins")) { + this.chart.destroy(); + this._setupChart(); + return; + } if (changedProps.has("type")) { this.chart.config.type = this.chartType; } - if (changedProps.has("data")) { this.chart.data = this.data; } @@ -67,7 +70,7 @@ export default class HaChartBase extends LitElement { protected render() { return html` ${this.options?.plugins?.legend?.display === true - ? html`
+ ? html`
    ${this.data.datasets.map( (dataset, index) => html`
+ ${this._tooltip.footer + ? // footer has white-space: pre; + // prettier-ignore + html`` + : ""}
` : ""} @@ -148,14 +159,7 @@ export default class HaChartBase extends LitElement { type: this.chartType, data: this.data, options: this._createOptions(), - plugins: [ - { - id: "afterRenderHook", - afterRender: (chart) => { - this._height = `${chart.height}px`; - }, - }, - ], + plugins: this._createPlugins(), }); } @@ -177,6 +181,22 @@ export default class HaChartBase extends LitElement { }; } + private _createPlugins() { + return [ + ...(this.plugins || []), + { + id: "afterRenderHook", + afterRender: (chart) => { + this._height = `${chart.height}px`; + }, + legend: { + ...this.options?.plugins?.legend, + display: false, + }, + }, + ]; + } + private _legendClick(ev) { if (!this.chart) { return; @@ -302,6 +322,10 @@ export default class HaChartBase extends LitElement { text-align: center; font-weight: 500; } + .chartTooltip .footer { + font-weight: 500; + white-space: pre; + } .chartTooltip .beforeBody { text-align: center; font-weight: 300; diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index e08743640e..30e6dff80f 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -119,11 +119,11 @@ class StateHistoryChartLine extends LitElement { private _generateData() { let colorIndex = 0; const computedStyles = getComputedStyle(this); - const deviceStates = this.data; + const entityStates = this.data; const datasets: ChartDataset<"line">[] = []; let endTime: Date; - if (deviceStates.length === 0) { + if (entityStates.length === 0) { return; } @@ -132,7 +132,7 @@ class StateHistoryChartLine extends LitElement { // Get the highest date from the last date of each device new Date( Math.max( - ...deviceStates.map((devSts) => + ...entityStates.map((devSts) => new Date( devSts.states[devSts.states.length - 1].last_changed ).getTime() @@ -144,7 +144,7 @@ class StateHistoryChartLine extends LitElement { } const names = this.names || {}; - deviceStates.forEach((states) => { + entityStates.forEach((states) => { const domain = states.domain; const name = names[states.entity_id] || states.name; // array containing [value1, value2, etc] diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts index 3f754e464c..fff8041e72 100644 --- a/src/components/entity/ha-entities-picker.ts +++ b/src/components/entity/ha-entities-picker.ts @@ -12,7 +12,7 @@ import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker"; class HaEntitiesPickerLight extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; - @property() public value?: string[]; + @property({ type: Array }) public value?: string[]; /** * Show entities from specific domains. @@ -30,6 +30,22 @@ class HaEntitiesPickerLight extends LitElement { @property({ type: Array, attribute: "exclude-domains" }) public excludeDomains?: string[]; + /** + * Show only entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + /** + * Show only entities with these unit of measuments. + * @type {Array} + * @attr include-unit-of-measurement + */ + @property({ type: Array, attribute: "include-unit-of-measurement" }) + public includeUnitOfMeasurement?: string[]; + @property({ attribute: "picked-entity-label" }) public pickedEntityLabel?: string; @@ -51,6 +67,8 @@ class HaEntitiesPickerLight extends LitElement { .hass=${this.hass} .includeDomains=${this.includeDomains} .excludeDomains=${this.excludeDomains} + .includeDeviceClasses=${this.includeDeviceClasses} + .includeUnitOfMeasurement=${this.includeUnitOfMeasurement} .entityFilter=${this._entityFilter} .value=${entityId} .label=${this.pickedEntityLabel} @@ -64,6 +82,8 @@ class HaEntitiesPickerLight extends LitElement { .hass=${this.hass} .includeDomains=${this.includeDomains} .excludeDomains=${this.excludeDomains} + .includeDeviceClasses=${this.includeDeviceClasses} + .includeUnitOfMeasurement=${this.includeUnitOfMeasurement} .entityFilter=${this._entityFilter} .label=${this.pickEntityLabel} @value-changed=${this._addEntity} @@ -81,11 +101,11 @@ class HaEntitiesPickerLight extends LitElement { } private async _updateEntities(entities) { + this.value = entities; + fireEvent(this, "value-changed", { value: entities, }); - - this.value = entities; } private _entityChanged(event: PolymerChangedEvent) { @@ -98,15 +118,14 @@ class HaEntitiesPickerLight extends LitElement { ) { return; } - if (newValue === "") { - this._updateEntities( - this._currentEntities.filter((ent) => ent !== curValue) - ); - } else { - this._updateEntities( - this._currentEntities.map((ent) => (ent === curValue ? newValue : ent)) - ); + const currentEntities = this._currentEntities; + if (!newValue || currentEntities.includes(newValue)) { + this._updateEntities(currentEntities.filter((ent) => ent !== curValue)); + return; } + this._updateEntities( + currentEntities.map((ent) => (ent === curValue ? newValue : ent)) + ); } private async _addEntity(event: PolymerChangedEvent) { diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index e17393a775..4f94b8ee58 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -42,6 +42,8 @@ const rowRenderer: ComboBoxLitRenderer = (item) => html` + + + + + ${item.name} + ${item.id} + + `; + +@customElement("ha-statistic-picker") +export class HaStatisticPicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property() public value?: string; + + @property({ attribute: "statistic-types" }) + public statisticTypes?: "mean" | "sum"; + + @property({ type: Array }) public statisticIds?: StatisticsMetaData[]; + + @property({ type: Boolean }) public disabled?: boolean; + + /** + * Show only statistics with these unit of measuments. + * @type {Array} + * @attr include-unit-of-measurement + */ + @property({ type: Array, attribute: "include-unit-of-measurement" }) + public includeUnitOfMeasurement?: string[]; + + /** + * Show only statistics on entities. + * @type {Boolean} + * @attr entities-only + */ + @property({ type: Boolean, attribute: "entities-only" }) + public entitiesOnly = false; + + @state() private _opened?: boolean; + + @query("ha-combo-box", true) public comboBox!: HaComboBox; + + private _init = false; + + private _getStatistics = memoizeOne( + ( + statisticIds: StatisticsMetaData[], + includeUnitOfMeasurement?: string[], + entitiesOnly?: boolean + ): Array<{ id: string; name: string; state?: HassEntity }> => { + if (!statisticIds.length) { + return [ + { + id: "", + name: this.hass.localize( + "ui.components.statistics-picker.no_statistics" + ), + }, + ]; + } + + if (includeUnitOfMeasurement) { + statisticIds = statisticIds.filter((meta) => + includeUnitOfMeasurement.includes(meta.unit_of_measurement) + ); + } + + const output: Array<{ + id: string; + name: string; + state?: HassEntity; + }> = []; + statisticIds.forEach((meta) => { + const entityState = this.hass.states[meta.statistic_id]; + if (!entityState) { + if (!entitiesOnly) { + output.push({ id: meta.statistic_id, name: meta.statistic_id }); + } + return; + } + output.push({ + id: meta.statistic_id, + name: computeStateName(entityState), + state: entityState, + }); + }); + + if (output.length === 1) { + return output; + } + return output.sort((a, b) => compare(a.name || "", b.name || "")); + } + ); + + public open() { + this.comboBox?.open(); + } + + public focus() { + this.comboBox?.focus(); + } + + public willUpdate(changedProps: PropertyValues) { + if ( + (!this.hasUpdated && !this.statisticIds) || + changedProps.has("statisticTypes") + ) { + this._getStatisticIds(); + } + if ( + (!this._init && this.statisticIds) || + (changedProps.has("_opened") && this._opened) + ) { + this._init = true; + if (this.hasUpdated) { + (this.comboBox as any).items = this._getStatistics( + this.statisticIds!, + this.includeUnitOfMeasurement, + this.entitiesOnly + ); + } else { + this.updateComplete.then(() => { + (this.comboBox as any).items = this._getStatistics( + this.statisticIds!, + this.includeUnitOfMeasurement, + this.entitiesOnly + ); + }); + } + } + } + + protected render(): TemplateResult { + return html` + + `; + } + + private async _getStatisticIds() { + this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes); + } + + private get _value() { + return this.value || ""; + } + + private _statisticChanged(ev: PolymerChangedEvent) { + ev.stopPropagation(); + const newValue = ev.detail.value; + + if (newValue !== this._value) { + this._setValue(newValue); + } + } + + private _openedChanged(ev: PolymerChangedEvent) { + this._opened = ev.detail.value; + } + + private _setValue(value: string) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + }, 0); + } + + static get styles(): CSSResultGroup { + return css` + paper-input > mwc-icon-button { + --mdc-icon-button-size: 24px; + padding: 2px; + color: var(--secondary-text-color); + } + [hidden] { + display: none; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-statistic-picker": HaStatisticPicker; + } +} diff --git a/src/components/entity/ha-statistics-picker.ts b/src/components/entity/ha-statistics-picker.ts new file mode 100644 index 0000000000..3ad6af9203 --- /dev/null +++ b/src/components/entity/ha-statistics-picker.ts @@ -0,0 +1,110 @@ +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import type { PolymerChangedEvent } from "../../polymer-types"; +import type { HomeAssistant } from "../../types"; +import "./ha-statistic-picker"; + +@customElement("ha-statistics-picker") +class HaStatisticsPicker extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ type: Array }) public value?: string[]; + + @property({ type: Array }) public statisticIds?: string[]; + + @property({ attribute: "statistic-types" }) + public statisticTypes?: "mean" | "sum"; + + @property({ attribute: "picked-statistic-label" }) + public pickedStatisticLabel?: string; + + @property({ attribute: "pick-statistic-label" }) + public pickStatisticLabel?: string; + + protected render(): TemplateResult { + if (!this.hass) { + return html``; + } + + const currentStatistics = this._currentStatistics; + return html` + ${currentStatistics.map( + (statisticId) => html` +
+ +
+ ` + )} +
+ +
+ `; + } + + private get _currentStatistics() { + return this.value || []; + } + + private async _updateStatistics(entities) { + this.value = entities; + + fireEvent(this, "value-changed", { + value: entities, + }); + } + + private _statisticChanged(event: PolymerChangedEvent) { + event.stopPropagation(); + const oldValue = (event.currentTarget as any).curValue; + const newValue = event.detail.value; + if (newValue === oldValue) { + return; + } + const currentStatistics = this._currentStatistics; + if (!newValue || currentStatistics.includes(newValue)) { + this._updateStatistics( + currentStatistics.filter((ent) => ent !== oldValue) + ); + return; + } + this._updateStatistics( + currentStatistics.map((ent) => (ent === oldValue ? newValue : ent)) + ); + } + + private async _addStatistic(event: PolymerChangedEvent) { + event.stopPropagation(); + const toAdd = event.detail.value; + (event.currentTarget as any).value = ""; + if (!toAdd) { + return; + } + const currentEntities = this._currentStatistics; + if (currentEntities.includes(toAdd)) { + return; + } + + this._updateStatistics([...currentEntities, toAdd]); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-statistics-picker": HaStatisticsPicker; + } +} diff --git a/src/components/ha-dialog.ts b/src/components/ha-dialog.ts index b776bdf6cb..b2963d6c3a 100644 --- a/src/components/ha-dialog.ts +++ b/src/components/ha-dialog.ts @@ -1,12 +1,15 @@ import { Dialog } from "@material/mwc-dialog"; import { mdiClose } from "@mdi/js"; -import { css, CSSResultGroup, html } from "lit"; +import { css, CSSResultGroup, html, TemplateResult } from "lit"; import { customElement } from "lit/decorators"; import { computeRTLDirection } from "../common/util/compute_rtl"; import type { HomeAssistant } from "../types"; import "./ha-icon-button"; -export const createCloseHeading = (hass: HomeAssistant, title: string) => html` +export const createCloseHeading = ( + hass: HomeAssistant, + title: string | TemplateResult +) => html` ${title} ({ + stat_energy_from: "", + stat_cost: null, + entity_energy_from: null, + entity_energy_price: null, + number_energy_price: null, + }); + +export const emptyFlowToGridSourceEnergyPreference = + (): FlowToGridSourceEnergyPreference => ({ + stat_energy_to: "", + stat_compensation: null, + entity_energy_to: null, + entity_energy_price: null, + number_energy_price: null, + }); + +export const emptyGridSourceEnergyPreference = + (): GridSourceTypeEnergyPreference => ({ + type: "grid", + flow_from: [], + flow_to: [], + cost_adjustment_day: 0, + }); + +export const emptySolarEnergyPreference = + (): SolarSourceTypeEnergyPreference => ({ + type: "solar", + stat_energy_from: "", + config_entry_solar_forecast: null, + }); + +export interface DeviceConsumptionEnergyPreference { + // This is an ever increasing value + stat_consumption: string; +} + +export interface FlowFromGridSourceEnergyPreference { + // kWh meter + stat_energy_from: string; + + // $ meter + stat_cost: string | null; + + // Can be used to generate costs if stat_cost omitted + entity_energy_from: string | null; + entity_energy_price: string | null; + number_energy_price: number | null; +} + +export interface FlowToGridSourceEnergyPreference { + // kWh meter + stat_energy_to: string; + + // $ meter + stat_compensation: string | null; + + // Can be used to generate costs if stat_cost omitted + entity_energy_to: string | null; + entity_energy_price: string | null; + number_energy_price: number | null; +} + +export interface GridSourceTypeEnergyPreference { + type: "grid"; + + flow_from: FlowFromGridSourceEnergyPreference[]; + flow_to: FlowToGridSourceEnergyPreference[]; + + cost_adjustment_day: number; +} + +export interface SolarSourceTypeEnergyPreference { + type: "solar"; + + stat_energy_from: string; + config_entry_solar_forecast: string[] | null; +} + +type EnergySource = + | SolarSourceTypeEnergyPreference + | GridSourceTypeEnergyPreference; + +export interface EnergyPreferences { + currency: string; + energy_sources: EnergySource[]; + device_consumption: DeviceConsumptionEnergyPreference[]; +} + +export interface EnergyInfo { + cost_sensors: Record; +} + +export const getEnergyInfo = (hass: HomeAssistant) => + hass.callWS({ + type: "energy/info", + }); + +export const getEnergyPreferences = (hass: HomeAssistant) => + hass.callWS({ + type: "energy/get_prefs", + }); + +export const saveEnergyPreferences = ( + hass: HomeAssistant, + prefs: Partial +) => + hass.callWS({ + type: "energy/save_prefs", + ...prefs, + }); + +interface EnergySourceByType { + grid?: GridSourceTypeEnergyPreference[]; + solar?: SolarSourceTypeEnergyPreference[]; +} + +export const energySourcesByType = (prefs: EnergyPreferences) => { + const types: EnergySourceByType = {}; + for (const source of prefs.energy_sources) { + if (source.type in types) { + types[source.type]!.push(source as any); + } else { + types[source.type] = [source as any]; + } + } + return types; +}; diff --git a/src/data/forecast_solar.ts b/src/data/forecast_solar.ts new file mode 100644 index 0000000000..91b9620216 --- /dev/null +++ b/src/data/forecast_solar.ts @@ -0,0 +1,10 @@ +import { HomeAssistant } from "../types"; + +export interface ForecastSolarForecast { + wh_hours: Record; +} + +export const getForecastSolarForecasts = (hass: HomeAssistant) => + hass.callWS>({ + type: "forecast_solar/forecasts", + }); diff --git a/src/data/history.ts b/src/data/history.ts index 80e9185d49..71cc46e231 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -70,6 +70,11 @@ export interface StatisticValue { state: number | null; } +export interface StatisticsMetaData { + unit_of_measurement: string; + statistic_id: string; +} + export const fetchRecent = ( hass: HomeAssistant, entityId: string, @@ -276,7 +281,7 @@ export const getStatisticIds = ( hass: HomeAssistant, statistic_type?: "mean" | "sum" ) => - hass.callWS({ + hass.callWS({ type: "history/list_statistic_ids", statistic_type, }); @@ -294,6 +299,48 @@ export const fetchStatistics = ( statistic_ids, }); +export const calculateStatisticSumGrowth = ( + values: StatisticValue[] +): number | null => { + if (values.length === 0) { + return null; + } + if (values.length === 1) { + return values[0].sum; + } + const endSum = values[values.length - 1].sum; + if (endSum === null) { + return null; + } + const startSum = values[0].sum; + if (startSum === null) { + return endSum; + } + return endSum - startSum; +}; + +export const calculateStatisticsSumGrowth = ( + data: Statistics, + stats: string[] +): number | null => { + let totalGrowth = 0; + + for (const stat of stats) { + if (!(stat in data)) { + return null; + } + const statGrowth = calculateStatisticSumGrowth(data[stat]); + + if (statGrowth === null) { + return null; + } + + totalGrowth += statGrowth; + } + + return totalGrowth; +}; + export const statisticsHaveType = ( stats: StatisticValue[], type: StatisticType diff --git a/src/dialogs/config-flow/dialog-data-entry-flow.ts b/src/dialogs/config-flow/dialog-data-entry-flow.ts index 0ac9cfd073..f37e9b7f0d 100644 --- a/src/dialogs/config-flow/dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/dialog-data-entry-flow.ts @@ -169,9 +169,11 @@ class DataEntryFlowDialog extends LitElement { this._params.flowConfig.deleteFlow(this.hass, this._step.flow_id); } - if (this._step !== null && this._params.dialogClosedCallback) { + if (this._step && this._params.dialogClosedCallback) { this._params.dialogClosedCallback({ flowFinished, + entryId: + "result" in this._step ? this._step.result?.entry_id : undefined, }); } diff --git a/src/dialogs/config-flow/show-dialog-config-flow.ts b/src/dialogs/config-flow/show-dialog-config-flow.ts index 92652cb7ed..4e97fb7ab2 100644 --- a/src/dialogs/config-flow/show-dialog-config-flow.ts +++ b/src/dialogs/config-flow/show-dialog-config-flow.ts @@ -39,6 +39,8 @@ export const showConfigFlowDialog = ( const [step] = await Promise.all([ createConfigFlow(hass, handler), hass.loadBackendTranslation("config", handler), + // Used as fallback if no header defined for step + hass.loadBackendTranslation("title", handler), ]); return step; }, diff --git a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts index d6b70f7645..45a914acf8 100644 --- a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts @@ -97,7 +97,10 @@ export type LoadingReason = export interface DataEntryFlowDialogParams { startFlowHandler?: string; continueFlowId?: string; - dialogClosedCallback?: (params: { flowFinished: boolean }) => void; + dialogClosedCallback?: (params: { + flowFinished: boolean; + entryId?: string; + }) => void; flowConfig: FlowConfig; showAdvanced?: boolean; } diff --git a/src/dialogs/make-dialog-manager.ts b/src/dialogs/make-dialog-manager.ts index 2acfe6473f..cd1b331530 100644 --- a/src/dialogs/make-dialog-manager.ts +++ b/src/dialogs/make-dialog-manager.ts @@ -62,7 +62,6 @@ export const showDialog = async ( LOADED[dialogTag] = dialogImport().then(() => { const dialogEl = document.createElement(dialogTag) as HassDialog; element.provideHass(dialogEl); - root.appendChild(dialogEl); return dialogEl; }); } @@ -94,6 +93,9 @@ export const showDialog = async ( } } const dialogElement = await LOADED[dialogTag]; + // Append it again so it's the last element in the root, + // so it's guaranteed to be on top of the other elements + root.appendChild(dialogElement); dialogElement.showDialog(dialogParams); }; diff --git a/src/layouts/hass-error-screen.ts b/src/layouts/hass-error-screen.ts index de0b3f1fcf..f7a181e0ab 100644 --- a/src/layouts/hass-error-screen.ts +++ b/src/layouts/hass-error-screen.ts @@ -13,7 +13,7 @@ class HassErrorScreen extends LitElement { @property({ type: Boolean }) public rootnav = false; - @property() public narrow?: boolean; + @property({ type: Boolean }) public narrow = false; @property() public error?: string; diff --git a/src/layouts/hass-loading-screen.ts b/src/layouts/hass-loading-screen.ts index 529659ba0a..291f24bb8d 100644 --- a/src/layouts/hass-loading-screen.ts +++ b/src/layouts/hass-loading-screen.ts @@ -16,7 +16,7 @@ class HassLoadingScreen extends LitElement { @property({ type: Boolean }) public rootnav = false; - @property() public narrow?: boolean; + @property({ type: Boolean }) public narrow = false; protected render(): TemplateResult { return html` diff --git a/src/layouts/partial-panel-resolver.ts b/src/layouts/partial-panel-resolver.ts index 612ced46c5..b3d1a72b6a 100644 --- a/src/layouts/partial-panel-resolver.ts +++ b/src/layouts/partial-panel-resolver.ts @@ -20,6 +20,7 @@ import { const CACHE_URL_PATHS = ["lovelace", "developer-tools"]; const COMPONENTS = { + energy: () => import("../panels/energy/ha-panel-energy"), calendar: () => import("../panels/calendar/ha-panel-calendar"), config: () => import("../panels/config/ha-panel-config"), custom: () => import("../panels/custom/ha-panel-custom"), @@ -43,7 +44,7 @@ const COMPONENTS = { class PartialPanelResolver extends HassRouterPage { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ type: Boolean }) public narrow?: boolean; + @property({ type: Boolean }) public narrow = false; private _waitForStart = false; diff --git a/src/panels/config/energy/components/ha-energy-device-settings.ts b/src/panels/config/energy/components/ha-energy-device-settings.ts new file mode 100644 index 0000000000..2ec08d014a --- /dev/null +++ b/src/panels/config/energy/components/ha-energy-device-settings.ts @@ -0,0 +1,118 @@ +import "@material/mwc-button/mwc-button"; +import { mdiDelete, mdiDevices } from "@mdi/js"; +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeStateName } from "../../../../common/entity/compute_state_name"; +import { stateIcon } from "../../../../common/entity/state_icon"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-card"; +import "../../../../components/ha-settings-row"; +import { + DeviceConsumptionEnergyPreference, + EnergyPreferences, + saveEnergyPreferences, +} from "../../../../data/energy"; +import { + showConfirmationDialog, + showAlertDialog, +} from "../../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { showEnergySettingsDeviceDialog } from "../dialogs/show-dialogs-energy"; +import { energyCardStyles } from "./styles"; + +@customElement("ha-energy-device-settings") +export class EnergyDeviceSettings extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public preferences!: EnergyPreferences; + + protected render(): TemplateResult { + return html` + +

+ Monitor individual + devices +

+ +
+

Monitor individual devices.

+

Devices

+ ${this.preferences.device_consumption.map((device) => { + const entityState = this.hass.states[device.stat_consumption]; + return html` +
+ + ${entityState + ? computeStateName(entityState) + : device.stat_consumption} + + + +
+ `; + })} +
+ + Add device +
+
+
+ `; + } + + private _addDevice() { + showEnergySettingsDeviceDialog(this, { + saveCallback: async (device) => { + await this._savePreferences({ + ...this.preferences, + device_consumption: + this.preferences.device_consumption.concat(device), + }); + }, + }); + } + + private async _deleteDevice(ev) { + const deviceToDelete: DeviceConsumptionEnergyPreference = + ev.currentTarget.device; + + if ( + !(await showConfirmationDialog(this, { + title: "Are you sure you wan't to delete this device?", + })) + ) { + return; + } + + try { + await this._savePreferences({ + ...this.preferences, + device_consumption: this.preferences.device_consumption.filter( + (device) => device !== deviceToDelete + ), + }); + } catch (err) { + 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-device-settings": EnergyDeviceSettings; + } +} diff --git a/src/panels/config/energy/components/ha-energy-grid-settings.ts b/src/panels/config/energy/components/ha-energy-grid-settings.ts new file mode 100644 index 0000000000..999445595e --- /dev/null +++ b/src/panels/config/energy/components/ha-energy-grid-settings.ts @@ -0,0 +1,367 @@ +import "@material/mwc-button/mwc-button"; +import { + mdiDelete, + mdiHomeExportOutline, + mdiHomeImportOutline, + mdiPencil, + mdiTransmissionTower, +} from "@mdi/js"; +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeStateName } from "../../../../common/entity/compute_state_name"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-card"; +import "../../../../components/ha-settings-row"; +import { + ConfigEntry, + deleteConfigEntry, + getConfigEntries, +} from "../../../../data/config_entries"; +import { + emptyGridSourceEnergyPreference, + EnergyPreferences, + energySourcesByType, + FlowFromGridSourceEnergyPreference, + FlowToGridSourceEnergyPreference, + saveEnergyPreferences, +} from "../../../../data/energy"; +import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { + showEnergySettingsGridFlowFromDialog, + showEnergySettingsGridFlowToDialog, +} from "../dialogs/show-dialogs-energy"; +import { energyCardStyles } from "./styles"; + +@customElement("ha-energy-grid-settings") +export class EnergyGridSettings extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public preferences!: EnergyPreferences; + + @state() private _configEntries?: ConfigEntry[]; + + protected firstUpdated() { + this._fetchCO2SignalConfigEntries(); + } + + protected render(): TemplateResult { + const types = energySourcesByType(this.preferences); + + const gridSource = types.grid + ? types.grid[0] + : emptyGridSourceEnergyPreference(); + + return html` + +

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

+ +
+

${this.hass.localize("ui.panel.config.energy.grid.sub")}

+

Grid consumption

+ ${gridSource.flow_from.map((flow) => { + const entityState = this.hass.states[flow.stat_energy_from]; + return html` +
+ ${entityState?.attributes.icon + ? html`` + : html``} + ${entityState + ? computeStateName(entityState) + : flow.stat_energy_from} + + + + + + +
+ `; + })} +
+ + Add consumption +
+ +

Return to grid

+ ${gridSource.flow_to.map((flow) => { + const entityState = this.hass.states[flow.stat_energy_to]; + return html` +
+ ${entityState?.attributes.icon + ? html`` + : html``} + ${entityState + ? computeStateName(entityState) + : flow.stat_energy_to} + + + + + + +
+ `; + })} +
+ + Add return +
+ +

Grid carbon footprint

+ ${this._configEntries?.map( + (entry) => html`
+ + ${entry.title} + + + + + + + + +
` + )} + ${this._configEntries?.length === 0 + ? html` +
+ + + Add CO2 signal integration + +
+ ` + : ""} +
+
+ `; + } + + private async _fetchCO2SignalConfigEntries() { + this._configEntries = (await getConfigEntries(this.hass)).filter( + (entry) => entry.domain === "co2signal" + ); + } + + private _addCO2Sensor() { + showConfigFlowDialog(this, { + startFlowHandler: "co2signal", + dialogClosedCallback: () => { + this._fetchCO2SignalConfigEntries(); + }, + }); + } + + private async _removeCO2Sensor(ev) { + const entryId = ev.currentTarget.closest(".row").entry.entry_id; + if ( + !(await showConfirmationDialog(this, { + title: + "Are you sure you wan't to delete this integration? It will remove the entities it provides", + })) + ) { + return; + } + + await deleteConfigEntry(this.hass, entryId); + this._fetchCO2SignalConfigEntries(); + } + + private _addFromSource() { + showEnergySettingsGridFlowFromDialog(this, { + currency: this.preferences.currency, + saveCallback: async (source) => { + const flowFrom = energySourcesByType(this.preferences).grid![0] + .flow_from; + + const preferences: EnergyPreferences = { + ...this.preferences, + energy_sources: this.preferences.energy_sources.map((src) => + src.type === "grid" + ? { ...src, flow_from: [...flowFrom, source] } + : src + ), + }; + await this._savePreferences(preferences); + }, + }); + } + + private _addToSource() { + showEnergySettingsGridFlowToDialog(this, { + currency: this.preferences.currency, + saveCallback: async (source) => { + const flowTo = energySourcesByType(this.preferences).grid![0].flow_to; + + const preferences: EnergyPreferences = { + ...this.preferences, + energy_sources: this.preferences.energy_sources.map((src) => + src.type === "grid" ? { ...src, flow_to: [...flowTo, source] } : src + ), + }; + await this._savePreferences(preferences); + }, + }); + } + + private _editFromSource(ev) { + const origSource: FlowFromGridSourceEnergyPreference = + ev.currentTarget.closest(".row").source; + showEnergySettingsGridFlowFromDialog(this, { + currency: this.preferences.currency, + source: { ...origSource }, + saveCallback: async (source) => { + const flowFrom = energySourcesByType(this.preferences).grid![0] + .flow_from; + + const preferences: EnergyPreferences = { + ...this.preferences, + energy_sources: this.preferences.energy_sources.map((src) => + src.type === "grid" + ? { + ...src, + flow_from: flowFrom.map((flow) => + flow === origSource ? source : flow + ), + } + : src + ), + }; + await this._savePreferences(preferences); + }, + }); + } + + private _editToSource(ev) { + const origSource: FlowToGridSourceEnergyPreference = + ev.currentTarget.closest(".row").source; + showEnergySettingsGridFlowToDialog(this, { + currency: this.preferences.currency, + source: { ...origSource }, + saveCallback: async (source) => { + const flowTo = energySourcesByType(this.preferences).grid![0].flow_to; + + const preferences: EnergyPreferences = { + ...this.preferences, + energy_sources: this.preferences.energy_sources.map((src) => + src.type === "grid" + ? { + ...src, + flow_to: flowTo.map((flow) => + flow === origSource ? source : flow + ), + } + : src + ), + }; + await this._savePreferences(preferences); + }, + }); + } + + private async _deleteFromSource(ev) { + const sourceToDelete: FlowFromGridSourceEnergyPreference = + ev.currentTarget.closest(".row").source; + + if ( + !(await showConfirmationDialog(this, { + title: "Are you sure you wan't to delete this source?", + })) + ) { + return; + } + + const flowFrom = energySourcesByType( + this.preferences + ).grid![0].flow_from.filter((flow) => flow !== sourceToDelete); + + const preferences: EnergyPreferences = { + ...this.preferences, + energy_sources: this.preferences.energy_sources.map((source) => + source.type === "grid" ? { ...source, flow_from: flowFrom } : source + ), + }; + + try { + await this._savePreferences(preferences); + } catch (err) { + showAlertDialog(this, { title: `Failed to save config: ${err.message}` }); + } + } + + private async _deleteToSource(ev) { + const sourceToDelete: FlowToGridSourceEnergyPreference = + ev.currentTarget.closest(".row").source; + + if ( + !(await showConfirmationDialog(this, { + title: "Are you sure you wan't to delete this source?", + })) + ) { + return; + } + + const flowTo = energySourcesByType( + this.preferences + ).grid![0].flow_to.filter((flow) => flow !== sourceToDelete); + + const preferences: EnergyPreferences = { + ...this.preferences, + energy_sources: this.preferences.energy_sources.map((source) => + source.type === "grid" ? { ...source, flow_to: flowTo } : source + ), + }; + + try { + await this._savePreferences(preferences); + } catch (err) { + 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-grid-settings": EnergyGridSettings; + } +} diff --git a/src/panels/config/energy/components/ha-energy-solar-settings.ts b/src/panels/config/energy/components/ha-energy-solar-settings.ts new file mode 100644 index 0000000000..a9023dc5ba --- /dev/null +++ b/src/panels/config/energy/components/ha-energy-solar-settings.ts @@ -0,0 +1,149 @@ +import "@material/mwc-button/mwc-button"; +import { mdiDelete, mdiPencil, mdiSolarPower } from "@mdi/js"; +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeStateName } from "../../../../common/entity/compute_state_name"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-card"; +import "../../../../components/ha-settings-row"; +import { + EnergyPreferences, + energySourcesByType, + saveEnergyPreferences, + SolarSourceTypeEnergyPreference, +} from "../../../../data/energy"; +import { + showConfirmationDialog, + showAlertDialog, +} from "../../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { showEnergySettingsSolarDialog } from "../dialogs/show-dialogs-energy"; +import { energyCardStyles } from "./styles"; + +@customElement("ha-energy-solar-settings") +export class EnergySolarSettings extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public preferences!: EnergyPreferences; + + protected render(): TemplateResult { + const types = energySourcesByType(this.preferences); + + const solarSources = types.solar || []; + + return html` + +

+ Configure solar + panels +

+ +
+

+ Let Home Assistant monitor your solar panels and give you insight on + their performace. +

+

Solar production

+ ${solarSources.map((source) => { + const entityState = this.hass.states[source.stat_energy_from]; + return html` +
+ ${entityState?.attributes.icon + ? html`` + : html``} + ${entityState + ? computeStateName(entityState) + : source.stat_energy_from} + + + + + + +
+ `; + })} +
+ + Add solar production +
+
+
+ `; + } + + private _addSource() { + showEnergySettingsSolarDialog(this, { + saveCallback: async (source) => { + await this._savePreferences({ + ...this.preferences, + energy_sources: this.preferences.energy_sources.concat(source), + }); + }, + }); + } + + private _editSource(ev) { + const origSource: SolarSourceTypeEnergyPreference = + ev.currentTarget.closest(".row").source; + showEnergySettingsSolarDialog(this, { + source: { ...origSource }, + 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: SolarSourceTypeEnergyPreference = + ev.currentTarget.closest(".row").source; + + if ( + !(await showConfirmationDialog(this, { + title: "Are you sure you wan't to delete this source?", + })) + ) { + return; + } + + try { + await this._savePreferences({ + ...this.preferences, + energy_sources: this.preferences.energy_sources.filter( + (source) => source !== sourceToDelete + ), + }); + } catch (err) { + 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-solar-settings": EnergySolarSettings; + } +} diff --git a/src/panels/config/energy/components/styles.ts b/src/panels/config/energy/components/styles.ts new file mode 100644 index 0000000000..91ef2109cd --- /dev/null +++ b/src/panels/config/energy/components/styles.ts @@ -0,0 +1,37 @@ +import { css } from "lit"; + +export const energyCardStyles = css` + ha-card { + height: 100%; + } + .card-header ha-svg-icon { + height: 32px; + width: 32px; + margin-right: 8px; + } + h3 { + margin-top: 24px; + margin-bottom: 4px; + } + .row { + display: flex; + align-items: center; + border-top: 1px solid var(--divider-color); + height: 48px; + box-sizing: border-box; + } + .row ha-svg-icon, + .row ha-icon, + .row img { + margin-right: 16px; + } + .row img { + height: 24px; + } + .row .content { + flex-grow: 1; + } + mwc-icon-button { + color: var(--secondary-text-color); + } +`; diff --git a/src/panels/config/energy/dialogs/dialog-energy-device-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-device-settings.ts new file mode 100644 index 0000000000..47826b1836 --- /dev/null +++ b/src/panels/config/energy/dialogs/dialog-energy-device-settings.ts @@ -0,0 +1,111 @@ +import { mdiDevices } from "@mdi/js"; +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-dialog"; +import { DeviceConsumptionEnergyPreference } from "../../../../data/energy"; +import { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyleDialog } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { EnergySettingsDeviceDialogParams } from "./show-dialogs-energy"; +import "@material/mwc-button/mwc-button"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-radio"; +import "../../../../components/ha-formfield"; +import "../../../../components/entity/ha-entity-picker"; + +const energyUnits = ["kWh"]; + +@customElement("dialog-energy-device-settings") +export class DialogEnergyDeviceSettings + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: EnergySettingsDeviceDialogParams; + + @state() private _device?: DeviceConsumptionEnergyPreference; + + @state() private _error?: string; + + public async showDialog( + params: EnergySettingsDeviceDialogParams + ): Promise { + this._params = params; + } + + public closeDialog(): void { + this._params = undefined; + this._device = undefined; + this._error = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._params) { + return html``; + } + + return html` + + Add a device`} + @closed=${this.closeDialog} + > + ${this._error ? html`

${this._error}

` : ""} +

Track your devices Learn more

+ + + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + +
+ `; + } + + private _statisticChanged(ev: CustomEvent<{ value: string }>) { + if (!ev.detail.value) { + this._device = undefined; + return; + } + this._device = { stat_consumption: ev.detail.value }; + } + + private async _save() { + try { + await this._params!.saveCallback(this._device!); + this.closeDialog(); + } catch (e) { + this._error = e.message; + } + } + + static get styles(): CSSResultGroup { + return haStyleDialog; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-energy-device-settings": DialogEnergyDeviceSettings; + } +} 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 new file mode 100644 index 0000000000..607e3384fb --- /dev/null +++ b/src/panels/config/energy/dialogs/dialog-energy-grid-flow-settings.ts @@ -0,0 +1,297 @@ +import { mdiTransmissionTower } 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/ha-dialog"; +import { + emptyFlowFromGridSourceEnergyPreference, + emptyFlowToGridSourceEnergyPreference, + FlowFromGridSourceEnergyPreference, + FlowToGridSourceEnergyPreference, +} from "../../../../data/energy"; +import { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyleDialog } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { EnergySettingsGridFlowDialogParams } from "./show-dialogs-energy"; +import "@material/mwc-button/mwc-button"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-radio"; +import "../../../../components/ha-formfield"; +import type { HaRadio } from "../../../../components/ha-radio"; +import "../../../../components/entity/ha-entity-picker"; + +const energyUnits = ["kWh"]; + +@customElement("dialog-energy-grid-flow-settings") +export class DialogEnergyGridFlowSettings + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: EnergySettingsGridFlowDialogParams; + + @state() private _source?: + | FlowFromGridSourceEnergyPreference + | FlowToGridSourceEnergyPreference; + + @state() private _costs?: "no-costs" | "number" | "entity" | "statistic"; + + @state() private _error?: string; + + public async showDialog( + params: EnergySettingsGridFlowDialogParams + ): Promise { + this._params = params; + this._source = params.source + ? { ...params.source } + : (this._source = + params.direction === "from" + ? emptyFlowFromGridSourceEnergyPreference() + : emptyFlowToGridSourceEnergyPreference()); + this._costs = this._source.entity_energy_price + ? "entity" + : this._source.number_energy_price + ? "number" + : this._source[ + params.direction === "from" ? "stat_cost" : "stat_compensation" + ] + ? "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``; + } + + return html` + ${this.hass.localize( + `ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.header` + )}`} + @closed=${this.closeDialog} + > + ${this._error ? html`

${this._error}

` : ""} +

+ ${this.hass.localize( + `ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph` + )} +

+ + + +

+ ${this.hass.localize( + `ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_para` + )} +

+ + + + + + + + ${this._costs === "statistic" + ? html`` + : ""} + + + + ${this._costs === "entity" + ? html`` + : ""} + + + + ${this._costs === "number" + ? html` + ${this.hass.localize( + `ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_number_suffix`, + { currency: this._params.currency } + )} + ` + : ""} + + + ${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 set _costStat(value: null | string) { + this._source![ + this._params!.direction === "from" ? "stat_cost" : "stat_compensation" + ] = value; + } + + private _numberPriceChanged(ev: CustomEvent) { + this._source!.number_energy_price = Number(ev.detail.value); + this._source!.entity_energy_price = null; + this._costStat = null; + } + + private _priceStatChanged(ev: CustomEvent) { + this._costStat = ev.detail.value; + this._source!.entity_energy_price = null; + this._source!.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; + } + + 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" + ? "entity_energy_from" + : "entity_energy_to" + ] = 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._costStat = null; + } + await this._params!.saveCallback(this._source!); + this.closeDialog(); + } catch (e) { + this._error = e.message; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-formfield { + display: block; + } + .price-options { + display: block; + padding-left: 52px; + margin-top: -16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-energy-grid-flow-settings": DialogEnergyGridFlowSettings; + } +} diff --git a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts new file mode 100644 index 0000000000..17f68591ef --- /dev/null +++ b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts @@ -0,0 +1,237 @@ +import { mdiSolarPower } 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/ha-dialog"; +import { + emptySolarEnergyPreference, + SolarSourceTypeEnergyPreference, +} from "../../../../data/energy"; +import { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { EnergySettingsSolarDialogParams } from "./show-dialogs-energy"; +import "@material/mwc-button/mwc-button"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-radio"; +import "../../../../components/ha-checkbox"; +import type { HaCheckbox } from "../../../../components/ha-checkbox"; +import "../../../../components/ha-formfield"; +import "../../../../components/entity/ha-entity-picker"; +import type { HaRadio } from "../../../../components/ha-radio"; +import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow"; +import { ConfigEntry, getConfigEntries } from "../../../../data/config_entries"; + +const energyUnits = ["kWh"]; + +@customElement("dialog-energy-solar-settings") +export class DialogEnergySolarSettings + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: EnergySettingsSolarDialogParams; + + @state() private _source?: SolarSourceTypeEnergyPreference; + + @state() private _configEntries?: ConfigEntry[]; + + @state() private _forecast?: boolean; + + @state() private _error?: string; + + public async showDialog( + params: EnergySettingsSolarDialogParams + ): Promise { + this._fetchForecastSolarConfigEntries(); + this._params = params; + this._source = params.source + ? { ...params.source } + : (this._source = emptySolarEnergyPreference()); + this._forecast = this._source.config_entry_solar_forecast !== null; + } + + 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``; + } + + return html` + + Configure solar panels`} + @closed=${this.closeDialog} + > + ${this._error ? html`

${this._error}

` : ""} +

Solar production for the win! Learn more

+ + + +

Solar production forecast

+

+ We can predict how much energy your solar panels will produce, you can + link or setup an integration that will provide this data. +

+ + + + + + + + ${this._forecast + ? html`
+ ${this._configEntries?.map( + (entry) => html` + ${entry.title} +
`} + > + + + ` + )} + + Add forecast + + ` + : ""} + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + +
+ `; + } + + private async _fetchForecastSolarConfigEntries() { + this._configEntries = (await getConfigEntries(this.hass)).filter( + (entry) => entry.domain === "forecast_solar" + ); + } + + private _handleForecastChanged(ev: CustomEvent) { + const input = ev.currentTarget as HaRadio; + this._forecast = input.value === "true"; + } + + private _forecastCheckChanged(ev) { + const input = ev.currentTarget as HaCheckbox; + const entry = (input as any).entry as ConfigEntry; + const checked = input.checked; + if (checked) { + if (this._source!.config_entry_solar_forecast === null) { + this._source!.config_entry_solar_forecast = []; + } + this._source!.config_entry_solar_forecast.push(entry.entry_id); + } else { + this._source!.config_entry_solar_forecast!.splice( + this._source!.config_entry_solar_forecast!.indexOf(entry.entry_id), + 1 + ); + } + } + + private _addForecast() { + showConfigFlowDialog(this, { + startFlowHandler: "forecast_solar", + dialogClosedCallback: (params) => { + if (params.entryId) { + if (this._source!.config_entry_solar_forecast === null) { + this._source!.config_entry_solar_forecast = []; + } + this._source!.config_entry_solar_forecast.push(params.entryId); + this._fetchForecastSolarConfigEntries(); + } + }, + }); + } + + private _statisticChanged(ev: CustomEvent<{ value: string }>) { + this._source!.stat_energy_from = ev.detail.value; + } + + private async _save() { + try { + if (!this._forecast) { + this._source!.config_entry_solar_forecast = null; + } + await this._params!.saveCallback(this._source!); + this.closeDialog(); + } catch (e) { + this._error = e.message; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + img { + height: 24px; + margin-right: 16px; + } + ha-formfield { + display: block; + } + .forecast-options { + padding-left: 32px; + } + .forecast-options mwc-button { + padding-left: 8px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-energy-solar-settings": DialogEnergySolarSettings; + } +} diff --git a/src/panels/config/energy/dialogs/show-dialogs-energy.ts b/src/panels/config/energy/dialogs/show-dialogs-energy.ts new file mode 100644 index 0000000000..62f9d4a48d --- /dev/null +++ b/src/panels/config/energy/dialogs/show-dialogs-energy.ts @@ -0,0 +1,85 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import { + DeviceConsumptionEnergyPreference, + FlowFromGridSourceEnergyPreference, + FlowToGridSourceEnergyPreference, + SolarSourceTypeEnergyPreference, +} from "../../../../data/energy"; + +export interface EnergySettingsGridFlowDialogParams { + source?: + | FlowFromGridSourceEnergyPreference + | FlowToGridSourceEnergyPreference; + currency: string; + direction: "from" | "to"; + saveCallback: ( + source: + | FlowFromGridSourceEnergyPreference + | FlowToGridSourceEnergyPreference + ) => Promise; +} + +export interface EnergySettingsGridFlowFromDialogParams { + source?: FlowFromGridSourceEnergyPreference; + currency: string; + saveCallback: (source: FlowFromGridSourceEnergyPreference) => Promise; +} + +export interface EnergySettingsGridFlowToDialogParams { + source?: FlowToGridSourceEnergyPreference; + currency: string; + saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise; +} + +export interface EnergySettingsSolarDialogParams { + source?: SolarSourceTypeEnergyPreference; + saveCallback: (source: SolarSourceTypeEnergyPreference) => Promise; +} + +export interface EnergySettingsDeviceDialogParams { + saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise; +} + +export const showEnergySettingsDeviceDialog = ( + element: HTMLElement, + dialogParams: EnergySettingsDeviceDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-energy-device-settings", + dialogImport: () => import("./dialog-energy-device-settings"), + dialogParams: dialogParams, + }); +}; + +export const showEnergySettingsSolarDialog = ( + element: HTMLElement, + dialogParams: EnergySettingsSolarDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-energy-solar-settings", + dialogImport: () => import("./dialog-energy-solar-settings"), + dialogParams: dialogParams, + }); +}; + +export const showEnergySettingsGridFlowFromDialog = ( + element: HTMLElement, + dialogParams: EnergySettingsGridFlowFromDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-energy-grid-flow-settings", + dialogImport: () => import("./dialog-energy-grid-flow-settings"), + dialogParams: { ...dialogParams, direction: "from" }, + }); +}; + +export const showEnergySettingsGridFlowToDialog = ( + element: HTMLElement, + dialogParams: EnergySettingsGridFlowToDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-energy-grid-flow-settings", + dialogImport: () => import("./dialog-energy-grid-flow-settings"), + dialogParams: { ...dialogParams, direction: "to" }, + }); +}; diff --git a/src/panels/config/energy/ha-config-energy.ts b/src/panels/config/energy/ha-config-energy.ts new file mode 100644 index 0000000000..3b81c275ca --- /dev/null +++ b/src/panels/config/energy/ha-config-energy.ts @@ -0,0 +1,164 @@ +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; + +import "../../../components/ha-svg-icon"; +import { + EnergyPreferences, + getEnergyPreferences, + saveEnergyPreferences, +} from "../../../data/energy"; + +import "../../../layouts/hass-loading-screen"; +import "../../../layouts/hass-tabs-subpage"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../types"; +import { configSections } from "../ha-panel-config"; +import "./components/ha-energy-grid-settings"; +import "./components/ha-energy-solar-settings"; +import "./components/ha-energy-device-settings"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; + +const INITIAL_CONFIG = { + currency: "€", + energy_sources: [], + device_consumption: [], +}; + +@customElement("ha-config-energy") +class HaConfigEnergy extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow!: boolean; + + @property({ type: Boolean }) public isWide!: boolean; + + @property({ type: Boolean }) public showAdvanced!: boolean; + + @property({ attribute: false }) public route!: Route; + + @state() private _searchParms = new URLSearchParams(window.location.search); + + @state() private _preferences?: EnergyPreferences; + + @state() private _error?: string; + + protected firstUpdated() { + this._fetchConfig(); + } + + protected render(): TemplateResult { + if (!this._preferences && !this._error) { + return html``; + } + + if (this._error) { + return html``; + } + + return html` + + +
+ + + + Save +
+
+
+ + + +
+
+ `; + } + + private _currencyChanged(ev: CustomEvent) { + this._preferences!.currency = ev.detail.value; + } + + private async _save() { + if (!this._preferences) { + return; + } + try { + this._preferences = await saveEnergyPreferences( + this.hass, + this._preferences + ); + } catch (err) { + showAlertDialog(this, { title: `Failed to save config: ${err.message}` }); + } + } + + private async _fetchConfig() { + try { + this._preferences = await getEnergyPreferences(this.hass); + } catch (e) { + if (e.code === "not_found") { + this._preferences = INITIAL_CONFIG; + } else { + this._error = e.message; + } + } + } + + private _prefsChanged(ev: CustomEvent) { + this._preferences = ev.detail.value; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ha-card { + margin: 8px; + } + .container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + grid-gap: 8px 8px; + padding: 8px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-energy": HaConfigEnergy; + } +} diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index e31711cf99..10fa5c5048 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -4,6 +4,7 @@ import { mdiDevices, mdiHomeAssistant, mdiInformation, + mdiLightningBolt, mdiMapMarkerRadius, mdiMathLog, mdiNfcVariant, @@ -105,13 +106,19 @@ export const configSections: { [name: string]: PageNavigation[] } = { core: true, }, ], - experimental: [ + experiences: [ { component: "tag", path: "/config/tags", translationKey: "ui.panel.config.tag.caption", iconPath: mdiNfcVariant, }, + { + component: "energy", + path: "/config/energy", + translationKey: "ui.panel.config.energy.caption", + iconPath: mdiLightningBolt, + }, ], lovelace: [ { @@ -248,6 +255,10 @@ class HaPanelConfig extends HassRouterPage { tag: "ha-config-entities", load: () => import("./entities/ha-config-entities"), }, + energy: { + tag: "ha-config-energy", + load: () => import("./energy/ha-config-energy"), + }, integrations: { tag: "ha-config-integrations", load: () => import("./integrations/ha-config-integrations"), diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index 20546603cb..e584ae152a 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -265,7 +265,10 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { protected render(): TemplateResult { if (!this._configEntries) { - return html``; + return html``; } const [ groupedConfigEntries, diff --git a/src/panels/config/tags/ha-config-tags.ts b/src/panels/config/tags/ha-config-tags.ts index 030bb53213..0ba76119cd 100644 --- a/src/panels/config/tags/ha-config-tags.ts +++ b/src/panels/config/tags/ha-config-tags.ts @@ -184,7 +184,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) { .narrow=${this.narrow} back-path="/config" .route=${this.route} - .tabs=${configSections.experimental} + .tabs=${configSections.experiences} .columns=${this._columns( this.narrow, this._canWriteTags, diff --git a/src/panels/energy/cards/energy-setup-wizard-card.ts b/src/panels/energy/cards/energy-setup-wizard-card.ts new file mode 100644 index 0000000000..315fd79ffc --- /dev/null +++ b/src/panels/energy/cards/energy-setup-wizard-card.ts @@ -0,0 +1,142 @@ +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { EnergyPreferences, saveEnergyPreferences } from "../../../data/energy"; +import { LovelaceCardConfig } from "../../../data/lovelace"; +import { HomeAssistant } from "../../../types"; +import { LovelaceCard, Lovelace } from "../../lovelace/types"; +import "@material/mwc-button/mwc-button"; +import "../../config/energy/components/ha-energy-grid-settings"; +import "../../config/energy/components/ha-energy-solar-settings"; +import "../../config/energy/components/ha-energy-device-settings"; +import { haStyle } from "../../../resources/styles"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; + +@customElement("energy-setup-wizard-card") +export class EnergySetupWizard extends LitElement implements LovelaceCard { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public lovelace?: Lovelace; + + @state() private _step = 0; + + private _preferences: EnergyPreferences = { + currency: "€", + energy_sources: [], + device_consumption: [], + }; + + public getCardSize() { + return 10; + } + + public setConfig(config: LovelaceCardConfig) { + if (config.preferences) { + this._preferences = config.preferences; + } + } + + protected firstUpdated() { + this.hass.loadFragmentTranslation("config"); + } + + protected render(): TemplateResult { + return html` +

${this.hass.localize("ui.panel.energy.setup.header")}

+

${this.hass.localize("ui.panel.energy.setup.slogan")}

+ +

Step ${this._step + 1} of 3

+ ${this._step === 0 + ? html` ` + : this._step === 1 + ? html` ` + : html` `} +
+ ${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` + ${this.hass.localize("ui.panel.energy.setup.done")} + `} +
+ `; + } + + private _prefsChanged(ev: CustomEvent) { + this._preferences = ev.detail.value; + } + + private _back() { + if (this._step === 0) { + return; + } + this._step--; + } + + private _next() { + if (this._step === 2) { + return; + } + this._step++; + } + + private async _setupDone() { + if (!this._preferences) { + return; + } + try { + this._preferences = await saveEnergyPreferences( + this.hass, + this._preferences + ); + } catch (err) { + showAlertDialog(this, { title: `Failed to save config: ${err.message}` }); + } + fireEvent(this, "reload-energy-panel"); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host { + display: block; + padding: 16px; + max-width: 700px; + margin: 0 auto; + } + mwc-button { + margin-top: 8px; + } + .buttons { + display: flex; + justify-content: space-between; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "energy-setup-wizard-card": EnergySetupWizard; + } +} diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts new file mode 100644 index 0000000000..c39908b83d --- /dev/null +++ b/src/panels/energy/ha-panel-energy.ts @@ -0,0 +1,130 @@ +import "@polymer/app-layout/app-header/app-header"; +import "@polymer/app-layout/app-toolbar/app-toolbar"; +import "@material/mwc-tab"; +import "@material/mwc-tab-bar"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../components/ha-menu-button"; +import "../../layouts/ha-app-layout"; +import { mdiCog } from "@mdi/js"; +import { haStyle } from "../../resources/styles"; +import "../lovelace/views/hui-view"; +import { HomeAssistant } from "../../types"; +import { Lovelace } from "../lovelace/types"; +import { LovelaceConfig } from "../../data/lovelace"; + +const LOVELACE_CONFIG: LovelaceConfig = { + views: [ + { + strategy: { + type: "energy", + }, + }, + ], +}; + +@customElement("ha-panel-energy") +class PanelEnergy extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + + @state() private _viewIndex = 0; + + @state() private _lovelace?: Lovelace; + + public willUpdate(changedProps: PropertyValues) { + if (!this.hasUpdated) { + this.hass.loadFragmentTranslation("lovelace"); + } + if (!changedProps.has("hass")) { + return; + } + const oldHass = changedProps.get("hass") as this["hass"]; + if (oldHass?.locale !== this.hass.locale) { + this._setLovelace(); + } + } + + protected render(): TemplateResult { + return html` + + + + +
${this.hass.localize("panel.energy")}
+ + + + + +
+
+ +
+ `; + } + + private _setLovelace() { + this._lovelace = { + config: LOVELACE_CONFIG, + rawConfig: LOVELACE_CONFIG, + editMode: false, + urlPath: "energy", + mode: "generated", + locale: this.hass.locale, + enableFullEditMode: () => undefined, + saveConfig: async () => undefined, + deleteConfig: async () => undefined, + setEditMode: () => undefined, + }; + } + + private _reloadView() { + // Force strategy to be re-run by make a copy of the view + const config = this._lovelace!.config; + this._lovelace = { + ...this._lovelace!, + config: { ...config, views: [{ ...config.views[0] }] }, + }; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + mwc-icon-button { + color: var(--text-primary-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-panel-energy": PanelEnergy; + } +} + +declare global { + interface HASSDomEvents { + "reload-energy-panel": undefined; + } +} diff --git a/src/panels/energy/strategies/energy-strategy.ts b/src/panels/energy/strategies/energy-strategy.ts new file mode 100644 index 0000000000..7e19dbfd3a --- /dev/null +++ b/src/panels/energy/strategies/energy-strategy.ts @@ -0,0 +1,120 @@ +import { EnergyPreferences, getEnergyPreferences } from "../../../data/energy"; +import { LovelaceViewConfig } from "../../../data/lovelace"; +import { LovelaceViewStrategy } from "../../lovelace/strategies/get-strategy"; + +const setupWizard = async (): Promise => { + await import("../cards/energy-setup-wizard-card"); + return { + type: "panel", + cards: [ + { + type: "custom:energy-setup-wizard-card", + }, + ], + }; +}; + +export class EnergyStrategy { + static async generateView( + info: Parameters[0] + ): ReturnType { + const hass = info.hass; + + const view: LovelaceViewConfig = { cards: [] }; + + let energyPrefs: EnergyPreferences; + + try { + energyPrefs = await getEnergyPreferences(hass); + } catch (e) { + if (e.code === "not_found") { + return setupWizard(); + } + view.cards!.push({ + type: "markdown", + content: `An error occured while fetching your energy preferences: ${e.message}.`, + }); + return view; + } + + view.type = "sidebar"; + + const hasGrid = energyPrefs.energy_sources.some( + (source) => source.type === "grid" + ); + const hasSolar = energyPrefs.energy_sources.some( + (source) => source.type === "solar" + ); + + // Only include if we have a grid source. + if (hasGrid) { + view.cards!.push({ + title: "Electricity", + type: "energy-summary-graph", + prefs: energyPrefs, + }); + } + + // Only include if we have a solar source. + if (hasSolar) { + view.cards!.push({ + title: "Solar production", + type: "energy-solar-graph", + prefs: energyPrefs, + }); + } + + // Only include if we have a grid. + if (hasGrid) { + view.cards!.push({ + title: "Costs", + type: "energy-costs-table", + prefs: energyPrefs, + }); + } + + // Only include if we have at least 1 device in the config. + if (energyPrefs.device_consumption.length) { + view.cards!.push({ + title: "Monitor individual devices", + type: "energy-devices-graph", + prefs: energyPrefs, + }); + } + + // Only include if we have a grid. + if (hasGrid) { + view.cards!.push({ + type: "energy-usage", + prefs: energyPrefs, + view_layout: { position: "sidebar" }, + }); + } + + // Only include if we have a solar source. + if (hasSolar) { + view.cards!.push({ + type: "energy-solar-consumed-gauge", + prefs: energyPrefs, + view_layout: { position: "sidebar" }, + }); + } + + // Only include if we have a grid + if (hasGrid) { + view.cards!.push({ + type: "energy-carbon-consumed-gauge", + prefs: energyPrefs, + view_layout: { position: "sidebar" }, + }); + } + + view.cards!.push({ + type: "energy-summary", + prefs: energyPrefs, + view_layout: { position: "sidebar" }, + }); + + return view; + } +} diff --git a/src/panels/lovelace/cards/hui-energy-carbon-consumed-gauge-card.ts b/src/panels/lovelace/cards/hui-energy-carbon-consumed-gauge-card.ts new file mode 100644 index 0000000000..7a3c5f68ba --- /dev/null +++ b/src/panels/lovelace/cards/hui-energy-carbon-consumed-gauge-card.ts @@ -0,0 +1,233 @@ +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { round } from "../../../common/number/round"; +import { subscribeOne } from "../../../common/util/subscribe-one"; +import "../../../components/ha-card"; +import "../../../components/ha-gauge"; +import { getConfigEntries } from "../../../data/config_entries"; +import { energySourcesByType } from "../../../data/energy"; +import { subscribeEntityRegistry } from "../../../data/entity_registry"; +import { + calculateStatisticsSumGrowth, + fetchStatistics, + Statistics, +} from "../../../data/history"; +import type { HomeAssistant } from "../../../types"; +import type { LovelaceCard } from "../types"; +import { severityMap } from "./hui-gauge-card"; +import type { EnergyCarbonGaugeCardConfig } from "./types"; + +@customElement("hui-energy-carbon-consumed-gauge-card") +class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergyCarbonGaugeCardConfig; + + @state() private _stats?: Statistics; + + @state() private _co2SignalEntity?: string | null; + + public getCardSize(): number { + return 4; + } + + public setConfig(config: EnergyCarbonGaugeCardConfig): void { + this._config = config; + } + + public willUpdate(changedProps) { + super.willUpdate(changedProps); + + if (!this.hasUpdated) { + this._getStatistics(); + this._fetchCO2SignalEntity(); + } + } + + protected render(): TemplateResult { + if (!this._config || !this.hass) { + return html``; + } + + if (!this._stats || this._co2SignalEntity === undefined) { + return html`Loading...`; + } + + if (!this._co2SignalEntity) { + return html``; + } + + const co2State = this.hass.states[this._co2SignalEntity]; + + if (!co2State) { + return html`No CO2 Signal entity found.`; + } + + const co2percentage = Number(co2State.state); + + if (isNaN(co2percentage)) { + return html``; + } + + const prefs = this._config!.prefs; + const types = energySourcesByType(prefs); + + const totalGridConsumption = calculateStatisticsSumGrowth( + this._stats, + types.grid![0].flow_from.map((flow) => flow.stat_energy_from) + ); + + const totalSolarProduction = types.solar + ? calculateStatisticsSumGrowth( + this._stats, + types.solar.map((source) => source.stat_energy_from) + ) + : undefined; + + const totalGridReturned = calculateStatisticsSumGrowth( + this._stats, + types.grid![0].flow_to.map((flow) => flow.stat_energy_to) + ); + + if (totalGridConsumption === null) { + return html`Couldn't calculate the total grid consumption.`; + } + + const highCarbonEnergy = (totalGridConsumption * co2percentage) / 100; + + const totalEnergyConsumed = + totalGridConsumption + + (totalSolarProduction || 0) - + (totalGridReturned || 0); + + const value = round((highCarbonEnergy / totalEnergyConsumed) * 100); + + return html` + + +
High-carbon energy consumed
+
+ `; + } + + private _computeSeverity(numberValue: number): string { + if (numberValue > 50) { + return severityMap.red; + } + if (numberValue > 30) { + return severityMap.yellow; + } + if (numberValue < 10) { + return severityMap.green; + } + return severityMap.normal; + } + + private async _fetchCO2SignalEntity() { + const [configEntries, entityRegistryEntries] = await Promise.all([ + getConfigEntries(this.hass), + subscribeOne(this.hass.connection, subscribeEntityRegistry), + ]); + + const co2ConfigEntry = configEntries.find( + (entry) => entry.domain === "co2signal" + ); + + if (!co2ConfigEntry) { + this._co2SignalEntity = null; + return; + } + + for (const entry of entityRegistryEntries) { + if (entry.config_entry_id !== co2ConfigEntry.entry_id) { + continue; + } + + // The integration offers 2 entities. We want the % one. + const co2State = this.hass.states[entry.entity_id]; + if (!co2State || co2State.attributes.unit_of_measurement !== "%") { + continue; + } + + this._co2SignalEntity = co2State.entity_id; + return; + } + this._co2SignalEntity = null; + } + + 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[] = []; + const prefs = this._config!.prefs; + for (const source of prefs.energy_sources) { + if (source.type === "solar") { + statistics.push(source.stat_energy_from); + continue; + } + + // grid source + for (const flowFrom of source.flow_from) { + statistics.push(flowFrom.stat_energy_from); + } + for (const flowTo of source.flow_to) { + statistics.push(flowTo.stat_energy_to); + } + } + + this._stats = await fetchStatistics( + this.hass!, + startDate, + undefined, + statistics + ); + } + + static get styles(): CSSResultGroup { + return css` + ha-card { + height: 100%; + overflow: hidden; + padding: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + box-sizing: border-box; + } + + ha-gauge { + --gauge-color: var(--label-badge-blue); + width: 100%; + max-width: 250px; + } + + .name { + text-align: center; + line-height: initial; + color: var(--primary-text-color); + width: 100%; + font-size: 15px; + margin-top: 8px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-carbon-consumed-gauge-card": HuiEnergyCarbonGaugeCard; + } +} diff --git a/src/panels/lovelace/cards/hui-energy-costs-table-card.ts b/src/panels/lovelace/cards/hui-energy-costs-table-card.ts new file mode 100644 index 0000000000..9c3a843c0f --- /dev/null +++ b/src/panels/lovelace/cards/hui-energy-costs-table-card.ts @@ -0,0 +1,252 @@ +// @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 "../../../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 + + ${this._config!.prefs.currency} ${cost.toFixed(2)} +
+ ${entity ? computeStateName(entity) : flow.stat_energy_to} + + ${round(energy)} kWh + + ${this._config!.prefs.currency} ${cost.toFixed(2)} +
Total + ${round(totalEnergy)} kWh + + ${this._config!.prefs.currency} ${totalCost.toFixed(2)} +
+
+
+
`; + } + + 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; + } + .total { + background-color: var(--primary-background-color); + --mdc-typography-body2-font-weight: 500; + } + 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/hui-energy-devices-graph-card.ts b/src/panels/lovelace/cards/hui-energy-devices-graph-card.ts new file mode 100644 index 0000000000..2897dc386b --- /dev/null +++ b/src/panels/lovelace/cards/hui-energy-devices-graph-card.ts @@ -0,0 +1,254 @@ +import { + ChartData, + ChartDataset, + ChartOptions, + ParsedDataType, +} from "chart.js"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +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 "../../../components/chart/ha-chart-base"; +import "../../../components/ha-card"; +import { + calculateStatisticSumGrowth, + fetchStatistics, + Statistics, +} from "../../../data/history"; +import { HomeAssistant } from "../../../types"; +import { LovelaceCard } from "../types"; +import { EnergyDevicesGraphCardConfig } from "./types"; + +@customElement("hui-energy-devices-graph-card") +export class HuiEnergyDevicesGraphCard + extends LitElement + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergyDevicesGraphCardConfig; + + @state() private _data?: Statistics; + + @state() private _chartData?: ChartData; + + @state() private _chartOptions?: ChartOptions; + + private _fetching = false; + + private _interval?: number; + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this._interval) { + clearInterval(this._interval); + this._interval = undefined; + } + } + + public connectedCallback() { + super.connectedCallback(); + if (!this.hasUpdated) { + return; + } + this._getStatistics(); + // statistics are created every hour + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._getStatistics(), + 1000 * 60 * 60 + ); + } + + public getCardSize(): Promise | number { + return 3; + } + + public setConfig(config: EnergyDevicesGraphCardConfig): void { + this._config = config; + } + + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + if (!this.hasUpdated) { + this._createOptions(); + } + if (!this._config || !changedProps.has("_config")) { + return; + } + + const oldConfig = changedProps.get("_config") as + | EnergyDevicesGraphCardConfig + | undefined; + + if (oldConfig !== this._config) { + this._getStatistics(); + // statistics are created every hour + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._getStatistics(), + 1000 * 60 * 60 + ); + } + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + return html` + +
+ ${this._chartData + ? html`` + : ""} +
+
+ `; + } + + private _createOptions() { + this._chartOptions = { + parsing: false, + animation: false, + responsive: true, + indexAxis: "y", + scales: { + x: { + title: { + display: true, + text: "kWh", + }, + }, + }, + elements: { bar: { borderWidth: 1.5 } }, + plugins: { + tooltip: { + mode: "nearest", + callbacks: { + label: (context) => + `${context.dataset.label}: ${ + Math.round(context.parsed.x * 100) / 100 + } kWh`, + }, + }, + }, + }; + } + + private async _getStatistics(): Promise { + 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 + + this._fetching = true; + const prefs = this._config!.prefs; + + try { + this._data = await fetchStatistics( + this.hass!, + startDate, + undefined, + prefs.device_consumption.map((device) => device.stat_consumption) + ); + } finally { + this._fetching = false; + } + + const statisticsData = Object.values(this._data!); + let endTime: Date; + + if (statisticsData.length === 0) { + return; + } + + endTime = new Date( + Math.max( + ...statisticsData.map((stats) => + new Date(stats[stats.length - 1].start).getTime() + ) + ) + ); + + if (endTime > new Date()) { + endTime = new Date(); + } + + const data: Array>["data"]> = []; + const borderColor: string[] = []; + const backgroundColor: string[] = []; + + const datasets: ChartDataset<"bar", ParsedDataType<"bar">[]>[] = [ + { + label: "Energy usage", + borderColor, + backgroundColor, + data, + }, + ]; + + Object.entries(this._data).forEach(([id, statistics], idx) => { + const entity = this.hass.states[id]; + const label = entity ? computeStateName(entity) : id; + + const color = getColorByIndex(idx); + + borderColor.push(color); + backgroundColor.push(color + "7F"); + + const value = calculateStatisticSumGrowth(statistics); + data.push({ + // @ts-expect-error + y: label, + x: value || 0, + }); + }); + + data.sort((a, b) => b.x - a.x); + + this._chartData = { + // labels, + datasets, + }; + } + + static get styles(): CSSResultGroup { + return css` + ha-card { + height: 100%; + } + .content { + padding: 16px; + } + .has-header { + padding-top: 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-devices-graph-card": HuiEnergyDevicesGraphCard; + } +} diff --git a/src/panels/lovelace/cards/hui-energy-solar-consumed-gauge-card.ts b/src/panels/lovelace/cards/hui-energy-solar-consumed-gauge-card.ts new file mode 100644 index 0000000000..c5e9dab835 --- /dev/null +++ b/src/panels/lovelace/cards/hui-energy-solar-consumed-gauge-card.ts @@ -0,0 +1,161 @@ +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { round } from "../../../common/number/round"; +import "../../../components/ha-card"; +import "../../../components/ha-gauge"; +import { energySourcesByType } from "../../../data/energy"; +import { + calculateStatisticsSumGrowth, + fetchStatistics, + Statistics, +} from "../../../data/history"; +import type { HomeAssistant } from "../../../types"; +import type { LovelaceCard } from "../types"; +import { severityMap } from "./hui-gauge-card"; +import type { EnergySolarGaugeCardConfig } from "./types"; + +@customElement("hui-energy-solar-consumed-gauge-card") +class HuiEnergySolarGaugeCard extends LitElement implements LovelaceCard { + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: EnergySolarGaugeCardConfig; + + @state() private _stats?: Statistics; + + public getCardSize(): number { + return 4; + } + + public setConfig(config: EnergySolarGaugeCardConfig): void { + this._config = config; + } + + public willUpdate(changedProps) { + super.willUpdate(changedProps); + + if (!this.hasUpdated) { + this._getStatistics(); + } + } + + protected render(): TemplateResult { + if (!this._config || !this.hass) { + return html``; + } + + if (!this._stats) { + return html`Loading...`; + } + + const prefs = this._config!.prefs; + const types = energySourcesByType(prefs); + + const totalSolarProduction = calculateStatisticsSumGrowth( + this._stats, + types.solar!.map((source) => source.stat_energy_from) + ); + + const productionReturnedToGrid = calculateStatisticsSumGrowth( + this._stats, + types.grid![0].flow_to.map((flow) => flow.stat_energy_to) + ); + + let value: number | undefined; + + if (productionReturnedToGrid !== null && totalSolarProduction !== null) { + const cosumedSolar = totalSolarProduction - productionReturnedToGrid; + value = round((cosumedSolar / totalSolarProduction) * 100); + } + return html` + + ${value + ? html` +
Self consumed solar energy
` + : html`Self consumed solar energy couldn't be calculated`} +
+ `; + } + + private _computeSeverity(numberValue: number): string { + if (numberValue > 50) { + return severityMap.green; + } + return severityMap.normal; + } + + 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[] = []; + const prefs = this._config!.prefs; + for (const source of prefs.energy_sources) { + if (source.type === "solar") { + statistics.push(source.stat_energy_from); + continue; + } + + // grid source + for (const flowFrom of source.flow_from) { + statistics.push(flowFrom.stat_energy_from); + } + for (const flowTo of source.flow_to) { + statistics.push(flowTo.stat_energy_to); + } + } + + this._stats = await fetchStatistics( + this.hass!, + startDate, + undefined, + statistics + ); + } + + static get styles(): CSSResultGroup { + return css` + ha-card { + height: 100%; + overflow: hidden; + padding: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + box-sizing: border-box; + } + + ha-gauge { + --gauge-color: var(--label-badge-blue); + width: 100%; + max-width: 250px; + } + + .name { + text-align: center; + line-height: initial; + color: var(--primary-text-color); + width: 100%; + font-size: 15px; + margin-top: 8px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-solar-consumed-gauge-card": HuiEnergySolarGaugeCard; + } +} diff --git a/src/panels/lovelace/cards/hui-energy-solar-graph-card.ts b/src/panels/lovelace/cards/hui-energy-solar-graph-card.ts new file mode 100644 index 0000000000..c258ac3f79 --- /dev/null +++ b/src/panels/lovelace/cards/hui-energy-solar-graph-card.ts @@ -0,0 +1,412 @@ +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import "../../../components/ha-card"; +import { ChartData, ChartDataset, ChartOptions } from "chart.js"; +import { HomeAssistant } from "../../../types"; +import { LovelaceCard } from "../types"; +import { EnergySolarGraphCardConfig } from "./types"; +import { fetchStatistics, Statistics } from "../../../data/history"; +import { + hex2rgb, + lab2rgb, + rgb2hex, + rgb2lab, +} from "../../../common/color/convert-color"; +import { labDarken } from "../../../common/color/lab"; +import { SolarSourceTypeEnergyPreference } from "../../../data/energy"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { + ForecastSolarForecast, + getForecastSolarForecasts, +} from "../../../data/forecast_solar"; +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 = { border: "#FF9800", background: "#ffcb80" }; + +@customElement("hui-energy-solar-graph-card") +export class HuiEnergySolarGraphCard + extends LitElement + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergySolarGraphCardConfig; + + @state() private _data?: Statistics; + + @state() private _chartData?: ChartData; + + @state() private _forecasts?: Record; + + @state() private _chartOptions?: ChartOptions; + + @state() private _showAllForecastData = false; + + private _fetching = false; + + private _interval?: number; + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this._interval) { + clearInterval(this._interval); + this._interval = undefined; + } + } + + public connectedCallback() { + super.connectedCallback(); + if (!this.hasUpdated) { + return; + } + this._getStatistics(); + // statistics are created every hour + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._getStatistics(), + 1000 * 60 * 60 + ); + } + + public getCardSize(): Promise | number { + return 3; + } + + public setConfig(config: EnergySolarGraphCardConfig): void { + this._config = config; + } + + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + if (!this.hasUpdated) { + this._createOptions(); + } + if (!this._config || !changedProps.has("_config")) { + return; + } + + const oldConfig = changedProps.get("_config") as + | EnergySolarGraphCardConfig + | undefined; + + if (oldConfig !== this._config) { + this._getStatistics(); + // statistics are created every hour + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._getStatistics(), + 1000 * 60 * 60 + ); + } + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + return html` + +
+ + ${this._chartData + ? html`` + : ""} +
+
+ `; + } + + private _createOptions() { + this._chartOptions = { + parsing: false, + animation: false, + scales: { + x: { + type: "time", + adapters: { + date: { + locale: this.hass.locale, + }, + }, + ticks: { + maxRotation: 0, + sampleSize: 5, + autoSkipPadding: 20, + major: { + enabled: true, + }, + font: (context) => + context.tick && context.tick.major + ? ({ weight: "bold" } as any) + : {}, + }, + time: { + tooltipFormat: "datetimeseconds", + }, + }, + y: { + type: "linear", + ticks: { + beginAtZero: true, + }, + }, + }, + plugins: { + tooltip: { + mode: "nearest", + callbacks: { + label: (context) => + `${context.dataset.label}: ${context.parsed.y} kWh`, + }, + }, + filler: { + propagate: false, + }, + legend: { + display: false, + labels: { + usePointStyle: true, + }, + }, + }, + hover: { + mode: "nearest", + }, + elements: { + line: { + tension: 0.4, + borderWidth: 1.5, + }, + point: { + hitRadius: 5, + }, + }, + }; + } + + private async _getStatistics(): Promise { + 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 + + this._fetching = true; + + const solarSources: SolarSourceTypeEnergyPreference[] = + this._config!.prefs.energy_sources.filter( + (source) => source.type === "solar" + ) as SolarSourceTypeEnergyPreference[]; + + try { + this._data = await fetchStatistics( + this.hass!, + startDate, + undefined, + solarSources.map((source) => source.stat_energy_from) + ); + } finally { + this._fetching = false; + } + + if ( + isComponentLoaded(this.hass, "forecast_solar") && + solarSources.some((source) => source.config_entry_solar_forecast) + ) { + this._forecasts = await getForecastSolarForecasts(this.hass); + } + + this._renderChart(); + } + + private _renderChart() { + const solarSources: SolarSourceTypeEnergyPreference[] = + this._config!.prefs.energy_sources.filter( + (source) => source.type === "solar" + ) as SolarSourceTypeEnergyPreference[]; + + const statisticsData = Object.values(this._data!); + const datasets: ChartDataset<"line">[] = []; + let endTime: Date; + + if (statisticsData.length === 0) { + return; + } + + endTime = new Date( + Math.max( + ...statisticsData.map((stats) => + new Date(stats[stats.length - 1].start).getTime() + ) + ) + ); + + if (endTime > new Date()) { + endTime = new Date(); + } + + solarSources.forEach((source, idx) => { + const data: ChartDataset<"line">[] = []; + const entity = this.hass.states[source.stat_energy_from]; + + const borderColor = + idx > 0 + ? rgb2hex( + lab2rgb(labDarken(rgb2lab(hex2rgb(SOLAR_COLOR.border)), idx)) + ) + : SOLAR_COLOR.border; + + data.push({ + label: `Production ${ + entity ? computeStateName(entity) : source.stat_energy_from + }`, + fill: true, + stepped: false, + borderColor: borderColor, + backgroundColor: borderColor + "7F", + data: [], + }); + + let prevValue: number | null = null; + let prevStart: string | null = null; + + // Process solar production data. + if (this._data![source.stat_energy_from]) { + for (const point of this._data![source.stat_energy_from]) { + if (!point.sum) { + continue; + } + if (prevValue === null) { + prevValue = point.sum; + continue; + } + if (prevStart === point.start) { + continue; + } + const value = Math.round((point.sum - prevValue) * 100) / 100; + const date = new Date(point.start); + data[0].data.push({ + x: date.getTime(), + y: value, + }); + prevStart = point.start; + prevValue = point.sum; + } + } + + const forecasts = this._forecasts; + + // Process solar forecast data. + if (forecasts && source.config_entry_solar_forecast) { + let forecastsData: Record | undefined; + source.config_entry_solar_forecast.forEach((configEntryId) => { + if (!forecastsData) { + forecastsData = forecasts![configEntryId]?.wh_hours; + return; + } + Object.entries(forecasts![configEntryId].wh_hours).forEach( + ([date, value]) => { + if (date in forecastsData!) { + forecastsData![date] += value; + } else { + forecastsData![date] = value; + } + } + ); + }); + + if (forecastsData) { + const forecast: ChartDataset<"line"> = { + label: `Forecast ${ + entity ? computeStateName(entity) : source.stat_energy_from + }`, + fill: false, + stepped: false, + borderColor: "#000", + borderDash: [7, 5], + pointRadius: 0, + data: [], + }; + data.push(forecast); + + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + for (const [date, value] of Object.entries(forecastsData)) { + const dateObj = new Date(date); + if (dateObj > tomorrow && !this._showAllForecastData) { + continue; + } + forecast.data.push({ + x: dateObj.getTime(), + y: value / 1000, + }); + } + } + } + + // Concat two arrays + Array.prototype.push.apply(datasets, data); + }); + + this._chartData = { + datasets, + }; + } + + private _showAllForecastChanged(ev) { + this._showAllForecastData = ev.target.checked; + this._renderChart(); + } + + static get styles(): CSSResultGroup { + return css` + ha-card { + height: 100%; + } + .content { + padding: 16px; + } + .has-header { + padding-top: 0; + } + ha-formfield { + margin-bottom: 16px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-solar-graph-card": HuiEnergySolarGraphCard; + } +} diff --git a/src/panels/lovelace/cards/hui-energy-summary-card.ts b/src/panels/lovelace/cards/hui-energy-summary-card.ts new file mode 100644 index 0000000000..0b5c47ccb3 --- /dev/null +++ b/src/panels/lovelace/cards/hui-energy-summary-card.ts @@ -0,0 +1,302 @@ +import { mdiCashMultiple, mdiSolarPower } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../components/ha-svg-icon"; +import { + energySourcesByType, + GridSourceTypeEnergyPreference, + SolarSourceTypeEnergyPreference, +} from "../../../data/energy"; +import { + calculateStatisticSumGrowth, + fetchStatistics, + Statistics, +} from "../../../data/history"; +import { HomeAssistant } from "../../../types"; +import { LovelaceCard } from "../types"; +import { EnergySummaryCardConfig } from "./types"; +import "../../../components/ha-card"; + +const renderSumStatHelper = ( + data: Statistics, + stats: string[], + unit: string +) => { + let totalGrowth = 0; + + for (const stat of stats) { + if (!(stat in data)) { + return "stat missing"; + } + const statGrowth = calculateStatisticSumGrowth(data[stat]); + + if (statGrowth === null) { + return "incomplete data"; + } + + totalGrowth += statGrowth; + } + + return `${totalGrowth.toFixed(2)} ${unit}`; +}; + +@customElement("hui-energy-summary-card") +class HuiEnergySummaryCard extends LitElement implements LovelaceCard { + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: EnergySummaryCardConfig; + + @state() private _data?: Statistics; + + private _fetching = false; + + public setConfig(config: EnergySummaryCardConfig): void { + this._config = config; + } + + public getCardSize(): Promise | number { + return 3; + } + + public willUpdate(changedProps) { + super.willUpdate(changedProps); + + if (!this._fetching && !this._data) { + this._getStatistics(); + } + } + + protected render() { + if (!this._config || !this.hass) { + return html``; + } + + const prefs = this._config!.prefs; + const types = energySourcesByType(prefs); + + const hasConsumption = types.grid !== undefined; + const hasProduction = types.solar !== undefined; + const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0; + const hasCost = + hasConsumption && + types.grid![0].flow_from.some((flow) => flow.stat_cost !== null); + + // total consumption = consumption_from_grid + solar_production - return_to_grid + + return html` + +
+ ${!hasConsumption + ? "" + : html` +
+ +
Total Consumption
+
+ ${!this._data + ? "" + : renderSumStatHelper( + this._data, + types.grid![0].flow_from.map( + (flow) => flow.stat_energy_from + ), + "kWh" + )} +
+
+ `} + ${!hasProduction + ? "" + : html` +
+ +
Total Production
+
+ ${!this._data + ? "" + : renderSumStatHelper( + this._data, + types.solar!.map((source) => source.stat_energy_from), + "kWh" + )} +
+
+ `} + ${!hasReturnToGrid + ? "" + : html` +
+ +
Production returned to grid
+
+ ${!this._data + ? "" + : renderSumStatHelper( + this._data, + types.grid![0].flow_to.map( + (flow) => flow.stat_energy_to + ), + "kWh" + )} +
+
+ `} + ${!hasReturnToGrid || !hasProduction + ? "" + : html` +
+ +
Amount of produced power self used
+
+ ${!this._data + ? "" + : this._renderSolarPowerConsumptionRatio( + types.solar![0], + types.grid![0] + )} +
+
+ `} + ${!hasCost + ? "" + : html` +
+ +
Total costs of today
+
+ ${!this._data + ? "" + : renderSumStatHelper( + this._data, + types + .grid![0].flow_from.map((flow) => flow.stat_cost) + .filter(Boolean) as string[], + prefs.currency + )} +
+
+ `} +
+
+ `; + } + + // This is superduper temp. + private async _getStatistics(): Promise { + 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 + + this._fetching = true; + const statistics: string[] = []; + const prefs = this._config!.prefs; + for (const source of prefs.energy_sources) { + if (source.type === "solar") { + statistics.push(source.stat_energy_from); + // Use ws command to get solar forecast + + // if (source.stat_predicted_energy_from) { + // statistics.push(source.stat_predicted_energy_from); + // } + 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); + } + } + + try { + this._data = await fetchStatistics( + this.hass!, + startDate, + undefined, + statistics + ); + } finally { + this._fetching = false; + } + } + + private _renderSolarPowerConsumptionRatio( + solarSource: SolarSourceTypeEnergyPreference, + gridSource: GridSourceTypeEnergyPreference + ) { + let returnToGrid = 0; + + for (const flowTo of gridSource.flow_to) { + if (!flowTo.stat_energy_to || !(flowTo.stat_energy_to in this._data!)) { + continue; + } + const flowReturned = calculateStatisticSumGrowth( + this._data![flowTo.stat_energy_to] + ); + if (flowReturned === null) { + return "incomplete return data"; + } + returnToGrid += flowReturned; + } + + if (!(solarSource.stat_energy_from in this._data!)) { + return "sun stat missing"; + } + + const production = calculateStatisticSumGrowth( + this._data![solarSource.stat_energy_from] + ); + + if (production === null) { + return "incomplete solar data"; + } + + if (production === 0) { + return "-"; + } + + const consumed = Math.max( + Math.min(((production - returnToGrid) / production) * 100, 100), + 0 + ); + + return `${consumed.toFixed(1)}%`; + } + + static styles = css` + .row { + display: flex; + align-items: center; + color: var(--primary-text-color); + } + ha-svg-icon { + padding: 8px; + color: var(--paper-item-icon-color); + } + div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .label { + flex: 1; + margin-left: 16px; + } + .data { + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-summary-card": HuiEnergySummaryCard; + } +} diff --git a/src/panels/lovelace/cards/hui-energy-summary-graph-card.ts b/src/panels/lovelace/cards/hui-energy-summary-graph-card.ts new file mode 100644 index 0000000000..f0ceab258e --- /dev/null +++ b/src/panels/lovelace/cards/hui-energy-summary-graph-card.ts @@ -0,0 +1,440 @@ +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import "../../../components/ha-card"; +import { ChartData, ChartDataset, ChartOptions } from "chart.js"; +import { HomeAssistant } from "../../../types"; +import { LovelaceCard } from "../types"; +import { EnergySummaryGraphCardConfig } from "./types"; +import { fetchStatistics, Statistics } from "../../../data/history"; +import { + hex2rgb, + lab2rgb, + rgb2hex, + rgb2lab, +} from "../../../common/color/convert-color"; +import { labDarken } from "../../../common/color/lab"; +import { computeStateName } from "../../../common/entity/compute_state_name"; +import "../../../components/chart/ha-chart-base"; +import { round } from "../../../common/number/round"; + +const NEGATIVE = ["to_grid"]; +const ORDER = { + used_solar: 0, + from_grid: 100, + to_grid: 200, +}; +const COLORS = { + to_grid: { border: "#56d256", background: "#87ceab" }, + from_grid: { border: "#126A9A", background: "#88b5cd" }, + used_solar: { border: "#FF9800", background: "#ffcb80" }, +}; + +@customElement("hui-energy-summary-graph-card") +export class HuiEnergySummaryGraphCard + extends LitElement + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergySummaryGraphCardConfig; + + @state() private _data?: Statistics; + + @state() private _chartData?: ChartData; + + @state() private _chartOptions?: ChartOptions; + + private _fetching = false; + + private _interval?: number; + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this._interval) { + clearInterval(this._interval); + this._interval = undefined; + } + } + + public connectedCallback() { + super.connectedCallback(); + if (!this.hasUpdated) { + return; + } + this._getStatistics(); + // statistics are created every hour + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._getStatistics(), + 1000 * 60 * 60 + ); + } + + public getCardSize(): Promise | number { + return 3; + } + + public setConfig(config: EnergySummaryGraphCardConfig): void { + this._config = config; + } + + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + if (!this.hasUpdated) { + this._createOptions(); + } + if (!this._config || !changedProps.has("_config")) { + return; + } + + const oldConfig = changedProps.get("_config") as + | EnergySummaryGraphCardConfig + | undefined; + + if (oldConfig !== this._config) { + this._getStatistics(); + // statistics are created every hour + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._getStatistics(), + 1000 * 60 * 60 + ); + } + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + return html` + +
+ ${this._chartData + ? html`` + : ""} +
+
+ `; + } + + private _createOptions() { + this._chartOptions = { + parsing: false, + animation: false, + scales: { + x: { + type: "time", + adapters: { + date: { + locale: this.hass.locale, + }, + }, + ticks: { + maxRotation: 0, + sampleSize: 5, + autoSkipPadding: 20, + major: { + enabled: true, + }, + font: (context) => + context.tick && context.tick.major + ? ({ weight: "bold" } as any) + : {}, + }, + time: { + tooltipFormat: "datetimeseconds", + }, + }, + y: { + stacked: true, + type: "linear", + ticks: { + beginAtZero: true, + callback: (value) => Math.abs(round(value)), + }, + }, + }, + plugins: { + tooltip: { + mode: "x", + intersect: true, + position: "nearest", + filter: (val) => val.formattedValue !== "0", + callbacks: { + label: (context) => + `${context.dataset.label}: ${Math.abs(context.parsed.y)} kWh`, + footer: (contexts) => { + let totalConsumed = 0; + let totalReturned = 0; + for (const context of contexts) { + const value = (context.dataset.data[context.dataIndex] as any) + .y; + if (value > 0) { + totalConsumed += value; + } else { + totalReturned += Math.abs(value); + } + } + return [ + `Total consumed: ${totalConsumed.toFixed(2)} kWh`, + `Total returned: ${totalReturned.toFixed(2)} kWh`, + ]; + }, + }, + }, + filler: { + propagate: false, + }, + legend: { + display: false, + labels: { + usePointStyle: true, + }, + }, + }, + hover: { + mode: "nearest", + }, + elements: { + line: { + tension: 0.4, + borderWidth: 1.5, + }, + point: { + hitRadius: 5, + }, + }, + }; + } + + private async _getStatistics(): Promise { + 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 + + this._fetching = true; + const prefs = this._config!.prefs; + const statistics: { + to_grid?: string[]; + from_grid?: string[]; + solar?: string[]; + } = {}; + + for (const source of prefs.energy_sources) { + if (source.type === "solar") { + if (statistics.solar) { + statistics.solar.push(source.stat_energy_from); + } else { + statistics.solar = [source.stat_energy_from]; + } + continue; + } + + // grid source + for (const flowFrom of source.flow_from) { + if (statistics.from_grid) { + statistics.from_grid.push(flowFrom.stat_energy_from); + } else { + statistics.from_grid = [flowFrom.stat_energy_from]; + } + } + for (const flowTo of source.flow_to) { + if (statistics.to_grid) { + statistics.to_grid.push(flowTo.stat_energy_to); + } else { + statistics.to_grid = [flowTo.stat_energy_to]; + } + } + } + + try { + this._data = await fetchStatistics( + this.hass!, + startDate, + undefined, + // Array.flat() + ([] as string[]).concat(...Object.values(statistics)) + ); + } finally { + this._fetching = false; + } + + const statisticsData = Object.values(this._data!); + const datasets: ChartDataset<"line">[] = []; + let endTime: Date; + + if (statisticsData.length === 0) { + return; + } + + endTime = new Date( + Math.max( + ...statisticsData.map((stats) => + new Date(stats[stats.length - 1].start).getTime() + ) + ) + ); + + if (endTime > new Date()) { + endTime = new Date(); + } + + const combinedData: { + [key: string]: { [statId: string]: { [start: string]: number } }; + } = {}; + const summedData: { [key: string]: { [start: string]: number } } = {}; + + Object.entries(statistics).forEach(([key, statIds]) => { + const sum = ["solar", "to_grid"].includes(key); + const add = key !== "solar"; + const totalStats: { [start: string]: number } = {}; + const sets: { [statId: string]: { [start: string]: number } } = {}; + statIds!.forEach((id) => { + const stats = this._data![id]; + if (!stats) { + return; + } + const set = {}; + let prevValue: number; + stats.forEach((stat) => { + if (!stat.sum) { + return; + } + if (!prevValue) { + prevValue = stat.sum; + return; + } + const val = stat.sum - prevValue; + // Get total of solar and to grid to calculate the solar energy used + if (sum) { + totalStats[stat.start] = + stat.start in totalStats ? totalStats[stat.start] + val : val; + } + if (add) { + set[stat.start] = val; + } + prevValue = stat.sum; + }); + sets[id] = set; + }); + if (sum) { + summedData[key] = totalStats; + } + if (add) { + combinedData[key] = sets; + } + }); + + if (summedData.to_grid && summedData.solar) { + const used_solar = {}; + for (const start of Object.keys(summedData.solar)) { + used_solar[start] = Math.max( + (summedData.solar[start] || 0) - (summedData.to_grid[start] || 0), + 0 + ); + } + combinedData.used_solar = { used_solar: used_solar }; + } + + let allKeys: string[] = []; + + Object.values(combinedData).forEach((sources) => { + Object.values(sources).forEach((source) => { + allKeys = allKeys.concat(Object.keys(source)); + }); + }); + + const uniqueKeys = Array.from(new Set(allKeys)); + + Object.entries(combinedData).forEach(([type, sources]) => { + const negative = NEGATIVE.includes(type); + + Object.entries(sources).forEach(([statId, source], idx) => { + const data: ChartDataset<"line">[] = []; + const entity = this.hass.states[statId]; + const color = COLORS[type]; + + data.push({ + label: + type === "used_solar" + ? "Solar" + : entity + ? computeStateName(entity) + : statId, + fill: true, + stepped: false, + order: ORDER[type] + idx, + 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, + stack: negative ? "negative" : "positive", + data: [], + }); + + // Process chart data. + for (const key of uniqueKeys) { + const value = key in source ? Math.round(source[key] * 100) / 100 : 0; + const date = new Date(key); + data[0].data.push({ + x: date.getTime(), + y: value && negative ? -1 * value : value, + }); + } + + // Concat two arrays + Array.prototype.push.apply(datasets, data); + }); + }); + + this._chartData = { + datasets, + }; + } + + static get styles(): CSSResultGroup { + return css` + ha-card { + height: 100%; + } + .content { + padding: 16px; + } + .has-header { + padding-top: 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-summary-graph-card": HuiEnergySummaryGraphCard; + } +} diff --git a/src/panels/lovelace/cards/hui-energy-usage-card.ts b/src/panels/lovelace/cards/hui-energy-usage-card.ts new file mode 100644 index 0000000000..ce71cbf281 --- /dev/null +++ b/src/panels/lovelace/cards/hui-energy-usage-card.ts @@ -0,0 +1,333 @@ +import { mdiHome, mdiLeaf, mdiSolarPower, mdiTransmissionTower } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { subscribeOne } from "../../../common/util/subscribe-one"; +import "../../../components/ha-svg-icon"; +import { getConfigEntries } from "../../../data/config_entries"; +import { energySourcesByType } from "../../../data/energy"; +import { subscribeEntityRegistry } from "../../../data/entity_registry"; +import { + calculateStatisticsSumGrowth, + fetchStatistics, + Statistics, +} from "../../../data/history"; +import { HomeAssistant } from "../../../types"; +import { LovelaceCard } from "../types"; +import { EnergySummaryCardConfig } from "./types"; +import "../../../components/ha-card"; +import { round } from "../../../common/number/round"; + +@customElement("hui-energy-usage-card") +class HuiEnergyUsageCard extends LitElement implements LovelaceCard { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergySummaryCardConfig; + + @state() private _stats?: Statistics; + + @state() private _co2SignalEntity?: string; + + private _fetching = false; + + public setConfig(config: EnergySummaryCardConfig): void { + this._config = config; + } + + public getCardSize(): Promise | number { + return 3; + } + + public willUpdate(changedProps) { + super.willUpdate(changedProps); + + if (!this._fetching && !this._stats) { + this._fetching = true; + Promise.all([this._getStatistics(), this._fetchCO2SignalEntity()]).then( + () => { + this._fetching = false; + } + ); + } + } + + protected render() { + if (!this._config) { + return html``; + } + + if (!this._stats) { + return html`Loading…`; + } + + const prefs = this._config!.prefs; + const types = energySourcesByType(prefs); + + // The strategy only includes this card if we have a grid. + const hasConsumption = true; + + const hasSolarProduction = types.solar !== undefined; + const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0; + + const totalGridConsumption = calculateStatisticsSumGrowth( + this._stats, + types.grid![0].flow_from.map((flow) => flow.stat_energy_from) + ); + + if (totalGridConsumption === null) { + return html`Total consumption couldn't be calculated`; + } + + let totalSolarProduction: number | null = null; + + if (hasSolarProduction) { + totalSolarProduction = calculateStatisticsSumGrowth( + this._stats, + types.solar!.map((source) => source.stat_energy_from) + ); + + if (totalSolarProduction === null) { + return html`Total production couldn't be calculated`; + } + } + + let productionReturnedToGrid: number | null = null; + + if (hasReturnToGrid) { + productionReturnedToGrid = calculateStatisticsSumGrowth( + this._stats, + types.grid![0].flow_to.map((flow) => flow.stat_energy_to) + ); + + if (productionReturnedToGrid === undefined) { + return html`Production returned to grid couldn't be calculated`; + } + } + + // total consumption = consumption_from_grid + solar_production - return_to_grid + + let co2percentage: number | undefined; + + if (this._co2SignalEntity) { + const co2State = this.hass.states[this._co2SignalEntity]; + if (co2State) { + co2percentage = Number(co2State.state); + if (isNaN(co2percentage)) { + co2percentage = undefined; + } + } + } + + // We are calculating low carbon consumption based on what we got from the grid + // minus what we gave back because what we gave back is low carbon + const relativeGridFlow = + totalGridConsumption - (productionReturnedToGrid || 0); + + let lowCarbonConsumption: number | undefined; + + if (co2percentage !== undefined) { + if (relativeGridFlow > 0) { + lowCarbonConsumption = round(relativeGridFlow * (co2percentage / 100)); + } else { + lowCarbonConsumption = 0; + } + } + + const totalConsumption = + totalGridConsumption + + (totalSolarProduction || 0) - + (productionReturnedToGrid || 0); + + const gridPctLowCarbon = + co2percentage === undefined ? 0 : co2percentage / 100; + const gridPctHighCarbon = 1 - gridPctLowCarbon; + + const homePctSolar = + ((totalSolarProduction || 0) - (productionReturnedToGrid || 0)) / + totalConsumption; + // When we know the ratio solar-grid, we can adjust the low/high carbon + // percentages to reflect that. + const homePctGridLowCarbon = gridPctLowCarbon * (1 - homePctSolar); + const homePctGridHighCarbon = gridPctHighCarbon * (1 - homePctSolar); + + return html` + +
+
+ ${co2percentage === undefined + ? "" + : html` +
+ Low-carbon +
+ + ${co2percentage}% / ${round(lowCarbonConsumption!)} kWh +
+
+ `} +
+ Solar +
+ + ${round(totalSolarProduction || 0)} kWh +
+
+
+
+
+
+ + ${round(totalGridConsumption - (productionReturnedToGrid || 0))} + kWh +
    +
  • + Grid high carbon: ${round(gridPctHighCarbon * 100, 1)}% +
  • +
  • Grid low carbon: ${round(gridPctLowCarbon * 100, 1)}%
  • +
+
+ Grid +
+
+
+ + ${round(totalConsumption)} kWh +
    +
  • + Grid high carbon: ${round(homePctGridHighCarbon * 100)}% +
  • +
  • + Grid low carbon: ${round(homePctGridLowCarbon * 100)}% +
  • +
  • Solar: ${round(homePctSolar * 100)}%
  • +
+
+ Home +
+
+
+
+ `; + } + + private async _fetchCO2SignalEntity() { + const [configEntries, entityRegistryEntries] = await Promise.all([ + getConfigEntries(this.hass), + subscribeOne(this.hass.connection, subscribeEntityRegistry), + ]); + + const co2ConfigEntry = configEntries.find( + (entry) => entry.domain === "co2signal" + ); + + if (!co2ConfigEntry) { + return; + } + + for (const entry of entityRegistryEntries) { + if (entry.config_entry_id !== co2ConfigEntry.entry_id) { + continue; + } + + // The integration offers 2 entities. We want the % one. + const co2State = this.hass.states[entry.entity_id]; + if (!co2State || co2State.attributes.unit_of_measurement !== "%") { + continue; + } + + this._co2SignalEntity = co2State.entity_id; + break; + } + } + + 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[] = []; + const prefs = this._config!.prefs; + for (const source of prefs.energy_sources) { + if (source.type === "solar") { + statistics.push(source.stat_energy_from); + continue; + } + + // grid source + for (const flowFrom of source.flow_from) { + statistics.push(flowFrom.stat_energy_from); + } + for (const flowTo of source.flow_to) { + statistics.push(flowTo.stat_energy_to); + } + } + + this._stats = await fetchStatistics( + this.hass!, + startDate, + undefined, + statistics + ); + } + + static styles = css` + :host { + --mdc-icon-size: 26px; + } + .row { + display: flex; + margin-bottom: 30px; + } + .row:last-child { + margin-bottom: 0; + } + .circle-container { + display: flex; + flex-direction: column; + align-items: center; + margin-right: 40px; + } + .circle { + width: 80px; + height: 80px; + border-radius: 50%; + border: 2px solid; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + font-size: 12px; + } + .label { + color: var(--secondary-text-color); + font-size: 12px; + } + .circle-container:last-child { + margin-right: 0; + } + .circle ul { + display: none; + } + .low-carbon { + border-color: #0da035; + } + .low-carbon ha-svg-icon { + color: #0da035; + } + .solar { + border-color: #ff9800; + } + .grid { + border-color: #134763; + } + .circle-container.home { + margin-left: 120px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-usage-card": HuiEnergyUsageCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index c68582450c..ebaecf3e9e 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -1,3 +1,4 @@ +import { EnergyPreferences } from "../../../data/energy"; import { StatisticType } from "../../../data/history"; import { ActionConfig, LovelaceCardConfig } from "../../../data/lovelace"; import { FullCalendarView } from "../../../types"; @@ -89,6 +90,36 @@ export interface ButtonCardConfig extends LovelaceCardConfig { show_state?: boolean; } +export interface EnergySummaryCardConfig extends LovelaceCardConfig { + type: "energy-summary"; + prefs: EnergyPreferences; +} + +export interface EnergySummaryGraphCardConfig extends LovelaceCardConfig { + type: "energy-summary-graph"; + prefs: EnergyPreferences; +} + +export interface EnergySolarGraphCardConfig extends LovelaceCardConfig { + type: "energy-solar-graph"; + prefs: EnergyPreferences; +} + +export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig { + type: "energy-devices-graph"; + prefs: EnergyPreferences; +} + +export interface EnergySolarGaugeCardConfig extends LovelaceCardConfig { + type: "energy-solar-consumed-gauge"; + prefs: EnergyPreferences; +} + +export interface EnergyCarbonGaugeCardConfig extends LovelaceCardConfig { + type: "energy-carbon-consumed-gauge"; + prefs: EnergyPreferences; +} + export interface EntityFilterCardConfig extends LovelaceCardConfig { type: "entity-filter"; entities: Array; @@ -332,3 +363,58 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig { hold_action?: ActionConfig; double_tap_action?: ActionConfig; } + +export interface EnergyFlowCardConfig extends LovelaceCardConfig { + type: string; + name?: string; + show_header_toggle?: boolean; + + show_warning?: boolean; + show_error?: boolean; + test_gui?: boolean; + show_w_not_kw?: any; + hide_inactive_lines?: boolean; + threshold_in_k?: number; + energy_flow_diagramm?: boolean; + energy_flow_diagramm_lines_factor?: number; + change_house_bubble_color_with_flow?: boolean; + + grid_icon?: string; + generation_icon?: string; + house_icon?: string; + battery_icon?: string; + appliance1_icon?: string; + appliance2_icon?: string; + + icon_entities?: Map; + line_entities?: Map; + + house_entity?: string; + battery_entity?: string; + generation_entity?: string; + grid_entity?: string; + + grid_to_house_entity?: string; + grid_to_battery_entity?: string; + + generation_to_grid_entity?: string; + generation_to_battery_entity?: string; + generation_to_house_entity?: string; + + battery_to_house_entity?: string; + battery_to_grid_entity?: string; + + grid_extra_entity?: string; + generation_extra_entity?: string; + house_extra_entity?: string; + battery_extra_entity?: string; + + appliance1_consumption_entity?: string; + appliance1_extra_entity?: string; + appliance2_consumption_entity?: string; + appliance2_extra_entity?: string; + + tap_action?: ActionConfig; + hold_action?: ActionConfig; + double_tap_action?: ActionConfig; +} diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index b50de9a7c0..33871cfe40 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -35,6 +35,18 @@ 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": () => import("../cards/hui-energy-summary-card"), + "energy-summary-graph": () => + import("../cards/hui-energy-summary-graph-card"), + "energy-solar-graph": () => import("../cards/hui-energy-solar-graph-card"), + "energy-devices-graph": () => + import("../cards/hui-energy-devices-graph-card"), + "energy-costs-table": () => import("../cards/hui-energy-costs-table-card"), + "energy-usage": () => import("../cards/hui-energy-usage-card"), + "energy-solar-consumed-gauge": () => + import("../cards/hui-energy-solar-consumed-gauge-card"), + "energy-carbon-consumed-gauge": () => + import("../cards/hui-energy-carbon-consumed-gauge-card"), grid: () => import("../cards/hui-grid-card"), starting: () => import("../cards/hui-starting-card"), "entity-filter": () => import("../cards/hui-entity-filter-card"), diff --git a/src/panels/lovelace/create-element/create-view-element.ts b/src/panels/lovelace/create-element/create-view-element.ts index 3728b7bbfd..01dc1ab439 100644 --- a/src/panels/lovelace/create-element/create-view-element.ts +++ b/src/panels/lovelace/create-element/create-view-element.ts @@ -10,6 +10,7 @@ const ALWAYS_LOADED_LAYOUTS = new Set(["masonry"]); const LAZY_LOAD_LAYOUTS = { panel: () => import("../views/hui-panel-view"), + sidebar: () => import("../views/hui-sidebar-view"), }; export const createViewElement = ( diff --git a/src/panels/lovelace/strategies/get-strategy.ts b/src/panels/lovelace/strategies/get-strategy.ts index 4514f9e3bd..66e426147d 100644 --- a/src/panels/lovelace/strategies/get-strategy.ts +++ b/src/panels/lovelace/strategies/get-strategy.ts @@ -1,6 +1,5 @@ import { LovelaceConfig, LovelaceViewConfig } from "../../../data/lovelace"; import { AsyncReturnType, HomeAssistant } from "../../../types"; -import { OriginalStatesStrategy } from "./original-states-strategy"; const MAX_WAIT_STRATEGY_LOAD = 5000; const CUSTOM_PREFIX = "custom:"; @@ -24,9 +23,12 @@ export interface LovelaceViewStrategy { const strategies: Record< string, - LovelaceDashboardStrategy & LovelaceViewStrategy + () => Promise > = { - "original-states": OriginalStatesStrategy, + "original-states": async () => + (await import("./original-states-strategy")).OriginalStatesStrategy, + energy: async () => + (await import("../../energy/strategies/energy-strategy")).EnergyStrategy, }; const getLovelaceStrategy = async < @@ -35,7 +37,7 @@ const getLovelaceStrategy = async < strategyType: string ): Promise => { if (strategyType in strategies) { - return strategies[strategyType] as T; + return (await strategies[strategyType]()) as T; } if (!strategyType.startsWith(CUSTOM_PREFIX)) { diff --git a/src/panels/lovelace/views/hui-panel-view.ts b/src/panels/lovelace/views/hui-panel-view.ts index bc43c1fde1..0fe7dd3d2f 100644 --- a/src/panels/lovelace/views/hui-panel-view.ts +++ b/src/panels/lovelace/views/hui-panel-view.ts @@ -9,6 +9,7 @@ import { } from "lit"; import { property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; +import { fireEvent } from "../../../common/dom/fire_event"; import { computeRTL } from "../../../common/util/compute_rtl"; import type { LovelaceViewConfig, @@ -18,7 +19,6 @@ import type { HomeAssistant } from "../../../types"; import { HuiErrorCard } from "../cards/hui-error-card"; import { HuiCardOptions } from "../components/hui-card-options"; import { HuiWarning } from "../components/hui-warning"; -import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog"; import type { Lovelace, LovelaceCard } from "../types"; let editCodeLoaded = false; @@ -61,7 +61,8 @@ export class PanelView extends LitElement implements LovelaceViewElement { | undefined; if ( - oldLovelace?.config !== this.lovelace?.config || + (!changedProperties.has("cards") && + oldLovelace?.config !== this.lovelace?.config) || (oldLovelace && oldLovelace?.editMode !== this.lovelace?.editMode) ) { this._createCard(); @@ -91,11 +92,7 @@ export class PanelView extends LitElement implements LovelaceViewElement { } private _addCard(): void { - showCreateCardDialog(this, { - lovelaceConfig: this.lovelace!.config, - saveConfig: this.lovelace!.saveConfig, - path: [this.index!], - }); + fireEvent(this, "ll-create-card"); } private _createCard(): void { diff --git a/src/panels/lovelace/views/hui-sidebar-view.ts b/src/panels/lovelace/views/hui-sidebar-view.ts new file mode 100644 index 0000000000..0b8bd652c5 --- /dev/null +++ b/src/panels/lovelace/views/hui-sidebar-view.ts @@ -0,0 +1,218 @@ +import { mdiPlus } from "@mdi/js"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { computeRTL } from "../../../common/util/compute_rtl"; +import type { + LovelaceViewConfig, + LovelaceViewElement, +} from "../../../data/lovelace"; +import type { HomeAssistant } from "../../../types"; +import { HuiErrorCard } from "../cards/hui-error-card"; +import type { Lovelace, LovelaceCard } from "../types"; + +let editCodeLoaded = false; + +export class SideBarView extends LitElement implements LovelaceViewElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public lovelace?: Lovelace; + + @property({ type: Number }) public index?: number; + + @property({ type: Boolean }) public isStrategy = false; + + @property({ attribute: false }) public cards: Array< + LovelaceCard | HuiErrorCard + > = []; + + @state() private _config?: LovelaceViewConfig; + + public setConfig(config: LovelaceViewConfig): void { + this._config = config; + } + + public willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (this.lovelace?.editMode && !editCodeLoaded) { + editCodeLoaded = true; + import("./default-view-editable"); + } + + if (changedProperties.has("cards")) { + this._createCards(); + } + + if (!changedProperties.has("lovelace")) { + return; + } + + const oldLovelace = changedProperties.get("lovelace") as + | Lovelace + | undefined; + + if ( + (!changedProperties.has("cards") && + oldLovelace?.config !== this.lovelace?.config) || + (oldLovelace && oldLovelace?.editMode !== this.lovelace?.editMode) + ) { + this._createCards(); + } + } + + protected render(): TemplateResult { + return html` + ${this.lovelace?.editMode && this.cards.length === 0 + ? html` + + + + ` + : ""} + `; + } + + private _addCard(): void { + fireEvent(this, "ll-create-card"); + } + + private _createCards(): void { + const mainDiv = document.createElement("div"); + mainDiv.id = "main"; + const sidebarDiv = document.createElement("div"); + sidebarDiv.id = "sidebar"; + + if (this.hasUpdated) { + const oldMain = this.renderRoot.querySelector("#main"); + const oldSidebar = this.renderRoot.querySelector("#sidebar"); + if (oldMain) { + this.renderRoot.removeChild(oldMain); + } + if (oldSidebar) { + this.renderRoot.removeChild(oldSidebar); + } + this.renderRoot.appendChild(mainDiv); + this.renderRoot.appendChild(sidebarDiv); + } else { + this.updateComplete.then(() => { + this.renderRoot.appendChild(mainDiv); + this.renderRoot.appendChild(sidebarDiv); + }); + } + + this.cards.forEach((card: LovelaceCard, idx) => { + const cardConfig = this._config?.cards?.[idx]; + if (this.isStrategy || !this.lovelace?.editMode) { + card.editMode = false; + if (cardConfig?.view_layout?.position !== "sidebar") { + mainDiv.appendChild(card); + } else { + sidebarDiv.appendChild(card); + } + return; + } + + const wrapper = document.createElement("hui-card-options"); + wrapper.hass = this.hass; + wrapper.lovelace = this.lovelace; + wrapper.path = [this.index!, 0]; + card.editMode = true; + wrapper.appendChild(card); + if (cardConfig?.view_layout?.position !== "sidebar") { + mainDiv.appendChild(card); + } else { + sidebarDiv.appendChild(card); + } + }); + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: flex; + padding-top: 4px; + margin-left: 4px; + margin-right: 4px; + height: 100%; + box-sizing: border-box; + justify-content: center; + } + + #main { + max-width: 1620px; + flex-grow: 2; + } + + #sidebar { + flex-grow: 1; + max-width: 380px; + } + + :host > div { + min-width: 0; + box-sizing: border-box; + } + + :host > div > * { + display: block; + margin: var(--masonry-view-card-margin, 4px 4px 8px); + } + + @media (max-width: 760px) { + :host { + flex-direction: column; + } + #sidebar { + max-width: unset; + } + } + + @media (max-width: 500px) { + :host > div > * { + margin-left: 0; + margin-right: 0; + } + } + + ha-fab { + position: sticky; + float: right; + right: calc(16px + env(safe-area-inset-right)); + bottom: calc(16px + env(safe-area-inset-bottom)); + z-index: 1; + } + + ha-fab.rtl { + float: left; + right: auto; + left: calc(16px + env(safe-area-inset-left)); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-sidebar-view": SideBarView; + } +} + +customElements.define("hui-sidebar-view", SideBarView); diff --git a/src/resources/chartjs.ts b/src/resources/chartjs.ts index 811addaa8b..1a39fb4b5e 100644 --- a/src/resources/chartjs.ts +++ b/src/resources/chartjs.ts @@ -10,6 +10,8 @@ import { Tooltip, CategoryScale, Chart, + BarElement, + BarController, } from "chart.js"; import { TextBarElement } from "../components/chart/timeline-chart/textbar-element"; import { TimelineController } from "../components/chart/timeline-chart/timeline-controller"; @@ -26,6 +28,8 @@ Chart.register( TimeScale, LinearScale, LineController, + BarController, + BarElement, PointElement, LineElement, TextBarElement, diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index 07e34ad80e..00bfb44bc9 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -151,6 +151,9 @@ export const connectionMixin = >( integration, configFlow ), + loadFragmentTranslation: (fragment) => + // @ts-ignore + this._loadFragmentTranslations(this.hass?.language, fragment), ...getState(), ...this._pendingHass, }; diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index e8fbed6413..c7ec776b06 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -276,8 +276,9 @@ export default >(superClass: T) => panelUrl: string ) { if (!panelUrl) { - return; + return undefined; } + const panelComponent = this.hass?.panels?.[panelUrl]?.component_name; // If it's the first call we don't have panel info yet to check the component. @@ -288,15 +289,16 @@ export default >(superClass: T) => : undefined; if (!fragment) { - return; + return undefined; } if (this.__loadedFragmetTranslations.has(fragment)) { - return; + return this.hass!.localize; } this.__loadedFragmetTranslations.add(fragment); const result = await getTranslation(fragment, language); await this._updateResources(result.language, result.data); + return this.hass!.localize; } private async _loadCoreTranslations(language: string) { diff --git a/src/translations/en.json b/src/translations/en.json index d031403a7b..c434885e8f 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1,5 +1,6 @@ { "panel": { + "energy": "Energy", "calendar": "Calendar", "config": "Configuration", "states": "Overview", @@ -449,6 +450,16 @@ "loading_history": "Loading state history...", "no_history_found": "No state history found." }, + "statistics_charts": { + "loading_statistics": "Loading statistics...", + "no_statistics_found": "No statistics found.", + "statistic_types": { + "min": "min", + "max": "max", + "mean": "mean", + "sum": "sum" + } + }, "service-picker": { "service": "Service" }, @@ -586,6 +597,7 @@ "person": "[%key:ui::panel::config::person::caption%]", "devices": "[%key:ui::panel::config::devices::caption%]", "entities": "[%key:ui::panel::config::entities::caption%]", + "energy": "[%key:ui::panel::config::energy::caption%]", "lovelace": "[%key:ui::panel::config::lovelace::caption%]", "core": "[%key:ui::panel::config::core::caption%]", "zone": "[%key:ui::panel::config::zone::caption%]", @@ -975,6 +987,55 @@ "companion_apps": "companion apps" } }, + "energy": { + "caption": "Energy", + "description": "Monitor your energy production and consumption", + "currency": "", + "grid": { + "title": "Configure grid", + "sub": "Configure the different tarrifs for the energy you consume from the grid, and, if you return energy to the grid, the energy you return to the grid.", + "flow_dialog": { + "from": { + "header": "Configure grid consumption", + "paragraph": "Grid consumption is the energy that flows from the energy grid to your home.", + "energy_stat": "Consumed Energy (kWh)", + "cost_para": "Select how Home Assistant should keep track of the costs of the consumed energy.", + "no_cost": "Do not track costs", + "cost_stat": "Use an entity tracking the total costs", + "cost_stat_input": "Entity keeping track of the total costs", + "cost_entity": "Use an entity with current price", + "cost_entity_input": "Entity with the current price", + "cost_number": "Use a static price", + "cost_number_input": "Price per kWh", + "cost_number_suffix": "{currency}/kWh" + }, + "to": { + "header": "Configure grid production", + "paragraph": "Grid production is the energy that flows from your solar panels to the grid.", + "energy_stat": "Energy returned to the grid (kWh)", + "cost_para": "Do you get money back when you return energy to the grid?", + "no_cost": "I do not get money back", + "cost_stat": "Use an entity tracking the total recieved money", + "cost_stat_input": "Entity keeping track of the total of received money", + "cost_entity": "Use an entity with current rate", + "cost_entity_input": "Entity with the current rate", + "cost_number": "Use a static rate", + "cost_number_input": "Rate per kWh", + "cost_number_suffix": "{currency}/kWh" + } + } + }, + "solar": { + "stat_production": "Your solar energy production", + "stat_return_to_grid": "Solar energy returned to the grid", + "stat_predicted_production": "Prediction of your solar energy production" + }, + "device_consumption": { + "description": "If you measure the power consumption of individual devices, you can select the entities with the power consumption below", + "add_stat": "Pick entity to track energy of", + "selected_stat": "Tracking energy for" + } + }, "helpers": { "caption": "Helpers", "description": "Elements that help build automations", @@ -3621,6 +3682,20 @@ "complete_access": "It will have access to all data in Home Assistant.", "hide_message": "Check docs for the panel_custom component to hide this message" } + }, + "energy": { + "setup": { + "header": "Setup your energy dashboard", + "slogan": "The world is heating up. Together we can fix that.", + "next": "Next", + "back": "Back", + "done": "Show me my energy dashboard!" + }, + "charts": { + "stat_house_energy_meter": "Total energy consumption", + "solar": "Solar", + "by_device": "Consumption by device" + } } } }, diff --git a/src/types.ts b/src/types.ts index 9f10eff84c..effe231479 100644 --- a/src/types.ts +++ b/src/types.ts @@ -239,6 +239,7 @@ export interface HomeAssistant { integration?: Parameters[3], configFlow?: Parameters[4] ): Promise; + loadFragmentTranslation(fragment: string): Promise; } export interface Route { diff --git a/yarn.lock b/yarn.lock index c10803e068..63bd07e770 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1966,6 +1966,32 @@ __metadata: languageName: node linkType: hard +"@material/data-table@npm:=12.0.0-canary.1a8d06483.0": + version: 12.0.0-canary.1a8d06483.0 + resolution: "@material/data-table@npm:12.0.0-canary.1a8d06483.0" + dependencies: + "@material/animation": 12.0.0-canary.1a8d06483.0 + "@material/base": 12.0.0-canary.1a8d06483.0 + "@material/checkbox": 12.0.0-canary.1a8d06483.0 + "@material/density": 12.0.0-canary.1a8d06483.0 + "@material/dom": 12.0.0-canary.1a8d06483.0 + "@material/elevation": 12.0.0-canary.1a8d06483.0 + "@material/feature-targeting": 12.0.0-canary.1a8d06483.0 + "@material/icon-button": 12.0.0-canary.1a8d06483.0 + "@material/linear-progress": 12.0.0-canary.1a8d06483.0 + "@material/list": 12.0.0-canary.1a8d06483.0 + "@material/menu": 12.0.0-canary.1a8d06483.0 + "@material/rtl": 12.0.0-canary.1a8d06483.0 + "@material/select": 12.0.0-canary.1a8d06483.0 + "@material/shape": 12.0.0-canary.1a8d06483.0 + "@material/theme": 12.0.0-canary.1a8d06483.0 + "@material/touch-target": 12.0.0-canary.1a8d06483.0 + "@material/typography": 12.0.0-canary.1a8d06483.0 + tslib: ^2.1.0 + checksum: 61a5abbc681742a1cd259fa52bda067324313658d438cbef86ee35c6a6858ffb41fa082d592fa8c8390104507e1c2629d0e21f7552598ac4ad2d8d3a00c768cb + languageName: node + linkType: hard + "@material/density@npm:12.0.0-canary.1a8d06483.0": version: 12.0.0-canary.1a8d06483.0 resolution: "@material/density@npm:12.0.0-canary.1a8d06483.0" @@ -2029,6 +2055,22 @@ __metadata: languageName: node linkType: hard +"@material/floating-label@npm:12.0.0-canary.1a8d06483.0": + version: 12.0.0-canary.1a8d06483.0 + resolution: "@material/floating-label@npm:12.0.0-canary.1a8d06483.0" + dependencies: + "@material/animation": 12.0.0-canary.1a8d06483.0 + "@material/base": 12.0.0-canary.1a8d06483.0 + "@material/dom": 12.0.0-canary.1a8d06483.0 + "@material/feature-targeting": 12.0.0-canary.1a8d06483.0 + "@material/rtl": 12.0.0-canary.1a8d06483.0 + "@material/theme": 12.0.0-canary.1a8d06483.0 + "@material/typography": 12.0.0-canary.1a8d06483.0 + tslib: ^2.1.0 + checksum: 49a926db76396dbe5b4b00e4526982afdc059617eaad7abbeee7a48fb05b19924feb512a5b1584e0e3a588fe9b2e572f7da60010810f2a72c8ab51ef29904344 + languageName: node + linkType: hard + "@material/form-field@npm:=12.0.0-canary.1a8d06483.0": version: 12.0.0-canary.1a8d06483.0 resolution: "@material/form-field@npm:12.0.0-canary.1a8d06483.0" @@ -2059,7 +2101,20 @@ __metadata: languageName: node linkType: hard -"@material/linear-progress@npm:=12.0.0-canary.1a8d06483.0": +"@material/line-ripple@npm:12.0.0-canary.1a8d06483.0": + version: 12.0.0-canary.1a8d06483.0 + resolution: "@material/line-ripple@npm:12.0.0-canary.1a8d06483.0" + dependencies: + "@material/animation": 12.0.0-canary.1a8d06483.0 + "@material/base": 12.0.0-canary.1a8d06483.0 + "@material/feature-targeting": 12.0.0-canary.1a8d06483.0 + "@material/theme": 12.0.0-canary.1a8d06483.0 + tslib: ^2.1.0 + checksum: e5a7d102819c5d23089afc3d9e85c771ed9f9b7eddce2a0339bb4ed37f3d0d10c36f90370855f9bef2f6e66d421ec082f732f7d930b503779615072299468002 + languageName: node + linkType: hard + +"@material/linear-progress@npm:12.0.0-canary.1a8d06483.0, @material/linear-progress@npm:=12.0.0-canary.1a8d06483.0": version: 12.0.0-canary.1a8d06483.0 resolution: "@material/linear-progress@npm:12.0.0-canary.1a8d06483.0" dependencies: @@ -2108,7 +2163,7 @@ __metadata: languageName: node linkType: hard -"@material/menu@npm:=12.0.0-canary.1a8d06483.0": +"@material/menu@npm:12.0.0-canary.1a8d06483.0, @material/menu@npm:=12.0.0-canary.1a8d06483.0": version: 12.0.0-canary.1a8d06483.0 resolution: "@material/menu@npm:12.0.0-canary.1a8d06483.0" dependencies: @@ -2385,6 +2440,21 @@ __metadata: languageName: node linkType: hard +"@material/notched-outline@npm:12.0.0-canary.1a8d06483.0": + version: 12.0.0-canary.1a8d06483.0 + resolution: "@material/notched-outline@npm:12.0.0-canary.1a8d06483.0" + dependencies: + "@material/base": 12.0.0-canary.1a8d06483.0 + "@material/feature-targeting": 12.0.0-canary.1a8d06483.0 + "@material/floating-label": 12.0.0-canary.1a8d06483.0 + "@material/rtl": 12.0.0-canary.1a8d06483.0 + "@material/shape": 12.0.0-canary.1a8d06483.0 + "@material/theme": 12.0.0-canary.1a8d06483.0 + tslib: ^2.1.0 + checksum: d0856c5aeba272df09c5c76e99cc630c1614458a8184959fb4a82d54908db5632f09519080831290d9786e989a92601de432c5a4704b6481664ce3fc22d44150 + languageName: node + linkType: hard + "@material/progress-indicator@npm:12.0.0-canary.1a8d06483.0": version: 12.0.0-canary.1a8d06483.0 resolution: "@material/progress-indicator@npm:12.0.0-canary.1a8d06483.0" @@ -2435,6 +2505,31 @@ __metadata: languageName: node linkType: hard +"@material/select@npm:12.0.0-canary.1a8d06483.0": + version: 12.0.0-canary.1a8d06483.0 + resolution: "@material/select@npm:12.0.0-canary.1a8d06483.0" + dependencies: + "@material/animation": 12.0.0-canary.1a8d06483.0 + "@material/base": 12.0.0-canary.1a8d06483.0 + "@material/density": 12.0.0-canary.1a8d06483.0 + "@material/dom": 12.0.0-canary.1a8d06483.0 + "@material/feature-targeting": 12.0.0-canary.1a8d06483.0 + "@material/floating-label": 12.0.0-canary.1a8d06483.0 + "@material/line-ripple": 12.0.0-canary.1a8d06483.0 + "@material/list": 12.0.0-canary.1a8d06483.0 + "@material/menu": 12.0.0-canary.1a8d06483.0 + "@material/menu-surface": 12.0.0-canary.1a8d06483.0 + "@material/notched-outline": 12.0.0-canary.1a8d06483.0 + "@material/ripple": 12.0.0-canary.1a8d06483.0 + "@material/rtl": 12.0.0-canary.1a8d06483.0 + "@material/shape": 12.0.0-canary.1a8d06483.0 + "@material/theme": 12.0.0-canary.1a8d06483.0 + "@material/typography": 12.0.0-canary.1a8d06483.0 + tslib: ^2.1.0 + checksum: 6a5483d3497a9ba835df56ca28e059b6f6693d363e6baea67106405ca19485fc517d2824bcb2ef64b94e836a9fb8977552243db9be7be858e7dfb9c1af51d2ab + languageName: node + linkType: hard + "@material/shape@npm:12.0.0-canary.1a8d06483.0, @material/shape@npm:=12.0.0-canary.1a8d06483.0": version: 12.0.0-canary.1a8d06483.0 resolution: "@material/shape@npm:12.0.0-canary.1a8d06483.0" @@ -8735,6 +8830,7 @@ fsevents@~2.3.1: "@koa/cors": ^3.1.0 "@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.6.0#./.yarn/patches/@lit-labs/virtualizer/0.7.0.patch" "@material/chips": =12.0.0-canary.1a8d06483.0 + "@material/data-table": =12.0.0-canary.1a8d06483.0 "@material/mwc-button": 0.22.0-canary.cc04657a.0 "@material/mwc-checkbox": 0.22.0-canary.cc04657a.0 "@material/mwc-circular-progress": 0.22.0-canary.cc04657a.0