node-red-contrib-knx-ultimate 5.0.3 → 5.2.1

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 +25 -0
  2. package/nodes/commonFunctions.js +93 -0
  3. package/nodes/icons/node-matter-icon.svg +12 -0
  4. package/nodes/knxUltimateIoTBridge.html +10 -3
  5. package/nodes/knxUltimateMatterBridge.html +429 -0
  6. package/nodes/knxUltimateMatterBridge.js +456 -0
  7. package/nodes/knxUltimateMatterDevice.html +586 -0
  8. package/nodes/knxUltimateMatterDevice.js +308 -0
  9. package/nodes/locales/de/knxUltimateMatterBridge.html +64 -0
  10. package/nodes/locales/de/knxUltimateMatterBridge.json +80 -0
  11. package/nodes/locales/de/knxUltimateMatterDevice.html +56 -0
  12. package/nodes/locales/de/knxUltimateMatterDevice.json +109 -0
  13. package/nodes/locales/de/matter-config.html +25 -0
  14. package/nodes/locales/de/matter-config.json +26 -0
  15. package/nodes/locales/en/knxUltimateMatterBridge.html +64 -0
  16. package/nodes/locales/en/knxUltimateMatterBridge.json +80 -0
  17. package/nodes/locales/en/knxUltimateMatterDevice.html +56 -0
  18. package/nodes/locales/en/knxUltimateMatterDevice.json +109 -0
  19. package/nodes/locales/en/matter-config.html +25 -0
  20. package/nodes/locales/en/matter-config.json +26 -0
  21. package/nodes/locales/es/knxUltimateMatterBridge.html +64 -0
  22. package/nodes/locales/es/knxUltimateMatterBridge.json +80 -0
  23. package/nodes/locales/es/knxUltimateMatterDevice.html +56 -0
  24. package/nodes/locales/es/knxUltimateMatterDevice.json +109 -0
  25. package/nodes/locales/es/matter-config.html +25 -0
  26. package/nodes/locales/es/matter-config.json +26 -0
  27. package/nodes/locales/fr/knxUltimateMatterBridge.html +64 -0
  28. package/nodes/locales/fr/knxUltimateMatterBridge.json +80 -0
  29. package/nodes/locales/fr/knxUltimateMatterDevice.html +56 -0
  30. package/nodes/locales/fr/knxUltimateMatterDevice.json +109 -0
  31. package/nodes/locales/fr/matter-config.html +25 -0
  32. package/nodes/locales/fr/matter-config.json +26 -0
  33. package/nodes/locales/it/knxUltimateMatterBridge.html +64 -0
  34. package/nodes/locales/it/knxUltimateMatterBridge.json +80 -0
  35. package/nodes/locales/it/knxUltimateMatterDevice.html +56 -0
  36. package/nodes/locales/it/knxUltimateMatterDevice.json +109 -0
  37. package/nodes/locales/it/matter-config.html +25 -0
  38. package/nodes/locales/it/matter-config.json +26 -0
  39. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +64 -0
  40. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +80 -0
  41. package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +56 -0
  42. package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +109 -0
  43. package/nodes/locales/zh-CN/matter-config.html +25 -0
  44. package/nodes/locales/zh-CN/matter-config.json +26 -0
  45. package/nodes/matter-config.html +226 -0
  46. package/nodes/matter-config.js +242 -0
  47. package/nodes/utils/matterBridgeDeviceFactory.mjs +511 -0
  48. package/nodes/utils/matterBridgeEngine.mjs +290 -0
  49. package/nodes/utils/matterEngine.mjs +475 -0
  50. package/nodes/utils/matterKnxConverter.js +204 -0
  51. package/package.json +7 -2
@@ -0,0 +1,586 @@
1
+ <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/11f26b4500.js"></script>
2
+ <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
3
+
4
+ <script type="text/javascript">
5
+ (function () {
6
+ RED.nodes.registerType('knxUltimateMatterDevice', {
7
+ category: 'KNX Ultimate Matter',
8
+ color: '#D7C7E9',
9
+ defaults: {
10
+ server: { type: 'knxUltimate-config', required: false },
11
+ serverMatter: { type: 'matter-config', required: true },
12
+ name: { value: '' },
13
+ matterNodeId: { value: '' },
14
+ mappings: { value: [] },
15
+ readStatusAtStartup: { value: 'yes' },
16
+ enableNodePINS: { value: 'no' },
17
+ outputs: { value: 0 },
18
+ inputs: { value: 0 }
19
+ },
20
+ inputs: 0,
21
+ outputs: 0,
22
+ icon: 'node-matter-icon.svg',
23
+ label() {
24
+ return this.name || 'Matter Device (BETA)';
25
+ },
26
+ paletteLabel: 'Matter Device (BETA)',
27
+ oneditprepare() {
28
+ try { RED.sidebar.show('help'); } catch (error) { /* empty */ }
29
+ const node = this;
30
+
31
+ let oNodeServer = RED.nodes.node($('#node-input-server').val());
32
+ $('#node-input-server').on('change.knxUltimateMatterDevice', function () {
33
+ try { oNodeServer = RED.nodes.node($(this).val()); } catch (error) { /* empty */ }
34
+ });
35
+ const oNodeServerMatter = () => RED.nodes.node($('#node-input-serverMatter').val());
36
+
37
+ // ------------------------------------------------------------------
38
+ // Suggested KNX DPT for well known Matter clusters/targets
39
+ // ------------------------------------------------------------------
40
+ const suggestDPT = (clusterId, kind, targetName) => {
41
+ const cid = Number(clusterId);
42
+ switch (cid) {
43
+ case 6: return '1.001'; // OnOff
44
+ case 8: return '5.001'; // LevelControl
45
+ case 3: return '1.017'; // Identify
46
+ case 258: // WindowCovering
47
+ if (targetName.indexOf('Percent') !== -1) return '5.001';
48
+ if (targetName === 'stopMotion') return '1.017';
49
+ return '1.008';
50
+ case 768: // ColorControl
51
+ if (targetName.toLowerCase().indexOf('colortemperature') !== -1) return '7.600';
52
+ return '5.001';
53
+ case 513: // Thermostat
54
+ if (targetName === 'systemMode') return '20.102';
55
+ return '9.001';
56
+ case 514: return '5.001'; // FanControl
57
+ case 1026: return '9.001'; // Temperature
58
+ case 1029: return '9.007'; // Humidity
59
+ case 1024: return '9.004'; // Illuminance
60
+ case 1030: return '1.011'; // Occupancy
61
+ case 69: return '1.002'; // BooleanState
62
+ case 257: return '1.001'; // DoorLock
63
+ case 47: return '5.001'; // PowerSource (battery)
64
+ case 144: return '14.056'; // ElectricalPowerMeasurement (W)
65
+ case 145: return '13.013'; // ElectricalEnergyMeasurement (kWh)
66
+ default: return '';
67
+ }
68
+ };
69
+
70
+ // ------------------------------------------------------------------
71
+ // Curated, human friendly Matter functions (simple view).
72
+ // Each entry maps a cluster target to a localized label (friendly.* keys).
73
+ // ------------------------------------------------------------------
74
+ const CURATED_TARGETS = {
75
+ command: [
76
+ { clusterId: 6, targetKind: 'command', target: 'on', key: 'onoff_switch' },
77
+ { clusterId: 6, targetKind: 'command', target: 'toggle', key: 'onoff_toggle' },
78
+ { clusterId: 8, targetKind: 'command', target: 'moveToLevelWithOnOff', key: 'level_set' },
79
+ { clusterId: 768, targetKind: 'command', target: 'moveToColorTemperature', key: 'colortemp_set' },
80
+ { clusterId: 258, targetKind: 'command', target: 'upOrOpen', key: 'cover_updown' },
81
+ { clusterId: 258, targetKind: 'command', target: 'stopMotion', key: 'cover_stop' },
82
+ { clusterId: 258, targetKind: 'command', target: 'goToLiftPercentage', key: 'cover_position_set' },
83
+ { clusterId: 258, targetKind: 'command', target: 'goToTiltPercentage', key: 'cover_tilt_set' },
84
+ { clusterId: 513, targetKind: 'attribute', target: 'occupiedHeatingSetpoint', key: 'thermo_heatsp_set' },
85
+ { clusterId: 513, targetKind: 'attribute', target: 'occupiedCoolingSetpoint', key: 'thermo_coolsp_set' },
86
+ { clusterId: 514, targetKind: 'attribute', target: 'percentSetting', key: 'fan_speed_set' },
87
+ { clusterId: 257, targetKind: 'command', target: 'lockDoor', key: 'lock_cmd' },
88
+ { clusterId: 3, targetKind: 'command', target: 'identify', key: 'identify_cmd' }
89
+ ],
90
+ status: [
91
+ { clusterId: 6, targetKind: 'attribute', target: 'onOff', key: 'onoff_state' },
92
+ { clusterId: 8, targetKind: 'attribute', target: 'currentLevel', key: 'level_state' },
93
+ { clusterId: 768, targetKind: 'attribute', target: 'colorTemperatureMireds', key: 'colortemp_state' },
94
+ { clusterId: 258, targetKind: 'attribute', target: 'currentPositionLiftPercent100ths', key: 'cover_position_state' },
95
+ { clusterId: 258, targetKind: 'attribute', target: 'currentPositionTiltPercent100ths', key: 'cover_tilt_state' },
96
+ { clusterId: 513, targetKind: 'attribute', target: 'localTemperature', key: 'thermo_localtemp' },
97
+ { clusterId: 513, targetKind: 'attribute', target: 'occupiedHeatingSetpoint', key: 'thermo_heatsp_state' },
98
+ { clusterId: 513, targetKind: 'attribute', target: 'occupiedCoolingSetpoint', key: 'thermo_coolsp_state' },
99
+ { clusterId: 514, targetKind: 'attribute', target: 'percentCurrent', key: 'fan_speed_state' },
100
+ { clusterId: 257, targetKind: 'attribute', target: 'lockState', key: 'lock_state' },
101
+ { clusterId: 1026, targetKind: 'attribute', target: 'measuredValue', key: 'temp_measured' },
102
+ { clusterId: 1029, targetKind: 'attribute', target: 'measuredValue', key: 'humidity_measured' },
103
+ { clusterId: 1024, targetKind: 'attribute', target: 'measuredValue', key: 'illuminance_measured' },
104
+ { clusterId: 1030, targetKind: 'attribute', target: 'occupancy', key: 'occupancy_state' },
105
+ { clusterId: 69, targetKind: 'attribute', target: 'stateValue', key: 'contact_state' },
106
+ { clusterId: 47, targetKind: 'attribute', target: 'batPercentRemaining', key: 'battery_percent' },
107
+ { clusterId: 144, targetKind: 'attribute', target: 'activePower', key: 'power_active' },
108
+ { clusterId: 144, targetKind: 'attribute', target: 'voltage', key: 'power_voltage' },
109
+ { clusterId: 144, targetKind: 'attribute', target: 'activeCurrent', key: 'power_current' },
110
+ { clusterId: 145, targetKind: 'attribute', target: 'cumulativeEnergyImported', key: 'energy_imported' },
111
+ { clusterId: 59, targetKind: 'attribute', target: 'currentPosition', key: 'switch_position' }
112
+ ]
113
+ };
114
+
115
+ // i18n helper: returns null when the key has no translation
116
+ const i18nOrNull = (subKey) => {
117
+ const text = node._('knxUltimateMatterDevice.' + subKey);
118
+ if (!text || text.indexOf(subKey) !== -1 || text.indexOf('knxUltimateMatterDevice') !== -1) return null;
119
+ return text;
120
+ };
121
+ const friendlyLabel = (key) => i18nOrNull('friendly.' + key);
122
+ const devTypeLabel = (deviceType) => {
123
+ const clean = (deviceType || '').toString().replace(/^MA-/i, '').replace(/[^a-zA-Z]/g, '').toLowerCase();
124
+ return i18nOrNull('devtype.' + clean) || deviceType;
125
+ };
126
+ // "currentPositionLiftPercent100ths" -> "Current position lift percent 100ths"
127
+ const prettifyName = (name) => (name || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
128
+
129
+ // Converts the raw cached Matter value into the same human unit shown in the friendly label
130
+ const formatCachedValue = (key, raw) => {
131
+ if (raw === undefined || raw === null || typeof raw === 'object') {
132
+ if (key === 'energy_imported' && raw && raw.energy !== undefined) return Math.round(Number(raw.energy) / 10000) / 100;
133
+ if (key === 'occupancy_state' && raw && typeof raw === 'object') return raw.occupied === true;
134
+ return undefined;
135
+ }
136
+ switch (key) {
137
+ case 'level_state': return Math.round(Number(raw) * 100 / 254);
138
+ case 'cover_position_state': case 'cover_tilt_state': return Math.round(Number(raw) / 100);
139
+ case 'thermo_localtemp': case 'thermo_heatsp_state': case 'thermo_coolsp_state': case 'temp_measured': case 'humidity_measured': return Math.round(Number(raw)) / 100;
140
+ case 'colortemp_state': return Number(raw) > 0 ? Math.round(1000000 / Number(raw)) : undefined;
141
+ case 'battery_percent': return Math.round(Number(raw) / 2);
142
+ case 'power_active': case 'power_voltage': case 'power_current': return Math.round(Number(raw)) / 1000;
143
+ case 'illuminance_measured': return Math.round(Math.pow(10, (Number(raw) - 1) / 10000));
144
+ case 'lock_state': return Number(raw) === 1;
145
+ default: return raw;
146
+ }
147
+ };
148
+
149
+ // ------------------------------------------------------------------
150
+ // Matter device picker (autocomplete on the name field, like Hue nodes)
151
+ // ------------------------------------------------------------------
152
+ const $deviceName = $('#node-input-name');
153
+ const $refreshButton = $('.matter-refresh-devices');
154
+ const $loadingIndicator = $('.matter-devices-loading');
155
+ let cachedDevices = [];
156
+ let deviceStructure = null; // { endpoints: [...] } of the selected device
157
+
158
+ function fetchDevices(matterServer, term, response, { forceRefresh = false } = {}) {
159
+ if (!matterServer) { response([]); return; }
160
+ if (!forceRefresh && cachedDevices.length > 0) {
161
+ response(filterDevices(cachedDevices, term));
162
+ return;
163
+ }
164
+ $loadingIndicator.show();
165
+ $.getJSON(`KNXUltimateMatterGetNodes?serverId=${encodeURIComponent(matterServer.id)}&_=${Date.now()}`, (data) => {
166
+ cachedDevices = (data && Array.isArray(data.devices)) ? data.devices : [];
167
+ response(filterDevices(cachedDevices, term));
168
+ }).always(() => {
169
+ $loadingIndicator.hide();
170
+ }).fail(() => {
171
+ cachedDevices = [];
172
+ response([]);
173
+ });
174
+ }
175
+
176
+ function filterDevices(devices, term) {
177
+ const cleaned = (term || '').replace(/exactmatch/gi, '').trim();
178
+ return $.map(devices, (value) => {
179
+ const sSearch = `${value.name} ${value.vendorName} ${value.productName} ${value.nodeId}`;
180
+ if (cleaned === '' || htmlUtilsfullCSVSearch(sSearch, cleaned)) {
181
+ return {
182
+ matterNodeId: value.nodeId,
183
+ label: `${value.name} (${value.vendorName} ${value.productName}) [${value.connectionState}]`,
184
+ value: value.name
185
+ };
186
+ }
187
+ return null;
188
+ });
189
+ }
190
+
191
+ $deviceName.autocomplete({
192
+ minLength: 0,
193
+ source(request, response) {
194
+ const matterServer = oNodeServerMatter();
195
+ if (!matterServer) { response([]); return; }
196
+ fetchDevices(matterServer, request.term, response);
197
+ },
198
+ select(event, ui) {
199
+ $('#node-input-matterNodeId').val(ui.item.matterNodeId);
200
+ loadStructure(ui.item.matterNodeId);
201
+ }
202
+ }).focus(function () {
203
+ $(this).autocomplete('search', `${$(this).val()}exactmatch`);
204
+ });
205
+
206
+ $refreshButton.on('click.knxUltimateMatterDevice', () => {
207
+ cachedDevices = [];
208
+ const matterServer = oNodeServerMatter();
209
+ if (!matterServer) return;
210
+ fetchDevices(matterServer, '', () => {
211
+ $deviceName.autocomplete('search', `${$deviceName.val()}exactmatch`);
212
+ }, { forceRefresh: true });
213
+ });
214
+
215
+ // ------------------------------------------------------------------
216
+ // Structure of the selected device -> options of the target selects
217
+ // ------------------------------------------------------------------
218
+ const targetValueOf = (ep, cluster, targetKind, targetName) => JSON.stringify({
219
+ endpointId: ep.endpointId, clusterId: cluster.id, clusterName: cluster.name, targetKind, target: targetName
220
+ });
221
+
222
+ const endpointGroupLabel = (ep) => {
223
+ const types = (ep.deviceTypes || []).map(devTypeLabel).join(', ');
224
+ return `${node._('knxUltimateMatterDevice.endpoint')} ${ep.endpointId} · ${types || ep.name}`;
225
+ };
226
+
227
+ function buildTargetOptions($select, direction, selectedValue) {
228
+ $select.empty();
229
+ $select.append($('<option></option>').attr('value', '').text(node._('knxUltimateMatterDevice.select_target')));
230
+ let totalAdded = 0;
231
+ const structureEndpoints = (deviceStructure && Array.isArray(deviceStructure.endpoints)) ? deviceStructure.endpoints : [];
232
+ structureEndpoints.forEach((ep) => {
233
+ const $group = $('<optgroup></optgroup>').attr('label', endpointGroupLabel(ep));
234
+ let added = 0;
235
+ // Only the curated, human friendly functions actually exposed by the device
236
+ (CURATED_TARGETS[direction] || []).forEach((entry) => {
237
+ const cluster = (ep.clusters || []).find((c) => Number(c.id) === entry.clusterId);
238
+ if (!cluster) return;
239
+ const pool = entry.targetKind === 'command' ? (cluster.commands || []) : (cluster.attributes || []);
240
+ const item = pool.find((el) => el.name === entry.target);
241
+ if (!item) return;
242
+ const label = friendlyLabel(entry.key) || prettifyName(entry.target);
243
+ let cachedText = '';
244
+ if (direction === 'status') {
245
+ const current = formatCachedValue(entry.key, item.value);
246
+ if (current !== undefined) cachedText = ` [${current}]`;
247
+ }
248
+ $group.append($('<option></option>')
249
+ .attr('value', targetValueOf(ep, cluster, entry.targetKind, entry.target))
250
+ .attr('title', `${cluster.name}.${entry.target}`)
251
+ .text(`${label}${cachedText}`));
252
+ added += 1;
253
+ });
254
+ if (added > 0) {
255
+ $select.append($group);
256
+ totalAdded += added;
257
+ }
258
+ });
259
+ if (totalAdded === 0 && structureEndpoints.length > 0) {
260
+ $select.append($('<option></option>').attr('value', '').attr('disabled', 'disabled').text(node._('knxUltimateMatterDevice.no_simple_targets')));
261
+ }
262
+ if (selectedValue) {
263
+ // Re-select by content (attribute cached values change the labels, not the values).
264
+ // This must work even while the device structure is still loading (or unavailable),
265
+ // otherwise the stored mapping would be lost on save.
266
+ $select.val(selectedValue);
267
+ if (!$select.val()) {
268
+ // The stored mapping is not in the current list (structure not loaded yet, device
269
+ // offline or hidden by the simple view): keep it anyway
270
+ $select.append($('<option></option>').attr('value', selectedValue).text(`${node._('knxUltimateMatterDevice.stored_target')}: ${targetToLabel(selectedValue)}`));
271
+ $select.val(selectedValue);
272
+ }
273
+ }
274
+ // Remember the selection so async rebuilds never lose it
275
+ $select.data('storedTargetValue', $select.val() || selectedValue || '');
276
+ }
277
+
278
+ function curatedEntryOf(target) {
279
+ return CURATED_TARGETS.command.concat(CURATED_TARGETS.status).find((e) => e.clusterId === Number(target.clusterId) && e.target === target.target && e.targetKind === target.targetKind);
280
+ }
281
+
282
+ function targetToLabel(jsonValue) {
283
+ try {
284
+ const t = JSON.parse(jsonValue);
285
+ const curated = curatedEntryOf(t);
286
+ const friendly = curated ? friendlyLabel(curated.key) : null;
287
+ if (friendly) return `${friendly} (EP${t.endpointId})`;
288
+ return `EP${t.endpointId} ${t.clusterName || t.clusterId}.${t.target}`;
289
+ } catch (error) {
290
+ return jsonValue;
291
+ }
292
+ }
293
+
294
+ function refreshAllTargetSelects() {
295
+ $('#node-input-mapping-container').editableList('items').each(function () {
296
+ const row = $(this);
297
+ const $direction = row.find('.matter-direction');
298
+ const $target = row.find('.matter-target');
299
+ // Prefer the live selection, fall back to the remembered one (the select can be
300
+ // temporarily empty while the device structure is being fetched)
301
+ const selected = $target.val() || $target.data('storedTargetValue') || '';
302
+ buildTargetOptions($target, $direction.val(), selected);
303
+ });
304
+ }
305
+
306
+ function loadStructure(nodeId) {
307
+ deviceStructure = null;
308
+ const matterServer = oNodeServerMatter();
309
+ if (!matterServer || !nodeId) { refreshAllTargetSelects(); return; }
310
+ $loadingIndicator.show();
311
+ $.getJSON(`KNXUltimateMatterGetStructure?serverId=${encodeURIComponent(matterServer.id)}&nodeId=${encodeURIComponent(nodeId)}&_=${Date.now()}`, (data) => {
312
+ if (data && !data.error) {
313
+ deviceStructure = data;
314
+ } else if (data && data.error) {
315
+ RED.notify(data.error, { type: 'warning' });
316
+ }
317
+ refreshAllTargetSelects();
318
+ }).always(() => {
319
+ $loadingIndicator.hide();
320
+ }).fail(() => {
321
+ refreshAllTargetSelects();
322
+ });
323
+ }
324
+
325
+ // ------------------------------------------------------------------
326
+ // Mappings editable list
327
+ // ------------------------------------------------------------------
328
+ function createLabeledField(row, key, element, options = {}) {
329
+ const wrapper = $('<div/>', {
330
+ style: 'display:flex; flex-direction:column; margin-right:10px;'
331
+ }).appendTo(row);
332
+ if (options.width) wrapper.css('width', options.width);
333
+ if (options.flex) wrapper.css('flex', options.flex);
334
+ $('<span/>', {
335
+ text: node._('knxUltimateMatterDevice.fields.' + key),
336
+ style: 'font-size:11px; color:#666; margin-bottom:2px;'
337
+ }).appendTo(wrapper);
338
+ element.css('width', '100%');
339
+ element.appendTo(wrapper);
340
+ return element;
341
+ }
342
+
343
+ const container = $('#node-input-mapping-container');
344
+ container.css('min-height', '260px').css('min-width', '640px').editableList({
345
+ sortable: true,
346
+ removable: true,
347
+ addItem: function (row, index, data) {
348
+ const mapping = $.extend(true, {
349
+ direction: 'command',
350
+ ga: '',
351
+ dpt: '',
352
+ gaName: '',
353
+ endpointId: '',
354
+ clusterId: '',
355
+ clusterName: '',
356
+ targetKind: '',
357
+ target: ''
358
+ }, data.mapping);
359
+
360
+ const block = $('<div/>').appendTo(row);
361
+ const topRow = $('<div/>').addClass('form-row').css({ display: 'flex', alignItems: 'flex-end', flexWrap: 'wrap', gap: '10px' }).appendTo(block);
362
+ const bottomRow = $('<div/>').addClass('form-row').css({ display: 'flex', alignItems: 'flex-end', flexWrap: 'wrap', gap: '10px', marginTop: '6px' }).appendTo(block);
363
+
364
+ // Direction
365
+ const $direction = $('<select/>', { class: 'matter-direction' });
366
+ $direction.append($('<option></option>').attr('value', 'command').text(node._('knxUltimateMatterDevice.direction.command')));
367
+ $direction.append($('<option></option>').attr('value', 'status').text(node._('knxUltimateMatterDevice.direction.status')));
368
+ createLabeledField(topRow, 'direction', $direction, { width: '170px' });
369
+ $direction.val(mapping.direction || 'command');
370
+
371
+ // Matter target
372
+ const $target = $('<select/>', { class: 'matter-target' });
373
+ createLabeledField(topRow, 'target', $target, { flex: '1 1 320px' });
374
+ const storedTargetValue = mapping.target !== '' ? JSON.stringify({ endpointId: mapping.endpointId, clusterId: mapping.clusterId, clusterName: mapping.clusterName, targetKind: mapping.targetKind, target: mapping.target }) : '';
375
+ buildTargetOptions($target, $direction.val(), storedTargetValue);
376
+
377
+ $direction.on('change', function () {
378
+ buildTargetOptions($target, $(this).val(), $target.val() || $target.data('storedTargetValue') || '');
379
+ });
380
+
381
+ // GA
382
+ const $ga = $('<input/>', { type: 'text', class: 'matter-ga', placeholder: '1/1/1' }).val(mapping.ga);
383
+ createLabeledField(bottomRow, 'ga', $ga, { width: '110px' });
384
+ try {
385
+ if (oNodeServer && oNodeServer.id) KNX_enableSecureFormatting($ga, oNodeServer.id);
386
+ } catch (error) { /* empty */ }
387
+
388
+ // DPT
389
+ const $dpt = $('<input/>', { type: 'text', class: 'matter-dpt', placeholder: '1.001' }).val(mapping.dpt);
390
+ createLabeledField(bottomRow, 'dpt', $dpt, { width: '90px' });
391
+
392
+ // GA name
393
+ const $gaName = $('<input/>', { type: 'text', class: 'matter-ganame' }).val(mapping.gaName);
394
+ createLabeledField(bottomRow, 'ganame', $gaName, { flex: '1 1 220px' });
395
+
396
+ $ga.autocomplete({
397
+ minLength: 0,
398
+ source(request, response) {
399
+ if (!oNodeServer) { response([]); return; }
400
+ $.getJSON(`knxUltimatecsv?nodeID=${oNodeServer.id}`, (data) => {
401
+ response($.map(data, (value) => {
402
+ const search = `${value.ga} (${value.devicename}) DPT${value.dpt}`;
403
+ if (htmlUtilsfullCSVSearch(search, request.term)) {
404
+ return {
405
+ label: `${value.ga} # ${value.devicename} # ${value.dpt}`,
406
+ value: value.ga,
407
+ dpt: value.dpt
408
+ };
409
+ }
410
+ return null;
411
+ }));
412
+ });
413
+ },
414
+ select(event, ui) {
415
+ if (!$dpt.val()) $dpt.val(ui.item.dpt);
416
+ if (!$gaName.val()) $gaName.val(ui.item.label.split('#')[1]?.trim() || '');
417
+ }
418
+ });
419
+ $ga.on('focus.knxUltimateMatterDevice click.knxUltimateMatterDevice', function () {
420
+ try { $(this).autocomplete('search', ''); } catch (error) { /* empty */ }
421
+ });
422
+
423
+ // Suggest the DPT when a Matter target is picked
424
+ $target.on('change', function () {
425
+ const val = $(this).val();
426
+ if (!val) return;
427
+ $target.data('storedTargetValue', val);
428
+ try {
429
+ const t = JSON.parse(val);
430
+ const suggested = suggestDPT(t.clusterId, t.targetKind, t.target);
431
+ if (suggested !== '' && !$dpt.val()) $dpt.val(suggested);
432
+ if (!$gaName.val()) {
433
+ const curated = curatedEntryOf(t);
434
+ const friendly = curated ? friendlyLabel(curated.key) : null;
435
+ $gaName.val(friendly || `${t.clusterName || ''} ${t.target}`.trim());
436
+ }
437
+ } catch (error) { /* empty */ }
438
+ });
439
+ }
440
+ });
441
+
442
+ (node.mappings || []).forEach((m) => {
443
+ container.editableList('addItem', { mapping: m });
444
+ });
445
+
446
+ // Load the structure of the already selected device
447
+ if (node.matterNodeId) {
448
+ loadStructure(node.matterNodeId);
449
+ }
450
+
451
+ $('#node-input-serverMatter').on('change.knxUltimateMatterDevice', () => {
452
+ cachedDevices = [];
453
+ });
454
+
455
+ $('#node-input-readStatusAtStartup').val(node.readStatusAtStartup || 'yes');
456
+ $('#node-input-enableNodePINS').val(node.enableNodePINS || 'no');
457
+ },
458
+ oneditsave() {
459
+ try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
460
+ const node = this;
461
+ const items = $('#node-input-mapping-container').editableList('items');
462
+ node.mappings = [];
463
+ items.each(function () {
464
+ const row = $(this);
465
+ const $targetSelect = row.find('.matter-target');
466
+ // Fall back to the remembered value: the select can be empty while the
467
+ // device structure is still loading, and the mapping must never be lost.
468
+ const targetValue = $targetSelect.val() || $targetSelect.data('storedTargetValue');
469
+ if (!targetValue) return; // Skip incomplete rows
470
+ let target = {};
471
+ try { target = JSON.parse(targetValue); } catch (error) { return; }
472
+ node.mappings.push({
473
+ direction: row.find('.matter-direction').val(),
474
+ ga: (row.find('.matter-ga').val() || '').trim(),
475
+ dpt: (row.find('.matter-dpt').val() || '').trim(),
476
+ gaName: (row.find('.matter-ganame').val() || '').trim(),
477
+ endpointId: target.endpointId,
478
+ clusterId: target.clusterId,
479
+ clusterName: target.clusterName,
480
+ targetKind: target.targetKind,
481
+ target: target.target
482
+ });
483
+ });
484
+ this.enableNodePINS = $('#node-input-enableNodePINS').val();
485
+ this.outputs = this.enableNodePINS === 'yes' ? 1 : 0;
486
+ this.inputs = this.enableNodePINS === 'yes' ? 1 : 0;
487
+ },
488
+ oneditcancel() {
489
+ try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
490
+ }
491
+ });
492
+ }());
493
+ </script>
494
+
495
+ <script type="text/html" data-template-name="knxUltimateMatterDevice">
496
+ <div class="form-row" style="margin-bottom:10px;">
497
+ <span style="color:#ff0000"><i class="fa fa-youtube"></i></span>&nbsp;<a target="_blank" href="https://www.youtube.com/playlist?list=PL9Yh1bjbLAYrU8PsVhW4xzEug2WtVFv3E"><b>KNX-Ultimate video tutorials (YouTube playlist)</b></a>
498
+ </div>
499
+
500
+ <div class="form-row">
501
+ <label for="node-input-server">
502
+ <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAKnRFWHRDcmVhdGlvbiBUaW1lAEZyIDYgQXVnIDIwMTAgMjE6NTI6MTkgKzAxMDD84aS8AAAAB3RJTUUH3gYYCicNV+4WIQAAAAlwSFlzAAALEgAACxIB0t1+/AAAAARnQU1BAACxjwv8YQUAAACUSURBVHjaY2CgFZg5c+Z/ZEyWAZ8+f/6/ZsWs/xoamqMGkGrA6Wla/1+fVARjEBuGsSoGmY4eZSCNL59d/g8DIDbIAHR14OgFGQByKjIGKX5+6/T///8gGMQGiV1+/B0Fg70GIkD+RMYgxf/O5/7//2MSmAZhkBi6OrgB6Bg5DGB4ajr3f2xqsYYLSDE2THJUDg0AAAqyDVd4tp4YAAAAAElFTkSuQmCC" />
503
+ <span data-i18n="common.knx_gw"></span>
504
+ </label>
505
+ <input type="text" id="node-input-server">
506
+ </div>
507
+
508
+ <div class="form-row">
509
+ <label for="node-input-serverMatter">
510
+ <i class="fa fa-share-alt"></i>
511
+ <span data-i18n="knxUltimateMatterDevice.matter_controller"></span>
512
+ </label>
513
+ <input type="text" id="node-input-serverMatter">
514
+ </div>
515
+
516
+ <div class="form-row">
517
+ <label for="node-input-name">
518
+ <i class="fa fa-microchip"></i> <span data-i18n="knxUltimateMatterDevice.matter_device"></span>
519
+ </label>
520
+ <input type="text" id="node-input-name" data-i18n="[placeholder]knxUltimateMatterDevice.device_placeholder" style="flex:1 1 240px; min-width:240px; max-width:240px;">
521
+ <button type="button" class="red-ui-button matter-refresh-devices" style="margin-left:6px; color:#1b7d33; border-color:#1b7d33;">
522
+ <i class="fa fa-refresh"></i>
523
+ </button>
524
+ <span class="matter-devices-loading" style="margin-left:6px; display:none; color:#1b7d33;">
525
+ <i class="fa fa-circle-notch fa-spin"></i>
526
+ </span>
527
+ </div>
528
+
529
+ <div class="form-tips" style="margin-bottom:8px;">
530
+ <i class="fa fa-circle-info"></i>
531
+ <span data-i18n="knxUltimateMatterDevice.mapping_help"></span>
532
+ </div>
533
+
534
+ <div class="form-row node-input-mapping-container-row">
535
+ <ol id="node-input-mapping-container"></ol>
536
+ </div>
537
+
538
+ <hr/>
539
+
540
+ <div class="form-row">
541
+ <label for="node-input-readStatusAtStartup" style="width:220px;">
542
+ <i class="fa fa-question-circle"></i> <span data-i18n="knxUltimateMatterDevice.read_status_startup"></span>
543
+ </label>
544
+ <select id="node-input-readStatusAtStartup" style="width:180px;">
545
+ <option value="yes" data-i18n="knxUltimateMatterDevice.opt_yes_emit"></option>
546
+ <option value="no" data-i18n="knxUltimateMatterDevice.opt_no"></option>
547
+ </select>
548
+ </div>
549
+
550
+ <div class="form-row">
551
+ <label for="node-input-enableNodePINS" style="width:220px;">
552
+ <i class="fa fa-code"></i> <span data-i18n="knxUltimateMatterDevice.node_pins"></span>
553
+ </label>
554
+ <select id="node-input-enableNodePINS" style="width:180px;">
555
+ <option value="no" data-i18n="knxUltimateMatterDevice.node_pins_hide"></option>
556
+ <option value="yes" data-i18n="knxUltimateMatterDevice.node_pins_show"></option>
557
+ </select>
558
+ </div>
559
+
560
+ <input type="hidden" id="node-input-matterNodeId">
561
+
562
+ <br/>
563
+ <br/>
564
+ <br/>
565
+ <br/>
566
+ <br/>
567
+ <br/>
568
+ </script>
569
+
570
+ <script type="text/markdown" data-help-name="knxUltimateMatterDevice">
571
+ Bridges a **Matter device** to **KNX group addresses**. This node is in **BETA**.
572
+
573
+ Select a Matter device (previously paired in the Matter Controller configuration node), then add mappings between KNX group addresses and the Matter clusters exposed by the device.
574
+
575
+ Each mapping has a direction:
576
+
577
+ - **KNX → Matter (command)**: a telegram received on the group address invokes a Matter cluster command (or writes an attribute). Example: GA `1/1/1` DPT 1.001 → `OnOff.on/off`.
578
+ - **Matter → KNX (status)**: when the subscribed Matter attribute changes, its value is converted and written to the group address. Example: `OnOff.onOff` → GA `1/1/2` DPT 1.001. GroupValue_Read requests on the group address are answered with the cached value.
579
+
580
+ Well-known clusters (OnOff, LevelControl, WindowCovering, Thermostat, ColorControl, sensors, DoorLock, battery, power/energy measurement) are automatically converted to/from the appropriate KNX DPT values (scaling, units). Other clusters are passed through raw.
581
+
582
+ If you enable the **node PINs**, you can also:
583
+
584
+ - send raw commands from the flow: `msg.payload = { endpointId: 1, clusterId: 6, command: "toggle" }` or `{ endpointId: 1, clusterId: 8, attribute: "onLevel", value: 128 }`
585
+ - receive every attribute change and cluster event of the device on the output pin.
586
+ </script>