UNPKG

node-red-contrib-knx-ultimate

Version:

Control your KNX intallation via Node-Red! Single Node KNX IN/OUT with optional ETS group address importer. Easy to use and highly configurable.

436 lines (393 loc) 22.7 kB
module.exports = function (RED) { function knxUltimateSceneController(config) { var fs = require('fs'); var path = require('path'); var mkdirp = require('mkdirp'); RED.nodes.createNode(this, config) var node = this; node.server = RED.nodes.getNode(config.server) node.name = config.name || "KNX Scene Controller"; node.outputtopic = typeof config.outputtopic === "undefined" ? "" : config.outputtopic; node.topic = config.topic || ""; node.dpt = config.dpt || "1.001" node.topicTrigger = config.topicTrigger || "true"; node.topicSave = config.topicSave || ""; node.dptSave = config.dptSave || "1.001" node.topicSaveTrigger = config.topicSaveTrigger || "true"; node.listenallga = false; // Dont' remove this. node.notifyreadrequest = false; node.notifyresponse = false node.notifywrite = true; // Dont' remove this. node.initialread = false node.outputtype = "write" node.outputRBE = "false" node.inputRBE = "false" node.rules = config.rules || [{}]; node.isSceneController = true; // Signal to config node, that this is a node scene controller node.userDir = path.join(RED.settings.userDir, "knxultimatestorage"); // 09/03/2020 Storage of ttsultimate (otherwise, at each upgrade to a newer version, the node path is wiped out and recreated, loosing all custom files) node.sysLogger = require("./utils/sysLogger.js").get({ loglevel: node.server.loglevel || "error" }); // 08/04/2021 new logger to adhere to the loglevel selected in the config-window node.timerWait = null; node.icountMessageInWindow = 0; node.disabled = false; // 21/09/2020 you can now disable the scene controller // 11/03/2020 Delete scene saved file, from html RED.httpAdmin.get("/knxultimatescenecontrollerdelete", RED.auth.needsPermission("knxUltimateSceneController.read"), function (req, res) { // Delete the file try { var newPath = node.userDir + "/scenecontroller/SceneController_" + req.query.FileName; fs.unlinkSync(newPath) } catch (error) { if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.warn("e " + error) } res.json({ status: 220 }); }); // 03/09/2021 async function delay(ms) { return new Promise(function (resolve, reject) { try { node.timerWait = setTimeout(resolve, ms); } catch (error) { reject(); } }); } function setupDirectory(aPath) { try { return fs.statSync(aPath).isDirectory(); } catch (e) { // Path does not exist if (e.code === 'ENOENT') { // Try and create it try { try { mkdirp.sync(aPath); if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.info('knxUltimate-Scene Controller: created directory path: ' + aPath); } catch (error) { if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error('knxUltimate-Scene Controller: failed to access path:: ' + aPath + " : " + error); return false; } return true; } catch (e) { if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error('knxUltimate-Scene Controller: failed to create path: ' + aPath + " : " + e); } } // Otherwise failure return false; } } // This stores all scenes values, that are been saved. try { setupDirectory(node.userDir); } catch (error) { } if (!setupDirectory(node.userDir + "/scenecontroller")) { if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error('knxUltimate-Scene Controller: Unable to set up permanent files directory: ' + node.userDir + "/scenecontroller"); node.setNodeStatus({ fill: "red", shape: "dot", text: "Unable to setup permanent files directory", payload: "", GA: "", dpt: "", devicename: node.name }) } else { } // Used to call the status update from the config node. node.setNodeStatus = ({ fill, shape, text, payload, GA, dpt, devicename }) => { if (node.server == null) { node.status({ fill: "red", shape: "dot", text: "[NO GATEWAY SELECTED]" }); return; } if (node.icountMessageInWindow == -999) return; // Locked out if (node.disabled === true) fill = "grey"; // 21/09/2020 if disabled, color is grey var dDate = new Date(); // 30/08/2019 Display only the things selected in the config GA = (typeof GA == "undefined" || GA == "") ? "" : "(" + GA + ") "; devicename = devicename || ""; dpt = (typeof dpt == "undefined" || dpt == "") ? "" : " DPT" + dpt; node.status({ fill: fill, shape: shape, text: GA + payload + ((node.listenallga && node.server.statusDisplayDeviceNameWhenALL) === true ? " " + devicename : "") + (node.server.statusDisplayDataPoint === true ? dpt : "") + (node.server.statusDisplayLastUpdate === true ? " (" + dDate.getDate() + ", " + dDate.toLocaleTimeString() + ")" : "") + " " + text }); // 16/02/2020 signal errors to the server if (fill.toUpperCase() == "RED") { if (node.server) { let oError = { nodeid: node.id, topic: node.outputtopic, devicename: devicename, GA: GA, text: text }; node.server.reportToWatchdogCalledByKNXUltimateNode(oError); }; }; } // 03/09/2021 Async function to allow await delay(x) async function RecallSceneAsync(_Payload, _ForceEvenControllerIsDisabled) { var curVal; var newVal; if (typeof _Payload === "object") { // If payload is an object, parse it as object try { curVal = JSON.stringify(_Payload); if (node.topicTrigger.toString().indexOf("{") > -1) { // Sanitize string, if not having quotes var correctJson = node.topicTrigger.replace(/(['"])?([a-z0-9A-Z_]+)(['"])?:/g, '"$2": '); try { newVal = JSON.stringify(JSON.parse(correctJson)); } catch (error) { // Not a valid JSON, thread as normal. newVal = node.topicTrigger.toString().toLowerCase(); } } else { // topicTrigge is not a JSON newVal = node.topicTrigger.toString().toLowerCase(); } } catch (error) { // Invalid JSON, threat as normal. curVal = _Payload.toString().toLowerCase(); newVal = node.topicTrigger.toString().toLowerCase(); } } else { // Not a JSON, threath as normal. curVal = _Payload.toString().toLowerCase(); newVal = node.topicTrigger.toString().toLowerCase(); } if (curVal === "false") { curVal = "0"; } if (curVal === "true") { curVal = "1"; } if (curVal.toString().indexOf("\"decr_incr\":1") > -1 && curVal.toString().indexOf("\"data\":0") == -1) { // Handling DIM curVal = "DIMUP"; } if (curVal.toString().indexOf("\"decr_incr\":0") > -1 && curVal.toString().indexOf("\"data\":0") == -1) {// Handling DIM curVal = "DIMDOWN"; } if (newVal === "false") { newVal = "0"; } if (newVal === "true") { newVal = "1"; } if (newVal.toString().indexOf("\"decr_incr\":1") > -1 && curVal.toString().indexOf("\"data\":0") == -1) {// Handling DIM newVal = "DIMUP"; } if (newVal.toString().indexOf("\"decr_incr\":0") > -1 && curVal.toString().indexOf("\"data\":0") == -1) {// Handling DIM newVal = "DIMDOWN"; } //if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.warn(curVal + " new: " + newVal) if (curVal != newVal) return; // 25/09/2020 If the node is disabled, doens't perform the action. if (node.disabled && !_ForceEvenControllerIsDisabled) { let t = setTimeout(() => { node.setNodeStatus({ fill: "grey", shape: "dot", text: "Recall while disabled", payload: "", GA: "", dpt: "", devicename: "" }); }, 500); node.send({ savescene: false, recallscene: true, savevalue: false, disabled: true }); return; } // Read the scene values from file, if any. let oSavedRules = null; try { oSavedRules = fs.readFileSync(node.userDir + "/scenecontroller/SceneController_" + node.id); oSavedRules = JSON.parse(oSavedRules); } catch (error) { } // Update the node.rules with the values taken from the file, if any, otherwise leave the default value for (var i = 0; i < node.rules.length; i++) { // rule is { topic: rowRuleTopic, devicename: rowRuleDeviceName, dpt:rowRuleDPT, send: rowRuleSend} var rule = node.rules[i]; var newVal = null; if (oSavedRules !== null) { var oSavedDev = oSavedRules.find(a => a.topic === rule.topic); if (typeof oSavedDev !== "undefined") { newVal = oSavedDev.send; if (newVal !== null) { rule.send = newVal.toString(); } } } // If payload is an object, parse it as object var oPayload; if (rule.send.toString().indexOf("{") > -1) { // Sanitize string, if not having quotes var correctJson = rule.send.replace(/(['"])?([a-z0-9A-Z_]+)(['"])?:/g, '"$2": '); try { oPayload = JSON.parse(correctJson); } catch (error) { oPayload = rule.send; } } else { oPayload = rule.send; } // 03/09/2021 wait command? if (rule.topic.toLowerCase() === "wait") { //if (isNaN(rule.send)) { if (rule.send === undefined || rule.send === "") { let t = setTimeout(() => { node.setNodeStatus({ fill: "red", shape: "dot", text: "Wait time is empty. See the WIKI for help.", payload: "", GA: "", dpt: "", devicename: "" }); }, 1000); } else { // 25/05/2022 added support for seconds and minutes let msWait = 0; try { if (rule.send.toString().endsWith("s")) { msWait = Number(rule.send.toString().slice(0, -1)) * 1000; // Seconds } else if (rule.send.toString().endsWith("m")) { msWait = Number(rule.send.toString().slice(0, -1)) * 60 * 1000; // Minutes } else if (rule.send.toString().endsWith("h")) { msWait = Number(rule.send.toString().slice(0, -1)) * 60 * 60 * 1000; // Hours } else { msWait = Number(rule.send); } } catch (error) { node.setNodeStatus({ fill: "red", shape: "dot", text: "Invalid wait time. See the WIKI for help: " + error.message, payload: "", GA: "", dpt: "", devicename: "" }); } await delay(msWait); } } else { // Topic is Group Address node.server.writeQueueAdd({ grpaddr: rule.topic, payload: oPayload, dpt: rule.dpt, outputtype: "write", nodecallerid: node.id }) } } let t = setTimeout(() => { // 21/03/2022 fixed possible memory leak. Previously was setTimeout without "let t = ". node.setNodeStatus({ fill: "green", shape: "dot", text: "Recall scene", payload: "", GA: "", dpt: "", devicename: "" }); }, 1000); await delay(500); node.send({ savescene: false, recallscene: true, savevalue: false, disabled: false }); } // 11/03/2020 in the middle of coronavirus. Whole italy is red zone, closed down. Recall scene. node.RecallScene = (_Payload, _ForceEvenControllerIsDisabled) => { try { RecallSceneAsync(_Payload, _ForceEvenControllerIsDisabled); } catch (error) { if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error("knxUltimateSceneController: Node " + node.id + " Recall scene error:" + error.message); } } // 11/03/2020 in the middle of coronavirus. Whole italy is red zone, closed down. Save scene. node.SaveScene = (_Payload, _ForceEvenControllerIsDisabled) => { var curVal; var newVal; if (typeof _Payload === "object") { // If payload is an object, parse it as object try { curVal = JSON.stringify(_Payload); if (node.topicSaveTrigger.toString().indexOf("{") > -1) { // Sanitize string, if not having quotes var correctJson = node.topicSaveTrigger.replace(/(['"])?([a-z0-9A-Z_]+)(['"])?:/g, '"$2": '); try { newVal = JSON.stringify(JSON.parse(correctJson)); } catch (error) { // Not a valid JSON, thread as normal. newVal = node.topicSaveTrigger.toString().toLowerCase(); } } else { // topicTrigge is not a JSON newVal = node.topicSaveTrigger.toString().toLowerCase(); } } catch (error) { // Invalid JSON, threat as normal. curVal = _Payload.toString().toLowerCase(); newVal = node.topicSaveTrigger.toString().toLowerCase(); } } else { // Not a JSON, threath as normal. curVal = _Payload.toString().toLowerCase(); newVal = node.topicSaveTrigger.toString().toLowerCase(); } if (curVal === "false") { curVal = "0"; } if (curVal === "true") { curVal = "1"; } if (curVal.toString().indexOf("\"decr_incr\":1") > -1 && curVal.toString().indexOf("\"data\":0") == -1) { // Handling DIM curVal = "DIMUP"; } if (curVal.toString().indexOf("\"decr_incr\":0") > -1 && curVal.toString().indexOf("\"data\":0") == -1) {// Handling DIM curVal = "DIMDOWN"; } if (newVal === "false") { newVal = "0"; } if (newVal === "true") { newVal = "1"; } if (newVal.toString().indexOf("\"decr_incr\":1") > -1 && curVal.toString().indexOf("\"data\":0") == -1) {// Handling DIM newVal = "DIMUP"; } if (newVal.toString().indexOf("\"decr_incr\":0") > -1 && curVal.toString().indexOf("\"data\":0") == -1) {// Handling DIM newVal = "DIMDOWN"; } //if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.warn(curVal + " new: " + newVal) if (curVal != newVal) return; // 25/09/2020 If the node is disabled, doens't perform the action. if (node.disabled && !_ForceEvenControllerIsDisabled) { let t = setTimeout(() => { // 21/03/2022 fixed possible memory leak. Previously was setTimeout without "let t = ". node.setNodeStatus({ fill: "grey", shape: "dot", text: "Saved while disabled", payload: "", GA: "", dpt: "", devicename: "" }); }, 500); node.send({ savescene: true, recallscene: false, savevalue: false, disabled: true }); return; } // Save the currentPayload of each device in the scene for (var i = 0; i < node.rules.length; i++) { // rule is { topic: rowRuleTopic, devicename: rowRuleDeviceName, dpt:rowRuleDPT, send: rowRuleSend} var oDevice = node.rules[i]; if (oDevice.hasOwnProperty("currentPayload")) { oDevice.send = oDevice.currentPayload.toString(); } } node.setNodeStatus({ fill: "blue", shape: "dot", text: "Saved scene", payload: "", GA: "", dpt: "", devicename: "" }); try { fs.writeFileSync(node.userDir + "/scenecontroller/SceneController_" + node.id, JSON.stringify(node.rules, null, 2), 'utf-8'); } catch (error) { node.setNodeStatus({ fill: "red", shape: "dot", text: "Error saving scene. Unable to access filesystem.", payload: "", GA: "", dpt: "", devicename: node.name }); return; } node.send({ savescene: true, recallscene: false, savevalue: false, disabled: false }); } // 12/08/2020 Save the topic's value into the group address node.SaveValue = _msg => { if (_msg.hasOwnProperty("topic") && _msg.hasOwnProperty("payload")) { // Save the currentPayload into the group address for (var i = 0; i < node.rules.length; i++) { // rule is { topic: rowRuleTopic, devicename: rowRuleDeviceName, dpt:rowRuleDPT, send: rowRuleSend} var oDevice = node.rules[i]; if (oDevice.hasOwnProperty("topic") && oDevice.hasOwnProperty("currentPayload") && oDevice.topic === _msg.topic) { oDevice.currentPayload = _msg.payload; } } node.setNodeStatus({ fill: "blue", shape: "dot", text: "Saved value", payload: _msg.payload, GA: _msg.topic, dpt: "", devicename: "" }); node.send({ savescene: false, recallscene: false, savevalue: true, disabled: node.disabled }); } else { node.setNodeStatus({ fill: "red", shape: "dot", text: "Error saving value; the msg.topic and msg.payload must be both present in the input message.", payload: "", GA: "", dpt: "", devicename: node.name }); } } // This function is called by the knx-ultimate config node, to output a msg.payload. node.handleSend = msg => { node.send(msg); }; node.on("input", function (msg) { if (typeof msg === "undefined") return; if (!node.server) return; // 29/08/2019 Server not instantiate if (node.server.linkStatus !== "connected") { if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error("knxUltimateSceneController: Lost link due to a connection error"); return; // 29/08/2019 If not connected, exit } // 07/02/2020 Revamped flood protection (avoid accepting too many messages as input) if (node.icountMessageInWindow == -999) return; // Locked out if (node.icountMessageInWindow == 0) { let t = setTimeout(() => { // 21/03/2022 fixed possible memory leak. Previously was setTimeout without "let t = ". if (node.icountMessageInWindow >= 120) { // Looping detected node.setNodeStatus({ fill: "red", shape: "ring", text: "DISABLED! Flood protection! Too many msg at the same time.", payload: "", GA: "", dpt: "", devicename: "" }) if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error("knxUltimateSceneController: Node " + node.id + " has been disabled due to Flood Protection. Too many messages in a timeframe. Check your flow's design or use RBE option."); node.icountMessageInWindow = -999; //Lock out node return; } else { node.icountMessageInWindow = -1; } }, 1000); } node.icountMessageInWindow += 1; if (msg.hasOwnProperty('savescene')) node.SaveScene(node.topicSaveTrigger, true); if (msg.hasOwnProperty('recallscene')) node.RecallScene(node.topicTrigger, true); if (msg.hasOwnProperty('savevalue')) node.SaveValue(msg); if (msg.hasOwnProperty('disabled')) { if (msg.disabled === true) { node.disabled = true; node.setNodeStatus({ fill: "grey", shape: "dot", text: "Disabled", payload: "", GA: "", dpt: "", devicename: "" }); } else { node.disabled = false; node.setNodeStatus({ fill: "green", shape: "dot", text: "Enabled", payload: "", GA: "", dpt: "", devicename: "" }); } } }) node.on("close", function (done) { if (node.server) { node.server.removeClient(node) } done(); }) // On each deploy, unsubscribe+resubscribe if (node.server) { node.server.removeClient(node); node.server.addClient(node); } } RED.nodes.registerType("knxUltimateSceneController", knxUltimateSceneController) }