node-red-contrib-symi-mesh 1.8.11 → 1.8.12
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/README.md +8 -0
- package/nodes/symi-ha-sync.html +131 -85
- package/nodes/symi-knx-bridge.html +1 -1
- package/nodes/symi-knx-bridge.js +57 -7
- package/nodes/symi-knx-ha-bridge.html +175 -95
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -704,6 +704,14 @@ node-red-contrib-symi-mesh/
|
|
|
704
704
|
|
|
705
705
|
## 更新日志
|
|
706
706
|
|
|
707
|
+
### v1.8.12 (2026-01-17)
|
|
708
|
+
- **KNX场景同步优化**:新增“场景”设备类型支持,修复了KNX场景(DPT 17.001)触发Mesh开关的逻辑。
|
|
709
|
+
- **场景映射**:支持配置 KNX 场景号(1-64) 与 Mesh 开关状态(开/关) 的直接映射。
|
|
710
|
+
- **防死循环**:针对场景触发的单向特性,特别优化了防环路机制,确保 Mesh 状态变化后不会再次触发场景发送。
|
|
711
|
+
- **UI兼容性恢复**:
|
|
712
|
+
- **字段扩展**:在经典界面中无缝集成了场景配置所需的“场景号”和“动作”字段。
|
|
713
|
+
- **配置窗口优化**:保留了配置窗口的尺寸自动扩展功能,提供更宽敞的编辑视野。
|
|
714
|
+
|
|
707
715
|
### v1.8.11 (2026-01-16)
|
|
708
716
|
|
|
709
717
|
**核心稳定性修复**:
|
package/nodes/symi-ha-sync.html
CHANGED
|
@@ -250,96 +250,133 @@
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
// 渲染映射列表
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
mappings.forEach(function(m, idx) {
|
|
263
|
-
try {
|
|
264
|
-
var row = $('<div class="mapping-row" data-idx="' + idx + '"></div>');
|
|
265
|
-
var symiOpts = getSymiOptions(m.symiMac, m.symiName);
|
|
266
|
-
var keyOpts = getKeyOptions(m.symiMac, m.symiKey || 1, m.symiChannels, m.symiEntityType);
|
|
267
|
-
var syncModeOpts = getSyncModeOptions(m.syncMode || 0);
|
|
268
|
-
var haOpts = getHaOptions(m.haEntityId, m.haEntityName);
|
|
269
|
-
|
|
270
|
-
row.html(
|
|
271
|
-
'<div class="mapping-main">' +
|
|
272
|
-
'<div class="symi-col">' +
|
|
273
|
-
' <select class="symi-select">' + symiOpts + '</select>' +
|
|
274
|
-
' <span class="symi-key-wrap">' + keyOpts + '</span>' +
|
|
275
|
-
'</div>' +
|
|
276
|
-
'<div class="arrow-col">' + syncModeOpts + '</div>' +
|
|
277
|
-
'<div class="ha-col">' +
|
|
278
|
-
' <select class="ha-select">' + haOpts + '</select>' +
|
|
279
|
-
'</div>' +
|
|
280
|
-
'<div class="del-col"><button type="button" class="red-ui-button red-ui-button-small btn-remove" title="删除"><i class="fa fa-times"></i></button></div>' +
|
|
281
|
-
'</div>'
|
|
282
|
-
);
|
|
283
|
-
container.append(row);
|
|
284
|
-
} catch (err) {
|
|
285
|
-
console.error("[symi-ha-sync] Render row error:", err, m);
|
|
253
|
+
$('#mapping-list').css('min-height','300px').editableList({
|
|
254
|
+
addItem: function(container, i, data) {
|
|
255
|
+
var m = data;
|
|
256
|
+
// 如果是新添加的项,初始化默认值
|
|
257
|
+
if (!m.symiMac && !m.haEntityId) {
|
|
258
|
+
m.symiMac = ''; m.symiName = ''; m.symiKey = 1; m.symiChannels = 1; m.symiDeviceType = '';
|
|
259
|
+
m.symiEntityType = ''; m.syncMode = 0;
|
|
260
|
+
m.haEntityId = ''; m.haEntityName = '';
|
|
286
261
|
}
|
|
287
|
-
});
|
|
288
262
|
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// 绑定事件
|
|
293
|
-
function bindEvents() {
|
|
294
|
-
var container = $('#mapping-list');
|
|
295
|
-
|
|
296
|
-
container.find('.symi-select').off('change').on('change', function() {
|
|
297
|
-
var row = $(this).closest('.mapping-row');
|
|
298
|
-
var idx = row.data('idx');
|
|
299
|
-
var mac = $(this).val();
|
|
300
|
-
var opt = $(this).find('option:selected');
|
|
263
|
+
var row = $('<div class="mapping-row" style="display:flex;align-items:center;gap:5px;padding:5px 0;"></div>').appendTo(container);
|
|
301
264
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
mappings[idx].symiEntityType = opt.data('entitytype') || '';
|
|
307
|
-
mappings[idx].symiKey = 1;
|
|
265
|
+
var symiOpts = getSymiOptions(m.symiMac, m.symiName);
|
|
266
|
+
var keyOpts = getKeyOptions(m.symiMac, m.symiKey || 1, m.symiChannels, m.symiEntityType);
|
|
267
|
+
var syncModeOpts = getSyncModeOptions(m.syncMode || 0);
|
|
268
|
+
var haOpts = getHaOptions(m.haEntityId, m.haEntityName);
|
|
308
269
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
270
|
+
// Symi Column
|
|
271
|
+
var symiCol = $('<div class="symi-col" style="flex:1;display:flex;gap:4px;min-width:0;"></div>').appendTo(row);
|
|
272
|
+
var symiSelect = $('<select class="symi-select" style="flex:1;padding:4px;border:1px solid #81c784;border-radius:3px;background:#e8f5e9;font-size:12px;">' + symiOpts + '</select>').appendTo(symiCol);
|
|
273
|
+
var symiKeyWrap = $('<span class="symi-key-wrap">' + keyOpts + '</span>').appendTo(symiCol);
|
|
274
|
+
|
|
275
|
+
// Arrow Column
|
|
276
|
+
var arrowCol = $('<div class="arrow-col" style="flex:0 0 100px;text-align:center;"></div>').appendTo(row);
|
|
277
|
+
var syncModeSelect = $(syncModeOpts).appendTo(arrowCol).css({width:'100%',padding:'4px',border:'1px solid #ccc',borderRadius:'3px',fontSize:'11px'});
|
|
278
|
+
|
|
279
|
+
// HA Column
|
|
280
|
+
var haCol = $('<div class="ha-col" style="flex:1;min-width:0;"></div>').appendTo(row);
|
|
281
|
+
var haSelect = $('<select class="ha-select" style="width:100%;padding:4px;border:1px solid #41BDF5;border-radius:3px;background:#e3f2fd;font-size:12px;">' + haOpts + '</select>').appendTo(haCol);
|
|
282
|
+
|
|
283
|
+
// 绑定事件
|
|
284
|
+
symiSelect.on('change', function() {
|
|
285
|
+
var mac = $(this).val();
|
|
286
|
+
var opt = $(this).find('option:selected');
|
|
287
|
+
|
|
288
|
+
m.symiMac = mac || '';
|
|
289
|
+
m.symiName = opt.data('name') || opt.text().replace(/ \[.*?\]/g, '');
|
|
290
|
+
m.symiChannels = parseInt(opt.data('channels')) || 1;
|
|
291
|
+
m.symiDeviceType = opt.data('devicetype') || '';
|
|
292
|
+
m.symiEntityType = opt.data('entitytype') || '';
|
|
293
|
+
m.symiKey = 1;
|
|
294
|
+
|
|
295
|
+
symiKeyWrap.html(getKeyOptions(mac, 1, m.symiChannels, m.symiEntityType));
|
|
296
|
+
// 重新绑定新生成的按键下拉框事件
|
|
297
|
+
bindKeyEvents();
|
|
298
|
+
});
|
|
317
299
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
300
|
+
function bindKeyEvents() {
|
|
301
|
+
symiKeyWrap.find('.symi-key').on('change', function() {
|
|
302
|
+
m.symiKey = parseInt($(this).val()) || 1;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
bindKeyEvents();
|
|
322
306
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
mappings[idx].haEntityId = $(this).val();
|
|
327
|
-
mappings[idx].haEntityName = opt.data('name') || opt.text().split(' (')[0].replace(/ \[.*?\]/g, '');
|
|
328
|
-
});
|
|
307
|
+
syncModeSelect.on('change', function() {
|
|
308
|
+
m.syncMode = parseInt($(this).val()) || 0;
|
|
309
|
+
});
|
|
329
310
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
311
|
+
haSelect.on('change', function() {
|
|
312
|
+
var opt = $(this).find('option:selected');
|
|
313
|
+
m.haEntityId = $(this).val();
|
|
314
|
+
m.haEntityName = opt.data('name') || opt.text().split(' (')[0].replace(/ \[.*?\]/g, '');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// 保存更新UI的方法供外部调用
|
|
318
|
+
container.data('refreshUI', function() {
|
|
319
|
+
var currentSymi = symiSelect.val();
|
|
320
|
+
var currentKey = symiKeyWrap.find('.symi-key').val();
|
|
321
|
+
var currentHa = haSelect.val();
|
|
322
|
+
|
|
323
|
+
symiSelect.html(getSymiOptions(m.symiMac, m.symiName));
|
|
324
|
+
// 如果之前选中的还在,保持选中;否则选中数据中的值
|
|
325
|
+
if (symiSelect.find('option[value="'+currentSymi+'"]').length) symiSelect.val(currentSymi);
|
|
326
|
+
|
|
327
|
+
// 刷新按键
|
|
328
|
+
// 注意:如果设备列表变了,channels可能变了,所以要重新生成
|
|
329
|
+
// 我们需要重新获取设备信息来确认channels
|
|
330
|
+
// 但这里简单起见,我们假设 getKeyOptions 会处理
|
|
331
|
+
// m.symiChannels 应该在 loadSymiDevices 时更新吗?
|
|
332
|
+
// loadSymiDevices 更新了 symiDevices 数组
|
|
333
|
+
// 我们需要从 symiDevices 中找到当前设备并更新 m.symiChannels
|
|
334
|
+
var dev = symiDevices.find(d => d.macAddress === m.symiMac);
|
|
335
|
+
if (dev) {
|
|
336
|
+
m.symiChannels = dev.channels;
|
|
337
|
+
m.symiEntityType = dev.entityType;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
symiKeyWrap.html(getKeyOptions(m.symiMac, m.symiKey, m.symiChannels, m.symiEntityType));
|
|
341
|
+
bindKeyEvents();
|
|
342
|
+
|
|
343
|
+
haSelect.html(getHaOptions(m.haEntityId, m.haEntityName));
|
|
344
|
+
if (haSelect.find('option[value="'+currentHa+'"]').length) haSelect.val(currentHa);
|
|
345
|
+
});
|
|
346
|
+
},
|
|
347
|
+
sortable: true,
|
|
348
|
+
removable: true,
|
|
349
|
+
addButton: false,
|
|
350
|
+
height: 400
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
function renderMappings() {
|
|
354
|
+
// 这个函数现在只用于初次加载
|
|
355
|
+
if (mappings && mappings.length > 0) {
|
|
356
|
+
// 只有当列表为空时才添加,避免重复
|
|
357
|
+
if ($('#mapping-list').editableList('items').length === 0) {
|
|
358
|
+
mappings.forEach(function(m) {
|
|
359
|
+
$('#mapping-list').editableList('addItem', m);
|
|
360
|
+
});
|
|
361
|
+
} else {
|
|
362
|
+
// 如果列表不为空,说明是刷新操作,调用 refreshUI
|
|
363
|
+
$('#mapping-list').editableList('items').each(function() {
|
|
364
|
+
var refresh = $(this).data('refreshUI');
|
|
365
|
+
if (refresh) refresh();
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
335
369
|
}
|
|
336
370
|
|
|
371
|
+
// 绑定事件 (已移至 addItem 内部)
|
|
372
|
+
function bindEvents() {}
|
|
373
|
+
|
|
337
374
|
// 刷新按钮
|
|
338
375
|
$('#refresh-symi-btn').on('click', function() {
|
|
339
376
|
var btn = $(this);
|
|
340
377
|
btn.prop('disabled', true).find('i').addClass('fa-spin');
|
|
341
378
|
loadSymiDevices(function() {
|
|
342
|
-
renderMappings();
|
|
379
|
+
renderMappings(); // 这里会触发 refreshUI
|
|
343
380
|
btn.prop('disabled', false).find('i').removeClass('fa-spin');
|
|
344
381
|
});
|
|
345
382
|
});
|
|
@@ -348,39 +385,48 @@
|
|
|
348
385
|
var btn = $(this);
|
|
349
386
|
btn.prop('disabled', true).find('i').addClass('fa-spin');
|
|
350
387
|
loadHaEntities(function() {
|
|
351
|
-
renderMappings();
|
|
388
|
+
renderMappings(); // 这里会触发 refreshUI
|
|
352
389
|
btn.prop('disabled', false).find('i').removeClass('fa-spin');
|
|
353
390
|
});
|
|
354
391
|
});
|
|
355
392
|
|
|
356
393
|
// 添加映射按钮
|
|
357
394
|
$('#btn-add-mapping').on('click', function() {
|
|
358
|
-
|
|
395
|
+
$('#mapping-list').editableList('addItem', {
|
|
359
396
|
symiMac: '', symiName: '', symiKey: 1, symiChannels: 1, symiDeviceType: '',
|
|
360
397
|
symiEntityType: '', syncMode: 0,
|
|
361
398
|
haEntityId: '', haEntityName: ''
|
|
362
399
|
});
|
|
363
|
-
renderMappings();
|
|
364
|
-
var list = $('#mapping-list');
|
|
365
|
-
list.scrollTop(list.prop('scrollHeight'));
|
|
366
400
|
});
|
|
367
401
|
|
|
368
402
|
// 配置变化时重新加载
|
|
369
403
|
$('#node-input-mqttConfig').on('change', function() {
|
|
370
404
|
setTimeout(function() {
|
|
371
|
-
loadSymiDevices(
|
|
405
|
+
loadSymiDevices(function() {
|
|
406
|
+
// 清空列表并重新渲染,或者只是刷新?
|
|
407
|
+
// 如果MQTT配置变了,设备列表完全不同,最好清空
|
|
408
|
+
// 但如果用户已经配置了一些,清空会丢失数据
|
|
409
|
+
// 所以我们尝试刷新,未找到的会显示红色
|
|
410
|
+
renderMappings();
|
|
411
|
+
});
|
|
372
412
|
}, 100);
|
|
373
413
|
});
|
|
374
414
|
|
|
375
415
|
$('#node-input-haServer').on('change', function() {
|
|
376
416
|
setTimeout(function() {
|
|
377
|
-
loadHaEntities(
|
|
417
|
+
loadHaEntities(function() {
|
|
418
|
+
renderMappings();
|
|
419
|
+
});
|
|
378
420
|
}, 100);
|
|
379
421
|
});
|
|
380
422
|
|
|
381
423
|
// 保存时更新所有数据
|
|
382
424
|
node._saveAll = function() {
|
|
383
|
-
|
|
425
|
+
var newMappings = [];
|
|
426
|
+
$('#mapping-list').editableList('items').each(function() {
|
|
427
|
+
newMappings.push($(this).data('data'));
|
|
428
|
+
});
|
|
429
|
+
node.mappings = JSON.stringify(newMappings);
|
|
384
430
|
node.cachedSymiDevices = JSON.stringify(cachedSymiDevices);
|
|
385
431
|
node.cachedHaEntities = JSON.stringify(cachedHaEntities);
|
|
386
432
|
$('#node-input-mappings').val(node.mappings);
|
|
@@ -400,7 +446,7 @@
|
|
|
400
446
|
var height = size.height;
|
|
401
447
|
for (var i = 0; i < rows.length; i++) { height -= $(rows[i]).outerHeight(true); }
|
|
402
448
|
height -= 120;
|
|
403
|
-
$('#mapping-list').
|
|
449
|
+
$('#mapping-list').editableList('height', Math.max(150, height));
|
|
404
450
|
},
|
|
405
451
|
oneditsave: function() {
|
|
406
452
|
if (this._saveAll) { this._saveAll(); }
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
'climate': ['开关地址*','温度地址','模式地址','风速地址','当前温度'],
|
|
113
113
|
'fresh_air': ['开关地址*','风速地址'],
|
|
114
114
|
'floor_heating': ['开关地址*','温度地址','当前温度'],
|
|
115
|
-
'scene': ['KNX组地址*', '场景号(1-64)*', '
|
|
115
|
+
'scene': ['KNX组地址*', '场景号(1-64)*', '触发Mesh动作(1=开/0=关)*']
|
|
116
116
|
};
|
|
117
117
|
|
|
118
118
|
// 统一的添加/编辑KNX实体面板
|
package/nodes/symi-knx-bridge.js
CHANGED
|
@@ -107,6 +107,13 @@ module.exports = function(RED) {
|
|
|
107
107
|
mapping.knxAddrTemp = knxEntity.statusAddr || '';
|
|
108
108
|
mapping.knxAddrCurrentTemp = knxEntity.ext1 || '';
|
|
109
109
|
break;
|
|
110
|
+
case 'scene':
|
|
111
|
+
// 场景: 组地址(cmd), 场景号(ext1), 动作(ext2)
|
|
112
|
+
// ext1: 场景号 1-64
|
|
113
|
+
// ext2: 动作 1=开, 0=关
|
|
114
|
+
mapping.sceneNumber = parseInt(knxEntity.ext1) || 1;
|
|
115
|
+
mapping.sceneAction = (parseInt(knxEntity.ext2) === 1);
|
|
116
|
+
break;
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
// 收集所有KNX地址用于快速查找
|
|
@@ -357,20 +364,19 @@ module.exports = function(RED) {
|
|
|
357
364
|
|
|
358
365
|
// 检查是否匹配配置的触发动作
|
|
359
366
|
if (switchValue === mapping.sceneAction) {
|
|
367
|
+
// 场景命令 DPT 17.001 (1字节无符号整数)
|
|
368
|
+
// payload = 场景号 - 1 (KNX wire format: 0-63)
|
|
369
|
+
const scenePayload = mapping.sceneNumber - 1;
|
|
370
|
+
|
|
360
371
|
// 构造防死循环key
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
const sceneLoopKey = `${loopKey}_scene_${mapping.sceneNumber}`;
|
|
372
|
+
// 统一使用 KNX 值 (0-63) 作为 key 的一部分
|
|
373
|
+
const sceneLoopKey = `${loopKey}_scene_${scenePayload}`;
|
|
364
374
|
|
|
365
375
|
if (node.shouldPreventSync('mesh-to-knx', sceneLoopKey)) {
|
|
366
376
|
node.log(`[Mesh->KNX] 跳过场景触发(防死循环): ${sceneLoopKey}`);
|
|
367
377
|
continue;
|
|
368
378
|
}
|
|
369
379
|
|
|
370
|
-
// 场景命令 DPT 17.001 (1字节无符号整数)
|
|
371
|
-
// payload = 场景号 - 1 (KNX wire format: 0-63)
|
|
372
|
-
const scenePayload = mapping.sceneNumber - 1;
|
|
373
|
-
|
|
374
380
|
const knxMsg = {
|
|
375
381
|
topic: mapping.knxAddrCmd,
|
|
376
382
|
payload: scenePayload,
|
|
@@ -1105,6 +1111,50 @@ module.exports = function(RED) {
|
|
|
1105
1111
|
});
|
|
1106
1112
|
}
|
|
1107
1113
|
}
|
|
1114
|
+
// 场景触发逻辑 (新增)
|
|
1115
|
+
else if (mapping.deviceType === 'scene') {
|
|
1116
|
+
if (addrFunc === 'cmd') { // 场景通常只有一个组地址,这里我们用cmdAddr匹配
|
|
1117
|
+
// 场景 DPT 17.001 实际上是 0-63 的整数
|
|
1118
|
+
// 但有时也会用 DPT 5.001 (0-255)
|
|
1119
|
+
const receivedScene = parseInt(value) || 0;
|
|
1120
|
+
const targetScene = parseInt(mapping.sceneNumber) || 1; // 配置的场景号 (1-64)
|
|
1121
|
+
|
|
1122
|
+
// KNX场景值通常是 场景号-1 (例如场景1发送0)
|
|
1123
|
+
// 但也有设备发送直接的场景号,这里我们兼容两种情况:
|
|
1124
|
+
// 如果接收值 == 配置值,或者 接收值 == 配置值-1,都认为匹配
|
|
1125
|
+
// 更严谨的做法是:DPT 17.001 规定传输值 = 场景号 - 1
|
|
1126
|
+
// 所以如果配置的是1 (UI显示1),实际收到应该是0
|
|
1127
|
+
// 我们这里做宽容匹配:
|
|
1128
|
+
|
|
1129
|
+
let matched = false;
|
|
1130
|
+
// 情况A: 标准 DPT 17.001, 收到值 = 场景号-1
|
|
1131
|
+
if (receivedScene === (targetScene - 1)) matched = true;
|
|
1132
|
+
// 情况B: 非标准或用户直接配置了0-63的原始值
|
|
1133
|
+
else if (receivedScene === targetScene) matched = true;
|
|
1134
|
+
|
|
1135
|
+
if (matched) {
|
|
1136
|
+
const action = (mapping.sceneAction === 'on' || mapping.sceneAction === true || mapping.sceneAction === 1);
|
|
1137
|
+
const loopKey = `${mapping.meshMac}_${mapping.meshChannel}_scene_${receivedScene}`;
|
|
1138
|
+
|
|
1139
|
+
// 防死循环
|
|
1140
|
+
if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
|
|
1141
|
+
node.log(`[KNX->Mesh] 跳过场景触发(防死循环): ${loopKey}`);
|
|
1142
|
+
done && done();
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
node.log(`[KNX->Mesh] 场景触发: 收到场景${receivedScene+1} -> Mesh开关=${action?'ON':'OFF'}`);
|
|
1147
|
+
node.queueCommand({
|
|
1148
|
+
direction: 'knx-to-mesh',
|
|
1149
|
+
mapping: mapping,
|
|
1150
|
+
type: 'switch',
|
|
1151
|
+
value: action,
|
|
1152
|
+
key: loopKey,
|
|
1153
|
+
sourceAddr: groupAddr
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1108
1158
|
// 窗帘设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
|
|
1109
1159
|
else if (mapping.deviceType === 'cover') {
|
|
1110
1160
|
const deviceKey = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
@@ -17,17 +17,108 @@
|
|
|
17
17
|
label: function() { return this.name || 'KNX HA桥接'; },
|
|
18
18
|
oneditprepare: function() {
|
|
19
19
|
const node = this;
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
// 设置编辑面板更宽更高
|
|
22
|
+
var panel = $('#dialog-form').parent();
|
|
23
|
+
if (panel.length) {
|
|
24
|
+
if (panel.width() < 1000) panel.css('width', '1000px');
|
|
25
|
+
if (panel.height() < 1000) panel.css('min-height', '1000px');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let haEntities = [];
|
|
21
29
|
const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖'};
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
// 初始化列表容器
|
|
32
|
+
$('#knx-list').css('min-height','300px').css('max-height','500px').editableList({
|
|
33
|
+
addItem: function(container, i, data) {
|
|
34
|
+
const e = data;
|
|
35
|
+
const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
36
|
+
const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
|
|
37
|
+
|
|
38
|
+
const row = $('<div class="knx-row" style="display:flex;align-items:center;font-size:12px;"></div>').appendTo(container);
|
|
39
|
+
$('<div style="flex:2;padding:0 5px;">'+e.name+'</div>').appendTo(row);
|
|
40
|
+
$('<div style="flex:1;padding:0 5px;">'+(typeLabels[e.type]||e.type)+inv+'</div>').appendTo(row);
|
|
41
|
+
$('<div style="flex:1;padding:0 5px;">'+e.cmdAddr+'</div>').appendTo(row);
|
|
42
|
+
$('<div style="flex:1;padding:0 5px;">'+(e.statusAddr||'-')+'</div>').appendTo(row);
|
|
43
|
+
$('<div style="flex:1;padding:0 5px;">'+(ext||'-')+'</div>').appendTo(row);
|
|
44
|
+
|
|
45
|
+
const btns = $('<div style="width:60px;text-align:right;"></div>').appendTo(row);
|
|
46
|
+
$('<button class="red-ui-button red-ui-button-small e-edit" title="编辑"><i class="fa fa-pencil"></i></button>')
|
|
47
|
+
.appendTo(btns)
|
|
48
|
+
.on('click', function() { editKnxEntity(data, container); });
|
|
49
|
+
|
|
50
|
+
container.find('.e-inv').on('change', function() {
|
|
51
|
+
e.invert = $(this).is(':checked');
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
sortable: true,
|
|
55
|
+
removable: true,
|
|
56
|
+
addButton: false,
|
|
57
|
+
height: 350
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
$('#map-list').css('min-height','300px').css('max-height','500px').editableList({
|
|
61
|
+
addItem: function(container, i, data) {
|
|
62
|
+
const m = data;
|
|
63
|
+
const row = $('<div class="map-row" style="display:flex;align-items:center;gap:5px;"></div>').appendTo(container);
|
|
64
|
+
|
|
65
|
+
// KNX实体选择
|
|
66
|
+
const selKnx = $('<select class="m-knx" style="flex:1;font-size:11px;"><option value="">--选择KNX--</option></select>').appendTo(row);
|
|
67
|
+
function updateKnxOpts() {
|
|
68
|
+
const currentVal = selKnx.val() || m.knxEntityId;
|
|
69
|
+
selKnx.empty().append('<option value="">--选择KNX--</option>');
|
|
70
|
+
const entities = [];
|
|
71
|
+
$('#knx-list').editableList('items').each(function() {
|
|
72
|
+
entities.push($(this).data('data'));
|
|
73
|
+
});
|
|
74
|
+
entities.forEach(e => {
|
|
75
|
+
const inv = e.invert ? '↕' : '';
|
|
76
|
+
$('<option value="'+e.id+'">'+e.name+'['+e.cmdAddr+']('+inv+(typeLabels[e.type]||'')+')</option>').appendTo(selKnx);
|
|
77
|
+
});
|
|
78
|
+
selKnx.val(currentVal);
|
|
79
|
+
}
|
|
80
|
+
updateKnxOpts();
|
|
81
|
+
container.data('updateKnxOpts', updateKnxOpts);
|
|
82
|
+
|
|
83
|
+
// HA实体选择 (使用 datalist 实现搜索)
|
|
84
|
+
const listId = 'ha-list-' + Math.floor(Math.random() * 1000000);
|
|
85
|
+
$('<input type="text" class="m-ha-input" placeholder="输入实体ID或名称搜索" value="'+(m.haEntityId||'')+'" list="'+listId+'" style="flex:1;font-size:11px;">').appendTo(row);
|
|
86
|
+
const datalist = $('<datalist id="'+listId+'"></datalist>').appendTo(row);
|
|
87
|
+
|
|
88
|
+
function updateHaOpts() {
|
|
89
|
+
datalist.empty();
|
|
90
|
+
haEntities.forEach(e => {
|
|
91
|
+
$('<option value="'+e.entity_id+'">'+e.name+'</option>').appendTo(datalist);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
updateHaOpts();
|
|
95
|
+
// 暴露更新方法
|
|
96
|
+
container.data('updateHaOpts', updateHaOpts);
|
|
97
|
+
|
|
98
|
+
// 绑定事件
|
|
99
|
+
selKnx.on('change', function() {
|
|
100
|
+
m.knxEntityId = $(this).val();
|
|
101
|
+
});
|
|
102
|
+
row.find('.m-ha-input').on('input change', function() {
|
|
103
|
+
m.haEntityId = $(this).val();
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
sortable: true,
|
|
107
|
+
removable: true,
|
|
108
|
+
height: 350
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 加载初始数据
|
|
112
|
+
try {
|
|
113
|
+
const knxEntities = JSON.parse(node.knxEntities || '[]');
|
|
114
|
+
knxEntities.forEach(e => $('#knx-list').editableList('addItem', e));
|
|
115
|
+
} catch(e) {}
|
|
25
116
|
|
|
26
117
|
function loadHaEntities() {
|
|
27
118
|
const sid = $('#node-input-haServer').val();
|
|
28
119
|
if (!sid) {
|
|
29
120
|
haEntities = [];
|
|
30
|
-
|
|
121
|
+
refreshHaLists();
|
|
31
122
|
return;
|
|
32
123
|
}
|
|
33
124
|
|
|
@@ -37,7 +128,7 @@
|
|
|
37
128
|
.done(function(data) {
|
|
38
129
|
console.log('[KNX-HA Bridge] 收到响应:', data);
|
|
39
130
|
haEntities = data || [];
|
|
40
|
-
|
|
131
|
+
refreshHaLists();
|
|
41
132
|
if (haEntities.length > 0) {
|
|
42
133
|
RED.notify('成功加载 ' + haEntities.length + ' 个HA实体', 'success');
|
|
43
134
|
} else {
|
|
@@ -47,11 +138,28 @@
|
|
|
47
138
|
.fail(function(err) {
|
|
48
139
|
console.error('[KNX-HA Bridge] 加载失败:', err);
|
|
49
140
|
haEntities = [];
|
|
50
|
-
|
|
141
|
+
refreshHaLists();
|
|
51
142
|
RED.notify('HA服务器还未就绪,请稍后重试', 'warning');
|
|
52
143
|
});
|
|
53
144
|
}
|
|
54
145
|
|
|
146
|
+
function refreshHaLists() {
|
|
147
|
+
$('#map-list').editableList('items').each(function() {
|
|
148
|
+
const updateFunc = $(this).data('updateHaOpts');
|
|
149
|
+
if (updateFunc) updateFunc();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 刷新映射列表(主要用于初次加载)
|
|
154
|
+
function refreshMappings() {
|
|
155
|
+
if ($('#map-list').editableList('items').length === 0) {
|
|
156
|
+
try {
|
|
157
|
+
const mappings = JSON.parse(node.mappings || '[]');
|
|
158
|
+
mappings.forEach(m => $('#map-list').editableList('addItem', m));
|
|
159
|
+
} catch(e) {}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
55
163
|
$('#download-tpl-btn').on('click', function() {
|
|
56
164
|
const tpl = `# KNX实体导入模板 (Tab分隔)
|
|
57
165
|
# 格式: 名称 类型 命令地址 状态地址 扩展1 扩展2 扩展3
|
|
@@ -81,32 +189,6 @@
|
|
|
81
189
|
a.download = 'knx-template.txt'; a.click();
|
|
82
190
|
});
|
|
83
191
|
|
|
84
|
-
function renderKnxEntities() {
|
|
85
|
-
const c = $('#knx-list'); c.empty(); $('#knx-cnt').text(knxEntities.length);
|
|
86
|
-
if (!knxEntities.length) { c.html('<div class="tips">点击"导入"或"添加"管理KNX实体</div>'); return; }
|
|
87
|
-
let h = '<table class="tbl"><tr><th>名称</th><th>类型</th><th>命令</th><th>状态</th><th>扩展</th><th style="width:60px">操作</th></tr>';
|
|
88
|
-
knxEntities.forEach((e,i) => {
|
|
89
|
-
const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
90
|
-
const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
|
|
91
|
-
h += '<tr data-ei="'+i+'"><td>'+e.name+'</td><td>'+(typeLabels[e.type]||e.type)+inv+'</td><td>'+e.cmdAddr+'</td><td>'+(e.statusAddr||'-')+'</td><td>'+(ext||'-')+'</td><td><button class="red-ui-button red-ui-button-small e-edit" title="编辑"><i class="fa fa-pencil"></i></button> <button class="red-ui-button red-ui-button-small e-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
|
|
92
|
-
});
|
|
93
|
-
c.html(h+'</table>');
|
|
94
|
-
$('.e-inv').off('change').on('change', function() {
|
|
95
|
-
const ei = $(this).closest('tr').data('ei');
|
|
96
|
-
knxEntities[ei].invert = $(this).is(':checked');
|
|
97
|
-
saveKnxData();
|
|
98
|
-
});
|
|
99
|
-
$('.e-edit').off('click').on('click', function() {
|
|
100
|
-
const ei = $(this).closest('tr').data('ei');
|
|
101
|
-
editKnxEntity(ei);
|
|
102
|
-
});
|
|
103
|
-
$('.e-del').off('click').on('click', function() {
|
|
104
|
-
const ei = $(this).closest('tr').data('ei');
|
|
105
|
-
knxEntities.splice(ei, 1);
|
|
106
|
-
renderKnxEntities(); renderMappings();
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
192
|
const typeFields = {
|
|
111
193
|
'switch': ['命令地址*','状态地址'],
|
|
112
194
|
'light_mono': ['开关地址*','状态地址','亮度地址'],
|
|
@@ -119,11 +201,16 @@
|
|
|
119
201
|
'floor_heating': ['开关地址*','温度地址','当前温度']
|
|
120
202
|
};
|
|
121
203
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
204
|
+
// 编辑KNX实体
|
|
205
|
+
let currentEditContainer = null;
|
|
206
|
+
let currentEditData = null;
|
|
207
|
+
|
|
208
|
+
function showEntityPanel(data, container) {
|
|
209
|
+
const isEdit = !!data;
|
|
210
|
+
const e = isEdit ? data : {name:'',type:'switch',cmdAddr:'',statusAddr:'',ext1:'',ext2:'',ext3:'',invert:false};
|
|
211
|
+
currentEditContainer = container;
|
|
212
|
+
currentEditData = data;
|
|
213
|
+
|
|
127
214
|
const typeOpts = Object.keys(typeLabels).map(t => '<option value="'+t+'"'+(t===e.type?' selected':'')+'>'+typeLabels[t]+'</option>').join('');
|
|
128
215
|
const title = isEdit ? '编辑: '+e.name : '添加KNX实体';
|
|
129
216
|
const btnText = isEdit ? '保存' : '添加';
|
|
@@ -163,7 +250,8 @@
|
|
|
163
250
|
const name = $('#edit-name').val().trim();
|
|
164
251
|
const cmd = $('#edit-cmd').val().trim();
|
|
165
252
|
if (!name || !cmd) { RED.notify('请填写名称和命令地址', 'warning'); return; }
|
|
166
|
-
|
|
253
|
+
|
|
254
|
+
const newData = {
|
|
167
255
|
id: isEdit ? e.id : 'k'+Date.now()+Math.random().toString(36).substr(2,4),
|
|
168
256
|
name: name,
|
|
169
257
|
type: $('#edit-type').val(),
|
|
@@ -174,60 +262,35 @@
|
|
|
174
262
|
ext3: $('#edit-ext3').val().trim(),
|
|
175
263
|
invert: $('#edit-inv').is(':checked')
|
|
176
264
|
};
|
|
177
|
-
|
|
178
|
-
|
|
265
|
+
|
|
266
|
+
if (isEdit) {
|
|
267
|
+
Object.assign(currentEditData, newData);
|
|
268
|
+
const items = [];
|
|
269
|
+
$('#knx-list').editableList('items').each(function() {
|
|
270
|
+
items.push($(this).data('data'));
|
|
271
|
+
});
|
|
272
|
+
$('#knx-list').editableList('empty');
|
|
273
|
+
items.forEach(item => $('#knx-list').editableList('addItem', item));
|
|
274
|
+
} else {
|
|
275
|
+
$('#knx-list').editableList('addItem', newData);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
$('#map-list').editableList('items').each(function() {
|
|
279
|
+
const updateFunc = $(this).data('updateKnxOpts');
|
|
280
|
+
if (updateFunc) updateFunc();
|
|
281
|
+
});
|
|
282
|
+
|
|
179
283
|
$('#edit-panel').hide().empty();
|
|
180
|
-
renderKnxEntities(); renderMappings();
|
|
181
284
|
RED.notify(isEdit?'已更新':'已添加', 'success');
|
|
182
285
|
});
|
|
183
286
|
$('#cancel-edit').on('click', function() { $('#edit-panel').hide().empty(); });
|
|
184
287
|
}
|
|
185
|
-
function editKnxEntity(
|
|
186
|
-
|
|
187
|
-
function renderMappings() {
|
|
188
|
-
const c = $('#map-list'); c.empty();
|
|
189
|
-
if (!mappings.length) { c.html('<div class="tips">点击"添加"创建映射</div>'); return; }
|
|
190
|
-
let h = '<table class="tbl"><tr><th style="width:24px">#</th><th style="width:45%">KNX实体</th><th style="width:45%">HA实体</th><th style="width:32px">删除</th></tr>';
|
|
191
|
-
mappings.forEach((m, i) => {
|
|
192
|
-
h += '<tr data-i="'+i+'"><td>'+(i+1)+'</td>';
|
|
193
|
-
h += '<td><select class="m-knx"><option value="">--选择KNX--</option>';
|
|
194
|
-
knxEntities.forEach(e => {
|
|
195
|
-
const inv = e.invert ? '↕' : '';
|
|
196
|
-
h += '<option value="'+e.id+'"'+(e.id===m.knxEntityId?' selected':'')+'>'+e.name+'['+e.cmdAddr+']('+inv+(typeLabels[e.type]||'')+')</option>';
|
|
197
|
-
});
|
|
198
|
-
h += '</select></td>';
|
|
199
|
-
h += '<td><input type="text" class="m-ha-input" placeholder="输入实体ID或名称搜索" value="'+(m.haEntityId||'')+'" list="ha-list-'+i+'" style="width:100%; font-size:11px">';
|
|
200
|
-
h += '<datalist id="ha-list-'+i+'">';
|
|
201
|
-
haEntities.forEach(e => {
|
|
202
|
-
h += '<option value="'+e.entity_id+'">'+e.name+'</option>';
|
|
203
|
-
});
|
|
204
|
-
h += '</datalist></td>';
|
|
205
|
-
h += '<td><button class="red-ui-button red-ui-button-small m-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
|
|
206
|
-
});
|
|
207
|
-
c.html(h+'</table>');
|
|
208
|
-
bindEvents();
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function bindEvents() {
|
|
212
|
-
$('.m-knx').off('change').on('change', function() {
|
|
213
|
-
const i = $(this).closest('tr').data('i');
|
|
214
|
-
mappings[i].knxEntityId = $(this).val();
|
|
215
|
-
});
|
|
216
|
-
$('.m-ha-input').off('input change').on('input change', function() {
|
|
217
|
-
const i = $(this).closest('tr').data('i');
|
|
218
|
-
mappings[i].haEntityId = $(this).val();
|
|
219
|
-
});
|
|
220
|
-
$('.m-del').off('click').on('click', function() {
|
|
221
|
-
mappings.splice($(this).closest('tr').data('i'), 1);
|
|
222
|
-
renderMappings();
|
|
223
|
-
});
|
|
224
|
-
}
|
|
288
|
+
function editKnxEntity(data, container) { showEntityPanel(data, container); }
|
|
225
289
|
|
|
226
290
|
$('#add-map-btn').on('click', function() {
|
|
227
|
-
|
|
228
|
-
renderMappings();
|
|
291
|
+
$('#map-list').editableList('addItem', { knxEntityId:'', haEntityId:'' });
|
|
229
292
|
});
|
|
230
|
-
$('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) {
|
|
293
|
+
$('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) { $('#map-list').editableList('empty'); } });
|
|
231
294
|
|
|
232
295
|
$('#import-btn').on('click', function() { $('#import-modal').show(); });
|
|
233
296
|
$('#import-cancel').on('click', function() { $('#import-modal').hide(); });
|
|
@@ -241,21 +304,32 @@
|
|
|
241
304
|
const p = line.split(/\t+/);
|
|
242
305
|
if (p.length >= 2 && p[1] && /\d+\/\d+\/\d+/.test(p[2]||'')) {
|
|
243
306
|
const id = 'k' + Date.now() + Math.random().toString(36).substr(2,4);
|
|
244
|
-
|
|
307
|
+
const entity = { id, name:p[0].trim(), type:p[1].trim(), cmdAddr:p[2].trim(), statusAddr:(p[3]||'').trim(), ext1:(p[4]||'').trim(), ext2:(p[5]||'').trim(), ext3:(p[6]||'').trim(), invert:false };
|
|
308
|
+
$('#knx-list').editableList('addItem', entity);
|
|
245
309
|
cnt++;
|
|
246
310
|
}
|
|
247
311
|
});
|
|
312
|
+
// 刷新映射列表中的KNX选项
|
|
313
|
+
$('#map-list').editableList('items').each(function() {
|
|
314
|
+
const updateFunc = $(this).data('updateKnxOpts');
|
|
315
|
+
if (updateFunc) updateFunc();
|
|
316
|
+
});
|
|
248
317
|
$('#import-modal').hide(); $('#import-input').val('');
|
|
249
|
-
renderKnxEntities(); renderMappings();
|
|
250
318
|
RED.notify('导入 '+cnt+' 个实体'+(cnt?'':'(需要有效组地址格式如1/2/3)'), cnt?'success':'warning');
|
|
251
319
|
});
|
|
252
|
-
$('#add-knx-btn').on('click', function() { showEntityPanel(
|
|
253
|
-
$('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) {
|
|
320
|
+
$('#add-knx-btn').on('click', function() { showEntityPanel(null, null); });
|
|
321
|
+
$('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) { $('#knx-list').editableList('empty'); } });
|
|
254
322
|
|
|
255
|
-
function saveKnxData() {
|
|
323
|
+
function saveKnxData() {
|
|
324
|
+
const entities = [];
|
|
325
|
+
$('#knx-list').editableList('items').each(function() {
|
|
326
|
+
entities.push($(this).data('data'));
|
|
327
|
+
});
|
|
328
|
+
$('#knx-data').val(JSON.stringify(entities));
|
|
329
|
+
}
|
|
256
330
|
|
|
257
|
-
|
|
258
|
-
renderKnxEntities = function() { origRender(); saveKnxData(); };
|
|
331
|
+
// 渲染后保存 (Deprecated in editableList mode, but kept for compatibility logic if any)
|
|
332
|
+
// renderKnxEntities = function() { origRender(); saveKnxData(); };
|
|
259
333
|
|
|
260
334
|
$('#node-input-haServer').on('change', function() {
|
|
261
335
|
setTimeout(loadHaEntities, 2000);
|
|
@@ -269,7 +343,7 @@
|
|
|
269
343
|
});
|
|
270
344
|
|
|
271
345
|
setTimeout(function() {
|
|
272
|
-
|
|
346
|
+
refreshMappings();
|
|
273
347
|
if ($('#node-input-haServer').val()) {
|
|
274
348
|
setTimeout(loadHaEntities, 2000);
|
|
275
349
|
}
|
|
@@ -277,12 +351,18 @@
|
|
|
277
351
|
},
|
|
278
352
|
oneditsave: function() {
|
|
279
353
|
const maps = [];
|
|
280
|
-
$('#map-list
|
|
281
|
-
const m =
|
|
354
|
+
$('#map-list').editableList('items').each(function() {
|
|
355
|
+
const m = $(this).data('data');
|
|
282
356
|
if (m.knxEntityId && m.haEntityId) maps.push(m);
|
|
283
357
|
});
|
|
284
358
|
this.mappings = JSON.stringify(maps);
|
|
285
|
-
|
|
359
|
+
|
|
360
|
+
const entities = [];
|
|
361
|
+
$('#knx-list').editableList('items').each(function() {
|
|
362
|
+
entities.push($(this).data('data'));
|
|
363
|
+
});
|
|
364
|
+
$('#knx-data').val(JSON.stringify(entities));
|
|
365
|
+
this.knxEntities = $('#knx-data').val();
|
|
286
366
|
}
|
|
287
367
|
});
|
|
288
368
|
</script>
|