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.
- package/README.md +395 -445
- package/RELEASE_CHECKLIST.md +106 -0
- package/examples/basic-flow.json +243 -0
- package/nodes/lightweight-protocol.js +12 -6
- package/nodes/modbus-master.html +13 -185
- package/nodes/modbus-master.js +453 -148
- package/nodes/modbus-server-config.html +174 -0
- package/nodes/modbus-server-config.js +18 -0
- package/nodes/modbus-slave-switch.html +33 -161
- package/nodes/modbus-slave-switch.js +208 -317
- package/nodes/serial-port-config.html +191 -0
- package/nodes/serial-port-config.js +270 -0
- package/package.json +9 -5
|
@@ -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
|
-
//
|
|
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:
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
410
|
-
return;
|
|
255
|
+
return; // 静默忽略无效帧
|
|
411
256
|
}
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
node.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
585
|
-
//
|
|
586
|
-
const deviceAddr = Math.floor((node.config.buttonNumber - 1) / 4) + 1;
|
|
587
|
-
const channel = ((node.config.buttonNumber - 1) % 4) + 1;
|
|
588
|
-
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
611
|
-
node.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
460
|
+
// 开关模式(默认):使用SET协议
|
|
461
|
+
command = protocol.buildSingleLightCommand(
|
|
462
|
+
node.config.switchId, // 本地地址(面板地址)
|
|
463
|
+
deviceAddr, // 设备地址(从按键事件中获取)
|
|
464
|
+
channel, // 通道(从按键事件中获取)
|
|
465
|
+
state
|
|
466
|
+
);
|
|
617
467
|
}
|
|
618
|
-
|
|
619
|
-
//
|
|
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,
|
|
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}
|
|
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
|
-
|
|
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
|
-
//
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
//
|
|
905
|
-
if (node.
|
|
777
|
+
// 注销RS-485数据监听器(不关闭连接,由配置节点管理)
|
|
778
|
+
if (node.serialPortConfig && node.serialDataListener) {
|
|
906
779
|
try {
|
|
907
|
-
node.
|
|
908
|
-
|
|
909
|
-
});
|
|
780
|
+
node.serialPortConfig.unregisterDataListener(node.serialDataListener);
|
|
781
|
+
node.log('RS-485数据监听器已注销');
|
|
910
782
|
} catch (err) {
|
|
911
|
-
node.warn(
|
|
783
|
+
node.warn(`注销RS-485监听器时出错: ${err.message}`);
|
|
912
784
|
}
|
|
785
|
+
node.serialDataListener = null;
|
|
913
786
|
}
|
|
914
787
|
|
|
915
788
|
// 关闭MQTT连接
|
|
916
|
-
if (node.mqttClient
|
|
917
|
-
|
|
918
|
-
node.
|
|
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
|
}
|