node-red-contrib-symi-modbus 2.6.8 → 2.6.9
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 +212 -366
- package/examples/basic-flow.json +33 -21
- package/nodes/custom-protocol.html +276 -0
- package/nodes/custom-protocol.js +240 -0
- package/nodes/homekit-bridge.html +44 -22
- package/nodes/homekit-bridge.js +18 -0
- package/nodes/mesh-protocol.js +286 -0
- package/nodes/modbus-dashboard.html +444 -0
- package/nodes/modbus-dashboard.js +116 -0
- package/nodes/modbus-debug.js +10 -2
- package/nodes/modbus-master.js +175 -74
- package/nodes/modbus-slave-switch.html +196 -12
- package/nodes/modbus-slave-switch.js +479 -157
- package/nodes/serial-port-config.js +84 -21
- package/package.json +5 -3
|
@@ -2,10 +2,256 @@ module.exports = function(RED) {
|
|
|
2
2
|
"use strict";
|
|
3
3
|
const mqtt = require("mqtt");
|
|
4
4
|
const protocol = require("./lightweight-protocol");
|
|
5
|
+
const meshProtocol = require("./mesh-protocol")(RED);
|
|
6
|
+
const storage = require('node-persist');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
5
9
|
|
|
6
10
|
// 全局防抖缓存:防止多个节点重复处理同一个按键事件
|
|
7
11
|
const globalDebounceCache = new Map(); // key: "switchId-buttonNumber", value: timestamp
|
|
8
12
|
|
|
13
|
+
// 初始化Mesh设备持久化存储
|
|
14
|
+
const meshPersistDir = path.join(RED.settings.userDir || os.homedir() + '/.node-red', 'mesh-devices-persist');
|
|
15
|
+
let meshStorageInitialized = false;
|
|
16
|
+
|
|
17
|
+
async function initMeshStorage() {
|
|
18
|
+
if (meshStorageInitialized) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
await storage.init({
|
|
23
|
+
dir: meshPersistDir,
|
|
24
|
+
stringify: JSON.stringify,
|
|
25
|
+
parse: JSON.parse,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
logging: false,
|
|
28
|
+
ttl: false,
|
|
29
|
+
expiredInterval: 2 * 60 * 1000,
|
|
30
|
+
forgiveParseErrors: true
|
|
31
|
+
});
|
|
32
|
+
meshStorageInitialized = true;
|
|
33
|
+
RED.log.info(`Mesh设备持久化存储初始化成功: ${meshPersistDir}`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
RED.log.error(`Mesh设备持久化存储初始化失败: ${err.message}`);
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Mesh设备发现API(使用共享连接)
|
|
41
|
+
RED.httpAdmin.post('/symi-mesh/discover', async function(req, res) {
|
|
42
|
+
try {
|
|
43
|
+
const serialPortConfigId = req.body.serialPortConfig;
|
|
44
|
+
RED.log.info(`[Mesh设备发现] 收到请求,连接配置ID: ${serialPortConfigId}`);
|
|
45
|
+
|
|
46
|
+
if (!serialPortConfigId) {
|
|
47
|
+
RED.log.warn('[Mesh设备发现] 缺少连接配置参数');
|
|
48
|
+
return res.status(400).json({error: '缺少连接配置'});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 获取配置节点
|
|
52
|
+
const serialPortConfig = RED.nodes.getNode(serialPortConfigId);
|
|
53
|
+
if (!serialPortConfig) {
|
|
54
|
+
RED.log.warn(`[Mesh设备发现] 连接配置节点不存在: ${serialPortConfigId}`);
|
|
55
|
+
return res.status(400).json({error: '连接配置不存在,请先创建RS-485连接配置'});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
RED.log.info(`[Mesh设备发现] 配置节点类型: ${serialPortConfig.type || '未知'}`);
|
|
59
|
+
RED.log.info(`[Mesh设备发现] 连接类型: ${serialPortConfig.connectionType || '串口'}`);
|
|
60
|
+
|
|
61
|
+
// 初始化持久化存储
|
|
62
|
+
await initMeshStorage();
|
|
63
|
+
|
|
64
|
+
// 发送设备列表请求
|
|
65
|
+
const requestFrame = meshProtocol.buildGetDeviceListFrame();
|
|
66
|
+
RED.log.info(`[Mesh设备发现] 发送设备列表请求: ${requestFrame.toString('hex').toUpperCase()}`);
|
|
67
|
+
|
|
68
|
+
// 设置响应超时
|
|
69
|
+
let timeoutHandle;
|
|
70
|
+
let isCompleted = false;
|
|
71
|
+
let dataHandler = null;
|
|
72
|
+
|
|
73
|
+
const completeRequest = async (devices, reason) => {
|
|
74
|
+
if (isCompleted) return;
|
|
75
|
+
isCompleted = true;
|
|
76
|
+
|
|
77
|
+
clearTimeout(timeoutHandle);
|
|
78
|
+
|
|
79
|
+
// 注销数据监听器
|
|
80
|
+
if (dataHandler) {
|
|
81
|
+
serialPortConfig.unregisterDataListener(dataHandler);
|
|
82
|
+
RED.log.info(`[Mesh设备发现] 已注销数据监听器`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (devices.length === 0) {
|
|
86
|
+
RED.log.warn(`[Mesh设备发现] 未发现设备 (${reason || '超时'})`);
|
|
87
|
+
} else {
|
|
88
|
+
RED.log.info(`[Mesh设备发现] 发现 ${devices.length} 个设备`);
|
|
89
|
+
|
|
90
|
+
// 持久化保存设备列表(MAC地址 → 短地址映射)
|
|
91
|
+
const deviceMap = {};
|
|
92
|
+
devices.forEach(device => {
|
|
93
|
+
deviceMap[device.mac] = {
|
|
94
|
+
shortAddr: device.shortAddr,
|
|
95
|
+
type: device.type,
|
|
96
|
+
buttons: device.buttons,
|
|
97
|
+
lastUpdate: Date.now()
|
|
98
|
+
};
|
|
99
|
+
RED.log.info(` - MAC: ${device.mac}, 短地址: ${device.shortAddr}, 类型: ${device.type}, 按键数: ${device.buttons}`);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await storage.setItem('meshDeviceMap', deviceMap);
|
|
103
|
+
RED.log.info(`[Mesh设备发现] 设备列表已保存到持久化存储`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
res.json(devices);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
timeoutHandle = setTimeout(() => {
|
|
110
|
+
RED.log.warn(`[Mesh设备发现] 5秒超时,未收到完整响应`);
|
|
111
|
+
completeRequest([], '超时');
|
|
112
|
+
}, 5000);
|
|
113
|
+
|
|
114
|
+
// 收集响应帧
|
|
115
|
+
const responseFrames = [];
|
|
116
|
+
let totalDevices = 0;
|
|
117
|
+
let receivedDevices = 0;
|
|
118
|
+
let buffer = Buffer.alloc(0);
|
|
119
|
+
|
|
120
|
+
dataHandler = (data) => {
|
|
121
|
+
// 拼接数据到缓冲区
|
|
122
|
+
buffer = Buffer.concat([buffer, data]);
|
|
123
|
+
RED.log.info(`[Mesh设备发现] 收到数据: ${data.toString('hex').toUpperCase()}, 缓冲区总长: ${buffer.length}`);
|
|
124
|
+
|
|
125
|
+
// 解析所有完整的帧
|
|
126
|
+
// 协议格式: 53 92 00 10 [总数] [索引] [MAC 6字节] [短地址 2字节] [vendor_id 2字节] [dev_type] [dev_sub_type] [online/status] [resv] [校验]
|
|
127
|
+
// 总长度: 1+1+1+1+16+1 = 21字节
|
|
128
|
+
while (buffer.length >= 21) {
|
|
129
|
+
// 查找帧头 0x53
|
|
130
|
+
const headerIndex = buffer.indexOf(0x53);
|
|
131
|
+
if (headerIndex === -1) {
|
|
132
|
+
buffer = Buffer.alloc(0);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (headerIndex > 0) {
|
|
137
|
+
buffer = buffer.slice(headerIndex);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (buffer.length < 21) break;
|
|
141
|
+
|
|
142
|
+
// 检查是否是设备列表响应 (0x53 0x92 0x00 0x10)
|
|
143
|
+
if (buffer[1] !== 0x92 || buffer[2] !== 0x00 || buffer[3] !== 0x10) {
|
|
144
|
+
buffer = buffer.slice(1);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 提取完整帧(21字节)
|
|
149
|
+
const frame = buffer.slice(0, 21);
|
|
150
|
+
buffer = buffer.slice(21);
|
|
151
|
+
|
|
152
|
+
// 解析帧数据
|
|
153
|
+
const deviceCount = frame[4]; // 设备总数
|
|
154
|
+
const index = frame[5]; // 索引 (00, 01, 02...)
|
|
155
|
+
const mac = Array.from(frame.slice(6, 12))
|
|
156
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
157
|
+
.join(':');
|
|
158
|
+
const shortAddr = frame[12] | (frame[13] << 8); // 小端序
|
|
159
|
+
const vendorId = frame[14] | (frame[15] << 8); // 小端序
|
|
160
|
+
const devType = frame[16];
|
|
161
|
+
const devSubType = frame[17]; // 按键数
|
|
162
|
+
const online = frame[18];
|
|
163
|
+
const resv = frame[19];
|
|
164
|
+
const checksum = frame[20];
|
|
165
|
+
|
|
166
|
+
RED.log.info(`[Mesh设备发现] 收到响应帧,索引: ${index}/${deviceCount-1}, 完整帧: ${frame.toString('hex').toUpperCase()}`);
|
|
167
|
+
|
|
168
|
+
// 第一帧:记录设备总数
|
|
169
|
+
if (totalDevices === 0) {
|
|
170
|
+
totalDevices = deviceCount;
|
|
171
|
+
RED.log.info(`[Mesh设备发现] 设备总数: ${totalDevices}`);
|
|
172
|
+
if (totalDevices === 0) {
|
|
173
|
+
completeRequest([], '网关下无设备');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 只保存开关类型的设备(devType=0x01或0x02)
|
|
179
|
+
if ((devType === 0x01 || devType === 0x02) && devSubType > 0) {
|
|
180
|
+
responseFrames.push({
|
|
181
|
+
shortAddr,
|
|
182
|
+
mac,
|
|
183
|
+
type: devType,
|
|
184
|
+
buttons: devSubType
|
|
185
|
+
});
|
|
186
|
+
RED.log.info(` - 索引${index}: MAC=${mac}, 短地址=0x${shortAddr.toString(16).toUpperCase().padStart(4, '0')}, 类型=0x${devType.toString(16).toUpperCase()}, 按键数=${devSubType}, 在线=${online}`);
|
|
187
|
+
} else {
|
|
188
|
+
RED.log.debug(` - 索引${index}: 跳过非开关设备,MAC=${mac}, 类型=0x${devType.toString(16).toUpperCase()}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
receivedDevices++;
|
|
192
|
+
RED.log.info(`[Mesh设备发现] 已收到 ${receivedDevices}/${totalDevices} 个设备数据帧`);
|
|
193
|
+
|
|
194
|
+
// 如果收到所有设备数据,返回结果
|
|
195
|
+
if (receivedDevices >= totalDevices) {
|
|
196
|
+
RED.log.info(`[Mesh设备发现] 所有设备数据接收完成,共${responseFrames.length}个开关设备`);
|
|
197
|
+
completeRequest(responseFrames, '完成');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// 使用共享连接(TCP和串口都一样)
|
|
204
|
+
RED.log.info(`[Mesh设备发现] 使用共享连接(${serialPortConfig.connectionType})`);
|
|
205
|
+
|
|
206
|
+
// 注册数据监听器
|
|
207
|
+
serialPortConfig.registerDataListener(dataHandler);
|
|
208
|
+
|
|
209
|
+
// 发送请求
|
|
210
|
+
try {
|
|
211
|
+
serialPortConfig.write(requestFrame, (err) => {
|
|
212
|
+
if (err) {
|
|
213
|
+
RED.log.error(`[Mesh设备发现] 发送请求失败: ${err.message}`);
|
|
214
|
+
serialPortConfig.unregisterDataListener(dataHandler);
|
|
215
|
+
completeRequest([], `发送失败: ${err.message}`);
|
|
216
|
+
} else {
|
|
217
|
+
RED.log.info(`[Mesh设备发现] 请求已发送,等待响应...`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
RED.log.error(`[Mesh设备发现] 发送请求异常: ${err.message}`);
|
|
222
|
+
serialPortConfig.unregisterDataListener(dataHandler);
|
|
223
|
+
return res.status(500).json({error: `发送请求失败: ${err.message}`});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
} catch (err) {
|
|
227
|
+
RED.log.error(`Mesh设备发现失败: ${err.message}`);
|
|
228
|
+
res.status(500).json({error: err.message});
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// 获取已保存的Mesh设备列表API
|
|
233
|
+
RED.httpAdmin.get('/symi-mesh/devices', async function(req, res) {
|
|
234
|
+
try {
|
|
235
|
+
await initMeshStorage();
|
|
236
|
+
const deviceMap = await storage.getItem('meshDeviceMap') || {};
|
|
237
|
+
|
|
238
|
+
// 转换为数组格式
|
|
239
|
+
const devices = Object.keys(deviceMap).map(mac => {
|
|
240
|
+
return {
|
|
241
|
+
mac: mac,
|
|
242
|
+
shortAddr: deviceMap[mac].shortAddr,
|
|
243
|
+
type: deviceMap[mac].type,
|
|
244
|
+
buttons: deviceMap[mac].buttons
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
res.json(devices);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
RED.log.error(`获取Mesh设备列表失败: ${err.message}`);
|
|
251
|
+
res.status(500).json({error: err.message});
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
9
255
|
// 串口列表API - 支持Windows、Linux、macOS所有串口设备
|
|
10
256
|
RED.httpAdmin.get('/modbus-slave-switch/serialports', async function(req, res) {
|
|
11
257
|
try {
|
|
@@ -87,9 +333,15 @@ module.exports = function(RED) {
|
|
|
87
333
|
mqttBaseTopic: node.mqttServerConfig ? node.mqttServerConfig.baseTopic : "modbus/relay",
|
|
88
334
|
// 开关面板配置
|
|
89
335
|
switchBrand: config.switchBrand || "symi", // 面板品牌(默认亖米)
|
|
90
|
-
buttonType: config.buttonType || "switch", // 按钮类型:switch=开关模式,scene
|
|
336
|
+
buttonType: config.buttonType || "switch", // 按钮类型:switch=开关模式,scene=场景模式,mesh=Mesh模式
|
|
91
337
|
switchId: parseInt(config.switchId) || 0, // 开关ID(0-255,物理面板地址)
|
|
92
338
|
buttonNumber: parseInt(config.buttonNumber) || 1, // 按钮编号(1-8)
|
|
339
|
+
// Mesh模式配置
|
|
340
|
+
meshMacAddress: config.meshMacAddress || "", // Mesh设备MAC地址
|
|
341
|
+
meshShortAddress: parseInt(config.meshShortAddress) || 0, // Mesh设备短地址
|
|
342
|
+
meshButtonNumber: parseInt(config.meshButtonNumber) || 1, // Mesh按键编号(1-6)
|
|
343
|
+
meshTotalButtons: parseInt(config.meshTotalButtons) || 1, // Mesh开关总路数(1-6)
|
|
344
|
+
// 目标继电器配置
|
|
93
345
|
targetSlaveAddress: parseInt(config.targetSlaveAddress) || 10, // 目标继电器从站地址
|
|
94
346
|
targetCoilNumber: modbusCoilNumber // 目标继电器线圈编号(0-31,从用户输入的1-32转换)
|
|
95
347
|
};
|
|
@@ -101,16 +353,37 @@ module.exports = function(RED) {
|
|
|
101
353
|
node.lastMqttErrorLog = 0; // MQTT错误日志时间
|
|
102
354
|
node.errorLogInterval = 10 * 60 * 1000; // 错误日志间隔:10分钟
|
|
103
355
|
|
|
356
|
+
// Mesh模式状态缓存
|
|
357
|
+
node.meshCurrentStates = null; // Mesh设备当前状态(用于保持其他路不变)
|
|
358
|
+
|
|
359
|
+
// Mesh模式:从持久化存储更新短地址(如果MAC地址对应的短地址发生变化)
|
|
360
|
+
if (node.config.buttonType === 'mesh' && node.config.meshMacAddress) {
|
|
361
|
+
initMeshStorage().then(async () => {
|
|
362
|
+
try {
|
|
363
|
+
const deviceMap = await storage.getItem('meshDeviceMap') || {};
|
|
364
|
+
const savedDevice = deviceMap[node.config.meshMacAddress];
|
|
365
|
+
|
|
366
|
+
if (savedDevice && savedDevice.shortAddr !== node.config.meshShortAddress) {
|
|
367
|
+
node.log(`Mesh设备短地址已更新: ${node.config.meshMacAddress} 从 ${node.config.meshShortAddress} 更新为 ${savedDevice.shortAddr}`);
|
|
368
|
+
node.config.meshShortAddress = savedDevice.shortAddr;
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
node.warn(`更新Mesh设备短地址失败: ${err.message}`);
|
|
372
|
+
}
|
|
373
|
+
}).catch(err => {
|
|
374
|
+
node.warn(`初始化Mesh存储失败: ${err.message}`);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
104
378
|
// 命令队列(处理多个按键同时按下)- 带时间戳
|
|
105
379
|
node.commandQueue = [];
|
|
106
380
|
node.isProcessingCommand = false;
|
|
107
381
|
|
|
108
|
-
//
|
|
109
|
-
node.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
node.queueTimeout = 3000;
|
|
382
|
+
// LED反馈去重:记录最后一次发送的状态和时间戳(防止重复发送)
|
|
383
|
+
node.lastSentLedState = {
|
|
384
|
+
value: null,
|
|
385
|
+
timestamp: 0
|
|
386
|
+
};
|
|
114
387
|
|
|
115
388
|
// 防死循环:记录最后一次状态变化的时间戳和值
|
|
116
389
|
node.lastStateChange = {
|
|
@@ -224,8 +497,6 @@ module.exports = function(RED) {
|
|
|
224
497
|
// 结束初始化阶段(5秒后)- 避免部署时大量LED反馈同时发送
|
|
225
498
|
setTimeout(() => {
|
|
226
499
|
node.isInitializing = false;
|
|
227
|
-
// 初始化完成后,处理积累的LED反馈队列
|
|
228
|
-
node.processLedFeedbackQueue();
|
|
229
500
|
}, 5000);
|
|
230
501
|
|
|
231
502
|
// 监听物理开关面板的按键事件
|
|
@@ -304,7 +575,13 @@ module.exports = function(RED) {
|
|
|
304
575
|
// 处理RS-485接收到的数据
|
|
305
576
|
node.handleRs485Data = function(data) {
|
|
306
577
|
try {
|
|
307
|
-
//
|
|
578
|
+
// 如果是Mesh模式,使用Mesh协议解析
|
|
579
|
+
if (node.config.buttonType === 'mesh') {
|
|
580
|
+
node.handleMeshData(data);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 解析轻量级协议帧(RS-485模式)
|
|
308
585
|
const frame = protocol.parseFrame(data);
|
|
309
586
|
if (!frame) {
|
|
310
587
|
return; // 静默忽略无效帧
|
|
@@ -373,7 +650,60 @@ module.exports = function(RED) {
|
|
|
373
650
|
node.error(`解析RS-485数据失败: ${err.message}`);
|
|
374
651
|
}
|
|
375
652
|
};
|
|
376
|
-
|
|
653
|
+
|
|
654
|
+
// 处理Mesh协议数据
|
|
655
|
+
node.handleMeshData = function(data) {
|
|
656
|
+
try {
|
|
657
|
+
// 解析Mesh状态事件
|
|
658
|
+
const event = meshProtocol.parseStatusEvent(data);
|
|
659
|
+
if (!event) {
|
|
660
|
+
return; // 静默忽略无效帧
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// 检查是否是我们监听的Mesh设备
|
|
664
|
+
if (event.shortAddr !== node.config.meshShortAddress) {
|
|
665
|
+
return; // 不是我们的设备,忽略
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// 检查消息类型
|
|
669
|
+
if (event.msgType !== meshProtocol.PROTOCOL.MSG_TYPE_SWITCH &&
|
|
670
|
+
event.msgType !== meshProtocol.PROTOCOL.MSG_TYPE_SWITCH_6) {
|
|
671
|
+
return; // 不是开关状态,忽略
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// 获取按钮状态
|
|
675
|
+
if (!event.states || event.states.length < node.config.meshButtonNumber) {
|
|
676
|
+
return; // 状态数据不完整
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const buttonState = event.states[node.config.meshButtonNumber - 1];
|
|
680
|
+
if (buttonState === null) {
|
|
681
|
+
return; // 状态未知,忽略
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// 全局防抖:防止多个节点重复处理同一个按键
|
|
685
|
+
const debounceKey = `mesh-${node.config.meshShortAddress}-${node.config.meshButtonNumber}`;
|
|
686
|
+
const now = Date.now();
|
|
687
|
+
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
688
|
+
|
|
689
|
+
// 全局防抖:200ms内只触发一次
|
|
690
|
+
if (now - lastTriggerTime < 200) {
|
|
691
|
+
return; // 静默忽略重复触发
|
|
692
|
+
}
|
|
693
|
+
globalDebounceCache.set(debounceKey, now);
|
|
694
|
+
|
|
695
|
+
// 更新当前状态缓存(用于后续控制时保持其他路不变)
|
|
696
|
+
node.meshCurrentStates = event.states;
|
|
697
|
+
|
|
698
|
+
// 发送命令到继电器
|
|
699
|
+
node.debug(`Mesh开关${buttonState ? 'ON' : 'OFF'}: MAC=${node.config.meshMacAddress} 按键${node.config.meshButtonNumber}`);
|
|
700
|
+
node.sendMqttCommand(buttonState);
|
|
701
|
+
|
|
702
|
+
} catch (err) {
|
|
703
|
+
node.error(`解析Mesh数据失败: ${err.message}`);
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
|
|
377
707
|
// 发送命令到继电器(支持两种模式:MQTT模式和内部事件模式)
|
|
378
708
|
node.sendMqttCommand = function(state) {
|
|
379
709
|
// 模式1:MQTT模式(通过MQTT发送命令,由主站节点统一处理)
|
|
@@ -458,7 +788,8 @@ module.exports = function(RED) {
|
|
|
458
788
|
node.isProcessingCommand = false;
|
|
459
789
|
};
|
|
460
790
|
|
|
461
|
-
//
|
|
791
|
+
// 发送控制指令到物理开关面板(控制指示灯等)
|
|
792
|
+
// 直接发送到全局队列,由serial-port-config统一管理(20ms间隔串行发送)
|
|
462
793
|
node.sendCommandToPanel = function(state) {
|
|
463
794
|
// 检查连接状态
|
|
464
795
|
if (!node.serialPortConfig || !node.serialPortConfig.connection) {
|
|
@@ -469,110 +800,83 @@ module.exports = function(RED) {
|
|
|
469
800
|
return;
|
|
470
801
|
}
|
|
471
802
|
|
|
472
|
-
//
|
|
473
|
-
const now = Date.now();
|
|
474
|
-
node.ledFeedbackQueue = node.ledFeedbackQueue.filter(item => (now - item.timestamp) < node.queueTimeout);
|
|
475
|
-
|
|
476
|
-
// 加入LED反馈队列(带时间戳)
|
|
477
|
-
// 注意:这里不指定协议类型,在发送时根据情况选择
|
|
478
|
-
node.ledFeedbackQueue.push({ state, timestamp: now });
|
|
479
|
-
|
|
480
|
-
// 启动队列处理
|
|
481
|
-
node.processLedFeedbackQueue();
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
// 处理LED反馈队列(基于面板ID的固定延迟)
|
|
485
|
-
node.processLedFeedbackQueue = async function() {
|
|
486
|
-
if (node.isProcessingLedFeedback || node.ledFeedbackQueue.length === 0) {
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// 初始化期间不处理LED反馈(避免部署时大量LED同时发送)
|
|
803
|
+
// 初始化期间不发送LED反馈(避免部署时大量LED同时发送)
|
|
491
804
|
if (node.isInitializing) {
|
|
492
805
|
return;
|
|
493
806
|
}
|
|
494
|
-
|
|
495
|
-
node.isProcessingLedFeedback = true;
|
|
496
|
-
|
|
497
|
-
// 清理过期队列项
|
|
807
|
+
|
|
498
808
|
const now = Date.now();
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const fixedDelay = node.config.switchId * 100;
|
|
505
|
-
if (fixedDelay > 0) {
|
|
506
|
-
await new Promise(resolve => setTimeout(resolve, fixedDelay));
|
|
809
|
+
|
|
810
|
+
// 防止重复发送:如果状态相同且时间间隔小于50ms,跳过
|
|
811
|
+
if (node.lastSentLedState.value === state && (now - node.lastSentLedState.timestamp) < 50) {
|
|
812
|
+
node.debug(`跳过重复LED反馈(状态未变化,间隔${now - node.lastSentLedState.timestamp}ms)`);
|
|
813
|
+
return;
|
|
507
814
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
815
|
+
|
|
816
|
+
// 更新最后发送状态
|
|
817
|
+
node.lastSentLedState.value = state;
|
|
818
|
+
node.lastSentLedState.timestamp = now;
|
|
819
|
+
|
|
820
|
+
// 构建LED反馈协议帧
|
|
821
|
+
let command;
|
|
822
|
+
|
|
823
|
+
if (node.config.buttonType === 'mesh') {
|
|
824
|
+
// Mesh模式:发送Mesh控制帧
|
|
825
|
+
command = meshProtocol.buildSwitchControlFrame(
|
|
826
|
+
node.config.meshShortAddress,
|
|
827
|
+
node.config.meshButtonNumber,
|
|
828
|
+
node.config.meshTotalButtons,
|
|
829
|
+
state,
|
|
830
|
+
node.meshCurrentStates || null
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
if (!command) {
|
|
834
|
+
node.error(`构建Mesh控制帧失败`);
|
|
835
|
+
return;
|
|
516
836
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
// 使用初始化时计算的deviceAddr和channel
|
|
522
|
-
// 当物理按键按下时,会更新为实际的deviceAddr和channel
|
|
523
|
-
const deviceAddr = node.buttonDeviceAddr;
|
|
524
|
-
const channel = node.buttonChannel;
|
|
525
|
-
|
|
526
|
-
// 根据按钮类型选择协议类型
|
|
527
|
-
// 开关模式:使用SET协议(0x03),面板LED需要接收SET指令
|
|
528
|
-
// 场景模式:使用REPORT协议(0x04),面板LED需要接收REPORT指令
|
|
529
|
-
let command;
|
|
530
|
-
if (node.config.buttonType === 'scene') {
|
|
531
|
-
// 场景模式:使用REPORT协议
|
|
532
|
-
command = protocol.buildSingleLightReport(
|
|
533
|
-
node.config.switchId, // 本地地址(面板地址)
|
|
534
|
-
deviceAddr, // 设备地址(从按键事件中获取)
|
|
535
|
-
channel, // 通道(从按键事件中获取)
|
|
536
|
-
state
|
|
537
|
-
);
|
|
538
|
-
} else {
|
|
539
|
-
// 开关模式(默认):使用SET协议
|
|
540
|
-
command = protocol.buildSingleLightCommand(
|
|
541
|
-
node.config.switchId, // 本地地址(面板地址)
|
|
542
|
-
deviceAddr, // 设备地址(从按键事件中获取)
|
|
543
|
-
channel, // 通道(从按键事件中获取)
|
|
544
|
-
state
|
|
545
|
-
);
|
|
546
|
-
}
|
|
837
|
+
} else {
|
|
838
|
+
// RS-485模式:使用轻量级协议
|
|
839
|
+
const deviceAddr = node.buttonDeviceAddr;
|
|
840
|
+
const channel = node.buttonChannel;
|
|
547
841
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
node.
|
|
561
|
-
|
|
842
|
+
// 根据按钮类型选择协议类型
|
|
843
|
+
if (node.config.buttonType === 'scene') {
|
|
844
|
+
// 场景模式:使用REPORT协议
|
|
845
|
+
command = protocol.buildSingleLightReport(
|
|
846
|
+
node.config.switchId,
|
|
847
|
+
deviceAddr,
|
|
848
|
+
channel,
|
|
849
|
+
state
|
|
850
|
+
);
|
|
851
|
+
} else {
|
|
852
|
+
// 开关模式(默认):使用SET协议
|
|
853
|
+
command = protocol.buildSingleLightCommand(
|
|
854
|
+
node.config.switchId,
|
|
855
|
+
deviceAddr,
|
|
856
|
+
channel,
|
|
857
|
+
state
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
562
861
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
862
|
+
// 直接发送到全局队列(由serial-port-config统一管理,20ms间隔串行发送)
|
|
863
|
+
node.serialPortConfig.write(command, (err) => {
|
|
864
|
+
if (err) {
|
|
865
|
+
node.error(`LED反馈失败: ${err.message}`);
|
|
866
|
+
} else {
|
|
867
|
+
// 输出调试日志,确认LED反馈已发送(包含协议帧十六进制)
|
|
868
|
+
const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
|
|
869
|
+
if (node.config.buttonType === 'mesh') {
|
|
870
|
+
node.log(`Mesh LED反馈已发送:MAC=${node.config.meshMacAddress} 按钮${node.config.meshButtonNumber} = ${state ? 'ON' : 'OFF'} [${hexStr}]`);
|
|
871
|
+
} else {
|
|
872
|
+
const deviceAddr = node.buttonDeviceAddr;
|
|
873
|
+
const channel = node.buttonChannel;
|
|
874
|
+
node.log(`LED反馈已发送:面板${node.config.switchId} 按钮${node.config.buttonNumber} 设备${deviceAddr} 通道${channel} = ${state ? 'ON' : 'OFF'} (${node.config.buttonType === 'scene' ? 'REPORT' : 'SET'}) [${hexStr}]`);
|
|
567
875
|
}
|
|
568
|
-
} catch (err) {
|
|
569
|
-
node.error(`发送LED反馈失败: ${err.message}`);
|
|
570
876
|
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
node.isProcessingLedFeedback = false;
|
|
877
|
+
});
|
|
574
878
|
};
|
|
575
|
-
|
|
879
|
+
|
|
576
880
|
// 更新节点状态显示
|
|
577
881
|
node.updateStatus = function() {
|
|
578
882
|
const rs485Status = node.isRs485Connected ? 'OK' : 'ERR';
|
|
@@ -691,55 +995,59 @@ module.exports = function(RED) {
|
|
|
691
995
|
node.mqttClient.on('error', (err) => {
|
|
692
996
|
// 连接失败,尝试下一个候选地址
|
|
693
997
|
const errorMsg = err.message || err.code || '连接失败';
|
|
694
|
-
node.warn(`MQTT连接错误: ${errorMsg} (broker: ${brokerUrl})`);
|
|
695
|
-
|
|
696
998
|
const now = Date.now();
|
|
999
|
+
|
|
1000
|
+
// 使用日志限流,避免长期断网时产生垃圾日志
|
|
1001
|
+
const shouldLogError = (now - node.lastMqttErrorLog) > node.errorLogInterval;
|
|
1002
|
+
if (shouldLogError) {
|
|
1003
|
+
node.debug(`MQTT连接错误: ${errorMsg} (broker: ${brokerUrl})`);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
697
1006
|
const timeSinceLastAttempt = now - lastConnectAttempt;
|
|
698
|
-
|
|
699
|
-
// 避免频繁重试(至少等待1
|
|
1007
|
+
|
|
1008
|
+
// 避免频繁重试(至少等待1秒)
|
|
700
1009
|
if (timeSinceLastAttempt < 1000) {
|
|
701
1010
|
setTimeout(() => {
|
|
702
1011
|
tryNextBroker();
|
|
703
1012
|
}, 1000);
|
|
704
1013
|
return;
|
|
705
1014
|
}
|
|
706
|
-
|
|
1015
|
+
|
|
707
1016
|
tryNextBroker();
|
|
708
|
-
|
|
1017
|
+
|
|
709
1018
|
function tryNextBroker() {
|
|
710
1019
|
// 尝试下一个候选地址
|
|
711
1020
|
currentCandidateIndex = (currentCandidateIndex + 1) % brokerCandidates.length;
|
|
712
1021
|
const nextBroker = brokerCandidates[currentCandidateIndex];
|
|
713
|
-
|
|
1022
|
+
|
|
714
1023
|
// 如果回到第一个地址,说明所有地址都试过了
|
|
715
1024
|
if (currentCandidateIndex === 0) {
|
|
716
1025
|
// 判断是否是局域网IP配置(只有一个候选地址)
|
|
717
1026
|
const isSingleIpConfig = brokerCandidates.length === 1;
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
node.
|
|
731
|
-
node.
|
|
732
|
-
node.
|
|
733
|
-
node.error('提示:如果Node-RED运行在Docker容器中,可能需要使用host.docker.internal或容器IP [此错误将在10分钟后再次显示]');
|
|
734
|
-
node.lastMqttErrorLog = now;
|
|
1027
|
+
|
|
1028
|
+
// 使用日志限流,避免长期断网时产生垃圾日志
|
|
1029
|
+
const shouldLog = (now - node.lastMqttErrorLog) > node.errorLogInterval;
|
|
1030
|
+
|
|
1031
|
+
if (shouldLog) {
|
|
1032
|
+
if (isSingleIpConfig) {
|
|
1033
|
+
// 局域网IP配置失败,使用debug级别(不写入日志文件)
|
|
1034
|
+
node.debug(`MQTT连接失败: ${errorMsg}`);
|
|
1035
|
+
node.debug(`无法连接到MQTT broker: ${brokerCandidates[0]}`);
|
|
1036
|
+
node.debug('请检查:1) MQTT broker是否在该地址运行 2) 网络是否连通 3) 端口是否正确');
|
|
1037
|
+
} else {
|
|
1038
|
+
// 多个fallback地址都失败,使用debug级别
|
|
1039
|
+
node.debug(`MQTT错误: ${errorMsg}`);
|
|
1040
|
+
node.debug(`所有MQTT broker候选地址都无法连接: ${brokerCandidates.join(', ')}`);
|
|
1041
|
+
node.debug('请检查:1) MQTT broker是否运行 2) 网络连接是否正常 3) broker地址是否正确');
|
|
735
1042
|
}
|
|
1043
|
+
node.lastMqttErrorLog = now;
|
|
736
1044
|
}
|
|
737
|
-
|
|
738
|
-
// 5
|
|
1045
|
+
|
|
1046
|
+
// 30秒后重试第一个地址(从5秒改为30秒,减少重试频率)
|
|
739
1047
|
setTimeout(() => {
|
|
740
1048
|
node.debug('重试连接MQTT broker...');
|
|
741
1049
|
tryConnect(brokerCandidates[0]);
|
|
742
|
-
},
|
|
1050
|
+
}, 30000);
|
|
743
1051
|
} else {
|
|
744
1052
|
node.debug(`尝试备用MQTT broker: ${nextBroker}`);
|
|
745
1053
|
setTimeout(() => {
|
|
@@ -747,7 +1055,7 @@ module.exports = function(RED) {
|
|
|
747
1055
|
}, 500); // 快速尝试下一个地址
|
|
748
1056
|
}
|
|
749
1057
|
}
|
|
750
|
-
|
|
1058
|
+
|
|
751
1059
|
node.updateStatus();
|
|
752
1060
|
});
|
|
753
1061
|
|
|
@@ -758,16 +1066,16 @@ module.exports = function(RED) {
|
|
|
758
1066
|
node.mqttClient.on('offline', () => {
|
|
759
1067
|
const now = Date.now();
|
|
760
1068
|
const shouldLog = (now - node.lastMqttErrorLog) > node.errorLogInterval;
|
|
761
|
-
|
|
1069
|
+
|
|
762
1070
|
if (shouldLog) {
|
|
763
|
-
node.
|
|
1071
|
+
node.debug('MQTT离线,正在尝试重连...');
|
|
764
1072
|
node.lastMqttErrorLog = now;
|
|
765
1073
|
}
|
|
766
|
-
|
|
1074
|
+
|
|
767
1075
|
// 尝试下一个候选地址
|
|
768
1076
|
currentCandidateIndex = (currentCandidateIndex + 1) % brokerCandidates.length;
|
|
769
1077
|
const nextBroker = brokerCandidates[currentCandidateIndex];
|
|
770
|
-
|
|
1078
|
+
|
|
771
1079
|
setTimeout(() => {
|
|
772
1080
|
tryConnect(nextBroker);
|
|
773
1081
|
}, 2000);
|
|
@@ -838,35 +1146,50 @@ module.exports = function(RED) {
|
|
|
838
1146
|
|
|
839
1147
|
// 处理输入消息
|
|
840
1148
|
node.on('input', function(msg) {
|
|
841
|
-
if (!node.mqttClient || !node.mqttClient.connected) {
|
|
842
|
-
node.warn('MQTT未连接');
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
1149
|
let value = false;
|
|
847
|
-
|
|
1150
|
+
|
|
848
1151
|
// 解析输入值
|
|
849
1152
|
if (typeof msg.payload === 'boolean') {
|
|
850
1153
|
value = msg.payload;
|
|
851
1154
|
} else if (typeof msg.payload === 'string') {
|
|
852
|
-
value = (msg.payload.toLowerCase() === 'on' ||
|
|
853
|
-
msg.payload.toLowerCase() === 'true' ||
|
|
1155
|
+
value = (msg.payload.toLowerCase() === 'on' ||
|
|
1156
|
+
msg.payload.toLowerCase() === 'true' ||
|
|
854
1157
|
msg.payload === '1');
|
|
855
1158
|
} else if (typeof msg.payload === 'number') {
|
|
856
1159
|
value = (msg.payload !== 0);
|
|
857
1160
|
}
|
|
858
|
-
|
|
859
|
-
//
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1161
|
+
|
|
1162
|
+
// 更新当前状态
|
|
1163
|
+
node.currentState = value;
|
|
1164
|
+
|
|
1165
|
+
// 输出消息到debug节点(无论是否使用MQTT)
|
|
1166
|
+
node.send({
|
|
1167
|
+
payload: value,
|
|
1168
|
+
topic: `switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
|
|
1169
|
+
switchId: node.config.switchId,
|
|
1170
|
+
button: node.config.buttonNumber,
|
|
1171
|
+
targetSlave: node.config.targetSlaveAddress,
|
|
1172
|
+
targetCoil: node.config.targetCoilNumber,
|
|
1173
|
+
source: 'input'
|
|
869
1174
|
});
|
|
1175
|
+
|
|
1176
|
+
// 如果启用MQTT,发布命令到MQTT
|
|
1177
|
+
if (node.mqttClient && node.mqttClient.connected) {
|
|
1178
|
+
const command = value ? 'ON' : 'OFF';
|
|
1179
|
+
node.mqttClient.publish(node.commandTopic, command, { qos: 1 }, (err) => {
|
|
1180
|
+
if (err) {
|
|
1181
|
+
node.error(`发布命令失败: ${err.message}`);
|
|
1182
|
+
} else {
|
|
1183
|
+
node.log(`发送命令: ${command} 到 ${node.commandTopic}`);
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
} else {
|
|
1187
|
+
// 本地模式:通过内部事件发送命令
|
|
1188
|
+
node.sendMqttCommand(value);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// 更新状态显示
|
|
1192
|
+
node.updateStatus();
|
|
870
1193
|
});
|
|
871
1194
|
|
|
872
1195
|
// 节点关闭时清理
|
|
@@ -881,7 +1204,6 @@ module.exports = function(RED) {
|
|
|
881
1204
|
|
|
882
1205
|
// 清理队列(释放内存)
|
|
883
1206
|
node.commandQueue = [];
|
|
884
|
-
node.ledFeedbackQueue = [];
|
|
885
1207
|
node.frameBuffer = Buffer.alloc(0);
|
|
886
1208
|
|
|
887
1209
|
// 注销RS-485数据监听器(不关闭连接,由配置节点管理)
|