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.
Files changed (61) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/README.md +1 -1
  3. package/nodes/commonFunctions.js +33 -0
  4. package/nodes/icons/node-matter-bridge-icon.svg +6 -0
  5. package/nodes/icons/node-matter-controller-icon.svg +6 -0
  6. package/nodes/knxUltimate-config.js +5 -1
  7. package/nodes/knxUltimateMatterBridge.html +1 -6
  8. package/nodes/knxUltimateMatterControllerDevice.html +156 -16
  9. package/nodes/knxUltimateMatterControllerDevice.js +5 -0
  10. package/nodes/locales/de/knxUltimateMatterBridge.html +2 -0
  11. package/nodes/locales/de/knxUltimateMatterControllerDevice.html +2 -0
  12. package/nodes/locales/de/knxUltimateMatterControllerDevice.json +10 -0
  13. package/nodes/locales/de/matter-config.html +4 -0
  14. package/nodes/locales/de/matter-config.json +7 -2
  15. package/nodes/locales/de/matterbridge-config.html +4 -0
  16. package/nodes/locales/de/matterbridge-config.json +4 -1
  17. package/nodes/locales/en/knxUltimateMatterBridge.html +2 -0
  18. package/nodes/locales/en/knxUltimateMatterControllerDevice.html +2 -0
  19. package/nodes/locales/en/knxUltimateMatterControllerDevice.json +10 -0
  20. package/nodes/locales/en/matter-config.html +4 -0
  21. package/nodes/locales/en/matter-config.json +7 -2
  22. package/nodes/locales/en/matterbridge-config.html +4 -0
  23. package/nodes/locales/en/matterbridge-config.json +4 -1
  24. package/nodes/locales/es/knxUltimateMatterBridge.html +2 -0
  25. package/nodes/locales/es/knxUltimateMatterControllerDevice.html +2 -0
  26. package/nodes/locales/es/knxUltimateMatterControllerDevice.json +10 -0
  27. package/nodes/locales/es/matter-config.html +4 -0
  28. package/nodes/locales/es/matter-config.json +7 -2
  29. package/nodes/locales/es/matterbridge-config.html +4 -0
  30. package/nodes/locales/es/matterbridge-config.json +4 -1
  31. package/nodes/locales/fr/knxUltimateMatterBridge.html +2 -0
  32. package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +2 -0
  33. package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +10 -0
  34. package/nodes/locales/fr/matter-config.html +4 -0
  35. package/nodes/locales/fr/matter-config.json +7 -2
  36. package/nodes/locales/fr/matterbridge-config.html +4 -0
  37. package/nodes/locales/fr/matterbridge-config.json +4 -1
  38. package/nodes/locales/it/knxUltimateMatterBridge.html +2 -0
  39. package/nodes/locales/it/knxUltimateMatterControllerDevice.html +2 -0
  40. package/nodes/locales/it/knxUltimateMatterControllerDevice.json +10 -0
  41. package/nodes/locales/it/matter-config.html +4 -0
  42. package/nodes/locales/it/matter-config.json +7 -2
  43. package/nodes/locales/it/matterbridge-config.html +4 -0
  44. package/nodes/locales/it/matterbridge-config.json +4 -1
  45. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +2 -0
  46. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +2 -0
  47. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +10 -0
  48. package/nodes/locales/zh-CN/matter-config.html +4 -0
  49. package/nodes/locales/zh-CN/matter-config.json +7 -2
  50. package/nodes/locales/zh-CN/matterbridge-config.html +4 -0
  51. package/nodes/locales/zh-CN/matterbridge-config.json +4 -1
  52. package/nodes/matter-config.html +59 -18
  53. package/nodes/matter-config.js +33 -0
  54. package/nodes/matterbridge-config.html +73 -3
  55. package/nodes/matterbridge-config.js +38 -0
  56. package/nodes/utils/matterControllerProfiles/doorLock.js +210 -0
  57. package/nodes/utils/matterControllerProfiles/index.js +21 -0
  58. package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +148 -0
  59. package/nodes/utils/matterStorageBackup.js +100 -0
  60. package/package.json +6 -2
  61. 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
  }
@@ -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;">matter</span>
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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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
- <br/>
274
- <br/>
275
- <br/>
276
- <br/>
277
- <br/>
314
+ <br/>
315
+ <br/>
316
+ <br/>
317
+ <br/>
318
+ <br/>
278
319
  </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) => {
@@ -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
- <b><span data-i18n="matterbridge-config.properties.title"></span></b>
91
- <p><span data-i18n="matterbridge-config.properties.intro"></span></p>
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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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 }