Virtual alike NEST Thermostat

#1

Hi all,

I would like to share with you the project I did with Node-RED.

As I work with shifts and it was being a nightmare to setup my Thermostat every week, created virtual one with Node-RED to control my Heater from any device (PC/Mobile/Tablet). I always loved the control of NEST Thermostat, so I was able to gather and create my own solution thanks to the work done from another guys.

I published all the info related in Hackster.io. There you have the code too.

Hope you like it! :slight_smile:

https://www.hackster.io/alex-corvis-84/diy-virtual-alike-nest-thermostat-with-node-red-04e6f4

1 Like
#2

Nice, thanks for sharing! I’ve just invested in the Draytek Wiser system myself with a whole bunch of TRV radiator controls.

One small improvement that I’d personally suggest. Ditch the DHT. Even the DHT22 is pretty poor at what it does. There was a time when prices meant that DHT’s were the only realistic game in town but that is no longer the case. You might look for ones based on the HTU21D, the HDC1080 or the BMP280 (or BME280 with pressure sensor). The 280’s are particularly well supported with libraries.

Also, you mention that Nest allows control from anywhere but your instructions are really only for when your browser is on the same network as the device. You might consider some information or at least pointers for people to think about how they could extent this and what the dangers are.

1 Like
#3

I can also recommend the SHT3xx from Sensirion, very accurate and modules are cheap from China.

2 Likes
#4

Thank for your comments and suggestions!

Please note that did not put the specific instructions about how to connect from outside, but mentioned that you can do It taking advantage of ddns services or alike! Of course, I have secured my Node-RED installation as well!

Glad you like It! :wink:

#5

Thanks for posting. I had fun messing around with it.
I live in Florida, no heating required :slight_smile:
I modified it for cooling via an A/C unit, added basic hysteresis, using Fahrenheit and added flow context for restoring the last sensor reading when hitting the page (no waiting for the next mqtt msg).

That ui template is awesome! …and I thought I had complex templates.

Well done!

Here’s the flow:

[{"id":"77885a61.45ca84","type":"mqtt in","z":"6843e799.d2d59","name":"","topic":"home/temp","qos":"2","broker":"37072e0d.0bdb32","x":80,"y":40,"wires":[["e422ce75.195f3","9472162d.7ffae8","b3865c14.c63ec8","6e0890e7.444eb8","2e179261.d9f6a6"]]},{"id":"8d2a15d4.f6b99","type":"mqtt out","z":"6843e799.d2d59","name":"","topic":"home/thermostat/state","qos":"","retain":"","broker":"37072e0d.0bdb32","x":840,"y":160,"wires":[]},{"id":"e422ce75.195f3","type":"function","z":"6843e799.d2d59","name":"Convert TEMP","func":"msg.payload = parseFloat(msg.payload);\nflow.set('lastTemp',msg.payload);//I added\nmsg.topic = 'sensor_temperature';\nreturn msg;","outputs":1,"noerr":0,"x":360,"y":120,"wires":[["2f95bdd6.47a17a","b1830a29.1006e"]]},{"id":"2f95bdd6.47a17a","type":"debug","z":"6843e799.d2d59","name":"Temp Sensor","active":true,"console":"false","complete":"payload","x":610,"y":120,"wires":[]},{"id":"9472162d.7ffae8","type":"ui_gauge","z":"6843e799.d2d59","name":"Gauge: Temp","group":"3c5607d.68b0878","order":1,"width":0,"height":0,"gtype":"gage","title":"Temp","label":"units","format":"{{value}} ºF","min":"70","max":"90","colors":["#0f5ef0","#6412d3","#b516e9"],"seg1":"","seg2":"","x":360,"y":40,"wires":[]},{"id":"50a985ce.d7e604","type":"ui_gauge","z":"6843e799.d2d59","name":"Gauge: Hum","group":"3c5607d.68b0878","order":2,"width":0,"height":0,"gtype":"gage","title":"Humidity","label":"units","format":"{{value}}","min":0,"max":"100","colors":["#00b500","#e6e600","#ca3838"],"seg1":"45","seg2":"70","x":310,"y":280,"wires":[]},{"id":"66268793.857b28","type":"mqtt in","z":"6843e799.d2d59","name":"","topic":"home/humidity","qos":"2","broker":"37072e0d.0bdb32","x":100,"y":300,"wires":[["50a985ce.d7e604","5eccd591.393294"]]},{"id":"edf3c70f.2ff0f","type":"ui_chart","z":"6843e799.d2d59","name":"","group":"c7b57a74.e224a","order":2,"width":0,"height":0,"label":"Humidity","chartType":"line","legend":"true","xformat":"HH:mm","interpolate":"linear","nodata":"","dot":false,"ymin":"30","ymax":"70","removeOlder":"3","removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"colors":["#1f77b4","#aec7e8","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"useOldStyle":false,"x":300,"y":340,"wires":[[],[]]},{"id":"b3865c14.c63ec8","type":"change","z":"6843e799.d2d59","name":"","rules":[{"t":"set","p":"topic","pt":"msg","to":"Temperature","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":350,"y":80,"wires":[["a3e9a941.20fdb"]]},{"id":"5eccd591.393294","type":"change","z":"6843e799.d2d59","name":"","rules":[{"t":"set","p":"topic","pt":"msg","to":"Humidity","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":110,"y":340,"wires":[["edf3c70f.2ff0f"]]},{"id":"a6aae9ca.7b79a","type":"ui_template","z":"6843e799.d2d59","group":"7202b03.bde625","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 || 70, // Minimum value for target temperature\n\t\t\tmaxValue: options.maxValue || 90, // 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,"templateScope":"local","x":510,"y":320,"wires":[["2107f73e.7dc2d"]]},{"id":"6e0890e7.444eb8","type":"function","z":"6843e799.d2d59","name":"ambient_temperature","func":"msg.topic = \"ambient_temperature\";\nreturn msg;","outputs":1,"noerr":0,"x":380,"y":200,"wires":[["a6aae9ca.7b79a"]]},{"id":"5920db.f850c724","type":"function","z":"6843e799.d2d59","name":"hvac_state","func":"global.set(\"color-state\",msg.payload);\n\nmsg.topic = \"hvac_state\";\nreturn msg;","outputs":1,"noerr":0,"x":1130,"y":380,"wires":[["a6aae9ca.7b79a"]]},{"id":"2107f73e.7dc2d","type":"function","z":"6843e799.d2d59","name":"target_temp","func":"global.set(\"room-target\",msg.payload);\n\nif (msg.topic == \"target_temperature\") {\nreturn msg;\n}","outputs":1,"noerr":0,"x":650,"y":320,"wires":[["740d8b79.d23e04","b1830a29.1006e","619d722a.b93c7c"]]},{"id":"740d8b79.d23e04","type":"debug","z":"6843e799.d2d59","name":"Target Temp","active":true,"console":"false","complete":"payload","x":830,"y":280,"wires":[]},{"id":"b1830a29.1006e","type":"function","z":"6843e799.d2d59","name":"Compare TEMPs","func":"context.target = context.target || 0.0;\ncontext.sensor = context.sensor || 0.0;\n\nif (msg.topic === 'sensor_temperature') {\n  context.sensor = msg.payload;\n} else if (msg.topic === 'target_temperature') {\n  context.target = msg.payload;\n}\n\n//Cooling with hysteresis\nvar setpoint = context.target;\nvar hysteresis = 1;\nvar current = context.sensor;\nif (current >= (setpoint+hysteresis)) {\n    msg.payload = 1;\n}\nelse if(current <= (setpoint-hysteresis)) {\n\tmsg.payload = 0;\n}\nreturn msg;\n\n// No hysteresis below, typically not desired\n//Heating\n//if (context.target >= context.sensor) {\n//  return {payload: 1};\n//} else {\n//  return {payload: 0};\n//}\n\n//Cooling\n//if (context.target >= context.sensor) {\n//  return {payload: 0};\n//} else {\n//  return {payload: 1};\n//}","outputs":1,"noerr":0,"x":630,"y":160,"wires":[["8d2a15d4.f6b99","fdb3dd5a.ca07b"]]},{"id":"fdb3dd5a.ca07b","type":"function","z":"6843e799.d2d59","name":"State Color Nest","func":"msg.topic = \"hvac_state\";\nreturn msg;","outputs":1,"noerr":0,"x":820,"y":120,"wires":[["33272eae.29b702","e38fae81.7393b"]]},{"id":"33272eae.29b702","type":"debug","z":"6843e799.d2d59","name":"STATE","active":true,"console":"false","complete":"payload","x":810,"y":80,"wires":[]},{"id":"e38fae81.7393b","type":"switch","z":"6843e799.d2d59","name":"","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"0","vt":"str"},{"t":"eq","v":"1","vt":"str"}],"checkall":"true","repair":false,"outputs":2,"x":990,"y":320,"wires":[["161c0507.322ac3"],["704a7a16.5c6be4"]]},{"id":"161c0507.322ac3","type":"template","z":"6843e799.d2d59","name":"Cooling OFF","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"off","output":"str","x":1130,"y":300,"wires":[["5920db.f850c724","dacf5640.8f6988"]]},{"id":"704a7a16.5c6be4","type":"template","z":"6843e799.d2d59","name":"Cooling ON","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"cooling","output":"str","x":1130,"y":340,"wires":[["5920db.f850c724","dacf5640.8f6988"]]},{"id":"dacf5640.8f6988","type":"debug","z":"6843e799.d2d59","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","x":1290,"y":340,"wires":[]},{"id":"619d722a.b93c7c","type":"change","z":"6843e799.d2d59","name":"","rules":[{"t":"set","p":"topic","pt":"msg","to":"Desired","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":830,"y":320,"wires":[["669ade33.cc59a"]]},{"id":"a3e9a941.20fdb","type":"ui_chart","z":"6843e799.d2d59","name":"","group":"c7b57a74.e224a","order":1,"width":0,"height":0,"label":"Temp","chartType":"line","legend":"true","xformat":"HH:mm:ss","interpolate":"linear","nodata":"","dot":false,"ymin":"70","ymax":"90","removeOlder":"3","removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"colors":["#1f77b4","#aec7e8","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"useOldStyle":false,"x":510,"y":80,"wires":[[],[]]},{"id":"669ade33.cc59a","type":"ui_chart","z":"6843e799.d2d59","name":"","group":"7202b03.bde625","order":0,"width":0,"height":0,"label":"Tempº Desired","chartType":"line","legend":"true","xformat":"HH:mm:ss","interpolate":"linear","nodata":"","dot":false,"ymin":"70","ymax":"90","removeOlder":"3","removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"colors":["#1f77b4","#aec7e8","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"useOldStyle":false,"x":840,"y":380,"wires":[[],[]]},{"id":"6354f567.7cb964","type":"ui_ui_control","z":"6843e799.d2d59","name":"ui change","x":80,"y":500,"wires":[["1783cd19.1e54fb"]]},{"id":"1783cd19.1e54fb","type":"delay","z":"6843e799.d2d59","name":"","pauseType":"delay","timeout":"1","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"x":80,"y":540,"wires":[["fec3fddb.ee1e1","8bb43645.a7bfc8","7404c612.22c64"]]},{"id":"fec3fddb.ee1e1","type":"function","z":"6843e799.d2d59","name":"global target-temp","func":"msg.payload = global.get(\"room-target\");\nmsg.topic = 'target_temperature';\nreturn msg;","outputs":1,"noerr":0,"x":290,"y":537,"wires":[["590ea488.97d3bc","a6aae9ca.7b79a"]]},{"id":"590ea488.97d3bc","type":"debug","z":"6843e799.d2d59","name":"global-target-temp","active":true,"console":"false","complete":"payload","x":290,"y":500,"wires":[]},{"id":"8bb43645.a7bfc8","type":"function","z":"6843e799.d2d59","name":"global color-state","func":"msg.payload = global.get(\"color-state\");\nmsg.topic = \"hvac_state\";\nreturn msg;","outputs":1,"noerr":0,"x":290,"y":611,"wires":[["a6aae9ca.7b79a","9b6e7309.cf51d"]]},{"id":"9b6e7309.cf51d","type":"debug","z":"6843e799.d2d59","name":"global-color-state","active":true,"console":"false","complete":"payload","x":290,"y":573,"wires":[]},{"id":"2e179261.d9f6a6","type":"function","z":"6843e799.d2d59","name":"Leaf","func":"minleaf = 76;//18 heat\nmaxleaf = 80;//22 heat\ntemperature = msg.payload;\nmsg.payload=false;//no Leaf\nif (temperature >= minleaf){\n    if (temperature <= maxleaf){\n        msg.payload = true;//has Leaf\n    }\n}\nmsg.topic = \"has_leaf\";\nreturn msg;","outputs":1,"noerr":0,"x":330,"y":160,"wires":[["a6aae9ca.7b79a"]]},{"id":"3a617457.c0a684","type":"inject","z":"6843e799.d2d59","name":"","topic":"","payload":"78.5","payloadType":"num","repeat":"","crontab":"","once":true,"onceDelay":0.1,"x":90,"y":136,"wires":[["e6c8dd72.66ce28"]]},{"id":"92ec7ca5.6bd86","type":"inject","z":"6843e799.d2d59","name":"Test - Clear Graphs","topic":"","payload":"","payloadType":"str","repeat":"","crontab":"","once":false,"onceDelay":0.1,"x":130,"y":400,"wires":[["22d1a068.7b82d8"]]},{"id":"22d1a068.7b82d8","type":"change","z":"6843e799.d2d59","name":"[]","rules":[{"t":"set","p":"payload","pt":"msg","to":"[]","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":290,"y":400,"wires":[["edf3c70f.2ff0f","a3e9a941.20fdb","669ade33.cc59a"]]},{"id":"b24e100f.a2416","type":"inject","z":"6843e799.d2d59","name":"","topic":"","payload":"45.7","payloadType":"num","repeat":"","crontab":"","once":true,"onceDelay":0.1,"x":90,"y":268,"wires":[["50a985ce.d7e604","5eccd591.393294"]]},{"id":"27a327fe.174e38","type":"comment","z":"6843e799.d2d59","name":"Hysteresis","info":"","x":628,"y":192,"wires":[]},{"id":"c40afc77.932238","type":"inject","z":"6843e799.d2d59","name":"","topic":"","payload":"79.5","payloadType":"num","repeat":"","crontab":"","once":false,"onceDelay":0.1,"x":90,"y":168,"wires":[["e6c8dd72.66ce28"]]},{"id":"4cffd3fd.09c3b4","type":"inject","z":"6843e799.d2d59","name":"","topic":"","payload":"77.0","payloadType":"num","repeat":"","crontab":"","once":false,"onceDelay":0.1,"x":90,"y":104,"wires":[["e6c8dd72.66ce28"]]},{"id":"7404c612.22c64","type":"function","z":"6843e799.d2d59","name":"flow lastTemp","func":"msg.payload = flow.get(\"lastTemp\");\nmsg.topic = \"ambient_temperature\";\nreturn msg;","outputs":1,"noerr":0,"x":280,"y":686,"wires":[["457db5d8.96d0bc","a6aae9ca.7b79a"]]},{"id":"457db5d8.96d0bc","type":"debug","z":"6843e799.d2d59","name":"flow-lastTemp","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","x":280,"y":648,"wires":[]},{"id":"c7f6b7bf.1ea868","type":"comment","z":"6843e799.d2d59","name":"I added","info":"I added to restore last temp when switching page,\nnot just when the next sensor value is received","x":450,"y":648,"wires":[]},{"id":"1af501b7.87a346","type":"comment","z":"6843e799.d2d59","name":"Restore current values when hitting page","info":"","x":180,"y":460,"wires":[]},{"id":"3150b3d7.1514e4","type":"comment","z":"6843e799.d2d59","name":"mqtt sim msg below","info":"","x":110,"y":72,"wires":[]},{"id":"e6c8dd72.66ce28","type":"change","z":"6843e799.d2d59","name":"passthru","rules":[{"t":"set","p":"payload","pt":"msg","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":80,"y":200,"wires":[["9472162d.7ffae8","b3865c14.c63ec8","e422ce75.195f3","2e179261.d9f6a6","6e0890e7.444eb8"]]},{"id":"c3eda778.f1a48","type":"comment","z":"6843e799.d2d59","name":"mqtt sim msg below","info":"","x":110,"y":236,"wires":[]},{"id":"37072e0d.0bdb32","type":"mqtt-broker","z":"","broker":"localhost","port":"1883","clientid":"","usetls":false,"compatmode":true,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","willTopic":"","willQos":"0","willPayload":""},{"id":"3c5607d.68b0878","type":"ui_group","z":"","name":"Measurements","tab":"fd8bb423.0d5e5","order":3,"disp":true,"width":"6","collapse":false},{"id":"c7b57a74.e224a","type":"ui_group","z":"","name":"Graphics","tab":"fd8bb423.0d5e5","order":2,"disp":true,"width":"6","collapse":false},{"id":"7202b03.bde625","type":"ui_group","z":"","name":"Thermostat Control","tab":"fd8bb423.0d5e5","order":1,"disp":true,"width":"6","collapse":false},{"id":"fd8bb423.0d5e5","type":"ui_tab","z":"","name":"Thermostat","icon":"dashboard"}]
1 Like
#6

Big thanks for the Restore current values when hitting page!!! It was one of the problems I wasn’t able to fix! Many thanks! Welldone! :slight_smile:

#7

Nice tutorial write up!
Is the nodered flow based upon https://github.com/Paul-Reed/thermostat - originally written by Dal Hundal, or have you started with a blank sheet and written the flow from scratch?

Paul

1 Like
#8

Yes! It’s based on his work, that in turn was based on JS Code from another developer. You can find my mention (both of them) in the credits section! This is the why OpenSource projects are so cool! :blush: I mixed It to develop my own solution with the mqtt data from sensors :wink:

Regards

1 Like
#9

Absolutely! I couldn’t agree more.

2 Likes