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 and ETS group address importer. Easy to use and highly configurable.

461 lines (423 loc) 16.7 kB
const loggerClass = require('./utils/sysLogger') module.exports = function (RED) { function knxUltimateStaircase (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 Staircase' 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 boolFromConfig = (value) => (value === true || value === 'true') node.gaTrigger = (config.gaTrigger || '').trim() node.dptTrigger = config.dptTrigger || '1.001' node.gaOutput = (config.gaOutput || '').trim() node.dptOutput = config.dptOutput || '1.001' node.gaStatus = (config.gaStatus || '').trim() node.dptStatus = config.dptStatus || '1.001' node.gaOverride = (config.gaOverride || '').trim() node.dptOverride = config.dptOverride || '1.001' node.gaBlock = (config.gaBlock || '').trim() node.dptBlock = config.dptBlock || '1.001' node.nameTriggerGA = config.nameTrigger || '' node.nameOutputGA = config.nameOutput || '' node.nameStatusGA = config.nameStatus || '' node.nameOverrideGA = config.nameOverride || '' node.nameBlockGA = config.nameBlock || '' node.timerDurationMs = Math.max(1000, Number(config.timerSeconds || 0) * 1000 || 60000) node.extendMode = config.extendMode || 'restart' node.triggerOffCancels = config.triggerOffCancels || 'yes' node.preWarnEnabled = boolFromConfig(config.preWarnEnable) node.preWarnMs = node.preWarnEnabled ? Math.max(0, Number(config.preWarnSeconds || 0) * 1000) : 0 node.preWarnMode = config.preWarnMode || 'status' node.preWarnFlashMs = Math.max(100, Number(config.preWarnFlashMs || 0) || 300) node.blockAction = config.blockAction || 'off' node.emitEvents = boolFromConfig(config.emitEvents) node.overrideActive = false node.blocked = false node.active = false node.outputState = false node.timer = null node.preWarnTimer = null node.statusTicker = null node.timerDeadline = 0 node.preWarned = 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(`Staircase send failed (${context}): ${error.message}`) } else { RED.log.error(`knxUltimateStaircase send failed (${context}): ${error.message}`) } } } const emitEvent = (event, payload = {}) => { if (!node.emitEvents) return const remaining = Math.max(0, Math.round((node.timerDeadline - Date.now()) / 1000)) node.send([{ topic: node.outputtopic || node.gaOutput || node.name, event, payload, remaining, active: node.active, override: node.overrideActive, blocked: node.blocked }]) } const formatPayloadForDpt = (value, dpt) => { if (!dpt || dpt === '') return value const prefix = dpt.split('.')[0] switch (prefix) { case '1': case '2': return !!value case '5': case '6': case '7': case '8': case '9': case '12': case '13': case '14': case '20': { const num = Number(value) return Number.isNaN(num) ? 0 : num } default: return value } } const sendOutput = (value, context = 'write') => { if (!node.gaOutput) return node.outputState = !!value const payload = formatPayloadForDpt(value, node.dptOutput) const outputType = context === 'response' ? 'response' : 'write' safeSendToKNX({ grpaddr: node.gaOutput, payload, dpt: node.dptOutput, outputtype: outputType }, context) } const sendStatusValue = (value, context = 'write') => { if (!node.gaStatus) return const payload = formatPayloadForDpt(value, node.dptStatus) const outputType = context === 'response' ? 'response' : 'write' safeSendToKNX({ grpaddr: node.gaStatus, payload, dpt: node.dptStatus, outputtype: outputType }, context) } 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 === '1' || trimmed === 'true' || trimmed === 'on') return true if (trimmed === '0' || trimmed === 'false' || trimmed === 'off') 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 stopStatusTicker = () => { if (node.statusTicker) { clearInterval(node.statusTicker) node.statusTicker = null } } const updateStatus = (overrideStatus = null) => { if (overrideStatus) { node.setNodeStatus(overrideStatus) return } if (node.overrideActive) { node.setNodeStatus({ fill: 'blue', shape: 'dot', text: 'Override ON', payload: true, GA: node.gaOverride, dpt: node.dptOverride, devicename: node.nameOverrideGA }) return } if (node.blocked) { node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Blocked', payload: true, GA: node.gaBlock, dpt: node.dptBlock, devicename: node.nameBlockGA }) return } if (node.active) { const remaining = Math.max(0, Math.round((node.timerDeadline - Date.now()) / 1000)) node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Active ${remaining}s`, payload: true, GA: node.gaOutput, dpt: node.dptOutput, devicename: node.nameOutputGA }) return } node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Idle', payload: node.outputState, GA: node.gaOutput, dpt: node.dptOutput, devicename: node.nameOutputGA }) } const startStatusTicker = () => { if (node.statusTicker) return node.statusTicker = setInterval(() => { if (!node.active || node.overrideActive || node.blocked) { stopStatusTicker() updateStatus() return } updateStatus() }, 1000) } const cancelTimers = () => { if (node.timer) { clearTimeout(node.timer) node.timer = null } if (node.preWarnTimer) { clearTimeout(node.preWarnTimer) node.preWarnTimer = null } node.preWarned = false stopStatusTicker() } const triggerPreWarning = () => { if (!node.active || node.preWarned) return node.preWarned = true if (node.preWarnMode === 'flash' && node.gaOutput) { const restore = node.outputState sendOutput(false, 'write') setTimeout(() => { if (node.active && !node.overrideActive && !node.blocked) sendOutput(restore || true, 'write') }, node.preWarnFlashMs) } else { sendStatusValue(true, 'write') } updateStatus({ fill: 'yellow', shape: 'dot', text: 'Pre-warning', GA: node.gaStatus, dpt: node.dptStatus, devicename: node.nameStatusGA, payload: true }) emitEvent('prewarn', { active: true }) } const scheduleTimers = () => { cancelTimers() const remaining = node.timerDeadline - Date.now() if (remaining <= 0) { finishCycle('timeout') return } node.timer = setTimeout(() => finishCycle('timeout'), remaining) if (node.preWarnEnabled && node.preWarnMs > 0 && remaining > node.preWarnMs) { node.preWarnTimer = setTimeout(triggerPreWarning, remaining - node.preWarnMs) node.preWarned = false } startStatusTicker() updateStatus() } const startCycle = (source = 'trigger') => { const now = Date.now() const baseDuration = node.timerDurationMs if (!node.active) { node.active = true node.timerDeadline = now + baseDuration node.preWarned = false sendOutput(true, 'write') sendStatusValue(true, 'write') emitEvent(source, { started: true }) } else { if (node.extendMode === 'restart') { node.timerDeadline = now + baseDuration } else if (node.extendMode === 'extend') { node.timerDeadline += baseDuration } emitEvent('extend', { restarted: node.extendMode === 'restart' }) } scheduleTimers() } const finishCycle = (reason = 'timeout') => { cancelTimers() if (node.overrideActive) { updateStatus() emitEvent(reason, { active: true, override: true }) return } node.active = false node.preWarned = false sendOutput(false, 'write') sendStatusValue(false, 'write') updateStatus({ fill: reason === 'manual-off' ? 'grey' : 'green', shape: 'ring', text: reason === 'manual-off' ? 'Stopped' : 'Finished', GA: node.gaOutput, dpt: node.dptOutput, devicename: node.nameOutputGA, payload: node.outputState }) emitEvent(reason, { active: false }) } const handleBlock = (value) => { const newState = !!value if (node.blocked === newState) { updateStatus() return } node.blocked = newState if (node.blocked) { cancelTimers() if (!node.overrideActive && node.blockAction === 'off') { node.active = false sendOutput(false, 'write') sendStatusValue(false, 'write') } updateStatus({ fill: 'yellow', shape: 'ring', text: 'Blocked', GA: node.gaBlock, dpt: node.dptBlock, devicename: node.nameBlockGA, payload: true }) } else { updateStatus() } emitEvent('block', { value: node.blocked }) } const handleOverride = (value) => { const newState = !!value if (node.overrideActive === newState) { updateStatus() return } node.overrideActive = newState if (node.overrideActive) { cancelTimers() node.active = false sendOutput(true, 'write') sendStatusValue(true, 'write') updateStatus({ fill: 'blue', shape: 'dot', text: 'Override ON', GA: node.gaOverride, dpt: node.dptOverride, devicename: node.nameOverrideGA, payload: node.overrideActive }) } else { updateStatus() } emitEvent('override', { value: node.overrideActive }) } const handleTrigger = (value) => { if (!value) { if (node.triggerOffCancels === 'yes') { if (node.active || node.outputState) { finishCycle('manual-off') } } return } if (node.overrideActive) { sendOutput(true, 'write') updateStatus() emitEvent('trigger', { ignored: true, reason: 'override' }) return } if (node.blocked) { updateStatus({ fill: 'yellow', shape: 'ring', text: 'Blocked', GA: node.gaBlock, dpt: node.dptBlock, devicename: node.nameBlockGA, payload: true }) emitEvent('trigger', { ignored: true, reason: 'blocked' }) return } startCycle('trigger') } const handleRead = (destination) => { if (node.gaOutput && destination === node.gaOutput) { sendOutput(node.overrideActive || node.active ? true : node.outputState, 'response') return } if (node.gaStatus && destination === node.gaStatus) { sendStatusValue(node.overrideActive || node.active, 'response') return } if (node.gaOverride && destination === node.gaOverride) { const payload = formatPayloadForDpt(node.overrideActive, node.dptOverride) safeSendToKNX({ grpaddr: node.gaOverride, payload, dpt: node.dptOverride, outputtype: 'response' }, 'response') return } if (node.gaBlock && destination === node.gaBlock) { const payload = formatPayloadForDpt(node.blocked, node.dptBlock) safeSendToKNX({ grpaddr: node.gaBlock, payload, dpt: node.dptBlock, outputtype: 'response' }, 'response') } } node.on('input', (msg, send, done) => { try { if (!msg) { if (done) done(); return } let processed = false if (typeof msg.payload === 'string') { const command = msg.payload.trim().toLowerCase() if (command === 'trigger' || command === 'start' || command === 'on' || command === 'open') { handleTrigger(true) processed = true } else if (command === 'cancel' || command === 'stop' || command === 'off' || command === 'close') { handleTrigger(false) processed = true } else if (command === 'toggle') { handleTrigger(true) processed = true } } if (!processed) { handleTrigger(boolFromPayload(msg.payload)) } if (done) done() } catch (error) { if (node.sysLogger) { node.sysLogger.error(`Staircase flow input error: ${error.message}`) } else { RED.log.error(`knxUltimateStaircase flow input error: ${error.message}`) } if (done) done(error) } }) node.handleSend = (msg) => { try { if (!msg || !msg.knx || !msg.knx.destination) return const dest = msg.knx.destination if (msg.knx.event === 'GroupValue_Read') { handleRead(dest) return } if (dest === node.gaTrigger) { handleTrigger(boolFromPayload(msg.payload)) return } if (dest === node.gaBlock) { handleBlock(boolFromPayload(msg.payload)) return } if (dest === node.gaOverride) { handleOverride(boolFromPayload(msg.payload)) } } catch (error) { if (node.sysLogger) { node.sysLogger.error(`Staircase handleSend error: ${error.message}`) } else { RED.log.error(`knxUltimateStaircase handleSend error: ${error.message}`) } } } updateStatus() node.on('close', (done) => { cancelTimers() stopStatusTicker() done() }) } RED.nodes.registerType('knxUltimateStaircase', knxUltimateStaircase) }