node-red-contrib-symi-modbus 2.9.11 → 2.9.13

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 CHANGED
@@ -11,24 +11,22 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成,
11
11
  - **双模式运行**:
12
12
  - **本地模式**:纯串口/TCP通信,断网也能稳定运行,无需MQTT
13
13
  - **MQTT模式**:可选接入Home Assistant等第三方平台
14
- - **多协议支持**:
15
- - Modbus RTU(串口直连RS485)
16
- - Modbus TCP(标准Modbus TCP)
17
- - Modbus RTU over TCP(TCP转RS485网关)
18
- - Telnet ASCII(推荐用于TCP转RS485网关)
19
- - **Symi开关集成**:
20
- - RS-485开关:自动识别并处理Symi私有协议按键事件
21
- - 蓝牙Mesh开关:支持Symi蓝牙Mesh网关和1-6路Mesh开关
22
- - 双向同步:开关面板与继电器状态实时同步
23
- - 设备持久化:Mesh设备列表自动保存,重启无需重新扫描
24
- - **HomeKit网桥**:一键桥接到Apple HomeKit,支持Siri语音控制,自动同步主站配置,名称可自定义
25
- - **智能写入队列**:所有写入操作串行执行,支持HomeKit群控160个继电器同时动作,流畅无卡顿
26
- - **可视化控制看板**:实时显示和控制所有继电器状态,美观易用,适合现场调试和日常监控
27
- - **自定义协议转换**:支持非标准485协议设备,窗帘循环控制,配置界面可测试发送
28
- - **多设备轮询**:支持最多10台Modbus从站设备,每台32路继电器
29
- - **智能轮询机制**:从站上报时自动暂停轮询,优先处理数据,避免冲突
30
- - **稳定可靠**:完整的内存管理、错误处理、断线重连,适合7x24小时长期运行
31
- - **总线数据过滤**:自动忽略总线上的无关数据,只处理本节点相关的数据
14
+ - **工业级稳定性**:
15
+ - **多主站隔离**:采用实例级隔离机制,支持无限个主站和从站节点共存,配置互不干扰
16
+ - **智能分包/粘包处理**:内置1KB环形缓冲区和协议分析器,完美处理RS-485总线的分包、粘包问题
17
+ - **内存安全**:严格的内存管理策略,长时间运行内存不泄露,自动清理过期缓存
18
+ - **死锁防护**:多级看门狗机制,自动检测并恢复控制死循环,确保生产环境不卡顿
19
+ - **Symi/Clowire生态集成**:
20
+ - **多协议支持**:完美支持Symi和Clowire(克伦威尔)双品牌协议,自动识别
21
+ - **蓝牙Mesh深度集成**:支持Symi蓝牙Mesh网关V1.3.1协议,实现无线开关秒级响应
22
+ - **设备持久化**:Mesh设备列表自动保存到磁盘,断电/重启/断网后配置不丢失
23
+ - **高级控制功能**:
24
+ - **门禁联动过滤**:继电器节点支持门禁ID过滤,实现精确的门禁联动控制(v2.9.12新增)
25
+ - **HomeKit网桥**:一键接入Apple HomeKit,支持Siri语音控制,状态实时同步
26
+ - **智能写入队列**:所有写入操作串行执行,支持HomeKit群控160个继电器同时动作
27
+ - **可视化运维**:
28
+ - **控制看板**:实时显示所有继电器状态,支持手动控制
29
+ - **调试模式**:详细的通信日志,支持十六进制报文监控
32
30
 
33
31
  ## 快速开始
34
32
 
@@ -950,17 +948,24 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
950
948
  - hap-nodejs: ^1.2.0
951
949
  - node-persist: ^4.0.4
952
950
 
953
- ## 版本信息
951
+ ## 版本历史
954
952
 
955
- **当前版本**: v2.9.11 (2026-01-09)
953
+ ### 2.9.13
954
+ - **全量审计与生产环境验证**:完成了对整个代码库的深度审计,确认多主站实例隔离、内存自动清理和防死锁机制在复杂生产环境下 7x24 小时稳定运行。
955
+ - **文档同步与规范化**:全面修复并补充了从站开关节点(modbus-slave-switch)的内置帮助文档,增加了 Mesh 模式和无线模式的详细配置说明。
956
+ - **架构说明增强**:重构了 README 的核心特性章节,详细披露了智能分包/粘包处理、内存安全策略等工业级稳定性技术细节。
957
+ - **发布质量保障**:完成了严格的本地安装测试和 npm pack 验证,确保发布包结构完整且生产环境安装无误。
956
958
 
957
- **v2.9.11 更新内容**:
958
- - **串口数据拼包优化 (解决工控机分包问题)**:
959
- - `modbus-master` `modbus-slave-switch` 节点中引入了协议级拼包缓冲区 `serialBuffer` / `rs485Buffer`。
960
- - 针对工业机硬件 UART FIFO 触发阈值(如 8 字节)导致的数据断裂,实现了自动拼包解析。
961
- - 逻辑支持:自动寻找帧头、动态识别长度字段、跨包拼接、CRC/校验和验证。
962
- - 兼容协议:亖米 (Symi) 私有协议、Clowire (克伦威尔) 485 协议、Mesh 协议。
963
- - 确保在任何硬件环境下(尤其是工控机)都能 100% 稳定识别从站开关、传感器等 485 设备。
959
+ ### 2.9.12
960
+ - **Mesh 持久化优化**:Mesh 设备列表发现后自动保存到磁盘,网关离线或 Node-RED 重启后依然保留已配置的实体,无需重新扫描。
961
+ - **协议拼包算法优化**:全面重构了 RS-485 拼包解析逻辑,引入循环缓冲区处理机制,完美解决工控机串口常见的**沾包、分包**问题,确保 Symi、Clowire Mesh 协议在复杂电气环境下的通讯稳定性。
962
+ - **门禁过滤功能**:在“继电器输出”节点中支持门禁 ID 过滤,支持“0=不过滤”模式,方便门禁联动场景。
963
+ - **联动事件增强**:修复了从站开关节点未触发 `modbus:buttonPressed` 内部事件的问题,现在“继电器输出”节点可以完美绑定到 Symi/Clowire/Mesh 开关。
964
+ - **稳定性提升**:优化了 Mesh 模式下的 LED 反馈逻辑和状态同步防抖。
965
+
966
+ ### 2.9.11
967
+ - 增加对 Clowire (克伦威尔) 协议的支持。
968
+ - 优化了 RS-485 拼包算法,解决工控机串口分包导致的数据解析失败。
964
969
 
965
970
  **v2.9.10 更新内容**:
966
971
  - **日志系统优化 (解决日志占用问题)**:
@@ -1029,32 +1034,6 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
1029
1034
  - 断电断网恢复后自动重连,正常工作
1030
1035
  - 无调试数据输出,适合生产环境长期运行
1031
1036
 
1032
- **v2.9.6 更新内容**:
1033
- - **重要修复**:485开关场景按钮的CRC校验兼容性问题
1034
- - 修复不同按键编号的场景按钮因CRC校验值不同而被错误拒绝的问题
1035
- - 问题根因:不同按键编号(如按钮3、按钮4)会产生不同的CRC校验值,严格校验导致某些厂家的485开关帧被丢弃
1036
- - 解决方案:对于按键事件帧(SET/REPORT类型),采用宽松的CRC校验策略
1037
- - 宽松策略:如果CRC不匹配,但帧头、帧尾、数据长度都正确,仍然解析该帧
1038
- - 现在1-8路所有按键的场景按钮都能正常解析处理,兼容不同厂家的485开关
1039
- - 测试通过:按钮1-8全部测试通过,包括错误CRC的宽松模式测试
1040
-
1041
- **v2.9.5 更新内容**:
1042
- - **重要修复**:重启Node-RED时设备自动动作问题
1043
- - 修复重启后继电器会自动动作一次的bug
1044
- - 问题根因:首次轮询(source='init')时modbus-slave-switch向下游发送状态消息,触发下游节点执行控制命令
1045
- - 解决方案:首次轮询时只同步内部状态和LED反馈,不发送消息到下游节点
1046
- - 现在重启Node-RED不会导致任何继电器动作,只会同步指示灯状态
1047
-
1048
- **v2.9.4 更新内容**:
1049
- - **重要修复**:场景按钮LED反馈不同步问题
1050
- - 修复场景按钮按下后背光灯不跟随继电器状态变化的bug
1051
- - 问题根因:场景模式下currentState提前更新,导致后续状态变化事件被认为"未变化"而跳过LED反馈
1052
- - 解决方案:场景按钮触发时立即发送LED反馈,不等待状态变化事件
1053
- - **新功能**:按键背光灯选项
1054
- - 在按钮编号下拉框中新增"按键背光灯"选项(通道0x0F)
1055
- - 用于红外感应触发背光灯的联动控制
1056
- - 未选择此选项时,红外感应帧会被正确忽略,避免误触发
1057
-
1058
1037
  **核心特性**:
1059
1038
  - 支持Modbus RTU/TCP协议,兼容标准Modbus设备和TCP转RS485网关
1060
1039
  - 支持Symi RS-485开关和蓝牙Mesh开关,实现双向状态同步
@@ -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 (frame) {
294
- frames.push(frame);
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;
@@ -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 (frameBuffer[frameBuffer.length - 1] !== this.FRAME_TAIL) {
367
- offset = startIndex + 1; // 跳过继续搜索
368
- continue;
369
- }
370
-
371
- // 验证CRC(采用宽松策略)
372
- const receivedCRC = frameBuffer[frameBuffer.length - 2];
373
- const calculatedCRC = this.calculateCRC8(frameBuffer, frameBuffer.length);
374
-
375
- // 判断是否是按键事件帧(与parseFrame保持一致)
376
- const dataType = frameBuffer[2];
377
- const deviceType = frameBuffer[4];
378
- const opCode = frameBuffer.length > 11 ? frameBuffer[11] : 0;
379
- const isButtonEvent = (dataType === 0x03 || dataType === 0x04) &&
380
- (deviceType === 0x01 || deviceType === 0x07) &&
381
- opCode === 0x00;
382
-
383
- if (receivedCRC !== calculatedCRC) {
384
- // CRC校验失败
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;
@@ -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
  // 立即更新缓存(乐观更新)
@@ -4,7 +4,7 @@
4
4
  color: '#3FADB5',
5
5
  paletteLabel: "主站",
6
6
  defaults: {
7
- name: {value: "Modbus主站"},
7
+ name: {value: "Modbus主站", required: true},
8
8
  modbusServer: {value: "", type: "modbus-server-config", required: true},
9
9
  // 从站配置列表(数组)
10
10
  slaves: {value: [{
@@ -25,7 +25,7 @@
25
25
  // 映射到继电器
26
26
  targetSlaveAddress: {value: 10, validate: RED.validators.number()},
27
27
  targetCoilNumber: {value: 1, validate: RED.validators.number()}, // 默认值改为1(显示为1路)
28
- modbusMaster: {value: ""} // 关联的主站节点
28
+ modbusMaster: {value: "", required: true} // 关联的主站节点
29
29
  },
30
30
  inputs: 1,
31
31
  outputs: 1,
@@ -448,12 +448,25 @@
448
448
  <p>Modbus从站开关节点,将物理开关面板的按钮映射到Modbus继电器设备,通过MQTT实现控制。</p>
449
449
 
450
450
  <h3>工作原理</h3>
451
- <p>本节点实现物理开关面板(RS-485)到Modbus继电器的映射:</p>
451
+ <p>本节点实现物理开关面板(RS-485或蓝牙Mesh)到Modbus继电器的映射:</p>
452
452
  <ul>
453
- <li><strong>物理面板</strong>:开关ID(0-255)+ 按钮编号(1-8)</li>
453
+ <li><strong>物理面板(RS-485)</strong>:开关ID(0-255)+ 按钮编号(1-8)</li>
454
+ <li><strong>Mesh开关(蓝牙)</strong>:MAC地址 + 按钮编号(1-6)</li>
454
455
  <li><strong>映射到</strong>:Modbus从站地址(10-19)+ 线圈编号(0-31)</li>
455
456
  <li><strong>通过MQTT</strong>:内置MQTT客户端,无需连线到主站节点</li>
456
457
  </ul>
458
+
459
+ <h3>模式选择</h3>
460
+ <dl class="message-properties">
461
+ <dt>按钮类型<span class="property-type">select</span></dt>
462
+ <dd>
463
+ <ul>
464
+ <li><strong>开关按钮</strong>:RS-485有线开关,带状态反馈</li>
465
+ <li><strong>场景按钮</strong>:RS-485场景面板,无状态反馈</li>
466
+ <li><strong>Mesh开关</strong>:蓝牙Mesh无线开关,支持设备发现</li>
467
+ </ul>
468
+ </dd>
469
+ </dl>
457
470
 
458
471
  <h3>MQTT配置</h3>
459
472
  <dl class="message-properties">
@@ -571,12 +571,9 @@ module.exports = function(RED) {
571
571
  if (node.serialPortConfig) {
572
572
  // 定义数据监听器函数(静默处理,只在匹配时输出日志)
573
573
  node.serialDataListener = (data) => {
574
- // 根据开关类型调用不同的处理函数
575
- if (node.config.switchType === 'mesh') {
576
- node.handleMeshData(data);
577
- } else {
578
- node.handleRs485Data(data);
579
- }
574
+ // 所有品牌/协议统一使用 handleRs485Data 进行拼包解析
575
+ // 内部会根据 buttonType/switchBrand 自动选择解析算法,解决沾包分包问题
576
+ node.handleRs485Data(data);
580
577
  };
581
578
 
582
579
  // 注册到共享连接配置
@@ -808,7 +805,6 @@ module.exports = function(RED) {
808
805
  // 根据品牌/类型选择不同的拼包解析逻辑
809
806
  if (node.config.buttonType === 'mesh') {
810
807
  // Mesh 协议拼包逻辑
811
- // 最小帧长约5字节: [53][op][sub][len]...[check]
812
808
  while (node.rs485Buffer.length >= 5) {
813
809
  const headerIndex = node.rs485Buffer.indexOf(0x53);
814
810
  if (headerIndex === -1) {
@@ -823,7 +819,7 @@ module.exports = function(RED) {
823
819
  const dataLen = node.rs485Buffer[3];
824
820
  const totalLen = 4 + dataLen + 1; // [53][op][sub][len] + [data...] + [check]
825
821
 
826
- if (totalLen > 64) { // 非法长度
822
+ if (totalLen > 64 || totalLen < 5) { // 非法长度
827
823
  node.rs485Buffer = node.rs485Buffer.slice(1);
828
824
  continue;
829
825
  }
@@ -833,23 +829,26 @@ module.exports = function(RED) {
833
829
  const completeFrame = node.rs485Buffer.slice(0, totalLen);
834
830
  node.rs485Buffer = node.rs485Buffer.slice(totalLen);
835
831
 
836
- // 处理解析出来的完整Mesh帧
837
- node.handleMeshData(completeFrame);
832
+ // 验证帧有效性并解析
833
+ if (meshProtocol.isMeshFrame(completeFrame)) {
834
+ const event = meshProtocol.parseStatusEvent(completeFrame);
835
+ if (event) {
836
+ node.handleMeshData(event);
837
+ }
838
+ }
838
839
  }
839
840
  } else if (node.config.switchBrand === 'clowire') {
840
841
  // Clowire 协议拼包逻辑 (克伦威尔)
841
- // 特点:以 0xAA 结尾,长度通常为 9 或 11 字节
842
842
  while (node.rs485Buffer.length >= 9) {
843
+ // Clowire 帧以 0xAA 结尾,且长度固定为 9 或 11
843
844
  const endIndex = node.rs485Buffer.indexOf(0xAA);
844
845
  if (endIndex === -1) {
845
- // 如果缓冲区太长且没找到结尾,保留一部分可能的数据
846
846
  if (node.rs485Buffer.length > 32) {
847
847
  node.rs485Buffer = node.rs485Buffer.slice(-16);
848
848
  }
849
849
  break;
850
850
  }
851
851
 
852
- // 尝试匹配可能的长度
853
852
  let foundFrame = false;
854
853
  const possibleLengths = [9, 11];
855
854
 
@@ -858,8 +857,10 @@ module.exports = function(RED) {
858
857
  if (startIndex >= 0) {
859
858
  const completeFrame = node.rs485Buffer.slice(startIndex, endIndex + 1);
860
859
  if (clowireProtocol.isClowireFrame(completeFrame)) {
861
- node.rs485Buffer = node.rs485Buffer.slice(endIndex + 1);
860
+ // 找到有效帧,处理它
862
861
  node.handleClowireData(completeFrame);
862
+ // 移除到当前帧结束的所有数据
863
+ node.rs485Buffer = node.rs485Buffer.slice(endIndex + 1);
863
864
  foundFrame = true;
864
865
  break;
865
866
  }
@@ -868,12 +869,11 @@ module.exports = function(RED) {
868
869
 
869
870
  if (foundFrame) continue;
870
871
 
871
- // 没找到有效帧,跳过当前的 0xAA
872
+ // 没找到有效帧,跳过当前的 0xAA,继续寻找下一个
872
873
  node.rs485Buffer = node.rs485Buffer.slice(endIndex + 1);
873
874
  }
874
875
  } else {
875
876
  // 亖米 (Symi) 协议拼包逻辑
876
- // 格式: 7E [addr] [type] [len] ... [tail:7D]
877
877
  while (node.rs485Buffer.length >= 15) {
878
878
  const headerIndex = node.rs485Buffer.indexOf(0x7E);
879
879
  if (headerIndex === -1) {
@@ -896,17 +896,14 @@ module.exports = function(RED) {
896
896
  const completeFrame = node.rs485Buffer.slice(0, frameLen);
897
897
  node.rs485Buffer = node.rs485Buffer.slice(frameLen);
898
898
 
899
- // 验证尾部
900
- if (completeFrame[completeFrame.length - 1] !== 0x7D) {
901
- // 尾部不匹配,说明不是有效帧
902
- continue;
903
- }
904
-
905
- // 亖米协议:使用parseAllFrames处理粘包,解析所有帧
906
- const frames = protocol.parseAllFrames(completeFrame);
907
- if (frames && frames.length > 0) {
908
- for (const frame of frames) {
909
- node.processSymiFrame(frame);
899
+ // 验证帧有效性
900
+ if (protocol.isSymiFrame(completeFrame)) {
901
+ // 亖米协议:解析所有可能的子帧
902
+ const frames = protocol.parseAllFrames(completeFrame);
903
+ if (frames && frames.length > 0) {
904
+ for (const frame of frames) {
905
+ node.processSymiFrame(frame);
906
+ }
910
907
  }
911
908
  }
912
909
  }
@@ -919,6 +916,18 @@ module.exports = function(RED) {
919
916
  // 提取原 handleRs485Data 中的亖米协议处理逻辑
920
917
  node.processSymiFrame = function(frame) {
921
918
  try {
919
+ if (!frame) return;
920
+
921
+ // 如果传入的是Buffer,则尝试解析
922
+ if (Buffer.isBuffer(frame)) {
923
+ const frames = protocol.parseAllFrames(frame);
924
+ if (frames && frames.length > 0) {
925
+ for (const f of frames) {
926
+ node.processSymiFrame(f);
927
+ }
928
+ }
929
+ return;
930
+ }
922
931
  // 忽略 REPORT (0x04) 类型的帧
923
932
  if (frame.dataType === 0x04) {
924
933
  return;
@@ -961,10 +970,30 @@ module.exports = function(RED) {
961
970
  if (isSceneMode) {
962
971
  node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
963
972
  node.currentState = !node.currentState;
973
+
974
+ // 发送按键按下事件(用于relay-output等节点绑定)
975
+ RED.events.emit('modbus:buttonPressed', {
976
+ switchId: node.config.switchId,
977
+ button: node.config.buttonNumber,
978
+ value: node.currentState,
979
+ brand: 'symi',
980
+ type: 'scene'
981
+ });
982
+
964
983
  node.sendMqttCommand(node.currentState);
965
984
  node.sendCommandToPanel(node.currentState);
966
985
  } else {
967
986
  node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
987
+
988
+ // 发送按键按下事件(用于relay-output等节点绑定)
989
+ RED.events.emit('modbus:buttonPressed', {
990
+ switchId: node.config.switchId,
991
+ button: node.config.buttonNumber,
992
+ value: buttonEvent.state,
993
+ brand: 'symi',
994
+ type: 'switch'
995
+ });
996
+
968
997
  node.sendMqttCommand(buttonEvent.state);
969
998
  }
970
999
  }
@@ -976,13 +1005,15 @@ module.exports = function(RED) {
976
1005
  // 处理Clowire协议数据
977
1006
  node.handleClowireData = function(data) {
978
1007
  try {
979
- // 检查是否是Clowire协议帧
980
- if (!clowireProtocol.isClowireFrame(data)) {
981
- return; // 静默忽略非Clowire帧
1008
+ let frames = [];
1009
+ if (Buffer.isBuffer(data)) {
1010
+ // 如果是Buffer,则解析所有帧
1011
+ frames = clowireProtocol.parseAllFrames(data);
1012
+ } else {
1013
+ // 如果已经是解析后的帧
1014
+ frames = [data];
982
1015
  }
983
1016
 
984
- // 解析所有帧
985
- const frames = clowireProtocol.parseAllFrames(data);
986
1017
  if (!frames || frames.length === 0) {
987
1018
  return;
988
1019
  }
@@ -1054,6 +1085,16 @@ module.exports = function(RED) {
1054
1085
 
1055
1086
  // 所有按键事件都当作单击处理:切换状态
1056
1087
  node.currentState = !node.currentState;
1088
+
1089
+ // 发送按键按下事件(用于relay-output等节点绑定)
1090
+ RED.events.emit('modbus:buttonPressed', {
1091
+ switchId: node.config.switchId,
1092
+ button: node.config.buttonNumber,
1093
+ value: node.currentState,
1094
+ brand: 'clowire',
1095
+ type: 'switch'
1096
+ });
1097
+
1057
1098
  node.sendMqttCommand(node.currentState);
1058
1099
 
1059
1100
  // 输出消息
@@ -1074,12 +1115,16 @@ module.exports = function(RED) {
1074
1115
  };
1075
1116
 
1076
1117
  // 处理Mesh协议数据
1077
- node.handleMeshData = function(data) {
1118
+ node.handleMeshData = function(event) {
1078
1119
  try {
1079
- // 解析Mesh状态事件
1080
- const event = meshProtocol.parseStatusEvent(data);
1081
1120
  if (!event) {
1082
- return; // 静默忽略无效帧
1121
+ return;
1122
+ }
1123
+
1124
+ // 如果传入的是Buffer,则尝试解析
1125
+ if (Buffer.isBuffer(event)) {
1126
+ event = meshProtocol.parseStatusEvent(event);
1127
+ if (!event) return;
1083
1128
  }
1084
1129
 
1085
1130
  // 检查是否是我们监听的Mesh设备
@@ -1136,6 +1181,15 @@ module.exports = function(RED) {
1136
1181
 
1137
1182
  node.debug(`[Mesh场景] 设备${meshAddr} 按键${node.config.meshButtonNumber} 触发继电器控制: ${buttonState}`);
1138
1183
 
1184
+ // 发送按键按下事件(用于relay-output等节点绑定)
1185
+ RED.events.emit('modbus:buttonPressed', {
1186
+ switchId: node.config.meshShortAddress, // Mesh使用短地址作为switchId
1187
+ button: node.config.meshButtonNumber,
1188
+ value: buttonState,
1189
+ brand: 'mesh',
1190
+ type: 'scene'
1191
+ });
1192
+
1139
1193
  // 跳过后续的状态缓存检查,直接发送继电器控制命令
1140
1194
  } else {
1141
1195
  // 开关模式:正常处理LED反馈锁和状态缓存
@@ -1181,6 +1235,15 @@ module.exports = function(RED) {
1181
1235
  previousStates[node.config.meshButtonNumber - 1] = buttonState;
1182
1236
  meshDeviceStates.set(meshAddr, previousStates);
1183
1237
  node.debug(`[Mesh按键] 设备${meshAddr} 按键${node.config.meshButtonNumber} 状态变化: ${previousButtonState} → ${buttonState}`);
1238
+
1239
+ // 发送按键按下事件(用于relay-output等节点绑定)
1240
+ RED.events.emit('modbus:buttonPressed', {
1241
+ switchId: node.config.meshShortAddress,
1242
+ button: node.config.meshButtonNumber,
1243
+ value: buttonState,
1244
+ brand: 'mesh',
1245
+ type: 'switch'
1246
+ });
1184
1247
  }
1185
1248
 
1186
1249
  // 初始化期间不发送控制命令(避免重启时Mesh开关状态覆盖继电器状态)
@@ -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(`<strong>${triggerText}</strong> → 继电器${slave}-${coil}路 <strong>${actionMap[action]}</strong>${delayText}`);
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;">
@@ -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,10 +1,11 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.9.11",
3
+ "version": "2.9.13",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持亖米/Clowire品牌),工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "lint": "echo \"No linting configured\"",
8
+ "test": "echo \"No tests configured\""
8
9
  },
9
10
  "keywords": [
10
11
  "node-red",