node-red-contrib-knx-ultimate 6.0.5 → 6.0.7
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 +8 -2
- package/README.md +1 -1
- package/nodes/commonFunctions.js +33 -0
- package/nodes/icons/node-matter-bridge-icon.svg +6 -0
- package/nodes/icons/node-matter-controller-icon.svg +6 -0
- package/nodes/knxUltimate-config.js +5 -1
- package/nodes/knxUltimateMatterBridge.html +1 -6
- package/nodes/knxUltimateMatterControllerDevice.html +156 -16
- package/nodes/knxUltimateMatterControllerDevice.js +5 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/de/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/de/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/de/matter-config.html +4 -0
- package/nodes/locales/de/matter-config.json +7 -2
- package/nodes/locales/de/matterbridge-config.html +4 -0
- package/nodes/locales/de/matterbridge-config.json +4 -1
- package/nodes/locales/en/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/en/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/en/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/en/matter-config.html +4 -0
- package/nodes/locales/en/matter-config.json +7 -2
- package/nodes/locales/en/matterbridge-config.html +4 -0
- package/nodes/locales/en/matterbridge-config.json +4 -1
- package/nodes/locales/es/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/es/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/es/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/es/matter-config.html +4 -0
- package/nodes/locales/es/matter-config.json +7 -2
- package/nodes/locales/es/matterbridge-config.html +4 -0
- package/nodes/locales/es/matterbridge-config.json +4 -1
- package/nodes/locales/fr/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/fr/matter-config.html +4 -0
- package/nodes/locales/fr/matter-config.json +7 -2
- package/nodes/locales/fr/matterbridge-config.html +4 -0
- package/nodes/locales/fr/matterbridge-config.json +4 -1
- package/nodes/locales/it/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/it/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/it/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/it/matter-config.html +4 -0
- package/nodes/locales/it/matter-config.json +7 -2
- package/nodes/locales/it/matterbridge-config.html +4 -0
- package/nodes/locales/it/matterbridge-config.json +4 -1
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/zh-CN/matter-config.html +4 -0
- package/nodes/locales/zh-CN/matter-config.json +7 -2
- package/nodes/locales/zh-CN/matterbridge-config.html +4 -0
- package/nodes/locales/zh-CN/matterbridge-config.json +4 -1
- package/nodes/matter-config.html +59 -18
- package/nodes/matter-config.js +33 -0
- package/nodes/matterbridge-config.html +73 -3
- package/nodes/matterbridge-config.js +38 -0
- package/nodes/utils/matterControllerProfiles/doorLock.js +210 -0
- package/nodes/utils/matterControllerProfiles/index.js +21 -0
- package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +148 -0
- package/nodes/utils/matterStorageBackup.js +100 -0
- package/package.json +6 -2
- package/resources/htmlUtils.js +11 -0
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
"deploy_first": "请先部署此配置节点,然后点击刷新。",
|
|
17
17
|
"reset_button": "重置配对",
|
|
18
18
|
"reset_confirm": "移除所有已配对的控制器并重新开始配对?设备将从 Alexa/Google/Apple 中消失。",
|
|
19
|
-
"reset_ok": "配对已重置。桥接器重新进入配对模式。"
|
|
19
|
+
"reset_ok": "配对已重置。桥接器重新进入配对模式。",
|
|
20
|
+
"storage_title": "Fabric 和配网备份", "storage_export": "导出", "storage_import": "导入",
|
|
21
|
+
"storage_warning": "备份包含 Fabric 凭据、私钥和配对数据。请像密码一样妥善保管。导入会替换此网桥的 Matter 存储并短暂重启。",
|
|
22
|
+
"storage_import_confirm": "要用所选备份替换此网桥的 Matter Fabric 和已配对控制器吗?", "storage_import_ok": "Matter 备份已导入,网桥已重启。"
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
}
|
package/nodes/matter-config.html
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<script type="text/javascript">
|
|
4
4
|
RED.nodes.registerType('matter-config', {
|
|
5
5
|
category: 'config',
|
|
6
|
+
icon: 'node-matter-controller-icon.svg',
|
|
6
7
|
defaults: {
|
|
7
8
|
name: { value: "Matter Controller" },
|
|
8
9
|
fabricLabel: { value: "KNX-Ultimate" }
|
|
@@ -20,6 +21,23 @@
|
|
|
20
21
|
var $devicesBody = $("#matter-devices-body");
|
|
21
22
|
var $refreshDevices = $("#matterRefreshDevices");
|
|
22
23
|
var $pairSpinner = $("#matterPairSpinner");
|
|
24
|
+
var $storageExport = $('#matter-storage-export');
|
|
25
|
+
var $storageImport = $('#matter-storage-import');
|
|
26
|
+
var $storageFile = $('#matter-storage-file');
|
|
27
|
+
|
|
28
|
+
function storageExportUrl() {
|
|
29
|
+
var adminRoot = (RED.settings && typeof RED.settings.httpAdminRoot === 'string') ? RED.settings.httpAdminRoot : '/';
|
|
30
|
+
adminRoot = String(adminRoot || '/').trim();
|
|
31
|
+
adminRoot = adminRoot === '/' ? '' : '/' + adminRoot.replace(/^\/+|\/+$/g, '');
|
|
32
|
+
var params = new URLSearchParams();
|
|
33
|
+
params.set('serverId', node.id);
|
|
34
|
+
params.set('_', String(Date.now()));
|
|
35
|
+
try {
|
|
36
|
+
var tokens = (RED.settings && typeof RED.settings.get === 'function') ? RED.settings.get('auth-tokens') : null;
|
|
37
|
+
if (tokens && typeof tokens.access_token === 'string' && tokens.access_token.trim() !== '') params.set('access_token', tokens.access_token.trim());
|
|
38
|
+
} catch (error) { }
|
|
39
|
+
return adminRoot + '/KNXUltimateMatterStorageExport?' + params.toString();
|
|
40
|
+
}
|
|
23
41
|
|
|
24
42
|
function notifyPairingError(msg) {
|
|
25
43
|
var text = RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.pairing_failed") + ': ' + (msg || '');
|
|
@@ -148,6 +166,32 @@
|
|
|
148
166
|
});
|
|
149
167
|
|
|
150
168
|
loadDevices();
|
|
169
|
+
|
|
170
|
+
$storageExport.on('click', function () {
|
|
171
|
+
window.location = storageExportUrl();
|
|
172
|
+
});
|
|
173
|
+
$storageImport.on('click', function () { $storageFile.trigger('click'); });
|
|
174
|
+
$storageFile.on('change', function () {
|
|
175
|
+
var file = this.files && this.files[0];
|
|
176
|
+
this.value = '';
|
|
177
|
+
if (!file || !window.confirm(RED._('node-red-contrib-knx-ultimate/matter-config:matter-config.properties.storage_import_confirm'))) return;
|
|
178
|
+
var reader = new FileReader();
|
|
179
|
+
reader.onload = function () {
|
|
180
|
+
var backup;
|
|
181
|
+
try { backup = JSON.parse(reader.result); } catch (error) { RED.notify(error.message, { type: 'error' }); return; }
|
|
182
|
+
$storageImport.prop('disabled', true);
|
|
183
|
+
$.ajax({
|
|
184
|
+
url: 'KNXUltimateMatterStorageImport?serverId=' + encodeURIComponent(node.id),
|
|
185
|
+
method: 'POST', contentType: 'application/json', data: JSON.stringify(backup)
|
|
186
|
+
}).done(function () {
|
|
187
|
+
RED.notify(RED._('node-red-contrib-knx-ultimate/matter-config:matter-config.properties.storage_import_ok'), { type: 'success' });
|
|
188
|
+
loadDevices();
|
|
189
|
+
}).fail(function (xhr) {
|
|
190
|
+
RED.notify((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText, { type: 'error', fixed: true });
|
|
191
|
+
}).always(function () { $storageImport.prop('disabled', false); });
|
|
192
|
+
};
|
|
193
|
+
reader.readAsText(file);
|
|
194
|
+
});
|
|
151
195
|
},
|
|
152
196
|
oneditsave: function () {
|
|
153
197
|
// Return to the info tab
|
|
@@ -167,7 +211,6 @@
|
|
|
167
211
|
</div>
|
|
168
212
|
|
|
169
213
|
<div class="form-row">
|
|
170
|
-
<b><span data-i18n="matter-config.properties.title"></span></b>
|
|
171
214
|
<p align="center" style="margin:8px 0;">
|
|
172
215
|
<svg width="70" height="77" viewBox="0 8 40 44" xmlns="http://www.w3.org/2000/svg" aria-label="Matter" style="vertical-align:middle;">
|
|
173
216
|
<g fill="currentColor" stroke="none">
|
|
@@ -187,9 +230,8 @@
|
|
|
187
230
|
</g>
|
|
188
231
|
</svg>
|
|
189
232
|
<br/>
|
|
190
|
-
<span style="font-size:18px; font-weight:600; letter-spacing:1px;"
|
|
233
|
+
<span style="font-size:18px; font-weight:600; letter-spacing:1px;" data-i18n="matter-config.properties.title"></span>
|
|
191
234
|
</p>
|
|
192
|
-
<p><span data-i18n="matter-config.properties.intro"></span></p>
|
|
193
235
|
</div>
|
|
194
236
|
|
|
195
237
|
<div class="form-row">
|
|
@@ -210,9 +252,17 @@
|
|
|
210
252
|
|
|
211
253
|
<hr/>
|
|
212
254
|
|
|
255
|
+
<div class="form-row">
|
|
256
|
+
<b><i class="fa fa-database"></i> <span data-i18n="matter-config.properties.storage_title"></span></b>
|
|
257
|
+
<button type="button" id="matter-storage-export" class="red-ui-button" style="margin-left:8px;"><i class="fa fa-download"></i> <span data-i18n="matter-config.properties.storage_export"></span></button>
|
|
258
|
+
<button type="button" id="matter-storage-import" class="red-ui-button"><i class="fa fa-upload"></i> <span data-i18n="matter-config.properties.storage_import"></span></button>
|
|
259
|
+
<input type="file" id="matter-storage-file" accept="application/json,.json" style="display:none;">
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<hr/>
|
|
263
|
+
|
|
213
264
|
<div class="form-row">
|
|
214
265
|
<b><i class="fa fa-qrcode"></i> <span data-i18n="matter-config.properties.pairing_title"></span></b>
|
|
215
|
-
<p><span data-i18n="matter-config.properties.pairing_help"></span></p>
|
|
216
266
|
</div>
|
|
217
267
|
|
|
218
268
|
<div class="form-row">
|
|
@@ -236,15 +286,6 @@
|
|
|
236
286
|
</label>
|
|
237
287
|
<input type="text" id="matter-pairing-name" placeholder="Kitchen plug" style="width:250px;">
|
|
238
288
|
</div>
|
|
239
|
-
<div class="form-tips" style="margin: 4px 0 8px; font-size:12px;">
|
|
240
|
-
<i class="fa fa-circle-info" style="color:#1b7d33; margin-right:4px;"></i>
|
|
241
|
-
<span data-i18n="matter-config.properties.pairing_hint">Prefer the QR code (MT:…): it carries the full device discriminator. The manual code
|
|
242
|
-
stores only a short discriminator and cannot reliably tell identical devices apart
|
|
243
|
-
(e.g. several Shelly of the same model), which can pair the wrong one or report it as
|
|
244
|
-
already commissioned. Pair one device at a time; if a device is already paired
|
|
245
|
-
elsewhere, factory-reset it first.</span>
|
|
246
|
-
</div>
|
|
247
|
-
|
|
248
289
|
<hr/>
|
|
249
290
|
|
|
250
291
|
<div class="form-row">
|
|
@@ -270,9 +311,9 @@
|
|
|
270
311
|
</div>
|
|
271
312
|
|
|
272
313
|
<br/>
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
314
|
+
<br/>
|
|
315
|
+
<br/>
|
|
316
|
+
<br/>
|
|
317
|
+
<br/>
|
|
318
|
+
<br/>
|
|
278
319
|
</script>
|
package/nodes/matter-config.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/* eslint-disable max-len */
|
|
2
2
|
const path = require('path')
|
|
3
|
+
const { exportMatterStorage, importMatterStorage } = require('./utils/matterStorageBackup')
|
|
4
|
+
const matterPackageVersion = require('@matter/main').version
|
|
5
|
+
const { Specification } = require('@matter/model')
|
|
3
6
|
|
|
4
7
|
// 10/09/2024 Setup the color logger
|
|
5
8
|
const loggerSetup = (options) => {
|
|
@@ -18,6 +21,7 @@ module.exports = (RED) => {
|
|
|
18
21
|
node.sysLogger = null
|
|
19
22
|
node.matterManager = null
|
|
20
23
|
node.timerMatterConfigCheckState = null
|
|
24
|
+
node.storageOperation = null
|
|
21
25
|
try {
|
|
22
26
|
node.sysLogger = loggerSetup({ loglevel: node.loglevel, setPrefix: 'matter-config.js' })
|
|
23
27
|
} catch (error) { console.log(error.stack) }
|
|
@@ -190,6 +194,35 @@ module.exports = (RED) => {
|
|
|
190
194
|
if (node.matterManager === null) throw new Error('Matter controller not started')
|
|
191
195
|
return node.matterManager.renameNode(_nodeId, _label)
|
|
192
196
|
}
|
|
197
|
+
|
|
198
|
+
const withStoppedController = async (operation) => {
|
|
199
|
+
if (node.storageOperation !== null) throw new Error('Another Matter storage operation is already running')
|
|
200
|
+
node.storageOperation = (async () => {
|
|
201
|
+
if (node.matterManager !== null) await node.matterManager.close()
|
|
202
|
+
node.matterManager = null
|
|
203
|
+
try {
|
|
204
|
+
return await operation()
|
|
205
|
+
} finally {
|
|
206
|
+
await node.initMatterConnection()
|
|
207
|
+
}
|
|
208
|
+
})()
|
|
209
|
+
try { return await node.storageOperation } finally { node.storageOperation = null }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
node.exportMatterStorage = () => withStoppedController(() => exportMatterStorage({
|
|
213
|
+
storagePath: node.matterStoragePath,
|
|
214
|
+
instanceId: node.matterInstanceId,
|
|
215
|
+
kind: 'controller',
|
|
216
|
+
protocolVersion: Specification.REVISION,
|
|
217
|
+
packageVersion: matterPackageVersion
|
|
218
|
+
}))
|
|
219
|
+
|
|
220
|
+
node.importMatterStorage = (backup) => withStoppedController(() => importMatterStorage({
|
|
221
|
+
storagePath: node.matterStoragePath,
|
|
222
|
+
instanceId: node.matterInstanceId,
|
|
223
|
+
kind: 'controller',
|
|
224
|
+
backup
|
|
225
|
+
}))
|
|
193
226
|
// END functions called from the nodes -------------------------------------------------------------
|
|
194
227
|
|
|
195
228
|
node.addClient = (_Node) => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<script type="text/javascript">
|
|
4
4
|
RED.nodes.registerType('matterbridge-config', {
|
|
5
5
|
category: 'config',
|
|
6
|
+
icon: 'node-matter-bridge-icon.svg',
|
|
6
7
|
defaults: {
|
|
7
8
|
name: { value: '' },
|
|
8
9
|
bridgeDeviceName: { value: '' },
|
|
@@ -15,6 +16,21 @@
|
|
|
15
16
|
var $pairingBody = $('#mbc-pairing-body');
|
|
16
17
|
var $refreshPairing = $('#mbc-refresh-pairing');
|
|
17
18
|
var $resetButton = $('#mbc-factory-reset');
|
|
19
|
+
var $storageFile = $('#mbc-storage-file');
|
|
20
|
+
|
|
21
|
+
function storageExportUrl() {
|
|
22
|
+
var adminRoot = (RED.settings && typeof RED.settings.httpAdminRoot === 'string') ? RED.settings.httpAdminRoot : '/';
|
|
23
|
+
adminRoot = String(adminRoot || '/').trim();
|
|
24
|
+
adminRoot = adminRoot === '/' ? '' : '/' + adminRoot.replace(/^\/+|\/+$/g, '');
|
|
25
|
+
var params = new URLSearchParams();
|
|
26
|
+
params.set('configId', node.id);
|
|
27
|
+
params.set('_', String(Date.now()));
|
|
28
|
+
try {
|
|
29
|
+
var tokens = (RED.settings && typeof RED.settings.get === 'function') ? RED.settings.get('auth-tokens') : null;
|
|
30
|
+
if (tokens && typeof tokens.access_token === 'string' && tokens.access_token.trim() !== '') params.set('access_token', tokens.access_token.trim());
|
|
31
|
+
} catch (error) { }
|
|
32
|
+
return adminRoot + '/KNXUltimateMatterBridgeStorageExport?' + params.toString();
|
|
33
|
+
}
|
|
18
34
|
|
|
19
35
|
var t = function (key) {
|
|
20
36
|
return RED._('node-red-contrib-knx-ultimate/matterbridge-config:matterbridge-config.properties.' + key);
|
|
@@ -37,7 +53,6 @@
|
|
|
37
53
|
info.fabrics.forEach(function (f) {
|
|
38
54
|
$pairingBody.append($('<div style="margin-left:10px;"></div>').text('• ' + (f.label || 'Controller') + ' (vendor ' + f.vendorId + ')'));
|
|
39
55
|
});
|
|
40
|
-
$pairingBody.append($('<p style="max-width:760px; margin:14px auto 0; color:#777;"></p>').text(t('commissioned_hint')));
|
|
41
56
|
$resetButton.show();
|
|
42
57
|
return;
|
|
43
58
|
}
|
|
@@ -69,6 +84,26 @@
|
|
|
69
84
|
loadPairingInfo();
|
|
70
85
|
}).always(function () { $resetButton.prop('disabled', false); });
|
|
71
86
|
});
|
|
87
|
+
$('#mbc-storage-export').on('click', function () {
|
|
88
|
+
window.location = storageExportUrl();
|
|
89
|
+
});
|
|
90
|
+
$('#mbc-storage-import').on('click', function () { $storageFile.trigger('click'); });
|
|
91
|
+
$storageFile.on('change', function () {
|
|
92
|
+
var file = this.files && this.files[0];
|
|
93
|
+
this.value = '';
|
|
94
|
+
if (!file || !window.confirm(t('storage_import_confirm'))) return;
|
|
95
|
+
var reader = new FileReader();
|
|
96
|
+
reader.onload = function () {
|
|
97
|
+
var backup;
|
|
98
|
+
try { backup = JSON.parse(reader.result); } catch (error) { RED.notify(error.message, { type: 'error' }); return; }
|
|
99
|
+
$('#mbc-storage-import').prop('disabled', true);
|
|
100
|
+
$.ajax({ url: 'KNXUltimateMatterBridgeStorageImport?configId=' + encodeURIComponent(node.id), method: 'POST', contentType: 'application/json', data: JSON.stringify(backup) })
|
|
101
|
+
.done(function () { RED.notify(t('storage_import_ok'), { type: 'success' }); loadPairingInfo(); })
|
|
102
|
+
.fail(function (xhr) { RED.notify((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText, { type: 'error', fixed: true }); })
|
|
103
|
+
.always(function () { $('#mbc-storage-import').prop('disabled', false); });
|
|
104
|
+
};
|
|
105
|
+
reader.readAsText(file);
|
|
106
|
+
});
|
|
72
107
|
|
|
73
108
|
loadPairingInfo();
|
|
74
109
|
},
|
|
@@ -87,8 +122,27 @@
|
|
|
87
122
|
</div>
|
|
88
123
|
|
|
89
124
|
<div class="form-row">
|
|
90
|
-
<
|
|
91
|
-
|
|
125
|
+
<p align="center" style="margin:8px 0;">
|
|
126
|
+
<svg width="70" height="77" viewBox="0 8 40 44" xmlns="http://www.w3.org/2000/svg" aria-label="Matter" style="vertical-align:middle;">
|
|
127
|
+
<g fill="currentColor" stroke="none">
|
|
128
|
+
<circle cx="20" cy="30" r="4.5"/>
|
|
129
|
+
<g>
|
|
130
|
+
<rect x="18.4" y="9" width="3.2" height="11"/>
|
|
131
|
+
<path d="M 14.5 18.5 L 25.5 18.5 L 20 25.5 Z"/>
|
|
132
|
+
</g>
|
|
133
|
+
<g transform="rotate(120 20 30)">
|
|
134
|
+
<rect x="18.4" y="9" width="3.2" height="11"/>
|
|
135
|
+
<path d="M 14.5 18.5 L 25.5 18.5 L 20 25.5 Z"/>
|
|
136
|
+
</g>
|
|
137
|
+
<g transform="rotate(240 20 30)">
|
|
138
|
+
<rect x="18.4" y="9" width="3.2" height="11"/>
|
|
139
|
+
<path d="M 14.5 18.5 L 25.5 18.5 L 20 25.5 Z"/>
|
|
140
|
+
</g>
|
|
141
|
+
</g>
|
|
142
|
+
</svg>
|
|
143
|
+
<br/>
|
|
144
|
+
<span style="font-size:18px; font-weight:600; letter-spacing:1px;" data-i18n="matterbridge-config.properties.title"></span>
|
|
145
|
+
</p>
|
|
92
146
|
</div>
|
|
93
147
|
|
|
94
148
|
<div class="form-row">
|
|
@@ -109,6 +163,15 @@
|
|
|
109
163
|
|
|
110
164
|
<hr/>
|
|
111
165
|
|
|
166
|
+
<div class="form-row">
|
|
167
|
+
<b><i class="fa fa-database"></i> <span data-i18n="matterbridge-config.properties.storage_title"></span></b>
|
|
168
|
+
<button type="button" id="mbc-storage-export" class="red-ui-button" style="margin-left:8px;"><i class="fa fa-download"></i> <span data-i18n="matterbridge-config.properties.storage_export"></span></button>
|
|
169
|
+
<button type="button" id="mbc-storage-import" class="red-ui-button"><i class="fa fa-upload"></i> <span data-i18n="matterbridge-config.properties.storage_import"></span></button>
|
|
170
|
+
<input type="file" id="mbc-storage-file" accept="application/json,.json" style="display:none;">
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<hr/>
|
|
174
|
+
|
|
112
175
|
<div class="form-row">
|
|
113
176
|
<b><i class="fa fa-qrcode"></i> <span data-i18n="matterbridge-config.properties.pairing_title"></span></b>
|
|
114
177
|
<button type="button" id="mbc-refresh-pairing" class="red-ui-button" style="margin-left:6px; color:#1b7d33; border-color:#1b7d33;">
|
|
@@ -119,4 +182,11 @@
|
|
|
119
182
|
</button>
|
|
120
183
|
</div>
|
|
121
184
|
<div class="form-row" id="mbc-pairing-body" style="text-align:center;"></div>
|
|
185
|
+
|
|
186
|
+
<br/>
|
|
187
|
+
<br/>
|
|
188
|
+
<br/>
|
|
189
|
+
<br/>
|
|
190
|
+
<br/>
|
|
191
|
+
<br/>
|
|
122
192
|
</script>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/* eslint-disable max-len */
|
|
2
2
|
const path = require('path')
|
|
3
|
+
const { exportMatterStorage, importMatterStorage } = require('./utils/matterStorageBackup')
|
|
4
|
+
const matterPackageVersion = require('@matter/main').version
|
|
5
|
+
const { Specification } = require('@matter/model')
|
|
3
6
|
|
|
4
7
|
// 10/09/2024 Setup the color logger
|
|
5
8
|
const loggerSetup = (options) => {
|
|
@@ -39,6 +42,7 @@ module.exports = (RED) => {
|
|
|
39
42
|
let startupTimer = null
|
|
40
43
|
let reconcileTimer = null
|
|
41
44
|
let closing = false
|
|
45
|
+
let storageOperation = null
|
|
42
46
|
|
|
43
47
|
Object.defineProperty(node, 'linkStatus', {
|
|
44
48
|
get: function () {
|
|
@@ -193,6 +197,40 @@ module.exports = (RED) => {
|
|
|
193
197
|
await node.matterBridge.factoryReset()
|
|
194
198
|
notifyClientsStatus()
|
|
195
199
|
}
|
|
200
|
+
|
|
201
|
+
const withStoppedBridge = async (operation) => {
|
|
202
|
+
if (storageOperation !== null) throw new Error('Another Matter storage operation is already running')
|
|
203
|
+
storageOperation = (async () => {
|
|
204
|
+
const engine = node.matterBridge
|
|
205
|
+
if (engine !== null) {
|
|
206
|
+
engine.removeAllListeners()
|
|
207
|
+
await engine.close()
|
|
208
|
+
runningBridges.delete(node.id)
|
|
209
|
+
node.matterBridge = null
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
return await operation()
|
|
213
|
+
} finally {
|
|
214
|
+
await ensureEngineStarted()
|
|
215
|
+
}
|
|
216
|
+
})()
|
|
217
|
+
try { return await storageOperation } finally { storageOperation = null }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
node.exportMatterStorage = () => withStoppedBridge(() => exportMatterStorage({
|
|
221
|
+
storagePath: node.matterStoragePath,
|
|
222
|
+
instanceId: node.matterInstanceId,
|
|
223
|
+
kind: 'bridge',
|
|
224
|
+
protocolVersion: Specification.REVISION,
|
|
225
|
+
packageVersion: matterPackageVersion
|
|
226
|
+
}))
|
|
227
|
+
|
|
228
|
+
node.importMatterStorage = (backup) => withStoppedBridge(() => importMatterStorage({
|
|
229
|
+
storagePath: node.matterStoragePath,
|
|
230
|
+
instanceId: node.matterInstanceId,
|
|
231
|
+
kind: 'bridge',
|
|
232
|
+
backup
|
|
233
|
+
}))
|
|
196
234
|
// END functions called from the device nodes -------------------------------------
|
|
197
235
|
|
|
198
236
|
node.on('close', (removed, done) => {
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const dptlib = require('knxultimate').dptlib
|
|
4
|
+
|
|
5
|
+
const DOOR_LOCK_CLUSTER_ID = 0x0101
|
|
6
|
+
const LOCK_STATE = {
|
|
7
|
+
NOT_FULLY_LOCKED: 0,
|
|
8
|
+
LOCKED: 1,
|
|
9
|
+
UNLOCKED: 2,
|
|
10
|
+
UNLATCHED: 3
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const decodeKnxBoolean = (msg, dpt) => {
|
|
14
|
+
if (msg && msg.knx && Buffer.isBuffer(msg.knx.rawValue)) {
|
|
15
|
+
return !!dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(dpt || '1.001'))
|
|
16
|
+
}
|
|
17
|
+
return !!(msg && msg.payload)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const lockStateName = (value) => {
|
|
21
|
+
switch (Number(value)) {
|
|
22
|
+
case LOCK_STATE.LOCKED: return 'locked'
|
|
23
|
+
case LOCK_STATE.UNLOCKED: return 'unlocked'
|
|
24
|
+
case LOCK_STATE.NOT_FULLY_LOCKED: return 'notFullyLocked'
|
|
25
|
+
case LOCK_STATE.UNLATCHED: return 'unlatched'
|
|
26
|
+
default: return 'unknown'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lockStateToBoolean = (value) => {
|
|
31
|
+
if (Number(value) === LOCK_STATE.LOCKED) return true
|
|
32
|
+
if (Number(value) === LOCK_STATE.UNLOCKED) return false
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const setupDoorLockProfile = (RED, node, config) => {
|
|
37
|
+
node.name = config.name || node.matterDeviceName || 'Control Matter door lock from KNX'
|
|
38
|
+
node.topic = node.name
|
|
39
|
+
node.notifyreadrequest = true
|
|
40
|
+
node.notifyreadrequestalsorespondtobus = 'false'
|
|
41
|
+
node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
|
|
42
|
+
node.notifyresponse = false
|
|
43
|
+
node.notifywrite = true
|
|
44
|
+
node.initialread = true
|
|
45
|
+
node.listenallga = true
|
|
46
|
+
node.outputtype = 'write'
|
|
47
|
+
node.outputRBE = 'false'
|
|
48
|
+
node.inputRBE = 'false'
|
|
49
|
+
node.passthrough = 'no'
|
|
50
|
+
node.currentLockState = undefined
|
|
51
|
+
node.knxUltimateAcceptedGAs = [config.GALightSwitch, config.GALightState]
|
|
52
|
+
.map((ga) => String(ga || '').trim())
|
|
53
|
+
.filter((ga) => ga !== '')
|
|
54
|
+
|
|
55
|
+
const setStatus = (fill, shape, text) => node.status({ fill, shape, text })
|
|
56
|
+
|
|
57
|
+
const commandArgs = () => {
|
|
58
|
+
const pin = String(node.credentials?.doorLockPin || '')
|
|
59
|
+
return pin === '' ? {} : { pinCode: Buffer.from(pin, 'utf8') }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sendFlow = (source, state, rawState) => {
|
|
63
|
+
if (config.enableNodePINS !== 'yes') return
|
|
64
|
+
node.send({
|
|
65
|
+
topic: node.topic,
|
|
66
|
+
payload: state,
|
|
67
|
+
matter: {
|
|
68
|
+
source,
|
|
69
|
+
nodeId: node.matterNodeId,
|
|
70
|
+
endpointId: node.matterEndpointId,
|
|
71
|
+
clusterId: DOOR_LOCK_CLUSTER_ID,
|
|
72
|
+
lockState: rawState,
|
|
73
|
+
lockStateName: rawState === undefined ? (state ? 'locked' : 'unlocked') : lockStateName(rawState)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const writeKnxState = (state, outputtype = 'write') => {
|
|
79
|
+
const ga = String(config.GALightState || '').trim()
|
|
80
|
+
if (ga === '' || !node.serverKNX) return
|
|
81
|
+
node.serverKNX.sendKNXTelegramToKNXEngine({
|
|
82
|
+
grpaddr: ga,
|
|
83
|
+
payload: state,
|
|
84
|
+
dpt: config.dptLightState || '1.001',
|
|
85
|
+
outputtype,
|
|
86
|
+
nodecallerid: node.id
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const publishMatterState = (rawState, source = 'matter') => {
|
|
91
|
+
node.currentLockState = Number(rawState)
|
|
92
|
+
const state = lockStateToBoolean(rawState)
|
|
93
|
+
const name = lockStateName(rawState)
|
|
94
|
+
if (state !== undefined) writeKnxState(state)
|
|
95
|
+
sendFlow(source, state, rawState)
|
|
96
|
+
setStatus(state === undefined ? 'yellow' : 'blue', state === undefined ? 'ring' : 'dot', `Matter: ${name}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const queueCommand = (locked, source = 'knx') => {
|
|
100
|
+
const manager = node.serverMatter?.matterManager
|
|
101
|
+
if (!manager) throw new Error('Matter controller not ready')
|
|
102
|
+
const capabilities = (() => {
|
|
103
|
+
try { return JSON.parse(config.matterDeviceCapabilities || '{}') } catch (error) { return {} }
|
|
104
|
+
})()
|
|
105
|
+
if (locked && capabilities.lockDoor === false) throw new Error('The Matter endpoint does not expose lockDoor')
|
|
106
|
+
if (!locked && capabilities.unlockDoor === false) throw new Error('The Matter endpoint does not expose unlockDoor')
|
|
107
|
+
const queued = manager.writeMatterQueueAdd({
|
|
108
|
+
nodeId: node.matterNodeId,
|
|
109
|
+
endpointId: node.matterEndpointId,
|
|
110
|
+
clusterId: DOOR_LOCK_CLUSTER_ID,
|
|
111
|
+
kind: 'command',
|
|
112
|
+
name: locked ? 'lockDoor' : 'unlockDoor',
|
|
113
|
+
args: commandArgs()
|
|
114
|
+
})
|
|
115
|
+
if (queued && typeof queued.catch === 'function') {
|
|
116
|
+
queued.catch((error) => {
|
|
117
|
+
RED.log.error(`knxUltimateMatterControllerDevice DoorLock command: ${error.message}`)
|
|
118
|
+
setStatus('red', 'ring', error.message)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
sendFlow(source, locked)
|
|
122
|
+
setStatus('green', 'dot', `KNX→Matter: ${locked ? 'lock' : 'unlock'}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
node.handleSend = (msg) => {
|
|
126
|
+
try {
|
|
127
|
+
if (!msg?.knx || !node.knxUltimateAcceptedGAs.includes(String(msg.knx.destination || '').trim())) return
|
|
128
|
+
if (msg.knx.event === 'GroupValue_Read') {
|
|
129
|
+
if (String(msg.knx.destination) === String(config.GALightState)) {
|
|
130
|
+
const state = lockStateToBoolean(node.currentLockState)
|
|
131
|
+
if (state !== undefined) writeKnxState(state, 'response')
|
|
132
|
+
}
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (String(msg.knx.destination) !== String(config.GALightSwitch)) return
|
|
136
|
+
queueCommand(decodeKnxBoolean(msg, config.dptLightSwitch), 'knx')
|
|
137
|
+
} catch (error) {
|
|
138
|
+
RED.log.error(`knxUltimateMatterControllerDevice DoorLock KNX: ${error.message}`)
|
|
139
|
+
setStatus('red', 'ring', error.message)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
node.handleSendMatter = (event) => {
|
|
144
|
+
try {
|
|
145
|
+
if (String(event?.nodeId) !== String(node.matterNodeId)) return
|
|
146
|
+
if (Number(event?.endpointId) !== Number(node.matterEndpointId)) return
|
|
147
|
+
if (Number(event?.clusterId) !== DOOR_LOCK_CLUSTER_ID || event.attributeName !== 'lockState') return
|
|
148
|
+
publishMatterState(event.value)
|
|
149
|
+
} catch (error) {
|
|
150
|
+
RED.log.error(`knxUltimateMatterControllerDevice DoorLock Matter: ${error.message}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
node.handleMatterClusterEvent = () => {}
|
|
154
|
+
node.handleMatterNodeInitialized = () => {
|
|
155
|
+
try {
|
|
156
|
+
const value = node.serverMatter?.matterManager?.getCachedAttribute(
|
|
157
|
+
node.matterNodeId,
|
|
158
|
+
node.matterEndpointId,
|
|
159
|
+
DOOR_LOCK_CLUSTER_ID,
|
|
160
|
+
'lockState'
|
|
161
|
+
)
|
|
162
|
+
if (value !== undefined && value !== null) publishMatterState(value, 'initial')
|
|
163
|
+
} catch (error) {
|
|
164
|
+
RED.log.error(`knxUltimateMatterControllerDevice DoorLock initial state: ${error.message}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
node.setNodeStatusMatter = (status) => {
|
|
168
|
+
if (status && status.text) setStatus(status.fill || 'grey', status.shape || 'ring', status.text)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (node.serverKNX) {
|
|
172
|
+
node.serverKNX.removeClient(node)
|
|
173
|
+
node.serverKNX.addClient(node)
|
|
174
|
+
} else {
|
|
175
|
+
setStatus('yellow', 'ring', 'No KNX gateway selected')
|
|
176
|
+
}
|
|
177
|
+
if (node.serverMatter) {
|
|
178
|
+
node.serverMatter.removeClient(node)
|
|
179
|
+
node.serverMatter.addClient(node)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
node.on('input', (msg, send, done) => {
|
|
183
|
+
try {
|
|
184
|
+
const value = typeof msg.payload === 'object' && msg.payload !== null && msg.payload.locked !== undefined
|
|
185
|
+
? msg.payload.locked
|
|
186
|
+
: msg.payload
|
|
187
|
+
if (typeof value !== 'boolean') throw new Error('Door Lock input requires msg.payload boolean or { locked: boolean }')
|
|
188
|
+
queueCommand(value, 'flow')
|
|
189
|
+
if (done) done()
|
|
190
|
+
} catch (error) {
|
|
191
|
+
setStatus('red', 'ring', error.message)
|
|
192
|
+
if (done) done(error)
|
|
193
|
+
else node.error(error, msg)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
node.on('close', (done) => {
|
|
198
|
+
try { if (node.serverKNX) node.serverKNX.removeClient(node) } catch (error) { /* empty */ }
|
|
199
|
+
try { if (node.serverMatter) node.serverMatter.removeClient(node) } catch (error) { /* empty */ }
|
|
200
|
+
done()
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
DOOR_LOCK_CLUSTER_ID,
|
|
206
|
+
LOCK_STATE,
|
|
207
|
+
lockStateName,
|
|
208
|
+
lockStateToBoolean,
|
|
209
|
+
setupDoorLockProfile
|
|
210
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { setupDoorLockProfile } = require('./doorLock')
|
|
4
|
+
const { setupMappedEndpointProfile } = require('./mappedEndpoint')
|
|
5
|
+
|
|
6
|
+
// Controller-side Matter device profiles live behind this registry. Keep profile
|
|
7
|
+
// selection capability-driven: the editor records the profile only after inspecting
|
|
8
|
+
// the endpoint's actual device types, clusters, attributes and supported commands.
|
|
9
|
+
const PROFILE_SETUPS = Object.freeze({
|
|
10
|
+
doorLock: setupDoorLockProfile,
|
|
11
|
+
mapped: setupMappedEndpointProfile
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const setupMatterControllerProfile = (profile, RED, node, config) => {
|
|
15
|
+
const setup = PROFILE_SETUPS[profile]
|
|
16
|
+
if (typeof setup !== 'function') return false
|
|
17
|
+
setup(RED, node, config)
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { PROFILE_SETUPS, setupMatterControllerProfile }
|