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