node-red-contrib-symi-modbus 2.5.2 → 2.5.4

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.
@@ -1,9 +1,10 @@
1
1
  module.exports = function(RED) {
2
2
  "use strict";
3
3
  const mqtt = require("mqtt");
4
- const ModbusRTU = require("modbus-serial");
5
- const net = require("net");
6
4
  const protocol = require("./lightweight-protocol");
5
+
6
+ // 全局防抖缓存:防止多个节点重复处理同一个按键事件
7
+ const globalDebounceCache = new Map(); // key: "switchId-buttonNumber", value: timestamp
7
8
 
8
9
  // 串口列表API - 支持Windows、Linux、macOS所有串口设备
9
10
  RED.httpAdmin.get('/modbus-slave-switch/serialports', async function(req, res) {
@@ -60,60 +61,65 @@ module.exports = function(RED) {
60
61
  function ModbusSlaveSwitchNode(config) {
61
62
  RED.nodes.createNode(this, config);
62
63
  const node = this;
63
-
64
+
65
+ // 获取RS-485连接配置节点
66
+ node.serialPortConfig = RED.nodes.getNode(config.serialPortConfig);
67
+ if (!node.serialPortConfig) {
68
+ node.error('未配置RS-485连接,请在节点配置中选择连接配置');
69
+ node.status({fill: "red", shape: "ring", text: "未配置连接"});
70
+ return;
71
+ }
72
+
64
73
  // 获取MQTT服务器配置节点
65
74
  node.mqttServerConfig = RED.nodes.getNode(config.mqttServer);
66
-
75
+
76
+ // 继电器路数转换:用户输入1-32路,Modbus使用0-31
77
+ const userCoilNumber = parseInt(config.targetCoilNumber) || 1; // 用户输入的路数(1-32)
78
+ const modbusCoilNumber = userCoilNumber - 1; // Modbus线圈编号(0-31)
79
+
67
80
  // 配置参数
68
81
  node.config = {
69
- // RS-485总线连接配置
70
- connectionType: config.connectionType,
71
- tcpHost: config.tcpHost,
72
- tcpPort: parseInt(config.tcpPort),
73
- serialPort: config.serialPort,
74
- serialBaudRate: parseInt(config.serialBaudRate),
75
- serialDataBits: parseInt(config.serialDataBits),
76
- serialStopBits: parseInt(config.serialStopBits),
77
- serialParity: config.serialParity,
78
- // 从config节点读取MQTT配置
82
+ // 从MQTT配置节点读取MQTT配置
79
83
  mqttBroker: node.mqttServerConfig ? node.mqttServerConfig.broker : "mqtt://localhost:1883",
80
84
  mqttUsername: node.mqttServerConfig ? node.mqttServerConfig.username : "",
81
85
  mqttPassword: node.mqttServerConfig ? (node.mqttServerConfig.credentials ? node.mqttServerConfig.credentials.password : "") : "",
82
86
  mqttBaseTopic: node.mqttServerConfig ? node.mqttServerConfig.baseTopic : "modbus/relay",
87
+ // 开关面板配置
83
88
  switchBrand: config.switchBrand || "symi", // 面板品牌(默认亖米)
89
+ buttonType: config.buttonType || "switch", // 按钮类型:switch=开关模式,scene=场景模式
84
90
  switchId: parseInt(config.switchId) || 0, // 开关ID(0-255,物理面板地址)
85
91
  buttonNumber: parseInt(config.buttonNumber) || 1, // 按钮编号(1-8)
86
92
  targetSlaveAddress: parseInt(config.targetSlaveAddress) || 10, // 目标继电器从站地址
87
- targetCoilNumber: parseInt(config.targetCoilNumber) || 0 // 目标继电器线圈编号
93
+ targetCoilNumber: modbusCoilNumber // 目标继电器线圈编号(0-31,从用户输入的1-32转换)
88
94
  };
89
-
95
+
90
96
  node.currentState = false;
91
97
  node.mqttClient = null;
92
- node.rs485Client = new ModbusRTU(); // RS-485总线客户端(仅串口模式使用)
93
- node.tcpSocket = null; // TCP Socket(TCP模式使用)
94
- node.isRs485Connected = false;
95
- node.reconnectTimer = null;
96
98
  node.isClosing = false;
99
+ node.lastTriggerTime = 0; // 上次触发时间(用于场景模式防抖)
97
100
  node.lastMqttErrorLog = 0; // MQTT错误日志时间
98
101
  node.errorLogInterval = 10 * 60 * 1000; // 错误日志间隔:10分钟
99
-
100
- // TCP帧缓冲区(用于处理分包和粘包)
101
- node.frameBuffer = Buffer.alloc(0);
102
-
102
+
103
103
  // 命令队列(处理多个按键同时按下)- 带时间戳
104
104
  node.commandQueue = [];
105
105
  node.isProcessingCommand = false;
106
-
106
+
107
107
  // 指示灯反馈队列(40ms间隔发送)- 带时间戳
108
108
  node.ledFeedbackQueue = [];
109
109
  node.isProcessingLedFeedback = false;
110
-
110
+
111
111
  // 队列超时时间(3秒)
112
112
  node.queueTimeout = 3000;
113
-
113
+
114
+ // 防死循环:记录最后一次状态变化的时间戳和值
115
+ node.lastStateChange = {
116
+ timestamp: 0,
117
+ value: false
118
+ };
119
+
114
120
  // 节点初始化标志(用于静默初始化期间的警告)
115
121
  node.isInitializing = true;
116
-
122
+
117
123
  // 节点关闭标志(用于静默关闭期间的警告)
118
124
  node.isClosing = false;
119
125
 
@@ -198,278 +204,129 @@ module.exports = function(RED) {
198
204
  // 连接RS-485总线(物理开关面板)
199
205
  node.connectRs485 = async function() {
200
206
  try {
201
- if (node.config.connectionType === "tcp") {
202
- // TCP连接验证
203
- if (!node.config.tcpHost) {
204
- throw new Error("TCP主机地址未配置");
205
- }
206
-
207
- // 使用net.Socket直接连接(不使用modbus-serial,因为需要监听原始数据)
208
- node.tcpSocket = new net.Socket();
209
-
210
- // 设置TCP选项
211
- node.tcpSocket.setKeepAlive(true, 60000);
212
- node.tcpSocket.setNoDelay(true);
213
-
214
- // 连接TCP
215
- await new Promise((resolve, reject) => {
216
- const timeout = setTimeout(() => {
217
- node.tcpSocket.destroy();
218
- reject(new Error('TCP连接超时'));
219
- }, 5000);
220
-
221
- node.tcpSocket.connect(node.config.tcpPort, node.config.tcpHost, () => {
222
- clearTimeout(timeout);
223
- resolve();
224
- });
225
-
226
- node.tcpSocket.on('error', (err) => {
227
- clearTimeout(timeout);
228
- reject(err);
229
- });
230
- });
231
-
232
- node.log(`RS485连接成功(TCP): ${node.config.tcpHost}:${node.config.tcpPort}`);
233
- node.log(`监听Symi开关${node.config.switchId}的按钮${node.config.buttonNumber}按键事件...`);
234
-
235
- } else {
236
- // 串口连接验证
237
- if (!node.config.serialPort) {
238
- throw new Error("串口未配置,请配置串口路径(如:COM1、/dev/ttyUSB0、/dev/ttyS1)");
239
- }
240
- await node.rs485Client.connectRTUBuffered(node.config.serialPort, {
241
- baudRate: node.config.serialBaudRate || 9600,
242
- dataBits: node.config.serialDataBits || 8,
243
- stopBits: node.config.serialStopBits || 1,
244
- parity: node.config.serialParity || 'none'
245
- });
246
- node.log(`RS485连接成功(串口): ${node.config.serialPort} @ ${node.config.serialBaudRate || 9600}bps`);
247
- node.log(`监听Symi开关${node.config.switchId}的按钮${node.config.buttonNumber}按键事件...`);
248
- }
249
-
207
+ // 使用共享连接配置(由配置节点管理)
208
+ node.log(`使用共享RS-485连接配置: ${node.serialPortConfig.connectionType === 'tcp' ?
209
+ `TCP ${node.serialPortConfig.tcpHost}:${node.serialPortConfig.tcpPort}` :
210
+ `串口 ${node.serialPortConfig.serialPort} @ ${node.serialPortConfig.baudRate}bps`}`);
211
+ node.log(`监听Symi开关${node.config.switchId}的按钮${node.config.buttonNumber}按键事件...`);
212
+
250
213
  node.isRs485Connected = true;
251
214
  node.updateStatus();
252
-
215
+
253
216
  // 结束初始化阶段(5秒后)- 避免部署时大量LED反馈同时发送
254
217
  setTimeout(() => {
255
218
  node.isInitializing = false;
256
219
  // 初始化完成后,处理积累的LED反馈队列
257
220
  node.processLedFeedbackQueue();
258
221
  }, 5000);
259
-
222
+
260
223
  // 监听物理开关面板的按键事件
261
224
  node.startListeningButtonEvents();
262
-
225
+
263
226
  } catch (err) {
264
227
  node.error(`RS-485连接失败: ${err.message}`);
265
228
  node.isRs485Connected = false;
266
229
  node.updateStatus();
267
-
268
- // 清理连接
269
- if (node.tcpSocket) {
270
- node.tcpSocket.destroy();
271
- node.tcpSocket = null;
272
- }
273
-
274
- // 5秒后重试连接(仅在配置有效时)
275
- if (!node.isClosing && !node.reconnectTimer) {
276
- // 如果是配置错误,不重试
277
- if (err.message.includes("未配置")) {
278
- node.warn("RS-485连接配置无效,请检查配置");
279
- return;
280
- }
281
- node.reconnectTimer = setTimeout(() => {
282
- node.reconnectTimer = null;
283
- node.connectRs485();
284
- }, 5000);
285
- }
286
230
  }
287
231
  };
288
232
 
289
233
  // 监听物理开关面板的按键事件
290
234
  node.startListeningButtonEvents = function() {
291
- node.log(`开始监听开关面板 ${node.config.switchId} 的按钮 ${node.config.buttonNumber}`);
292
-
293
- // 监听RS-485数据
294
- if (node.config.connectionType === "tcp") {
295
- // TCP模式:监听socket数据(需要处理帧边界)
296
- if (node.tcpSocket) {
297
- node.tcpSocket.on('data', (data) => {
298
- node.handleTcpData(data);
299
- });
300
-
301
- // 监听连接关闭事件
302
- node.tcpSocket.on('close', () => {
303
- // 部署或初始化期间静默警告
304
- if (!node.isClosing && !node.isInitializing) {
305
- node.warn('TCP连接已关闭');
306
- }
307
- node.isRs485Connected = false;
308
- node.updateStatus();
309
-
310
- // 尝试重连
311
- if (!node.isClosing && !node.reconnectTimer) {
312
- node.reconnectTimer = setTimeout(() => {
313
- node.reconnectTimer = null;
314
- node.connectRs485();
315
- }, 5000);
316
- }
317
- });
318
-
319
- // 监听连接错误事件(限制错误日志频率)
320
- node.tcpSocket.on('error', (err) => {
321
- const now = Date.now();
322
- // 只在10分钟内记录一次错误日志(避免刷屏)
323
- if (now - node.lastMqttErrorLog > node.errorLogInterval) {
324
- node.error(`TCP连接错误: ${err.message}`);
325
- node.lastMqttErrorLog = now;
326
- }
327
- });
328
-
329
- node.log('TCP数据监听已启动(使用帧缓冲区)');
330
- } else {
331
- node.error('TCP socket未初始化,无法监听数据');
332
- }
235
+ // 使用共享连接配置的数据监听器
236
+ if (node.serialPortConfig) {
237
+ // 定义数据监听器函数(静默处理,只在匹配时输出日志)
238
+ node.serialDataListener = (data) => {
239
+ node.handleRs485Data(data);
240
+ };
241
+
242
+ // 注册到共享连接配置
243
+ node.serialPortConfig.registerDataListener(node.serialDataListener);
333
244
  } else {
334
- // 串口模式:监听串口数据(串口通常自动处理帧边界)
335
- if (node.rs485Client._port) {
336
- node.rs485Client._port.on('data', (data) => {
337
- node.handleRs485Data(data);
338
- });
339
- node.log('串口数据监听已启动');
340
- } else {
341
- node.error('串口未初始化,无法监听数据');
342
- }
343
- }
344
- };
345
-
346
- // 处理TCP流式数据(需要缓冲和帧边界检测)
347
- node.handleTcpData = function(data) {
348
- try {
349
- // 将接收到的数据追加到缓冲区
350
- node.frameBuffer = Buffer.concat([node.frameBuffer, data]);
351
-
352
- // 输出缓冲区状态
353
- node.log(`TCP收到 ${data.length} 字节,缓冲区共 ${node.frameBuffer.length} 字节`);
354
-
355
- // 从缓冲区提取完整的帧
356
- while (node.frameBuffer.length > 0) {
357
- // 查找帧头 0x7E
358
- const startIdx = node.frameBuffer.indexOf(0x7E);
359
-
360
- if (startIdx === -1) {
361
- // 没有找到帧头,清空缓冲区
362
- node.frameBuffer = Buffer.alloc(0);
363
- break;
364
- }
365
-
366
- if (startIdx > 0) {
367
- // 丢弃帧头之前的无效数据
368
- node.frameBuffer = node.frameBuffer.slice(startIdx);
369
- }
370
-
371
- // 查找帧尾 0x7D
372
- const endIdx = node.frameBuffer.indexOf(0x7D, 1);
373
-
374
- if (endIdx === -1) {
375
- // 没有找到帧尾,等待更多数据
376
- if (node.frameBuffer.length > 100) {
377
- // 缓冲区过大,可能是错误数据,清空
378
- node.warn('缓冲区过大,清空');
379
- node.frameBuffer = Buffer.alloc(0);
380
- }
381
- break;
382
- }
383
-
384
- // 提取完整的帧(包括帧头和帧尾)
385
- const frame = node.frameBuffer.slice(0, endIdx + 1);
386
-
387
- // 从缓冲区移除已处理的帧
388
- node.frameBuffer = node.frameBuffer.slice(endIdx + 1);
389
-
390
- // 处理这一帧
391
- node.handleRs485Data(frame);
392
- }
393
- } catch (err) {
394
- node.error(`TCP数据处理错误: ${err.message}`);
395
- node.frameBuffer = Buffer.alloc(0); // 错误时清空缓冲区
245
+ node.error('RS-485连接配置未初始化,无法监听数据');
396
246
  }
397
247
  };
398
-
248
+
399
249
  // 处理RS-485接收到的数据
400
250
  node.handleRs485Data = function(data) {
401
251
  try {
402
- // 输出原始数据(十六进制)- 方便调试
403
- const hexData = Array.from(data).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
404
- node.log(`RS485收到数据: ${hexData}`);
405
-
406
252
  // 解析轻量级协议帧
407
253
  const frame = protocol.parseFrame(data);
408
254
  if (!frame) {
409
- node.warn(`无效的协议帧(CRC校验失败或格式错误): ${hexData}`);
410
- return;
255
+ return; // 静默忽略无效帧
411
256
  }
412
-
413
- node.log(`解析成功: 设备地址=${frame.deviceAddr} 通道=${frame.channel} 数据类型=0x${frame.dataType.toString(16).toUpperCase()} 操作码=0x${frame.opCode.toString(16).toUpperCase()}`);
414
-
257
+
258
+ // 忽略 REPORT (0x04) 类型的帧(这是面板对我们指令的确认,不是按键事件)
259
+ // 只处理 SET (0x03) 类型的帧(真正的按键事件)
260
+ if (frame.dataType === 0x04) {
261
+ return; // 静默忽略REPORT帧
262
+ }
263
+
415
264
  // 检测是否是按键按下事件
416
265
  const buttonEvent = protocol.detectButtonPress(frame);
417
266
  if (!buttonEvent) {
418
- node.log(`不是按键事件(设备类型=${frame.deviceType} 数据类型=${frame.dataType} 操作码=${frame.opCode})`);
419
- return;
267
+ return; // 静默忽略非按键事件
420
268
  }
421
-
422
- node.log(`检测到按键事件: 类型=${buttonEvent.type} 本地地址=${buttonEvent.raw.localAddr} 设备=${buttonEvent.deviceAddr} 通道=${buttonEvent.channel}`);
423
-
269
+
424
270
  // 计算实际按键编号(Symi协议公式)
425
271
  // 例如:devAddr=1,channel=1→按键1;devAddr=2,channel=1→按键5
426
272
  const actualButtonNumber = buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel;
427
-
428
- node.log(`实际按键编号: ${actualButtonNumber}(设备${buttonEvent.deviceAddr} × 4 - 4 + 通道${buttonEvent.channel})`);
429
-
273
+
430
274
  // 检查是否是我们监听的开关面板和按钮
431
275
  // switchId对应本地地址(物理面板地址)
432
276
  // buttonNumber对应实际按键编号(1-8)
433
277
  if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
434
- if (buttonEvent.type === 'single') {
435
- node.log(`匹配成功!面板${node.config.switchId} 按键${node.config.buttonNumber} 状态=${buttonEvent.state ? 'ON' : 'OFF'}`);
436
- // 发送MQTT命令到继电器
437
- node.sendMqttCommand(buttonEvent.state);
438
- } else if (buttonEvent.type === 'multi') {
439
- node.log(`匹配成功!面板${node.config.switchId} 多键按钮${node.config.buttonNumber} 状态=${buttonEvent.state ? 'ON' : 'OFF'}`);
440
- // 发送MQTT命令到继电器
441
- node.sendMqttCommand(buttonEvent.state);
278
+ // 保存原始的deviceAddr和channel,用于LED反馈
279
+ node.buttonDeviceAddr = buttonEvent.deviceAddr;
280
+ node.buttonChannel = buttonEvent.channel;
281
+ const isSceneMode = node.config.buttonType === 'scene' || buttonEvent.deviceType === 0x07;
282
+
283
+ // 全局防抖:防止多个节点重复处理同一个按键
284
+ const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}`;
285
+ const now = Date.now();
286
+ const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
287
+
288
+ // 全局防抖:200ms内只触发一次(开关和场景统一防抖时间)
289
+ if (now - lastTriggerTime < 200) {
290
+ return; // 静默忽略重复触发
442
291
  }
443
- } else {
444
- if (buttonEvent.raw.localAddr !== node.config.switchId) {
445
- node.log(`面板地址不匹配: 收到${buttonEvent.raw.localAddr} 期望${node.config.switchId}`);
292
+ globalDebounceCache.set(debounceKey, now);
293
+
294
+ if (isSceneMode) {
295
+ node.log(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
296
+ // 场景模式:切换状态(每次触发时翻转)
297
+ node.currentState = !node.currentState;
298
+ node.sendMqttCommand(node.currentState);
446
299
  } else {
447
- node.log(`按键编号不匹配: 收到${actualButtonNumber} 期望${node.config.buttonNumber}`);
300
+
301
+ // 开关模式:根据状态发送ON/OFF
302
+ node.log(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
303
+ node.sendMqttCommand(buttonEvent.state);
448
304
  }
449
305
  }
306
+ // 不匹配的节点静默忽略,不输出任何日志
450
307
  } catch (err) {
451
308
  node.error(`解析RS-485数据失败: ${err.message}`);
452
- node.error(`错误数据: ${Array.from(data).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ')}`);
453
309
  }
454
310
  };
455
311
 
456
- // 发送MQTT命令到继电器(入队)
312
+ // 发送命令到继电器(通过MQTT,由主站节点统一处理)
457
313
  node.sendMqttCommand = function(state) {
458
314
  if (!node.mqttClient || !node.mqttClient.connected) {
459
315
  node.warn('MQTT未连接,无法发送命令');
460
316
  return;
461
317
  }
462
-
463
- // 清理过期队列项(超过2秒)
464
- const now = Date.now();
465
- node.commandQueue = node.commandQueue.filter(item => (now - item.timestamp) < node.queueTimeout);
466
-
467
- // 加入命令队列(带时间戳)
468
- node.commandQueue.push({ state, timestamp: now });
469
- node.log(`MQTT命令已入队,队列长度: ${node.commandQueue.length}`);
470
-
471
- // 启动队列处理
472
- node.processCommandQueue();
318
+
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
+ });
473
330
  };
474
331
 
475
332
  // 处理命令队列(防止多个按键同时按下造成冲突)
@@ -506,17 +363,16 @@ module.exports = function(RED) {
506
363
  }
507
364
  });
508
365
  });
509
-
510
- node.log(`MQTT命令已发送: ${command} → ${node.commandTopic}`);
366
+
511
367
  node.currentState = state;
512
368
  node.updateStatus();
513
-
369
+
514
370
  // 队列间隔40ms(避免MQTT broker过载)
515
371
  if (node.commandQueue.length > 0) {
516
372
  await new Promise(resolve => setTimeout(resolve, 40));
517
373
  }
518
374
  } catch (err) {
519
- node.error(`发布MQTT命令失败: ${err.message}`);
375
+ node.error(`MQTT发送失败: ${err.message}`);
520
376
  }
521
377
  }
522
378
 
@@ -525,21 +381,23 @@ module.exports = function(RED) {
525
381
 
526
382
  // 发送控制指令到物理开关面板(控制指示灯等)- 入队
527
383
  node.sendCommandToPanel = function(state) {
528
- if (!node.isRs485Connected) {
384
+ // 检查连接状态
385
+ if (!node.serialPortConfig || !node.serialPortConfig.connection) {
529
386
  // 初始化期间静默警告
530
387
  if (!node.isInitializing) {
531
- node.warn('RS-485未连接,无法发送指示灯反馈');
388
+ node.warn('RS-485连接未建立,无法发送指示灯反馈');
532
389
  }
533
390
  return;
534
391
  }
535
-
392
+
536
393
  // 清理过期队列项(超过3秒)
537
394
  const now = Date.now();
538
395
  node.ledFeedbackQueue = node.ledFeedbackQueue.filter(item => (now - item.timestamp) < node.queueTimeout);
539
-
396
+
540
397
  // 加入LED反馈队列(带时间戳)
398
+ // 注意:这里不指定协议类型,在发送时根据情况选择
541
399
  node.ledFeedbackQueue.push({ state, timestamp: now });
542
-
400
+
543
401
  // 启动队列处理
544
402
  node.processLedFeedbackQueue();
545
403
  };
@@ -581,44 +439,49 @@ module.exports = function(RED) {
581
439
  const state = item.state;
582
440
 
583
441
  try {
584
- // 根据按键编号反推设备地址和通道(Symi协议)
585
- // 例如:按键1→devAddr=1,channel=1;按键5→devAddr=2,channel=1
586
- const deviceAddr = Math.floor((node.config.buttonNumber - 1) / 4) + 1;
587
- const channel = ((node.config.buttonNumber - 1) % 4) + 1;
588
-
589
- // 使用轻量级协议构建状态反馈指令(REPORT类型,用于同步指示灯)
590
- // localAddr=面板地址,deviceAddr和channel根据按键编号计算
591
- const command = protocol.buildSingleLightReport(
592
- node.config.switchId, // 本地地址(面板地址)
593
- deviceAddr, // 设备地址(根据按键编号计算)
594
- channel, // 通道(根据按键编号计算)
595
- state
596
- );
597
-
598
- // 发送到RS-485总线
599
- if (node.config.connectionType === "tcp") {
600
- // TCP模式
601
- if (node.tcpSocket && !node.tcpSocket.destroyed) {
602
- node.tcpSocket.write(command);
603
- const hexCmd = Array.from(command).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
604
- node.log(`LED反馈已发送(TCP/REPORT): 面板${node.config.switchId} 按键${node.config.buttonNumber} ${state ? 'ON' : 'OFF'} [${hexCmd}]`);
605
- } else {
606
- node.warn('TCP socket未连接,无法发送LED反馈');
607
- }
442
+ // 使用保存的原始deviceAddr和channel(从按键事件中获取)
443
+ // 如果没有保存,则根据按键编号反推(兼容旧版本)
444
+ const deviceAddr = node.buttonDeviceAddr || (Math.floor((node.config.buttonNumber - 1) / 4) + 1);
445
+ const channel = node.buttonChannel || (((node.config.buttonNumber - 1) % 4) + 1);
446
+
447
+ // 根据按钮类型选择协议类型
448
+ // 开关模式:使用SET协议(0x03),面板LED需要接收SET指令
449
+ // 场景模式:使用REPORT协议(0x04),面板LED需要接收REPORT指令
450
+ let command;
451
+ if (node.config.buttonType === 'scene') {
452
+ // 场景模式:使用REPORT协议
453
+ command = protocol.buildSingleLightReport(
454
+ node.config.switchId, // 本地地址(面板地址)
455
+ deviceAddr, // 设备地址(从按键事件中获取)
456
+ channel, // 通道(从按键事件中获取)
457
+ state
458
+ );
608
459
  } else {
609
- // 串口模式
610
- if (node.rs485Client._port) {
611
- node.rs485Client._port.write(command);
612
- const hexCmd = Array.from(command).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
613
- node.log(`LED反馈已发送(串口/REPORT): 面板${node.config.switchId} 按键${node.config.buttonNumber} ${state ? 'ON' : 'OFF'} [${hexCmd}]`);
614
- } else {
615
- node.warn('串口未连接,无法发送LED反馈');
616
- }
460
+ // 开关模式(默认):使用SET协议
461
+ command = protocol.buildSingleLightCommand(
462
+ node.config.switchId, // 本地地址(面板地址)
463
+ deviceAddr, // 设备地址(从按键事件中获取)
464
+ channel, // 通道(从按键事件中获取)
465
+ state
466
+ );
617
467
  }
618
-
619
- // 队列间隔100ms(确保RS-485总线有足够时间处理)
468
+
469
+ // 发送到RS-485总线(使用共享连接配置)
470
+ if (node.serialPortConfig) {
471
+ node.serialPortConfig.write(command, (err) => {
472
+ if (err) {
473
+ node.error(`LED反馈失败: ${err.message}`);
474
+ }
475
+ // 成功时不输出日志,减少总线负担
476
+ });
477
+ } else {
478
+ node.warn('RS-485连接未配置');
479
+ }
480
+
481
+ // 队列间隔20ms(优化速度,确保200个节点能快速反馈)
482
+ // 20ms × 200节点 = 4秒完成所有反馈
620
483
  if (node.ledFeedbackQueue.length > 0) {
621
- await new Promise(resolve => setTimeout(resolve, 100));
484
+ await new Promise(resolve => setTimeout(resolve, 20));
622
485
  }
623
486
  } catch (err) {
624
487
  node.error(`发送LED反馈失败: ${err.message}`);
@@ -676,9 +539,7 @@ module.exports = function(RED) {
676
539
  if (node.config.mqttUsername) {
677
540
  options.username = node.config.mqttUsername;
678
541
  options.password = node.config.mqttPassword;
679
- node.log(`MQTT认证: 用户名=${node.config.mqttUsername}, 密码已设置=${!!node.config.mqttPassword}`);
680
- } else {
681
- node.warn('MQTT未配置认证信息,如果broker需要认证将会连接失败');
542
+ node.log(`MQTT认证: 用户名=${node.config.mqttUsername}`);
682
543
  }
683
544
 
684
545
  // 尝试连接函数
@@ -697,6 +558,7 @@ module.exports = function(RED) {
697
558
  lastConnectAttempt = Date.now();
698
559
 
699
560
  node.mqttClient.on('connect', () => {
561
+ node.mqttConnected = true;
700
562
  node.log(`MQTT已连接: ${brokerUrl}`);
701
563
 
702
564
  // 成功连接后,更新配置的broker地址(下次优先使用成功的地址)
@@ -809,13 +671,30 @@ module.exports = function(RED) {
809
671
  node.mqttClient.on('message', (topic, message) => {
810
672
  if (topic === node.stateTopic) {
811
673
  const state = message.toString();
812
- node.currentState = (state === 'ON' || state === 'true' || state === '1');
813
-
674
+ const newState = (state === 'ON' || state === 'true' || state === '1');
675
+
676
+ // 防死循环:如果状态在100ms内没有变化,跳过LED反馈
677
+ const now = Date.now();
678
+ const timeSinceLastChange = now - node.lastStateChange.timestamp;
679
+ const stateChanged = (newState !== node.lastStateChange.value);
680
+
681
+ if (!stateChanged && timeSinceLastChange < 100) {
682
+ // 状态未变化且时间间隔太短,跳过LED反馈(避免死循环)
683
+ node.log(`跳过LED反馈(状态未变化,间隔${timeSinceLastChange}ms)`);
684
+ return;
685
+ }
686
+
687
+ // 更新状态
688
+ node.currentState = newState;
689
+ node.lastStateChange.timestamp = now;
690
+ node.lastStateChange.value = newState;
691
+
814
692
  node.updateStatus();
815
-
693
+
816
694
  // 发送控制指令到物理开关面板(同步指示灯等)
695
+ // 场景模式和开关模式都发送LED反馈(使用原始deviceAddr和channel)
817
696
  node.sendCommandToPanel(node.currentState);
818
-
697
+
819
698
  // 输出状态
820
699
  node.send({
821
700
  payload: node.currentState,
@@ -890,34 +769,46 @@ module.exports = function(RED) {
890
769
  node.reconnectTimer = null;
891
770
  }
892
771
 
893
- // 关闭TCP连接
894
- if (node.tcpSocket && !node.tcpSocket.destroyed) {
895
- try {
896
- node.tcpSocket.destroy();
897
- node.log('TCP连接已关闭');
898
- } catch (err) {
899
- node.warn(`关闭TCP连接时出错: ${err.message}`);
900
- }
901
- node.tcpSocket = null;
902
- }
772
+ // 清理队列(释放内存)
773
+ node.commandQueue = [];
774
+ node.ledFeedbackQueue = [];
775
+ node.frameBuffer = Buffer.alloc(0);
903
776
 
904
- // 关闭串口RS-485连接
905
- if (node.rs485Client && node.isRs485Connected && node.config.connectionType !== "tcp") {
777
+ // 注销RS-485数据监听器(不关闭连接,由配置节点管理)
778
+ if (node.serialPortConfig && node.serialDataListener) {
906
779
  try {
907
- node.rs485Client.close(() => {
908
- node.log('RS-485连接已关闭');
909
- });
780
+ node.serialPortConfig.unregisterDataListener(node.serialDataListener);
781
+ node.log('RS-485数据监听器已注销');
910
782
  } catch (err) {
911
- node.warn(`关闭RS-485连接时出错: ${err.message}`);
783
+ node.warn(`注销RS-485监听器时出错: ${err.message}`);
912
784
  }
785
+ node.serialDataListener = null;
913
786
  }
914
787
 
915
788
  // 关闭MQTT连接
916
- if (node.mqttClient && node.mqttClient.connected) {
917
- node.mqttClient.end(false, () => {
918
- node.log('MQTT连接已关闭');
789
+ if (node.mqttClient) {
790
+ try {
791
+ if (node.mqttClient.connected) {
792
+ // 移除所有监听器(防止内存泄漏)
793
+ node.mqttClient.removeAllListeners();
794
+ node.mqttClient.end(false, () => {
795
+ node.log('MQTT连接已关闭');
796
+ node.mqttClient = null;
797
+ node.mqttConnected = false;
798
+ done();
799
+ });
800
+ } else {
801
+ node.mqttClient.removeAllListeners();
802
+ node.mqttClient = null;
803
+ node.mqttConnected = false;
804
+ done();
805
+ }
806
+ } catch (err) {
807
+ node.warn(`关闭MQTT连接时出错: ${err.message}`);
808
+ node.mqttClient = null;
809
+ node.mqttConnected = false;
919
810
  done();
920
- });
811
+ }
921
812
  } else {
922
813
  done();
923
814
  }