Quite some time ago I posted a picture of my floor heating regulation. For each room I am running a software thermostat which needed many nodes and was clumsy and hard to read but it works perfectly. (12 rooms, 12 Nod On STPH-2-1-05 Enocean HT sensors, 1 UniPi 1.1, 2 shelly pro4, 23 valves, 3 z-wave radiator thermostats)
Therefore, I was looking for and easy 2 point regulator (thermostat) in Node-red which fulfilled my requirements. Unfortunately, there was no such piece of software and I wrote a single function node that does it all. (including boost functionality)
If you are interested, please feel free to use it and if you could improve it, I would very much appreciate your feedback.
[{"id":"7c89a60c.c53b78","type":"tab","label":"SW-Thermostat","disabled":false,"info":""},{"id":"4b541c43.e7c024","type":"mqtt in","z":"7c89a60c.c53b78","name":"ist Temp DGWZ","topic":"shellies/shellyht-58F85A/sensor/temperature/#","qos":"0","datatype":"auto","broker":"99603ece.52e2a","nl":false,"rap":true,"rh":0,"x":220,"y":380,"wires":[["951c9593.ed66c8"]]},{"id":"cd9c90bf.de3fb","type":"change","z":"7c89a60c.c53b78","name":"automatic on off","rules":[{"t":"set","p":"automatic-switch","pt":"flow","to":"off","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":680,"y":80,"wires":[[]]},{"id":"2d0ceba.3666e14","type":"inject","z":"7c89a60c.c53b78","name":"auto off","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":480,"y":80,"wires":[["cd9c90bf.de3fb"]]},{"id":"37cae76f.4fe0f8","type":"function","z":"7c89a60c.c53b78","name":"Zweipunktregler","func":"let topic = msg.topic;\nlet payload = msg.payload;\nlet runtime;\nlet starttime;\nlet endtime;\nlet myTimeout = 0;\n\n//--------\n\nswitch(topic) {\n case \"soll\":\n soll();\n break;\n case \"config_hys\":\n config_hys();\n break;\n case \"config_boost\":\n config_boost();\n break;\n case \"config_room\":\n config_room(payload);\n break; \n case \"ist\":\n payload = parseFloat(payload);\n context.set(\"ist\", payload);\n if (context.get(\"min\") === 0 && context.get(\"max\") === 0){\n reset();\n } \n ist(payload);\n break;\n case \"switchit\":\n switchit(payload);\n break; \n case \"reset\":\n reset();\n break; \n default:\n return null;\n}\nreturn null;\n\n//--------\n\nfunction soll (){\n context.set (\"soll\",msg.payload);\n t = \"soll initialisiert mit \" + msg.payload + \"Grad C\";\n node.status({fill:\"green\",shape:\"dot\",text:t}); \n return; \n}\n\n//--------\n\nfunction config_hys(){\n context.set (\"h_low\", msg.payload.hys_low);\n context.set (\"h_high\", msg.payload.hys_high);\n node.status({fill:\"green\",shape:\"dot\",text:\"Hystereris initialisiert\"}); \n return;\n}\n\n//--------\n\nfunction config_boost(){\n let dauer = msg.payload.dauer * 1000 * 60;\n context.set (\"dauer\", dauer);\n context.set (\"anhebung\", msg.payload.anhebung);\n let t = \"boost: \" + msg.payload.anhebung + \" Grad C \" + \"fĂĽr \" + msg.payload.dauer + \" min\";\n node.status({fill:\"green\",shape:\"dot\",text:t}); \n return null;\n}\n\n//--------\n\nfunction config_room(payload){\n context.set (\"room\", payload);\n let t = \"Room: \" + payload;\n node.status({fill:\"green\",shape:\"dot\",text:t});\n msg.topic = \"room\";\n msg.payload = payload;\n node.send(msg); \n return null;\n}\n\n//--------\nfunction ist(payload){\n minmax(payload);\n timeString();\n timeStamp();\n let c = context.get(\"counter\");\n c++;\n context.set(\"counter\", c);\n let ist = payload;\n let soll = context.get(\"soll\");\n let delta = ist - soll;\n msg.topic = \"soll\";\n msg.payload = parseFloat(soll.toFixed(2));\n node.send(msg); \n msg.topic = \"ist\";\n //msg.payload = parseFloat(ist.toFixed(2));\n msg.payload = ist;\n node.send(msg); \n msg.topic = \"delta\";\n msg.payload = parseFloat(delta.toFixed(2));\n node.send(msg);\n let high = context.get(\"h_high\");\n let low = context.get(\"h_low\");\n let off = soll + high;\n let on = soll + low;\n\n if (ist > off){\n let status = \"ist:\" + c + \"/\" + ist + \" soll:\" + soll + \" Hys:\" + low + \" to \" + high + \" switch off\";\n node.status({fill:\"green\",shape:\"dot\",text:status});\n let stat = context.get(\"status\");\n if (stat != \"off\"){\n runtime_stop();\n context.set(\"status\",\"off\");\n msg.topic = \"Regler\";\n msg.payload = \"off\";\n node.send(msg);\n } \n }\n \n if ((ist > on) && (ist < off)){\n let status = \"ist:\" + c + \"/\" + ist + \" soll:\" + soll + \" Hys:\" + low + \" to \" + high + \" switch null\";\n node.status({fill:\"green\",shape:\"dot\",text:status}); \n msg.topic = \"Regler\";\n msg.payload = \"on target\";\n node.send(msg);\n }\n \n if (ist < on){\n let status = \"ist:\" + c + \"/\" + ist + \" soll:\" + soll + \" Hys:\" + low + \" to \" + high + \" switch on\";\n node.status({fill:\"green\",shape:\"dot\",text:status});\n let stat = context.get(\"status\");\n if (stat != \"on\"){\n runtime_start();\n context.set(\"status\",\"on\");\n msg.topic = \"Regler\";\n msg.payload = \"on\";\n node.send(msg);\n } \n }\n return;\n} \n\n//--------\n\nfunction minmax(temp){\n let ist = temp;\n let min = context.get(\"min\");\n let max = context.get(\"max\");\n\n if (ist < min){\n min = ist\n context.set(\"min\", ist);\n } \n else if (ist > max){\n max = ist\n context.set(\"max\", ist)\n }\n\n let t = \"ist:\" + ist + \" min:\" + min +\" max:\" + max; \n node.status({fill:\"green\",shape:\"dot\",text:t});\n\n msg.topic = \"min\";\n msg.payload = min;\n node.send(msg);\n\n msg.topic = \"max\";\n msg.payload = max;\n node.send(msg);\n return;\n} \n\n//--------\n\nfunction switchit(onoff){\n msg.payload = onoff;\n node.send(msg);\n if (onoff === \"on\"){\n context.set (\"boost\", true);\n msg.topic = \"soll\";\n msg.payload = boost_on();\n node.send(msg);\n myTimeout = setTimeout(boost_off,context.get(\"dauer\"));\n context.set(\"myTimeout\", myTimeout);\n }\n else if (onoff === \"off\"){\n context.set (\"boost\", false);\n clearTimeout(context.get(\"myTimeout\"));\n msg.topic = \"soll\";\n msg.payload = context.get(\"soll\");\n node.send(msg);\n }\n return; \n}\n\n//--------\n\nfunction boost_on(){\n let soll_prev = context.get(\"soll\");\n let soll_boost = soll_prev + context.get(\"anhebung\");\n let dauer = context.get(\"dauer\");\n node.status({fill:\"red\",shape:\"dot\",text:\"soll:\" + soll_boost + \" fĂĽr \" + dauer/1000/60 + \" min\"}); \n return soll_boost; \n}\n\nfunction boost_off(){\n msg.payload = context.get(\"soll\");\n node.status({fill:\"green\",shape:\"dot\",text:\"soll:\" + msg.payload}); \n node.send(msg); \n return;\n}\n\n//--------\n\nfunction runtime_start(){\n let r = context.get(\"room\");\n global.set(r,\"on\"); \n context.set(\"starttime\", Date.now());\n return;\n}\n\n//--------\n\nfunction runtime_stop(){\n let r = context.get(\"room\");\n global.set(r,\"off\");\n let end = Date.now();\n //node.warn(\"runtime_stop: message\" + end);\n let start = context.get(\"starttime\")\n if (start === 0){\n start = Date.now();\n }\n //node.warn(\"runtime_stop: message\" + start);\n \n let runtime = end - start;\n msg.topic = \"runtime\";\n msg.payload = runtime;\n node.send(msg);\n\n msg.topic = \"runtimestr\";\n msg.payload = runtime_str(runtime);\n node.send(msg);\n \n let total = context.get(\"runtimetotal\");\n //node.warn(\"total: message\" + context.get(runtimetotal)); \n total = total + runtime;\n context.set(\"runtimetotal\", total);\n msg.topic = \"runtimetotal\";\n msg.payload = total;\n node.send(msg);\n \n return;\n}\n\n//--------\n\nfunction reset(){\n let ist_midnight = context.get(\"ist\");\n context.set(\"min\", ist_midnight);\n context.set(\"max\", ist_midnight);\n return;\n}\n\n//--------\n\nfunction timeString(){\n msg.topic = \"timeString\";\n msg.payload = current_date_time(Date.now());\n node.send(msg);\n return;\n}\n\n//--------\n\nfunction timeStamp(){\n msg.topic = \"timeStamp\";\n msg.payload = Date.now();\n node.send(msg);\n return;\n}\n\n//--------\n\nfunction current_date_time(timestamp){\n let date = new Date(timestamp);\n let year = date.getFullYear();\n let month = (\"0\" + (date.getMonth() + 1)).slice(-2);\n let day = (\"0\" + date.getDate()).slice(-2);\n let hours = (\"0\" + date.getHours()).slice(-2);\n let minutes = (\"0\" + date.getMinutes()).slice(-2);\n let seconds = (\"0\" + date.getSeconds()).slice(-2);\n let datestring = day + \"-\" + month + \"-\" + year + \" \" + hours + ':' + minutes + ':' + seconds; \n return datestring;\n}\n \n//--------\n\n// HH:MM:SS\n\nfunction runtime_str(ms){\n let t = ms / 1000;\n let h = (Math.floor(t / 3600)).toString();\n let m = (Math.floor(t%3600 / 60)).toString();\n let s = (Math.floor(t%3600 % 60)).toString();\n let r = h + \":\" + (\"0\" + m).slice(-2) + \":\" + (\"0\" + s).slice(-2);\n return r; \n}\n","outputs":1,"noerr":0,"initialize":"// Code added here will be run once\n// whenever the node is started.\nnode.status({fill:\"red\",shape:\"ring\",text:\"not configured\"});\n\nif (context.get(\"counter\") === undefined) {\n context.set(\"counter\", 0)\n}\n\ncontext.set (\"boost\",false);\n\nif (context.get(\"max\") === undefined) {\n context.set(\"max\", 0)\n}\nif (context.get(\"min\") === undefined) {\n context.set(\"min\", 0)\n}\n\nif (context.get(\"starttime\") === undefined){\n context.set(\"starttime\", 0);\n}\n\nif (context.get(\"endtime\") === undefined){\n context.set(\"endtime\", 0);\n}\n\nif (context.get(\"runtime\") === undefined){\n context.set(\"runtime\", 0);\n}\n\nif (context.get(\"runtimetotal\") === undefined){\n context.set(\"runtimetotal\", 0);\n}\n\nif (context.get(\"status\") === undefined){\n context.set(\"status\", \"start\");\n}\n\nlet t = \"ist:\" + \"0\" + \" min:\" + \"0\" +\" max:\" + \"0\"; \nnode.status({fill:\"green\",shape:\"dot\",text:t});","finalize":"","libs":[],"x":780,"y":400,"wires":[["ffd8d6dd.22d358"]]},{"id":"a44dfc2.508d3","type":"inject","z":"7c89a60c.c53b78","name":"","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"ist","payload":"20.89","payloadType":"num","x":480,"y":620,"wires":[["37cae76f.4fe0f8"]]},{"id":"42ae6d31.7f8fb4","type":"inject","z":"7c89a60c.c53b78","name":"","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"ist","payload":"21.01","payloadType":"num","x":480,"y":580,"wires":[["37cae76f.4fe0f8"]]},{"id":"d28d691b.61d758","type":"inject","z":"7c89a60c.c53b78","name":"config_hys:{\"hys-high\":0.0,\"hys-low\":-0.1}","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":"0.4","topic":"config_hys","payload":"{\"hys_high\":0.0,\"hys_low\":-0.1}","payloadType":"json","x":380,"y":140,"wires":[["37cae76f.4fe0f8"]]},{"id":"cf8e5796.cfe258","type":"debug","z":"7c89a60c.c53b78","name":"soll","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1310,"y":180,"wires":[]},{"id":"84325e62.d7bbc","type":"inject","z":"7c89a60c.c53b78","name":"soll temp manu 21.00","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":"0.3","topic":"soll","payload":"21.00","payloadType":"num","x":300,"y":420,"wires":[["93bd80d.8ae7e8"]]},{"id":"ffd8d6dd.22d358","type":"switch","z":"7c89a60c.c53b78","name":"soll/ist/delta/Regler...","property":"topic","propertyType":"msg","rules":[{"t":"eq","v":"soll","vt":"str"},{"t":"eq","v":"ist","vt":"str"},{"t":"eq","v":"delta","vt":"str"},{"t":"eq","v":"min","vt":"str"},{"t":"eq","v":"max","vt":"str"},{"t":"eq","v":"Regler","vt":"str"},{"t":"eq","v":"timeString","vt":"str"},{"t":"eq","v":"timeStamp","vt":"str"},{"t":"eq","v":"room","vt":"str"},{"t":"eq","v":"runtime","vt":"str"},{"t":"eq","v":"runtimestr","vt":"str"},{"t":"eq","v":"runtimetotal","vt":"str"}],"checkall":"true","repair":false,"outputs":12,"x":1060,"y":400,"wires":[["cf8e5796.cfe258"],["3b704c18.75f8b4"],["8f92f8cc.a6ef38"],["fa2f0338.4bbfb"],["3508834a.1f16ec"],["bf3a182c.e36468"],["33bd85e9.2a995a"],["cf0b1f45.7b82c"],["9eddd5e.0dd3e28"],["c39d193d.85f9c8"],["d57047e2.aec3a8"],["3abb331e.d7630c"]]},{"id":"bf3a182c.e36468","type":"debug","z":"7c89a60c.c53b78","name":"Regler","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1310,"y":380,"wires":[]},{"id":"8f92f8cc.a6ef38","type":"debug","z":"7c89a60c.c53b78","name":"delta","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1310,"y":260,"wires":[]},{"id":"3b704c18.75f8b4","type":"debug","z":"7c89a60c.c53b78","name":"ist","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1310,"y":220,"wires":[]},{"id":"33bd85e9.2a995a","type":"debug","z":"7c89a60c.c53b78","name":"timeString","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1320,"y":420,"wires":[]},{"id":"cf0b1f45.7b82c","type":"debug","z":"7c89a60c.c53b78","name":"timestamp","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1330,"y":460,"wires":[]},{"id":"12e277b8.6351b8","type":"inject","z":"7c89a60c.c53b78","name":"config_boost:{\"anhebung\":3,\"dauer\":1}","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":"0.5","topic":"config_boost","payload":"{\"anhebung\":3,\"dauer\":1}","payloadType":"json","x":390,"y":180,"wires":[["37cae76f.4fe0f8"]]},{"id":"83f1c8cf.3eebc8","type":"inject","z":"7c89a60c.c53b78","name":"switch boost on","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"switchit","payload":"on","payloadType":"str","x":460,"y":300,"wires":[["37cae76f.4fe0f8"]]},{"id":"1bdaeaa9.507775","type":"inject","z":"7c89a60c.c53b78","name":"switch boost off","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"switchit","payload":"off","payloadType":"str","x":460,"y":340,"wires":[["37cae76f.4fe0f8"]]},{"id":"fa2f0338.4bbfb","type":"debug","z":"7c89a60c.c53b78","name":"min","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1310,"y":300,"wires":[]},{"id":"3508834a.1f16ec","type":"debug","z":"7c89a60c.c53b78","name":"max","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1310,"y":340,"wires":[]},{"id":"a3e3b021.60bde","type":"inject","z":"7c89a60c.c53b78","name":"reset min max","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"00 00 * * *","once":false,"onceDelay":0.1,"topic":"reset","payload":"","payloadType":"date","x":460,"y":260,"wires":[["37cae76f.4fe0f8"]]},{"id":"951c9593.ed66c8","type":"change","z":"7c89a60c.c53b78","name":"","rules":[{"t":"set","p":"topic","pt":"msg","to":"ist","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":470,"y":380,"wires":[["37cae76f.4fe0f8"]]},{"id":"d5b6b10b.f6a15","type":"inject","z":"7c89a60c.c53b78","name":"","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":"1","topic":"config_room","payload":"DGWZ","payloadType":"str","x":440,"y":220,"wires":[["37cae76f.4fe0f8"]]},{"id":"9eddd5e.0dd3e28","type":"debug","z":"7c89a60c.c53b78","name":"room","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1310,"y":500,"wires":[]},{"id":"c39d193d.85f9c8","type":"debug","z":"7c89a60c.c53b78","name":"runtime","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1320,"y":540,"wires":[]},{"id":"d57047e2.aec3a8","type":"debug","z":"7c89a60c.c53b78","name":"runtimestr","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1320,"y":580,"wires":[]},{"id":"3abb331e.d7630c","type":"debug","z":"7c89a60c.c53b78","name":"runtimetotal","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1330,"y":620,"wires":[]},{"id":"d3ff4f8c.7064d","type":"switch","z":"7c89a60c.c53b78","name":"auto","property":"automatic-switch","propertyType":"flow","rules":[{"t":"eq","v":"on","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":490,"y":460,"wires":[["37cae76f.4fe0f8"]]},{"id":"93bd80d.8ae7e8","type":"switch","z":"7c89a60c.c53b78","name":"manu","property":"automatic-switch","propertyType":"flow","rules":[{"t":"eq","v":"off","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":490,"y":420,"wires":[["37cae76f.4fe0f8"]]},{"id":"6950687a.bd9128","type":"function","z":"7c89a60c.c53b78","name":"soll temp auto","func":"\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":260,"y":460,"wires":[["d3ff4f8c.7064d"]]},{"id":"3e65ed7.2ee2e12","type":"comment","z":"7c89a60c.c53b78","name":"test only - off","info":"","x":310,"y":580,"wires":[]},{"id":"28f154da.f9578c","type":"comment","z":"7c89a60c.c53b78","name":"test only - on","info":"","x":310,"y":620,"wires":[]},{"id":"32cd0e60.b2e4f2","type":"comment","z":"7c89a60c.c53b78","name":"von cron plus","info":"different heating profiles stored on disk and can be loaded into cron plus","x":90,"y":460,"wires":[]},{"id":"d48cfc52.3f4b7","type":"comment","z":"7c89a60c.c53b78","name":"slider","info":"","x":110,"y":420,"wires":[]},{"id":"4ed38a00.cc2144","type":"comment","z":"7c89a60c.c53b78","name":"gauge / chart","info":"","x":1530,"y":180,"wires":[]},{"id":"6a073b42.b70e34","type":"comment","z":"7c89a60c.c53b78","name":"text / chart","info":"","x":1520,"y":300,"wires":[]},{"id":"8e060b9b.460b18","type":"comment","z":"7c89a60c.c53b78","name":"text / chart","info":"","x":1520,"y":340,"wires":[]},{"id":"4c0dff50.cd6d5","type":"comment","z":"7c89a60c.c53b78","name":"gauge / chart","info":"","x":1530,"y":260,"wires":[]},{"id":"c4a1a80d.8fd9f8","type":"comment","z":"7c89a60c.c53b78","name":"gauge / chart","info":"","x":1530,"y":220,"wires":[]},{"id":"8ff1745d.64f6e8","type":"comment","z":"7c89a60c.c53b78","name":"on / off relay/valve","info":"","x":1550,"y":380,"wires":[]},{"id":"3d0ab3dc.d4d00c","type":"comment","z":"7c89a60c.c53b78","name":"ms","info":"","x":1510,"y":540,"wires":[]},{"id":"155251af.7da36e","type":"comment","z":"7c89a60c.c53b78","name":"HH:MM:SS","info":"","x":1520,"y":580,"wires":[]},{"id":"1d00c73e.537889","type":"comment","z":"7c89a60c.c53b78","name":"ms","info":"","x":1510,"y":620,"wires":[]},{"id":"9c538999.93c378","type":"comment","z":"7c89a60c.c53b78","name":"text (global variable)","info":"thermostat sets global(\"DGWZ\",\"on\") or global(\"DGWZ\",\"off\").\nIf you have more than one room you could check the status of all rooms and switch a main circulation pump on or off.","x":1550,"y":500,"wires":[]},{"id":"cb986f8f.63da9","type":"comment","z":"7c89a60c.c53b78","name":"run at midnight","info":"","x":260,"y":260,"wires":[]},{"id":"99603ece.52e2a","type":"mqtt-broker","broker":"10.0.0.43","port":"1883","clientid":"","usetls":false,"compatmode":true,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","willTopic":"","willQos":"0","willPayload":""}]