node-red-contrib-symi-modbus 2.5.0 → 2.5.2
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 +12 -77
- package/nodes/lightweight-protocol.js +35 -1
- package/nodes/modbus-master.js +8 -8
- package/nodes/modbus-slave-switch.js +201 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -507,83 +507,18 @@ docker run --privileged ...
|
|
|
507
507
|
- **容错能力**:Modbus从站离线不影响其他从站,MQTT断线自动重连
|
|
508
508
|
|
|
509
509
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
-
|
|
518
|
-
-
|
|
519
|
-
-
|
|
520
|
-
-
|
|
521
|
-
-
|
|
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
|
-
```
|
|
578
|
-
|
|
579
|
-
**测试验证**:
|
|
580
|
-
- TCP网关(192.168.2.110:1031)连接正常
|
|
581
|
-
- 帧缓冲区正确处理分包数据
|
|
582
|
-
- 多个按键同时按下不丢失
|
|
583
|
-
- LED指示灯同步稳定
|
|
584
|
-
- 适合Linux工控机24/7长期稳定运行
|
|
585
|
-
|
|
586
|
-
---
|
|
510
|
+
## 版本信息
|
|
511
|
+
|
|
512
|
+
**当前版本**: v2.5.2
|
|
513
|
+
|
|
514
|
+
**主要特性**:
|
|
515
|
+
- Modbus TCP/RTU协议完整支持
|
|
516
|
+
- MQTT自动发现与Home Assistant集成
|
|
517
|
+
- 物理开关面板双向同步(Symi轻量级协议)
|
|
518
|
+
- TCP帧缓冲与命令队列机制
|
|
519
|
+
- 多设备并发控制互斥锁
|
|
520
|
+
- 智能MQTT连接fallback
|
|
521
|
+
- 适合Linux工控机长期稳定运行
|
|
587
522
|
|
|
588
523
|
**升级方式**:
|
|
589
524
|
```bash
|
|
@@ -62,7 +62,7 @@ module.exports = {
|
|
|
62
62
|
},
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
*
|
|
65
|
+
* 构建单灯控制指令(SET类型,用于主动控制)
|
|
66
66
|
* @param {number} localAddr - 本机地址(发送方)
|
|
67
67
|
* @param {number} deviceAddr - 设备地址
|
|
68
68
|
* @param {number} channel - 通道号(1-8)
|
|
@@ -95,6 +95,40 @@ module.exports = {
|
|
|
95
95
|
return buffer;
|
|
96
96
|
},
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* 构建单灯状态反馈指令(REPORT类型,用于状态同步到面板指示灯)
|
|
100
|
+
* @param {number} localAddr - 本机地址(发送方)
|
|
101
|
+
* @param {number} deviceAddr - 设备地址
|
|
102
|
+
* @param {number} channel - 通道号(1-8)
|
|
103
|
+
* @param {boolean} state - 灯状态(true=开,false=关)
|
|
104
|
+
* @returns {Buffer} 完整的指令帧
|
|
105
|
+
*/
|
|
106
|
+
buildSingleLightReport: function(localAddr, deviceAddr, channel, state) {
|
|
107
|
+
const buffer = Buffer.alloc(15);
|
|
108
|
+
let idx = 0;
|
|
109
|
+
|
|
110
|
+
buffer[idx++] = this.FRAME_HEADER; // 帧头 0x7E
|
|
111
|
+
buffer[idx++] = localAddr; // 本机地址
|
|
112
|
+
buffer[idx++] = this.DATA_TYPE_REPORT; // 数据类型:上报(关键!)
|
|
113
|
+
buffer[idx++] = 0x0F; // 数据长度
|
|
114
|
+
buffer[idx++] = this.DEVICE_TYPE_LIGHT; // 设备类型:灯光
|
|
115
|
+
buffer[idx++] = 0x00; // 品牌ID
|
|
116
|
+
buffer[idx++] = deviceAddr; // 设备地址
|
|
117
|
+
buffer[idx++] = channel; // 设备通道
|
|
118
|
+
buffer[idx++] = 0x00; // 房间号
|
|
119
|
+
buffer[idx++] = 0x00; // 房间类型
|
|
120
|
+
buffer[idx++] = 0x00; // 房间ID
|
|
121
|
+
buffer[idx++] = this.LIGHT_OP_SINGLE; // 操作码:单灯控制
|
|
122
|
+
buffer[idx++] = state ? 0x01 : 0x00; // 操作信息:1开0关
|
|
123
|
+
|
|
124
|
+
// 计算CRC8校验(从帧头到操作信息)
|
|
125
|
+
const crc = this.calculateCRC8(buffer, 15);
|
|
126
|
+
buffer[idx++] = crc; // CRC8校验
|
|
127
|
+
buffer[idx++] = this.FRAME_TAIL; // 报尾 0x7D
|
|
128
|
+
|
|
129
|
+
return buffer;
|
|
130
|
+
},
|
|
131
|
+
|
|
98
132
|
/**
|
|
99
133
|
* 构建多灯控制指令
|
|
100
134
|
* @param {number} localAddr - 本机地址
|
package/nodes/modbus-master.js
CHANGED
|
@@ -780,15 +780,15 @@ module.exports = function(RED) {
|
|
|
780
780
|
return;
|
|
781
781
|
}
|
|
782
782
|
|
|
783
|
-
// 等待锁释放(最多等待
|
|
784
|
-
const maxWait =
|
|
783
|
+
// 等待锁释放(最多等待500ms)
|
|
784
|
+
const maxWait = 500;
|
|
785
785
|
const startWait = Date.now();
|
|
786
786
|
while (node.modbusLock && (Date.now() - startWait) < maxWait) {
|
|
787
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
787
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
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
|
-
// 等待锁释放(最多等待
|
|
838
|
-
const maxWait =
|
|
837
|
+
// 等待锁释放(最多等待500ms)
|
|
838
|
+
const maxWait = 500;
|
|
839
839
|
const startWait = Date.now();
|
|
840
840
|
while (node.modbusLock && (Date.now() - startWait) < maxWait) {
|
|
841
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
841
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
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
|
|
|
@@ -89,7 +89,8 @@ module.exports = function(RED) {
|
|
|
89
89
|
|
|
90
90
|
node.currentState = false;
|
|
91
91
|
node.mqttClient = null;
|
|
92
|
-
node.rs485Client = new ModbusRTU(); // RS-485
|
|
92
|
+
node.rs485Client = new ModbusRTU(); // RS-485总线客户端(仅串口模式使用)
|
|
93
|
+
node.tcpSocket = null; // TCP Socket(TCP模式使用)
|
|
93
94
|
node.isRs485Connected = false;
|
|
94
95
|
node.reconnectTimer = null;
|
|
95
96
|
node.isClosing = false;
|
|
@@ -99,14 +100,23 @@ module.exports = function(RED) {
|
|
|
99
100
|
// TCP帧缓冲区(用于处理分包和粘包)
|
|
100
101
|
node.frameBuffer = Buffer.alloc(0);
|
|
101
102
|
|
|
102
|
-
//
|
|
103
|
+
// 命令队列(处理多个按键同时按下)- 带时间戳
|
|
103
104
|
node.commandQueue = [];
|
|
104
105
|
node.isProcessingCommand = false;
|
|
105
106
|
|
|
106
|
-
// 指示灯反馈队列(40ms
|
|
107
|
+
// 指示灯反馈队列(40ms间隔发送)- 带时间戳
|
|
107
108
|
node.ledFeedbackQueue = [];
|
|
108
109
|
node.isProcessingLedFeedback = false;
|
|
109
110
|
|
|
111
|
+
// 队列超时时间(3秒)
|
|
112
|
+
node.queueTimeout = 3000;
|
|
113
|
+
|
|
114
|
+
// 节点初始化标志(用于静默初始化期间的警告)
|
|
115
|
+
node.isInitializing = true;
|
|
116
|
+
|
|
117
|
+
// 节点关闭标志(用于静默关闭期间的警告)
|
|
118
|
+
node.isClosing = false;
|
|
119
|
+
|
|
110
120
|
// MQTT主题(映射到继电器设备)
|
|
111
121
|
node.stateTopic = `${node.config.mqttBaseTopic}/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}/state`;
|
|
112
122
|
node.commandTopic = `${node.config.mqttBaseTopic}/${node.config.targetSlaveAddress}/${node.config.targetCoilNumber}/set`;
|
|
@@ -193,11 +203,35 @@ module.exports = function(RED) {
|
|
|
193
203
|
if (!node.config.tcpHost) {
|
|
194
204
|
throw new Error("TCP主机地址未配置");
|
|
195
205
|
}
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
});
|
|
198
230
|
});
|
|
231
|
+
|
|
199
232
|
node.log(`RS485连接成功(TCP): ${node.config.tcpHost}:${node.config.tcpPort}`);
|
|
200
233
|
node.log(`监听Symi开关${node.config.switchId}的按钮${node.config.buttonNumber}按键事件...`);
|
|
234
|
+
|
|
201
235
|
} else {
|
|
202
236
|
// 串口连接验证
|
|
203
237
|
if (!node.config.serialPort) {
|
|
@@ -213,10 +247,16 @@ module.exports = function(RED) {
|
|
|
213
247
|
node.log(`监听Symi开关${node.config.switchId}的按钮${node.config.buttonNumber}按键事件...`);
|
|
214
248
|
}
|
|
215
249
|
|
|
216
|
-
node.rs485Client.setTimeout(5000);
|
|
217
250
|
node.isRs485Connected = true;
|
|
218
251
|
node.updateStatus();
|
|
219
252
|
|
|
253
|
+
// 结束初始化阶段(5秒后)- 避免部署时大量LED反馈同时发送
|
|
254
|
+
setTimeout(() => {
|
|
255
|
+
node.isInitializing = false;
|
|
256
|
+
// 初始化完成后,处理积累的LED反馈队列
|
|
257
|
+
node.processLedFeedbackQueue();
|
|
258
|
+
}, 5000);
|
|
259
|
+
|
|
220
260
|
// 监听物理开关面板的按键事件
|
|
221
261
|
node.startListeningButtonEvents();
|
|
222
262
|
|
|
@@ -225,6 +265,12 @@ module.exports = function(RED) {
|
|
|
225
265
|
node.isRs485Connected = false;
|
|
226
266
|
node.updateStatus();
|
|
227
267
|
|
|
268
|
+
// 清理连接
|
|
269
|
+
if (node.tcpSocket) {
|
|
270
|
+
node.tcpSocket.destroy();
|
|
271
|
+
node.tcpSocket = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
228
274
|
// 5秒后重试连接(仅在配置有效时)
|
|
229
275
|
if (!node.isClosing && !node.reconnectTimer) {
|
|
230
276
|
// 如果是配置错误,不重试
|
|
@@ -247,12 +293,42 @@ module.exports = function(RED) {
|
|
|
247
293
|
// 监听RS-485数据
|
|
248
294
|
if (node.config.connectionType === "tcp") {
|
|
249
295
|
// TCP模式:监听socket数据(需要处理帧边界)
|
|
250
|
-
if (node.
|
|
251
|
-
|
|
252
|
-
socket.on('data', (data) => {
|
|
296
|
+
if (node.tcpSocket) {
|
|
297
|
+
node.tcpSocket.on('data', (data) => {
|
|
253
298
|
node.handleTcpData(data);
|
|
254
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
|
+
|
|
255
329
|
node.log('TCP数据监听已启动(使用帧缓冲区)');
|
|
330
|
+
} else {
|
|
331
|
+
node.error('TCP socket未初始化,无法监听数据');
|
|
256
332
|
}
|
|
257
333
|
} else {
|
|
258
334
|
// 串口模式:监听串口数据(串口通常自动处理帧边界)
|
|
@@ -261,6 +337,8 @@ module.exports = function(RED) {
|
|
|
261
337
|
node.handleRs485Data(data);
|
|
262
338
|
});
|
|
263
339
|
node.log('串口数据监听已启动');
|
|
340
|
+
} else {
|
|
341
|
+
node.error('串口未初始化,无法监听数据');
|
|
264
342
|
}
|
|
265
343
|
}
|
|
266
344
|
};
|
|
@@ -341,31 +419,33 @@ module.exports = function(RED) {
|
|
|
341
419
|
return;
|
|
342
420
|
}
|
|
343
421
|
|
|
344
|
-
node.log(`检测到按键事件: 类型=${buttonEvent.type} 设备=${buttonEvent.deviceAddr} 通道=${buttonEvent.channel}`);
|
|
422
|
+
node.log(`检测到按键事件: 类型=${buttonEvent.type} 本地地址=${buttonEvent.raw.localAddr} 设备=${buttonEvent.deviceAddr} 通道=${buttonEvent.channel}`);
|
|
423
|
+
|
|
424
|
+
// 计算实际按键编号(Symi协议公式)
|
|
425
|
+
// 例如:devAddr=1,channel=1→按键1;devAddr=2,channel=1→按键5
|
|
426
|
+
const actualButtonNumber = buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel;
|
|
345
427
|
|
|
346
|
-
|
|
347
|
-
|
|
428
|
+
node.log(`实际按键编号: ${actualButtonNumber}(设备${buttonEvent.deviceAddr} × 4 - 4 + 通道${buttonEvent.channel})`);
|
|
429
|
+
|
|
430
|
+
// 检查是否是我们监听的开关面板和按钮
|
|
431
|
+
// switchId对应本地地址(物理面板地址)
|
|
432
|
+
// buttonNumber对应实际按键编号(1-8)
|
|
433
|
+
if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
|
|
348
434
|
if (buttonEvent.type === 'single') {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// 发送MQTT命令到继电器
|
|
353
|
-
node.sendMqttCommand(buttonEvent.state);
|
|
354
|
-
} else {
|
|
355
|
-
node.log(`按钮编号不匹配: 收到${buttonEvent.channel} 期望${node.config.buttonNumber}`);
|
|
356
|
-
}
|
|
435
|
+
node.log(`匹配成功!面板${node.config.switchId} 按键${node.config.buttonNumber} 状态=${buttonEvent.state ? 'ON' : 'OFF'}`);
|
|
436
|
+
// 发送MQTT命令到继电器
|
|
437
|
+
node.sendMqttCommand(buttonEvent.state);
|
|
357
438
|
} else if (buttonEvent.type === 'multi') {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const state = buttonEvent.buttonStates[buttonIndex];
|
|
362
|
-
node.log(`匹配成功!开关${node.config.switchId} 多键按钮${node.config.buttonNumber} 状态=${state ? 'ON' : 'OFF'}`);
|
|
363
|
-
// 发送MQTT命令到继电器
|
|
364
|
-
node.sendMqttCommand(state);
|
|
365
|
-
}
|
|
439
|
+
node.log(`匹配成功!面板${node.config.switchId} 多键按钮${node.config.buttonNumber} 状态=${buttonEvent.state ? 'ON' : 'OFF'}`);
|
|
440
|
+
// 发送MQTT命令到继电器
|
|
441
|
+
node.sendMqttCommand(buttonEvent.state);
|
|
366
442
|
}
|
|
367
443
|
} else {
|
|
368
|
-
|
|
444
|
+
if (buttonEvent.raw.localAddr !== node.config.switchId) {
|
|
445
|
+
node.log(`面板地址不匹配: 收到${buttonEvent.raw.localAddr} 期望${node.config.switchId}`);
|
|
446
|
+
} else {
|
|
447
|
+
node.log(`按键编号不匹配: 收到${actualButtonNumber} 期望${node.config.buttonNumber}`);
|
|
448
|
+
}
|
|
369
449
|
}
|
|
370
450
|
} catch (err) {
|
|
371
451
|
node.error(`解析RS-485数据失败: ${err.message}`);
|
|
@@ -380,8 +460,12 @@ module.exports = function(RED) {
|
|
|
380
460
|
return;
|
|
381
461
|
}
|
|
382
462
|
|
|
383
|
-
//
|
|
384
|
-
|
|
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 });
|
|
385
469
|
node.log(`MQTT命令已入队,队列长度: ${node.commandQueue.length}`);
|
|
386
470
|
|
|
387
471
|
// 启动队列处理
|
|
@@ -396,8 +480,20 @@ module.exports = function(RED) {
|
|
|
396
480
|
|
|
397
481
|
node.isProcessingCommand = true;
|
|
398
482
|
|
|
483
|
+
// 清理过期队列项
|
|
484
|
+
const now = Date.now();
|
|
485
|
+
node.commandQueue = node.commandQueue.filter(item => (now - item.timestamp) < node.queueTimeout);
|
|
486
|
+
|
|
399
487
|
while (node.commandQueue.length > 0) {
|
|
400
|
-
const
|
|
488
|
+
const item = node.commandQueue.shift();
|
|
489
|
+
|
|
490
|
+
// 再次检查是否过期
|
|
491
|
+
if (Date.now() - item.timestamp >= node.queueTimeout) {
|
|
492
|
+
node.warn(`丢弃过期命令(${Date.now() - item.timestamp}ms)`);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const state = item.state;
|
|
401
497
|
const command = state ? 'ON' : 'OFF';
|
|
402
498
|
|
|
403
499
|
try {
|
|
@@ -430,56 +526,99 @@ module.exports = function(RED) {
|
|
|
430
526
|
// 发送控制指令到物理开关面板(控制指示灯等)- 入队
|
|
431
527
|
node.sendCommandToPanel = function(state) {
|
|
432
528
|
if (!node.isRs485Connected) {
|
|
433
|
-
|
|
529
|
+
// 初始化期间静默警告
|
|
530
|
+
if (!node.isInitializing) {
|
|
531
|
+
node.warn('RS-485未连接,无法发送指示灯反馈');
|
|
532
|
+
}
|
|
434
533
|
return;
|
|
435
534
|
}
|
|
436
535
|
|
|
437
|
-
//
|
|
438
|
-
|
|
536
|
+
// 清理过期队列项(超过3秒)
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
node.ledFeedbackQueue = node.ledFeedbackQueue.filter(item => (now - item.timestamp) < node.queueTimeout);
|
|
539
|
+
|
|
540
|
+
// 加入LED反馈队列(带时间戳)
|
|
541
|
+
node.ledFeedbackQueue.push({ state, timestamp: now });
|
|
439
542
|
|
|
440
543
|
// 启动队列处理
|
|
441
544
|
node.processLedFeedbackQueue();
|
|
442
545
|
};
|
|
443
546
|
|
|
444
|
-
// 处理LED
|
|
547
|
+
// 处理LED反馈队列(基于面板ID的固定延迟)
|
|
445
548
|
node.processLedFeedbackQueue = async function() {
|
|
446
549
|
if (node.isProcessingLedFeedback || node.ledFeedbackQueue.length === 0) {
|
|
447
550
|
return;
|
|
448
551
|
}
|
|
449
552
|
|
|
553
|
+
// 初始化期间不处理LED反馈(避免部署时大量LED同时发送)
|
|
554
|
+
if (node.isInitializing) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
450
558
|
node.isProcessingLedFeedback = true;
|
|
451
559
|
|
|
560
|
+
// 清理过期队列项
|
|
561
|
+
const now = Date.now();
|
|
562
|
+
node.ledFeedbackQueue = node.ledFeedbackQueue.filter(item => (now - item.timestamp) < node.queueTimeout);
|
|
563
|
+
|
|
564
|
+
// 基于面板ID的固定延迟,避免多个节点同时写入TCP冲突
|
|
565
|
+
// 面板1=100ms, 面板2=200ms, 面板3=300ms, 面板4=400ms...
|
|
566
|
+
// 这样不同面板的LED反馈永远不会同时发送
|
|
567
|
+
const fixedDelay = node.config.switchId * 100;
|
|
568
|
+
if (fixedDelay > 0) {
|
|
569
|
+
await new Promise(resolve => setTimeout(resolve, fixedDelay));
|
|
570
|
+
}
|
|
571
|
+
|
|
452
572
|
while (node.ledFeedbackQueue.length > 0) {
|
|
453
|
-
const
|
|
573
|
+
const item = node.ledFeedbackQueue.shift();
|
|
574
|
+
|
|
575
|
+
// 再次检查是否过期
|
|
576
|
+
if (Date.now() - item.timestamp >= node.queueTimeout) {
|
|
577
|
+
node.warn(`丢弃过期LED反馈(${Date.now() - item.timestamp}ms)`);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const state = item.state;
|
|
454
582
|
|
|
455
583
|
try {
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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, // 通道(根据按键编号计算)
|
|
462
595
|
state
|
|
463
596
|
);
|
|
464
597
|
|
|
465
598
|
// 发送到RS-485总线
|
|
466
599
|
if (node.config.connectionType === "tcp") {
|
|
467
600
|
// TCP模式
|
|
468
|
-
if (node.
|
|
469
|
-
node.
|
|
470
|
-
|
|
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反馈');
|
|
471
607
|
}
|
|
472
608
|
} else {
|
|
473
609
|
// 串口模式
|
|
474
610
|
if (node.rs485Client._port) {
|
|
475
611
|
node.rs485Client._port.write(command);
|
|
476
|
-
|
|
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反馈');
|
|
477
616
|
}
|
|
478
617
|
}
|
|
479
618
|
|
|
480
|
-
// 队列间隔
|
|
619
|
+
// 队列间隔100ms(确保RS-485总线有足够时间处理)
|
|
481
620
|
if (node.ledFeedbackQueue.length > 0) {
|
|
482
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
621
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
483
622
|
}
|
|
484
623
|
} catch (err) {
|
|
485
624
|
node.error(`发送LED反馈失败: ${err.message}`);
|
|
@@ -751,8 +890,19 @@ module.exports = function(RED) {
|
|
|
751
890
|
node.reconnectTimer = null;
|
|
752
891
|
}
|
|
753
892
|
|
|
754
|
-
// 关闭
|
|
755
|
-
if (node.
|
|
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
|
+
}
|
|
903
|
+
|
|
904
|
+
// 关闭串口RS-485连接
|
|
905
|
+
if (node.rs485Client && node.isRs485Connected && node.config.connectionType !== "tcp") {
|
|
756
906
|
try {
|
|
757
907
|
node.rs485Client.close(() => {
|
|
758
908
|
node.log('RS-485连接已关闭');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-modbus",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.2",
|
|
4
4
|
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、智能MQTT连接(自动fallback HassOS/Docker环境)、Home Assistant自动发现和多品牌开关面板,生产级稳定版本",
|
|
5
5
|
"main": "nodes/modbus-master.js",
|
|
6
6
|
"scripts": {
|