node-red-contrib-symi-modbus 2.9.0 → 2.9.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
@@ -888,22 +888,37 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
888
888
 
889
889
  ## 版本信息
890
890
 
891
- **当前版本**: v2.9.0 (2025-12-05)
892
-
893
- **v2.9.0 更新内容**:
894
- - **重要修复**:一个按键现在可以控制多个不同从站的继电器
895
- - 修复了全局防抖机制导致的问题:之前一个开关配置控制多个从站时,只有第一个从站会执行
896
- - 现在支持:一个物理按键同时控制从站0A和0B的同一线圈,两个都会正确执行
897
- - 优化防抖key设计,包含目标从站地址,确保不同目标互不影响
898
- - RS-485开关、Mesh开关、Mesh场景模式均已修复
899
-
900
- **v2.8.9 更新内容**:
901
- - 新增Mesh开关无线模式支持,适用于场景触发按键
902
- - 修复Mesh开关群控LED反馈问题,确保状态完全同步
903
- - 优化LED反馈防抖机制(100ms),智能合并多次状态变化
904
- - 群控多个继电器时,Mesh开关只发送一次LED反馈,包含所有按钮的最新状态
905
- - 修复串口永久连接机制,彻底解决部署时串口锁定问题
906
- - RS-485开关功能完全不受影响,保持稳定运行
891
+ **当前版本**: v2.9.2 (2025-12-15)
892
+
893
+ **v2.9.2 更新内容**:
894
+ - **稳定性增强**:全局防抖缓存定期清理机制
895
+ - 每10分钟自动清理超过1分钟未使用的防抖记录
896
+ - 防止长期运行时内存缓慢增长
897
+ - **稳定性增强**:Mesh无线模式按键注册清理
898
+ - 节点关闭时正确清理meshWirelessButtons全局Map
899
+ - 避免重新部署时残留无效注册信息
900
+ - **代码审查**:全面检查所有节点代码
901
+ - 确认所有定时器在节点关闭时正确清理
902
+ - 确认所有事件监听器正确注销
903
+ - 确认所有连接资源正确释放
904
+ - 支持断电断网后自动恢复运行
905
+
906
+ **v2.9.1 更新内容**:
907
+ - **重要修复**:RS-485开关指示灯同步问题
908
+ - 修复TCP粘包导致的协议帧解析失败问题
909
+ - 新增`parseAllFrames`函数,正确处理多个帧粘在一起的情况
910
+ - 根据协议的`dataLen`字段精确提取帧边界,确保每个帧都被正确解析
911
+ - **重要修复**:Modbus控制看板部署失效问题
912
+ - 修复主站更新后重新部署导致看板无法正确显示的问题
913
+ - 部署时自动清空并重新初始化状态缓存
914
+ - 新增`refresh` API,点击刷新按钮从主站获取真实继电器状态
915
+ - **优化**:控制看板刷新功能
916
+ - 刷新按钮现在会主动从主站获取最新的继电器状态
917
+ - 确保显示的状态与实际继电器状态同步
918
+ - **优化**:指示灯反馈队列间隔调整为50ms
919
+ - TCP/串口写入间隔从40ms调整为50ms
920
+ - 触发源面板优先处理,确保按键响应流畅
921
+ - 按线圈顺序陆续反馈所有有动作的继电器指示灯
907
922
 
908
923
  **核心特性**:
909
924
  - 支持Modbus RTU/TCP协议,兼容标准Modbus设备和TCP转RS485网关
@@ -201,7 +201,7 @@ module.exports = {
201
201
  },
202
202
 
203
203
  /**
204
- * 解析接收到的协议帧
204
+ * 解析接收到的协议帧(支持粘包处理)
205
205
  * @param {Buffer} buffer - 接收到的数据
206
206
  * @returns {Object|null} 解析结果或null
207
207
  */
@@ -210,42 +210,172 @@ module.exports = {
210
210
  return null;
211
211
  }
212
212
 
213
- // 检查帧头和帧尾
214
- if (buffer[0] !== this.FRAME_HEADER || buffer[buffer.length - 1] !== this.FRAME_TAIL) {
215
- return null;
213
+ // 查找帧头位置
214
+ let startIndex = -1;
215
+ for (let i = 0; i < buffer.length; i++) {
216
+ if (buffer[i] === this.FRAME_HEADER) {
217
+ startIndex = i;
218
+ break;
219
+ }
220
+ }
221
+
222
+ if (startIndex === -1) {
223
+ return null; // 没有找到帧头
224
+ }
225
+
226
+ // 从帧头开始检查是否有足够的数据
227
+ if (buffer.length - startIndex < 15) {
228
+ return null; // 数据不完整
229
+ }
230
+
231
+ // 读取数据长度字段(第4个字节,索引3)
232
+ const dataLen = buffer[startIndex + 3];
233
+
234
+ // 检查帧长度是否合理(数据长度就是整帧长度)
235
+ if (dataLen < 15 || dataLen > 64) {
236
+ return null; // 长度不合理
237
+ }
238
+
239
+ // 检查是否有完整的帧数据
240
+ if (buffer.length - startIndex < dataLen) {
241
+ return null; // 数据不完整
242
+ }
243
+
244
+ // 提取单个帧
245
+ const frameBuffer = buffer.slice(startIndex, startIndex + dataLen);
246
+
247
+ // 检查帧尾
248
+ if (frameBuffer[frameBuffer.length - 1] !== this.FRAME_TAIL) {
249
+ return null; // 帧尾不正确
216
250
  }
217
251
 
218
252
  // 验证CRC8校验
219
- const receivedCRC = buffer[buffer.length - 2];
220
- const calculatedCRC = this.calculateCRC8(buffer, buffer.length);
253
+ const receivedCRC = frameBuffer[frameBuffer.length - 2];
254
+ const calculatedCRC = this.calculateCRC8(frameBuffer, frameBuffer.length);
221
255
  if (receivedCRC !== calculatedCRC) {
222
256
  return null; // 校验失败
223
257
  }
224
258
 
225
259
  // 解析数据
226
260
  const frame = {
227
- localAddr: buffer[1],
228
- dataType: buffer[2],
229
- dataLen: buffer[3],
230
- deviceType: buffer[4],
231
- brandID: buffer[5],
232
- deviceAddr: buffer[6],
233
- channel: buffer[7],
234
- roomNo: buffer[8],
235
- roomType: buffer[9],
236
- roomID: buffer[10],
237
- opCode: buffer[11],
238
- opInfo: []
261
+ localAddr: frameBuffer[1],
262
+ dataType: frameBuffer[2],
263
+ dataLen: frameBuffer[3],
264
+ deviceType: frameBuffer[4],
265
+ brandID: frameBuffer[5],
266
+ deviceAddr: frameBuffer[6],
267
+ channel: frameBuffer[7],
268
+ roomNo: frameBuffer[8],
269
+ roomType: frameBuffer[9],
270
+ roomID: frameBuffer[10],
271
+ opCode: frameBuffer[11],
272
+ opInfo: [],
273
+ frameLength: dataLen, // 记录帧长度,用于粘包处理
274
+ startIndex: startIndex // 记录帧起始位置,用于粘包处理
239
275
  };
240
276
 
241
277
  // 提取操作信息
242
- for (let i = 12; i < buffer.length - 2; i++) {
243
- frame.opInfo.push(buffer[i]);
278
+ for (let i = 12; i < frameBuffer.length - 2; i++) {
279
+ frame.opInfo.push(frameBuffer[i]);
244
280
  }
245
281
 
246
282
  return frame;
247
283
  },
248
284
 
285
+ /**
286
+ * 解析所有协议帧(处理粘包,返回所有帧)
287
+ * @param {Buffer} buffer - 接收到的数据(可能包含多个帧)
288
+ * @returns {Array} 解析出的所有帧数组
289
+ */
290
+ parseAllFrames: function(buffer) {
291
+ const frames = [];
292
+ if (!buffer || buffer.length < 15) {
293
+ return frames;
294
+ }
295
+
296
+ let offset = 0;
297
+ let maxIterations = 10; // 防止无限循环
298
+
299
+ while (offset < buffer.length && maxIterations > 0) {
300
+ maxIterations--;
301
+
302
+ // 查找下一个帧头
303
+ let startIndex = -1;
304
+ for (let i = offset; i < buffer.length; i++) {
305
+ if (buffer[i] === this.FRAME_HEADER) {
306
+ startIndex = i;
307
+ break;
308
+ }
309
+ }
310
+
311
+ if (startIndex === -1) {
312
+ break; // 没有更多帧头
313
+ }
314
+
315
+ // 检查是否有足够的数据读取长度字段
316
+ if (buffer.length - startIndex < 4) {
317
+ break; // 数据不完整
318
+ }
319
+
320
+ // 读取数据长度
321
+ const dataLen = buffer[startIndex + 3];
322
+
323
+ // 检查帧长度是否合理
324
+ if (dataLen < 15 || dataLen > 64) {
325
+ offset = startIndex + 1; // 跳过这个字节继续搜索
326
+ continue;
327
+ }
328
+
329
+ // 检查是否有完整的帧
330
+ if (buffer.length - startIndex < dataLen) {
331
+ break; // 数据不完整
332
+ }
333
+
334
+ // 提取帧数据
335
+ const frameBuffer = buffer.slice(startIndex, startIndex + dataLen);
336
+
337
+ // 检查帧尾
338
+ if (frameBuffer[frameBuffer.length - 1] !== this.FRAME_TAIL) {
339
+ offset = startIndex + 1; // 跳过继续搜索
340
+ continue;
341
+ }
342
+
343
+ // 验证CRC
344
+ const receivedCRC = frameBuffer[frameBuffer.length - 2];
345
+ const calculatedCRC = this.calculateCRC8(frameBuffer, frameBuffer.length);
346
+ if (receivedCRC !== calculatedCRC) {
347
+ offset = startIndex + 1; // CRC错误,跳过继续搜索
348
+ continue;
349
+ }
350
+
351
+ // 解析帧
352
+ const frame = {
353
+ localAddr: frameBuffer[1],
354
+ dataType: frameBuffer[2],
355
+ dataLen: frameBuffer[3],
356
+ deviceType: frameBuffer[4],
357
+ brandID: frameBuffer[5],
358
+ deviceAddr: frameBuffer[6],
359
+ channel: frameBuffer[7],
360
+ roomNo: frameBuffer[8],
361
+ roomType: frameBuffer[9],
362
+ roomID: frameBuffer[10],
363
+ opCode: frameBuffer[11],
364
+ opInfo: []
365
+ };
366
+
367
+ // 提取操作信息
368
+ for (let i = 12; i < frameBuffer.length - 2; i++) {
369
+ frame.opInfo.push(frameBuffer[i]);
370
+ }
371
+
372
+ frames.push(frame);
373
+ offset = startIndex + dataLen; // 移动到下一帧
374
+ }
375
+
376
+ return frames;
377
+ },
378
+
249
379
  /**
250
380
  * 检测是否是按键按下事件
251
381
  * @param {Object} frame - 解析后的帧
@@ -237,13 +237,38 @@
237
237
  }
238
238
  });
239
239
 
240
- // 添加刷新按钮
240
+ // 添加刷新按钮(从主站重新获取真实状态)
241
241
  $("#btn-refresh-dashboard").on("click", function() {
242
- stopPolling();
243
- renderDashboard();
244
- if (masterNodeSelect.val()) {
245
- startPolling();
242
+ var masterNodeId = masterNodeSelect.val();
243
+ if (!masterNodeId) {
244
+ RED.notify('请先选择主站节点', 'warning');
245
+ return;
246
246
  }
247
+
248
+ stopPolling();
249
+
250
+ // 调用refresh API从主站重新获取真实状态
251
+ $.ajax({
252
+ url: '/modbus-dashboard/refresh/' + masterNodeId,
253
+ method: 'POST',
254
+ success: function(data) {
255
+ if (data && data.states) {
256
+ // 更新本地缓存
257
+ stateCache = Object.assign({}, data.states);
258
+ console.log('状态已从主站刷新:', Object.keys(stateCache).length + '个线圈');
259
+ }
260
+ // 重新渲染UI
261
+ renderDashboard();
262
+ startPolling();
263
+ },
264
+ error: function(err) {
265
+ console.error('刷新状态失败:', err);
266
+ RED.notify('刷新状态失败: ' + (err.responseJSON ? err.responseJSON.error : err.statusText), 'error');
267
+ // 即使失败也重新渲染
268
+ renderDashboard();
269
+ startPolling();
270
+ }
271
+ });
247
272
  });
248
273
 
249
274
  // 初始渲染
@@ -3,6 +3,9 @@ module.exports = function(RED) {
3
3
 
4
4
  // 全局状态缓存(所有dashboard节点共享)
5
5
  var globalStateCache = {};
6
+
7
+ // 跟踪已注册的dashboard节点(用于部署时重新初始化)
8
+ var registeredDashboards = new Map();
6
9
 
7
10
  function ModbusDashboardNode(config) {
8
11
  RED.nodes.createNode(this, config);
@@ -14,6 +17,9 @@ module.exports = function(RED) {
14
17
  masterNode: config.masterNode
15
18
  };
16
19
 
20
+ // 保存主站节点ID用于后续引用
21
+ node.masterNodeId = config.masterNode;
22
+
17
23
  // 获取主站节点
18
24
  var masterNode = RED.nodes.getNode(node.config.masterNode);
19
25
  if (!masterNode) {
@@ -22,6 +28,17 @@ module.exports = function(RED) {
22
28
  return;
23
29
  }
24
30
 
31
+ // 部署时清空该主站相关的缓存并重新初始化
32
+ // 这样确保每次部署后状态从主站重新获取
33
+ if (masterNode.config && masterNode.config.slaves) {
34
+ masterNode.config.slaves.forEach(function(slave) {
35
+ for (var coil = slave.coilStart; coil <= slave.coilEnd; coil++) {
36
+ var key = slave.address + "_" + coil;
37
+ delete globalStateCache[key];
38
+ }
39
+ });
40
+ }
41
+
25
42
  // 监听主站状态更新事件
26
43
  node.stateUpdateHandler = function(data) {
27
44
  if (!data || typeof data !== 'object') {
@@ -48,6 +65,12 @@ module.exports = function(RED) {
48
65
  });
49
66
  }
50
67
 
68
+ // 注册到全局跟踪
69
+ registeredDashboards.set(node.id, {
70
+ masterNodeId: node.masterNodeId,
71
+ node: node
72
+ });
73
+
51
74
  // 更新节点状态
52
75
  node.status({fill: "green", shape: "dot", text: "监控中"});
53
76
 
@@ -56,6 +79,7 @@ module.exports = function(RED) {
56
79
  if (masterNode && node.stateUpdateHandler) {
57
80
  masterNode.removeListener('stateUpdate', node.stateUpdateHandler);
58
81
  }
82
+ registeredDashboards.delete(node.id);
59
83
  node.status({});
60
84
  });
61
85
  }
@@ -87,6 +111,45 @@ module.exports = function(RED) {
87
111
  });
88
112
  });
89
113
 
114
+ // HTTP API:刷新状态(从主站重新获取真实状态)
115
+ RED.httpAdmin.post('/modbus-dashboard/refresh/:masterNodeId', function(req, res) {
116
+ var masterNodeId = req.params.masterNodeId;
117
+ var masterNode = RED.nodes.getNode(masterNodeId);
118
+
119
+ if (!masterNode) {
120
+ res.status(404).json({error: '主站节点不存在'});
121
+ return;
122
+ }
123
+
124
+ // 清空该主站相关的缓存
125
+ if (masterNode.config && masterNode.config.slaves) {
126
+ masterNode.config.slaves.forEach(function(slave) {
127
+ for (var coil = slave.coilStart; coil <= slave.coilEnd; coil++) {
128
+ var key = slave.address + "_" + coil;
129
+ delete globalStateCache[key];
130
+ }
131
+ });
132
+ }
133
+
134
+ // 从主站重新获取当前状态
135
+ if (masterNode.deviceStates) {
136
+ Object.keys(masterNode.deviceStates).forEach(function(slaveId) {
137
+ var deviceState = masterNode.deviceStates[slaveId];
138
+ if (deviceState && deviceState.coils) {
139
+ deviceState.coils.forEach(function(value, coil) {
140
+ var key = slaveId + "_" + coil;
141
+ globalStateCache[key] = value;
142
+ });
143
+ }
144
+ });
145
+ }
146
+
147
+ res.json({
148
+ success: true,
149
+ states: globalStateCache
150
+ });
151
+ });
152
+
90
153
  // HTTP API:发送控制命令
91
154
  RED.httpAdmin.post('/modbus-dashboard/control', function(req, res) {
92
155
  var slave = parseInt(req.body.slave);
@@ -150,7 +150,7 @@ module.exports = function(RED) {
150
150
  // 写入队列机制(确保所有写入操作串行执行,避免锁竞争)
151
151
  node.writeQueue = []; // 写入队列
152
152
  node.isProcessingWrite = false; // 是否正在处理写入队列
153
- node.writeQueueInterval = 40; // 写入队列处理间隔(40ms,确保总线稳定,避免数据丢失)
153
+ node.writeQueueInterval = 50; // 写入队列处理间隔(50ms,厂家推荐间隔,确保总线稳定)
154
154
 
155
155
  // 定期清理机制(每小时清理一次,防止内存泄漏)
156
156
  node.cleanupTimer = setInterval(() => {
@@ -30,6 +30,27 @@ module.exports = function(RED) {
30
30
  let meshLedFeedbackQueueTimer = null;
31
31
  let meshLedFeedbackGlobalLock = 0; // 全局锁定时间戳:在此时间之前,忽略所有Mesh面板的状态上报
32
32
 
33
+ // 全局防抖缓存定期清理(每10分钟清理超过1分钟未使用的条目,防止内存泄漏)
34
+ let globalDebounceCacheCleanupTimer = null;
35
+ function startGlobalDebounceCacheCleanup() {
36
+ if (globalDebounceCacheCleanupTimer) return;
37
+ globalDebounceCacheCleanupTimer = setInterval(() => {
38
+ const now = Date.now();
39
+ const expireThreshold = 60 * 1000; // 1分钟未使用的条目将被清理
40
+ let cleanedCount = 0;
41
+ for (const [key, timestamp] of globalDebounceCache.entries()) {
42
+ if (now - timestamp > expireThreshold) {
43
+ globalDebounceCache.delete(key);
44
+ cleanedCount++;
45
+ }
46
+ }
47
+ if (cleanedCount > 0) {
48
+ RED.log.debug(`全局防抖缓存清理: 删除${cleanedCount}条过期记录,剩余${globalDebounceCache.size}条`);
49
+ }
50
+ }, 10 * 60 * 1000); // 每10分钟执行一次
51
+ }
52
+ startGlobalDebounceCacheCleanup();
53
+
33
54
  // 初始化Mesh设备持久化存储
34
55
  const meshPersistDir = path.join(RED.settings.userDir || os.homedir() + '/.node-red', 'mesh-devices-persist');
35
56
  let meshStorageInitialized = false;
@@ -738,7 +759,7 @@ module.exports = function(RED) {
738
759
  RED.events.on('modbus:coilStateChanged', node.stateChangeListener);
739
760
  };
740
761
 
741
- // 处理RS-485接收到的数据
762
+ // 处理RS-485接收到的数据(支持TCP粘包处理)
742
763
  node.handleRs485Data = function(data) {
743
764
  try {
744
765
  // 如果是Mesh模式,使用Mesh协议解析
@@ -747,77 +768,80 @@ module.exports = function(RED) {
747
768
  return;
748
769
  }
749
770
 
750
- // 解析轻量级协议帧(RS-485模式)
751
- const frame = protocol.parseFrame(data);
752
- if (!frame) {
771
+ // 使用parseAllFrames处理粘包,解析所有帧
772
+ const frames = protocol.parseAllFrames(data);
773
+ if (!frames || frames.length === 0) {
753
774
  return; // 静默忽略无效帧
754
775
  }
755
776
 
756
- // 忽略 REPORT (0x04) 类型的帧(这是面板对我们指令的确认,不是按键事件)
757
- // 只处理 SET (0x03) 类型的帧(真正的按键事件)
758
- if (frame.dataType === 0x04) {
759
- return; // 静默忽略REPORT帧
760
- }
761
-
762
- // 检测是否是按键按下事件
763
- const buttonEvent = protocol.detectButtonPress(frame);
764
- if (!buttonEvent) {
765
- return; // 静默忽略非按键事件
766
- }
777
+ // 处理每一个帧
778
+ for (const frame of frames) {
779
+ // 忽略 REPORT (0x04) 类型的帧(这是面板对我们指令的确认,不是按键事件)
780
+ // 只处理 SET (0x03) 类型的帧(真正的按键事件)
781
+ if (frame.dataType === 0x04) {
782
+ continue; // 静默忽略REPORT帧
783
+ }
767
784
 
768
- // 计算实际按键编号(Symi协议公式)
769
- // 例如:devAddr=1,channel=1→按键1;devAddr=2,channel=1→按键5
770
- const actualButtonNumber = buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel;
771
-
772
- // 检查是否是我们监听的开关面板和按钮
773
- // switchId对应本地地址(物理面板地址)
774
- // buttonNumber对应实际按键编号(1-8)
775
- if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
776
- // 保存原始的deviceAddr和channel,用于LED反馈
777
- const oldDeviceAddr = node.buttonDeviceAddr;
778
- const oldChannel = node.buttonChannel;
779
- node.buttonDeviceAddr = buttonEvent.deviceAddr;
780
- node.buttonChannel = buttonEvent.channel;
781
-
782
- // 输出调试日志,对比计算值和实际值
783
- if (oldDeviceAddr !== buttonEvent.deviceAddr || oldChannel !== buttonEvent.channel) {
784
- node.log(`按键事件更新LED反馈地址:计算值(设备${oldDeviceAddr} 通道${oldChannel}) → 实际值(设备${buttonEvent.deviceAddr} 通道${buttonEvent.channel})`);
785
+ // 检测是否是按键按下事件
786
+ const buttonEvent = protocol.detectButtonPress(frame);
787
+ if (!buttonEvent) {
788
+ continue; // 静默忽略非按键事件
785
789
  }
786
790
 
787
- // 判断按钮类型:优先使用协议解析结果,其次使用配置
788
- const isSceneMode = buttonEvent.isSceneMode ||
789
- node.config.buttonType === 'scene' ||
790
- buttonEvent.deviceType === 0x07;
791
+ // 计算实际按键编号(Symi协议公式)
792
+ // 例如:devAddr=1,channel=1→按键1;devAddr=2,channel=1→按键5
793
+ const actualButtonNumber = buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel;
794
+
795
+ // 检查是否是我们监听的开关面板和按钮
796
+ // switchId对应本地地址(物理面板地址)
797
+ // buttonNumber对应实际按键编号(1-8)
798
+ if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
799
+ // 保存原始的deviceAddr和channel,用于LED反馈
800
+ const oldDeviceAddr = node.buttonDeviceAddr;
801
+ const oldChannel = node.buttonChannel;
802
+ node.buttonDeviceAddr = buttonEvent.deviceAddr;
803
+ node.buttonChannel = buttonEvent.channel;
804
+
805
+ // 输出调试日志,对比计算值和实际值
806
+ if (oldDeviceAddr !== buttonEvent.deviceAddr || oldChannel !== buttonEvent.channel) {
807
+ node.log(`按键事件更新LED反馈地址:计算值(设备${oldDeviceAddr} 通道${oldChannel}) → 实际值(设备${buttonEvent.deviceAddr} 通道${buttonEvent.channel})`);
808
+ }
791
809
 
792
- // 全局防抖:防止同一个按键的同一个目标重复触发
793
- // 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
794
- const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
795
- const now = Date.now();
796
- const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
810
+ // 判断按钮类型:优先使用协议解析结果,其次使用配置
811
+ const isSceneMode = buttonEvent.isSceneMode ||
812
+ node.config.buttonType === 'scene' ||
813
+ buttonEvent.deviceType === 0x07;
797
814
 
798
- // 全局防抖:200ms内只触发一次(开关和场景统一防抖时间)
799
- if (now - lastTriggerTime < 200) {
800
- return; // 静默忽略重复触发
801
- }
802
- globalDebounceCache.set(debounceKey, now);
815
+ // 全局防抖:防止同一个按键的同一个目标重复触发
816
+ // 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
817
+ const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
818
+ const now = Date.now();
819
+ const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
803
820
 
804
- // 设置触发源(用于优先队列)
805
- if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
806
- node.serialPortConfig.setTriggerSource(node.config.switchId);
807
- }
821
+ // 全局防抖:200ms内只触发一次(开关和场景统一防抖时间)
822
+ if (now - lastTriggerTime < 200) {
823
+ continue; // 静默忽略重复触发
824
+ }
825
+ globalDebounceCache.set(debounceKey, now);
808
826
 
809
- if (isSceneMode) {
810
- node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
811
- // 场景模式:切换状态(每次触发时翻转)
812
- node.currentState = !node.currentState;
813
- node.sendMqttCommand(node.currentState);
814
- } else {
815
- // 开关模式:根据状态发送ON/OFF
816
- node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
817
- node.sendMqttCommand(buttonEvent.state);
827
+ // 设置触发源(用于优先队列)
828
+ if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
829
+ node.serialPortConfig.setTriggerSource(node.config.switchId);
830
+ }
831
+
832
+ if (isSceneMode) {
833
+ node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
834
+ // 场景模式:切换状态(每次触发时翻转)
835
+ node.currentState = !node.currentState;
836
+ node.sendMqttCommand(node.currentState);
837
+ } else {
838
+ // 开关模式:根据状态发送ON/OFF
839
+ node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
840
+ node.sendMqttCommand(buttonEvent.state);
841
+ }
818
842
  }
843
+ // 不匹配的节点静默忽略,不输出任何日志
819
844
  }
820
- // 不匹配的节点静默忽略,不输出任何日志
821
845
  } catch (err) {
822
846
  node.error(`解析RS-485数据失败: ${err.message}`);
823
847
  }
@@ -1475,6 +1499,15 @@ module.exports = function(RED) {
1475
1499
  clearTimeout(meshLedDebounceTimers.get(meshAddr).timer);
1476
1500
  meshLedDebounceTimers.delete(meshAddr);
1477
1501
  }
1502
+
1503
+ // 清理meshWirelessButtons中当前节点的按键注册
1504
+ if (node.config.meshWirelessMode === true && meshWirelessButtons.has(meshAddr)) {
1505
+ meshWirelessButtons.get(meshAddr).delete(node.config.meshButtonNumber);
1506
+ // 如果该设备没有其他无线按键了,删除整个条目
1507
+ if (meshWirelessButtons.get(meshAddr).size === 0) {
1508
+ meshWirelessButtons.delete(meshAddr);
1509
+ }
1510
+ }
1478
1511
  }
1479
1512
 
1480
1513
  // 清理队列(释放内存)
@@ -452,9 +452,9 @@ module.exports = function(RED) {
452
452
  });
453
453
  });
454
454
 
455
- // TCP写入间隔(40ms,确保指示灯反馈稳定)
455
+ // TCP写入间隔(50ms,厂家推荐间隔,确保指示灯反馈稳定)
456
456
  if (node.writeQueue.length > 0) {
457
- await new Promise(resolve => setTimeout(resolve, 40));
457
+ await new Promise(resolve => setTimeout(resolve, 50));
458
458
  }
459
459
  } else {
460
460
  if (!node.connection.isOpen) {
@@ -486,9 +486,9 @@ module.exports = function(RED) {
486
486
  });
487
487
  });
488
488
 
489
- // 串口写入间隔(40ms,确保指示灯反馈稳定)
489
+ // 串口写入间隔(50ms,厂家推荐间隔,确保指示灯反馈稳定)
490
490
  if (node.writeQueue.length > 0) {
491
- await new Promise(resolve => setTimeout(resolve, 40));
491
+ await new Promise(resolve => setTimeout(resolve, 50));
492
492
  }
493
493
  }
494
494
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步,工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {