node-red-contrib-symi-modbus 2.6.7 → 2.7.0
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 +139 -15
- package/nodes/homekit-bridge.html +251 -0
- package/nodes/homekit-bridge.js +328 -0
- package/nodes/modbus-master.js +140 -70
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成,
|
|
|
17
17
|
- Modbus RTU over TCP(TCP转RS485网关)
|
|
18
18
|
- Telnet ASCII(推荐用于TCP转RS485网关)
|
|
19
19
|
- **Symi开关集成**:自动识别并处理Symi私有协议按键事件,实现开关面板与继电器的双向同步
|
|
20
|
+
- **HomeKit网桥**:一键桥接到Apple HomeKit,支持Siri语音控制,自动同步主站配置,名称可自定义
|
|
21
|
+
- **智能写入队列**:所有写入操作串行执行,支持HomeKit群控160个继电器同时动作,流畅无卡顿
|
|
20
22
|
- **多设备轮询**:支持最多10台Modbus从站设备,每台32路继电器
|
|
21
23
|
- **智能轮询机制**:从站上报时自动暂停轮询,优先处理数据,避免冲突
|
|
22
24
|
- **稳定可靠**:完整的内存管理、错误处理、断线重连,适合7x24小时长期运行
|
|
@@ -146,6 +148,72 @@ node-red-restart
|
|
|
146
148
|
5. **无需连线**:主站和从站通过内部事件自动通信,无需手动连线
|
|
147
149
|
6. 部署流程
|
|
148
150
|
|
|
151
|
+
### 6. 配置HomeKit网桥节点(可选)
|
|
152
|
+
|
|
153
|
+
将Modbus继电器桥接到Apple HomeKit,实现Siri语音控制:
|
|
154
|
+
|
|
155
|
+
**配置步骤**:
|
|
156
|
+
|
|
157
|
+
1. **添加网桥节点**
|
|
158
|
+
- 拖拽 **HomeKit网桥** 节点到流程画布
|
|
159
|
+
- 双击节点打开配置界面
|
|
160
|
+
|
|
161
|
+
2. **基础配置**
|
|
162
|
+
- 网桥名称: `Modbus继电器网桥`(在HomeKit中显示)
|
|
163
|
+
- 选择主站节点: 从下拉框选择已配置的主站
|
|
164
|
+
- 配对码: `031-45-154`(添加到HomeKit时使用)
|
|
165
|
+
- 端口: `51828`(保持默认)
|
|
166
|
+
|
|
167
|
+
3. **配置继电器名称**(推荐)
|
|
168
|
+
- 选择主站后,下方会自动显示所有继电器
|
|
169
|
+
- 为每个继电器输入友好的中文名称
|
|
170
|
+
- 例如:`客厅灯`、`卧室灯`、`空调插座`等
|
|
171
|
+
- 配置后在HomeKit中直接显示,无需再修改
|
|
172
|
+
|
|
173
|
+
4. **部署并添加到HomeKit**
|
|
174
|
+
- 点击"完成"并部署流程
|
|
175
|
+
- 打开iPhone/iPad的"家庭"App
|
|
176
|
+
- 点击右上角"+" → "添加配件"
|
|
177
|
+
- 选择"更多选项"
|
|
178
|
+
- 找到"Modbus继电器网桥"
|
|
179
|
+
- 输入配对码:`031-45-154`
|
|
180
|
+
- 完成配对
|
|
181
|
+
|
|
182
|
+
**使用说明**:
|
|
183
|
+
- 线圈0-15显示为开关,线圈16-31显示为插座
|
|
184
|
+
- 支持Siri语音控制:"嘿Siri,打开客厅灯"
|
|
185
|
+
- 支持HomeKit自动化和场景
|
|
186
|
+
- 配置会自动保存,重启后无需重新配对
|
|
187
|
+
- **群控性能**:支持同时控制多个继电器(如创建编组),智能队列机制确保流畅无卡顿
|
|
188
|
+
|
|
189
|
+
**群控说明**(客户友好模式):
|
|
190
|
+
|
|
191
|
+
本节点专为智能家居群控场景优化,支持以下高性能操作:
|
|
192
|
+
|
|
193
|
+
1. **HomeKit编组群控**:
|
|
194
|
+
- 在HomeKit中创建房间或编组,可同时控制多个继电器
|
|
195
|
+
- 例如:创建"客厅"编组,包含10个灯光开关,一键全开/全关
|
|
196
|
+
- 智能队列机制确保所有继电器按序快速执行,无超时警告
|
|
197
|
+
- 10个继电器同时动作仅需约200ms(20ms间隔×10)
|
|
198
|
+
|
|
199
|
+
2. **场景联动**:
|
|
200
|
+
- 支持HomeKit场景(如"回家模式"、"离家模式")
|
|
201
|
+
- 场景可包含多个继电器动作,自动串行执行
|
|
202
|
+
- 前16个线圈(继电器)可同时动作,后16个线圈(场景)按需触发
|
|
203
|
+
|
|
204
|
+
3. **性能保证**:
|
|
205
|
+
- 最大支持10台继电器(每台32路),共320个线圈
|
|
206
|
+
- 前160个线圈(10台×16路)可用于继电器控制,支持群控
|
|
207
|
+
- 后160个线圈(10台×16路)可用于场景触发,一般不会全部同时动作
|
|
208
|
+
- 智能写入队列确保所有操作串行执行,避免总线冲突
|
|
209
|
+
- 长期稳定运行,反复控制不会造成内存增加、卡顿或死机
|
|
210
|
+
|
|
211
|
+
4. **技术细节**:
|
|
212
|
+
- 写入队列间隔:20ms(确保总线稳定)
|
|
213
|
+
- 轮询恢复时间:20ms(快速响应)
|
|
214
|
+
- 锁等待超时:100ms(快速检测异常)
|
|
215
|
+
- 队列自动处理,无需手动干预
|
|
216
|
+
|
|
149
217
|
## 核心特性说明
|
|
150
218
|
|
|
151
219
|
### Symi开关自动识别
|
|
@@ -578,6 +646,40 @@ msg.payload = "ON"; // 或 "OFF"
|
|
|
578
646
|
msg.payload = 1; // 或 0
|
|
579
647
|
```
|
|
580
648
|
|
|
649
|
+
### HomeKit网桥节点
|
|
650
|
+
|
|
651
|
+
HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
652
|
+
|
|
653
|
+
**配置参数**:
|
|
654
|
+
- **主站节点**:选择要桥接的Modbus主站节点(必填)
|
|
655
|
+
- **网桥名称**:在HomeKit中显示的网桥名称(默认:Modbus继电器网桥)
|
|
656
|
+
- **MAC地址**:HomeKit网桥的唯一标识符(自动生成,无需修改)
|
|
657
|
+
- **配对码**:HomeKit配对时使用的PIN码(格式:XXX-XX-XXX,默认:031-45-154)
|
|
658
|
+
- **端口**:HomeKit网桥监听端口(默认:51828)
|
|
659
|
+
- **继电器名称配置**:为每个继电器配置友好的中文名称(可选)
|
|
660
|
+
|
|
661
|
+
**自动同步规则**:
|
|
662
|
+
- 自动读取主站节点配置的所有从站和线圈
|
|
663
|
+
- 线圈0-15(1-16路):创建为开关(Switch)配件
|
|
664
|
+
- 线圈16-31(17-32路):创建为插座(Outlet)配件
|
|
665
|
+
- 监听主站的状态变化事件,实时同步到HomeKit
|
|
666
|
+
- 接收HomeKit控制命令,发送到主站执行
|
|
667
|
+
|
|
668
|
+
**使用示例**:
|
|
669
|
+
1. 在Node-RED中配置好主站节点和从站
|
|
670
|
+
2. 添加HomeKit网桥节点,选择主站节点
|
|
671
|
+
3. 为每个继电器配置友好的名称(例如:客厅灯、卧室灯)
|
|
672
|
+
4. 部署流程
|
|
673
|
+
5. 在iPhone的"家庭"App中添加配件,输入配对码
|
|
674
|
+
6. 完成配对后,即可在HomeKit中控制继电器,支持Siri语音控制
|
|
675
|
+
|
|
676
|
+
**注意事项**:
|
|
677
|
+
- 确保主站节点已正确配置并运行
|
|
678
|
+
- 配对码格式必须为XXX-XX-XXX(8位数字)
|
|
679
|
+
- 端口不能与其他服务冲突(默认51828)
|
|
680
|
+
- 配置信息持久化存储在~/.node-red/homekit-persist目录
|
|
681
|
+
- 重启Node-RED后自动恢复配对状态,无需重新配对
|
|
682
|
+
|
|
581
683
|
## 输出消息格式
|
|
582
684
|
|
|
583
685
|
### 主站节点
|
|
@@ -638,28 +740,46 @@ msg.payload = 1; // 或 0
|
|
|
638
740
|
|
|
639
741
|
## 项目信息
|
|
640
742
|
|
|
641
|
-
**版本**: v2.
|
|
743
|
+
**版本**: v2.7.0
|
|
642
744
|
|
|
643
745
|
**核心功能**:
|
|
644
746
|
- 支持多种Modbus协议(Telnet ASCII、RTU over TCP、Modbus TCP、Modbus RTU串口)
|
|
645
747
|
- 多设备轮询(最多10台从站,每台32路继电器,轮询间隔100-10000ms可调)
|
|
646
|
-
- Symi私有协议自动识别(支持两种485开关控制方式)
|
|
647
|
-
- 智能轮询暂停机制(从站上报时自动暂停,处理完成后恢复)
|
|
648
748
|
- 🔥 **双模式支持**(本地模式和MQTT模式可选切换,断网也能稳定运行)
|
|
649
|
-
- 🔥
|
|
650
|
-
-
|
|
651
|
-
-
|
|
652
|
-
-
|
|
653
|
-
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
- serialport: ^12.0.0(原生串口通信)
|
|
658
|
-
- mqtt: ^5.14.1(最新稳定版,可选依赖)
|
|
749
|
+
- 🔥 **免连线通信**(主站和从站通过内部事件通信,无需连线)
|
|
750
|
+
- 🔥 **HomeKit网桥**(一键桥接到Apple HomeKit,支持Siri语音控制)
|
|
751
|
+
- 🔥 **智能写入队列**(所有写入操作串行执行,避免锁竞争,支持HomeKit群控)
|
|
752
|
+
- MQTT集成(可选启用,Home Assistant自动发现)
|
|
753
|
+
- 物理开关面板双向同步(支持开关模式和场景模式)
|
|
754
|
+
- 长期稳定运行(内存管理、智能重连、异步处理)
|
|
755
|
+
|
|
756
|
+
**技术要求**:
|
|
659
757
|
- Node.js: >=14.0.0
|
|
660
758
|
- Node-RED: >=2.0.0
|
|
661
759
|
|
|
662
|
-
**最新更新(v2.
|
|
760
|
+
**最新更新(v2.7.0)**:
|
|
761
|
+
- **🔥 智能写入队列机制**:
|
|
762
|
+
- 所有写入操作(HomeKit、MQTT、内部事件)统一进入队列串行执行
|
|
763
|
+
- 彻底解决HomeKit群控时的锁竞争问题,不再出现"写入线圈等待超时"警告
|
|
764
|
+
- 写入间隔优化到20ms,确保快速响应(10个继电器同时动作仅需200ms)
|
|
765
|
+
- 队列自动处理,无需手动干预,确保总线稳定性
|
|
766
|
+
- 支持最多160个继电器(10台从站×16路)同时群控,流畅无卡顿
|
|
767
|
+
- **性能优化**:
|
|
768
|
+
- 轮询恢复时间从100ms优化到20ms,提升响应速度5倍
|
|
769
|
+
- 锁等待超时从1000ms降低到100ms,快速检测异常
|
|
770
|
+
- 内存占用更低,CPU占用更少,适合长期运行
|
|
771
|
+
|
|
772
|
+
**v2.6.8更新**:
|
|
773
|
+
- **🔥 新增HomeKit网桥节点**:
|
|
774
|
+
- 一键桥接Modbus继电器到Apple HomeKit
|
|
775
|
+
- 自动同步主站配置的所有从站和继电器
|
|
776
|
+
- 支持在Node-RED中配置继电器友好名称
|
|
777
|
+
- 双向同步:HomeKit控制继电器,继电器状态实时同步到HomeKit
|
|
778
|
+
- 支持Siri语音控制和HomeKit自动化场景
|
|
779
|
+
- 持久化存储配对信息,重启后自动恢复
|
|
780
|
+
- 线圈0-15显示为开关,线圈16-31显示为插座(避免误触发)
|
|
781
|
+
|
|
782
|
+
**v2.6.7更新**:
|
|
663
783
|
- **🔥 修复LED反馈功能**:
|
|
664
784
|
- 主站轮询检测到状态变化时自动广播事件
|
|
665
785
|
- 从站开关节点监听状态变化并发送LED反馈到物理面板
|
|
@@ -986,7 +1106,7 @@ msg.payload = 1; // 或 0
|
|
|
986
1106
|
|
|
987
1107
|
## 项目信息
|
|
988
1108
|
|
|
989
|
-
**版本**: v2.
|
|
1109
|
+
**版本**: v2.7.0
|
|
990
1110
|
|
|
991
1111
|
**核心功能**:
|
|
992
1112
|
- 支持多种Modbus协议(Telnet ASCII、RTU over TCP、Modbus TCP、Modbus RTU串口)
|
|
@@ -995,6 +1115,8 @@ msg.payload = 1; // 或 0
|
|
|
995
1115
|
- 智能轮询暂停机制(从站上报时自动暂停,处理完成后恢复)
|
|
996
1116
|
- 🔥 **双模式支持**(本地模式和MQTT模式可选切换,断网也能稳定运行)
|
|
997
1117
|
- 🔥 **免连线通信**(主站和从站通过内部事件通信,无需连线,支持本地模式和MQTT模式)
|
|
1118
|
+
- 🔥 **HomeKit网桥**(一键桥接到Apple HomeKit,支持Siri语音控制,名称可自定义)
|
|
1119
|
+
- 🔥 **智能写入队列**(所有写入操作串行执行,避免锁竞争,支持HomeKit群控)
|
|
998
1120
|
- MQTT集成(可选启用,Home Assistant自动发现,实体唯一性保证,QoS=0高性能发布)
|
|
999
1121
|
- 物理开关面板双向同步(亖米协议支持,LED反馈同步,支持开关模式和场景模式)
|
|
1000
1122
|
- 共享连接架构(多个从站开关节点共享同一个串口/TCP连接,支持500+节点)
|
|
@@ -1004,5 +1126,7 @@ msg.payload = 1; // 或 0
|
|
|
1004
1126
|
- modbus-serial: ^8.0.23(内部封装serialport,支持TCP和串口)
|
|
1005
1127
|
- serialport: ^12.0.0(原生串口通信)
|
|
1006
1128
|
- mqtt: ^5.14.1(最新稳定版,可选依赖)
|
|
1129
|
+
- hap-nodejs: ^1.2.0(HomeKit桥接)
|
|
1130
|
+
- node-persist: ^4.0.4(持久化存储)
|
|
1007
1131
|
- Node.js: >=14.0.0
|
|
1008
1132
|
- Node-RED: >=2.0.0
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('homekit-bridge', {
|
|
3
|
+
category: 'SYMI-MODBUS',
|
|
4
|
+
color: '#FF9800',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: {value: "HomeKit网桥"},
|
|
7
|
+
bridgeName: {value: "Modbus继电器网桥"},
|
|
8
|
+
username: {value: ""}, // 自动生成MAC地址
|
|
9
|
+
pincode: {value: "031-45-154"},
|
|
10
|
+
port: {value: 51828},
|
|
11
|
+
masterNode: {value: "", required: true},
|
|
12
|
+
// 继电器名称配置(持久化)
|
|
13
|
+
relayNames: {value: {}} // 格式: {"10_0": "客厅灯", "10_1": "卧室灯", ...}
|
|
14
|
+
},
|
|
15
|
+
inputs: 0,
|
|
16
|
+
outputs: 0,
|
|
17
|
+
icon: "home.png",
|
|
18
|
+
label: function() {
|
|
19
|
+
return this.name || "HomeKit网桥";
|
|
20
|
+
},
|
|
21
|
+
oneditprepare: function() {
|
|
22
|
+
var node = this;
|
|
23
|
+
|
|
24
|
+
// 填充主站节点选择器
|
|
25
|
+
var masterNodeSelect = $("#node-input-masterNode");
|
|
26
|
+
masterNodeSelect.empty();
|
|
27
|
+
masterNodeSelect.append('<option value="">请选择主站节点</option>');
|
|
28
|
+
|
|
29
|
+
// 查找所有modbus-master节点
|
|
30
|
+
RED.nodes.eachNode(function(n) {
|
|
31
|
+
if (n.type === "modbus-master") {
|
|
32
|
+
var label = n.name || `主站 ${n.id.substring(0, 8)}`;
|
|
33
|
+
var selected = (n.id === node.masterNode) ? ' selected' : '';
|
|
34
|
+
masterNodeSelect.append(`<option value="${n.id}"${selected}>${label}</option>`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 自动生成MAC地址(如果未设置)
|
|
39
|
+
if (!node.username || node.username === "") {
|
|
40
|
+
node.username = generateMacAddress();
|
|
41
|
+
$("#node-input-username").val(node.username);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 初始化继电器名称配置
|
|
45
|
+
if (!node.relayNames) {
|
|
46
|
+
node.relayNames = {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 监听主站节点选择变化
|
|
50
|
+
masterNodeSelect.on("change", function() {
|
|
51
|
+
renderRelayNameConfig();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// 初始渲染
|
|
55
|
+
renderRelayNameConfig();
|
|
56
|
+
|
|
57
|
+
// 生成MAC地址函数
|
|
58
|
+
function generateMacAddress() {
|
|
59
|
+
var mac = [];
|
|
60
|
+
for (var i = 0; i < 6; i++) {
|
|
61
|
+
var byte = Math.floor(Math.random() * 256).toString(16).toUpperCase();
|
|
62
|
+
mac.push(byte.length === 1 ? "0" + byte : byte);
|
|
63
|
+
}
|
|
64
|
+
return mac.join(":");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 渲染继电器名称配置
|
|
68
|
+
function renderRelayNameConfig() {
|
|
69
|
+
var masterNodeId = $("#node-input-masterNode").val();
|
|
70
|
+
var container = $("#relay-names-container");
|
|
71
|
+
container.empty();
|
|
72
|
+
|
|
73
|
+
if (!masterNodeId) {
|
|
74
|
+
container.html('<div style="padding: 20px; text-align: center; color: #999;">请先选择主站节点</div>');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 获取主站节点配置
|
|
79
|
+
var masterNode = RED.nodes.node(masterNodeId);
|
|
80
|
+
if (!masterNode || !masterNode.slaves || masterNode.slaves.length === 0) {
|
|
81
|
+
container.html('<div style="padding: 20px; text-align: center; color: #999;">主站节点未配置从站</div>');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 遍历所有从站和线圈
|
|
86
|
+
masterNode.slaves.forEach(function(slave) {
|
|
87
|
+
var slaveSection = $('<div class="slave-section" style="margin-bottom: 20px; padding: 15px; border: 1px solid #e0e0e0; border-radius: 8px; background: #f9f9f9;">');
|
|
88
|
+
|
|
89
|
+
slaveSection.html(`
|
|
90
|
+
<div style="font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #333;">
|
|
91
|
+
从站 ${slave.address} (线圈 ${slave.coilStart}-${slave.coilEnd})
|
|
92
|
+
</div>
|
|
93
|
+
`);
|
|
94
|
+
|
|
95
|
+
var relayGrid = $('<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">');
|
|
96
|
+
|
|
97
|
+
for (var coil = slave.coilStart; coil <= slave.coilEnd; coil++) {
|
|
98
|
+
var key = slave.address + "_" + coil;
|
|
99
|
+
var currentName = node.relayNames[key] || getDefaultRelayName(slave.address, coil);
|
|
100
|
+
var relayType = coil < 16 ? "开关" : "插座";
|
|
101
|
+
|
|
102
|
+
var relayRow = $(`
|
|
103
|
+
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: white; border-radius: 4px;">
|
|
104
|
+
<span style="min-width: 80px; font-size: 12px; color: #666;">线圈${coil} (${relayType}):</span>
|
|
105
|
+
<input type="text" class="relay-name-input" data-key="${key}" value="${currentName}"
|
|
106
|
+
style="flex: 1; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;"
|
|
107
|
+
placeholder="输入名称...">
|
|
108
|
+
</div>
|
|
109
|
+
`);
|
|
110
|
+
|
|
111
|
+
relayGrid.append(relayRow);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
slaveSection.append(relayGrid);
|
|
115
|
+
container.append(slaveSection);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// 绑定输入事件
|
|
119
|
+
$(".relay-name-input").on("input", function() {
|
|
120
|
+
var key = $(this).data("key");
|
|
121
|
+
var value = $(this).val().trim();
|
|
122
|
+
if (value) {
|
|
123
|
+
node.relayNames[key] = value;
|
|
124
|
+
} else {
|
|
125
|
+
delete node.relayNames[key];
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 获取默认继电器名称
|
|
131
|
+
function getDefaultRelayName(slaveAddr, coil) {
|
|
132
|
+
var type = coil < 16 ? "开关" : "场景";
|
|
133
|
+
return `从站${slaveAddr}_${type}${coil + 1}`;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
oneditsave: function() {
|
|
137
|
+
// 保存时确保relayNames已更新
|
|
138
|
+
// 已在input事件中实时更新,无需额外处理
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
</script>
|
|
142
|
+
|
|
143
|
+
<script type="text/html" data-template-name="homekit-bridge">
|
|
144
|
+
<div class="form-row">
|
|
145
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 节点名称</label>
|
|
146
|
+
<input type="text" id="node-input-name" placeholder="HomeKit网桥">
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div class="form-row">
|
|
150
|
+
<label for="node-input-masterNode"><i class="fa fa-microchip"></i> 主站节点</label>
|
|
151
|
+
<select id="node-input-masterNode" style="width: 70%;">
|
|
152
|
+
<option value="">请选择主站节点</option>
|
|
153
|
+
</select>
|
|
154
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px;">选择要桥接的Modbus主站节点</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<hr style="margin: 20px 0; border: none; border-top: 1px solid #e0e0e0;">
|
|
158
|
+
|
|
159
|
+
<div class="form-row">
|
|
160
|
+
<label for="node-input-bridgeName"><i class="fa fa-home"></i> 网桥名称</label>
|
|
161
|
+
<input type="text" id="node-input-bridgeName" placeholder="Modbus继电器网桥">
|
|
162
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px;">在HomeKit中显示的网桥名称</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div class="form-row">
|
|
166
|
+
<label for="node-input-username"><i class="fa fa-barcode"></i> MAC地址</label>
|
|
167
|
+
<input type="text" id="node-input-username" placeholder="自动生成" readonly>
|
|
168
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px;">HomeKit网桥的唯一标识符(自动生成)</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div class="form-row">
|
|
172
|
+
<label for="node-input-pincode"><i class="fa fa-key"></i> 配对码</label>
|
|
173
|
+
<input type="text" id="node-input-pincode" placeholder="031-45-154">
|
|
174
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px;">HomeKit配对时使用的PIN码(格式:XXX-XX-XXX)</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div class="form-row">
|
|
178
|
+
<label for="node-input-port"><i class="fa fa-plug"></i> 端口</label>
|
|
179
|
+
<input type="number" id="node-input-port" placeholder="51828" min="1024" max="65535">
|
|
180
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px;">HomeKit网桥监听端口(默认:51828)</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<hr style="margin: 20px 0; border: none; border-top: 1px solid #e0e0e0;">
|
|
184
|
+
|
|
185
|
+
<div class="form-row">
|
|
186
|
+
<label style="width: 100%; margin-bottom: 10px;">
|
|
187
|
+
<i class="fa fa-list"></i> 继电器名称配置
|
|
188
|
+
</label>
|
|
189
|
+
<div id="relay-names-container" style="width: 100%; max-height: 400px; overflow-y: auto; border: 1px solid #e0e0e0; border-radius: 4px; padding: 10px; background: white;">
|
|
190
|
+
<div style="padding: 20px; text-align: center; color: #999;">请先选择主站节点</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px;">
|
|
193
|
+
配置每个继电器在HomeKit中显示的名称<br>
|
|
194
|
+
线圈0-15显示为开关,线圈16-31显示为插座(避免误触发场景)
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</script>
|
|
198
|
+
|
|
199
|
+
<script type="text/html" data-help-name="homekit-bridge">
|
|
200
|
+
<p>HomeKit网桥节点,将Modbus继电器桥接到Apple HomeKit。</p>
|
|
201
|
+
|
|
202
|
+
<h3>功能特性</h3>
|
|
203
|
+
<ul>
|
|
204
|
+
<li>自动同步主站节点配置的所有从站和继电器</li>
|
|
205
|
+
<li>线圈0-15显示为开关(Switch),线圈16-31显示为插座(Outlet)</li>
|
|
206
|
+
<li>支持在Node-RED中配置每个继电器的名称,无需在HomeKit中修改</li>
|
|
207
|
+
<li>双向同步:HomeKit控制继电器,继电器状态同步到HomeKit</li>
|
|
208
|
+
<li>持久化存储配置和配对信息,重启后自动恢复</li>
|
|
209
|
+
</ul>
|
|
210
|
+
|
|
211
|
+
<h3>配置说明</h3>
|
|
212
|
+
<dl class="message-properties">
|
|
213
|
+
<dt>主站节点 <span class="property-type">必填</span></dt>
|
|
214
|
+
<dd>选择要桥接的Modbus主站节点</dd>
|
|
215
|
+
|
|
216
|
+
<dt>网桥名称 <span class="property-type">可选</span></dt>
|
|
217
|
+
<dd>在HomeKit中显示的网桥名称(默认:Modbus继电器网桥)</dd>
|
|
218
|
+
|
|
219
|
+
<dt>MAC地址 <span class="property-type">自动生成</span></dt>
|
|
220
|
+
<dd>HomeKit网桥的唯一标识符,自动生成,无需修改</dd>
|
|
221
|
+
|
|
222
|
+
<dt>配对码 <span class="property-type">必填</span></dt>
|
|
223
|
+
<dd>HomeKit配对时使用的PIN码(格式:XXX-XX-XXX,默认:031-45-154)</dd>
|
|
224
|
+
|
|
225
|
+
<dt>端口 <span class="property-type">可选</span></dt>
|
|
226
|
+
<dd>HomeKit网桥监听端口(默认:51828)</dd>
|
|
227
|
+
|
|
228
|
+
<dt>继电器名称配置 <span class="property-type">可选</span></dt>
|
|
229
|
+
<dd>配置每个继电器在HomeKit中显示的名称,支持中文</dd>
|
|
230
|
+
</dl>
|
|
231
|
+
|
|
232
|
+
<h3>使用步骤</h3>
|
|
233
|
+
<ol>
|
|
234
|
+
<li>选择已配置的Modbus主站节点</li>
|
|
235
|
+
<li>配置网桥名称和配对码</li>
|
|
236
|
+
<li>为每个继电器配置友好的名称(可选)</li>
|
|
237
|
+
<li>部署流程</li>
|
|
238
|
+
<li>在iPhone/iPad的"家庭"App中添加配件,输入配对码</li>
|
|
239
|
+
<li>完成配对后,即可在HomeKit中控制继电器</li>
|
|
240
|
+
</ol>
|
|
241
|
+
|
|
242
|
+
<h3>注意事项</h3>
|
|
243
|
+
<ul>
|
|
244
|
+
<li>确保主站节点已正确配置并运行</li>
|
|
245
|
+
<li>配对码格式必须为XXX-XX-XXX(8位数字)</li>
|
|
246
|
+
<li>端口不能与其他服务冲突</li>
|
|
247
|
+
<li>线圈16-31使用插座类型,避免在HomeKit中误触发所有开关</li>
|
|
248
|
+
<li>配置信息持久化存储在~/.node-red/homekit-persist目录</li>
|
|
249
|
+
</ul>
|
|
250
|
+
</script>
|
|
251
|
+
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
const hap = require('hap-nodejs');
|
|
4
|
+
const storage = require('node-persist');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
function HomekitBridgeNode(config) {
|
|
9
|
+
RED.nodes.createNode(this, config);
|
|
10
|
+
const node = this;
|
|
11
|
+
|
|
12
|
+
// 配置参数
|
|
13
|
+
node.config = {
|
|
14
|
+
name: config.name || "HomeKit网桥",
|
|
15
|
+
bridgeName: config.bridgeName || "Modbus继电器网桥",
|
|
16
|
+
username: config.username,
|
|
17
|
+
pincode: config.pincode || "031-45-154",
|
|
18
|
+
port: parseInt(config.port) || 51828,
|
|
19
|
+
masterNodeId: config.masterNode,
|
|
20
|
+
relayNames: config.relayNames || {}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// 运行时状态
|
|
24
|
+
node.bridge = null;
|
|
25
|
+
node.accessories = {};
|
|
26
|
+
node.stateChangeListener = null;
|
|
27
|
+
node.masterNode = null;
|
|
28
|
+
node.isInitialized = false;
|
|
29
|
+
|
|
30
|
+
// 初始化持久化存储
|
|
31
|
+
const persistDir = path.join(RED.settings.userDir || os.homedir() + '/.node-red', 'homekit-persist');
|
|
32
|
+
|
|
33
|
+
// 初始化函数
|
|
34
|
+
async function initialize() {
|
|
35
|
+
try {
|
|
36
|
+
// 初始化存储
|
|
37
|
+
await storage.init({
|
|
38
|
+
dir: persistDir,
|
|
39
|
+
stringify: JSON.stringify,
|
|
40
|
+
parse: JSON.parse,
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
logging: false,
|
|
43
|
+
ttl: false,
|
|
44
|
+
expiredInterval: 2 * 60 * 1000,
|
|
45
|
+
forgiveParseErrors: true
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
node.log(`持久化存储初始化成功: ${persistDir}`);
|
|
49
|
+
|
|
50
|
+
// 保存继电器名称配置到持久化存储
|
|
51
|
+
await storage.setItem('relayNames', node.config.relayNames);
|
|
52
|
+
node.log(`继电器名称配置已保存: ${Object.keys(node.config.relayNames).length} 个`);
|
|
53
|
+
|
|
54
|
+
// 获取主站节点
|
|
55
|
+
node.masterNode = RED.nodes.getNode(node.config.masterNodeId);
|
|
56
|
+
if (!node.masterNode) {
|
|
57
|
+
node.error("未找到主站节点");
|
|
58
|
+
node.status({fill: "red", shape: "dot", text: "主站节点未找到"});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 从主站节点的 config.slaves 获取从站配置
|
|
63
|
+
const slaves = node.masterNode.config ? node.masterNode.config.slaves : null;
|
|
64
|
+
if (!slaves || slaves.length === 0) {
|
|
65
|
+
node.error("主站节点未配置从站");
|
|
66
|
+
node.status({fill: "red", shape: "dot", text: "主站未配置从站"});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
node.log(`找到主站节点,从站数量: ${slaves.length}`);
|
|
71
|
+
|
|
72
|
+
// 创建HomeKit网桥
|
|
73
|
+
await createBridge();
|
|
74
|
+
|
|
75
|
+
// 注册状态变化监听器
|
|
76
|
+
registerStateChangeListener();
|
|
77
|
+
|
|
78
|
+
node.isInitialized = true;
|
|
79
|
+
node.status({fill: "green", shape: "dot", text: "运行中"});
|
|
80
|
+
node.log(`HomeKit网桥已启动: ${node.config.bridgeName}`);
|
|
81
|
+
node.log(`配对码: ${node.config.pincode}`);
|
|
82
|
+
node.log(`端口: ${node.config.port}`);
|
|
83
|
+
|
|
84
|
+
} catch (err) {
|
|
85
|
+
node.error(`初始化失败: ${err.message}`);
|
|
86
|
+
node.status({fill: "red", shape: "dot", text: "初始化失败"});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 创建HomeKit网桥
|
|
91
|
+
async function createBridge() {
|
|
92
|
+
// 创建网桥配件
|
|
93
|
+
const uuid = hap.uuid.generate('homebridge:modbus-bridge:' + node.config.username);
|
|
94
|
+
node.bridge = new hap.Bridge(node.config.bridgeName, uuid);
|
|
95
|
+
|
|
96
|
+
// 配置网桥信息
|
|
97
|
+
node.bridge.getService(hap.Service.AccessoryInformation)
|
|
98
|
+
.setCharacteristic(hap.Characteristic.Manufacturer, "SYMI")
|
|
99
|
+
.setCharacteristic(hap.Characteristic.Model, "Modbus Relay Bridge")
|
|
100
|
+
.setCharacteristic(hap.Characteristic.SerialNumber, node.config.username)
|
|
101
|
+
.setCharacteristic(hap.Characteristic.FirmwareRevision, "2.6.8");
|
|
102
|
+
|
|
103
|
+
// 遍历所有从站和线圈,创建配件
|
|
104
|
+
let accessoryCount = 0;
|
|
105
|
+
const slaves = node.masterNode.config ? node.masterNode.config.slaves : [];
|
|
106
|
+
slaves.forEach(function(slave) {
|
|
107
|
+
for (let coil = slave.coilStart; coil <= slave.coilEnd; coil++) {
|
|
108
|
+
const key = `${slave.address}_${coil}`;
|
|
109
|
+
const accessory = createAccessory(slave.address, coil);
|
|
110
|
+
if (accessory) {
|
|
111
|
+
node.bridge.addBridgedAccessory(accessory);
|
|
112
|
+
node.accessories[key] = accessory;
|
|
113
|
+
accessoryCount++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
node.log(`已创建 ${accessoryCount} 个HomeKit配件`);
|
|
119
|
+
|
|
120
|
+
// 发布网桥
|
|
121
|
+
node.bridge.publish({
|
|
122
|
+
username: node.config.username,
|
|
123
|
+
pincode: node.config.pincode,
|
|
124
|
+
port: node.config.port,
|
|
125
|
+
category: hap.Categories.BRIDGE
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// 部署后自动更新所有配件名称(确保使用最新配置)
|
|
129
|
+
setTimeout(() => {
|
|
130
|
+
updateAllAccessoryNames();
|
|
131
|
+
}, 1000);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 创建单个配件
|
|
135
|
+
function createAccessory(slaveAddr, coil) {
|
|
136
|
+
const key = `${slaveAddr}_${coil}`;
|
|
137
|
+
const name = node.config.relayNames[key] || getDefaultRelayName(slaveAddr, coil);
|
|
138
|
+
const uuid = hap.uuid.generate(`homebridge:modbus-relay:${slaveAddr}:${coil}`);
|
|
139
|
+
|
|
140
|
+
// 线圈0-15使用开关,线圈16-31使用插座(避免误触发)
|
|
141
|
+
const isSwitch = coil < 16;
|
|
142
|
+
const accessory = new hap.Accessory(name, uuid);
|
|
143
|
+
|
|
144
|
+
// 保存关键信息到配件对象(用于后续更新名称)
|
|
145
|
+
accessory._slaveAddr = slaveAddr;
|
|
146
|
+
accessory._coil = coil;
|
|
147
|
+
accessory._isSwitch = isSwitch;
|
|
148
|
+
|
|
149
|
+
// 配置配件信息
|
|
150
|
+
accessory.getService(hap.Service.AccessoryInformation)
|
|
151
|
+
.setCharacteristic(hap.Characteristic.Manufacturer, "SYMI")
|
|
152
|
+
.setCharacteristic(hap.Characteristic.Model, isSwitch ? "Modbus Switch" : "Modbus Outlet")
|
|
153
|
+
.setCharacteristic(hap.Characteristic.SerialNumber, `${slaveAddr}-${coil}`)
|
|
154
|
+
.setCharacteristic(hap.Characteristic.FirmwareRevision, "2.6.8");
|
|
155
|
+
|
|
156
|
+
// 添加服务(开关或插座)
|
|
157
|
+
const service = isSwitch
|
|
158
|
+
? accessory.addService(hap.Service.Switch, name)
|
|
159
|
+
: accessory.addService(hap.Service.Outlet, name);
|
|
160
|
+
|
|
161
|
+
// 配置On特性
|
|
162
|
+
service.getCharacteristic(hap.Characteristic.On)
|
|
163
|
+
.on('get', (callback) => {
|
|
164
|
+
// 从主站获取当前状态
|
|
165
|
+
const state = getRelayState(slaveAddr, coil);
|
|
166
|
+
callback(null, state);
|
|
167
|
+
})
|
|
168
|
+
.on('set', (value, callback) => {
|
|
169
|
+
// 发送控制命令到主站
|
|
170
|
+
setRelayState(slaveAddr, coil, value);
|
|
171
|
+
callback(null);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// 如果是插座,配置InUse特性(始终为true)
|
|
175
|
+
if (!isSwitch) {
|
|
176
|
+
service.getCharacteristic(hap.Characteristic.OutletInUse)
|
|
177
|
+
.on('get', (callback) => {
|
|
178
|
+
callback(null, true);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return accessory;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 更新配件名称(重新部署时调用)
|
|
186
|
+
function updateAccessoryName(accessory, newName) {
|
|
187
|
+
try {
|
|
188
|
+
const isSwitch = accessory._isSwitch;
|
|
189
|
+
const service = isSwitch
|
|
190
|
+
? accessory.getService(hap.Service.Switch)
|
|
191
|
+
: accessory.getService(hap.Service.Outlet);
|
|
192
|
+
|
|
193
|
+
if (service) {
|
|
194
|
+
// 更新服务名称
|
|
195
|
+
service.setCharacteristic(hap.Characteristic.Name, newName);
|
|
196
|
+
// 更新配件显示名称
|
|
197
|
+
accessory.displayName = newName;
|
|
198
|
+
node.log(`配件名称已更新: ${accessory._slaveAddr}_${accessory._coil} -> ${newName}`);
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
node.error(`更新配件名称失败: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 获取默认继电器名称
|
|
206
|
+
function getDefaultRelayName(slaveAddr, coil) {
|
|
207
|
+
const type = coil < 16 ? "开关" : "场景";
|
|
208
|
+
return `从站${slaveAddr}_${type}${coil + 1}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 获取继电器状态(修复:使用 deviceStates 而不是 coilStates)
|
|
212
|
+
function getRelayState(slaveAddr, coil) {
|
|
213
|
+
if (!node.masterNode || !node.masterNode.deviceStates) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const deviceState = node.masterNode.deviceStates[slaveAddr];
|
|
217
|
+
if (!deviceState || !deviceState.coils) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return deviceState.coils[coil] || false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 设置继电器状态
|
|
224
|
+
function setRelayState(slaveAddr, coil, value) {
|
|
225
|
+
// 发送内部事件到主站
|
|
226
|
+
RED.events.emit('modbus:writeCoil', {
|
|
227
|
+
slave: slaveAddr,
|
|
228
|
+
coil: coil,
|
|
229
|
+
value: value,
|
|
230
|
+
source: 'homekit'
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
node.log(`HomeKit控制: 从站${slaveAddr} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 注册状态变化监听器
|
|
237
|
+
function registerStateChangeListener() {
|
|
238
|
+
node.stateChangeListener = function(data) {
|
|
239
|
+
try {
|
|
240
|
+
const slave = parseInt(data.slave);
|
|
241
|
+
const coil = parseInt(data.coil);
|
|
242
|
+
const value = Boolean(data.value);
|
|
243
|
+
const key = `${slave}_${coil}`;
|
|
244
|
+
|
|
245
|
+
// 查找对应的配件
|
|
246
|
+
const accessory = node.accessories[key];
|
|
247
|
+
if (!accessory) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 更新HomeKit状态
|
|
252
|
+
const isSwitch = coil < 16;
|
|
253
|
+
const service = isSwitch
|
|
254
|
+
? accessory.getService(hap.Service.Switch)
|
|
255
|
+
: accessory.getService(hap.Service.Outlet);
|
|
256
|
+
|
|
257
|
+
if (service) {
|
|
258
|
+
service.getCharacteristic(hap.Characteristic.On).updateValue(value);
|
|
259
|
+
node.log(`状态同步到HomeKit: 从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
node.error(`状态变化监听器错误: ${err.message}`);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
RED.events.on('modbus:coilStateChanged', node.stateChangeListener);
|
|
267
|
+
node.log("已注册状态变化监听器(用于HomeKit同步)");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 监听配置更新(重新部署时)
|
|
271
|
+
node.on('input', function(msg) {
|
|
272
|
+
// 检查是否是配置更新消息
|
|
273
|
+
if (msg.topic === 'updateNames' && node.isInitialized) {
|
|
274
|
+
updateAllAccessoryNames();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// 更新所有配件名称
|
|
279
|
+
function updateAllAccessoryNames() {
|
|
280
|
+
try {
|
|
281
|
+
let updateCount = 0;
|
|
282
|
+
Object.keys(node.accessories).forEach(key => {
|
|
283
|
+
const accessory = node.accessories[key];
|
|
284
|
+
const newName = node.config.relayNames[key] || getDefaultRelayName(accessory._slaveAddr, accessory._coil);
|
|
285
|
+
updateAccessoryName(accessory, newName);
|
|
286
|
+
updateCount++;
|
|
287
|
+
});
|
|
288
|
+
node.log(`已更新 ${updateCount} 个配件名称`);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
node.error(`批量更新配件名称失败: ${err.message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 节点关闭时清理
|
|
295
|
+
node.on('close', function(done) {
|
|
296
|
+
node.log("正在停止HomeKit网桥...");
|
|
297
|
+
|
|
298
|
+
// 移除状态变化监听器
|
|
299
|
+
if (node.stateChangeListener) {
|
|
300
|
+
RED.events.removeListener('modbus:coilStateChanged', node.stateChangeListener);
|
|
301
|
+
node.log("状态变化监听器已移除");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 停止网桥
|
|
305
|
+
if (node.bridge) {
|
|
306
|
+
try {
|
|
307
|
+
node.bridge.unpublish();
|
|
308
|
+
node.log("HomeKit网桥已停止");
|
|
309
|
+
} catch (err) {
|
|
310
|
+
node.warn(`停止网桥时出错: ${err.message}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 清理资源
|
|
315
|
+
node.accessories = {};
|
|
316
|
+
node.bridge = null;
|
|
317
|
+
node.isInitialized = false;
|
|
318
|
+
|
|
319
|
+
done();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// 启动初始化
|
|
323
|
+
initialize();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
RED.nodes.registerType("homekit-bridge", HomekitBridgeNode);
|
|
327
|
+
};
|
|
328
|
+
|
package/nodes/modbus-master.js
CHANGED
|
@@ -147,6 +147,11 @@ module.exports = function(RED) {
|
|
|
147
147
|
node.pollingPausedCount = 0; // 暂停轮询计数器
|
|
148
148
|
node._discoveryPublished = false; // Discovery发布标志(避免重复)
|
|
149
149
|
|
|
150
|
+
// 写入队列机制(确保所有写入操作串行执行,避免锁竞争)
|
|
151
|
+
node.writeQueue = []; // 写入队列
|
|
152
|
+
node.isProcessingWrite = false; // 是否正在处理写入队列
|
|
153
|
+
node.writeQueueInterval = 20; // 写入队列处理间隔(20ms,确保快速响应)
|
|
154
|
+
|
|
150
155
|
// 定期清理机制(每小时清理一次,防止内存泄漏)
|
|
151
156
|
node.cleanupTimer = setInterval(() => {
|
|
152
157
|
// 清理过期的错误日志记录
|
|
@@ -805,7 +810,7 @@ module.exports = function(RED) {
|
|
|
805
810
|
}
|
|
806
811
|
|
|
807
812
|
if (!node.isConnected) {
|
|
808
|
-
node.
|
|
813
|
+
node.log('轮询已停止(Modbus未连接)');
|
|
809
814
|
return;
|
|
810
815
|
}
|
|
811
816
|
|
|
@@ -817,7 +822,7 @@ module.exports = function(RED) {
|
|
|
817
822
|
// 获取当前从站配置(在轮询前获取,确保使用正确的间隔)
|
|
818
823
|
const currentSlave = node.config.slaves[node.currentSlaveIndex];
|
|
819
824
|
if (!currentSlave) {
|
|
820
|
-
node.
|
|
825
|
+
node.log(`从站索引${node.currentSlaveIndex}无效,重置为0`);
|
|
821
826
|
node.currentSlaveIndex = 0;
|
|
822
827
|
// 继续轮询,不要中断
|
|
823
828
|
node.pollTimer = setTimeout(pollLoop, 200);
|
|
@@ -829,8 +834,8 @@ module.exports = function(RED) {
|
|
|
829
834
|
try {
|
|
830
835
|
await node.pollNextSlave();
|
|
831
836
|
} catch (err) {
|
|
832
|
-
//
|
|
833
|
-
node.
|
|
837
|
+
// 捕获所有异常,防止轮询中断(只记录到日志文件,不输出到调试窗口)
|
|
838
|
+
node.log(`轮询异常(已忽略,继续轮询): ${err.message}`);
|
|
834
839
|
}
|
|
835
840
|
|
|
836
841
|
// 移动到下一个从站
|
|
@@ -877,7 +882,7 @@ module.exports = function(RED) {
|
|
|
877
882
|
// 获取当前从站配置
|
|
878
883
|
const slave = node.config.slaves[node.currentSlaveIndex];
|
|
879
884
|
if (!slave) {
|
|
880
|
-
node.
|
|
885
|
+
node.log(`从站索引${node.currentSlaveIndex}无效,重置为0`);
|
|
881
886
|
node.currentSlaveIndex = 0;
|
|
882
887
|
return;
|
|
883
888
|
}
|
|
@@ -993,7 +998,7 @@ module.exports = function(RED) {
|
|
|
993
998
|
|
|
994
999
|
// 确保deviceState存在(防止未定义错误)
|
|
995
1000
|
if (!node.deviceStates[slaveId]) {
|
|
996
|
-
node.
|
|
1001
|
+
node.log(`从站${slaveId}状态未初始化,跳过错误记录`);
|
|
997
1002
|
return;
|
|
998
1003
|
}
|
|
999
1004
|
|
|
@@ -1016,15 +1021,15 @@ module.exports = function(RED) {
|
|
|
1016
1021
|
// 增加超时计数(仅用于统计,不影响轮询)
|
|
1017
1022
|
node.deviceStates[slaveId].timeoutCount++;
|
|
1018
1023
|
|
|
1019
|
-
//
|
|
1024
|
+
// 只记录日志到文件,不输出到调试窗口(避免日志刷屏)
|
|
1020
1025
|
if (shouldLog) {
|
|
1021
|
-
node.
|
|
1026
|
+
node.log(`从站${slaveId}轮询超时: ${err.message} (线圈${slave.coilStart}-${slave.coilEnd}) [连续超时${node.deviceStates[slaveId].timeoutCount}次]`);
|
|
1022
1027
|
node.lastErrorLog[slaveId] = now;
|
|
1023
1028
|
}
|
|
1024
1029
|
} else {
|
|
1025
|
-
// 非超时错误(如CRC
|
|
1030
|
+
// 非超时错误(如CRC错误、总线干扰),记录日志到文件,不输出到调试窗口
|
|
1026
1031
|
if (shouldLog) {
|
|
1027
|
-
node.
|
|
1032
|
+
node.log(`从站${slaveId}轮询失败: ${err.message} (线圈${slave.coilStart}-${slave.coilEnd})`);
|
|
1028
1033
|
node.lastErrorLog[slaveId] = now;
|
|
1029
1034
|
}
|
|
1030
1035
|
}
|
|
@@ -1157,33 +1162,66 @@ module.exports = function(RED) {
|
|
|
1157
1162
|
}
|
|
1158
1163
|
};
|
|
1159
1164
|
|
|
1160
|
-
//
|
|
1161
|
-
node.
|
|
1162
|
-
if (
|
|
1163
|
-
node.warn('Modbus未连接');
|
|
1165
|
+
// 写入队列处理函数(串行执行所有写入操作)
|
|
1166
|
+
node.processWriteQueue = async function() {
|
|
1167
|
+
if (node.isProcessingWrite || node.writeQueue.length === 0) {
|
|
1164
1168
|
return;
|
|
1165
1169
|
}
|
|
1166
1170
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1171
|
+
node.isProcessingWrite = true;
|
|
1172
|
+
|
|
1173
|
+
while (node.writeQueue.length > 0) {
|
|
1174
|
+
const task = node.writeQueue.shift();
|
|
1170
1175
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1176
|
+
try {
|
|
1177
|
+
if (task.type === 'single') {
|
|
1178
|
+
await node._writeSingleCoilInternal(task.slaveId, task.coil, task.value);
|
|
1179
|
+
} else if (task.type === 'multiple') {
|
|
1180
|
+
await node._writeMultipleCoilsInternal(task.slaveId, task.startCoil, task.values);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// 写入成功,调用回调
|
|
1184
|
+
if (task.resolve) {
|
|
1185
|
+
task.resolve();
|
|
1186
|
+
}
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
// 写入失败,调用错误回调
|
|
1189
|
+
if (task.reject) {
|
|
1190
|
+
task.reject(err);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// 等待一段时间再处理下一个任务(20ms间隔,确保总线稳定)
|
|
1195
|
+
if (node.writeQueue.length > 0) {
|
|
1196
|
+
await new Promise(resolve => setTimeout(resolve, node.writeQueueInterval));
|
|
1197
|
+
}
|
|
1178
1198
|
}
|
|
1179
1199
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1200
|
+
node.isProcessingWrite = false;
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
// 写单个线圈(内部实现,不经过队列)
|
|
1204
|
+
node._writeSingleCoilInternal = async function(slaveId, coil, value) {
|
|
1205
|
+
if (!node.isConnected) {
|
|
1206
|
+
throw new Error('Modbus未连接');
|
|
1184
1207
|
}
|
|
1185
|
-
|
|
1208
|
+
|
|
1209
|
+
// 暂停轮询(写操作优先)
|
|
1210
|
+
node.pausePolling = true;
|
|
1211
|
+
const pauseStartTime = Date.now();
|
|
1212
|
+
|
|
1186
1213
|
try {
|
|
1214
|
+
// 等待锁释放(最多等待100ms,因为有队列机制,不需要等太久)
|
|
1215
|
+
const maxWait = 100;
|
|
1216
|
+
const startWait = Date.now();
|
|
1217
|
+
while (node.modbusLock && (Date.now() - startWait) < maxWait) {
|
|
1218
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (node.modbusLock) {
|
|
1222
|
+
throw new Error(`写入线圈等待锁超时: 从站${slaveId} 线圈${coil}`);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1187
1225
|
// 设置锁
|
|
1188
1226
|
node.modbusLock = true;
|
|
1189
1227
|
|
|
@@ -1201,7 +1239,7 @@ module.exports = function(RED) {
|
|
|
1201
1239
|
node.deviceStates[slaveId].coils[coil] = value;
|
|
1202
1240
|
|
|
1203
1241
|
node.log(`写入成功: 从站${slaveId} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
1204
|
-
|
|
1242
|
+
|
|
1205
1243
|
// 发布到MQTT和触发事件
|
|
1206
1244
|
node.publishMqttState(slaveId, coil, value);
|
|
1207
1245
|
node.emit('stateUpdate', {
|
|
@@ -1209,11 +1247,11 @@ module.exports = function(RED) {
|
|
|
1209
1247
|
coil: coil,
|
|
1210
1248
|
value: value
|
|
1211
1249
|
});
|
|
1212
|
-
|
|
1250
|
+
|
|
1213
1251
|
// 释放锁
|
|
1214
1252
|
node.modbusLock = false;
|
|
1215
|
-
|
|
1216
|
-
//
|
|
1253
|
+
|
|
1254
|
+
// 延迟恢复轮询(给从站响应预留时间,减少到20ms)
|
|
1217
1255
|
setTimeout(() => {
|
|
1218
1256
|
node.pausePolling = false;
|
|
1219
1257
|
const pauseDuration = Date.now() - pauseStartTime;
|
|
@@ -1221,56 +1259,70 @@ module.exports = function(RED) {
|
|
|
1221
1259
|
node.debug(`轮询暂停 ${pauseDuration}ms,跳过 ${node.pollingPausedCount} 次轮询`);
|
|
1222
1260
|
node.pollingPausedCount = 0;
|
|
1223
1261
|
}
|
|
1224
|
-
},
|
|
1225
|
-
|
|
1262
|
+
}, 20);
|
|
1263
|
+
|
|
1226
1264
|
} catch (err) {
|
|
1227
1265
|
// 释放锁
|
|
1228
1266
|
node.modbusLock = false;
|
|
1229
|
-
|
|
1267
|
+
|
|
1230
1268
|
// 恢复轮询
|
|
1231
1269
|
node.pausePolling = false;
|
|
1232
1270
|
node.pollingPausedCount = 0;
|
|
1233
|
-
|
|
1271
|
+
|
|
1234
1272
|
node.error(`写入线圈失败: 从站${slaveId} 线圈${coil} - ${err.message}`);
|
|
1235
|
-
throw err;
|
|
1273
|
+
throw err;
|
|
1236
1274
|
}
|
|
1237
1275
|
};
|
|
1276
|
+
|
|
1277
|
+
// 写单个线圈(公共接口,通过队列执行)
|
|
1278
|
+
node.writeSingleCoil = function(slaveId, coil, value) {
|
|
1279
|
+
return new Promise((resolve, reject) => {
|
|
1280
|
+
// 添加到队列
|
|
1281
|
+
node.writeQueue.push({
|
|
1282
|
+
type: 'single',
|
|
1283
|
+
slaveId: slaveId,
|
|
1284
|
+
coil: coil,
|
|
1285
|
+
value: value,
|
|
1286
|
+
resolve: resolve,
|
|
1287
|
+
reject: reject
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
// 触发队列处理
|
|
1291
|
+
node.processWriteQueue();
|
|
1292
|
+
});
|
|
1293
|
+
};
|
|
1238
1294
|
|
|
1239
|
-
//
|
|
1240
|
-
node.
|
|
1295
|
+
// 批量写入多个线圈(内部实现,不经过队列)
|
|
1296
|
+
node._writeMultipleCoilsInternal = async function(slaveId, startCoil, values) {
|
|
1241
1297
|
if (!node.isConnected) {
|
|
1242
|
-
|
|
1243
|
-
return;
|
|
1298
|
+
throw new Error('Modbus未连接');
|
|
1244
1299
|
}
|
|
1245
|
-
|
|
1300
|
+
|
|
1246
1301
|
// 暂停轮询(从站上报优先处理)
|
|
1247
1302
|
node.pausePolling = true;
|
|
1248
1303
|
const pauseStartTime = Date.now();
|
|
1249
|
-
|
|
1250
|
-
// 等待锁释放(最多等待500ms)
|
|
1251
|
-
const maxWait = 500;
|
|
1252
|
-
const startWait = Date.now();
|
|
1253
|
-
while (node.modbusLock && (Date.now() - startWait) < maxWait) {
|
|
1254
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
if (node.modbusLock) {
|
|
1258
|
-
node.error(`批量写入线圈超时: 从站${slaveId} 起始线圈${startCoil} (等待锁释放超时)`);
|
|
1259
|
-
// 恢复轮询
|
|
1260
|
-
node.pausePolling = false;
|
|
1261
|
-
return;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1304
|
+
|
|
1264
1305
|
try {
|
|
1306
|
+
// 等待锁释放(最多等待100ms)
|
|
1307
|
+
const maxWait = 100;
|
|
1308
|
+
const startWait = Date.now();
|
|
1309
|
+
while (node.modbusLock && (Date.now() - startWait) < maxWait) {
|
|
1310
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (node.modbusLock) {
|
|
1314
|
+
throw new Error(`批量写入线圈等待锁超时: 从站${slaveId} 起始线圈${startCoil}`);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1265
1317
|
// 设置锁
|
|
1266
1318
|
node.modbusLock = true;
|
|
1267
|
-
|
|
1319
|
+
|
|
1268
1320
|
node.client.setID(slaveId);
|
|
1269
1321
|
await node.client.writeCoils(startCoil, values);
|
|
1270
|
-
|
|
1322
|
+
|
|
1271
1323
|
// 记录写入时间(用于暂停轮询)
|
|
1272
1324
|
node.lastWriteTime[slaveId] = Date.now();
|
|
1273
|
-
|
|
1325
|
+
|
|
1274
1326
|
// 更新本地状态
|
|
1275
1327
|
for (let i = 0; i < values.length; i++) {
|
|
1276
1328
|
node.deviceStates[slaveId].coils[startCoil + i] = values[i];
|
|
@@ -1282,13 +1334,13 @@ module.exports = function(RED) {
|
|
|
1282
1334
|
value: values[i]
|
|
1283
1335
|
});
|
|
1284
1336
|
}
|
|
1285
|
-
|
|
1337
|
+
|
|
1286
1338
|
node.debug(`批量写入成功: 从站${slaveId} 起始线圈${startCoil} 共${values.length}个线圈`);
|
|
1287
|
-
|
|
1339
|
+
|
|
1288
1340
|
// 释放锁
|
|
1289
1341
|
node.modbusLock = false;
|
|
1290
|
-
|
|
1291
|
-
//
|
|
1342
|
+
|
|
1343
|
+
// 延迟恢复轮询(给从站响应预留时间,减少到20ms)
|
|
1292
1344
|
setTimeout(() => {
|
|
1293
1345
|
node.pausePolling = false;
|
|
1294
1346
|
const pauseDuration = Date.now() - pauseStartTime;
|
|
@@ -1296,20 +1348,38 @@ module.exports = function(RED) {
|
|
|
1296
1348
|
node.debug(`轮询暂停 ${pauseDuration}ms,跳过 ${node.pollingPausedCount} 次轮询`);
|
|
1297
1349
|
node.pollingPausedCount = 0;
|
|
1298
1350
|
}
|
|
1299
|
-
},
|
|
1300
|
-
|
|
1351
|
+
}, 20);
|
|
1352
|
+
|
|
1301
1353
|
} catch (err) {
|
|
1302
1354
|
// 释放锁
|
|
1303
1355
|
node.modbusLock = false;
|
|
1304
|
-
|
|
1356
|
+
|
|
1305
1357
|
// 恢复轮询
|
|
1306
1358
|
node.pausePolling = false;
|
|
1307
1359
|
node.pollingPausedCount = 0;
|
|
1308
|
-
|
|
1360
|
+
|
|
1309
1361
|
node.error(`批量写入线圈失败: 从站${slaveId} 起始线圈${startCoil} - ${err.message}`);
|
|
1310
|
-
throw err;
|
|
1362
|
+
throw err;
|
|
1311
1363
|
}
|
|
1312
1364
|
};
|
|
1365
|
+
|
|
1366
|
+
// 批量写入多个线圈(公共接口,通过队列执行)
|
|
1367
|
+
node.writeMultipleCoils = function(slaveId, startCoil, values) {
|
|
1368
|
+
return new Promise((resolve, reject) => {
|
|
1369
|
+
// 添加到队列
|
|
1370
|
+
node.writeQueue.push({
|
|
1371
|
+
type: 'multiple',
|
|
1372
|
+
slaveId: slaveId,
|
|
1373
|
+
startCoil: startCoil,
|
|
1374
|
+
values: values,
|
|
1375
|
+
resolve: resolve,
|
|
1376
|
+
reject: reject
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
// 触发队列处理
|
|
1380
|
+
node.processWriteQueue();
|
|
1381
|
+
});
|
|
1382
|
+
};
|
|
1313
1383
|
|
|
1314
1384
|
// 监听内部事件(从站开关节点发送的写入命令)
|
|
1315
1385
|
// 这是免连线通信的核心机制
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-modbus",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant
|
|
3
|
+
"version": "2.7.0",
|
|
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"
|
|
@@ -37,12 +37,15 @@
|
|
|
37
37
|
"modbus-server-config": "nodes/modbus-server-config.js",
|
|
38
38
|
"mqtt-server-config": "nodes/mqtt-server-config.js",
|
|
39
39
|
"serial-port-config": "nodes/serial-port-config.js",
|
|
40
|
-
"modbus-debug": "nodes/modbus-debug.js"
|
|
40
|
+
"modbus-debug": "nodes/modbus-debug.js",
|
|
41
|
+
"homekit-bridge": "nodes/homekit-bridge.js"
|
|
41
42
|
}
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
45
|
+
"hap-nodejs": "^1.2.0",
|
|
44
46
|
"modbus-serial": "^8.0.23",
|
|
45
47
|
"mqtt": "^5.14.1",
|
|
48
|
+
"node-persist": "^4.0.4",
|
|
46
49
|
"serialport": "^12.0.0"
|
|
47
50
|
},
|
|
48
51
|
"repository": {
|