Update water heater more info (#17544)

This commit is contained in:
Paul Bottein 2023-08-14 09:31:06 +02:00 committed by GitHub
parent 25b4d91d72
commit aa85b87e11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 670 additions and 235 deletions

View File

@ -36,6 +36,7 @@ const STATE_COLORED_DOMAIN = new Set([
"timer",
"update",
"vacuum",
"water_heater",
]);
export const stateColorCss = (stateObj: HassEntity, state?: string) => {

70
src/data/water_heater.ts Normal file
View File

@ -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<OperationMode, string> =
{
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];

View File

@ -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`
<p class="action">
${this.hass.localize(
"ui.dialogs.more_info_control.water_heater.target"
)}
</p>
`;
}
private _renderButtons() {
return html`
<div class="buttons">
<ha-outlined-icon-button
.step=${-this._step}
@click=${this._handleButton}
>
<ha-svg-icon .path=${mdiMinus}></ha-svg-icon>
</ha-outlined-icon-button>
<ha-outlined-icon-button
.step=${this._step}
@click=${this._handleButton}
>
<ha-svg-icon .path=${mdiPlus}></ha-svg-icon>
</ha-outlined-icon-button>
</div>
`;
}
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`
<p class="temperature">
<span aria-hidden="true">
${temperatureInteger}
${digits !== 0
? html`<span class="decimal">${temperatureDecimal}</span>`
: nothing}
<span class="unit">
${this.hass.config.unit_system.temperature}
</span>
</span>
<span class="visually-hidden">
${this.stateObj.attributes.temperature}
${this.hass.config.unit_system.temperature}
</span>
</p>
`;
}
protected render() {
const supportsTargetTemperature = supportsFeature(
this.stateObj,
WaterHeaterEntityFeature.TARGET_TEMPERATURE
);
const mainColor = stateColorCss(this.stateObj);
if (supportsTargetTemperature && this._targetTemperature != null) {
return html`
<div
class="container"
style=${styleMap({
"--main-color": mainColor,
})}
>
<ha-control-circular-slider
.value=${this._targetTemperature}
.min=${this._min}
.max=${this._max}
.step=${this._step}
.current=${this.stateObj.attributes.current_temperature}
.disabled=${this.stateObj!.state === UNAVAILABLE}
@value-changed=${this._valueChanged}
@value-changing=${this._valueChanging}
>
</ha-control-circular-slider>
<div class="info">
<div class="action-container">${this._renderLabel()}</div>
<div class="temperature-container">
${this._renderTargetTemperature(this._targetTemperature)}
</div>
</div>
${this._renderButtons()}
</div>
`;
}
return html`
<div class="container">
<ha-control-circular-slider
.current=${this.stateObj.attributes.current_temperature}
.min=${this._min}
.max=${this._max}
.step=${this._step}
disabled
>
</ha-control-circular-slider>
</div>
`;
}
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;
}
}

View File

@ -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 = [

View File

@ -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`
<style include="iron-flex"></style>
<style>
:host {
color: var(--primary-text-color);
}
ha-select {
width: 100%;
}
ha-water_heater-control.range-control-left,
ha-water_heater-control.range-control-right {
float: left;
width: 46%;
}
ha-water_heater-control.range-control-left {
margin-right: 4%;
}
ha-water_heater-control.range-control-right {
margin-left: 4%;
}
.single-row {
padding: 8px 0;
}
</style>
<div class$="[[computeClassNames(stateObj)]]">
<div class="container-temperature">
<div class$="[[stateObj.attributes.operation_mode]]">
<div hidden$="[[!supportsTemperatureControls(stateObj)]]">
[[localize('ui.card.water_heater.target_temperature')]]
</div>
<template is="dom-if" if="[[supportsTemperature(stateObj)]]">
<ha-water_heater-control
value="[[stateObj.attributes.temperature]]"
units="[[hass.config.unit_system.temperature]]"
step="[[computeTemperatureStepSize(hass, stateObj)]]"
min="[[stateObj.attributes.min_temp]]"
max="[[stateObj.attributes.max_temp]]"
on-change="targetTemperatureChanged"
>
</ha-water_heater-control>
</template>
</div>
</div>
<template is="dom-if" if="[[supportsOperationMode(stateObj)]]">
<div class="container-operation_list">
<div class="controls">
<ha-select
label="[[localize('ui.card.water_heater.operation')]]"
value="[[stateObj.attributes.operation_mode]]"
on-selected="handleOperationmodeChanged"
fixedMenuPosition
naturalMenuWidth
on-closed="stopPropagation"
>
<template
is="dom-repeat"
items="[[stateObj.attributes.operation_list]]"
>
<mwc-list-item value="[[item]]">
[[_localizeOperationMode(localize, item)]]
</mwc-list-item>
</template>
</ha-select>
</div>
</div>
</template>
<template is="dom-if" if="[[supportsAwayMode(stateObj)]]">
<div class="container-away_mode">
<div class="center horizontal layout single-row">
<div class="flex">
[[localize('ui.card.water_heater.away_mode')]]
</div>
<ha-switch
checked="[[awayToggleChecked]]"
on-change="awayToggleChanged"
>
</ha-switch>
</div>
</div>
</template>
</div>
`;
}
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);

View File

@ -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`
<div class="current">
${currentTemperature != null
? html`
<div>
<p class="label">
${this.hass.formatEntityAttributeName(
this.stateObj,
"current_temperature"
)}
</p>
<p class="value">
${formatNumber(currentTemperature, this.hass.locale)}
${this.hass.config.unit_system.temperature}
</p>
</div>
`
: nothing}
</div>
<div class="controls">
<ha-more-info-water_heater-temperature
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-more-info-water_heater-temperature>
</div>
<div class="secondary-controls">
<div class="secondary-controls-scroll">
${supportOperationMode && stateObj.attributes.operation_list
? html`
<ha-control-select-menu
.label=${this.hass.formatEntityAttributeName(
stateObj,
"operation"
)}
.value=${stateObj.state}
fixedMenuPosition
naturalMenuWidth
@selected=${this._handleOperationModeChanged}
@closed=${stopPropagation}
>
<ha-svg-icon
slot="icon"
.path=${computeOperationModeIcon(
stateObj.state as OperationMode
) ?? mdiWaterBoiler}
></ha-svg-icon>
${stateObj.attributes.operation_list
.concat()
.sort(compareWaterHeaterOperationMode)
.map(
(mode) => html`
<ha-list-item .value=${mode} graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${computeOperationModeIcon(mode)}
></ha-svg-icon>
${this.hass.formatEntityState(stateObj, mode)}
</ha-list-item>
`
)}
</ha-control-select-menu>
`
: nothing}
${supportAwayMode
? html`
<ha-control-select-menu
.label=${this.hass.formatEntityAttributeName(
stateObj,
"away_mode"
)}
.value=${stateObj.attributes.away_mode}
fixedMenuPosition
naturalMenuWidth
@selected=${this._handleAwayModeChanged}
@closed=${stopPropagation}
>
<ha-svg-icon
slot="icon"
.path=${stateObj.attributes.away_mode === "on"
? mdiAccountArrowRight
: mdiAccount}
></ha-svg-icon>
<ha-list-item .value=${"on"} graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${mdiAccountArrowRight}
></ha-svg-icon>
${this.hass.localize("state.default.on")}
</ha-list-item>
<ha-list-item .value=${"off"} graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${mdiAccount}
></ha-svg-icon>
${this.hass.localize("state.default.off")}
</ha-list-item>
</ha-control-select-menu>
`
: nothing}
</div>
</div>
`;
}
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;
}
}

View File

@ -176,6 +176,12 @@ documentContainer.innerHTML = `<custom-style>
--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;

View File

@ -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": {