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.

464 lines (426 loc) 16.9 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 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 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) }