diff --git a/src/common/entity/state_color.ts b/src/common/entity/state_color.ts index 5223076767..d1c8e25d78 100644 --- a/src/common/entity/state_color.ts +++ b/src/common/entity/state_color.ts @@ -36,6 +36,7 @@ const STATE_COLORED_DOMAIN = new Set([ "timer", "update", "vacuum", + "water_heater", ]); export const stateColorCss = (stateObj: HassEntity, state?: string) => { diff --git a/src/data/water_heater.ts b/src/data/water_heater.ts new file mode 100644 index 0000000000..ebb656b7a5 --- /dev/null +++ b/src/data/water_heater.ts @@ -0,0 +1,70 @@ +import { + mdiFinance, + mdiFireCircle, + mdiHeatWave, + mdiLeaf, + mdiLightningBolt, + mdiPower, + mdiRocketLaunch, +} from "@mdi/js"; +import type { + HassEntityAttributeBase, + HassEntityBase, +} from "home-assistant-js-websocket"; + +export const enum WaterHeaterEntityFeature { + TARGET_TEMPERATURE = 1, + OPERATION_MODE = 2, + AWAY_MODE = 4, +} + +export type OperationMode = + | "eco" + | "electric" + | "performance" + | "high_demand" + | "heat_pump" + | "gas" + | "off"; + +export type WaterHeaterEntity = HassEntityBase & { + attributes: HassEntityAttributeBase & { + target_temp_step?: number; + min_temp: number; + max_temp: number; + current_temperature?: number; + temperature?: number; + operation_mode: OperationMode; + operation_list: OperationMode[]; + away_mode?: "on" | "off"; + }; +}; + +const hvacModeOrdering: { [key in OperationMode]: number } = { + eco: 1, + electric: 2, + performance: 3, + high_demand: 4, + heat_pump: 5, + gas: 6, + off: 7, +}; + +export const compareWaterHeaterOperationMode = ( + mode1: OperationMode, + mode2: OperationMode +) => hvacModeOrdering[mode1] - hvacModeOrdering[mode2]; + +export const WATER_HEATER_OPERATION_MODE_ICONS: Record = + { + eco: mdiLeaf, + electric: mdiLightningBolt, + performance: mdiRocketLaunch, + high_demand: mdiFinance, + heat_pump: mdiHeatWave, + gas: mdiFireCircle, + off: mdiPower, + }; + +export const computeOperationModeIcon = (mode: OperationMode) => + WATER_HEATER_OPERATION_MODE_ICONS[mode]; diff --git a/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts b/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts new file mode 100644 index 0000000000..29a40a23f4 --- /dev/null +++ b/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts @@ -0,0 +1,327 @@ +import { mdiMinus, mdiPlus } from "@mdi/js"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + css, + html, + nothing, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { stateColorCss } from "../../../../common/entity/state_color"; +import { supportsFeature } from "../../../../common/entity/supports-feature"; +import { clamp } from "../../../../common/number/clamp"; +import { formatNumber } from "../../../../common/number/format_number"; +import { debounce } from "../../../../common/util/debounce"; +import "../../../../components/ha-control-circular-slider"; +import "../../../../components/ha-outlined-icon-button"; +import "../../../../components/ha-svg-icon"; +import { UNAVAILABLE } from "../../../../data/entity"; +import { + WaterHeaterEntity, + WaterHeaterEntityFeature, +} from "../../../../data/water_heater"; +import { HomeAssistant } from "../../../../types"; + +@customElement("ha-more-info-water_heater-temperature") +export class HaMoreInfoWaterHeaterTemperature extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public stateObj!: WaterHeaterEntity; + + @state() private _targetTemperature?: number; + + protected willUpdate(changedProp: PropertyValues): void { + super.willUpdate(changedProp); + if (changedProp.has("stateObj")) { + this._targetTemperature = this.stateObj.attributes.temperature; + } + } + + private get _step() { + return ( + this.stateObj.attributes.target_temp_step || + (this.hass.config.unit_system.temperature.indexOf("F") === -1 ? 0.5 : 1) + ); + } + + private get _min() { + return this.stateObj.attributes.min_temp; + } + + private get _max() { + return this.stateObj.attributes.max_temp; + } + + private _valueChanged(ev: CustomEvent) { + const value = (ev.detail as any).value; + if (isNaN(value)) return; + this._targetTemperature = value; + this._callService(); + } + + private _valueChanging(ev: CustomEvent) { + const value = (ev.detail as any).value; + if (isNaN(value)) return; + this._targetTemperature = value; + } + + private _debouncedCallService = debounce(() => this._callService(), 1000); + + private _callService() { + this.hass.callService("water_heater", "set_temperature", { + entity_id: this.stateObj!.entity_id, + temperature: this._targetTemperature, + }); + } + + private _handleButton(ev) { + const step = ev.currentTarget.step as number; + + let temp = this._targetTemperature ?? this._min; + temp += step; + temp = clamp(temp, this._min, this._max); + + this._targetTemperature = temp; + this._debouncedCallService(); + } + + private _renderLabel() { + return html` +

+ ${this.hass.localize( + "ui.dialogs.more_info_control.water_heater.target" + )} +

+ `; + } + + private _renderButtons() { + return html` +
+ + + + + + +
+ `; + } + + private _renderTargetTemperature(temperature: number) { + const digits = this._step.toString().split(".")?.[1]?.length ?? 0; + const formatted = formatNumber(temperature, this.hass.locale, { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }); + const [temperatureInteger] = formatted.includes(".") + ? formatted.split(".") + : formatted.split(","); + + const temperatureDecimal = formatted.replace(temperatureInteger, ""); + + return html` +

+ + + ${this.stateObj.attributes.temperature} + ${this.hass.config.unit_system.temperature} + +

+ `; + } + + protected render() { + const supportsTargetTemperature = supportsFeature( + this.stateObj, + WaterHeaterEntityFeature.TARGET_TEMPERATURE + ); + + const mainColor = stateColorCss(this.stateObj); + + if (supportsTargetTemperature && this._targetTemperature != null) { + return html` +
+ + +
+
${this._renderLabel()}
+
+ ${this._renderTargetTemperature(this._targetTemperature)} +
+
+ ${this._renderButtons()} +
+ `; + } + + return html` +
+ + +
+ `; + } + + static get styles(): CSSResultGroup { + return css` + /* Layout */ + .container { + position: relative; + } + .info { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.1px; + } + .info * { + margin: 0; + pointer-events: auto; + } + /* Elements */ + .temperature-container { + margin-bottom: 30px; + } + .temperature { + display: inline-flex; + font-size: 58px; + line-height: 64px; + letter-spacing: -0.25px; + margin: 0; + } + .temperature span { + display: inline-flex; + } + .temperature .unit { + font-size: 24px; + line-height: 40px; + } + .temperature .decimal { + font-size: 24px; + line-height: 40px; + align-self: flex-end; + margin-right: -18px; + } + .action-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 200px; + height: 48px; + margin-bottom: 6px; + } + .action { + font-weight: 500; + text-align: center; + color: var(--action-color, inherit); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .buttons { + position: absolute; + bottom: 10px; + left: 0; + right: 0; + margin: 0 auto; + width: 120px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + .buttons ha-outlined-icon-button { + --md-outlined-icon-button-container-size: 48px; + --md-outlined-icon-button-icon-size: 24px; + } + /* Accessibility */ + .visually-hidden { + position: absolute; + overflow: hidden; + clip: rect(0 0 0 0); + height: 1px; + width: 1px; + margin: -1px; + padding: 0; + border: 0; + } + /* Slider */ + ha-control-circular-slider { + --control-circular-slider-color: var( + --main-color, + var(--disabled-color) + ); + } + ha-control-circular-slider::after { + display: block; + content: ""; + position: absolute; + top: -10%; + left: -10%; + right: -10%; + bottom: -10%; + background: radial-gradient( + 50% 50% at 50% 50%, + var(--action-color, transparent) 0%, + transparent 100% + ); + opacity: 0.15; + pointer-events: none; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-more-info-water_heater-temperature": HaMoreInfoWaterHeaterTemperature; + } +} diff --git a/src/dialogs/more-info/const.ts b/src/dialogs/more-info/const.ts index 580faa2e40..d77742b5be 100644 --- a/src/dialogs/more-info/const.ts +++ b/src/dialogs/more-info/const.ts @@ -27,6 +27,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [ "lock", "siren", "switch", + "water_heater", ]; /** Domains with separate more info dialog. */ export const DOMAINS_WITH_MORE_INFO = [ diff --git a/src/dialogs/more-info/controls/more-info-water_heater.js b/src/dialogs/more-info/controls/more-info-water_heater.js deleted file mode 100644 index bc7c673d41..0000000000 --- a/src/dialogs/more-info/controls/more-info-water_heater.js +++ /dev/null @@ -1,235 +0,0 @@ -import "@material/mwc-list/mwc-list-item"; -import "@polymer/iron-flex-layout/iron-flex-layout-classes"; -import { timeOut } from "@polymer/polymer/lib/utils/async"; -import { Debouncer } from "@polymer/polymer/lib/utils/debounce"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { featureClassNames } from "../../../common/entity/feature_class_names"; -import { supportsFeature } from "../../../common/entity/supports-feature"; -import "../../../components/ha-select"; -import "../../../components/ha-switch"; -import "../../../components/ha-water_heater-control"; -import { EventsMixin } from "../../../mixins/events-mixin"; -import LocalizeMixin from "../../../mixins/localize-mixin"; - -/* - * @appliesMixin EventsMixin - * @appliesMixin LocalizeMixin - */ -class MoreInfoWaterHeater extends LocalizeMixin(EventsMixin(PolymerElement)) { - static get template() { - return html` - - - -
-
-
-
- [[localize('ui.card.water_heater.target_temperature')]] -
- -
-
- - - - -
- `; - } - - static get properties() { - return { - hass: { - type: Object, - }, - - stateObj: { - type: Object, - observer: "stateObjChanged", - }, - - awayToggleChecked: Boolean, - }; - } - - stateObjChanged(newVal, oldVal) { - if (newVal) { - this.setProperties({ - awayToggleChecked: newVal.attributes.away_mode === "on", - }); - } - - if (oldVal) { - this._debouncer = Debouncer.debounce( - this._debouncer, - timeOut.after(500), - () => { - this.fire("iron-resize"); - } - ); - } - } - - computeTemperatureStepSize(hass, stateObj) { - if (stateObj.attributes.target_temp_step) { - return stateObj.attributes.target_temp_step; - } - if (hass.config.unit_system.temperature.indexOf("F") !== -1) { - return 1; - } - return 0.5; - } - - supportsTemperatureControls(stateObj) { - return this.supportsTemperature(stateObj); - } - - supportsTemperature(stateObj) { - return ( - supportsFeature(stateObj, 1) && - typeof stateObj.attributes.temperature === "number" - ); - } - - supportsOperationMode(stateObj) { - return supportsFeature(stateObj, 2); - } - - supportsAwayMode(stateObj) { - return supportsFeature(stateObj, 4); - } - - computeClassNames(stateObj) { - const _featureClassNames = { - 1: "has-target_temperature", - 2: "has-operation_mode", - 4: "has-away_mode", - }; - - const classes = [featureClassNames(stateObj, _featureClassNames)]; - - classes.push("more-info-water_heater"); - - return classes.join(" "); - } - - targetTemperatureChanged(ev) { - const temperature = ev.target.value; - if (temperature === this.stateObj.attributes.temperature) return; - this.callServiceHelper("set_temperature", { temperature: temperature }); - } - - awayToggleChanged(ev) { - const oldVal = this.stateObj.attributes.away_mode === "on"; - const newVal = ev.target.checked; - if (oldVal === newVal) return; - this.callServiceHelper("set_away_mode", { away_mode: newVal }); - } - - handleOperationmodeChanged(ev) { - const oldVal = this.stateObj.attributes.operation_mode; - const newVal = ev.target.value; - if (!newVal || oldVal === newVal) return; - this.callServiceHelper("set_operation_mode", { - operation_mode: newVal, - }); - } - - stopPropagation(ev) { - ev.stopPropagation(); - } - - callServiceHelper(service, data) { - // We call stateChanged after a successful call to re-sync the inputs - // with the state. It will be out of sync if our service call did not - // result in the entity to be turned on. Since the state is not changing, - // the resync is not called automatic. - /* eslint-disable no-param-reassign */ - data.entity_id = this.stateObj.entity_id; - /* eslint-enable no-param-reassign */ - this.hass.callService("water_heater", service, data).then(() => { - this.stateObjChanged(this.stateObj); - }); - } - - _localizeOperationMode(localize, mode) { - return ( - localize(`component.water_heater.entity_component._.state.${mode}`) || - mode - ); - } -} - -customElements.define("more-info-water_heater", MoreInfoWaterHeater); diff --git a/src/dialogs/more-info/controls/more-info-water_heater.ts b/src/dialogs/more-info/controls/more-info-water_heater.ts new file mode 100644 index 0000000000..874e851b8e --- /dev/null +++ b/src/dialogs/more-info/controls/more-info-water_heater.ts @@ -0,0 +1,262 @@ +import { mdiAccount, mdiAccountArrowRight, mdiWaterBoiler } from "@mdi/js"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { stopPropagation } from "../../../common/dom/stop_propagation"; +import { supportsFeature } from "../../../common/entity/supports-feature"; +import { formatNumber } from "../../../common/number/format_number"; +import "../../../components/ha-control-select-menu"; +import "../../../components/ha-list-item"; +import { + OperationMode, + WaterHeaterEntity, + WaterHeaterEntityFeature, + compareWaterHeaterOperationMode, + computeOperationModeIcon, +} from "../../../data/water_heater"; +import { HomeAssistant } from "../../../types"; +import { moreInfoControlStyle } from "../components/ha-more-info-control-style"; +import "../components/water_heater/ha-more-info-water_heater-temperature"; + +@customElement("more-info-water_heater") +class MoreInfoWaterHeater extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public stateObj?: WaterHeaterEntity; + + protected render() { + if (!this.stateObj) { + return nothing; + } + + const stateObj = this.stateObj; + + const supportOperationMode = supportsFeature( + stateObj, + WaterHeaterEntityFeature.OPERATION_MODE + ); + + const supportAwayMode = supportsFeature( + stateObj, + WaterHeaterEntityFeature.AWAY_MODE + ); + + const currentTemperature = this.stateObj.attributes.current_temperature; + + return html` +
+ ${currentTemperature != null + ? html` +
+

+ ${this.hass.formatEntityAttributeName( + this.stateObj, + "current_temperature" + )} +

+

+ ${formatNumber(currentTemperature, this.hass.locale)} + ${this.hass.config.unit_system.temperature} +

+
+ ` + : nothing} +
+
+ +
+
+
+ ${supportOperationMode && stateObj.attributes.operation_list + ? html` + + + ${stateObj.attributes.operation_list + .concat() + .sort(compareWaterHeaterOperationMode) + .map( + (mode) => html` + + + ${this.hass.formatEntityState(stateObj, mode)} + + ` + )} + + ` + : nothing} + ${supportAwayMode + ? html` + + + + + ${this.hass.localize("state.default.on")} + + + + ${this.hass.localize("state.default.off")} + + + ` + : nothing} +
+
+ `; + } + + private _handleOperationModeChanged(ev) { + const newVal = ev.target.value; + this._callServiceHelper( + this.stateObj!.state, + newVal, + "set_operation_mode", + { + operation_mode: newVal, + } + ); + } + + private _handleAwayModeChanged(ev) { + const newVal = ev.target.value === "on"; + const oldVal = this.stateObj!.attributes.away_mode === "on"; + + this._callServiceHelper(oldVal, newVal, "set_away_mode", { + away_mode: newVal, + }); + } + + private async _callServiceHelper( + oldVal: unknown, + newVal: unknown, + service: string, + data: { + entity_id?: string; + [key: string]: unknown; + } + ) { + if (oldVal === newVal) { + return; + } + + data.entity_id = this.stateObj!.entity_id; + const curState = this.stateObj; + + await this.hass.callService("water_heater", service, data); + + // We reset stateObj to re-sync the inputs with the state. It will be out + // of sync if our service call did not result in the entity to be turned + // on. Since the state is not changing, the resync is not called automatic. + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + + // No need to resync if we received a new state. + if (this.stateObj !== curState) { + return; + } + + this.stateObj = undefined; + await this.updateComplete; + // Only restore if not set yet by a state change + if (this.stateObj === undefined) { + this.stateObj = curState; + } + } + + static get styles(): CSSResultGroup { + return [ + moreInfoControlStyle, + css` + :host { + color: var(--primary-text-color); + } + + .current { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + text-align: center; + margin-bottom: 40px; + } + + .current div { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + flex: 1; + } + + .current p { + margin: 0; + text-align: center; + color: var(--primary-text-color); + } + + .current .label { + opacity: 0.8; + font-size: 14px; + line-height: 16px; + letter-spacing: 0.4px; + margin-bottom: 4px; + } + + .current .value { + font-size: 22px; + font-weight: 500; + line-height: 28px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "more-info-water_heater": MoreInfoWaterHeater; + } +} diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts index 7536f9f87c..b0f80d7d41 100644 --- a/src/resources/ha-style.ts +++ b/src/resources/ha-style.ts @@ -176,6 +176,12 @@ documentContainer.innerHTML = ` --state-sensor-battery-high-color: var(--green-color); --state-sensor-battery-low-color: var(--red-color); --state-sensor-battery-medium-color: var(--orange-color); + --state-water_heater-eco-color: var(--green-color); + --state-water_heater-electric-color: var(--orange-color); + --state-water_heater-gas-color: var(--orange-color); + --state-water_heater-heat_pump-color: var(--orange-color); + --state-water_heater-high_demand-color: var(--deep-orange-color); + --state-water_heater-performance-color: var(--deep-orange-color); /* history colors */ --history-unavailable-color: transparent; diff --git a/src/translations/en.json b/src/translations/en.json index 8d7af1e857..b105f95d70 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1005,6 +1005,9 @@ "humidifier": { "target_label": "[%key:ui::dialogs::more_info_control::climate::target_label%]", "target": "[%key:ui::dialogs::more_info_control::climate::target%]" + }, + "water_heater": { + "target": "[%key:ui::dialogs::more_info_control::climate::target%]" } }, "entity_registry": {