node-red-contrib-vib-smart-boiler
Version: 
Smart boiler node to control multiple thermostat
414 lines (320 loc) • 17.8 kB
JavaScript
/*
__   _____ ___ ___        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);
  }