From 741c0c08b9026e8cec6f01ff33b60d0186ee4ac6 Mon Sep 17 00:00:00 2001 From: Zack Arnett Date: Fri, 26 Oct 2018 03:30:58 -0400 Subject: [PATCH] Thermostat Card LoveLace (#1814) * POC/WIP: Thermostat Card * Fix jQuery imports * Cleaning out testing code and working on reviews * Colors Dynamic + mode dynamic * Minor changes * adding html prefix * Dynamic Text size and colors - getting somwhere slowly. * Review Changes - Working version (i think) * Updating Gallery Entry * Travies Review * Remove provide plugin, move CSS to JS * Add provideHass to demo * Demo fixes * tweak margins * Travis changes * Style Tweaks * Update to client Width range --- gallery/src/data/entity.js | 16 + gallery/src/demos/demo-hui-thermostat-card.js | 88 +++++ gallery/webpack.config.js | 4 + package.json | 2 + .../lovelace/cards/hui-thermostat-card.ts | 349 ++++++++++++++++++ .../lovelace/common/create-card-element.js | 2 + src/resources/jquery.roundslider.js | 6 + src/resources/jquery.ts | 5 + webpack.config.js | 4 + yarn.lock | 12 + 10 files changed, 488 insertions(+) create mode 100644 gallery/src/demos/demo-hui-thermostat-card.js create mode 100644 src/panels/lovelace/cards/hui-thermostat-card.ts create mode 100644 src/resources/jquery.roundslider.js create mode 100644 src/resources/jquery.ts diff --git a/gallery/src/data/entity.js b/gallery/src/data/entity.js index b4e5761935..b2b90a50ca 100644 --- a/gallery/src/data/entity.js +++ b/gallery/src/data/entity.js @@ -99,6 +99,21 @@ export class CoverEntity extends Entity { } } +export class ClimateEntity extends Entity { + async handleService(domain, service, data) { + if (domain !== this.domain) return; + + if (service === "set_operation_mode") { + this.update( + data.operation_mode === "heat" ? "heat" : data.operation_mode, + Object.assign(this.attributes, { + operation_mode: data.operation_mode, + }) + ); + } + } +} + export class GroupEntity extends Entity { async handleService(domain, service, data) { if (!["homeassistant", this.domain].includes(domain)) return; @@ -115,6 +130,7 @@ export class GroupEntity extends Entity { } const TYPES = { + climate: ClimateEntity, light: LightEntity, lock: LockEntity, cover: CoverEntity, diff --git a/gallery/src/demos/demo-hui-thermostat-card.js b/gallery/src/demos/demo-hui-thermostat-card.js new file mode 100644 index 0000000000..3c2d5d6cf1 --- /dev/null +++ b/gallery/src/demos/demo-hui-thermostat-card.js @@ -0,0 +1,88 @@ +import { html } from "@polymer/polymer/lib/utils/html-tag.js"; +import { PolymerElement } from "@polymer/polymer/polymer-element.js"; + +import getEntity from "../data/entity.js"; +import provideHass from "../data/provide_hass.js"; +import "../components/demo-cards.js"; + +const ENTITIES = [ + getEntity("climate", "ecobee", "auto", { + current_temperature: 73, + min_temp: 45, + max_temp: 95, + temperature: null, + target_temp_high: 75, + target_temp_low: 70, + fan_mode: "Auto Low", + fan_list: ["On Low", "On High", "Auto Low", "Auto High", "Off"], + operation_mode: "auto", + operation_list: ["heat", "cool", "auto", "off"], + hold_mode: "home", + swing_mode: "Auto", + swing_list: ["Auto", "1", "2", "3", "Off"], + friendly_name: "Ecobee", + supported_features: 1014, + }), + getEntity("climate", "nest", "heat", { + current_temperature: 17, + min_temp: 15, + max_temp: 25, + temperature: 19, + fan_mode: "Auto Low", + fan_list: ["On Low", "On High", "Auto Low", "Auto High", "Off"], + operation_mode: "heat", + operation_list: ["heat", "cool", "auto", "off"], + hold_mode: "home", + swing_mode: "Auto", + swing_list: ["Auto", "1", "2", "3", "Off"], + friendly_name: "Nest", + supported_features: 1014, + }), +]; + +const CONFIGS = [ + { + heading: "Range example", + config: ` +- type: thermostat + entity: climate.ecobee +- type: thermostat + entity: climate.nest + `, + }, + { + heading: "Single temp example", + config: ` +- type: thermostat + entity: climate.nest + `, + }, +]; + +class DemoThermostatEntity extends PolymerElement { + static get template() { + return html` + + `; + } + + static get properties() { + return { + _configs: { + type: Object, + value: CONFIGS, + }, + }; + } + + ready() { + super.ready(); + const hass = provideHass(this.$.demos); + hass.addEntities(ENTITIES); + } +} + +customElements.define("demo-hui-thermostat-card", DemoThermostatEntity); diff --git a/gallery/webpack.config.js b/gallery/webpack.config.js index d92eb482d7..7b711ac355 100644 --- a/gallery/webpack.config.js +++ b/gallery/webpack.config.js @@ -17,6 +17,10 @@ module.exports = { module: { rules: [ babelLoaderConfig({ latestBuild: true }), + { + test: /\.css$/, + use: "raw-loader", + }, { test: /\.(html)$/, use: { diff --git a/package.json b/package.json index d3366ce751..c3efdce221 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "fecha": "^2.3.3", "home-assistant-js-websocket": "^3.1.4", "intl-messageformat": "^2.2.0", + "jquery": "^3.3.1", "js-yaml": "^3.12.0", "leaflet": "^1.3.4", "lit-html": "^0.12.0", @@ -85,6 +86,7 @@ "preact-compat": "^3.18.4", "react-big-calendar": "^0.19.2", "regenerator-runtime": "^0.12.1", + "round-slider": "^1.3.2", "unfetch": "^4.0.1", "web-animations-js": "^2.3.1", "xss": "^1.0.3" diff --git a/src/panels/lovelace/cards/hui-thermostat-card.ts b/src/panels/lovelace/cards/hui-thermostat-card.ts new file mode 100644 index 0000000000..748d48d8f2 --- /dev/null +++ b/src/panels/lovelace/cards/hui-thermostat-card.ts @@ -0,0 +1,349 @@ +import { html, LitElement } from "@polymer/lit-element"; +import { classMap } from "lit-html/directives/classMap.js"; +import { jQuery } from "../../../resources/jquery"; + +import "../../../components/ha-card.js"; +import "../../../components/ha-icon.js"; +import { roundSliderStyle } from "../../../resources/jquery.roundslider"; + +import { HomeAssistant } from "../../../types.js"; +import { hassLocalizeLitMixin } from "../../../mixins/lit-localize-mixin"; +import { LovelaceCard, LovelaceConfig } from "../types.js"; + +const thermostatConfig = { + radius: 150, + step: 1, + circleShape: "pie", + startAngle: 315, + width: 5, + lineCap: "round", + handleSize: "+10", + showTooltip: false, +}; + +const modeIcons = { + auto: "hass:autorenew", + heat: "hass:fire", + cool: "hass:snowflake", + off: "hass:fan-off", +}; + +interface Config extends LovelaceConfig { + entity: string; +} + +function formatTemp(temps) { + return temps.filter(Boolean).join("-"); +} + +export class HuiThermostatCard extends hassLocalizeLitMixin(LitElement) + implements LovelaceCard { + public hass?: HomeAssistant; + protected config?: Config; + + static get properties() { + return { + hass: {}, + config: {}, + }; + } + + public getCardSize() { + return 4; + } + + public setConfig(config: Config) { + if (!config.entity || config.entity.split(".")[0] !== "climate") { + throw new Error("Specify an entity from within the climate domain."); + } + + this.config = config; + } + + protected render() { + if (!this.hass || !this.config) { + return html``; + } + const stateObj = this.hass.states[this.config.entity]; + const broadCard = this.clientWidth > 390; + return html` + ${this.renderStyle()} + +
+
+
+
Upstairs
+
+ ${ + stateObj.attributes.current_temperature + } + ${ + this.hass.config.unit_system.temperature + } + +
+
+
+
${this.localize( + `state.climate.${stateObj.state}` + )}
+
+ ${stateObj.attributes.operation_list.map((modeItem) => + this._renderIcon(modeItem, stateObj.attributes.operation_mode) + )} +
+
+
+ + `; + } + + protected shouldUpdate(changedProps) { + if (changedProps.get("hass")) { + return changedProps.get("hass").states[this.config!.entity] !== + this.hass!.states[this.config!.entity] + ? true + : false; + } + return changedProps; + } + + protected firstUpdated() { + const stateObj = this.hass!.states[this.config!.entity]; + + const _sliderType = + stateObj.attributes.target_temp_low && + stateObj.attributes.target_temp_high + ? "range" + : "min-range"; + + jQuery("#thermostat", this.shadowRoot).roundSlider({ + ...thermostatConfig, + radius: this.clientWidth / 3, + min: stateObj.attributes.min_temp, + max: stateObj.attributes.max_temp, + sliderType: _sliderType, + change: (value) => this._setTemperature(value), + drag: (value) => this._dragEvent(value), + }); + } + + protected updated() { + const attrs = this.hass!.states[this.config!.entity].attributes; + + let sliderValue; + let uiValue; + + if (attrs.target_temp_low && attrs.target_temp_high) { + sliderValue = `${attrs.target_temp_low}, ${attrs.target_temp_high}`; + uiValue = formatTemp([attrs.target_temp_low, attrs.target_temp_high]); + } else { + sliderValue = uiValue = attrs.temperature; + } + + jQuery("#thermostat", this.shadowRoot).roundSlider({ + value: sliderValue, + }); + this.shadowRoot!.querySelector("#set-temperature")!.innerHTML = uiValue; + } + + private renderStyle() { + return html` + ${roundSliderStyle} + + `; + } + + private _dragEvent(e) { + this.shadowRoot!.querySelector("#set-temperature")!.innerHTML = formatTemp( + String(e.value).split(",") + ); + } + + private _setTemperature(e) { + const stateObj = this.hass!.states[this.config!.entity]; + if ( + stateObj.attributes.target_temp_low && + stateObj.attributes.target_temp_high + ) { + if (e.handle.index === 1) { + this.hass!.callService("climate", "set_temperature", { + entity_id: this.config!.entity, + target_temp_low: e.handle.value, + target_temp_high: stateObj.attributes.target_temp_high, + }); + } else { + this.hass!.callService("climate", "set_temperature", { + entity_id: this.config!.entity, + target_temp_low: stateObj.attributes.target_temp_low, + target_temp_high: e.handle.value, + }); + } + } else { + this.hass!.callService("climate", "set_temperature", { + entity_id: this.config!.entity, + temperature: e.value, + }); + } + } + + private _renderIcon(mode, currentMode) { + return html``; + } + + private _handleModeClick(e: MouseEvent) { + this.hass!.callService("climate", "set_operation_mode", { + entity_id: this.config!.entity, + operation_mode: (e.currentTarget as any).mode, + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-thermostat-card": HuiThermostatCard; + } +} + +customElements.define("hui-thermostat-card", HuiThermostatCard); diff --git a/src/panels/lovelace/common/create-card-element.js b/src/panels/lovelace/common/create-card-element.js index f6465cb8a5..3cadc4b052 100644 --- a/src/panels/lovelace/common/create-card-element.js +++ b/src/panels/lovelace/common/create-card-element.js @@ -20,6 +20,7 @@ import "../cards/hui-picture-glance-card"; import "../cards/hui-plant-status-card.js"; import "../cards/hui-sensor-card.js"; import "../cards/hui-vertical-stack-card.ts"; +import "../cards/hui-thermostat-card.ts"; import "../cards/hui-weather-forecast-card"; import "../cards/hui-gauge-card.js"; @@ -46,6 +47,7 @@ const CARD_TYPES = new Set([ "picture-glance", "plant-status", "sensor", + "thermostat", "vertical-stack", "weather-forecast", ]); diff --git a/src/resources/jquery.roundslider.js b/src/resources/jquery.roundslider.js new file mode 100644 index 0000000000..16ada36315 --- /dev/null +++ b/src/resources/jquery.roundslider.js @@ -0,0 +1,6 @@ +import { html } from "@polymer/lit-element"; +import "./jquery"; +import "round-slider"; +import roundSliderCSS from "round-slider/dist/roundslider.min.css"; + +export const roundSliderStyle = html``; diff --git a/src/resources/jquery.ts b/src/resources/jquery.ts new file mode 100644 index 0000000000..650365b431 --- /dev/null +++ b/src/resources/jquery.ts @@ -0,0 +1,5 @@ +import jQuery_ from "jquery"; + +(window as any).jQuery = jQuery_; + +export const jQuery = jQuery_; diff --git a/webpack.config.js b/webpack.config.js index 3ece128c29..639d9db55e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -63,6 +63,10 @@ function createConfig(isProdBuild, latestBuild) { module: { rules: [ babelLoaderConfig({ latestBuild }), + { + test: /\.css$/, + use: "raw-loader", + }, { test: /\.(html)$/, use: { diff --git a/yarn.lock b/yarn.lock index 05917754b3..a2c350890c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9080,6 +9080,11 @@ joi@^11.1.1: isemail "3.x.x" topo "2.x.x" +"jquery@>= 1.4.1", jquery@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" + integrity sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg== + js-levenshtein@^1.1.3: version "1.1.4" resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.4.tgz#3a56e3cbf589ca0081eb22cd9ba0b1290a16d26e" @@ -12838,6 +12843,13 @@ rollup@^0.58.2: "@types/estree" "0.0.38" "@types/node" "*" +round-slider@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/round-slider/-/round-slider-1.3.2.tgz#8fb363f4fe2ab653b8160a13aa4d493634bac050" + integrity sha512-JAUSXwuxiLv/kliHNP2GbnXID87hFqoxac38UIcvkpyYTwSzpTKlqvMKLB7xWnDOgX/9MCD7B2Ab41pk6cGiWQ== + dependencies: + jquery ">= 1.4.1" + run-async@^2.0.0, run-async@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"