node-red-contrib-symi-modbus 2.9.10 → 2.9.12
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 +11 -3
- package/nodes/clowire-protocol.js +28 -3
- package/nodes/homekit-bridge.js +2 -1
- package/nodes/lightweight-protocol.js +56 -53
- package/nodes/mesh-protocol.js +76 -0
- package/nodes/modbus-dashboard.html +4 -3
- package/nodes/modbus-dashboard.js +3 -1
- package/nodes/modbus-master.js +83 -57
- package/nodes/modbus-slave-switch.js +235 -83
- package/nodes/relay-output.html +49 -2
- package/nodes/relay-output.js +2 -0
- package/nodes/serial-port-config.js +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -950,12 +950,20 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
950
950
|
- hap-nodejs: ^1.2.0
|
|
951
951
|
- node-persist: ^4.0.4
|
|
952
952
|
|
|
953
|
-
##
|
|
953
|
+
## 版本历史
|
|
954
954
|
|
|
955
|
-
|
|
955
|
+
### 2.9.12
|
|
956
|
+
- **Mesh 持久化优化**:Mesh 设备列表发现后自动保存到磁盘,网关离线或 Node-RED 重启后依然保留已配置的实体,无需重新扫描。
|
|
957
|
+
- **门禁过滤功能**:在“继电器输出”节点中支持门禁 ID 过滤,支持“0=不过滤”模式,方便门禁联动场景。
|
|
958
|
+
- **联动事件增强**:修复了从站开关节点未触发 `modbus:buttonPressed` 内部事件的问题,现在“继电器输出”节点可以完美绑定到 Symi/Clowire/Mesh 开关。
|
|
959
|
+
- **稳定性提升**:优化了 Mesh 模式下的 LED 反馈逻辑和状态同步防抖。
|
|
960
|
+
|
|
961
|
+
### 2.9.11
|
|
962
|
+
- 增加对 Clowire (克伦威尔) 协议的支持。
|
|
963
|
+
- 优化了 RS-485 拼包算法,解决工控机串口分包导致的数据解析失败。
|
|
956
964
|
|
|
957
965
|
**v2.9.10 更新内容**:
|
|
958
|
-
-
|
|
966
|
+
- **日志系统优化 (解决日志占用问题)**:
|
|
959
967
|
- **彻底静默重连日志**:将 TCP/串口连接过程中的 `node.error` 降级为 `node.log` 或 `node.debug`,不再发送到 Node-RED 调试面板(Debug Tab),彻底解决重连期间日志刷屏问题。
|
|
960
968
|
- **智能过滤**:相同的连接错误在重试期间不再重复输出日志(每 10 分钟仅后台提醒一次)。
|
|
961
969
|
- **状态栏增强**:将具体错误信息(如“拒绝连接”、“串口不存在”)直接显示在节点状态文字中,无需查看日志即可掌握连接状况。
|
|
@@ -193,6 +193,29 @@ module.exports = {
|
|
|
193
193
|
return buffer;
|
|
194
194
|
},
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* 判断是否是有效的 Clowire 协议帧
|
|
198
|
+
* @param {Buffer} buffer - 接收到的数据
|
|
199
|
+
* @returns {boolean} 是否有效
|
|
200
|
+
*/
|
|
201
|
+
isClowireFrame: function(buffer) {
|
|
202
|
+
if (!buffer || buffer.length < 9) return false;
|
|
203
|
+
if (buffer[buffer.length - 1] !== this.FRAME_TAIL) return false;
|
|
204
|
+
|
|
205
|
+
// 验证CRC16
|
|
206
|
+
const dataLen = buffer.length - 3;
|
|
207
|
+
const receivedCRC = buffer[dataLen] | (buffer[dataLen + 1] << 8);
|
|
208
|
+
const calculatedCRC = this.calculateCRC16(buffer, dataLen);
|
|
209
|
+
|
|
210
|
+
if (receivedCRC === calculatedCRC) return true;
|
|
211
|
+
|
|
212
|
+
// 宽松模式:如果是按键事件帧,允许CRC不匹配
|
|
213
|
+
const cmdCode = buffer[1];
|
|
214
|
+
const isButtonEvent = (cmdCode === this.CMD_BUTTON_EVENT || cmdCode === this.CMD_READ);
|
|
215
|
+
|
|
216
|
+
return isButtonEvent;
|
|
217
|
+
},
|
|
218
|
+
|
|
196
219
|
/**
|
|
197
220
|
* 解析接收到的协议帧
|
|
198
221
|
* @param {Buffer} buffer - 接收到的数据
|
|
@@ -288,10 +311,12 @@ module.exports = {
|
|
|
288
311
|
|
|
289
312
|
// 提取帧
|
|
290
313
|
const frameBuffer = buffer.slice(offset, endIndex + 1);
|
|
291
|
-
const frame = this.parseFrame(frameBuffer);
|
|
292
314
|
|
|
293
|
-
if (
|
|
294
|
-
|
|
315
|
+
if (this.isClowireFrame(frameBuffer)) {
|
|
316
|
+
const frame = this.parseFrame(frameBuffer);
|
|
317
|
+
if (frame) {
|
|
318
|
+
frames.push(frame);
|
|
319
|
+
}
|
|
295
320
|
}
|
|
296
321
|
|
|
297
322
|
offset = endIndex + 1;
|
package/nodes/homekit-bridge.js
CHANGED
|
@@ -227,7 +227,8 @@ module.exports = function(RED) {
|
|
|
227
227
|
slave: slaveAddr,
|
|
228
228
|
coil: coil,
|
|
229
229
|
value: value,
|
|
230
|
-
source: 'homekit'
|
|
230
|
+
source: 'homekit',
|
|
231
|
+
masterId: node.config.masterNodeId
|
|
231
232
|
});
|
|
232
233
|
|
|
233
234
|
node.log(`HomeKit控制: 从站${slaveAddr} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
@@ -315,6 +315,36 @@ module.exports = {
|
|
|
315
315
|
* @param {Buffer} buffer - 接收到的数据(可能包含多个帧)
|
|
316
316
|
* @returns {Array} 解析出的所有帧数组
|
|
317
317
|
*/
|
|
318
|
+
/**
|
|
319
|
+
* 判断是否是有效的亖米协议帧
|
|
320
|
+
* @param {Buffer} buffer - 接收到的数据
|
|
321
|
+
* @returns {boolean} 是否有效
|
|
322
|
+
*/
|
|
323
|
+
isSymiFrame: function(buffer) {
|
|
324
|
+
if (!buffer || buffer.length < 15) return false;
|
|
325
|
+
if (buffer[0] !== this.FRAME_HEADER) return false;
|
|
326
|
+
if (buffer[buffer.length - 1] !== this.FRAME_TAIL) return false;
|
|
327
|
+
|
|
328
|
+
const dataLen = buffer[3];
|
|
329
|
+
if (buffer.length !== dataLen) return false;
|
|
330
|
+
|
|
331
|
+
// 校验和验证
|
|
332
|
+
const receivedCRC = buffer[buffer.length - 2];
|
|
333
|
+
const calculatedCRC = this.calculateCRC8(buffer, buffer.length);
|
|
334
|
+
|
|
335
|
+
if (receivedCRC === calculatedCRC) return true;
|
|
336
|
+
|
|
337
|
+
// 宽松模式:如果是按键事件帧,允许CRC不匹配
|
|
338
|
+
const dataType = buffer[2];
|
|
339
|
+
const deviceType = buffer[4];
|
|
340
|
+
const opCode = buffer.length > 11 ? buffer[11] : 0;
|
|
341
|
+
const isButtonEvent = (dataType === 0x03 || dataType === 0x04) &&
|
|
342
|
+
(deviceType === 0x01 || deviceType === 0x07) &&
|
|
343
|
+
opCode === 0x00;
|
|
344
|
+
|
|
345
|
+
return isButtonEvent;
|
|
346
|
+
},
|
|
347
|
+
|
|
318
348
|
parseAllFrames: function(buffer) {
|
|
319
349
|
const frames = [];
|
|
320
350
|
if (!buffer || buffer.length < 15) {
|
|
@@ -362,61 +392,34 @@ module.exports = {
|
|
|
362
392
|
// 提取帧数据
|
|
363
393
|
const frameBuffer = buffer.slice(startIndex, startIndex + dataLen);
|
|
364
394
|
|
|
365
|
-
//
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (isButtonEvent &&
|
|
387
|
-
frameBuffer[0] === this.FRAME_HEADER &&
|
|
388
|
-
frameBuffer[frameBuffer.length - 1] === this.FRAME_TAIL &&
|
|
389
|
-
frameBuffer[3] === dataLen) {
|
|
390
|
-
// 宽松模式:允许CRC不匹配的按键事件帧通过
|
|
391
|
-
} else {
|
|
392
|
-
offset = startIndex + 1; // 非按键事件,CRC错误,跳过继续搜索
|
|
393
|
-
continue;
|
|
395
|
+
// 验证帧有效性
|
|
396
|
+
if (this.isSymiFrame(frameBuffer)) {
|
|
397
|
+
// 解析帧
|
|
398
|
+
const frame = {
|
|
399
|
+
localAddr: frameBuffer[1],
|
|
400
|
+
dataType: frameBuffer[2],
|
|
401
|
+
dataLen: frameBuffer[3],
|
|
402
|
+
deviceType: frameBuffer[4],
|
|
403
|
+
brandID: frameBuffer[5],
|
|
404
|
+
deviceAddr: frameBuffer[6],
|
|
405
|
+
channel: frameBuffer[7],
|
|
406
|
+
roomNo: frameBuffer[8],
|
|
407
|
+
roomType: frameBuffer[9],
|
|
408
|
+
roomID: frameBuffer[10],
|
|
409
|
+
opCode: frameBuffer[11],
|
|
410
|
+
opInfo: []
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// 提取操作信息
|
|
414
|
+
for (let i = 12; i < frameBuffer.length - 2; i++) {
|
|
415
|
+
frame.opInfo.push(frameBuffer[i]);
|
|
394
416
|
}
|
|
417
|
+
|
|
418
|
+
frames.push(frame);
|
|
419
|
+
offset = startIndex + dataLen;
|
|
420
|
+
} else {
|
|
421
|
+
offset = startIndex + 1; // 无效帧,跳过继续搜索
|
|
395
422
|
}
|
|
396
|
-
|
|
397
|
-
// 解析帧
|
|
398
|
-
const frame = {
|
|
399
|
-
localAddr: frameBuffer[1],
|
|
400
|
-
dataType: frameBuffer[2],
|
|
401
|
-
dataLen: frameBuffer[3],
|
|
402
|
-
deviceType: frameBuffer[4],
|
|
403
|
-
brandID: frameBuffer[5],
|
|
404
|
-
deviceAddr: frameBuffer[6],
|
|
405
|
-
channel: frameBuffer[7],
|
|
406
|
-
roomNo: frameBuffer[8],
|
|
407
|
-
roomType: frameBuffer[9],
|
|
408
|
-
roomID: frameBuffer[10],
|
|
409
|
-
opCode: frameBuffer[11],
|
|
410
|
-
opInfo: []
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
// 提取操作信息
|
|
414
|
-
for (let i = 12; i < frameBuffer.length - 2; i++) {
|
|
415
|
-
frame.opInfo.push(frameBuffer[i]);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
frames.push(frame);
|
|
419
|
-
offset = startIndex + dataLen; // 移动到下一帧
|
|
420
423
|
}
|
|
421
424
|
|
|
422
425
|
return frames;
|
package/nodes/mesh-protocol.js
CHANGED
|
@@ -332,9 +332,85 @@ module.exports = function(RED) {
|
|
|
332
332
|
return stateValue;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* 判断是否是有效的Mesh帧
|
|
337
|
+
* @param {Buffer} buffer - 接收到的数据
|
|
338
|
+
* @returns {boolean} 是否有效
|
|
339
|
+
*/
|
|
340
|
+
function isMeshFrame(buffer) {
|
|
341
|
+
if (!buffer || buffer.length < 5) return false;
|
|
342
|
+
if (buffer[0] !== PROTOCOL.HEADER) return false;
|
|
343
|
+
|
|
344
|
+
const dataLen = buffer[3];
|
|
345
|
+
const totalLen = 4 + dataLen + 1;
|
|
346
|
+
|
|
347
|
+
if (buffer.length !== totalLen) return false;
|
|
348
|
+
|
|
349
|
+
// 校验和验证
|
|
350
|
+
const receivedChecksum = buffer[buffer.length - 1];
|
|
351
|
+
const calculatedChecksum = calculateChecksum(buffer.slice(0, buffer.length - 1));
|
|
352
|
+
|
|
353
|
+
return receivedChecksum === calculatedChecksum;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 解析所有Mesh协议帧(处理粘包)
|
|
358
|
+
* @param {Buffer} buffer - 接收到的数据(可能包含多个帧)
|
|
359
|
+
* @returns {Array} 解析出的所有事件数组
|
|
360
|
+
*/
|
|
361
|
+
function parseAllFrames(buffer) {
|
|
362
|
+
const events = [];
|
|
363
|
+
if (!buffer || buffer.length < 5) return events;
|
|
364
|
+
|
|
365
|
+
let offset = 0;
|
|
366
|
+
let maxIterations = 10;
|
|
367
|
+
|
|
368
|
+
while (offset < buffer.length && maxIterations > 0) {
|
|
369
|
+
maxIterations--;
|
|
370
|
+
|
|
371
|
+
// 查找帧头
|
|
372
|
+
const headerIndex = buffer.indexOf(PROTOCOL.HEADER, offset);
|
|
373
|
+
if (headerIndex === -1) break;
|
|
374
|
+
|
|
375
|
+
// 更新偏移量到帧头
|
|
376
|
+
offset = headerIndex;
|
|
377
|
+
|
|
378
|
+
// 检查剩余长度是否足够读取长度字节
|
|
379
|
+
if (buffer.length - offset < 4) break;
|
|
380
|
+
|
|
381
|
+
const dataLen = buffer[offset + 3];
|
|
382
|
+
const totalLen = 4 + dataLen + 1;
|
|
383
|
+
|
|
384
|
+
// 检查非法长度(Mesh帧通常不会太大)
|
|
385
|
+
if (totalLen > 64 || totalLen < 5) {
|
|
386
|
+
offset++;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 检查剩余数据是否足够一个完整帧
|
|
391
|
+
if (buffer.length - offset < totalLen) break;
|
|
392
|
+
|
|
393
|
+
const frameBuffer = buffer.slice(offset, offset + totalLen);
|
|
394
|
+
|
|
395
|
+
// 验证帧有效性
|
|
396
|
+
if (isMeshFrame(frameBuffer)) {
|
|
397
|
+
const event = parseStatusEvent(frameBuffer);
|
|
398
|
+
if (event) {
|
|
399
|
+
events.push(event);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
offset += totalLen;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return events;
|
|
407
|
+
}
|
|
408
|
+
|
|
335
409
|
return {
|
|
336
410
|
PROTOCOL,
|
|
337
411
|
calculateChecksum,
|
|
412
|
+
isMeshFrame,
|
|
413
|
+
parseAllFrames,
|
|
338
414
|
buildGetDeviceListFrame,
|
|
339
415
|
parseDeviceListResponse,
|
|
340
416
|
buildSwitchControlFrame,
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
var newState = !currentState;
|
|
135
135
|
|
|
136
136
|
// 发送控制命令(通过HTTP API)
|
|
137
|
-
sendControlCommand(slaveAddr, coil, newState);
|
|
137
|
+
sendControlCommand(slaveAddr, coil, newState, masterNodeId);
|
|
138
138
|
|
|
139
139
|
// 立即更新UI(乐观更新)
|
|
140
140
|
stateCache[key] = newState;
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// 发送控制命令
|
|
162
|
-
function sendControlCommand(slaveAddr, coil, value) {
|
|
162
|
+
function sendControlCommand(slaveAddr, coil, value, masterNodeId) {
|
|
163
163
|
// 通过Node-RED的admin API发送注入命令
|
|
164
164
|
$.ajax({
|
|
165
165
|
url: '/modbus-dashboard/control',
|
|
@@ -168,7 +168,8 @@
|
|
|
168
168
|
data: JSON.stringify({
|
|
169
169
|
slave: slaveAddr,
|
|
170
170
|
coil: coil,
|
|
171
|
-
value: value
|
|
171
|
+
value: value,
|
|
172
|
+
masterNodeId: masterNodeId
|
|
172
173
|
}),
|
|
173
174
|
success: function() {
|
|
174
175
|
console.log(`控制命令已发送: 从站${slaveAddr} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
@@ -155,6 +155,7 @@ module.exports = function(RED) {
|
|
|
155
155
|
var slave = parseInt(req.body.slave);
|
|
156
156
|
var coil = parseInt(req.body.coil);
|
|
157
157
|
var value = Boolean(req.body.value);
|
|
158
|
+
var masterNodeId = req.body.masterNodeId;
|
|
158
159
|
|
|
159
160
|
if (isNaN(slave) || isNaN(coil)) {
|
|
160
161
|
res.status(400).json({error: '参数错误'});
|
|
@@ -166,7 +167,8 @@ module.exports = function(RED) {
|
|
|
166
167
|
slave: slave,
|
|
167
168
|
coil: coil,
|
|
168
169
|
value: value,
|
|
169
|
-
source: 'dashboard'
|
|
170
|
+
source: 'dashboard',
|
|
171
|
+
masterId: masterNodeId
|
|
170
172
|
});
|
|
171
173
|
|
|
172
174
|
// 立即更新缓存(乐观更新)
|
package/nodes/modbus-master.js
CHANGED
|
@@ -1161,73 +1161,99 @@ module.exports = function(RED) {
|
|
|
1161
1161
|
// 这样可以确保每个从站都按照正确的间隔轮询
|
|
1162
1162
|
};
|
|
1163
1163
|
|
|
1164
|
-
//
|
|
1165
|
-
|
|
1164
|
+
// 初始化拼包缓冲区
|
|
1165
|
+
node.serialBuffer = Buffer.alloc(0);
|
|
1166
|
+
|
|
1167
|
+
// 处理Symi按键事件(私有协议)- 增加拼包支持
|
|
1166
1168
|
node.handleSymiButtonEvent = function(data) {
|
|
1167
1169
|
try {
|
|
1168
|
-
//
|
|
1169
|
-
|
|
1170
|
-
if (!frame) {
|
|
1171
|
-
// 不是有效的Symi帧(CRC校验失败或格式错误),静默忽略
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1170
|
+
// 将新数据拼接到缓冲区
|
|
1171
|
+
node.serialBuffer = Buffer.concat([node.serialBuffer, data]);
|
|
1174
1172
|
|
|
1175
|
-
//
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
// 不是按键事件,静默忽略
|
|
1179
|
-
return;
|
|
1173
|
+
// 限制缓冲区大小,防止异常情况下内存溢出(最大1KB)
|
|
1174
|
+
if (node.serialBuffer.length > 1024) {
|
|
1175
|
+
node.serialBuffer = node.serialBuffer.slice(-1024);
|
|
1180
1176
|
}
|
|
1181
1177
|
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1184
|
-
//
|
|
1185
|
-
|
|
1186
|
-
|
|
1178
|
+
// 循环处理缓冲区中的所有完整帧
|
|
1179
|
+
while (node.serialBuffer.length >= 15) {
|
|
1180
|
+
// 查找帧头 0x7E
|
|
1181
|
+
const startIndex = node.serialBuffer.indexOf(0x7E);
|
|
1182
|
+
if (startIndex === -1) {
|
|
1183
|
+
// 没找到帧头,清空缓冲区
|
|
1184
|
+
node.serialBuffer = Buffer.alloc(0);
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
1187
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
// 查找对应的从站和线圈
|
|
1194
|
-
// 假设:设备地址1对应从站10,设备地址2对应从站11,以此类推
|
|
1195
|
-
// 通道号直接对应线圈号(1-8 → 0-7)
|
|
1196
|
-
const slaveId = 10 + (deviceAddr - 1);
|
|
1197
|
-
const coilNumber = channel - 1;
|
|
1198
|
-
|
|
1199
|
-
// 检查从站是否在配置中
|
|
1200
|
-
const slaveConfig = node.config.slaves.find(s => s.address === slaveId);
|
|
1201
|
-
if (!slaveConfig) {
|
|
1202
|
-
// 从站未配置,静默忽略(不是本节点的数据)
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1188
|
+
// 如果帧头不在开始位置,丢弃前面的垃圾数据
|
|
1189
|
+
if (startIndex > 0) {
|
|
1190
|
+
node.serialBuffer = node.serialBuffer.slice(startIndex);
|
|
1191
|
+
}
|
|
1205
1192
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1193
|
+
// 再次检查剩余长度是否足够解析长度字段(至少4字节)
|
|
1194
|
+
if (node.serialBuffer.length < 4) break;
|
|
1195
|
+
|
|
1196
|
+
// 获取协议声明的整帧长度(第4个字节)
|
|
1197
|
+
const frameLen = node.serialBuffer[3];
|
|
1198
|
+
|
|
1199
|
+
// 验证长度合理性 (Symi协议最小15字节,最大一般不超过64)
|
|
1200
|
+
if (frameLen < 15 || frameLen > 64) {
|
|
1201
|
+
// 长度非法,丢弃这个错误的帧头,继续找下一个
|
|
1202
|
+
node.serialBuffer = node.serialBuffer.slice(1);
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1211
1205
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1206
|
+
// 检查缓冲区数据是否已经达到完整帧长度
|
|
1207
|
+
if (node.serialBuffer.length < frameLen) {
|
|
1208
|
+
// 数据还没到齐,跳出循环等待下一波数据
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1215
1211
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1212
|
+
// 截取完整帧进行解析
|
|
1213
|
+
const completeFrame = node.serialBuffer.slice(0, frameLen);
|
|
1214
|
+
|
|
1215
|
+
// 移除缓冲区中已处理的部分
|
|
1216
|
+
node.serialBuffer = node.serialBuffer.slice(frameLen);
|
|
1217
|
+
|
|
1218
|
+
// 执行解析逻辑
|
|
1219
|
+
const frame = protocol.parseFrame(completeFrame);
|
|
1220
|
+
if (!frame) continue;
|
|
1221
|
+
|
|
1222
|
+
// 只处理SET类型(0x03)的按键事件
|
|
1223
|
+
if (frame.dataType !== 0x03) continue;
|
|
1224
|
+
if (frame.deviceType !== 0x01) continue;
|
|
1225
|
+
|
|
1226
|
+
const deviceAddr = frame.deviceAddr;
|
|
1227
|
+
const channel = frame.channel;
|
|
1228
|
+
const state = frame.opInfo[0] === 0x01;
|
|
1220
1229
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
node.
|
|
1230
|
-
node.
|
|
1230
|
+
const slaveId = 10 + (deviceAddr - 1);
|
|
1231
|
+
const coilNumber = channel - 1;
|
|
1232
|
+
|
|
1233
|
+
const slaveConfig = node.config.slaves.find(s => s.address === slaveId);
|
|
1234
|
+
if (!slaveConfig) continue;
|
|
1235
|
+
|
|
1236
|
+
if (coilNumber < slaveConfig.coilStart || coilNumber > slaveConfig.coilEnd) continue;
|
|
1237
|
+
|
|
1238
|
+
node.debug(`Symi按键事件(拼包成功): 设备${deviceAddr} 通道${channel} 状态=${state ? 'ON' : 'OFF'}`);
|
|
1239
|
+
node.debug(`Symi按键映射: 设备${deviceAddr}通道${channel} → 从站${slaveId}线圈${coilNumber}`);
|
|
1240
|
+
|
|
1241
|
+
// 写入线圈(异步,不阻塞)
|
|
1242
|
+
node.writeSingleCoil(slaveId, coilNumber, state).catch(err => {
|
|
1243
|
+
node.log(`Symi按键控制失败: ${err.message}`);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
// 发送应答帧(REPORT类型0x04,反馈LED状态)
|
|
1247
|
+
// 协议规定:面板发送0x03(SET)按键事件,主机应该用0x04(REPORT)反馈LED状态
|
|
1248
|
+
const responseFrame = protocol.buildSingleLightReport(0x01, deviceAddr, channel, state);
|
|
1249
|
+
if (node.client._port && node.client._port.write) {
|
|
1250
|
+
node.client._port.write(responseFrame);
|
|
1251
|
+
node.log(`Symi应答已发送(REPORT): 设备${deviceAddr} 通道${channel} 状态=${state ? 'ON' : 'OFF'}`);
|
|
1252
|
+
} else if (node.client._client && node.client._client.write) {
|
|
1253
|
+
// TCP模式
|
|
1254
|
+
node.client._client.write(responseFrame);
|
|
1255
|
+
node.log(`Symi应答已发送(REPORT/TCP): 设备${deviceAddr} 通道${channel} 状态=${state ? 'ON' : 'OFF'}`);
|
|
1256
|
+
}
|
|
1231
1257
|
}
|
|
1232
1258
|
|
|
1233
1259
|
} catch (err) {
|
|
@@ -399,6 +399,9 @@ module.exports = function(RED) {
|
|
|
399
399
|
node.lastMqttErrorLog = 0; // MQTT错误日志时间
|
|
400
400
|
node.errorLogInterval = 10 * 60 * 1000; // 错误日志间隔:10分钟
|
|
401
401
|
|
|
402
|
+
// RS-485 拼包缓冲区(解决工控机分包问题)
|
|
403
|
+
node.rs485Buffer = Buffer.alloc(0);
|
|
404
|
+
|
|
402
405
|
// Mesh模式状态缓存
|
|
403
406
|
node.meshCurrentStates = null; // Mesh设备当前状态(用于保持其他路不变)
|
|
404
407
|
|
|
@@ -569,7 +572,7 @@ module.exports = function(RED) {
|
|
|
569
572
|
// 定义数据监听器函数(静默处理,只在匹配时输出日志)
|
|
570
573
|
node.serialDataListener = (data) => {
|
|
571
574
|
// 根据开关类型调用不同的处理函数
|
|
572
|
-
if (node.config.
|
|
575
|
+
if (node.config.buttonType === 'mesh') {
|
|
573
576
|
node.handleMeshData(data);
|
|
574
577
|
} else {
|
|
575
578
|
node.handleRs485Data(data);
|
|
@@ -791,112 +794,229 @@ module.exports = function(RED) {
|
|
|
791
794
|
};
|
|
792
795
|
|
|
793
796
|
// 处理RS-485接收到的数据(支持TCP粘包处理)
|
|
797
|
+
// 处理从串口接收到的RS-485原始数据
|
|
794
798
|
node.handleRs485Data = function(data) {
|
|
795
799
|
try {
|
|
796
|
-
//
|
|
800
|
+
// 将新数据拼接到缓冲区
|
|
801
|
+
node.rs485Buffer = Buffer.concat([node.rs485Buffer, data]);
|
|
802
|
+
|
|
803
|
+
// 限制缓冲区大小,防止异常情况下内存溢出(最大1KB)
|
|
804
|
+
if (node.rs485Buffer.length > 1024) {
|
|
805
|
+
node.rs485Buffer = node.rs485Buffer.slice(-1024);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// 根据品牌/类型选择不同的拼包解析逻辑
|
|
797
809
|
if (node.config.buttonType === 'mesh') {
|
|
798
|
-
|
|
799
|
-
|
|
810
|
+
// Mesh 协议拼包逻辑
|
|
811
|
+
while (node.rs485Buffer.length >= 5) {
|
|
812
|
+
const headerIndex = node.rs485Buffer.indexOf(0x53);
|
|
813
|
+
if (headerIndex === -1) {
|
|
814
|
+
node.rs485Buffer = Buffer.alloc(0);
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
if (headerIndex > 0) {
|
|
818
|
+
node.rs485Buffer = node.rs485Buffer.slice(headerIndex);
|
|
819
|
+
}
|
|
820
|
+
if (node.rs485Buffer.length < 4) break;
|
|
821
|
+
|
|
822
|
+
const dataLen = node.rs485Buffer[3];
|
|
823
|
+
const totalLen = 4 + dataLen + 1; // [53][op][sub][len] + [data...] + [check]
|
|
824
|
+
|
|
825
|
+
if (totalLen > 64 || totalLen < 5) { // 非法长度
|
|
826
|
+
node.rs485Buffer = node.rs485Buffer.slice(1);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (node.rs485Buffer.length < totalLen) break; // 数据未到齐
|
|
831
|
+
|
|
832
|
+
const completeFrame = node.rs485Buffer.slice(0, totalLen);
|
|
833
|
+
node.rs485Buffer = node.rs485Buffer.slice(totalLen);
|
|
834
|
+
|
|
835
|
+
// 验证帧有效性并解析
|
|
836
|
+
if (meshProtocol.isMeshFrame(completeFrame)) {
|
|
837
|
+
const event = meshProtocol.parseStatusEvent(completeFrame);
|
|
838
|
+
if (event) {
|
|
839
|
+
node.handleMeshData(event);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
} else if (node.config.switchBrand === 'clowire') {
|
|
844
|
+
// Clowire 协议拼包逻辑 (克伦威尔)
|
|
845
|
+
while (node.rs485Buffer.length >= 9) {
|
|
846
|
+
// Clowire 帧以 0xAA 结尾,且长度固定为 9 或 11
|
|
847
|
+
const endIndex = node.rs485Buffer.indexOf(0xAA);
|
|
848
|
+
if (endIndex === -1) {
|
|
849
|
+
if (node.rs485Buffer.length > 32) {
|
|
850
|
+
node.rs485Buffer = node.rs485Buffer.slice(-16);
|
|
851
|
+
}
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
let foundFrame = false;
|
|
856
|
+
const possibleLengths = [9, 11];
|
|
857
|
+
|
|
858
|
+
for (const frameLen of possibleLengths) {
|
|
859
|
+
const startIndex = endIndex - frameLen + 1;
|
|
860
|
+
if (startIndex >= 0) {
|
|
861
|
+
const completeFrame = node.rs485Buffer.slice(startIndex, endIndex + 1);
|
|
862
|
+
if (clowireProtocol.isClowireFrame(completeFrame)) {
|
|
863
|
+
// 找到有效帧,处理它
|
|
864
|
+
node.handleClowireData(completeFrame);
|
|
865
|
+
// 移除到当前帧结束的所有数据
|
|
866
|
+
node.rs485Buffer = node.rs485Buffer.slice(endIndex + 1);
|
|
867
|
+
foundFrame = true;
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (foundFrame) continue;
|
|
874
|
+
|
|
875
|
+
// 没找到有效帧,跳过当前的 0xAA,继续寻找下一个
|
|
876
|
+
node.rs485Buffer = node.rs485Buffer.slice(endIndex + 1);
|
|
877
|
+
}
|
|
878
|
+
} else {
|
|
879
|
+
// 亖米 (Symi) 协议拼包逻辑
|
|
880
|
+
while (node.rs485Buffer.length >= 15) {
|
|
881
|
+
const headerIndex = node.rs485Buffer.indexOf(0x7E);
|
|
882
|
+
if (headerIndex === -1) {
|
|
883
|
+
node.rs485Buffer = Buffer.alloc(0);
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
if (headerIndex > 0) {
|
|
887
|
+
node.rs485Buffer = node.rs485Buffer.slice(headerIndex);
|
|
888
|
+
}
|
|
889
|
+
if (node.rs485Buffer.length < 4) break;
|
|
890
|
+
|
|
891
|
+
const frameLen = node.rs485Buffer[3];
|
|
892
|
+
if (frameLen < 15 || frameLen > 64) {
|
|
893
|
+
node.rs485Buffer = node.rs485Buffer.slice(1);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (node.rs485Buffer.length < frameLen) break;
|
|
898
|
+
|
|
899
|
+
const completeFrame = node.rs485Buffer.slice(0, frameLen);
|
|
900
|
+
node.rs485Buffer = node.rs485Buffer.slice(frameLen);
|
|
901
|
+
|
|
902
|
+
// 验证帧有效性
|
|
903
|
+
if (protocol.isSymiFrame(completeFrame)) {
|
|
904
|
+
// 亖米协议:解析所有可能的子帧
|
|
905
|
+
const frames = protocol.parseAllFrames(completeFrame);
|
|
906
|
+
if (frames && frames.length > 0) {
|
|
907
|
+
for (const frame of frames) {
|
|
908
|
+
node.processSymiFrame(frame);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
800
913
|
}
|
|
914
|
+
} catch (err) {
|
|
915
|
+
node.log(`解析RS-485数据失败: ${err.message}`);
|
|
916
|
+
}
|
|
917
|
+
};
|
|
801
918
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
919
|
+
// 提取原 handleRs485Data 中的亖米协议处理逻辑
|
|
920
|
+
node.processSymiFrame = function(frame) {
|
|
921
|
+
try {
|
|
922
|
+
if (!frame) return;
|
|
923
|
+
|
|
924
|
+
// 如果传入的是Buffer,则尝试解析
|
|
925
|
+
if (Buffer.isBuffer(frame)) {
|
|
926
|
+
const frames = protocol.parseAllFrames(frame);
|
|
927
|
+
if (frames && frames.length > 0) {
|
|
928
|
+
for (const f of frames) {
|
|
929
|
+
node.processSymiFrame(f);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
// 忽略 REPORT (0x04) 类型的帧
|
|
935
|
+
if (frame.dataType === 0x04) {
|
|
805
936
|
return;
|
|
806
937
|
}
|
|
807
938
|
|
|
808
|
-
//
|
|
809
|
-
const
|
|
810
|
-
if (!
|
|
811
|
-
return;
|
|
939
|
+
// 检测是否是按键按下事件
|
|
940
|
+
const buttonEvent = protocol.detectButtonPress(frame);
|
|
941
|
+
if (!buttonEvent) {
|
|
942
|
+
return;
|
|
812
943
|
}
|
|
813
944
|
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
945
|
+
// 计算实际按键编号
|
|
946
|
+
const actualButtonNumber = (buttonEvent.deviceAddr === 1) ? buttonEvent.channel : (buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel);
|
|
947
|
+
|
|
948
|
+
if (buttonEvent.channel === 0x0F && node.config.buttonNumber !== 15) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
|
|
953
|
+
// 判断按钮类型
|
|
954
|
+
const isSceneMode = buttonEvent.isSceneMode ||
|
|
955
|
+
node.config.buttonType === 'scene' ||
|
|
956
|
+
buttonEvent.deviceType === 0x07;
|
|
957
|
+
|
|
958
|
+
// 全局防抖
|
|
959
|
+
const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
|
|
960
|
+
const now = Date.now();
|
|
961
|
+
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
821
962
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if (!buttonEvent) {
|
|
825
|
-
continue; // 静默忽略非按键事件
|
|
963
|
+
if (now - lastTriggerTime < 200) {
|
|
964
|
+
return;
|
|
826
965
|
}
|
|
966
|
+
globalDebounceCache.set(debounceKey, now);
|
|
827
967
|
|
|
828
|
-
//
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
// 特殊:channel=0x0F(15)是红外感应触发背光灯
|
|
832
|
-
const actualButtonNumber = (buttonEvent.deviceAddr === 1) ? buttonEvent.channel : (buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel);
|
|
833
|
-
|
|
834
|
-
// 检查是否是我们监听的开关面板和按钮
|
|
835
|
-
// switchId对应本地地址(物理面板地址)
|
|
836
|
-
// buttonNumber对应实际按键编号(1-8,或15表示背光灯)
|
|
837
|
-
//
|
|
838
|
-
// 注意:通道0x0F是红外感应触发背光灯
|
|
839
|
-
// 只有当用户配置了buttonNumber=15(按键背光灯)时才处理该帧
|
|
840
|
-
// 否则忽略红外感应帧(避免误触发普通按键事件)
|
|
841
|
-
if (buttonEvent.channel === 0x0F && node.config.buttonNumber !== 15) {
|
|
842
|
-
continue; // 忽略红外感应帧(仅当未配置为背光灯模式时)
|
|
968
|
+
// 设置触发源
|
|
969
|
+
if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
|
|
970
|
+
node.serialPortConfig.setTriggerSource(node.config.switchId);
|
|
843
971
|
}
|
|
844
|
-
|
|
845
|
-
if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
|
|
846
|
-
// 判断按钮类型:优先使用协议解析结果,其次使用配置
|
|
847
|
-
const isSceneMode = buttonEvent.isSceneMode ||
|
|
848
|
-
node.config.buttonType === 'scene' ||
|
|
849
|
-
buttonEvent.deviceType === 0x07;
|
|
850
|
-
|
|
851
|
-
// 全局防抖:防止同一个按键的同一个目标重复触发
|
|
852
|
-
// 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
|
|
853
|
-
const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
|
|
854
|
-
const now = Date.now();
|
|
855
|
-
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
856
972
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
973
|
+
if (isSceneMode) {
|
|
974
|
+
node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
|
|
975
|
+
node.currentState = !node.currentState;
|
|
976
|
+
|
|
977
|
+
// 发送按键按下事件(用于relay-output等节点绑定)
|
|
978
|
+
RED.events.emit('modbus:buttonPressed', {
|
|
979
|
+
switchId: node.config.switchId,
|
|
980
|
+
button: node.config.buttonNumber,
|
|
981
|
+
value: node.currentState,
|
|
982
|
+
brand: 'symi',
|
|
983
|
+
type: 'scene'
|
|
984
|
+
});
|
|
862
985
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
}
|
|
986
|
+
node.sendMqttCommand(node.currentState);
|
|
987
|
+
node.sendCommandToPanel(node.currentState);
|
|
988
|
+
} else {
|
|
989
|
+
node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
|
|
990
|
+
|
|
991
|
+
// 发送按键按下事件(用于relay-output等节点绑定)
|
|
992
|
+
RED.events.emit('modbus:buttonPressed', {
|
|
993
|
+
switchId: node.config.switchId,
|
|
994
|
+
button: node.config.buttonNumber,
|
|
995
|
+
value: buttonEvent.state,
|
|
996
|
+
brand: 'symi',
|
|
997
|
+
type: 'switch'
|
|
998
|
+
});
|
|
867
999
|
|
|
868
|
-
|
|
869
|
-
node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
|
|
870
|
-
// 场景模式:切换状态(每次触发时翻转)
|
|
871
|
-
node.currentState = !node.currentState;
|
|
872
|
-
node.sendMqttCommand(node.currentState);
|
|
873
|
-
|
|
874
|
-
// 场景模式:立即发送LED反馈(修复:不等待状态变化事件)
|
|
875
|
-
// 因为currentState已经更新,后续的coilStateChanged事件会被认为"状态未变化"而跳过
|
|
876
|
-
node.sendCommandToPanel(node.currentState);
|
|
877
|
-
} else {
|
|
878
|
-
// 开关模式:根据状态发送ON/OFF
|
|
879
|
-
node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
|
|
880
|
-
node.sendMqttCommand(buttonEvent.state);
|
|
881
|
-
}
|
|
1000
|
+
node.sendMqttCommand(buttonEvent.state);
|
|
882
1001
|
}
|
|
883
|
-
// 不匹配的节点静默忽略,不输出任何日志
|
|
884
1002
|
}
|
|
885
1003
|
} catch (err) {
|
|
886
|
-
node.log(
|
|
1004
|
+
node.log(`处理亖米协议帧失败: ${err.message}`);
|
|
887
1005
|
}
|
|
888
1006
|
};
|
|
889
1007
|
|
|
890
1008
|
// 处理Clowire协议数据
|
|
891
1009
|
node.handleClowireData = function(data) {
|
|
892
1010
|
try {
|
|
893
|
-
|
|
894
|
-
if (
|
|
895
|
-
|
|
1011
|
+
let frames = [];
|
|
1012
|
+
if (Buffer.isBuffer(data)) {
|
|
1013
|
+
// 如果是Buffer,则解析所有帧
|
|
1014
|
+
frames = clowireProtocol.parseAllFrames(data);
|
|
1015
|
+
} else {
|
|
1016
|
+
// 如果已经是解析后的帧
|
|
1017
|
+
frames = [data];
|
|
896
1018
|
}
|
|
897
1019
|
|
|
898
|
-
// 解析所有帧
|
|
899
|
-
const frames = clowireProtocol.parseAllFrames(data);
|
|
900
1020
|
if (!frames || frames.length === 0) {
|
|
901
1021
|
return;
|
|
902
1022
|
}
|
|
@@ -968,6 +1088,16 @@ module.exports = function(RED) {
|
|
|
968
1088
|
|
|
969
1089
|
// 所有按键事件都当作单击处理:切换状态
|
|
970
1090
|
node.currentState = !node.currentState;
|
|
1091
|
+
|
|
1092
|
+
// 发送按键按下事件(用于relay-output等节点绑定)
|
|
1093
|
+
RED.events.emit('modbus:buttonPressed', {
|
|
1094
|
+
switchId: node.config.switchId,
|
|
1095
|
+
button: node.config.buttonNumber,
|
|
1096
|
+
value: node.currentState,
|
|
1097
|
+
brand: 'clowire',
|
|
1098
|
+
type: 'switch'
|
|
1099
|
+
});
|
|
1100
|
+
|
|
971
1101
|
node.sendMqttCommand(node.currentState);
|
|
972
1102
|
|
|
973
1103
|
// 输出消息
|
|
@@ -988,12 +1118,16 @@ module.exports = function(RED) {
|
|
|
988
1118
|
};
|
|
989
1119
|
|
|
990
1120
|
// 处理Mesh协议数据
|
|
991
|
-
node.handleMeshData = function(
|
|
1121
|
+
node.handleMeshData = function(event) {
|
|
992
1122
|
try {
|
|
993
|
-
// 解析Mesh状态事件
|
|
994
|
-
const event = meshProtocol.parseStatusEvent(data);
|
|
995
1123
|
if (!event) {
|
|
996
|
-
return;
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// 如果传入的是Buffer,则尝试解析
|
|
1128
|
+
if (Buffer.isBuffer(event)) {
|
|
1129
|
+
event = meshProtocol.parseStatusEvent(event);
|
|
1130
|
+
if (!event) return;
|
|
997
1131
|
}
|
|
998
1132
|
|
|
999
1133
|
// 检查是否是我们监听的Mesh设备
|
|
@@ -1050,6 +1184,15 @@ module.exports = function(RED) {
|
|
|
1050
1184
|
|
|
1051
1185
|
node.debug(`[Mesh场景] 设备${meshAddr} 按键${node.config.meshButtonNumber} 触发继电器控制: ${buttonState}`);
|
|
1052
1186
|
|
|
1187
|
+
// 发送按键按下事件(用于relay-output等节点绑定)
|
|
1188
|
+
RED.events.emit('modbus:buttonPressed', {
|
|
1189
|
+
switchId: node.config.meshShortAddress, // Mesh使用短地址作为switchId
|
|
1190
|
+
button: node.config.meshButtonNumber,
|
|
1191
|
+
value: buttonState,
|
|
1192
|
+
brand: 'mesh',
|
|
1193
|
+
type: 'scene'
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1053
1196
|
// 跳过后续的状态缓存检查,直接发送继电器控制命令
|
|
1054
1197
|
} else {
|
|
1055
1198
|
// 开关模式:正常处理LED反馈锁和状态缓存
|
|
@@ -1095,6 +1238,15 @@ module.exports = function(RED) {
|
|
|
1095
1238
|
previousStates[node.config.meshButtonNumber - 1] = buttonState;
|
|
1096
1239
|
meshDeviceStates.set(meshAddr, previousStates);
|
|
1097
1240
|
node.debug(`[Mesh按键] 设备${meshAddr} 按键${node.config.meshButtonNumber} 状态变化: ${previousButtonState} → ${buttonState}`);
|
|
1241
|
+
|
|
1242
|
+
// 发送按键按下事件(用于relay-output等节点绑定)
|
|
1243
|
+
RED.events.emit('modbus:buttonPressed', {
|
|
1244
|
+
switchId: node.config.meshShortAddress,
|
|
1245
|
+
button: node.config.meshButtonNumber,
|
|
1246
|
+
value: buttonState,
|
|
1247
|
+
brand: 'mesh',
|
|
1248
|
+
type: 'switch'
|
|
1249
|
+
});
|
|
1098
1250
|
}
|
|
1099
1251
|
|
|
1100
1252
|
// 初始化期间不发送控制命令(避免重启时Mesh开关状态覆盖继电器状态)
|
package/nodes/relay-output.html
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
paletteLabel: "继电器输出",
|
|
6
6
|
defaults: {
|
|
7
7
|
name: { value: "" },
|
|
8
|
+
// 目标主站节点
|
|
9
|
+
masterNode: { value: "", required: true },
|
|
8
10
|
// RS-485连接配置(共享配置节点)
|
|
9
11
|
serialPortConfig: { value: "", type: "serial-port-config", required: false },
|
|
10
12
|
// 物理开关面板配置(触发源)
|
|
@@ -37,6 +39,34 @@
|
|
|
37
39
|
oneditprepare: function() {
|
|
38
40
|
const node = this;
|
|
39
41
|
|
|
42
|
+
// 填充主站节点选择器
|
|
43
|
+
var masterNodeSelect = $("#node-input-masterNode");
|
|
44
|
+
masterNodeSelect.empty();
|
|
45
|
+
masterNodeSelect.append('<option value="">请选择主站节点</option>');
|
|
46
|
+
|
|
47
|
+
// 查找所有modbus-master节点
|
|
48
|
+
var masters = [];
|
|
49
|
+
RED.nodes.eachNode(function(n) {
|
|
50
|
+
if (n.type === "modbus-master") {
|
|
51
|
+
masters.push(n);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
masters.sort(function(a, b) {
|
|
56
|
+
return (a.name || "").localeCompare(b.name || "");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
masters.forEach(function(n) {
|
|
60
|
+
var label = n.name || `主站 ${n.id.substring(0, 8)}`;
|
|
61
|
+
var selected = (n.id === node.masterNode) ? ' selected' : '';
|
|
62
|
+
masterNodeSelect.append(`<option value="${n.id}"${selected}>${label}</option>`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// 如果当前没有主站节点,显示提示
|
|
66
|
+
if (masters.length === 0) {
|
|
67
|
+
masterNodeSelect.append('<option value="" disabled>未找到主站节点,请先添加主站</option>');
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
// 延时提示
|
|
41
71
|
$("#node-input-delayMs").on("change", function() {
|
|
42
72
|
var val = parseInt($(this).val()) || 0;
|
|
@@ -76,6 +106,13 @@
|
|
|
76
106
|
var action = $("#node-input-action").val() || 'on';
|
|
77
107
|
var delay = parseInt($("#node-input-delayMs").val()) || 0;
|
|
78
108
|
var buttonType = $("#node-input-buttonType").val();
|
|
109
|
+
var masterNodeId = $("#node-input-masterNode").val();
|
|
110
|
+
|
|
111
|
+
var masterLabel = "未选择主站";
|
|
112
|
+
if (masterNodeId) {
|
|
113
|
+
var masterNode = RED.nodes.node(masterNodeId);
|
|
114
|
+
masterLabel = masterNode ? (masterNode.name || `主站 ${masterNodeId.substring(0, 8)}`) : "未知主站";
|
|
115
|
+
}
|
|
79
116
|
|
|
80
117
|
const actionMap = { 'on': '打开', 'off': '关闭', 'follow': '跟随', 'toggle': '翻转' };
|
|
81
118
|
const btnLabel = btnNum == 15 ? '背光灯' : `按钮${btnNum}`;
|
|
@@ -85,10 +122,10 @@
|
|
|
85
122
|
: `开关${switchId}-${btnLabel}`;
|
|
86
123
|
var delayText = delay > 0 ? `, 延时${delay}ms` : '';
|
|
87
124
|
|
|
88
|
-
$("#config-summary").html(
|
|
125
|
+
$("#config-summary").html(`[${masterLabel}] <strong>${triggerText}</strong> → 继电器${slave}-${coil}路 <strong>${actionMap[action]}</strong>${delayText}`);
|
|
89
126
|
}
|
|
90
127
|
|
|
91
|
-
$("#node-input-switchId, #node-input-buttonNumber, #node-input-slaveAddress, #node-input-coilNumber, #node-input-action, #node-input-delayMs, #node-input-buttonType").on("change", updateSummary);
|
|
128
|
+
$("#node-input-switchId, #node-input-buttonNumber, #node-input-slaveAddress, #node-input-coilNumber, #node-input-action, #node-input-delayMs, #node-input-buttonType, #node-input-masterNode").on("change", updateSummary);
|
|
92
129
|
updateSummary();
|
|
93
130
|
}
|
|
94
131
|
});
|
|
@@ -198,6 +235,16 @@
|
|
|
198
235
|
</label>
|
|
199
236
|
</div>
|
|
200
237
|
|
|
238
|
+
<div class="form-row">
|
|
239
|
+
<label for="node-input-masterNode" style="width: 110px;"><i class="fa fa-microchip"></i> 主站节点</label>
|
|
240
|
+
<select id="node-input-masterNode" style="width: calc(70% - 110px);">
|
|
241
|
+
<option value="">请选择主站节点</option>
|
|
242
|
+
</select>
|
|
243
|
+
<div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
|
|
244
|
+
选择要控制的目标Modbus主站节点
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
201
248
|
<div class="form-row">
|
|
202
249
|
<label for="node-input-slaveAddress" style="width: 110px;"><i class="fa fa-map-marker"></i> 从站地址</label>
|
|
203
250
|
<input type="number" id="node-input-slaveAddress" placeholder="10" min="1" max="247" style="width: 90px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
|
package/nodes/relay-output.js
CHANGED
|
@@ -23,6 +23,7 @@ module.exports = function(RED) {
|
|
|
23
23
|
node.filterDeviceId = parseInt(config.filterDeviceId) || 0; // 0=不过滤,>0=只响应指定门禁ID
|
|
24
24
|
|
|
25
25
|
// 配置参数 - 目标继电器
|
|
26
|
+
node.masterNodeId = config.masterNode;
|
|
26
27
|
node.name = config.name || '';
|
|
27
28
|
node.slaveAddress = parseInt(config.slaveAddress) || 10;
|
|
28
29
|
node.coilNumber = parseInt(config.coilNumber) || 1;
|
|
@@ -102,6 +103,7 @@ module.exports = function(RED) {
|
|
|
102
103
|
coil: coilIndex,
|
|
103
104
|
value: value,
|
|
104
105
|
source: 'relay-output',
|
|
106
|
+
masterId: node.masterNodeId,
|
|
105
107
|
nodeId: node.id
|
|
106
108
|
});
|
|
107
109
|
|
|
@@ -402,6 +402,21 @@ module.exports = function(RED) {
|
|
|
402
402
|
|
|
403
403
|
// 写入数据(带队列机制,防止并发冲突,支持优先级)
|
|
404
404
|
node.write = function(data, callback, priority, switchId) {
|
|
405
|
+
// 限制队列长度,防止异常堆积导致内存溢出
|
|
406
|
+
if (node.writeQueue.length > 500) {
|
|
407
|
+
const err = new Error('写入队列已满,抛弃旧指令');
|
|
408
|
+
node.log(err.message);
|
|
409
|
+
// 抛弃队列中最早的10个普通优先级指令
|
|
410
|
+
let removed = 0;
|
|
411
|
+
for (let i = 0; i < node.writeQueue.length && removed < 10; i++) {
|
|
412
|
+
if (node.writeQueue[i].priority === 0) {
|
|
413
|
+
node.writeQueue.splice(i, 1);
|
|
414
|
+
i--;
|
|
415
|
+
removed++;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
405
420
|
// 加入写入队列
|
|
406
421
|
const queueItem = { data, callback, priority: priority || 0, switchId };
|
|
407
422
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-modbus",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.12",
|
|
4
4
|
"description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持亖米/Clowire品牌),工控机长期稳定运行",
|
|
5
5
|
"main": "nodes/modbus-master.js",
|
|
6
6
|
"scripts": {
|