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.
502 lines (462 loc) • 19 kB
JavaScript
const loggerClass = require('./utils/sysLogger');
module.exports = function (RED) {
function knxUltimateGarage(config) {
RED.nodes.createNode(this, config);
const node = this;
node.serverKNX = RED.nodes.getNode(config.server) || undefined;
const pushStatus = (status) => {
if (!status) return;
const provider = node.serverKNX;
if (provider && typeof provider.applyStatusUpdate === 'function') {
provider.applyStatusUpdate(node, status);
} else {
node.status(status);
}
};
if (node.serverKNX === undefined) {
pushStatus({ fill: 'red', shape: 'dot', text: '[THE GATEWAY NODE HAS BEEN DISABLED]' });
return;
}
node.name = config.name || 'KNX Garage';
node.outputtopic = config.outputtopic || '';
node.listenallga = true;
node.notifyreadrequest = true;
node.notifyresponse = true;
node.notifywrite = true;
node.initialread = false;
node.outputtype = 'write';
node.outputRBE = 'false';
node.inputRBE = 'false';
try {
const baseLogLevel = (node.serverKNX && node.serverKNX.loglevel) ? node.serverKNX.loglevel : 'error';
node.sysLogger = new loggerClass({ loglevel: baseLogLevel, setPrefix: node.type + ' <' + (node.name || node.id || '') + '>' });
} catch (error) { console.log(error.stack); }
node.setNodeStatus = ({ fill = 'grey', shape = 'ring', text: statusText = '', payload = '', GA = '', dpt = '', devicename = '' }) => {
try {
if (!node.serverKNX) {
pushStatus({ fill: 'red', shape: 'dot', text: '[NO GATEWAY SELECTED]' });
return;
}
const dDate = new Date();
const gaLabel = GA ? `(${GA}) ` : '';
const deviceLabel = devicename ? ` ${devicename}` : '';
const dptLabel = dpt ? ` DPT${dpt}` : '';
let payloadLabel = '';
if (payload !== undefined && payload !== null && payload !== '') {
payloadLabel = typeof payload === 'object' ? JSON.stringify(payload) : `${payload}`;
}
const composed = `${gaLabel}${payloadLabel}${deviceLabel}${dptLabel} (day ${dDate.getDate()}, ${dDate.toLocaleTimeString()}) ${statusText}`.trim();
pushStatus({ fill, shape, text: composed });
if (fill && fill.toUpperCase() === 'RED' && node.serverKNX && typeof node.serverKNX.reportToWatchdogCalledByKNXUltimateNode === 'function') {
node.serverKNX.reportToWatchdogCalledByKNXUltimateNode({ nodeid: node.id, topic: node.outputtopic, devicename, GA, text: statusText });
}
} catch (error) {
if (node.sysLogger) node.sysLogger.error(`Status update failed: ${error.message}`);
}
};
const MOVEMENT_PULSE_MS = 1500;
const boolFromConfig = (value) => (value === true || value === 'true');
const boolFromPayload = (value) => {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const trimmed = value.trim().toLowerCase();
if (trimmed === 'true' || trimmed === '1' || trimmed === 'on' || trimmed === 'open') return true;
if (trimmed === 'false' || trimmed === '0' || trimmed === 'off' || trimmed === 'close' || trimmed === 'closed') return false;
}
if (value && typeof value === 'object') {
if (Object.prototype.hasOwnProperty.call(value, 'value')) return boolFromPayload(value.value);
if (Object.prototype.hasOwnProperty.call(value, 'state')) return boolFromPayload(value.state);
}
return false;
};
const safeSendToKNX = (telegram, context = 'write') => {
try {
if (!node.serverKNX) return;
node.serverKNX.sendKNXTelegramToKNXEngine({ ...telegram, nodecallerid: node.id });
} catch (error) {
if (node.sysLogger) {
node.sysLogger.error(`Garage send failed (${context}): ${error.message}`);
} else {
RED.log.error(`knxUltimateGarage send failed (${context}): ${error.message}`);
}
}
};
const emitEvent = (event, payload = {}) => {
if (!node.emitEvents) return;
node.send([
{
topic: node.outputtopic || node.gaCommand || node.name,
event,
payload,
state: node.doorState,
disabled: node.disabled,
holdOpen: node.holdOpenActive,
obstruction: node.obstructionActive
}
]);
};
node.gaCommand = (config.gaCommand || '').trim();
node.dptCommand = config.dptCommand || '1.001';
node.gaImpulse = (config.gaImpulse || '').trim();
node.dptImpulse = config.dptImpulse || '1.017';
node.gaHoldOpen = (config.gaHoldOpen || '').trim();
node.dptHoldOpen = config.dptHoldOpen || '1.001';
node.gaDisable = (config.gaDisable || '').trim();
node.dptDisable = config.dptDisable || '1.001';
node.gaPhotocell = (config.gaPhotocell || '').trim();
node.dptPhotocell = config.dptPhotocell || '1.001';
node.gaMoving = (config.gaMoving || '').trim();
node.dptMoving = config.dptMoving || '1.001';
node.gaObstruction = (config.gaObstruction || '').trim();
node.dptObstruction = config.dptObstruction || '1.001';
node.nameCommandGA = config.nameCommand || '';
node.nameImpulseGA = config.nameImpulse || '';
node.nameHoldOpenGA = config.nameHoldOpen || '';
node.nameDisableGA = config.nameDisable || '';
node.namePhotocellGA = config.namePhotocell || '';
node.nameMovingGA = config.nameMoving || '';
node.nameObstructionGA = config.nameObstruction || '';
node.autoCloseEnabled = boolFromConfig(config.autoCloseEnable);
node.autoCloseSeconds = Math.max(0, Number(config.autoCloseSeconds || 0));
node.emitEvents = boolFromConfig(config.emitEvents);
node.disabled = false;
node.holdOpenActive = false;
node.obstructionActive = false;
node.photocellActive = false;
node.doorState = 'closed';
node.autoCloseTimer = null;
node.autoCloseDeadline = 0;
node.movementTimer = null;
node.lastImpulseValue = false;
node.commandEchoBlockUntil = 0;
node.impulseEchoBlockUntil = 0;
const cancelAutoClose = () => {
if (node.autoCloseTimer) {
clearTimeout(node.autoCloseTimer);
node.autoCloseTimer = null;
}
node.autoCloseDeadline = 0;
};
const setMovement = (active, reason = '') => {
if (node.gaMoving === '') return;
safeSendToKNX({ grpaddr: node.gaMoving, payload: !!active, dpt: node.dptMoving, outputtype: 'write' }, reason || 'movement');
};
const scheduleMovementPulse = () => {
if (node.gaMoving === '') return;
setMovement(true, 'movement-start');
if (node.movementTimer) clearTimeout(node.movementTimer);
node.movementTimer = setTimeout(() => {
setMovement(false, 'movement-stop');
node.movementTimer = null;
}, MOVEMENT_PULSE_MS);
};
const setObstruction = (active, reason = '') => {
if (node.obstructionActive === active) return;
node.obstructionActive = active;
if (node.gaObstruction !== '') {
safeSendToKNX({ grpaddr: node.gaObstruction, payload: !!active, dpt: node.dptObstruction, outputtype: 'write' }, reason || 'obstruction');
}
emitEvent('obstruction', { active, reason });
updateStatus();
};
const scheduleAutoClose = () => {
cancelAutoClose();
if (node.doorState !== 'open') return;
if (!node.autoCloseEnabled) return;
if (node.autoCloseSeconds <= 0) return;
if (node.holdOpenActive) return;
if (node.disabled) return;
node.autoCloseDeadline = Date.now() + node.autoCloseSeconds * 1000;
node.autoCloseTimer = setTimeout(() => {
node.autoCloseTimer = null;
node.autoCloseDeadline = 0;
closeDoor('auto-close');
}, node.autoCloseSeconds * 1000);
updateStatus();
};
const updateStatus = (override = null) => {
if (override) {
node.setNodeStatus(override);
return;
}
if (node.disabled) {
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Disabled', payload: true, GA: node.gaDisable, dpt: node.dptDisable, devicename: node.nameDisableGA });
return;
}
if (node.obstructionActive) {
node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Obstruction', payload: true, GA: node.gaObstruction, dpt: node.dptObstruction, devicename: node.nameObstructionGA });
return;
}
const holdText = node.holdOpenActive ? ' (hold)' : '';
switch (node.doorState) {
case 'opening':
node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Opening' + holdText, payload: true, GA: node.gaCommand, dpt: node.dptCommand, devicename: node.nameCommandGA });
break;
case 'open': {
if (node.autoCloseTimer && node.autoCloseDeadline > Date.now()) {
const remaining = Math.max(0, Math.round((node.autoCloseDeadline - Date.now()) / 1000));
node.setNodeStatus({ fill: 'green', shape: 'ring', text: `Open (${remaining}s)` + holdText, payload: true, GA: node.gaCommand, dpt: node.dptCommand, devicename: node.nameCommandGA });
} else {
node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Open' + holdText, payload: true, GA: node.gaCommand, dpt: node.dptCommand, devicename: node.nameCommandGA });
}
break;
}
case 'closing':
node.setNodeStatus({ fill: 'yellow', shape: 'dot', text: 'Closing', GA: node.gaCommand, dpt: node.dptCommand, devicename: node.nameCommandGA });
break;
case 'closed':
node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Closed', payload: false, GA: node.gaCommand, dpt: node.dptCommand, devicename: node.nameCommandGA });
break;
default:
node.setNodeStatus({ fill: 'blue', shape: 'ring', text: node.doorState || 'Idle', GA: node.gaCommand });
}
};
const finishMovement = (targetState) => {
if (node.movementTimer) {
clearTimeout(node.movementTimer);
node.movementTimer = null;
}
if (node.gaMoving !== '') setMovement(false, 'movement-finish');
node.doorState = targetState;
if (targetState === 'open') scheduleAutoClose();
updateStatus();
};
const openDoor = (source, { skipSend = false } = {}) => {
if (node.disabled) {
emitEvent('blocked', { reason: 'disabled', request: 'open', source });
updateStatus({ fill: 'grey', shape: 'ring', text: 'Disabled', GA: node.gaDisable, dpt: node.dptDisable, devicename: node.nameDisableGA, payload: node.disabled });
return;
}
cancelAutoClose();
node.doorState = 'opening';
if (!skipSend) {
if (node.gaCommand !== '') {
safeSendToKNX({ grpaddr: node.gaCommand, payload: true, dpt: node.dptCommand, outputtype: 'write' }, source || 'open');
node.commandEchoBlockUntil = Date.now() + 500;
} else if (node.gaImpulse !== '') {
triggerImpulse('open');
}
}
if (node.gaMoving !== '') scheduleMovementPulse();
setTimeout(() => finishMovement('open'), 300);
emitEvent('open', { source });
};
const closeDoor = (source, { skipSend = false } = {}) => {
if (node.disabled) {
emitEvent('blocked', { reason: 'disabled', request: 'close', source });
updateStatus({ fill: 'grey', shape: 'ring', text: 'Disabled', GA: node.gaDisable, dpt: node.dptDisable, devicename: node.nameDisableGA, payload: node.disabled });
return;
}
if (node.holdOpenActive) {
emitEvent('blocked', { reason: 'hold-open', request: 'close', source });
scheduleAutoClose();
return;
}
node.doorState = 'closing';
if (!skipSend) {
if (node.gaCommand !== '') {
safeSendToKNX({ grpaddr: node.gaCommand, payload: false, dpt: node.dptCommand, outputtype: 'write' }, source || 'close');
node.commandEchoBlockUntil = Date.now() + 500;
} else if (node.gaImpulse !== '') {
triggerImpulse('close');
}
}
if (node.gaMoving !== '') scheduleMovementPulse();
setTimeout(() => finishMovement('closed'), 300);
emitEvent('close', { source });
};
const triggerImpulse = (source) => {
if (node.gaImpulse === '') return;
safeSendToKNX({ grpaddr: node.gaImpulse, payload: true, dpt: node.dptImpulse, outputtype: 'write' }, source || 'impulse');
node.impulseEchoBlockUntil = Date.now() + 500;
setTimeout(() => {
safeSendToKNX({ grpaddr: node.gaImpulse, payload: false, dpt: node.dptImpulse, outputtype: 'write' }, 'impulse-reset');
}, 250);
};
const toggleDoor = (source, { skipSend = false } = {}) => {
if (node.doorState === 'open' || node.doorState === 'opening') {
closeDoor(source, { skipSend });
} else {
openDoor(source, { skipSend });
}
};
const handleHoldOpen = (value) => {
const newState = !!value;
if (node.holdOpenActive === newState) return;
node.holdOpenActive = newState;
if (node.holdOpenActive) {
cancelAutoClose();
emitEvent('hold-open', { active: true });
} else {
if (node.doorState === 'open') scheduleAutoClose();
emitEvent('hold-open', { active: false });
}
updateStatus();
};
const handleDisable = (value) => {
const newState = !!value;
if (node.disabled === newState) return;
node.disabled = newState;
if (node.disabled) {
cancelAutoClose();
emitEvent('disabled', { active: true });
} else {
if (node.doorState === 'open') scheduleAutoClose();
emitEvent('disabled', { active: false });
}
updateStatus();
};
const handlePhotocell = (value) => {
const newState = !!value;
node.photocellActive = newState;
if (newState) {
setObstruction(true, 'photocell');
if (node.doorState === 'closing') {
openDoor('photocell');
} else {
updateStatus();
}
} else {
setObstruction(false, 'photocell-clear');
}
};
const handleCommandIncoming = (value) => {
const boolValue = !!value;
if (node.commandEchoBlockUntil > Date.now()) return;
if (boolValue) {
openDoor('command', { skipSend: true });
} else {
closeDoor('command', { skipSend: true });
}
};
const handleImpulseIncoming = (value) => {
const current = !!value;
if (node.impulseEchoBlockUntil > Date.now()) {
node.lastImpulseValue = current;
return;
}
if (node.lastImpulseValue === current) return;
node.lastImpulseValue = current;
if (!current) return; // rising edge only
toggleDoor('impulse', { skipSend: true });
};
const respondToRead = (destination) => {
const respond = (ga, payload, dpt) => {
if (ga === '') return;
safeSendToKNX({ grpaddr: ga, payload, dpt, outputtype: 'response' }, 'read-response');
};
if (destination === node.gaCommand) {
respond(node.gaCommand, node.doorState === 'open', node.dptCommand);
return true;
}
if (destination === node.gaHoldOpen) {
respond(node.gaHoldOpen, node.holdOpenActive, node.dptHoldOpen);
return true;
}
if (destination === node.gaDisable) {
respond(node.gaDisable, node.disabled, node.dptDisable);
return true;
}
if (destination === node.gaPhotocell) {
respond(node.gaPhotocell, node.photocellActive, node.dptPhotocell);
return true;
}
if (destination === node.gaMoving) {
respond(node.gaMoving, !!node.movementTimer, node.dptMoving);
return true;
}
if (destination === node.gaObstruction) {
respond(node.gaObstruction, node.obstructionActive, node.dptObstruction);
return true;
}
return false;
};
node.handleSend = (msg) => {
try {
if (!msg || !msg.knx || !msg.knx.destination) return;
const dest = msg.knx.destination;
if (msg.knx.event === 'GroupValue_Read') {
respondToRead(dest);
return;
}
if (dest === node.gaCommand) {
handleCommandIncoming(boolFromPayload(msg.payload));
return;
}
if (dest === node.gaImpulse) {
handleImpulseIncoming(boolFromPayload(msg.payload));
return;
}
if (dest === node.gaHoldOpen) {
handleHoldOpen(boolFromPayload(msg.payload));
return;
}
if (dest === node.gaDisable) {
handleDisable(boolFromPayload(msg.payload));
return;
}
if (dest === node.gaPhotocell) {
handlePhotocell(boolFromPayload(msg.payload));
return;
}
if (dest === node.gaObstruction) {
setObstruction(boolFromPayload(msg.payload), 'external');
return;
}
if (dest === node.gaMoving) {
const active = boolFromPayload(msg.payload);
if (active) {
node.doorState = 'moving';
}
updateStatus();
}
} catch (error) {
if (node.sysLogger) {
node.sysLogger.error(`Garage handleSend error: ${error.message}`);
} else {
RED.log.error(`knxUltimateGarage handleSend error: ${error.message}`);
}
}
};
node.on('input', (msg, send, done) => {
try {
const payload = msg.payload;
if (payload === undefined || payload === null) {
if (done) done();
return;
}
if (typeof payload === 'string') {
const lowered = payload.trim().toLowerCase();
if (lowered === 'open') {
openDoor('flow');
} else if (lowered === 'close') {
closeDoor('flow');
} else if (lowered === 'toggle') {
toggleDoor('flow');
} else {
if (boolFromPayload(payload)) openDoor('flow'); else closeDoor('flow');
}
} else if (typeof payload === 'boolean' || typeof payload === 'number' || typeof payload === 'object') {
if (boolFromPayload(payload)) openDoor('flow'); else closeDoor('flow');
}
if (done) done();
} catch (error) {
if (node.sysLogger) {
node.sysLogger.error(`Garage onInput error: ${error.message}`);
} else {
RED.log.error(`knxUltimateGarage onInput error: ${error.message}`);
}
if (done) done(error);
}
});
updateStatus();
scheduleAutoClose();
node.on('close', (done) => {
cancelAutoClose();
if (node.movementTimer) clearTimeout(node.movementTimer);
done();
});
}
RED.nodes.registerType('knxUltimateGarage', knxUltimateGarage);
};