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 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
  **核心稳定性修复**:
@@ -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(); }
@@ -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地址用于快速查找
@@ -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
- // 注意:这里使用的是 knx-to-mesh 方向的记录来防止 mesh-to-knx 的发送
362
- // 即:如果最近收到了 KNX 场景命令导致 Mesh 变化,这里应该拦截
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
- 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.11",
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": {