node-red-contrib-symi-modbus 2.6.6 → 2.6.7

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.
@@ -123,6 +123,13 @@ module.exports = function(RED) {
123
123
 
124
124
  // 节点关闭标志(用于静默关闭期间的警告)
125
125
  node.isClosing = false;
126
+
127
+ // 根据按钮编号计算deviceAddr和channel(用于LED反馈)
128
+ // Symi协议公式:按键编号 = deviceAddr * 4 - 4 + channel
129
+ // 反推公式:deviceAddr = floor((buttonNumber - 1) / 4) + 1, channel = ((buttonNumber - 1) % 4) + 1
130
+ // 这样即使没有物理按键事件,也能正确发送LED反馈
131
+ node.buttonDeviceAddr = Math.floor((node.config.buttonNumber - 1) / 4) + 1;
132
+ node.buttonChannel = ((node.config.buttonNumber - 1) % 4) + 1;
126
133
 
127
134
  // MQTT主题(映射到继电器设备)
128
135
  node.stateTopic = `${node.config.mqttBaseTopic}/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}/state`;
@@ -224,6 +231,9 @@ module.exports = function(RED) {
224
231
  // 监听物理开关面板的按键事件
225
232
  node.startListeningButtonEvents();
226
233
 
234
+ // 监听主站的状态变化事件(用于LED反馈)
235
+ node.startListeningStateChanges();
236
+
227
237
  } catch (err) {
228
238
  node.error(`RS-485连接失败: ${err.message}`);
229
239
  node.isRs485Connected = false;
@@ -247,6 +257,50 @@ module.exports = function(RED) {
247
257
  }
248
258
  };
249
259
 
260
+ // 监听主站的状态变化事件(用于LED反馈)
261
+ node.startListeningStateChanges = function() {
262
+ // 定义状态变化监听器
263
+ node.stateChangeListener = (data) => {
264
+ if (!data || typeof data !== 'object') {
265
+ return;
266
+ }
267
+
268
+ const slave = parseInt(data.slave);
269
+ const coil = parseInt(data.coil);
270
+ const value = Boolean(data.value);
271
+
272
+ // 检查是否是我们关注的从站和线圈
273
+ if (slave === node.config.targetSlaveAddress && coil === node.config.targetCoilNumber) {
274
+ node.log(`收到状态变化事件:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
275
+
276
+ // 更新当前状态
277
+ node.currentState = value;
278
+ node.lastStateChange.timestamp = Date.now();
279
+ node.lastStateChange.value = value;
280
+
281
+ // 更新节点状态显示
282
+ node.updateStatus();
283
+
284
+ // 发送LED反馈到物理开关面板
285
+ node.sendCommandToPanel(value);
286
+
287
+ // 输出状态消息
288
+ node.send({
289
+ payload: value,
290
+ topic: `switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
291
+ switchId: node.config.switchId,
292
+ button: node.config.buttonNumber,
293
+ targetSlave: node.config.targetSlaveAddress,
294
+ targetCoil: node.config.targetCoilNumber
295
+ });
296
+ }
297
+ };
298
+
299
+ // 注册内部事件监听器
300
+ RED.events.on('modbus:coilStateChanged', node.stateChangeListener);
301
+ node.log('已注册状态变化监听器(用于LED反馈)');
302
+ };
303
+
250
304
  // 处理RS-485接收到的数据
251
305
  node.handleRs485Data = function(data) {
252
306
  try {
@@ -277,9 +331,20 @@ module.exports = function(RED) {
277
331
  // buttonNumber对应实际按键编号(1-8)
278
332
  if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
279
333
  // 保存原始的deviceAddr和channel,用于LED反馈
334
+ const oldDeviceAddr = node.buttonDeviceAddr;
335
+ const oldChannel = node.buttonChannel;
280
336
  node.buttonDeviceAddr = buttonEvent.deviceAddr;
281
337
  node.buttonChannel = buttonEvent.channel;
282
- const isSceneMode = node.config.buttonType === 'scene' || buttonEvent.deviceType === 0x07;
338
+
339
+ // 输出调试日志,对比计算值和实际值
340
+ if (oldDeviceAddr !== buttonEvent.deviceAddr || oldChannel !== buttonEvent.channel) {
341
+ node.log(`按键事件更新LED反馈地址:计算值(设备${oldDeviceAddr} 通道${oldChannel}) → 实际值(设备${buttonEvent.deviceAddr} 通道${buttonEvent.channel})`);
342
+ }
343
+
344
+ // 判断按钮类型:优先使用协议解析结果,其次使用配置
345
+ const isSceneMode = buttonEvent.isSceneMode ||
346
+ node.config.buttonType === 'scene' ||
347
+ buttonEvent.deviceType === 0x07;
283
348
 
284
349
  // 全局防抖:防止多个节点重复处理同一个按键
285
350
  const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}`;
@@ -293,12 +358,11 @@ module.exports = function(RED) {
293
358
  globalDebounceCache.set(debounceKey, now);
294
359
 
295
360
  if (isSceneMode) {
296
- node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
361
+ node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
297
362
  // 场景模式:切换状态(每次触发时翻转)
298
363
  node.currentState = !node.currentState;
299
364
  node.sendMqttCommand(node.currentState);
300
365
  } else {
301
-
302
366
  // 开关模式:根据状态发送ON/OFF
303
367
  node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
304
368
  node.sendMqttCommand(buttonEvent.state);
@@ -310,10 +374,10 @@ module.exports = function(RED) {
310
374
  }
311
375
  };
312
376
 
313
- // 发送命令到继电器(支持两种模式:MQTT模式和本地模式)
377
+ // 发送命令到继电器(支持两种模式:MQTT模式和内部事件模式)
314
378
  node.sendMqttCommand = function(state) {
315
379
  // 模式1:MQTT模式(通过MQTT发送命令,由主站节点统一处理)
316
- if (node.mqttClient && node.mqttClient.connected) {
380
+ if (node.config.enableMqtt && node.mqttClient && node.mqttClient.connected) {
317
381
  // 直接发送MQTT命令(不使用队列,立即发送)
318
382
  // 主站节点会处理去重和防抖
319
383
  const commandTopic = `${node.config.mqttBaseTopic}/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}/set`;
@@ -322,26 +386,26 @@ module.exports = function(RED) {
322
386
  node.mqttClient.publish(commandTopic, payload, { qos: 1 }, (err) => {
323
387
  if (err) {
324
388
  node.error(`MQTT发送失败: ${err.message}`);
389
+ } else {
390
+ node.debug(`MQTT模式:发送命令到${commandTopic} = ${payload}`);
325
391
  }
326
- // 成功时不输出日志,减少总线负担
327
392
  });
328
393
  return;
329
394
  }
330
395
 
331
- // 模式2:本地模式(通过Node-RED消息输出,直接连线到主站节点)
332
- // 当MQTT未连接时,通过节点输出发送控制命令
333
- const msg = {
334
- payload: {
335
- cmd: "writeCoil",
336
- slave: node.config.targetSlaveAddress,
337
- coil: node.config.targetCoilNumber,
338
- value: state
339
- },
340
- topic: `modbus/write/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}`
341
- };
342
-
343
- node.send(msg);
344
- node.debug(`本地模式:发送控制命令到从站${node.config.targetSlaveAddress} 线圈${node.config.targetCoilNumber} = ${state ? 'ON' : 'OFF'}`);
396
+ // 模式2:内部事件模式(通过RED内部事件系统,免连线通信)
397
+ // 当MQTT未启用或未连接时,通过内部事件发送控制命令
398
+ // 主站节点会监听这些事件并执行写入操作
399
+ RED.events.emit('modbus:writeCoil', {
400
+ slave: node.config.targetSlaveAddress,
401
+ coil: node.config.targetCoilNumber,
402
+ value: state,
403
+ source: 'slave-switch',
404
+ nodeId: node.id
405
+ });
406
+
407
+ // 输出日志确认发送
408
+ node.log(`内部事件模式:发送命令到从站${node.config.targetSlaveAddress} 线圈${node.config.targetCoilNumber} = ${state ? 'ON' : 'OFF'}`);
345
409
  };
346
410
 
347
411
  // 处理命令队列(防止多个按键同时按下造成冲突)
@@ -454,10 +518,10 @@ module.exports = function(RED) {
454
518
  const state = item.state;
455
519
 
456
520
  try {
457
- // 使用保存的原始deviceAddr和channel(从按键事件中获取)
458
- // 如果没有保存,则根据按键编号反推(兼容旧版本)
459
- const deviceAddr = node.buttonDeviceAddr || (Math.floor((node.config.buttonNumber - 1) / 4) + 1);
460
- const channel = node.buttonChannel || (((node.config.buttonNumber - 1) % 4) + 1);
521
+ // 使用初始化时计算的deviceAddr和channel
522
+ // 当物理按键按下时,会更新为实际的deviceAddr和channel
523
+ const deviceAddr = node.buttonDeviceAddr;
524
+ const channel = node.buttonChannel;
461
525
 
462
526
  // 根据按钮类型选择协议类型
463
527
  // 开关模式:使用SET协议(0x03),面板LED需要接收SET指令
@@ -486,8 +550,11 @@ module.exports = function(RED) {
486
550
  node.serialPortConfig.write(command, (err) => {
487
551
  if (err) {
488
552
  node.error(`LED反馈失败: ${err.message}`);
553
+ } else {
554
+ // 输出调试日志,确认LED反馈已发送(包含协议帧十六进制)
555
+ const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
556
+ node.log(`LED反馈已发送:面板${node.config.switchId} 按钮${node.config.buttonNumber} 设备${deviceAddr} 通道${channel} = ${state ? 'ON' : 'OFF'} (${node.config.buttonType === 'scene' ? 'REPORT' : 'SET'}) [${hexStr}]`);
489
557
  }
490
- // 成功时不输出日志,减少总线负担
491
558
  });
492
559
  } else {
493
560
  node.warn('RS-485连接未配置');
@@ -551,8 +618,8 @@ module.exports = function(RED) {
551
618
  node.connectMqtt = function() {
552
619
  // 检查是否启用MQTT
553
620
  if (!node.config.enableMqtt) {
554
- node.log('MQTT未启用 - 使用本地模式(通过Node-RED连线控制)');
555
- node.log('提示:将此节点连线到主站节点,即可实现本地控制');
621
+ node.log('MQTT未启用 - 使用内部事件模式(免连线通信)');
622
+ node.log('提示:物理开关面板按键会通过内部事件自动发送到主站节点');
556
623
  return;
557
624
  }
558
625
 
@@ -827,6 +894,13 @@ module.exports = function(RED) {
827
894
  }
828
895
  node.serialDataListener = null;
829
896
  }
897
+
898
+ // 移除状态变化监听器
899
+ if (node.stateChangeListener) {
900
+ RED.events.removeListener('modbus:coilStateChanged', node.stateChangeListener);
901
+ node.stateChangeListener = null;
902
+ node.log('状态变化监听器已移除');
903
+ }
830
904
 
831
905
  // 关闭MQTT连接
832
906
  if (node.mqttClient) {
@@ -32,6 +32,10 @@ module.exports = function(RED) {
32
32
  // 数据监听器列表(每个从站开关节点注册一个)
33
33
  node.dataListeners = [];
34
34
 
35
+ // 写入队列(防止多个节点同时写入冲突)
36
+ node.writeQueue = [];
37
+ node.isWriting = false;
38
+
35
39
  // 打开TCP连接
36
40
  node.openTcpConnection = function() {
37
41
  if (node.connection && !node.connection.destroyed) {
@@ -47,8 +51,8 @@ module.exports = function(RED) {
47
51
 
48
52
  try {
49
53
  node.connection = new net.Socket();
50
- node.connection.setKeepAlive(true, 30000); // 启用TCP Keep-Alive,30秒间隔
51
- node.connection.setTimeout(60000); // 60秒超时
54
+ node.connection.setKeepAlive(true, 10000); // 启用TCP Keep-Alive,10秒间隔
55
+ node.connection.setTimeout(0); // 禁用超时(永不超时)
52
56
 
53
57
  node.connection.connect(node.tcpPort, node.tcpHost, () => {
54
58
  node.log(`TCP连接已建立: ${node.tcpHost}:${node.tcpPort}`);
@@ -56,6 +60,8 @@ module.exports = function(RED) {
56
60
  });
57
61
 
58
62
  // 监听数据(分发给所有注册的监听器)
63
+ // 注意:不在这里做协议解析,直接广播原始数据
64
+ // 让各个监听器(modbus-master、modbus-slave-switch等)自己决定如何处理
59
65
  node.connection.on('data', (data) => {
60
66
  // 广播给所有监听器
61
67
  node.dataListeners.forEach(listener => {
@@ -84,21 +90,21 @@ module.exports = function(RED) {
84
90
  // 监听关闭(统一的重连入口)
85
91
  node.connection.on('close', (hadError) => {
86
92
  node.log(`TCP连接已关闭${hadError ? '(有错误)' : ''}`);
87
-
93
+
88
94
  // 清理连接对象
89
95
  if (node.connection) {
90
96
  node.connection.removeAllListeners();
91
97
  node.connection.destroy();
92
98
  node.connection = null;
93
99
  }
94
-
100
+
95
101
  // 自动重连(如果不是主动关闭且有监听器在使用)
96
102
  if (!node.isClosing && node.dataListeners.length > 0) {
97
103
  const delay = Math.min(5000 * Math.pow(2, node.reconnectAttempts), 60000); // 指数退避,最大60秒
98
104
  node.reconnectAttempts++;
99
-
105
+
100
106
  node.log(`${delay/1000}秒后尝试重新连接TCP(第${node.reconnectAttempts}次)...`);
101
-
107
+
102
108
  node.reconnectTimer = setTimeout(() => {
103
109
  node.reconnectTimer = null;
104
110
  node.openTcpConnection();
@@ -296,54 +302,111 @@ module.exports = function(RED) {
296
302
  }
297
303
  };
298
304
 
299
- // 写入数据
305
+ // 写入数据(带队列机制,防止并发冲突)
300
306
  node.write = function(data, callback) {
301
- if (!node.connection) {
302
- const err = new Error('连接未建立');
303
- if (callback) callback(err);
307
+ // 加入写入队列
308
+ node.writeQueue.push({ data, callback });
309
+
310
+ // 启动队列处理
311
+ node.processWriteQueue();
312
+ };
313
+
314
+ // 处理写入队列(串行执行,避免并发冲突)
315
+ node.processWriteQueue = async function() {
316
+ if (node.isWriting || node.writeQueue.length === 0) {
304
317
  return;
305
318
  }
306
319
 
307
- if (node.connectionType === 'tcp') {
308
- if (node.connection.destroyed) {
309
- const err = new Error('TCP连接已断开');
310
- if (callback) callback(err);
311
- return;
312
- }
313
- node.connection.write(data, (err) => {
314
- if (err) {
315
- node.error(`TCP写入失败: ${err.message}`);
320
+ node.isWriting = true;
321
+
322
+ while (node.writeQueue.length > 0) {
323
+ const item = node.writeQueue.shift();
324
+ const { data, callback } = item;
325
+
326
+ try {
327
+ if (!node.connection) {
328
+ const err = new Error('连接未建立');
329
+ if (callback) callback(err);
330
+ continue;
316
331
  }
317
- if (callback) callback(err);
318
- });
319
- } else {
320
- if (!node.connection.isOpen) {
321
- const err = new Error('串口未打开');
322
- if (callback) callback(err);
323
- return;
324
- }
325
- node.connection.write(data, (err) => {
326
- if (err) {
327
- node.error(`串口写入失败: ${err.message}`);
332
+
333
+ if (node.connectionType === 'tcp') {
334
+ if (node.connection.destroyed) {
335
+ const err = new Error('TCP连接已断开');
336
+ if (callback) callback(err);
337
+ continue;
338
+ }
339
+
340
+ // TCP写入(异步)
341
+ await new Promise((resolve, reject) => {
342
+ node.connection.write(data, (err) => {
343
+ if (err) {
344
+ node.error(`TCP写入失败: ${err.message}`);
345
+ if (callback) callback(err);
346
+ reject(err);
347
+ } else {
348
+ if (callback) callback(null);
349
+ resolve();
350
+ }
351
+ });
352
+ });
353
+
354
+ // TCP写入间隔(20ms,避免网关处理不过来)
355
+ if (node.writeQueue.length > 0) {
356
+ await new Promise(resolve => setTimeout(resolve, 20));
357
+ }
358
+ } else {
359
+ if (!node.connection.isOpen) {
360
+ const err = new Error('串口未打开');
361
+ if (callback) callback(err);
362
+ continue;
363
+ }
364
+
365
+ // 串口写入(异步)
366
+ await new Promise((resolve, reject) => {
367
+ node.connection.write(data, (err) => {
368
+ if (err) {
369
+ node.error(`串口写入失败: ${err.message}`);
370
+ if (callback) callback(err);
371
+ reject(err);
372
+ } else {
373
+ if (callback) callback(null);
374
+ resolve();
375
+ }
376
+ });
377
+ });
378
+
379
+ // 串口写入间隔(10ms,串口速度较快)
380
+ if (node.writeQueue.length > 0) {
381
+ await new Promise(resolve => setTimeout(resolve, 10));
382
+ }
328
383
  }
329
- if (callback) callback(err);
330
- });
384
+ } catch (err) {
385
+ // 写入失败,继续处理下一个
386
+ node.warn(`写入失败(已跳过): ${err.message}`);
387
+ }
331
388
  }
389
+
390
+ node.isWriting = false;
332
391
  };
333
392
 
334
393
  // 节点关闭时清理
335
394
  node.on('close', function(done) {
336
395
  node.isClosing = true; // 标记正在关闭,防止自动重连
337
-
396
+
338
397
  // 清除重连定时器
339
398
  if (node.reconnectTimer) {
340
399
  clearTimeout(node.reconnectTimer);
341
400
  node.reconnectTimer = null;
342
401
  }
343
-
402
+
344
403
  // 清空所有监听器
345
404
  node.dataListeners = [];
346
405
 
406
+ // 清空写入队列
407
+ node.writeQueue = [];
408
+ node.isWriting = false;
409
+
347
410
  // 关闭连接
348
411
  if (node.connection) {
349
412
  if (node.connectionType === 'tcp') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.6.6",
3
+ "version": "2.6.7",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现和物理开关面板双向同步,工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {