node-red-contrib-knx-ultimate 6.0.11 → 6.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +11 -3
  2. package/nodes/knxUltimateHueLight.html +1 -5
  3. package/nodes/knxUltimateMatterControllerDevice.html +584 -47
  4. package/nodes/knxUltimateMatterControllerDevice.js +37 -0
  5. package/nodes/locales/de/knxUltimateMatterBridge.html +0 -2
  6. package/nodes/locales/de/knxUltimateMatterControllerDevice.html +23 -3
  7. package/nodes/locales/de/knxUltimateMatterControllerDevice.json +39 -1
  8. package/nodes/locales/de/matter-config.html +11 -1
  9. package/nodes/locales/de/matter-config.json +12 -0
  10. package/nodes/locales/en/knxUltimateMatterBridge.html +0 -2
  11. package/nodes/locales/en/knxUltimateMatterControllerDevice.html +23 -3
  12. package/nodes/locales/en/knxUltimateMatterControllerDevice.json +39 -1
  13. package/nodes/locales/en/matter-config.html +11 -1
  14. package/nodes/locales/en/matter-config.json +12 -0
  15. package/nodes/locales/es/knxUltimateMatterBridge.html +0 -2
  16. package/nodes/locales/es/knxUltimateMatterControllerDevice.html +23 -3
  17. package/nodes/locales/es/knxUltimateMatterControllerDevice.json +39 -1
  18. package/nodes/locales/es/matter-config.html +11 -1
  19. package/nodes/locales/es/matter-config.json +12 -0
  20. package/nodes/locales/fr/knxUltimateMatterBridge.html +0 -2
  21. package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +23 -3
  22. package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +47 -1
  23. package/nodes/locales/fr/matter-config.html +11 -1
  24. package/nodes/locales/fr/matter-config.json +12 -0
  25. package/nodes/locales/it/knxUltimateMatterBridge.html +0 -2
  26. package/nodes/locales/it/knxUltimateMatterControllerDevice.html +23 -3
  27. package/nodes/locales/it/knxUltimateMatterControllerDevice.json +39 -1
  28. package/nodes/locales/it/matter-config.html +11 -1
  29. package/nodes/locales/it/matter-config.json +12 -0
  30. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +0 -2
  31. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +23 -3
  32. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +47 -1
  33. package/nodes/locales/zh-CN/matter-config.html +11 -1
  34. package/nodes/locales/zh-CN/matter-config.json +12 -0
  35. package/nodes/matter-config.html +215 -23
  36. package/nodes/matter-config.js +53 -6
  37. package/nodes/utils/matterControllerCommandGate.js +47 -0
  38. package/nodes/utils/matterControllerProfiles/doorLock.js +19 -4
  39. package/nodes/utils/matterControllerProfiles/fan.js +0 -1
  40. package/nodes/utils/matterControllerProfiles/index.js +3 -1
  41. package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +32 -15
  42. package/nodes/utils/matterControllerProfiles/switch.js +0 -1
  43. package/nodes/utils/matterControllerProfiles/thermostat.js +0 -1
  44. package/nodes/utils/matterControllerProfiles/universal.js +462 -0
  45. package/nodes/utils/matterControllerProfiles/windowCovering.js +0 -1
  46. package/nodes/utils/matterControllerSemanticInput.js +212 -0
  47. package/nodes/utils/matterEngine.mjs +120 -17
  48. package/package.json +1 -1
  49. package/resources/matterQrScanner.js +98 -0
  50. package/resources/zxing-browser-0.2.0.min.js +1 -0
  51. package/resources/zxing-browser-LICENSE.txt +21 -0
@@ -0,0 +1,212 @@
1
+ 'use strict'
2
+
3
+ const { CLUSTER } = require('./matterKnxConverter')
4
+
5
+ const normalizeToken = (value) => String(value || '').trim().toLowerCase().replace(/[^a-z0-9]/g, '')
6
+
7
+ const requireNumber = (value, functionName, min, max) => {
8
+ const numeric = Number(value)
9
+ if (!Number.isFinite(numeric)) throw new Error(`Matter function "${functionName}" requires a numeric value`)
10
+ if (min !== undefined && numeric < min) throw new Error(`Matter function "${functionName}" requires a value >= ${min}`)
11
+ if (max !== undefined && numeric > max) throw new Error(`Matter function "${functionName}" requires a value <= ${max}`)
12
+ return numeric
13
+ }
14
+
15
+ const requireBoolean = (value, functionName) => {
16
+ if (value === true || value === 1 || value === '1' || normalizeToken(value) === 'true' || normalizeToken(value) === 'on') return true
17
+ if (value === false || value === 0 || value === '0' || normalizeToken(value) === 'false' || normalizeToken(value) === 'off') return false
18
+ throw new Error(`Matter function "${functionName}" requires true/false`)
19
+ }
20
+
21
+ const command = (clusterId, names, value) => ({
22
+ clusterId,
23
+ targetKind: 'command',
24
+ names: Array.isArray(names) ? names : [names],
25
+ value
26
+ })
27
+
28
+ const attribute = (clusterId, names, value, writableOnly = false) => ({
29
+ clusterId,
30
+ targetKind: 'attribute',
31
+ names: Array.isArray(names) ? names : [names],
32
+ value,
33
+ writableOnly
34
+ })
35
+
36
+ // Friendly functions deliberately use the same names as the Matter Bridge where
37
+ // the concepts overlap. Values use human units (percent, °C, lux, W and kWh).
38
+ const SEMANTIC_FUNCTIONS = {
39
+ onoff: {
40
+ read: attribute(CLUSTER.ON_OFF, 'onOff'),
41
+ write: (value) => {
42
+ if (normalizeToken(value) === 'toggle') return command(CLUSTER.ON_OFF, 'toggle', false)
43
+ const on = requireBoolean(value, 'onoff')
44
+ return command(CLUSTER.ON_OFF, on ? 'on' : 'off', on)
45
+ }
46
+ },
47
+ level: {
48
+ read: attribute(CLUSTER.LEVEL_CONTROL, 'currentLevel'),
49
+ write: (value) => command(CLUSTER.LEVEL_CONTROL, ['moveToLevelWithOnOff', 'moveToLevel'], requireNumber(value, 'level', 0, 100))
50
+ },
51
+ position: {
52
+ read: attribute(CLUSTER.WINDOW_COVERING, ['currentPositionLiftPercent100ths', 'currentPositionLiftPercentage']),
53
+ write: (value) => command(CLUSTER.WINDOW_COVERING, 'goToLiftPercentage', requireNumber(value, 'position', 0, 100))
54
+ },
55
+ tiltposition: {
56
+ read: attribute(CLUSTER.WINDOW_COVERING, ['currentPositionTiltPercent100ths', 'currentPositionTiltPercentage']),
57
+ write: (value) => command(CLUSTER.WINDOW_COVERING, 'goToTiltPercentage', requireNumber(value, 'tiltposition', 0, 100))
58
+ },
59
+ open: {
60
+ write: () => command(CLUSTER.WINDOW_COVERING, 'upOrOpen', false)
61
+ },
62
+ close: {
63
+ write: () => command(CLUSTER.WINDOW_COVERING, 'downOrClose', true)
64
+ },
65
+ stop: {
66
+ write: () => command(CLUSTER.WINDOW_COVERING, 'stopMotion', undefined)
67
+ },
68
+ setpoint: {
69
+ read: attribute(CLUSTER.THERMOSTAT, 'occupiedHeatingSetpoint'),
70
+ write: (value) => attribute(CLUSTER.THERMOSTAT, 'occupiedHeatingSetpoint', requireNumber(value, 'setpoint'), true)
71
+ },
72
+ coolingsetpoint: {
73
+ read: attribute(CLUSTER.THERMOSTAT, 'occupiedCoolingSetpoint'),
74
+ write: (value) => attribute(CLUSTER.THERMOSTAT, 'occupiedCoolingSetpoint', requireNumber(value, 'coolingsetpoint'), true)
75
+ },
76
+ currenttemp: {
77
+ read: attribute(CLUSTER.THERMOSTAT, 'localTemperature')
78
+ },
79
+ fanspeed: {
80
+ read: attribute(CLUSTER.FAN_CONTROL, ['percentCurrent', 'percentSetting']),
81
+ write: (value) => attribute(CLUSTER.FAN_CONTROL, 'percentSetting', requireNumber(value, 'fanspeed', 0, 100), true)
82
+ },
83
+ temperature: {
84
+ read: attribute(CLUSTER.TEMPERATURE, 'measuredValue')
85
+ },
86
+ humidity: {
87
+ read: attribute(CLUSTER.HUMIDITY, 'measuredValue')
88
+ },
89
+ illuminance: {
90
+ read: attribute(CLUSTER.ILLUMINANCE, 'measuredValue')
91
+ },
92
+ occupancy: {
93
+ read: attribute(CLUSTER.OCCUPANCY, 'occupancy')
94
+ },
95
+ contact: {
96
+ read: attribute(CLUSTER.BOOLEAN_STATE, 'stateValue')
97
+ },
98
+ battery: {
99
+ read: attribute(CLUSTER.POWER_SOURCE, 'batPercentRemaining')
100
+ },
101
+ activepower: {
102
+ read: attribute(CLUSTER.ELECTRICAL_POWER, 'activePower')
103
+ },
104
+ importedenergy: {
105
+ read: attribute(CLUSTER.ELECTRICAL_ENERGY, 'cumulativeEnergyImported')
106
+ },
107
+ identify: {
108
+ write: () => command(CLUSTER.IDENTIFY, 'identify', undefined)
109
+ }
110
+ }
111
+
112
+ const getInputStructure = (capabilities) => {
113
+ if (!capabilities || typeof capabilities !== 'object') return undefined
114
+ const structure = capabilities.inputStructure
115
+ return structure && Array.isArray(structure.clusters) ? structure : undefined
116
+ }
117
+
118
+ const targetSupported = (capabilities, candidate) => {
119
+ const structure = getInputStructure(capabilities)
120
+ if (!structure) return true
121
+ const cluster = structure.clusters.find((item) => Number(item.id) === Number(candidate.clusterId))
122
+ if (!cluster) return false
123
+ const collection = candidate.targetKind === 'command' ? cluster.commands : cluster.attributes
124
+ if (!Array.isArray(collection)) return false
125
+ return candidate.names.some((name) => collection.some((item) =>
126
+ normalizeToken(item.name) === normalizeToken(name) &&
127
+ (!candidate.writableOnly || item.writable === true)
128
+ ))
129
+ }
130
+
131
+ const materializeTarget = (capabilities, candidate) => {
132
+ if (!candidate) return undefined
133
+ const structure = getInputStructure(capabilities)
134
+ if (!structure) return { ...candidate, target: candidate.names[0] }
135
+ const cluster = structure.clusters.find((item) => Number(item.id) === Number(candidate.clusterId))
136
+ const collection = candidate.targetKind === 'command' ? cluster?.commands : cluster?.attributes
137
+ if (!Array.isArray(collection)) return undefined
138
+ for (const name of candidate.names) {
139
+ const found = collection.find((item) =>
140
+ normalizeToken(item.name) === normalizeToken(name) &&
141
+ (!candidate.writableOnly || item.writable === true)
142
+ )
143
+ if (found) return { ...candidate, target: found.name }
144
+ }
145
+ return undefined
146
+ }
147
+
148
+ const semanticPayload = (msg) => {
149
+ const payload = msg?.payload
150
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Buffer.isBuffer(payload)) return undefined
151
+ const rawFunction = payload.function ?? payload.fn
152
+ if (rawFunction === undefined || rawFunction === null || String(rawFunction).trim() === '') return undefined
153
+ return {
154
+ functionName: normalizeToken(rawFunction),
155
+ value: payload.value,
156
+ requestFromRemote: payload.requestFromRemote === true || payload.remote === true
157
+ }
158
+ }
159
+
160
+ const supportedSemanticFunctions = (capabilities) => Object.entries(SEMANTIC_FUNCTIONS)
161
+ .filter(([, definition]) => {
162
+ const readSupported = definition.read && targetSupported(capabilities, definition.read)
163
+ const writeSupported = definition.write && [undefined, 0, true, false, 'toggle'].some((value) => {
164
+ try {
165
+ return targetSupported(capabilities, definition.write(value))
166
+ } catch (error) {
167
+ return false
168
+ }
169
+ })
170
+ return readSupported || writeSupported
171
+ })
172
+ .map(([name]) => name)
173
+
174
+ const resolveSemanticInput = (msg, capabilities) => {
175
+ const input = semanticPayload(msg)
176
+ if (!input) return undefined
177
+ const definition = SEMANTIC_FUNCTIONS[input.functionName]
178
+ if (!definition) {
179
+ const supported = supportedSemanticFunctions(capabilities)
180
+ throw new Error(`Unsupported Matter function "${input.functionName}"${supported.length ? `. Supported: ${supported.join(', ')}` : ''}`)
181
+ }
182
+ const wantsWrite = input.value !== undefined || !definition.read
183
+ let candidate
184
+ if (wantsWrite) {
185
+ if (!definition.write) throw new Error(`Matter function "${input.functionName}" is read-only`)
186
+ candidate = definition.write(input.value)
187
+ } else {
188
+ candidate = definition.read
189
+ }
190
+ const resolved = materializeTarget(capabilities, candidate)
191
+ if (!resolved) throw new Error(`Matter endpoint does not support function "${input.functionName}"`)
192
+ return {
193
+ functionName: input.functionName,
194
+ requestFromRemote: input.requestFromRemote,
195
+ operation: wantsWrite ? 'write' : 'read',
196
+ value: resolved.value,
197
+ mapping: {
198
+ endpointId: msg.endpointId,
199
+ clusterId: resolved.clusterId,
200
+ targetKind: resolved.targetKind,
201
+ target: resolved.target
202
+ }
203
+ }
204
+ }
205
+
206
+ module.exports = {
207
+ SEMANTIC_FUNCTIONS,
208
+ normalizeToken,
209
+ resolveSemanticInput,
210
+ semanticPayload,
211
+ supportedSemanticFunctions
212
+ }
@@ -23,7 +23,12 @@ const normalizeHostForCompare = (value) => {
23
23
  // emitting 'connected', 'disconnected', 'event' (attribute changes), 'matterEvent' (cluster events),
24
24
  // 'nodeState' and 'structureChanged'.
25
25
  class classMatter extends EventEmitter {
26
- constructor (_storagePath, _instanceId, _fabricLabel, _sysLogger, { startQueue = true } = {}) {
26
+ constructor (_storagePath, _instanceId, _fabricLabel, _sysLogger, {
27
+ startQueue = true,
28
+ commandTimeoutMs = 15000,
29
+ queuePollMs = 150,
30
+ queueMaxConcurrent = 32
31
+ } = {}) {
27
32
  super()
28
33
  this.matterConnectionStatus = 'disconnected'
29
34
  this.storagePath = _storagePath
@@ -33,7 +38,14 @@ class classMatter extends EventEmitter {
33
38
  this.controller = null
34
39
  this.pairedNodes = new Map() // nodeId (string) -> PairedNode
35
40
  this.commandQueue = []
41
+ // Commands remain strictly ordered per Matter node, but different nodes use
42
+ // independent lanes. An unreachable device must never head-of-line block every
43
+ // other endpoint sharing this controller/fabric.
44
+ this.commandQueueInFlight = new Map()
36
45
  this.queueMaxLength = 2000
46
+ this.queueMaxConcurrent = Math.max(1, Number(queueMaxConcurrent) || 32)
47
+ this.commandTimeoutMs = Math.max(10, Number(commandTimeoutMs) || 15000)
48
+ this.queuePollMs = Math.max(10, Number(queuePollMs) || 150)
37
49
  this.exitAllQueues = false
38
50
  this._api = null // Lazy loaded matter.js exports
39
51
  this._logThrottle = new Map()
@@ -75,7 +87,8 @@ class classMatter extends EventEmitter {
75
87
  if (this._api !== null) return this._api
76
88
  const { Environment, StorageService, Logger, LogLevel } = await import('@matter/main')
77
89
  const { Seconds } = await import('@matter/general')
78
- const { CommissioningController, NodeStates } = await import('@project-chip/matter.js')
90
+ const { CommissioningController } = await import('@project-chip/matter.js')
91
+ const { NodeStates } = await import('@project-chip/matter.js/device')
79
92
  const { ManualPairingCodeCodec, QrPairingCodeCodec, NodeId } = await import('@matter/main/types')
80
93
  const { BasicInformation, GeneralCommissioning } = await import('@matter/main/clusters')
81
94
  this._api = {
@@ -505,12 +518,18 @@ class classMatter extends EventEmitter {
505
518
  if (typeof cc.isAttributeSupportedByName === 'function' && !cc.isAttributeSupportedByName(attrName)) return
506
519
  // Skip the global meta attributes (FeatureMap, AttributeList, ClusterRevision...):
507
520
  // they are useless for KNX mappings and only confuse the user.
508
- if (Number(cc.attributes[attrName].id) >= 65528) return
521
+ const exposeFeatureMap = [47, 768].includes(Number(cc.id)) && attrName === 'featureMap'
522
+ if (Number(cc.attributes[attrName].id) >= 65528 && !exposeFeatureMap) return
509
523
  let value
510
524
  try {
511
525
  value = cc.attributes[attrName].getLocal()
512
526
  } catch (error) { value = undefined }
513
- attributes.push({ name: attrName, id: Number(cc.attributes[attrName].id), value: this._jsonSafe(value) })
527
+ attributes.push({
528
+ name: attrName,
529
+ id: Number(cc.attributes[attrName].id),
530
+ value: this._jsonSafe(value),
531
+ writable: cc.attributes[attrName]?.attribute?.schema?.writable === true
532
+ })
514
533
  } catch (error) { /* empty */ }
515
534
  })
516
535
  const commands = []
@@ -617,56 +636,140 @@ class classMatter extends EventEmitter {
617
636
  return true
618
637
  }
619
638
 
639
+ _isNodeCommissioned = (nodeId) => {
640
+ const key = String(nodeId)
641
+ try {
642
+ if (this.controller && typeof this.controller.getCommissionedNodes === 'function') {
643
+ return this.controller.getCommissionedNodes().some((commissionedNodeId) => commissionedNodeId.toString() === key)
644
+ }
645
+ } catch (error) { /* Fall back to the attached-node map while the controller changes state. */ }
646
+ return this.pairedNodes.has(key)
647
+ }
648
+
620
649
  // Add a command or attribute write to the queue.
621
650
  // _item: { nodeId, endpointId, clusterId, kind: 'command'|'attributeWrite', name, args }
622
651
  writeMatterQueueAdd = async (_item) => {
623
- return this._enqueueCommand(_item)
652
+ const nodeId = _item?.nodeId === undefined || _item?.nodeId === null ? '' : String(_item.nodeId)
653
+ const item = { ..._item, nodeId }
654
+ if (nodeId === '' || !this._isNodeCommissioned(nodeId)) {
655
+ const error = new Error(`Matter node ${nodeId || '(empty)'} is no longer commissioned`)
656
+ error.code = 'MATTER_NODE_NOT_COMMISSIONED'
657
+ // Defer the notification so a legacy fire-and-forget caller cannot overwrite
658
+ // the red orphan-device status with its immediate optimistic "command sent" state.
659
+ queueMicrotask(() => this.emit('commandError', { item, error: error.message, code: error.code }))
660
+ throw error
661
+ }
662
+ return this._enqueueCommand(item)
624
663
  }
625
664
 
626
665
  deleteMatterQueue = async (_nodeIdString) => {
627
- this.commandQueue = this.commandQueue.filter((el) => el.nodeId !== _nodeIdString)
666
+ const nodeId = String(_nodeIdString)
667
+ this.commandQueue = this.commandQueue.filter((el) => String(el.nodeId) !== nodeId)
628
668
  }
629
669
 
630
- processQueueItem = async () => {
631
- let item = null
670
+ _withCommandTimeout = async (operation) => {
671
+ let timer
672
+ try {
673
+ return await Promise.race([
674
+ Promise.resolve().then(operation),
675
+ new Promise((resolve, reject) => {
676
+ timer = setTimeout(() => {
677
+ const error = new Error(`Matter command timed out after ${this.commandTimeoutMs} ms`)
678
+ error.code = 'MATTER_COMMAND_TIMEOUT'
679
+ reject(error)
680
+ }, this.commandTimeoutMs)
681
+ timer.unref?.()
682
+ })
683
+ ])
684
+ } finally {
685
+ if (timer !== undefined) clearTimeout(timer)
686
+ }
687
+ }
688
+
689
+ processQueueItem = async (_item = undefined) => {
690
+ let item = _item ?? null
632
691
  try {
633
692
  if (this.matterConnectionStatus !== 'connected') return
634
- item = this.commandQueue.pop()
693
+ if (item === null) item = this.commandQueue.pop()
635
694
  if (!item) return
636
695
  const node = this.pairedNodes.get(item.nodeId)
637
- if (node === undefined) throw new Error(`Matter node ${item.nodeId} unknown or not yet connected`)
696
+ if (node === undefined || !this._isNodeCommissioned(item.nodeId)) {
697
+ const error = new Error(`Matter node ${item.nodeId} is no longer commissioned`)
698
+ error.code = 'MATTER_NODE_NOT_COMMISSIONED'
699
+ throw error
700
+ }
638
701
  const clusterClient = this._findClusterClient(node, item.endpointId, item.clusterId)
639
702
  if (clusterClient === undefined) throw new Error(`Cluster ${item.clusterId} not found on endpoint ${item.endpointId} of node ${item.nodeId}`)
640
703
  if (item.kind === 'attributeWrite') {
641
704
  const attribute = clusterClient.attributes[item.name]
642
705
  if (attribute === undefined) throw new Error(`Attribute ${item.name} not found in cluster ${clusterClient.name}`)
643
- await attribute.set(item.args)
706
+ await this._withCommandTimeout(() => attribute.set(item.args))
644
707
  } else {
645
708
  const command = clusterClient.commands[item.name]
646
709
  if (typeof command !== 'function') throw new Error(`Command ${item.name} not found in cluster ${clusterClient.name}`)
647
- await command(item.args)
710
+ await this._withCommandTimeout(() => command(item.args))
648
711
  }
649
712
  } catch (error) {
650
713
  const target = item ? `${item.nodeId}/${item.endpointId}/${item.clusterId}/${item.name}` : 'unknown'
714
+ if (!error.code && item) {
715
+ let connectionState = 'unknown'
716
+ try {
717
+ const targetNode = this.pairedNodes.get(item.nodeId)
718
+ if (targetNode?.connectionState !== undefined) connectionState = this.nodeStateToString(targetNode.connectionState)
719
+ } catch (stateError) { /* empty */ }
720
+ if (connectionState !== 'unknown' && connectionState !== 'connected') {
721
+ error.code = 'MATTER_NODE_UNAVAILABLE'
722
+ } else if (/disconnected|reconnecting|unreachable|no session|peer communication|network.*(?:failed|error)|timed? ?out/i.test(error.message || '')) {
723
+ error.code = 'MATTER_NODE_UNAVAILABLE'
724
+ }
725
+ }
651
726
  this._logThrottled('error', `matter:queue:error:${target}`, `classMatter: processQueueItem (${target}): ${error.message}`, 30000)
652
- this.emit('commandError', { item, error: error.message })
727
+ this.emit('commandError', { item, error: error.message, code: error.code })
728
+ }
729
+ }
730
+
731
+ _takeNextQueueItem = () => {
732
+ if (!Array.isArray(this.commandQueue) || this.commandQueue.length === 0) return undefined
733
+ // New items are unshifted, therefore the oldest FIFO item is at the end.
734
+ // Walk backwards until a device lane that is not already busy is found.
735
+ for (let index = this.commandQueue.length - 1; index >= 0; index--) {
736
+ const item = this.commandQueue[index]
737
+ const nodeId = String(item?.nodeId ?? '')
738
+ if (this.commandQueueInFlight.has(nodeId)) continue
739
+ return this.commandQueue.splice(index, 1)[0]
740
+ }
741
+ return undefined
742
+ }
743
+
744
+ _dispatchQueueItems = () => {
745
+ if (this.matterConnectionStatus !== 'connected') return
746
+ while (this.commandQueueInFlight.size < this.queueMaxConcurrent) {
747
+ const item = this._takeNextQueueItem()
748
+ if (!item) return
749
+ const nodeId = String(item.nodeId)
750
+ const laneToken = {}
751
+ this.commandQueueInFlight.set(nodeId, laneToken)
752
+ Promise.resolve(this.processQueueItem(item))
753
+ .catch((error) => this._logThrottled('error', `matter:queue:lane:${nodeId}`, `classMatter: queue lane ${nodeId}: ${error.message}`, 30000))
754
+ .finally(() => {
755
+ if (this.commandQueueInFlight.get(nodeId) === laneToken) this.commandQueueInFlight.delete(nodeId)
756
+ })
653
757
  }
654
758
  }
655
759
 
656
760
  handleQueue = async () => {
657
761
  do {
658
762
  if (this.matterConnectionStatus === 'connected' && this.commandQueue && this.commandQueue.length > 0) {
659
- try {
660
- await this.processQueueItem()
661
- } catch (error) { /* empty */ }
763
+ this._dispatchQueueItems()
662
764
  }
663
- await pleaseWait(150)
765
+ await pleaseWait(this.queuePollMs)
664
766
  } while (!this.exitAllQueues)
665
767
  }
666
768
 
667
769
  close = async () => {
668
770
  this.exitAllQueues = true
669
771
  this.commandQueue = []
772
+ this.commandQueueInFlight.clear()
670
773
  this._logThrottle.clear()
671
774
  this.matterConnectionStatus = 'disconnected'
672
775
  try {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.0.11",
6
+ "version": "6.1.0",
7
7
  "description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
8
8
  "files": [
9
9
  "nodes/",
@@ -0,0 +1,98 @@
1
+ (function (root, factory) {
2
+ const api = factory()
3
+ if (typeof module === 'object' && module.exports) module.exports = api
4
+ if (root) root.KNXUltimateMatterQrScanner = api
5
+ }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
6
+ 'use strict'
7
+
8
+ function normalizeMatterQrPayload (rawValue) {
9
+ const value = typeof rawValue === 'string' ? rawValue.trim() : ''
10
+ if (!/^MT:[0-9A-Z.-]+$/.test(value)) return null
11
+ return value
12
+ }
13
+
14
+ function getResultText (result) {
15
+ if (result && typeof result.getText === 'function') return result.getText()
16
+ if (result && typeof result.text === 'string') return result.text
17
+ return ''
18
+ }
19
+
20
+ function getCameraStatus (browserWindow, browserNavigator) {
21
+ if (!browserWindow || browserWindow.isSecureContext !== true) {
22
+ return { available: false, reason: 'insecure' }
23
+ }
24
+ if (!browserNavigator || !browserNavigator.mediaDevices || typeof browserNavigator.mediaDevices.getUserMedia !== 'function') {
25
+ return { available: false, reason: 'unsupported' }
26
+ }
27
+ return { available: true, reason: null }
28
+ }
29
+
30
+ function stopVideo (videoElement) {
31
+ if (!videoElement) return
32
+ const stream = videoElement.srcObject
33
+ if (stream && typeof stream.getTracks === 'function') {
34
+ stream.getTracks().forEach(function (track) {
35
+ try {
36
+ track.stop()
37
+ } catch (error) { /* empty */ }
38
+ })
39
+ }
40
+ try {
41
+ videoElement.pause()
42
+ } catch (error) { /* empty */ }
43
+ try {
44
+ videoElement.srcObject = null
45
+ } catch (error) { /* empty */ }
46
+ }
47
+
48
+ function isCameraPermissionError (error) {
49
+ return Boolean(error && (error.name === 'NotAllowedError' || error.name === 'SecurityError'))
50
+ }
51
+
52
+ function invertCanvasPixels (canvas) {
53
+ if (!canvas || typeof canvas.getContext !== 'function') throw new Error('Canvas is not available')
54
+ let context
55
+ try {
56
+ context = canvas.getContext('2d', { willReadFrequently: true })
57
+ } catch (error) {
58
+ context = canvas.getContext('2d')
59
+ }
60
+ if (!context) throw new Error('Canvas 2D context is not available')
61
+ const imageData = context.getImageData(0, 0, canvas.width, canvas.height)
62
+ for (let index = 0; index < imageData.data.length; index += 4) {
63
+ imageData.data[index] = 255 - imageData.data[index]
64
+ imageData.data[index + 1] = 255 - imageData.data[index + 1]
65
+ imageData.data[index + 2] = 255 - imageData.data[index + 2]
66
+ }
67
+ context.putImageData(imageData, 0, 0)
68
+ return canvas
69
+ }
70
+
71
+ function enableInvertedQrFallback (reader) {
72
+ if (!reader || typeof reader.decodeFromCanvas !== 'function') throw new Error('QR reader is not available')
73
+ const decodeFromCanvas = reader.decodeFromCanvas.bind(reader)
74
+ reader.decodeFromCanvas = function (canvas) {
75
+ try {
76
+ return decodeFromCanvas(canvas)
77
+ } catch (normalError) {
78
+ try {
79
+ invertCanvasPixels(canvas)
80
+ return decodeFromCanvas(canvas)
81
+ } catch (invertedError) {
82
+ throw normalError
83
+ }
84
+ }
85
+ }
86
+ return reader
87
+ }
88
+
89
+ return {
90
+ enableInvertedQrFallback,
91
+ getCameraStatus,
92
+ getResultText,
93
+ invertCanvasPixels,
94
+ isCameraPermissionError,
95
+ normalizeMatterQrPayload,
96
+ stopVideo
97
+ }
98
+ }))