UNPKG

node-red-contrib-vib-smart-valve

Version:
786 lines (645 loc) 32 kB
/* __ _____ ___ ___ Author: Vincent BESSON \ \ / /_ _| _ ) _ \ Release: 0.72 \ V / | || _ \ / Date: 20230930 \_/ |___|___/_|_\ Description: Nodered Heating Valve Management 2023 Licence: Creative Commons ______________________ */ // Terminal command: node-red -v -D logging.console.level=trace // Web http://localhost:1880/ var moment = require('moment'); const mqtt = require("mqtt"); const fs = require('fs'); module.exports = function(RED) { var path = require('path') var util = require('util') var SmartValve = function(n) { RED.nodes.createNode(this, n) this.name = n.name this.settings = RED.nodes.getNode(n.settings) // Get global settings var global = this.context().global; this.topic = n.topic; this.groupId=n.groupId; // GroupId <!> Important for the SmartBoiler, interger unique this.climates = n.climates; // Array of climate entities to be manages this.tempEntity=n.tempEntity ? n.tempEntity : ''; // Reference Temperture entity this.tempEntityTopic=n.tempEntityTopic ? n.tempEntityTopic : ''; this.mqttSettings = RED.nodes.getNode(n.mqttSettings); // MQTT connexion settings this.mqttstack=[]; this.cycleDuration=n.cycleDuration ? parseInt(n.cycleDuration): 5; // duration cycle in min this.spUpdateMode=n.spUpdateMode ? n.spUpdateMode : 'spUpdateMode.statechange.startup'; // Execution mode [statechange|+startup|every cycle] this.adjustValveTempMode=n.adjustValveTempMode ? n.adjustValveTempMode : 'adjustValveTempMode.noAdjust'; this.debugInfo=n.debugInfo? n.debugInfo :false; // debug verbose to the console this.allowOverride=n.allowOverride ? n.allowOverride :false; // Allow Manual update from the valve // climate this.executionMode=true; this.offSp=n.offSp ? n.offSp: 5; this.prevSp=0; this.prevRequestSp=undefined; this.requestSp=undefined; this.firstEval = true; this.valveManualSpUpdate=false; this.valveManualSp=0; this.startTs=0; this.refTemp=undefined; this.prevRefTemp=undefined; this.lastInputTs=0; this.mqttDiscardMessageLastInputDuration=15; // Mqtt Message will be discarded within x s duration after last input sp message (it is to avoid ping pong) this.lastInputSp=0; var node = this; node.previousRefTemp=0; node.previousSp=0; node.lastCycleTs=0; node.manualTrigger=false; function getRandomInt(min,max) { return min+Math.floor(Math.random() * (max-min)); } function nlog(msg){ if (node.debugInfo==true){ node.log(msg); } } this.ev=function(){ node.manualTrigger=true; evaluate(); } function flushDataToFS(){ let fs_name="data_"+node.id; let data={}; data.climates=node.climates; data.requestSp=node.requestSp; data.prevRequestSp=node.prevRequestSp; data.refTemp=node.refTemp; fs.writeFile(fs_name, JSON.stringify(data), (err) => { if (err) throw err; }); } function loadDataFromFs(){ let fs_name="data_"+node.id; fs.readFile(fs_name, (err, inputD) => { if (err){ nlog("unable to open file:"+fs_name); return; }; let data=JSON.parse(inputD.toString()); if (data.requestSp!=undefined){ node.requestSp=data.requestSp; } if (data.prevRequestSp!=undefined){ node.prevRequestSp=data.prevRequestSp; } if (data.refTemp!=undefined){ node.refTemp=data.refTemp; } if (data.climates!=undefined){ node.climates.forEach((climate) => { data.climates.forEach((dclimate)=>{ if (climate.entityTopic==dclimate.entityTopic){ climate.sp=dclimate.sp; climate.t=dclimate.t; nlog("****************YESSSSS **************************"); } }); }); } }); } function unlinkFs(){ let fs_name="data_"+node.id; fs.unlinkSync(fs_name); } function subscribeMqtt(){ let subArray=[]; if (node.mqttclient==null || node.mqttclient.connected!=true){ node.warn("subscribeMqtt: MQTT not connected..."); return; } if (node.tempEntityTopic!=undefined && node.tempEntityTopic!=""){ subArray.push(node.tempEntityTopic); } node.climates.forEach((climate) => { nlog("climateEntityTopic:"+climate.entityTopic); if (climate!=undefined && climate.entityTopic!=undefined && climate.entityTopic!=""){ subArray.push(climate.entityTopic); } }); node.mqttclient.subscribe(subArray, () => { nlog("MQTT Subscribed to topic:"); nlog(" "+JSON.stringify(subArray)); }) } function callbackMqtt(topic,p){ if (topic==node.tempEntityTopic){ nlog(" set mqtt refTemp:"+p.temperature); node.refTemp=p.temperature; if (node.refTemp!=node.refTemp){ evaluate(); node.prevRefTemp=node.refTemp; } return; }else{ let bTriggerProcess=false; for ( climate of node.climates) { if(climate!=undefined && climate.entityTopic!=undefined && climate.entityTopic==topic){ nlog(" Match"); nlog(" climate.entityTopic:"+climate.entityTopic); nlog(" topic:"+topic); nlog(" set mqtt climate prop:"+climate.entity); climate.lastSeen=moment(); // hum p.last_seen; climate.sp=p.occupied_heating_setpoint; climate.t=p.local_temperature; if (node.refTemp===undefined){ node.refTemp=p.local_temperature; nlog(" set node.reftemp:"+p.local_temperature); nlog(" bTriggerProcess:true"); bTriggerProcess=true; } if (node.requestSp===undefined){ node.requestSp=p.occupied_heating_setpoint; nlog(" set node.requestSp:"+p.occupied_heating_setpoint); nlog(" bTriggerProcess:true"); bTriggerProcess=true; } if (climate.sp!=node.requestSp && climate.sp!=node.prevRequestSp){ nlog(" climate.sp!=node.requestSp"); nlog(" climate.sp:"+climate.sp+" <> node.requestSp:"+node.requestSp); nlog(" climate.sp:"+climate.sp+" <> node.prevRequestSp:"+node.prevRequestSp); let now = moment(); let diff=now.diff(node.lastInputTs)/1000; nlog(" lastInputTs Elapsed:"+diff); if (diff>node.mqttDiscardMessageLastInputDuration){ nlog(" mqttDiscardMessageLastInputDuration:"+node.mqttDiscardMessageLastInputDuration); nlog(" bTriggerProcess:true"); bTriggerProcess=true; }else{ nlog(" bTriggerProcess:false"); } } break;; } } if (bTriggerProcess==true){ nlog(" bTriggerProces sp changed-> trigger evaluate();"); evaluate(); bTriggerProcess=false; }else{ nlog(" no trigger...") } } } function sendMqtt(){ if (node.mqttclient==null || node.mqttclient.connected!=true){ node.warn("sendMqtt: 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(); } }; node.on('input', function(msg) { if (msg===undefined || msg.payload===undefined){ node.warn("invalid input returning"); return; } let command=msg.payload.command; if (command !==undefined && command.match(/^(1|set|on|0|off|trigger)$/i)) { if (command == '1' || command== 'trigger' || command == 'on' || command == 'set'){ if (command=='on'){ node.executionMode=true; evaluate(); return; } if (node.executionMode==false){ evaluate(); return; } if (msg.payload.setpoint=== undefined || isNaN(msg.payload.setpoint) || parseFloat(msg.payload.setpoint)<0 || parseFloat(msg.payload.setpoint)>35){ //<----------- Todo define Max & Min in config node.warn('received trigger missing or invalid msg.sp number'); return; } node.manualTrigger = true; node.prevRequestSp=node.requestSp; node.requestSp=parseFloat(msg.payload.setpoint).toFixed(2); nlog("incoming request sp:"+node.requestSp); node.lastInputTs=moment(); this.lastInputSp=node.requestSp; node.executionMode=true; evaluate(); }else if(command=="0"|| command=='off'){ nlog("set smart-valve off") node.executionMode=false; evaluate(); } } else node.warn('Failed to interpret incoming msg.payload. Ignoring it!') }); function evaluate() { /* Evaluation process to trigger action // -> Phase 1 : check if a manual update occured directly on the valve -> Phahse 2a If yes then: phase2_ManualUpdate(); -> update all the valve with the new value -> Phahse 2b If no Then: phase2_UpdateValve(); -> update the valve with the last node.requestSp -> Phase 3 phase3_UpdateExtTemperature() -> update the external temp value on the TRV -> Phase 4 phase4_UpdateBoiler() -> update the boiler -> Phase 5 flushDataToFS -> save current data */ nlog("---------------------------------------"); nlog("Evaluate() new cycle"); nlog("---------------------------------------"); nlog(" node.manualTrigger:"+node.manualTrigger); nlog(" node.firstEval:"+node.firstEval); nlog(" node.refTemp:"+node.refTemp); nlog(" node.requestSp:"+node.requestSp); nlog(" allowOverride:"+node.allowOverride); let now = moment(); // <-------- 60 s is needed for Home assistant to update let diff=now.diff(node.lastCycleTs)/1000; nlog(" lastCycleTs elapsed:"+diff); if (diff<10){ // <----------to avoid ping pong party nlog(" lastCycle elapsed too short < 10s returning"); return; } node.lastCycleTs=moment(); // to avoid ping pong party if (node.refTemp===undefined || node.requestSp===undefined){ nlog(" <!> evaluate(): node.refTemp or node.requestSp is undefined returning"); node.status({ fill: 'yellow', shape: 'dot', text:("Unavailable sp: "+node.requestSp+"°C, temp: "+node.refTemp+"°C") }); // Change 1 parameters to the valve to trigger full update // Warning: to be fixed cause a little bit dirty if (node.climates[0]!=undefined && node.climates[0].entityTopic!=undefined && node.climates[0].entityTopic!=""){ let rndInt=getRandomInt(-10,10); nlog(" away_preset_temperature updating with random int:"+rndInt) let mqttmsg={topic:node.climates[0].entityTopic+"/set/away_preset_temperature",payload:rndInt.toString(),qos:0,retain:false}; node.mqttstack.push(mqttmsg); sendMqtt(); } return; }else{ if (/*Math.round(*/parseFloat(node.refTemp)/*)*/>parseFloat(node.requestSp)){ node.status({ fill: 'green', shape: 'dot', text:("temp: "+/*Math.round(*/node.refTemp/*)*/+"°C, sp: "+node.requestSp+"°C") }); }else{ node.status({ fill: 'red', shape: 'dot', text:("temp: "+/*Math.round(*/node.refTemp/*)*/+"°C, sp: "+node.requestSp+"°C") }); } } if (node.executionMode==false){ // <--- Put default temperature mode nlog("<!> smart-valve is off returning"); node.climates.forEach((climate) => { let msg={}; msg.payload={ topic: node.topic, domain:"climate", service:"set_temperature", target:{ entity_id:[ climate.entity ] }, data:{ temperature:node.offSp // We update all valve with the same Manual SP } }; node.send([msg,null]); }); let msg={}; msg.payload={ command:"set", topic: node.topic, setpoint:node.offSp, temperature:node.refTemp, name:node.name, groupid:node.groupId } // <-- Send a Remove command to boiler when off nlog("output to boiler:"); nlog(JSON.stringify(msg)); node.send([null,msg]); node.status({ fill: 'gray', shape: 'dot', text:("Off sp: "+node.offSp+"°C, temp: "+node.refTemp+"°C") }); return; } phase1_Check(); phase1_CheckLastSeen(); if(node.valveManualSpUpdate==true && node.allowOverride==true){ // There is a ManualUpdate directly on the valve, update all valve phase2_ManualUpdate(); }else{ // No Manual Update we can proceed to check if phase2_UpdateValve(); } phase3_UpdateExtTemperature(); phase4_UpdateBoiler(); node.firstEval = false; flushDataToFS(); } function phase1_CheckLastSeen(){ } function phase1_Check(){ nlog("---------------------------------------"); nlog("Phase 1 - Check for Manual update on TRV"); nlog("---------------------------------------"); //node.climates.forEach((climate) => { // Check if Manual update occured on one of the valve for ( climate of node.climates) { if (climate.sp === undefined ) { node.warn("<!> climate.sp is null or empty skipping"); return; } nlog("-->"+climate.entity); nlog(" Phase 1 climate.sp:"+climate.sp); nlog(" Phase 1 node.requestSp:"+node.requestSp); nlog(" Phase 1 node.firstEval:"+node.firstEval); nlog(" Phase 1 node.allowOverride:"+node.allowOverride); nlog(" Phase 1 node.manualTrigger:"+node.manualTrigger); if(node.firstEval == true && node.manualTrigger==false){ // At startup node.requestSp==0; // we should assign the existing sp to node.requestSP // If Smart-scheduler is wire as input node.manualTriger will be true nlog(" Phase 1 first Eval node.reqestSp=climate.sp"); node.requestSp=climate.sp; }else if (node.manualTrigger == false && climate.sp!=node.requestSp && node.firstEval == false && node.allowOverride==true){ /*let now = moment(); // <-------- 60 s is needed for Home assistant to update let diff=now.diff(node.startTs)/1000; nlog(" Phase 1 diff startTs:"+diff); if (diff<60){ nlog(" Phase 1 node.startTs < 60s returning"); return; }*/ let now = moment(); // <-------- 60 s is needed for Home assistant to update let diff=now.diff(node.lastSeen)/1000; if (diff>120){ nlog(" Phase 1 !!! STRANGE !!! device is asleep for more than 2 min and still sending update "); nlog(" Phase 1 !!! STRANGE !!! device is not considered"); nlog(" Phase 1 !!! STRANGE !!! Continue the loop"); continue; } nlog(" Phase 1 manual update from the valve detected"); node.valveManualSp=climate.sp; node.valveManualSpUpdate=true; nlog(" Phase 1 node.valveManualSp:"+node.valveManualSp); // Add 02/01/24 nlog(" Phase 1 update lastInputTs (manual update is compare to input to avoid ping pong)") node.lastInputTs=moment(); nlog (" Phase 1 returning before the end of the loop as manual update found") break; } }//); nlog("---------------------------------------"); nlog("End of Phase 1"); nlog("---------------------------------------\n"); } function phase2_ManualUpdate(){ nlog("---------------------------------------"); nlog("Phase 2a phase2_ManualUpdate()"); nlog("---------------------------------------"); nlog(" Phase 2 set node.prevSp=node.requestSp:"+node.requestSp); nlog(" Phase 2 set node.requestSp=node.valveManualSp:"+node.valveManualSp); node.prevRequestSp=node.requestSp; node.requestSp=node.valveManualSp; for ( climate of node.climates) { //node.climates.forEach((climate) => { let msg={}; msg.payload={ topic: node.topic, domain:"climate", service:"set_temperature", target:{ entity_id:[ climate.entity ] }, data:{ temperature:node.valveManualSp // We update all valve with the same Manual SP } }; // 02/01/24 Everything by Mqtt for more simplicity //node.send([msg,null]); if (node.mqttUpdates==true && climate.valveSpTopic!=undefined && climate.valveSpTopic!=""){ let mqttmsg={topic:climate.valveSpTopic,payload:parseFloat(node.valveManualSp),qos:0,retain:false}; node.warn(JSON.stringify(mqttmsg)); node.mqttstack.push(mqttmsg); } }//); sendMqtt(); node.valveManualSpUpdate=false; let msg={ topic:node.topic, payload:{ command:"override", setpoint:node.valveManualSp, noout:true } } node.send([null,msg]); node.status({ fill: 'yellow', shape: 'dot', text:("Manual override sp: "+node.valveManualSp+"°C, temp: "+node.refTemp+"°C") }); nlog("---------------------------------------"); nlog("End of Phase 2a"); nlog("---------------------------------------\n"); } function phase2_UpdateValve(){ nlog("---------------------------------------"); nlog("Phase 2b phase2_UpdateValve()"); nlog("---------------------------------------"); for ( climate of node.climates) { //node.climates.forEach((climate) => { if (climate.entity === null || climate.entity === "") { node.warn("Phase 2 climate.entity is null or empty skipping"); return; } nlog("-->Phase 2:"+climate.entity); nlog(" node.firstEval:"+node.firstEval); nlog(" node.manualTrigger:"+node.manualTrigger); nlog(" node.spUpdateMode:"+node.spUpdateMode); nlog(" climate.sp:"+climate.sp); nlog(" node.requestSp:"+node.requestSp); if (climate.sp==undefined){ node.warn("Phase 2 climate.sp is null or empty skipping"); return; } if (node.firstEval== true || (node.manualTrigger == true && climate.sp!=node.requestSp) || node.spUpdateMode=="spUpdateMode.cycle"){ nlog(" condition:"); nlog(" climate.sp:"+climate.sp); nlog(" node.requestSp:"+node.requestSp); nlog(" node.firstEval:"+node.firstEval); nlog(" node.manualTrigger:"+node.manualTrigger); nlog(" node.spUpdateMode:"+node.spUpdateMode); /* let msg={}; msg.payload={ topic: node.topic, domain:"climate", service:"set_temperature", target:{ entity_id:[ climate.entity ] }, data:{ temperature:node.requestSp } }; */ climate.lastRequestSp=moment(); // we store last updateTS //nlog(" Output msg:"+JSON.stringify(msg)); // 02/01/24 Everything by Mqtt for more simplicity //node.send([msg,null]); if (node.mqttUpdates==true && climate.valveSpTopic!=undefined && climate.valveSpTopic!=""){ nlog(" send MQTT update"); let mqttmsg={topic:climate.valveSpTopic,payload:parseFloat(node.requestSp),qos:0,retain:false}; node.mqttstack.push(mqttmsg); } } }//); sendMqtt(); // Update the status in UI if (/*Math.round(*/parseFloat(node.refTemp)/*)*/>parseFloat(node.requestSp)){ node.status({ fill: 'green', shape: 'dot', text:("temp: "+/*Math.round(*/node.refTemp/*)*/+"°C, sp: "+node.requestSp+"°C") }); }else{ node.status({ fill: 'red', shape: 'dot', text:("temp: "+/*Math.round(*/node.refTemp/*)*/+"°C, sp: "+node.requestSp+"°C") }); } node.manualTrigger = false; nlog("---------------------------------------"); nlog("End of Phase 2b"); nlog("---------------------------------------\n"); } function phase3_UpdateExtTemperature(){ nlog("---------------------------------------"); nlog("Phase 3 phase3_UpdateExtTemperature()"); nlog("---------------------------------------"); node.climates.forEach((climate) => { nlog("-->Phase 3: phase3_UpdateExtTemperature():"+climate.entity); if (climate.valveExtTempTopic === null || climate.valveExtTempTopic === "") { node.warn(" <!>climate.valveExtTempTopic is null or empty skipping"); return; } if (node.mqttUpdates==true && node.refTemp!=undefined && climate.t!=node.refTemp){ nlog(" Update mqtt valve external_temp:"+node.refTemp); let mqttmsg={topic:climate.valveExtTempTopic,payload:parseFloat(node.refTemp),qos:0,retain:false}; node.mqttstack.push(mqttmsg); mqttmsg={topic:climate.entityTopic+"/set/sensor",payload:"external",qos:0,retain:false}; node.mqttstack.push(mqttmsg); }else{ nlog(" No update needed..."); } }); sendMqtt(); nlog("---------------------------------------"); nlog("End of Phase 3"); nlog("---------------------------------------\n"); } function phase4_UpdateBoiler(){ nlog("---------------------------------------"); nlog("Phase 4 phase4_UpdateBoiler()"); nlog("---------------------------------------"); if (node.refTemp!=node.previousRefTemp || node.requestSp!=node.previousSp || node.firstEval==true){ let msg={}; msg.payload={ command:"set", topic: node.topic, setpoint:node.requestSp, temperature:node.refTemp, name:node.name, groupid:node.groupId } node.send([null,msg]); nlog(" sp:"+node.requestSp); nlog(" temp:"+node.refTemp); nlog(" name:"+node.name); nlog(" id:"+node.groupId); node.previousRefTemp=node.refTemp; node.prevSp=node.requestSp; }else{ nlog(" no update..."); } nlog("---------------------------------------"); nlog("End of Phase 4"); nlog("---------------------------------------\n"); } node.startTs=moment(); node.warn("<!> nodeid:"+node.id); //unlinkFs(); loadDataFromFs(); node.mqttUpdates=true; 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', () => { subscribeMqtt(); sendMqtt(); }); node.mqttclient.on('message', (topic, payload) => { let p=payload.toString(); node.log('MQTT msg received topic:'+topic); callbackMqtt(topic,JSON.parse(p)); }); } node.evalInterval = setInterval(evaluate, parseInt(node.cycleDuration)*60000) // Run initially directly after start / deploy. if (node.triggerMode != 'triggerMode.statechange') { setTimeout(evaluate, 1000) } node.on('close', function() { clearInterval(node.evalInterval) }) } RED.nodes.registerType('smart-valve', SmartValve) RED.httpAdmin.post("/smartvalve/:id", RED.auth.needsPermission("inject.write"), function(req,res) { var node = RED.nodes.getNode(req.params.id); if (node != null) { try { node.ev(); res.sendStatus(200); } catch(err) { res.sendStatus(500); node.error(RED._("inject.failed",{error:err.toString()})); } } else { res.sendStatus(404); } }); }