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.
307 lines (268 loc) • 11.3 kB
JavaScript
const loggerClass = require('./utils/sysLogger')
let sendNowEndpointRegistered = false
module.exports = function (RED) {
if (!sendNowEndpointRegistered) {
RED.httpAdmin.post('/knxUltimateDateTime/sendNow', RED.auth.needsPermission('knxUltimate-config.write'), (req, res) => {
try {
const { id } = req.body || {}
if (!id) {
res.status(400).json({ error: 'Missing node id' })
return
}
const targetNode = RED.nodes.getNode(id)
if (!targetNode) {
res.status(404).json({ error: 'KNX DateTime node not found' })
return
}
if (typeof targetNode.triggerSend !== 'function') {
res.status(400).json({ error: 'Node does not support sendNow' })
return
}
const result = targetNode.triggerSend({ reason: 'button' })
res.json({ status: 'ok', queued: result && result.queued === true })
} catch (error) {
res.status(500).json({ error: error.message || 'KNX DateTime send failed' })
}
})
sendNowEndpointRegistered = true
}
function knxUltimateDateTime (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 DateTime'
node.outputtopic = config.outputtopic || ''
node.gaDateTime = (config.gaDateTime || '').trim()
node.nameDateTime = config.nameDateTime || ''
node.gaDate = (config.gaDate || '').trim()
node.nameDate = config.nameDate || ''
node.gaTime = (config.gaTime || '').trim()
node.nameTime = config.nameTime || ''
node.sendOnDeploy = (config.sendOnDeploy === true || config.sendOnDeploy === 'true')
node.sendOnDeployDelay = Math.max(0, Number(config.sendOnDeployDelay || 0))
node.periodicSend = (config.periodicSend === true || config.periodicSend === 'true')
node.periodicSendInterval = Math.max(1, Number(config.periodicSendInterval || 60))
node.periodicSendUnit = (config.periodicSendUnit || 's').toString()
node.listenallga = false
node.notifyreadrequest = false
node.notifyresponse = false
node.notifywrite = false
node.initialread = false
node.outputtype = 'write'
node.outputRBE = 'false'
node.inputRBE = 'false'
node._timerDeploy = null
node._timerPeriodic = null
node._timerWaitConnected = null
node._pendingSend = null
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) { /* ignore */ }
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 = payload && payload.constructor && payload.constructor.name === 'Date'
? payload.toLocaleString()
: (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) {
node.sysLogger?.warn(`Status update failed: ${error.message}`)
}
}
node.handleSend = (msg) => {
node.send(msg)
}
const hasAnyDestination = () => {
return !!(node.gaDateTime || node.gaDate || node.gaTime)
}
const isConnected = () => {
try {
return node.serverKNX && node.serverKNX.linkStatus === 'connected' && node.serverKNX.knxConnection !== null
} catch (error) {
return false
}
}
const parseDateFromPayload = (payload) => {
if (payload === undefined || payload === null || payload === '') return new Date()
if (payload && payload.constructor && payload.constructor.name === 'Date') return payload
if (typeof payload === 'number') return new Date(payload)
if (typeof payload === 'string') {
const trimmed = payload.trim()
if (trimmed === '' || trimmed.toLowerCase() === 'now') return new Date()
return new Date(trimmed)
}
if (typeof payload === 'object') {
if (payload.dateTime) return parseDateFromPayload(payload.dateTime)
if (payload.timestamp) return parseDateFromPayload(payload.timestamp)
if (payload.epoch) return parseDateFromPayload(payload.epoch)
}
return new Date(payload)
}
const buildDestinations = () => {
const list = []
if (node.gaDateTime) {
list.push({ ga: node.gaDateTime, dpt: '19.001', name: node.nameDateTime })
}
if (node.gaDate) {
list.push({ ga: node.gaDate, dpt: '11.001', name: node.nameDate })
}
if (node.gaTime) {
list.push({ ga: node.gaTime, dpt: '10.001', name: node.nameTime })
}
return list
}
const clearWaitConnectedTimer = () => {
if (node._timerWaitConnected) {
clearInterval(node._timerWaitConnected)
node._timerWaitConnected = null
}
}
const ensureWaitConnectedTimer = () => {
if (node._timerWaitConnected) return
node._timerWaitConnected = setInterval(() => {
if (!node._pendingSend) {
clearWaitConnectedTimer()
return
}
if (!isConnected()) return
const pending = node._pendingSend
node._pendingSend = null
clearWaitConnectedTimer()
doSend(pending.date, pending.reason, pending.sourceMsg)
}, 1500)
}
const doSend = (dateObj, reason, sourceMsg = null) => {
if (!node.serverKNX) return
const destinations = buildDestinations()
if (destinations.length === 0) {
node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'No group address configured' })
return
}
destinations.forEach((dest) => {
try {
node.serverKNX.sendKNXTelegramToKNXEngine({
grpaddr: dest.ga,
payload: dateObj,
dpt: dest.dpt,
outputtype: 'write',
nodecallerid: node.id
})
node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Sent (${reason})`, payload: dateObj, GA: dest.ga, dpt: dest.dpt, devicename: dest.name })
} catch (error) {
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Send failed (${reason}): ${error.message}`, payload: dateObj, GA: dest.ga, dpt: dest.dpt, devicename: dest.name })
}
})
const outMsg = {
topic: node.outputtopic || node.name || 'knxUltimateDateTime',
payload: dateObj,
reason,
sent: destinations.map((d) => ({ ga: d.ga, dpt: d.dpt, name: d.name })),
knxUltimateDateTime: {
date: dateObj && dateObj.constructor && dateObj.constructor.name === 'Date' ? dateObj.toISOString() : undefined
}
}
if (sourceMsg && sourceMsg._msgid) outMsg._msgid = sourceMsg._msgid
node.send(outMsg)
}
node.triggerSend = ({ reason = 'input', msg = null } = {}) => {
if (!hasAnyDestination()) {
node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'No group address configured' })
return { queued: false }
}
let dateObj
try {
dateObj = parseDateFromPayload(msg ? msg.payload : undefined)
if (!(dateObj && dateObj.constructor && dateObj.constructor.name === 'Date') || isNaN(dateObj.getTime())) {
throw new Error('Invalid date/time payload')
}
} catch (error) {
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Invalid payload: ${error.message}` })
return { queued: false }
}
if (!isConnected()) {
node._pendingSend = { date: dateObj, reason, sourceMsg: msg }
node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: `Gateway not connected, queued (${reason})` })
ensureWaitConnectedTimer()
return { queued: true }
}
doSend(dateObj, reason, msg)
return { queued: false }
}
const startTimers = () => {
if (!hasAnyDestination()) {
node.setNodeStatus({ fill: 'red', shape: 'ring', text: 'Configure at least one GA (DateTime/Date/Time)' })
return
}
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Ready' })
if (node.sendOnDeploy) {
if (node._timerDeploy) clearTimeout(node._timerDeploy)
node._timerDeploy = setTimeout(() => {
node.triggerSend({ reason: 'startup' })
}, node.sendOnDeployDelay * 1000)
}
if (node.periodicSend) {
const multiplier = node.periodicSendUnit === 'm' ? 60000 : 1000
const intervalMs = Math.max(1000, node.periodicSendInterval * multiplier)
if (node._timerPeriodic) clearInterval(node._timerPeriodic)
node._timerPeriodic = setInterval(() => {
node.triggerSend({ reason: 'periodic' })
}, intervalMs)
}
}
node.on('input', function (msg) {
node.triggerSend({ reason: 'input', msg })
})
node.on('close', function (done) {
try {
if (node._timerDeploy) clearTimeout(node._timerDeploy)
if (node._timerPeriodic) clearInterval(node._timerPeriodic)
clearWaitConnectedTimer()
node._timerDeploy = null
node._timerPeriodic = null
node._pendingSend = null
} catch (error) { /* ignore */ }
if (node.serverKNX) {
node.serverKNX.removeClient(node)
}
done()
})
// Subscribe to config node lifecycle and keep gateway connected even if this is the only node in the flow.
if (node.serverKNX) {
node.serverKNX.removeClient(node)
node.serverKNX.addClient(node)
}
startTimers()
}
RED.nodes.registerType('knxUltimateDateTime', knxUltimateDateTime)
}