From b802a410b9557618bc096581a0f20e496ce02f84 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Aug 2021 12:59:41 -0700 Subject: [PATCH] Add energy validation UI (#9802) * Add basic validation UI * Also refresh validation results when prefs change * Update look * Remove || true * Add missing errors * Validate state class * Rename file * Simplify energySourcesByType * Update src/translations/en.json * Update ha-energy-validation-result.ts --- src/common/util/group-by.ts | 15 +++ src/data/energy.ts | 30 +++-- .../components/ha-energy-battery-settings.ts | 33 ++++- .../components/ha-energy-device-settings.ts | 14 +++ .../components/ha-energy-gas-settings.ts | 32 ++++- .../components/ha-energy-grid-settings.ts | 35 +++++- .../components/ha-energy-solar-settings.ts | 33 ++++- .../components/ha-energy-validation-result.ts | 114 ++++++++++++++++++ src/panels/config/energy/ha-config-energy.ts | 27 ++++- src/translations/en.json | 39 +++++- 10 files changed, 342 insertions(+), 30 deletions(-) create mode 100644 src/common/util/group-by.ts create mode 100644 src/panels/config/energy/components/ha-energy-validation-result.ts diff --git a/src/common/util/group-by.ts b/src/common/util/group-by.ts new file mode 100644 index 0000000000..0f3f76dfff --- /dev/null +++ b/src/common/util/group-by.ts @@ -0,0 +1,15 @@ +export const groupBy = ( + list: T[], + keySelector: (item: T) => string +): { [key: string]: T[] } => { + const result = {}; + for (const item of list) { + const key = keySelector(item); + if (key in result) { + result[key].push(item); + } else { + result[key] = [item]; + } + } + return result; +}; diff --git a/src/data/energy.ts b/src/data/energy.ts index 8e801ced29..799bb284f7 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -6,6 +6,7 @@ import { startOfYesterday, } from "date-fns"; import { Collection, getCollection } from "home-assistant-js-websocket"; +import { groupBy } from "../common/util/group-by"; import { subscribeOne } from "../common/util/subscribe-one"; import { HomeAssistant } from "../types"; import { ConfigEntry, getConfigEntries } from "./config_entries"; @@ -144,11 +145,27 @@ export interface EnergyInfo { cost_sensors: Record; } +export interface EnergyValidationIssue { + type: string; + identifier: string; + value?: unknown; +} + +export interface EnergyPreferencesValidation { + energy_sources: EnergyValidationIssue[][]; + device_consumption: EnergyValidationIssue[][]; +} + export const getEnergyInfo = (hass: HomeAssistant) => hass.callWS({ type: "energy/info", }); +export const getEnergyPreferenceValidation = (hass: HomeAssistant) => + hass.callWS({ + type: "energy/validate", + }); + export const getEnergyPreferences = (hass: HomeAssistant) => hass.callWS({ type: "energy/get_prefs", @@ -173,17 +190,8 @@ interface EnergySourceByType { gas?: GasSourceTypeEnergyPreference[]; } -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; -}; +export const energySourcesByType = (prefs: EnergyPreferences) => + groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType; export interface EnergyData { start: Date; diff --git a/src/panels/config/energy/components/ha-energy-battery-settings.ts b/src/panels/config/energy/components/ha-energy-battery-settings.ts index 9caa3b8e60..d1360fd61d 100644 --- a/src/panels/config/energy/components/ha-energy-battery-settings.ts +++ b/src/panels/config/energy/components/ha-energy-battery-settings.ts @@ -10,7 +10,8 @@ import "../../../../components/ha-settings-row"; import { BatterySourceTypeEnergyPreference, EnergyPreferences, - energySourcesByType, + EnergyPreferencesValidation, + EnergyValidationIssue, saveEnergyPreferences, } from "../../../../data/energy"; import { @@ -21,6 +22,7 @@ import { haStyle } from "../../../../resources/styles"; import { HomeAssistant } from "../../../../types"; import { documentationUrl } from "../../../../util/documentation-url"; import { showEnergySettingsBatteryDialog } from "../dialogs/show-dialogs-energy"; +import "./ha-energy-validation-result"; import { energyCardStyles } from "./styles"; @customElement("ha-energy-battery-settings") @@ -30,10 +32,23 @@ export class EnergyBatterySettings extends LitElement { @property({ attribute: false }) public preferences!: EnergyPreferences; - protected render(): TemplateResult { - const types = energySourcesByType(this.preferences); + @property({ attribute: false }) + public validationResult?: EnergyPreferencesValidation; - const batterySources = types.battery || []; + protected render(): TemplateResult { + const batterySources: BatterySourceTypeEnergyPreference[] = []; + const batteryValidation: EnergyValidationIssue[][] = []; + + this.preferences.energy_sources.forEach((source, idx) => { + if (source.type !== "battery") { + return; + } + batterySources.push(source); + + if (this.validationResult) { + batteryValidation.push(this.validationResult.energy_sources[idx]); + } + }); return html` @@ -54,6 +69,16 @@ export class EnergyBatterySettings extends LitElement { )}

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

Battery systems

${batterySources.map((source) => { const fromEntityState = this.hass.states[source.stat_energy_from]; diff --git a/src/panels/config/energy/components/ha-energy-device-settings.ts b/src/panels/config/energy/components/ha-energy-device-settings.ts index 561973a0e0..c99b36849d 100644 --- a/src/panels/config/energy/components/ha-energy-device-settings.ts +++ b/src/panels/config/energy/components/ha-energy-device-settings.ts @@ -9,6 +9,7 @@ import "../../../../components/ha-card"; import { DeviceConsumptionEnergyPreference, EnergyPreferences, + EnergyPreferencesValidation, saveEnergyPreferences, } from "../../../../data/energy"; import { @@ -19,6 +20,7 @@ import { haStyle } from "../../../../resources/styles"; import { HomeAssistant } from "../../../../types"; import { documentationUrl } from "../../../../util/documentation-url"; import { showEnergySettingsDeviceDialog } from "../dialogs/show-dialogs-energy"; +import "./ha-energy-validation-result"; import { energyCardStyles } from "./styles"; @customElement("ha-energy-device-settings") @@ -28,6 +30,9 @@ export class EnergyDeviceSettings extends LitElement { @property({ attribute: false }) public preferences!: EnergyPreferences; + @property({ attribute: false }) + public validationResult?: EnergyPreferencesValidation; + protected render(): TemplateResult { return html` @@ -55,6 +60,15 @@ export class EnergyDeviceSettings extends LitElement { )}

+ ${this.validationResult?.device_consumption.map( + (result) => + html` + + ` + )}

Devices

${this.preferences.device_consumption.map((device) => { const entityState = this.hass.states[device.stat_consumption]; diff --git a/src/panels/config/energy/components/ha-energy-gas-settings.ts b/src/panels/config/energy/components/ha-energy-gas-settings.ts index 00fc47e1df..4b57b0ee3d 100644 --- a/src/panels/config/energy/components/ha-energy-gas-settings.ts +++ b/src/panels/config/energy/components/ha-energy-gas-settings.ts @@ -7,9 +7,10 @@ import { computeStateName } from "../../../../common/entity/compute_state_name"; import "../../../../components/ha-card"; import { EnergyPreferences, - energySourcesByType, saveEnergyPreferences, GasSourceTypeEnergyPreference, + EnergyPreferencesValidation, + EnergyValidationIssue, } from "../../../../data/energy"; import { showConfirmationDialog, @@ -19,6 +20,7 @@ import { haStyle } from "../../../../resources/styles"; import { HomeAssistant } from "../../../../types"; import { documentationUrl } from "../../../../util/documentation-url"; import { showEnergySettingsGasDialog } from "../dialogs/show-dialogs-energy"; +import "./ha-energy-validation-result"; import { energyCardStyles } from "./styles"; @customElement("ha-energy-gas-settings") @@ -28,10 +30,23 @@ export class EnergyGasSettings extends LitElement { @property({ attribute: false }) public preferences!: EnergyPreferences; - protected render(): TemplateResult { - const types = energySourcesByType(this.preferences); + @property({ attribute: false }) + public validationResult?: EnergyPreferencesValidation; - const gasSources = types.gas || []; + protected render(): TemplateResult { + const gasSources: GasSourceTypeEnergyPreference[] = []; + const gasValidation: EnergyValidationIssue[][] = []; + + this.preferences.energy_sources.forEach((source, idx) => { + if (source.type !== "gas") { + return; + } + gasSources.push(source); + + if (this.validationResult) { + gasValidation.push(this.validationResult.energy_sources[idx]); + } + }); return html` @@ -50,6 +65,15 @@ export class EnergyGasSettings extends LitElement { >${this.hass.localize("ui.panel.config.energy.gas.learn_more")}

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

Gas consumption

${gasSources.map((source) => { const entityState = this.hass.states[source.stat_energy_from]; diff --git a/src/panels/config/energy/components/ha-energy-grid-settings.ts b/src/panels/config/energy/components/ha-energy-grid-settings.ts index 5bf861f9d4..6f59fe6a18 100644 --- a/src/panels/config/energy/components/ha-energy-grid-settings.ts +++ b/src/panels/config/energy/components/ha-energy-grid-settings.ts @@ -19,7 +19,9 @@ import { import { emptyGridSourceEnergyPreference, EnergyPreferences, + EnergyPreferencesValidation, energySourcesByType, + EnergyValidationIssue, FlowFromGridSourceEnergyPreference, FlowToGridSourceEnergyPreference, GridSourceTypeEnergyPreference, @@ -38,6 +40,7 @@ import { showEnergySettingsGridFlowFromDialog, showEnergySettingsGridFlowToDialog, } from "../dialogs/show-dialogs-energy"; +import "./ha-energy-validation-result"; import { energyCardStyles } from "./styles"; @customElement("ha-energy-grid-settings") @@ -47,6 +50,9 @@ export class EnergyGridSettings extends LitElement { @property({ attribute: false }) public preferences!: EnergyPreferences; + @property({ attribute: false }) + public validationResult?: EnergyPreferencesValidation; + @state() private _configEntries?: ConfigEntry[]; protected firstUpdated() { @@ -54,11 +60,23 @@ export class EnergyGridSettings extends LitElement { } protected render(): TemplateResult { - const types = energySourcesByType(this.preferences); + const gridIdx = this.preferences.energy_sources.findIndex( + (source) => source.type === "grid" + ); - const gridSource = types.grid - ? types.grid[0] - : emptyGridSourceEnergyPreference(); + let gridSource: GridSourceTypeEnergyPreference; + let gridValidation: EnergyValidationIssue[] | undefined; + + if (gridIdx === -1) { + gridSource = emptyGridSourceEnergyPreference(); + } else { + gridSource = this.preferences.energy_sources[ + gridIdx + ] as GridSourceTypeEnergyPreference; + if (this.validationResult) { + gridValidation = this.validationResult.energy_sources[gridIdx]; + } + } return html` @@ -82,6 +100,15 @@ export class EnergyGridSettings extends LitElement { )}

+ ${gridValidation + ? html` + + ` + : ""} +

Grid consumption

${gridSource.flow_from.map((flow) => { const entityState = this.hass.states[flow.stat_energy_from]; diff --git a/src/panels/config/energy/components/ha-energy-solar-settings.ts b/src/panels/config/energy/components/ha-energy-solar-settings.ts index 109c61d36e..d58550e8d8 100644 --- a/src/panels/config/energy/components/ha-energy-solar-settings.ts +++ b/src/panels/config/energy/components/ha-energy-solar-settings.ts @@ -7,7 +7,8 @@ import { computeStateName } from "../../../../common/entity/compute_state_name"; import "../../../../components/ha-card"; import { EnergyPreferences, - energySourcesByType, + EnergyPreferencesValidation, + EnergyValidationIssue, saveEnergyPreferences, SolarSourceTypeEnergyPreference, } from "../../../../data/energy"; @@ -19,6 +20,7 @@ import { haStyle } from "../../../../resources/styles"; import { HomeAssistant } from "../../../../types"; import { documentationUrl } from "../../../../util/documentation-url"; import { showEnergySettingsSolarDialog } from "../dialogs/show-dialogs-energy"; +import "./ha-energy-validation-result"; import { energyCardStyles } from "./styles"; @customElement("ha-energy-solar-settings") @@ -28,10 +30,23 @@ export class EnergySolarSettings extends LitElement { @property({ attribute: false }) public preferences!: EnergyPreferences; - protected render(): TemplateResult { - const types = energySourcesByType(this.preferences); + @property({ attribute: false }) + public validationResult?: EnergyPreferencesValidation; - const solarSources = types.solar || []; + protected render(): TemplateResult { + const solarSources: SolarSourceTypeEnergyPreference[] = []; + const solarValidation: EnergyValidationIssue[][] = []; + + this.preferences.energy_sources.forEach((source, idx) => { + if (source.type !== "solar") { + return; + } + solarSources.push(source); + + if (this.validationResult) { + solarValidation.push(this.validationResult.energy_sources[idx]); + } + }); return html` @@ -55,6 +70,16 @@ export class EnergySolarSettings extends LitElement { )}

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

Solar production

${solarSources.map((source) => { const entityState = this.hass.states[source.stat_energy_from]; diff --git a/src/panels/config/energy/components/ha-energy-validation-result.ts b/src/panels/config/energy/components/ha-energy-validation-result.ts new file mode 100644 index 0000000000..344b6d85b3 --- /dev/null +++ b/src/panels/config/energy/components/ha-energy-validation-result.ts @@ -0,0 +1,114 @@ +import { mdiAlertOutline } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { groupBy } from "../../../../common/util/group-by"; +import "../../../../components/ha-svg-icon"; +import { EnergyValidationIssue } from "../../../../data/energy"; +import { HomeAssistant } from "../../../../types"; + +@customElement("ha-energy-validation-result") +class EnergyValidationMessage extends LitElement { + @property({ attribute: false }) + public hass!: HomeAssistant; + + @property() + public issues!: EnergyValidationIssue[]; + + public render() { + if (this.issues.length === 0) { + return html``; + } + + const grouped = groupBy(this.issues, (issue) => issue.type); + + return Object.entries(grouped).map( + ([issueType, gIssues]) => html` +
+
+ +
+
+
+ ${this.hass.localize( + `ui.panel.config.energy.validation.issues.${issueType}.title` + ) || issueType} +
+ + ${this.hass.localize( + `ui.panel.config.energy.validation.issues.${issueType}.description` + )} + ${issueType === "entity_not_tracked" + ? html` + (${this.hass.localize( + "ui.panel.config.common.learn_more" + )}) + ` + : ""} + +
    + ${gIssues.map( + (issue) => + html`
  • + ${issue.identifier}${issue.value + ? html` (${issue.value})` + : ""} +
  • ` + )} +
+
+
+ ` + ); + } + + static styles = css` + .issue-type { + position: relative; + padding: 4px; + display: flex; + margin: 4px 0; + } + .issue-type::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: var(--warning-color); + opacity: 0.12; + pointer-events: none; + content: ""; + border-radius: 4px; + } + .icon { + margin: 4px 8px; + width: 24px; + color: var(--warning-color); + } + .content { + padding-right: 4px; + } + .title { + font-weight: bold; + margin-top: 5px; + } + ul { + padding-left: 24px; + margin: 4px 0; + } + a { + color: var(--primary-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-energy-validation-result": EnergyValidationMessage; + } +} diff --git a/src/panels/config/energy/ha-config-energy.ts b/src/panels/config/energy/ha-config-energy.ts index 96a98e1251..bd1efaa2ab 100644 --- a/src/panels/config/energy/ha-config-energy.ts +++ b/src/panels/config/energy/ha-config-energy.ts @@ -1,7 +1,12 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import "../../../components/ha-svg-icon"; -import { EnergyPreferences, getEnergyPreferences } from "../../../data/energy"; +import { + EnergyPreferences, + EnergyPreferencesValidation, + getEnergyPreferences, + getEnergyPreferenceValidation, +} from "../../../data/energy"; import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-tabs-subpage"; import { haStyle } from "../../../resources/styles"; @@ -34,6 +39,8 @@ class HaConfigEnergy extends LitElement { @state() private _preferences?: EnergyPreferences; + @state() private _validationResult?: EnergyPreferencesValidation; + @state() private _error?: string; protected firstUpdated() { @@ -76,16 +83,19 @@ class HaConfigEnergy extends LitElement { @@ -104,6 +115,7 @@ class HaConfigEnergy extends LitElement { } private async _fetchConfig() { + const validationPromise = getEnergyPreferenceValidation(this.hass); try { this._preferences = await getEnergyPreferences(this.hass); } catch (e) { @@ -113,10 +125,21 @@ class HaConfigEnergy extends LitElement { this._error = e.message; } } + try { + this._validationResult = await validationPromise; + } catch (e) { + this._error = e.message; + } } - private _prefsChanged(ev: CustomEvent) { + private async _prefsChanged(ev: CustomEvent) { this._preferences = ev.detail.value; + this._validationResult = undefined; + try { + this._validationResult = await getEnergyPreferenceValidation(this.hass); + } catch (e) { + this._error = e.message; + } } static get styles(): CSSResultGroup { diff --git a/src/translations/en.json b/src/translations/en.json index 321ca5db0c..300d5e60f3 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -934,7 +934,8 @@ "common": { "editor": { "confirm_unsaved": "You have unsaved changes. Are you sure you want to leave?" - } + }, + "learn_more": "Learn more" }, "areas": { "caption": "Areas", @@ -1079,6 +1080,42 @@ "dialog": { "selected_stat_intro": "Select the entity that represents the device energy usage." } + }, + "validation": { + "issues": { + "entity_not_defined": { + "title": "Entity not defined", + "description": "Check the integration or your configuration that provides:" + }, + "recorder_untracked": { + "title": "Entity not tracked", + "description": "The recorder has been configured to exclude these configured entities:" + }, + "entity_unavailable": { + "title": "Entity unavailable", + "description": "The state of these configured entities are currently not available:" + }, + "entity_state_non_numeric": { + "title": "Entity has non-numeric state", + "description": "The following entities have a state that cannot be parsed as a number:" + }, + "entity_negative_state": { + "title": "Entity has a negative state", + "description": "The following entities have a negative state while a positive state is expected:" + }, + "entity_unexpected_unit_energy": { + "title": "Unexpected unit of measurement", + "description": "The following entities do not have expected units of measurement kWh or Wh:" + }, + "entity_unexpected_unit_price": { + "title": "Unexpected unit of measurement", + "description": "The following entities do not have expected units of measurement that ends with /kWh or /Wh:" + }, + "entity_unexpected_state_class_total_increasing": { + "title": "Unexpected state class", + "description": "The following entities do not have expected state class \"total_increasing\"" + } + } } }, "helpers": {