UNPKG

node-red-contrib-vib-smart-boiler

Version:

Smart boiler node to control multiple thermostat

414 lines (320 loc) 17.8 kB
/* __ _____ ___ ___ Author: Vincent BESSON \ \ / /_ _| _ ) _ \ Release: 0.66 \ V / | || _ \ / Date: 20231010 \_/ |___|___/_|_\ Description: Nodered Heating Boiler Management 2023 Licence: Creative Commons ______________________ */ /* TODO: ----- */ var moment = require('moment'); // require const mqtt = require("mqtt"); var pjson = require('./package.json'); // Terminal command node-red -v -D logging.console.level=trace module.exports = function (RED) { function SmartBoiler(n) { RED.nodes.createNode(this, n) this.name = n.name this.mqttclient=null; this.mqttstack=[]; // Stack of MQTT message to be sent this.boilerTempTopic=n.boilerTempTopic; // MQTT Topic to update the boiler current temperature this.boilerSpTopic=n.boilerSpTopic; // MQTT Topic to update the boiler set point temperature this.extTempEntity=n.extTempEntity; this.extTempBoilerTopic=n.extTempBoilerTopic; this.boilerCHeatingSwTopic=n.boilerCHeatingSwTopic; this.boilerLeadingDeviceTopic=n.boilerLeadingDeviceTopic; // MQTT Topic to update the boiler Leading Device Topic (Text) this.boilerLeadingDeviceIdTopic=n.boilerLeadingDeviceIdTopic; this.mqttUpdates=n.mqttUpdates; // Send MQTT updates this.outputUpdates=n.outputUpdates; // Send updates to output this.triggerMode = n.triggerMode ? n.triggerMode : 'trigger.statechange.startup' // output / mqtt update mode this.cycleDuration=n.cycleDuration ? n.cycleDuration : 60; // Update cycle duration to be sent to boiler this.defaultSp=n.defaultSp ? n.defaultSp :5; // Default boiler set point if no update received in the given timeframe (maxDurationSinceLastInput) this.defaultTemp=n.defaultTemp ? n.defaultTemp :10; // Default boiler current temperature if no update received in the given timeframe (maxDurationSinceLastInput) this.maxDurationSinceLastInput=n.maxDurationSinceLastInput ? n.maxDurationSinceLastInput : 5; // Important to avoid the Boiler to continue to heat endlessly, expressed in min this.lastInputTs=moment(); // Timestamp of the last input msg received this.debugInfo=n.debugInfo ? n.debugInfo:false // boolean flag to trigger debug information to the console. this.mqttSettings = RED.nodes.getNode(n.mqttSettings); // MQTT connexion settings this.boilerSecurity=n.boilerSecurity?n.boilerSecurity:false; // send security msg after max duration period this.liveStack=[]; // Stack of valve information var global = this.context().global; var node = this; node.activeItem=undefined; // Current Active Valve as reference for the boiler sp>temp node.previousItem=undefined; // Item of the stack sent to the boiler function nlog(msg){ if (node.debugInfo==true){ node.log(msg); } } function sendMqtt(){ // Send MQTT msg back on the node.livestack if (node.mqttclient==null || node.mqttclient.connected!=true){ node.warn("MQTT not connected..."); return; } let msg=node.mqttstack.shift(); while (msg!==undefined){ nlog('MQTT-> msg dequeueing'); if (msg.topic===undefined || msg.payload===undefined) return; let msgstr=JSON.stringify(msg.payload).replace(/\\"/g, '"'); node.mqttclient.publish(msg.topic.toString(),msgstr,{ qos: msg.qos, retain: msg.retain },(error) => { if (error) { node.error("mqtt error: "+error) } }); msg=node.mqttstack.shift(); } }; function processInput (msg){ // Processing input msg // Expected structure of the incomming msg {sp: int, temp: int, name:text} let bFound=false; // is the item exist in the stack let now = moment(); node.lastInputTs=now; let sp=parseFloat(msg.setpoint) //let adjustedTemp=Math.round(parseFloat(msg.temperature)); // <--- test ongoing let adjustedTemp=parseFloat(msg.temperature); let groupid=parseInt(msg.groupid) node.liveStack.forEach(function(item){ if (item.id==groupid){ // item is found in the stack bFound=true; item.sp=sp; item.name=msg.name; item.temp=adjustedTemp; item.lastupdate= now.toISOString(); // last update timestamp of the item } }); if (bFound==false){ // Not found add to the stack let newItem={}; newItem.id=groupid newItem.sp=sp; newItem.temp=adjustedTemp; newItem.name=msg.name; newItem.lastupdate= now.toISOString(); node.liveStack.push(newItem); } evaluate(); // Evaluate change straight away return; } function evaluate(){ let bUpdate=false; // state is updated ? let bFoundActiveValve=false; // activeValve (Sp>Temp) is found ? let now = moment(); let diff=node.lastInputTs.diff(now,"m"); // check for external temperature let t_ext=0; let externalTempEntity=global.get("homeassistant.homeAssistant.states['"+node.extTempEntity+"']"); if (externalTempEntity!==undefined && !isNaN(externalTempEntity.state)){ t_ext=parseFloat(externalTempEntity.state); let mqttmsg={topic:node.extTempBoilerTopic,payload:{temp:parseFloat(t_ext)},qos:0,retain:false}; node.mqttstack.push(mqttmsg); } if (node.boilerSecurity==true && Math.abs(diff)>=node.maxDurationSinceLastInput){ nlog("maxDurationSinceLastInput exceed, sending security message to the boiler"); nlog("node.maxDurationSinceLastInput:"+node.maxDurationSinceLastInput+" diff:"+diff); if (node.outputUpdates==true){ let msg={}; msg.payload={temperature:node.defaultTemp,setpoint:node.defaultSp,name:"Security mode",external_temperature:t_ext}; node.send([msg,null]); } if (node.mqttUpdates==true){ let mqttmsg={topic:node.boilerSpTopic,payload:parseInt(node.defaultSp),qos:0,retain:false}; node.mqttstack.push(mqttmsg); mqttmsg={topic:node.boilerTempTopic,payload:parseInt(node.defaultTemp),qos:0,retain:false}; node.mqttstack.push(mqttmsg); mqttmsg={topic:node.boilerLeadingDeviceTopic,payload:{"value":"Security mode"},qos:0,retain:false}; node.mqttstack.push(mqttmsg); mqttmsg={topic:node.boilerLeadingDeviceTopic,payload:{"value":0},qos:0,retain:false}; node.mqttstack.push(mqttmsg); sendMqtt(); } node.status({ fill: 'red', shape: 'dot', text:("No update, sp: "+node.defaultSp+"°C, temp: "+node.defaultTemp+"°C") }); return; } nlog(JSON.stringify(node.liveStack)); bFoundActiveValve=false; node.previousActiveItemGap=node.activeItemGap; node.activeItemGap=-99; node.previousItem=node.activeItem; nlog("node.previousActiveItemGap:"+node.previousActiveItemGap); node.liveStack.forEach(function(item){ // For each item in the stack, // if the set point > current temp then the Valve is active // select the valve where the Gap is the higher // if there is no active valve, select the valve (passive) with the highest sp // node.activeItem equal to the active valve to be sent to the boiler // node.passiveItem equal to the passive valve in case there is no activeItem let itemGap=parseFloat(item.sp)-parseFloat(item.temp); nlog("id:"+item.id+" itemGap:"+itemGap); if (itemGap>node.activeItemGap){ node.activeItemGap=itemGap; node.activeItem=item; nlog("become activeItem:"+item.id); }else if (itemGap==node.activeItemGap && node.activeItem.sp<item.sp){ node.activeItemGap=itemGap; node.activeItem=item; nlog("become activeItem:"+item.id); } }); if(node.previousItem===undefined && node.activeItem!==undefined){ bUpdate=true; } if (node.previousItem!==undefined && node.previousItem.id!=node.activeItem.id){ bUpdate=true; } else if (node.previousItem!==undefined && node.previousActiveItemGap!=node.activeItemGap){ bUpdate=true; } if (node.activeItemGap>0){ bFoundActiveValve=true; if (node.boilerCHeatingSwTopic===undefined || node.boilerCHeatingSwTopic=="") return; let mqttmsg={topic:node.boilerCHeatingSwTopic,payload:1,qos:0,retain:false}; node.mqttstack.push(mqttmsg); }else{ if (node.boilerCHeatingSwTopic===undefined || node.boilerCHeatingSwTopic=="") return; let mqttmsg={topic:node.boilerCHeatingSwTopic,payload:0,qos:0,retain:false}; node.mqttstack.push(mqttmsg); } nlog("node.activeItemGap:"+node.activeItemGap); nlog("bFoundActiveValve:"+bFoundActiveValve); nlog("bUpdate:"+bUpdate); if (node.activeItem) nlog("activeItem:"+node.activeItem.name); else nlog("activeItem:undefined"); if (node.activeItem!==undefined && (bUpdate==true || node.triggerMode=="triggerMode.everyCycle")){ let msg={}; msg.payload=node.activeItem; msg.payload.external_temperature=t_ext; if (node.mqttUpdates==true){ let mqttmsg={topic:node.boilerSpTopic,payload:parseInt(node.activeItem.sp),qos:0,retain:false}; node.mqttstack.push(mqttmsg); mqttmsg={topic:node.boilerTempTopic,payload:parseFloat(node.activeItem.temp),qos:0,retain:false}; node.mqttstack.push(mqttmsg); mqttmsg={topic:node.boilerLeadingDeviceTopic,payload:{value:node.activeItem.name},qos:0,retain:false}; node.mqttstack.push(mqttmsg); if (node.boilerLeadingDeviceIdTopic!=undefined && node.boilerLeadingDeviceIdTopic!="" && node.activeItem!=undefined && node.activeItem.id!=undefined){ mqttmsg={topic:node.boilerLeadingDeviceIdTopic,payload:{value:node.activeItem.id},qos:0,retain:false}; } node.mqttstack.push(mqttmsg); sendMqtt(); } if (node.outputUpdates) node.send([msg,null]); if (bFoundActiveValve==true){ node.status({ fill: 'red', shape: 'dot', text:("Active:"+node.activeItem.name+", sp: "+node.activeItem.sp+"°C, temp: "+node.activeItem.temp+"°C") }); }else{ node.status({ fill: 'blue', shape: 'dot', text:("Active:"+node.activeItem.name+", sp: "+node.activeItem.sp+"°C, temp: "+node.activeItem.temp+"°C") }); } } } // INPUT MESSAGE MGNT // Command: set // Command: stack // // *************************************************************************************** node.on('input', function(msg) { if (msg.payload===undefined || msg.payload.command==undefined){ node.warn("input message is invalid returning"); return; } if (msg.payload.command=="set"){ // Process new data item if( msg.payload.setpoint===undefined || msg.payload.temperature===undefined || msg.payload.name===undefined || msg.payload.groupid===undefined){ node.error("input msg is invalid expecting msg.payload{command:'set',setpoint:,temperature:,name:,groupid}"); return; } if( isNaN(msg.payload.setpoint) || isNaN(msg.payload.temperature) || isNaN(msg.payload.groupid)){ node.error("invalid input msg format expect temperature, setpoint, id to be number"); return; } processInput(msg.payload); } else if (msg.payload.command=="trigger"){ let msg={}; msg.payload=node.activeItem; node.send([msg,null]); let mqttmsg={topic:node.boilerSpTopic,payload:parseInt(node.activeItem.sp),qos:0,retain:false}; node.mqttstack.push(mqttmsg); mqttmsg={topic:node.boilerTempTopic,payload:parseInt(node.activeItem.temp),qos:0,retain:false}; node.mqttstack.push(mqttmsg); mqttmsg={topic:node.boilerLeadingDeviceTopic,payload:{value:node.activeItem.name},qos:0,retain:false}; node.mqttstack.push(mqttmsg); if (node.boilerLeadingDeviceIdTopic!=undefined && node.boilerLeadingDeviceIdTopic!="" && node.activeItem!=undefined && node.activeItem.id!=undefined){ mqttmsg={topic:node.boilerLeadingDeviceIdTopic,payload:{value:node.activeItem.id},qos:0,retain:false}; } node.mqttstack.push(mqttmsg); sendMqtt(); } else if (msg.payload.command=="stack"){ // output the current stack let msg={}; msg.payload=node.liveStack; node.send([msg,null]); } }); if (node.mqttUpdates==true && node.mqttSettings && node.mqttSettings.mqttHost){ const protocol = 'mqtt' const host = node.mqttSettings.mqttHost const port = node.mqttSettings.mqttPort const clientId=`smb_${Math.random().toString(16).slice(3)}`; const connectUrl = `${protocol}://${host}:${port}` node.mqttclient = mqtt.connect(connectUrl, { clientId, clean: true, keepalive:60, connectTimeout: 4000, username: node.mqttSettings.mqttUser, password: node.mqttSettings.mqttPassword, reconnectPeriod: 1000, }); node.mqttclient.on('error', function (error) { node.warn("MQTT error: "+error); }); node.mqttclient.on('connect', () => { let msg=node.mqttstack.shift(); while (msg!==undefined){ nlog('MQTT Connected -> start dequeuing'); if (msg.topic===undefined || msg.payload===undefined) return; node.mqttclient.publish(msg.topic,JSON.stringify(msg.payload),{ qos: msg.qos, retain: msg.retain },(error) => { if (error) { node.error(error) } }); msg=node.mqttstack.shift(); } }); } node.evalInterval = setInterval(evaluate, node.cycleDuration*1000); if (node.triggerMode != 'triggerMode.statechange') { setTimeout(evaluate, 1000) } node.on('close', function() { nlog("closing connexion"); node.log('MQTT disconnecting'); clearInterval(node.evalInterval); node.mqttclient.end(); done(); }) } RED.nodes.registerType('smart-boiler', SmartBoiler); }