node-red-contrib-symi-modbus 2.7.0 → 2.7.2
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 +132 -69
- package/nodes/custom-protocol.html +276 -0
- package/nodes/custom-protocol.js +201 -0
- package/nodes/modbus-dashboard.html +396 -0
- package/nodes/modbus-dashboard.js +98 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成,
|
|
|
19
19
|
- **Symi开关集成**:自动识别并处理Symi私有协议按键事件,实现开关面板与继电器的双向同步
|
|
20
20
|
- **HomeKit网桥**:一键桥接到Apple HomeKit,支持Siri语音控制,自动同步主站配置,名称可自定义
|
|
21
21
|
- **智能写入队列**:所有写入操作串行执行,支持HomeKit群控160个继电器同时动作,流畅无卡顿
|
|
22
|
+
- **可视化控制看板**:实时显示和控制所有继电器状态,美观易用,适合现场调试和日常监控
|
|
23
|
+
- **自定义协议转换**:支持非标准485协议设备,窗帘循环控制,配置界面可测试发送
|
|
22
24
|
- **多设备轮询**:支持最多10台Modbus从站设备,每台32路继电器
|
|
23
25
|
- **智能轮询机制**:从站上报时自动暂停轮询,优先处理数据,避免冲突
|
|
24
26
|
- **稳定可靠**:完整的内存管理、错误处理、断线重连,适合7x24小时长期运行
|
|
@@ -680,6 +682,102 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
680
682
|
- 配置信息持久化存储在~/.node-red/homekit-persist目录
|
|
681
683
|
- 重启Node-RED后自动恢复配对状态,无需重新配对
|
|
682
684
|
|
|
685
|
+
### 控制看板节点(客户友好模式)
|
|
686
|
+
|
|
687
|
+
控制看板节点提供可视化界面,实时显示和控制所有从站的继电器状态,适合现场调试和日常监控。
|
|
688
|
+
|
|
689
|
+
**配置参数**:
|
|
690
|
+
- **节点名称**:控制看板的显示名称(默认:Modbus控制看板)
|
|
691
|
+
- **主站节点**:选择要监控的Modbus主站节点(必填)
|
|
692
|
+
|
|
693
|
+
**功能特性**:
|
|
694
|
+
- **实时状态显示**:在配置界面中显示所有从站和线圈的实时状态
|
|
695
|
+
- **一键控制**:点击按钮即可控制继电器开关,无需部署流程
|
|
696
|
+
- **美观布局**:网格布局,按从站分组显示,一目了然
|
|
697
|
+
- **名称同步**:自动同步HomeKit网桥配置的继电器名称
|
|
698
|
+
- **快速响应**:状态实时更新(500ms轮询),响应迅速
|
|
699
|
+
- **零开销**:不参与实际Modbus通信,不影响主站性能
|
|
700
|
+
|
|
701
|
+
**使用步骤**:
|
|
702
|
+
1. 在Node-RED中添加控制看板节点
|
|
703
|
+
2. 选择已配置的Modbus主站节点
|
|
704
|
+
3. 双击节点打开配置界面,即可看到所有继电器状态
|
|
705
|
+
4. 点击按钮即可控制继电器开关(绿色=ON,红色=OFF)
|
|
706
|
+
5. 部署流程后,节点会显示"监控中"状态
|
|
707
|
+
|
|
708
|
+
**使用场景**:
|
|
709
|
+
- **现场调试**:快速测试继电器是否正常工作
|
|
710
|
+
- **日常监控**:实时查看所有继电器状态
|
|
711
|
+
- **批量控制**:快速控制多个继电器
|
|
712
|
+
- **客户演示**:美观的界面,适合向客户展示系统功能
|
|
713
|
+
|
|
714
|
+
**技术细节**:
|
|
715
|
+
- 使用HTTP API与主站通信,通过内部事件发送控制命令
|
|
716
|
+
- 状态缓存机制,减少网络请求
|
|
717
|
+
- 仅在配置界面打开时才轮询状态,关闭后自动停止
|
|
718
|
+
- 与HomeKit网桥共享继电器名称配置,保持一致性
|
|
719
|
+
|
|
720
|
+
**注意事项**:
|
|
721
|
+
- 确保主站节点已正确配置并运行
|
|
722
|
+
- 控制看板只在配置界面打开时才轮询状态
|
|
723
|
+
- 继电器名称需在HomeKit网桥节点中配置
|
|
724
|
+
- 本节点不参与实际Modbus通信,不会增加主站负担
|
|
725
|
+
|
|
726
|
+
### 自定义协议节点
|
|
727
|
+
|
|
728
|
+
自定义协议节点用于控制非标准Modbus协议的485设备,支持开关、窗帘、其他三种设备类型。
|
|
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. 选择设备类型(例如:窗帘)
|
|
753
|
+
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 → 发送"打开"指令(例如:01 05 00 00 FF 00 8C 3A)
|
|
762
|
+
第2次收到false → 发送"关闭"指令(例如:01 05 00 00 00 00 CD CA)
|
|
763
|
+
第3次收到true → 发送"暂停"指令(例如:01 05 00 01 FF 00 DD FA)
|
|
764
|
+
第4次收到false → 循环回到"打开"指令
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
**技术细节**:
|
|
768
|
+
- 输入消息:`msg.payload = true/false`(从从站开关节点接收)
|
|
769
|
+
- 输出消息:`msg.payload = Buffer`(16进制数据,可连线到debug节点)
|
|
770
|
+
- 16进制字符串自动转换为Buffer格式
|
|
771
|
+
- 支持空格、大小写混合输入,自动格式化
|
|
772
|
+
- 窗帘模式内部维护状态索引,自动循环
|
|
773
|
+
|
|
774
|
+
**注意事项**:
|
|
775
|
+
- 16进制指令最多48字节
|
|
776
|
+
- 窗帘模式需要配置三个指令(打开、关闭、暂停)
|
|
777
|
+
- 测试功能需要先选择串口配置且串口已打开
|
|
778
|
+
- 输出需要连线到debug节点才能发送到串口
|
|
779
|
+
- 非标准协议设备数量不多时推荐使用连线方式
|
|
780
|
+
|
|
683
781
|
## 输出消息格式
|
|
684
782
|
|
|
685
783
|
### 主站节点
|
|
@@ -740,7 +838,7 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
740
838
|
|
|
741
839
|
## 项目信息
|
|
742
840
|
|
|
743
|
-
**版本**: v2.7.
|
|
841
|
+
**版本**: v2.7.2
|
|
744
842
|
|
|
745
843
|
**核心功能**:
|
|
746
844
|
- 支持多种Modbus协议(Telnet ASCII、RTU over TCP、Modbus TCP、Modbus RTU串口)
|
|
@@ -749,6 +847,8 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
749
847
|
- 🔥 **免连线通信**(主站和从站通过内部事件通信,无需连线)
|
|
750
848
|
- 🔥 **HomeKit网桥**(一键桥接到Apple HomeKit,支持Siri语音控制)
|
|
751
849
|
- 🔥 **智能写入队列**(所有写入操作串行执行,避免锁竞争,支持HomeKit群控)
|
|
850
|
+
- 🔥 **可视化控制看板**(实时显示和控制所有继电器状态,美观易用)
|
|
851
|
+
- 🔥 **自定义协议转换**(支持非标准485协议设备,窗帘循环控制)
|
|
752
852
|
- MQTT集成(可选启用,Home Assistant自动发现)
|
|
753
853
|
- 物理开关面板双向同步(支持开关模式和场景模式)
|
|
754
854
|
- 长期稳定运行(内存管理、智能重连、异步处理)
|
|
@@ -757,71 +857,25 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
757
857
|
- Node.js: >=14.0.0
|
|
758
858
|
- Node-RED: >=2.0.0
|
|
759
859
|
|
|
760
|
-
**最新更新(v2.7.
|
|
761
|
-
- **🔥
|
|
762
|
-
-
|
|
763
|
-
-
|
|
764
|
-
-
|
|
765
|
-
-
|
|
766
|
-
-
|
|
767
|
-
-
|
|
768
|
-
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
**v2.
|
|
773
|
-
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
- 持久化存储配对信息,重启后自动恢复
|
|
780
|
-
- 线圈0-15显示为开关,线圈16-31显示为插座(避免误触发)
|
|
781
|
-
|
|
782
|
-
**v2.6.7更新**:
|
|
783
|
-
- **🔥 修复LED反馈功能**:
|
|
784
|
-
- 主站轮询检测到状态变化时自动广播事件
|
|
785
|
-
- 从站开关节点监听状态变化并发送LED反馈到物理面板
|
|
786
|
-
- 支持开关模式(SET协议)和场景模式(REPORT协议)
|
|
787
|
-
- LED反馈队列间隔20ms,避免总线拥堵
|
|
788
|
-
- **🔥 TCP连接稳定性优化**:
|
|
789
|
-
- 禁用TCP超时(永久连接),避免无数据时超时断开
|
|
790
|
-
- Keep-Alive间隔从30秒优化到10秒
|
|
791
|
-
- 适应客户长期不在家、总线无数据的场景
|
|
792
|
-
- **日志优化**:
|
|
793
|
-
- 移除频繁的轮询日志("轮询持续运行中"、"正在轮询从站X")
|
|
794
|
-
- 轮询永久稳定运行,无需频繁确认
|
|
795
|
-
- 减少日志输出,降低硬盘占用
|
|
796
|
-
|
|
797
|
-
**v2.6.6更新**:
|
|
798
|
-
- **🔥 彻底解决MQTT日志刷屏问题**:
|
|
799
|
-
- 局域网IP检测优化:配置192.168.x.x等IP后不再尝试fallback地址
|
|
800
|
-
- 高频MQTT日志改为debug级别:broker候选、认证、重连等
|
|
801
|
-
- 默认不输出到日志文件和调试窗口
|
|
802
|
-
- 配置局域网IP后立即连接,不产生多余日志
|
|
803
|
-
- **日志输出优化**:
|
|
804
|
-
- 连接成功/失败:仍使用log(重要信息)
|
|
805
|
-
- 重连尝试、fallback地址、认证信息:改为debug(调试信息)
|
|
806
|
-
- 仅启用debug模式时才在调试窗口显示
|
|
807
|
-
- 彻底解决日志刷屏和硬盘占用问题
|
|
808
|
-
|
|
809
|
-
**v2.6.5更新**:
|
|
810
|
-
- **🔥 修复MQTT报错问题**:从站开关节点新增"启用MQTT"勾选框
|
|
811
|
-
- 默认不启用MQTT,不会尝试连接
|
|
812
|
-
- 本地模式和MQTT模式自由切换
|
|
813
|
-
|
|
814
|
-
**v2.6.4更新**:
|
|
815
|
-
- **🔥 日志优化**:大幅减少日志输出,保证长期稳定运行
|
|
816
|
-
- 高频操作日志改为debug级别
|
|
817
|
-
- 默认不输出到日志文件
|
|
818
|
-
- 完善的内存清理机制
|
|
819
|
-
|
|
820
|
-
**v2.6.3更新**:
|
|
821
|
-
- **🔥 MQTT可选配置**:完全兼容无MQTT环境
|
|
822
|
-
- 本地模式:纯串口通信
|
|
823
|
-
- MQTT模式:可选接入HA
|
|
824
|
-
- 智能切换和状态显示
|
|
860
|
+
**最新更新(v2.7.2)**:
|
|
861
|
+
- **🔥 新增自定义协议节点**:
|
|
862
|
+
- 支持控制非标准Modbus协议的485设备
|
|
863
|
+
- 三种设备类型:开关(打开/关闭)、窗帘(打开/关闭/暂停循环)、其他(打开/关闭)
|
|
864
|
+
- 窗帘模式:true/false交替触发,循环发送打开→关闭→暂停→打开...
|
|
865
|
+
- 16进制指令配置,最多48字节,自动格式化
|
|
866
|
+
- 配置界面可直接测试发送指令到串口总线
|
|
867
|
+
- 配置持久化保存,重启后自动恢复
|
|
868
|
+
- 连线方式:从站开关 → 自定义协议 → debug节点
|
|
869
|
+
|
|
870
|
+
**历史更新**:
|
|
871
|
+
- **v2.7.1**:新增可视化控制看板节点,实时显示和控制所有继电器状态
|
|
872
|
+
- **v2.7.0**:智能写入队列机制,解决HomeKit群控锁竞争问题,支持160个继电器同时群控
|
|
873
|
+
- **v2.6.8**:新增HomeKit网桥节点,一键桥接到Apple HomeKit,支持Siri语音控制
|
|
874
|
+
- **v2.6.7**:修复LED反馈功能,TCP连接稳定性优化,日志优化
|
|
875
|
+
- **v2.6.6**:解决MQTT日志刷屏问题,局域网IP检测优化
|
|
876
|
+
- **v2.6.5**:修复MQTT报错问题,新增"启用MQTT"勾选框
|
|
877
|
+
- **v2.6.4**:日志优化,大幅减少日志输出
|
|
878
|
+
- **v2.6.3**:MQTT可选配置,完全兼容无MQTT环境
|
|
825
879
|
|
|
826
880
|
**性能优化**:
|
|
827
881
|
- 轮询间隔优化:修复间隔计算逻辑,确保每个从站使用正确的轮询间隔
|
|
@@ -830,6 +884,7 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
830
884
|
- 减少调试日志输出,降低CPU占用
|
|
831
885
|
- 互斥锁机制确保读写操作不冲突
|
|
832
886
|
- 共享连接配置节点,避免串口资源冲突
|
|
887
|
+
- 写入队列20ms间隔,快速响应,总线稳定
|
|
833
888
|
|
|
834
889
|
**许可证**: MIT License
|
|
835
890
|
|
|
@@ -844,11 +899,17 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
844
899
|
### 节点与分类(Palette)
|
|
845
900
|
|
|
846
901
|
- 侧边栏分类名:`SYMI-MODBUS`
|
|
847
|
-
-
|
|
902
|
+
- 包含节点:
|
|
903
|
+
- `modbus-master`(主站)
|
|
904
|
+
- `modbus-slave-switch`(从站开关)
|
|
905
|
+
- `modbus-dashboard`(控制看板)
|
|
906
|
+
- `homekit-bridge`(HomeKit网桥)
|
|
907
|
+
- `custom-protocol`(自定义协议)
|
|
908
|
+
- `modbus-debug`(调试)
|
|
848
909
|
- 如果未显示该分类或节点:
|
|
849
910
|
- 刷新浏览器缓存(Shift+刷新)
|
|
850
911
|
- 重启 Node-RED(如:`node-red-restart` 或系统服务方式)
|
|
851
|
-
- 在“节点管理(Manage Palette
|
|
912
|
+
- 在“节点管理(Manage Palette)”确认安装版本为最新版
|
|
852
913
|
|
|
853
914
|
### 调试节点(modbus-debug)使用要点
|
|
854
915
|
|
|
@@ -1106,7 +1167,7 @@ msg.payload = 1; // 或 0
|
|
|
1106
1167
|
|
|
1107
1168
|
## 项目信息
|
|
1108
1169
|
|
|
1109
|
-
**版本**: v2.7.
|
|
1170
|
+
**版本**: v2.7.2
|
|
1110
1171
|
|
|
1111
1172
|
**核心功能**:
|
|
1112
1173
|
- 支持多种Modbus协议(Telnet ASCII、RTU over TCP、Modbus TCP、Modbus RTU串口)
|
|
@@ -1117,6 +1178,8 @@ msg.payload = 1; // 或 0
|
|
|
1117
1178
|
- 🔥 **免连线通信**(主站和从站通过内部事件通信,无需连线,支持本地模式和MQTT模式)
|
|
1118
1179
|
- 🔥 **HomeKit网桥**(一键桥接到Apple HomeKit,支持Siri语音控制,名称可自定义)
|
|
1119
1180
|
- 🔥 **智能写入队列**(所有写入操作串行执行,避免锁竞争,支持HomeKit群控)
|
|
1181
|
+
- 🔥 **可视化控制看板**(实时显示和控制所有继电器状态,美观易用,客户友好)
|
|
1182
|
+
- 🔥 **自定义协议转换**(支持非标准485协议设备,窗帘循环控制,配置界面可测试)
|
|
1120
1183
|
- MQTT集成(可选启用,Home Assistant自动发现,实体唯一性保证,QoS=0高性能发布)
|
|
1121
1184
|
- 物理开关面板双向同步(亖米协议支持,LED反馈同步,支持开关模式和场景模式)
|
|
1122
1185
|
- 共享连接架构(多个从站开关节点共享同一个串口/TCP连接,支持500+节点)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('custom-protocol', {
|
|
3
|
+
category: 'SYMI-MODBUS',
|
|
4
|
+
color: '#FF9800',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: {value: "自定义协议"},
|
|
7
|
+
deviceType: {value: "switch", required: true},
|
|
8
|
+
serialConfig: {value: "", type: "serial-port-config", required: true},
|
|
9
|
+
openCmd: {value: ""},
|
|
10
|
+
closeCmd: {value: ""},
|
|
11
|
+
pauseCmd: {value: ""}
|
|
12
|
+
},
|
|
13
|
+
inputs: 1,
|
|
14
|
+
outputs: 1,
|
|
15
|
+
icon: "font-awesome/fa-code",
|
|
16
|
+
label: function() {
|
|
17
|
+
var typeNames = {
|
|
18
|
+
'switch': '开关',
|
|
19
|
+
'curtain': '窗帘',
|
|
20
|
+
'other': '其他'
|
|
21
|
+
};
|
|
22
|
+
var typeName = typeNames[this.deviceType] || '自定义';
|
|
23
|
+
return this.name || (typeName + "协议");
|
|
24
|
+
},
|
|
25
|
+
oneditprepare: function() {
|
|
26
|
+
var node = this;
|
|
27
|
+
|
|
28
|
+
// 设备类型切换
|
|
29
|
+
$("#node-input-deviceType").on("change", function() {
|
|
30
|
+
var deviceType = $(this).val();
|
|
31
|
+
if (deviceType === "curtain") {
|
|
32
|
+
$("#pause-cmd-row").show();
|
|
33
|
+
} else {
|
|
34
|
+
$("#pause-cmd-row").hide();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 初始化显示
|
|
39
|
+
if (node.deviceType === "curtain") {
|
|
40
|
+
$("#pause-cmd-row").show();
|
|
41
|
+
} else {
|
|
42
|
+
$("#pause-cmd-row").hide();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 16进制输入验证和格式化
|
|
46
|
+
function validateHexInput(input) {
|
|
47
|
+
var value = $(input).val().trim();
|
|
48
|
+
// 移除所有非16进制字符
|
|
49
|
+
value = value.replace(/[^0-9A-Fa-f\s]/g, '');
|
|
50
|
+
// 自动添加空格分隔
|
|
51
|
+
value = value.replace(/\s+/g, '').match(/.{1,2}/g);
|
|
52
|
+
if (value) {
|
|
53
|
+
value = value.join(' ').toUpperCase();
|
|
54
|
+
// 限制48字节
|
|
55
|
+
var bytes = value.split(' ');
|
|
56
|
+
if (bytes.length > 48) {
|
|
57
|
+
bytes = bytes.slice(0, 48);
|
|
58
|
+
value = bytes.join(' ');
|
|
59
|
+
RED.notify('指令长度已限制为48字节', 'warning');
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
value = '';
|
|
63
|
+
}
|
|
64
|
+
$(input).val(value);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 绑定输入验证
|
|
69
|
+
$("#node-input-openCmd, #node-input-closeCmd, #node-input-pauseCmd").on("blur", function() {
|
|
70
|
+
validateHexInput(this);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// 测试按钮功能
|
|
74
|
+
function sendTestCommand(hexString, cmdName) {
|
|
75
|
+
if (!hexString || hexString.trim() === '') {
|
|
76
|
+
RED.notify('请先输入16进制指令', 'warning');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var serialConfig = $("#node-input-serialConfig").val();
|
|
81
|
+
if (!serialConfig) {
|
|
82
|
+
RED.notify('请先选择串口配置', 'warning');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 发送测试命令
|
|
87
|
+
$.ajax({
|
|
88
|
+
url: '/custom-protocol/test',
|
|
89
|
+
method: 'POST',
|
|
90
|
+
contentType: 'application/json',
|
|
91
|
+
data: JSON.stringify({
|
|
92
|
+
serialConfig: serialConfig,
|
|
93
|
+
hexString: hexString,
|
|
94
|
+
cmdName: cmdName
|
|
95
|
+
}),
|
|
96
|
+
success: function(result) {
|
|
97
|
+
if (result.success) {
|
|
98
|
+
RED.notify('✓ ' + cmdName + '指令已发送: ' + hexString, 'success');
|
|
99
|
+
} else {
|
|
100
|
+
RED.notify('✗ 发送失败: ' + result.error, 'error');
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
error: function(err) {
|
|
104
|
+
RED.notify('✗ 发送失败: ' + err.statusText, 'error');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 绑定测试按钮
|
|
110
|
+
$("#btn-test-open").on("click", function() {
|
|
111
|
+
var hexString = validateHexInput("#node-input-openCmd");
|
|
112
|
+
sendTestCommand(hexString, '打开');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
$("#btn-test-close").on("click", function() {
|
|
116
|
+
var hexString = validateHexInput("#node-input-closeCmd");
|
|
117
|
+
sendTestCommand(hexString, '关闭');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
$("#btn-test-pause").on("click", function() {
|
|
121
|
+
var hexString = validateHexInput("#node-input-pauseCmd");
|
|
122
|
+
sendTestCommand(hexString, '暂停');
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
oneditsave: function() {
|
|
126
|
+
// 保存前验证和格式化
|
|
127
|
+
validateHexInput("#node-input-openCmd");
|
|
128
|
+
validateHexInput("#node-input-closeCmd");
|
|
129
|
+
if (this.deviceType === "curtain") {
|
|
130
|
+
validateHexInput("#node-input-pauseCmd");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// 辅助函数
|
|
136
|
+
function validateHexInput(selector) {
|
|
137
|
+
var input = $(selector);
|
|
138
|
+
var value = input.val().trim();
|
|
139
|
+
value = value.replace(/[^0-9A-Fa-f\s]/g, '');
|
|
140
|
+
value = value.replace(/\s+/g, '').match(/.{1,2}/g);
|
|
141
|
+
if (value) {
|
|
142
|
+
value = value.join(' ').toUpperCase();
|
|
143
|
+
var bytes = value.split(' ');
|
|
144
|
+
if (bytes.length > 48) {
|
|
145
|
+
bytes = bytes.slice(0, 48);
|
|
146
|
+
value = bytes.join(' ');
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
value = '';
|
|
150
|
+
}
|
|
151
|
+
input.val(value);
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<script type="text/html" data-template-name="custom-protocol">
|
|
157
|
+
<div class="form-row">
|
|
158
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 节点名称</label>
|
|
159
|
+
<input type="text" id="node-input-name" placeholder="自定义协议">
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="form-row">
|
|
163
|
+
<label for="node-input-deviceType"><i class="fa fa-cog"></i> 设备类型</label>
|
|
164
|
+
<select id="node-input-deviceType" style="width: 70%;">
|
|
165
|
+
<option value="switch">开关(打开/关闭)</option>
|
|
166
|
+
<option value="curtain">窗帘(打开/关闭/暂停循环)</option>
|
|
167
|
+
<option value="other">其他(打开/关闭)</option>
|
|
168
|
+
</select>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div class="form-row">
|
|
172
|
+
<label for="node-input-serialConfig"><i class="fa fa-plug"></i> 串口配置</label>
|
|
173
|
+
<input type="text" id="node-input-serialConfig" style="width: 70%;">
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div class="form-row">
|
|
177
|
+
<label style="width: 100%; font-weight: bold; margin-top: 15px; margin-bottom: 10px;">
|
|
178
|
+
<i class="fa fa-code"></i> 16进制指令配置(最多48字节)
|
|
179
|
+
</label>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div class="form-row">
|
|
183
|
+
<label for="node-input-openCmd"><i class="fa fa-arrow-up"></i> 打开指令</label>
|
|
184
|
+
<input type="text" id="node-input-openCmd" placeholder="例如: 01 05 00 00 FF 00 8C 3A" style="width: 50%;">
|
|
185
|
+
<button type="button" id="btn-test-open" class="red-ui-button" style="margin-left: 5px;">
|
|
186
|
+
<i class="fa fa-play"></i> 测试
|
|
187
|
+
</button>
|
|
188
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px; margin-left: 105px;">
|
|
189
|
+
输入16进制码,空格分隔,自动格式化
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="form-row">
|
|
194
|
+
<label for="node-input-closeCmd"><i class="fa fa-arrow-down"></i> 关闭指令</label>
|
|
195
|
+
<input type="text" id="node-input-closeCmd" placeholder="例如: 01 05 00 00 00 00 CD CA" style="width: 50%;">
|
|
196
|
+
<button type="button" id="btn-test-close" class="red-ui-button" style="margin-left: 5px;">
|
|
197
|
+
<i class="fa fa-play"></i> 测试
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div class="form-row" id="pause-cmd-row" style="display: none;">
|
|
202
|
+
<label for="node-input-pauseCmd"><i class="fa fa-pause"></i> 暂停指令</label>
|
|
203
|
+
<input type="text" id="node-input-pauseCmd" placeholder="例如: 01 05 00 01 FF 00 DD FA" style="width: 50%;">
|
|
204
|
+
<button type="button" id="btn-test-pause" class="red-ui-button" style="margin-left: 5px;">
|
|
205
|
+
<i class="fa fa-play"></i> 测试
|
|
206
|
+
</button>
|
|
207
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px; margin-left: 105px;">
|
|
208
|
+
仅窗帘模式需要配置
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="form-row" style="margin-top: 20px;">
|
|
213
|
+
<div style="padding: 10px; background: #f0f8ff; border-left: 4px solid #2196F3; border-radius: 4px;">
|
|
214
|
+
<strong>使用说明:</strong><br>
|
|
215
|
+
<ul style="margin: 5px 0; padding-left: 20px; font-size: 12px;">
|
|
216
|
+
<li><strong>开关/其他模式</strong>: true发送打开指令,false发送关闭指令</li>
|
|
217
|
+
<li><strong>窗帘模式</strong>: true/false交替触发,循环发送打开→关闭→暂停→打开...</li>
|
|
218
|
+
<li>16进制码自动格式化为大写,空格分隔</li>
|
|
219
|
+
<li>点击"测试"按钮可直接发送到串口总线验证</li>
|
|
220
|
+
<li>配置自动持久化保存</li>
|
|
221
|
+
</ul>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</script>
|
|
225
|
+
|
|
226
|
+
<script type="text/html" data-help-name="custom-protocol">
|
|
227
|
+
<p>自定义协议节点,用于控制非标准Modbus协议的485设备。</p>
|
|
228
|
+
|
|
229
|
+
<h3>功能特性</h3>
|
|
230
|
+
<ul>
|
|
231
|
+
<li>支持三种设备类型:开关、窗帘、其他</li>
|
|
232
|
+
<li>窗帘模式支持打开/关闭/暂停循环控制</li>
|
|
233
|
+
<li>16进制指令配置,最多48字节</li>
|
|
234
|
+
<li>配置界面可直接测试发送指令</li>
|
|
235
|
+
<li>配置持久化保存</li>
|
|
236
|
+
</ul>
|
|
237
|
+
|
|
238
|
+
<h3>设备类型说明</h3>
|
|
239
|
+
<dl>
|
|
240
|
+
<dt>开关模式</dt>
|
|
241
|
+
<dd>接收true发送打开指令,接收false发送关闭指令</dd>
|
|
242
|
+
|
|
243
|
+
<dt>窗帘模式</dt>
|
|
244
|
+
<dd>true/false交替触发,循环发送:打开→关闭→暂停→打开...</dd>
|
|
245
|
+
|
|
246
|
+
<dt>其他模式</dt>
|
|
247
|
+
<dd>与开关模式相同,接收true/false发送对应指令</dd>
|
|
248
|
+
</dl>
|
|
249
|
+
|
|
250
|
+
<h3>输入消息</h3>
|
|
251
|
+
<p>从从站开关节点接收:</p>
|
|
252
|
+
<pre>msg.payload = true/false</pre>
|
|
253
|
+
|
|
254
|
+
<h3>输出消息</h3>
|
|
255
|
+
<p>输出Buffer格式的16进制数据,可连线到debug节点发送到串口:</p>
|
|
256
|
+
<pre>msg.payload = Buffer</pre>
|
|
257
|
+
|
|
258
|
+
<h3>使用示例</h3>
|
|
259
|
+
<ol>
|
|
260
|
+
<li>配置串口节点</li>
|
|
261
|
+
<li>选择设备类型(开关/窗帘/其他)</li>
|
|
262
|
+
<li>输入16进制指令(空格分隔)</li>
|
|
263
|
+
<li>点击"测试"按钮验证指令是否正确</li>
|
|
264
|
+
<li>连线:从站开关 → 自定义协议 → debug节点</li>
|
|
265
|
+
<li>部署流程,触发从站开关即可发送自定义指令</li>
|
|
266
|
+
</ol>
|
|
267
|
+
|
|
268
|
+
<h3>注意事项</h3>
|
|
269
|
+
<ul>
|
|
270
|
+
<li>16进制指令最多48字节</li>
|
|
271
|
+
<li>窗帘模式需要配置三个指令(打开、关闭、暂停)</li>
|
|
272
|
+
<li>测试功能需要先选择串口配置</li>
|
|
273
|
+
<li>输出需要连线到debug节点才能发送到串口</li>
|
|
274
|
+
</ul>
|
|
275
|
+
</script>
|
|
276
|
+
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
function CustomProtocolNode(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
var node = this;
|
|
7
|
+
|
|
8
|
+
// 配置
|
|
9
|
+
node.config = {
|
|
10
|
+
name: config.name || "自定义协议",
|
|
11
|
+
deviceType: config.deviceType || "switch",
|
|
12
|
+
serialConfig: config.serialConfig,
|
|
13
|
+
openCmd: config.openCmd || "",
|
|
14
|
+
closeCmd: config.closeCmd || "",
|
|
15
|
+
pauseCmd: config.pauseCmd || ""
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// 窗帘模式的状态索引(0=打开, 1=关闭, 2=暂停)
|
|
19
|
+
node.curtainStateIndex = 0;
|
|
20
|
+
|
|
21
|
+
// 获取串口配置节点
|
|
22
|
+
var serialNode = RED.nodes.getNode(node.config.serialConfig);
|
|
23
|
+
if (!serialNode) {
|
|
24
|
+
node.error('未找到串口配置节点');
|
|
25
|
+
node.status({fill: "red", shape: "ring", text: "未配置串口"});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 16进制字符串转Buffer
|
|
30
|
+
function hexStringToBuffer(hexString) {
|
|
31
|
+
if (!hexString || hexString.trim() === '') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// 移除空格和非16进制字符
|
|
37
|
+
var hex = hexString.replace(/\s+/g, '').replace(/[^0-9A-Fa-f]/g, '');
|
|
38
|
+
|
|
39
|
+
// 确保是偶数长度
|
|
40
|
+
if (hex.length % 2 !== 0) {
|
|
41
|
+
node.warn('16进制字符串长度必须为偶数: ' + hexString);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 限制48字节
|
|
46
|
+
if (hex.length > 96) {
|
|
47
|
+
hex = hex.substring(0, 96);
|
|
48
|
+
node.warn('指令长度超过48字节,已截断');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 转换为Buffer
|
|
52
|
+
var buffer = Buffer.from(hex, 'hex');
|
|
53
|
+
return buffer;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
node.error('16进制字符串转换失败: ' + err.message);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 发送指令到串口
|
|
61
|
+
function sendCommand(buffer, cmdName) {
|
|
62
|
+
if (!buffer) {
|
|
63
|
+
node.warn('指令为空,跳过发送');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
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);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 处理输入消息
|
|
84
|
+
node.on('input', function(msg) {
|
|
85
|
+
var value = msg.payload;
|
|
86
|
+
|
|
87
|
+
// 只接受布尔值
|
|
88
|
+
if (typeof value !== 'boolean') {
|
|
89
|
+
node.warn('输入必须为true/false,当前类型: ' + typeof value);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (node.config.deviceType === 'curtain') {
|
|
94
|
+
// 窗帘模式:true/false交替触发,循环发送三个指令
|
|
95
|
+
var commands = [
|
|
96
|
+
{name: '打开', hex: node.config.openCmd},
|
|
97
|
+
{name: '关闭', hex: node.config.closeCmd},
|
|
98
|
+
{name: '暂停', hex: node.config.pauseCmd}
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// 获取当前指令
|
|
102
|
+
var currentCmd = commands[node.curtainStateIndex];
|
|
103
|
+
if (!currentCmd || !currentCmd.hex) {
|
|
104
|
+
node.warn('窗帘模式缺少' + currentCmd.name + '指令配置');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 转换并发送
|
|
109
|
+
var buffer = hexStringToBuffer(currentCmd.hex);
|
|
110
|
+
if (buffer) {
|
|
111
|
+
sendCommand(buffer, currentCmd.name);
|
|
112
|
+
|
|
113
|
+
// 移动到下一个状态
|
|
114
|
+
node.curtainStateIndex = (node.curtainStateIndex + 1) % 3;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
// 开关/其他模式:true发送打开,false发送关闭
|
|
118
|
+
var cmdName = value ? '打开' : '关闭';
|
|
119
|
+
var hexString = value ? node.config.openCmd : node.config.closeCmd;
|
|
120
|
+
|
|
121
|
+
if (!hexString) {
|
|
122
|
+
node.warn(cmdName + '指令未配置');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var buffer = hexStringToBuffer(hexString);
|
|
127
|
+
if (buffer) {
|
|
128
|
+
sendCommand(buffer, cmdName);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 初始状态
|
|
134
|
+
var typeNames = {
|
|
135
|
+
'switch': '开关',
|
|
136
|
+
'curtain': '窗帘',
|
|
137
|
+
'other': '其他'
|
|
138
|
+
};
|
|
139
|
+
var typeName = typeNames[node.config.deviceType] || '自定义';
|
|
140
|
+
node.status({fill: "blue", shape: "ring", text: typeName + "模式就绪"});
|
|
141
|
+
|
|
142
|
+
// 清理
|
|
143
|
+
node.on('close', function() {
|
|
144
|
+
node.status({});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
RED.nodes.registerType("custom-protocol", CustomProtocolNode);
|
|
149
|
+
|
|
150
|
+
// HTTP API:测试发送指令
|
|
151
|
+
RED.httpAdmin.post('/custom-protocol/test', function(req, res) {
|
|
152
|
+
var serialConfigId = req.body.serialConfig;
|
|
153
|
+
var hexString = req.body.hexString;
|
|
154
|
+
var cmdName = req.body.cmdName;
|
|
155
|
+
|
|
156
|
+
if (!serialConfigId || !hexString) {
|
|
157
|
+
res.status(400).json({success: false, error: '参数错误'});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 获取串口配置节点
|
|
162
|
+
var serialNode = RED.nodes.getNode(serialConfigId);
|
|
163
|
+
if (!serialNode) {
|
|
164
|
+
res.status(404).json({success: false, error: '未找到串口配置节点'});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// 转换16进制字符串为Buffer
|
|
170
|
+
var hex = hexString.replace(/\s+/g, '').replace(/[^0-9A-Fa-f]/g, '');
|
|
171
|
+
if (hex.length % 2 !== 0) {
|
|
172
|
+
res.status(400).json({success: false, error: '16进制字符串长度必须为偶数'});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (hex.length > 96) {
|
|
176
|
+
hex = hex.substring(0, 96);
|
|
177
|
+
}
|
|
178
|
+
var buffer = Buffer.from(hex, 'hex');
|
|
179
|
+
|
|
180
|
+
// 发送到串口
|
|
181
|
+
if (serialNode.serialPort && serialNode.serialPort.isOpen) {
|
|
182
|
+
serialNode.serialPort.write(buffer, function(err) {
|
|
183
|
+
if (err) {
|
|
184
|
+
res.json({success: false, error: err.message});
|
|
185
|
+
} else {
|
|
186
|
+
res.json({
|
|
187
|
+
success: true,
|
|
188
|
+
message: cmdName + '指令已发送',
|
|
189
|
+
bytes: buffer.length
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
res.status(503).json({success: false, error: '串口未打开'});
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
res.status(500).json({success: false, error: err.message});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('modbus-dashboard', {
|
|
3
|
+
category: 'SYMI-MODBUS',
|
|
4
|
+
color: '#4CAF50',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: {value: "Modbus控制看板"},
|
|
7
|
+
masterNode: {value: "", required: true}
|
|
8
|
+
},
|
|
9
|
+
inputs: 0,
|
|
10
|
+
outputs: 0,
|
|
11
|
+
icon: "font-awesome/fa-dashboard",
|
|
12
|
+
label: function() {
|
|
13
|
+
return this.name || "Modbus控制看板";
|
|
14
|
+
},
|
|
15
|
+
oneditprepare: function() {
|
|
16
|
+
var node = this;
|
|
17
|
+
var stateCache = {}; // 缓存所有线圈状态
|
|
18
|
+
var relayNamesCache = {}; // 缓存继电器名称
|
|
19
|
+
|
|
20
|
+
// 填充主站节点选择器
|
|
21
|
+
var masterNodeSelect = $("#node-input-masterNode");
|
|
22
|
+
masterNodeSelect.empty();
|
|
23
|
+
masterNodeSelect.append('<option value="">请选择主站节点</option>');
|
|
24
|
+
|
|
25
|
+
RED.nodes.eachNode(function(n) {
|
|
26
|
+
if (n.type === "modbus-master") {
|
|
27
|
+
var label = n.name || `主站 ${n.id.substring(0, 8)}`;
|
|
28
|
+
var selected = (n.id === node.masterNode) ? ' selected' : '';
|
|
29
|
+
masterNodeSelect.append(`<option value="${n.id}"${selected}>${label}</option>`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// 加载HomeKit网桥的名称配置
|
|
34
|
+
function loadRelayNames() {
|
|
35
|
+
RED.nodes.eachNode(function(n) {
|
|
36
|
+
if (n.type === "homekit-bridge" && n.masterNode === node.masterNode) {
|
|
37
|
+
if (n.relayNames) {
|
|
38
|
+
relayNamesCache = Object.assign({}, n.relayNames);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 获取继电器名称
|
|
45
|
+
function getRelayName(slaveAddr, coil) {
|
|
46
|
+
var key = slaveAddr + "_" + coil;
|
|
47
|
+
if (relayNamesCache[key]) {
|
|
48
|
+
return relayNamesCache[key];
|
|
49
|
+
}
|
|
50
|
+
var relayType = coil < 16 ? "开关" : "插座";
|
|
51
|
+
return `${relayType}${coil + 1}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 渲染控制面板
|
|
55
|
+
function renderDashboard() {
|
|
56
|
+
var masterNodeId = $("#node-input-masterNode").val();
|
|
57
|
+
var container = $("#dashboard-container");
|
|
58
|
+
container.empty();
|
|
59
|
+
|
|
60
|
+
if (!masterNodeId) {
|
|
61
|
+
container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;">请先选择主站节点</div>');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var masterNode = RED.nodes.node(masterNodeId);
|
|
66
|
+
if (!masterNode || !masterNode.slaves || masterNode.slaves.length === 0) {
|
|
67
|
+
container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;">主站节点未配置从站</div>');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 加载继电器名称
|
|
72
|
+
loadRelayNames();
|
|
73
|
+
|
|
74
|
+
// 遍历所有从站
|
|
75
|
+
masterNode.slaves.forEach(function(slave) {
|
|
76
|
+
var slaveSection = $('<div class="slave-section">');
|
|
77
|
+
|
|
78
|
+
var slaveHeader = $(`
|
|
79
|
+
<div class="slave-header">
|
|
80
|
+
<span class="slave-title">从站 ${slave.address}</span>
|
|
81
|
+
<span class="slave-info">线圈 ${slave.coilStart}-${slave.coilEnd}</span>
|
|
82
|
+
</div>
|
|
83
|
+
`);
|
|
84
|
+
|
|
85
|
+
var coilGrid = $('<div class="coil-grid">');
|
|
86
|
+
|
|
87
|
+
for (var coil = slave.coilStart; coil <= slave.coilEnd; coil++) {
|
|
88
|
+
var key = slave.address + "_" + coil;
|
|
89
|
+
var relayName = getRelayName(slave.address, coil);
|
|
90
|
+
var currentState = stateCache[key] || false;
|
|
91
|
+
var stateClass = currentState ? 'state-on' : 'state-off';
|
|
92
|
+
var stateText = currentState ? 'ON' : 'OFF';
|
|
93
|
+
|
|
94
|
+
var coilItem = $(`
|
|
95
|
+
<div class="coil-item">
|
|
96
|
+
<div class="coil-header">
|
|
97
|
+
<span class="coil-name" title="${relayName}">${relayName}</span>
|
|
98
|
+
<span class="coil-number">线圈${coil}</span>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="coil-control">
|
|
101
|
+
<button class="btn-toggle ${stateClass}" data-slave="${slave.address}" data-coil="${coil}">
|
|
102
|
+
<span class="state-text">${stateText}</span>
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
`);
|
|
107
|
+
|
|
108
|
+
coilGrid.append(coilItem);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
slaveSection.append(slaveHeader);
|
|
112
|
+
slaveSection.append(coilGrid);
|
|
113
|
+
container.append(slaveSection);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 绑定按钮点击事件
|
|
117
|
+
$(".btn-toggle").off("click").on("click", function() {
|
|
118
|
+
var slaveAddr = parseInt($(this).data("slave"));
|
|
119
|
+
var coil = parseInt($(this).data("coil"));
|
|
120
|
+
var key = slaveAddr + "_" + coil;
|
|
121
|
+
var currentState = stateCache[key] || false;
|
|
122
|
+
var newState = !currentState;
|
|
123
|
+
|
|
124
|
+
// 发送控制命令(通过HTTP API)
|
|
125
|
+
sendControlCommand(slaveAddr, coil, newState);
|
|
126
|
+
|
|
127
|
+
// 立即更新UI(乐观更新)
|
|
128
|
+
stateCache[key] = newState;
|
|
129
|
+
updateButtonState($(this), newState);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 更新按钮状态
|
|
134
|
+
function updateButtonState(button, state) {
|
|
135
|
+
if (state) {
|
|
136
|
+
button.removeClass('state-off').addClass('state-on');
|
|
137
|
+
button.find('.state-text').text('ON');
|
|
138
|
+
} else {
|
|
139
|
+
button.removeClass('state-on').addClass('state-off');
|
|
140
|
+
button.find('.state-text').text('OFF');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 发送控制命令
|
|
145
|
+
function sendControlCommand(slaveAddr, coil, value) {
|
|
146
|
+
// 通过Node-RED的admin API发送注入命令
|
|
147
|
+
$.ajax({
|
|
148
|
+
url: '/modbus-dashboard/control',
|
|
149
|
+
method: 'POST',
|
|
150
|
+
contentType: 'application/json',
|
|
151
|
+
data: JSON.stringify({
|
|
152
|
+
slave: slaveAddr,
|
|
153
|
+
coil: coil,
|
|
154
|
+
value: value
|
|
155
|
+
}),
|
|
156
|
+
success: function() {
|
|
157
|
+
console.log(`控制命令已发送: 从站${slaveAddr} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
158
|
+
},
|
|
159
|
+
error: function(err) {
|
|
160
|
+
console.error('控制命令发送失败:', err);
|
|
161
|
+
RED.notify('控制命令发送失败', 'error');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 轮询状态更新(每500ms)
|
|
167
|
+
var pollInterval = null;
|
|
168
|
+
function startPolling() {
|
|
169
|
+
if (pollInterval) {
|
|
170
|
+
clearInterval(pollInterval);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pollInterval = setInterval(function() {
|
|
174
|
+
var masterNodeId = $("#node-input-masterNode").val();
|
|
175
|
+
if (!masterNodeId) return;
|
|
176
|
+
|
|
177
|
+
$.ajax({
|
|
178
|
+
url: '/modbus-dashboard/state',
|
|
179
|
+
method: 'GET',
|
|
180
|
+
success: function(data) {
|
|
181
|
+
if (data && data.states) {
|
|
182
|
+
// 更新状态缓存
|
|
183
|
+
Object.keys(data.states).forEach(function(key) {
|
|
184
|
+
var oldState = stateCache[key];
|
|
185
|
+
var newState = data.states[key];
|
|
186
|
+
stateCache[key] = newState;
|
|
187
|
+
|
|
188
|
+
// 如果状态变化,更新UI
|
|
189
|
+
if (oldState !== newState) {
|
|
190
|
+
var parts = key.split('_');
|
|
191
|
+
var slaveAddr = parts[0];
|
|
192
|
+
var coil = parts[1];
|
|
193
|
+
var button = $(`.btn-toggle[data-slave="${slaveAddr}"][data-coil="${coil}"]`);
|
|
194
|
+
if (button.length > 0) {
|
|
195
|
+
updateButtonState(button, newState);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
error: function(err) {
|
|
202
|
+
console.error('状态轮询失败:', err);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}, 500);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 停止轮询
|
|
209
|
+
function stopPolling() {
|
|
210
|
+
if (pollInterval) {
|
|
211
|
+
clearInterval(pollInterval);
|
|
212
|
+
pollInterval = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 监听主站节点选择变化
|
|
217
|
+
masterNodeSelect.on("change", function() {
|
|
218
|
+
renderDashboard();
|
|
219
|
+
stopPolling();
|
|
220
|
+
if ($(this).val()) {
|
|
221
|
+
startPolling();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// 初始渲染
|
|
226
|
+
renderDashboard();
|
|
227
|
+
if (node.masterNode) {
|
|
228
|
+
startPolling();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 对话框关闭时停止轮询
|
|
232
|
+
$("#node-dialog-cancel, #node-dialog-ok").on("click", function() {
|
|
233
|
+
stopPolling();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
</script>
|
|
238
|
+
|
|
239
|
+
<script type="text/html" data-template-name="modbus-dashboard">
|
|
240
|
+
<div class="form-row">
|
|
241
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 节点名称</label>
|
|
242
|
+
<input type="text" id="node-input-name" placeholder="Modbus控制看板">
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div class="form-row">
|
|
246
|
+
<label for="node-input-masterNode"><i class="fa fa-microchip"></i> 主站节点</label>
|
|
247
|
+
<select id="node-input-masterNode" style="width: 70%;">
|
|
248
|
+
<option value="">请选择主站节点</option>
|
|
249
|
+
</select>
|
|
250
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px;">选择要监控的Modbus主站节点</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div class="form-row" style="margin-top: 20px;">
|
|
254
|
+
<label style="width: 100%; font-weight: bold; margin-bottom: 10px;">
|
|
255
|
+
<i class="fa fa-dashboard"></i> 控制面板
|
|
256
|
+
</label>
|
|
257
|
+
<div id="dashboard-container" style="
|
|
258
|
+
max-height: 500px;
|
|
259
|
+
overflow-y: auto;
|
|
260
|
+
border: 1px solid #ddd;
|
|
261
|
+
border-radius: 4px;
|
|
262
|
+
background: #fafafa;
|
|
263
|
+
padding: 10px;
|
|
264
|
+
"></div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<style>
|
|
268
|
+
.slave-section {
|
|
269
|
+
margin-bottom: 20px;
|
|
270
|
+
background: white;
|
|
271
|
+
border-radius: 8px;
|
|
272
|
+
padding: 15px;
|
|
273
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.slave-header {
|
|
277
|
+
display: flex;
|
|
278
|
+
justify-content: space-between;
|
|
279
|
+
align-items: center;
|
|
280
|
+
margin-bottom: 15px;
|
|
281
|
+
padding-bottom: 10px;
|
|
282
|
+
border-bottom: 2px solid #4CAF50;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.slave-title {
|
|
286
|
+
font-weight: bold;
|
|
287
|
+
font-size: 16px;
|
|
288
|
+
color: #333;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.slave-info {
|
|
292
|
+
font-size: 12px;
|
|
293
|
+
color: #666;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.coil-grid {
|
|
297
|
+
display: grid;
|
|
298
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
299
|
+
gap: 10px;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.coil-item {
|
|
303
|
+
background: #f9f9f9;
|
|
304
|
+
border: 1px solid #e0e0e0;
|
|
305
|
+
border-radius: 6px;
|
|
306
|
+
padding: 10px;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.coil-header {
|
|
310
|
+
display: flex;
|
|
311
|
+
justify-content: space-between;
|
|
312
|
+
align-items: center;
|
|
313
|
+
margin-bottom: 8px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.coil-name {
|
|
317
|
+
font-weight: 500;
|
|
318
|
+
font-size: 13px;
|
|
319
|
+
color: #333;
|
|
320
|
+
overflow: hidden;
|
|
321
|
+
text-overflow: ellipsis;
|
|
322
|
+
white-space: nowrap;
|
|
323
|
+
max-width: 120px;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.coil-number {
|
|
327
|
+
font-size: 11px;
|
|
328
|
+
color: #999;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.coil-control {
|
|
332
|
+
display: flex;
|
|
333
|
+
justify-content: center;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.btn-toggle {
|
|
337
|
+
width: 100%;
|
|
338
|
+
padding: 8px 16px;
|
|
339
|
+
border: none;
|
|
340
|
+
border-radius: 4px;
|
|
341
|
+
font-weight: bold;
|
|
342
|
+
font-size: 13px;
|
|
343
|
+
cursor: pointer;
|
|
344
|
+
transition: all 0.2s;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.btn-toggle.state-on {
|
|
348
|
+
background: #4CAF50;
|
|
349
|
+
color: white;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.btn-toggle.state-on:hover {
|
|
353
|
+
background: #45a049;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.btn-toggle.state-off {
|
|
357
|
+
background: #f44336;
|
|
358
|
+
color: white;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.btn-toggle.state-off:hover {
|
|
362
|
+
background: #da190b;
|
|
363
|
+
}
|
|
364
|
+
</style>
|
|
365
|
+
</script>
|
|
366
|
+
|
|
367
|
+
<script type="text/html" data-help-name="modbus-dashboard">
|
|
368
|
+
<p>Modbus控制看板节点,提供可视化界面显示和控制所有从站的继电器状态。</p>
|
|
369
|
+
|
|
370
|
+
<h3>功能特性</h3>
|
|
371
|
+
<ul>
|
|
372
|
+
<li>实时显示所有从站和线圈的状态</li>
|
|
373
|
+
<li>支持直接点击按钮控制继电器开关</li>
|
|
374
|
+
<li>自动同步HomeKit网桥配置的继电器名称</li>
|
|
375
|
+
<li>美观的网格布局,按从站分组显示</li>
|
|
376
|
+
<li>状态实时更新(500ms轮询)</li>
|
|
377
|
+
<li>零额外开销,不参与实际Modbus通信</li>
|
|
378
|
+
</ul>
|
|
379
|
+
|
|
380
|
+
<h3>使用步骤</h3>
|
|
381
|
+
<ol>
|
|
382
|
+
<li>选择已配置的Modbus主站节点</li>
|
|
383
|
+
<li>在配置界面中查看所有继电器状态</li>
|
|
384
|
+
<li>点击按钮即可控制继电器开关</li>
|
|
385
|
+
<li>部署流程后,节点会显示监控状态</li>
|
|
386
|
+
</ol>
|
|
387
|
+
|
|
388
|
+
<h3>注意事项</h3>
|
|
389
|
+
<ul>
|
|
390
|
+
<li>确保主站节点已正确配置并运行</li>
|
|
391
|
+
<li>控制看板只在配置界面打开时才轮询状态</li>
|
|
392
|
+
<li>继电器名称与HomeKit网桥共享,修改需在HomeKit网桥节点中进行</li>
|
|
393
|
+
<li>本节点不参与实际Modbus通信,不会增加主站负担</li>
|
|
394
|
+
</ul>
|
|
395
|
+
</script>
|
|
396
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// 全局状态缓存(所有dashboard节点共享)
|
|
5
|
+
var globalStateCache = {};
|
|
6
|
+
|
|
7
|
+
function ModbusDashboardNode(config) {
|
|
8
|
+
RED.nodes.createNode(this, config);
|
|
9
|
+
var node = this;
|
|
10
|
+
|
|
11
|
+
// 配置
|
|
12
|
+
node.config = {
|
|
13
|
+
name: config.name || "Modbus控制看板",
|
|
14
|
+
masterNode: config.masterNode
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// 获取主站节点
|
|
18
|
+
var masterNode = RED.nodes.getNode(node.config.masterNode);
|
|
19
|
+
if (!masterNode) {
|
|
20
|
+
node.error('未找到主站节点');
|
|
21
|
+
node.status({fill: "red", shape: "ring", text: "未配置主站"});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 监听主站状态更新事件
|
|
26
|
+
node.stateUpdateHandler = function(data) {
|
|
27
|
+
if (!data || typeof data !== 'object') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var key = data.slave + "_" + data.coil;
|
|
32
|
+
globalStateCache[key] = data.value;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// 注册事件监听器
|
|
36
|
+
masterNode.on('stateUpdate', node.stateUpdateHandler);
|
|
37
|
+
|
|
38
|
+
// 初始化状态缓存(从主站获取当前状态)
|
|
39
|
+
if (masterNode.deviceStates) {
|
|
40
|
+
Object.keys(masterNode.deviceStates).forEach(function(slaveId) {
|
|
41
|
+
var deviceState = masterNode.deviceStates[slaveId];
|
|
42
|
+
if (deviceState && deviceState.coils) {
|
|
43
|
+
deviceState.coils.forEach(function(value, coil) {
|
|
44
|
+
var key = slaveId + "_" + coil;
|
|
45
|
+
globalStateCache[key] = value;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 更新节点状态
|
|
52
|
+
node.status({fill: "green", shape: "dot", text: "监控中"});
|
|
53
|
+
|
|
54
|
+
// 清理
|
|
55
|
+
node.on('close', function() {
|
|
56
|
+
if (masterNode && node.stateUpdateHandler) {
|
|
57
|
+
masterNode.removeListener('stateUpdate', node.stateUpdateHandler);
|
|
58
|
+
}
|
|
59
|
+
node.status({});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
RED.nodes.registerType("modbus-dashboard", ModbusDashboardNode);
|
|
64
|
+
|
|
65
|
+
// HTTP API:获取状态
|
|
66
|
+
RED.httpAdmin.get('/modbus-dashboard/state', function(req, res) {
|
|
67
|
+
res.json({
|
|
68
|
+
states: globalStateCache
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// HTTP API:发送控制命令
|
|
73
|
+
RED.httpAdmin.post('/modbus-dashboard/control', function(req, res) {
|
|
74
|
+
var slave = parseInt(req.body.slave);
|
|
75
|
+
var coil = parseInt(req.body.coil);
|
|
76
|
+
var value = Boolean(req.body.value);
|
|
77
|
+
|
|
78
|
+
if (isNaN(slave) || isNaN(coil)) {
|
|
79
|
+
res.status(400).json({error: '参数错误'});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 发送内部事件(通过免连线通信机制)
|
|
84
|
+
RED.events.emit('modbus:writeCoil', {
|
|
85
|
+
slave: slave,
|
|
86
|
+
coil: coil,
|
|
87
|
+
value: value,
|
|
88
|
+
source: 'dashboard'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 立即更新缓存(乐观更新)
|
|
92
|
+
var key = slave + "_" + coil;
|
|
93
|
+
globalStateCache[key] = value;
|
|
94
|
+
|
|
95
|
+
res.json({success: true});
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-modbus",
|
|
3
|
-
"version": "2.7.
|
|
4
|
-
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit
|
|
3
|
+
"version": "2.7.2",
|
|
4
|
+
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步,工控机长期稳定运行",
|
|
5
5
|
"main": "nodes/modbus-master.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
@@ -38,7 +38,9 @@
|
|
|
38
38
|
"mqtt-server-config": "nodes/mqtt-server-config.js",
|
|
39
39
|
"serial-port-config": "nodes/serial-port-config.js",
|
|
40
40
|
"modbus-debug": "nodes/modbus-debug.js",
|
|
41
|
-
"homekit-bridge": "nodes/homekit-bridge.js"
|
|
41
|
+
"homekit-bridge": "nodes/homekit-bridge.js",
|
|
42
|
+
"modbus-dashboard": "nodes/modbus-dashboard.js",
|
|
43
|
+
"custom-protocol": "nodes/custom-protocol.js"
|
|
42
44
|
}
|
|
43
45
|
},
|
|
44
46
|
"dependencies": {
|