@flowfuse/nr-mqtt-nodes 0.1.0

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.
@@ -0,0 +1,1826 @@
1
+ /*
2
+ This is a fork of the MQTT nodes from Node-RED.
3
+ The original code can be found at:
4
+ https://github.com/node-red/node-red/blob/abceb1185bc81e869c11918bed3fb17e0bc83dd5/packages/node_modules/@node-red/nodes/core/network/10-mqtt.js
5
+ Below is the copyright notice for the original code.
6
+ The copyright notice for this fork is the same as the original.
7
+ ### Changes:
8
+ - Hide advanced features
9
+ - Remove the config node for MQTT broker
10
+ - Remove the dynamic connection control
11
+ - lint errors fixed up
12
+ */
13
+ /* eslint-disable brace-style */
14
+ const ProxyHelper = require('./lib/proxyHelper.js')
15
+ const { createNrMqttId, createNrMqttClientId } = require('./lib/util.js')
16
+ const TeamBrokerApi = require('./lib/TeamBrokerApi.js')
17
+ const os = require('os')
18
+ const Got = require('got').default
19
+
20
+ module.exports = function (RED) {
21
+ 'use strict'
22
+ const forgeSettings = RED.settings.flowforge || {}
23
+ const mqttSettings = forgeSettings.mqttNodes || {}
24
+ const featureEnabled = !!mqttSettings.featureEnabled
25
+ const teamId = forgeSettings.teamID
26
+ const deviceId = forgeSettings.deviceId || ''
27
+ const projectId = forgeSettings.instanceID || forgeSettings.projectID || ''
28
+ // ensure settings are present
29
+ if (!teamId || (!deviceId && !projectId)) {
30
+ throw new Error('FlowFuse MQTT nodes cannot be loaded outside of an FlowFuse EE environment')
31
+ }
32
+ const HA_INSTANCE = !!deviceId && mqttSettings?.ha >= 2
33
+ // It is not unreasonable to expect `projectID` and `applicationID` are set for an instance
34
+ // owned device, however an application owned device should not have a projectID.
35
+ // therefore, assume project owned if `projectID` is set
36
+ // eslint-disable-next-line no-unused-vars
37
+ const DEVICE_OWNER_TYPE = deviceId ? (forgeSettings.projectID ? 'instance' : 'application') : null
38
+ // Generate a unique ID based on the hostname
39
+ // This is then used when in HA/sharedSubscription mode to ensure the instance has
40
+ // a unique clientId that is stable across restarts
41
+ const haInstanceId = HA_INSTANCE ? crypto.createHash('md5').update(os.hostname()).digest('hex').substring(0, 4) : null
42
+
43
+ let instanceType = ''
44
+ let instanceId = ''
45
+ if (deviceId) {
46
+ instanceType = 'device'
47
+ instanceId = deviceId
48
+ } else if (projectId) {
49
+ instanceType = 'instance'
50
+ instanceId = projectId
51
+ } else {
52
+ // throw error?
53
+ throw new Error('FlowFuse MQTT nodes cannot be loaded due to missing instance information')
54
+ }
55
+
56
+ const TEAM_CLIENT_AUTH_ID = createNrMqttId(forgeSettings.teamID, instanceType, instanceId) // e.g., 'mq:remote:teamId:deviceId' or 'mq:hosted:teamId:projectId:haId'
57
+ const TEAM_CLIENT_CLIENT_ID = createNrMqttClientId(forgeSettings.teamID, instanceType, instanceId, haInstanceId) // e.g., 'mq:remote:deviceId' or 'mq:hosted:projectId:haId'
58
+ const ALLOW_DYNAMIC_CONNECT_OPTIONS = false // disable dynamic connect options for this type of node (fixed broker connection)
59
+ const got = Got.extend({
60
+ agent: ProxyHelper.getHTTPProxyAgent(forgeSettings.forgeURL, { timeout: 4000 })
61
+ })
62
+
63
+ /** @type {MQTTBrokerNode} */
64
+ const sharedBroker = new MQTTBrokerNode(mqttSettings)
65
+
66
+ /* Monitor link status and attept to relink if node has users but is unlinked */
67
+ let linkTryCount = 0
68
+ const MAX_LINK_ATTEMPTS = 5
69
+ sharedBroker.linkMonitorInterval = setInterval(async function () {
70
+ if (Object.keys(sharedBroker.users).length < 1) {
71
+ return // no users registered (yet)
72
+ }
73
+ if (sharedBroker.linked && !sharedBroker.linkFailed) {
74
+ clearInterval(sharedBroker.linkMonitorInterval)
75
+ sharedBroker.linkMonitorInterval = null
76
+ }
77
+ if (sharedBroker.linkFailed) {
78
+ try {
79
+ linkTryCount++
80
+ await sharedBroker.link()
81
+ linkTryCount = 0
82
+ } catch (_err) {
83
+ if (linkTryCount >= MAX_LINK_ATTEMPTS) {
84
+ clearInterval(sharedBroker.linkMonitorInterval)
85
+ sharedBroker.linkMonitorInterval = null
86
+ sharedBroker.warn('Maximum Failed Link Attempts. Restart or redeploy to re-establish the connection.')
87
+ }
88
+ }
89
+ }
90
+ }, (Math.floor(Math.random() * 10000) + 55000)) // 55-65 seconds
91
+
92
+ const mqtt = require('mqtt')
93
+ const isUtf8 = require('is-utf8')
94
+
95
+ const knownMediaTypes = {
96
+ 'text/css': 'string',
97
+ 'text/html': 'string',
98
+ 'text/plain': 'string',
99
+ 'application/json': 'json',
100
+ 'application/octet-stream': 'buffer',
101
+ 'application/pdf': 'buffer',
102
+ 'application/x-gtar': 'buffer',
103
+ 'application/x-gzip': 'buffer',
104
+ 'application/x-tar': 'buffer',
105
+ 'application/xml': 'string',
106
+ 'application/zip': 'buffer',
107
+ 'audio/aac': 'buffer',
108
+ 'audio/ac3': 'buffer',
109
+ 'audio/basic': 'buffer',
110
+ 'audio/mp4': 'buffer',
111
+ 'audio/ogg': 'buffer',
112
+ 'image/bmp': 'buffer',
113
+ 'image/gif': 'buffer',
114
+ 'image/jpeg': 'buffer',
115
+ 'image/tiff': 'buffer',
116
+ 'image/png': 'buffer'
117
+ }
118
+ // #region "Supporting functions"
119
+ function matchTopic (ts, t) {
120
+ if (ts === '#') {
121
+ return true
122
+ } else if (ts.startsWith('$share')) {
123
+ /* The following allows shared subscriptions (as in MQTT v5)
124
+ http://docs.oasis-open.org/mqtt/mqtt/v5.0/cs02/mqtt-v5.0-cs02.html#_Toc514345522
125
+
126
+ 4.8.2 describes shares like:
127
+ $share/{ShareName}/{filter}
128
+ $share is a literal string that marks the Topic Filter as being a Shared Subscription Topic Filter.
129
+ {ShareName} is a character string that does not include "/", "+" or "#"
130
+ {filter} The remainder of the string has the same syntax and semantics as a Topic Filter in a non-shared subscription. Refer to section 4.7.
131
+ */
132
+ ts = ts.replace(/^\$share\/[^#+/]+\/(.*)/g, '$1')
133
+ }
134
+ // eslint-disable-next-line no-useless-escape
135
+ const re = new RegExp('^' + ts.replace(/([\[\]\?\(\)\\\\$\^\*\.|])/g, '\\$1').replace(/\+/g, '[^/]+').replace(/\/#$/, '(\/.*)?') + '$')
136
+ return re.test(t)
137
+ }
138
+
139
+ /**
140
+ * Helper function for setting integer property values in the MQTT V5 properties object
141
+ * @param {object} src Source object containing properties
142
+ * @param {object} dst Destination object to set/add properties
143
+ * @param {string} propName The property name to set in the Destination object
144
+ * @param {integer} [minVal] The minimum value. If the src value is less than minVal, it will NOT be set in the destination
145
+ * @param {integer} [maxVal] The maximum value. If the src value is greater than maxVal, it will NOT be set in the destination
146
+ * @param {integer} [def] An optional default to set in the destination object if prop is NOT present in the soruce object
147
+ */
148
+ function setIntProp (src, dst, propName, minVal, maxVal, def) {
149
+ if (hasProperty(src, propName)) {
150
+ const v = parseInt(src[propName])
151
+ if (isNaN(v)) return
152
+ if (minVal != null) {
153
+ if (v < minVal) return
154
+ }
155
+ if (maxVal != null) {
156
+ if (v > maxVal) return
157
+ }
158
+ dst[propName] = v
159
+ } else {
160
+ if (typeof def !== 'undefined') dst[propName] = def
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Test a topic string is valid for subscription
166
+ * @param {string} topic
167
+ * @returns `true` if it is a valid topic
168
+ */
169
+ function isValidSubscriptionTopic (topic) {
170
+ return /^(#$|(\+|[^+#]*)(\/(\+|[^+#]*))*(\/(\+|#|[^+#]*))?$)/.test(topic)
171
+ }
172
+
173
+ /**
174
+ * Test a topic string is valid for publishing
175
+ * @param {string} topic
176
+ * @returns `true` if it is a valid topic
177
+ */
178
+ function isValidPublishTopic (topic) {
179
+ if (topic.length === 0) return false
180
+ // eslint-disable-next-line no-useless-escape
181
+ return !/[\+#\b\f\n\r\t\v\0]/.test(topic)
182
+ }
183
+
184
+ /**
185
+ * Helper function for setting string property values in the MQTT V5 properties object
186
+ * @param {object} src Source object containing properties
187
+ * @param {object} dst Destination object to set/add properties
188
+ * @param {string} propName The property name to set in the Destination object
189
+ * @param {string} [def] An optional default to set in the destination object if prop is NOT present in the soruce object
190
+ */
191
+ function setStrProp (src, dst, propName, def) {
192
+ if (src[propName] && typeof src[propName] === 'string') {
193
+ dst[propName] = src[propName]
194
+ } else {
195
+ if (typeof def !== 'undefined') dst[propName] = def
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Helper function for setting boolean property values in the MQTT V5 properties object
201
+ * @param {object} src Source object containing properties
202
+ * @param {object} dst Destination object to set/add properties
203
+ * @param {string} propName The property name to set in the Destination object
204
+ * @param {boolean} [def] An optional default to set in the destination object if prop is NOT present in the soruce object
205
+ */
206
+ function setBoolProp (src, dst, propName, def) {
207
+ if (src[propName] != null) {
208
+ if (src[propName] === 'true' || src[propName] === true) {
209
+ dst[propName] = true
210
+ } else if (src[propName] === 'false' || src[propName] === false) {
211
+ dst[propName] = false
212
+ }
213
+ } else {
214
+ if (typeof def !== 'undefined') dst[propName] = def
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Helper function for copying the MQTT v5 srcUserProperties object (parameter1) to the properties object (parameter2).
220
+ * Any property in srcUserProperties that is NOT a key/string pair will be silently discarded.
221
+ * NOTE: if no sutable properties are present, the userProperties object will NOT be added to the properties object
222
+ * @param {object} srcUserProperties An object with key/value string pairs
223
+ * @param {object} properties A properties object in which userProperties will be copied to
224
+ */
225
+ function setUserProperties (srcUserProperties, properties) {
226
+ if (srcUserProperties && typeof srcUserProperties === 'object') {
227
+ const _clone = {}
228
+ let count = 0
229
+ const keys = Object.keys(srcUserProperties)
230
+ if (!keys || !keys.length) return null
231
+ keys.forEach(key => {
232
+ const val = srcUserProperties[key]
233
+ if (typeof val === 'string') {
234
+ count++
235
+ _clone[key] = val
236
+ } else if (val !== undefined && val !== null) {
237
+ try {
238
+ _clone[key] = JSON.stringify(val)
239
+ count++
240
+ } catch (err) {
241
+ // Silently drop property
242
+ }
243
+ }
244
+ })
245
+ if (count) properties.userProperties = _clone
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Helper function for copying the MQTT v5 buffer type properties
251
+ * NOTE: if src[propName] is not a buffer, dst[propName] will NOT be assigned a value (unless def is set)
252
+ * @param {object} src Source object containing properties
253
+ * @param {object} dst Destination object to set/add properties
254
+ * @param {string} propName The property name to set in the Destination object
255
+ * @param {boolean} [def] An optional default to set in the destination object if prop is NOT present in the Source object
256
+ */
257
+ function setBufferProp (src, dst, propName, def) {
258
+ if (!dst) return
259
+ if (src && dst) {
260
+ const buf = src[propName]
261
+ if (buf && typeof Buffer.isBuffer(buf)) {
262
+ dst[propName] = Buffer.from(buf)
263
+ }
264
+ } else {
265
+ if (typeof def !== 'undefined') dst[propName] = def
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Helper function for applying changes to an objects properties ONLY when the src object actually has the property.
271
+ * This avoids setting a `dst` property null/undefined when the `src` object doesnt have the named property.
272
+ * @param {object} src Source object containing properties
273
+ * @param {object} dst Destination object to set property
274
+ * @param {string} propName The property name to set in the Destination object
275
+ * @param {boolean} force force the dst property to be updated/created even if src property is empty
276
+ */
277
+ function setIfHasProperty (src, dst, propName, force) {
278
+ if (src && dst && propName) {
279
+ const ok = force || hasProperty(src, propName)
280
+ if (ok) {
281
+ dst[propName] = src[propName]
282
+ }
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Helper function to test an object has a property
288
+ * @param {object} obj Object to test
289
+ * @param {string} propName Name of property to find
290
+ * @returns true if object has property `propName`
291
+ */
292
+ function hasProperty (obj, propName) {
293
+ // JavaScript does not protect the property name hasOwnProperty
294
+ // Object.prototype.hasOwnProperty.call is the recommended/safer test
295
+ return Object.prototype.hasOwnProperty.call(obj, propName)
296
+ }
297
+
298
+ /**
299
+ * Handle the payload / packet recieved in MQTT In and MQTT Sub nodes
300
+ */
301
+ function subscriptionHandler (node, datatype, topic, payload, packet) {
302
+ const msg = { topic, payload: null, qos: packet.qos, retain: packet.retain }
303
+ const v5 = (node && node.brokerConn)
304
+ ? node.brokerConn.v5()
305
+ : Object.prototype.hasOwnProperty.call(packet, 'properties')
306
+ if (v5 && packet.properties) {
307
+ setStrProp(packet.properties, msg, 'responseTopic')
308
+ setBufferProp(packet.properties, msg, 'correlationData')
309
+ setStrProp(packet.properties, msg, 'contentType')
310
+ setIntProp(packet.properties, msg, 'messageExpiryInterval', 0)
311
+ setBoolProp(packet.properties, msg, 'payloadFormatIndicator')
312
+ setStrProp(packet.properties, msg, 'reasonString')
313
+ setUserProperties(packet.properties.userProperties, msg)
314
+ }
315
+ const v5isUtf8 = v5 ? msg.payloadFormatIndicator === true : null
316
+ const v5HasMediaType = v5 ? !!msg.contentType : null
317
+ const v5MediaTypeLC = v5 ? (msg.contentType + '').toLowerCase() : null
318
+
319
+ if (datatype === 'buffer') {
320
+ // payload = payload;
321
+ } else if (datatype === 'base64') {
322
+ payload = payload.toString('base64')
323
+ } else if (datatype === 'utf8') {
324
+ payload = payload.toString('utf8')
325
+ } else if (datatype === 'json') {
326
+ if (v5isUtf8 || isUtf8(payload)) {
327
+ try {
328
+ payload = JSON.parse(payload.toString())
329
+ } catch (e) {
330
+ node.error(RED._('ff-mqtt.errors.invalid-json-parse'), { payload, topic, qos: packet.qos, retain: packet.retain }); return
331
+ }
332
+ } else {
333
+ node.error((RED._('ff-mqtt.errors.invalid-json-string')), { payload, topic, qos: packet.qos, retain: packet.retain }); return
334
+ }
335
+ } else {
336
+ // "auto" (legacy) or "auto-detect" (new default)
337
+ if (v5isUtf8 || v5HasMediaType) {
338
+ const outputType = knownMediaTypes[v5MediaTypeLC]
339
+ switch (outputType) {
340
+ case 'string':
341
+ payload = payload.toString()
342
+ break
343
+ case 'buffer':
344
+ // no change
345
+ break
346
+ case 'json':
347
+ try {
348
+ // since v5 type states this should be JSON, parse it & error out if NOT JSON
349
+ payload = payload.toString()
350
+ const obj = JSON.parse(payload)
351
+ if (datatype === 'auto-detect') {
352
+ payload = obj // as mode is "auto-detect", return the parsed JSON
353
+ }
354
+ } catch (e) {
355
+ node.error(RED._('ff-mqtt.errors.invalid-json-parse'), { payload, topic, qos: packet.qos, retain: packet.retain }); return
356
+ }
357
+ break
358
+ default:
359
+ if (v5isUtf8 || isUtf8(payload)) {
360
+ payload = payload.toString() // auto String
361
+ if (datatype === 'auto-detect') {
362
+ try {
363
+ payload = JSON.parse(payload) // auto to parsed object (attempt)
364
+ } catch (e) {
365
+ /* mute error - it simply isnt JSON, just leave payload as a string */
366
+ }
367
+ }
368
+ }
369
+ break
370
+ }
371
+ } else if (isUtf8(payload)) {
372
+ payload = payload.toString() // auto String
373
+ if (datatype === 'auto-detect') {
374
+ try {
375
+ payload = JSON.parse(payload)
376
+ } catch (e) {
377
+ /* mute error - it simply isnt JSON, just leave payload as a string */
378
+ }
379
+ }
380
+ } // else {
381
+ // leave as buffer
382
+ // }
383
+ }
384
+ msg.payload = payload
385
+ if (node.brokerConn && (node.brokerConn.broker === 'localhost' || node.brokerConn.broker === '127.0.0.1')) {
386
+ msg._topic = topic
387
+ }
388
+ node.send(msg)
389
+ }
390
+
391
+ /**
392
+ * Send an mqtt message to broker
393
+ * @param {MQTTOutNode} node the owner node
394
+ * @param {object} msg The msg to prepare for publishing
395
+ * @param {function} done callback when done
396
+ */
397
+ function doPublish (node, msg, done) {
398
+ try {
399
+ done = typeof done === 'function' ? done : function noop () {}
400
+ const v5 = node.brokerConn.options && +node.brokerConn.options.protocolVersion === 5
401
+
402
+ // Sanitise the `msg` object properties ready for publishing
403
+ if (msg.qos) {
404
+ msg.qos = parseInt(msg.qos)
405
+ if ((msg.qos !== 0) && (msg.qos !== 1) && (msg.qos !== 2)) {
406
+ msg.qos = null
407
+ }
408
+ }
409
+
410
+ /* If node properties exists, override/set that to property in msg */
411
+ if (node.topic) { msg.topic = node.topic }
412
+ msg.qos = Number(node.qos || msg.qos || 0)
413
+ msg.retain = node.retain || msg.retain || false
414
+ msg.retain = ((msg.retain === true) || (msg.retain === 'true')) || false
415
+
416
+ if (v5) {
417
+ if (node.userProperties) {
418
+ msg.userProperties = node.userProperties
419
+ }
420
+ if (node.responseTopic) {
421
+ msg.responseTopic = node.responseTopic
422
+ }
423
+ if (node.correlationData) {
424
+ msg.correlationData = node.correlationData
425
+ }
426
+ if (node.contentType) {
427
+ msg.contentType = node.contentType
428
+ }
429
+ if (node.messageExpiryInterval) {
430
+ msg.messageExpiryInterval = node.messageExpiryInterval
431
+ }
432
+ }
433
+ if (hasProperty(msg, 'payload')) {
434
+ // send the message
435
+ node.brokerConn.publish(msg, function (err) {
436
+ if (err && err.warn) {
437
+ node.warn(err)
438
+ return
439
+ }
440
+ done(err)
441
+ })
442
+ } else {
443
+ done()
444
+ }
445
+ } catch (error) {
446
+ done(error)
447
+ }
448
+ }
449
+
450
+ function updateStatus (node, allNodes) {
451
+ let setStatus = setStatusDisconnected
452
+ if (node.connecting) {
453
+ setStatus = setStatusConnecting
454
+ } else if (node.connected) {
455
+ setStatus = setStatusConnected
456
+ }
457
+ setStatus(node, allNodes)
458
+ }
459
+
460
+ function setStatusFeatureDisabled (node, allNodes) {
461
+ if (allNodes) {
462
+ for (const id in node.users) {
463
+ if (hasProperty(node.users, id)) {
464
+ node.users[id].status({ fill: 'red', shape: 'ring', text: 'common.status.featureDisabled' })
465
+ }
466
+ }
467
+ } else {
468
+ node.status({ fill: 'red', shape: 'ring', text: 'common.status.featureDisabled' })
469
+ }
470
+ }
471
+
472
+ function setStatusDisconnected (node, allNodes) {
473
+ if (allNodes) {
474
+ for (const id in node.users) {
475
+ if (hasProperty(node.users, id)) {
476
+ node.users[id].status({ fill: 'red', shape: 'ring', text: 'common.status.disconnected' })
477
+ }
478
+ }
479
+ } else {
480
+ node.status({ fill: 'red', shape: 'ring', text: 'common.status.disconnected' })
481
+ }
482
+ }
483
+
484
+ function setStatusConnecting (node, allNodes) {
485
+ if (allNodes) {
486
+ for (const id in node.users) {
487
+ if (hasProperty(node.users, id)) {
488
+ node.users[id].status({ fill: 'yellow', shape: 'ring', text: 'common.status.connecting' })
489
+ }
490
+ }
491
+ } else {
492
+ node.status({ fill: 'yellow', shape: 'ring', text: 'common.status.connecting' })
493
+ }
494
+ }
495
+
496
+ function setStatusConnected (node, allNodes) {
497
+ if (allNodes) {
498
+ for (const id in node.users) {
499
+ if (hasProperty(node.users, id)) {
500
+ node.users[id].status({ fill: 'green', shape: 'dot', text: 'common.status.connected' })
501
+ }
502
+ }
503
+ } else {
504
+ node.status({ fill: 'green', shape: 'dot', text: 'common.status.connected' })
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Perform the connect action
510
+ * @param {MQTTInNode|MQTTOutNode} node
511
+ * @param {Object} msg
512
+ * @param {Function} done
513
+ */
514
+ function handleConnectAction (node, msg, done) {
515
+ const actionData = ALLOW_DYNAMIC_CONNECT_OPTIONS && (typeof msg.broker === 'object' ? msg.broker : null)
516
+ if (!featureEnabled || !mqttSettings.broker) {
517
+ node.error('Team Broker is not enabled in this FlowFuse EE environment', msg)
518
+ return
519
+ }
520
+ if (node.brokerConn.canConnect()) {
521
+ // Not currently connected/connecting - trigger the connect
522
+ if (actionData) {
523
+ node.brokerConn.setOptions(actionData)
524
+ }
525
+ node.brokerConn.connect(function () {
526
+ done()
527
+ })
528
+ } else {
529
+ // Already Connected/Connecting
530
+ if (!actionData) {
531
+ // All is good - already connected and no broker override provided
532
+ done()
533
+ } else if (actionData.force) {
534
+ // The force flag tells us to cycle the connection.
535
+ node.brokerConn.disconnect(function () {
536
+ node.brokerConn.setOptions(actionData)
537
+ node.brokerConn.connect(function () {
538
+ done()
539
+ })
540
+ })
541
+ } else {
542
+ // Without force flag, we will refuse to cycle an active connection
543
+ done(new Error(RED._('ff-mqtt.errors.invalid-action-alreadyconnected')))
544
+ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Perform the disconnect action
550
+ * @param {MQTTInNode|MQTTOutNode} node
551
+ * @param {Function} done
552
+ */
553
+ function handleDisconnectAction (node, done) {
554
+ node.brokerConn.disconnect(function () {
555
+ done()
556
+ })
557
+ }
558
+ const unsubscribeCandidates = {}
559
+ // #endregion "Supporting functions"
560
+
561
+ // #region "Broker node"
562
+ function MQTTBrokerNode (n) {
563
+ /** @type {MQTTBrokerNode} */
564
+ const node = this
565
+ node._initialised = false
566
+ node._initialising = false
567
+
568
+ Object.defineProperty(node, 'initialised', {
569
+ get: function () {
570
+ return node._initialised
571
+ }
572
+ })
573
+ Object.defineProperty(node, 'initialising', {
574
+ get: function () {
575
+ return node._initialising
576
+ }
577
+ })
578
+ Object.defineProperty(node, 'linked', {
579
+ get: function () {
580
+ return node._linked
581
+ }
582
+ })
583
+ Object.defineProperty(node, 'linkFailed', {
584
+ get: function () {
585
+ return node._linkFailed
586
+ }
587
+ })
588
+
589
+ node.users = {}
590
+ // Config node state
591
+ node.brokerurl = ''
592
+ node.connected = false
593
+ node.connecting = false
594
+ node.closing = false
595
+ node.options = {}
596
+ node.queue = []
597
+ node.subscriptions = {}
598
+ node.clientListeners = []
599
+ /** @type {mqtt.MqttClient} */
600
+ node.client = null
601
+ node.linkPromise = null
602
+ node._linked = mqttSettings.linked || false
603
+ node._linkFailed = false
604
+
605
+ node.link = async function () {
606
+ if (node.linkPromise) {
607
+ return node.linkPromise // already linking, return the existing promise
608
+ }
609
+ if (!featureEnabled || !mqttSettings.broker) {
610
+ return false
611
+ }
612
+ const teamBrokerApi = TeamBrokerApi.TeamBrokerApi(got, {
613
+ forgeURL: forgeSettings.forgeURL,
614
+ teamId,
615
+ instanceType,
616
+ instanceId,
617
+ token: mqttSettings.token || ''
618
+ })
619
+ try {
620
+ node.linkPromise = teamBrokerApi.link(mqttSettings.broker.password)
621
+ await node.linkPromise
622
+ node.linkPromise = null // reset the link promise
623
+ node._linkFailed = false
624
+ node._linked = true
625
+ if (node._initialised === false) {
626
+ node.initialise()
627
+ node.connect()
628
+ }
629
+ return node._linked
630
+ } catch (err) {
631
+ const code = err.code || err.statusCode || err.status || 'unknown'
632
+ const name = err.name || 'UnknownError'
633
+ const error = new Error(`Failed to link to FlowFuse broker: ${name} (${code})`, { cause: err })
634
+ node._linkFailed = true
635
+ node._linked = false
636
+ throw error
637
+ } finally {
638
+ node.linkPromise = null // reset the link promise
639
+ }
640
+ }
641
+
642
+ node.initialise = function () {
643
+ try {
644
+ if (node._initialising) {
645
+ return // already initialising, simply return
646
+ }
647
+ node._initialising = true
648
+
649
+ if (!featureEnabled || !mqttSettings.broker) {
650
+ throw new Error('Team Broker is not enabled in this FlowFuse EE environment')
651
+ }
652
+
653
+ const settings = {
654
+ broker: mqttSettings.broker.url || ''
655
+ }
656
+ node.credentials = {
657
+ user: TEAM_CLIENT_AUTH_ID,
658
+ password: mqttSettings.broker.password || ''
659
+ }
660
+ settings.username = TEAM_CLIENT_AUTH_ID
661
+ settings.password = mqttSettings.broker.password || ''
662
+ settings.clientid = TEAM_CLIENT_CLIENT_ID
663
+ settings.autoConnect = mqttSettings.autoConnect !== false
664
+ settings.usetls = mqttSettings.usetls || false
665
+ settings.compatmode = mqttSettings.compatmode || false
666
+ settings.protocolVersion = mqttSettings.protocolVersion || 5
667
+ settings.keepalive = mqttSettings.keepalive || 60
668
+ settings.cleansession = mqttSettings.cleansession !== false // default to true
669
+ settings.topicAliasMaximum = mqttSettings.topicAliasMaximum || 0
670
+ node.setOptions(settings, true) // initial options
671
+ node._initialised = true
672
+ } catch (error) {
673
+ node._initialised = false
674
+ node.error(error)
675
+ throw error // re-throw the error to stop initialisation
676
+ } finally {
677
+ node._initialising = false
678
+ }
679
+ }
680
+
681
+ node.setOptions = function (opts, init) {
682
+ if (!opts || typeof opts !== 'object') {
683
+ return // nothing to change, simply return
684
+ }
685
+ // apply property changes (only if the property exists in the opts object)
686
+ setIfHasProperty(opts, node, 'url', init)
687
+ setIfHasProperty(opts, node, 'broker', init)
688
+ setIfHasProperty(opts, node, 'port', init)
689
+ setIfHasProperty(opts, node, 'clientid', init)
690
+ setIfHasProperty(opts, node, 'autoConnect', init)
691
+ setIfHasProperty(opts, node, 'usetls', init)
692
+ setIfHasProperty(opts, node, 'verifyservercert', init)
693
+ setIfHasProperty(opts, node, 'compatmode', init)
694
+ setIfHasProperty(opts, node, 'protocolVersion', init)
695
+ setIfHasProperty(opts, node, 'keepalive', init)
696
+ setIfHasProperty(opts, node, 'cleansession', init)
697
+ setIfHasProperty(opts, node, 'autoUnsubscribe', init)
698
+ setIfHasProperty(opts, node, 'topicAliasMaximum', init)
699
+ setIfHasProperty(opts, node, 'maximumPacketSize', init)
700
+ setIfHasProperty(opts, node, 'receiveMaximum', init)
701
+ // https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901116
702
+ if (hasProperty(opts, 'userProperties')) {
703
+ node.userProperties = opts.userProperties
704
+ } else if (hasProperty(opts, 'userProps')) {
705
+ node.userProperties = opts.userProps
706
+ }
707
+ if (hasProperty(opts, 'sessionExpiry')) {
708
+ node.sessionExpiryInterval = opts.sessionExpiry
709
+ } else if (hasProperty(opts, 'sessionExpiryInterval')) {
710
+ node.sessionExpiryInterval = opts.sessionExpiryInterval
711
+ }
712
+
713
+ function createLWT (topic, payload, qos, retain, v5opts, v5SubPropName) {
714
+ let message
715
+ if (topic) {
716
+ message = {
717
+ topic,
718
+ payload: payload || '',
719
+ qos: Number(qos || 0),
720
+ retain: retain === 'true' || retain === true
721
+ }
722
+ if (v5opts) {
723
+ let v5Properties = message
724
+ if (v5SubPropName) {
725
+ v5Properties = message[v5SubPropName] = {}
726
+ }
727
+ // re-align local prop name to mqttjs std
728
+ if (hasProperty(v5opts, 'respTopic')) { v5opts.responseTopic = v5opts.respTopic }
729
+ if (hasProperty(v5opts, 'correl')) { v5opts.correlationData = v5opts.correl }
730
+ if (hasProperty(v5opts, 'expiry')) { v5opts.messageExpiryInterval = v5opts.expiry }
731
+ if (hasProperty(v5opts, 'delay')) { v5opts.willDelayInterval = v5opts.delay }
732
+ if (hasProperty(v5opts, 'userProps')) { v5opts.userProperties = v5opts.userProps }
733
+ // setup v5 properties
734
+ if (typeof v5opts.userProperties === 'string' && /^ *{/.test(v5opts.userProperties)) {
735
+ try {
736
+ setUserProperties(JSON.parse(v5opts.userProps), v5Properties)
737
+ } catch (err) {}
738
+ } else if (typeof v5opts.userProperties === 'object') {
739
+ setUserProperties(v5opts.userProperties, v5Properties)
740
+ }
741
+ setStrProp(v5opts, v5Properties, 'contentType')
742
+ setStrProp(v5opts, v5Properties, 'responseTopic')
743
+ setBufferProp(v5opts, v5Properties, 'correlationData')
744
+ setIntProp(v5opts, v5Properties, 'messageExpiryInterval')
745
+ setIntProp(v5opts, v5Properties, 'willDelayInterval')
746
+ }
747
+ }
748
+ return message
749
+ }
750
+
751
+ if (init) {
752
+ if (hasProperty(opts, 'birthTopic')) {
753
+ node.birthMessage = createLWT(opts.birthTopic, opts.birthPayload, opts.birthQos, opts.birthRetain, opts.birthMsg, '')
754
+ }
755
+ if (hasProperty(opts, 'closeTopic')) {
756
+ node.closeMessage = createLWT(opts.closeTopic, opts.closePayload, opts.closeQos, opts.closeRetain, opts.closeMsg, '')
757
+ }
758
+ if (hasProperty(opts, 'willTopic')) {
759
+ // will v5 properties must be set in the "properties" sub object
760
+ node.options.will = createLWT(opts.willTopic, opts.willPayload, opts.willQos, opts.willRetain, opts.willMsg, 'properties')
761
+ }
762
+ } else {
763
+ // update options
764
+ if (hasProperty(opts, 'birth')) {
765
+ if (typeof opts.birth !== 'object') { opts.birth = {} }
766
+ node.birthMessage = createLWT(opts.birth.topic, opts.birth.payload, opts.birth.qos, opts.birth.retain, opts.birth.properties, '')
767
+ }
768
+ if (hasProperty(opts, 'close')) {
769
+ if (typeof opts.close !== 'object') { opts.close = {} }
770
+ node.closeMessage = createLWT(opts.close.topic, opts.close.payload, opts.close.qos, opts.close.retain, opts.close.properties, '')
771
+ }
772
+ if (hasProperty(opts, 'will')) {
773
+ if (typeof opts.will !== 'object') { opts.will = {} }
774
+ // will v5 properties must be set in the "properties" sub object
775
+ node.options.will = createLWT(opts.will.topic, opts.will.payload, opts.will.qos, opts.will.retain, opts.will.properties, 'properties')
776
+ }
777
+ }
778
+
779
+ if (node.credentials) {
780
+ node.username = node.credentials.user
781
+ node.password = node.credentials.password
782
+ }
783
+ if (!init & hasProperty(opts, 'username')) {
784
+ node.username = opts.username
785
+ }
786
+ if (!init & hasProperty(opts, 'password')) {
787
+ node.password = opts.password
788
+ }
789
+
790
+ // If the config node is missing certain options (it was probably deployed prior to an update to the node code),
791
+ // select/generate sensible options for the new fields
792
+ if (typeof node.usetls === 'undefined') {
793
+ node.usetls = false
794
+ }
795
+ if (typeof node.verifyservercert === 'undefined') {
796
+ node.verifyservercert = false
797
+ }
798
+ if (typeof node.keepalive === 'undefined') {
799
+ node.keepalive = 60
800
+ } else if (typeof node.keepalive === 'string') {
801
+ node.keepalive = Number(node.keepalive)
802
+ }
803
+ if (typeof node.cleansession === 'undefined') {
804
+ node.cleansession = true
805
+ }
806
+ if (typeof node.autoUnsubscribe !== 'boolean') {
807
+ node.autoUnsubscribe = true
808
+ }
809
+ // use url or build a url from usetls://broker:port
810
+ if (node.url && node.brokerurl !== node.url) {
811
+ node.brokerurl = node.url
812
+ } else {
813
+ // if the broker is ws:// or wss:// or tcp://
814
+ if ((typeof node.broker === 'string') && node.broker.indexOf('://') > -1) {
815
+ node.brokerurl = node.broker
816
+ // Only for ws or wss, check if proxy env var for additional configuration
817
+ if (node.brokerurl.indexOf('wss://') > -1 || node.brokerurl.indexOf('ws://') > -1) {
818
+ // check if proxy is set in env
819
+ const agent = ProxyHelper.getWSProxyAgent(node.brokerurl)
820
+ if (agent) {
821
+ node.options.wsOptions = node.options.wsOptions || {}
822
+ node.options.wsOptions.agent = agent
823
+ }
824
+ }
825
+ } else {
826
+ // construct the std mqtt:// url
827
+ if (node.usetls) {
828
+ node.brokerurl = 'mqtts://'
829
+ } else {
830
+ node.brokerurl = 'mqtt://'
831
+ }
832
+ if (node.broker !== '') {
833
+ // Check for an IPv6 address
834
+ if (/(?:^|(?<=\s))(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(?=\s|$)/.test(node.broker)) {
835
+ node.brokerurl = node.brokerurl + '[' + node.broker + ']:'
836
+ } else {
837
+ node.brokerurl = node.brokerurl + node.broker + ':'
838
+ }
839
+ // port now defaults to 1883 if unset.
840
+ if (!node.port) {
841
+ node.brokerurl = node.brokerurl + '1883'
842
+ } else {
843
+ node.brokerurl = node.brokerurl + node.port
844
+ }
845
+ } else {
846
+ node.brokerurl = node.brokerurl + 'localhost:1883'
847
+ }
848
+ }
849
+ }
850
+
851
+ // Ensure cleansession set if clientid not supplied
852
+ if (!node.cleansession && !node.clientid) {
853
+ node.cleansession = true
854
+ node.warn(RED._('ff-mqtt.errors.nonclean-missingclientid'))
855
+ }
856
+
857
+ // Build options for passing to the MQTT.js API
858
+ node.options.username = node.username
859
+ node.options.password = node.password
860
+ node.options.keepalive = node.keepalive
861
+ node.options.clean = node.cleansession
862
+ node.options.clientId = node.clientid || 'nodered' + RED.util.generateId()
863
+ node.options.reconnectPeriod = RED.settings.mqttReconnectTime || 5000
864
+ delete node.options.protocolId // V4+ default
865
+ delete node.options.protocolVersion // V4 default
866
+ delete node.options.properties// V5 only
867
+
868
+ if (node.compatmode === 'true' || node.compatmode === true || +node.protocolVersion === 3) {
869
+ node.options.protocolId = 'MQIsdp'// V3 compat only
870
+ node.options.protocolVersion = 3
871
+ } else if (+node.protocolVersion === 5) {
872
+ delete node.options.protocolId
873
+ node.options.protocolVersion = 5
874
+ node.options.properties = {}
875
+ node.options.properties.requestResponseInformation = true
876
+ node.options.properties.requestProblemInformation = true
877
+ if (node.userProperties && /^ *{/.test(node.userProperties)) {
878
+ try {
879
+ setUserProperties(JSON.parse(node.userProperties), node.options.properties)
880
+ } catch (err) {}
881
+ }
882
+ if (node.sessionExpiryInterval && node.sessionExpiryInterval !== '0') {
883
+ setIntProp(node, node.options.properties, 'sessionExpiryInterval')
884
+ }
885
+ }
886
+ // Ensure will payload, if set, is a string
887
+ if (node.options.will && Object.hasOwn(node.options.will, 'payload')) {
888
+ let payload = node.options.will.payload
889
+ if (payload === null || typeof payload === 'undefined') {
890
+ payload = ''
891
+ } else if (!Buffer.isBuffer(payload)) {
892
+ if (typeof payload === 'object') {
893
+ payload = JSON.stringify(payload)
894
+ } else if (typeof payload !== 'string') {
895
+ payload = '' + payload
896
+ }
897
+ }
898
+ node.options.will.payload = payload
899
+ }
900
+
901
+ if (node.usetls && n.tls) {
902
+ const tlsNode = RED.nodes.getNode(n.tls)
903
+ if (tlsNode) {
904
+ tlsNode.addTLSOptions(node.options)
905
+ }
906
+ }
907
+
908
+ // If there's no rejectUnauthorized already, then this could be an
909
+ // old config where this option was provided on the broker node and
910
+ // not the tls node
911
+ if (typeof node.options.rejectUnauthorized === 'undefined') {
912
+ node.options.rejectUnauthorized = (node.verifyservercert === 'true' || node.verifyservercert === true)
913
+ }
914
+ }
915
+ node.v5 = () => node.options && +node.options.protocolVersion === 5
916
+ node.subscriptionIdentifiersAvailable = () => node.v5() && node.serverProperties && node.serverProperties.subscriptionIdentifiersAvailable
917
+
918
+ // n.autoConnect = !(n.autoConnect === 'false' || n.autoConnect === false)
919
+ // node.setOptions(n, true)
920
+
921
+ // Define functions called by MQTT in and out nodes
922
+ node.registerAsync = async function (mqttNode) {
923
+ node.users[mqttNode.id] = mqttNode
924
+ if (!featureEnabled || !mqttSettings.broker) {
925
+ setStatusFeatureDisabled(node, true)
926
+ }
927
+ if (Object.keys(node.users).length === 1) {
928
+ if (!featureEnabled || !mqttSettings.broker) {
929
+ node.error('Team Broker is not enabled in this FlowFuse EE environment')
930
+ return
931
+ }
932
+ try {
933
+ if (!node.linked) {
934
+ node.log('Auto linking FlowFuse MQTT node')
935
+ await node.link()
936
+ }
937
+ } catch (err) {
938
+ const error = new Error('Failed to link FlowFuse MQTT node', { cause: err })
939
+ node.error(error)
940
+ }
941
+ try {
942
+ node.initialise()
943
+ node.connect()
944
+ } catch (err) {
945
+ const error = new Error('Failed to initialize and connect FlowFuse MQTT node', { cause: err })
946
+ node.error(error)
947
+ setStatusDisconnected(node, true)
948
+ } finally {
949
+ // update nodes status
950
+ setTimeout(function () {
951
+ updateStatus(node, true)
952
+ }, 1)
953
+ }
954
+ }
955
+ }
956
+
957
+ node.deregister = function (mqttNode, done, autoDisconnect) {
958
+ setStatusDisconnected(mqttNode, false)
959
+ delete node.users[mqttNode.id]
960
+ if (autoDisconnect && !node.closing && (node.connected || node.connecting) && Object.keys(node.users).length === 0) {
961
+ node.disconnect(done)
962
+ } else {
963
+ done()
964
+ }
965
+ }
966
+ node.deregisterAsync = async function (mqttNode, autoDisconnect) {
967
+ return new Promise((resolve, reject) => {
968
+ node.deregister(mqttNode, (err) => {
969
+ if (err) {
970
+ reject(err)
971
+ } else {
972
+ resolve()
973
+ }
974
+ }, autoDisconnect)
975
+ })
976
+ }
977
+ node.canConnect = function () {
978
+ return !node.connected && !node.connecting && featureEnabled && mqttSettings.broker
979
+ }
980
+ node.connect = function (callback) {
981
+ if (node.canConnect()) {
982
+ node.closing = false
983
+ node.connecting = true
984
+ setStatusConnecting(node, true)
985
+ try {
986
+ node.serverProperties = {}
987
+ if (node.client) {
988
+ // belt and braces to avoid left over clients
989
+ node.client.end(true)
990
+ node._clientRemoveListeners()
991
+ }
992
+ node.client = mqtt.connect(node.brokerurl, node.options)
993
+ node.client.setMaxListeners(0)
994
+ let callbackDone = false // prevent re-connects causing node._clientOn('connect' firing callback multiple times
995
+ // Register successful connect or reconnect handler
996
+ node._clientOn('connect', function (connack) {
997
+ node.closing = false
998
+ node.connecting = false
999
+ node.connected = true
1000
+ if (!callbackDone && typeof callback === 'function') {
1001
+ callback()
1002
+ }
1003
+ callbackDone = true
1004
+ node.topicAliases = {}
1005
+ node.log(RED._('ff-mqtt.state.connected', { broker: (node.clientid ? node.clientid + '@' : '') + node.brokerurl }))
1006
+ if (+node.options.protocolVersion === 5 && connack && hasProperty(connack, 'properties')) {
1007
+ if (typeof connack.properties === 'object') {
1008
+ // clean & assign all props sent from server.
1009
+ setIntProp(connack.properties, node.serverProperties, 'topicAliasMaximum', 0)
1010
+ setIntProp(connack.properties, node.serverProperties, 'receiveMaximum', 0)
1011
+ setIntProp(connack.properties, node.serverProperties, 'sessionExpiryInterval', 0, 0xFFFFFFFF)
1012
+ setIntProp(connack.properties, node.serverProperties, 'maximumQoS', 0, 2)
1013
+ setBoolProp(connack.properties, node.serverProperties, 'retainAvailable', true)
1014
+ setBoolProp(connack.properties, node.serverProperties, 'wildcardSubscriptionAvailable', true)
1015
+ setBoolProp(connack.properties, node.serverProperties, 'subscriptionIdentifiersAvailable', true)
1016
+ setBoolProp(connack.properties, node.serverProperties, 'sharedSubscriptionAvailable')
1017
+ setIntProp(connack.properties, node.serverProperties, 'maximumPacketSize', 0)
1018
+ setIntProp(connack.properties, node.serverProperties, 'serverKeepAlive')
1019
+ setStrProp(connack.properties, node.serverProperties, 'responseInformation')
1020
+ setStrProp(connack.properties, node.serverProperties, 'serverReference')
1021
+ setStrProp(connack.properties, node.serverProperties, 'assignedClientIdentifier')
1022
+ setStrProp(connack.properties, node.serverProperties, 'reasonString')
1023
+ setUserProperties(connack.properties, node.serverProperties)
1024
+ }
1025
+ }
1026
+ setStatusConnected(node, true)
1027
+ // Remove any existing listeners before resubscribing to avoid duplicates in the event of a re-connection
1028
+ node._clientRemoveListeners('message')
1029
+
1030
+ // Re-subscribe to stored topics
1031
+ for (const s in node.subscriptions) {
1032
+ if (hasProperty(node.subscriptions, s)) {
1033
+ for (const r in node.subscriptions[s]) {
1034
+ if (hasProperty(node.subscriptions[s], r)) {
1035
+ node.subscribe(node.subscriptions[s][r])
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ // Send any birth message
1042
+ if (node.birthMessage) {
1043
+ setTimeout(() => {
1044
+ node.publish(node.birthMessage)
1045
+ }, 1)
1046
+ }
1047
+ })
1048
+ node._clientOn('reconnect', function () {
1049
+ setStatusConnecting(node, true)
1050
+ })
1051
+ // Broker Disconnect - V5 event
1052
+ node._clientOn('disconnect', function (packet) {
1053
+ // Emitted after receiving disconnect packet from broker. MQTT 5.0 feature.
1054
+ const rc = (packet && packet.properties && packet.reasonCode) || packet.reasonCode
1055
+ const rs = (packet && packet.properties && packet.properties.reasonString) || ''
1056
+ const details = {
1057
+ broker: (node.clientid ? node.clientid + '@' : '') + node.brokerurl,
1058
+ reasonCode: rc,
1059
+ reasonString: rs
1060
+ }
1061
+ node.connected = false
1062
+ node.log(RED._('ff-mqtt.state.broker-disconnected', details))
1063
+ setStatusDisconnected(node, true)
1064
+ })
1065
+ // Register disconnect handlers
1066
+ node._clientOn('close', function () {
1067
+ if (node.connected) {
1068
+ node.connected = false
1069
+ node.log(RED._('ff-mqtt.state.disconnected', { broker: (node.clientid ? node.clientid + '@' : '') + node.brokerurl }))
1070
+ setStatusDisconnected(node, true)
1071
+ } else if (node.connecting) {
1072
+ node.log(RED._('ff-mqtt.state.connect-failed', { broker: (node.clientid ? node.clientid + '@' : '') + node.brokerurl }))
1073
+ }
1074
+ })
1075
+
1076
+ // Register connect error handler
1077
+ // The client's own reconnect logic will take care of errors
1078
+ // eslint-disable-next-line n/handle-callback-err
1079
+ node._clientOn('error', function (error) {
1080
+ })
1081
+ } catch (err) {
1082
+ // eslint-disable-next-line no-console
1083
+ console.log(err)
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ node.disconnect = function (callback) {
1089
+ const _callback = function () {
1090
+ if (node.connected || node.connecting) {
1091
+ setStatusDisconnected(node, true)
1092
+ }
1093
+ if (node.client) { node._clientRemoveListeners() }
1094
+ node.connecting = false
1095
+ node.connected = false
1096
+ callback && typeof callback === 'function' && callback()
1097
+ }
1098
+ if (!node.client) { return _callback() }
1099
+ if (node.closing) { return _callback() }
1100
+
1101
+ /**
1102
+ * Call end and wait for the client to end (or timeout)
1103
+ * @param {mqtt.MqttClient} client The broker client
1104
+ * @param {number} ms The time to wait for the client to end
1105
+ * @returns
1106
+ */
1107
+ const waitEnd = (client, ms) => {
1108
+ return new Promise((resolve, reject) => {
1109
+ node.closing = true
1110
+ if (!client) {
1111
+ resolve()
1112
+ } else {
1113
+ const t = setTimeout(() => {
1114
+ // clean end() has exceeded WAIT_END, lets force end!
1115
+ client && client.end(true)
1116
+ resolve()
1117
+ }, ms)
1118
+ client.end(() => {
1119
+ clearTimeout(t)
1120
+ resolve()
1121
+ })
1122
+ }
1123
+ })
1124
+ }
1125
+ if (node.connected && node.closeMessage) {
1126
+ node.publish(node.closeMessage, function (_err) {
1127
+ waitEnd(node.client, 2000).then(() => {
1128
+ _callback()
1129
+ }).catch((e) => {
1130
+ _callback()
1131
+ })
1132
+ })
1133
+ } else {
1134
+ waitEnd(node.client, 2000).then(() => {
1135
+ _callback()
1136
+ }).catch((e) => {
1137
+ _callback()
1138
+ })
1139
+ }
1140
+ }
1141
+ node.subscriptionIds = {}
1142
+ node.subid = 1
1143
+
1144
+ // typedef for subscription object:
1145
+ /**
1146
+ * @typedef {Object} Subscription
1147
+ * @property {String} topic - topic to subscribe to
1148
+ * @property {Object} [options] - options object
1149
+ * @property {Number} [options.qos] - quality of service
1150
+ * @property {Number} [options.nl] - no local
1151
+ * @property {Number} [options.rap] - retain as published
1152
+ * @property {Number} [options.rh] - retain handling
1153
+ * @property {Number} [options.properties] - MQTT 5.0 properties
1154
+ * @property {Number} [options.properties.subscriptionIdentifier] - MQTT 5.0 subscription identifier
1155
+ * @property {Number} [options.properties.userProperties] - MQTT 5.0 user properties
1156
+ * @property {Function} callback
1157
+ * @property {String} ref - reference to the node that created the subscription
1158
+ */
1159
+
1160
+ /**
1161
+ * Create a subscription object
1162
+ * @param {String} _topic - topic to subscribe to
1163
+ * @param {Object} _options - options object
1164
+ * @param {String} _ref - reference to the node that created the subscription
1165
+ * @returns {Subscription}
1166
+ */
1167
+ function createSubscriptionObject (_topic, _options, _ref, _brokerId) {
1168
+ /** @type {Subscription} */
1169
+ const subscription = {}
1170
+ const ref = _ref || 0
1171
+ let options
1172
+ let qos = 1 // default to QoS 1 (AWS and several other brokers don't support QoS 2)
1173
+
1174
+ // if options is an object, then clone it
1175
+ if (typeof _options === 'object') {
1176
+ options = RED.util.cloneMessage(_options || {})
1177
+ qos = _options.qos
1178
+ } else if (typeof _options === 'number') {
1179
+ qos = _options
1180
+ }
1181
+ options = options || {}
1182
+
1183
+ // sanitise qos
1184
+ if (typeof qos === 'number' && qos >= 0 && qos <= 2) {
1185
+ options.qos = qos
1186
+ }
1187
+
1188
+ subscription.topic = _topic
1189
+ subscription.qos = qos
1190
+ subscription.options = RED.util.cloneMessage(options)
1191
+ subscription.ref = ref
1192
+ subscription.brokerId = _brokerId
1193
+ return subscription
1194
+ }
1195
+
1196
+ /**
1197
+ * If topic is a subscription object, then use that, otherwise look up the topic in
1198
+ * the subscriptions object. If the topic is not found, then create a new subscription
1199
+ * object and add it to the subscriptions object.
1200
+ * @param {Subscription|String} topic
1201
+ * @param {*} options
1202
+ * @param {*} callback
1203
+ * @param {*} ref
1204
+ */
1205
+ node.subscribe = function (topic, options, callback, ref) {
1206
+ /** @type {Subscription} */
1207
+ let subscription
1208
+ let doCompare = false
1209
+ let changesFound = false
1210
+
1211
+ // function signature 1: subscribe(subscription: Subscription)
1212
+ if (typeof topic === 'object' && topic !== null) {
1213
+ subscription = topic
1214
+ topic = subscription.topic
1215
+ options = subscription.options
1216
+ ref = subscription.ref
1217
+ callback = subscription.callback
1218
+ }
1219
+
1220
+ // function signature 2: subscribe(topic: String, options: Object, callback: Function, ref: String)
1221
+ else if (typeof topic === 'string') {
1222
+ // since this is a call where all params are provided, it might be
1223
+ // a node change (modification) so we need to check for changes
1224
+ doCompare = true
1225
+ subscription = node.subscriptions[topic] && node.subscriptions[topic][ref]
1226
+ }
1227
+
1228
+ // bad function call
1229
+ else {
1230
+ console.warn('Invalid call to node.subscribe')
1231
+ return
1232
+ }
1233
+ const thisBrokerId = node.type === 'mqtt-broker' ? node.id : node.broker
1234
+
1235
+ // unsubscribe topics where the broker has changed
1236
+ const oldBrokerSubs = (unsubscribeCandidates[ref] || []).filter(sub => sub.brokerId !== thisBrokerId)
1237
+ oldBrokerSubs.forEach(sub => {
1238
+ /** @type {MQTTBrokerNode} */
1239
+ const _brokerConn = node // RED.nodes.getNode(sub.brokerId)
1240
+ if (_brokerConn) {
1241
+ _brokerConn.unsubscribe(sub.topic, sub.ref, true)
1242
+ }
1243
+ })
1244
+
1245
+ // if subscription is found (or sent in as a parameter), then check for changes.
1246
+ // if there are any changes requested, tidy up the old subscription
1247
+ if (subscription) {
1248
+ if (doCompare) {
1249
+ // compare the current sub to the passed in parameters. Use RED.util.compareObjects against
1250
+ // only the minimal set of properties to identify if the subscription has changed
1251
+ const currentSubscription = createSubscriptionObject(subscription.topic, subscription.options, subscription.ref)
1252
+ const newSubscription = createSubscriptionObject(topic, options, ref)
1253
+ changesFound = RED.util.compareObjects(currentSubscription, newSubscription) === false
1254
+ }
1255
+ }
1256
+
1257
+ if (changesFound) {
1258
+ if (subscription.handler) {
1259
+ node._clientRemoveListeners('message', subscription.handler)
1260
+ subscription.handler = null
1261
+ }
1262
+ const _brokerConn = node // RED.nodes.getNode(subscription.brokerId)
1263
+ if (_brokerConn) {
1264
+ _brokerConn.unsubscribe(subscription.topic, subscription.ref, true)
1265
+ }
1266
+ }
1267
+
1268
+ // clean up the unsubscribe candidate list
1269
+ delete unsubscribeCandidates[ref]
1270
+
1271
+ // determine if this is an existing subscription
1272
+ const existingSubscription = typeof subscription === 'object' && subscription !== null
1273
+
1274
+ // if existing subscription is not found or has changed, create a new subscription object
1275
+ if (existingSubscription === false || changesFound) {
1276
+ subscription = createSubscriptionObject(topic, options, ref, node.id)
1277
+ }
1278
+
1279
+ // setup remainder of subscription properties and event handling
1280
+ node.subscriptions[topic] = node.subscriptions[topic] || {}
1281
+ node.subscriptions[topic][ref] = subscription
1282
+ if (!node.subscriptionIds[topic]) {
1283
+ node.subscriptionIds[topic] = node.subid++
1284
+ }
1285
+ subscription.options = subscription.options || {}
1286
+ subscription.options.properties = options.properties || {}
1287
+ subscription.options.properties.subscriptionIdentifier = node.subscriptionIds[topic]
1288
+ subscription.callback = callback
1289
+
1290
+ // if the client is connected, then setup the handler and subscribe
1291
+ if (node.connected) {
1292
+ const subIdsAvailable = node.subscriptionIdentifiersAvailable()
1293
+
1294
+ if (!subscription.handler) {
1295
+ subscription.handler = function (mtopic, mpayload, mpacket) {
1296
+ const sops = subscription.options ? subscription.options.properties : {}
1297
+ const pops = mpacket.properties || {}
1298
+ if (subIdsAvailable && pops.subscriptionIdentifier && sops.subscriptionIdentifier && (pops.subscriptionIdentifier !== sops.subscriptionIdentifier)) {
1299
+ // do nothing as subscriptionIdentifier does not match
1300
+ } else if (matchTopic(topic, mtopic)) {
1301
+ subscription.callback && subscription.callback(mtopic, mpayload, mpacket)
1302
+ }
1303
+ }
1304
+ }
1305
+ node._clientOn('message', subscription.handler)
1306
+ // if the broker doesn't support subscription identifiers, then don't send them (AWS support)
1307
+ if (subscription.options.properties && subscription.options.properties.subscriptionIdentifier && subIdsAvailable !== true) {
1308
+ delete subscription.options.properties.subscriptionIdentifier
1309
+ }
1310
+ node.client.subscribe(topic, subscription.options, function (err, granted) {
1311
+ if (err) {
1312
+ node.error(RED._('ff-mqtt.errors.subscribe-failed', { topic, error: err.message }), { topic })
1313
+ }
1314
+ })
1315
+ }
1316
+ }
1317
+
1318
+ node.unsubscribe = function (topic, ref, removeClientSubscription) {
1319
+ ref = ref || 0
1320
+ const unsub = removeClientSubscription || node.autoUnsubscribe !== false
1321
+ const sub = node.subscriptions[topic]
1322
+ let brokerId = node.id
1323
+ if (sub) {
1324
+ if (sub[ref]) {
1325
+ brokerId = sub[ref].brokerId || brokerId
1326
+ if (node.client && sub[ref].handler) {
1327
+ node._clientRemoveListeners('message', sub[ref].handler)
1328
+ sub[ref].handler = null
1329
+ }
1330
+ if (unsub) {
1331
+ delete sub[ref]
1332
+ }
1333
+ }
1334
+ // if instructed to remove the actual MQTT client subscription
1335
+ if (unsub) {
1336
+ // if there are no more subscriptions for the topic, then remove the topic
1337
+ if (Object.keys(sub).length === 0) {
1338
+ try {
1339
+ node.client.unsubscribe(topic)
1340
+ } catch (_err) {
1341
+ // do nothing
1342
+ } finally {
1343
+ // remove unsubscribe candidate as it is now REALLY unsubscribed
1344
+ delete node.subscriptions[topic]
1345
+ delete node.subscriptionIds[topic]
1346
+ if (unsubscribeCandidates[ref]) {
1347
+ unsubscribeCandidates[ref] = unsubscribeCandidates[ref].filter(sub => sub.topic !== topic)
1348
+ }
1349
+ }
1350
+ }
1351
+ } else {
1352
+ // if instructed to not remove the client subscription, then add it to the candidate list
1353
+ // of subscriptions to be removed when the the same ref is used in a subsequent subscribe
1354
+ // and the topic has changed
1355
+ unsubscribeCandidates[ref] = unsubscribeCandidates[ref] || []
1356
+ unsubscribeCandidates[ref].push({
1357
+ topic,
1358
+ ref,
1359
+ brokerId
1360
+ })
1361
+ }
1362
+ }
1363
+ }
1364
+ node.topicAliases = {}
1365
+
1366
+ node.publish = function (msg, done) {
1367
+ if (node.connected) {
1368
+ if (msg.payload === null || msg.payload === undefined) {
1369
+ msg.payload = ''
1370
+ } else if (!Buffer.isBuffer(msg.payload)) {
1371
+ if (typeof msg.payload === 'object') {
1372
+ msg.payload = JSON.stringify(msg.payload)
1373
+ } else if (typeof msg.payload !== 'string') {
1374
+ msg.payload = '' + msg.payload
1375
+ }
1376
+ }
1377
+ const options = {
1378
+ qos: msg.qos || 0,
1379
+ retain: msg.retain || false
1380
+ }
1381
+ let topicOK = hasProperty(msg, 'topic') && (typeof msg.topic === 'string') && (isValidPublishTopic(msg.topic))
1382
+ // https://github.com/mqttjs/MQTT.js/blob/master/README.md#mqttclientpublishtopic-message-options-callback
1383
+ if (+node.options.protocolVersion === 5) {
1384
+ const bsp = node.serverProperties || {}
1385
+ if (msg.userProperties && typeof msg.userProperties !== 'object') {
1386
+ delete msg.userProperties
1387
+ }
1388
+ if (hasProperty(msg, 'topicAlias') && !isNaN(Number(msg.topicAlias))) {
1389
+ msg.topicAlias = parseInt(msg.topicAlias)
1390
+ } else {
1391
+ delete msg.topicAlias
1392
+ }
1393
+ options.properties = options.properties || {}
1394
+ setStrProp(msg, options.properties, 'responseTopic')
1395
+ setBufferProp(msg, options.properties, 'correlationData')
1396
+ setStrProp(msg, options.properties, 'contentType')
1397
+ setIntProp(msg, options.properties, 'messageExpiryInterval', 0)
1398
+ setUserProperties(msg.userProperties, options.properties)
1399
+ setIntProp(msg, options.properties, 'topicAlias', 1, bsp.topicAliasMaximum || 0)
1400
+ setBoolProp(msg, options.properties, 'payloadFormatIndicator')
1401
+ // FUTURE setIntProp(msg, options.properties, "subscriptionIdentifier", 1, 268435455);
1402
+
1403
+ // check & sanitise topic
1404
+ if (topicOK && options.properties.topicAlias) {
1405
+ const aliasValid = (bsp.topicAliasMaximum && bsp.topicAliasMaximum >= options.properties.topicAlias)
1406
+ if (!aliasValid) {
1407
+ done('Invalid topicAlias')
1408
+ return
1409
+ }
1410
+ if (node.topicAliases[options.properties.topicAlias] === msg.topic) {
1411
+ msg.topic = ''
1412
+ } else {
1413
+ node.topicAliases[options.properties.topicAlias] = msg.topic
1414
+ }
1415
+ } else if (!msg.topic && options.properties.responseTopic) {
1416
+ msg.topic = msg.responseTopic
1417
+ topicOK = isValidPublishTopic(msg.topic)
1418
+ delete msg.responseTopic // prevent responseTopic being resent?
1419
+ }
1420
+ }
1421
+
1422
+ if (topicOK) {
1423
+ node.client.publish(msg.topic, msg.payload, options, function (err) {
1424
+ if (done) {
1425
+ done(err)
1426
+ } else if (err) {
1427
+ node.error(err, msg)
1428
+ }
1429
+ })
1430
+ } else {
1431
+ const error = new Error(RED._('ff-mqtt.errors.invalid-topic'))
1432
+ error.warn = true
1433
+ if (done) {
1434
+ done(error)
1435
+ } else {
1436
+ node.warn(error, msg)
1437
+ }
1438
+ }
1439
+ }
1440
+ }
1441
+
1442
+ // no `on` or `close` handlers for the static broker node
1443
+ // node.on('close', function (done) {
1444
+ // node.disconnect(function () {
1445
+ // done()
1446
+ // })
1447
+ // })
1448
+
1449
+ // fake the node.status function if it is not already defined
1450
+ if (typeof node.status !== 'function') {
1451
+ /** @type {function} */
1452
+ node.status = (options) => options
1453
+ }
1454
+
1455
+ // Provide logging functions for this shared broker client via RED.log
1456
+ const decorateDebugMsg = (msg) => {
1457
+ if (typeof msg === 'object' && msg.message) {
1458
+ msg = msg.message
1459
+ }
1460
+ if (!msg.includes('FlowFuse MQTT')) {
1461
+ msg = `FlowFuse MQTT Nodes Client: ${msg}`
1462
+ }
1463
+ return msg
1464
+ }
1465
+ // mimic the node.warn function if it is not already defined
1466
+ if (typeof node.warn !== 'function') {
1467
+ /** @type {function} */
1468
+ node.warn = (msg) => {
1469
+ RED.log.warn(decorateDebugMsg(msg))
1470
+ }
1471
+ }
1472
+ // mimic the node.error function if it is not already defined
1473
+ if (typeof node.error !== 'function') {
1474
+ /** @type {function} */
1475
+ node.error = (msg, _msg) => {
1476
+ RED.log.error(decorateDebugMsg(msg), _msg)
1477
+ }
1478
+ }
1479
+ // mimic the node.log function if it is not already defined
1480
+ if (typeof node.log !== 'function') {
1481
+ /** @type {function} */
1482
+ node.log = (msg) => {
1483
+ RED.log.info(decorateDebugMsg(msg))
1484
+ }
1485
+ }
1486
+
1487
+ /**
1488
+ * Add event handlers to the MQTT.js client and track them so that
1489
+ * we do not remove any handlers that the MQTT client uses internally.
1490
+ * Use {@link node._clientRemoveListeners `node._clientRemoveListeners`} to remove handlers
1491
+ * @param {string} event The name of the event
1492
+ * @param {function} handler The handler for this event
1493
+ */
1494
+ node._clientOn = function (event, handler) {
1495
+ node.clientListeners.push({ event, handler })
1496
+ node.client.on(event, handler)
1497
+ }
1498
+
1499
+ /**
1500
+ * Remove event handlers from the MQTT.js client & only the events
1501
+ * that we attached in {@link node._clientOn `node._clientOn`}.
1502
+ * * If `event` is omitted, then all events matching `handler` are removed
1503
+ * * If `handler` is omitted, then all events named `event` are removed
1504
+ * * If both parameters are omitted, then all events are removed
1505
+ * @param {string} [event] The name of the event (optional)
1506
+ * @param {function} [handler] The handler for this event (optional)
1507
+ */
1508
+ node._clientRemoveListeners = function (event, handler) {
1509
+ node.clientListeners = node.clientListeners.filter((l) => {
1510
+ if (event && event !== l.event) { return true }
1511
+ if (handler && handler !== l.handler) { return true }
1512
+ node.client.removeListener(l.event, l.handler)
1513
+ return false // found and removed, filter out this one
1514
+ })
1515
+ }
1516
+ }
1517
+
1518
+ // #endregion "Broker node"
1519
+
1520
+ // #region MQTTIn node
1521
+ function MQTTInNode (n) {
1522
+ RED.nodes.createNode(this, n)
1523
+ /** @type {MQTTInNode} */const node = this
1524
+
1525
+ /** @type {MQTTBrokerNode} */node.brokerConn = sharedBroker // RED.nodes.getNode(node.broker)
1526
+
1527
+ node.dynamicSubs = {}
1528
+ node.isDynamic = hasProperty(n, 'inputs') && +n.inputs === 1
1529
+ node.inputs = n.inputs
1530
+ node.topic = n.topic
1531
+ node.qos = parseInt(n.qos)
1532
+ node.subscriptionIdentifier = n.subscriptionIdentifier// https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901117
1533
+ node.nl = n.nl
1534
+ node.rap = n.rap
1535
+ node.rh = n.rh
1536
+
1537
+ const Actions = {
1538
+ CONNECT: 'connect',
1539
+ DISCONNECT: 'disconnect',
1540
+ SUBSCRIBE: 'subscribe',
1541
+ UNSUBSCRIBE: 'unsubscribe',
1542
+ GETSUBS: 'getSubscriptions'
1543
+ }
1544
+ const allowableActions = Object.values(Actions)
1545
+
1546
+ if (isNaN(node.qos) || node.qos < 0 || node.qos > 2) {
1547
+ node.qos = 2
1548
+ }
1549
+ if (!node.isDynamic && !isValidSubscriptionTopic(node.topic)) {
1550
+ return node.warn(RED._('ff-mqtt.errors.invalid-topic'))
1551
+ }
1552
+ node.datatype = n.datatype || 'utf8'
1553
+ if (node.brokerConn) {
1554
+ setStatusDisconnected(node)
1555
+ if (node.topic || node.isDynamic) {
1556
+ node.brokerConn.registerAsync(node).then(() => {
1557
+ if (!node.isDynamic) {
1558
+ const options = { qos: node.qos }
1559
+ const v5 = node.brokerConn.options && +node.brokerConn.options.protocolVersion === 5
1560
+ if (v5) {
1561
+ setIntProp(node, options, 'rh', 0, 2, 0)
1562
+ if (node.nl === 'true' || node.nl === true) options.nl = true
1563
+ else if (node.nl === 'false' || node.nl === false) options.nl = false
1564
+ if (node.rap === 'true' || node.rap === true) options.rap = true
1565
+ else if (node.rap === 'false' || node.rap === false) options.rap = false
1566
+ }
1567
+ node._topic = node.topic // store the original topic incase node is later changed
1568
+ node.brokerConn.subscribe(node.topic, options, function (topic, payload, packet) {
1569
+ subscriptionHandler(node, node.datatype, topic, payload, packet)
1570
+ }, node.id)
1571
+ }
1572
+ if (node.brokerConn.connected) {
1573
+ node.status({ fill: 'green', shape: 'dot', text: 'common.status.connected' })
1574
+ }
1575
+ }).catch((err) => {
1576
+ node.error(err)
1577
+ setStatusDisconnected(node, true)
1578
+ })
1579
+ } else {
1580
+ node.error(RED._('ff-mqtt.errors.not-defined'))
1581
+ }
1582
+ node.on('input', function (msg, send, done) {
1583
+ const v5 = node.brokerConn.options && +node.brokerConn.options.protocolVersion === 5
1584
+ const action = msg.action
1585
+
1586
+ if (!allowableActions.includes(action)) {
1587
+ done(new Error(RED._('ff-mqtt.errors.invalid-action-action')))
1588
+ return
1589
+ }
1590
+
1591
+ if (action === Actions.CONNECT) {
1592
+ handleConnectAction(node, msg, done)
1593
+ } else if (action === Actions.DISCONNECT) {
1594
+ handleDisconnectAction(node, done)
1595
+ } else if (action === Actions.SUBSCRIBE || action === Actions.UNSUBSCRIBE) {
1596
+ const subscriptions = []
1597
+ let actionData
1598
+ // coerce msg.topic into an array of strings or objects (for later iteration)
1599
+ if (action === Actions.UNSUBSCRIBE && msg.topic === true) {
1600
+ actionData = Object.values(node.dynamicSubs)
1601
+ } else if (Array.isArray(msg.topic)) {
1602
+ actionData = msg.topic
1603
+ } else if (typeof msg.topic === 'string' || typeof msg.topic === 'object') {
1604
+ actionData = [msg.topic]
1605
+ } else {
1606
+ done(new Error(RED._('ff-mqtt.errors.invalid-action-badsubscription')))
1607
+ return
1608
+ }
1609
+ // ensure each subscription is an object with topic etc
1610
+ for (let index = 0; index < actionData.length; index++) {
1611
+ let subscription = actionData[index]
1612
+ if (typeof subscription === 'string') {
1613
+ subscription = { topic: subscription }
1614
+ }
1615
+ if (!subscription.topic || !isValidSubscriptionTopic(subscription.topic)) {
1616
+ done(new Error(RED._('ff-mqtt.errors.invalid-topic')))
1617
+ return
1618
+ }
1619
+ subscriptions.push(subscription)
1620
+ }
1621
+ if (action === Actions.UNSUBSCRIBE) {
1622
+ subscriptions.forEach(function (sub) {
1623
+ node.brokerConn.unsubscribe(sub.topic, node.id, true)
1624
+ delete node.dynamicSubs[sub.topic]
1625
+ })
1626
+ // user can access current subscriptions through the complete node is so desired
1627
+ msg.subscriptions = Object.values(node.dynamicSubs)
1628
+ done()
1629
+ } else if (action === Actions.SUBSCRIBE) {
1630
+ subscriptions.forEach(function (sub) {
1631
+ // always unsubscribe before subscribe to prevent multiple subs to same topic
1632
+ if (node.dynamicSubs[sub.topic]) {
1633
+ node.brokerConn.unsubscribe(sub.topic, node.id, true)
1634
+ delete node.dynamicSubs[sub.topic]
1635
+ }
1636
+
1637
+ // prepare options. Default qos 2 & rap flag true (same as 'mqtt in' node ui defaults when adding to editor)
1638
+ const options = {}
1639
+ setIntProp(sub, options, 'qos', 0, 2, 2)// default to qos 2 (same as 'mqtt in' default)
1640
+ sub.qos = options.qos
1641
+ if (v5) {
1642
+ setIntProp(sub, options, 'rh', 0, 2, 0) // default rh to 0:send retained messages (same as 'mqtt in' default)
1643
+ sub.rh = options.rh
1644
+ setBoolProp(sub, options, 'rap', true) // default rap to true:Keep retain flag of original publish (same as 'mqtt in' default)
1645
+ sub.rap = options.rap
1646
+ if (sub.nl === 'true' || sub.nl === true) {
1647
+ options.nl = true
1648
+ sub.nl = true
1649
+ } else if (sub.nl === 'false' || sub.nl === false) {
1650
+ options.nl = false
1651
+ sub.nl = false
1652
+ } else {
1653
+ delete sub.nl
1654
+ }
1655
+ }
1656
+
1657
+ // subscribe to sub.topic & hook up subscriptionHandler
1658
+ node.brokerConn.subscribe(sub.topic, options, function (topic, payload, packet) {
1659
+ subscriptionHandler(node, sub.datatype || node.datatype, topic, payload, packet)
1660
+ }, node.id)
1661
+ node.dynamicSubs[sub.topic] = sub // save for later unsubscription & 'list' action
1662
+ })
1663
+ // user can access current subscriptions through the complete node is so desired
1664
+ msg.subscriptions = Object.values(node.dynamicSubs)
1665
+ done()
1666
+ }
1667
+ } else if (action === Actions.GETSUBS) {
1668
+ // send list of subscriptions in payload
1669
+ msg.topic = 'subscriptions'
1670
+ msg.payload = Object.values(node.dynamicSubs)
1671
+ send(msg)
1672
+ done()
1673
+ }
1674
+ })
1675
+
1676
+ node.on('close', async function (removed, done) {
1677
+ try {
1678
+ if (node.linkPromise) {
1679
+ await node.linkPromise
1680
+ }
1681
+ } catch (_error) {
1682
+ // do nothing, just ensure that the linkPromise is resolved before closing
1683
+ }
1684
+ try {
1685
+ if (node.isDynamic) {
1686
+ Object.keys(node.dynamicSubs).forEach(function (topic) {
1687
+ node.brokerConn.unsubscribe(topic, node.id, removed)
1688
+ })
1689
+ node.dynamicSubs = {}
1690
+ } else {
1691
+ node.brokerConn.unsubscribe(node.topic, node.id, removed)
1692
+ }
1693
+ if (node.brokerConn) {
1694
+ await node.brokerConn.deregisterAsync(node, removed)
1695
+ node.brokerConn = null
1696
+ }
1697
+ } finally {
1698
+ done()
1699
+ }
1700
+ })
1701
+ } else {
1702
+ node.error(RED._('ff-mqtt.errors.missing-config'))
1703
+ }
1704
+ }
1705
+
1706
+ RED.nodes.registerType('ff-mqtt-in', MQTTInNode, {
1707
+ settings: {
1708
+ ffMqttInFeatureEnabled: {
1709
+ value: featureEnabled,
1710
+ exportable: true // make available in the editor
1711
+ },
1712
+ ffMqttInForgeUrl: {
1713
+ value: forgeSettings.forgeURL,
1714
+ exportable: true // make available in the editor
1715
+ },
1716
+ ffMqttInInstanceId: {
1717
+ value: instanceId,
1718
+ exportable: true // make available in the editor
1719
+ },
1720
+ ffMqttInInstanceType: {
1721
+ value: instanceType,
1722
+ exportable: true // make available in the editor
1723
+ },
1724
+ ffMqttInUserTeamBrokerClientUrl: {
1725
+ value: `${forgeSettings.forgeURL}/team-by-id/${teamId}/brokers/team-broker/client?searchQuery=${instanceId}`,
1726
+ exportable: true // make available in the editor
1727
+ }
1728
+ }
1729
+ })
1730
+ // #endregion "MQTTIn node"
1731
+
1732
+ // #region "MQTTOut node"
1733
+ function MQTTOutNode (n) {
1734
+ RED.nodes.createNode(this, n)
1735
+ const node = this
1736
+ node.topic = n.topic
1737
+ node.qos = n.qos || null
1738
+ node.retain = n.retain
1739
+
1740
+ node.responseTopic = n.respTopic// https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901114
1741
+ node.correlationData = n.correl// https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901115
1742
+ node.contentType = n.contentType// https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901118
1743
+ node.messageExpiryInterval = n.expiry // https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901112
1744
+ try {
1745
+ if (/^ *{/.test(n.userProps)) {
1746
+ // setup this.userProperties
1747
+ setUserProperties(JSON.parse(n.userProps), node)// https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901116
1748
+ }
1749
+ } catch (err) {}
1750
+ // node.topicAlias = n.topicAlias; //https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901113
1751
+ // node.payloadFormatIndicator = n.payloadFormatIndicator; //https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901111
1752
+ // node.subscriptionIdentifier = n.subscriptionIdentifier;//https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901117
1753
+
1754
+ /** @type {MQTTBrokerNode} */node.brokerConn = sharedBroker // RED.nodes.getNode(node.broker)
1755
+
1756
+ const Actions = {
1757
+ CONNECT: 'connect',
1758
+ DISCONNECT: 'disconnect'
1759
+ }
1760
+
1761
+ if (node.brokerConn) {
1762
+ setStatusDisconnected(node)
1763
+ node.on('input', function (msg, send, done) {
1764
+ if (msg.action) {
1765
+ if (msg.action === Actions.CONNECT) {
1766
+ handleConnectAction(node, msg, done)
1767
+ } else if (msg.action === Actions.DISCONNECT) {
1768
+ handleDisconnectAction(node, done)
1769
+ } else {
1770
+ done(new Error(RED._('ff-mqtt.errors.invalid-action-action')))
1771
+ }
1772
+ } else {
1773
+ doPublish(node, msg, done)
1774
+ }
1775
+ })
1776
+ if (node.brokerConn.connected) {
1777
+ node.status({ fill: 'green', shape: 'dot', text: 'common.status.connected' })
1778
+ }
1779
+ node.brokerConn.registerAsync(node)
1780
+ node.on('close', async function (removed, done) {
1781
+ try {
1782
+ if (node.linkPromise) {
1783
+ await node.linkPromise
1784
+ }
1785
+ } catch (_error) {
1786
+ // do nothing, just ensure that the linkPromise is resolved before closing
1787
+ }
1788
+ try {
1789
+ if (node.brokerConn) {
1790
+ await node.brokerConn.deregisterAsync(node, removed)
1791
+ node.brokerConn = null
1792
+ }
1793
+ } finally {
1794
+ done()
1795
+ }
1796
+ })
1797
+ } else {
1798
+ node.error(RED._('ff-mqtt.errors.missing-config'))
1799
+ }
1800
+ }
1801
+ RED.nodes.registerType('ff-mqtt-out', MQTTOutNode, {
1802
+ settings: {
1803
+ ffMqttOutFeatureEnabled: {
1804
+ value: featureEnabled,
1805
+ exportable: true // make available in the editor
1806
+ },
1807
+ ffMqttOutForgeUrl: {
1808
+ value: forgeSettings.forgeURL,
1809
+ exportable: true // make available in the editor
1810
+ },
1811
+ ffMqttOutInstanceId: {
1812
+ value: instanceId,
1813
+ exportable: true // make available in the editor
1814
+ },
1815
+ ffMqttOutInstanceType: {
1816
+ value: instanceType,
1817
+ exportable: true // make available in the editor
1818
+ },
1819
+ ffMqttOutUserTeamBrokerClientUrl: {
1820
+ value: `${forgeSettings.forgeURL}/team-by-id/${teamId}/brokers/team-broker/client?searchQuery=${instanceId}`,
1821
+ exportable: true // make available in the editor
1822
+ }
1823
+ }
1824
+ })
1825
+ // #endregion "MQTTOut node"
1826
+ }