node-red-contrib-knx-ultimate
Version:
Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control and ETS group address importer. Easy to use and highly configurable.
1,087 lines (1,015 loc) • 101 kB
JavaScript
/* eslint-disable prefer-template */
/* eslint-disable no-inner-declarations */
/* eslint-disable curly */
/* eslint-disable max-len */
/* eslint-disable prefer-arrow-callback */
const fs = require("fs");
const path = require("path");
const net = require("net");
const os = require("os");
const _ = require("lodash");
const knx = require("knxultimate");
// 2025-09: Use KNXUltimate built-in keyring for KNX Secure validation
let Keyring;
try {
// Not exported by default; import from build path
({ Keyring } = require("knxultimate/build/secure/keyring"));
} catch (e) {
Keyring = null;
}
//const dptlib = require('knxultimate').dptlib;
const dptlib = require('knxultimate').dptlib;
const loggerClass = require('./utils/sysLogger')
// const { Server } = require('http')
const payloadRounder = require("./utils/payloadManipulation");
const utils = require('./utils/utils');
// DATAPONT MANIPULATION HELPERS
// ####################
const sortBy = (field) => (a, b) => {
if (a[field] > b[field]) {
return 1;
} else {
return -1;
}
};
const onlyDptKeys = (kv) => {
return kv[0].startsWith("DPT");
};
const extractBaseNo = (kv) => {
return {
subtypes: kv[1].subtypes,
base: parseInt(kv[1].id.replace("DPT", "")),
};
};
const convertSubtype = (baseType) => (kv) => {
const value = `${baseType.base}.${kv[0]}`;
// let sRet = value + " " + kv[1].name + (kv[1].unit === undefined ? "" : " (" + kv[1].unit + ")");
const sRet = value + " " + kv[1].name;
return {
value,
text: sRet,
};
};
const toConcattedSubtypes = (acc, baseType) => {
const subtypes = Object.entries(baseType.subtypes).sort(sortBy(0)).map(convertSubtype(baseType));
return acc.concat(subtypes);
};
// ####################
const BIT_COUNT_TABLE = Array.from({ length: 256 }, (_, value) => {
let count = 0;
let temp = value;
while (temp) {
temp &= temp - 1;
count++;
}
return count;
});
const parseIPv4Address = (str) => {
if (typeof str !== "string") return null;
const parts = str.trim().split('.');
if (parts.length !== 4) return null;
const octets = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!/^\d+$/.test(part)) return null;
const value = Number(part);
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
octets.push(value);
}
return octets;
};
const countNetmaskBits = (maskOctets) => {
if (!Array.isArray(maskOctets) || maskOctets.length !== 4) return 0;
let total = 0;
for (let i = 0; i < 4; i++) {
const oct = maskOctets[i];
if (!Number.isInteger(oct) || oct < 0 || oct > 255) return 0;
total += BIT_COUNT_TABLE[oct];
}
return total;
};
const computeIPv4NetworkKey = (ipOctets, maskOctets) => {
if (!Array.isArray(ipOctets) || ipOctets.length !== 4 || !Array.isArray(maskOctets) || maskOctets.length !== 4) return null;
const result = [];
for (let i = 0; i < 4; i++) {
const ipVal = ipOctets[i];
const maskVal = maskOctets[i];
if (!Number.isInteger(ipVal) || ipVal < 0 || ipVal > 255 || !Number.isInteger(maskVal) || maskVal < 0 || maskVal > 255) return null;
result.push(ipVal & maskVal);
}
return result.join('.');
};
const buildNetmaskOctetsFromPrefix = (prefix) => {
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return null;
const octets = [0, 0, 0, 0];
let remaining = prefix;
for (let i = 0; i < 4; i++) {
const bits = Math.max(0, Math.min(remaining, 8));
octets[i] = bits === 0 ? 0 : ((0xff << (8 - bits)) & 0xff);
remaining -= bits;
}
return octets;
};
const deriveNetmaskOctets = (iface) => {
if (!iface) return null;
const netmask = typeof iface.netmask === "string" && iface.netmask.trim() !== "" ? iface.netmask : null;
if (netmask) {
const octets = parseIPv4Address(netmask);
if (octets) return octets;
}
if (typeof iface.cidr === "string" && iface.cidr.includes('/')) {
const parts = iface.cidr.split('/');
if (parts.length === 2) {
const prefix = Number(parts[1]);
const octets = buildNetmaskOctetsFromPrefix(prefix);
if (octets) return octets;
}
}
return null;
};
const isMulticastIPv4 = (octets) => {
if (!Array.isArray(octets) || octets.length !== 4) return false;
const first = octets[0];
return first >= 224 && first <= 239;
};
const findAutoEthernetInterface = (targetIP) => {
const targetOctets = parseIPv4Address(targetIP);
if (!targetOctets || isMulticastIPv4(targetOctets)) return null;
const interfaces = os.networkInterfaces();
if (!interfaces || typeof interfaces !== "object") return null;
let bestMatch = null;
let bestMaskBits = -1;
Object.keys(interfaces).forEach((ifname) => {
const entries = Array.isArray(interfaces[ifname]) ? interfaces[ifname] : [];
entries.forEach((entry) => {
if (!entry || entry.internal) return;
const family = entry.family === 'IPv4' || entry.family === 4;
if (!family) return;
const ifaceOctets = parseIPv4Address(entry.address);
if (!ifaceOctets) return;
const maskOctets = deriveNetmaskOctets(entry);
if (!maskOctets) return;
const ifaceNetwork = computeIPv4NetworkKey(ifaceOctets, maskOctets);
const targetNetwork = computeIPv4NetworkKey(targetOctets, maskOctets);
if (!ifaceNetwork || !targetNetwork || ifaceNetwork !== targetNetwork) return;
const maskBits = countNetmaskBits(maskOctets);
if (maskBits > bestMaskBits) {
bestMaskBits = maskBits;
bestMatch = {
name: ifname,
address: entry.address,
netmask: maskOctets.join('.'),
maskBits,
};
}
});
});
return bestMatch;
};
module.exports = (RED) => {
function knxUltimateConfigNode(config) {
RED.nodes.createNode(this, config);
const node = this;
node.host = config.host;
node.port = parseInt(config.port);
node.physAddr = config.physAddr // the KNX physical address we'd like to use
node.suppressACKRequest = typeof config.suppressACKRequest === "undefined" ? true : config.suppressACKRequest; // enable this option to suppress the acknowledge flag with outgoing L_Data.req requests. LoxOne needs this
node.linkStatus = "disconnected"; // Can be: connected or disconnected
node.nodeClients = []; // Stores the registered clients
node.KNXEthInterface = typeof config.KNXEthInterface === "undefined" ? "Auto" : config.KNXEthInterface;
node.KNXEthInterfaceManuallyInput = typeof config.KNXEthInterfaceManuallyInput === "undefined" ? "" : config.KNXEthInterfaceManuallyInput; // If you manually set the interface name, it will be wrote here
node.timerDoInitialRead = null; // 17/02/2020 Timer (timeout) to do initial read of all nodes requesting initial read, after all nodes have been registered to the sercer
node.stopETSImportIfNoDatapoint = typeof config.stopETSImportIfNoDatapoint === "undefined" ? "stop" : config.stopETSImportIfNoDatapoint; // 09/01/2020 Stop, Import Fake or Skip the import if a group address has unset datapoint
node.userDir = path.join(RED.settings.userDir, "knxultimatestorage"); // 04/04/2021 Supergiovane: Storage for service files
node.exposedGAs = [];
node.loglevel = config.loglevel !== undefined ? config.loglevel : "error"; // 18/02/2020 Loglevel default error
if (node.loglevel === 'trace') node.loglevel = 'debug'; // Backward compatibility
if (node.loglevel === 'silent') node.loglevel = 'disable'; // Backward compatibility
node.sysLogger = null; // 20/03/2022 Default
try {
node.sysLogger = new loggerClass({ loglevel: node.loglevel, setPrefix: node.type + " <" + (node.name || node.id || '') + ">" });
} catch (error) { console.log(error.stack) }
node.csv = readCSV(config.csv); // Array from ETS CSV Group Addresses {ga:group address, dpt: datapoint, devicename: full device name with main and subgroups}
// 12/11/2021 Connect at start delay
node.autoReconnect = true; // 20/03/2022 Default
if (config.autoReconnect === "no" || config.autoReconnect === false) {
node.autoReconnect = false;
} else {
node.autoReconnect = true;
}
node.ignoreTelegramsWithRepeatedFlag = config.ignoreTelegramsWithRepeatedFlag === undefined ? false : config.ignoreTelegramsWithRepeatedFlag;
const throttleSecondsRaw = Number(config.statusUpdateThrottle);
node.statusUpdateThrottleMs = Number.isFinite(throttleSecondsRaw) && throttleSecondsRaw > 0
? throttleSecondsRaw * 1000
: 0;
node.applyStatusUpdate = (targetNode, status) => {
try {
if (!targetNode || typeof targetNode.status !== 'function') return;
const throttle = node.statusUpdateThrottleMs;
if (!throttle) {
targetNode.status(status);
return;
}
if (!targetNode.__knxStatusThrottle) {
targetNode.__knxStatusThrottle = { pending: undefined, timer: null };
}
const tracker = targetNode.__knxStatusThrottle;
tracker.pending = status;
if (tracker.timer) return;
tracker.timer = setTimeout(() => {
try {
if (tracker.pending !== undefined) {
targetNode.status(tracker.pending);
}
} catch (timerError) {
node.sysLogger?.warn('Unable to apply throttled status: ' + timerError.message);
} finally {
tracker.pending = undefined;
tracker.timer = null;
}
}, throttle);
} catch (error) {
node.sysLogger?.warn('applyStatusUpdate error: ' + error.message);
}
};
// 24/07/2021 KNX Secure checks...
node.keyringFileXML = typeof config.keyringFileXML === "undefined" || config.keyringFileXML.trim() === "" ? "" : config.keyringFileXML;
node.knxSecureSelected = typeof config.knxSecureSelected === "undefined" ? false : config.knxSecureSelected;
node.secureCredentialsMode = typeof config.secureCredentialsMode === "undefined" ? "keyring" : config.secureCredentialsMode;
// 2025-09 Secure Tunnel Interface IA selection (Auto/Manual)
node.tunnelIASelection = typeof config.tunnelIASelection === "undefined" ? "Auto" : config.tunnelIASelection;
node.tunnelIA = typeof config.tunnelIA === "undefined" || config.tunnelIA === null ? "" : String(config.tunnelIA);
node.tunnelInterfaceIndividualAddress = typeof config.tunnelInterfaceIndividualAddress === "undefined" || config.tunnelInterfaceIndividualAddress === null
? ""
: String(config.tunnelInterfaceIndividualAddress);
const normalizedTunnelIA = (value) => {
if (typeof value !== "string") return "";
const trimmed = value.trim();
return trimmed === "undefined" ? "" : trimmed;
};
node.tunnelIA = normalizedTunnelIA(node.tunnelIA);
node.tunnelInterfaceIndividualAddress = normalizedTunnelIA(node.tunnelInterfaceIndividualAddress);
if (!node.tunnelInterfaceIndividualAddress && node.tunnelIA) {
node.tunnelInterfaceIndividualAddress = node.tunnelIA;
} else if (!node.tunnelIA && node.tunnelInterfaceIndividualAddress) {
node.tunnelIA = node.tunnelInterfaceIndividualAddress;
}
node.tunnelUserPassword = typeof config.tunnelUserPassword === "undefined" ? "" : config.tunnelUserPassword;
node.tunnelUserId = typeof config.tunnelUserId === "undefined" ? "" : config.tunnelUserId;
node.name = config.name === undefined || config.name === "" ? node.host : config.name; // 12/08/2021
node.timerKNXUltimateCheckState = null; // 08/10/2021 Check the state. If not connected and autoreconnect is true, retrig the connetion attempt.
node.knxConnectionProperties = null; // Retains the connection properties
node.allowLauch_initKNXConnection = true; // See the node.timerKNXUltimateCheckState function
node.hostProtocol = config.hostProtocol === undefined ? "Auto" : config.hostProtocol; // 20/03/2022 Default
node.knxConnection = null; // 20/03/2022 Default
node.delaybetweentelegrams = (config.delaybetweentelegrams === undefined || config.delaybetweentelegrams === null || config.delaybetweentelegrams === '') ? 25 : Number(config.delaybetweentelegrams);
if (node.delaybetweentelegrams < 25) node.delaybetweentelegrams = 25; // Protection avoiding handleKNXQueue hangs
if (node.delaybetweentelegrams > 100) node.delaybetweentelegrams = 100; // Protection avoiding handleKNXQueue hangs
node.timerSaveExposedGAs = null; // Timer to save the exposed GA every once in a while
// 05/12/2021 Set the protocol (this is undefined if coming from ild versions
if (node.hostProtocol === "Auto") {
// Auto set protocol based on IP
if (
node.host.startsWith("224.") ||
node.host.startsWith("225.") ||
node.host.startsWith("232.") ||
node.host.startsWith("233.") ||
node.host.startsWith("234.") ||
node.host.startsWith("235.") ||
node.host.startsWith("239.")
) {
node.hostProtocol = "Multicast";
} else {
const isSecure = node.knxSecureSelected === true || node.knxSecureSelected === "true";
node.hostProtocol = isSecure ? "TunnelTCP" : "TunnelUDP";
}
node.sysLogger?.info("IP Protocol AUTO SET to " + node.hostProtocol + ", based on IP " + node.host);
}
node.setAllClientsStatus = (_status, _color, _text) => {
node.nodeClients.forEach((oClient) => {
try {
if (oClient.setNodeStatus !== undefined) oClient.setNodeStatus({
fill: _color,
shape: "dot",
text: _status + " " + _text,
payload: "",
GA: oClient.topic,
dpt: "",
devicename: "",
});
} catch (error) {
node.sysLogger?.warn("Wow setAllClientsStatus error " + error.message);
}
});
};
//
// KNX-SECURE
// Validate keyring (if available) and prepare secure configuration
//
node.secureTunnelConfig = undefined;
(async () => {
try {
if (node.knxSecureSelected) {
const secureMode = typeof node.secureCredentialsMode === "string" ? node.secureCredentialsMode : "keyring";
const useManual = secureMode === "manual" || secureMode === "combined";
const useKeyring = secureMode === "keyring" || secureMode === "combined";
const secureConfig = {};
const modeParts = [];
if (useManual) {
const manualIA = (node.tunnelInterfaceIndividualAddress || "").trim();
const manualUserId = (node.tunnelUserId || "").trim();
const manualPwd = node.tunnelUserPassword || "";
if (manualIA) {
secureConfig.tunnelInterfaceIndividualAddress = manualIA;
}
if (manualUserId) {
secureConfig.tunnelUserId = manualUserId;
}
// Always include password property so KNX library receives the intended value (even if empty)
secureConfig.tunnelUserPassword = manualPwd;
modeParts.push("manual tunnel credentials");
}
if (useKeyring) {
secureConfig.knxkeys_file_path = node.keyringFileXML || "";
secureConfig.knxkeys_password = node.credentials?.keyringFilePassword || "";
try {
const manualSelectionIA = normalizedTunnelIA(node.tunnelIA);
if (node.tunnelIASelection === "Manual" && manualSelectionIA) {
if (!secureConfig.tunnelInterfaceIndividualAddress) {
secureConfig.tunnelInterfaceIndividualAddress = manualSelectionIA;
}
} else if (!secureConfig.tunnelInterfaceIndividualAddress) {
secureConfig.tunnelInterfaceIndividualAddress = ""; // Auto (let KNX stack select)
}
} catch (e) { /* empty */ }
// Optional early validation to give immediate feedback (non-fatal)
if (Keyring && node.keyringFileXML && (node.credentials?.keyringFilePassword || "") !== "") {
try {
const kr = new Keyring();
await kr.load(node.keyringFileXML, node.credentials.keyringFilePassword);
if (node.loglevel === "debug") {
try {
const toIAString = (value) => {
if (!value) return "";
return typeof value.toString === "function" ? value.toString() : String(value);
};
const toBufferString = (value) => {
if (!value) return "";
if (Buffer.isBuffer(value)) return value.toString("hex");
return String(value);
};
const interfaceMap = kr.getInterfaces?.();
const interfaces = Array.from(interfaceMap ? interfaceMap.values() : []).map((iface) => ({
type: iface.type || "",
individualAddress: toIAString(iface.individualAddress),
host: toIAString(iface.host),
userId: typeof iface.userId === "number" ? iface.userId : "",
password: iface.password || "",
decryptedPassword: iface.decryptedPassword || "",
authentication: iface.authentication || "",
decryptedAuthentication: iface.decryptedAuthentication || "",
groupAddresses: Array.from(iface.groupAddresses ? iface.groupAddresses.entries() : []).map(([ga, senders]) => ({
address: ga,
senders: Array.isArray(senders) ? senders.map(toIAString) : [],
})),
}));
const backbones = (kr.getBackbones?.() || []).map((backbone) => ({
multicastAddress: backbone.multicastAddress || "",
latency: typeof backbone.latency === "number" ? backbone.latency : "",
key: backbone.key || "",
decryptedKey: toBufferString(backbone.decryptedKey),
}));
const groupAddressMap = kr.getGroupAddresses?.();
const groupAddresses = Array.from(groupAddressMap ? groupAddressMap.values() : []).map((group) => ({
address: toIAString(group.address),
key: group.key || "",
decryptedKey: toBufferString(group.decryptedKey),
}));
const deviceMap = kr.getDevices?.();
const devices = Array.from(deviceMap ? deviceMap.values() : []).map((device) => ({
individualAddress: toIAString(device.individualAddress),
toolKey: device.toolKey || "",
decryptedToolKey: toBufferString(device.decryptedToolKey),
managementPassword: device.managementPassword || "",
decryptedManagementPassword: device.decryptedManagementPassword || "",
authentication: device.authentication || "",
decryptedAuthentication: device.decryptedAuthentication || "",
sequenceNumber: typeof device.sequenceNumber === "number" ? device.sequenceNumber : "",
serialNumber: device.serialNumber || "",
}));
const lines = [];
lines.push("================ KNX Secure keyring debug dump ================");
lines.push(`Node: ${node.name || node.id || ""}`);
lines.push(`Created By: ${kr.getCreatedBy?.() || ""}`);
lines.push(`Created On: ${kr.getCreated?.() || ""}`);
lines.push(`Password (node credentials): ${node.credentials?.keyringFilePassword || ""}`);
lines.push("");
lines.push("Interfaces:");
if (interfaces.length === 0) {
lines.push(" (none)");
} else {
interfaces.forEach((iface, idx) => {
lines.push(` [${idx + 1}] ${iface.individualAddress || "(unknown)"} (${iface.type || ""})`);
lines.push(` Host: ${iface.host || ""}`);
lines.push(` User ID: ${iface.userId === "" ? "" : iface.userId}`);
lines.push(` Password (encoded): ${iface.password || ""}`);
lines.push(` Password (decoded): ${iface.decryptedPassword || ""}`);
lines.push(` Authentication (encoded): ${iface.authentication || ""}`);
lines.push(` Authentication (decoded): ${iface.decryptedAuthentication || ""}`);
if (!iface.groupAddresses || iface.groupAddresses.length === 0) {
lines.push(" Group Addresses: (none)");
} else {
lines.push(" Group Addresses:");
iface.groupAddresses.forEach((ga) => {
const senders = ga.senders && ga.senders.length > 0 ? ga.senders.join(", ") : "(none)";
lines.push(` - ${ga.address}: senders ${senders}`);
});
}
lines.push("");
});
}
lines.push("Backbones:");
if (backbones.length === 0) {
lines.push(" (none)");
} else {
backbones.forEach((backbone, idx) => {
lines.push(` [${idx + 1}] Multicast: ${backbone.multicastAddress || ""}`);
lines.push(` Latency: ${backbone.latency === "" ? "" : backbone.latency}`);
lines.push(` Key (encoded): ${backbone.key || ""}`);
lines.push(` Key (decoded hex): ${backbone.decryptedKey || ""}`);
lines.push("");
});
}
lines.push("Group Addresses:");
if (groupAddresses.length === 0) {
lines.push(" (none)");
} else {
groupAddresses.forEach((group, idx) => {
lines.push(` [${idx + 1}] ${group.address || ""}`);
lines.push(` Key (encoded): ${group.key || ""}`);
lines.push(` Key (decoded hex): ${group.decryptedKey || ""}`);
lines.push("");
});
}
lines.push("Devices:");
if (devices.length === 0) {
lines.push(" (none)");
} else {
devices.forEach((device, idx) => {
lines.push(` [${idx + 1}] ${device.individualAddress || ""}`);
lines.push(` Tool Key (encoded): ${device.toolKey || ""}`);
lines.push(` Tool Key (decoded hex): ${device.decryptedToolKey || ""}`);
lines.push(` Management Password (encoded): ${device.managementPassword || ""}`);
lines.push(` Management Password (decoded): ${device.decryptedManagementPassword || ""}`);
lines.push(` Authentication (encoded): ${device.authentication || ""}`);
lines.push(` Authentication (decoded): ${device.decryptedAuthentication || ""}`);
lines.push(` Sequence Number: ${device.sequenceNumber === "" ? "" : device.sequenceNumber}`);
lines.push(` Serial Number: ${device.serialNumber || ""}`);
lines.push("");
});
}
lines.push("Raw keyring (XML/base64 as provided):");
lines.push(node.keyringFileXML || "(empty)");
lines.push("================ End of keyring debug dump ================");
node.sysLogger?.debug(lines.join("\n"));
} catch (dumpError) {
node.sysLogger?.error("KNX Secure: unable to log keyring details: " + dumpError.message);
}
}
const createdBy = kr.getCreatedBy?.() || "unknown";
const created = kr.getCreated?.() || "unknown";
RED.log.info(`KNX-Secure: Keyring validated (Created by ${createdBy} on ${created}) using node ${node.name || node.id}`);
} catch (err) {
node.sysLogger?.error("KNX Secure: keyring validation failed: " + err.message);
// Keep secure enabled: KNXClient will emit detailed errors on connect
}
}
modeParts.push("keyring file/password");
}
if (Object.keys(secureConfig).length > 0) {
node.secureTunnelConfig = secureConfig;
} else {
node.secureTunnelConfig = undefined;
}
if (modeParts.length > 0) {
RED.log.info(`KNX-Secure: secure mode selected (${modeParts.join(" + ")}). Node ${node.name || node.id}`);
}
} else {
RED.log.info("KNX-Unsecure: connection to insecure interface/router using node " + (node.name || node.id));
}
} catch (error) {
node.sysLogger?.error("KNX Secure: error preparing secure configuration: " + error.message);
node.secureTunnelConfig = undefined;
node.knxSecureSelected = false;
const t = setTimeout(() => node.setAllClientsStatus("Error", "red", "KNX Secure " + error.message), 2000);
}
})();
// 04/04/2021 Supergiovane, creates the service paths where the persistent files are created.
// The values file is stored only upon disconnection/close
// ************************
function setupDirectory(_aPath) {
if (!fs.existsSync(_aPath)) {
// Create the path
try {
fs.mkdirSync(_aPath);
return true;
} catch (error) {
return false;
}
} else {
return true;
}
}
if (!setupDirectory(node.userDir)) {
node.sysLogger?.error("Unable to set up MAIN directory: " + node.userDir);
}
if (!setupDirectory(path.join(node.userDir, "knxpersistvalues"))) {
node.sysLogger?.error("Unable to set up cache directory: " + path.join(node.userDir, "knxpersistvalues"));
} else {
node.sysLogger?.info("payload cache set to " + path.join(node.userDir, "knxpersistvalues"));
}
async function saveExposedGAs() {
const sFile = path.join(node.userDir, "knxpersistvalues", "knxpersist" + node.id + ".json");
try {
if (node.exposedGAs.length > 0) {
fs.writeFileSync(sFile, JSON.stringify(node.exposedGAs));
//node.sysLogger?.debug("wrote peristent values to the file " + sFile);
}
} catch (err) {
node.sysLogger?.error("unable to write peristent values to the file " + sFile + " " + err.message);
}
}
function loadExposedGAs() {
const sFile = path.join(node.userDir, "knxpersistvalues", "knxpersist" + node.id + ".json");
try {
node.exposedGAs = JSON.parse(fs.readFileSync(sFile, "utf8"));
} catch (err) {
node.exposedGAs = [];
node.sysLogger?.info("unable to read peristent file " + sFile + " " + err.message);
}
}
// ************************
// 16/02/2020 KNX-Ultimate nodes calls this function, then this funcion calls the same function on the Watchdog
node.reportToWatchdogCalledByKNXUltimateNode = (_oError) => {
// _oError is = { nodeid: node.id, topic: node.outputtopic, devicename: devicename, GA: GA, text: text };
const readHistory = [];
const delay = 0;
node.nodeClients
.filter((_oClient) => _oClient.isWatchDog !== undefined && _oClient.isWatchDog === true)
.forEach((_oClient) => {
_oClient.signalNodeErrorCalledByConfigNode(_oError);
});
};
node.addClient = (_Node) => {
// Check if node already exists
if (node.nodeClients.filter((x) => x.id === _Node.id).length === 0) {
// Add _Node to the clients array
if (node.autoReconnect) {
_Node.setNodeStatus({
fill: "grey",
shape: "ring",
text: "Node initialized.",
payload: "",
GA: "",
dpt: "",
devicename: "",
});
} else {
_Node.setNodeStatus({
fill: "red",
shape: "ring",
text: "Autoconnect disabled. Please manually connect.",
payload: "",
GA: "",
dpt: "",
devicename: "",
});
}
node.nodeClients.push(_Node);
}
};
node.removeClient = async (_Node) => {
// Remove the client node from the clients array
try {
node.nodeClients = node.nodeClients.filter((x) => x.id !== _Node.id);
} catch (error) { /* empty */ }
// If no clien nodes, disconnect from bus.
if (node.nodeClients.length === 0) {
try {
await node.Disconnect();
} catch (error) { /* empty */ }
}
};
// 17/02/2020 Do initial read (called by node.timerDoInitialRead timer)
function DoInitialReadFromKNXBusOrFile() {
if (node.linkStatus !== "connected") return; // 29/08/2019 If not connected, exit
node.sysLogger?.info("Do DoInitialReadFromKNXBusOrFile");
loadExposedGAs(); // 04/04/2021 load the current values of GA payload
node.sysLogger?.info("Loaded persist GA values", node.exposedGAs?.length);
if (node.timerSaveExposedGAs !== null) clearInterval(node.timerSaveExposedGAs);
node.timerSaveExposedGAs = setInterval(async () => {
await saveExposedGAs();
}, 5000);
node.sysLogger?.info("Started timerSaveExposedGAs with array lenght ", node.exposedGAs?.length);
try {
const readHistory = [];
// First, read from file. This allow all virtual devices to get their values from file.
node.nodeClients
.filter((_oClient) => _oClient.initialread === 2 || _oClient.initialread === 3)
.filter((_oClient) => _oClient.hasOwnProperty("isWatchDog") === false)
.forEach((_oClient) => {
if (node.linkStatus !== "connected") return; // 16/08/2021 If not connected, exit
// 04/04/2020 selected READ FROM FILE 2 or from file then from bus 3
if (_oClient.listenallga === true) {
// 13/12/2021 DA FARE
} else {
try {
if (node.exposedGAs.length > 0) {
const oExposedGA = node.exposedGAs.find((a) => a.ga === _oClient.topic);
if (oExposedGA !== undefined) {
// Retrieve the value from exposedGAs
const msg = buildInputMessage({
_srcGA: "",
_destGA: _oClient.topic,
_event: "GroupValue_Response",
_Rawvalue: Buffer.from(oExposedGA.rawValue.data),
_inputDpt: _oClient.dpt,
_devicename: _oClient.name ? _oClient.name : "",
_outputtopic: _oClient.outputtopic,
_oNode: _oClient,
_echoed: false
});
_oClient.previouspayload = ""; // 05/04/2021 Added previous payload
_oClient.currentPayload = msg.payload;
_oClient.setNodeStatus({
fill: "grey",
shape: "dot",
text: "Update value from persist file",
payload: _oClient.currentPayload,
GA: _oClient.topic,
dpt: _oClient.dpt,
devicename: _oClient.name || "",
});
// 06/05/2021 If, after the rawdata has been savad to file, the user changes the datapoint, the buildInputMessage returns payload null, because it's unable to convert the value
if (msg.payload === null) {
// Delete the exposedGA
node.exposedGAs = node.exposedGAs.filter((item) => item.ga !== _oClient.topic);
_oClient.setNodeStatus({
fill: "yellow",
shape: "dot",
text: "Datapoint has been changed, remove the value from persist file",
payload: _oClient.currentPayload,
GA: _oClient.topic,
dpt: _oClient.dpt,
devicename: _oClient.devicename || "",
});
node.sysLogger?.error("DoInitialReadFromKNXBusOrFile: Datapoint may have been changed, remove the value from persist file of " + _oClient.topic + " Devicename " + _oClient.name + " Currend DPT " + _oClient.dpt + " Node.id " + _oClient.id);
} else {
if (_oClient.notifyresponse) _oClient.handleSend(msg);
}
} else {
if (_oClient.initialread === 3) {
// Not found, issue a READ to the bus
if (!readHistory.includes(_oClient.topic)) {
node.sysLogger?.debug("DoInitialReadFromKNXBusOrFile 3: sent read request to GA " + _oClient.topic);
_oClient.setNodeStatus({
fill: "grey",
shape: "dot",
text: "Persist value not found, issuing READ request to BUS",
payload: _oClient.currentPayload,
GA: _oClient.topic,
dpt: _oClient.dpt,
devicename: _oClient.devicename || "",
});
node.sendKNXTelegramToKNXEngine({
grpaddr: _oClient.topic,
payload: "",
dpt: "",
outputtype: "read",
nodecallerid: _oClient.id,
});
readHistory.push(_oClient.topic);
}
}
}
}
} catch (error) {
node.sysLogger?.error("DoInitialReadFromKNXBusOrFile: " + error.stack);
}
}
});
// Then, after all values have been read from file, read from BUS
// This allow the virtual devices to get their values before this will be readed from bus
node.nodeClients
.filter((_oClient) => _oClient.initialread === 1)
.filter((_oClient) => _oClient.hasOwnProperty("isWatchDog") === false)
.forEach((_oClient) => {
if (node.linkStatus !== "connected") return; // 16/08/2021 If not connected, exit
// 04/04/2020 selected READ FROM BUS 1
if (_oClient.hasOwnProperty("isalertnode") && _oClient.isalertnode) {
_oClient.initialReadAllDevicesInRules();
} else if (_oClient.hasOwnProperty("isLoadControlNode") && _oClient.isLoadControlNode) {
_oClient.initialReadAllDevicesInRules();
} else if (_oClient.listenallga === true) {
for (let index = 0; index < node.csv.length; index++) {
const element = node.csv[index];
if (!readHistory.includes(element.ga)) {
node.sendKNXTelegramToKNXEngine({
grpaddr: element.ga,
payload: "",
dpt: "",
outputtype: "read",
nodecallerid: element.id,
});
readHistory.push(element.ga);
node.sysLogger?.debug("DoInitialReadFromKNXBusOrFile from Universal Node: sent read request to GA " + element.ga);
}
}
} else {
if (!readHistory.includes(_oClient.topic)) {
node.sendKNXTelegramToKNXEngine({
grpaddr: _oClient.topic,
payload: "",
dpt: "",
outputtype: "read",
nodecallerid: _oClient.id,
});
readHistory.push(_oClient.topic);
node.sysLogger?.debug("DoInitialReadFromKNXBusOrFile: sent read request to GA " + _oClient.topic);
}
}
});
} catch (error) { }
}
// 01/02/2020 Dinamic change of the KNX Gateway IP, Port and Physical Address
// This new thing has been requested by proServ RealKNX staff.
node.setGatewayConfig = async (
/** @type {string} */ _sIP,
/** @type {number} */ _iPort,
/** @type {string} */ _sPhysicalAddress,
/** @type {string} */ _sBindToEthernetInterface,
/** @type {string} */ _Protocol,
/** @type {string} */ _CSV,
) => {
if (typeof _sIP !== "undefined" && _sIP !== "") node.host = _sIP;
if (typeof _iPort !== "undefined" && _iPort !== 0) node.port = _iPort;
if (typeof _sPhysicalAddress !== "undefined" && _sPhysicalAddress !== "") node.physAddr = _sPhysicalAddress;
if (typeof _sBindToEthernetInterface !== "undefined") node.KNXEthInterface = _sBindToEthernetInterface;
if (typeof _Protocol !== "undefined") node.hostProtocol = _Protocol;
if (typeof _CSV !== "undefined" && _CSV !== "") {
try {
const sTemp = readCSV(_CSV); // 27/09/2022 Set the new CSV
node.csv = sTemp;
} catch (error) {
node.sysLogger?.info("Node's main config setting error. " + error.message || "");
}
}
node.sysLogger?.info(
"Node's main config setting has been changed. New config: IP " +
node.host +
" Port " +
node.port +
" PhysicalAddress " +
node.physAddr +
" BindToInterface " +
node.KNXEthInterface +
(typeof _CSV !== "undefined" && _CSV !== "" ? ". A new group address CSV has been imported." : ""),
);
try {
await node.Disconnect();
// node.setKnxConnectionProperties(); // 28/12/2021 Commented
node.setAllClientsStatus("CONFIG", "yellow", "KNXUltimage-config:setGatewayConfig: disconnected by new setting...");
node.sysLogger?.debug("KNXUltimage-config:setGatewayConfig: disconnected by setGatewayConfig.");
} catch (error) { }
};
// 05/05/2021 force connection or disconnection from the KNX BUS and disable the autoreconenctions attempts.
// This new thing has been requested by proServ RealKNX staff.
node.connectGateway = async (_bConnection) => {
if (_bConnection === undefined) return;
node.sysLogger?.info(
(_bConnection === true ? "Forced connection from watchdog" : "Forced disconnection from watchdog") +
node.host +
" Port " +
node.port +
" PhysicalAddress " +
node.physAddr +
" BindToInterface " +
node.KNXEthInterface,
);
if (_bConnection === true) {
// CONNECT AND ENABLE RECONNECTION ATTEMPTS
try {
await node.Disconnect();
node.setAllClientsStatus("CONFIG", "yellow", "Forced GW connection from watchdog.");
node.autoReconnect = true;
} catch (error) { }
} else {
// DISCONNECT AND DISABLE RECONNECTION ATTEMPTS
try {
node.autoReconnect = false;
await node.Disconnect();
const t = setTimeout(() => {
// 21/03/2022 fixed possible memory leak. Previously was setTimeout without "let t = ".
node.setAllClientsStatus("CONFIG", "yellow", "Forced GW disconnection and stop reconnection attempts, from watchdog.");
}, 2000);
} catch (error) { }
}
};
node.setKnxConnectionProperties = () => {
// 25/08/2021 Moved out of node.initKNXConnection
node.knxConnectionProperties = {
ipAddr: node.host,
ipPort: node.port,
physAddr: node.physAddr, // the KNX physical address we'd like to use
suppress_ack_ldatareq: node.suppressACKRequest,
loglevel: node.loglevel,
hostProtocol: node.hostProtocol,
isSecureKNXEnabled: node.knxSecureSelected,
secureTunnelConfig: node.knxSecureSelected ? node.secureTunnelConfig : undefined,
localIPAddress: "", // Riempito da KNXEngine
KNXQueueSendIntervalMilliseconds: Number(node.delaybetweentelegrams),
connectionKeepAliveTimeout: 30 // Every 30 seconds, send a connectionstatus_request
};
// 11/07/2022 Test if the IP is a valid one or is a DNS Name
switch (net.isIP(node.host)) {
case 0:
// Invalid IP, resolve the DNS name.
const dns = require("dns-sync");
let resolvedIP = null;
try {
resolvedIP = dns.resolve(node.host);
} catch (error) {
throw new Error("net.isIP: INVALID IP OR DNS NAME. Error checking the Gateway Host in Config node. " + error.message);
}
if (resolvedIP === null || net.isIP(resolvedIP) === 0) {
// Error in resolving DNS Name
node.sysLogger?.error(
"net.isIP: INVALID IP OR DNS NAME. Check the Gateway Host in Config node " + node.name + " " + node.host,
);
throw new Error("net.isIP: INVALID IP OR DNS NAME. Check the Gateway Host in Config node.");
}
node.sysLogger?.info(
"net.isIP: The gateway is not specified as IP. The DNS resolver pointed me to the IP " +
node.host +
", in Config node " +
node.name,
);
node.knxConnectionProperties.ipAddr = resolvedIP;
case 4:
// It's an IPv4
break;
case 6:
// It's an IPv6
break;
default:
break;
}
if (node.KNXEthInterface !== "Auto") {
let sIfaceName = "";
if (node.KNXEthInterface === "Manual") {
sIfaceName = node.KNXEthInterfaceManuallyInput;
node.sysLogger?.info("Bind KNX Bus to interface : " + sIfaceName + " (Interface's name entered by hand). Node " + node.name);
} else {
sIfaceName = node.KNXEthInterface;
node.sysLogger?.info(
"Bind KNX Bus to interface : " + sIfaceName + " (Interface's name selected from dropdown list). Node " + node.name,
);
}
node.knxConnectionProperties.interface = sIfaceName;
} else {
// Remove any manual binding and try to auto-select based on subnet
try {
delete node.knxConnectionProperties.interface;
} catch (error) { }
const targetIP = node.knxConnectionProperties?.ipAddr || node.host;
const autoInterface = typeof targetIP === "string" ? findAutoEthernetInterface(targetIP) : null;
if (autoInterface && autoInterface.name) {
node.knxConnectionProperties.interface = autoInterface.name;
const maskInfo = autoInterface.netmask
? autoInterface.netmask + (autoInterface.maskBits ? " (" + autoInterface.maskBits + ")" : "")
: (autoInterface.maskBits ? String(autoInterface.maskBits) : "mask unknown");
node.sysLogger?.info(
"Bind KNX Bus to interface (Auto) -> " +
autoInterface.name +
" (" + autoInterface.address + " " + maskInfo + ")" +
". Node " + node.name,
);
} else {
node.sysLogger?.info(
"Bind KNX Bus to interface (Auto). Node " +
node.name +
(targetIP ? " - no matching local interface found for " + targetIP + "." : "."),
);
}
}
};
// node.setKnxConnectionProperties(); 28/12/2021 Commented
node.initKNXConnection = async () => {
try {
node.setKnxConnectionProperties(); // 28/12/2021 Added
} catch (error) {
node.sysLogger?.error("setKnxConnectionProperties: " + error.message);
if (node.linkStatus !== "disconnected") await node.Disconnect();
return;
}
// 12/08/2021 Avoid start connection if there are no knx-ultimate nodes linked to this gateway
// At start, initKNXConnection is already called only if the gateway has clients, but in the successive calls from the error handler, this check is not done.
if (node.nodeClients.length === 0) {
try {
node.sysLogger?.info("No nodes linked to this gateway " + node.name);
try {
if (node.linkStatus !== "disconnected") await node.Disconnect();
} catch (error) { }
return;
} catch (error) { }
}
try {
// 02/01/2022 This is important to free the tunnel in case of hard disconnection.
await node.Disconnect();
} catch (error) {
// node.sysLogger?.info(error)
}
try {
// Unsetting handlers if node.knxConnection was existing
try {
if (node.knxConnection !== null && node.knxConnection !== undefined) {
await node.knxConnection.Disconnect();
node.sysLogger?.debug("removing old handlers. Node " + node.name);
node.knxConnection.removeAllListeners();
}
} catch (error) {
node.sysLogger?.info("BANANA ERRORINO", error);
}
//node.knxConnectionProperties.localSocketAddress = { address: '192.168.2.2', port: 59000 }
node.knxConnection = new knx.KNXClient(node.knxConnectionProperties);
// Setting handlers
// ######################################
node.knxConnection.on(knx.KNXClientEvents.indication, handleBusEvents);
node.knxConnection.on(knx.KNXClientEvents.error, (err) => {
try {
node.sysLogger?.error("received KNXClientEvents.error: " + (err.message === undefined ? err : err.message));
} catch (error) {
}
// 31/03/2022 Don't care about some errors
if (err.message !== undefined && (err.message === "ROUTING_LOST_MESSAGE" || err.message === "ROUTING_BUSY")) {
node.sysLogger?.error(
"KNXClientEvents.error: " +
(err.message === undefined ? err : err.message) +
" consider DECREASING the transmission speed, by increasing the telegram's DELAY in the gateway configuration node!",
);
return;
}
node.Disconnect("Disconnected by error " + (err.message === undefined ? err : err.message), "red");
node.sysLogger?.error("Disconnected by: " + (err.message === undefined ? err : err.message));
});
node.knxConnection.on(knx.KNXClientEvents.disconnected, (info) => {
if (node.linkStatus !== "disconnected") {
node.linkStatus = "disconnected";
node.sysLogger?.warn("Disconnected event %s", info);
node.Disconnect("Disconnected by event: " + info || "", "red"); // 11/03/2022
}
});
node.knxConnection.on(knx.KNXClientEvents.close, (info) => {
node.sysLogger?.debug("KNXClient socket closed.");
node.linkStatus = "disconnected";
});
node.knxConnection.on(knx.KNXClientEvents.connected, (info) => {
node.linkStatus = "connected";
// Start the timer to do initial read.
if (node.timerDoInitialRead !== null) clearTimeout(node.timerDoInitialRead);
node.timerDoInitialRead = setTimeout(() => {
try {
DoInitialReadFromKNXBusOrFile();
} catch (error) {
node.sysLogger?.error("DoInitialReadFromKNXBusOrFile " + error.stack);
}
}, 1000); // 17/02/2020 Do initial read of all nodes requesting initial read
const t = setTimeout(() => {
// 21/03/2022 fixed possible memory leak. Previously was setTimeout without "let t = ".
node.setAllClientsStatus("Connected.", "green", "On duty.");
}, 500);
node.sysLogger?.info("Connected to %o", info);
});
node.knxConnection.on(knx.KNXClientEvents.connecting, (info) => {
node.linkStatus = "connecting";
node.sysLogger?.debug("Connecting to" + info.ipAddr || "");
node.setAllClientsStatus(info.ipAddr || "", "grey", "Connecting...");
});
// ######################################
node.setAllClientsStatus("Connecting... ", "grey", "");
node.sysLogger?.info("perform websocket connection on " + node.name);
try {
node.sysLogger?.info("Connecting... " + node.name);
node.knxConnection.Connect();
} catch (error) {
node.sysLogger?.error("node.knxConnection.Connect() " + node.name + ": " + error.message);
node.linkStatus = "disconnected";
throw error;
}
} catch (error) {
if (node.sysLogger !== null) {
node.sysLogger.error("Error in instantiating knxConnection " + error.stack + " Node " + node.name);
node.error("KNXUltimate-config: Error in instantiating knxConnection " + error.message + " Node " + node.name);
}
node.linkStatus = "disconnected";
// 21/03/2022 fixed possible memory leak. Previously was setTimeout without "l