node-red-contrib-symi-modbus 2.3.0 → 2.5.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
@@ -507,37 +507,80 @@ docker run --privileged ...
507
507
  - **容错能力**:Modbus从站离线不影响其他从站,MQTT断线自动重连
508
508
 
509
509
 
510
- > **最新版本 v2.3.0** - Symi轻量级协议完美支持,8键开关面板按键识别正常,MQTT双向同步稳定
510
+ > **最新版本 v2.5.0** - Mac串口兼容性修复,互斥锁优化,确保各平台稳定运行
511
511
 
512
512
  ## 版本更新
513
513
 
514
- ### v2.3.0 (2025-10-20) - Symi轻量级协议支持完善
514
+ ### v2.5.0 (2025-10-20) - Mac串口兼容性与互斥锁优化
515
515
 
516
516
  **核心修复**:
517
- - 修复从站开关节点对Symi轻量级协议的解析支持
518
- - 协议检测同时支持SET(0x03)和REPORT(0x04)类型帧(面板按键发送SET类型)
519
- - 添加详细的RS485数据接收日志(十六进制输出)
520
- - 增强连接状态提示(RS485连接成功会明确显示)
521
- - 添加完整的按键事件检测日志(设备地址、通道、状态匹配)
522
- - 确保从站开关与主站轮询的双向同步不冲突
523
-
524
- **协议修复说明**:
525
- - Symi 8键开关面板按键时发送 `7E 01 03 0F 01 00 01 [01-08] 00 00 00 00 01 [CRC] 7D`
526
- - 数据类型为 `0x03`(SET),不是 `0x04`(REPORT)
527
- - 旧版本只检测REPORT类型,导致无法识别物理按键
528
- - 新版本同时支持SET和REPORT,完美兼容所有Symi协议设备
529
-
530
- **调试增强**:
531
- - RS485数据接收时输出原始十六进制数据
532
- - 协议解析成功输出设备地址、通道、数据类型、操作码
533
- - 按键匹配成功输出完整的状态信息
534
- - 按键不匹配时输出期望值和实际值对比
535
- - 所有日志级别合理,不会造成系统负担
517
+ - 修复Mac串口设备选择问题,明确使用 `/dev/cu.*` 设备
518
+ - 延长互斥锁等待时间(从500ms到6秒),匹配Modbus 5秒超时
519
+ - 优化锁等待间隔(从10ms到50ms),降低CPU占用
520
+ - 添加Mac串口说明到UI(cu.usbserial-xxx, cu.wchusbserial*)
521
+ - 更新串口placeholder提示,包含Mac设备示例
522
+ - 改进超时错误消息,提示可能是轮询阻塞
523
+
524
+ **Mac串口设备说明**:
525
+ Mac上USB转串口会创建两种设备:
526
+ - `/dev/tty.*` - Terminal设备(用于等待incoming连接)**不要用**
527
+ - `/dev/cu.*` - Call-out设备(用于主动连接)**正确选择**
528
+
529
+ 例如:
530
+ ```
531
+ /dev/cu.wchusbserial83420 ← 使用这个
532
+ ✅ /dev/cu.usbserial-83420 ← 或这个
533
+ /dev/tty.wchusbserial83420 ← 不要用(会报Resource busy错误)
534
+ ❌ /dev/tty.usbserial-83420 ← 不要用
535
+ ```
536
+
537
+ **互斥锁优化说明**:
538
+ - 旧版本:写入操作等待锁最多500ms,但Modbus超时是5000ms
539
+ - 问题:如果轮询超时(5秒),写入操作在500ms就放弃,导致"等待锁释放超时"
540
+ - 修复:写入操作等待锁最多6秒,确保能等到轮询超时后锁释放
541
+ - 等待间隔从10ms增加到50ms,降低CPU占用
542
+
543
+ **测试验证**:
544
+ - Mac串口连接正常(cu.wchusbserial83420)
545
+ - 轮询和写入操作不再冲突
546
+ - 写入不再提前超时
547
+ - 适合Windows/Linux/macOS平台长期稳定运行
548
+
549
+ ---
550
+
551
+ ### v2.4.0 (2025-10-20) - TCP帧缓冲与命令队列机制
552
+
553
+ **核心修复**:
554
+ - 添加TCP流式数据帧缓冲区,正确处理分包和粘包问题
555
+ - 帧边界检测(7E开始,7D结束),确保完整帧解析
556
+ - 添加MQTT命令队列,防止多个按键同时按下造成冲突
557
+ - 添加LED指示灯反馈队列(40ms间隔),避免RS-485总线过载
558
+ - 增强RS485连接状态提示(明确显示TCP/串口连接成功和监听状态)
559
+ - 分离TCP和串口数据处理逻辑(TCP使用缓冲区,串口直接处理)
560
+
561
+ **TCP帧缓冲机制**:
562
+ - TCP是流式协议,一个完整的协议帧可能被分成多个数据包
563
+ - 或者多个帧合并在一个数据包中
564
+ - 使用缓冲区累积数据,查找帧头(0x7E)和帧尾(0x7D)
565
+ - 提取完整帧后再解析,确保CRC校验正确
566
+
567
+ **队列机制**:
568
+ - **命令队列**:多个按键同时按下时,按顺序发送MQTT命令(40ms间隔)
569
+ - **LED反馈队列**:继电器状态反馈到指示灯时,按顺序发送(40ms间隔)
570
+ - 避免MQTT broker和RS-485总线过载
571
+ - 确保每个命令和反馈都被正确处理
572
+
573
+ **工作流程**:
574
+ ```
575
+ 物理按键 → TCP数据 → 帧缓冲区 → 提取完整帧 → 解析 → 命令队列 → MQTT发布
576
+ 继电器状态变化 → MQTT状态 → LED反馈队列 → RS-485总线 → 指示灯同步
577
+ ```
536
578
 
537
579
  **测试验证**:
538
- - 本地Node-RED环境测试通过
539
- - 8键开关面板按键识别正常
540
- - MQTT双向同步稳定
580
+ - TCP网关(192.168.2.110:1031)连接正常
581
+ - 帧缓冲区正确处理分包数据
582
+ - 多个按键同时按下不丢失
583
+ - LED指示灯同步稳定
541
584
  - 适合Linux工控机24/7长期稳定运行
542
585
 
543
586
  ---
@@ -311,7 +311,7 @@
311
311
  <label for="node-input-serialPort" style="width: 110px;"><i class="fa fa-terminal"></i> 串口</label>
312
312
  <div style="display: inline-block; width: calc(70% - 110px);">
313
313
  <div style="display: flex; gap: 5px; align-items: center;">
314
- <input type="text" id="node-input-serialPort" placeholder="COM1, /dev/ttyUSB0, /dev/ttyS1" style="flex: 1; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
314
+ <input type="text" id="node-input-serialPort" placeholder="COM1, /dev/ttyUSB0, /dev/cu.usbserial-xxx" style="flex: 1; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
315
315
  <select id="port-list-master" style="flex: 1; padding: 5px; font-family: monospace; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; display: none;">
316
316
  <option value="">-- 选择检测到的串口 --</option>
317
317
  </select>
@@ -324,6 +324,7 @@
324
324
  <span style="color: #555; font-size: 11px;">
325
325
  • <strong>Windows</strong>: COM1, COM2, COM3...<br>
326
326
  • <strong>Linux</strong>: /dev/ttyUSB0, /dev/ttyS0, /dev/ttyAMA0<br>
327
+ • <strong>macOS</strong>: <span style="background: #fff3cd; padding: 1px 4px; border-radius: 2px; font-weight: 600;">/dev/cu.*</span> (例如 /dev/cu.usbserial-xxx, /dev/cu.wchusbserial*)<br>
327
328
  • <strong>HassOS</strong>: 插件配置添加设备映射即可<br>
328
329
  • <strong>Docker</strong>: 需映射设备 <code style="background: #c8e6c9; padding: 2px 6px; border-radius: 3px; font-size: 10px;">--device=/dev/ttyUSB0</code> 或 <code style="background: #c8e6c9; padding: 2px 6px; border-radius: 3px; font-size: 10px;">--privileged</code>
329
330
  </span>
@@ -780,15 +780,15 @@ module.exports = function(RED) {
780
780
  return;
781
781
  }
782
782
 
783
- // 等待锁释放(最多等待500ms)
784
- const maxWait = 500;
783
+ // 等待锁释放(最多等待6秒,因为Modbus超时是5秒)
784
+ const maxWait = 6000;
785
785
  const startWait = Date.now();
786
786
  while (node.modbusLock && (Date.now() - startWait) < maxWait) {
787
- await new Promise(resolve => setTimeout(resolve, 10));
787
+ await new Promise(resolve => setTimeout(resolve, 50));
788
788
  }
789
789
 
790
790
  if (node.modbusLock) {
791
- node.error(`写入线圈超时: 从站${slaveId} 线圈${coil} (等待锁释放超时)`);
791
+ node.error(`写入线圈超时: 从站${slaveId} 线圈${coil} (等待锁释放超时,轮询可能阻塞)`);
792
792
  return;
793
793
  }
794
794
 
@@ -834,15 +834,15 @@ module.exports = function(RED) {
834
834
  return;
835
835
  }
836
836
 
837
- // 等待锁释放(最多等待500ms)
838
- const maxWait = 500;
837
+ // 等待锁释放(最多等待6秒,因为Modbus超时是5秒)
838
+ const maxWait = 6000;
839
839
  const startWait = Date.now();
840
840
  while (node.modbusLock && (Date.now() - startWait) < maxWait) {
841
- await new Promise(resolve => setTimeout(resolve, 10));
841
+ await new Promise(resolve => setTimeout(resolve, 50));
842
842
  }
843
843
 
844
844
  if (node.modbusLock) {
845
- node.error(`批量写入线圈超时: 从站${slaveId} 起始线圈${startCoil} (等待锁释放超时)`);
845
+ node.error(`批量写入线圈超时: 从站${slaveId} 起始线圈${startCoil} (等待锁释放超时,轮询可能阻塞)`);
846
846
  return;
847
847
  }
848
848
 
@@ -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`;
@@ -235,20 +246,75 @@ module.exports = function(RED) {
235
246
 
236
247
  // 监听RS-485数据
237
248
  if (node.config.connectionType === "tcp") {
238
- // TCP模式:监听socket数据
249
+ // TCP模式:监听socket数据(需要处理帧边界)
239
250
  if (node.rs485Client._port && node.rs485Client._port.socket) {
240
251
  const socket = node.rs485Client._port.socket;
241
252
  socket.on('data', (data) => {
242
- node.handleRs485Data(data);
253
+ node.handleTcpData(data);
243
254
  });
255
+ node.log('TCP数据监听已启动(使用帧缓冲区)');
244
256
  }
245
257
  } else {
246
- // 串口模式:监听串口数据
258
+ // 串口模式:监听串口数据(串口通常自动处理帧边界)
247
259
  if (node.rs485Client._port) {
248
260
  node.rs485Client._port.on('data', (data) => {
249
261
  node.handleRs485Data(data);
250
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);
251
314
  }
315
+ } catch (err) {
316
+ node.error(`TCP数据处理错误: ${err.message}`);
317
+ node.frameBuffer = Buffer.alloc(0); // 错误时清空缓冲区
252
318
  }
253
319
  };
254
320
 
@@ -307,59 +373,120 @@ module.exports = function(RED) {
307
373
  }
308
374
  };
309
375
 
310
- // 发送MQTT命令到继电器
376
+ // 发送MQTT命令到继电器(入队)
311
377
  node.sendMqttCommand = function(state) {
312
378
  if (!node.mqttClient || !node.mqttClient.connected) {
313
379
  node.warn('MQTT未连接,无法发送命令');
314
380
  return;
315
381
  }
316
382
 
317
- const command = state ? 'ON' : 'OFF';
318
- node.mqttClient.publish(node.commandTopic, command, { qos: 1 }, (err) => {
319
- if (err) {
320
- node.error(`发布MQTT命令失败: ${err.message}`);
321
- } else {
322
- 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}`);
323
415
  node.currentState = state;
324
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}`);
325
424
  }
326
- });
425
+ }
426
+
427
+ node.isProcessingCommand = false;
327
428
  };
328
429
 
329
- // 发送控制指令到物理开关面板(控制指示灯等)
330
- node.sendCommandToPanel = async function(state) {
430
+ // 发送控制指令到物理开关面板(控制指示灯等)- 入队
431
+ node.sendCommandToPanel = function(state) {
331
432
  if (!node.isRs485Connected) {
332
- node.warn('RS-485未连接');
433
+ node.warn('RS-485未连接,无法发送指示灯反馈');
333
434
  return;
334
435
  }
335
436
 
336
- try {
337
- // 使用轻量级协议构建单灯控制指令
338
- // localAddr=0x01(本机),deviceAddr=开关ID,channel=按钮编号,state=开关状态
339
- const command = protocol.buildSingleLightCommand(
340
- 0x01, // 本机地址(网关地址)
341
- node.config.switchId,
342
- node.config.buttonNumber,
343
- state
344
- );
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();
345
454
 
346
- // 发送到RS-485总线
347
- if (node.config.connectionType === "tcp") {
348
- // TCP模式
349
- if (node.rs485Client._port && node.rs485Client._port.socket) {
350
- node.rs485Client._port.socket.write(command);
351
- 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
+ }
352
478
  }
353
- } else {
354
- // 串口模式
355
- if (node.rs485Client._port) {
356
- node.rs485Client._port.write(command);
357
- 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));
358
483
  }
484
+ } catch (err) {
485
+ node.error(`发送LED反馈失败: ${err.message}`);
359
486
  }
360
- } catch (err) {
361
- node.error(`发送控制指令失败: ${err.message}`);
362
487
  }
488
+
489
+ node.isProcessingLedFeedback = false;
363
490
  };
364
491
 
365
492
  // 更新节点状态显示
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、智能MQTT连接(自动fallback HassOS/Docker环境)、Home Assistant自动发现和多品牌开关面板,生产级稳定版本",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {