node-red-contrib-knx-ultimate 4.3.24 → 5.0.1

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,20 @@ 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
+ node.mqttUrl = typeof config.mqttUrl === 'string' ? config.mqttUrl.trim() : ''
47
+ node.mqttBaseTopic = typeof config.mqttBaseTopic === 'string' && config.mqttBaseTopic.trim() !== '' ? config.mqttBaseTopic.trim() : 'knx-ultimate'
48
+ node.mqttDiscovery = config.mqttDiscovery !== false && config.mqttDiscovery !== 'false'
49
+ node.mqttDiscoveryPrefix = typeof config.mqttDiscoveryPrefix === 'string' && config.mqttDiscoveryPrefix.trim() !== '' ? config.mqttDiscoveryPrefix.trim() : 'homeassistant'
50
+ node.mqttCustomEntities = Array.isArray(config.mqttCustomEntities) ? config.mqttCustomEntities : []
51
+ // Group addresses to expose as simple entities. Once the user curates the list
52
+ // (mqttExposeConfigured), only the listed GAs are exposed; otherwise all imported GAs are.
53
+ node.mqttExposeConfigured = config.mqttExposeConfigured === true
54
+ node.mqttExposedGAs = Array.isArray(config.mqttExposedGAs) ? config.mqttExposedGAs : []
55
+ node.mqttBridge = null
56
+
43
57
  const safeNumber = (value, fallback = 0) => {
44
58
  if (value === null || value === undefined || value === '') return fallback
45
59
  const parsed = Number(value)
@@ -143,6 +157,85 @@ module.exports = function (RED) {
143
157
  }
144
158
  }
145
159
 
160
+ // HOME ASSISTANT (MQTT) BRIDGE -----------------------------------------------------------
161
+ node.startMqttBridge = () => {
162
+ if (node.mqttBridge !== null) return // already running
163
+ if (!node.mqttUrl) {
164
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT broker URL missing' })
165
+ return
166
+ }
167
+ try {
168
+ // Lazy-require so the node still loads if the optional mqtt dependency is missing.
169
+ const { createMqttBridge } = require('./lib/mqtt-bridge.js')
170
+ node.mqttBridge = createMqttBridge({
171
+ node,
172
+ url: node.mqttUrl,
173
+ baseTopic: node.mqttBaseTopic,
174
+ discovery: node.mqttDiscovery,
175
+ discoveryPrefix: node.mqttDiscoveryPrefix,
176
+ username: node.credentials ? node.credentials.mqttUsername : undefined,
177
+ password: node.credentials ? node.credentials.mqttPassword : undefined,
178
+ groupAddresses: (node.serverKNX && Array.isArray(node.serverKNX.csv)) ? node.serverKNX.csv : [],
179
+ customEntities: node.mqttCustomEntities,
180
+ // null => expose all imported GAs (until the user curates the list).
181
+ exposedGAs: node.mqttExposeConfigured ? node.mqttExposedGAs : null,
182
+ onCommand: ({ ga, dpt, value }) => {
183
+ // A Home Assistant command arrived: write it to the KNX bus.
184
+ try {
185
+ node.serverKNX.sendKNXTelegramToKNXEngine({
186
+ grpaddr: ga,
187
+ payload: value,
188
+ dpt,
189
+ outputtype: 'write',
190
+ nodecallerid: node.id
191
+ })
192
+ } catch (error) {
193
+ if (node.sysLogger) node.sysLogger.error('HA bridge write failed (' + ga + '): ' + error.message)
194
+ }
195
+ },
196
+ onStatus: (status) => {
197
+ if (!status) return
198
+ if (status.state === 'connected') {
199
+ pushStatus({ fill: 'green', shape: 'dot', text: 'HA connected (' + (status.detail || '0') + ' entities)' })
200
+ } else if (status.state === 'error') {
201
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT ' + (status.detail || 'error') })
202
+ } else if (status.state === 'reconnect' || status.state === 'offline') {
203
+ pushStatus({ fill: 'yellow', shape: 'ring', text: 'MQTT ' + status.state })
204
+ }
205
+ }
206
+ })
207
+ node.mqttBridge.connect()
208
+ pushStatus({ fill: 'grey', shape: 'ring', text: 'HA mode: connecting (' + node.mqttBridge.entityCount + ' entities)' })
209
+ } catch (error) {
210
+ node.mqttBridge = null
211
+ if (node.sysLogger) node.sysLogger.error('startMqttBridge failed: ' + error.message)
212
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT bridge: ' + error.message })
213
+ }
214
+ }
215
+
216
+ node.stopMqttBridge = (done) => {
217
+ const bridge = node.mqttBridge
218
+ node.mqttBridge = null
219
+ let called = false
220
+ const cb = () => {
221
+ if (called) return
222
+ called = true
223
+ if (typeof done === 'function') {
224
+ try { done() } catch (error) { /* ignore */ }
225
+ }
226
+ }
227
+ if (!bridge) {
228
+ cb()
229
+ return
230
+ }
231
+ try {
232
+ bridge.close(cb)
233
+ } catch (error) {
234
+ if (node.sysLogger) node.sysLogger.error('stopMqttBridge error: ' + (error && error.message))
235
+ cb()
236
+ }
237
+ }
238
+
146
239
  const isBooleanDpt = (dpt) => typeof dpt === 'string' && dpt.startsWith('1.')
147
240
 
148
241
  const toBoolean = (value) => {
@@ -339,6 +432,15 @@ module.exports = function (RED) {
339
432
  const destination = msg.knx && msg.knx.destination ? msg.knx.destination : sanitizeString(msg.topic)
340
433
  if (!destination) return
341
434
 
435
+ // Home Assistant mode: mirror the decoded value to MQTT and stop (no IoT mappings).
436
+ if (node.nodeMode === 'homeassistant') {
437
+ const event = msg.knx ? msg.knx.event : undefined
438
+ if (node.mqttBridge && event !== 'GroupValue_Read') {
439
+ node.mqttBridge.publishState(destination, msg.payload)
440
+ }
441
+ return
442
+ }
443
+
342
444
  const meta = {
343
445
  event: msg.knx ? msg.knx.event : undefined,
344
446
  source: msg.knx ? msg.knx.source : undefined,
@@ -382,6 +484,11 @@ module.exports = function (RED) {
382
484
  node.handleSend = handleKnxTelegram
383
485
 
384
486
  node.on('input', (msg, send, done) => {
487
+ // In Home Assistant mode, commands flow in over MQTT, not via the flow input.
488
+ if (node.nodeMode === 'homeassistant') {
489
+ if (done) done()
490
+ return
491
+ }
385
492
  if (!node.acceptFlowInput) {
386
493
  if (done) done()
387
494
  return
@@ -446,14 +553,30 @@ module.exports = function (RED) {
446
553
  })
447
554
 
448
555
  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 */
556
+ // Always call done() exactly once, even if something throws, so a deploy / Node-RED exit
557
+ // is never blocked by the bridge teardown.
558
+ let finished = false
559
+ const finish = () => {
560
+ if (finished) return
561
+ finished = true
562
+ if (typeof done === 'function') {
563
+ try { done() } catch (error) { /* ignore */ }
454
564
  }
455
565
  }
456
- if (done) done()
566
+ try {
567
+ if (node.serverKNX && typeof node.serverKNX.removeClient === 'function') {
568
+ try {
569
+ node.serverKNX.removeClient(node)
570
+ } catch (error) {
571
+ /* empty */
572
+ }
573
+ }
574
+ // Stop the MQTT bridge (best-effort, hard-capped so redeploy never blocks on the broker).
575
+ node.stopMqttBridge(finish)
576
+ } catch (error) {
577
+ if (node.sysLogger) node.sysLogger.error('close handler error: ' + (error && error.message))
578
+ finish()
579
+ }
457
580
  })
458
581
 
459
582
  const registerClient = () => {
@@ -492,9 +615,18 @@ module.exports = function (RED) {
492
615
  }
493
616
 
494
617
  registerClient()
495
- updateIdleStatus()
496
- issueInitialReads()
618
+ if (node.nodeMode === 'homeassistant') {
619
+ node.startMqttBridge()
620
+ } else {
621
+ updateIdleStatus()
622
+ issueInitialReads()
623
+ }
497
624
  }
498
625
 
499
- RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge)
626
+ RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge, {
627
+ credentials: {
628
+ mqttUsername: { type: 'text' },
629
+ mqttPassword: { type: 'password' }
630
+ }
631
+ })
500
632
  }
@@ -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
+ }