node-red-contrib-symi-modbus 2.7.3 → 2.7.5

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
@@ -56,7 +56,7 @@ node-red-restart
56
56
  **配置方法**:
57
57
  1. 主站节点:不启用MQTT或不配置MQTT服务器
58
58
  2. 从站开关节点:不配置MQTT服务器
59
- 3. **无需连线**:主站和从站通过内部事件自动通信(v2.6.7+)
59
+ 3. **无需连线**:主站和从站通过内部事件自动通信
60
60
 
61
61
  **优势**:
62
62
  - ✅ 断网也能稳定运行
@@ -725,63 +725,35 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
725
725
 
726
726
  ### 自定义协议节点
727
727
 
728
- 自定义协议节点用于控制非标准Modbus协议的485设备,支持开关、窗帘、其他三种设备类型。
728
+ 用于控制非标准Modbus协议的485设备(如窗帘、特殊开关等)。
729
729
 
730
- **配置参数**:
731
- - **节点名称**:自定义协议节点的显示名称(默认:自定义协议)
732
- - **设备类型**:选择设备类型(开关/窗帘/其他)
733
- - **串口配置**:选择串口配置节点(必填)
734
- - **打开指令**:16进制打开指令(最多48字节)
735
- - **关闭指令**:16进制关闭指令(最多48字节)
736
- - **暂停指令**:16进制暂停指令(仅窗帘模式,最多48字节)
737
-
738
- **设备类型说明**:
739
- - **开关模式**:接收`true`发送打开指令,接收`false`发送关闭指令
740
- - **窗帘模式**:无论收到`true`还是`false`,都触发下一个指令,循环顺序:打开 → 暂停 → 关闭 → 暂停 → 打开...
741
- - **其他模式**:与开关模式相同,接收`true/false`发送对应指令
742
-
743
- **功能特性**:
744
- - **16进制配置**:支持空格分隔的16进制码,自动格式化为大写
745
- - **字节限制**:每个指令最多48字节,超出自动截断
746
- - **测试功能**:配置界面可直接点击"测试"按钮发送指令到串口总线
747
- - **持久化保存**:配置自动保存,重启后自动恢复
748
- - **连线方式**:从站开关 → 自定义协议 → debug节点
749
-
750
- **使用示例**:
751
- 1. 在Node-RED中添加自定义协议节点
752
- 2. 选择设备类型(例如:窗帘)
730
+ **配置步骤**:
731
+ 1. 添加自定义协议节点到流程画布
732
+ 2. 选择设备类型(开关/窗帘/其他)
753
733
  3. 选择串口配置节点
754
- 4. 输入16进制指令(例如:`01 05 00 00 FF 00 8C 3A`)
755
- 5. 点击"测试"按钮验证指令是否正确
756
- 6. 连线:从站开关 自定义协议 debug节点
757
- 7. 部署流程,触发从站开关即可发送自定义指令
758
-
759
- **窗帘模式示例**:
760
- ```
761
- 第1次收到true/false → 发送"打开"指令(例如:01 05 00 00 FF 00 8C 3A)
762
- 第2次收到true/false → 发送"暂停"指令(例如:01 05 00 01 FF 00 DD FA)
763
- 第3次收到true/false → 发送"关闭"指令(例如:01 05 00 00 00 00 CD CA)
764
- 第4次收到true/false → 发送"暂停"指令(例如:01 05 00 01 FF 00 DD FA)
765
- 第5次收到true/false → 循环回到"打开"指令
766
- ```
734
+ 4. 输入16进制指令(空格分隔,自动格式化为大写)
735
+ - 打开指令:例如 `01 05 00 00 FF 00 8C 3A`
736
+ - 关闭指令:例如 `01 05 00 00 00 00 CD CA`
737
+ - 暂停指令:仅窗帘模式需要,例如 `01 05 00 01 FF 00 DD FA`
738
+ 5. 点击"测试"按钮验证指令是否正确发送
739
+ 6. 连线:从站开关 → 自定义协议节点
740
+ 7. 部署流程
767
741
 
768
- **适配两种开关模式**:
769
- - **有状态开关**(开关模式):从站开关关联实际线圈,输出`true/false`交替
770
- - **无状态开关**(场景模式):从站开关关联虚拟线圈(如线圈32),每次点击只发`true`或`false`
742
+ **设备类型说明**:
743
+ - **开关模式**:收到`true`发送打开指令,收到`false`发送关闭指令
744
+ - **窗帘模式**:每次触发循环发送下一个指令(打开 → 暂停 → 关闭 → 暂停 → 打开...)
745
+ - **其他模式**:与开关模式相同
771
746
 
772
- **技术细节**:
773
- - 输入消息:`msg.payload = true/false`(从从站开关节点接收)
774
- - 输出消息:`msg.payload = Buffer`(16进制数据,可连线到debug节点)
775
- - 16进制字符串自动转换为Buffer格式
776
- - 支持空格、大小写混合输入,自动格式化
777
- - 窗帘模式内部维护状态索引,自动循环(4个状态:打开→暂停→关闭→暂停)
747
+ **使用场景**:
748
+ - 窗帘控制:支持打开/暂停/关闭循环控制
749
+ - 特殊开关:非标准Modbus协议的485设备
750
+ - 自定义设备:任何需要发送固定16进制指令的设备
778
751
 
779
752
  **注意事项**:
780
- - 16进制指令最多48字节
781
- - 窗帘模式需要配置三个指令(打开、关闭、暂停)
782
- - 测试功能需要先选择串口配置且串口已打开
783
- - 输出需要连线到debug节点才能发送到串口
784
- - 非标准协议设备数量不多时推荐使用连线方式
753
+ - 每个指令最多48字节
754
+ - 窗帘模式需配置三个指令(打开、关闭、暂停)
755
+ - 测试功能需先选择串口配置
756
+ - 无需连线到debug节点,直接通过串口配置节点发送数据
785
757
 
786
758
  ## 输出消息格式
787
759
 
@@ -841,323 +813,34 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
841
813
  ]
842
814
  ```
843
815
 
844
- ## 项目信息
816
+ 完整示例请参考项目中的 `examples/basic-flow.json` 文件。
845
817
 
846
- **版本**: v2.7.2
818
+ ## 技术栈
847
819
 
848
- **核心功能**:
849
- - 支持多种Modbus协议(Telnet ASCII、RTU over TCP、Modbus TCP、Modbus RTU串口)
850
- - 多设备轮询(最多10台从站,每台32路继电器,轮询间隔100-10000ms可调)
851
- - 🔥 **双模式支持**(本地模式和MQTT模式可选切换,断网也能稳定运行)
852
- - 🔥 **免连线通信**(主站和从站通过内部事件通信,无需连线)
853
- - 🔥 **HomeKit网桥**(一键桥接到Apple HomeKit,支持Siri语音控制)
854
- - 🔥 **智能写入队列**(所有写入操作串行执行,避免锁竞争,支持HomeKit群控)
855
- - 🔥 **可视化控制看板**(实时显示和控制所有继电器状态,美观易用)
856
- - 🔥 **自定义协议转换**(支持非标准485协议设备,窗帘循环控制)
857
- - MQTT集成(可选启用,Home Assistant自动发现)
858
- - 物理开关面板双向同步(支持开关模式和场景模式)
859
- - 长期稳定运行(内存管理、智能重连、异步处理)
860
-
861
- **技术要求**:
862
820
  - Node.js: >=14.0.0
863
821
  - Node-RED: >=2.0.0
822
+ - modbus-serial: ^8.0.23
823
+ - serialport: ^12.0.0
824
+ - mqtt: ^5.14.1(可选)
825
+ - hap-nodejs: ^1.2.0
826
+ - node-persist: ^4.0.4
864
827
 
865
- ## 许可证
828
+ ## 版本信息
866
829
 
867
- MIT License
830
+ **当前版本**: v2.7.5
868
831
 
869
- ## 作者
832
+ **更新内容**:
833
+ - 修复从站开关节点LED反馈重复发送问题
834
+ - 优化状态变化广播机制,避免重复触发
835
+ - 增强队列处理稳定性,确保所有状态正确传递
836
+ - 改进日志输出,便于问题排查
870
837
 
871
- symi-daguo
872
- - NPM: https://www.npmjs.com/~symi-daguo
873
- - GitHub: https://github.com/symi-daguo
838
+ ## 许可证
874
839
 
875
- ## 支持
840
+ MIT License
841
+
842
+ ## 支持与反馈
876
843
 
844
+ - GitHub: https://github.com/symi-daguo/node-red-contrib-symi-modbus
877
845
  - Issues: https://github.com/symi-daguo/node-red-contrib-symi-modbus/issues
878
846
  - NPM: https://www.npmjs.com/package/node-red-contrib-symi-modbus
879
-
880
- ### 节点与分类(Palette)
881
-
882
- - 侧边栏分类名:`SYMI-MODBUS`
883
- - 包含节点:
884
- - `modbus-master`(主站)
885
- - `modbus-slave-switch`(从站开关)
886
- - `modbus-dashboard`(控制看板)
887
- - `homekit-bridge`(HomeKit网桥)
888
- - `custom-protocol`(自定义协议)
889
- - `modbus-debug`(调试)
890
- - 如果未显示该分类或节点:
891
- - 刷新浏览器缓存(Shift+刷新)
892
- - 重启 Node-RED(如:`node-red-restart` 或系统服务方式)
893
- - 在“节点管理(Manage Palette)”确认安装版本为最新版
894
-
895
- ### 调试节点(modbus-debug)使用要点
896
-
897
- - 数据来源选择:`sourceType = serial`(共享串口)或 `modbus`(独立服务器)
898
- - 共享串口:需要选择并关联一个 `serial-port-config` 配置节点
899
- - 独立服务器:需要选择并关联一个 `modbus-server-config` 配置节点
900
- - HEX显示:可选大写、可选时间戳、`maxBytes` 控制显示长度
901
- - 输出:`msg.payload`(格式化HEX)、`msg.buffer`(原始Buffer)、`msg.meta`(来源信息)
902
-
903
-
904
- ### MQTT自动发现
905
-
906
- 启用MQTT后,自动生成Home Assistant兼容的Discovery配置:
907
- - **唯一性保证**:每个实体使用稳定的`unique_id`,避免重复生成
908
- - **设备分组**:同一从站的所有继电器自动分组到一个设备下
909
- - **状态持久化**:使用`retain=true`确保状态持久化
910
- - **在线状态**:自动发布设备可用性状态
911
-
912
- ### 配置持久化
913
-
914
- 所有节点配置自动保存到Node-RED的flows文件中:
915
- - 从站地址、线圈范围、轮询间隔
916
- - MQTT服务器配置
917
- - 开关面板映射关系
918
-
919
- 部署后配置永久生效,重启Node-RED后自动恢复。
920
-
921
- ### 长期稳定运行
922
-
923
- 针对工控机24/7长期运行优化:
924
- - **内存管理**:自动清理缓存,释放无用对象
925
- - **事件监听器清理**:关闭时移除所有监听器,防止内存泄漏
926
- - **智能日志限流**:错误日志10分钟输出一次,避免日志刷屏
927
- - **智能重连机制**:
928
- - Modbus连接断开自动重连(指数退避:5秒→10秒→20秒...最大60秒)
929
- - MQTT连接断开自动重连(支持多地址fallback)
930
- - 串口拔插自动检测并重连
931
- - TCP网络故障自动恢复
932
- - 连接前彻底清理旧实例,避免资源泄漏
933
- - **互斥锁机制**:防止读写冲突导致的数据异常
934
- - **TCP永久连接**:
935
- - 禁用TCP超时(永久连接),避免无数据时超时断开
936
- - Keep-Alive心跳10秒间隔,确保连接活跃
937
- - 适应客户长期不在家、总线无数据的场景
938
- - 网络故障自动重连,恢复后立即恢复通信
939
-
940
- ## 技术规格
941
-
942
- ### Modbus协议
943
-
944
- - **协议类型**:Modbus TCP / Modbus RTU
945
- - **底层库**:modbus-serial ^8.0.23(内部封装serialport,支持TCP和串口)
946
- - **功能码支持**:0x01(读线圈)、0x05(写单个线圈)、0x0F(写多个线圈)
947
- - **从站地址范围**:1-247(建议从10开始)
948
- - **线圈数量**:每台设备32个(0-31)
949
- - **最大设备数**:10台同时轮询
950
- - **轮询间隔**:默认200ms(建议300-500ms,支持100-10000ms)
951
- - **串口配置**:9600 8-N-1(波特率9600,8数据位,无校验,1停止位)
952
- - **超时设置**:5000ms(TCP和串口通用)
953
-
954
- ### 兼容性
955
-
956
- - **Node.js**: >= 14.0.0
957
- - **Node-RED**: >= 2.0.0
958
- - **MQTT Broker**: Mosquitto / EMQX / Any MQTT 3.1.1/5.0
959
- - **Home Assistant**: 2024.x+(MQTT Discovery标准)
960
- - **操作系统**: Windows / Linux / macOS / HassOS
961
-
962
- ## Home Assistant集成
963
-
964
- ### 自动发现
965
-
966
- 启用MQTT后,设备自动出现在Home Assistant中:
967
- - 实体ID: `switch.relay_{从站地址}_{线圈编号}`
968
- - 设备名称: `Modbus继电器-{从站地址}`
969
- - 自动分组: 同一从站的所有继电器分组到一个设备
970
-
971
- ### MQTT主题结构
972
-
973
- ```
974
- 状态主题: modbus/relay/{从站}/{线圈}/state
975
- 命令主题: modbus/relay/{从站}/{线圈}/set
976
- 可用性主题: modbus/relay/{从站}/availability
977
- 发现主题: homeassistant/switch/modbus_relay_{从站}_{线圈}/config
978
- ```
979
-
980
- ## 故障排除
981
-
982
- ### 串口连接失败
983
-
984
- **Linux**:
985
- ```bash
986
- # 查看串口设备
987
- ls -l /dev/ttyUSB* /dev/ttyS*
988
-
989
- # 添加用户到dialout组(需要重新登录)
990
- sudo usermod -a -G dialout $USER
991
- ```
992
-
993
- **macOS**:
994
- ```bash
995
- # 查看串口设备(注意macOS使用cu.*而不是tty.*)
996
- ls -l /dev/cu.*
997
- ```
998
-
999
- **Docker/HassOS**:
1000
- ```yaml
1001
- # 在docker-compose.yml或HassOS插件配置中添加设备映射
1002
- devices:
1003
- - /dev/ttyUSB0:/dev/ttyUSB0
1004
- ```
1005
-
1006
- ### MQTT连接失败
1007
-
1008
- 1. 确认MQTT broker正在运行:
1009
- ```bash
1010
- # Linux
1011
- sudo systemctl status mosquitto
1012
-
1013
- # macOS
1014
- brew services list | grep mosquitto
1015
- ```
1016
-
1017
- 2. 测试MQTT连接:
1018
- ```bash
1019
- mosquitto_sub -h localhost -t test
1020
- ```
1021
-
1022
- 3. 检查Node-RED日志中的MQTT连接信息
1023
-
1024
- ### 主站轮询不工作
1025
-
1026
- 1. **检查从站配置**:确认已添加所有从站设备(如10、11、12、13)
1027
- 2. **检查轮询间隔**:默认200ms,建议300-500ms(多台从站时避免总线拥堵)
1028
- 3. **查看Node-RED调试日志**:部署后查看日志中的轮询信息
1029
- 4. **检查串口波特率**:确认波特率为9600(与从站设备一致)
1030
- 5. **检查从站地址**:确认从站地址正确(1-247)
1031
- 6. **确认从站设备在线**:使用Modbus调试工具测试从站是否响应
1032
- 7. **检查MQTT连接**:确保MQTT broker地址正确,轮询不依赖MQTT但状态发布需要MQTT
1033
- 8. **测试连接**:
1034
- - TCP连接问题:先用 `modbus-serial` 单独测试TCP连接
1035
- - 串口问题:先用 `serialport` 单独测试串口通信
1036
-
1037
- ### 从站开关无响应
1038
-
1039
- 1. 检查RS-485连接是否正常
1040
- 2. 确认开关面板地址和按钮编号正确
1041
- 3. 检查MQTT连接状态
1042
- 4. 查看Node-RED日志中的协议解析信息
1043
-
1044
- ## 输入消息格式
1045
-
1046
- ### 主站节点
1047
-
1048
- ```javascript
1049
- // 启动轮询
1050
- msg.payload = {cmd: "start"};
1051
-
1052
- // 停止轮询
1053
- msg.payload = {cmd: "stop"};
1054
-
1055
- // 写单个线圈
1056
- msg.payload = {
1057
- cmd: "writeCoil",
1058
- slave: 10, // 从站地址
1059
- coil: 0, // 线圈编号
1060
- value: true // true=开, false=关
1061
- };
1062
-
1063
- // 批量写多个线圈
1064
- msg.payload = {
1065
- cmd: "writeCoils",
1066
- slave: 10, // 从站地址
1067
- startCoil: 0, // 起始线圈
1068
- values: [true, false, true, false] // 线圈值数组
1069
- };
1070
- ```
1071
-
1072
- ### 从站开关节点
1073
-
1074
- ```javascript
1075
- // 发送开关命令
1076
- msg.payload = true; // 或 false
1077
- msg.payload = "ON"; // 或 "OFF"
1078
- msg.payload = 1; // 或 0
1079
- ```
1080
-
1081
- ## 输出消息格式
1082
-
1083
- ### 主站节点
1084
-
1085
- ```javascript
1086
- {
1087
- payload: {
1088
- slave: 10, // 从站地址
1089
- coils: [true, false, ...], // 线圈状态数组
1090
- timestamp: 1234567890 // 时间戳
1091
- }
1092
- }
1093
- ```
1094
-
1095
- ### 从站开关节点
1096
-
1097
- ```javascript
1098
- {
1099
- payload: true, // 开关状态
1100
- topic: "switch_0_btn1", // 主题
1101
- switchId: 0, // 开关面板ID
1102
- button: 1, // 按钮编号
1103
- targetSlave: 10, // 目标从站地址
1104
- targetCoil: 0 // 目标线圈编号
1105
- }
1106
- ```
1107
-
1108
- ## 性能指标
1109
-
1110
- - **内存占用**:< 50MB(单个主站节点,轮询10个设备)
1111
- - **CPU占用**:< 5%(正常轮询状态)
1112
- - **连接延迟**:Modbus响应 < 100ms,MQTT发布 < 50ms
1113
- - **稳定运行**:经过工控机7x24小时长期运行验证
1114
- - **容错能力**:Modbus从站离线不影响其他从站,MQTT断线自动重连
1115
-
1116
- ## 示例Flow
1117
-
1118
- ```json
1119
- [
1120
- {
1121
- "id": "modbus-master-1",
1122
- "type": "modbus-master",
1123
- "name": "主站",
1124
- "connectionType": "serial",
1125
- "serialPort": "/dev/ttyUSB0",
1126
- "serialBaudRate": 9600,
1127
- "slaves": [
1128
- {"address": 10, "coilStart": 0, "coilEnd": 31, "pollInterval": 200},
1129
- {"address": 11, "coilStart": 0, "coilEnd": 31, "pollInterval": 200},
1130
- {"address": 12, "coilStart": 0, "coilEnd": 31, "pollInterval": 200},
1131
- {"address": 13, "coilStart": 0, "coilEnd": 31, "pollInterval": 200}
1132
- ],
1133
- "enableMqtt": true,
1134
- "mqttServer": "mqtt-config-1"
1135
- }
1136
- ]
1137
- ```
1138
-
1139
- ## 项目信息
1140
-
1141
- **当前版本**: v2.7.3
1142
-
1143
- **最新更新**(v2.7.3):
1144
- - 优化窗帘控制逻辑:打开 → 暂停 → 关闭 → 暂停 → 打开(循环),更符合实际使用场景
1145
- - 优化MQTT断网日志:长期断网时减少日志输出,避免垃圾日志影响性能和硬盘空间
1146
- - 重试间隔优化:从5秒改为30秒,减少重试频率,降低系统负担
1147
- - 日志级别优化:MQTT错误从error改为debug,不写入日志文件
1148
-
1149
- **技术栈**:
1150
- - Node.js: >=14.0.0
1151
- - Node-RED: >=2.0.0
1152
- - modbus-serial: ^8.0.23
1153
- - serialport: ^12.0.0
1154
- - mqtt: ^5.14.1(可选)
1155
- - hap-nodejs: ^1.2.0
1156
- - node-persist: ^4.0.4
1157
-
1158
- **历史版本**:
1159
- - v2.7.2: 新增自定义协议节点,支持非标准485协议设备
1160
- - v2.7.1: 新增可视化控制看板节点
1161
- - v2.7.0: 智能写入队列机制,支持HomeKit群控
1162
- - v2.6.8: 新增HomeKit网桥节点
1163
- - v2.6.7及更早: 基础功能实现
@@ -1,26 +1,27 @@
1
1
  [
2
+ {
3
+ "id": "modbus_server_config_1",
4
+ "type": "modbus-server-config",
5
+ "name": "Modbus服务器",
6
+ "connectionType": "tcp",
7
+ "tcpHost": "127.0.0.1",
8
+ "tcpPort": "502"
9
+ },
2
10
  {
3
11
  "id": "modbus_master_1",
4
12
  "type": "modbus-master",
5
13
  "name": "Modbus主站",
6
- "connectionType": "tcp",
7
- "tcpHost": "127.0.0.1",
8
- "tcpPort": "502",
9
- "serialPort": "COM1",
10
- "serialBaudRate": "9600",
11
- "serialDataBits": "8",
12
- "serialStopBits": "1",
13
- "serialParity": "none",
14
- "slaveStartAddress": "10",
15
- "slaveCount": "1",
16
- "coilStart": "0",
17
- "coilEnd": "31",
18
- "pollInterval": "100",
14
+ "serverConfig": "modbus_server_config_1",
15
+ "slaves": [
16
+ {
17
+ "address": 10,
18
+ "coilStart": 0,
19
+ "coilEnd": 31,
20
+ "pollInterval": 300
21
+ }
22
+ ],
19
23
  "enableMqtt": false,
20
- "mqttBroker": "mqtt://localhost:1883",
21
- "mqttUsername": "",
22
- "mqttPassword": "",
23
- "mqttBaseTopic": "modbus/relay",
24
+ "mqttConfig": "",
24
25
  "x": 320,
25
26
  "y": 140,
26
27
  "wires": [["debug_1"]]
@@ -95,13 +95,13 @@
95
95
  }),
96
96
  success: function(result) {
97
97
  if (result.success) {
98
- RED.notify('✓ ' + cmdName + '指令已发送: ' + hexString, 'success');
98
+ RED.notify(cmdName + '指令已发送: ' + hexString, 'success');
99
99
  } else {
100
- RED.notify('发送失败: ' + result.error, 'error');
100
+ RED.notify('发送失败: ' + result.error, 'error');
101
101
  }
102
102
  },
103
103
  error: function(err) {
104
- RED.notify('发送失败: ' + err.statusText, 'error');
104
+ RED.notify('发送失败: ' + err.statusText, 'error');
105
105
  }
106
106
  });
107
107
  }
@@ -64,20 +64,42 @@ module.exports = function(RED) {
64
64
  return;
65
65
  }
66
66
 
67
- // 输出消息(可连线到debug节点)
68
- var msg = {
69
- payload: buffer,
70
- topic: 'custom-protocol',
71
- command: cmdName
72
- };
73
-
74
- node.send(msg);
75
- node.status({fill: "green", shape: "dot", text: cmdName + " (" + buffer.length + "字节)"});
76
-
77
- // 3秒后清除状态
78
- setTimeout(function() {
79
- node.status({fill: "blue", shape: "ring", text: "就绪"});
80
- }, 3000);
67
+ // 直接通过串口配置节点发送数据
68
+ if (!serialNode || !serialNode.connection) {
69
+ node.error('串口连接未建立');
70
+ node.status({fill: "red", shape: "ring", text: "连接未建立"});
71
+ return;
72
+ }
73
+
74
+ // 检查连接状态
75
+ var isConnected = false;
76
+ if (serialNode.connectionType === 'tcp') {
77
+ isConnected = serialNode.connection && !serialNode.connection.destroyed;
78
+ } else {
79
+ isConnected = serialNode.connection && serialNode.connection.isOpen;
80
+ }
81
+
82
+ if (!isConnected) {
83
+ node.error('串口/TCP连接未打开');
84
+ node.status({fill: "red", shape: "ring", text: "连接未打开"});
85
+ return;
86
+ }
87
+
88
+ // 使用串口配置节点的write方法(带队列机制)
89
+ serialNode.write(buffer, function(err) {
90
+ if (err) {
91
+ node.error('发送失败: ' + err.message);
92
+ node.status({fill: "red", shape: "ring", text: "发送失败"});
93
+ } else {
94
+ node.log(cmdName + '指令已发送: ' + buffer.toString('hex').toUpperCase());
95
+ node.status({fill: "green", shape: "dot", text: cmdName + " (" + buffer.length + "字节)"});
96
+
97
+ // 3秒后清除状态
98
+ setTimeout(function() {
99
+ node.status({fill: "blue", shape: "ring", text: "就绪"});
100
+ }, 3000);
101
+ }
102
+ });
81
103
  }
82
104
 
83
105
  // 处理输入消息
@@ -180,22 +202,36 @@ module.exports = function(RED) {
180
202
  }
181
203
  var buffer = Buffer.from(hex, 'hex');
182
204
 
183
- // 发送到串口
184
- if (serialNode.serialPort && serialNode.serialPort.isOpen) {
185
- serialNode.serialPort.write(buffer, function(err) {
186
- if (err) {
187
- res.json({success: false, error: err.message});
188
- } else {
189
- res.json({
190
- success: true,
191
- message: cmdName + '指令已发送',
192
- bytes: buffer.length
193
- });
194
- }
195
- });
205
+ // 检查连接状态
206
+ if (!serialNode.connection) {
207
+ res.status(503).json({success: false, error: '连接未建立'});
208
+ return;
209
+ }
210
+
211
+ var isConnected = false;
212
+ if (serialNode.connectionType === 'tcp') {
213
+ isConnected = !serialNode.connection.destroyed;
196
214
  } else {
197
- res.status(503).json({success: false, error: '串口未打开'});
215
+ isConnected = serialNode.connection.isOpen;
198
216
  }
217
+
218
+ if (!isConnected) {
219
+ res.status(503).json({success: false, error: '连接未打开'});
220
+ return;
221
+ }
222
+
223
+ // 使用串口配置节点的write方法(带队列机制)
224
+ serialNode.write(buffer, function(err) {
225
+ if (err) {
226
+ res.json({success: false, error: err.message});
227
+ } else {
228
+ res.json({
229
+ success: true,
230
+ message: cmdName + '指令已发送',
231
+ bytes: buffer.length
232
+ });
233
+ }
234
+ });
199
235
  } catch (err) {
200
236
  res.status(500).json({success: false, error: err.message});
201
237
  }
@@ -51,6 +51,11 @@
51
51
  renderRelayNameConfig();
52
52
  });
53
53
 
54
+ // 添加刷新按钮
55
+ $("#btn-refresh-homekit").on("click", function() {
56
+ renderRelayNameConfig();
57
+ });
58
+
54
59
  // 初始渲染
55
60
  renderRelayNameConfig();
56
61
 
@@ -148,10 +153,13 @@
148
153
 
149
154
  <div class="form-row">
150
155
  <label for="node-input-masterNode"><i class="fa fa-microchip"></i> 主站节点</label>
151
- <select id="node-input-masterNode" style="width: 70%;">
156
+ <select id="node-input-masterNode" style="width: 55%;">
152
157
  <option value="">请选择主站节点</option>
153
158
  </select>
154
- <div style="font-size: 11px; color: #999; margin-top: 5px;">选择要桥接的Modbus主站节点</div>
159
+ <button type="button" id="btn-refresh-homekit" class="red-ui-button" style="margin-left: 5px;">
160
+ <i class="fa fa-refresh"></i> 刷新
161
+ </button>
162
+ <div style="font-size: 11px; color: #999; margin-top: 5px;">选择要桥接的Modbus主站节点,点击刷新按钮更新显示</div>
155
163
  </div>
156
164
 
157
165
  <hr style="margin: 20px 0; border: none; border-top: 1px solid #e0e0e0;">
@@ -222,6 +222,15 @@
222
222
  }
223
223
  });
224
224
 
225
+ // 添加刷新按钮
226
+ $("#btn-refresh-dashboard").on("click", function() {
227
+ stopPolling();
228
+ renderDashboard();
229
+ if (masterNodeSelect.val()) {
230
+ startPolling();
231
+ }
232
+ });
233
+
225
234
  // 初始渲染
226
235
  renderDashboard();
227
236
  if (node.masterNode) {
@@ -244,10 +253,13 @@
244
253
 
245
254
  <div class="form-row">
246
255
  <label for="node-input-masterNode"><i class="fa fa-microchip"></i> 主站节点</label>
247
- <select id="node-input-masterNode" style="width: 70%;">
256
+ <select id="node-input-masterNode" style="width: 55%;">
248
257
  <option value="">请选择主站节点</option>
249
258
  </select>
250
- <div style="font-size: 11px; color: #999; margin-top: 5px;">选择要监控的Modbus主站节点</div>
259
+ <button type="button" id="btn-refresh-dashboard" class="red-ui-button" style="margin-left: 5px;">
260
+ <i class="fa fa-refresh"></i> 刷新
261
+ </button>
262
+ <div style="font-size: 11px; color: #999; margin-top: 5px;">选择要监控的Modbus主站节点,点击刷新按钮更新显示</div>
251
263
  </div>
252
264
 
253
265
  <div class="form-row" style="margin-top: 20px;">
@@ -28,9 +28,12 @@ module.exports = function(RED) {
28
28
  node.localConnection = null;
29
29
  node.localConnType = null; // tcp | serial
30
30
 
31
- const sendHexMsg = (data) => {
31
+ const sendHexMsg = (data, direction) => {
32
32
  if (!data || !Buffer.isBuffer(data) || data.length === 0) return;
33
33
 
34
+ // direction: 'received' (接收) 或 'sent' (发送)
35
+ const isSent = direction === 'sent';
36
+
34
37
  let buf = data;
35
38
  if (node.maxBytes > 0 && buf.length > node.maxBytes) {
36
39
  buf = buf.subarray(0, node.maxBytes);
@@ -41,6 +44,7 @@ module.exports = function(RED) {
41
44
  length: data.length,
42
45
  displayedLength: buf.length,
43
46
  truncated: node.maxBytes > 0 && data.length > node.maxBytes,
47
+ direction: isSent ? 'TX' : 'RX'
44
48
  };
45
49
 
46
50
  // 来源信息
@@ -76,7 +80,11 @@ module.exports = function(RED) {
76
80
  if (node.includeTimestamp) msg.timestamp = Date.now();
77
81
 
78
82
  node.send(msg);
79
- node.status({ fill: "green", shape: "dot", text: `RX ${data.length}B` });
83
+
84
+ // 状态显示:TX=发送,RX=接收
85
+ const statusText = isSent ? `TX ${data.length}B` : `RX ${data.length}B`;
86
+ const statusColor = isSent ? "blue" : "green";
87
+ node.status({ fill: statusColor, shape: "dot", text: statusText });
80
88
  };
81
89
 
82
90
  // 选择来源:共享串口配置 或 独立连接到 Modbus 服务器配置
@@ -1170,6 +1170,10 @@ module.exports = function(RED) {
1170
1170
 
1171
1171
  node.isProcessingWrite = true;
1172
1172
 
1173
+ // 记录队列开始处理时间
1174
+ const queueStartTime = Date.now();
1175
+ const queueLength = node.writeQueue.length;
1176
+
1173
1177
  while (node.writeQueue.length > 0) {
1174
1178
  const task = node.writeQueue.shift();
1175
1179
 
@@ -1189,6 +1193,8 @@ module.exports = function(RED) {
1189
1193
  if (task.reject) {
1190
1194
  task.reject(err);
1191
1195
  }
1196
+ // 写入失败不中断队列,继续处理下一个任务
1197
+ node.warn(`队列任务失败,继续处理下一个任务: ${err.message}`);
1192
1198
  }
1193
1199
 
1194
1200
  // 等待一段时间再处理下一个任务(20ms间隔,确保总线稳定)
@@ -1197,6 +1203,12 @@ module.exports = function(RED) {
1197
1203
  }
1198
1204
  }
1199
1205
 
1206
+ // 队列处理完成,输出统计信息
1207
+ const queueDuration = Date.now() - queueStartTime;
1208
+ if (queueLength > 1) {
1209
+ node.debug(`写入队列处理完成:${queueLength}个任务,耗时${queueDuration}ms`);
1210
+ }
1211
+
1200
1212
  node.isProcessingWrite = false;
1201
1213
  };
1202
1214
 
@@ -1236,6 +1248,7 @@ module.exports = function(RED) {
1236
1248
  node.lastWriteTime[slaveId] = Date.now();
1237
1249
 
1238
1250
  // 更新本地状态
1251
+ const oldValue = node.deviceStates[slaveId].coils[coil];
1239
1252
  node.deviceStates[slaveId].coils[coil] = value;
1240
1253
 
1241
1254
  node.log(`写入成功: 从站${slaveId} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
@@ -1248,6 +1261,17 @@ module.exports = function(RED) {
1248
1261
  value: value
1249
1262
  });
1250
1263
 
1264
+ // 只在状态真正改变时广播状态变化事件(用于LED反馈)
1265
+ // 避免重复广播导致LED反馈死循环
1266
+ if (oldValue !== value) {
1267
+ RED.events.emit('modbus:coilStateChanged', {
1268
+ slave: slaveId,
1269
+ coil: coil,
1270
+ value: value,
1271
+ source: 'write'
1272
+ });
1273
+ }
1274
+
1251
1275
  // 释放锁
1252
1276
  node.modbusLock = false;
1253
1277
 
@@ -1325,14 +1349,29 @@ module.exports = function(RED) {
1325
1349
 
1326
1350
  // 更新本地状态
1327
1351
  for (let i = 0; i < values.length; i++) {
1328
- node.deviceStates[slaveId].coils[startCoil + i] = values[i];
1352
+ const coilIndex = startCoil + i;
1353
+ const oldValue = node.deviceStates[slaveId].coils[coilIndex];
1354
+ const newValue = values[i];
1355
+
1356
+ node.deviceStates[slaveId].coils[coilIndex] = newValue;
1357
+
1329
1358
  // 发布到MQTT和触发事件
1330
- node.publishMqttState(slaveId, startCoil + i, values[i]);
1359
+ node.publishMqttState(slaveId, coilIndex, newValue);
1331
1360
  node.emit('stateUpdate', {
1332
1361
  slave: slaveId,
1333
- coil: startCoil + i,
1334
- value: values[i]
1362
+ coil: coilIndex,
1363
+ value: newValue
1335
1364
  });
1365
+
1366
+ // 只在状态真正改变时广播状态变化事件(用于LED反馈)
1367
+ if (oldValue !== newValue) {
1368
+ RED.events.emit('modbus:coilStateChanged', {
1369
+ slave: slaveId,
1370
+ coil: coilIndex,
1371
+ value: newValue,
1372
+ source: 'write'
1373
+ });
1374
+ }
1336
1375
  }
1337
1376
 
1338
1377
  node.debug(`批量写入成功: 从站${slaveId} 起始线圈${startCoil} 共${values.length}个线圈`);
@@ -1396,18 +1435,10 @@ module.exports = function(RED) {
1396
1435
  node.log(`收到内部事件:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
1397
1436
 
1398
1437
  try {
1399
- // 执行写入操作
1438
+ // 执行写入操作(writeSingleCoil内部已经会广播状态变化事件)
1400
1439
  await node.writeSingleCoil(slave, coil, value);
1401
1440
 
1402
- // 写入成功后,广播状态变化事件(用于LED反馈)
1403
- RED.events.emit('modbus:coilStateChanged', {
1404
- slave: slave,
1405
- coil: coil,
1406
- value: value,
1407
- source: 'master'
1408
- });
1409
-
1410
- node.log(`内部事件写入成功,已广播状态变化:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
1441
+ node.log(`内部事件写入成功:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
1411
1442
  } catch (err) {
1412
1443
  node.error(`内部事件写入失败: 从站${slave} 线圈${coil} - ${err.message}`);
1413
1444
  }
@@ -118,6 +118,12 @@ module.exports = function(RED) {
118
118
  value: false
119
119
  };
120
120
 
121
+ // 防止LED反馈重复发送:记录最后一次LED反馈的时间戳和值
122
+ node.lastLedFeedback = {
123
+ timestamp: 0,
124
+ value: false
125
+ };
126
+
121
127
  // 节点初始化标志(用于静默初始化期间的警告)
122
128
  node.isInitializing = true;
123
129
 
@@ -469,10 +475,26 @@ module.exports = function(RED) {
469
475
  return;
470
476
  }
471
477
 
472
- // 清理过期队列项(超过3秒)
478
+ // 防止重复发送:如果状态相同且时间间隔小于100ms,跳过
473
479
  const now = Date.now();
480
+ const timeSinceLastFeedback = now - node.lastLedFeedback.timestamp;
481
+ const stateChanged = (state !== node.lastLedFeedback.value);
482
+
483
+ if (!stateChanged && timeSinceLastFeedback < 100) {
484
+ node.debug(`跳过重复LED反馈(状态未变化,间隔${timeSinceLastFeedback}ms)`);
485
+ return;
486
+ }
487
+
488
+ // 清理过期队列项(超过3秒)
474
489
  node.ledFeedbackQueue = node.ledFeedbackQueue.filter(item => (now - item.timestamp) < node.queueTimeout);
475
490
 
491
+ // 检查队列中是否已有相同状态的反馈(去重)
492
+ const hasSameState = node.ledFeedbackQueue.some(item => item.state === state);
493
+ if (hasSameState) {
494
+ node.debug(`队列中已有相同状态的LED反馈,跳过添加`);
495
+ return;
496
+ }
497
+
476
498
  // 加入LED反馈队列(带时间戳)
477
499
  // 注意:这里不指定协议类型,在发送时根据情况选择
478
500
  node.ledFeedbackQueue.push({ state, timestamp: now });
@@ -481,40 +503,36 @@ module.exports = function(RED) {
481
503
  node.processLedFeedbackQueue();
482
504
  };
483
505
 
484
- // 处理LED反馈队列(基于面板ID的固定延迟)
506
+ // 处理LED反馈队列(20ms间隔串行发送)
485
507
  node.processLedFeedbackQueue = async function() {
486
508
  if (node.isProcessingLedFeedback || node.ledFeedbackQueue.length === 0) {
487
509
  return;
488
510
  }
489
-
511
+
490
512
  // 初始化期间不处理LED反馈(避免部署时大量LED同时发送)
491
513
  if (node.isInitializing) {
492
514
  return;
493
515
  }
494
-
516
+
495
517
  node.isProcessingLedFeedback = true;
496
-
518
+
497
519
  // 清理过期队列项
498
520
  const now = Date.now();
499
521
  node.ledFeedbackQueue = node.ledFeedbackQueue.filter(item => (now - item.timestamp) < node.queueTimeout);
500
-
501
- // 基于面板ID的固定延迟,避免多个节点同时写入TCP冲突
502
- // 面板1=100ms, 面板2=200ms, 面板3=300ms, 面板4=400ms...
503
- // 这样不同面板的LED反馈永远不会同时发送
504
- const fixedDelay = node.config.switchId * 100;
505
- if (fixedDelay > 0) {
506
- await new Promise(resolve => setTimeout(resolve, fixedDelay));
507
- }
508
-
522
+
523
+ // 记录队列开始处理时间
524
+ const queueStartTime = Date.now();
525
+ const queueLength = node.ledFeedbackQueue.length;
526
+
509
527
  while (node.ledFeedbackQueue.length > 0) {
510
528
  const item = node.ledFeedbackQueue.shift();
511
-
529
+
512
530
  // 再次检查是否过期
513
531
  if (Date.now() - item.timestamp >= node.queueTimeout) {
514
532
  node.warn(`丢弃过期LED反馈(${Date.now() - item.timestamp}ms)`);
515
533
  continue;
516
534
  }
517
-
535
+
518
536
  const state = item.state;
519
537
 
520
538
  try {
@@ -551,6 +569,10 @@ module.exports = function(RED) {
551
569
  if (err) {
552
570
  node.error(`LED反馈失败: ${err.message}`);
553
571
  } else {
572
+ // 记录最后一次LED反馈的时间戳和值
573
+ node.lastLedFeedback.timestamp = Date.now();
574
+ node.lastLedFeedback.value = state;
575
+
554
576
  // 输出调试日志,确认LED反馈已发送(包含协议帧十六进制)
555
577
  const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
556
578
  node.log(`LED反馈已发送:面板${node.config.switchId} 按钮${node.config.buttonNumber} 设备${deviceAddr} 通道${channel} = ${state ? 'ON' : 'OFF'} (${node.config.buttonType === 'scene' ? 'REPORT' : 'SET'}) [${hexStr}]`);
@@ -567,9 +589,16 @@ module.exports = function(RED) {
567
589
  }
568
590
  } catch (err) {
569
591
  node.error(`发送LED反馈失败: ${err.message}`);
592
+ // LED反馈失败不中断队列,继续处理下一个
570
593
  }
571
594
  }
572
-
595
+
596
+ // 队列处理完成,输出统计信息
597
+ const queueDuration = Date.now() - queueStartTime;
598
+ if (queueLength > 1) {
599
+ node.debug(`LED反馈队列处理完成:${queueLength}个任务,耗时${queueDuration}ms`);
600
+ }
601
+
573
602
  node.isProcessingLedFeedback = false;
574
603
  };
575
604
 
@@ -842,35 +871,50 @@ module.exports = function(RED) {
842
871
 
843
872
  // 处理输入消息
844
873
  node.on('input', function(msg) {
845
- if (!node.mqttClient || !node.mqttClient.connected) {
846
- node.warn('MQTT未连接');
847
- return;
848
- }
849
-
850
874
  let value = false;
851
-
875
+
852
876
  // 解析输入值
853
877
  if (typeof msg.payload === 'boolean') {
854
878
  value = msg.payload;
855
879
  } else if (typeof msg.payload === 'string') {
856
- value = (msg.payload.toLowerCase() === 'on' ||
857
- msg.payload.toLowerCase() === 'true' ||
880
+ value = (msg.payload.toLowerCase() === 'on' ||
881
+ msg.payload.toLowerCase() === 'true' ||
858
882
  msg.payload === '1');
859
883
  } else if (typeof msg.payload === 'number') {
860
884
  value = (msg.payload !== 0);
861
885
  }
862
-
863
- // 发布命令到MQTT
864
- const command = value ? 'ON' : 'OFF';
865
- node.mqttClient.publish(node.commandTopic, command, { qos: 1 }, (err) => {
866
- if (err) {
867
- node.error(`发布命令失败: ${err.message}`);
868
- } else {
869
- node.log(`发送命令: ${command}${node.commandTopic}`);
870
- node.currentState = value;
871
- node.updateStatus();
872
- }
886
+
887
+ // 更新当前状态
888
+ node.currentState = value;
889
+
890
+ // 输出消息到debug节点(无论是否使用MQTT)
891
+ node.send({
892
+ payload: value,
893
+ topic: `switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
894
+ switchId: node.config.switchId,
895
+ button: node.config.buttonNumber,
896
+ targetSlave: node.config.targetSlaveAddress,
897
+ targetCoil: node.config.targetCoilNumber,
898
+ source: 'input'
873
899
  });
900
+
901
+ // 如果启用MQTT,发布命令到MQTT
902
+ if (node.mqttClient && node.mqttClient.connected) {
903
+ const command = value ? 'ON' : 'OFF';
904
+ node.mqttClient.publish(node.commandTopic, command, { qos: 1 }, (err) => {
905
+ if (err) {
906
+ node.error(`发布命令失败: ${err.message}`);
907
+ } else {
908
+ node.log(`发送命令: ${command} 到 ${node.commandTopic}`);
909
+ }
910
+ });
911
+ } else {
912
+ // 本地模式:通过内部事件发送命令
913
+ node.sendMqttCommand(value);
914
+ }
915
+
916
+ // 更新状态显示
917
+ node.updateStatus();
874
918
  });
875
919
 
876
920
  // 节点关闭时清理
@@ -345,6 +345,15 @@ module.exports = function(RED) {
345
345
  if (callback) callback(err);
346
346
  reject(err);
347
347
  } else {
348
+ // 广播发送的数据给所有监听器(包括debug节点)
349
+ node.dataListeners.forEach(listener => {
350
+ try {
351
+ listener(data, 'sent');
352
+ } catch (e) {
353
+ node.error('监听器处理发送数据失败: ' + e.message);
354
+ }
355
+ });
356
+
348
357
  if (callback) callback(null);
349
358
  resolve();
350
359
  }
@@ -370,6 +379,15 @@ module.exports = function(RED) {
370
379
  if (callback) callback(err);
371
380
  reject(err);
372
381
  } else {
382
+ // 广播发送的数据给所有监听器(包括debug节点)
383
+ node.dataListeners.forEach(listener => {
384
+ try {
385
+ listener(data, 'sent');
386
+ } catch (e) {
387
+ node.error('监听器处理发送数据失败: ' + e.message);
388
+ }
389
+ });
390
+
373
391
  if (callback) callback(null);
374
392
  resolve();
375
393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.7.3",
3
+ "version": "2.7.5",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步,工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {