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, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.
505 lines (465 loc) • 18.9 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 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)
}