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

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
@@ -952,10 +952,18 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
952
952
 
953
953
  ## 版本信息
954
954
 
955
- **当前版本**: v2.9.10 (2026-01-08)
955
+ **当前版本**: v2.9.11 (2026-01-09)
956
+
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 设备。
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
  - **状态栏增强**:将具体错误信息(如“拒绝连接”、“串口不存在”)直接显示在节点状态文字中,无需查看日志即可掌握连接状况。
@@ -1161,73 +1161,99 @@ module.exports = function(RED) {
1161
1161
  // 这样可以确保每个从站都按照正确的间隔轮询
1162
1162
  };
1163
1163
 
1164
- // 处理Symi按键事件(私有协议)
1165
- // 静默处理:只处理本节点相关的数据,忽略总线上的其他数据
1164
+ // 初始化拼包缓冲区
1165
+ node.serialBuffer = Buffer.alloc(0);
1166
+
1167
+ // 处理Symi按键事件(私有协议)- 增加拼包支持
1166
1168
  node.handleSymiButtonEvent = function(data) {
1167
1169
  try {
1168
- // 解析Symi协议帧(带CRC校验)
1169
- const frame = protocol.parseFrame(data);
1170
- if (!frame) {
1171
- // 不是有效的Symi帧(CRC校验失败或格式错误),静默忽略
1172
- return;
1173
- }
1170
+ // 将新数据拼接到缓冲区
1171
+ node.serialBuffer = Buffer.concat([node.serialBuffer, data]);
1174
1172
 
1175
- // 只处理SET类型(0x03)的按键事件,忽略REPORT类型(0x04)
1176
- // REPORT类型是面板对我们指令的确认,不是按键事件
1177
- if (frame.dataType !== 0x03) {
1178
- // 不是按键事件,静默忽略
1179
- return;
1173
+ // 限制缓冲区大小,防止异常情况下内存溢出(最大1KB)
1174
+ if (node.serialBuffer.length > 1024) {
1175
+ node.serialBuffer = node.serialBuffer.slice(-1024);
1180
1176
  }
1181
1177
 
1182
- // 检查是否是灯光设备
1183
- if (frame.deviceType !== 0x01) {
1184
- // 不是灯光设备,静默忽略
1185
- return;
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
- const deviceAddr = frame.deviceAddr; // 设备地址(1-255)
1190
- const channel = frame.channel; // 通道号(1-8)
1191
- const state = frame.opInfo[0] === 0x01; // 状态(1=开,0=关)
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
- if (coilNumber < slaveConfig.coilStart || coilNumber > slaveConfig.coilEnd) {
1208
- // 线圈不在范围内,静默忽略(不是本节点的数据)
1209
- return;
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
- node.debug(`Symi按键事件: 设备${deviceAddr} 通道${channel} 状态=${state ? 'ON' : 'OFF'}`);
1214
- node.debug(`Symi按键映射: 设备${deviceAddr}通道${channel} → 从站${slaveId}线圈${coilNumber}`);
1206
+ // 检查缓冲区数据是否已经达到完整帧长度
1207
+ if (node.serialBuffer.length < frameLen) {
1208
+ // 数据还没到齐,跳出循环等待下一波数据
1209
+ break;
1210
+ }
1215
1211
 
1216
- // 写入线圈(异步,不阻塞)
1217
- node.writeSingleCoil(slaveId, coilNumber, state).catch(err => {
1218
- node.log(`Symi按键控制失败: ${err.message}`);
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
- // 发送应答帧(REPORT类型0x04,反馈LED状态)
1222
- // 协议规定:面板发送0x03(SET)按键事件,主机应该用0x04(REPORT)反馈LED状态
1223
- const responseFrame = protocol.buildSingleLightReport(0x01, deviceAddr, channel, state);
1224
- if (node.client._port && node.client._port.write) {
1225
- node.client._port.write(responseFrame);
1226
- node.log(`Symi应答已发送(REPORT): 设备${deviceAddr} 通道${channel} 状态=${state ? 'ON' : 'OFF'}`);
1227
- } else if (node.client._client && node.client._client.write) {
1228
- // TCP模式
1229
- node.client._client.write(responseFrame);
1230
- node.log(`Symi应答已发送(REPORT/TCP): 设备${deviceAddr} 通道${channel} 状态=${state ? 'ON' : 'OFF'}`);
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
 
@@ -791,102 +794,185 @@ 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
- // 如果是Mesh模式,使用Mesh协议解析
797
- if (node.config.buttonType === 'mesh') {
798
- node.handleMeshData(data);
799
- return;
800
- }
800
+ // 将新数据拼接到缓冲区
801
+ node.rs485Buffer = Buffer.concat([node.rs485Buffer, data]);
801
802
 
802
- // 根据品牌选择协议解析
803
- if (node.config.switchBrand === 'clowire') {
804
- node.handleClowireData(data);
805
- return;
803
+ // 限制缓冲区大小,防止异常情况下内存溢出(最大1KB)
804
+ if (node.rs485Buffer.length > 1024) {
805
+ node.rs485Buffer = node.rs485Buffer.slice(-1024);
806
806
  }
807
807
 
808
- // 亖米协议:使用parseAllFrames处理粘包,解析所有帧
809
- const frames = protocol.parseAllFrames(data);
810
- if (!frames || frames.length === 0) {
811
- return; // 静默忽略无效帧
812
- }
808
+ // 根据品牌/类型选择不同的拼包解析逻辑
809
+ if (node.config.buttonType === 'mesh') {
810
+ // Mesh 协议拼包逻辑
811
+ // 最小帧长约5字节: [53][op][sub][len]...[check]
812
+ while (node.rs485Buffer.length >= 5) {
813
+ const headerIndex = node.rs485Buffer.indexOf(0x53);
814
+ if (headerIndex === -1) {
815
+ node.rs485Buffer = Buffer.alloc(0);
816
+ break;
817
+ }
818
+ if (headerIndex > 0) {
819
+ node.rs485Buffer = node.rs485Buffer.slice(headerIndex);
820
+ }
821
+ if (node.rs485Buffer.length < 4) break;
813
822
 
814
- // 处理每一个帧
815
- for (const frame of frames) {
816
- // 忽略 REPORT (0x04) 类型的帧(这是面板对我们指令的确认,不是按键事件)
817
- // 只处理 SET (0x03) 类型的帧(真正的按键事件)
818
- if (frame.dataType === 0x04) {
819
- continue; // 静默忽略REPORT帧
820
- }
823
+ const dataLen = node.rs485Buffer[3];
824
+ const totalLen = 4 + dataLen + 1; // [53][op][sub][len] + [data...] + [check]
825
+
826
+ if (totalLen > 64) { // 非法长度
827
+ node.rs485Buffer = node.rs485Buffer.slice(1);
828
+ continue;
829
+ }
830
+
831
+ if (node.rs485Buffer.length < totalLen) break; // 数据未到齐
821
832
 
822
- // 检测是否是按键按下事件
823
- const buttonEvent = protocol.detectButtonPress(frame);
824
- if (!buttonEvent) {
825
- continue; // 静默忽略非按键事件
833
+ const completeFrame = node.rs485Buffer.slice(0, totalLen);
834
+ node.rs485Buffer = node.rs485Buffer.slice(totalLen);
835
+
836
+ // 处理解析出来的完整Mesh帧
837
+ node.handleMeshData(completeFrame);
826
838
  }
839
+ } else if (node.config.switchBrand === 'clowire') {
840
+ // Clowire 协议拼包逻辑 (克伦威尔)
841
+ // 特点:以 0xAA 结尾,长度通常为 9 或 11 字节
842
+ while (node.rs485Buffer.length >= 9) {
843
+ const endIndex = node.rs485Buffer.indexOf(0xAA);
844
+ if (endIndex === -1) {
845
+ // 如果缓冲区太长且没找到结尾,保留一部分可能的数据
846
+ if (node.rs485Buffer.length > 32) {
847
+ node.rs485Buffer = node.rs485Buffer.slice(-16);
848
+ }
849
+ break;
850
+ }
827
851
 
828
- // 计算实际按键编号
829
- // 8键面板:deviceAddr=1时,channel直接就是按键编号1-8
830
- // 例如:devAddr=1,channel=7→按键7
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; // 忽略红外感应帧(仅当未配置为背光灯模式时)
852
+ // 尝试匹配可能的长度
853
+ let foundFrame = false;
854
+ const possibleLengths = [9, 11];
855
+
856
+ for (const frameLen of possibleLengths) {
857
+ const startIndex = endIndex - frameLen + 1;
858
+ if (startIndex >= 0) {
859
+ const completeFrame = node.rs485Buffer.slice(startIndex, endIndex + 1);
860
+ if (clowireProtocol.isClowireFrame(completeFrame)) {
861
+ node.rs485Buffer = node.rs485Buffer.slice(endIndex + 1);
862
+ node.handleClowireData(completeFrame);
863
+ foundFrame = true;
864
+ break;
865
+ }
866
+ }
867
+ }
868
+
869
+ if (foundFrame) continue;
870
+
871
+ // 没找到有效帧,跳过当前的 0xAA
872
+ node.rs485Buffer = node.rs485Buffer.slice(endIndex + 1);
843
873
  }
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;
874
+ } else {
875
+ // 亖米 (Symi) 协议拼包逻辑
876
+ // 格式: 7E [addr] [type] [len] ... [tail:7D]
877
+ while (node.rs485Buffer.length >= 15) {
878
+ const headerIndex = node.rs485Buffer.indexOf(0x7E);
879
+ if (headerIndex === -1) {
880
+ node.rs485Buffer = Buffer.alloc(0);
881
+ break;
882
+ }
883
+ if (headerIndex > 0) {
884
+ node.rs485Buffer = node.rs485Buffer.slice(headerIndex);
885
+ }
886
+ if (node.rs485Buffer.length < 4) break;
856
887
 
857
- // 全局防抖:200ms内只触发一次(开关和场景统一防抖时间)
858
- if (now - lastTriggerTime < 200) {
859
- continue; // 静默忽略重复触发
888
+ const frameLen = node.rs485Buffer[3];
889
+ if (frameLen < 15 || frameLen > 64) {
890
+ node.rs485Buffer = node.rs485Buffer.slice(1);
891
+ continue;
860
892
  }
861
- globalDebounceCache.set(debounceKey, now);
862
893
 
863
- // 设置触发源(用于优先队列)
864
- if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
865
- node.serialPortConfig.setTriggerSource(node.config.switchId);
894
+ if (node.rs485Buffer.length < frameLen) break;
895
+
896
+ const completeFrame = node.rs485Buffer.slice(0, frameLen);
897
+ node.rs485Buffer = node.rs485Buffer.slice(frameLen);
898
+
899
+ // 验证尾部
900
+ if (completeFrame[completeFrame.length - 1] !== 0x7D) {
901
+ // 尾部不匹配,说明不是有效帧
902
+ continue;
866
903
  }
867
904
 
868
- if (isSceneMode) {
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);
905
+ // 亖米协议:使用parseAllFrames处理粘包,解析所有帧
906
+ const frames = protocol.parseAllFrames(completeFrame);
907
+ if (frames && frames.length > 0) {
908
+ for (const frame of frames) {
909
+ node.processSymiFrame(frame);
910
+ }
881
911
  }
882
912
  }
883
- // 不匹配的节点静默忽略,不输出任何日志
884
913
  }
885
914
  } catch (err) {
886
915
  node.log(`解析RS-485数据失败: ${err.message}`);
887
916
  }
888
917
  };
889
918
 
919
+ // 提取原 handleRs485Data 中的亖米协议处理逻辑
920
+ node.processSymiFrame = function(frame) {
921
+ try {
922
+ // 忽略 REPORT (0x04) 类型的帧
923
+ if (frame.dataType === 0x04) {
924
+ return;
925
+ }
926
+
927
+ // 检测是否是按键按下事件
928
+ const buttonEvent = protocol.detectButtonPress(frame);
929
+ if (!buttonEvent) {
930
+ return;
931
+ }
932
+
933
+ // 计算实际按键编号
934
+ const actualButtonNumber = (buttonEvent.deviceAddr === 1) ? buttonEvent.channel : (buttonEvent.deviceAddr * 4 - 4 + buttonEvent.channel);
935
+
936
+ if (buttonEvent.channel === 0x0F && node.config.buttonNumber !== 15) {
937
+ return;
938
+ }
939
+
940
+ if (buttonEvent.raw.localAddr === node.config.switchId && actualButtonNumber === node.config.buttonNumber) {
941
+ // 判断按钮类型
942
+ const isSceneMode = buttonEvent.isSceneMode ||
943
+ node.config.buttonType === 'scene' ||
944
+ buttonEvent.deviceType === 0x07;
945
+
946
+ // 全局防抖
947
+ const debounceKey = `${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
948
+ const now = Date.now();
949
+ const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
950
+
951
+ if (now - lastTriggerTime < 200) {
952
+ return;
953
+ }
954
+ globalDebounceCache.set(debounceKey, now);
955
+
956
+ // 设置触发源
957
+ if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
958
+ node.serialPortConfig.setTriggerSource(node.config.switchId);
959
+ }
960
+
961
+ if (isSceneMode) {
962
+ node.debug(`场景触发: 面板${node.config.switchId} 按键${node.config.buttonNumber} (opInfo=0x${buttonEvent.opInfo.toString(16).toUpperCase()})`);
963
+ node.currentState = !node.currentState;
964
+ node.sendMqttCommand(node.currentState);
965
+ node.sendCommandToPanel(node.currentState);
966
+ } else {
967
+ node.debug(`开关${buttonEvent.state ? 'ON' : 'OFF'}: 面板${node.config.switchId} 按键${node.config.buttonNumber}`);
968
+ node.sendMqttCommand(buttonEvent.state);
969
+ }
970
+ }
971
+ } catch (err) {
972
+ node.log(`处理亖米协议帧失败: ${err.message}`);
973
+ }
974
+ };
975
+
890
976
  // 处理Clowire协议数据
891
977
  node.handleClowireData = function(data) {
892
978
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.9.10",
3
+ "version": "2.9.11",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持亖米/Clowire品牌),工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {