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
This commit is contained in:
Zack Arnett 2018-10-26 03:30:58 -04:00 committed by Paulus Schoutsen
parent c42d9385d1
commit 741c0c08b9
10 changed files with 488 additions and 0 deletions

View File

@ -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,

View File

@ -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`
<demo-cards
id='demos'
configs="[[_configs]]"
></demo-cards>
`;
}
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);

View File

@ -17,6 +17,10 @@ module.exports = {
module: {
rules: [
babelLoaderConfig({ latestBuild: true }),
{
test: /\.css$/,
use: "raw-loader",
},
{
test: /\.(html)$/,
use: {

View File

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

View File

@ -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()}
<ha-card
class="${classMap({
[stateObj.attributes.operation_mode]: true,
large: broadCard,
small: !broadCard,
})}">
<div id="root">
<div id="thermostat"></div>
<div id="tooltip">
<div class="title">Upstairs</div>
<div class="current-temperature">
<span class="current-temperature-text">${
stateObj.attributes.current_temperature
}
<span class="uom">${
this.hass.config.unit_system.temperature
}</span>
</span>
</div>
<div class="climate-info">
<div id="set-temperature"></div>
<div class="current-mode">${this.localize(
`state.climate.${stateObj.state}`
)}</div>
<div class="modes">
${stateObj.attributes.operation_list.map((modeItem) =>
this._renderIcon(modeItem, stateObj.attributes.operation_mode)
)}
</div>
</div>
</div>
</ha-card>
`;
}
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}
<style>
:host {
display: block;
}
ha-card {
overflow: hidden;
}
#root {
position: relative;
overflow: hidden;
}
.auto {
--mode-color: green;
}
.cool {
--mode-color: #2b9af9;
}
.heat {
--mode-color: #FF8100;
}
.off {
--mode-color: #8a8a8a;
}
.large {
--thermostat-padding-top: 25px;
--thermostat-margin-bottom: 25px;
--title-font-size: 28px;
--title-margin-top: 20%;
--climate-info-margin-top: 17%;
--modes-margin-top: 2%;
--set-temperature-font-size: 25px;
--current-temperature-font-size: 71px;
--current-temperature-margin-top: 10%;
--current-temperature-text-padding-left: 15px;
--uom-font-size: 20px;
--uom-margin-left: -18px;
--current-mode-font-size: 18px;
--set-temperature-padding-bottom: 5px;
}
.small {
--thermostat-padding-top: 15px;
--thermostat-margin-bottom: 15px;
--title-font-size: 18px;
--title-margin-top: 20%;
--climate-info-margin-top: 7.5%;
--modes-margin-top: 1%;
--set-temperature-font-size: 16px;
--current-temperature-font-size: 25px;
--current-temperature-margin-top: 5%;
--current-temperature-text-padding-left: 7px;
--uom-font-size: 12px;
--uom-margin-left: -5px;
--current-mode-font-size: 14px;
--set-temperature-padding-bottom: 0px;
}
#thermostat {
margin: 0 auto var(--thermostat-margin-bottom);
padding-top: var(--thermostat-padding-top);
}
#thermostat .rs-range-color {
background-color: var(--mode-color, var(--disabled-text-color));
}
#thermostat .rs-path-color {
background-color: #d6d6d6;
}
#thermostat .rs-handle {
background-color: #FFF;
padding: 7px;
border: 2px solid #d6d6d6;
}
#thermostat .rs-handle.rs-focus {
border-color: var(--mode-color, var(--disabled-text-color));
}
#thermostat .rs-handle:after {
border-color: var(--mode-color, var(--disabled-text-color));
background-color: var(--mode-color, var(--disabled-text-color));
}
#thermostat .rs-border {
border-color: transparent;
}
#thermostat .rs-bar.rs-transition.rs-first, .rs-bar.rs-transition.rs-second{
z-index: 20 !important;
}
#tooltip {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
text-align: center;
z-index: 15;
}
#set-temperature {
font-size: var(--set-temperature-font-size);
padding-bottom: var(--set-temperature-padding-bottom);
}
.title {
font-size: var(--title-font-size);
margin-top: var(--title-margin-top);
}
.climate-info {
margin-top: var(--climate-info-margin-top);
}
.current-mode {
font-size: var(--current-mode-font-size);
color: var(--secondary-text-color);
}
.modes {
margin-top: var(--modes-margin-top);
}
.modes ha-icon {
color: var(--disabled-text-color);
cursor: pointer;
display: inline-block;
margin: 0 10px;
}
.modes ha-icon.selected-icon {
color: var(--mode-color);
}
.current-temperature {
margin-top: var(--current-temperature-margin-top);
font-size: var(--current-temperature-font-size);
}
.current-temperature-text {
padding-left: var(--current-temperature-text-padding-left);
}
.uom {
font-size: var(--uom-font-size);
vertical-align: top;
margin-left: var(--uom-margin-left);
}
</style>
`;
}
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`<ha-icon
class="${classMap({ "selected-icon": currentMode === mode })}"
.mode="${mode}"
.icon="${modeIcons[mode]}"
@click="${this._handleModeClick}"
></ha-icon>`;
}
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);

View File

@ -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",
]);

View File

@ -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`<style>${roundSliderCSS}</style>`;

5
src/resources/jquery.ts Normal file
View File

@ -0,0 +1,5 @@
import jQuery_ from "jquery";
(window as any).jQuery = jQuery_;
export const jQuery = jQuery_;

View File

@ -63,6 +63,10 @@ function createConfig(isProdBuild, latestBuild) {
module: {
rules: [
babelLoaderConfig({ latestBuild }),
{
test: /\.css$/,
use: "raw-loader",
},
{
test: /\.(html)$/,
use: {

View File

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