Convert thermostat to round-slider (#3734)

* Convert to round-slider

Closes https://github.com/home-assistant/home-assistant-polymer/issues/3622
Closes https://github.com/home-assistant/home-assistant-polymer/issues/2756

* scaling

* address review comments

* css tweaks

* remove jquery

* address comments

* simplify set-temperature

* handle long name

* remove increased handleSize

* address comments

* address comments

* address comments

* address comment

* need coffee
This commit is contained in:
Ian Richardson 2019-10-17 14:00:39 -05:00 committed by Bram Kragten
parent 141c3f1ea4
commit f5e3a9ad40
6 changed files with 327 additions and 421 deletions

View File

@ -84,7 +84,6 @@
"hls.js": "^0.12.4", "hls.js": "^0.12.4",
"home-assistant-js-websocket": "^4.4.0", "home-assistant-js-websocket": "^4.4.0",
"intl-messageformat": "^2.2.0", "intl-messageformat": "^2.2.0",
"jquery": "^3.4.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"leaflet": "^1.4.0", "leaflet": "^1.4.0",
"lit-element": "^2.2.1", "lit-element": "^2.2.1",
@ -98,7 +97,6 @@
"react-big-calendar": "^0.20.4", "react-big-calendar": "^0.20.4",
"regenerator-runtime": "^0.13.2", "regenerator-runtime": "^0.13.2",
"roboto-fontface": "^0.10.0", "roboto-fontface": "^0.10.0",
"round-slider": "^1.3.3",
"superstruct": "^0.6.1", "superstruct": "^0.6.1",
"tslib": "^1.10.0", "tslib": "^1.10.0",
"unfetch": "^4.1.0", "unfetch": "^4.1.0",

View File

@ -5,9 +5,12 @@ import {
TemplateResult, TemplateResult,
customElement, customElement,
property, property,
css,
CSSResult,
} from "lit-element"; } from "lit-element";
import { classMap } from "lit-html/directives/class-map"; import { classMap } from "lit-html/directives/class-map";
import "@polymer/paper-icon-button/paper-icon-button"; import "@polymer/paper-icon-button/paper-icon-button";
import "@thomasloven/round-slider";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon"; import "../../../components/ha-icon";
@ -19,7 +22,6 @@ import { computeStateName } from "../../../common/entity/compute_state_name";
import { hasConfigOrEntityChanged } from "../common/has-changed"; import { hasConfigOrEntityChanged } from "../common/has-changed";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { loadRoundslider } from "../../../resources/jquery.roundslider.ondemand";
import { UNIT_F } from "../../../common/const"; import { UNIT_F } from "../../../common/const";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { ThermostatCardConfig } from "./types"; import { ThermostatCardConfig } from "./types";
@ -29,17 +31,7 @@ import {
compareClimateHvacModes, compareClimateHvacModes,
CLIMATE_PRESET_NONE, CLIMATE_PRESET_NONE,
} from "../../../data/climate"; } from "../../../data/climate";
import { HassEntity } from "home-assistant-js-websocket";
const thermostatConfig = {
radius: 150,
circleShape: "pie",
startAngle: 315,
width: 5,
lineCap: "round",
handleSize: "+10",
showTooltip: false,
animation: false,
};
const modeIcons: { [mode in HvacMode]: string } = { const modeIcons: { [mode in HvacMode]: string } = {
auto: "hass:calendar-repeat", auto: "hass:calendar-repeat",
@ -63,18 +55,15 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;
@property() private _config?: ThermostatCardConfig; @property() private _config?: ThermostatCardConfig;
@property() private _loaded?: boolean;
@property() private _roundSliderStyle?: TemplateResult; @property() private _setTemp?: number | number[];
@property() private _jQuery?: any;
private _broadCard?: boolean;
private _loaded?: boolean;
private _updated?: boolean; private _updated?: boolean;
private _large?: boolean;
private _medium?: boolean;
private _small?: boolean;
private _radius?: number;
public getCardSize(): number { public getCardSize(): number {
return 4; return 4;
@ -114,26 +103,69 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
} }
const mode = stateObj.state in modeIcons ? stateObj.state : "unknown-mode"; const mode = stateObj.state in modeIcons ? stateObj.state : "unknown-mode";
const name =
this._config!.name ||
computeStateName(this.hass!.states[this._config!.entity]);
if (!this._radius || this._radius === 0) {
this._radius = 100;
}
return html` return html`
${this.renderStyle()}
<ha-card <ha-card
class="${classMap({ class=${classMap({
[mode]: true, [mode]: true,
large: this._broadCard!, large: this._large!,
small: !this._broadCard, medium: this._medium!,
})}" small: this._small!,
longName: name.length > 10,
})}
> >
<div id="root"> <div id="root">
<paper-icon-button <paper-icon-button
icon="hass:dots-vertical" icon="hass:dots-vertical"
class="more-info" class="more-info"
@click="${this._handleMoreInfo}" @click=${this._handleMoreInfo}
></paper-icon-button> ></paper-icon-button>
<div id="thermostat"></div> <div id="thermostat">
<div id="tooltip"> ${stateObj.state === "unavailable"
<div class="title"> ? html`
${this._config.name || computeStateName(stateObj)} <round-slider
.radius=${this._radius}
disabled="true"
></round-slider>
`
: stateObj.attributes.target_temp_low &&
stateObj.attributes.target_temp_high
? html`
<round-slider
.radius=${this._radius}
.low=${stateObj.attributes.target_temp_low}
.high=${stateObj.attributes.target_temp_high}
.min=${stateObj.attributes.min_temp}
.max=${stateObj.attributes.max_temp}
.step=${this._stepSize}
@value-changing=${this._dragEvent}
@value-changed=${this._setTemperature}
></round-slider>
`
: html`
<round-slider
.radius=${this._radius}
.value=${stateObj.attributes.temperature !== null &&
Number.isFinite(Number(stateObj.attributes.temperature))
? stateObj.attributes.temperature
: stateObj.attributes.min_temp}
.step=${this._stepSize}
.min=${stateObj.attributes.min_temp}
.max=${stateObj.attributes.max_temp}
@value-changing=${this._dragEvent}
@value-changed=${this._setTemperature}
></round-slider>
`}
</div> </div>
<div id="tooltip">
<div class="title">${name}</div>
<div class="current-temperature"> <div class="current-temperature">
<span class="current-temperature-text"> <span class="current-temperature-text">
${stateObj.attributes.current_temperature} ${stateObj.attributes.current_temperature}
@ -147,7 +179,18 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
</span> </span>
</div> </div>
<div class="climate-info"> <div class="climate-info">
<div id="set-temperature"></div> <div id="set-temperature">
${!this._setTemp
? ""
: Array.isArray(this._setTemp)
? html`
${this._setTemp[0].toFixed(1)} -
${this._setTemp[1].toFixed(1)}
`
: html`
${this._setTemp.toFixed(1)}
`}
</div>
<div class="current-mode"> <div class="current-mode">
${stateObj.attributes.hvac_action ${stateObj.attributes.hvac_action
? this.hass!.localize( ? this.hass!.localize(
@ -185,13 +228,6 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
return hasConfigOrEntityChanged(this, changedProps); return hasConfigOrEntityChanged(this, changedProps);
} }
protected firstUpdated(): void {
this._updated = true;
if (this.isConnected && !this._loaded) {
this._initialLoad();
}
}
protected updated(changedProps: PropertyValues): void { protected updated(changedProps: PropertyValues): void {
super.updated(changedProps); super.updated(changedProps);
if (!this._config || !this.hass || !changedProps.has("hass")) { if (!this._config || !this.hass || !changedProps.has("hass")) {
@ -204,31 +240,31 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
applyThemesOnElement(this, this.hass.themes, this._config.theme); applyThemesOnElement(this, this.hass.themes, this._config.theme);
} }
const stateObj = this.hass.states[this._config.entity] as ClimateEntity; this._setTemp = this._getSetTemp(this.hass!.states[this._config!.entity]);
if (!stateObj) {
return;
} }
if ( protected firstUpdated(): void {
this._jQuery && this._updated = true;
// If jQuery changed, we just rendered in firstUpdated if (this.isConnected && !this._loaded) {
!changedProps.has("_jQuery") && this._initialLoad();
(!oldHass || oldHass.states[this._config.entity] !== stateObj)
) {
const [sliderValue, uiValue, sliderType] = this._genSliderValue(stateObj);
this._jQuery("#thermostat", this.shadowRoot).roundSlider({
sliderType,
value: sliderValue,
disabled: sliderValue === null,
min: stateObj.attributes.min_temp,
max: stateObj.attributes.max_temp,
});
this._updateSetTemp(uiValue);
} }
} }
private async _initialLoad(): Promise<void> {
this._large = this._medium = this._small = false;
this._radius = this.clientWidth / 3.9;
if (this.clientWidth > 450) {
this._large = true;
} else if (this.clientWidth < 350) {
this._small = true;
} else {
this._medium = true;
}
this._loaded = true;
}
private get _stepSize(): number { private get _stepSize(): number {
const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity; const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity;
@ -238,119 +274,55 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
return this.hass!.config.unit_system.temperature === UNIT_F ? 1 : 0.5; return this.hass!.config.unit_system.temperature === UNIT_F ? 1 : 0.5;
} }
private async _initialLoad(): Promise<void> { private _getSetTemp(stateObj: HassEntity) {
const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity;
if (!stateObj) {
// Card will require refresh to work again
return;
}
this._loaded = true;
await this.updateComplete;
let radius = this.clientWidth / 3.2;
this._broadCard = this.clientWidth > 390;
if (radius === 0) {
radius = 100;
}
(this.shadowRoot!.querySelector(
"#thermostat"
) as HTMLElement)!.style.height = radius * 2 + "px";
const loaded = await loadRoundslider();
this._roundSliderStyle = loaded.roundSliderStyle;
this._jQuery = loaded.jQuery;
const [sliderValue, uiValue, sliderType] = this._genSliderValue(stateObj);
this._jQuery("#thermostat", this.shadowRoot).roundSlider({
...thermostatConfig,
radius,
min: stateObj.attributes.min_temp,
max: stateObj.attributes.max_temp,
sliderType,
change: (value) => this._setTemperature(value),
drag: (value) => this._dragEvent(value),
value: sliderValue,
disabled: sliderValue === null,
step: this._stepSize,
});
this._updateSetTemp(uiValue);
}
private _genSliderValue(
stateObj: ClimateEntity
): [string | number | null, string, string] {
let sliderType: string;
let sliderValue: string | number | null;
let uiValue: string;
if (stateObj.state === "unavailable") { if (stateObj.state === "unavailable") {
sliderType = "min-range"; return this.hass!.localize("state.default.unavailable");
sliderValue = null;
uiValue = this.hass!.localize("state.default.unavailable");
} else if (
stateObj.attributes.target_temp_low &&
stateObj.attributes.target_temp_high
) {
sliderType = "range";
sliderValue = `${stateObj.attributes.target_temp_low}, ${
stateObj.attributes.target_temp_high
}`;
uiValue = this.formatTemp(
[
String(stateObj.attributes.target_temp_low),
String(stateObj.attributes.target_temp_high),
],
false
);
} else {
sliderType = "min-range";
sliderValue = Number.isFinite(Number(stateObj.attributes.temperature))
? stateObj.attributes.temperature
: null;
uiValue = sliderValue !== null ? String(sliderValue) : "";
} }
return [sliderValue, uiValue, sliderType];
}
private _updateSetTemp(value: string): void {
this.shadowRoot!.querySelector("#set-temperature")!.innerHTML = value;
}
private _dragEvent(e): void {
this._updateSetTemp(this.formatTemp(String(e.value).split(","), true));
}
private _setTemperature(e): void {
const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity;
if ( if (
stateObj.attributes.target_temp_low && stateObj.attributes.target_temp_low &&
stateObj.attributes.target_temp_high stateObj.attributes.target_temp_high
) { ) {
if (e.handle.index === 1) { return [
stateObj.attributes.target_temp_low,
stateObj.attributes.target_temp_high,
];
}
return stateObj.attributes.temperature;
}
private _dragEvent(e): void {
const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity;
if (e.detail.low) {
this._setTemp = [e.detail.low, stateObj.attributes.target_temp_high];
} else if (e.detail.high) {
this._setTemp = [stateObj.attributes.target_temp_low, e.detail.high];
} else {
this._setTemp = e.detail.value;
}
}
private _setTemperature(e): void {
const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity;
if (e.detail.low) {
this.hass!.callService("climate", "set_temperature", { this.hass!.callService("climate", "set_temperature", {
entity_id: this._config!.entity, entity_id: this._config!.entity,
target_temp_low: e.handle.value, target_temp_low: e.detail.low,
target_temp_high: stateObj.attributes.target_temp_high, target_temp_high: stateObj.attributes.target_temp_high,
}); });
} else { } else if (e.detail.high) {
this.hass!.callService("climate", "set_temperature", { this.hass!.callService("climate", "set_temperature", {
entity_id: this._config!.entity, entity_id: this._config!.entity,
target_temp_low: stateObj.attributes.target_temp_low, target_temp_low: stateObj.attributes.target_temp_low,
target_temp_high: e.handle.value, target_temp_high: e.detail.high,
}); });
}
} else { } else {
this.hass!.callService("climate", "set_temperature", { this.hass!.callService("climate", "set_temperature", {
entity_id: this._config!.entity, entity_id: this._config!.entity,
temperature: e.value, temperature: e.detail.value,
}); });
} }
} }
@ -382,25 +354,8 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
}); });
} }
private formatTemp(temps: string[], spaceStepSize: boolean): string { static get styles(): CSSResult {
temps = temps.filter(Boolean); return css`
// If we are sliding the slider, append 0 to the temperatures if we're
// having a 0.5 step size, so that the text doesn't jump while sliding
if (spaceStepSize) {
const stepSize = this._stepSize;
temps = temps.map((val) =>
val.includes(".") || stepSize === 1 ? val : `${val}.0`
);
}
return temps.join("-");
}
private renderStyle(): TemplateResult {
return html`
${this._roundSliderStyle}
<style>
:host { :host {
display: block; display: block;
} }
@ -457,11 +412,11 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
--title-position-top: 33% !important; --title-position-top: 33% !important;
} }
.large { .large {
--thermostat-padding-top: 25px; --thermostat-padding-top: 32px;
--thermostat-margin-bottom: 25px; --thermostat-margin-bottom: 32px;
--title-font-size: 28px; --title-font-size: 28px;
--title-position-top: 27%; --title-position-top: 25%;
--climate-info-position-top: 81%; --climate-info-position-top: 80%;
--set-temperature-font-size: 25px; --set-temperature-font-size: 25px;
--current-temperature-font-size: 71px; --current-temperature-font-size: 71px;
--current-temperature-position-top: 10%; --current-temperature-position-top: 10%;
@ -469,6 +424,25 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
--uom-font-size: 20px; --uom-font-size: 20px;
--uom-margin-left: -18px; --uom-margin-left: -18px;
--current-mode-font-size: 18px; --current-mode-font-size: 18px;
--current-mod-margin-top: 6px;
--current-mod-margin-bottom: 12px;
--set-temperature-margin-bottom: -5px;
}
.medium {
--thermostat-padding-top: 20px;
--thermostat-margin-bottom: 20px;
--title-font-size: 23px;
--title-position-top: 27%;
--climate-info-position-top: 84%;
--set-temperature-font-size: 20px;
--current-temperature-font-size: 65px;
--current-temperature-position-top: 10%;
--current-temperature-text-padding-left: 15px;
--uom-font-size: 18px;
--uom-margin-left: -16px;
--current-mode-font-size: 16px;
--current-mod-margin-top: 4px;
--current-mod-margin-bottom: 4px;
--set-temperature-margin-bottom: -5px; --set-temperature-margin-bottom: -5px;
} }
.small { .small {
@ -476,53 +450,35 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
--thermostat-margin-bottom: 15px; --thermostat-margin-bottom: 15px;
--title-font-size: 18px; --title-font-size: 18px;
--title-position-top: 28%; --title-position-top: 28%;
--climate-info-position-top: 79%; --climate-info-position-top: 78%;
--set-temperature-font-size: 16px; --set-temperature-font-size: 16px;
--current-temperature-font-size: 25px; --current-temperature-font-size: 55px;
--current-temperature-position-top: 5%; --current-temperature-position-top: 5%;
--current-temperature-text-padding-left: 7px; --current-temperature-text-padding-left: 16px;
--uom-font-size: 12px; --uom-font-size: 16px;
--uom-margin-left: -5px; --uom-margin-left: -14px;
--current-mode-font-size: 14px; --current-mode-font-size: 14px;
--current-mod-margin-top: 2px;
--current-mod-margin-bottom: 4px;
--set-temperature-margin-bottom: 0px; --set-temperature-margin-bottom: 0px;
} }
.longName {
--title-font-size: 18px;
}
#thermostat { #thermostat {
margin: 0 auto var(--thermostat-margin-bottom); margin: 0 auto var(--thermostat-margin-bottom);
padding-top: var(--thermostat-padding-top); padding-top: var(--thermostat-padding-top);
padding-bottom: 32px;
display: flex;
justify-content: center;
align-items: center;
} }
#thermostat .rs-range-color { #thermostat round-slider {
background-color: var(--mode-color, var(--disabled-text-color)); margin: 0 auto;
} display: inline-block;
#thermostat .rs-path-color { --round-slider-path-color: var(--disabled-text-color);
background-color: var(--disabled-text-color); --round-slider-bar-color: var(--mode-color);
} z-index: 20;
#thermostat .rs-handle {
background-color: var(--paper-card-background-color, white);
padding: 10px;
margin: -10px 0 0 -8px !important;
border: 2px solid var(--disabled-text-color);
}
#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: var(--rail-border-color);
}
#thermostat .rs-bar.rs-transition.rs-first,
.rs-bar.rs-transition.rs-second {
z-index: 20 !important;
}
#thermostat .rs-readonly {
z-index: 10;
top: auto;
}
#thermostat .rs-inner.rs-bg-color.rs-border,
#thermostat .rs-overlay.rs-transition.rs-bg-color {
background-color: var(--paper-card-background-color, white);
} }
#tooltip { #tooltip {
position: absolute; position: absolute;
@ -556,9 +512,8 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
.current-mode { .current-mode {
font-size: var(--current-mode-font-size); font-size: var(--current-mode-font-size);
color: var(--secondary-text-color); color: var(--secondary-text-color);
} margin-top: var(--current-mod-margin-top);
.modes { margin-bottom: var(--current-mod-margin-bottom);
margin-top: 16px;
} }
.modes ha-icon { .modes ha-icon {
color: var(--disabled-text-color); color: var(--disabled-text-color);
@ -592,7 +547,6 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
z-index: 25; z-index: 25;
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
</style>
`; `;
} }
} }

View File

@ -1,14 +0,0 @@
import { html } from "lit-element";
// jQuery import should come before plugin import
import { jQuery as jQuery_ } from "./jquery";
import "round-slider";
// eslint-disable-next-line
import roundSliderCSS from "round-slider/dist/roundslider.min.css";
export const jQuery = jQuery_;
export const roundSliderStyle = html`
<style>
${roundSliderCSS}
</style>
`;

View File

@ -1,15 +0,0 @@
import { TemplateResult } from "lit-element";
interface LoadedRoundSlider {
roundSliderStyle: TemplateResult;
jQuery: any;
}
let loaded: Promise<LoadedRoundSlider>;
export const loadRoundslider = async (): Promise<LoadedRoundSlider> => {
if (!loaded) {
loaded = import(/* webpackChunkName: "jquery-roundslider" */ "./jquery.roundslider");
}
return loaded;
};

View File

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

View File

@ -7686,11 +7686,6 @@ joi@^14.3.1:
isemail "3.x.x" isemail "3.x.x"
topo "3.x.x" topo "3.x.x"
"jquery@>= 1.4.1", jquery@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.4.0.tgz#8de513fa0fa4b2c7d2e48a530e26f0596936efdf"
integrity sha512-ggRCXln9zEqv6OqAGXFEcshF5dSBvCkzj6Gm2gzuR5fWawaX8t7cxKVkkygKODrDAzKdoYw3l/e3pm3vlT4IbQ==
js-levenshtein@^1.1.3: js-levenshtein@^1.1.3:
version "1.1.6" version "1.1.6"
resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d"
@ -11050,13 +11045,6 @@ rollup@^1.3.0:
"@types/node" "^11.11.6" "@types/node" "^11.11.6"
acorn "^6.1.1" acorn "^6.1.1"
round-slider@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/round-slider/-/round-slider-1.3.3.tgz#0ec82261317b0aba35ac86a48fbd024baa50b261"
integrity sha512-Mo6HYiN5l6O0UNI/ytjr8ERgtZcXGRZ6A6+V6f2S/hKkbSaQnKCW+lpLPfoxWuW1xiPbAJs3qrXeps1xC+sjVg==
dependencies:
jquery ">= 1.4.1"
run-async@^2.2.0: run-async@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"