├── images ├── sauna.jpg ├── wifi.png ├── TH16_board.png ├── schematic.png ├── Sauna_APP_0m.png ├── Sauna_APP_1m.png ├── Sauna_App_59m.png ├── Sauna_App_60m.png ├── CaptivePortal1.png ├── CaptivePortal2.png ├── CaptivePortal3.png ├── CaptivePortal5.png ├── CaptivePortall4.png ├── Sauna_App_Heating.png ├── schematic_original.png └── schematic_original_alt.png ├── resources ├── MQTT_DASH_no_newlines.json ├── MQTT_DASH_with_newlines.json └── MQTT_DASH_Config_command_Line.txt ├── README.md ├── LICENSE └── src ├── SAUNA.h └── SAUNA.ino /images/sauna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/sauna.jpg -------------------------------------------------------------------------------- /images/wifi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/wifi.png -------------------------------------------------------------------------------- /images/TH16_board.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/TH16_board.png -------------------------------------------------------------------------------- /images/schematic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/schematic.png -------------------------------------------------------------------------------- /images/Sauna_APP_0m.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/Sauna_APP_0m.png -------------------------------------------------------------------------------- /images/Sauna_APP_1m.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/Sauna_APP_1m.png -------------------------------------------------------------------------------- /images/Sauna_App_59m.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/Sauna_App_59m.png -------------------------------------------------------------------------------- /images/Sauna_App_60m.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/Sauna_App_60m.png -------------------------------------------------------------------------------- /images/CaptivePortal1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/CaptivePortal1.png -------------------------------------------------------------------------------- /images/CaptivePortal2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/CaptivePortal2.png -------------------------------------------------------------------------------- /images/CaptivePortal3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/CaptivePortal3.png -------------------------------------------------------------------------------- /images/CaptivePortal5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/CaptivePortal5.png -------------------------------------------------------------------------------- /images/CaptivePortall4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/CaptivePortall4.png -------------------------------------------------------------------------------- /images/Sauna_App_Heating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/Sauna_App_Heating.png -------------------------------------------------------------------------------- /images/schematic_original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/schematic_original.png -------------------------------------------------------------------------------- /images/schematic_original_alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/images/schematic_original_alt.png -------------------------------------------------------------------------------- /resources/MQTT_DASH_no_newlines.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/resources/MQTT_DASH_no_newlines.json -------------------------------------------------------------------------------- /resources/MQTT_DASH_with_newlines.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwiCago/HarviaWiFi/HEAD/resources/MQTT_DASH_with_newlines.json -------------------------------------------------------------------------------- /resources/MQTT_DASH_Config_command_Line.txt: -------------------------------------------------------------------------------- 1 | mosquitto_pub -h localhost -p 1883 -t 'metrics/exchange' -m '[ { "iconOff": "ic_power_settings_new", "iconOn": "ic_power_settings_new", "offColor": -1, 2 | "onColor": -1, "payloadOff": "{\"state\":false}", "payloadOn": "{\"state\":true}", "enableIntermediateState": true, "enablePub": true, 3 | "enteredIntermediateStateAt": 1548626272, "intermediateStateTimeout": 0, "jsOnReceive": "", "jsonPath": "", "qos": 0, "retained": false, 4 | "topic": "", "topicPub": "sonoff/sauna/set", "updateLastPayloadOnPub": true, "id": "5cc2df87-56d1-4852-a785-3fa969d558c5", 5 | "jsBlinkExpression": "", "jsOnDisplay": "", "jsOnTap": "", "longId": 1, "name": "On/Off", "type": 2 }, { "iconOff": "ic_flash_off", 6 | "iconOn": "ic_flash_on", "offColor": -1, "onColor": -1, "payloadOff": "0", "payloadOn": "1", "enableIntermediateState": true, 7 | "enablePub": false, "enteredIntermediateStateAt": 0, "intermediateStateTimeout": 0, "jsOnReceive": "", "jsonPath": "$.state", 8 | "qos": 0, "retained": false, "topic": "sonoff/sauna", "topicPub": "", "updateLastPayloadOnPub": true, "id": 9 | "d9eacfa0-385b-4717-a09c-ea9d2f56acd5", "jsBlinkExpression": "", "jsOnDisplay": "", "jsOnTap": "", "longId": 4, "name": "Power", 10 | "type": 2 }, { "iconOff": "ic_fire_01", "iconOn": "ic_fire_01", "offColor": -10461088, "onColor": -49088, "payloadOff": "0", "payloadOn": "1", 11 | "enableIntermediateState": true, "enablePub": false, "enteredIntermediateStateAt": 0, "intermediateStateTimeout": 0, 12 | "jsOnReceive": "", "jsonPath": "$.active", "qos": 0, "retained": false, "topic": "sonoff/sauna", "topicPub": "", "updateLastPayloadOnPub": true, 13 | "id": "2e63a4ac-6c06-4d2f-b748-019f8da2bfee", "jsBlinkExpression": "", "jsOnDisplay": "", "jsOnTap": "", "longId": 5, "name": "Active", "type": 2 }, 14 | { "mainTextSize": "LARGE", "postfix": "", "prefix": "", "textColor": -1, "enableIntermediateState": true, "enablePub": true, 15 | "enteredIntermediateStateAt": 0, "intermediateStateTimeout": 0, "jsOnReceive": "", "jsonPath": "", "qos": 0, "retained": false, 16 | "topic": "", "topicPub": "", "updateLastPayloadOnPub": true, "id": "4e2bd1b1-2495-4263-923b-8629f20ccede", "jsBlinkExpression": "", 17 | "jsOnDisplay": "", "jsOnTap": "", "longId": 10, "name": "", "type": 1 }, { "mainTextSize": "LARGE", "postfix": "°C", "prefix": "", 18 | "textColor": -1, "enableIntermediateState": true, "enablePub": false, "enteredIntermediateStateAt": 0, 19 | "intermediateStateTimeout": 0, "jsOnReceive": "", "jsonPath": "$.celcius", "lastJsonPathValue": "1.5", "qos": 0, "retained": false, 20 | "topic": "sonoff/sauna", "topicPub": "", "updateLastPayloadOnPub": true, "id": "c6681108-1cb9-4da1-8697-9a0f65359540", 21 | "jsBlinkExpression": "", "jsOnDisplay": "", "jsOnTap": "", "longId": 3, "name": "Temp", "type": 1 }, { "mainTextSize": "LARGE", "postfix": "°F", 22 | "prefix": "", "textColor": -1, "enableIntermediateState": true, "enablePub": false, "enteredIntermediateStateAt": 0, 23 | "intermediateStateTimeout": 0, "jsOnReceive": "", "jsonPath": "$.fahrenheit", "qos": 0, "retained": false, "topic": "sonoff/sauna", "topicPub": "", 24 | "updateLastPayloadOnPub": true, "id": "03f4e423-3fe5-46b2-a680-bb1d787fa258", "jsBlinkExpression": "", "jsOnDisplay": "", 25 | "jsOnTap": "", "longId": 8, "name": "Temp", "type": 1 }, { "items": [ { "label": "70", "payload": "{\"setpoint\":70}" }, { "label": "75", 26 | "payload": "{\"setpoint\":75}" }, { "label": "80", "payload": "{\"setpoint\":80}" }, { "label": "85", "payload": "{\"setpoint\":85}" }, 27 | { "label": "90", "payload": "{\"setpoint\":90}" }, { "label": "95", "payload": "{\"setpoint\":95}" }, { "label": "100", "payload": "{\"setpoint\":100}" }, 28 | { "label": "105", "payload": "{\"setpoint\":105}" }, { "label": "110", "payload": "{\"setpoint\":110}" } ], "mainTextSize": "LARGE", 29 | "textColor": -1, "enableIntermediateState": true, "enablePub": true, "enteredIntermediateStateAt": 0, "intermediateStateTimeout": 0, 30 | "jsOnReceive": "", "jsonPath": "$.setpoint", "qos": 0, "retained": false, "topic": "sonoff/sauna", "topicPub": "sonoff/sauna/set", 31 | "updateLastPayloadOnPub": true, "id": "4b9f092c-fe6b-4760-af79-7ed29f888549", "jsBlinkExpression": "", "jsOnDisplay": "", "jsOnTap": "", 32 | "longId": 6, "name": "SetPoint", "type": 4 }, { "mainTextSize": "LARGE", "postfix": "m", "prefix": "", "textColor": -1, "enableIntermediateState": true, 33 | "enablePub": false, "enteredIntermediateStateAt": 0, "intermediateStateTimeout": 0, "jsOnReceive": "", "jsonPath": "$.autooff", "qos": 0, 34 | "retained": false, "topic": "sonoff/sauna", "topicPub": "", "updateLastPayloadOnPub": true, "id": "cb4ba265-b6cb-4a6a-b6ff-9deabdd3357e", 35 | "jsBlinkExpression": "", "jsOnDisplay": "", "jsOnTap": "", "longId": 9, "name": "AutoOff", "type": 1 } ]' 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HarviaWiFi 2 | 3 | Arduino code to control Harvia sauna heaters, with esp8266 enabled sonoff TH device 4 | 5 | 6 | 7 | WARNING: Modifing your Sauna heater with WiFi can cause death if you are not careful. You are dealing with 240V, so take precautions not to kill yourself. I am not responsible if you kill yourself or burn your sauna/house down. You have been warned. 8 | 9 | ## Contents 10 | - sources 11 | - schematics 12 | - instructions 13 | 14 | ## Controlling the sauna 15 | ### Captive Portal 16 | 17 | 18 | - First start is via Captive portal 19 | - Access Point Configuration 20 | - MQTT Configuration 21 | - Sauna Configuration 22 | - See more captive portal sample images in images folder 23 | 24 | ### MQTT DASH APP 25 | 26 | 27 | - MQTT DASH app 28 | - https://play.google.com/store/apps/details?id=net.routix.mqttdash&hl=en 29 | - Turn on/off sauna 30 | - Set point temperature in C 31 | - View Temperature in C/F, operating state and AutoOff status 32 | - See Resource files for faster config of APP 33 | - See more APP sample images in images folder 34 | - In order to use this APP or any other MQTT app, you will need to install an MQTT broker. 35 | - Mosquitto is such a broker and easy to install. Do google search for instructions 36 | - Integrations 37 | - With MQTT you can easily integrate with openhab and homeassistant, which in turn can be linked with alexa or google assistant. 38 | 39 | ## Flashing firmware 40 | ### Firmware requirements 41 | - You will need latest Arduino IDE 42 | - Install ESP8266 board in the IDE 43 | - Install JSON library 5, not 6. I need to recode to support 6+ 44 | - Download pubsub client library for arduino 45 | - Download this code 46 | - Connect SONOFF TH16 to serial <--> usb adapter 47 | - Flash Settings 48 | - Generic ESP8266 Module 49 | - CPU Frequency 80Mhz 50 | - Flash Size 1M 64k SPIFFS 51 | - Flash Mode DIO 52 | - Debug None 53 | - Port COM (select from serial list) 54 | - All other settings default (unless over air after first flash) 55 | - Flash OTA Settings 56 | - Debug Level OTA 57 | - Port sauna at xxx.xxx.xxx.xxx 58 | 59 | ### First time flashing 60 | - !!! DO NOT have mains connected to device when flashing with a serial <-> usb adapter. It will destroy the device and quite possibly your computer and you might even kill yourself !!! 61 | - Connect SONOFF TH16 via serial <-> usb adapterto computer (make sure yours has 3v3 option), do not plug into your computer yet 62 | - Hold white button down and then connect usb side of adapter to your computer. 63 | - Select the COM device under Port setting of the IDE and then Upload 64 | - After flashing is complete, it should reboot and be in captive portal mode. 65 | - Connect your table or phone to open wifi "sauna" and it should ask you to sign in. If not open your browser and go to 192.168.1.1 66 | - Enter config details necessary to connect to your network and MQTT broker. The device will reboot and connect to your network. You should see data on MQTT broker. 67 | - If you mess up configuration at anytime, just hold button down for at least 30 seconds and this will reset back to captive portal mode. 68 | - Now that you have flashed first time, the device should be listed in the OTA section of port selection. If you want to make changes, you can now OTA flash. You can OTA flash even when installed in Sauna. Your Sonoff TH16 may now be install into your sauna. Remember turn off your breaker before doing so!!! 69 | 70 | ### Notes 71 | 72 | - I repeat! Never ever have mains(240V or 120V) connected to device when flashing with a serial <-> usb adapter 73 | - In order to Flash for the first time, you will need to solder wires or headers to the board. When ready to flash, hold push button down and apply power. 74 | - How to backup SONOFF firmware, in case you want to put original firmware back on it. 75 | - esptool.py --port COMPORT read_flash 0x00000 0x100000 sonoff_TH16.bin 76 | - Note: the sonoff TH16 has pads for UFL connector(bottom left of pic below). This allows you to use an external antenna if needed. 77 | 78 | 79 | ## Hardware Installation 80 | 81 | ## Installation 82 | - Turn OFF breaker, 240V will kill you. 83 | - Review Circuit diagrams, so you know which spade connectors to move 84 | - Put 6 20A fuses into fuse block and close 85 | - Open bottom panel of Harvia heater 86 | - Install Fuse block in a convinient spot, I chose to install where thermostat and timer switch were. 87 | - Find the Thermostat 88 | - It has 6 terminals 89 | - Move the 3 from same side and connect to fuse block, same side 90 | - Move the other 3 from other side and connect to fuse block, on opposite side. 91 | - Be sure to keep order of wires, label T1 T2 T3 92 | - Find timer 93 | - There are 3 wires, one goes to limit switch and the other 2 go to contactor 94 | - Two will be close together and one on the side kind of alone. Move the lone one to one side of fuse block. Label as R 95 | - Now two are left. Take the one that goes to limit switch and connect to fuse block. Label L2 96 | - Now there is one left and it goes to contactor, move it to fuse block and label L1 97 | - You should now have all 3 wires on one side of fuse block 98 | - Remove or move the Timer and Thermostat(including its sensor). I moved mine to the side and not in my garage where they will get lost. 99 | - Connecting Wifi SONOFF TH16 100 | - Make 3 long jumper wires with spade connectors on them 101 | - From fuse block opposite L1 connect to sonoff N 102 | - From fuse block opposite L2 connect to sonoff L(in) 103 | - From fuse block opposite R connect to sonoff L(out) 104 | - Hardware installation should be complete and easily reversible, thanks to fuse block 105 | - Turn on breaker and see if Sauna is listed in your Wifi. Connect to it configure and enjoy 106 | 107 | 108 | ### New Circuit Diagram 109 | 110 | 111 | 112 | ### Original Circuit Diagram 113 | 114 | 115 | 116 | ### Parts Required 117 | 118 | - SONOFF TH16 (buy two as a spare) 119 | - https://www.amazon.com/dp/B06XTNSJ46/ 120 | - DS18B20 Waterproof Temperature Sensor 121 | - https://www.amazon.com/gp/product/B078NRBNM8/ 122 | - 2.5mm extension cable 123 | - https://www.amazon.com/gp/product/B00FJEH1PY/ 124 | - Fuse Block 125 | - https://www.amazon.com/gp/product/B0752QMXGC/ 126 | - 20 Amp ATM fuses 127 | - https://www.amazon.com/dp/B000CF7CRW/ 128 | 129 | 130 | ## License 131 | 132 | Licensed under the GNU Lesser General Public License. 133 | https://www.gnu.org/licenses/lgpl.html 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /src/SAUNA.h: -------------------------------------------------------------------------------- 1 | /* 2 | SAUNA.h - Harvia sauna heater control code for Sonoff TH esp8266 based devices. 3 | Copyright (c) 2019 Al Betschart. All right reserved. 4 | This library is free software; you can redistribute it and/or 5 | modify it under the terms of the GNU Lesser General Public 6 | License as published by the Free Software Foundation; either 7 | version 2.1 of the License, or (at your option) any later version. 8 | This library is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 11 | Lesser General Public License for more details. 12 | You should have received a copy of the GNU Lesser General Public 13 | License along with this library; if not, write to the Free Software 14 | Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 15 | */ 16 | const char* setup_page = "" 17 | "" 18 | "" 19 | "" 20 | "SAUNA Config" 21 | "" 28 | "" 67 | "" 68 | "" 69 | "

Sauna Config page

" 70 | "Available Access Points
" 71 | " AP_LIST
" 72 | "Enter credentials
" 73 | "
" 74 | "
" 75 | "Access Point Configuration:" 76 | "
" 77 | "
" 78 | "
" 79 | "
" 80 | "
" 81 | "MQTT Configuration:" 82 | "
" 83 | "
" 84 | "
" 85 | "
" 86 | "
" 87 | "
" 88 | " " 89 | "
" 90 | "
" 91 | "Sauna Configuration:" 92 | "Override Regulatory Rules:
" 93 | "
" 102 | "
" 109 | "
" 113 | "
" 114 | "" 115 | "
" 116 | "" 117 | ""; 118 | 119 | const char* save_page = "" 120 | "" 121 | "" 122 | "SAUNA Config" 123 | "" 126 | "" 127 | "" 128 | "Rebooting and connecting to WiFi network! You should see it listed in on your access point. In order to reconfigure reset device, by holding button for more than 10seconds." 129 | "" 130 | ""; 131 | 132 | 133 | const char* ssid_link = "SSID_DETAIL
"; 134 | -------------------------------------------------------------------------------- /src/SAUNA.ino: -------------------------------------------------------------------------------- 1 | /* 2 | SAUNA.ino - Harvia sauna heater control code for Sonoff TH esp8266 based devices. 3 | Copyright (c) 2019 Al Betschart. All right reserved. 4 | This library is free software; you can redistribute it and/or 5 | modify it under the terms of the GNU Lesser General Public 6 | License as published by the Free Software Foundation; either 7 | version 2.1 of the License, or (at your option) any later version. 8 | This library is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 11 | Lesser General Public License for more details. 12 | You should have received a copy of the GNU Lesser General Public 13 | License along with this library; if not, write to the Free Software 14 | Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 15 | */ 16 | #include "SAUNA.h" 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | #include 24 | #include 25 | #include 26 | //Note PubSubClient.h has a MQTT_MAX_PACKET_SIZE of 128 defined, so either raise it to 256 or use short topics 27 | #include 28 | #include 29 | #include 30 | 31 | // =============================== 32 | // 33 | // WARNING: Modifing your Sauna heater with WiFi can cause death if you are not careful. 34 | // You are dealing with 240V, so take precautions not to kill yourself. I am not responsible 35 | // if you kill yourself or burn your sauna/house down. You have been warned. 36 | // 37 | // =============================== 38 | 39 | #define BUTTON_PIN 0 // Sonoff button 40 | #define RELAY_PIN 12 // Sonoff relay 41 | #define LED_PIN 13 // Sonoff blue LED 42 | #define SENSOR_PIN 14 // Sonoff sensor DS 43 | 44 | //DALLAS Sensors can exceed 80C, TH sensors cannot! So use DS 45 | #define TEMPERATURE_PRECISION 9 46 | OneWire oneWire(SENSOR_PIN); 47 | DallasTemperature sensors(&oneWire); 48 | DeviceAddress sensor; 49 | 50 | // wifi/mqtt variables (these are set by config page and then stored in SPIFFS) 51 | char ap_ssid[32]; 52 | char ap_pwd[64]; 53 | char host_name[10]; 54 | char mqtt_server[40]; 55 | char mqtt_port[6]; 56 | char mqtt_user[10]; 57 | char mqtt_pwd[10]; 58 | char client_id[10]; 59 | char sub_topic[40]; 60 | char set_topic[40]; 61 | WiFiClient espClient; 62 | PubSubClient mqtt_client(espClient); 63 | 64 | // Device Variables 65 | const unsigned long DEBOUNCE = 200; // the debounce time, increase if the output flickers 66 | const unsigned long REBOOT = 5000; // reboot timout for button 67 | const unsigned long FACTORY = 30000; // factory reset, returns to captive protal mode 68 | const unsigned int SEND_INTERVAL = 5000; // interval to return sauan status 69 | boolean deviceOn = false; // device On or Off 70 | int buttonState = LOW; // variable for reading the pushbutton status 71 | int lastButtonState = HIGH; // previous state of the button 72 | int relayState = LOW; // device actively powered 73 | unsigned long lastTime = 0; // the last time the output pin was toggled 74 | unsigned long lastTempSend; // the last time the temperature was read/sent 75 | unsigned long onTime; // the time the device went to ON status 76 | float setTemp = 60.0; // set temperatur, app adjustable 77 | float offSetTemp = 0.5; // does not need adjusting, this prevents short cycling. The heated rocks make sauna go beyond offset 78 | boolean delayedOff = false; // shall off timer start when temperature is reached? Config page adjustable, stored in SPIFFS 79 | int maxRunTime = 60; // max time the sauna will run, depends on abpve variable. Config page adjustable, stored in SPIFFS 80 | boolean firstTemp = false; // first time temperature reached, for delayed off timer 81 | float maxTemp = 90.0; // max temp in Celcius. Config page adjustable, stored in SPIFFS 82 | 83 | //Captive portal variables, only used for config page 84 | const byte DNS_PORT = 53; 85 | IPAddress apIP(192, 168, 1, 1); 86 | IPAddress netMsk(255, 255, 255, 0); 87 | DNSServer dnsServer; 88 | ESP8266WebServer server(80); 89 | boolean captive = false; 90 | 91 | // ============================================================================ 92 | // SETUP 93 | void setup() { 94 | Serial.begin(9600); 95 | Serial.println("Booting"); 96 | init_IO(); 97 | setDefaults(); 98 | esp_info(); 99 | load_config(); 100 | if (init_wifi()) { 101 | init_DS(); 102 | init_OTA(); 103 | init_MQTT(); 104 | } 105 | else { 106 | init_captivePortal(); 107 | } 108 | Serial.println("Ready"); 109 | } 110 | 111 | // Initial IO and force sauna into off status 112 | void init_IO() { 113 | pinMode(BUTTON_PIN, INPUT); // on/off button 114 | pinMode(RELAY_PIN, OUTPUT); // relay 115 | digitalWrite(RELAY_PIN, relayState); // relay off 116 | pinMode(LED_PIN, OUTPUT); // led 117 | digitalWrite(LED_PIN, LOW); // always on 118 | } 119 | 120 | // Set default values on boot, will be over written by SPIFFS read after first config save 121 | void setDefaults() { 122 | strcpy(ap_ssid, ""); 123 | strcpy(ap_pwd, ""); 124 | strcpy(host_name, "sauna"); 125 | strcpy(mqtt_server, ""); 126 | strcpy(mqtt_port, ""); 127 | strcpy(mqtt_user, ""); 128 | strcpy(mqtt_pwd, ""); 129 | strcpy(client_id, ""); 130 | strcpy(sub_topic, ""); 131 | strcpy(set_topic, ""); 132 | delayedOff = false; 133 | maxRunTime = 60; 134 | maxTemp = 90.0; 135 | } 136 | 137 | // Initialize Dallas Sensor 138 | void init_DS() { 139 | sensors.begin();//only for Dallas 140 | sensors.getAddress(sensor, 0);//only for Dallas 141 | sensors.setResolution(sensor, TEMPERATURE_PRECISION); 142 | } 143 | 144 | // Initialize WIFI and decide if Captive Portal AP mode or client mode 145 | boolean init_wifi() { 146 | if (ap_ssid[0] == '\0') { 147 | Serial.println("\n\r \n\rStarting in AP mode"); 148 | WiFi.mode(WIFI_AP); 149 | WiFi.softAPConfig(apIP, apIP, netMsk); 150 | WiFi.softAP("Sauna"); 151 | Serial.print("IP address: "); 152 | Serial.println(WiFi.softAPIP()); 153 | return false; 154 | } 155 | else { 156 | WiFi.hostname(host_name); 157 | WiFi.mode(WIFI_STA); 158 | WiFi.begin(ap_ssid, ap_pwd); 159 | Serial.print("\n\r \n\rWorking to connect"); 160 | //Wait for connection to wifi ap when in client mode 161 | while (WiFi.status() != WL_CONNECTED) { 162 | ledStatus(false, 250); 163 | ledStatus(true, 250); 164 | Serial.print("."); 165 | checkButton(); //emergency reset option, incase of bad password 166 | } 167 | Serial.println(""); 168 | Serial.print("Connected to "); 169 | Serial.println(ap_ssid); 170 | Serial.println("Ready"); 171 | Serial.print("IP address: "); 172 | Serial.println(WiFi.localIP()); 173 | return true; 174 | } 175 | } 176 | 177 | // Enable OTA only when connected as a client. 178 | void init_OTA() { 179 | Serial.println("Start OTA Listener"); 180 | ArduinoOTA.setHostname(host_name); 181 | ArduinoOTA.onStart([]() { 182 | Serial.println("Start"); 183 | }); 184 | ArduinoOTA.onEnd([]() { 185 | Serial.println("\nEnd"); 186 | }); 187 | ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { 188 | Serial.printf("Progress: %u%%\r", (progress / (total / 100))); 189 | ledStatus((progress / (total / 100)) % 2 ? false : true, 0); 190 | }); 191 | ArduinoOTA.onError([](ota_error_t error) { 192 | Serial.printf("Error[%u]: ", error); 193 | if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed"); 194 | else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed"); 195 | else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed"); 196 | else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed"); 197 | else if (error == OTA_END_ERROR) Serial.println("End Failed"); 198 | }); 199 | ArduinoOTA.begin(); 200 | } 201 | 202 | // Init MQTT only when connected as a client. 203 | void init_MQTT() { 204 | mqtt_client.setServer(mqtt_server, atoi(mqtt_port)); 205 | mqtt_client.setCallback(mqttCallback); 206 | mqttConnect(); 207 | Serial.println("MQTT connected"); 208 | } 209 | 210 | // Subscribe to MQTT topic and set callbacks 211 | void mqttConnect() { 212 | while (!mqtt_client.connected()) { // Loop until we're reconnected 213 | if (mqtt_client.connect(client_id, mqtt_user, mqtt_pwd)) { // Attempt to connect 214 | mqtt_client.subscribe(set_topic); 215 | } else { 216 | delay(5000); // Wait 5 seconds before retrying 217 | } 218 | } 219 | } 220 | 221 | // MQTT callback, this is where we process received data from broker 222 | void mqttCallback(char* topic, byte* payload, unsigned int length) { 223 | // Copy payload into message buffer 224 | char message[length + 1]; 225 | for (int i = 0; i < length; i++) { 226 | message[i] = (char)payload[i]; 227 | } 228 | message[length] = '\0'; 229 | if (strcmp(topic, set_topic) == 0) { //if the incoming message is on the boiler_set_topic topic... 230 | // Parse message into JSON 231 | const size_t bufferSize = JSON_OBJECT_SIZE(2); 232 | DynamicJsonBuffer jsonBuffer(bufferSize); 233 | JsonObject& root = jsonBuffer.parseObject(message); 234 | if (!root.success()) { 235 | mqtt_client.publish(sub_topic, "!root.success(): invalid JSON on set_topic..."); 236 | return; 237 | } 238 | if (root.containsKey("state")) { 239 | boolean state = root["state"]; 240 | deviceOn = state; 241 | if (deviceOn) { 242 | onTime = millis(); 243 | firstTemp = false; 244 | } 245 | ledStatus(false, 100); 246 | ledStatus(true, 100); 247 | ledStatus(false, 100); 248 | ledStatus(true, 0); 249 | } 250 | else if (root.containsKey("setpoint")) { 251 | setTemp = root["setpoint"] > maxTemp ? maxTemp : root["setpoint"]; 252 | } 253 | else { 254 | mqtt_client.publish(sub_topic, "sauna: update failed"); 255 | } 256 | } 257 | } 258 | 259 | // MQTT publish, this is where we data to broker 260 | void sendData(float tempC, float tempF) { 261 | const size_t bufferSize = JSON_OBJECT_SIZE(6); 262 | DynamicJsonBuffer jsonBuffer(bufferSize); 263 | 264 | JsonObject& root = jsonBuffer.createObject(); 265 | root["celcius"] = tempC; 266 | root["fahrenheit"] = tempF; 267 | root["state"] = deviceOn == true ? 1 : 0; 268 | root["active"] = relayState == HIGH ? 1 : 0; 269 | root["setpoint"] = setTemp; 270 | int timeLeft = round(((maxRunTime * 60 * 1000) - (millis() - onTime)) / (60 * 1000)) + 1; 271 | root["autooff"] = !deviceOn || timeLeft < 0 || timeLeft > maxRunTime ? 0 : timeLeft; 272 | 273 | char buffer[512]; 274 | root.printTo(buffer, sizeof(buffer)); 275 | 276 | bool retain = true; 277 | if (!mqtt_client.publish(sub_topic, buffer, retain)) { 278 | mqtt_client.publish(sub_topic, "failed to publish to topic"); 279 | } 280 | ledStatus(false, 200); 281 | ledStatus(true, 0); 282 | } 283 | 284 | // Initialize captive portal page 285 | void init_captivePortal() { 286 | Serial.println("Starting captive portal"); 287 | dnsServer.setErrorReplyCode(DNSReplyCode::NoError); 288 | dnsServer.start(DNS_PORT, "*", apIP); 289 | server.on("/", handle_root); 290 | server.on("/generate_204", handle_root); 291 | server.on("/save", handle_save); 292 | server.onNotFound(handleNotFound); 293 | server.begin(); 294 | captive = true; 295 | } 296 | 297 | // Handler for page not found, always return 200 298 | void handleNotFound() { 299 | Serial.print("\t\t\t\t URI Not Found: "); 300 | Serial.println(server.uri()); 301 | server.send ( 200, "text/plain", "URI Not Found" ); 302 | } 303 | 304 | // Handler to serve our config page 305 | void handle_root() { 306 | Serial.println("Root served"); 307 | String toSend = setup_page; 308 | toSend.replace("AP_LIST", scan()); 309 | server.send(200, "text/html", toSend); 310 | delay(100); 311 | } 312 | 313 | // Handler to serve or save page 314 | void handle_save() { 315 | Serial.println("Save served"); 316 | if (server.hasArg("submit")) { 317 | save_config(server.arg("ap_ssid"), server.arg("ap_pwd"), server.arg("host_name"), 318 | server.arg("mqtt_host"), server.arg("mqtt_port"), server.arg("mqtt_user"), 319 | server.arg("mqtt_pwd"), server.arg("mqtt_id"), server.arg("mqtt_sub"), 320 | server.arg("mqtt_pub"), server.arg("delayoff"), server.arg("maxtime"), 321 | server.arg("maxtemp")); 322 | } 323 | String toSend = save_page; 324 | server.send(200, "text/html", toSend); 325 | delay(100); 326 | ESP.restart(); 327 | } 328 | 329 | // Scan for wifi AP availabilty, used for Config page to help select an AP 330 | String scan() { 331 | String wifiList = ""; 332 | int n = WiFi.scanNetworks(); 333 | for (int i = 0; i < n; ++i) { 334 | String linkText = ssid_link; 335 | // Print SSID and RSSI for each network found 336 | Serial.print(i + 1); 337 | Serial.print(": "); 338 | Serial.print(WiFi.SSID(i)); 339 | Serial.print(" ("); 340 | Serial.print(WiFi.RSSI(i)); 341 | Serial.print(")"); 342 | Serial.println((WiFi.encryptionType(i) == ENC_TYPE_NONE) ? " " : "*"); 343 | linkText.replace("SSID_DETAIL", WiFi.SSID(i) + " ( " + WiFi.RSSI(i) + " )"); 344 | linkText.replace("SSID", WiFi.SSID(i)); 345 | delay(10); 346 | wifiList += linkText; 347 | } 348 | return wifiList; 349 | } 350 | 351 | // CHIP info, mainly for debugging 352 | void esp_info() { 353 | uint32_t realSize = ESP.getFlashChipRealSize(); 354 | uint32_t ideSize = ESP.getFlashChipSize(); 355 | FlashMode_t ideMode = ESP.getFlashChipMode(); 356 | 357 | Serial.printf("Chip ID: %08x", ESP.getChipId()); 358 | Serial.printf("Flash real id: %08X\n", ESP.getFlashChipId()); 359 | Serial.printf("Flash real size: %u bytes\n\n", realSize); 360 | 361 | Serial.printf("Flash ide size: %u bytes\n", ideSize); 362 | Serial.printf("Flash ide speed: %u Hz\n", ESP.getFlashChipSpeed()); 363 | Serial.printf("Flash ide mode: %s\n", (ideMode == FM_QIO ? "QIO" : ideMode == FM_QOUT ? "QOUT" : ideMode == FM_DIO ? "DIO" : ideMode == FM_DOUT ? "DOUT" : "UNKNOWN")); 364 | 365 | if (ideSize != realSize) { 366 | Serial.println("Flash Chip configuration wrong!\n"); 367 | } else { 368 | Serial.println("Flash Chip configuration ok.\n"); 369 | } 370 | } 371 | 372 | // Helper function for making LED blink 373 | void ledStatus(boolean state, int delay_ms) { 374 | digitalWrite(LED_PIN, state ? LOW : HIGH); 375 | if (delay_ms > 0) { 376 | delay(delay_ms); 377 | } 378 | } 379 | 380 | // Read DS temperatur value and send to MQTT topic 381 | void readSensor() { 382 | if (millis() < (lastTempSend + SEND_INTERVAL)) { // only send the temperature every Xms as defined at top 383 | return; 384 | } 385 | sensors.requestTemperatures(); 386 | float c = sensors.getTempC(sensor); 387 | float f = DallasTemperature::toFahrenheit(c); 388 | sendData(c, f); 389 | changeRelayState(c); 390 | lastTempSend = millis(); 391 | } 392 | 393 | // Turn on/off Sauna based on status and temperature 394 | void changeRelayState(float temp) { 395 | if (deviceOn && delayedOff && !firstTemp) { 396 | onTime = millis(); 397 | } 398 | if(deviceOn) { 399 | checkTime(); 400 | } 401 | if (deviceOn && temp < setTemp - offSetTemp) { 402 | relayState = HIGH; 403 | digitalWrite(RELAY_PIN, relayState); 404 | } 405 | else if ((!deviceOn) || (deviceOn && temp > setTemp + offSetTemp)) { 406 | relayState = LOW; 407 | digitalWrite(RELAY_PIN, relayState); 408 | if (deviceOn && delayedOff && !firstTemp) { 409 | firstTemp = true; 410 | } 411 | } 412 | 413 | } 414 | 415 | // Auto off timer 416 | void checkTime() { 417 | if (deviceOn && millis() - onTime > maxRunTime * 60 * 1000) { 418 | deviceOn = false; 419 | } 420 | } 421 | 422 | void load_config() { 423 | delay(1000); 424 | if (SPIFFS.begin()) { 425 | //save_config("", "", "", "", "", "", "", "", "", "", "false", "60", "90.0"); //we can use this if we need to add more config, so SPIFF will match 426 | Serial.println("mounted file system"); 427 | if (SPIFFS.exists("/config.json")) { 428 | //file exists, reading and loading 429 | Serial.println("reading config file"); 430 | File configFile = SPIFFS.open("/config.json", "r"); 431 | if (configFile) { 432 | Serial.println("opened config file"); 433 | size_t size = configFile.size(); 434 | // Allocate a buffer to store contents of the file. 435 | std::unique_ptr buf(new char[size]); 436 | 437 | configFile.readBytes(buf.get(), size); 438 | const size_t bufferSize = JSON_OBJECT_SIZE(10); 439 | DynamicJsonBuffer jsonBuffer(bufferSize); 440 | JsonObject& json = jsonBuffer.parseObject(buf.get()); 441 | json.printTo(Serial); 442 | if (json.success()) { 443 | Serial.println("\nparsed json"); 444 | strcpy(ap_ssid, json["ap_ssid"]); 445 | strcpy(ap_pwd, json["ap_pwd"]); 446 | strcpy(host_name, json["host_name"]); 447 | strcpy(mqtt_server, json["mqtt_host"]); 448 | strcpy(mqtt_port, json["mqtt_port"]); 449 | strcpy(mqtt_user, json["mqtt_user"]); 450 | strcpy(mqtt_pwd, json["mqtt_pwd"]); 451 | strcpy(client_id, json["mqtt_id"]); 452 | strcpy(sub_topic, json["mqtt_sub"]); 453 | strcpy(set_topic, json["mqtt_pub"]); 454 | delayedOff = json["delayedOff"]; 455 | maxRunTime = json["maxRunTime"]; 456 | maxTemp = json["maxTemp"]; 457 | } else { 458 | Serial.println("failed to load json config"); 459 | } 460 | configFile.close(); 461 | } 462 | } 463 | } else { 464 | Serial.println("failed to mount FS"); 465 | } 466 | } 467 | 468 | void save_config(String apSsid, String apPwd, String hostName, 469 | String mqttHost, String mqttPort, String mqttUser, 470 | String mqttPwd, String mqttId, String mqttSub, 471 | String mqttPub, String delayoff, String maxtime, 472 | String maxtemp) { 473 | const size_t bufferSize = JSON_OBJECT_SIZE(12); 474 | DynamicJsonBuffer jsonBuffer(bufferSize); 475 | JsonObject& json = jsonBuffer.createObject(); 476 | json["ap_ssid"] = apSsid; 477 | json["ap_pwd"] = apPwd; 478 | json["host_name"] = hostName; 479 | json["mqtt_host"] = mqttHost; 480 | json["mqtt_port"] = mqttPort; 481 | json["mqtt_user"] = mqttUser; 482 | json["mqtt_pwd"] = mqttPwd; 483 | json["mqtt_id"] = mqttId; 484 | json["mqtt_sub"] = mqttSub; 485 | json["mqtt_pub"] = mqttPub; 486 | json["delayedOff"] = delayoff == "" ? "false" : delayoff; 487 | json["maxRunTime"] = maxtime == "" ? "60" : maxtime; 488 | json["maxTemp"] = maxtemp == "" ? "90.0" : maxtemp; 489 | 490 | File configFile = SPIFFS.open("/config.json", "w"); 491 | if (!configFile) { 492 | Serial.println("failed to open config file for writing"); 493 | } 494 | 495 | json.printTo(Serial); 496 | json.printTo(configFile); 497 | configFile.close(); 498 | } 499 | 500 | 501 | void checkButton() { 502 | int buttonState = digitalRead(BUTTON_PIN); 503 | if (buttonState == LOW && lastButtonState == HIGH) { 504 | lastTime = millis(); 505 | } 506 | else if (buttonState == HIGH && lastButtonState == LOW && millis() - lastTime > FACTORY) { 507 | save_config("", "", "", "", "", "", "", "", "", "", "false", "60", "90.0"); 508 | delay(100); 509 | ESP.restart(); 510 | } 511 | else if (buttonState == HIGH && lastButtonState == LOW && millis() - lastTime > REBOOT) { 512 | ESP.restart(); 513 | } 514 | else if (buttonState == HIGH && lastButtonState == LOW && millis() - lastTime > DEBOUNCE) { 515 | deviceOn = deviceOn == true ? false : true; 516 | if (deviceOn) { 517 | onTime = millis(); 518 | firstTemp = false; 519 | } 520 | ledStatus(false, 100); 521 | ledStatus(true, 100); 522 | ledStatus(false, 100); 523 | ledStatus(true, 0); 524 | } 525 | lastButtonState = buttonState; 526 | } 527 | 528 | 529 | // =============================== 530 | // Program LOOP 531 | void loop() { 532 | if (captive) { 533 | dnsServer.processNextRequest(); 534 | server.handleClient(); 535 | } 536 | else { 537 | ArduinoOTA.handle(); 538 | if (!mqtt_client.connected()) { 539 | mqttConnect(); 540 | } 541 | checkButton(); 542 | readSensor(); 543 | mqtt_client.loop(); 544 | } 545 | } 546 | --------------------------------------------------------------------------------