node-red-contrib-symi-modbus 2.8.0 → 2.8.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 +40 -25
- package/RELEASE_CHECKLIST.md +99 -0
- package/nodes/custom-protocol.html +1 -1
- package/nodes/homekit-bridge.html +1 -1
- package/nodes/mesh-protocol.js +43 -22
- package/nodes/modbus-dashboard.html +1 -1
- package/nodes/modbus-debug.html +1 -1
- package/nodes/modbus-master.html +1 -1
- package/nodes/modbus-master.js +6 -5
- package/nodes/modbus-slave-switch.html +4 -5
- package/nodes/modbus-slave-switch.js +57 -5
- package/nodes/serial-port-config.js +66 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -251,8 +251,8 @@ node-red-restart
|
|
|
251
251
|
1. **HomeKit编组群控**:
|
|
252
252
|
- 在HomeKit中创建房间或编组,可同时控制多个继电器
|
|
253
253
|
- 例如:创建"客厅"编组,包含10个灯光开关,一键全开/全关
|
|
254
|
-
-
|
|
255
|
-
- 10个继电器同时动作仅需约
|
|
254
|
+
- 智能优先队列机制确保触发源面板最快响应,其他继电器按序执行
|
|
255
|
+
- 10个继电器同时动作仅需约400ms(40ms间隔×10),触发源面板优先反馈
|
|
256
256
|
|
|
257
257
|
2. **场景联动**:
|
|
258
258
|
- 支持HomeKit场景(如"回家模式"、"离家模式")
|
|
@@ -267,7 +267,8 @@ node-red-restart
|
|
|
267
267
|
- 长期稳定运行,反复控制不会造成内存增加、卡顿或死机
|
|
268
268
|
|
|
269
269
|
4. **技术细节**:
|
|
270
|
-
- 写入队列间隔:
|
|
270
|
+
- 写入队列间隔:40ms(确保总线稳定,优化指示灯反馈)
|
|
271
|
+
- 智能优先队列:触发源面板优先处理(500ms优先窗口)
|
|
271
272
|
- 轮询恢复时间:20ms(快速响应)
|
|
272
273
|
- 锁等待超时:100ms(快速检测异常)
|
|
273
274
|
- 队列自动处理,无需手动干预
|
|
@@ -883,43 +884,57 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
883
884
|
|
|
884
885
|
## 版本信息
|
|
885
886
|
|
|
886
|
-
**当前版本**: v2.8.
|
|
887
|
+
**当前版本**: v2.8.2
|
|
887
888
|
|
|
888
|
-
### v2.8.
|
|
889
|
+
### v2.8.2 (2025-11-11)
|
|
889
890
|
|
|
890
|
-
|
|
891
|
-
-
|
|
892
|
-
-
|
|
893
|
-
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
-
|
|
900
|
-
-
|
|
891
|
+
**核心功能**:
|
|
892
|
+
- **Mesh开关完整支持**:支持Symi蓝牙Mesh网关和1-6路Mesh开关,实现双向状态同步
|
|
893
|
+
- 全局共享状态管理:同一Mesh设备的多个按钮节点共享状态数组,确保LED反馈正确
|
|
894
|
+
- Mesh控制帧格式:完全兼容Symi Mesh协议,支持1/2/3/4/6路开关
|
|
895
|
+
- 设备持久化:Mesh设备列表自动保存,重启无需重新扫描
|
|
896
|
+
- LED状态同步:继电器状态变化时自动同步到Mesh开关LED指示灯
|
|
897
|
+
- **彻底解决Mesh开关LED不跟随继电器状态同步的问题**
|
|
898
|
+
|
|
899
|
+
**重要修复**:
|
|
900
|
+
- 修复Mesh LED反馈状态不同步问题:引入全局共享状态Map,所有按钮节点共享同一个状态数组
|
|
901
|
+
- 修复Mesh控制帧格式错误:移除多余参数,修正长度字段,确保与Mesh网关协议一致
|
|
902
|
+
- 修复部署后LED指示灯不同步问题:主站首次轮询时主动广播所有线圈状态
|
|
903
|
+
- 优化初始化逻辑:从站开关节点无需查询状态,主站节点主动推送,更加高效可靠
|
|
904
|
+
|
|
905
|
+
**稳定性增强**:
|
|
906
|
+
- 完整的资源清理:所有定时器、监听器、连接在节点关闭时正确释放,防止内存泄漏
|
|
907
|
+
- 断网重连机制:TCP连接断开后自动重连,不影响本地串口通信
|
|
908
|
+
- 防抖机制:避免重复处理同一按键事件,防止死循环
|
|
909
|
+
- 全局队列优化:40ms间隔 + 智能优先队列,确保长期稳定流畅运行
|
|
910
|
+
- 串行发送:所有LED反馈按优先级和加入顺序依次发送,间隔40ms(TCP和串口统一)
|
|
911
|
+
- 去重机制:每个节点50ms内不重复发送相同状态,避免总线拥堵
|
|
901
912
|
|
|
902
913
|
**典型场景说明**:
|
|
903
914
|
1. **场景1:单个继电器控制**
|
|
904
|
-
- 按下开关 → 继电器动作 → LED
|
|
915
|
+
- 按下开关 → 设置触发源 → 继电器动作 → LED反馈优先加入队列 → 40ms后发送
|
|
916
|
+
- 该面板的LED反馈会优先处理,响应最快
|
|
905
917
|
|
|
906
918
|
2. **场景2:批量控制(如17通道全开,16个继电器)**
|
|
907
|
-
- 触发17通道 → 1-16路继电器全开
|
|
919
|
+
- 触发17通道 → 设置触发源(操作的面板) → 1-16路继电器全开
|
|
908
920
|
- 假设每个继电器绑定2个开关面板 = 32个LED反馈
|
|
909
|
-
-
|
|
921
|
+
- 触发源面板的LED反馈优先处理(最快响应)
|
|
922
|
+
- 其他面板按加入队列的顺序依次发送,耗时约1280ms(40ms×32)
|
|
910
923
|
|
|
911
924
|
3. **场景3:快速切换(如17通道全开后立即18通道全关)**
|
|
912
|
-
- 17通道触发 → 1-16路全开 →
|
|
913
|
-
- 18通道触发 → 1-16路全关 →
|
|
914
|
-
-
|
|
925
|
+
- 17通道触发 → 设置触发源 → 1-16路全开 → 触发源面板优先反馈
|
|
926
|
+
- 18通道触发 → 更新触发源 → 1-16路全关 → 新触发源面板优先反馈
|
|
927
|
+
- 全局队列按优先级和顺序处理,确保所有面板最终显示正确状态
|
|
915
928
|
|
|
916
929
|
4. **场景4:双控开关(1个继电器绑定2个不同ID的开关面板)**
|
|
917
930
|
- 继电器状态变化 → 2个LED反馈加入全局队列
|
|
918
|
-
-
|
|
931
|
+
- 如果是其中一个面板触发的,该面板优先反馈
|
|
932
|
+
- 另一个面板按顺序发送,间隔40ms
|
|
919
933
|
|
|
920
934
|
5. **场景5:大规模部署(50个8键开关 = 400个按键)**
|
|
921
|
-
- 批量控制触发 → 所有LED反馈加入全局队列
|
|
922
|
-
-
|
|
935
|
+
- 批量控制触发 → 设置触发源 → 所有LED反馈加入全局队列
|
|
936
|
+
- 触发源面板优先反馈(用户最快看到响应)
|
|
937
|
+
- 其他面板按部署时的节点顺序依次发送,耗时约16秒(40ms×400)
|
|
923
938
|
- 确保总线稳定,不会冲突或丢失
|
|
924
939
|
|
|
925
940
|
## 许可证
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# v2.8.2 发布前检查清单
|
|
2
|
+
|
|
3
|
+
## 代码质量检查
|
|
4
|
+
|
|
5
|
+
### 1. 资源管理 ✅
|
|
6
|
+
- [x] 所有定时器在节点关闭时正确清理(clearTimeout/clearInterval)
|
|
7
|
+
- [x] 所有事件监听器在节点关闭时正确移除(removeListener/removeAllListeners)
|
|
8
|
+
- [x] 所有连接在节点关闭时正确关闭(TCP/串口/MQTT)
|
|
9
|
+
- [x] 全局缓存合理管理,不会无限增长
|
|
10
|
+
|
|
11
|
+
### 2. 内存泄漏防护 ✅
|
|
12
|
+
- [x] modbus-slave-switch.js: 完整的close处理
|
|
13
|
+
- [x] modbus-master.js: 完整的close处理
|
|
14
|
+
- [x] serial-port-config.js: 完整的close处理
|
|
15
|
+
- [x] 全局Map使用合理(globalDebounceCache, meshDeviceStates)
|
|
16
|
+
|
|
17
|
+
### 3. 稳定性保障 ✅
|
|
18
|
+
- [x] 断网重连机制:TCP连接断开后自动重连
|
|
19
|
+
- [x] 防抖机制:避免重复处理同一按键事件(200ms防抖)
|
|
20
|
+
- [x] 队列机制:40ms间隔串行发送,防止总线冲突
|
|
21
|
+
- [x] 去重机制:50ms内不重复发送相同状态
|
|
22
|
+
- [x] 初始化保护:部署期间不发送LED反馈
|
|
23
|
+
|
|
24
|
+
### 4. Mesh功能完整性 ✅
|
|
25
|
+
- [x] 全局共享状态管理:meshDeviceStates Map
|
|
26
|
+
- [x] Mesh控制帧格式正确:移除多余参数,长度字段正确
|
|
27
|
+
- [x] 设备持久化:自动保存和加载Mesh设备列表
|
|
28
|
+
- [x] LED状态同步:继电器变化时自动同步到Mesh开关
|
|
29
|
+
- [x] 支持1/2/3/4/6路Mesh开关
|
|
30
|
+
|
|
31
|
+
### 5. 错误处理 ✅
|
|
32
|
+
- [x] 所有异步操作都有try-catch
|
|
33
|
+
- [x] 连接失败时有重连机制
|
|
34
|
+
- [x] 错误日志清晰,便于排查问题
|
|
35
|
+
|
|
36
|
+
## 文档完整性检查
|
|
37
|
+
|
|
38
|
+
### 1. README.md ✅
|
|
39
|
+
- [x] 版本号更新为v2.8.2
|
|
40
|
+
- [x] 只保留最新版本的更新日志
|
|
41
|
+
- [x] Mesh开关配置说明完整
|
|
42
|
+
- [x] 安装步骤清晰
|
|
43
|
+
- [x] 使用示例完整
|
|
44
|
+
|
|
45
|
+
### 2. package.json ✅
|
|
46
|
+
- [x] 版本号更新为2.8.2
|
|
47
|
+
- [x] 依赖版本正确
|
|
48
|
+
- [x] 关键词完整
|
|
49
|
+
- [x] 仓库信息正确
|
|
50
|
+
|
|
51
|
+
## 功能测试
|
|
52
|
+
|
|
53
|
+
### 1. 基础功能 ✅
|
|
54
|
+
- [x] Modbus主站轮询正常
|
|
55
|
+
- [x] RS-485从站开关按键触发正常
|
|
56
|
+
- [x] LED反馈正常
|
|
57
|
+
|
|
58
|
+
### 2. Mesh功能 ✅
|
|
59
|
+
- [x] Mesh设备扫描正常
|
|
60
|
+
- [x] Mesh按键触发继电器正常
|
|
61
|
+
- [x] 继电器状态变化同步到Mesh LED正常
|
|
62
|
+
- [x] 无死循环问题
|
|
63
|
+
|
|
64
|
+
### 3. 稳定性测试 ✅
|
|
65
|
+
- [x] 长时间运行不卡顿
|
|
66
|
+
- [x] 断网重连正常
|
|
67
|
+
- [x] 重启Node-RED后状态恢复正常
|
|
68
|
+
- [x] 无内存泄漏
|
|
69
|
+
|
|
70
|
+
## 发布步骤
|
|
71
|
+
|
|
72
|
+
1. ✅ 确认所有检查项通过
|
|
73
|
+
2. ✅ 更新版本号(package.json和README.md)
|
|
74
|
+
3. ⏳ 提交代码到Git
|
|
75
|
+
4. ⏳ 发布到npm
|
|
76
|
+
5. ⏳ 创建GitHub Release
|
|
77
|
+
|
|
78
|
+
## 发布命令
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# 1. 提交代码
|
|
82
|
+
git add .
|
|
83
|
+
git commit -m "Release v2.8.2: Mesh开关完整支持,修复LED同步问题"
|
|
84
|
+
git push origin main
|
|
85
|
+
|
|
86
|
+
# 2. 发布到npm
|
|
87
|
+
npm publish
|
|
88
|
+
|
|
89
|
+
# 3. 创建Git标签
|
|
90
|
+
git tag v2.8.2
|
|
91
|
+
git push origin v2.8.2
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 发布后验证
|
|
95
|
+
|
|
96
|
+
- [ ] npm上版本号正确
|
|
97
|
+
- [ ] 安装测试正常
|
|
98
|
+
- [ ] 文档显示正确
|
|
99
|
+
|
package/nodes/mesh-protocol.js
CHANGED
|
@@ -114,17 +114,19 @@ module.exports = function(RED) {
|
|
|
114
114
|
if (totalButtons === 1) {
|
|
115
115
|
// 单路开关
|
|
116
116
|
stateValue = state ? 0x02 : 0x01;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
PROTOCOL.OP_DEVICE_CONTROL,
|
|
120
|
-
0x05, // Length
|
|
117
|
+
// 帧格式:[53] [30] [长度] [地址低] [地址高] [消息类型0x02] [状态值] [校验]
|
|
118
|
+
const data = Buffer.from([
|
|
121
119
|
shortAddr & 0xFF,
|
|
122
120
|
(shortAddr >> 8) & 0xFF,
|
|
123
|
-
0x00, // ACK = 0
|
|
124
|
-
0x05, // 重传5次
|
|
125
121
|
PROTOCOL.MSG_TYPE_SWITCH,
|
|
126
122
|
stateValue
|
|
127
123
|
]);
|
|
124
|
+
const buffer = Buffer.from([
|
|
125
|
+
PROTOCOL.HEADER,
|
|
126
|
+
PROTOCOL.OP_DEVICE_CONTROL,
|
|
127
|
+
data.length, // Length = 4
|
|
128
|
+
...data
|
|
129
|
+
]);
|
|
128
130
|
const checksum = calculateChecksum(buffer);
|
|
129
131
|
return Buffer.concat([buffer, Buffer.from([checksum])]);
|
|
130
132
|
} else if (totalButtons >= 2 && totalButtons <= 4) {
|
|
@@ -132,22 +134,34 @@ module.exports = function(RED) {
|
|
|
132
134
|
if (currentStates && currentStates.length === totalButtons) {
|
|
133
135
|
stateValue = buildMultiSwitchState(currentStates, buttonNumber, state);
|
|
134
136
|
} else {
|
|
135
|
-
//
|
|
137
|
+
// 如果没有当前状态,使用默认全关状态,然后修改目标按钮
|
|
138
|
+
const defaultStates = {
|
|
139
|
+
2: 0x05, // 01 01 (2路全关)
|
|
140
|
+
3: 0x15, // 01 01 01 (3路全关)
|
|
141
|
+
4: 0x55 // 01 01 01 01 (4路全关)
|
|
142
|
+
};
|
|
143
|
+
stateValue = defaultStates[totalButtons] || 0x55;
|
|
144
|
+
|
|
145
|
+
// 修改目标按钮的状态
|
|
136
146
|
const bitPos = (buttonNumber - 1) * 2;
|
|
137
|
-
|
|
147
|
+
const mask = ~(0x03 << bitPos); // 清除目标位
|
|
148
|
+
const newBits = (state ? 0x02 : 0x01) << bitPos; // 新状态位
|
|
149
|
+
stateValue = (stateValue & mask) | newBits;
|
|
138
150
|
}
|
|
139
151
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
PROTOCOL.OP_DEVICE_CONTROL,
|
|
143
|
-
0x05, // Length
|
|
152
|
+
// 帧格式:[53] [30] [长度] [地址低] [地址高] [消息类型0x02] [状态值] [校验]
|
|
153
|
+
const data = Buffer.from([
|
|
144
154
|
shortAddr & 0xFF,
|
|
145
155
|
(shortAddr >> 8) & 0xFF,
|
|
146
|
-
0x00, // ACK = 0
|
|
147
|
-
0x05, // 重传5次
|
|
148
156
|
PROTOCOL.MSG_TYPE_SWITCH,
|
|
149
157
|
stateValue
|
|
150
158
|
]);
|
|
159
|
+
const buffer = Buffer.from([
|
|
160
|
+
PROTOCOL.HEADER,
|
|
161
|
+
PROTOCOL.OP_DEVICE_CONTROL,
|
|
162
|
+
data.length, // Length = 4
|
|
163
|
+
...data
|
|
164
|
+
]);
|
|
151
165
|
const checksum = calculateChecksum(buffer);
|
|
152
166
|
return Buffer.concat([buffer, Buffer.from([checksum])]);
|
|
153
167
|
} else if (totalButtons === 6) {
|
|
@@ -156,23 +170,30 @@ module.exports = function(RED) {
|
|
|
156
170
|
if (currentStates && currentStates.length === totalButtons) {
|
|
157
171
|
stateValue16 = buildMultiSwitchState(currentStates, buttonNumber, state);
|
|
158
172
|
} else {
|
|
159
|
-
//
|
|
173
|
+
// 如果没有当前状态,使用默认全关状态(0x5555),然后修改目标按钮
|
|
174
|
+
stateValue16 = 0x5555; // 6路全关
|
|
175
|
+
|
|
176
|
+
// 修改目标按钮的状态
|
|
160
177
|
const bitPos = (buttonNumber - 1) * 2;
|
|
161
|
-
|
|
178
|
+
const mask = ~(0x03 << bitPos); // 清除目标位
|
|
179
|
+
const newBits = (state ? 0x02 : 0x01) << bitPos; // 新状态位
|
|
180
|
+
stateValue16 = (stateValue16 & mask) | newBits;
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
PROTOCOL.OP_DEVICE_CONTROL,
|
|
167
|
-
0x06, // Length
|
|
183
|
+
// 帧格式:[53] [30] [长度] [地址低] [地址高] [消息类型0x02] [状态值低] [状态值高] [校验]
|
|
184
|
+
const data = Buffer.from([
|
|
168
185
|
shortAddr & 0xFF,
|
|
169
186
|
(shortAddr >> 8) & 0xFF,
|
|
170
|
-
0x00, // ACK = 0
|
|
171
|
-
0x05, // 重传5次
|
|
172
187
|
PROTOCOL.MSG_TYPE_SWITCH,
|
|
173
188
|
stateValue16 & 0xFF,
|
|
174
189
|
(stateValue16 >> 8) & 0xFF
|
|
175
190
|
]);
|
|
191
|
+
const buffer = Buffer.from([
|
|
192
|
+
PROTOCOL.HEADER,
|
|
193
|
+
PROTOCOL.OP_DEVICE_CONTROL,
|
|
194
|
+
data.length, // Length = 5
|
|
195
|
+
...data
|
|
196
|
+
]);
|
|
176
197
|
const checksum = calculateChecksum(buffer);
|
|
177
198
|
return Buffer.concat([buffer, Buffer.from([checksum])]);
|
|
178
199
|
}
|
package/nodes/modbus-debug.html
CHANGED
package/nodes/modbus-master.html
CHANGED
package/nodes/modbus-master.js
CHANGED
|
@@ -150,7 +150,7 @@ module.exports = function(RED) {
|
|
|
150
150
|
// 写入队列机制(确保所有写入操作串行执行,避免锁竞争)
|
|
151
151
|
node.writeQueue = []; // 写入队列
|
|
152
152
|
node.isProcessingWrite = false; // 是否正在处理写入队列
|
|
153
|
-
node.writeQueueInterval =
|
|
153
|
+
node.writeQueueInterval = 40; // 写入队列处理间隔(40ms,确保总线稳定,避免数据丢失)
|
|
154
154
|
|
|
155
155
|
// 定期清理机制(每小时清理一次,防止内存泄漏)
|
|
156
156
|
node.cleanupTimer = setInterval(() => {
|
|
@@ -946,13 +946,14 @@ module.exports = function(RED) {
|
|
|
946
946
|
});
|
|
947
947
|
|
|
948
948
|
// 广播状态变化事件(用于LED反馈)
|
|
949
|
-
//
|
|
950
|
-
|
|
949
|
+
// 首次轮询:广播所有线圈状态(让从站开关节点初始化LED)
|
|
950
|
+
// 后续轮询:只在状态真正改变时广播
|
|
951
|
+
if (isFirstPoll || oldValue !== newValue) {
|
|
951
952
|
RED.events.emit('modbus:coilStateChanged', {
|
|
952
953
|
slave: slaveId,
|
|
953
954
|
coil: coilIndex,
|
|
954
955
|
value: newValue,
|
|
955
|
-
source: 'polling'
|
|
956
|
+
source: isFirstPoll ? 'init' : 'polling'
|
|
956
957
|
});
|
|
957
958
|
}
|
|
958
959
|
}
|
|
@@ -1197,7 +1198,7 @@ module.exports = function(RED) {
|
|
|
1197
1198
|
node.warn(`队列任务失败,继续处理下一个任务: ${err.message}`);
|
|
1198
1199
|
}
|
|
1199
1200
|
|
|
1200
|
-
// 等待一段时间再处理下一个任务(
|
|
1201
|
+
// 等待一段时间再处理下一个任务(40ms间隔,确保总线稳定)
|
|
1201
1202
|
if (node.writeQueue.length > 0) {
|
|
1202
1203
|
await new Promise(resolve => setTimeout(resolve, node.writeQueueInterval));
|
|
1203
1204
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script type="text/javascript">
|
|
2
2
|
RED.nodes.registerType('modbus-slave-switch', {
|
|
3
|
-
category: 'SYMI
|
|
3
|
+
category: 'SYMI MODBUS',
|
|
4
4
|
color: '#E9967A',
|
|
5
5
|
defaults: {
|
|
6
6
|
name: {value: "从站开关"},
|
|
@@ -179,10 +179,9 @@
|
|
|
179
179
|
$("#node-input-enableMqtt").trigger("change");
|
|
180
180
|
$("#node-input-buttonType").trigger("change");
|
|
181
181
|
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
182
|
+
// 始终加载已保存的Mesh设备列表(无论当前是什么模式)
|
|
183
|
+
// 这样切换到Mesh模式时,列表已经加载好了
|
|
184
|
+
loadSavedMeshDevices();
|
|
186
185
|
}
|
|
187
186
|
});
|
|
188
187
|
</script>
|
|
@@ -9,6 +9,10 @@ module.exports = function(RED) {
|
|
|
9
9
|
|
|
10
10
|
// 全局防抖缓存:防止多个节点重复处理同一个按键事件
|
|
11
11
|
const globalDebounceCache = new Map(); // key: "switchId-buttonNumber", value: timestamp
|
|
12
|
+
|
|
13
|
+
// 全局Mesh设备状态缓存(按短地址索引)
|
|
14
|
+
// 用于同一个Mesh设备的多个按钮节点共享状态数组
|
|
15
|
+
const meshDeviceStates = new Map(); // key: meshShortAddress, value: [state1, state2, ...]
|
|
12
16
|
|
|
13
17
|
// 初始化Mesh设备持久化存储
|
|
14
18
|
const meshPersistDir = path.join(RED.settings.userDir || os.homedir() + '/.node-red', 'mesh-devices-persist');
|
|
@@ -549,6 +553,19 @@ module.exports = function(RED) {
|
|
|
549
553
|
node.lastStateChange.timestamp = Date.now();
|
|
550
554
|
node.lastStateChange.value = value;
|
|
551
555
|
|
|
556
|
+
// Mesh模式:更新全局共享状态缓存(用于构建正确的控制帧)
|
|
557
|
+
if (node.config.buttonType === 'mesh') {
|
|
558
|
+
const meshAddr = node.config.meshShortAddress;
|
|
559
|
+
if (!meshDeviceStates.has(meshAddr)) {
|
|
560
|
+
// 初始化状态数组(全部设为null,表示未知状态)
|
|
561
|
+
meshDeviceStates.set(meshAddr, new Array(node.config.meshTotalButtons).fill(null));
|
|
562
|
+
}
|
|
563
|
+
// 更新对应按钮的状态
|
|
564
|
+
const states = meshDeviceStates.get(meshAddr);
|
|
565
|
+
states[node.config.meshButtonNumber - 1] = value;
|
|
566
|
+
node.debug(`[Mesh状态更新] 设备${meshAddr} 按钮${node.config.meshButtonNumber} = ${value}, 完整状态=${JSON.stringify(states)}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
552
569
|
// 更新节点状态显示
|
|
553
570
|
node.updateStatus();
|
|
554
571
|
|
|
@@ -634,6 +651,11 @@ module.exports = function(RED) {
|
|
|
634
651
|
}
|
|
635
652
|
globalDebounceCache.set(debounceKey, now);
|
|
636
653
|
|
|
654
|
+
// 设置触发源(用于优先队列)
|
|
655
|
+
if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
|
|
656
|
+
node.serialPortConfig.setTriggerSource(node.config.switchId);
|
|
657
|
+
}
|
|
658
|
+
|
|
637
659
|
if (isSceneMode) {
|
|
638
660
|
node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
|
|
639
661
|
// 场景模式:切换状态(每次触发时翻转)
|
|
@@ -692,8 +714,21 @@ module.exports = function(RED) {
|
|
|
692
714
|
}
|
|
693
715
|
globalDebounceCache.set(debounceKey, now);
|
|
694
716
|
|
|
695
|
-
//
|
|
696
|
-
|
|
717
|
+
// 更新全局共享状态缓存(用于后续控制时保持其他路不变)
|
|
718
|
+
const meshAddr = node.config.meshShortAddress;
|
|
719
|
+
meshDeviceStates.set(meshAddr, event.states);
|
|
720
|
+
node.debug(`[Mesh按键] 设备${meshAddr} 状态更新=${JSON.stringify(event.states)}`);
|
|
721
|
+
|
|
722
|
+
// 初始化期间不发送控制命令(避免重启时Mesh开关状态覆盖继电器状态)
|
|
723
|
+
if (node.isInitializing) {
|
|
724
|
+
node.debug(`初始化期间忽略Mesh按键事件:MAC=${node.config.meshMacAddress} 按键${node.config.meshButtonNumber}`);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 设置触发源(用于优先队列)- Mesh使用短地址作为switchId
|
|
729
|
+
if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
|
|
730
|
+
node.serialPortConfig.setTriggerSource(node.config.meshShortAddress);
|
|
731
|
+
}
|
|
697
732
|
|
|
698
733
|
// 发送命令到继电器
|
|
699
734
|
node.debug(`Mesh开关${buttonState ? 'ON' : 'OFF'}: MAC=${node.config.meshMacAddress} 按键${node.config.meshButtonNumber}`);
|
|
@@ -791,17 +826,21 @@ module.exports = function(RED) {
|
|
|
791
826
|
// 发送控制指令到物理开关面板(控制指示灯等)
|
|
792
827
|
// 直接发送到全局队列,由serial-port-config统一管理(20ms间隔串行发送)
|
|
793
828
|
node.sendCommandToPanel = function(state) {
|
|
829
|
+
node.debug(`[sendCommandToPanel] 被调用:buttonType=${node.config.buttonType} state=${state} isInitializing=${node.isInitializing}`);
|
|
830
|
+
|
|
794
831
|
// 检查连接状态
|
|
795
832
|
if (!node.serialPortConfig || !node.serialPortConfig.connection) {
|
|
796
833
|
// 初始化期间静默警告
|
|
797
834
|
if (!node.isInitializing) {
|
|
798
835
|
node.warn('RS-485连接未建立,无法发送指示灯反馈');
|
|
799
836
|
}
|
|
837
|
+
node.debug(`[sendCommandToPanel] 连接未建立,退出`);
|
|
800
838
|
return;
|
|
801
839
|
}
|
|
802
840
|
|
|
803
841
|
// 初始化期间不发送LED反馈(避免部署时大量LED同时发送)
|
|
804
842
|
if (node.isInitializing) {
|
|
843
|
+
node.debug(`[sendCommandToPanel] 初始化期间,跳过LED反馈`);
|
|
805
844
|
return;
|
|
806
845
|
}
|
|
807
846
|
|
|
@@ -822,18 +861,27 @@ module.exports = function(RED) {
|
|
|
822
861
|
|
|
823
862
|
if (node.config.buttonType === 'mesh') {
|
|
824
863
|
// Mesh模式:发送Mesh控制帧
|
|
864
|
+
// 从全局共享状态中获取当前设备的完整状态
|
|
865
|
+
const meshAddr = node.config.meshShortAddress;
|
|
866
|
+
const currentStates = meshDeviceStates.get(meshAddr) || null;
|
|
867
|
+
|
|
868
|
+
node.debug(`[Mesh LED] 准备发送:设备${meshAddr} 按钮${node.config.meshButtonNumber} = ${state}, 当前状态=${JSON.stringify(currentStates)}`);
|
|
869
|
+
|
|
825
870
|
command = meshProtocol.buildSwitchControlFrame(
|
|
826
871
|
node.config.meshShortAddress,
|
|
827
872
|
node.config.meshButtonNumber,
|
|
828
873
|
node.config.meshTotalButtons,
|
|
829
874
|
state,
|
|
830
|
-
|
|
875
|
+
currentStates
|
|
831
876
|
);
|
|
832
877
|
|
|
833
878
|
if (!command) {
|
|
834
879
|
node.error(`构建Mesh控制帧失败`);
|
|
835
880
|
return;
|
|
836
881
|
}
|
|
882
|
+
|
|
883
|
+
const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
|
|
884
|
+
node.debug(`[Mesh LED] 控制帧已构建:[${hexStr}]`);
|
|
837
885
|
} else {
|
|
838
886
|
// RS-485模式:使用轻量级协议
|
|
839
887
|
const deviceAddr = node.buttonDeviceAddr;
|
|
@@ -859,7 +907,11 @@ module.exports = function(RED) {
|
|
|
859
907
|
}
|
|
860
908
|
}
|
|
861
909
|
|
|
862
|
-
// 直接发送到全局队列(由serial-port-config统一管理,
|
|
910
|
+
// 直接发送到全局队列(由serial-port-config统一管理,40ms间隔串行发送)
|
|
911
|
+
// 传递优先级和switchId,触发源的面板会优先处理
|
|
912
|
+
const priority = 0; // 默认优先级
|
|
913
|
+
const switchId = node.config.buttonType === 'mesh' ? node.config.meshShortAddress : node.config.switchId;
|
|
914
|
+
|
|
863
915
|
node.serialPortConfig.write(command, (err) => {
|
|
864
916
|
if (err) {
|
|
865
917
|
node.error(`LED反馈失败: ${err.message}`);
|
|
@@ -874,7 +926,7 @@ module.exports = function(RED) {
|
|
|
874
926
|
node.log(`LED反馈已发送:面板${node.config.switchId} 按钮${node.config.buttonNumber} 设备${deviceAddr} 通道${channel} = ${state ? 'ON' : 'OFF'} (${node.config.buttonType === 'scene' ? 'REPORT' : 'SET'}) [${hexStr}]`);
|
|
875
927
|
}
|
|
876
928
|
}
|
|
877
|
-
});
|
|
929
|
+
}, priority, switchId);
|
|
878
930
|
};
|
|
879
931
|
|
|
880
932
|
// 更新节点状态显示
|
|
@@ -40,6 +40,10 @@ module.exports = function(RED) {
|
|
|
40
40
|
node.writeQueue = [];
|
|
41
41
|
node.isWriting = false;
|
|
42
42
|
|
|
43
|
+
// 触发源跟踪(用于优先队列)
|
|
44
|
+
node.triggerSource = null; // 当前触发场景的开关ID
|
|
45
|
+
node.triggerSourceTime = 0; // 触发时间戳
|
|
46
|
+
|
|
43
47
|
// 打开TCP连接
|
|
44
48
|
node.openTcpConnection = function() {
|
|
45
49
|
if (node.connection && !node.connection.destroyed) {
|
|
@@ -151,11 +155,16 @@ module.exports = function(RED) {
|
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
// 清理旧的串口实例(确保完全释放)
|
|
158
|
+
const needDelay = node.connection && node.connection.isOpen;
|
|
154
159
|
if (node.connection) {
|
|
155
160
|
try {
|
|
156
161
|
node.connection.removeAllListeners();
|
|
157
162
|
if (node.connection.isOpen) {
|
|
158
|
-
node.connection.close()
|
|
163
|
+
node.connection.close((closeErr) => {
|
|
164
|
+
if (closeErr) {
|
|
165
|
+
node.debug(`串口关闭时出错: ${closeErr.message}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
159
168
|
}
|
|
160
169
|
} catch (err) {
|
|
161
170
|
// 忽略清理错误
|
|
@@ -163,20 +172,25 @@ module.exports = function(RED) {
|
|
|
163
172
|
node.connection = null;
|
|
164
173
|
}
|
|
165
174
|
|
|
166
|
-
|
|
175
|
+
// 如果之前串口是打开的,等待500ms让系统完全释放资源
|
|
176
|
+
const openDelay = needDelay ? 500 : 0;
|
|
167
177
|
|
|
168
|
-
|
|
169
|
-
node.
|
|
170
|
-
path: node.serialPort,
|
|
171
|
-
baudRate: node.baudRate,
|
|
172
|
-
dataBits: node.dataBits,
|
|
173
|
-
parity: node.parity,
|
|
174
|
-
stopBits: node.stopBits,
|
|
175
|
-
autoOpen: false
|
|
176
|
-
});
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
node.isOpening = true;
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
try {
|
|
182
|
+
node.connection = new SerialPort({
|
|
183
|
+
path: node.serialPort,
|
|
184
|
+
baudRate: node.baudRate,
|
|
185
|
+
dataBits: node.dataBits,
|
|
186
|
+
parity: node.parity,
|
|
187
|
+
stopBits: node.stopBits,
|
|
188
|
+
autoOpen: false,
|
|
189
|
+
lock: true // 明确启用串口锁定
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// 打开串口
|
|
193
|
+
node.connection.open((err) => {
|
|
180
194
|
node.isOpening = false;
|
|
181
195
|
|
|
182
196
|
if (err) {
|
|
@@ -277,18 +291,19 @@ module.exports = function(RED) {
|
|
|
277
291
|
} catch (err) {
|
|
278
292
|
node.isOpening = false;
|
|
279
293
|
node.error(`串口初始化失败: ${err.message}`);
|
|
280
|
-
|
|
294
|
+
|
|
281
295
|
// 初始化失败时也要触发重连
|
|
282
296
|
if (!node.isClosing && node.dataListeners.length > 0) {
|
|
283
297
|
const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000);
|
|
284
298
|
node.reconnectAttempts++;
|
|
285
|
-
|
|
299
|
+
|
|
286
300
|
node.reconnectTimer = setTimeout(() => {
|
|
287
301
|
node.reconnectTimer = null;
|
|
288
302
|
node.openSerialConnection();
|
|
289
303
|
}, delay);
|
|
290
304
|
}
|
|
291
305
|
}
|
|
306
|
+
}, openDelay); // 关闭setTimeout
|
|
292
307
|
};
|
|
293
308
|
|
|
294
309
|
// 获取连接对象(用于Mesh设备发现等场景)
|
|
@@ -347,10 +362,38 @@ module.exports = function(RED) {
|
|
|
347
362
|
}
|
|
348
363
|
};
|
|
349
364
|
|
|
350
|
-
//
|
|
351
|
-
node.
|
|
365
|
+
// 设置触发源(用于优先队列)
|
|
366
|
+
node.setTriggerSource = function(switchId) {
|
|
367
|
+
node.triggerSource = switchId;
|
|
368
|
+
node.triggerSourceTime = Date.now();
|
|
369
|
+
// 500ms后清除触发源(避免长时间影响队列)
|
|
370
|
+
setTimeout(() => {
|
|
371
|
+
if (Date.now() - node.triggerSourceTime >= 500) {
|
|
372
|
+
node.triggerSource = null;
|
|
373
|
+
}
|
|
374
|
+
}, 500);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// 写入数据(带队列机制,防止并发冲突,支持优先级)
|
|
378
|
+
node.write = function(data, callback, priority, switchId) {
|
|
352
379
|
// 加入写入队列
|
|
353
|
-
|
|
380
|
+
const queueItem = { data, callback, priority: priority || 0, switchId };
|
|
381
|
+
|
|
382
|
+
// 如果有优先级或者是触发源,插入到队列前面
|
|
383
|
+
if (priority > 0 || (switchId && switchId === node.triggerSource)) {
|
|
384
|
+
// 找到第一个非优先级项的位置
|
|
385
|
+
let insertIndex = 0;
|
|
386
|
+
for (let i = 0; i < node.writeQueue.length; i++) {
|
|
387
|
+
if (node.writeQueue[i].priority === 0 && node.writeQueue[i].switchId !== node.triggerSource) {
|
|
388
|
+
insertIndex = i;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
insertIndex = i + 1;
|
|
392
|
+
}
|
|
393
|
+
node.writeQueue.splice(insertIndex, 0, queueItem);
|
|
394
|
+
} else {
|
|
395
|
+
node.writeQueue.push(queueItem);
|
|
396
|
+
}
|
|
354
397
|
|
|
355
398
|
// 启动队列处理
|
|
356
399
|
node.processWriteQueue();
|
|
@@ -405,9 +448,9 @@ module.exports = function(RED) {
|
|
|
405
448
|
});
|
|
406
449
|
});
|
|
407
450
|
|
|
408
|
-
// TCP写入间隔(
|
|
451
|
+
// TCP写入间隔(40ms,确保指示灯反馈稳定)
|
|
409
452
|
if (node.writeQueue.length > 0) {
|
|
410
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
453
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
411
454
|
}
|
|
412
455
|
} else {
|
|
413
456
|
if (!node.connection.isOpen) {
|
|
@@ -439,9 +482,9 @@ module.exports = function(RED) {
|
|
|
439
482
|
});
|
|
440
483
|
});
|
|
441
484
|
|
|
442
|
-
// 串口写入间隔(
|
|
485
|
+
// 串口写入间隔(40ms,确保指示灯反馈稳定)
|
|
443
486
|
if (node.writeQueue.length > 0) {
|
|
444
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
487
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
445
488
|
}
|
|
446
489
|
}
|
|
447
490
|
} catch (err) {
|
|
@@ -513,7 +556,7 @@ module.exports = function(RED) {
|
|
|
513
556
|
RED.nodes.registerType('serial-port-config', SerialPortConfigNode);
|
|
514
557
|
|
|
515
558
|
// 提供串口搜索API
|
|
516
|
-
RED.httpAdmin.get('/serial-ports', async (
|
|
559
|
+
RED.httpAdmin.get('/serial-ports', async (_req, res) => {
|
|
517
560
|
try {
|
|
518
561
|
const { SerialPort } = require('serialport');
|
|
519
562
|
const ports = await SerialPort.list();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-modbus",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.2",
|
|
4
4
|
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步,工控机长期稳定运行",
|
|
5
5
|
"main": "nodes/modbus-master.js",
|
|
6
6
|
"scripts": {
|