├── LICENSE ├── README.md ├── example └── example_flow ├── images └── thermo.png ├── thermocss ├── thermo.css └── thermo.min.css └── thermojs └── thermo.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Dal Hundal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # thermostat 2 | ## A node-red interactive thermostat 3 | 4 |  5 | ### Setup a node-red static directory 6 | 7 | Create a new static directory within your `.node-red` directory from which you can serve the CSS and Javascript files, for example 'public'. 8 | Git clone this repo into your newly created static directory; 9 | 10 | cd && cd .node-red/public 11 | git clone https://github.com/Paul-Reed/thermostat.git 12 | 13 | Edit your node-red settings file, usually `nano /home/pi/.node-red/settings.js` as follows; 14 | 15 | Firstly, edit httpStatic to read your static folder 16 | `httpStatic: '/home/pi/.node-red/public',` 17 | and also if you havent already done so, httpAdminRoot must also be used to make the editor UI available at a path other than /. So for example if you changed the setting to `httpAdminRoot: '/admin',` then instead of your editor being accessed at the URL `192.168.168.13:1880/` you would use `192.168.168.13:1880/admin` 18 | 19 | Then stop and restart node-red 20 | 21 | ### Use of thermostat 22 | 23 | It is necessary to link both the CSS and Javascript script within the dashboard flow to your static folder, this is normally done by adding; 24 | 25 | 26 | 27 | 28 | to a template node. 29 | 30 | To load the thermometer widget; 31 | 32 |
33 | 34 | There is an [example node-red flow](/example/example_flow) to get you started, once you have completed the above. 35 | 36 | To change the temperature setting, hold your mouse down for 1 second on the thermostat, before trying to adjust it, or on a touchscreen device, touch the thermostat for 1 second and then swipe up/down or left/right to adjust. 37 | 38 | ### Credit 39 | 40 | Originally written by [Dal Hundal](http://codepen.io/dalhundal) 41 | 42 | ### Licence 43 | 44 | Feel free to use the code however you like! 45 | -------------------------------------------------------------------------------- /example/example_flow: -------------------------------------------------------------------------------- 1 | [{"id":"aa70bd7f.0d5d7","type":"ui_template","z":"ffae6a3b.7c2d28","group":"8880d363.148ac","name":"Thermostat","order":1,"width":"6","height":"6","format":"\n\n","storeOutMessages":true,"fwdInMessages":false,"x":649,"y":799,"wires":[["e146f1f6.6729c"]]},{"id":"d07e483e.ec3898","type":"function","z":"ffae6a3b.7c2d28","name":"ambient_temperature","func":"msg.topic = \"ambient_temperature\";\nreturn msg;","outputs":1,"noerr":0,"x":414,"y":800,"wires":[["aa70bd7f.0d5d7"]]},{"id":"3c8560d.11875a","type":"function","z":"ffae6a3b.7c2d28","name":"target_temperature","func":"msg.topic = \"target_temperature\";\nreturn msg;","outputs":1,"noerr":0,"x":404,"y":840,"wires":[["aa70bd7f.0d5d7"]]},{"id":"9b84772d.f05428","type":"function","z":"ffae6a3b.7c2d28","name":"hvac_state","func":"msg.topic = \"hvac_state\";\nreturn msg;","outputs":1,"noerr":0,"x":384,"y":880,"wires":[["aa70bd7f.0d5d7"]]},{"id":"330ab134.b7a35e","type":"function","z":"ffae6a3b.7c2d28","name":"has_leaf","func":"msg.topic = \"has_leaf\";\nreturn msg;","outputs":1,"noerr":0,"x":374,"y":920,"wires":[["aa70bd7f.0d5d7"]]},{"id":"66d9b621.4c92e8","type":"function","z":"ffae6a3b.7c2d28","name":"away","func":"msg.topic = \"away\";\nreturn msg;","outputs":1,"noerr":0,"x":364,"y":960,"wires":[["aa70bd7f.0d5d7"]]},{"id":"75851787.f0a948","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"has_leaf","payload":"true","payloadType":"bool","repeat":"","crontab":"","once":true,"x":120,"y":967,"wires":[["330ab134.b7a35e"]]},{"id":"d7bc9e56.db1be","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"has_leaf","payload":"false","payloadType":"bool","repeat":"","crontab":"","once":false,"x":119,"y":1003,"wires":[["330ab134.b7a35e"]]},{"id":"8e32c6a4.9e2058","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"away","payload":"true","payloadType":"bool","repeat":"","crontab":"","once":false,"x":108,"y":1039,"wires":[["66d9b621.4c92e8"]]},{"id":"88e5e8a5.f09e28","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"away","payload":"false","payloadType":"bool","repeat":"","crontab":"","once":false,"x":108,"y":1076,"wires":[["66d9b621.4c92e8"]]},{"id":"bc8dd486.9bfe18","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"hvac_state","payload":"off","payloadType":"str","repeat":"","crontab":"","once":false,"x":120,"y":931,"wires":[["9b84772d.f05428"]]},{"id":"e77d736f.2e521","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"hvac_state","payload":"heating","payloadType":"str","repeat":"","crontab":"","once":false,"x":140,"y":858,"wires":[["9b84772d.f05428"]]},{"id":"4c9da540.6bb67c","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"hvac_state","payload":"cooling","payloadType":"str","repeat":"","crontab":"","once":false,"x":140,"y":895,"wires":[["9b84772d.f05428"]]},{"id":"9c858353.428d8","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"ambient_temperature","payload":"19.5","payloadType":"num","repeat":"","crontab":"","once":true,"x":161,"y":785,"wires":[["d07e483e.ec3898"]]},{"id":"879bd496.29a898","type":"inject","z":"ffae6a3b.7c2d28","name":"","topic":"target_temperature","payload":"20","payloadType":"num","repeat":"","crontab":"","once":true,"x":152,"y":822,"wires":[["3c8560d.11875a"]]},{"id":"e146f1f6.6729c","type":"function","z":"ffae6a3b.7c2d28","name":"target_temperature","func":"if (msg.topic == \"target_temperature\") {\nreturn msg;\n}","outputs":1,"noerr":0,"x":718,"y":836,"wires":[["d0771486.c79ba8"]]},{"id":"d0771486.c79ba8","type":"debug","z":"ffae6a3b.7c2d28","name":"","active":true,"console":"false","complete":"false","x":738,"y":872,"wires":[]},{"id":"8880d363.148ac","type":"ui_group","z":"","name":"Thermostat","tab":"db58ad1a.e37a8","order":2,"disp":true,"width":"6"},{"id":"db58ad1a.e37a8","type":"ui_tab","z":"","name":"Example","icon":"dashboard"}] 2 | -------------------------------------------------------------------------------- /images/thermo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Paul-Reed/thermostat/2f8c97b1950701473672e33848cae2470aa3da77/images/thermo.png -------------------------------------------------------------------------------- /thermocss/thermo.css: -------------------------------------------------------------------------------- 1 | 129 | -------------------------------------------------------------------------------- /thermocss/thermo.min.css: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /thermojs/thermo.js: -------------------------------------------------------------------------------- 1 | var thermostatDial = (function() { 2 | 3 | /* 4 | * Utility functions 5 | */ 6 | 7 | // Create an element with proper SVG namespace, optionally setting its attributes and appending it to another element 8 | function createSVGElement(tag,attributes,appendTo) { 9 | var element = document.createElementNS('http://www.w3.org/2000/svg',tag); 10 | attr(element,attributes); 11 | if (appendTo) { 12 | appendTo.appendChild(element); 13 | } 14 | return element; 15 | } 16 | 17 | // Set attributes for an element 18 | function attr(element,attrs) { 19 | for (var i in attrs) { 20 | element.setAttribute(i,attrs[i]); 21 | } 22 | } 23 | 24 | // Rotate a cartesian point about given origin by X degrees 25 | function rotatePoint(point, angle, origin) { 26 | var radians = angle * Math.PI/180; 27 | var x = point[0]-origin[0]; 28 | var y = point[1]-origin[1]; 29 | var x1 = x*Math.cos(radians) - y*Math.sin(radians) + origin[0]; 30 | var y1 = x*Math.sin(radians) + y*Math.cos(radians) + origin[1]; 31 | return [x1,y1]; 32 | } 33 | 34 | // Rotate an array of cartesian points about a given origin by X degrees 35 | function rotatePoints(points, angle, origin) { 36 | return points.map(function(point) { 37 | return rotatePoint(point, angle, origin); 38 | }); 39 | } 40 | 41 | // Given an array of points, return an SVG path string representing the shape they define 42 | function pointsToPath(points) { 43 | return points.map(function(point, iPoint) { 44 | return (iPoint>0?'L':'M') + point[0] + ' ' + point[1]; 45 | }).join(' ')+'Z'; 46 | } 47 | 48 | function circleToPath(cx, cy, r) { 49 | return [ 50 | "M",cx,",",cy, 51 | "m",0-r,",",0, 52 | "a",r,",",r,0,1,",",0,r*2,",",0, 53 | "a",r,",",r,0,1,",",0,0-r*2,",",0, 54 | "z" 55 | ].join(' ').replace(/\s,\s/g,","); 56 | } 57 | 58 | function donutPath(cx,cy,rOuter,rInner) { 59 | return circleToPath(cx,cy,rOuter) + " " + circleToPath(cx,cy,rInner); 60 | } 61 | 62 | // Restrict a number to a min + max range 63 | function restrictToRange(val,min,max) { 64 | if (val < min) return min; 65 | if (val > max) return max; 66 | return val; 67 | } 68 | 69 | // Round a number to the nearest 0.5 70 | function roundHalf(num) { 71 | return Math.round(num*2)/2; 72 | } 73 | 74 | function setClass(el, className, state) { 75 | el.classList[state ? 'add' : 'remove'](className); 76 | } 77 | 78 | /* 79 | * The "MEAT" 80 | */ 81 | 82 | return function(targetElement, options) { 83 | var self = this; 84 | 85 | /* 86 | * Options 87 | */ 88 | options = options || {}; 89 | options = { 90 | diameter: options.diameter || 400, 91 | minValue: options.minValue || 10, // Minimum value for target temperature 92 | maxValue: options.maxValue || 30, // Maximum value for target temperature 93 | numTicks: options.numTicks || 200, // Number of tick lines to display around the dial 94 | onSetTargetTemperature: options.onSetTargetTemperature || function() {}, // Function called when new target temperature set by the dial 95 | }; 96 | 97 | /* 98 | * Properties - calculated from options in many cases 99 | */ 100 | var properties = { 101 | tickDegrees: 300, // Degrees of the dial that should be covered in tick lines 102 | rangeValue: options.maxValue - options.minValue, 103 | radius: options.diameter/2, 104 | ticksOuterRadius: options.diameter / 30, 105 | ticksInnerRadius: options.diameter / 8, 106 | hvac_states: ['off', 'heating', 'cooling'], 107 | dragLockAxisDistance: 15, 108 | } 109 | properties.lblAmbientPosition = [properties.radius, properties.ticksOuterRadius-(properties.ticksOuterRadius-properties.ticksInnerRadius)/2] 110 | properties.offsetDegrees = 180-(360-properties.tickDegrees)/2; 111 | 112 | /* 113 | * Object state 114 | */ 115 | var state = { 116 | target_temperature: options.minValue, 117 | ambient_temperature: options.minValue, 118 | hvac_state: properties.hvac_states[0], 119 | has_leaf: false, 120 | away: false 121 | }; 122 | 123 | /* 124 | * Property getter / setters 125 | */ 126 | Object.defineProperty(this,'target_temperature',{ 127 | get: function() { 128 | return state.target_temperature; 129 | }, 130 | set: function(val) { 131 | state.target_temperature = restrictTargetTemperature(+val); 132 | render(); 133 | } 134 | }); 135 | Object.defineProperty(this,'ambient_temperature',{ 136 | get: function() { 137 | return state.ambient_temperature; 138 | }, 139 | set: function(val) { 140 | state.ambient_temperature = roundHalf(+val); 141 | render(); 142 | } 143 | }); 144 | Object.defineProperty(this,'hvac_state',{ 145 | get: function() { 146 | return state.hvac_state; 147 | }, 148 | set: function(val) { 149 | if (properties.hvac_states.indexOf(val)>=0) { 150 | state.hvac_state = val; 151 | render(); 152 | } 153 | } 154 | }); 155 | Object.defineProperty(this,'has_leaf',{ 156 | get: function() { 157 | return state.has_leaf; 158 | }, 159 | set: function(val) { 160 | state.has_leaf = !!val; 161 | render(); 162 | } 163 | }); 164 | Object.defineProperty(this,'away',{ 165 | get: function() { 166 | return state.away; 167 | }, 168 | set: function(val) { 169 | state.away = !!val; 170 | render(); 171 | } 172 | }); 173 | 174 | /* 175 | * SVG 176 | */ 177 | var svg = createSVGElement('svg',{ 178 | width: '100%', //options.diameter+'px', 179 | height: '100%', //options.diameter+'px', 180 | viewBox: '0 0 '+options.diameter+' '+options.diameter, 181 | class: 'dial' 182 | },targetElement); 183 | // CIRCULAR DIAL 184 | var circle = createSVGElement('circle',{ 185 | cx: properties.radius, 186 | cy: properties.radius, 187 | r: properties.radius, 188 | class: 'dial__shape' 189 | },svg); 190 | // EDITABLE INDICATOR 191 | var editCircle = createSVGElement('path',{ 192 | d: donutPath(properties.radius,properties.radius,properties.radius-4,properties.radius-8), 193 | class: 'dial__editableIndicator', 194 | },svg); 195 | 196 | /* 197 | * Ticks 198 | */ 199 | var ticks = createSVGElement('g',{ 200 | class: 'dial__ticks' 201 | },svg); 202 | var tickPoints = [ 203 | [properties.radius-1, properties.ticksOuterRadius], 204 | [properties.radius+1, properties.ticksOuterRadius], 205 | [properties.radius+1, properties.ticksInnerRadius], 206 | [properties.radius-1, properties.ticksInnerRadius] 207 | ]; 208 | var tickPointsLarge = [ 209 | [properties.radius-1.5, properties.ticksOuterRadius], 210 | [properties.radius+1.5, properties.ticksOuterRadius], 211 | [properties.radius+1.5, properties.ticksInnerRadius+20], 212 | [properties.radius-1.5, properties.ticksInnerRadius+20] 213 | ]; 214 | var theta = properties.tickDegrees/options.numTicks; 215 | var tickArray = []; 216 | for (var iTick=0; iTick