node-red-contrib-symi-mesh 1.8.10 → 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 +18 -0
- package/nodes/symi-gateway.js +10 -0
- package/nodes/symi-ha-sync.html +131 -85
- package/nodes/symi-knx-bridge.html +2 -2
- package/nodes/symi-knx-bridge.js +77 -11
- package/nodes/symi-knx-ha-bridge.html +175 -95
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -704,6 +704,24 @@ 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
|
+
|
|
715
|
+
### v1.8.11 (2026-01-16)
|
|
716
|
+
|
|
717
|
+
**核心稳定性修复**:
|
|
718
|
+
- **僵尸节点彻底清除**:修复了在删除或修改 KNX 场景映射并重新部署后,旧的配置逻辑仍在后台运行的问题。
|
|
719
|
+
- **原因分析**:旧节点实例销毁时,未能完全解绑网关事件监听器,导致“僵尸节点”继续响应事件。
|
|
720
|
+
- **解决方案**:引入 `node.isClosed` 标志位,强制拦截销毁后的所有逻辑执行;同时修复了网关连接状态监听器的内存泄漏问题。
|
|
721
|
+
- **内存泄漏修复**:将所有匿名事件监听器改为具名函数,确保在节点关闭时能被正确移除,防止多次部署后的内存累积。
|
|
722
|
+
- **空指针异常防护**:在 `symi-gateway` 中增加了多处连接状态检查,防止网关断开后后台任务访问已销毁的客户端对象,彻底消除 `Cannot read properties of null (reading 'sendFrame')` 报错刷屏。
|
|
723
|
+
- **UI 显示优化**:修复了 KNX 开关类型在配置列表中错误显示“扩展”列数据的问题,现在开关类型的扩展列将正确显示为“-”。
|
|
724
|
+
|
|
707
725
|
### v1.8.10 (2026-01-16)
|
|
708
726
|
|
|
709
727
|
**新增功能**:
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -495,6 +495,10 @@ module.exports = function(RED) {
|
|
|
495
495
|
}
|
|
496
496
|
|
|
497
497
|
for (const attr of queryAttrs) {
|
|
498
|
+
if (!this.client) {
|
|
499
|
+
this.error(`查询设备${device.name}失败: 网关未连接 (Client is null)`);
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
498
502
|
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
499
503
|
await this.client.sendFrame(frame, 2);
|
|
500
504
|
await this.sleep(150); // 增加延迟确保设备有时间响应
|
|
@@ -522,6 +526,7 @@ module.exports = function(RED) {
|
|
|
522
526
|
this.log(`[三合一检测] 查询 ${device.name} 完整状态...`);
|
|
523
527
|
const threeInOneAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D, 0x6A, 0x6C];
|
|
524
528
|
for (const attr of threeInOneAttrs) {
|
|
529
|
+
if (!this.client) break;
|
|
525
530
|
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
526
531
|
await this.client.sendFrame(frame, 2);
|
|
527
532
|
await this.sleep(150);
|
|
@@ -545,6 +550,7 @@ module.exports = function(RED) {
|
|
|
545
550
|
// 查询温控器状态
|
|
546
551
|
const tempCtrlAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D];
|
|
547
552
|
for (const attr of tempCtrlAttrs) {
|
|
553
|
+
if (!this.client) break;
|
|
548
554
|
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
549
555
|
await this.client.sendFrame(frame, 2);
|
|
550
556
|
await this.sleep(150);
|
|
@@ -567,6 +573,10 @@ module.exports = function(RED) {
|
|
|
567
573
|
this.log('启用设备状态上报功能...');
|
|
568
574
|
for (const device of devices) {
|
|
569
575
|
try {
|
|
576
|
+
if (!this.client) {
|
|
577
|
+
this.error('网关连接断开,停止启用状态上报');
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
570
580
|
// 启用状态上报:msg_type=0x10, param=0x01
|
|
571
581
|
const frame = this.protocolHandler.buildDeviceControlFrame(device.networkAddress, 0x10, Buffer.from([0x01]));
|
|
572
582
|
await this.client.sendFrame(frame, 2);
|
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(); }
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
if (!knxEntities.length) { c.html('<div class="tips">点击"导入"或"添加"管理KNX实体</div>'); return; }
|
|
80
80
|
let h = '<table class="tbl"><tr><th>名称</th><th>类型</th><th>命令</th><th>状态</th><th>扩展</th><th style="width:60px">操作</th></tr>';
|
|
81
81
|
knxEntities.forEach((e,i) => {
|
|
82
|
-
const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
82
|
+
const ext = (e.type === 'switch') ? '-' : [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
83
83
|
const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
|
|
84
84
|
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>';
|
|
85
85
|
});
|
|
@@ -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地址用于快速查找
|
|
@@ -178,6 +185,7 @@ module.exports = function(RED) {
|
|
|
178
185
|
|
|
179
186
|
// 初始化标记
|
|
180
187
|
node.initializing = true;
|
|
188
|
+
node.isClosed = false; // 防止僵尸节点执行逻辑
|
|
181
189
|
node.initTimer = setTimeout(() => {
|
|
182
190
|
node.initializing = false;
|
|
183
191
|
node.log('[KNX Bridge] 初始化完成,开始同步');
|
|
@@ -261,6 +269,9 @@ module.exports = function(RED) {
|
|
|
261
269
|
|
|
262
270
|
// ========== Mesh设备状态变化处理 ==========
|
|
263
271
|
const handleMeshStateChange = (eventData) => {
|
|
272
|
+
// 防止僵尸节点执行
|
|
273
|
+
if (node.isClosed) return;
|
|
274
|
+
|
|
264
275
|
// 只检查initializing,不检查syncLock
|
|
265
276
|
// syncLock会导致队列处理期间丢失事件,改用per-device时间戳防回环
|
|
266
277
|
if (node.initializing) return;
|
|
@@ -353,20 +364,19 @@ module.exports = function(RED) {
|
|
|
353
364
|
|
|
354
365
|
// 检查是否匹配配置的触发动作
|
|
355
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
|
+
|
|
356
371
|
// 构造防死循环key
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
const sceneLoopKey = `${loopKey}_scene_${mapping.sceneNumber}`;
|
|
372
|
+
// 统一使用 KNX 值 (0-63) 作为 key 的一部分
|
|
373
|
+
const sceneLoopKey = `${loopKey}_scene_${scenePayload}`;
|
|
360
374
|
|
|
361
375
|
if (node.shouldPreventSync('mesh-to-knx', sceneLoopKey)) {
|
|
362
376
|
node.log(`[Mesh->KNX] 跳过场景触发(防死循环): ${sceneLoopKey}`);
|
|
363
377
|
continue;
|
|
364
378
|
}
|
|
365
379
|
|
|
366
|
-
// 场景命令 DPT 17.001 (1字节无符号整数)
|
|
367
|
-
// payload = 场景号 - 1 (KNX wire format: 0-63)
|
|
368
|
-
const scenePayload = mapping.sceneNumber - 1;
|
|
369
|
-
|
|
370
380
|
const knxMsg = {
|
|
371
381
|
topic: mapping.knxAddrCmd,
|
|
372
382
|
payload: scenePayload,
|
|
@@ -1101,6 +1111,50 @@ module.exports = function(RED) {
|
|
|
1101
1111
|
});
|
|
1102
1112
|
}
|
|
1103
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
|
+
}
|
|
1104
1158
|
// 窗帘设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
|
|
1105
1159
|
else if (mapping.deviceType === 'cover') {
|
|
1106
1160
|
const deviceKey = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
@@ -1198,13 +1252,17 @@ module.exports = function(RED) {
|
|
|
1198
1252
|
// ========== 事件监听 ==========
|
|
1199
1253
|
|
|
1200
1254
|
// 监听网关连接状态
|
|
1201
|
-
|
|
1255
|
+
const handleGatewayConnected = () => {
|
|
1256
|
+
if (node.isClosed) return;
|
|
1202
1257
|
node.status({ fill: 'green', shape: 'ring', text: `网关已连接 ${node.mappings.length}个映射` });
|
|
1203
|
-
}
|
|
1258
|
+
};
|
|
1259
|
+
node.gateway.on('gateway-connected', handleGatewayConnected);
|
|
1204
1260
|
|
|
1205
|
-
|
|
1261
|
+
const handleGatewayDisconnected = () => {
|
|
1262
|
+
if (node.isClosed) return;
|
|
1206
1263
|
node.status({ fill: 'red', shape: 'ring', text: '网关断开' });
|
|
1207
|
-
}
|
|
1264
|
+
};
|
|
1265
|
+
node.gateway.on('gateway-disconnected', handleGatewayDisconnected);
|
|
1208
1266
|
|
|
1209
1267
|
// 监听Mesh设备状态变化
|
|
1210
1268
|
node.gateway.on('device-state-changed', handleMeshStateChange);
|
|
@@ -1212,6 +1270,9 @@ module.exports = function(RED) {
|
|
|
1212
1270
|
// ========== 场景执行事件处理 ==========
|
|
1213
1271
|
// 当收到场景执行通知时,查询所有已映射设备的状态
|
|
1214
1272
|
const handleSceneExecuted = (eventData) => {
|
|
1273
|
+
// 防止僵尸节点执行
|
|
1274
|
+
if (node.isClosed) return;
|
|
1275
|
+
|
|
1215
1276
|
if (node.initializing) return;
|
|
1216
1277
|
|
|
1217
1278
|
node.log(`[场景执行] 检测到场景${eventData.sceneId}执行,延迟查询设备状态`);
|
|
@@ -1253,6 +1314,9 @@ module.exports = function(RED) {
|
|
|
1253
1314
|
|
|
1254
1315
|
// ========== 清理 ==========
|
|
1255
1316
|
node.on('close', function(done) {
|
|
1317
|
+
// 标记为已关闭,阻止任何后续事件处理
|
|
1318
|
+
node.isClosed = true;
|
|
1319
|
+
|
|
1256
1320
|
// 清除初始化定时器
|
|
1257
1321
|
if (node.initTimer) {
|
|
1258
1322
|
clearTimeout(node.initTimer);
|
|
@@ -1262,6 +1326,8 @@ module.exports = function(RED) {
|
|
|
1262
1326
|
if (node.gateway) {
|
|
1263
1327
|
node.gateway.removeListener('device-state-changed', handleMeshStateChange);
|
|
1264
1328
|
node.gateway.removeListener('scene-executed', handleSceneExecuted);
|
|
1329
|
+
node.gateway.removeListener('gateway-connected', handleGatewayConnected);
|
|
1330
|
+
node.gateway.removeListener('gateway-disconnected', handleGatewayDisconnected);
|
|
1265
1331
|
}
|
|
1266
1332
|
|
|
1267
1333
|
// 销毁 SyncUtils 实例,清理资源
|
|
@@ -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>
|