node-red-contrib-symi-modbus 2.9.13 → 2.9.14

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
@@ -950,32 +950,31 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
950
950
 
951
951
  ## 版本历史
952
952
 
953
- ### 2.9.13
954
- - **全量审计与生产环境验证**:完成了对整个代码库的深度审计,确认多主站实例隔离、内存自动清理和防死锁机制在复杂生产环境下 7x24 小时稳定运行。
955
- - **文档同步与规范化**:全面修复并补充了从站开关节点(modbus-slave-switch)的内置帮助文档,增加了 Mesh 模式和无线模式的详细配置说明。
956
- - **架构说明增强**:重构了 README 的核心特性章节,详细披露了智能分包/粘包处理、内存安全策略等工业级稳定性技术细节。
957
- - **发布质量保障**:完成了严格的本地安装测试和 npm pack 验证,确保发布包结构完整且生产环境安装无误。
958
-
959
- ### 2.9.12
960
- - **Mesh 持久化优化**:Mesh 设备列表发现后自动保存到磁盘,网关离线或 Node-RED 重启后依然保留已配置的实体,无需重新扫描。
961
- - **协议拼包算法优化**:全面重构了 RS-485 拼包解析逻辑,引入循环缓冲区处理机制,完美解决工控机串口常见的**沾包、分包**问题,确保 Symi、Clowire 和 Mesh 协议在复杂电气环境下的通讯稳定性。
962
- - **门禁过滤功能**:在“继电器输出”节点中支持门禁 ID 过滤,支持“0=不过滤”模式,方便门禁联动场景。
963
- - **联动事件增强**:修复了从站开关节点未触发 `modbus:buttonPressed` 内部事件的问题,现在“继电器输出”节点可以完美绑定到 Symi/Clowire/Mesh 开关。
964
- - **稳定性提升**:优化了 Mesh 模式下的 LED 反馈逻辑和状态同步防抖。
965
-
966
- ### 2.9.11
967
- - 增加对 Clowire (克伦威尔) 协议的支持。
968
- - 优化了 RS-485 拼包算法,解决工控机串口分包导致的数据解析失败。
953
+ **v2.9.14 更新内容**:
954
+ - **全协议指示灯同步优化**:重构了从站开关节点(modbus-slave-switch)的状态同步逻辑,将原有的 Mesh 模式高性能 LED 反馈机制扩展至所有协议(Symi、Clowire、RS-485),显著提升了群控场景下指示灯同步的准确率。
955
+ - **非连续线圈配置支持**:优化了继电器控制逻辑,支持按键与非连续、跨从站线圈的灵活绑定,不再受限于线圈序号必须连续的物理约束。
956
+ - **全局并发锁机制改进**:改进了全局 LED 反馈锁(Global Lock)机制,将锁定时间优化为 150ms,并引入了更精准的面板状态缓存(panelKey-based caching),有效解决了高频群控时个别按键指示灯同步失败的问题。
957
+ - **稳定性与防抖增强**:统一了所有协议的指示灯同步防抖时间为 50ms,确保在继电器频繁动作时总线通信的有序与稳定。
958
+
959
+ **v2.9.13 更新内容**:
960
+ - **全量审计与生产环境验证**:确认多主站隔离、内存自动清理和防死锁机制稳定运行。
961
+ - **文档规范化**:修复并补充了从站开关节点的内置帮助文档,增加 Mesh 和无线模式说明。
962
+ - **发布质量保障**:完成严格的本地安装测试和 npm pack 验证。
963
+
964
+ **v2.9.12 更新内容**:
965
+ - **Mesh 持久化**:设备列表自动保存到磁盘,重启不丢失。
966
+ - **RS-485 沾包处理**:重构拼包逻辑,引入循环缓冲区处理沾包、分包问题。
967
+ - **门禁过滤**:继电器输出节点支持门禁 ID 过滤。
968
+ - **事件联动**:修复 `modbus:buttonPressed` 事件,支持开关与继电器输出节点联动。
969
+
970
+ **v2.9.11 更新内容**:
971
+ - **Clowire 支持**:增加对克伦威尔协议的支持。
972
+ - **拼包算法优化**:进一步解决工控机串口高频分包问题。
969
973
 
970
974
  **v2.9.10 更新内容**:
971
- - **日志系统优化 (解决日志占用问题)**:
972
- - **彻底静默重连日志**:将 TCP/串口连接过程中的 `node.error` 降级为 `node.log` 或 `node.debug`,不再发送到 Node-RED 调试面板(Debug Tab),彻底解决重连期间日志刷屏问题。
973
- - **智能过滤**:相同的连接错误在重试期间不再重复输出日志(每 10 分钟仅后台提醒一次)。
974
- - **状态栏增强**:将具体错误信息(如“拒绝连接”、“串口不存在”)直接显示在节点状态文字中,无需查看日志即可掌握连接状况。
975
- - **多主站配置隔离修复**:
976
- - 修复了多个Modbus主站节点在编辑时配置互相干扰的问题(通过Scoped Event Handler实现)
977
- - 确保每个主站节点的从站列表配置完全独立,互不影响
978
- - **从站开关主站关联**:
975
+ - **日志静默优化**:重连期间不再刷屏调试面板,错误信息显示在节点状态栏。
976
+ - **多主站配置隔离**:修复多个主站节点配置互相干扰的问题。
977
+ - **稳定性增强**:优化了内存管理和重连机制。
979
978
  - **新增关联主站功能**:从站开关节点新增"关联主站"配置项
980
979
  - **多主站支持**:支持选择特定的主站节点,解决多主站环境下相同从站地址冲突的问题
981
980
  - **智能过滤**:从站开关只响应关联主站的状态更新,避免误触发
@@ -609,171 +609,111 @@ module.exports = function(RED) {
609
609
  node.debug(`收到首次轮询状态同步:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
610
610
  }
611
611
 
612
- // Mesh模式:监听所有绑定到同一个面板的线圈,更新全局状态缓存
613
- if (node.config.buttonType === 'mesh' && slave === node.config.targetSlaveAddress) {
614
- const meshAddr = node.config.meshShortAddress;
615
- if (!meshDeviceStates.has(meshAddr)) {
616
- // 初始化状态数组(全部设为null,表示未知状态)
617
- meshDeviceStates.set(meshAddr, new Array(node.config.meshTotalButtons).fill(null));
612
+ // 核心变量准备
613
+ const isMesh = (node.config.buttonType === 'mesh');
614
+ const panelKey = isMesh ? node.config.meshShortAddress : node.config.switchId;
615
+ const isButtonPress = (triggerSource === 'button-press' || triggerSource === 'scene-trigger');
616
+ const isInit = (triggerSource === 'init' || data.source === 'init');
617
+ const isRelayControl = !isButtonPress && !isInit;
618
+
619
+ // 更新全局状态缓存(用于所有协议的LED反馈优化)
620
+ if (panelKey) {
621
+ if (!meshDeviceStates.has(panelKey)) {
622
+ const totalButtons = isMesh ? node.config.meshTotalButtons : 8; // 非Mesh默认支持8路
623
+ meshDeviceStates.set(panelKey, new Array(totalButtons).fill(null));
624
+ }
625
+ const states = meshDeviceStates.get(panelKey);
626
+
627
+ // 计算按键索引
628
+ let buttonIndex = -1;
629
+ if (isMesh) {
630
+ const baseCoil = node.config.targetCoilNumber - (node.config.meshButtonNumber - 1);
631
+ buttonIndex = coil - baseCoil;
632
+ } else if (slave === node.config.targetSlaveAddress) {
633
+ // RS-485模式:假设线圈是连续的,或者通过targetCoilNumber定位
634
+ // 这里我们优先处理当前节点绑定的按键
635
+ if (coil === node.config.targetCoilNumber) {
636
+ buttonIndex = node.config.buttonNumber - 1;
637
+ }
618
638
  }
619
639
 
620
- // 计算线圈对应的按钮编号(线圈0→按钮1,线圈1→按钮2,...)
621
- const baseCoil = node.config.targetCoilNumber - (node.config.meshButtonNumber - 1);
622
- const buttonIndex = coil - baseCoil;
623
-
624
- // 检查是否是同一个面板的线圈(线圈范围:baseCoil ~ baseCoil+totalButtons-1)
625
- if (buttonIndex >= 0 && buttonIndex < node.config.meshTotalButtons) {
626
- // 更新全局状态缓存
627
- const states = meshDeviceStates.get(meshAddr);
640
+ if (buttonIndex >= 0 && buttonIndex < states.length) {
628
641
  const oldValue = states[buttonIndex];
629
642
  states[buttonIndex] = value;
630
643
  const stateChanged = (oldValue !== value);
631
644
 
632
- // 核心修复:区分按键触发和继电器直接控制
633
- const isButtonPress = (triggerSource === 'button-press');
634
- const isInit = (triggerSource === 'init' || data.source === 'init');
635
- const isRelayControl = !isButtonPress && !isInit;
636
-
637
- // 继电器控制:所有同一面板的线圈变化都会重置定时器(只有按钮1节点负责)
638
- if (isRelayControl && stateChanged && node.config.meshButtonNumber === 1) {
645
+ // 继电器控制:面板关联的任何线圈变化都会触发LED同步
646
+ if (isRelayControl && stateChanged) {
639
647
  const now = Date.now();
640
-
641
- // 获取或创建定时器对象
642
- let timerObj = meshLedDebounceTimers.get(meshAddr);
648
+ let timerObj = meshLedDebounceTimers.get(panelKey);
643
649
 
644
650
  if (!timerObj) {
645
- // 首次变化:创建新的定时器对象
646
651
  timerObj = {
647
652
  timer: null,
648
653
  nodeId: node.id,
649
654
  serialPortConfig: node.serialPortConfig,
650
- firstChangeTime: now, // 记录第一次变化的时间
651
- changeCount: 0 // 记录变化次数
655
+ firstChangeTime: now,
656
+ changeCount: 0
652
657
  };
653
- meshLedDebounceTimers.set(meshAddr, timerObj);
658
+ meshLedDebounceTimers.set(panelKey, timerObj);
654
659
  }
655
660
 
656
- // 增加变化计数
657
661
  timerObj.changeCount++;
662
+ if (timerObj.timer) clearTimeout(timerObj.timer);
658
663
 
659
- // 清除之前的定时器
660
- if (timerObj.timer) {
661
- clearTimeout(timerObj.timer);
662
- }
663
-
664
- // 设置新的定时器:100ms内如果没有新的状态变化,才发送LED反馈
665
664
  const capturedNode = node;
666
665
  timerObj.timer = setTimeout(() => {
667
- // 检查是否是当前节点设置的定时器
668
- const currentTimerObj = meshLedDebounceTimers.get(meshAddr);
669
- if (currentTimerObj && currentTimerObj.nodeId === capturedNode.id) {
670
- meshLedDebounceTimers.delete(meshAddr);
671
- const currentStates = meshDeviceStates.get(meshAddr);
672
-
673
- // 计算全局锁定时间:100ms(足够发送LED反馈,但不影响下一次按键)
674
- const lockDuration = 100;
675
- meshLedFeedbackGlobalLock = Date.now() + lockDuration;
676
-
677
- // 发送LED反馈到物理开关面板(使用最新的完整状态)
678
- const button1State = currentStates[0]; // 按钮1的状态
679
- capturedNode.sendCommandToPanel(button1State);
666
+ const currentTimerObj = meshLedDebounceTimers.get(panelKey);
667
+ if (currentTimerObj) {
668
+ meshLedDebounceTimers.delete(panelKey);
669
+
670
+ // 设置全局锁定:100ms内忽略面板状态上报
671
+ // 改进:使用更精准的锁定时间,并增加序列号或时间戳校验
672
+ meshLedFeedbackGlobalLock = Date.now() + 150;
673
+
674
+ capturedNode.debug(`[LED反馈] 面板${panelKey} 继电器触发同步`);
675
+ capturedNode.sendCommandToPanel(capturedNode.currentState);
680
676
  }
681
- }, 100);
682
- }
683
-
684
- // 只有当前节点绑定的线圈变化时,才更新节点状态并触发按键LED反馈
685
- if (coil === node.config.targetCoilNumber) {
686
- // 更新当前节点状态
687
- node.currentState = value;
688
- node.lastStateChange.timestamp = Date.now();
689
- node.lastStateChange.value = value;
690
-
691
- if (stateChanged) {
692
- if (isButtonPress) {
693
- // 按键触发:立即发送整个面板LED状态(快速响应)
694
- node.debug(`[LED反馈] Mesh${meshAddr} 按钮${node.config.meshButtonNumber}: 按键触发,立即发送整个面板LED状态`);
695
-
696
- // 设置全局锁定:100ms内忽略所有Mesh面板的状态上报(缩短锁定时间,避免影响下一次按键)
697
- meshLedFeedbackGlobalLock = Date.now() + 100;
698
-
699
- // 立即发送LED反馈(整个面板)
700
- node.sendCommandToPanel(value);
701
- } else if (isInit) {
702
- // 首次轮询:发送LED反馈(同步初始状态)
703
- // 使用防抖机制,避免重复发送
704
- if (!meshLedDebounceTimers.has(meshAddr)) {
705
- const capturedNode = node;
706
- const timer = setTimeout(() => {
707
- const timerObj = meshLedDebounceTimers.get(meshAddr);
708
- if (timerObj && timerObj.nodeId === capturedNode.id) {
709
- meshLedDebounceTimers.delete(meshAddr);
710
- const currentStates = meshDeviceStates.get(meshAddr);
711
- capturedNode.debug(`[LED反馈] Mesh${meshAddr} 首次轮询,发送整个面板LED状态: ${JSON.stringify(currentStates)}`);
712
-
713
- // 设置全局锁定:100ms内忽略所有Mesh面板的状态上报
714
- meshLedFeedbackGlobalLock = Date.now() + 100;
715
-
716
- capturedNode.sendCommandToPanel(value);
717
- }
718
- }, 200); // 200ms防抖时间,等待所有初始状态收集完毕
719
-
720
- meshLedDebounceTimers.set(meshAddr, {
721
- timer: timer,
722
- nodeId: node.id,
723
- serialPortConfig: node.serialPortConfig
724
- });
725
-
726
- node.debug(`[LED防抖] Mesh${meshAddr} 按钮${node.config.meshButtonNumber}: 首次轮询,设置防抖定时器(200ms)`);
727
- }
728
- }
729
- }
730
-
731
- // 更新节点状态显示
732
- node.updateStatus();
733
-
734
- // 输出状态消息(立即输出,不等待防抖)
735
- node.send({
736
- payload: value,
737
- topic: `switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
738
- switchId: node.config.switchId,
739
- button: node.config.buttonNumber,
740
- targetSlave: node.config.targetSlaveAddress,
741
- targetCoil: node.config.targetCoilNumber
742
- });
677
+ }, 50); // 50ms防抖
743
678
  }
744
679
  }
745
- } else if (slave === node.config.targetSlaveAddress && coil === node.config.targetCoilNumber) {
746
- // RS-485模式或非Mesh模式:只处理自己绑定的线圈
747
- // 检查状态是否真正变化
748
- const stateChanged = (node.currentState !== value);
749
-
750
- // 区分触发源
751
- const isButtonPress = (triggerSource === 'button-press' || triggerSource === 'scene-trigger');
752
- const isInit = (data.source === 'init'); // 首次轮询
753
- const isPolling = (data.source === 'polling'); // 轮询变化
680
+ }
754
681
 
755
- // 更新当前状态
682
+ // 只有当前节点绑定的线圈变化时,才更新节点状态并处理后续逻辑
683
+ if (slave === node.config.targetSlaveAddress && coil === node.config.targetCoilNumber) {
684
+ const stateChanged = (node.currentState !== value);
756
685
  node.currentState = value;
757
686
  node.lastStateChange.timestamp = Date.now();
758
687
  node.lastStateChange.value = value;
759
688
 
760
- if (stateChanged && !isButtonPress) {
761
- // RS-485模式:状态变化且非按键触发时发送LED反馈
762
- // 按键触发的LED反馈已在handleRs485Data中发送,避免重复
763
- node.sendCommandToPanel(value);
689
+ if (stateChanged) {
690
+ if (isButtonPress) {
691
+ // 按键触发:立即同步(高优先级)
692
+ meshLedFeedbackGlobalLock = Date.now() + 150;
693
+ node.sendCommandToPanel(value);
694
+ } else if (isInit) {
695
+ // 首次轮询:防抖同步
696
+ if (panelKey && !meshLedDebounceTimers.has(panelKey)) {
697
+ const capturedNode = node;
698
+ const timer = setTimeout(() => {
699
+ if (meshLedDebounceTimers.has(panelKey)) {
700
+ meshLedDebounceTimers.delete(panelKey);
701
+ meshLedFeedbackGlobalLock = Date.now() + 150;
702
+ capturedNode.sendCommandToPanel(value);
703
+ }
704
+ }, 200);
705
+ meshLedDebounceTimers.set(panelKey, { timer, nodeId: node.id });
706
+ }
707
+ }
764
708
  }
765
709
 
766
- // 更新节点状态显示
767
710
  node.updateStatus();
768
711
 
769
- // 首次轮询时只同步内部状态和LED反馈,不发送消息到下游
770
- // 避免重启Node-RED时触发下游节点导致继电器动作
771
712
  if (isInit) {
772
713
  node.debug(`首次轮询状态同步:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}(不发送到下游)`);
773
714
  return;
774
715
  }
775
716
 
776
- // 输出状态消息(仅在非首次轮询时发送)
777
717
  node.send({
778
718
  payload: value,
779
719
  topic: `switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
@@ -781,7 +721,7 @@ module.exports = function(RED) {
781
721
  button: node.config.buttonNumber,
782
722
  targetSlave: node.config.targetSlaveAddress,
783
723
  targetCoil: node.config.targetCoilNumber,
784
- source: isPolling ? 'polling' : triggerSource // 传递触发源
724
+ source: (data.source === 'polling') ? 'polling' : triggerSource
785
725
  });
786
726
  }
787
727
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.9.13",
3
+ "version": "2.9.14",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持亖米/Clowire品牌),工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {