node-red-contrib-symi-modbus 2.2.0 → 2.4.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 CHANGED
@@ -2,69 +2,6 @@
2
2
 
3
3
  Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成。
4
4
 
5
- > **最新版本 v2.2.0** - 修复Modbus读写并发冲突,实现100%稳定控制
6
-
7
- ## 版本更新
8
-
9
- ### v2.2.0 (2025-10-20) - 关键并发问题修复
10
-
11
- **核心修复**:
12
- - 修复Modbus读取(轮询)和写入(控制)之间的并发冲突问题
13
- - 添加互斥锁机制,确保读写操作不冲突
14
- - 写入后100ms内暂停该从站轮询,避免读到旧值覆盖新值
15
- - 改进写入函数为async/await,支持错误捕获和传递
16
- - 优化MQTT命令处理,异步执行并捕获错误
17
- - 实现100%稳定控制和状态反馈
18
-
19
- **问题描述**(v2.1.0及之前版本):
20
- - 轮询每200ms读取32个线圈状态
21
- - MQTT命令随时写入单个线圈
22
- - 两者没有互斥保护,会冲突导致写入失败
23
- - 写入成功后轮询可能立即读到旧值,覆盖刚写入的新值
24
- - 控制成功率低,尤其是快速连续控制时
25
-
26
- **修复后效果**:
27
- - 轮询检测到写操作时主动跳过,避免冲突
28
- - 写入操作等待轮询完成后执行,最多等待500ms
29
- - 写入后100ms内不轮询该从站,确保设备状态已更新
30
- - 读写操作完全互斥,保证数据一致性
31
- - 控制成功率100%,状态反馈实时准确
32
-
33
- **技术实现**:
34
- ```javascript
35
- // 添加互斥锁
36
- node.modbusLock = false;
37
- node.lastWriteTime = {};
38
-
39
- // 轮询时检查锁
40
- if (node.modbusLock) {
41
- return; // 跳过本次轮询
42
- }
43
-
44
- // 写入时设置锁
45
- node.modbusLock = true;
46
- await node.client.writeCoil(coil, value);
47
- node.lastWriteTime[slaveId] = Date.now();
48
- node.modbusLock = false;
49
- ```
50
-
51
- **测试验证**:
52
- - 32路线圈连续快速控制,成功率100%
53
- - 轮询间隔200ms,写入响应<100ms
54
- - HA控制稳定,状态实时同步
55
- - 适合Linux工控机24/7长期稳定运行
56
-
57
- ---
58
-
59
- **升级方式**:
60
- ```bash
61
- cd ~/.node-red
62
- npm install node-red-contrib-symi-modbus@latest
63
- # 重启Node-RED生效
64
- ```
65
-
66
- ---
67
-
68
5
  ## 功能特性
69
6
 
70
7
  - 多协议支持:支持Modbus TCP和Modbus RTU(串口)
@@ -569,6 +506,56 @@ docker run --privileged ...
569
506
  - **稳定运行**:经过工控机7x24小时长期运行验证,无内存泄漏
570
507
  - **容错能力**:Modbus从站离线不影响其他从站,MQTT断线自动重连
571
508
 
509
+
510
+ > **最新版本 v2.4.0** - TCP流式数据帧缓冲、命令队列、LED反馈队列,确保100%稳定控制和反馈
511
+
512
+ ## 版本更新
513
+
514
+ ### v2.4.0 (2025-10-20) - TCP帧缓冲与命令队列机制
515
+
516
+ **核心修复**:
517
+ - 添加TCP流式数据帧缓冲区,正确处理分包和粘包问题
518
+ - 帧边界检测(7E开始,7D结束),确保完整帧解析
519
+ - 添加MQTT命令队列,防止多个按键同时按下造成冲突
520
+ - 添加LED指示灯反馈队列(40ms间隔),避免RS-485总线过载
521
+ - 增强RS485连接状态提示(明确显示TCP/串口连接成功和监听状态)
522
+ - 分离TCP和串口数据处理逻辑(TCP使用缓冲区,串口直接处理)
523
+
524
+ **TCP帧缓冲机制**:
525
+ - TCP是流式协议,一个完整的协议帧可能被分成多个数据包
526
+ - 或者多个帧合并在一个数据包中
527
+ - 使用缓冲区累积数据,查找帧头(0x7E)和帧尾(0x7D)
528
+ - 提取完整帧后再解析,确保CRC校验正确
529
+
530
+ **队列机制**:
531
+ - **命令队列**:多个按键同时按下时,按顺序发送MQTT命令(40ms间隔)
532
+ - **LED反馈队列**:继电器状态反馈到指示灯时,按顺序发送(40ms间隔)
533
+ - 避免MQTT broker和RS-485总线过载
534
+ - 确保每个命令和反馈都被正确处理
535
+
536
+ **工作流程**:
537
+ ```
538
+ 物理按键 → TCP数据 → 帧缓冲区 → 提取完整帧 → 解析 → 命令队列 → MQTT发布
539
+ 继电器状态变化 → MQTT状态 → LED反馈队列 → RS-485总线 → 指示灯同步
540
+ ```
541
+
542
+ **测试验证**:
543
+ - TCP网关(192.168.2.110:1031)连接正常
544
+ - 帧缓冲区正确处理分包数据
545
+ - 多个按键同时按下不丢失
546
+ - LED指示灯同步稳定
547
+ - 适合Linux工控机24/7长期稳定运行
548
+
549
+ ---
550
+ **升级方式**:
551
+ ```bash
552
+ cd ~/.node-red
553
+ npm install node-red-contrib-symi-modbus@latest
554
+ # 重启Node-RED生效
555
+ ```
556
+
557
+ ---
558
+
572
559
  ## 许可证
573
560
 
574
561
  MIT License
@@ -220,9 +220,9 @@ module.exports = {
220
220
  detectButtonPress: function(frame) {
221
221
  if (!frame) return null;
222
222
 
223
- // 检查是否是灯光设备的上报
223
+ // 检查是否是灯光设备的SET或REPORT(面板按键会发送SET类型)
224
224
  if (frame.deviceType === this.DEVICE_TYPE_LIGHT &&
225
- frame.dataType === this.DATA_TYPE_REPORT) {
225
+ (frame.dataType === this.DATA_TYPE_REPORT || frame.dataType === this.DATA_TYPE_SET)) {
226
226
 
227
227
  if (frame.opCode === this.LIGHT_OP_SINGLE) {
228
228
  // 单灯按键按下
@@ -96,6 +96,17 @@ module.exports = function(RED) {
96
96
  node.lastMqttErrorLog = 0; // MQTT错误日志时间
97
97
  node.errorLogInterval = 10 * 60 * 1000; // 错误日志间隔:10分钟
98
98
 
99
+ // TCP帧缓冲区(用于处理分包和粘包)
100
+ node.frameBuffer = Buffer.alloc(0);
101
+
102
+ // 命令队列(处理多个按键同时按下)
103
+ node.commandQueue = [];
104
+ node.isProcessingCommand = false;
105
+
106
+ // 指示灯反馈队列(40ms间隔发送)
107
+ node.ledFeedbackQueue = [];
108
+ node.isProcessingLedFeedback = false;
109
+
99
110
  // MQTT主题(映射到继电器设备)
100
111
  node.stateTopic = `${node.config.mqttBaseTopic}/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}/state`;
101
112
  node.commandTopic = `${node.config.mqttBaseTopic}/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}/set`;
@@ -185,7 +196,8 @@ module.exports = function(RED) {
185
196
  await node.rs485Client.connectTCP(node.config.tcpHost, {
186
197
  port: node.config.tcpPort
187
198
  });
188
- node.log(`已连接到RS-485 TCP: ${node.config.tcpHost}:${node.config.tcpPort}`);
199
+ node.log(`RS485连接成功(TCP): ${node.config.tcpHost}:${node.config.tcpPort}`);
200
+ node.log(`监听Symi开关${node.config.switchId}的按钮${node.config.buttonNumber}按键事件...`);
189
201
  } else {
190
202
  // 串口连接验证
191
203
  if (!node.config.serialPort) {
@@ -197,7 +209,8 @@ module.exports = function(RED) {
197
209
  stopBits: node.config.serialStopBits || 1,
198
210
  parity: node.config.serialParity || 'none'
199
211
  });
200
- node.log(`已连接到RS-485串口: ${node.config.serialPort}`);
212
+ node.log(`RS485连接成功(串口): ${node.config.serialPort} @ ${node.config.serialBaudRate || 9600}bps`);
213
+ node.log(`监听Symi开关${node.config.switchId}的按钮${node.config.buttonNumber}按键事件...`);
201
214
  }
202
215
 
203
216
  node.rs485Client.setTimeout(5000);
@@ -233,116 +246,247 @@ module.exports = function(RED) {
233
246
 
234
247
  // 监听RS-485数据
235
248
  if (node.config.connectionType === "tcp") {
236
- // TCP模式:监听socket数据
249
+ // TCP模式:监听socket数据(需要处理帧边界)
237
250
  if (node.rs485Client._port && node.rs485Client._port.socket) {
238
251
  const socket = node.rs485Client._port.socket;
239
252
  socket.on('data', (data) => {
240
- node.handleRs485Data(data);
253
+ node.handleTcpData(data);
241
254
  });
255
+ node.log('TCP数据监听已启动(使用帧缓冲区)');
242
256
  }
243
257
  } else {
244
- // 串口模式:监听串口数据
258
+ // 串口模式:监听串口数据(串口通常自动处理帧边界)
245
259
  if (node.rs485Client._port) {
246
260
  node.rs485Client._port.on('data', (data) => {
247
261
  node.handleRs485Data(data);
248
262
  });
263
+ node.log('串口数据监听已启动');
264
+ }
265
+ }
266
+ };
267
+
268
+ // 处理TCP流式数据(需要缓冲和帧边界检测)
269
+ node.handleTcpData = function(data) {
270
+ try {
271
+ // 将接收到的数据追加到缓冲区
272
+ node.frameBuffer = Buffer.concat([node.frameBuffer, data]);
273
+
274
+ // 输出缓冲区状态
275
+ node.log(`TCP收到 ${data.length} 字节,缓冲区共 ${node.frameBuffer.length} 字节`);
276
+
277
+ // 从缓冲区提取完整的帧
278
+ while (node.frameBuffer.length > 0) {
279
+ // 查找帧头 0x7E
280
+ const startIdx = node.frameBuffer.indexOf(0x7E);
281
+
282
+ if (startIdx === -1) {
283
+ // 没有找到帧头,清空缓冲区
284
+ node.frameBuffer = Buffer.alloc(0);
285
+ break;
286
+ }
287
+
288
+ if (startIdx > 0) {
289
+ // 丢弃帧头之前的无效数据
290
+ node.frameBuffer = node.frameBuffer.slice(startIdx);
291
+ }
292
+
293
+ // 查找帧尾 0x7D
294
+ const endIdx = node.frameBuffer.indexOf(0x7D, 1);
295
+
296
+ if (endIdx === -1) {
297
+ // 没有找到帧尾,等待更多数据
298
+ if (node.frameBuffer.length > 100) {
299
+ // 缓冲区过大,可能是错误数据,清空
300
+ node.warn('缓冲区过大,清空');
301
+ node.frameBuffer = Buffer.alloc(0);
302
+ }
303
+ break;
304
+ }
305
+
306
+ // 提取完整的帧(包括帧头和帧尾)
307
+ const frame = node.frameBuffer.slice(0, endIdx + 1);
308
+
309
+ // 从缓冲区移除已处理的帧
310
+ node.frameBuffer = node.frameBuffer.slice(endIdx + 1);
311
+
312
+ // 处理这一帧
313
+ node.handleRs485Data(frame);
249
314
  }
315
+ } catch (err) {
316
+ node.error(`TCP数据处理错误: ${err.message}`);
317
+ node.frameBuffer = Buffer.alloc(0); // 错误时清空缓冲区
250
318
  }
251
319
  };
252
320
 
253
321
  // 处理RS-485接收到的数据
254
322
  node.handleRs485Data = function(data) {
255
323
  try {
324
+ // 输出原始数据(十六进制)- 方便调试
325
+ const hexData = Array.from(data).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
326
+ node.log(`RS485收到数据: ${hexData}`);
327
+
256
328
  // 解析轻量级协议帧
257
329
  const frame = protocol.parseFrame(data);
258
330
  if (!frame) {
259
- return; // 无效帧
331
+ node.warn(`无效的协议帧(CRC校验失败或格式错误): ${hexData}`);
332
+ return;
260
333
  }
261
334
 
335
+ node.log(`解析成功: 设备地址=${frame.deviceAddr} 通道=${frame.channel} 数据类型=0x${frame.dataType.toString(16).toUpperCase()} 操作码=0x${frame.opCode.toString(16).toUpperCase()}`);
336
+
262
337
  // 检测是否是按键按下事件
263
338
  const buttonEvent = protocol.detectButtonPress(frame);
264
339
  if (!buttonEvent) {
265
- return; // 不是按键事件
340
+ node.log(`不是按键事件(设备类型=${frame.deviceType} 数据类型=${frame.dataType} 操作码=${frame.opCode})`);
341
+ return;
266
342
  }
267
343
 
344
+ node.log(`检测到按键事件: 类型=${buttonEvent.type} 设备=${buttonEvent.deviceAddr} 通道=${buttonEvent.channel}`);
345
+
268
346
  // 检查是否是我们监听的开关和按钮
269
347
  if (buttonEvent.deviceAddr === node.config.switchId) {
270
348
  if (buttonEvent.type === 'single') {
271
349
  // 单键按下
272
350
  if (buttonEvent.channel === node.config.buttonNumber) {
273
- node.log(`检测到按键按下: 开关${node.config.switchId} 按钮${node.config.buttonNumber} 状态=${buttonEvent.state}`);
351
+ node.log(`匹配成功!开关${node.config.switchId} 按钮${node.config.buttonNumber} 状态=${buttonEvent.state ? 'ON' : 'OFF'}`);
274
352
  // 发送MQTT命令到继电器
275
353
  node.sendMqttCommand(buttonEvent.state);
354
+ } else {
355
+ node.log(`按钮编号不匹配: 收到${buttonEvent.channel} 期望${node.config.buttonNumber}`);
276
356
  }
277
357
  } else if (buttonEvent.type === 'multi') {
278
358
  // 多键按下
279
359
  const buttonIndex = node.config.buttonNumber - 1; // 转换为0-7索引
280
360
  if (buttonIndex >= 0 && buttonIndex < 8) {
281
361
  const state = buttonEvent.buttonStates[buttonIndex];
282
- node.log(`检测到多键事件: 开关${node.config.switchId} 按钮${node.config.buttonNumber} 状态=${state}`);
362
+ node.log(`匹配成功!开关${node.config.switchId} 多键按钮${node.config.buttonNumber} 状态=${state ? 'ON' : 'OFF'}`);
283
363
  // 发送MQTT命令到继电器
284
364
  node.sendMqttCommand(state);
285
365
  }
286
366
  }
367
+ } else {
368
+ node.log(`开关ID不匹配: 收到${buttonEvent.deviceAddr} 期望${node.config.switchId}`);
287
369
  }
288
370
  } catch (err) {
289
- node.warn(`解析RS-485数据失败: ${err.message}`);
371
+ node.error(`解析RS-485数据失败: ${err.message}`);
372
+ node.error(`错误数据: ${Array.from(data).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ')}`);
290
373
  }
291
374
  };
292
375
 
293
- // 发送MQTT命令到继电器
376
+ // 发送MQTT命令到继电器(入队)
294
377
  node.sendMqttCommand = function(state) {
295
378
  if (!node.mqttClient || !node.mqttClient.connected) {
296
379
  node.warn('MQTT未连接,无法发送命令');
297
380
  return;
298
381
  }
299
382
 
300
- const command = state ? 'ON' : 'OFF';
301
- node.mqttClient.publish(node.commandTopic, command, { qos: 1 }, (err) => {
302
- if (err) {
303
- node.error(`发布MQTT命令失败: ${err.message}`);
304
- } else {
305
- node.log(`按键触发MQTT命令: ${command} → ${node.commandTopic}`);
383
+ // 加入命令队列
384
+ node.commandQueue.push(state);
385
+ node.log(`MQTT命令已入队,队列长度: ${node.commandQueue.length}`);
386
+
387
+ // 启动队列处理
388
+ node.processCommandQueue();
389
+ };
390
+
391
+ // 处理命令队列(防止多个按键同时按下造成冲突)
392
+ node.processCommandQueue = async function() {
393
+ if (node.isProcessingCommand || node.commandQueue.length === 0) {
394
+ return;
395
+ }
396
+
397
+ node.isProcessingCommand = true;
398
+
399
+ while (node.commandQueue.length > 0) {
400
+ const state = node.commandQueue.shift();
401
+ const command = state ? 'ON' : 'OFF';
402
+
403
+ try {
404
+ await new Promise((resolve, reject) => {
405
+ node.mqttClient.publish(node.commandTopic, command, { qos: 1 }, (err) => {
406
+ if (err) {
407
+ reject(err);
408
+ } else {
409
+ resolve();
410
+ }
411
+ });
412
+ });
413
+
414
+ node.log(`MQTT命令已发送: ${command} → ${node.commandTopic}`);
306
415
  node.currentState = state;
307
416
  node.updateStatus();
417
+
418
+ // 队列间隔40ms(避免MQTT broker过载)
419
+ if (node.commandQueue.length > 0) {
420
+ await new Promise(resolve => setTimeout(resolve, 40));
421
+ }
422
+ } catch (err) {
423
+ node.error(`发布MQTT命令失败: ${err.message}`);
308
424
  }
309
- });
425
+ }
426
+
427
+ node.isProcessingCommand = false;
310
428
  };
311
429
 
312
- // 发送控制指令到物理开关面板(控制指示灯等)
313
- node.sendCommandToPanel = async function(state) {
430
+ // 发送控制指令到物理开关面板(控制指示灯等)- 入队
431
+ node.sendCommandToPanel = function(state) {
314
432
  if (!node.isRs485Connected) {
315
- node.warn('RS-485未连接');
433
+ node.warn('RS-485未连接,无法发送指示灯反馈');
316
434
  return;
317
435
  }
318
436
 
319
- try {
320
- // 使用轻量级协议构建单灯控制指令
321
- // localAddr=0x01(本机),deviceAddr=开关ID,channel=按钮编号,state=开关状态
322
- const command = protocol.buildSingleLightCommand(
323
- 0x01, // 本机地址(网关地址)
324
- node.config.switchId,
325
- node.config.buttonNumber,
326
- state
327
- );
437
+ // 加入LED反馈队列
438
+ node.ledFeedbackQueue.push(state);
439
+
440
+ // 启动队列处理
441
+ node.processLedFeedbackQueue();
442
+ };
443
+
444
+ // 处理LED反馈队列(40ms间隔发送)
445
+ node.processLedFeedbackQueue = async function() {
446
+ if (node.isProcessingLedFeedback || node.ledFeedbackQueue.length === 0) {
447
+ return;
448
+ }
449
+
450
+ node.isProcessingLedFeedback = true;
451
+
452
+ while (node.ledFeedbackQueue.length > 0) {
453
+ const state = node.ledFeedbackQueue.shift();
328
454
 
329
- // 发送到RS-485总线
330
- if (node.config.connectionType === "tcp") {
331
- // TCP模式
332
- if (node.rs485Client._port && node.rs485Client._port.socket) {
333
- node.rs485Client._port.socket.write(command);
334
- node.log(`发送控制指令(TCP): 开关${node.config.switchId} 按钮${node.config.buttonNumber} ${state ? 'ON' : 'OFF'}`);
455
+ try {
456
+ // 使用轻量级协议构建单灯控制指令
457
+ // localAddr=0x01(本机),deviceAddr=开关ID,channel=按钮编号,state=开关状态
458
+ const command = protocol.buildSingleLightCommand(
459
+ 0x01, // 本机地址(网关地址)
460
+ node.config.switchId,
461
+ node.config.buttonNumber,
462
+ state
463
+ );
464
+
465
+ // 发送到RS-485总线
466
+ if (node.config.connectionType === "tcp") {
467
+ // TCP模式
468
+ if (node.rs485Client._port && node.rs485Client._port.socket) {
469
+ node.rs485Client._port.socket.write(command);
470
+ node.log(`LED反馈已发送(TCP): 开关${node.config.switchId} 按钮${node.config.buttonNumber} ${state ? 'ON' : 'OFF'}`);
471
+ }
472
+ } else {
473
+ // 串口模式
474
+ if (node.rs485Client._port) {
475
+ node.rs485Client._port.write(command);
476
+ node.log(`LED反馈已发送(串口): 开关${node.config.switchId} 按钮${node.config.buttonNumber} ${state ? 'ON' : 'OFF'}`);
477
+ }
335
478
  }
336
- } else {
337
- // 串口模式
338
- if (node.rs485Client._port) {
339
- node.rs485Client._port.write(command);
340
- node.log(`发送控制指令(串口): 开关${node.config.switchId} 按钮${node.config.buttonNumber} ${state ? 'ON' : 'OFF'}`);
479
+
480
+ // 队列间隔40ms
481
+ if (node.ledFeedbackQueue.length > 0) {
482
+ await new Promise(resolve => setTimeout(resolve, 40));
341
483
  }
484
+ } catch (err) {
485
+ node.error(`发送LED反馈失败: ${err.message}`);
342
486
  }
343
- } catch (err) {
344
- node.error(`发送控制指令失败: ${err.message}`);
345
487
  }
488
+
489
+ node.isProcessingLedFeedback = false;
346
490
  };
347
491
 
348
492
  // 更新节点状态显示
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、智能MQTT连接(自动fallback HassOS/Docker环境)、Home Assistant自动发现和多品牌开关面板,生产级稳定版本",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {