node-red-contrib-symi-modbus 2.6.1 → 2.6.3

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
@@ -18,7 +18,10 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成。
18
18
  - **32路继电器**:每台设备支持32个线圈(继电器通道)
19
19
  - **智能轮询**:从站上报时自动暂停轮询,优先处理数据,完成后继续轮询
20
20
  - **灵活配置**:可自定义轮询间隔(100-10000ms,默认200ms)、线圈范围、从站地址
21
- - **MQTT集成**:自动生成Home Assistant兼容的MQTT发现消息
21
+ - **🔥 双模式支持**:
22
+ - **本地模式**:纯串口通信,无需MQTT,断网也能稳定运行
23
+ - **MQTT模式**:可选接入Home Assistant等第三方平台
24
+ - **MQTT集成**(可选):自动生成Home Assistant兼容的MQTT发现消息
22
25
  - **实时状态**:实时监控和控制继电器状态
23
26
  - **主从模式**:提供主站节点和从站控制节点
24
27
  - **稳定可靠**:完整的内存管理和错误处理,适合工控机长期稳定运行
@@ -39,8 +42,36 @@ node-red-restart
39
42
  2. 搜索 `node-red-contrib-symi-modbus`
40
43
  3. 点击安装
41
44
 
42
- ### 2. 配置MQTT服务器
45
+ ### 2. 选择运行模式
43
46
 
47
+ 本节点支持两种运行模式,根据需求选择:
48
+
49
+ #### 模式1:本地模式(推荐用于纯串口控制)
50
+
51
+ **适用场景**:
52
+ - 无需对接第三方平台
53
+ - 断网环境下稳定运行
54
+ - 纯本地化控制,不受网络影响
55
+
56
+ **配置方法**:
57
+ 1. 主站节点:不启用MQTT或不配置MQTT服务器
58
+ 2. 从站开关节点:不配置MQTT服务器
59
+ 3. 将从站开关节点的输出连线到主站节点的输入
60
+
61
+ **优势**:
62
+ - ✅ 断网也能稳定运行
63
+ - ✅ 不依赖外部服务
64
+ - ✅ 响应速度更快
65
+ - ✅ 配置更简单
66
+
67
+ #### 模式2:MQTT模式(推荐用于Home Assistant集成)
68
+
69
+ **适用场景**:
70
+ - 需要对接Home Assistant等平台
71
+ - 需要远程控制
72
+ - 需要状态持久化
73
+
74
+ **配置方法**:
44
75
  1. 拖拽任意节点到流程画布
45
76
  2. 双击节点,找到"MQTT服务器"字段
46
77
  3. 点击编辑按钮,填写MQTT服务器信息:
@@ -48,6 +79,11 @@ node-red-restart
48
79
  - 用户名/密码: 按需填写
49
80
  - 基础主题: `modbus/relay` (默认)
50
81
 
82
+ **优势**:
83
+ - ✅ Home Assistant自动发现
84
+ - ✅ 支持远程控制
85
+ - ✅ 状态持久化存储
86
+
51
87
  ### 3. 配置主站节点
52
88
 
53
89
  1. 拖拽 **Modbus主站** 节点到流程画布
@@ -71,7 +107,9 @@ node-red-restart
71
107
  - 从站地址: `10` (默认,可添加多个,如10、11、12、13)
72
108
  - 线圈范围: `0-31`
73
109
  - 轮询间隔: `200ms` (默认,支持100-10000ms)
74
- 4. 启用MQTT并选择MQTT服务器配置
110
+ 4. MQTT配置(可选):
111
+ - 本地模式:不启用MQTT或留空
112
+ - MQTT模式:启用MQTT并选择MQTT服务器配置
75
113
  5. 部署流程
76
114
 
77
115
  ### 4. 配置RS-485连接配置节点(用于从站开关)
@@ -231,8 +269,14 @@ node-red-restart
231
269
  - **内存管理**:自动清理缓存,释放无用对象
232
270
  - **事件监听器清理**:关闭时移除所有监听器,防止内存泄漏
233
271
  - **智能日志限流**:错误日志10分钟输出一次,避免日志刷屏
234
- - **自动重连**:Modbus和MQTT断线自动重连(5秒间隔)
272
+ - **智能重连机制**:
273
+ - Modbus连接断开自动重连(指数退避:5秒→10秒→20秒...最大60秒)
274
+ - MQTT连接断开自动重连(支持多地址fallback)
275
+ - 串口拔插自动检测并重连
276
+ - TCP网络故障自动恢复
277
+ - 连接前彻底清理旧实例,避免资源泄漏
235
278
  - **互斥锁机制**:防止读写冲突导致的数据异常
279
+ - **Keep-Alive心跳**:TCP连接启用30秒心跳检测
236
280
 
237
281
  ## 技术规格
238
282
 
@@ -435,53 +479,40 @@ msg.payload = 1; // 或 0
435
479
 
436
480
  ## 项目信息
437
481
 
438
- **版本**: v2.6.1
482
+ **版本**: v2.6.3
439
483
 
440
484
  **核心功能**:
441
485
  - 支持多种Modbus协议(Telnet ASCII、RTU over TCP、Modbus TCP、Modbus RTU串口)
442
486
  - 多设备轮询(最多10台从站,每台32路继电器,轮询间隔100-10000ms可调)
443
487
  - Symi私有协议自动识别(支持两种485开关控制方式)
444
488
  - 智能轮询暂停机制(从站上报时自动暂停,处理完成后恢复)
445
- - MQTT集成(Home Assistant自动发现,实体唯一性保证,QoS=0高性能发布)
489
+ - 🔥 **双模式支持**(本地模式和MQTT模式可选切换,断网也能稳定运行)
490
+ - MQTT集成(可选启用,Home Assistant自动发现,实体唯一性保证,QoS=0高性能发布)
446
491
  - 物理开关面板双向同步(亖米协议支持,LED反馈同步)
447
492
  - 共享连接架构(多个从站开关节点共享同一个串口/TCP连接,支持500+节点)
448
- - 长期稳定运行(内存管理、自动重连、错误日志限流、异步MQTT发布)
493
+ - 长期稳定运行(内存管理、智能重连、错误日志限流、异步MQTT发布)
449
494
 
450
495
  **技术栈**:
451
496
  - modbus-serial: ^8.0.23(内部封装serialport,支持TCP和串口)
452
497
  - serialport: ^12.0.0(原生串口通信)
453
- - mqtt: ^5.14.1(最新稳定版)
498
+ - mqtt: ^5.14.1(最新稳定版,可选依赖)
454
499
  - Node.js: >=14.0.0
455
500
  - Node-RED: >=2.0.0
456
501
 
457
- **最新更新(v2.6.1)**:
458
- - **串口自动重连**:断开后5秒自动重连,确保长期稳定运行
459
- - **连接状态管理**:完善的串口和TCP连接状态监控
460
- - **错误恢复**:串口拔插后自动恢复,无需重启Node-RED
461
-
462
- **v2.6.0更新**:
463
- - **双模式支持**:完整支持开关按钮和场景按钮两种模式
464
- - 开关按钮:独立开/关控制 + SET协议LED反馈
465
- - 场景按钮:Toggle切换控制 + REPORT协议LED反馈
466
- - **精确LED反馈**:使用原始设备地址和通道,确保LED反馈到正确按键
467
- - **完美状态同步**:
468
- - 物理按键控制 → LED同步 ✓
469
- - Home Assistant远程控制 → LED同步 ✓
470
- - 双向同步,实时响应
471
- - **协议优化**:
472
- - 开关模式使用SET协议(0x03)
473
- - 场景模式使用REPORT协议(0x04)
474
- - 自动fallback机制确保HA远程控制正常
475
- - **性能提升**:
476
- - 全局防抖机制(200ms)避免重复触发
477
- - 基于面板ID的固定延迟避免TCP冲突
478
- - 防死循环机制确保系统稳定
479
- - 支持500+节点大规模部署
480
- - **用户体验**:
481
- - 继电器路数优化:直接输入1-32路
482
- - 完善串口配置:支持所有串口参数
483
- - 智能日志输出:减少系统负担
484
- - 长期稳定运行:工控机7x24小时验证
502
+ **最新更新(v2.6.3)**:
503
+ - **🔥 MQTT可选配置**:完全兼容无MQTT环境
504
+ - 本地模式:纯串口通信,断网也能稳定运行
505
+ - MQTT模式:可选接入Home Assistant等第三方平台
506
+ - 智能切换:MQTT断开时自动切换到本地模式
507
+ - 状态显示:清晰区分当前运行模式
508
+ - **改进的错误提示**:
509
+ - MQTT未配置时不再报错,提示使用本地模式
510
+ - 友好的配置引导信息
511
+ - 更清晰的节点状态指示
512
+ - **增强的稳定性**:
513
+ - 支持纯本地化部署,不受网络影响
514
+ - 工控机断网环境下持续稳定运行
515
+ - 保持所有原有功能的兼容性
485
516
 
486
517
  **性能优化**:
487
518
  - 轮询间隔优化:修复间隔计算逻辑,确保每个从站使用正确的轮询间隔
@@ -4,7 +4,7 @@
4
4
  color: '#3FADB5',
5
5
  defaults: {
6
6
  name: {value: "Modbus主站"},
7
- modbusServer: {value: "", type: "modbus-server-config"},
7
+ modbusServer: {value: "", type: "modbus-server-config", required: true},
8
8
  // 从站配置列表(数组)
9
9
  slaves: {value: [{
10
10
  address: 10,
@@ -12,9 +12,9 @@
12
12
  coilEnd: 31,
13
13
  pollInterval: 200
14
14
  }]},
15
- // MQTT配置(引用config节点)
16
- enableMqtt: {value: true},
17
- mqttServer: {value: "", type: "mqtt-server-config"}
15
+ // MQTT配置(可选)
16
+ enableMqtt: {value: false}, // 默认不启用MQTT(本地模式)
17
+ mqttServer: {value: "", type: "mqtt-server-config", required: false}
18
18
  },
19
19
  inputs: 1,
20
20
  outputs: 1,
@@ -215,19 +215,25 @@
215
215
  </button>
216
216
  </div>
217
217
 
218
- <!-- MQTT配置 -->
218
+ <!-- MQTT配置(可选) -->
219
219
  <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
220
220
  <div class="form-row">
221
221
  <label style="width: 100%; margin-bottom: 8px;">
222
222
  <i class="fa fa-cloud" style="color: #ff9800;"></i>
223
223
  <span style="font-size: 14px; font-weight: 600; color: #333;">MQTT集成配置</span>
224
+ <span style="margin-left: 8px; padding: 2px 8px; background: #e3f2fd; color: #1976d2; border-radius: 3px; font-size: 11px; font-weight: 500;">可选</span>
224
225
  </label>
226
+ <div style="font-size: 11px; color: #555; padding: 10px 12px; background: linear-gradient(135deg, #e8f5e9 0%, #f1f8f4 100%); border-left: 4px solid #4caf50; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 12px;">
227
+ <strong>💡 运行模式选择:</strong><br>
228
+ <strong style="color: #2e7d32;">本地模式</strong>(默认):纯串口通信,断网也能稳定运行,不依赖外部服务<br>
229
+ <strong style="color: #1976d2;">MQTT模式</strong>:可接入Home Assistant等第三方平台,支持远程控制
230
+ </div>
225
231
  </div>
226
232
 
227
233
  <div class="form-row">
228
234
  <label for="node-input-enableMqtt" style="width: 110px;"><i class="fa fa-toggle-on"></i> 启用MQTT</label>
229
235
  <input type="checkbox" id="node-input-enableMqtt" style="width: auto; margin-left: 5px; transform: scale(1.3); cursor: pointer;">
230
- <span style="margin-left: 15px; font-size: 11px; color: #666; font-style: italic;">用于Home Assistant集成和远程控制</span>
236
+ <span style="margin-left: 15px; font-size: 11px; color: #666; font-style: italic;">勾选后切换到MQTT模式</span>
231
237
  </div>
232
238
 
233
239
  <div class="form-row form-row-mqtt">
@@ -131,6 +131,7 @@ module.exports = function(RED) {
131
131
  node.isConnected = false;
132
132
  node.pollTimer = null;
133
133
  node.reconnectTimer = null;
134
+ node.reconnectAttempts = 0; // 重连尝试次数
134
135
  node.currentSlaveIndex = 0;
135
136
  node.deviceStates = {}; // 存储每个设备的状态
136
137
  node.mqttClient = null;
@@ -176,6 +177,29 @@ module.exports = function(RED) {
176
177
 
177
178
  // 连接Modbus
178
179
  node.connectModbus = async function() {
180
+ // 清除旧的重连定时器
181
+ if (node.reconnectTimer) {
182
+ clearTimeout(node.reconnectTimer);
183
+ node.reconnectTimer = null;
184
+ }
185
+
186
+ // 关闭旧连接(确保完全释放)
187
+ if (node.client && node.client.isOpen) {
188
+ try {
189
+ node.log('关闭旧的Modbus连接...');
190
+ await new Promise((resolve) => {
191
+ node.client.close(() => {
192
+ resolve();
193
+ });
194
+ });
195
+ } catch (err) {
196
+ node.warn(`关闭旧连接时出错: ${err.message}`);
197
+ }
198
+ }
199
+
200
+ // 创建新的Modbus客户端实例
201
+ node.client = new ModbusRTU();
202
+
179
203
  try {
180
204
  if (node.config.connectionType === "tcp") {
181
205
  // 验证TCP主机地址
@@ -208,12 +232,15 @@ module.exports = function(RED) {
208
232
 
209
233
  node.log(`TCP网关连接成功(${modeNames[tcpMode]}): ${node.config.tcpHost}:${node.config.tcpPort}`);
210
234
  } else {
235
+ node.log(`正在连接串口: ${node.config.serialPort} @ ${node.config.serialBaudRate || 9600}bps...`);
236
+
211
237
  await node.client.connectRTUBuffered(node.config.serialPort, {
212
238
  baudRate: node.config.serialBaudRate || 9600,
213
239
  dataBits: node.config.serialDataBits || 8,
214
240
  stopBits: node.config.serialStopBits || 1,
215
241
  parity: node.config.serialParity || 'none'
216
242
  });
243
+
217
244
  node.log(`串口Modbus连接成功: ${node.config.serialPort} @ ${node.config.serialBaudRate || 9600}bps`);
218
245
  }
219
246
 
@@ -221,7 +248,9 @@ module.exports = function(RED) {
221
248
  const timeout = node.config.connectionType === "serial" ? 10000 : 5000;
222
249
  node.client.setTimeout(timeout);
223
250
  node.log(`Modbus超时设置: ${timeout}ms`);
251
+
224
252
  node.isConnected = true;
253
+ node.reconnectAttempts = 0; // 重置重连计数
225
254
  node.log(`Modbus已连接: ${node.config.connectionType === "tcp" ? `${node.config.tcpHost}:${node.config.tcpPort}` : node.config.serialPort}`);
226
255
  node.updateNodeStatus();
227
256
 
@@ -239,7 +268,6 @@ module.exports = function(RED) {
239
268
  }
240
269
 
241
270
  // 立即启动轮询(不等待MQTT连接)
242
- // 修复:确保轮询在Modbus连接成功后立即启动,不依赖MQTT状态
243
271
  if (!node.pollTimer) {
244
272
  node.log('Modbus连接成功,立即启动轮询...');
245
273
  node.startPolling();
@@ -252,12 +280,17 @@ module.exports = function(RED) {
252
280
  node.isConnected = false;
253
281
  node.updateNodeStatus();
254
282
 
255
- // 5秒后重试连接(使用定时器避免递归)
283
+ // 使用指数退避策略重试(5秒、10秒、20秒...最大60秒)
256
284
  if (!node.isClosing && !node.reconnectTimer) {
285
+ const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000);
286
+ node.reconnectAttempts++;
287
+
288
+ node.log(`${delay/1000}秒后重试Modbus连接(第${node.reconnectAttempts}次)...`);
289
+
257
290
  node.reconnectTimer = setTimeout(() => {
258
291
  node.reconnectTimer = null;
259
292
  node.connectModbus();
260
- }, 5000);
293
+ }, delay);
261
294
  }
262
295
  }
263
296
  };
@@ -339,13 +372,15 @@ module.exports = function(RED) {
339
372
  // 连接MQTT(带智能重试和fallback)
340
373
  node.connectMqtt = function() {
341
374
  if (!node.config.enableMqtt) {
342
- node.log('MQTT未启用,跳过MQTT连接');
375
+ node.log('MQTT未启用 - 使用纯本地模式(仅串口通信)');
376
+ node.log('提示:如需Home Assistant集成,请在节点配置中启用MQTT');
343
377
  return;
344
378
  }
345
379
 
346
380
  // 验证MQTT broker配置
347
381
  if (!node.config.mqttBroker || node.config.mqttBroker.trim() === '') {
348
- node.warn('MQTT已启用但broker地址未配置,跳过MQTT连接');
382
+ node.warn('MQTT已启用但broker地址未配置 - 使用纯本地模式');
383
+ node.warn('提示:请在MQTT服务器配置节点中设置broker地址,或禁用MQTT功能');
349
384
  return;
350
385
  }
351
386
 
@@ -913,12 +948,18 @@ module.exports = function(RED) {
913
948
  node.updateNodeStatus();
914
949
 
915
950
  // 检测是否是连接断开错误
916
- if (err.message &&
917
- (err.message.includes('ECONNRESET') ||
918
- err.message.includes('ETIMEDOUT') ||
919
- err.message.includes('ENOTCONN') ||
920
- err.message.includes('Port Not Open'))) {
951
+ const isConnectionError = err.message && (
952
+ err.message.includes('ECONNRESET') ||
953
+ err.message.includes('ETIMEDOUT') ||
954
+ err.message.includes('ENOTCONN') ||
955
+ err.message.includes('ECONNREFUSED') ||
956
+ err.message.includes('EHOSTUNREACH') ||
957
+ err.message.includes('Port Not Open') ||
958
+ err.message.includes('Port is not open') ||
959
+ err.message.includes('Cannot call write')
960
+ );
921
961
 
962
+ if (isConnectionError) {
922
963
  // 连接断开,尝试重连
923
964
  if (shouldLog) {
924
965
  node.warn('检测到连接断开,尝试重连...');
@@ -936,12 +977,17 @@ module.exports = function(RED) {
936
977
  }
937
978
  }
938
979
 
939
- // 尝试重连
980
+ // 使用指数退避策略重连
940
981
  if (!node.isClosing && !node.reconnectTimer) {
982
+ const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000);
983
+ node.reconnectAttempts++;
984
+
985
+ node.log(`${delay/1000}秒后重试Modbus连接(第${node.reconnectAttempts}次)...`);
986
+
941
987
  node.reconnectTimer = setTimeout(() => {
942
988
  node.reconnectTimer = null;
943
989
  node.connectModbus();
944
- }, 5000);
990
+ }, delay);
945
991
  }
946
992
  }
947
993
  }
@@ -5,9 +5,9 @@
5
5
  defaults: {
6
6
  name: {value: "从站开关"},
7
7
  // RS-485连接配置(共享配置节点)
8
- serialPortConfig: {value: "", type: "serial-port-config"},
9
- // MQTT配置
10
- mqttServer: {value: "", type: "mqtt-server-config"},
8
+ serialPortConfig: {value: "", type: "serial-port-config", required: true},
9
+ // MQTT配置(可选)
10
+ mqttServer: {value: "", type: "mqtt-server-config", required: false},
11
11
  // 开关面板配置
12
12
  switchBrand: {value: "symi"}, // 品牌选择
13
13
  buttonType: {value: "switch"}, // 按钮类型:switch=开关模式,scene=场景模式
@@ -55,18 +55,22 @@
55
55
  </div>
56
56
  </div>
57
57
 
58
- <!-- MQTT配置 -->
58
+ <!-- MQTT配置(可选) -->
59
59
  <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
60
60
  <div class="form-row">
61
61
  <label style="width: 100%; margin-bottom: 8px;">
62
62
  <i class="fa fa-cloud" style="color: #9c27b0;"></i>
63
63
  <span style="font-size: 14px; font-weight: 600; color: #333;">MQTT服务器配置</span>
64
+ <span style="margin-left: 8px; padding: 2px 8px; background: #e3f2fd; color: #1976d2; border-radius: 3px; font-size: 11px; font-weight: 500;">可选</span>
64
65
  </label>
66
+ <div style="font-size: 11px; color: #555; padding: 10px 12px; background: linear-gradient(135deg, #e3f2fd 0%, #f0f7ff 100%); border-left: 4px solid #2196f3; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
67
+ <strong>💡 提示:</strong>不配置MQTT则使用<strong>本地模式</strong>(纯串口通信),配置MQTT则使用<strong>MQTT模式</strong>(可接入Home Assistant等平台)
68
+ </div>
65
69
  </div>
66
70
 
67
71
  <div class="form-row">
68
72
  <label for="node-input-mqttServer" style="width: 110px;"><i class="fa fa-server"></i> MQTT服务器</label>
69
- <input type="text" id="node-input-mqttServer" placeholder="选择或添加MQTT服务器配置" style="width: calc(70% - 110px);">
73
+ <input type="text" id="node-input-mqttServer" placeholder="" style="width: calc(70% - 110px);">
70
74
  <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
71
75
  选择已配置的MQTT服务器(需与主站节点使用同一配置)
72
76
  </div>
@@ -309,24 +309,38 @@ module.exports = function(RED) {
309
309
  }
310
310
  };
311
311
 
312
- // 发送命令到继电器(通过MQTT,由主站节点统一处理)
312
+ // 发送命令到继电器(支持两种模式:MQTT模式和本地模式)
313
313
  node.sendMqttCommand = function(state) {
314
- if (!node.mqttClient || !node.mqttClient.connected) {
315
- node.warn('MQTT未连接,无法发送命令');
314
+ // 模式1:MQTT模式(通过MQTT发送命令,由主站节点统一处理)
315
+ if (node.mqttClient && node.mqttClient.connected) {
316
+ // 直接发送MQTT命令(不使用队列,立即发送)
317
+ // 主站节点会处理去重和防抖
318
+ const commandTopic = `${node.config.mqttBaseTopic}/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}/set`;
319
+ const payload = state ? 'ON' : 'OFF';
320
+
321
+ node.mqttClient.publish(commandTopic, payload, { qos: 1 }, (err) => {
322
+ if (err) {
323
+ node.error(`MQTT发送失败: ${err.message}`);
324
+ }
325
+ // 成功时不输出日志,减少总线负担
326
+ });
316
327
  return;
317
328
  }
318
329
 
319
- // 直接发送MQTT命令(不使用队列,立即发送)
320
- // 主站节点会处理去重和防抖
321
- const commandTopic = `${node.config.mqttBaseTopic}/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}/set`;
322
- const payload = state ? 'ON' : 'OFF';
323
-
324
- node.mqttClient.publish(commandTopic, payload, { qos: 1 }, (err) => {
325
- if (err) {
326
- node.error(`MQTT发送失败: ${err.message}`);
327
- }
328
- // 成功时不输出日志,减少总线负担
329
- });
330
+ // 模式2:本地模式(通过Node-RED消息输出,直接连线到主站节点)
331
+ // 当MQTT未连接时,通过节点输出发送控制命令
332
+ const msg = {
333
+ payload: {
334
+ cmd: "writeCoil",
335
+ slave: node.config.targetSlaveAddress,
336
+ coil: node.config.targetCoilNumber,
337
+ value: state
338
+ },
339
+ topic: `modbus/write/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}`
340
+ };
341
+
342
+ node.send(msg);
343
+ node.log(`本地模式:发送控制命令到从站${node.config.targetSlaveAddress} 线圈${node.config.targetCoilNumber} = ${state ? 'ON' : 'OFF'}`);
330
344
  };
331
345
 
332
346
  // 处理命令队列(防止多个按键同时按下造成冲突)
@@ -494,20 +508,40 @@ module.exports = function(RED) {
494
508
  // 更新节点状态显示
495
509
  node.updateStatus = function() {
496
510
  const rs485Status = node.isRs485Connected ? 'OK' : 'ERR';
497
- const mqttStatus = node.mqttClient && node.mqttClient.connected ? 'OK' : 'ERR';
498
511
  const state = node.currentState ? 'ON' : 'OFF';
499
-
500
- if (node.isRs485Connected && node.mqttClient && node.mqttClient.connected) {
501
- node.status({
502
- fill: node.currentState ? "green" : "grey",
503
- shape: "dot",
504
- text: `RS485-${rs485Status} MQTT-${mqttStatus} ${state}`
505
- });
512
+ const hasMqttConfig = node.config.mqttBroker && node.config.mqttBroker.trim() !== '';
513
+ const mqttConnected = node.mqttClient && node.mqttClient.connected;
514
+
515
+ // RS485连接正常
516
+ if (node.isRs485Connected) {
517
+ if (!hasMqttConfig) {
518
+ // 本地模式(未配置MQTT)
519
+ node.status({
520
+ fill: node.currentState ? "green" : "blue",
521
+ shape: "dot",
522
+ text: `本地模式 RS485-${rs485Status} ${state}`
523
+ });
524
+ } else if (mqttConnected) {
525
+ // MQTT模式(已配置且已连接)
526
+ node.status({
527
+ fill: node.currentState ? "green" : "grey",
528
+ shape: "dot",
529
+ text: `MQTT模式 ${state}`
530
+ });
531
+ } else {
532
+ // MQTT配置但未连接(使用本地模式)
533
+ node.status({
534
+ fill: node.currentState ? "green" : "blue",
535
+ shape: "ring",
536
+ text: `本地模式(MQTT断开) ${state}`
537
+ });
538
+ }
506
539
  } else {
540
+ // RS485连接失败
507
541
  node.status({
508
542
  fill: "red",
509
543
  shape: "ring",
510
- text: `RS485-${rs485Status} MQTT-${mqttStatus}`
544
+ text: `RS485连接失败`
511
545
  });
512
546
  }
513
547
  };
@@ -516,7 +550,8 @@ module.exports = function(RED) {
516
550
  node.connectMqtt = function() {
517
551
  // 验证MQTT broker配置
518
552
  if (!node.config.mqttBroker || node.config.mqttBroker.trim() === '') {
519
- node.warn('MQTT broker地址未配置,跳过MQTT连接');
553
+ node.log('MQTT未配置 - 使用本地模式(通过Node-RED连线控制)');
554
+ node.log('提示:将此节点连线到主站节点,即可实现本地控制');
520
555
  return;
521
556
  }
522
557
 
@@ -26,6 +26,8 @@ module.exports = function(RED) {
26
26
  node.connection = null; // TCP socket 或 SerialPort
27
27
  node.isOpening = false; // 标记是否正在打开连接
28
28
  node.isClosing = false; // 标记节点是否正在关闭
29
+ node.reconnectTimer = null; // 重连定时器
30
+ node.reconnectAttempts = 0; // 重连尝试次数
29
31
 
30
32
  // 数据监听器列表(每个从站开关节点注册一个)
31
33
  node.dataListeners = [];
@@ -37,11 +39,20 @@ module.exports = function(RED) {
37
39
  return;
38
40
  }
39
41
 
42
+ // 清除旧的重连定时器
43
+ if (node.reconnectTimer) {
44
+ clearTimeout(node.reconnectTimer);
45
+ node.reconnectTimer = null;
46
+ }
47
+
40
48
  try {
41
49
  node.connection = new net.Socket();
50
+ node.connection.setKeepAlive(true, 30000); // 启用TCP Keep-Alive,30秒间隔
51
+ node.connection.setTimeout(60000); // 60秒超时
42
52
 
43
53
  node.connection.connect(node.tcpPort, node.tcpHost, () => {
44
54
  node.log(`TCP连接已建立: ${node.tcpHost}:${node.tcpPort}`);
55
+ node.reconnectAttempts = 0; // 重置重连计数
45
56
  });
46
57
 
47
58
  // 监听数据(分发给所有注册的监听器)
@@ -56,21 +67,43 @@ module.exports = function(RED) {
56
67
  });
57
68
  });
58
69
 
70
+ // 监听超时
71
+ node.connection.on('timeout', () => {
72
+ node.warn('TCP连接超时,尝试重连...');
73
+ if (node.connection) {
74
+ node.connection.destroy();
75
+ }
76
+ });
77
+
59
78
  // 监听错误
60
79
  node.connection.on('error', (err) => {
61
80
  node.error(`TCP连接错误: ${err.message}`);
81
+ // 不在这里重连,在close事件中统一处理
62
82
  });
63
83
 
64
- // 监听关闭
65
- node.connection.on('close', () => {
66
- node.log('TCP连接已关闭');
67
- // 自动重连
68
- setTimeout(() => {
69
- if (node.dataListeners.length > 0) {
70
- node.log('尝试重新连接TCP...');
84
+ // 监听关闭(统一的重连入口)
85
+ node.connection.on('close', (hadError) => {
86
+ node.log(`TCP连接已关闭${hadError ? '(有错误)' : ''}`);
87
+
88
+ // 清理连接对象
89
+ if (node.connection) {
90
+ node.connection.removeAllListeners();
91
+ node.connection.destroy();
92
+ node.connection = null;
93
+ }
94
+
95
+ // 自动重连(如果不是主动关闭且有监听器在使用)
96
+ if (!node.isClosing && node.dataListeners.length > 0) {
97
+ const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000); // 指数退避,最大60秒
98
+ node.reconnectAttempts++;
99
+
100
+ node.log(`${delay/1000}秒后尝试重新连接TCP(第${node.reconnectAttempts}次)...`);
101
+
102
+ node.reconnectTimer = setTimeout(() => {
103
+ node.reconnectTimer = null;
71
104
  node.openTcpConnection();
72
- }
73
- }, 5000);
105
+ }, delay);
106
+ }
74
107
  });
75
108
  } catch (err) {
76
109
  node.error(`TCP连接初始化失败: ${err.message}`);
@@ -89,6 +122,25 @@ module.exports = function(RED) {
89
122
  return;
90
123
  }
91
124
 
125
+ // 清除旧的重连定时器
126
+ if (node.reconnectTimer) {
127
+ clearTimeout(node.reconnectTimer);
128
+ node.reconnectTimer = null;
129
+ }
130
+
131
+ // 清理旧的串口实例(确保完全释放)
132
+ if (node.connection) {
133
+ try {
134
+ node.connection.removeAllListeners();
135
+ if (node.connection.isOpen) {
136
+ node.connection.close();
137
+ }
138
+ } catch (err) {
139
+ // 忽略清理错误
140
+ }
141
+ node.connection = null;
142
+ }
143
+
92
144
  node.isOpening = true;
93
145
 
94
146
  try {
@@ -107,10 +159,24 @@ module.exports = function(RED) {
107
159
 
108
160
  if (err) {
109
161
  node.error(`串口打开失败: ${err.message}`);
162
+
163
+ // 打开失败时也要触发重连(如果有监听器在使用)
164
+ if (!node.isClosing && node.dataListeners.length > 0) {
165
+ const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000);
166
+ node.reconnectAttempts++;
167
+
168
+ node.log(`${delay/1000}秒后尝试重新打开串口(第${node.reconnectAttempts}次)...`);
169
+
170
+ node.reconnectTimer = setTimeout(() => {
171
+ node.reconnectTimer = null;
172
+ node.openSerialConnection();
173
+ }, delay);
174
+ }
110
175
  return;
111
176
  }
112
177
 
113
178
  node.log(`串口已打开: ${node.serialPort} @ ${node.baudRate}bps ${node.dataBits}${node.parity.charAt(0).toUpperCase()}${node.stopBits}`);
179
+ node.reconnectAttempts = 0; // 重置重连计数
114
180
 
115
181
  // 监听数据(分发给所有注册的监听器)
116
182
  node.connection.on('data', (data) => {
@@ -127,27 +193,55 @@ module.exports = function(RED) {
127
193
  // 监听错误
128
194
  node.connection.on('error', (err) => {
129
195
  node.error(`串口错误: ${err.message}`);
196
+ // 不在这里重连,在close事件中统一处理
130
197
  });
131
198
 
132
- // 监听关闭
133
- node.connection.on('close', () => {
134
- node.log('串口已关闭');
135
- // 自动重连(如果还有监听器在使用)
136
- setTimeout(() => {
137
- if (node.dataListeners.length > 0 && !node.isClosing) {
138
- node.log('检测到串口断开,5秒后尝试重新连接...');
139
- node.openSerialConnection();
199
+ // 监听关闭(统一的重连入口)
200
+ node.connection.on('close', (err) => {
201
+ if (err) {
202
+ node.log(`串口已关闭(错误: ${err.message})`);
203
+ } else {
204
+ node.log('串口已关闭');
205
+ }
206
+
207
+ // 清理连接对象
208
+ if (node.connection) {
209
+ try {
210
+ node.connection.removeAllListeners();
211
+ } catch (e) {
212
+ // 忽略清理错误
140
213
  }
141
- }, 5000);
142
- });
143
-
144
- // 监听断开连接
145
- node.connection.on('disconnect', () => {
146
- node.log('串口设备已拔出');
214
+ node.connection = null;
215
+ }
216
+
217
+ // 自动重连(如果不是主动关闭且有监听器在使用)
218
+ if (!node.isClosing && node.dataListeners.length > 0) {
219
+ const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000); // 指数退避,最大60秒
220
+ node.reconnectAttempts++;
221
+
222
+ node.log(`检测到串口断开,${delay/1000}秒后尝试重新连接(第${node.reconnectAttempts}次)...`);
223
+
224
+ node.reconnectTimer = setTimeout(() => {
225
+ node.reconnectTimer = null;
226
+ node.openSerialConnection();
227
+ }, delay);
228
+ }
147
229
  });
148
230
  });
149
231
  } catch (err) {
232
+ node.isOpening = false;
150
233
  node.error(`串口初始化失败: ${err.message}`);
234
+
235
+ // 初始化失败时也要触发重连
236
+ if (!node.isClosing && node.dataListeners.length > 0) {
237
+ const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000);
238
+ node.reconnectAttempts++;
239
+
240
+ node.reconnectTimer = setTimeout(() => {
241
+ node.reconnectTimer = null;
242
+ node.openSerialConnection();
243
+ }, delay);
244
+ }
151
245
  }
152
246
  };
153
247
 
@@ -241,25 +335,46 @@ module.exports = function(RED) {
241
335
  node.on('close', function(done) {
242
336
  node.isClosing = true; // 标记正在关闭,防止自动重连
243
337
 
338
+ // 清除重连定时器
339
+ if (node.reconnectTimer) {
340
+ clearTimeout(node.reconnectTimer);
341
+ node.reconnectTimer = null;
342
+ }
343
+
244
344
  // 清空所有监听器
245
345
  node.dataListeners = [];
246
346
 
247
347
  // 关闭连接
248
348
  if (node.connection) {
249
349
  if (node.connectionType === 'tcp') {
250
- if (!node.connection.destroyed) {
251
- node.connection.destroy();
350
+ try {
351
+ node.connection.removeAllListeners();
352
+ if (!node.connection.destroyed) {
353
+ node.connection.destroy();
354
+ }
355
+ } catch (err) {
356
+ node.warn(`关闭TCP连接时出错: ${err.message}`);
252
357
  }
358
+ node.connection = null;
253
359
  done();
254
360
  } else {
255
- if (node.connection.isOpen) {
256
- node.connection.close((err) => {
257
- if (err) {
258
- node.error(`串口关闭失败: ${err.message}`);
259
- }
361
+ try {
362
+ node.connection.removeAllListeners();
363
+ if (node.connection.isOpen) {
364
+ node.connection.close((err) => {
365
+ if (err) {
366
+ node.error(`串口关闭失败: ${err.message}`);
367
+ }
368
+ node.connection = null;
369
+ done();
370
+ });
371
+ } else {
372
+ node.connection = null;
260
373
  done();
261
- });
262
- } else {
374
+ }
375
+ } catch (err) {
376
+ node.warn(`关闭串口连接时出错: ${err.message}`);
377
+ node.connection = null;
263
378
  done();
264
379
  }
265
380
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.6.1",
4
- "description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、智能MQTT连接(自动fallback HassOS/Docker环境)、Home Assistant自动发现和物理开关面板双向同步,工控机长期稳定运行",
3
+ "version": "2.6.3",
4
+ "description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现和物理开关面板双向同步,工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {
7
7
  "test": "echo \"Error: no test specified\" && exit 1"