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) • 18.7 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)
}