Hi all! What´s up Makers?!
This is my first project I would like to share with Hackster community! :)
I created this project because as I work with shifts, it was always a nightmare to setup and configure manually every week my Old-Home-NO-InternetOfThings-device-Thermostat due my shifts does not depends on a regular basis.
So I needed a solution which It could let me turn On or Off my Heating system wherever I am, few minutes before coming home and then... save as well some energy efficency at home!
As I always liked NEST design Thermostat I ended building up a virtual alike one!
You would be able to Monitor the Temperature and Humidity of your home-room and (of course) turn On/Off your Heating system from anywhere directly from any Internet connected device (PC/Tablet/Mobile).
So, once we know the story behind this project and its aim, let´s start!
INSTALLING SOFTWARE !1. Setup Raspbian Stretch OS on RaspberryPi 3
2. Install Mosquitto Broker on RaspberryPi 3
3. Install Node-Red on RaspberryPi 3
4. Install Atom+PlatformIO on your PC
HACKING HARDWARE !The device that would turn On/Off the Heating system is the ITEAD 1CH. I chose this device because it is small, really really cheap and it can be powered with a 5V mobile phone charger via micro-USB connector. As you can check, this is a perfect DIY Wi-Fi relay module that ITEA sells ready for Makers to tinker with.
It comes with a proprietary software-ready (a.k.a firmware) that works with a mobile application called EWeLink.
What´s the problem? You must register. Once logged, you could be able to turn On/Off any device connected to the relay terminals. But, hey! We´re Makers! We want to use/hack our purchased devices and do not depend on external providers, servers and applications! We want to make our own infrastructure! Information privacy!
This is where hardware hacking begins...
The best part comes when you realize that you can modify the original firmware of the device and install another one non-dependent from vendor. As the device is based on the module ESP2866, there are few Open-Source projects for this module, and I fell in love specially with one called ESPurna Firmware.
ESPurna Firmware + Adding DHT22 SensorESPurna ("spark" in Catalan) is a custom firmware for ESP8266 based smart switches and sensors. It uses the Arduino Core for ESP8266 framework and a number of 3rd party libraries.
Thanks to the great articles published by his author (@xoseperez) and his wonderful help on Twitter, I was able to learn how to modify, build and flash the custom firmware adding the device new features that didn´t come when purchased.
Surfing on Internet for device schematics details, I found the GPIO pinout of the PSA-01 (ESP8266 based) module.
As it is based on ESP2866, I guessed it could have some GPIOs to interact with... And I was right! Also, I checked that ITEAD have other kind of products like SonOff TH10/TH16 that comes with an audio jack to connect a temperature/humidity probe sensor.
Having checked that ITEAD products can be 'hacked' adding more type of sensors, I was wondering if I could add one to my device ITEAD 1-CH. It would be interesting to add a sensor to the smart Wi-Fi relay in order to monitor the temperature and humidity of the room where placed and then create some kind of Thermostat with it.
So, I tried to solder some pins directly over the GPIO pins of the PSA-01 module in order to connect a DHT22 sensor. You just need to solder 3 pins (GND, 3v3 and GPIO14) to connect it and 3 pins (RX, TX, GND) to flash the module. I finally soldered pins in all GPIO, but it is not mandatory, as we wil use just GPIO14.
Now it is time to modify the right source files, build up the ESPurna custom firmware and check if the device reads the data from the added sensor.
PlatformIO - Building the custom FirmwareOnce installed Atom+PlatformIO, you need to download the latest version of ESPurna custom firmware and extract it.
Add Project Folder (File-> Add Project Folder) and select the folder named code from folder recently extracted.
Now you will have the project added to the Project column (left pane).
You need to modify the following source files accordingly as the below images shows.
/config/sensors.h
/platformio.ini
Now, we´re ready to flash our device with the ESPurna custom firmware!
Connect your USB-to-Serial wires to ITEAD device following the below connections:
TX <-> RX
RX <-> TX
3v3 <-> 3v3
GND <-> GND
In order to flash ITEAD device, you need to enter into flash mode. This can be easily done powering the board (connect to an USB port on your PC) while pressing the button that is closer to the micro USB connector.
Once connected, the LEDs will light Red indicating device is on Flash mode, ready to upload the firmware.
Prior to flash device, check that build ends with success. Just prest the build button (tick icon) and after a while, if all is OK, you will receive the SUCCESS messages.
Now you are ready to upload the ESPurna custom firmware with PlatformIO. Just press the Upload button (right arrow) on PlatformIO and USB-to-Serial adapter will start to blink and flash your device.
Below a quick videos of the Flash process.
Once flashed the device, disconnect from USB-to-Serial wires, connect the DHT22 sensor accordingly (3v3,GND,GPIO14) and power it up with a microUSB mobile phone charger.
At first boot, the device will start on soft AP creating a Wi-Fi SSID named "DEVICE_XXXXXX", where DEVICE will be an identifier of your device and XXXXXX are the last 3 bytes of the radio MAC.
Connect with phone, PC, laptop, whatever to that network, password is "fibonacci". Once connected browse to http://192.168.4.1
CONFIGURING THE ITEAD 'Thermostat'First of all, you will be prompted to an authentication challenge. Please follow the official procedures to setup your Wi-Fi and change default password.
Once configured, you should see the default Web interface of ESPurna custom firmware where you will be able to check the status of Switch, Temperature and Humidity value readings of DHT22 sensor as well as configure your own Wi-Fi details, MQTT, NTP, HTTP API, Port, Switches, Schedule, Thingspeak, Domoticz, Amazon Alexa integration...
ESPurna have a great variety of nice features (more than the original firmware) !
That's why we all should love open-source projects like this.
The 'Thermostat' will communicate with our RaspberryPi 3 via MQTT protocol and Node-RED will manage the logic within its flow editor depending the values received by the sensor and deploy the user interface to interact with. That's why you installed Mosquitto Broker and Node-RED tool on your RaspberryPi 3 at the beginning of this tutorial !
Once you setup your device to connect to your desired home Wi-Fi network (left pane of web interface -> WIFI, where you can scan networks and select the desired one) you will need to setup the MQTT details accordingly that will match with the installation of your Mosquitto Broker.
Enable MQTT, write the IP address of your MQTT Broker (your RaspberryPi 3 IP), MQTT port (default 1883) and the most important parameter: MQTT Root Topic.
If you want to change the name of your device on your Network in order to identify it easily, you can do it changing its Hostname on General section e.g. I named it as ITEAD 1CH WiFi
For this setting to take effect you should restart the WiFi interface clicking the "Reconnect" button.
At this point, all the infrastructure needed has been set up and configured. You only need to setup the flow logic in Node-RED and deploy to make it work.
Open your Node-RED instance in a browser from your PC (http://YourRaspberryPi IP Address:1880) and you just need to copy all the code below and import into your Node-RED instance installed in your RaspberryPi 3 into current flow.
[
{
"id": "da904081.4e064",
"type": "tab",
"label": "Termostato",
"disabled": false,
"info": ""
},
{
"id": "69149863.7cc6c8",
"type": "mqtt in",
"z": "da904081.4e064",
"name": "SENSOR AM2302-TEMP",
"topic": "/home/Thermostat/temperature",
"qos": "2",
"broker": "a41e02ae.5c398",
"x": 152.5,
"y": 143.25000286102295,
"wires": [
[
"81b5d072.aa892",
"57f50795.dcd598",
"26941838.3c1f98",
"182fb20b.04250e",
"49e810b6.0cfd3"
]
]
},
{
"id": "56929f0c.9c681",
"type": "mqtt out",
"z": "da904081.4e064",
"name": "HEATER ON/OFF",
"topic": "/home/Thermostat/relay/0/set",
"qos": "",
"retain": "",
"broker": "a41e02ae.5c398",
"x": 1096.3213119506836,
"y": 549.5357418060303,
"wires": []
},
{
"id": "81b5d072.aa892",
"type": "function",
"z": "da904081.4e064",
"name": "Convertir Mensaje TEMP",
"func": "msg.payload = parseFloat(msg.payload);\nmsg.topic = 'sensor_temperature';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 492.2261734008789,
"y": 170.11905670166016,
"wires": [
[
"ded5ab1.95be358",
"d15df687.45c468"
]
]
},
{
"id": "ded5ab1.95be358",
"type": "debug",
"z": "da904081.4e064",
"name": "Temp Sensor",
"active": true,
"console": "false",
"complete": "payload",
"x": 754.619010925293,
"y": 45.42859649658203,
"wires": []
},
{
"id": "57f50795.dcd598",
"type": "ui_gauge",
"z": "da904081.4e064",
"name": "Gauge: Temp",
"group": "f9325f56.6c0ed",
"order": 1,
"width": 0,
"height": 0,
"gtype": "gage",
"title": "Temperatura",
"label": "units",
"format": "{{value}} ºC",
"min": "10",
"max": "40",
"colors": [
"#ffe700",
"#e68b00",
"#ff0000"
],
"seg1": "20",
"seg2": "30",
"x": 411.75,
"y": 40,
"wires": []
},
{
"id": "56720eec.3da18",
"type": "ui_gauge",
"z": "da904081.4e064",
"name": "Gauge: Hum",
"group": "f9325f56.6c0ed",
"order": 2,
"width": 0,
"height": 0,
"gtype": "gage",
"title": "Humedad",
"label": "units",
"format": "{{value}}",
"min": 0,
"max": "100",
"colors": [
"#00b500",
"#e6e600",
"#ca3838"
],
"seg1": "45",
"seg2": "70",
"x": 402.8333435058594,
"y": 557.4999992847443,
"wires": []
},
{
"id": "6a09073.47b18f8",
"type": "mqtt in",
"z": "da904081.4e064",
"name": "SENSOR AM2302-HUM",
"topic": "/home/Thermostat/humidity",
"qos": "2",
"broker": "a41e02ae.5c398",
"x": 154.047607421875,
"y": 534.5714390277863,
"wires": [
[
"56720eec.3da18",
"74c0c78a.1f64e8"
]
]
},
{
"id": "192cca65.5e9e96",
"type": "ui_chart",
"z": "da904081.4e064",
"name": "",
"group": "7c31da65.7796a4",
"order": 2,
"width": 0,
"height": 0,
"label": "Humedad",
"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": 378.3333282470703,
"y": 619.9999916553497,
"wires": [
[],
[]
]
},
{
"id": "26941838.3c1f98",
"type": "change",
"z": "da904081.4e064",
"name": "",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "Temperatura",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 494.61902618408203,
"y": 91.95237159729004,
"wires": [
[
"eee08de0.0d78a"
]
]
},
{
"id": "74c0c78a.1f64e8",
"type": "change",
"z": "da904081.4e064",
"name": "",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "Humedad",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 208.3333282470703,
"y": 619.9999916553497,
"wires": [
[
"192cca65.5e9e96"
]
]
},
{
"id": "ad50a979.98c8c8",
"type": "ui_template",
"z": "da904081.4e064",
"group": "95e5f821.53ff28",
"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,
"templateScope": "local",
"x": 634.5238075256348,
"y": 442.8571138381958,
"wires": [
[
"f362e7cb.90b8a8"
]
]
},
{
"id": "182fb20b.04250e",
"type": "function",
"z": "da904081.4e064",
"name": "ambient_temperature",
"func": "msg.topic = \"ambient_temperature\";\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 357.6190490722656,
"y": 308.33333587646484,
"wires": [
[
"ad50a979.98c8c8"
]
]
},
{
"id": "9e25b1e0.00ed9",
"type": "function",
"z": "da904081.4e064",
"name": "hvac_state",
"func": "global.set(\"color-state\",msg.payload);\n\nmsg.topic = \"hvac_state\";\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 600.714241027832,
"y": 721.4285278320312,
"wires": [
[
"ad50a979.98c8c8"
]
]
},
{
"id": "f362e7cb.90b8a8",
"type": "function",
"z": "da904081.4e064",
"name": "target_temperature",
"func": "global.set(\"room-target\",msg.payload);\n\nif (msg.topic == \"target_temperature\") {\nreturn msg;\n}",
"outputs": 1,
"noerr": 0,
"x": 830,
"y": 660,
"wires": [
[
"c97472f8.396e1",
"d15df687.45c468",
"a2df08f6.45cbb8"
]
]
},
{
"id": "c97472f8.396e1",
"type": "debug",
"z": "da904081.4e064",
"name": "Target Temp",
"active": true,
"console": "false",
"complete": "payload",
"x": 1082.142921447754,
"y": 710.476203918457,
"wires": []
},
{
"id": "d15df687.45c468",
"type": "function",
"z": "da904081.4e064",
"name": "Comparar 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\nif (context.target >= context.sensor) {\n return {payload: 1};\n} else {\n return {payload: 0};\n}",
"outputs": 1,
"noerr": 0,
"x": 834.8214263916016,
"y": 427.14288997650146,
"wires": [
[
"56929f0c.9c681",
"850d28fd.fbcd68"
]
]
},
{
"id": "850d28fd.fbcd68",
"type": "function",
"z": "da904081.4e064",
"name": "Estado Color Nest",
"func": "msg.topic = \"hvac_state\";\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 750,
"y": 240,
"wires": [
[
"169d3283.26605d",
"7123dcdb.01b5e4"
]
]
},
{
"id": "169d3283.26605d",
"type": "debug",
"z": "da904081.4e064",
"name": "STATE",
"active": false,
"console": "false",
"complete": "payload",
"x": 950,
"y": 180,
"wires": []
},
{
"id": "7123dcdb.01b5e4",
"type": "switch",
"z": "da904081.4e064",
"name": "",
"property": "payload",
"propertyType": "msg",
"rules": [
{
"t": "eq",
"v": "0",
"vt": "str"
},
{
"t": "eq",
"v": "1",
"vt": "str"
}
],
"checkall": "true",
"outputs": 2,
"x": 910,
"y": 300,
"wires": [
[
"9b5a6f7.266c29"
],
[
"72c6507e.b6579"
]
]
},
{
"id": "9b5a6f7.266c29",
"type": "template",
"z": "da904081.4e064",
"name": "Calefacción OFF",
"field": "payload",
"fieldType": "msg",
"format": "handlebars",
"syntax": "mustache",
"template": "off",
"output": "str",
"x": 1082.8571586608887,
"y": 268.5713920593262,
"wires": [
[
"9e25b1e0.00ed9",
"651008fb.7547b8"
]
]
},
{
"id": "72c6507e.b6579",
"type": "template",
"z": "da904081.4e064",
"name": "Calefacción ON",
"field": "payload",
"fieldType": "msg",
"format": "handlebars",
"syntax": "mustache",
"template": "heating",
"output": "str",
"x": 1075.714370727539,
"y": 371.4285821914673,
"wires": [
[
"9e25b1e0.00ed9"
]
]
},
{
"id": "651008fb.7547b8",
"type": "debug",
"z": "da904081.4e064",
"name": "SALIDA",
"active": false,
"console": "false",
"complete": "payload",
"x": 1203.5713729858398,
"y": 162.14287090301514,
"wires": []
},
{
"id": "a2df08f6.45cbb8",
"type": "change",
"z": "da904081.4e064",
"name": "",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "Deseada",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 1070,
"y": 640,
"wires": [
[
"bf4ac0a5.19532"
]
]
},
{
"id": "eee08de0.0d78a",
"type": "ui_chart",
"z": "da904081.4e064",
"name": "",
"group": "7c31da65.7796a4",
"order": 1,
"width": 0,
"height": 0,
"label": "Temperatura",
"chartType": "line",
"legend": "true",
"xformat": "HH:mm:ss",
"interpolate": "linear",
"nodata": "",
"dot": false,
"ymin": "10",
"ymax": "30",
"removeOlder": "3",
"removeOlderPoints": "",
"removeOlderUnit": "3600",
"cutout": 0,
"useOneColor": false,
"colors": [
"#1f77b4",
"#aec7e8",
"#ff7f0e",
"#2ca02c",
"#98df8a",
"#d62728",
"#ff9896",
"#9467bd",
"#c5b0d5"
],
"useOldStyle": false,
"x": 772.8571428571428,
"y": 112.85714285714285,
"wires": [
[],
[]
]
},
{
"id": "bf4ac0a5.19532",
"type": "ui_chart",
"z": "da904081.4e064",
"name": "",
"group": "95e5f821.53ff28",
"order": 0,
"width": 0,
"height": 0,
"label": "Tempº Deseada",
"chartType": "line",
"legend": "true",
"xformat": "HH:mm:ss",
"interpolate": "linear",
"nodata": "",
"dot": false,
"ymin": "10",
"ymax": "30",
"removeOlder": "3",
"removeOlderPoints": "",
"removeOlderUnit": "3600",
"cutout": 0,
"useOneColor": false,
"colors": [
"#1f77b4",
"#aec7e8",
"#ff7f0e",
"#2ca02c",
"#98df8a",
"#d62728",
"#ff9896",
"#9467bd",
"#c5b0d5"
],
"useOldStyle": false,
"x": 1304.2857142857142,
"y": 634.2857142857142,
"wires": [
[],
[]
]
},
{
"id": "6ba07343.29a95c",
"type": "ui_ui_control",
"z": "da904081.4e064",
"name": "ui change",
"x": 86.66666412353516,
"y": 338.33330726623535,
"wires": [
[
"827ff685.e970e8"
]
]
},
{
"id": "827ff685.e970e8",
"type": "delay",
"z": "da904081.4e064",
"name": "",
"pauseType": "delay",
"timeout": "1",
"timeoutUnits": "seconds",
"rate": "1",
"nbRateUnits": "1",
"rateUnits": "second",
"randomFirst": "1",
"randomLast": "5",
"randomUnits": "seconds",
"drop": false,
"x": 176.6666717529297,
"y": 419.9999809265137,
"wires": [
[
"78adbba4.e5bbe4"
]
]
},
{
"id": "78adbba4.e5bbe4",
"type": "function",
"z": "da904081.4e064",
"name": "global target-temp",
"func": "msg.payload = global.get(\"room-target\");\nmsg.topic = 'target_temperature';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 361.6666793823242,
"y": 468.33334159851074,
"wires": [
[
"575a336e.a3b5ac",
"ad50a979.98c8c8"
]
]
},
{
"id": "575a336e.a3b5ac",
"type": "debug",
"z": "da904081.4e064",
"name": "global-target-temp",
"active": true,
"console": "false",
"complete": "payload",
"x": 351.6667251586914,
"y": 371.6667528152466,
"wires": []
},
{
"id": "fab41969.1480f8",
"type": "ui_ui_control",
"z": "da904081.4e064",
"name": "ui change",
"x": 116.66666412353516,
"y": 714.9999694824219,
"wires": [
[
"abb28c3b.d52d1"
]
]
},
{
"id": "abb28c3b.d52d1",
"type": "delay",
"z": "da904081.4e064",
"name": "",
"pauseType": "delay",
"timeout": "1",
"timeoutUnits": "seconds",
"rate": "1",
"nbRateUnits": "1",
"rateUnits": "second",
"randomFirst": "1",
"randomLast": "5",
"randomUnits": "seconds",
"drop": false,
"x": 249.99998474121094,
"y": 783.3332862854004,
"wires": [
[
"485b77e9.f539c8"
]
]
},
{
"id": "485b77e9.f539c8",
"type": "function",
"z": "da904081.4e064",
"name": "global color-state",
"func": "msg.payload = global.get(\"color-state\");\nmsg.topic = \"hvac_state\";\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 359.99998474121094,
"y": 688.3333435058594,
"wires": [
[
"ad50a979.98c8c8",
"61f53c5f.e4f1e4"
]
]
},
{
"id": "61f53c5f.e4f1e4",
"type": "debug",
"z": "da904081.4e064",
"name": "global-color-state",
"active": true,
"console": "false",
"complete": "payload",
"x": 509.99998474121094,
"y": 786.6666984558105,
"wires": []
},
{
"id": "49e810b6.0cfd3",
"type": "function",
"z": "da904081.4e064",
"name": "Leaf",
"func": "minleaf = 18;\nmaxleaf = 22;\ntemperature = msg.payload;\nmsg.payload=false;\nif (temperature >= minleaf){\n if (temperature <= maxleaf){\n msg.payload = true;\n }\n}\nmsg.topic = \"has_leaf\";\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 444.9999771118164,
"y": 233.3333396911621,
"wires": [
[
"ad50a979.98c8c8"
]
]
},
{
"id": "a41e02ae.5c398",
"type": "mqtt-broker",
"z": "",
"broker": "localhost",
"port": "1883",
"clientid": "",
"usetls": false,
"compatmode": true,
"keepalive": "60",
"cleansession": true,
"willTopic": "",
"willQos": "0",
"willPayload": "",
"birthTopic": "",
"birthQos": "0",
"birthPayload": ""
},
{
"id": "f9325f56.6c0ed",
"type": "ui_group",
"z": "",
"name": "Medidas",
"tab": "3c5450a1.d2046",
"order": 3,
"disp": true,
"width": "6"
},
{
"id": "7c31da65.7796a4",
"type": "ui_group",
"z": "",
"name": "Gráficas",
"tab": "3c5450a1.d2046",
"order": 2,
"disp": true,
"width": "6"
},
{
"id": "95e5f821.53ff28",
"type": "ui_group",
"z": "",
"name": "Control Tersmostato",
"tab": "3c5450a1.d2046",
"order": 1,
"disp": true,
"width": "6"
},
{
"id": "3c5450a1.d2046",
"type": "ui_tab",
"z": "",
"name": "Termostato IOT",
"icon": "dashboard"
}
]
Once imported, you should be able to see all blocks and its flows interconnected.
Now modify the MQTT input and output nodes (the ones that shows as connected) according to your own configuration, specifying the MQTT Broker Server IP (your RaspberryPi 3 IP address or localhost) and desired Topics based on your MQTT Root Topic configured previously. E.g. Below my configurations
It only remains to Deploy the current flow in order to make it work. Press the arrow down next to Deploy button, select FULL and finally press Deploy.
If you have configured all correctly, following the steps provided along this tutorial, and most important, you have had fun with it... Let's wait! The Best is yet to come!
Now you have your own DIY Virtual alike NEST Thermostat with Node-RED. You would be able to monitor the temperature and humidity of the room where device located, turn On/Off your Heating system and check your 'Thermostat' from any device connected to your Wi-Fi Network (or you can configure a service like No-IP into your RaspberryPi 3, open ports in your router and connect away from home!).
Just open a browser and go to http://YourRaspberryPi3_IP:1880/ui/ and voilà!
As this is a DIY project, there are few improvements that can be made (I invite and call the Hackster.IO community work on it!).
Listed below sort of things that can be done:
- Design a 3D print case enclosure
- Add more sensors & integrate them into Node-RED flow
- Amazon Alexa/Google Home integration to turn On/Off your Heating system by voice commands
- Add a LCD to show basic values (replacing ITEAD Wi-Fi Switch by an ESP2866 device like Wemos D1 Mini or NodeMCU+LCD Display)
- Add physical components to control the Thermostat
- ... & whatever improvement you can imagine!
Comments