node-red-contrib-symi-modbus 2.8.1 → 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 CHANGED
@@ -884,23 +884,31 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
884
884
 
885
885
  ## 版本信息
886
886
 
887
- **当前版本**: v2.8.1
888
-
889
- ### v2.8.1 (2025-11-11)
890
-
891
- **重要更新**:
892
- - 优化指示灯反馈间隔为40ms(从30ms调整),进一步提升总线稳定性
893
- - 实现智能优先队列机制:触发场景的开关面板优先获得LED反馈
894
- - 提升用户体验:操作的面板最快响应,其他面板按序反馈
895
-
896
- **智能优先队列机制**:
897
- - **触发源优先**:当某个开关触发场景时,该开关面板的LED反馈会优先处理
898
- - **优先窗口**:触发后500ms内,该面板的所有LED反馈都会插入到队列前面
899
- - **用户体验优化**:用户操作的面板会最快看到反馈,其他面板即使慢一点也不会被察觉
900
- - **全局队列**:所有从站开关节点共享同一个全局队列(由serial-port-config管理)
901
- - **串行发送**:所有LED反馈按优先级和加入顺序依次发送,间隔40ms(TCP和串口统一)
902
- - **完整性保证**:所有LED反馈都能正确发送,不会遗漏
903
- - **去重机制**:每个节点50ms内不重复发送相同状态,避免总线拥堵
887
+ **当前版本**: v2.8.2
888
+
889
+ ### v2.8.2 (2025-11-11)
890
+
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内不重复发送相同状态,避免总线拥堵
904
912
 
905
913
  **典型场景说明**:
906
914
  1. **场景1:单个继电器控制**
@@ -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
+
@@ -114,17 +114,19 @@ module.exports = function(RED) {
114
114
  if (totalButtons === 1) {
115
115
  // 单路开关
116
116
  stateValue = state ? 0x02 : 0x01;
117
- const buffer = Buffer.from([
118
- PROTOCOL.HEADER,
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
- // 如果没有当前状态,只设置目标按钮,其他位设为00(保持不变)
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
- stateValue = (state ? 0x02 : 0x01) << bitPos;
147
+ const mask = ~(0x03 << bitPos); // 清除目标位
148
+ const newBits = (state ? 0x02 : 0x01) << bitPos; // 新状态位
149
+ stateValue = (stateValue & mask) | newBits;
138
150
  }
139
151
 
140
- const buffer = Buffer.from([
141
- PROTOCOL.HEADER,
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
- // 如果没有当前状态,只设置目标按钮,其他位设为00(保持不变)
173
+ // 如果没有当前状态,使用默认全关状态(0x5555),然后修改目标按钮
174
+ stateValue16 = 0x5555; // 6路全关
175
+
176
+ // 修改目标按钮的状态
160
177
  const bitPos = (buttonNumber - 1) * 2;
161
- stateValue16 = (state ? 0x02 : 0x01) << bitPos;
178
+ const mask = ~(0x03 << bitPos); // 清除目标位
179
+ const newBits = (state ? 0x02 : 0x01) << bitPos; // 新状态位
180
+ stateValue16 = (stateValue16 & mask) | newBits;
162
181
  }
163
182
 
164
- const buffer = Buffer.from([
165
- PROTOCOL.HEADER,
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
  }
@@ -946,13 +946,14 @@ module.exports = function(RED) {
946
946
  });
947
947
 
948
948
  // 广播状态变化事件(用于LED反馈)
949
- // 只在状态真正改变时广播(不包括首次轮询)
950
- if (!isFirstPoll && oldValue !== newValue) {
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
  }
@@ -179,10 +179,9 @@
179
179
  $("#node-input-enableMqtt").trigger("change");
180
180
  $("#node-input-buttonType").trigger("change");
181
181
 
182
- // 如果是Mesh模式,加载已保存的设备列表
183
- if (node.buttonType === 'mesh') {
184
- loadSavedMeshDevices();
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
 
@@ -697,8 +714,16 @@ module.exports = function(RED) {
697
714
  }
698
715
  globalDebounceCache.set(debounceKey, now);
699
716
 
700
- // 更新当前状态缓存(用于后续控制时保持其他路不变)
701
- node.meshCurrentStates = event.states;
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
+ }
702
727
 
703
728
  // 设置触发源(用于优先队列)- Mesh使用短地址作为switchId
704
729
  if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
@@ -801,17 +826,21 @@ module.exports = function(RED) {
801
826
  // 发送控制指令到物理开关面板(控制指示灯等)
802
827
  // 直接发送到全局队列,由serial-port-config统一管理(20ms间隔串行发送)
803
828
  node.sendCommandToPanel = function(state) {
829
+ node.debug(`[sendCommandToPanel] 被调用:buttonType=${node.config.buttonType} state=${state} isInitializing=${node.isInitializing}`);
830
+
804
831
  // 检查连接状态
805
832
  if (!node.serialPortConfig || !node.serialPortConfig.connection) {
806
833
  // 初始化期间静默警告
807
834
  if (!node.isInitializing) {
808
835
  node.warn('RS-485连接未建立,无法发送指示灯反馈');
809
836
  }
837
+ node.debug(`[sendCommandToPanel] 连接未建立,退出`);
810
838
  return;
811
839
  }
812
840
 
813
841
  // 初始化期间不发送LED反馈(避免部署时大量LED同时发送)
814
842
  if (node.isInitializing) {
843
+ node.debug(`[sendCommandToPanel] 初始化期间,跳过LED反馈`);
815
844
  return;
816
845
  }
817
846
 
@@ -832,18 +861,27 @@ module.exports = function(RED) {
832
861
 
833
862
  if (node.config.buttonType === 'mesh') {
834
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
+
835
870
  command = meshProtocol.buildSwitchControlFrame(
836
871
  node.config.meshShortAddress,
837
872
  node.config.meshButtonNumber,
838
873
  node.config.meshTotalButtons,
839
874
  state,
840
- node.meshCurrentStates || null
875
+ currentStates
841
876
  );
842
877
 
843
878
  if (!command) {
844
879
  node.error(`构建Mesh控制帧失败`);
845
880
  return;
846
881
  }
882
+
883
+ const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
884
+ node.debug(`[Mesh LED] 控制帧已构建:[${hexStr}]`);
847
885
  } else {
848
886
  // RS-485模式:使用轻量级协议
849
887
  const deviceAddr = node.buttonDeviceAddr;
@@ -155,11 +155,16 @@ module.exports = function(RED) {
155
155
  }
156
156
 
157
157
  // 清理旧的串口实例(确保完全释放)
158
+ const needDelay = node.connection && node.connection.isOpen;
158
159
  if (node.connection) {
159
160
  try {
160
161
  node.connection.removeAllListeners();
161
162
  if (node.connection.isOpen) {
162
- node.connection.close();
163
+ node.connection.close((closeErr) => {
164
+ if (closeErr) {
165
+ node.debug(`串口关闭时出错: ${closeErr.message}`);
166
+ }
167
+ });
163
168
  }
164
169
  } catch (err) {
165
170
  // 忽略清理错误
@@ -167,20 +172,25 @@ module.exports = function(RED) {
167
172
  node.connection = null;
168
173
  }
169
174
 
170
- node.isOpening = true;
175
+ // 如果之前串口是打开的,等待500ms让系统完全释放资源
176
+ const openDelay = needDelay ? 500 : 0;
171
177
 
172
- try {
173
- node.connection = new SerialPort({
174
- path: node.serialPort,
175
- baudRate: node.baudRate,
176
- dataBits: node.dataBits,
177
- parity: node.parity,
178
- stopBits: node.stopBits,
179
- autoOpen: false
180
- });
178
+ setTimeout(() => {
179
+ node.isOpening = true;
181
180
 
182
- // 打开串口
183
- node.connection.open((err) => {
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) => {
184
194
  node.isOpening = false;
185
195
 
186
196
  if (err) {
@@ -281,18 +291,19 @@ module.exports = function(RED) {
281
291
  } catch (err) {
282
292
  node.isOpening = false;
283
293
  node.error(`串口初始化失败: ${err.message}`);
284
-
294
+
285
295
  // 初始化失败时也要触发重连
286
296
  if (!node.isClosing && node.dataListeners.length > 0) {
287
297
  const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000);
288
298
  node.reconnectAttempts++;
289
-
299
+
290
300
  node.reconnectTimer = setTimeout(() => {
291
301
  node.reconnectTimer = null;
292
302
  node.openSerialConnection();
293
303
  }, delay);
294
304
  }
295
305
  }
306
+ }, openDelay); // 关闭setTimeout
296
307
  };
297
308
 
298
309
  // 获取连接对象(用于Mesh设备发现等场景)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.8.1",
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": {