UNPKG

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, ETS group address importer, and KNX routing between interfaces. Easy to use and highly configurable.

505 lines (465 loc) 18.9 kB
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 ts = (node.serverKNX && typeof node.serverKNX.formatStatusTimestamp === 'function') ? node.serverKNX.formatStatusTimestamp(dDate, { legacyDayLabel: true }) : `day ${dDate.getDate()}, ${dDate.toLocaleTimeString()}` 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} (${ts}) ${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) }