node-red-contrib-symi-modbus 2.8.9 → 2.9.1
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 +25 -1
- 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 +68 -62
- package/nodes/serial-port-config.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -888,7 +888,31 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
888
888
|
|
|
889
889
|
## 版本信息
|
|
890
890
|
|
|
891
|
-
**当前版本**: v2.
|
|
891
|
+
**当前版本**: v2.9.1 (2025-12-13)
|
|
892
|
+
|
|
893
|
+
**v2.9.1 更新内容**:
|
|
894
|
+
- **重要修复**:RS-485开关指示灯同步问题
|
|
895
|
+
- 修复TCP粘包导致的协议帧解析失败问题
|
|
896
|
+
- 新增`parseAllFrames`函数,正确处理多个帧粘在一起的情况
|
|
897
|
+
- 根据协议的`dataLen`字段精确提取帧边界,确保每个帧都被正确解析
|
|
898
|
+
- **重要修复**:Modbus控制看板部署失效问题
|
|
899
|
+
- 修复主站更新后重新部署导致看板无法正确显示的问题
|
|
900
|
+
- 部署时自动清空并重新初始化状态缓存
|
|
901
|
+
- 新增`refresh` API,点击刷新按钮从主站获取真实继电器状态
|
|
902
|
+
- **优化**:控制看板刷新功能
|
|
903
|
+
- 刷新按钮现在会主动从主站获取最新的继电器状态
|
|
904
|
+
- 确保显示的状态与实际继电器状态同步
|
|
905
|
+
- **优化**:指示灯反馈队列间隔调整为50ms
|
|
906
|
+
- TCP/串口写入间隔从40ms调整为50ms
|
|
907
|
+
- 触发源面板优先处理,确保按键响应流畅
|
|
908
|
+
- 按线圈顺序陆续反馈所有有动作的继电器指示灯
|
|
909
|
+
|
|
910
|
+
**v2.9.0 更新内容**:
|
|
911
|
+
- **重要修复**:一个按键现在可以控制多个不同从站的继电器
|
|
912
|
+
- 修复了全局防抖机制导致的问题:之前一个开关配置控制多个从站时,只有第一个从站会执行
|
|
913
|
+
- 现在支持:一个物理按键同时控制从站0A和0B的同一线圈,两个都会正确执行
|
|
914
|
+
- 优化防抖key设计,包含目标从站地址,确保不同目标互不影响
|
|
915
|
+
- RS-485开关、Mesh开关、Mesh场景模式均已修复
|
|
892
916
|
|
|
893
917
|
**v2.8.9 更新内容**:
|
|
894
918
|
- 新增Mesh开关无线模式支持,适用于场景触发按键
|
|
@@ -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(() => {
|
|
@@ -738,7 +738,7 @@ module.exports = function(RED) {
|
|
|
738
738
|
RED.events.on('modbus:coilStateChanged', node.stateChangeListener);
|
|
739
739
|
};
|
|
740
740
|
|
|
741
|
-
// 处理RS-485
|
|
741
|
+
// 处理RS-485接收到的数据(支持TCP粘包处理)
|
|
742
742
|
node.handleRs485Data = function(data) {
|
|
743
743
|
try {
|
|
744
744
|
// 如果是Mesh模式,使用Mesh协议解析
|
|
@@ -747,76 +747,80 @@ module.exports = function(RED) {
|
|
|
747
747
|
return;
|
|
748
748
|
}
|
|
749
749
|
|
|
750
|
-
//
|
|
751
|
-
const
|
|
752
|
-
if (!
|
|
750
|
+
// 使用parseAllFrames处理粘包,解析所有帧
|
|
751
|
+
const frames = protocol.parseAllFrames(data);
|
|
752
|
+
if (!frames || frames.length === 0) {
|
|
753
753
|
return; // 静默忽略无效帧
|
|
754
754
|
}
|
|
755
755
|
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
const buttonEvent = protocol.detectButtonPress(frame);
|
|
764
|
-
if (!buttonEvent) {
|
|
765
|
-
return; // 静默忽略非按键事件
|
|
766
|
-
}
|
|
756
|
+
// 处理每一个帧
|
|
757
|
+
for (const frame of frames) {
|
|
758
|
+
// 忽略 REPORT (0x04) 类型的帧(这是面板对我们指令的确认,不是按键事件)
|
|
759
|
+
// 只处理 SET (0x03) 类型的帧(真正的按键事件)
|
|
760
|
+
if (frame.dataType === 0x04) {
|
|
761
|
+
continue; // 静默忽略REPORT帧
|
|
762
|
+
}
|
|
767
763
|
|
|
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})`);
|
|
764
|
+
// 检测是否是按键按下事件
|
|
765
|
+
const buttonEvent = protocol.detectButtonPress(frame);
|
|
766
|
+
if (!buttonEvent) {
|
|
767
|
+
continue; // 静默忽略非按键事件
|
|
785
768
|
}
|
|
786
769
|
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
770
|
+
// 计算实际按键编号(Symi协议公式)
|
|
771
|
+
// 例如:devAddr=1,channel=1→按键1;devAddr=2,channel=1→按键5
|
|
772
|
+
const actualButtonNumber = buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel;
|
|
773
|
+
|
|
774
|
+
// 检查是否是我们监听的开关面板和按钮
|
|
775
|
+
// switchId对应本地地址(物理面板地址)
|
|
776
|
+
// buttonNumber对应实际按键编号(1-8)
|
|
777
|
+
if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
|
|
778
|
+
// 保存原始的deviceAddr和channel,用于LED反馈
|
|
779
|
+
const oldDeviceAddr = node.buttonDeviceAddr;
|
|
780
|
+
const oldChannel = node.buttonChannel;
|
|
781
|
+
node.buttonDeviceAddr = buttonEvent.deviceAddr;
|
|
782
|
+
node.buttonChannel = buttonEvent.channel;
|
|
783
|
+
|
|
784
|
+
// 输出调试日志,对比计算值和实际值
|
|
785
|
+
if (oldDeviceAddr !== buttonEvent.deviceAddr || oldChannel !== buttonEvent.channel) {
|
|
786
|
+
node.log(`按键事件更新LED反馈地址:计算值(设备${oldDeviceAddr} 通道${oldChannel}) → 实际值(设备${buttonEvent.deviceAddr} 通道${buttonEvent.channel})`);
|
|
787
|
+
}
|
|
791
788
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
789
|
+
// 判断按钮类型:优先使用协议解析结果,其次使用配置
|
|
790
|
+
const isSceneMode = buttonEvent.isSceneMode ||
|
|
791
|
+
node.config.buttonType === 'scene' ||
|
|
792
|
+
buttonEvent.deviceType === 0x07;
|
|
796
793
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
794
|
+
// 全局防抖:防止同一个按键的同一个目标重复触发
|
|
795
|
+
// 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
|
|
796
|
+
const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
|
|
797
|
+
const now = Date.now();
|
|
798
|
+
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
802
799
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
800
|
+
// 全局防抖:200ms内只触发一次(开关和场景统一防抖时间)
|
|
801
|
+
if (now - lastTriggerTime < 200) {
|
|
802
|
+
continue; // 静默忽略重复触发
|
|
803
|
+
}
|
|
804
|
+
globalDebounceCache.set(debounceKey, now);
|
|
807
805
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
806
|
+
// 设置触发源(用于优先队列)
|
|
807
|
+
if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
|
|
808
|
+
node.serialPortConfig.setTriggerSource(node.config.switchId);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (isSceneMode) {
|
|
812
|
+
node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
|
|
813
|
+
// 场景模式:切换状态(每次触发时翻转)
|
|
814
|
+
node.currentState = !node.currentState;
|
|
815
|
+
node.sendMqttCommand(node.currentState);
|
|
816
|
+
} else {
|
|
817
|
+
// 开关模式:根据状态发送ON/OFF
|
|
818
|
+
node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
|
|
819
|
+
node.sendMqttCommand(buttonEvent.state);
|
|
820
|
+
}
|
|
817
821
|
}
|
|
822
|
+
// 不匹配的节点静默忽略,不输出任何日志
|
|
818
823
|
}
|
|
819
|
-
// 不匹配的节点静默忽略,不输出任何日志
|
|
820
824
|
} catch (err) {
|
|
821
825
|
node.error(`解析RS-485数据失败: ${err.message}`);
|
|
822
826
|
}
|
|
@@ -873,7 +877,8 @@ module.exports = function(RED) {
|
|
|
873
877
|
// 场景模式:跳过LED反馈锁和状态缓存检查,直接触发继电器
|
|
874
878
|
if (isSceneMode) {
|
|
875
879
|
// 场景模式:全局防抖(200ms内只触发一次)
|
|
876
|
-
|
|
880
|
+
// 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
|
|
881
|
+
const debounceKey = `mesh-scene-${node.config.meshShortAddress}-${node.config.meshButtonNumber}-${node.config.targetSlaveAddress}`;
|
|
877
882
|
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
878
883
|
|
|
879
884
|
if (now - lastTriggerTime < 200) {
|
|
@@ -912,8 +917,9 @@ module.exports = function(RED) {
|
|
|
912
917
|
return;
|
|
913
918
|
}
|
|
914
919
|
|
|
915
|
-
//
|
|
916
|
-
|
|
920
|
+
// 第四步:全局防抖:防止同一个按键的同一个目标重复触发
|
|
921
|
+
// 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
|
|
922
|
+
const debounceKey = `mesh-${node.config.meshShortAddress}-${node.config.meshButtonNumber}-${node.config.targetSlaveAddress}`;
|
|
917
923
|
now = Date.now();
|
|
918
924
|
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
919
925
|
|
|
@@ -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.
|
|
3
|
+
"version": "2.9.1",
|
|
4
4
|
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步,工控机长期稳定运行",
|
|
5
5
|
"main": "nodes/modbus-master.js",
|
|
6
6
|
"scripts": {
|