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 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
  **新增功能**:
@@ -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);
@@ -250,96 +250,133 @@
250
250
  }
251
251
 
252
252
  // 渲染映射列表
253
- function renderMappings() {
254
- var container = $('#mapping-list');
255
- container.empty();
256
-
257
- if (!mappings || !Array.isArray(mappings) || mappings.length === 0) {
258
- container.append('<div class="mapping-empty">暂无映射,点击下方按钮添加</div>');
259
- return;
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
- bindEvents();
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
- mappings[idx].symiMac = mac || '';
303
- mappings[idx].symiName = opt.data('name') || opt.text().replace(/ \[.*?\]/g, '');
304
- mappings[idx].symiChannels = parseInt(opt.data('channels')) || 1;
305
- mappings[idx].symiDeviceType = opt.data('devicetype') || '';
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
- row.find('.symi-key-wrap').html(getKeyOptions(mac, 1, mappings[idx].symiChannels, mappings[idx].symiEntityType));
310
- bindEvents();
311
- });
312
-
313
- container.find('.symi-key').off('change').on('change', function() {
314
- var idx = $(this).closest('.mapping-row').data('idx');
315
- mappings[idx].symiKey = parseInt($(this).val()) || 1;
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
- container.find('.sync-mode').off('change').on('change', function() {
319
- var idx = $(this).closest('.mapping-row').data('idx');
320
- mappings[idx].syncMode = parseInt($(this).val()) || 0;
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
- container.find('.ha-select').off('change').on('change', function() {
324
- var idx = $(this).closest('.mapping-row').data('idx');
325
- var opt = $(this).find('option:selected');
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
- container.find('.btn-remove').off('click').on('click', function() {
331
- var idx = $(this).closest('.mapping-row').data('idx');
332
- mappings.splice(idx, 1);
333
- renderMappings();
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
- mappings.push({
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(renderMappings);
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(renderMappings);
417
+ loadHaEntities(function() {
418
+ renderMappings();
419
+ });
378
420
  }, 100);
379
421
  });
380
422
 
381
423
  // 保存时更新所有数据
382
424
  node._saveAll = function() {
383
- node.mappings = JSON.stringify(mappings);
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').css('max-height', Math.max(150, height) + 'px');
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)*', '绑定Mesh开关状态(1=开/0=关)*']
115
+ 'scene': ['KNX组地址*', '场景号(1-64)*', '触发Mesh动作(1=开/0=关)*']
116
116
  };
117
117
 
118
118
  // 统一的添加/编辑KNX实体面板
@@ -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
- // 注意:这里使用的是 knx-to-mesh 方向的记录来防止 mesh-to-knx 的发送
358
- // 即:如果最近收到了 KNX 场景命令导致 Mesh 变化,这里应该拦截
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
- node.gateway.on('gateway-connected', () => {
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
- node.gateway.on('gateway-disconnected', () => {
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
- let mappings = [], haEntities = [], knxEntities = [];
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
- try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
24
- try { knxEntities = JSON.parse(node.knxEntities || '[]'); } catch(e) { knxEntities = []; }
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
- renderMappings();
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
- renderMappings();
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
- renderMappings();
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
- let editingIndex = -1;
123
- function showEntityPanel(index) {
124
- const isEdit = index >= 0;
125
- const e = isEdit ? knxEntities[index] : {name:'',type:'switch',cmdAddr:'',statusAddr:'',ext1:'',ext2:'',ext3:'',invert:false};
126
- editingIndex = index;
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
- const entity = {
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
- if (isEdit) { knxEntities[editingIndex] = entity; }
178
- else { knxEntities.push(entity); }
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(index) { showEntityPanel(index); }
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
- mappings.push({ knxEntityId:'', haEntityId:'' });
228
- renderMappings();
291
+ $('#map-list').editableList('addItem', { knxEntityId:'', haEntityId:'' });
229
292
  });
230
- $('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) { mappings=[]; renderMappings(); } });
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
- knxEntities.push({ 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 });
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(-1); });
253
- $('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) { knxEntities=[]; renderKnxEntities(); renderMappings(); } });
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() { $('#knx-data').val(JSON.stringify(knxEntities)); }
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
- const origRender = renderKnxEntities;
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
- renderKnxEntities();
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 tr[data-i]').each(function() {
281
- const m = { knxEntityId: $(this).find('.m-knx').val(), haEntityId: $(this).find('.m-ha-input').val() };
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
- this.knxEntities = $('#knx-data').val() || '[]';
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.8.10",
3
+ "version": "1.8.12",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {