Monitoring and controlling my furnace using Tasmota

Introduction

This article is a follow-up of my previous (old)  French post “Domotiser une ancienne chaudière mazout : Gestion du thermostat“. That previous built used a custom firmware to control relays, measure water temps and show status on a OLED screen.

Time has past and Tasmota is a very capable and easy to integrate framework. I don’t use much the OLED screen so I decided it was time to get rid of that old custom software.

Obviously, Tasmota supports OLED displays, I might consider going down the docs at Tasmota > Features > Displays. A custom build using USE_DISPLAY_SSD1306 will be required as well as quite some read.

Hardware

This built is similar to Monitoring a boiler using Tasmota on ESP8266 and DS18B20 sensors but adds relays to control the Power and Circulator.

Another option is to use an OpenTherm interface but that’d require changing my physical thermostat. As far as possible I prefer non invasive hacks that are easy to reverse.

The below overview shows the main components such that:

Test bed and wall box assembly

Bill Of Materials

The BoM is exactly the same as for the Monitoring a boiler using Tasmota on ESP8266 and DS18B20 sensors except you need one or two relays to control the furnace power / circultor.

Part Quantity Approx. Price
Perfboard : Main PCB to put your assembly 1 15€ for a set of 34
PCB 2 pin terminal : Optional direct 5V power input 1 10€ for a set of 50
PCB 3 pin terminal : One per temp sensor 4 14€ for a set of 50
WeMos D1 Mini ESP8266 : The brain 1 5€
DS18B20 : Waterproof temp sensors with 1m wire 3 10€ for a set of 10
Flexible silicone wire rolls : To wire the board 1 12€ for a set of 5 spools of 10 meters
Resistors assortment kit : One 4.7Kohm for data bus of DS18B20 1 5€ for a set of 500
5V relay module : Use a 2 Way to control power and circulator, 1 Way for circulator alone 1 2€

Software

Tasmota

Just connect your WeMos D1 Mini ESP8266 to your computer over USB and install the regular Tasmota image using https://tasmota.github.io/install/.

To configure your board, use Module type = Generic(18) with below settings (adjust based on your actual wiring):

  • D5 GPIO14 : Relay_i #1 for Power
  • D6 GPIO12 : Relay_i #2 for Circulator
  • D7 GPIO13 : DS18x20 #1 – As the DS18B20 uses a bus, a single data pin is required for both sensors (or more!)

Finally configure the MQTT to point to you Home Assistant server.

Home Assistant

In HA, make sure to setup the Tasmota integration, this will detect your Tasmota device over MQTT and setup the entities automatically.

Once entities are present in HA, I included them as shown below.

Here is the YAML configuration

type: vertical-stack
cards:
  - cards:
      - show_name: true
        show_icon: true
        entity: switch.tasmota_furnace_relay_power
        hold_action:
          action: none
        tap_action:
          action: toggle
        type: button
        icon: mdi:power
      - show_name: true
        show_icon: true
        entity: switch.tasmota_furnace_relay_circulator
        hold_action:
          action: none
        tap_action:
          action: toggle
        type: button
        icon: mdi:engine
    type: horizontal-stack
  - type: history-graph
    entities:
      - entity: switch.tasmota_furnace_relay_circulator
      - entity: sensor.tasmota_furnace_ds18b20_1_temperature
      - entity: sensor.tasmota_furnace_ds18b20_2_temperature
    hours_to_show: 8
    refresh_interval: 1
    title: Chaudière

Creating a thermostat based on Node-RED

My initial logic was to write a loop and accordingly act within the loop.

This proved to be a big PITA as multiple messages started flowing down the pipe, causing havoc. Concurrent messages interfered with the control logic and the switch was kind of flashing.

I’m now using a much more simple approach : Allow a single message at any given time, killing any undesired control message while already busy. This looks like a critical section and is achieved using a semaphore. The semaphore is a simple flag set early and released when job is done (or skipped).

Here is pseudo code code for this control logic:

  • Triggers :
    • Every minute (heat/rest are whole minute based) for full automation
    • Manual trigger from Node-RED
    • When mode is changed from UI
  • Check if allowed to send a command using a semaphore gate (“Is it already busy?”)
    • Get defined settings (heat/rest durations and auto/manual control)
    • Based on thermostat mode
      • Manual : Stop automation here for obvious reasons
      • Automatic : Run automation with settings defined in UI
      • Otherwise : Build message with predefined settings, based on heating strategy
    • Run heat/rest magic
      • Update UI using new settings (heat/rest durations)
      • Heat for specified duration (turn on circulator) and wait
      • Rest for specified duration (turn off circulator) and wait
      • Clear the semaphore to allow further messages (“I’m done, ready to work”)

Here is the actual flow along with its JSON:

 

[{"id":"eb408b97.5c50a8","type":"comment","z":"8fcdcb3d.6fcf08","name":"Triggers and setup","info":"","x":770,"y":40,"wires":[]},{"id":"c83ef0a8.3cd77","type":"comment","z":"8fcdcb3d.6fcf08","name":"Update UI (heat/rest durations sliders)","info":"","x":830,"y":300,"wires":[]},{"id":"1ac272ed.8b58ed","type":"function","z":"8fcdcb3d.6fcf08","name":"Setup boiler","func":"// Set constants for thermostat modes\nconst cThermostatModeManuel = \"Manuel\";\nconst cThermostatModeAutomatique = \"Automatique\";\nconst cThermostatModeNormal = \"Normal\";\nconst cThermostatModeBoost = \"Boost\";\n\nconst cBoilerModeAuto = \"auto\";\nconst cBoilerModeManual = \"manual\";\n\n// Get mode from UI\nvar thermostatMode = cThermostatModeManuel; // Default\nconst globalHomeAssistant = global.get('homeassistant').homeAssistant;\nif (globalHomeAssistant.isConnected) {\n  thermostatMode = globalHomeAssistant.states[\"input_select.boiler_thermostat_mode\"].state;\n}\n\n// Define heat/rest delays based on thermostat mode\nconst heatingModes = new Map([\n  [cThermostatModeManuel, { heat: 0, rest: 0, boiler_mode: cBoilerModeManual }],\n  [cThermostatModeAutomatique, { heat: 0, rest: 0, boiler_mode: cBoilerModeAuto }],\n  [cThermostatModeNormal, { heat: 2, rest: 10, boiler_mode: cBoilerModeAuto }],\n  [cThermostatModeBoost, { heat: 10, rest: 1, boiler_mode: cBoilerModeAuto }]\n]);\n\n// Based on Thermostat mode, get active settings\nconst settings = heatingModes.get(thermostatMode);\nmsg.thermostatMode = thermostatMode;\nmsg.duration_heat = settings.heat;\nmsg.duration_rest = settings.rest;\nmsg.boiler_mode = settings.boiler_mode;\n\n// If manual : Clear semaphore and drop message\nif (cBoilerModeManual == msg.boiler_mode) {\n  //node.warn(\"Thermostate mode - manual mode, skipping\");\n  global.set(\"furnaceControlBusy\", undefined);\n  return;\n}\n\n// Overwrite if \"Automatique\" (aka automatic with values from UI)\nif (cThermostatModeAutomatique == thermostatMode) {\n  if (globalHomeAssistant.isConnected) {\n    msg.duration_heat = Number(globalHomeAssistant.states[\"input_number.slider_boiler_heat\"].state);\n    msg.duration_rest = Number(globalHomeAssistant.states[\"input_number.slider_boiler_rest\"].state);\n  }\n}\n\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":1090,"y":200,"wires":[["1998cd41.f26df3","dd9cc74da6188be5"]]},{"id":"1998cd41.f26df3","type":"debug","z":"8fcdcb3d.6fcf08","name":"Debug thermostat mode","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1350,"y":240,"wires":[]},{"id":"feb8fbe2.d7df68","type":"server-state-changed","z":"8fcdcb3d.6fcf08","name":"Furnace : Thermostat mode","server":"1e5e7dca.395042","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["input_select.boiler_thermostat_mode"],"substring":[],"regex":[]},"outputInitially":true,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"2","forType":"num","forUnits":"seconds","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[],"x":840,"y":180,"wires":[["baf53ed4484f08a4"]],"info":"Set a 5 secs delay to allow for manual tuning of delays in UI"},{"id":"c5cb1b119f3345f7","type":"delay","z":"8fcdcb3d.6fcf08","name":"Heating","pauseType":"delayv","timeout":"3","timeoutUnits":"minutes","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":1240,"y":560,"wires":[["aae85b34d38269a4"]]},{"id":"4d3783b50a776937","type":"delay","z":"8fcdcb3d.6fcf08","name":"Rest","pauseType":"delayv","timeout":"7","timeoutUnits":"minutes","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":1230,"y":620,"wires":[["7ebe38ac487147ad"]]},{"id":"f4fafa0d3b3007c0","type":"api-call-service","z":"8fcdcb3d.6fcf08","name":"Heat","server":"1e5e7dca.395042","version":7,"debugenabled":false,"action":"switch.turn_on","floorId":[],"areaId":[],"deviceId":[],"entityId":["switch.tasmota_furnace_relay_circulator"],"labelId":[],"data":"","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":false,"domain":"switch","service":"turn_on","x":850,"y":560,"wires":[["47b10d30590d174c"]]},{"id":"aae85b34d38269a4","type":"api-call-service","z":"8fcdcb3d.6fcf08","name":"Rest","server":"1e5e7dca.395042","version":7,"debugenabled":false,"action":"switch.turn_off","floorId":[],"areaId":[],"deviceId":[],"entityId":["switch.tasmota_furnace_relay_circulator"],"labelId":[],"data":"","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":false,"domain":"switch","service":"turn_off","x":850,"y":620,"wires":[["00e64e5136b06f41"]]},{"id":"47b10d30590d174c","type":"function","z":"8fcdcb3d.6fcf08","name":"Set delay","func":"const globalHomeAssistant = global.get('homeassistant').homeAssistant;\n\n// Get delay from slider\nvar delay = 2; // Default\nif (globalHomeAssistant.isConnected) {\n    delay = globalHomeAssistant.states[\"input_number.slider_boiler_heat\"].state;\n}\n\n// Update message\nmsg.delay = Number(delay) * 1000 * 60; // Cast delay to milliseconds\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":1080,"y":560,"wires":[["c5cb1b119f3345f7"]]},{"id":"00e64e5136b06f41","type":"function","z":"8fcdcb3d.6fcf08","name":"Set delay","func":"// Constants\nconst globalHomeAssistant = global.get('homeassistant').homeAssistant;\nconst cMinDelayEco = 10;\n\n// Get delay from slider\nvar delay = 0; // Default\nif (globalHomeAssistant.isConnected) {\n    delay = globalHomeAssistant.states[\"input_number.slider_boiler_rest\"].state;\n}\n\n// Check if \"Chauffage éco\" is enabled, limiting max temp for all rooms\nif (\"on\" == globalHomeAssistant.states[\"input_boolean.chauffage_eco\"].state) {\n    if (delay < cMinDelayEco) {\n        delay = cMinDelayEco;\n    }\n}\n/*\n*/\n\n// Update message\nmsg.delay = Number(delay) * 1000 * 60; // Cast delay to milliseconds\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":1080,"y":620,"wires":[["4d3783b50a776937"]]},{"id":"64e5573c0732e7ce","type":"inject","z":"8fcdcb3d.6fcf08","name":"Start thermostat","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"10","topic":"","payload":"","payloadType":"date","x":800,"y":140,"wires":[["baf53ed4484f08a4"]]},{"id":"11c89048274bdefe","type":"api-call-service","z":"8fcdcb3d.6fcf08","name":"Set heat","server":"1e5e7dca.395042","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.slider_boiler_heat"],"labelId":[],"data":"{\"value\":\"{{duration_heat}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":false,"domain":"input_number","service":"set_value","x":860,"y":360,"wires":[["ea53b426efd0098e"]]},{"id":"ea53b426efd0098e","type":"api-call-service","z":"8fcdcb3d.6fcf08","name":"Set rest","server":"1e5e7dca.395042","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.slider_boiler_rest"],"labelId":[],"data":"{\"value\":\"{{duration_rest}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":false,"domain":"input_number","service":"set_value","x":860,"y":420,"wires":[["4fce910c65b5dd88"]]},{"id":"b5442cdff8f10bd2","type":"inject","z":"8fcdcb3d.6fcf08","name":"Repeat every minute","props":[],"repeat":"60","crontab":"","once":true,"onceDelay":"10","topic":"","x":820,"y":100,"wires":[["baf53ed4484f08a4"]]},{"id":"7ebe38ac487147ad","type":"function","z":"8fcdcb3d.6fcf08","name":"Drop semaphore","func":"global.set(\"furnaceControlBusy\", undefined);\nreturn;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":890,"y":680,"wires":[[]]},{"id":"baf53ed4484f08a4","type":"function","z":"8fcdcb3d.6fcf08","name":"Semaphore gate","func":"// Continue only if Home Assistant is properly connected\nconst globalHomeAssistant = global.get('homeassistant').homeAssistant;\nif (!globalHomeAssistant.isConnected) {\n    node.warn(\"Thermostate mode - HA connection not ready yet, skipping\");\n    return;\n}\n\n// Prevent concurrent messages using a semaphore\nif (global.get(\"furnaceControlBusy\") == \"1\") {\n    node.warn(\"Thermostate mode - furnaceControlBusy already set, skipping\");\n    return;\n} else {\n    global.set(\"furnaceControlBusy\", \"1\"); \n    msg.semaphore_state = global.get(\"furnaceControlBusy\");\n}\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1110,"y":140,"wires":[["1ac272ed.8b58ed"]]},{"id":"dd9cc74da6188be5","type":"link out","z":"8fcdcb3d.6fcf08","name":"link out 10","mode":"link","links":["404d8683433196f3"],"x":1255,"y":200,"wires":[]},{"id":"404d8683433196f3","type":"link in","z":"8fcdcb3d.6fcf08","name":"link in 2","links":["dd9cc74da6188be5"],"x":735,"y":360,"wires":[["11c89048274bdefe"]]},{"id":"282e253f6c0d30f9","type":"link in","z":"8fcdcb3d.6fcf08","name":"link in 3","links":[],"x":735,"y":560,"wires":[["f4fafa0d3b3007c0"]]},{"id":"4fce910c65b5dd88","type":"link out","z":"8fcdcb3d.6fcf08","name":"link out 11","mode":"link","links":[],"x":975,"y":420,"wires":[]},{"id":"b59b0ca068019589","type":"comment","z":"8fcdcb3d.6fcf08","name":"Boiler heat/rest magic","info":"","x":780,"y":500,"wires":[]},{"id":"1e5e7dca.395042","type":"server","name":"Home Assistant","version":5,"addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true,"heartbeat":false,"heartbeatInterval":30,"areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":"at: ","statusYear":"hidden","statusMonth":"short","statusDay":"numeric","statusHourCycle":"h23","statusTimeFormat":"h:m","enableGlobalContextStore":true}]

Conclusion

I enjoyed working on this build and it is probably the hardware I use the most for my home automation. The use of Tasmota dramatically simplifies its deployment and maintenance.

However this is only one of three big parts for my heating automation:

  1. Furnace control : This post
  2. Radiators control using Zigbee valves : It’s as simple as adding the Sonoff ZBDongle-P and some Zigbee radiator valves from MOES
  3. Full radiators automation : Home Assistant to control per room valves on a schedule

I still have some work to put on automating the heating itself. I’m currently relying on the physical thermostat and manual circulator control.

Post Author: Shut

Leave a Reply

Your email address will not be published. Required fields are marked *