NEST style thermostat Dashboard widget for Node-red

Yesterday I have found this nice peace of code work form Dal Hundal.  After playing around decided to port  it in NODE-RED so more people could use it and this great peace of work could be used without diving deeper into html and java-script. This became my favorite thermostat widget for my house room temperature control so far i came across on the net.

Fully responsive design. Touch enabled set-point makes it even more cool. Press and hold finger over and it will activate set point sliding function.

Also it has several display modes like heating, cooling and away. It makes it more intractable and user intuitive. For more ECO friendly there is possible to turn on and off that little green leaf.

Let’s try to port this in Node-red. Data exchange is based on topics. You can can push separate payloads with specific topic. If you change the set-point in the web browser it will trigger back payload to node-red with topic “target_temperature” and a value.

  • ambient_temperature, target_temperature are numeric (21.5) payloads.
  • hvac_state is string (off, heating, cooling) payload.
  • has_leave, away are boolean (true, false) payloads.

If you familiar with CSS and JAVA there are more stuff to customize, colors, ranges etc.

Some options in JAVA script:

options = {
 diameter: options.diameter || 400,
 minValue: options.minValue || 10, //Minimum value for target temperature
 maxValue: options.maxValue || 30, //Maximum value for target temperature
 numTicks: options.numTicks || 200, //Number of tick lines to display around the dial
 onSetTargetTemperature: options.onSetTargetTemperature || function() {}, // Function called when new target temperature set by the dial
};

Widget can be rendered anywhere in the page. You just have to include div with thermostat ID.

<div id="Mythermostat"></div>
var nest = new thermostatDial(document.getElementById('Mythermostat')

IF you have more than one thermostat widget make sure they have unique names to avoid  rendering wrong data to wrong divs.

Here is exported node for node-red-dashboard:

[{"id":"e58106c5.09e6f8","type":"ui_template","z":"af771ecf.61b81","group":"fc99c71b.6e1b68","name":"Nest","order":1,"width":"6","height":"6","format":"<div id=\"thermostat\"></div>\n\n<style>\n@import url(http://fonts.googleapis.com/css?family=Open+Sans:300);\n#thermostat {\n  margin: 0 auto;\n  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n.dial {\n  -webkit-user-select: none;\n     -moz-user-select: none;\n      -ms-user-select: none;\n          user-select: none;\n}\n.dial.away .dial__ico__leaf {\n  visibility: hidden;\n}\n.dial.away .dial__lbl--target {\n  visibility: hidden;\n}\n.dial.away .dial__lbl--target--half {\n  visibility: hidden;\n}\n.dial.away .dial__lbl--away {\n  opacity: 1;\n}\n.dial .dial__shape {\n  -webkit-transition: fill 0.5s;\n  transition: fill 0.5s;\n}\n.dial__ico__leaf {\n  fill: #13EB13;\n  opacity: 0;\n  -webkit-transition: opacity 0.5s;\n  transition: opacity 0.5s;\n  pointer-events: none;\n}\n.dial.has-leaf .dial__ico__leaf {\n  display: block;\n  opacity: 1;\n  pointer-events: initial;\n}\n.dial__editableIndicator {\n  fill: white;\n  fill-rule: evenodd;\n  opacity: 0;\n  -webkit-transition: opacity 0.5s;\n  transition: opacity 0.5s;\n}\n.dial--edit .dial__editableIndicator {\n  opacity: 1;\n}\n.dial--state--off .dial__shape {\n  fill: #3d3c3c;\n}\n.dial--state--heating .dial__shape {\n  fill: #E36304;\n}\n.dial--state--cooling .dial__shape {\n  fill: #007AF1;\n}\n.dial__ticks path {\n  fill: rgba(255, 255, 255, 0.3);\n}\n.dial__ticks path.active {\n  fill: rgba(255, 255, 255, 0.8);\n}\n.dial text {\n  fill: white;\n  text-anchor: middle;\n  font-family: Helvetica, sans-serif;\n  alignment-baseline: central;\n}\n.dial__lbl--target {\n  font-size: 120px;\n  font-weight: bold;\n}\n.dial__lbl--target--half {\n  font-size: 40px;\n  font-weight: bold;\n  opacity: 0;\n  -webkit-transition: opacity 0.1s;\n  transition: opacity 0.1s;\n}\n.dial__lbl--target--half.shown {\n  opacity: 1;\n  -webkit-transition: opacity 0s;\n  transition: opacity 0s;\n}\n.dial__lbl--ambient {\n  font-size: 22px;\n  font-weight: bold;\n}\n.dial__lbl--away {\n  font-size: 72px;\n  font-weight: bold;\n  opacity: 0;\n  pointer-events: none;\n}\n#controls {\n  font-family: Open Sans;\n  background-color: rgba(255, 255, 255, 0.25);\n  padding: 20px;\n  border-radius: 5px;\n  position: absolute;\n  left: 50%;\n  -webkit-transform: translatex(-50%);\n          transform: translatex(-50%);\n  margin-top: 20px;\n}\n#controls label {\n  text-align: left;\n  display: block;\n}\n#controls label span {\n  display: inline-block;\n  width: 200px;\n  text-align: right;\n  font-size: 0.8em;\n  text-transform: uppercase;\n}\n#controls p {\n  margin: 0;\n  margin-bottom: 1em;\n  padding-bottom: 1em;\n  border-bottom: 2px solid #ccc;\n}\n</style>\n<script>\n    var thermostatDial = (function() {\n\t\n\t/*\n\t * Utility functions\n\t */\n\t\n\t// Create an element with proper SVG namespace, optionally setting its attributes and appending it to another element\n\tfunction createSVGElement(tag,attributes,appendTo) {\n\t\tvar element = document.createElementNS('http://www.w3.org/2000/svg',tag);\n\t\tattr(element,attributes);\n\t\tif (appendTo) {\n\t\t\tappendTo.appendChild(element);\n\t\t}\n\t\treturn element;\n\t}\n\t\n\t// Set attributes for an element\n\tfunction attr(element,attrs) {\n\t\tfor (var i in attrs) {\n\t\t\telement.setAttribute(i,attrs[i]);\n\t\t}\n\t}\n\t\n\t// Rotate a cartesian point about given origin by X degrees\n\tfunction rotatePoint(point, angle, origin) {\n\t\tvar radians = angle * Math.PI/180;\n\t\tvar x = point[0]-origin[0];\n\t\tvar y = point[1]-origin[1];\n\t\tvar x1 = x*Math.cos(radians) - y*Math.sin(radians) + origin[0];\n\t\tvar y1 = x*Math.sin(radians) + y*Math.cos(radians) + origin[1];\n\t\treturn [x1,y1];\n\t}\n\t\n\t// Rotate an array of cartesian points about a given origin by X degrees\n\tfunction rotatePoints(points, angle, origin) {\n\t\treturn points.map(function(point) {\n\t\t\treturn rotatePoint(point, angle, origin);\n\t\t});\n\t}\n\t\n\t// Given an array of points, return an SVG path string representing the shape they define\n\tfunction pointsToPath(points) {\n\t\treturn points.map(function(point, iPoint) {\n\t\t\treturn (iPoint>0?'L':'M') + point[0] + ' ' + point[1];\n\t\t}).join(' ')+'Z';\n\t}\n\t\n\tfunction circleToPath(cx, cy, r) {\n\t\treturn [\n\t\t\t\"M\",cx,\",\",cy,\n\t\t\t\"m\",0-r,\",\",0,\n\t\t\t\"a\",r,\",\",r,0,1,\",\",0,r*2,\",\",0,\n\t\t\t\"a\",r,\",\",r,0,1,\",\",0,0-r*2,\",\",0,\n\t\t\t\"z\"\n\t\t].join(' ').replace(/\\s,\\s/g,\",\");\n\t}\n\t\n\tfunction donutPath(cx,cy,rOuter,rInner) {\n\t\treturn circleToPath(cx,cy,rOuter) + \" \" + circleToPath(cx,cy,rInner);\n\t}\n\t\n\t// Restrict a number to a min + max range\n\tfunction restrictToRange(val,min,max) {\n\t\tif (val < min) return min;\n\t\tif (val > max) return max;\n\t\treturn val;\n\t}\n\t\n\t// Round a number to the nearest 0.5\n\tfunction roundHalf(num) {\n\t\treturn Math.round(num*2)/2;\n\t}\n\t\n\tfunction setClass(el, className, state) {\n\t\tel.classList[state ? 'add' : 'remove'](className);\n\t}\n\t\n\t/*\n\t * The \"MEAT\"\n\t */\n\n\treturn function(targetElement, options) {\n\t\tvar self = this;\n\t\t\n\t\t/*\n\t\t * Options\n\t\t */\n\t\toptions = options || {};\n\t\toptions = {\n\t\t\tdiameter: options.diameter || 400,\n\t\t\tminValue: options.minValue || 10, // Minimum value for target temperature\n\t\t\tmaxValue: options.maxValue || 30, // Maximum value for target temperature\n\t\t\tnumTicks: options.numTicks || 200, // Number of tick lines to display around the dial\n\t\t\tonSetTargetTemperature: options.onSetTargetTemperature || function() {}, // Function called when new target temperature set by the dial\n\t\t};\n\t\t\n\t\t/*\n\t\t * Properties - calculated from options in many cases\n\t\t */\n\t\tvar properties = {\n\t\t\ttickDegrees: 300, //  Degrees of the dial that should be covered in tick lines\n\t\t\trangeValue: options.maxValue - options.minValue,\n\t\t\tradius: options.diameter/2,\n\t\t\tticksOuterRadius: options.diameter / 30,\n\t\t\tticksInnerRadius: options.diameter / 8,\n\t\t\thvac_states: ['off', 'heating', 'cooling'],\n\t\t\tdragLockAxisDistance: 15,\n\t\t}\n\t\tproperties.lblAmbientPosition = [properties.radius, properties.ticksOuterRadius-(properties.ticksOuterRadius-properties.ticksInnerRadius)/2]\n\t\tproperties.offsetDegrees = 180-(360-properties.tickDegrees)/2;\n\t\t\n\t\t/*\n\t\t * Object state\n\t\t */\n\t\tvar state = {\n\t\t\ttarget_temperature: options.minValue,\n\t\t\tambient_temperature: options.minValue,\n\t\t\thvac_state: properties.hvac_states[0],\n\t\t\thas_leaf: false,\n\t\t\taway: false\n\t\t};\n\t\t\n\t\t/*\n\t\t * Property getter / setters\n\t\t */\n\t\tObject.defineProperty(this,'target_temperature',{\n\t\t\tget: function() {\n\t\t\t\treturn state.target_temperature;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tstate.target_temperature = restrictTargetTemperature(+val);\n\t\t\t\trender();\n\t\t\t}\n\t\t});\n\t\tObject.defineProperty(this,'ambient_temperature',{\n\t\t\tget: function() {\n\t\t\t\treturn state.ambient_temperature;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tstate.ambient_temperature = roundHalf(+val);\n\t\t\t\trender();\n\t\t\t}\n\t\t});\n\t\tObject.defineProperty(this,'hvac_state',{\n\t\t\tget: function() {\n\t\t\t\treturn state.hvac_state;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tif (properties.hvac_states.indexOf(val)>=0) {\n\t\t\t\t\tstate.hvac_state = val;\n\t\t\t\t\trender();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\tObject.defineProperty(this,'has_leaf',{\n\t\t\tget: function() {\n\t\t\t\treturn state.has_leaf;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tstate.has_leaf = !!val;\n\t\t\t\trender();\n\t\t\t}\n\t\t});\n\t\tObject.defineProperty(this,'away',{\n\t\t\tget: function() {\n\t\t\t\treturn state.away;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tstate.away = !!val;\n\t\t\t\trender();\n\t\t\t}\n\t\t});\n\t\t\n\t\t/*\n\t\t * SVG\n\t\t */\n\t\tvar svg = createSVGElement('svg',{\n\t\t\twidth: '100%', //options.diameter+'px',\n\t\t\theight: '100%', //options.diameter+'px',\n\t\t\tviewBox: '0 0 '+options.diameter+' '+options.diameter,\n\t\t\tclass: 'dial'\n\t\t},targetElement);\n\t\t// CIRCULAR DIAL\n\t\tvar circle = createSVGElement('circle',{\n\t\t\tcx: properties.radius,\n\t\t\tcy: properties.radius,\n\t\t\tr: properties.radius,\n\t\t\tclass: 'dial__shape'\n\t\t},svg);\n\t\t// EDITABLE INDICATOR\n\t\tvar editCircle = createSVGElement('path',{\n\t\t\td: donutPath(properties.radius,properties.radius,properties.radius-4,properties.radius-8),\n\t\t\tclass: 'dial__editableIndicator',\n\t\t},svg);\n\t\t\n\t\t/*\n\t\t * Ticks\n\t\t */\n\t\tvar ticks = createSVGElement('g',{\n\t\t\tclass: 'dial__ticks'\t\n\t\t},svg);\n\t\tvar tickPoints = [\n\t\t\t[properties.radius-1, properties.ticksOuterRadius],\n\t\t\t[properties.radius+1, properties.ticksOuterRadius],\n\t\t\t[properties.radius+1, properties.ticksInnerRadius],\n\t\t\t[properties.radius-1, properties.ticksInnerRadius]\n\t\t];\n\t\tvar tickPointsLarge = [\n\t\t\t[properties.radius-1.5, properties.ticksOuterRadius],\n\t\t\t[properties.radius+1.5, properties.ticksOuterRadius],\n\t\t\t[properties.radius+1.5, properties.ticksInnerRadius+20],\n\t\t\t[properties.radius-1.5, properties.ticksInnerRadius+20]\n\t\t];\n\t\tvar theta = properties.tickDegrees/options.numTicks;\n\t\tvar tickArray = [];\n\t\tfor (var iTick=0; iTick<options.numTicks; iTick++) {\n\t\t\ttickArray.push(createSVGElement('path',{d:pointsToPath(tickPoints)},ticks));\n\t\t};\n\t\t\n\t\t/*\n\t\t * Labels\n\t\t */\n\t\tvar lblTarget = createSVGElement('text',{\n\t\t\tx: properties.radius,\n\t\t\ty: properties.radius,\n\t\t\tclass: 'dial__lbl dial__lbl--target'\n\t\t},svg);\n\t\tvar lblTarget_text = document.createTextNode('');\n\t\tlblTarget.appendChild(lblTarget_text);\n\t\t//\n\t\tvar lblTargetHalf = createSVGElement('text',{\n\t\t\tx: properties.radius + properties.radius/2.5,\n\t\t\ty: properties.radius - properties.radius/8,\n\t\t\tclass: 'dial__lbl dial__lbl--target--half'\n\t\t},svg);\n\t\tvar lblTargetHalf_text = document.createTextNode('5');\n\t\tlblTargetHalf.appendChild(lblTargetHalf_text);\n\t\t//\n\t\tvar lblAmbient = createSVGElement('text',{\n\t\t\tclass: 'dial__lbl dial__lbl--ambient'\n\t\t},svg);\n\t\tvar lblAmbient_text = document.createTextNode('');\n\t\tlblAmbient.appendChild(lblAmbient_text);\n\t\t//\n\t\tvar lblAway = createSVGElement('text',{\n\t\t\tx: properties.radius,\n\t\t\ty: properties.radius,\n\t\t\tclass: 'dial__lbl dial__lbl--away'\n\t\t},svg);\n\t\tvar lblAway_text = document.createTextNode('AWAY');\n\t\tlblAway.appendChild(lblAway_text);\n\t\t//\n\t\tvar icoLeaf = createSVGElement('path',{\n\t\t\tclass: 'dial__ico__leaf'\n\t\t},svg);\n\t\t\n\t\t/*\n\t\t * LEAF\n\t\t */\n\t\tvar leafScale = properties.radius/5/100;\n\t\tvar 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) {\n\t\t\treturn isNaN(x) ? x : x*leafScale;\n\t\t}).join(' ');\n\t\tvar translate = [properties.radius-(leafScale*100*0.5),properties.radius*1.5]\n\t\tvar icoLeaf = createSVGElement('path',{\n\t\t\tclass: 'dial__ico__leaf',\n\t\t\td: leafDef,\n\t\t\ttransform: 'translate('+translate[0]+','+translate[1]+')'\n\t\t},svg);\n\t\t\t\n\t\t/*\n\t\t * RENDER\n\t\t */\n\t\tfunction render() {\n\t\t\trenderAway();\n\t\t\trenderHvacState();\n\t\t\trenderTicks();\n\t\t\trenderTargetTemperature();\n\t\t\trenderAmbientTemperature();\n\t\t\trenderLeaf();\n\t\t}\n\t\trender();\n\n\t\t/*\n\t\t * RENDER - ticks\n\t\t */\n\t\tfunction renderTicks() {\n\t\t\tvar vMin, vMax;\n\t\t\tif (self.away) {\n\t\t\t\tvMin = self.ambient_temperature;\n\t\t\t\tvMax = vMin;\n\t\t\t} else {\n\t\t\t\tvMin = Math.min(self.ambient_temperature, self.target_temperature);\n\t\t\t\tvMax = Math.max(self.ambient_temperature, self.target_temperature);\n\t\t\t}\n\t\t\tvar min = restrictToRange(Math.round((vMin-options.minValue)/properties.rangeValue * options.numTicks),0,options.numTicks-1);\n\t\t\tvar max = restrictToRange(Math.round((vMax-options.minValue)/properties.rangeValue * options.numTicks),0,options.numTicks-1);\n\t\t\t//\n\t\t\ttickArray.forEach(function(tick,iTick) {\n\t\t\t\tvar isLarge = iTick==min || iTick==max;\n\t\t\t\tvar isActive = iTick >= min && iTick <= max;\n\t\t\t\tattr(tick,{\n\t\t\t\t\td: pointsToPath(rotatePoints(isLarge ? tickPointsLarge: tickPoints,iTick*theta-properties.offsetDegrees,[properties.radius, properties.radius])),\n\t\t\t\t\tclass: isActive ? 'active' : ''\n\t\t\t\t});\n\t\t\t});\n\t\t}\n\t\n\t\t/*\n\t\t * RENDER - ambient temperature\n\t\t */\n\t\tfunction renderAmbientTemperature() {\n\t\t\tlblAmbient_text.nodeValue = Math.floor(self.ambient_temperature);\n\t\t\tif (self.ambient_temperature%1!=0) {\n\t\t\t\tlblAmbient_text.nodeValue += '⁵';\n\t\t\t}\n\t\t\tvar peggedValue = restrictToRange(self.ambient_temperature, options.minValue, options.maxValue);\n\t\t\tdegs = properties.tickDegrees * (peggedValue-options.minValue)/properties.rangeValue - properties.offsetDegrees;\n\t\t\tif (peggedValue > self.target_temperature) {\n\t\t\t\tdegs += 8;\n\t\t\t} else {\n\t\t\t\tdegs -= 8;\n\t\t\t}\n\t\t\tvar pos = rotatePoint(properties.lblAmbientPosition,degs,[properties.radius, properties.radius]);\n\t\t\tattr(lblAmbient,{\n\t\t\t\tx: pos[0],\n\t\t\t\ty: pos[1]\n\t\t\t});\n\t\t}\n\n\t\t/*\n\t\t * RENDER - target temperature\n\t\t */\n\t\tfunction renderTargetTemperature() {\n\t\t\tlblTarget_text.nodeValue = Math.floor(self.target_temperature);\n\t\t\tsetClass(lblTargetHalf,'shown',self.target_temperature%1!=0);\n\t\t}\n\t\t\n\t\t/*\n\t\t * RENDER - leaf\n\t\t */\n\t\tfunction renderLeaf() {\n\t\t\tsetClass(svg,'has-leaf',self.has_leaf);\n\t\t}\n\t\t\n\t\t/*\n\t\t * RENDER - HVAC state\n\t\t */\n\t\tfunction renderHvacState() {\n\t\t\tArray.prototype.slice.call(svg.classList).forEach(function(c) {\n\t\t\t\tif (c.match(/^dial--state--/)) {\n\t\t\t\t\tsvg.classList.remove(c);\n\t\t\t\t};\n\t\t\t});\n\t\t\tsvg.classList.add('dial--state--'+self.hvac_state);\n\t\t}\n\t\t\n\t\t/*\n\t\t * RENDER - away\n\t\t */\n\t\tfunction renderAway() {\n\t\t\tsvg.classList[self.away ? 'add' : 'remove']('away');\n\t\t}\n\t\t\n\t\t/*\n\t\t * Drag to control\n\t\t */\n\t\tvar _drag = {\n\t\t\tinProgress: false,\n\t\t\tstartPoint: null,\n\t\t\tstartTemperature: 0,\n\t\t\tlockAxis: undefined\n\t\t};\n\t\t\n\t\tfunction eventPosition(ev) {\n\t\t\tif (ev.targetTouches && ev.targetTouches.length) {\n\t\t\t\treturn  [ev.targetTouches[0].clientX, ev.targetTouches[0].clientY];\n\t\t\t} else {\n\t\t\t\treturn [ev.x, ev.y];\n\t\t\t};\n\t\t}\n\t\t\n\t\tvar startDelay;\n\t\tfunction dragStart(ev) {\n\t\t\tstartDelay = setTimeout(function() {\n\t\t\t\tsetClass(svg, 'dial--edit', true);\n\t\t\t\t_drag.inProgress = true;\n\t\t\t\t_drag.startPoint = eventPosition(ev);\n\t\t\t\t_drag.startTemperature = self.target_temperature || options.minValue;\n\t\t\t\t_drag.lockAxis = undefined;\n\t\t\t},1000);\n\t\t};\n\t\t\n\t\tfunction dragEnd (ev) {\n\t\t\tclearTimeout(startDelay);\n\t\t\tsetClass(svg, 'dial--edit', false);\n\t\t\tif (!_drag.inProgress) return;\n\t\t\t_drag.inProgress = false;\n\t\t\tif (self.target_temperature != _drag.startTemperature) {\n\t\t\t\tif (typeof options.onSetTargetTemperature == 'function') {\n\t\t\t\t\toptions.onSetTargetTemperature(self.target_temperature);\n\t\t\t\t};\n\t\t\t};\n\t\t};\n\t\t\n\t\tfunction dragMove(ev) {\n\t\t\tev.preventDefault();\n\t\t\tif (!_drag.inProgress) return;\n\t\t\tvar evPos =  eventPosition(ev);\n\t\t\tvar dy = _drag.startPoint[1]-evPos[1];\n\t\t\tvar dx = evPos[0] - _drag.startPoint[0];\n\t\t\tvar dxy;\n\t\t\tif (_drag.lockAxis == 'x') {\n\t\t\t\tdxy  = dx;\n\t\t\t} else if (_drag.lockAxis == 'y') {\n\t\t\t\tdxy = dy;\n\t\t\t} else if (Math.abs(dy) > properties.dragLockAxisDistance) {\n\t\t\t\t_drag.lockAxis = 'y';\n\t\t\t\tdxy = dy;\n\t\t\t} else if (Math.abs(dx) > properties.dragLockAxisDistance) {\n\t\t\t\t_drag.lockAxis = 'x';\n\t\t\t\tdxy = dx;\n\t\t\t} else {\n\t\t\t\tdxy = (Math.abs(dy) > Math.abs(dx)) ? dy : dx;\n\t\t\t};\n\t\t\tvar dValue = (dxy*getSizeRatio())/(options.diameter)*properties.rangeValue;\n\t\t\tself.target_temperature = roundHalf(_drag.startTemperature+dValue);\n\t\t}\n\t\t\n\t\tsvg.addEventListener('mousedown',dragStart);\n\t\tsvg.addEventListener('touchstart',dragStart);\n\t\t\n\t\tsvg.addEventListener('mouseup',dragEnd);\n\t\tsvg.addEventListener('mouseleave',dragEnd);\n\t\tsvg.addEventListener('touchend',dragEnd);\n\t\t\n\t\tsvg.addEventListener('mousemove',dragMove);\n\t\tsvg.addEventListener('touchmove',dragMove);\n\t\t//\n\t\t\n\t\t/*\n\t\t * Helper functions\n\t\t */\n\t\tfunction restrictTargetTemperature(t) {\n\t\t\treturn restrictToRange(roundHalf(t),options.minValue,options.maxValue);\n\t\t}\n\t\t\n\t\tfunction angle(point) {\n\t\t\tvar dx = point[0] - properties.radius;\n\t\t\tvar dy = point[1] - properties.radius;\n\t\t\tvar theta = Math.atan(dx/dy) / (Math.PI/180);\n\t\t\tif (point[0]>=properties.radius && point[1] < properties.radius) {\n\t\t\t\ttheta = 90-theta - 90;\n\t\t\t} else if (point[0]>=properties.radius && point[1] >= properties.radius) {\n\t\t\t\ttheta = 90-theta + 90;\n\t\t\t} else if (point[0]<properties.radius && point[1] >= properties.radius) {\n\t\t\t\ttheta = 90-theta + 90;\n\t\t\t} else if (point[0]<properties.radius && point[1] < properties.radius) {\n\t\t\t\ttheta = 90-theta+270;\n\t\t\t}\n\t\t\treturn theta;\n\t\t};\n\t\t\n\t\tfunction getSizeRatio() {\n\t\t\treturn options.diameter / targetElement.clientWidth;\n\t\t}\n\t\t\n\t};\n})();\n\n/* ==== */\n(function(scope) {\n    \n    var nest = new thermostatDial(document.getElementById('thermostat'),{\n    \tonSetTargetTemperature: function(v) {\n    \t\tscope.send({topic: \"target_temperature\", payload: v});\n    \t}\n    });\n\n\n    scope.$watch('msg', function(data) {\n        //console.log(data.topic+\"  \"+data.payload);\n        if (data.topic == \"ambient_temperature\") {\n            nest.ambient_temperature = data.payload;\n        } if (data.topic == \"target_temperature\") {\n            nest.target_temperature = data.payload;\n        } if (data.topic == \"hvac_state\") {\n            nest.hvac_state = data.payload;\n        } if (data.topic == \"has_leaf\") {\n            nest.has_leaf = data.payload;\n        } if (data.topic == \"away\") {\n            nest.away = data.payload;\n        }\n    });\n})(scope);\n\n</script>","storeOutMessages":true,"fwdInMessages":false,"x":750,"y":660,"wires":[["6243968f.9b9dc8"]]},{"id":"3089c813.cd8af8","type":"function","z":"af771ecf.61b81","name":"ambient_temperature","func":"msg.topic = \"ambient_temperature\";\nreturn msg;","outputs":1,"noerr":0,"x":540,"y":660,"wires":[["e58106c5.09e6f8"]]},{"id":"c7b89cf3.827e6","type":"function","z":"af771ecf.61b81","name":"target_temperature","func":"msg.topic = \"target_temperature\";\nreturn msg;","outputs":1,"noerr":0,"x":530,"y":700,"wires":[["e58106c5.09e6f8"]]},{"id":"538f357a.82df3c","type":"function","z":"af771ecf.61b81","name":"hvac_state","func":"msg.topic = \"hvac_state\";\nreturn msg;","outputs":1,"noerr":0,"x":510,"y":740,"wires":[["e58106c5.09e6f8"]]},{"id":"164b470d.30c139","type":"function","z":"af771ecf.61b81","name":"has_leaf","func":"msg.topic = \"has_leaf\";\nreturn msg;","outputs":1,"noerr":0,"x":500,"y":780,"wires":[["e58106c5.09e6f8"]]},{"id":"1b0a2da5.7ebbf2","type":"function","z":"af771ecf.61b81","name":"away","func":"msg.topic = \"away\";\nreturn msg;","outputs":1,"noerr":0,"x":490,"y":820,"wires":[["e58106c5.09e6f8"]]},{"id":"2d99f44b.60791c","type":"inject","z":"af771ecf.61b81","name":"","topic":"has_leaf","payload":"true","payloadType":"bool","repeat":"","crontab":"","once":true,"x":230,"y":860,"wires":[["164b470d.30c139"]]},{"id":"4cf623ab.a4630c","type":"inject","z":"af771ecf.61b81","name":"","topic":"has_leaf","payload":"false","payloadType":"bool","repeat":"","crontab":"","once":false,"x":230,"y":900,"wires":[["164b470d.30c139"]]},{"id":"f44a5d52.8fcd6","type":"inject","z":"af771ecf.61b81","name":"","topic":"away","payload":"true","payloadType":"bool","repeat":"","crontab":"","once":false,"x":220,"y":940,"wires":[["1b0a2da5.7ebbf2"]]},{"id":"cfb7f3a9.74b9f","type":"inject","z":"af771ecf.61b81","name":"","topic":"away","payload":"false","payloadType":"bool","repeat":"","crontab":"","once":false,"x":220,"y":980,"wires":[["1b0a2da5.7ebbf2"]]},{"id":"7e6ba13a.52cdf","type":"inject","z":"af771ecf.61b81","name":"","topic":"hvac_state","payload":"off","payloadType":"str","repeat":"","crontab":"","once":false,"x":230,"y":740,"wires":[["538f357a.82df3c"]]},{"id":"42ee10d6.b6cad","type":"inject","z":"af771ecf.61b81","name":"","topic":"hvac_state","payload":"heating","payloadType":"str","repeat":"","crontab":"","once":false,"x":250,"y":780,"wires":[["538f357a.82df3c"]]},{"id":"a915eb2a.a78de8","type":"inject","z":"af771ecf.61b81","name":"","topic":"hvac_state","payload":"cooling","payloadType":"str","repeat":"","crontab":"","once":false,"x":250,"y":820,"wires":[["538f357a.82df3c"]]},{"id":"6a4e43fe.d1affc","type":"inject","z":"af771ecf.61b81","name":"","topic":"ambient_temperature","payload":"21.5","payloadType":"num","repeat":"","crontab":"","once":true,"x":270,"y":660,"wires":[["3089c813.cd8af8"]]},{"id":"f2324a98.a641f8","type":"inject","z":"af771ecf.61b81","name":"","topic":"target_temperature","payload":"20","payloadType":"num","repeat":"","crontab":"","once":true,"x":260,"y":700,"wires":[["c7b89cf3.827e6"]]},{"id":"6243968f.9b9dc8","type":"function","z":"af771ecf.61b81","name":"target_temperature","func":"if (msg.topic == \"target_temperature\") {\nreturn msg;\n}","outputs":1,"noerr":0,"x":930,"y":660,"wires":[["c06229e0.1407e8"]]},{"id":"c06229e0.1407e8","type":"debug","z":"af771ecf.61b81","name":"","active":true,"console":"false","complete":"false","x":1130,"y":660,"wires":[]},{"id":"fc99c71b.6e1b68","type":"ui_group","z":"","name":"Nest widget","tab":"3854b451.89111c","order":2,"disp":true,"width":"6"},{"id":"3854b451.89111c","type":"ui_tab","z":"","name":"Show","icon":"dashboard"}]

13 thoughts on “NEST style thermostat Dashboard widget for Node-red

  1. Thanks for this – it makes customising the code much easier.

    I’m having problems refreshing the display after changing tabs on the dashboard. The ambient temp updates every minute, so that’s not too bad, but the target temp is static and does not re-load. I’m currently using MQTT as the store for these settings, and it only seems to publish on change. Have you found a way to get around this?

    1. Good point S and welcome as the first commented!
      in my case i’m pushing JSON directly from thermostats more frequently so there is no notable first update delay.
      But i think workaround will be to use GLOBAL variable in node-red. Try to create a function after you receive MQTT message store values to global variable, then read value from global and to push to thermostat widget.

      It does something similar in node-red dashboard text/value fields (it loads last value in the browser even if there is no MQTT message), but template node does not store last value so you have to take care manually.

  2. Hello! I would like to say thanks for share this Nest interface ported to NODE-Red. I always loved NEST hardware and interface and finally I´ve Integrated into a DIY solution to work as my house IOT Thermostat which runs on RaspberryPi as server for MQTT Broker/NODE-Red and a ITEAD WiFi Relay (https://www.itead.cc/inching-self-locking-wifi-wireless-switch.html) hacked with Temp/Hum Sensor running ESPurna Firmware to monitoring room temperature/hum and set the desired with Nest interface.

    Some Pics:
    https://twitter.com/AlexCorvis84/status/930949683379343360

    Also, I´m suffering the same behaviour as user @smadds detailed. All is working fine, but when I login into UI Dashboard from another device, the the target temp is static and does not re-load with the previous value I set from different device and it is set as minimum-default.

    I will try to integrate the tip you metioned about adding a function and save the value as GLOBAL in NODE-Red.

    I´ll keep you updated. Meanwhile, let me say BIG THANKS again!

  3. Hi II created a global varable that holds the temperature from MQTT (updates every 30 Seconds) then edited the nest template at line 613 ish :-

    scope.$watch(‘msg’, function(data) {
    //console.log(data.topic+” “+data.payload);
    //if (data.topic == “ambient_temperature”) {
    nest.ambient_temperature = (global.get (“OurRoomAmbient”));
    …………..
    It dosn’t work and i didn’t really expect it to be that easy (i am a noob at script), but could this be sort of pointing in right direction ? I am using it in conjunction with ramp-thermostat.. a great node.

    1. I would suggest to use @smadds approach and use “UI control” block to trigger and load persistent value in to widget. It is easy to mage that way not checking several hundreds of code lines 😉 every time you want to change something.

      You just have to push data in block and you will get the output. You have to make on periodical and on UI change update push messages.

      As periodical updates is not big deal to make, UI changes can be make with UI change block as Simon mentioned.

      https://gist.github.com/smadds/c27acc599cb9d8c983c5696c27fedd02

  4. Thanks i will give it a go later tonight. I will leave the code alone..over 600 lines to break!
    also is there any issues with using multiple instances, I need 12!

  5. To reload settings on connect make a function block with this code.

    if(msg.payload == “connect” || msg.payload == “lost”)
    return RED.util.cloneMessage(context.get(“state”));

    context.set(“state”, RED.util.cloneMessage(msg) );
    return msg;

    Connect to the input your input message and the “UI Control”, connect the output to the nest. You need one per nest input.

    It just stores the last message it got (cloned so it stays the same) and then send it when ui events are received (again a clone in case it changes).

    You can have more than one in a page but for each one you must change the id of the div at the top, in the css and the reference to it in the javascript at the bottom (in the call to getElemtentById).

  6. Hi, did you update your blog post since these comments were made? The reason is that I have just imported your flow, and my new target temp seems to show fine on a second device, or when I refresh the page on the first device. The only thing that doesn’t have the updated value is the ambient temp (opposite to user @smadds) which is to be expected, as this value is just coming from an inject node… presumably if this were hooked up to a heating system it would update regularly.

    By the way, have you got this working with the actual Nest hardware, using node-red-contrib-nest? If so could you post your flow? I’m struggling a lot with trying to work out when, how, and how often to get values from Nest API and how to set target temps etc. back to the Nest device!

    Cheers

    Mat

  7. Hi
    just loaded a new pi with latest version of node red (v0.19.5) and the leaf is no longer green, it goes the same color as the color set in node red theme, horrible blue as default.
    Is there a simple way to make the leaf stay green no matter what the theme settings are.
    I have tried changing:
    .dial__ico__leaf {
    fill: #00ff00;
    This seems to have no effect, as you can proberbly guess i am not too good at scripts.
    Great Widget! I have 12 of them running on one page!
    Many Thanks.

Leave a Reply