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.
- package/README.md +188 -36
- package/nodes/lightweight-protocol.js +13 -1
- package/nodes/modbus-debug.html +11 -48
- package/nodes/modbus-debug.js +5 -62
- package/nodes/modbus-master.js +233 -77
- package/nodes/modbus-slave-switch.html +2 -1
- package/nodes/modbus-slave-switch.js +101 -27
- package/nodes/serial-port-config.js +97 -34
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
332
|
-
// 当MQTT
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
node.
|
|
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
|
-
//
|
|
458
|
-
//
|
|
459
|
-
const deviceAddr = node.buttonDeviceAddr
|
|
460
|
-
const channel = node.buttonChannel
|
|
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未启用 -
|
|
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,
|
|
51
|
-
node.connection.setTimeout(
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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