From 22f2f797b29c37d549e0e5b127e849d5a5740287 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 16 Dec 2018 10:35:58 +0100 Subject: [PATCH] :sparkles: Adds simple thermostat custom lovelace card --- .../lovelace/resources/simple_thermostat.yaml | 3 + .../simple-thermostat/simple-thermostat.js | 450 ++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100755 config/lovelace/resources/simple_thermostat.yaml create mode 100755 config/www/lovelace/cards/simple-thermostat/simple-thermostat.js diff --git a/config/lovelace/resources/simple_thermostat.yaml b/config/lovelace/resources/simple_thermostat.yaml new file mode 100755 index 00000000..578df694 --- /dev/null +++ b/config/lovelace/resources/simple_thermostat.yaml @@ -0,0 +1,3 @@ +--- +url: /local/lovelace/cards/simple-thermostat/simple-thermostat.js?v=1 +type: module diff --git a/config/www/lovelace/cards/simple-thermostat/simple-thermostat.js b/config/www/lovelace/cards/simple-thermostat/simple-thermostat.js new file mode 100755 index 00000000..38696db4 --- /dev/null +++ b/config/www/lovelace/cards/simple-thermostat/simple-thermostat.js @@ -0,0 +1,450 @@ +import { LitElement, html } from 'https://unpkg.com/@polymer/lit-element@^0.6.1/lit-element.js?module'; + +import { Debouncer } from "https://unpkg.com/@polymer/polymer/lib/utils/debounce"; + +function renderStyles () { + return html` + + ` +} + +function formatNumber (number) { + const [int, dec] = String(number).split('.') + return `${int}.${dec || '0'}` +} + +const DEBOUNCE_TIMEOUT = 1000 +const STEP_SIZE = .5 +const UPDATE_PROPS = ['entity', 'sensors', '_temperature'] +const modeIcons = { + auto: "hass:autorenew", + manual: "hass:cursor-pointer", + heat: "hass:fire", + cool: "hass:snowflake", + off: "hass:power", + fan_only: "hass:fan", + eco: "hass:leaf", + dry: "hass:water-percent", + idle: "hass:power", +} + +const STATE_ICONS = { + off: 'mdi:radiator-off', + on: 'mdi:radiator', + idle: 'mdi:radiator-disabled', + heat: 'mdi:radiator', + cool: 'mdi:snowflake', + auto: 'mdi:radiator', + manual: 'mdi:radiator', + boost: 'mdi:fire', + away: 'mdi:radiator-disabled' +} + +const DEFAULT_HIDE = { + temperature: false, + state: false, + mode: false, +} + +class SimpleThermostat extends LitElement { + + static get properties () { + return { + _hass: Object, + config: Object, + entity: Object, + sensors: Array, + icon: String, + _temperature: { + type: Number, + notify: true, + }, + _mode: String, + _hide: Object, + name: String, + } + } + + constructor () { + super(); + + this._hass = null + this.entity = null + this.icon = null + this.sensors = [] + this._stepSize = STEP_SIZE + this._temperature = null + this._mode = null + this._hide = DEFAULT_HIDE + } + + set hass (hass) { + this._hass = hass + + const entity = hass.states[this.config.entity] + if (this.entity !== entity) { + this.entity = entity; + + const { + attributes: { + operation_mode: mode, + operation_list: modes = [], + temperature: _temperature, + } + } = entity + this._temperature = _temperature + this._mode = mode + } + + if (this.config.icon) { + this.icon = this.config.icon; + } else { + this.icon = STATE_ICONS; + } + + if (this.config.step_size) { + this._stepSize = this.config.step_size + } + + if (this.config.hide) { + this._hide = { ...DEFAULT_HIDE, ...this.config.hide } + } + + if (typeof this.config.name === 'string') { + this.name = this.config.name + } else if (this.config.name === false) { + this.name = false + } else { + this.name = entity.attributes.friendly_name + } + + if (this.config.sensors) { + this.sensors = this.config.sensors.map(({ name: wantedName, entity }) => { + const state = hass.states[entity] + const name = [ + wantedName, + state.attributes && state.attributes.friendly_name, + entity + ].find(n => !!n) + return { name, entity, state } + }) + } + + } + + shouldUpdate (changedProps) { + return UPDATE_PROPS.some(prop => changedProps.has(prop)) + } + + localize (label, prefix) { + const lang = this._hass.selectedLanguage || this._hass.language; + return this._hass.resources[lang][`${prefix}${label}`] || label + } + + render ({ _hass, _hide, config, entity, sensors } = this) { + if (!entity) return + const { + state, + attributes: { + min_temp: minTemp = null, + max_temp: maxTemp = null, + current_temperature: current, + operation_list: operations = [], + operation_mode: operation, + }, + } = entity + const unit = this._hass.config.unit_system.temperature + + const sensorHtml = [ + _hide.temperature ? null : this.renderInfoItem( + `${formatNumber(current)}${unit}`, 'Temperature' + ), + _hide.state ? null : this.renderInfoItem(this.localize(state, 'state.climate.'), 'State'), + _hide.mode ? null : this.renderModeSelector(operations, operation), + sensors.map(({ name, state }) => { + return this.renderInfoItem(state, name) + }) || null, + ].filter(it => it !== null) + + const increaseTemperature = this.handleTemperatureChange.bind(this, +this._stepSize) + const decreaseTemperature = this.handleTemperatureChange.bind(this, -this._stepSize) + return html` + ${renderStyles()} + + ${ this.renderHeader() } +
+ ${sensorHtml}
+ +
+
+ + + +
+

${formatNumber(this._temperature)}

+
+ + +
+ ${unit} +
+
+
+ ` + } + + renderHeader () { + if (this.name === false) return '' + + let icon = this.icon + const { state } = this.entity + if (typeof this.icon === 'object') { + icon = state in this.icon ? this.icon[state] : false + } + + return html` +
+ ${ icon && html` + + ` || '' } +

+ ${this.name} +

+
+ ` + } + + renderModeSelector (modes, mode) { + const selected = modes.indexOf(mode) + return html` + + Mode: + + + + ${ modes.map(m => + html` + + ${this.localize(m, 'state.climate.')} + ` + ) } + + + + + ` + } + + renderInfoItem (state, heading) { + if (!state) return + + let valueCell + if (typeof state === 'string') { + valueCell = html`${state}` + } + else { + valueCell = html` + ${state.state} ${state.attributes.unit_of_measurement} + ` + } + return html` + + ${heading}: + ${valueCell} + + ` + } + + handleTemperatureChange (change, e) { + e.stopPropagation(); + this.setTemperature(this._temperature + change) + } + + setTemperature (temperature) { + this._debouncedSetTemperature = Debouncer.debounce( + this._debouncedSetTemperature, + { + run: (fn) => { + this._temperature = temperature + return window.setTimeout(fn, DEBOUNCE_TIMEOUT) + }, + cancel: handle => window.clearTimeout(handle), + }, + () => { + this._hass.callService("climate", "set_temperature", { + entity_id: this.config.entity, + temperature: this._temperature, + }) + } + ) + } + + setMode (e) { + const { detail: { value: node } } = e + if (!node) return + const value = node.getAttribute('mode-value') + if (value && value !== this._mode) { + this._hass.callService("climate", "set_operation_mode", { + entity_id: this.config.entity, + operation_mode: value, + }); + } + } + + openEntityPopover (entityId) { + this.fire('hass-more-info', { entityId }); + } + + fire (type, detail, options) { + options = options || {}; + detail = (detail === null || detail === undefined) ? {} : detail; + const e = new Event(type, { + bubbles: options.bubbles === undefined ? true : options.bubbles, + cancelable: Boolean(options.cancelable), + composed: options.composed === undefined ? true : options.composed + }); + e.detail = detail; + this.dispatchEvent(e); + return e; + } + + setConfig(config) { + if (!config.entity) { + throw new Error('You need to define an entity'); + } + this.config = config; + } + + // The height of your card. Home Assistant uses this to automatically + // distribute all cards over the available columns. + getCardSize() { + return 3; + } +} + +customElements.define('simple-thermostat', SimpleThermostat);