node-red-contrib-knx-ultimate 5.0.0 → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@ module.exports = function (RED) {
22
22
  return
23
23
  }
24
24
 
25
- node.name = config.name || 'KNX IoT Bridge'
25
+ node.name = config.name || 'KNX MQTT - IoT'
26
26
  node.outputtopic = config.outputtopic || ''
27
27
 
28
28
  node.listenallga = true
@@ -40,6 +40,27 @@ module.exports = function (RED) {
40
40
 
41
41
  node.mappings = Array.isArray(config.mappings) ? config.mappings : []
42
42
 
43
+ // Operation mode: 'iot' (classic IoT mappings, default) or 'homeassistant' (native
44
+ // MQTT bridge with Home Assistant discovery for every group address + cover/climate).
45
+ node.nodeMode = config.nodeMode === 'homeassistant' ? 'homeassistant' : 'iot'
46
+ // Home Assistant bus wiring: 'standalone' (default) talks to the KNX gateway directly,
47
+ // 'flow' uses the node's input/output pins instead (wire a KNXUltimate universal node to
48
+ // both): the input pin feeds KNX bus telegrams in, the output pin emits telegrams to write.
49
+ node.haBusMode = config.haBusMode === 'flow' ? 'flow' : 'standalone'
50
+ node.mqttUrl = typeof config.mqttUrl === 'string' ? config.mqttUrl.trim() : ''
51
+ node.mqttBaseTopic = typeof config.mqttBaseTopic === 'string' && config.mqttBaseTopic.trim() !== '' ? config.mqttBaseTopic.trim() : 'knx-ultimate'
52
+ node.mqttDiscovery = config.mqttDiscovery !== false && config.mqttDiscovery !== 'false'
53
+ node.mqttDiscoveryPrefix = typeof config.mqttDiscoveryPrefix === 'string' && config.mqttDiscoveryPrefix.trim() !== '' ? config.mqttDiscoveryPrefix.trim() : 'homeassistant'
54
+ node.mqttCustomEntities = Array.isArray(config.mqttCustomEntities) ? config.mqttCustomEntities : []
55
+ // Group addresses to expose as simple entities. Once the user curates the list
56
+ // (mqttExposeConfigured), only the listed GAs are exposed; otherwise all imported GAs are.
57
+ node.mqttExposeConfigured = config.mqttExposeConfigured === true
58
+ node.mqttExposedGAs = Array.isArray(config.mqttExposedGAs) ? config.mqttExposedGAs : []
59
+ // Group addresses the user marked as read-only: they are still exposed (state is
60
+ // published to HA) but never accept commands back to the KNX bus.
61
+ node.mqttReadOnlyGAs = Array.isArray(config.mqttReadOnlyGAs) ? config.mqttReadOnlyGAs : []
62
+ node.mqttBridge = null
63
+
43
64
  const safeNumber = (value, fallback = 0) => {
44
65
  if (value === null || value === undefined || value === '') return fallback
45
66
  const parsed = Number(value)
@@ -143,6 +164,98 @@ module.exports = function (RED) {
143
164
  }
144
165
  }
145
166
 
167
+ // HOME ASSISTANT (MQTT) BRIDGE -----------------------------------------------------------
168
+ node.startMqttBridge = () => {
169
+ if (node.mqttBridge !== null) return // already running
170
+ if (!node.mqttUrl) {
171
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT broker URL missing' })
172
+ return
173
+ }
174
+ try {
175
+ // Lazy-require so the node still loads if the optional mqtt dependency is missing.
176
+ const { createMqttBridge } = require('./lib/mqtt-bridge.js')
177
+ node.mqttBridge = createMqttBridge({
178
+ node,
179
+ url: node.mqttUrl,
180
+ baseTopic: node.mqttBaseTopic,
181
+ discovery: node.mqttDiscovery,
182
+ discoveryPrefix: node.mqttDiscoveryPrefix,
183
+ username: node.credentials ? node.credentials.mqttUsername : undefined,
184
+ password: node.credentials ? node.credentials.mqttPassword : undefined,
185
+ groupAddresses: (node.serverKNX && Array.isArray(node.serverKNX.csv)) ? node.serverKNX.csv : [],
186
+ customEntities: node.mqttCustomEntities,
187
+ // null => expose all imported GAs (until the user curates the list).
188
+ exposedGAs: node.mqttExposeConfigured ? node.mqttExposedGAs : null,
189
+ // GAs exposed as read-only (state only, no command topic back to KNX).
190
+ readOnlyGAs: node.mqttReadOnlyGAs,
191
+ onCommand: ({ ga, dpt, value }) => {
192
+ // A Home Assistant command arrived: write it to the KNX bus.
193
+ try {
194
+ if (node.haBusMode === 'flow') {
195
+ // Flow mode: emit a message on the (single) output pin for a downstream
196
+ // KNXUltimate universal node to write to the bus (destination + dpt + payload).
197
+ node.send({
198
+ topic: ga,
199
+ destination: ga,
200
+ dpt: dpt || '',
201
+ payload: value
202
+ })
203
+ return
204
+ }
205
+ node.serverKNX.sendKNXTelegramToKNXEngine({
206
+ grpaddr: ga,
207
+ payload: value,
208
+ dpt,
209
+ outputtype: 'write',
210
+ nodecallerid: node.id
211
+ })
212
+ } catch (error) {
213
+ if (node.sysLogger) node.sysLogger.error('HA bridge write failed (' + ga + '): ' + error.message)
214
+ }
215
+ },
216
+ onStatus: (status) => {
217
+ if (!status) return
218
+ if (status.state === 'connected') {
219
+ pushStatus({ fill: 'green', shape: 'dot', text: 'HA connected (' + (status.detail || '0') + ' entities)' })
220
+ } else if (status.state === 'error') {
221
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT ' + (status.detail || 'error') })
222
+ } else if (status.state === 'reconnect' || status.state === 'offline') {
223
+ pushStatus({ fill: 'yellow', shape: 'ring', text: 'MQTT ' + status.state })
224
+ }
225
+ }
226
+ })
227
+ node.mqttBridge.connect()
228
+ pushStatus({ fill: 'grey', shape: 'ring', text: 'HA mode: connecting (' + node.mqttBridge.entityCount + ' entities)' })
229
+ } catch (error) {
230
+ node.mqttBridge = null
231
+ if (node.sysLogger) node.sysLogger.error('startMqttBridge failed: ' + error.message)
232
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT bridge: ' + error.message })
233
+ }
234
+ }
235
+
236
+ node.stopMqttBridge = (done) => {
237
+ const bridge = node.mqttBridge
238
+ node.mqttBridge = null
239
+ let called = false
240
+ const cb = () => {
241
+ if (called) return
242
+ called = true
243
+ if (typeof done === 'function') {
244
+ try { done() } catch (error) { /* ignore */ }
245
+ }
246
+ }
247
+ if (!bridge) {
248
+ cb()
249
+ return
250
+ }
251
+ try {
252
+ bridge.close(cb)
253
+ } catch (error) {
254
+ if (node.sysLogger) node.sysLogger.error('stopMqttBridge error: ' + (error && error.message))
255
+ cb()
256
+ }
257
+ }
258
+
146
259
  const isBooleanDpt = (dpt) => typeof dpt === 'string' && dpt.startsWith('1.')
147
260
 
148
261
  const toBoolean = (value) => {
@@ -333,12 +446,30 @@ module.exports = function (RED) {
333
446
  }
334
447
  }
335
448
 
449
+ // Home Assistant mode: mirror a decoded KNX telegram to MQTT. Works with a telegram coming
450
+ // from the gateway (standalone) or from the input pin (flow mode); both share the shape
451
+ // produced by the KNXUltimate universal node ({ payload, knx: { destination, event } }).
452
+ const publishKnxToMqtt = (msg) => {
453
+ if (!msg || !node.mqttBridge) return
454
+ const destination = msg.knx && msg.knx.destination ? msg.knx.destination : sanitizeString(msg.topic)
455
+ if (!destination) return
456
+ const event = msg.knx ? msg.knx.event : undefined
457
+ if (event === 'GroupValue_Read') return // read requests carry no value
458
+ node.mqttBridge.publishState(destination, msg.payload)
459
+ }
460
+
336
461
  const handleKnxTelegram = (msg) => {
337
462
  try {
338
463
  if (!msg) return
339
464
  const destination = msg.knx && msg.knx.destination ? msg.knx.destination : sanitizeString(msg.topic)
340
465
  if (!destination) return
341
466
 
467
+ // Home Assistant mode: mirror the decoded value to MQTT and stop (no IoT mappings).
468
+ if (node.nodeMode === 'homeassistant') {
469
+ publishKnxToMqtt(msg)
470
+ return
471
+ }
472
+
342
473
  const meta = {
343
474
  event: msg.knx ? msg.knx.event : undefined,
344
475
  source: msg.knx ? msg.knx.source : undefined,
@@ -382,6 +513,14 @@ module.exports = function (RED) {
382
513
  node.handleSend = handleKnxTelegram
383
514
 
384
515
  node.on('input', (msg, send, done) => {
516
+ // In Home Assistant mode, commands flow in over MQTT, not via the flow input. The one
517
+ // exception is 'flow' bus mode, where the input pin carries KNX bus telegrams (from a
518
+ // KNXUltimate universal node) that must be mirrored to MQTT.
519
+ if (node.nodeMode === 'homeassistant') {
520
+ if (node.haBusMode === 'flow') publishKnxToMqtt(msg)
521
+ if (done) done()
522
+ return
523
+ }
385
524
  if (!node.acceptFlowInput) {
386
525
  if (done) done()
387
526
  return
@@ -446,14 +585,30 @@ module.exports = function (RED) {
446
585
  })
447
586
 
448
587
  node.on('close', (done) => {
449
- if (node.serverKNX && typeof node.serverKNX.removeClient === 'function') {
450
- try {
451
- node.serverKNX.removeClient(node)
452
- } catch (error) {
453
- /* empty */
588
+ // Always call done() exactly once, even if something throws, so a deploy / Node-RED exit
589
+ // is never blocked by the bridge teardown.
590
+ let finished = false
591
+ const finish = () => {
592
+ if (finished) return
593
+ finished = true
594
+ if (typeof done === 'function') {
595
+ try { done() } catch (error) { /* ignore */ }
454
596
  }
455
597
  }
456
- if (done) done()
598
+ try {
599
+ if (node.serverKNX && typeof node.serverKNX.removeClient === 'function') {
600
+ try {
601
+ node.serverKNX.removeClient(node)
602
+ } catch (error) {
603
+ /* empty */
604
+ }
605
+ }
606
+ // Stop the MQTT bridge (best-effort, hard-capped so redeploy never blocks on the broker).
607
+ node.stopMqttBridge(finish)
608
+ } catch (error) {
609
+ if (node.sysLogger) node.sysLogger.error('close handler error: ' + (error && error.message))
610
+ finish()
611
+ }
457
612
  })
458
613
 
459
614
  const registerClient = () => {
@@ -491,10 +646,22 @@ module.exports = function (RED) {
491
646
  }
492
647
  }
493
648
 
494
- registerClient()
495
- updateIdleStatus()
496
- issueInitialReads()
649
+ // In HA 'flow' mode the KNX telegrams arrive on the input pin, so we must NOT subscribe to
650
+ // the gateway's client feed (that would double-publish and bypass the intended wiring).
651
+ const useFlowBus = node.nodeMode === 'homeassistant' && node.haBusMode === 'flow'
652
+ if (!useFlowBus) registerClient()
653
+ if (node.nodeMode === 'homeassistant') {
654
+ node.startMqttBridge()
655
+ } else {
656
+ updateIdleStatus()
657
+ issueInitialReads()
658
+ }
497
659
  }
498
660
 
499
- RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge)
661
+ RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge, {
662
+ credentials: {
663
+ mqttUsername: { type: 'text' },
664
+ mqttPassword: { type: 'password' }
665
+ }
666
+ })
500
667
  }
@@ -0,0 +1,171 @@
1
+ 'use strict'
2
+
3
+ // Shared Home Assistant mapping helpers for the native KNX MQTT bridge.
4
+ //
5
+ // A KNX gateway exposes many group addresses (GA), each carrying a Datapoint Type (DPT).
6
+ // Home Assistant, on the other hand, models things as typed entities (switch, sensor,
7
+ // binary_sensor, number, text...). This module is the translation layer between the two:
8
+ //
9
+ // - mapDptToHa(dpt) -> { domain, deviceClass?, unit?, min?, max?, step? }
10
+ // - formatValueForMqtt(value) -> string to publish on a HA state topic
11
+ // - parseCommandFromMqtt(text, dpt) -> JS value to write on the KNX bus (or null)
12
+ //
13
+ // The mapping is intentionally pragmatic: it covers the common DPTs and falls back to a
14
+ // read-only "sensor" for everything else, so an unknown DPT never breaks discovery.
15
+
16
+ // Extract the main/sub parts of a DPT string ("DPT9.001", "9.001", "9" ...).
17
+ function splitDpt (dptRaw) {
18
+ const clean = String(dptRaw == null ? '' : dptRaw).replace(/^dpt/i, '').trim()
19
+ const parts = clean.split('.')
20
+ const main = parseInt(parts[0], 10)
21
+ const sub = parts.length > 1 ? parts[1].trim() : ''
22
+ return { main: Number.isFinite(main) ? main : null, sub }
23
+ }
24
+
25
+ // Unit + HA device_class for the most common 2-byte float DPTs (DPT 9.xxx).
26
+ function unitForFloat (sub) {
27
+ switch (sub) {
28
+ case '001': return { unit: '°C', deviceClass: 'temperature' }
29
+ case '002': return { unit: 'K' }
30
+ case '003': return { unit: 'K/h' }
31
+ case '004': return { unit: 'lx', deviceClass: 'illuminance' }
32
+ case '005': return { unit: 'm/s', deviceClass: 'wind_speed' }
33
+ case '006': return { unit: 'Pa', deviceClass: 'pressure' }
34
+ case '007': return { unit: '%', deviceClass: 'humidity' }
35
+ case '008': return { unit: 'ppm' }
36
+ case '009': return { unit: 'mg/m³' }
37
+ case '010': return { unit: 'm/s' }
38
+ case '011': return { unit: 's' }
39
+ case '020': return { unit: 'mV', deviceClass: 'voltage' }
40
+ case '021': return { unit: 'mA', deviceClass: 'current' }
41
+ case '022': return { unit: 'W/m²', deviceClass: 'irradiance' }
42
+ case '024': return { unit: 'K' }
43
+ case '025': return { unit: '1/h' }
44
+ case '026': return { unit: 'l/h' }
45
+ case '027': return { unit: '°F', deviceClass: 'temperature' }
46
+ case '028': return { unit: 'km/h', deviceClass: 'wind_speed' }
47
+ default: return {}
48
+ }
49
+ }
50
+
51
+ // Map a KNX DPT to a Home Assistant entity descriptor.
52
+ // `domain` is the HA MQTT platform; the extra fields tune the discovery config.
53
+ function mapDptToHa (dptRaw) {
54
+ const { main, sub } = splitDpt(dptRaw)
55
+
56
+ if (main === 1) {
57
+ // 1-bit boolean. A handful of subtypes are clearly read-only states; the rest are
58
+ // treated as controllable switches.
59
+ const readOnly = ['005', '011', '019']
60
+ if (readOnly.includes(sub)) {
61
+ const deviceClass = sub === '005' ? 'problem' : (sub === '019' ? 'opening' : undefined)
62
+ return { domain: 'binary_sensor', deviceClass, writable: false }
63
+ }
64
+ return { domain: 'switch', writable: true }
65
+ }
66
+
67
+ if (main === 5) {
68
+ if (sub === '001') return { domain: 'number', min: 0, max: 100, step: 1, unit: '%', writable: true } // scaling
69
+ if (sub === '003') return { domain: 'number', min: 0, max: 360, step: 1, unit: '°', writable: true } // angle
70
+ return { domain: 'number', min: 0, max: 255, step: 1, writable: true }
71
+ }
72
+
73
+ if (main === 6) return { domain: 'sensor', writable: false } // 1-byte signed
74
+ if (main === 7) return { domain: 'sensor', writable: false } // 2-byte unsigned
75
+ if (main === 8) return { domain: 'sensor', writable: false } // 2-byte signed
76
+
77
+ if (main === 9) {
78
+ const u = unitForFloat(sub)
79
+ return { domain: 'sensor', unit: u.unit, deviceClass: u.deviceClass, writable: false }
80
+ }
81
+
82
+ if (main === 12) return { domain: 'sensor', writable: false } // 4-byte unsigned
83
+ if (main === 13) {
84
+ if (sub === '010') return { domain: 'sensor', unit: 'Wh', deviceClass: 'energy', writable: false }
85
+ if (sub === '013') return { domain: 'sensor', unit: 'kWh', deviceClass: 'energy', writable: false }
86
+ return { domain: 'sensor', writable: false } // 4-byte signed
87
+ }
88
+
89
+ if (main === 14) {
90
+ // 4-byte float (engineering values). A few well-known subtypes carry a clear unit.
91
+ if (sub === '056') return { domain: 'sensor', unit: 'W', deviceClass: 'power', writable: false }
92
+ if (sub === '027') return { domain: 'sensor', unit: 'V', deviceClass: 'voltage', writable: false }
93
+ if (sub === '019') return { domain: 'sensor', unit: 'A', deviceClass: 'current', writable: false }
94
+ if (sub === '068') return { domain: 'sensor', unit: '°C', deviceClass: 'temperature', writable: false }
95
+ return { domain: 'sensor', writable: false }
96
+ }
97
+
98
+ if (main === 16) return { domain: 'text', writable: true } // character string
99
+
100
+ // Scenes (17/18), HVAC modes (20), colours (232) and anything else: expose as a
101
+ // read-only sensor so the value is visible without risking a wrong write encoding.
102
+ return { domain: 'sensor', writable: false }
103
+ }
104
+
105
+ // Render a KNX-decoded JS value to the string published on a HA state topic.
106
+ function formatValueForMqtt (value) {
107
+ if (value === null || value === undefined) return ''
108
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
109
+ if (typeof value === 'object') {
110
+ try {
111
+ return JSON.stringify(value)
112
+ } catch (_err) {
113
+ return ''
114
+ }
115
+ }
116
+ return String(value)
117
+ }
118
+
119
+ // Parse a HA command payload (string) into the JS value to write on the KNX bus.
120
+ // Returns null when the payload can't be coerced to the DPT (the write is then skipped).
121
+ function parseCommandFromMqtt (text, dptRaw) {
122
+ const { main } = splitDpt(dptRaw)
123
+ const s = (text == null ? '' : String(text)).trim()
124
+ if (s === '') return null
125
+
126
+ if (main === 1) {
127
+ const low = s.toLowerCase()
128
+ if (['true', 'on', '1', 'open', 'up', 'yes'].includes(low)) return true
129
+ if (['false', 'off', '0', 'closed', 'close', 'down', 'no'].includes(low)) return false
130
+ return null
131
+ }
132
+
133
+ if ([5, 6, 7, 8, 9, 12, 13, 14].includes(main)) {
134
+ const n = Number(s)
135
+ return Number.isFinite(n) ? n : null
136
+ }
137
+
138
+ if (main === 16) return s
139
+
140
+ // Unknown/structured DPT: accept JSON objects, otherwise pass the raw string through.
141
+ if (s.startsWith('{') || s.startsWith('[')) {
142
+ try {
143
+ return JSON.parse(s)
144
+ } catch (_err) {
145
+ return s
146
+ }
147
+ }
148
+ return s
149
+ }
150
+
151
+ // Default KNX DPTs for the roles of composite (cover/climate) entities. Used to encode
152
+ // writes when the role GA is not present in the ETS CSV (so its DPT can't be resolved).
153
+ const COVER_DEFAULT_DPT = {
154
+ upDown: '1.008', // 0 = Up/Open, 1 = Down/Close
155
+ stop: '1.007', // step/stop
156
+ position: '5.001' // 0..100 %
157
+ }
158
+ const CLIMATE_DEFAULT_DPT = {
159
+ currentTemp: '9.001',
160
+ setpoint: '9.001',
161
+ onOff: '1.001'
162
+ }
163
+
164
+ module.exports = {
165
+ splitDpt,
166
+ mapDptToHa,
167
+ formatValueForMqtt,
168
+ parseCommandFromMqtt,
169
+ COVER_DEFAULT_DPT,
170
+ CLIMATE_DEFAULT_DPT
171
+ }