node-red-contrib-symi-mesh 1.8.8 → 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,38 @@ 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
+
727
+ ### v1.8.9 (2026-01-14)
728
+
729
+ **KNX 协议回显消除算法升级**:
730
+ - **值校验机制**:在原有的“时间窗口”回显消除基础上,新增“状态值”深度比对。
731
+ - **原理**:当 KNX 收到消息时,不仅检查该设备最近是否发送过命令,还对比收到的值与发出的值是否一致。
732
+ - **效果**:彻底解决“Mesh 开 -> KNX 开 -> 立即 KNX 关”场景下,OFF 指令被误判为回显而被拦截的问题。只有当时间相近且值完全相同时,才判定为回显并忽略;值发生变化时,立即响应并同步,实现毫秒级双向跟随。
733
+ **KNX 开关双向同步逻辑深度优化**:
734
+ - **防死循环机制精细化**:将开关设备的防死循环锁从“设备+通道”级别细化到“设备+通道+状态值”级别。
735
+ - **修复场景**:KNX 控制打开(ON)后,立即手动关闭 Mesh 设备(OFF),旧版本会因死循环锁未过期而丢弃 OFF 状态上报。
736
+ - **新逻辑**:KNX 发送 ON 指令只锁定 Mesh->KNX 的 ON 上报,不影响 Mesh->KNX 的 OFF 上报,确保快速连续反向操作能 100% 同步。
737
+ - **状态同步零延迟**:移除不必要的通用防抖检查,确保 Mesh 端的状态变化能毫秒级同步到 KNX 总线。
738
+
707
739
  ### v1.8.8 (2026-01-14)
708
740
 
709
741
  **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) {
@@ -152,7 +153,12 @@ module.exports = function(RED) {
152
153
  node.lastSyncTime = 0;
153
154
  node.stateCache = {}; // Mesh设备状态缓存
154
155
  node.knxStateCache = {}; // KNX设备状态缓存
155
- node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间,防止自己发的命令又被处理
156
+ node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间
157
+ node.lastKnxValueSent = {}; // 记录发送到KNX地址的值
158
+
159
+ // 错误日志限流
160
+ node.lastErrorTime = 0;
161
+ node.ERROR_THROTTLE_MS = 60000; // 1分钟
156
162
 
157
163
  // 初始化通用同步工具类
158
164
  node.syncUtils = new SyncUtils({
@@ -302,7 +308,12 @@ module.exports = function(RED) {
302
308
  // 窗帘设备需要单独处理防死循环(动作和位置分开检查)
303
309
  if (mapping.deviceType === 'cover') {
304
310
  // 窗帘的防死循环在内部单独处理,这里不跳过
305
- } else {
311
+ }
312
+ // 开关设备使用带值的精细化key,这里先不检查
313
+ else if (mapping.deviceType === 'switch') {
314
+ // 在具体处理逻辑中检查
315
+ }
316
+ else {
306
317
  // 其他设备统一防死循环检查
307
318
  if (node.shouldPreventSync('mesh-to-knx', loopKey)) {
308
319
  node.log(`[Mesh->KNX] 跳过(防死循环): ${loopKey}`);
@@ -314,16 +325,82 @@ module.exports = function(RED) {
314
325
  if (mapping.deviceType === 'switch') {
315
326
  const switchKey = `switch_${mapping.meshChannel}`;
316
327
  if (changed[switchKey] !== undefined) {
317
- node.log(`[Mesh->KNX] 开关变化: ${mapping.name || eventData.device.name} CH${mapping.meshChannel} = ${changed[switchKey]}`);
328
+ const val = changed[switchKey];
329
+ const switchValue = (val === 1 || val === true || val === 'on' || val === 'ON');
330
+ const specificLoopKey = `${loopKey}_switch_${switchValue}`;
331
+
332
+ if (node.shouldPreventSync('mesh-to-knx', specificLoopKey)) {
333
+ node.log(`[Mesh->KNX] 跳过(防死循环): ${specificLoopKey}`);
334
+ continue;
335
+ }
336
+
337
+ node.log(`[Mesh->KNX] 开关变化: ${mapping.name || eventData.device.name} CH${mapping.meshChannel} = ${val}`);
318
338
  node.queueCommand({
319
339
  direction: 'mesh-to-knx',
320
340
  mapping: mapping,
321
341
  type: 'switch',
322
- value: changed[switchKey],
323
- key: loopKey
342
+ value: val,
343
+ key: specificLoopKey
324
344
  });
325
345
  }
326
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
+ }
327
404
  // 调光灯设备(单色、双色、RGB、RGBCW)- 简化逻辑:谁动谁跟,忽略过程反馈
328
405
  else if (mapping.deviceType.startsWith('light_')) {
329
406
  if (!eventData.isUserControl) continue;
@@ -551,7 +628,17 @@ module.exports = function(RED) {
551
628
  }
552
629
  await node.sleep(50); // 命令间隔50ms
553
630
  } catch (err) {
554
- 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
+ }
555
642
  }
556
643
  }
557
644
  } finally {
@@ -760,6 +847,7 @@ module.exports = function(RED) {
760
847
  // 记录发送到的KNX地址和时间,防止自己发的命令又被处理
761
848
  const destAddr = knxMsg.knx.destination;
762
849
  node.lastKnxAddrSent[destAddr] = Date.now();
850
+ node.lastKnxValueSent[destAddr] = knxMsg.payload;
763
851
 
764
852
  // knxUltimate输入格式:topic + destination + payload + dpt + event
765
853
  // topic: 当setTopicType=str时,knxUltimate使用msg.topic作为目标地址
@@ -967,36 +1055,42 @@ module.exports = function(RED) {
967
1055
  }
968
1056
 
969
1057
  // 检查是否是我们自己发出去的命令(防止自己发的命令被监听后又处理)
970
- // 检查该设备的所有关联地址,只要有一个最近发送过,就认为是回显
971
- const wasSentRecently = mapping.allKnxAddrs.some(addr => {
1058
+ // 检查该设备的所有关联地址,只要有一个最近发送过且值相同,就认为是回显
1059
+ const isEcho = mapping.allKnxAddrs.some(addr => {
972
1060
  const lastSentTime = node.lastKnxAddrSent[addr] || 0;
973
- return (Date.now() - lastSentTime) < DEFAULT_TIMEOUT;
1061
+ const inWindow = (Date.now() - lastSentTime) < DEFAULT_TIMEOUT;
1062
+ if (!inWindow) return false;
1063
+
1064
+ // 如果在时间窗口内,进一步检查值是否相同
1065
+ const lastValue = node.lastKnxValueSent[addr];
1066
+ // 对于布尔值,直接比较;对于数字,允许微小误差?KNX通常是精确的
1067
+ return msg.payload === lastValue;
974
1068
  });
975
1069
 
976
- if (wasSentRecently) {
977
- node.debug(`[KNX输入] 跳过(自己发的回显): ${groupAddr} (设备: ${mapping.name})`);
1070
+ if (isEcho) {
1071
+ node.debug(`[KNX输入] 跳过(自己发的回显): ${groupAddr} (设备: ${mapping.name}, 值: ${msg.payload})`);
978
1072
  done && done();
979
1073
  return;
980
1074
  }
981
1075
 
982
1076
  // 确定地址功能(优先使用地址匹配,比DPT更可靠)
983
1077
  const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
984
- const loopKey = `${mapping.meshMac}_${mapping.meshChannel}`;
985
-
986
- // 防死循环检查(基于设备的双向同步防护)
987
- if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
988
- node.debug(`[KNX->Mesh] 跳过同步(防死循环): ${loopKey}, addr=${groupAddr}`);
989
- done && done();
990
- return;
991
- }
992
-
993
- node.log(`[KNX输入] 收到: ${groupAddr} = ${value}, DPT=${dpt}, 功能=${addrFunc}, 设备=${mapping.deviceType}`);
994
1078
 
995
1079
  // 根据设备类型和地址功能处理
996
1080
  if (mapping.deviceType === 'switch') {
997
1081
  // 开关命令(只处理cmd地址)
998
1082
  if (addrFunc === 'cmd' || addrFunc === 'status') {
999
1083
  const switchValue = (value === 1 || value === true || value === 'on' || value === 'ON');
1084
+ const loopKey = `${mapping.meshMac}_${mapping.meshChannel}_switch_${switchValue}`;
1085
+
1086
+ // 防死循环检查
1087
+ if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
1088
+ node.debug(`[KNX->Mesh] 跳过同步(防死循环): ${loopKey}, addr=${groupAddr}`);
1089
+ done && done();
1090
+ return;
1091
+ }
1092
+
1093
+ node.log(`[KNX输入] 收到: ${groupAddr} = ${value}, DPT=${dpt}, 功能=${addrFunc}, 设备=${mapping.deviceType}`);
1000
1094
  node.queueCommand({
1001
1095
  direction: 'knx-to-mesh',
1002
1096
  mapping: mapping,
@@ -1011,6 +1105,7 @@ module.exports = function(RED) {
1011
1105
  else if (mapping.deviceType === 'cover') {
1012
1106
  const deviceKey = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
1013
1107
  const now = Date.now();
1108
+ const loopKey = `${mapping.meshMac}_${mapping.meshChannel}_cover`; // 窗帘使用通用key,因为有专门的controlLock
1014
1109
 
1015
1110
  // KNX主动发起控制,直接抢占锁定(用户操作优先)
1016
1111
  node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.8.8",
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": {