node-red-contrib-symi-modbus 2.9.0 → 2.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -16
- package/nodes/lightweight-protocol.js +150 -20
- package/nodes/modbus-dashboard.html +30 -5
- package/nodes/modbus-dashboard.js +63 -0
- package/nodes/modbus-master.js +1 -1
- package/nodes/modbus-slave-switch.js +93 -60
- package/nodes/serial-port-config.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -888,22 +888,37 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
888
888
|
|
|
889
889
|
## 版本信息
|
|
890
890
|
|
|
891
|
-
**当前版本**: v2.9.
|
|
892
|
-
|
|
893
|
-
**v2.9.
|
|
894
|
-
-
|
|
895
|
-
-
|
|
896
|
-
-
|
|
897
|
-
-
|
|
898
|
-
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
-
|
|
902
|
-
-
|
|
903
|
-
-
|
|
904
|
-
-
|
|
905
|
-
|
|
906
|
-
|
|
891
|
+
**当前版本**: v2.9.2 (2025-12-15)
|
|
892
|
+
|
|
893
|
+
**v2.9.2 更新内容**:
|
|
894
|
+
- **稳定性增强**:全局防抖缓存定期清理机制
|
|
895
|
+
- 每10分钟自动清理超过1分钟未使用的防抖记录
|
|
896
|
+
- 防止长期运行时内存缓慢增长
|
|
897
|
+
- **稳定性增强**:Mesh无线模式按键注册清理
|
|
898
|
+
- 节点关闭时正确清理meshWirelessButtons全局Map
|
|
899
|
+
- 避免重新部署时残留无效注册信息
|
|
900
|
+
- **代码审查**:全面检查所有节点代码
|
|
901
|
+
- 确认所有定时器在节点关闭时正确清理
|
|
902
|
+
- 确认所有事件监听器正确注销
|
|
903
|
+
- 确认所有连接资源正确释放
|
|
904
|
+
- 支持断电断网后自动恢复运行
|
|
905
|
+
|
|
906
|
+
**v2.9.1 更新内容**:
|
|
907
|
+
- **重要修复**:RS-485开关指示灯同步问题
|
|
908
|
+
- 修复TCP粘包导致的协议帧解析失败问题
|
|
909
|
+
- 新增`parseAllFrames`函数,正确处理多个帧粘在一起的情况
|
|
910
|
+
- 根据协议的`dataLen`字段精确提取帧边界,确保每个帧都被正确解析
|
|
911
|
+
- **重要修复**:Modbus控制看板部署失效问题
|
|
912
|
+
- 修复主站更新后重新部署导致看板无法正确显示的问题
|
|
913
|
+
- 部署时自动清空并重新初始化状态缓存
|
|
914
|
+
- 新增`refresh` API,点击刷新按钮从主站获取真实继电器状态
|
|
915
|
+
- **优化**:控制看板刷新功能
|
|
916
|
+
- 刷新按钮现在会主动从主站获取最新的继电器状态
|
|
917
|
+
- 确保显示的状态与实际继电器状态同步
|
|
918
|
+
- **优化**:指示灯反馈队列间隔调整为50ms
|
|
919
|
+
- TCP/串口写入间隔从40ms调整为50ms
|
|
920
|
+
- 触发源面板优先处理,确保按键响应流畅
|
|
921
|
+
- 按线圈顺序陆续反馈所有有动作的继电器指示灯
|
|
907
922
|
|
|
908
923
|
**核心特性**:
|
|
909
924
|
- 支持Modbus RTU/TCP协议,兼容标准Modbus设备和TCP转RS485网关
|
|
@@ -201,7 +201,7 @@ module.exports = {
|
|
|
201
201
|
},
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
|
-
*
|
|
204
|
+
* 解析接收到的协议帧(支持粘包处理)
|
|
205
205
|
* @param {Buffer} buffer - 接收到的数据
|
|
206
206
|
* @returns {Object|null} 解析结果或null
|
|
207
207
|
*/
|
|
@@ -210,42 +210,172 @@ module.exports = {
|
|
|
210
210
|
return null;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
// 查找帧头位置
|
|
214
|
+
let startIndex = -1;
|
|
215
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
216
|
+
if (buffer[i] === this.FRAME_HEADER) {
|
|
217
|
+
startIndex = i;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (startIndex === -1) {
|
|
223
|
+
return null; // 没有找到帧头
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 从帧头开始检查是否有足够的数据
|
|
227
|
+
if (buffer.length - startIndex < 15) {
|
|
228
|
+
return null; // 数据不完整
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 读取数据长度字段(第4个字节,索引3)
|
|
232
|
+
const dataLen = buffer[startIndex + 3];
|
|
233
|
+
|
|
234
|
+
// 检查帧长度是否合理(数据长度就是整帧长度)
|
|
235
|
+
if (dataLen < 15 || dataLen > 64) {
|
|
236
|
+
return null; // 长度不合理
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 检查是否有完整的帧数据
|
|
240
|
+
if (buffer.length - startIndex < dataLen) {
|
|
241
|
+
return null; // 数据不完整
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 提取单个帧
|
|
245
|
+
const frameBuffer = buffer.slice(startIndex, startIndex + dataLen);
|
|
246
|
+
|
|
247
|
+
// 检查帧尾
|
|
248
|
+
if (frameBuffer[frameBuffer.length - 1] !== this.FRAME_TAIL) {
|
|
249
|
+
return null; // 帧尾不正确
|
|
216
250
|
}
|
|
217
251
|
|
|
218
252
|
// 验证CRC8校验
|
|
219
|
-
const receivedCRC =
|
|
220
|
-
const calculatedCRC = this.calculateCRC8(
|
|
253
|
+
const receivedCRC = frameBuffer[frameBuffer.length - 2];
|
|
254
|
+
const calculatedCRC = this.calculateCRC8(frameBuffer, frameBuffer.length);
|
|
221
255
|
if (receivedCRC !== calculatedCRC) {
|
|
222
256
|
return null; // 校验失败
|
|
223
257
|
}
|
|
224
258
|
|
|
225
259
|
// 解析数据
|
|
226
260
|
const frame = {
|
|
227
|
-
localAddr:
|
|
228
|
-
dataType:
|
|
229
|
-
dataLen:
|
|
230
|
-
deviceType:
|
|
231
|
-
brandID:
|
|
232
|
-
deviceAddr:
|
|
233
|
-
channel:
|
|
234
|
-
roomNo:
|
|
235
|
-
roomType:
|
|
236
|
-
roomID:
|
|
237
|
-
opCode:
|
|
238
|
-
opInfo: []
|
|
261
|
+
localAddr: frameBuffer[1],
|
|
262
|
+
dataType: frameBuffer[2],
|
|
263
|
+
dataLen: frameBuffer[3],
|
|
264
|
+
deviceType: frameBuffer[4],
|
|
265
|
+
brandID: frameBuffer[5],
|
|
266
|
+
deviceAddr: frameBuffer[6],
|
|
267
|
+
channel: frameBuffer[7],
|
|
268
|
+
roomNo: frameBuffer[8],
|
|
269
|
+
roomType: frameBuffer[9],
|
|
270
|
+
roomID: frameBuffer[10],
|
|
271
|
+
opCode: frameBuffer[11],
|
|
272
|
+
opInfo: [],
|
|
273
|
+
frameLength: dataLen, // 记录帧长度,用于粘包处理
|
|
274
|
+
startIndex: startIndex // 记录帧起始位置,用于粘包处理
|
|
239
275
|
};
|
|
240
276
|
|
|
241
277
|
// 提取操作信息
|
|
242
|
-
for (let i = 12; i <
|
|
243
|
-
frame.opInfo.push(
|
|
278
|
+
for (let i = 12; i < frameBuffer.length - 2; i++) {
|
|
279
|
+
frame.opInfo.push(frameBuffer[i]);
|
|
244
280
|
}
|
|
245
281
|
|
|
246
282
|
return frame;
|
|
247
283
|
},
|
|
248
284
|
|
|
285
|
+
/**
|
|
286
|
+
* 解析所有协议帧(处理粘包,返回所有帧)
|
|
287
|
+
* @param {Buffer} buffer - 接收到的数据(可能包含多个帧)
|
|
288
|
+
* @returns {Array} 解析出的所有帧数组
|
|
289
|
+
*/
|
|
290
|
+
parseAllFrames: function(buffer) {
|
|
291
|
+
const frames = [];
|
|
292
|
+
if (!buffer || buffer.length < 15) {
|
|
293
|
+
return frames;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let offset = 0;
|
|
297
|
+
let maxIterations = 10; // 防止无限循环
|
|
298
|
+
|
|
299
|
+
while (offset < buffer.length && maxIterations > 0) {
|
|
300
|
+
maxIterations--;
|
|
301
|
+
|
|
302
|
+
// 查找下一个帧头
|
|
303
|
+
let startIndex = -1;
|
|
304
|
+
for (let i = offset; i < buffer.length; i++) {
|
|
305
|
+
if (buffer[i] === this.FRAME_HEADER) {
|
|
306
|
+
startIndex = i;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (startIndex === -1) {
|
|
312
|
+
break; // 没有更多帧头
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 检查是否有足够的数据读取长度字段
|
|
316
|
+
if (buffer.length - startIndex < 4) {
|
|
317
|
+
break; // 数据不完整
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 读取数据长度
|
|
321
|
+
const dataLen = buffer[startIndex + 3];
|
|
322
|
+
|
|
323
|
+
// 检查帧长度是否合理
|
|
324
|
+
if (dataLen < 15 || dataLen > 64) {
|
|
325
|
+
offset = startIndex + 1; // 跳过这个字节继续搜索
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 检查是否有完整的帧
|
|
330
|
+
if (buffer.length - startIndex < dataLen) {
|
|
331
|
+
break; // 数据不完整
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 提取帧数据
|
|
335
|
+
const frameBuffer = buffer.slice(startIndex, startIndex + dataLen);
|
|
336
|
+
|
|
337
|
+
// 检查帧尾
|
|
338
|
+
if (frameBuffer[frameBuffer.length - 1] !== this.FRAME_TAIL) {
|
|
339
|
+
offset = startIndex + 1; // 跳过继续搜索
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 验证CRC
|
|
344
|
+
const receivedCRC = frameBuffer[frameBuffer.length - 2];
|
|
345
|
+
const calculatedCRC = this.calculateCRC8(frameBuffer, frameBuffer.length);
|
|
346
|
+
if (receivedCRC !== calculatedCRC) {
|
|
347
|
+
offset = startIndex + 1; // CRC错误,跳过继续搜索
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 解析帧
|
|
352
|
+
const frame = {
|
|
353
|
+
localAddr: frameBuffer[1],
|
|
354
|
+
dataType: frameBuffer[2],
|
|
355
|
+
dataLen: frameBuffer[3],
|
|
356
|
+
deviceType: frameBuffer[4],
|
|
357
|
+
brandID: frameBuffer[5],
|
|
358
|
+
deviceAddr: frameBuffer[6],
|
|
359
|
+
channel: frameBuffer[7],
|
|
360
|
+
roomNo: frameBuffer[8],
|
|
361
|
+
roomType: frameBuffer[9],
|
|
362
|
+
roomID: frameBuffer[10],
|
|
363
|
+
opCode: frameBuffer[11],
|
|
364
|
+
opInfo: []
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// 提取操作信息
|
|
368
|
+
for (let i = 12; i < frameBuffer.length - 2; i++) {
|
|
369
|
+
frame.opInfo.push(frameBuffer[i]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
frames.push(frame);
|
|
373
|
+
offset = startIndex + dataLen; // 移动到下一帧
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return frames;
|
|
377
|
+
},
|
|
378
|
+
|
|
249
379
|
/**
|
|
250
380
|
* 检测是否是按键按下事件
|
|
251
381
|
* @param {Object} frame - 解析后的帧
|
|
@@ -237,13 +237,38 @@
|
|
|
237
237
|
}
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
//
|
|
240
|
+
// 添加刷新按钮(从主站重新获取真实状态)
|
|
241
241
|
$("#btn-refresh-dashboard").on("click", function() {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
242
|
+
var masterNodeId = masterNodeSelect.val();
|
|
243
|
+
if (!masterNodeId) {
|
|
244
|
+
RED.notify('请先选择主站节点', 'warning');
|
|
245
|
+
return;
|
|
246
246
|
}
|
|
247
|
+
|
|
248
|
+
stopPolling();
|
|
249
|
+
|
|
250
|
+
// 调用refresh API从主站重新获取真实状态
|
|
251
|
+
$.ajax({
|
|
252
|
+
url: '/modbus-dashboard/refresh/' + masterNodeId,
|
|
253
|
+
method: 'POST',
|
|
254
|
+
success: function(data) {
|
|
255
|
+
if (data && data.states) {
|
|
256
|
+
// 更新本地缓存
|
|
257
|
+
stateCache = Object.assign({}, data.states);
|
|
258
|
+
console.log('状态已从主站刷新:', Object.keys(stateCache).length + '个线圈');
|
|
259
|
+
}
|
|
260
|
+
// 重新渲染UI
|
|
261
|
+
renderDashboard();
|
|
262
|
+
startPolling();
|
|
263
|
+
},
|
|
264
|
+
error: function(err) {
|
|
265
|
+
console.error('刷新状态失败:', err);
|
|
266
|
+
RED.notify('刷新状态失败: ' + (err.responseJSON ? err.responseJSON.error : err.statusText), 'error');
|
|
267
|
+
// 即使失败也重新渲染
|
|
268
|
+
renderDashboard();
|
|
269
|
+
startPolling();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
247
272
|
});
|
|
248
273
|
|
|
249
274
|
// 初始渲染
|
|
@@ -3,6 +3,9 @@ module.exports = function(RED) {
|
|
|
3
3
|
|
|
4
4
|
// 全局状态缓存(所有dashboard节点共享)
|
|
5
5
|
var globalStateCache = {};
|
|
6
|
+
|
|
7
|
+
// 跟踪已注册的dashboard节点(用于部署时重新初始化)
|
|
8
|
+
var registeredDashboards = new Map();
|
|
6
9
|
|
|
7
10
|
function ModbusDashboardNode(config) {
|
|
8
11
|
RED.nodes.createNode(this, config);
|
|
@@ -14,6 +17,9 @@ module.exports = function(RED) {
|
|
|
14
17
|
masterNode: config.masterNode
|
|
15
18
|
};
|
|
16
19
|
|
|
20
|
+
// 保存主站节点ID用于后续引用
|
|
21
|
+
node.masterNodeId = config.masterNode;
|
|
22
|
+
|
|
17
23
|
// 获取主站节点
|
|
18
24
|
var masterNode = RED.nodes.getNode(node.config.masterNode);
|
|
19
25
|
if (!masterNode) {
|
|
@@ -22,6 +28,17 @@ module.exports = function(RED) {
|
|
|
22
28
|
return;
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
// 部署时清空该主站相关的缓存并重新初始化
|
|
32
|
+
// 这样确保每次部署后状态从主站重新获取
|
|
33
|
+
if (masterNode.config && masterNode.config.slaves) {
|
|
34
|
+
masterNode.config.slaves.forEach(function(slave) {
|
|
35
|
+
for (var coil = slave.coilStart; coil <= slave.coilEnd; coil++) {
|
|
36
|
+
var key = slave.address + "_" + coil;
|
|
37
|
+
delete globalStateCache[key];
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
// 监听主站状态更新事件
|
|
26
43
|
node.stateUpdateHandler = function(data) {
|
|
27
44
|
if (!data || typeof data !== 'object') {
|
|
@@ -48,6 +65,12 @@ module.exports = function(RED) {
|
|
|
48
65
|
});
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
// 注册到全局跟踪
|
|
69
|
+
registeredDashboards.set(node.id, {
|
|
70
|
+
masterNodeId: node.masterNodeId,
|
|
71
|
+
node: node
|
|
72
|
+
});
|
|
73
|
+
|
|
51
74
|
// 更新节点状态
|
|
52
75
|
node.status({fill: "green", shape: "dot", text: "监控中"});
|
|
53
76
|
|
|
@@ -56,6 +79,7 @@ module.exports = function(RED) {
|
|
|
56
79
|
if (masterNode && node.stateUpdateHandler) {
|
|
57
80
|
masterNode.removeListener('stateUpdate', node.stateUpdateHandler);
|
|
58
81
|
}
|
|
82
|
+
registeredDashboards.delete(node.id);
|
|
59
83
|
node.status({});
|
|
60
84
|
});
|
|
61
85
|
}
|
|
@@ -87,6 +111,45 @@ module.exports = function(RED) {
|
|
|
87
111
|
});
|
|
88
112
|
});
|
|
89
113
|
|
|
114
|
+
// HTTP API:刷新状态(从主站重新获取真实状态)
|
|
115
|
+
RED.httpAdmin.post('/modbus-dashboard/refresh/:masterNodeId', function(req, res) {
|
|
116
|
+
var masterNodeId = req.params.masterNodeId;
|
|
117
|
+
var masterNode = RED.nodes.getNode(masterNodeId);
|
|
118
|
+
|
|
119
|
+
if (!masterNode) {
|
|
120
|
+
res.status(404).json({error: '主站节点不存在'});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 清空该主站相关的缓存
|
|
125
|
+
if (masterNode.config && masterNode.config.slaves) {
|
|
126
|
+
masterNode.config.slaves.forEach(function(slave) {
|
|
127
|
+
for (var coil = slave.coilStart; coil <= slave.coilEnd; coil++) {
|
|
128
|
+
var key = slave.address + "_" + coil;
|
|
129
|
+
delete globalStateCache[key];
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 从主站重新获取当前状态
|
|
135
|
+
if (masterNode.deviceStates) {
|
|
136
|
+
Object.keys(masterNode.deviceStates).forEach(function(slaveId) {
|
|
137
|
+
var deviceState = masterNode.deviceStates[slaveId];
|
|
138
|
+
if (deviceState && deviceState.coils) {
|
|
139
|
+
deviceState.coils.forEach(function(value, coil) {
|
|
140
|
+
var key = slaveId + "_" + coil;
|
|
141
|
+
globalStateCache[key] = value;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
res.json({
|
|
148
|
+
success: true,
|
|
149
|
+
states: globalStateCache
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
90
153
|
// HTTP API:发送控制命令
|
|
91
154
|
RED.httpAdmin.post('/modbus-dashboard/control', function(req, res) {
|
|
92
155
|
var slave = parseInt(req.body.slave);
|
package/nodes/modbus-master.js
CHANGED
|
@@ -150,7 +150,7 @@ module.exports = function(RED) {
|
|
|
150
150
|
// 写入队列机制(确保所有写入操作串行执行,避免锁竞争)
|
|
151
151
|
node.writeQueue = []; // 写入队列
|
|
152
152
|
node.isProcessingWrite = false; // 是否正在处理写入队列
|
|
153
|
-
node.writeQueueInterval =
|
|
153
|
+
node.writeQueueInterval = 50; // 写入队列处理间隔(50ms,厂家推荐间隔,确保总线稳定)
|
|
154
154
|
|
|
155
155
|
// 定期清理机制(每小时清理一次,防止内存泄漏)
|
|
156
156
|
node.cleanupTimer = setInterval(() => {
|
|
@@ -30,6 +30,27 @@ module.exports = function(RED) {
|
|
|
30
30
|
let meshLedFeedbackQueueTimer = null;
|
|
31
31
|
let meshLedFeedbackGlobalLock = 0; // 全局锁定时间戳:在此时间之前,忽略所有Mesh面板的状态上报
|
|
32
32
|
|
|
33
|
+
// 全局防抖缓存定期清理(每10分钟清理超过1分钟未使用的条目,防止内存泄漏)
|
|
34
|
+
let globalDebounceCacheCleanupTimer = null;
|
|
35
|
+
function startGlobalDebounceCacheCleanup() {
|
|
36
|
+
if (globalDebounceCacheCleanupTimer) return;
|
|
37
|
+
globalDebounceCacheCleanupTimer = setInterval(() => {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const expireThreshold = 60 * 1000; // 1分钟未使用的条目将被清理
|
|
40
|
+
let cleanedCount = 0;
|
|
41
|
+
for (const [key, timestamp] of globalDebounceCache.entries()) {
|
|
42
|
+
if (now - timestamp > expireThreshold) {
|
|
43
|
+
globalDebounceCache.delete(key);
|
|
44
|
+
cleanedCount++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (cleanedCount > 0) {
|
|
48
|
+
RED.log.debug(`全局防抖缓存清理: 删除${cleanedCount}条过期记录,剩余${globalDebounceCache.size}条`);
|
|
49
|
+
}
|
|
50
|
+
}, 10 * 60 * 1000); // 每10分钟执行一次
|
|
51
|
+
}
|
|
52
|
+
startGlobalDebounceCacheCleanup();
|
|
53
|
+
|
|
33
54
|
// 初始化Mesh设备持久化存储
|
|
34
55
|
const meshPersistDir = path.join(RED.settings.userDir || os.homedir() + '/.node-red', 'mesh-devices-persist');
|
|
35
56
|
let meshStorageInitialized = false;
|
|
@@ -738,7 +759,7 @@ module.exports = function(RED) {
|
|
|
738
759
|
RED.events.on('modbus:coilStateChanged', node.stateChangeListener);
|
|
739
760
|
};
|
|
740
761
|
|
|
741
|
-
// 处理RS-485
|
|
762
|
+
// 处理RS-485接收到的数据(支持TCP粘包处理)
|
|
742
763
|
node.handleRs485Data = function(data) {
|
|
743
764
|
try {
|
|
744
765
|
// 如果是Mesh模式,使用Mesh协议解析
|
|
@@ -747,77 +768,80 @@ module.exports = function(RED) {
|
|
|
747
768
|
return;
|
|
748
769
|
}
|
|
749
770
|
|
|
750
|
-
//
|
|
751
|
-
const
|
|
752
|
-
if (!
|
|
771
|
+
// 使用parseAllFrames处理粘包,解析所有帧
|
|
772
|
+
const frames = protocol.parseAllFrames(data);
|
|
773
|
+
if (!frames || frames.length === 0) {
|
|
753
774
|
return; // 静默忽略无效帧
|
|
754
775
|
}
|
|
755
776
|
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
const buttonEvent = protocol.detectButtonPress(frame);
|
|
764
|
-
if (!buttonEvent) {
|
|
765
|
-
return; // 静默忽略非按键事件
|
|
766
|
-
}
|
|
777
|
+
// 处理每一个帧
|
|
778
|
+
for (const frame of frames) {
|
|
779
|
+
// 忽略 REPORT (0x04) 类型的帧(这是面板对我们指令的确认,不是按键事件)
|
|
780
|
+
// 只处理 SET (0x03) 类型的帧(真正的按键事件)
|
|
781
|
+
if (frame.dataType === 0x04) {
|
|
782
|
+
continue; // 静默忽略REPORT帧
|
|
783
|
+
}
|
|
767
784
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
// 检查是否是我们监听的开关面板和按钮
|
|
773
|
-
// switchId对应本地地址(物理面板地址)
|
|
774
|
-
// buttonNumber对应实际按键编号(1-8)
|
|
775
|
-
if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
|
|
776
|
-
// 保存原始的deviceAddr和channel,用于LED反馈
|
|
777
|
-
const oldDeviceAddr = node.buttonDeviceAddr;
|
|
778
|
-
const oldChannel = node.buttonChannel;
|
|
779
|
-
node.buttonDeviceAddr = buttonEvent.deviceAddr;
|
|
780
|
-
node.buttonChannel = buttonEvent.channel;
|
|
781
|
-
|
|
782
|
-
// 输出调试日志,对比计算值和实际值
|
|
783
|
-
if (oldDeviceAddr !== buttonEvent.deviceAddr || oldChannel !== buttonEvent.channel) {
|
|
784
|
-
node.log(`按键事件更新LED反馈地址:计算值(设备${oldDeviceAddr} 通道${oldChannel}) → 实际值(设备${buttonEvent.deviceAddr} 通道${buttonEvent.channel})`);
|
|
785
|
+
// 检测是否是按键按下事件
|
|
786
|
+
const buttonEvent = protocol.detectButtonPress(frame);
|
|
787
|
+
if (!buttonEvent) {
|
|
788
|
+
continue; // 静默忽略非按键事件
|
|
785
789
|
}
|
|
786
790
|
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
+
// 计算实际按键编号(Symi协议公式)
|
|
792
|
+
// 例如:devAddr=1,channel=1→按键1;devAddr=2,channel=1→按键5
|
|
793
|
+
const actualButtonNumber = buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel;
|
|
794
|
+
|
|
795
|
+
// 检查是否是我们监听的开关面板和按钮
|
|
796
|
+
// switchId对应本地地址(物理面板地址)
|
|
797
|
+
// buttonNumber对应实际按键编号(1-8)
|
|
798
|
+
if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
|
|
799
|
+
// 保存原始的deviceAddr和channel,用于LED反馈
|
|
800
|
+
const oldDeviceAddr = node.buttonDeviceAddr;
|
|
801
|
+
const oldChannel = node.buttonChannel;
|
|
802
|
+
node.buttonDeviceAddr = buttonEvent.deviceAddr;
|
|
803
|
+
node.buttonChannel = buttonEvent.channel;
|
|
804
|
+
|
|
805
|
+
// 输出调试日志,对比计算值和实际值
|
|
806
|
+
if (oldDeviceAddr !== buttonEvent.deviceAddr || oldChannel !== buttonEvent.channel) {
|
|
807
|
+
node.log(`按键事件更新LED反馈地址:计算值(设备${oldDeviceAddr} 通道${oldChannel}) → 实际值(设备${buttonEvent.deviceAddr} 通道${buttonEvent.channel})`);
|
|
808
|
+
}
|
|
791
809
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
810
|
+
// 判断按钮类型:优先使用协议解析结果,其次使用配置
|
|
811
|
+
const isSceneMode = buttonEvent.isSceneMode ||
|
|
812
|
+
node.config.buttonType === 'scene' ||
|
|
813
|
+
buttonEvent.deviceType === 0x07;
|
|
797
814
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
815
|
+
// 全局防抖:防止同一个按键的同一个目标重复触发
|
|
816
|
+
// 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
|
|
817
|
+
const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
|
|
818
|
+
const now = Date.now();
|
|
819
|
+
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
803
820
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
821
|
+
// 全局防抖:200ms内只触发一次(开关和场景统一防抖时间)
|
|
822
|
+
if (now - lastTriggerTime < 200) {
|
|
823
|
+
continue; // 静默忽略重复触发
|
|
824
|
+
}
|
|
825
|
+
globalDebounceCache.set(debounceKey, now);
|
|
808
826
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
827
|
+
// 设置触发源(用于优先队列)
|
|
828
|
+
if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
|
|
829
|
+
node.serialPortConfig.setTriggerSource(node.config.switchId);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (isSceneMode) {
|
|
833
|
+
node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
|
|
834
|
+
// 场景模式:切换状态(每次触发时翻转)
|
|
835
|
+
node.currentState = !node.currentState;
|
|
836
|
+
node.sendMqttCommand(node.currentState);
|
|
837
|
+
} else {
|
|
838
|
+
// 开关模式:根据状态发送ON/OFF
|
|
839
|
+
node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
|
|
840
|
+
node.sendMqttCommand(buttonEvent.state);
|
|
841
|
+
}
|
|
818
842
|
}
|
|
843
|
+
// 不匹配的节点静默忽略,不输出任何日志
|
|
819
844
|
}
|
|
820
|
-
// 不匹配的节点静默忽略,不输出任何日志
|
|
821
845
|
} catch (err) {
|
|
822
846
|
node.error(`解析RS-485数据失败: ${err.message}`);
|
|
823
847
|
}
|
|
@@ -1475,6 +1499,15 @@ module.exports = function(RED) {
|
|
|
1475
1499
|
clearTimeout(meshLedDebounceTimers.get(meshAddr).timer);
|
|
1476
1500
|
meshLedDebounceTimers.delete(meshAddr);
|
|
1477
1501
|
}
|
|
1502
|
+
|
|
1503
|
+
// 清理meshWirelessButtons中当前节点的按键注册
|
|
1504
|
+
if (node.config.meshWirelessMode === true && meshWirelessButtons.has(meshAddr)) {
|
|
1505
|
+
meshWirelessButtons.get(meshAddr).delete(node.config.meshButtonNumber);
|
|
1506
|
+
// 如果该设备没有其他无线按键了,删除整个条目
|
|
1507
|
+
if (meshWirelessButtons.get(meshAddr).size === 0) {
|
|
1508
|
+
meshWirelessButtons.delete(meshAddr);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1478
1511
|
}
|
|
1479
1512
|
|
|
1480
1513
|
// 清理队列(释放内存)
|
|
@@ -452,9 +452,9 @@ module.exports = function(RED) {
|
|
|
452
452
|
});
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
-
// TCP写入间隔(
|
|
455
|
+
// TCP写入间隔(50ms,厂家推荐间隔,确保指示灯反馈稳定)
|
|
456
456
|
if (node.writeQueue.length > 0) {
|
|
457
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
457
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
458
458
|
}
|
|
459
459
|
} else {
|
|
460
460
|
if (!node.connection.isOpen) {
|
|
@@ -486,9 +486,9 @@ module.exports = function(RED) {
|
|
|
486
486
|
});
|
|
487
487
|
});
|
|
488
488
|
|
|
489
|
-
// 串口写入间隔(
|
|
489
|
+
// 串口写入间隔(50ms,厂家推荐间隔,确保指示灯反馈稳定)
|
|
490
490
|
if (node.writeQueue.length > 0) {
|
|
491
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
491
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
492
492
|
}
|
|
493
493
|
}
|
|
494
494
|
} catch (err) {
|
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.2",
|
|
4
4
|
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步,工控机长期稳定运行",
|
|
5
5
|
"main": "nodes/modbus-master.js",
|
|
6
6
|
"scripts": {
|