So, i wanted to create a custom UI for my Nefit easy smart thermostat. so, I went looking at Codepen and found this: nest codepen. And I thought, let’s create that to Home Assistant. Easier said than done.I had no experience with Polymer, so my code is probably not perfect, but it works.
Here is the result:
In the center is my target temperature (18°C) and the right is my current temperature (27°C).
Yes, it is warm here…
You can set the temperature by clicking the arrows and increase or decrease the target temperature by 0.5°C every click. or by holding the temperature for 1 second and draging it up or down.
Officially I made it for my personal use, but I still want to share it.
If you want to use my code (I know it’s not perfect yet), here is how you install it:
Add this to your confihuration.yaml:
input configuration.yaml
input_boolean:
smart_climate:
frontend configuration.yaml
frontend:
javascript_version: latest
extra_html_url:
- /local/custom_ui/state-card-custom-climate.html
extra_html_url_es5:
- /local/custom_ui/state-card-custom-climate.html
customize configuration.yaml
customize:
input_boolean.climate:
custom_ui_state_card: state-card-custom-climate
config:
heating: climate.{YOUR climate component name}
group configuration.yaml
group:
climate:
name: Climate
entities:
- input_boolean.climate
- climate.{YOUR climate component name}
state-card-custom-climate.html
file: /www/custom_ui/state-card-custom-climate.html
<dom-module id="state-card-custom-climate">
<h1>hallo</h1>
<template>
<style is="custom-style" include="iron-flex iron-flex-alignment"></style>
<style>
#nefit-thermostat {
width: 50vmin;
height: 50vmin;
margin: 0 auto;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.dial {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.dial.away .dial__ico__leaf {
visibility: hidden;
}
.dial.away .dial__lbl--target {
visibility: hidden;
}
.dial.away .dial__lbl--target--half {
visibility: hidden;
}
.dial.away .dial__lbl--away {
opacity: 1;
}
.dial .dial__shape {
transition: fill 0.5s;
}
.dial__ico__leaf {
fill: #13EB13;
opacity: 0;
transition: opacity 0.5s;
pointer-events: none;
}
.dail_arrow_down,
.dail_arrow_up{
fill: #fff;
padding: 7px;
stroke: transparent;
stroke-width: 10%;
}
.dial.has-leaf .dial__ico__leaf {
display: block;
opacity: 1;
pointer-events: initial;
}
.dial__editableIndicator {
fill: white;
fill-rule: evenodd;
opacity: 0;
transition: opacity 0.5s;
}
.dial--edit .dial__editableIndicator {
opacity: 1;
}
.dial--state--off .dial__shape {
fill: #222;
}
.dial--state--heating .dial__shape {
fill: #E36304;
}
.dial--state--cooling .dial__shape {
fill: #007AF1;
}
.dial__ticks path {
fill: rgba(255, 255, 255, 0.3);
}
.dial__ticks path.active {
fill: rgba(255, 255, 255, 0.8);
}
.dial text {
fill: white;
text-anchor: middle;
font-family: Helvetica, sans-serif;
alignment-baseline: central;
}
.dial__lbl--target {
font-size: 120px;
font-weight: bold;
}
.dial__lbl--target--half {
font-size: 40px;
font-weight: bold;
opacity: 0;
transition: opacity 0.1s;
}
.dial__lbl--target--half.shown {
opacity: 1;
transition: opacity 0s;
}
.dial__lbl--ambient {
font-size: 22px;
font-weight: bold;
}
.dial__lbl--away {
font-size: 72px;
font-weight: bold;
opacity: 0;
pointer-events: none;
}
#controls {
font-family: Open Sans;
background-color: rgba(255, 255, 255, 0.25);
padding: 20px;
border-radius: 5px;
position: absolute;
left: 50%;
-webkit-transform: translatex(-50%);
transform: translatex(-50%);
margin-top: 20px;
}
#controls label {
text-align: left;
display: block;
}
#controls label span {
display: inline-block;
width: 200px;
text-align: right;
font-size: 0.8em;
text-transform: uppercase;
}
#controls p {
margin: 0;
margin-bottom: 1em;
padding-bottom: 1em;
border-bottom: 2px solid #ccc;
}
</style>
<div class='horizontal justified layout'>
<div id='nefit-thermostat'></div>
</div>
</template>
</dom-module>
<script>
class StateCardScriptCustomText extends Polymer.Element {
static get is() { return 'state-card-custom-climate'; }
static get properties() {
return {
hass: Object,
stateObj: Object,
timeout:Object,
inDialog: {
type: Boolean,
value: false,
},
heatingObj: {
type: Object,
observer: 'processAttributes',
computed: 'computeHeatingObj(hass, stateObj)',
}
};
}
computeHeatingObj (hass, stateObj) {
return stateObj && stateObj.attributes && stateObj.attributes.config && stateObj.attributes.config.heating ? hass.states[stateObj.attributes.config.heating] : null;
}
ready() {
super.ready();
smartThermostat = new thermostatDial(this.root.querySelector('#nefit-thermostat'),{
onSetTargetTemperature: function(v) {
clearTimeout(this.timeout);
this.timeout = setTimeout(function(){
this.hass.callService('climate', 'set_temperature', {entity_id: this.stateObj.attributes.config.heating,temperature: v});
}.bind(this),700)
}.bind(this)
});
this.processAttributes();
}
processAttributes(){
if(typeof smartThermostat == 'undefined'){
return;
}
smartThermostat.target_temperature=this.heatingObj.attributes.temperature;
if(this.heatingObj.attributes.operation_mode == 'idle'){
smartThermostat.hvac_state = 'off';
}else{
smartThermostat.hvac_state = 'heating';
}
smartThermostat.ambient_temperature = this.heatingObj.attributes.current_temperature
}
}
customElements.define(StateCardScriptCustomText.is, StateCardScriptCustomText);
var thermostatDial = (function() {
/*
* Utility functions
*/
// Create an element with proper SVG namespace, optionally setting its attributes and appending it to another element
function createSVGElement(tag,attributes,appendTo) {
var element = document.createElementNS('http://www.w3.org/2000/svg',tag);
attr(element,attributes);
if (appendTo) {
appendTo.appendChild(element);
}
return element;
}
// Set attributes for an element
function attr(element,attrs) {
for (var i in attrs) {
element.setAttribute(i,attrs[i]);
}
}
// Rotate a cartesian point about given origin by X degrees
function rotatePoint(point, angle, origin) {
var radians = angle * Math.PI/180;
var x = point[0]-origin[0];
var y = point[1]-origin[1];
var x1 = x*Math.cos(radians) - y*Math.sin(radians) + origin[0];
var y1 = x*Math.sin(radians) + y*Math.cos(radians) + origin[1];
return [x1,y1];
}
// Rotate an array of cartesian points about a given origin by X degrees
function rotatePoints(points, angle, origin) {
return points.map(function(point) {
return rotatePoint(point, angle, origin);
});
}
// Given an array of points, return an SVG path string representing the shape they define
function pointsToPath(points) {
return points.map(function(point, iPoint) {
return (iPoint>0?'L':'M') + point[0] + ' ' + point[1];
}).join(' ')+'Z';
}
function circleToPath(cx, cy, r) {
return [
"M",cx,",",cy,
"m",0-r,",",0,
"a",r,",",r,0,1,",",0,r*2,",",0,
"a",r,",",r,0,1,",",0,0-r*2,",",0,
"z"
].join(' ').replace(/\s,\s/g,",");
}
function donutPath(cx,cy,rOuter,rInner) {
return circleToPath(cx,cy,rOuter) + " " + circleToPath(cx,cy,rInner);
}
// Restrict a number to a min + max range
function restrictToRange(val,min,max) {
if (val < min) return min;
if (val > max) return max;
return val;
}
// Round a number to the nearest 0.5
function roundHalf(num) {
return Math.round(num*2)/2;
}
function setClass(el, className, state) {
el.classList[state ? 'add' : 'remove'](className);
}
/*
* The "MEAT"
*/
return function(targetElement, options) {
var self = this;
/*
* Options
*/
options = options || {};
options = {
diameter: options.diameter || 400,
minValue: options.minValue || 5, // Minimum value for target temperature
maxValue: options.maxValue || 30, // Maximum value for target temperature
numTicks: options.numTicks || 150, // Number of tick lines to display around the dial
onSetTargetTemperature: options.onSetTargetTemperature || function() {}, // Function called when new target temperature set by the dial
};
/*
* Properties - calculated from options in many cases
*/
var properties = {
tickDegrees: 300, // Degrees of the dial that should be covered in tick lines
rangeValue: options.maxValue - options.minValue,
radius: options.diameter/2,
ticksOuterRadius: options.diameter / 30,
ticksInnerRadius: options.diameter / 8,
hvac_states: ['off', 'heating', 'cooling'],
dragLockAxisDistance: 15,
}
properties.lblAmbientPosition = [properties.radius, properties.ticksOuterRadius-(properties.ticksOuterRadius-properties.ticksInnerRadius)/2]
properties.offsetDegrees = 180-(360-properties.tickDegrees)/2;
/*
* Object state
*/
var state = {
target_temperature: 0,
ambient_temperature: options.minValue,
hvac_state: properties.hvac_states[0],
has_leaf: false,
away: false,
};
/*
* Property getter / setters
*/
Object.defineProperty(this,'target_temperature',{
get: function() {
return state.target_temperature;
},
set: function(val) {
if (self.target_temperature != val && self.target_temperature > 0) {
if (typeof options.onSetTargetTemperature == 'function') {
options.onSetTargetTemperature(val);
};
};
if(val < 17){
this.has_leaf = true;
}else{
this.has_leaf = false;
}
state.target_temperature = restrictTargetTemperature(+val);
render();
}
});
Object.defineProperty(this,'ambient_temperature',{
get: function() {
return state.ambient_temperature;
},
set: function(val) {
state.ambient_temperature = roundHalf(+val);
render();
}
});
Object.defineProperty(this,'hvac_state',{
get: function() {
return state.hvac_state;
},
set: function(val) {
if (properties.hvac_states.indexOf(val)>=0) {
state.hvac_state = val;
render();
}
}
});
Object.defineProperty(this,'has_leaf',{
get: function() {
return state.has_leaf;
},
set: function(val) {
state.has_leaf = !!val;
render();
}
});
Object.defineProperty(this,'away',{
get: function() {
return state.away;
},
set: function(val) {
state.away = !!val;
render();
}
});
/*
* SVG
*/
var svg = createSVGElement('svg',{
width: '100%', //options.diameter+'px',
height: '100%', //options.diameter+'px',
viewBox: '0 0 '+options.diameter+' '+options.diameter,
class: 'dial'
},targetElement);
// CIRCULAR DIAL
var circle = createSVGElement('circle',{
cx: properties.radius,
cy: properties.radius,
r: properties.radius,
class: 'dial__shape'
},svg);
// EDITABLE INDICATOR
var editCircle = createSVGElement('path',{
d: donutPath(properties.radius,properties.radius,properties.radius-4,properties.radius-8),
class: 'dial__editableIndicator',
},svg);
/*
* Ticks
*/
var ticks = createSVGElement('g',{
class: 'dial__ticks'
},svg);
var tickPoints = [
[properties.radius-1, properties.ticksOuterRadius],
[properties.radius+1, properties.ticksOuterRadius],
[properties.radius+1, properties.ticksInnerRadius],
[properties.radius-1, properties.ticksInnerRadius]
];
var tickPointsLarge = [
[properties.radius-1.5, properties.ticksOuterRadius],
[properties.radius+1.5, properties.ticksOuterRadius],
[properties.radius+1.5, properties.ticksInnerRadius+20],
[properties.radius-1.5, properties.ticksInnerRadius+20]
];
var theta = properties.tickDegrees/options.numTicks;
var tickArray = [];
for (var iTick=0; iTick<options.numTicks; iTick++) {
tickArray.push(createSVGElement('path',{d:pointsToPath(tickPoints)},ticks));
};
/*
* Labels
*/
var lblTarget = createSVGElement('text',{
x: properties.radius,
y: properties.radius,
class: 'dial__lbl dial__lbl--target'
},svg);
var lblTarget_text = document.createTextNode('');
lblTarget.appendChild(lblTarget_text);
//
var lblTargetHalf = createSVGElement('text',{
x: properties.radius + properties.radius/2.5,
y: properties.radius - properties.radius/8,
class: 'dial__lbl dial__lbl--target--half'
},svg);
var lblTargetHalf_text = document.createTextNode('5');
lblTargetHalf.appendChild(lblTargetHalf_text);
//
var lblAmbient = createSVGElement('text',{
class: 'dial__lbl dial__lbl--ambient'
},svg);
var lblAmbient_text = document.createTextNode('');
lblAmbient.appendChild(lblAmbient_text);
//
var lblAway = createSVGElement('text',{
x: properties.radius,
y: properties.radius,
class: 'dial__lbl dial__lbl--away'
},svg);
var lblAway_text = document.createTextNode('AWAY');
lblAway.appendChild(lblAway_text);
//
var icoLeaf = createSVGElement('path',{
class: 'dial__ico__leaf'
},svg);
/*
* LEAF
*/
var leafScale = properties.radius/5/100;
var leafDef = ["M", 3, 84, "c", 24, 17, 51, 18, 73, -6, "C", 100, 52, 100, 22, 100, 4, "c", -13, 15, -37, 9, -70, 19, "C", 4, 32, 0, 63, 0, 76, "c", 6, -7, 18, -17, 33, -23, 24, -9, 34, -9, 48, -20, -9, 10, -20, 16, -43, 24, "C", 22, 63, 8, 78, 3, 84, "z"].map(function(x) {
return isNaN(x) ? x : x*leafScale;
}).join(' ');
var translate = [properties.radius-(leafScale*100*0.5),properties.radius*1.5]
var icoLeaf = createSVGElement('path',{
class: 'dial__ico__leaf',
d: leafDef,
transform: 'translate('+translate[0]+','+translate[1]+')'
},svg);
/**
arrows
**/
var icoArrowUp = createSVGElement('path',{
class: 'dail_arrow_up'
},svg);
var arrowUpScale = 4;
var arrowUpDef = ["M", 12, 8 ,"l" ,-6, 6, 1.41, 1.41, "L", 12, 10.83, "l", 4.59, 4.58, "L", 18, 14, "z"].map(function(x) {
return isNaN(x) ? x : x*arrowUpScale;
}).join(' ');
var translateArrowUp = [properties.radius-(arrowUpScale*11),properties.radius*0.3]
var icoArrowUp = createSVGElement('path',{
class: 'dail_arrow_up',
d: arrowUpDef,
transform: 'translate('+translateArrowUp[0]+','+translateArrowUp[1]+')'
},svg);
icoArrowUp.addEventListener('click',function(Event){
Event.stopPropagation();
self.target_temperature += 0.5;
return false;
});
icoArrowUp.addEventListener('tap',function(Event){
Event.stopPropagation();
self.target_temperature += 0.5;
return false;
});
var icoArrowDown = createSVGElement('path',{
class: 'dail_arrow_down'
},svg);
var arrowDownScale = 4;
var arrowDownDef = ["M",16.59, 8.59,"L",12, 13.17, 7.41, 8.59, 6, 10, "l", 6, 6, 6, -6, "z"].map(function(x) {
return isNaN(x) ? x : x*arrowDownScale;
}).join(' ');
var translateArrowDown = [properties.radius-(arrowDownScale*11),properties.radius*1.3]
var icoArrowDown = createSVGElement('path',{
class: 'dail_arrow_down',
d: arrowDownDef,
transform: 'translate('+translateArrowDown[0]+','+translateArrowDown[1]+')'
},svg);
icoArrowDown.addEventListener('click',function(Event){
Event.stopPropagation();
self.target_temperature -= 0.5;
return false;
});
icoArrowDown.addEventListener('tap',function(Event){
Event.stopPropagation();
self.target_temperature -= 0.5;
return false;
});
// svg.addEventListener('touchend',dragStart);
/*
* RENDER
*/
function render() {
renderAway();
renderHvacState();
renderTicks();
renderTargetTemperature();
renderAmbientTemperature();
renderLeaf();
}
render();
/*
* RENDER - ticks
*/
function renderTicks() {
var vMin, vMax;
if (self.away) {
vMin = self.ambient_temperature;
vMax = vMin;
} else {
vMin = Math.min(self.ambient_temperature, self.target_temperature);
vMax = Math.max(self.ambient_temperature, self.target_temperature);
}
var min = restrictToRange(Math.round((vMin-options.minValue)/properties.rangeValue * options.numTicks),0,options.numTicks-1);
var max = restrictToRange(Math.round((vMax-options.minValue)/properties.rangeValue * options.numTicks),0,options.numTicks-1);
//
tickArray.forEach(function(tick,iTick) {
var isLarge = iTick==min || iTick==max;
var isActive = iTick >= min && iTick <= max;
attr(tick,{
d: pointsToPath(rotatePoints(isLarge ? tickPointsLarge: tickPoints,iTick*theta-properties.offsetDegrees,[properties.radius, properties.radius])),
class: isActive ? 'active' : ''
});
});
}
/*
* RENDER - ambient temperature
*/
function renderAmbientTemperature() {
lblAmbient_text.nodeValue = Math.floor(self.ambient_temperature);
if (self.ambient_temperature%1!=0) {
lblAmbient_text.nodeValue += '⁵';
}
var peggedValue = restrictToRange(self.ambient_temperature, options.minValue, options.maxValue);
degs = properties.tickDegrees * (peggedValue-options.minValue)/properties.rangeValue - properties.offsetDegrees;
if (peggedValue > self.target_temperature) {
degs += 8;
} else {
degs -= 8;
}
var pos = rotatePoint(properties.lblAmbientPosition,degs,[properties.radius, properties.radius]);
attr(lblAmbient,{
x: pos[0],
y: pos[1]
});
}
/*
* RENDER - target temperature
*/
function renderTargetTemperature() {
lblTarget_text.nodeValue = Math.floor(self.target_temperature);
setClass(lblTargetHalf,'shown',self.target_temperature%1!=0);
}
/*
* RENDER - leaf
*/
function renderLeaf() {
setClass(svg,'has-leaf',self.has_leaf);
}
/*
* RENDER - HVAC state
*/
function renderHvacState() {
Array.prototype.slice.call(svg.classList).forEach(function(c) {
if (c.match(/^dial--state--/)) {
svg.classList.remove(c);
};
});
svg.classList.add('dial--state--'+self.hvac_state);
}
/*
* RENDER - awau
*/
function renderAway() {
svg.classList[self.away ? 'add' : 'remove']('away');
}
/*
* Drag to control
*/
var _drag = {
inProgress: false,
startPoint: null,
startTemperature: 0,
lockAxis: undefined
};
function eventPosition(ev) {
if (ev.targetTouches && ev.targetTouches.length) {
return [ev.targetTouches[0].clientX, ev.targetTouches[0].clientY];
} else {
return [ev.x, ev.y];
};
}
var startDelay;
function dragStart(ev) {
startDelay = setTimeout(function() {
setClass(svg, 'dial--edit', true);
_drag.inProgress = true;
_drag.startPoint = eventPosition(ev);
_drag.startTemperature = self.target_temperature || options.minValue;
_drag.lockAxis = undefined;
},1000);
};
function dragEnd (ev) {
clearTimeout(startDelay);
setClass(svg, 'dial--edit', false);
if (!_drag.inProgress) return;
_drag.inProgress = false;
if (self.target_temperature != _drag.startTemperature) {
if (typeof options.onSetTargetTemperature == 'function') {
options.onSetTargetTemperature(self.target_temperature);
};
};
};
function dragMove(ev) {
ev.preventDefault();
if (!_drag.inProgress) return;
var evPos = eventPosition(ev);
var dy = _drag.startPoint[1]-evPos[1];
var dx = evPos[0] - _drag.startPoint[0];
var dxy;
if (_drag.lockAxis == 'x') {
dxy = dx;
} else if (_drag.lockAxis == 'y') {
dxy = dy;
} else if (Math.abs(dy) > properties.dragLockAxisDistance) {
_drag.lockAxis = 'y';
dxy = dy;
} else if (Math.abs(dx) > properties.dragLockAxisDistance) {
_drag.lockAxis = 'x';
dxy = dx;
} else {
dxy = (Math.abs(dy) > Math.abs(dx)) ? dy : dx;
};
var dValue = (dxy*getSizeRatio())/(options.diameter)*properties.rangeValue;
self.target_temperature = roundHalf(_drag.startTemperature+dValue);
}
svg.addEventListener('mousedown',dragStart);
svg.addEventListener('touchstart',dragStart);
svg.addEventListener('mouseup',dragEnd);
svg.addEventListener('mouseleave',dragEnd);
svg.addEventListener('touchend',dragEnd);
svg.addEventListener('mousemove',dragMove);
svg.addEventListener('touchmove',dragMove);
//
/*
* Helper functions
*/
function restrictTargetTemperature(t) {
return restrictToRange(roundHalf(t),options.minValue,options.maxValue);
}
function angle(point) {
var dx = point[0] - properties.radius;
var dy = point[1] - properties.radius;
var theta = Math.atan(dx/dy) / (Math.PI/180);
if (point[0]>=properties.radius && point[1] < properties.radius) {
theta = 90-theta - 90;
} else if (point[0]>=properties.radius && point[1] >= properties.radius) {
theta = 90-theta + 90;
} else if (point[0]<properties.radius && point[1] >= properties.radius) {
theta = 90-theta + 90;
} else if (point[0]<properties.radius && point[1] < properties.radius) {
theta = 90-theta+270;
}
return theta;
};
function getSizeRatio() {
return options.diameter / targetElement.clientWidth;
}
};
})();
/* ==== */
var smartThermostat;
</script>
This is my first time I create a custom UI. Hope you guys like it. If you have any suggestions let me know