diff --git a/src/common/const.js b/src/common/const.js
index 2f604bd000..6d7ff4f1c8 100644
--- a/src/common/const.js
+++ b/src/common/const.js
@@ -21,6 +21,7 @@ export const DOMAINS_WITH_CARD = [
'script',
'timer',
'vacuum',
+ 'water_heater',
'weblink',
];
@@ -43,6 +44,7 @@ export const DOMAINS_WITH_MORE_INFO = [
'sun',
'updater',
'vacuum',
+ 'water_heater',
'weather'
];
diff --git a/src/common/entity/domain_icon.js b/src/common/entity/domain_icon.js
index bfc64952bd..f4e6daaa62 100644
--- a/src/common/entity/domain_icon.js
+++ b/src/common/entity/domain_icon.js
@@ -39,6 +39,7 @@ const fixedIcons = {
timer: 'hass:timer',
updater: 'hass:cloud-upload',
vacuum: 'hass:robot-vacuum',
+ water_heater: 'hass:thermometer',
weblink: 'hass:open-in-new',
};
diff --git a/src/components/ha-water_heater-control.js b/src/components/ha-water_heater-control.js
new file mode 100644
index 0000000000..22af9ad0b4
--- /dev/null
+++ b/src/components/ha-water_heater-control.js
@@ -0,0 +1,131 @@
+import '@polymer/iron-flex-layout/iron-flex-layout-classes.js';
+import '@polymer/paper-icon-button/paper-icon-button.js';
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+
+import EventsMixin from '../mixins/events-mixin.js';
+
+/*
+ * @appliesMixin EventsMixin
+ */
+class HaWaterHeaterControl extends EventsMixin(PolymerElement) {
+ static get template() {
+ return html`
+
+
+
+
+
+ [[value]] [[units]]
+
+
+`;
+ }
+
+ static get properties() {
+ return {
+ value: {
+ type: Number,
+ observer: 'valueChanged'
+ },
+ units: {
+ type: String,
+ },
+ min: {
+ type: Number,
+ },
+ max: {
+ type: Number,
+ },
+ step: {
+ type: Number,
+ value: 1,
+ },
+ };
+ }
+
+ temperatureStateInFlux(inFlux) {
+ this.$.target_temperature.classList.toggle('in-flux', inFlux);
+ }
+
+ incrementValue() {
+ const newval = this.value + this.step;
+ if (this.value < this.max) {
+ this.last_changed = Date.now();
+ this.temperatureStateInFlux(true);
+ }
+ if (newval <= this.max) {
+ // If no initial target_temp
+ // this forces control to start
+ // from the min configured instead of 0
+ if (newval <= this.min) {
+ this.value = this.min;
+ } else {
+ this.value = newval;
+ }
+ } else {
+ this.value = this.max;
+ }
+ }
+
+ decrementValue() {
+ const newval = this.value - this.step;
+ if (this.value > this.min) {
+ this.last_changed = Date.now();
+ this.temperatureStateInFlux(true);
+ }
+ if (newval >= this.min) {
+ this.value = newval;
+ } else {
+ this.value = this.min;
+ }
+ }
+
+ valueChanged() {
+ // when the last_changed timestamp is changed,
+ // trigger a potential event fire in
+ // the future, as long as last changed is far enough in the
+ // past.
+ if (this.last_changed) {
+ window.setTimeout(() => {
+ const now = Date.now();
+ if (now - this.last_changed >= 2000) {
+ this.fire('change');
+ this.temperatureStateInFlux(false);
+ this.last_changed = null;
+ }
+ }, 2010);
+ }
+ }
+}
+
+customElements.define('ha-water_heater-control', HaWaterHeaterControl);
diff --git a/src/components/ha-water_heater-state.js b/src/components/ha-water_heater-state.js
new file mode 100644
index 0000000000..5557e7e431
--- /dev/null
+++ b/src/components/ha-water_heater-state.js
@@ -0,0 +1,74 @@
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+
+import LocalizeMixin from '../mixins/localize-mixin.js';
+
+/*
+ * @appliesMixin LocalizeMixin
+ */
+class HaWaterHeaterState extends LocalizeMixin(PolymerElement) {
+ static get template() {
+ return html`
+
+
+
+
+ [[_localizeState(stateObj.state)]]
+
+ [[computeTarget(hass, stateObj)]]
+
+
+
+
+ [[localize('ui.card.water_heater.currently')]]: [[currentStatus]]
+
+
+`;
+ }
+
+ static get properties() {
+ return {
+ hass: Object,
+ stateObj: Object,
+ };
+ }
+
+ computeTarget(hass, stateObj) {
+ if (!hass || !stateObj) return null;
+ // We're using "!= null" on purpose so that we match both null and undefined.
+ if (stateObj.attributes.target_temp_low != null
+ && stateObj.attributes.target_temp_high != null) {
+ return `${stateObj.attributes.target_temp_low} - ${stateObj.attributes.target_temp_high} ${hass.config.unit_system.temperature}`;
+ }
+ if (stateObj.attributes.temperature != null) {
+ return `${stateObj.attributes.temperature} ${hass.config.unit_system.temperature}`;
+ }
+
+ return '';
+ }
+
+ _localizeState(state) {
+ return this.localize(`state.water_heater.${state}`) || state;
+ }
+}
+customElements.define('ha-water_heater-state', HaWaterHeaterState);
diff --git a/src/components/state-history-chart-line.js b/src/components/state-history-chart-line.js
index ac0e661de8..751675be0b 100644
--- a/src/components/state-history-chart-line.js
+++ b/src/components/state-history-chart-line.js
@@ -137,7 +137,7 @@ class StateHistoryChartLine extends LocalizeMixin(PolymerElement) {
});
}
- if (domain === 'thermostat' || domain === 'climate') {
+ if (domain === 'thermostat' || domain === 'climate' || domain === 'water_heater') {
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
diff --git a/src/data/ha-state-history-data.js b/src/data/ha-state-history-data.js
index c5830c7349..47d6f0da0c 100644
--- a/src/data/ha-state-history-data.js
+++ b/src/data/ha-state-history-data.js
@@ -10,7 +10,7 @@ import LocalizeMixin from '../mixins/localize-mixin.js';
const RECENT_THRESHOLD = 60000; // 1 minute
const RECENT_CACHE = {};
-const DOMAINS_USE_LAST_UPDATED = ['thermostat', 'climate'];
+const DOMAINS_USE_LAST_UPDATED = ['thermostat', 'climate', 'water_heater'];
const LINE_ATTRIBUTES_TO_KEEP = ['temperature', 'current_temperature', 'target_temp_low', 'target_temp_high'];
const stateHistoryCache = {};
@@ -33,6 +33,8 @@ function computeHistory(hass, stateHistory, localize, language) {
unit = stateWithUnit.attributes.unit_of_measurement;
} else if (computeStateDomain(stateInfo[0]) === 'climate') {
unit = hass.config.unit_system.temperature;
+ } else if (computeStateDomain(stateInfo[0]) === 'water_heater') {
+ unit = hass.config.unit_system.temperature;
}
if (!unit) {
diff --git a/src/dialogs/more-info/controls/more-info-content.js b/src/dialogs/more-info/controls/more-info-content.js
index d2ffe21cff..4e1f1b340b 100644
--- a/src/dialogs/more-info/controls/more-info-content.js
+++ b/src/dialogs/more-info/controls/more-info-content.js
@@ -18,6 +18,7 @@ import './more-info-script.js';
import './more-info-sun.js';
import './more-info-updater.js';
import './more-info-vacuum.js';
+import './more-info-water_heater.js';
import './more-info-weather.js';
import stateMoreInfoType from '../../../common/entity/state_more_info_type.js';
diff --git a/src/dialogs/more-info/controls/more-info-water_heater.js b/src/dialogs/more-info/controls/more-info-water_heater.js
new file mode 100644
index 0000000000..24b152d1e9
--- /dev/null
+++ b/src/dialogs/more-info/controls/more-info-water_heater.js
@@ -0,0 +1,244 @@
+import '@polymer/iron-flex-layout/iron-flex-layout-classes.js';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu.js';
+import '@polymer/paper-item/paper-item.js';
+import '@polymer/paper-listbox/paper-listbox.js';
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import { timeOut } from '@polymer/polymer/lib/utils/async.js';
+import { Debouncer } from '@polymer/polymer/lib/utils/debounce.js';
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+
+import '../../../components/ha-water_heater-control.js';
+import '../../../components/ha-paper-slider.js';
+
+import featureClassNames from '../../../common/entity/feature_class_names';
+
+import EventsMixin from '../../../mixins/events-mixin.js';
+import LocalizeMixin from '../../../mixins/localize-mixin.js';
+
+/*
+ * @appliesMixin EventsMixin
+ * @appliesMixin LocalizeMixin
+ */
+class MoreInfoWaterHeater extends LocalizeMixin(EventsMixin(PolymerElement)) {
+ static get template() {
+ return html`
+
+
+
+
+
+
+
+
[[localize('ui.card.water_heater.target_temperature')]]
+
+
+
+
+
+
+
+
+
+
+
+
+
+ [[_localizeOperationMode(localize, item)]]
+
+
+
+
+
+
+
+
+
+
+
[[localize('ui.card.water_heater.away_mode')]]
+
+
+
+
+
+
+`;
+ }
+
+ static get properties() {
+ return {
+ hass: {
+ type: Object,
+ },
+
+ stateObj: {
+ type: Object,
+ observer: 'stateObjChanged',
+ },
+
+ operationIndex: {
+ type: Number,
+ value: -1,
+ observer: 'handleOperationmodeChanged',
+ },
+ 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');
+ }
+ );
+ }
+ }
+
+ handleOperationListUpdate() {
+ // force polymer to recognize selected item change (to update actual label)
+ this.operationIndex = -1;
+ if (this.stateObj.attributes.operation_list) {
+ this.operationIndex = this.stateObj.attributes.operation_list.indexOf(
+ this.stateObj.attributes.operation_mode
+ );
+ }
+ }
+
+ 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 (stateObj.attributes.supported_features & 1) !== 0
+ && typeof stateObj.attributes.temperature === 'number';
+ }
+
+ supportsOperationMode(stateObj) {
+ return (stateObj.attributes.supported_features & 2) !== 0;
+ }
+
+ supportsAwayMode(stateObj) {
+ return (stateObj.attributes.supported_features & 4) !== 0;
+ }
+
+ computeClassNames(stateObj) {
+ const _featureClassNames = {
+ 1: 'has-target_temperature',
+ 2: 'has-operation_mode',
+ 4: 'has-away_mode'
+ };
+
+
+ var 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(operationIndex) {
+ // Selected Option will transition to '' before transitioning to new value
+ if (operationIndex === '' || operationIndex === -1) return;
+ const operationInput = this.stateObj.attributes.operation_list[operationIndex];
+ if (operationInput === this.stateObj.attributes.operation_mode) return;
+
+ this.callServiceHelper('set_operation_mode', { operation_mode: operationInput });
+ }
+
+ 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(`state.water_heater.${mode}`) || mode;
+ }
+}
+
+customElements.define('more-info-water_heater', MoreInfoWaterHeater);
diff --git a/src/state-summary/state-card-content.js b/src/state-summary/state-card-content.js
index c183ef3c44..7ec7a84461 100644
--- a/src/state-summary/state-card-content.js
+++ b/src/state-summary/state-card-content.js
@@ -14,6 +14,7 @@ import './state-card-script.js';
import './state-card-timer.js';
import './state-card-toggle.js';
import './state-card-vacuum.js';
+import './state-card-water_heater.js';
import './state-card-weblink.js';
import stateCardType from '../common/entity/state_card_type.js';
diff --git a/src/state-summary/state-card-water_heater.js b/src/state-summary/state-card-water_heater.js
new file mode 100644
index 0000000000..9a9022dc3a
--- /dev/null
+++ b/src/state-summary/state-card-water_heater.js
@@ -0,0 +1,52 @@
+import '@polymer/iron-flex-layout/iron-flex-layout-classes.js';
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+
+import '../components/entity/state-info.js';
+import '../components/ha-water_heater-state.js';
+
+class StateCardWaterHeater extends PolymerElement {
+ static get template() {
+ return html`
+
+
+
+
+ ${this.stateInfoTemplate}
+
+
+`;
+ }
+
+ static get stateInfoTemplate() {
+ return html`
+
+`;
+ }
+
+ static get properties() {
+ return {
+ hass: Object,
+ stateObj: Object,
+ inDialog: {
+ type: Boolean,
+ value: false,
+ }
+ };
+ }
+}
+customElements.define('state-card-water_heater', StateCardWaterHeater);
diff --git a/src/translations/en.json b/src/translations/en.json
index b8408b8174..7710751d4e 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -401,6 +401,13 @@
"turn_off": "Turn off"
}
},
+ "water_heater": {
+ "currently": "Currently",
+ "on_off": "On / off",
+ "target_temperature": "Target temperature",
+ "operation": "Operation",
+ "away_mode": "Away mode"
+ },
"weather": {
"attributes": {
"air_pressure": "Air pressure",
diff --git a/src/util/hass-attributes-util.js b/src/util/hass-attributes-util.js
index 466e7987c3..4770f05378 100644
--- a/src/util/hass-attributes-util.js
+++ b/src/util/hass-attributes-util.js
@@ -81,7 +81,7 @@ hassAttributeUtil.LOGIC_STATE_ATTRIBUTES = hassAttributeUtil.LOGIC_STATE_ATTRIBU
hidden: { type: 'boolean', description: 'Hide from UI' },
assumed_state: {
type: 'boolean',
- domains: ['switch', 'light', 'cover', 'climate', 'fan', 'group']
+ domains: ['switch', 'light', 'cover', 'climate', 'fan', 'group', 'water_heater']
},
initial_state: {
type: 'string',