node-red-contrib-symi-mesh 1.8.9 → 1.8.10

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,26 @@ node-red-contrib-symi-mesh/
704
704
 
705
705
  ## 更新日志
706
706
 
707
+ ### v1.8.10 (2026-01-16)
708
+
709
+ **新增功能**:
710
+ - **KNX 场景联动支持**:新增“场景”设备类型,支持 Mesh 开关按键与 KNX 场景的双向联动。
711
+ - **双向同步**:
712
+ - KNX 触发场景 -> 自动控制 Mesh 开关 (ON/OFF)
713
+ - Mesh 按键操作 -> 自动触发 KNX 场景 (发送场景号)
714
+ - **防死循环**:针对场景触发的单向特性,特别优化了防环路机制,确保 Mesh 状态变化后不会再次触发场景发送。
715
+ - **配置方式**:在 KNX 桥接节点中添加 KNX 实体时选择“场景”类型,需要配置以下三个参数:
716
+ 1. **KNX组地址**:场景控制的组地址(如 1/1/1)。
717
+ 2. **场景号(1-64)**:KNX 标准场景编号(对应 DPT 17.001 0-63 值)。
718
+ 3. **绑定Mesh开关状态**:**必填项**。设置当触发该 KNX 场景时,关联的 Mesh 开关应变为“开”还是“关”。
719
+ - **为什么必须指定状态?** 场景通常是确定的状态(如“离家”=全关),而不是翻转(Toggle)。如果使用翻转,当灯已经是关闭状态时,再次触发“离家”会导致灯打开,这违背了场景的初衷。
720
+ - **逻辑说明**:
721
+ - **KNX -> Mesh**:收到 KNX 场景号 -> Mesh 开关执行指定状态(如设为 0,则执行关)。
722
+ - **Mesh -> KNX**:Mesh 开关变为指定状态(如变为关) -> 发送 KNX 场景号。
723
+ - **日志优化**:
724
+ - **错误限流**:当 Mesh 网关离线时,KNX 同步节点会自动抑制重复的连接错误日志(1分钟内只显示一次),避免日志刷屏。
725
+ - **网络噪音过滤**:TCP 客户端自动过滤常见的网络连接错误(如 EHOSTUNREACH),减少生产环境的日志干扰。
726
+
707
727
  ### v1.8.9 (2026-01-14)
708
728
 
709
729
  **KNX 协议回显消除算法升级**:
package/lib/tcp-client.js CHANGED
@@ -110,7 +110,8 @@ class TCPClient extends EventEmitter {
110
110
  }
111
111
  // 只在首次连接或重要错误时记录,避免大量重复日志
112
112
  // ECONNRESET通常是网络波动,不记录错误
113
- else if (!this.connected && error.code !== 'ECONNREFUSED' && error.code !== 'ECONNRESET') {
113
+ // EHOSTUNREACH/ETIMEDOUT 也是常见网络错误,不记录
114
+ else if (!this.connected && !['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ETIMEDOUT'].includes(error.code)) {
114
115
  this.logger.log('TCP client error: ' + error.message);
115
116
  }
116
117
 
@@ -26,7 +26,7 @@
26
26
  }
27
27
 
28
28
  let mappings = [], devices = [], knxEntities = [];
29
- const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖'};
29
+ const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖',scene:'场景'};
30
30
 
31
31
  try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
32
32
  try { knxEntities = JSON.parse(node.knxEntities || '[]'); } catch(e) { knxEntities = []; }
@@ -64,7 +64,10 @@
64
64
  # 新风 (开关, 风速)
65
65
  全屋新风 fresh_air 4/1/1 4/2/1
66
66
  # 地暖 (开关, 温度, 当前温度)
67
- 客厅地暖 floor_heating 5/1/1 5/2/1 5/3/1`;
67
+ 客厅地暖 floor_heating 5/1/1 5/2/1 5/3/1
68
+ # 场景 (地址, 编号, 动作1开0关)
69
+ 回家模式 scene 0/0/1 1 1
70
+ 离家模式 scene 0/0/1 2 0`;
68
71
  const blob = new Blob([tpl], {type:'text/plain;charset=utf-8'});
69
72
  const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
70
73
  a.download = 'knx-template.txt'; a.click();
@@ -108,7 +111,8 @@
108
111
  'cover': ['上下地址*','位置地址','停止地址'],
109
112
  'climate': ['开关地址*','温度地址','模式地址','风速地址','当前温度'],
110
113
  'fresh_air': ['开关地址*','风速地址'],
111
- 'floor_heating': ['开关地址*','温度地址','当前温度']
114
+ 'floor_heating': ['开关地址*','温度地址','当前温度'],
115
+ 'scene': ['KNX组地址*', '场景号(1-64)*', '绑定Mesh开关状态(1=开/0=关)*']
112
116
  };
113
117
 
114
118
  // 统一的添加/编辑KNX实体面板
@@ -291,7 +295,7 @@
291
295
  .tbl th { background:#f0f0f0; }
292
296
  .tbl select { width:100%; font-size:11px; padding:2px; }
293
297
  .tbl input[type="checkbox"] { margin:0; }
294
- #knx-list, #map-list { max-height:180px; overflow-y:auto; border:1px solid #ccc; margin:5px 0; padding:3px; }
298
+ #knx-list, #map-list { max-height:450px; overflow-y:auto; border:1px solid #ccc; margin:5px 0; padding:3px; }
295
299
  .tips { color:#666; padding:8px; text-align:center; font-size:12px; }
296
300
  .sec { display:flex; justify-content:space-between; align-items:center; margin:10px 0 4px; padding-bottom:4px; border-bottom:1px solid #ddd; }
297
301
  .sec b { font-size:12px; }
@@ -23,7 +23,8 @@ module.exports = function(RED) {
23
23
  'cover': { dpt: '1.008', name: '窗帘', hasChannel: false },
24
24
  'climate': { dpt: '9.001', name: '空调', hasChannel: false },
25
25
  'fresh_air': { dpt: '1.001', name: '新风', hasChannel: false },
26
- 'floor_heating': { dpt: '9.001', name: '地暖', hasChannel: false }
26
+ 'floor_heating': { dpt: '9.001', name: '地暖', hasChannel: false },
27
+ 'scene': { dpt: '17.001', name: '场景', hasChannel: false }
27
28
  };
28
29
 
29
30
  function SymiKNXBridgeNode(config) {
@@ -155,6 +156,10 @@ module.exports = function(RED) {
155
156
  node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间
156
157
  node.lastKnxValueSent = {}; // 记录发送到KNX地址的值
157
158
 
159
+ // 错误日志限流
160
+ node.lastErrorTime = 0;
161
+ node.ERROR_THROTTLE_MS = 60000; // 1分钟
162
+
158
163
  // 初始化通用同步工具类
159
164
  node.syncUtils = new SyncUtils({
160
165
  defaultTimeout: DEFAULT_TIMEOUT,
@@ -339,6 +344,63 @@ module.exports = function(RED) {
339
344
  });
340
345
  }
341
346
  }
347
+ // 场景设备
348
+ else if (mapping.deviceType === 'scene') {
349
+ const switchKey = `switch_${mapping.meshChannel}`;
350
+ if (changed[switchKey] !== undefined) {
351
+ const val = changed[switchKey];
352
+ const switchValue = (val === 1 || val === true || val === 'on' || val === 'ON');
353
+
354
+ // 检查是否匹配配置的触发动作
355
+ if (switchValue === mapping.sceneAction) {
356
+ // 构造防死循环key
357
+ // 注意:这里使用的是 knx-to-mesh 方向的记录来防止 mesh-to-knx 的发送
358
+ // 即:如果最近收到了 KNX 场景命令导致 Mesh 变化,这里应该拦截
359
+ const sceneLoopKey = `${loopKey}_scene_${mapping.sceneNumber}`;
360
+
361
+ if (node.shouldPreventSync('mesh-to-knx', sceneLoopKey)) {
362
+ node.log(`[Mesh->KNX] 跳过场景触发(防死循环): ${sceneLoopKey}`);
363
+ continue;
364
+ }
365
+
366
+ // 场景命令 DPT 17.001 (1字节无符号整数)
367
+ // payload = 场景号 - 1 (KNX wire format: 0-63)
368
+ const scenePayload = mapping.sceneNumber - 1;
369
+
370
+ const knxMsg = {
371
+ topic: mapping.knxAddrCmd,
372
+ payload: scenePayload,
373
+ dpt: '17.001',
374
+ knx: {
375
+ destination: mapping.knxAddrCmd,
376
+ dpt: '17.001',
377
+ action: 'write'
378
+ }
379
+ };
380
+
381
+ node.log(`[Mesh->KNX] 场景触发: Mesh开关=${switchValue?'ON':'OFF'} -> 发送场景 ${mapping.sceneNumber} 到 ${mapping.knxAddrCmd}`);
382
+
383
+ // 直接发送,不经过通用队列(因为是单向触发)
384
+ node.send([knxMsg, {
385
+ topic: 'mesh-to-knx',
386
+ payload: {
387
+ direction: 'Mesh→KNX',
388
+ type: 'scene',
389
+ scene: mapping.sceneNumber,
390
+ trigger: switchValue ? 'ON' : 'OFF'
391
+ },
392
+ timestamp: new Date().toISOString()
393
+ }]);
394
+
395
+ // 记录发送时间
396
+ node.lastKnxAddrSent[mapping.knxAddrCmd] = Date.now();
397
+ node.lastKnxValueSent[mapping.knxAddrCmd] = scenePayload;
398
+
399
+ // 记录同步时间,防止反向循环
400
+ node.recordSyncTime('mesh-to-knx', sceneLoopKey);
401
+ }
402
+ }
403
+ }
342
404
  // 调光灯设备(单色、双色、RGB、RGBCW)- 简化逻辑:谁动谁跟,忽略过程反馈
343
405
  else if (mapping.deviceType.startsWith('light_')) {
344
406
  if (!eventData.isUserControl) continue;
@@ -566,7 +628,17 @@ module.exports = function(RED) {
566
628
  }
567
629
  await node.sleep(50); // 命令间隔50ms
568
630
  } catch (err) {
569
- node.log(`同步失败: ${err.message}`);
631
+ const now = Date.now();
632
+ // 如果是连接相关错误,进行限流
633
+ if (err.message && (err.message.includes('Not connected') || err.message.includes('Connection'))) {
634
+ if (now - node.lastErrorTime > node.ERROR_THROTTLE_MS) {
635
+ node.lastErrorTime = now;
636
+ node.error(`同步失败(网关离线): ${err.message} (后续类似错误将被抑制1分钟)`);
637
+ }
638
+ } else {
639
+ // 其他错误正常记录
640
+ node.error(`同步失败: ${err.message}`);
641
+ }
570
642
  }
571
643
  }
572
644
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.8.9",
3
+ "version": "1.8.10",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {