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.
- package/CHANGELOG.md +11 -3
- package/nodes/knxUltimateHueLight.html +1 -5
- package/nodes/knxUltimateMatterControllerDevice.html +584 -47
- package/nodes/knxUltimateMatterControllerDevice.js +37 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/de/knxUltimateMatterControllerDevice.html +23 -3
- package/nodes/locales/de/knxUltimateMatterControllerDevice.json +39 -1
- package/nodes/locales/de/matter-config.html +11 -1
- package/nodes/locales/de/matter-config.json +12 -0
- package/nodes/locales/en/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/en/knxUltimateMatterControllerDevice.html +23 -3
- package/nodes/locales/en/knxUltimateMatterControllerDevice.json +39 -1
- package/nodes/locales/en/matter-config.html +11 -1
- package/nodes/locales/en/matter-config.json +12 -0
- package/nodes/locales/es/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/es/knxUltimateMatterControllerDevice.html +23 -3
- package/nodes/locales/es/knxUltimateMatterControllerDevice.json +39 -1
- package/nodes/locales/es/matter-config.html +11 -1
- package/nodes/locales/es/matter-config.json +12 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +23 -3
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +47 -1
- package/nodes/locales/fr/matter-config.html +11 -1
- package/nodes/locales/fr/matter-config.json +12 -0
- package/nodes/locales/it/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/it/knxUltimateMatterControllerDevice.html +23 -3
- package/nodes/locales/it/knxUltimateMatterControllerDevice.json +39 -1
- package/nodes/locales/it/matter-config.html +11 -1
- package/nodes/locales/it/matter-config.json +12 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +23 -3
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +47 -1
- package/nodes/locales/zh-CN/matter-config.html +11 -1
- package/nodes/locales/zh-CN/matter-config.json +12 -0
- package/nodes/matter-config.html +215 -23
- package/nodes/matter-config.js +53 -6
- package/nodes/utils/matterControllerCommandGate.js +47 -0
- package/nodes/utils/matterControllerProfiles/doorLock.js +19 -4
- package/nodes/utils/matterControllerProfiles/fan.js +0 -1
- package/nodes/utils/matterControllerProfiles/index.js +3 -1
- package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +32 -15
- package/nodes/utils/matterControllerProfiles/switch.js +0 -1
- package/nodes/utils/matterControllerProfiles/thermostat.js +0 -1
- package/nodes/utils/matterControllerProfiles/universal.js +462 -0
- package/nodes/utils/matterControllerProfiles/windowCovering.js +0 -1
- package/nodes/utils/matterControllerSemanticInput.js +212 -0
- package/nodes/utils/matterEngine.mjs +120 -17
- package/package.json +1 -1
- package/resources/matterQrScanner.js +98 -0
- package/resources/zxing-browser-0.2.0.min.js +1 -0
- 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, {
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
666
|
+
const nodeId = String(_nodeIdString)
|
|
667
|
+
this.commandQueue = this.commandQueue.filter((el) => String(el.nodeId) !== nodeId)
|
|
628
668
|
}
|
|
629
669
|
|
|
630
|
-
|
|
631
|
-
let
|
|
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
|
|
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
|
-
|
|
660
|
-
await this.processQueueItem()
|
|
661
|
-
} catch (error) { /* empty */ }
|
|
763
|
+
this._dispatchQueueItems()
|
|
662
764
|
}
|
|
663
|
-
await pleaseWait(
|
|
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
|
|
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
|
+
}))
|