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 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
- **当前版本**: v2.9.10 (2026-01-08)
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 (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
  // 立即更新缓存(乐观更新)
@@ -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
 
@@ -569,7 +572,7 @@ module.exports = function(RED) {
569
572
  // 定义数据监听器函数(静默处理,只在匹配时输出日志)
570
573
  node.serialDataListener = (data) => {
571
574
  // 根据开关类型调用不同的处理函数
572
- if (node.config.switchType === 'mesh') {
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
- // 如果是Mesh模式,使用Mesh协议解析
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
- node.handleMeshData(data);
799
- return;
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
- if (node.config.switchBrand === 'clowire') {
804
- node.handleClowireData(data);
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
- // 亖米协议:使用parseAllFrames处理粘包,解析所有帧
809
- const frames = protocol.parseAllFrames(data);
810
- if (!frames || frames.length === 0) {
811
- return; // 静默忽略无效帧
939
+ // 检测是否是按键按下事件
940
+ const buttonEvent = protocol.detectButtonPress(frame);
941
+ if (!buttonEvent) {
942
+ return;
812
943
  }
813
944
 
814
- // 处理每一个帧
815
- for (const frame of frames) {
816
- // 忽略 REPORT (0x04) 类型的帧(这是面板对我们指令的确认,不是按键事件)
817
- // 只处理 SET (0x03) 类型的帧(真正的按键事件)
818
- if (frame.dataType === 0x04) {
819
- continue; // 静默忽略REPORT帧
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
- const buttonEvent = protocol.detectButtonPress(frame);
824
- if (!buttonEvent) {
825
- continue; // 静默忽略非按键事件
963
+ if (now - lastTriggerTime < 200) {
964
+ return;
826
965
  }
966
+ globalDebounceCache.set(debounceKey, now);
827
967
 
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; // 忽略红外感应帧(仅当未配置为背光灯模式时)
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
- // 全局防抖:200ms内只触发一次(开关和场景统一防抖时间)
858
- if (now - lastTriggerTime < 200) {
859
- continue; // 静默忽略重复触发
860
- }
861
- globalDebounceCache.set(debounceKey, now);
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
- if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
865
- node.serialPortConfig.setTriggerSource(node.config.switchId);
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
- 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);
881
- }
1000
+ node.sendMqttCommand(buttonEvent.state);
882
1001
  }
883
- // 不匹配的节点静默忽略,不输出任何日志
884
1002
  }
885
1003
  } catch (err) {
886
- node.log(`解析RS-485数据失败: ${err.message}`);
1004
+ node.log(`处理亖米协议帧失败: ${err.message}`);
887
1005
  }
888
1006
  };
889
1007
 
890
1008
  // 处理Clowire协议数据
891
1009
  node.handleClowireData = function(data) {
892
1010
  try {
893
- // 检查是否是Clowire协议帧
894
- if (!clowireProtocol.isClowireFrame(data)) {
895
- return; // 静默忽略非Clowire帧
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(data) {
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开关状态覆盖继电器状态)
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.9.10",
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": {