node-red-contrib-symi-modbus 2.9.7 → 2.9.9

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
@@ -471,8 +471,8 @@ node-red-restart
471
471
  - 连接前彻底清理旧实例,避免资源泄漏
472
472
  - **互斥锁机制**:防止读写冲突导致的数据异常
473
473
  - **TCP永久连接**:
474
- - 禁用TCP超时(永久连接),避免无数据时超时断开
475
- - Keep-Alive心跳10秒间隔,确保连接活跃
474
+ - 禁用TCP超时(永久连接),避免无数据时超时断开
475
+ - Keep-Alive心跳10秒间隔,确保连接活跃
476
476
  - 适应客户长期不在家、总线无数据的场景
477
477
  - 网络故障自动重连,恢复后立即恢复通信
478
478
 
@@ -952,7 +952,35 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
952
952
 
953
953
  ## 版本信息
954
954
 
955
- **当前版本**: v2.9.7 (2025-12-24)
955
+ **当前版本**: v2.9.9 (2026-01-08)
956
+
957
+ **v2.9.9 更新内容**:
958
+ - **深度优化多 TCP 主站并发**:
959
+ - **独立主站标识**:为每个 TCP 主站生成唯一标识,支持多个 TCP 连接独立运行,互不干扰
960
+ - **全局实例管理**:建立全局实例表,解决多节点并发时的资源抢占和卡顿问题
961
+ - **TCP 参数调优**:
962
+ - 降低 TCP 超时时间至 1000ms,提升异常响应速度
963
+ - 启用 Keep-Alive(10秒心跳),确保长连接稳定性
964
+ - 开启 NoDelay,禁用 Nagle 算法,显著降低指令发送延迟
965
+ - **稳定性增强**:完善节点关闭时的资源释放逻辑,防止内存泄漏和连接残留
966
+
967
+ **v2.9.8 更新内容**:
968
+ - **新增品牌支持**:Clowire(克伦威尔)智能面板
969
+ - 支持Clowire 1-8键开关面板,与亖米面板使用方式完全一致
970
+ - 支持单击、双击、长按等按键事件(统一当作单击处理,触发一次状态切换)
971
+ - 支持红外感应器(有人/无人状态检测,复用按键背光灯配置buttonNumber=15)
972
+ - 支持LED指示灯双向同步(继电器状态→面板LED)
973
+ - 协议文档:`doc/Clowire-标准面板485协议-V1.3.md`
974
+ - **界面优化**:
975
+ - 面板品牌下拉框新增"Clowire(克伦威尔)"选项
976
+ - Clowire默认面板地址为2(符合出厂设置)
977
+ - 节点标签显示品牌名称(如"Clowire[2]-按钮1")
978
+ - 感应器复用按键背光灯配置(buttonNumber=15),无需单独配置
979
+ - **协议实现**:
980
+ - 新增`clowire-protocol.js`协议模块
981
+ - 支持MODBUS RTU通讯格式(9600 8N1)
982
+ - 支持CRC16校验和帧尾0xAA
983
+ - 支持批量LED控制(0x25命令码)
956
984
 
957
985
  **v2.9.7 更新内容**:
958
986
  - **新增节点**:继电器输出节点(relay-output)
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Clowire 标准面板485协议实现
3
+ * 协议版本:V1.3
4
+ * 厂家:宁波星宏智能技术有限公司
5
+ *
6
+ * 协议特点:
7
+ * - 基于 MODBUS RTU 通讯格式
8
+ * - 面板主动发送模式(按键按下时主动上报)
9
+ * - 支持单击、双击、三击、长按
10
+ * - 支持 LED 指示灯控制
11
+ * - 支持红外感应信号上报
12
+ *
13
+ * 通讯帧格式:
14
+ * [面板地址][命令码][寄存器地址高][寄存器地址低][数据高][数据低][CRC低][CRC高][帧尾0xAA]
15
+ */
16
+
17
+ module.exports = {
18
+ // 帧尾
19
+ FRAME_TAIL: 0xAA,
20
+
21
+ // 广播地址
22
+ BROADCAST_ADDR: 0xFF,
23
+
24
+ // 默认面板地址
25
+ DEFAULT_PANEL_ADDR: 0x02,
26
+
27
+ // 命令码
28
+ CMD_READ: 0x03, // 读取寄存器
29
+ CMD_WRITE: 0x06, // 写入单个寄存器
30
+ CMD_BUTTON_EVENT: 0x20, // 按键事件上报
31
+ CMD_BATCH_CONTROL: 0x25, // 批量控制
32
+
33
+ // 寄存器地址
34
+ REG_PANEL_ADDR: 0x1000, // 面板地址
35
+ REG_BUTTON_S1: 0x1011, // 按键S1状态
36
+ REG_BUTTON_S2: 0x1012, // 按键S2状态
37
+ REG_BUTTON_S3: 0x1013, // 按键S3状态
38
+ REG_BUTTON_S4: 0x1014, // 按键S4状态
39
+ REG_BUTTON_S5: 0x1015, // 按键S5状态
40
+ REG_BUTTON_S6: 0x1016, // 按键S6状态
41
+ REG_BUTTON_S7: 0x1017, // 按键S7状态
42
+ REG_BUTTON_S8: 0x1018, // 按键S8状态
43
+ REG_DOUBLE_CLICK_INTERVAL: 0x1019, // 双击时间间隔
44
+ REG_TRIPLE_CLICK_INTERVAL: 0x101A, // 三击时间间隔
45
+ REG_LONG_PRESS_TIME: 0x101B, // 长按时间
46
+ REG_BACKLIGHT: 0x1020, // 字符背光
47
+ REG_LED1: 0x1021, // LED1指示灯
48
+ REG_LED2: 0x1022, // LED2指示灯
49
+ REG_LED3: 0x1023, // LED3指示灯
50
+ REG_LED4: 0x1024, // LED4指示灯
51
+ REG_LED5: 0x1025, // LED5指示灯
52
+ REG_LED6: 0x1026, // LED6指示灯
53
+ REG_LED7: 0x1027, // LED7指示灯
54
+ REG_LED8: 0x1028, // LED8指示灯
55
+ REG_SENSOR: 0x1040, // 感应信号
56
+ REG_VERSION: 0x1041, // 版本查询
57
+ REG_LONG_PRESS: 0x2050, // 长按寄存器
58
+
59
+ // 按键状态值
60
+ BUTTON_SINGLE_CLICK: 0x0080, // 单击 (bit7=1)
61
+ BUTTON_DOUBLE_CLICK: 0x0040, // 双击 (bit6=1)
62
+ BUTTON_TRIPLE_CLICK: 0x0020, // 三击 (bit5=1)
63
+ BUTTON_LONG_PRESS: 0x0180, // 长按
64
+ BUTTON_LONG_RELEASE: 0x01FE, // 长按松开
65
+
66
+ // 感应信号值
67
+ SENSOR_DETECTED: 0x0080, // 有感应信号
68
+ SENSOR_NONE: 0x0000, // 无感应信号
69
+
70
+ /**
71
+ * 计算 MODBUS CRC16 校验
72
+ * @param {Buffer} buffer - 数据缓冲区
73
+ * @param {number} len - 数据长度(不包含CRC和帧尾)
74
+ * @returns {number} CRC16校验值(低字节在前)
75
+ */
76
+ calculateCRC16: function(buffer, len) {
77
+ let crc = 0xFFFF;
78
+ for (let i = 0; i < len; i++) {
79
+ crc ^= buffer[i];
80
+ for (let j = 0; j < 8; j++) {
81
+ if (crc & 0x0001) {
82
+ crc = (crc >> 1) ^ 0xA001;
83
+ } else {
84
+ crc >>= 1;
85
+ }
86
+ }
87
+ }
88
+ return crc;
89
+ },
90
+
91
+ /**
92
+ * 构建LED指示灯控制指令
93
+ * @param {number} panelAddr - 面板地址(1-127)
94
+ * @param {number} buttonNumber - 按钮编号(1-8)
95
+ * @param {boolean} state - LED状态(true=开,false=关)
96
+ * @returns {Buffer} 完整的指令帧
97
+ */
98
+ buildLedControlCommand: function(panelAddr, buttonNumber, state) {
99
+ const buffer = Buffer.alloc(9);
100
+ let idx = 0;
101
+
102
+ // 面板地址
103
+ buffer[idx++] = panelAddr;
104
+ // 命令码:写入单个寄存器
105
+ buffer[idx++] = this.CMD_WRITE;
106
+ // 寄存器地址(LED1=0x1021, LED2=0x1022, ...)
107
+ const regAddr = 0x1020 + buttonNumber;
108
+ buffer[idx++] = (regAddr >> 8) & 0xFF; // 高字节
109
+ buffer[idx++] = regAddr & 0xFF; // 低字节
110
+ // 数据(00 01=开,00 00=关)
111
+ buffer[idx++] = 0x00;
112
+ buffer[idx++] = state ? 0x01 : 0x00;
113
+
114
+ // 计算CRC16
115
+ const crc = this.calculateCRC16(buffer, 6);
116
+ buffer[idx++] = crc & 0xFF; // CRC低字节
117
+ buffer[idx++] = (crc >> 8) & 0xFF; // CRC高字节
118
+ // 帧尾
119
+ buffer[idx++] = this.FRAME_TAIL;
120
+
121
+ return buffer;
122
+ },
123
+
124
+ /**
125
+ * 构建批量LED控制指令(使用0x25命令码)
126
+ * @param {number} panelAddr - 面板地址(1-127)
127
+ * @param {Array<boolean>} ledStates - LED状态数组(最多8个)
128
+ * @param {boolean} backlight - 字符背光状态
129
+ * @returns {Buffer} 完整的指令帧
130
+ */
131
+ buildBatchLedCommand: function(panelAddr, ledStates, backlight = true) {
132
+ const buffer = Buffer.alloc(11);
133
+ let idx = 0;
134
+
135
+ // 广播地址
136
+ buffer[idx++] = this.BROADCAST_ADDR;
137
+ // 命令码:批量控制
138
+ buffer[idx++] = this.CMD_BATCH_CONTROL;
139
+ // 面板开始地址(高字节、低字节)
140
+ buffer[idx++] = 0x00;
141
+ buffer[idx++] = panelAddr;
142
+ // 连续面板数量(高字节、低字节)
143
+ buffer[idx++] = 0x00;
144
+ buffer[idx++] = 0x01;
145
+
146
+ // 面板数据(2字节)
147
+ // 高字节:B8=背光,B9-B12=继电器(预留)
148
+ // 低字节:B0-B5=LED1-LED6
149
+ let highByte = backlight ? 0x01 : 0x00; // B8=背光
150
+ let lowByte = 0x00;
151
+
152
+ for (let i = 0; i < Math.min(ledStates.length, 6); i++) {
153
+ if (ledStates[i]) {
154
+ lowByte |= (1 << i);
155
+ }
156
+ }
157
+
158
+ buffer[idx++] = highByte;
159
+ buffer[idx++] = lowByte;
160
+
161
+ // 计算CRC16
162
+ const crc = this.calculateCRC16(buffer, 8);
163
+ buffer[idx++] = crc & 0xFF; // CRC低字节
164
+ buffer[idx++] = (crc >> 8) & 0xFF; // CRC高字节
165
+ // 帧尾
166
+ buffer[idx++] = this.FRAME_TAIL;
167
+
168
+ return buffer;
169
+ },
170
+
171
+ /**
172
+ * 构建字符背光控制指令
173
+ * @param {number} panelAddr - 面板地址(1-127)
174
+ * @param {boolean} state - 背光状态(true=开,false=关)
175
+ * @returns {Buffer} 完整的指令帧
176
+ */
177
+ buildBacklightCommand: function(panelAddr, state) {
178
+ const buffer = Buffer.alloc(9);
179
+ let idx = 0;
180
+
181
+ buffer[idx++] = panelAddr;
182
+ buffer[idx++] = this.CMD_WRITE;
183
+ buffer[idx++] = 0x10; // 寄存器地址高字节
184
+ buffer[idx++] = 0x20; // 寄存器地址低字节(0x1020)
185
+ buffer[idx++] = 0x00;
186
+ buffer[idx++] = state ? 0x01 : 0x00;
187
+
188
+ const crc = this.calculateCRC16(buffer, 6);
189
+ buffer[idx++] = crc & 0xFF;
190
+ buffer[idx++] = (crc >> 8) & 0xFF;
191
+ buffer[idx++] = this.FRAME_TAIL;
192
+
193
+ return buffer;
194
+ },
195
+
196
+ /**
197
+ * 解析接收到的协议帧
198
+ * @param {Buffer} buffer - 接收到的数据
199
+ * @returns {Object|null} 解析结果或null
200
+ */
201
+ parseFrame: function(buffer) {
202
+ if (!buffer || buffer.length < 9) {
203
+ return null;
204
+ }
205
+
206
+ // 查找帧尾位置
207
+ let endIndex = -1;
208
+ for (let i = buffer.length - 1; i >= 0; i--) {
209
+ if (buffer[i] === this.FRAME_TAIL) {
210
+ endIndex = i;
211
+ break;
212
+ }
213
+ }
214
+
215
+ if (endIndex === -1 || endIndex < 8) {
216
+ return null;
217
+ }
218
+
219
+ // 提取帧数据(从开头到帧尾)
220
+ const frameBuffer = buffer.slice(0, endIndex + 1);
221
+
222
+ // 验证CRC16
223
+ const dataLen = frameBuffer.length - 3; // 不包含CRC(2字节)和帧尾(1字节)
224
+ const receivedCRC = frameBuffer[dataLen] | (frameBuffer[dataLen + 1] << 8);
225
+ const calculatedCRC = this.calculateCRC16(frameBuffer, dataLen);
226
+
227
+ // CRC校验(宽松模式:按键事件允许CRC不匹配)
228
+ const cmdCode = frameBuffer[1];
229
+ const isButtonEvent = (cmdCode === this.CMD_BUTTON_EVENT || cmdCode === this.CMD_READ);
230
+
231
+ if (receivedCRC !== calculatedCRC && !isButtonEvent) {
232
+ return null;
233
+ }
234
+
235
+ // 解析帧
236
+ const frame = {
237
+ panelAddr: frameBuffer[0],
238
+ cmdCode: frameBuffer[1],
239
+ regAddrHigh: frameBuffer[2],
240
+ regAddrLow: frameBuffer[3],
241
+ regAddr: (frameBuffer[2] << 8) | frameBuffer[3],
242
+ dataHigh: frameBuffer[4],
243
+ dataLow: frameBuffer[5],
244
+ data: (frameBuffer[4] << 8) | frameBuffer[5],
245
+ crc: receivedCRC,
246
+ frameLength: frameBuffer.length
247
+ };
248
+
249
+ return frame;
250
+ },
251
+
252
+ /**
253
+ * 解析所有协议帧(处理粘包)
254
+ * @param {Buffer} buffer - 接收到的数据(可能包含多个帧)
255
+ * @returns {Array} 解析出的所有帧数组
256
+ */
257
+ parseAllFrames: function(buffer) {
258
+ const frames = [];
259
+ if (!buffer || buffer.length < 9) {
260
+ return frames;
261
+ }
262
+
263
+ let offset = 0;
264
+ let maxIterations = 10;
265
+
266
+ while (offset < buffer.length && maxIterations > 0) {
267
+ maxIterations--;
268
+
269
+ // 查找帧尾
270
+ let endIndex = -1;
271
+ for (let i = offset; i < buffer.length; i++) {
272
+ if (buffer[i] === this.FRAME_TAIL) {
273
+ endIndex = i;
274
+ break;
275
+ }
276
+ }
277
+
278
+ if (endIndex === -1) {
279
+ break;
280
+ }
281
+
282
+ // 检查帧长度(最小9字节)
283
+ const frameLen = endIndex - offset + 1;
284
+ if (frameLen < 9) {
285
+ offset = endIndex + 1;
286
+ continue;
287
+ }
288
+
289
+ // 提取帧
290
+ const frameBuffer = buffer.slice(offset, endIndex + 1);
291
+ const frame = this.parseFrame(frameBuffer);
292
+
293
+ if (frame) {
294
+ frames.push(frame);
295
+ }
296
+
297
+ offset = endIndex + 1;
298
+ }
299
+
300
+ return frames;
301
+ },
302
+
303
+ /**
304
+ * 检测是否是按键事件
305
+ * @param {Object} frame - 解析后的帧
306
+ * @returns {Object|null} 按键事件信息或null
307
+ */
308
+ detectButtonEvent: function(frame) {
309
+ if (!frame) return null;
310
+
311
+ // 检查命令码是否是按键事件上报(0x20)或读取(0x03)
312
+ if (frame.cmdCode !== this.CMD_BUTTON_EVENT && frame.cmdCode !== this.CMD_READ) {
313
+ return null;
314
+ }
315
+
316
+ // 检查寄存器地址是否在按键范围内(0x1011-0x1018)
317
+ if (frame.regAddr >= this.REG_BUTTON_S1 && frame.regAddr <= this.REG_BUTTON_S8) {
318
+ // 按键事件
319
+ const buttonNumber = frame.regAddr - this.REG_BUTTON_S1 + 1; // 1-8
320
+ const data = frame.data;
321
+
322
+ // 判断按键类型
323
+ let eventType = 'unknown';
324
+ let state = true; // 默认触发状态
325
+
326
+ if (data === this.BUTTON_SINGLE_CLICK || (data & 0x0080) !== 0) {
327
+ eventType = 'single_click';
328
+ } else if (data === this.BUTTON_DOUBLE_CLICK || (data & 0x0040) !== 0) {
329
+ eventType = 'double_click';
330
+ } else if (data === this.BUTTON_TRIPLE_CLICK || (data & 0x0020) !== 0) {
331
+ eventType = 'triple_click';
332
+ }
333
+
334
+ return {
335
+ type: 'button',
336
+ panelAddr: frame.panelAddr,
337
+ buttonNumber: buttonNumber,
338
+ eventType: eventType,
339
+ state: state,
340
+ data: data,
341
+ raw: frame
342
+ };
343
+ }
344
+
345
+ // 检查是否是长按事件(寄存器0x2050)
346
+ if (frame.regAddr === this.REG_LONG_PRESS) {
347
+ const buttonNumber = frame.dataHigh; // 按键编号在高字节
348
+ const pressState = frame.dataLow; // 按下状态在低字节
349
+
350
+ return {
351
+ type: 'button',
352
+ panelAddr: frame.panelAddr,
353
+ buttonNumber: buttonNumber,
354
+ eventType: pressState === 0xFE ? 'long_press_release' : 'long_press',
355
+ state: pressState !== 0xFE,
356
+ data: frame.data,
357
+ raw: frame
358
+ };
359
+ }
360
+
361
+ // 检查是否是感应信号(寄存器0x1040)
362
+ if (frame.regAddr === this.REG_SENSOR) {
363
+ const hasPresence = (frame.data & 0x0080) !== 0;
364
+
365
+ return {
366
+ type: 'sensor',
367
+ panelAddr: frame.panelAddr,
368
+ sensorType: 'presence',
369
+ state: hasPresence,
370
+ data: frame.data,
371
+ raw: frame
372
+ };
373
+ }
374
+
375
+ return null;
376
+ },
377
+
378
+ /**
379
+ * 判断是否是Clowire协议帧
380
+ * @param {Buffer} buffer - 接收到的数据
381
+ * @returns {boolean} 是否是Clowire协议
382
+ */
383
+ isClowireFrame: function(buffer) {
384
+ if (!buffer || buffer.length < 9) {
385
+ return false;
386
+ }
387
+
388
+ // 检查帧尾是否是0xAA
389
+ const lastByte = buffer[buffer.length - 1];
390
+ if (lastByte !== this.FRAME_TAIL) {
391
+ return false;
392
+ }
393
+
394
+ // 检查命令码是否是Clowire支持的命令
395
+ const cmdCode = buffer[1];
396
+ const validCmds = [this.CMD_READ, this.CMD_WRITE, this.CMD_BUTTON_EVENT, this.CMD_BATCH_CONTROL];
397
+ if (!validCmds.includes(cmdCode)) {
398
+ return false;
399
+ }
400
+
401
+ // 检查寄存器地址是否在Clowire范围内(0x1000-0x2FFF)
402
+ const regAddr = (buffer[2] << 8) | buffer[3];
403
+ if (regAddr < 0x1000 || regAddr > 0x2FFF) {
404
+ return false;
405
+ }
406
+
407
+ return true;
408
+ }
409
+ };
@@ -3,7 +3,11 @@ module.exports = function(RED) {
3
3
  const ModbusRTU = require("modbus-serial");
4
4
  const mqtt = require("mqtt");
5
5
  const protocol = require("./lightweight-protocol");
6
-
6
+
7
+ // 全局主站实例注册表(用于多主站独立运行,避免互相干扰)
8
+ // 每个主站节点有独立的Modbus客户端实例,互不影响
9
+ const masterInstances = new Map();
10
+
7
11
  // 串口列表API - 支持Windows、Linux、macOS所有串口设备
8
12
  RED.httpAdmin.get('/modbus-master/serialports', async function(req, res) {
9
13
  try {
@@ -90,13 +94,16 @@ module.exports = function(RED) {
90
94
  function ModbusMasterNode(config) {
91
95
  RED.nodes.createNode(this, config);
92
96
  const node = this;
93
-
97
+
98
+ // 生成唯一的主站标识(用于多主站独立运行)
99
+ node.masterId = `master_${node.id}`;
100
+
94
101
  // 获取Modbus服务器配置节点
95
102
  node.modbusServerConfig = RED.nodes.getNode(config.modbusServer);
96
-
103
+
97
104
  // 获取MQTT服务器配置节点
98
105
  node.mqttServerConfig = RED.nodes.getNode(config.mqttServer);
99
-
106
+
100
107
  // 配置参数(从Modbus服务器配置节点读取)
101
108
  node.config = {
102
109
  connectionType: node.modbusServerConfig ? (node.modbusServerConfig.connectionType || "tcp") : "tcp",
@@ -120,13 +127,19 @@ module.exports = function(RED) {
120
127
  mqttPassword: node.mqttServerConfig ? (node.mqttServerConfig.credentials ? node.mqttServerConfig.credentials.password : "") : "",
121
128
  mqttBaseTopic: node.mqttServerConfig ? node.mqttServerConfig.baseTopic : "modbus/relay"
122
129
  };
123
-
130
+
124
131
  // 验证Modbus服务器配置
125
132
  if (!node.modbusServerConfig) {
126
133
  node.warn('未配置Modbus服务器,将使用默认配置');
127
134
  }
128
-
129
- // Modbus客户端
135
+
136
+ // 记录主站配置信息(用于调试多主站问题)
137
+ const connInfo = node.config.connectionType === "tcp"
138
+ ? `TCP ${node.config.tcpHost}:${node.config.tcpPort}`
139
+ : `串口 ${node.config.serialPort}`;
140
+ node.log(`主站初始化: ${node.masterId}, 连接: ${connInfo}`);
141
+
142
+ // Modbus客户端(每个主站独立的客户端实例)
130
143
  node.client = new ModbusRTU();
131
144
  node.isConnected = false;
132
145
  node.pollTimer = null;
@@ -139,7 +152,7 @@ module.exports = function(RED) {
139
152
  node.isClosing = false;
140
153
  node.lastErrorLog = {}; // 记录每个从站的最后错误日志时间
141
154
  node.lastMqttErrorLog = 0; // MQTT错误日志时间
142
- node.errorLogInterval = 5 * 60 * 1000; // 错误日志间隔:5分钟(降低到5分钟,更快发现问题)
155
+ node.errorLogInterval = 5 * 60 * 1000; // 错误日志间隔:5分钟
143
156
  node.consecutiveErrors = {}; // 记录每个从站的连续错误次数
144
157
  node.modbusLock = false; // Modbus操作互斥锁(防止读写冲突)
145
158
  node.lastWriteTime = {}; // 记录每个从站的最后写入时间
@@ -152,6 +165,15 @@ module.exports = function(RED) {
152
165
  node.isProcessingWrite = false; // 是否正在处理写入队列
153
166
  node.writeQueueInterval = 50; // 写入队列处理间隔(50ms,厂家推荐间隔,确保总线稳定)
154
167
 
168
+ // 注册到全局主站实例表(用于调试和监控)
169
+ masterInstances.set(node.masterId, {
170
+ nodeId: node.id,
171
+ connection: connInfo,
172
+ slaves: node.config.slaves.map(s => s.address),
173
+ startTime: Date.now()
174
+ });
175
+ node.log(`已注册主站实例,当前主站数量: ${masterInstances.size}`);
176
+
155
177
  // 定期清理机制(每小时清理一次,防止内存泄漏)
156
178
  node.cleanupTimer = setInterval(() => {
157
179
  // 清理过期的错误日志记录
@@ -244,7 +266,7 @@ module.exports = function(RED) {
244
266
  throw new Error('TCP主机地址未配置,请在节点配置中填写Modbus服务器IP地址');
245
267
  }
246
268
 
247
- const tcpMode = node.config.tcpMode || "telnet";
269
+ const tcpMode = node.modbusServerConfig ? (node.modbusServerConfig.tcpMode || "telnet") : "telnet";
248
270
  const modeNames = {
249
271
  "telnet": "Telnet ASCII",
250
272
  "rtu": "RTU over TCP",
@@ -253,24 +275,35 @@ module.exports = function(RED) {
253
275
 
254
276
  node.log(`正在连接TCP网关(${modeNames[tcpMode]}): ${node.config.tcpHost}:${node.config.tcpPort}...`);
255
277
 
278
+ // TCP连接选项(优化多主站并发性能)
279
+ const tcpOptions = {
280
+ port: node.config.tcpPort,
281
+ // 注意:modbus-serial内部会设置socket选项
282
+ };
283
+
256
284
  if (tcpMode === "telnet") {
257
- await node.client.connectTelnet(node.config.tcpHost, {
258
- port: node.config.tcpPort
259
- });
285
+ await node.client.connectTelnet(node.config.tcpHost, tcpOptions);
260
286
  } else if (tcpMode === "rtu") {
261
- await node.client.connectTcpRTUBuffered(node.config.tcpHost, {
262
- port: node.config.tcpPort
263
- });
287
+ await node.client.connectTcpRTUBuffered(node.config.tcpHost, tcpOptions);
264
288
  } else {
265
- await node.client.connectTCP(node.config.tcpHost, {
266
- port: node.config.tcpPort
267
- });
289
+ await node.client.connectTCP(node.config.tcpHost, tcpOptions);
290
+ }
291
+
292
+ // 优化TCP socket设置(提升多主站并发性能)
293
+ if (node.client._client) {
294
+ // 启用TCP Keep-Alive,10秒间隔
295
+ node.client._client.setKeepAlive(true, 10000);
296
+ // 禁用Nagle算法,减少延迟
297
+ node.client._client.setNoDelay(true);
298
+ // 设置socket超时为0(永不超时,由Modbus层控制超时)
299
+ node.client._client.setTimeout(0);
300
+ node.log('TCP socket优化: Keep-Alive=10s, NoDelay=true');
268
301
  }
269
302
 
270
303
  node.log(`TCP网关连接成功(${modeNames[tcpMode]}): ${node.config.tcpHost}:${node.config.tcpPort}`);
271
304
  } else {
272
305
  node.log(`正在连接串口: ${node.config.serialPort} @ ${node.config.serialBaudRate || 9600}bps...`);
273
-
306
+
274
307
  await node.client.connectRTUBuffered(node.config.serialPort, {
275
308
  baudRate: node.config.serialBaudRate || 9600,
276
309
  dataBits: node.config.serialDataBits || 8,
@@ -278,12 +311,14 @@ module.exports = function(RED) {
278
311
  parity: node.config.serialParity || 'none',
279
312
  lock: false // 禁用串口锁定,解决HassOS中"Cannot lock port"错误
280
313
  });
281
-
314
+
282
315
  node.log(`串口Modbus连接成功: ${node.config.serialPort} @ ${node.config.serialBaudRate || 9600}bps`);
283
316
  }
284
317
 
285
- // 设置超时时间(串口需要更长的超时时间,但不能太长以免影响轮询)
286
- const timeout = node.config.connectionType === "serial" ? 3000 : 2000;
318
+ // 设置Modbus超时时间
319
+ // TCP模式:降低到1000ms,避免一个主站超时阻塞其他主站太久
320
+ // 串口模式:保持2000ms,串口通信需要更长时间
321
+ const timeout = node.config.connectionType === "serial" ? 2000 : 1000;
287
322
  node.client.setTimeout(timeout);
288
323
  node.log(`Modbus超时设置: ${timeout}ms`);
289
324
 
@@ -293,6 +328,13 @@ module.exports = function(RED) {
293
328
  node.warn(`串口错误(已忽略): ${err.message}`);
294
329
  });
295
330
  }
331
+
332
+ // TCP模式:设置socket错误处理器
333
+ if (node.client._client) {
334
+ node.client._client.on('error', (err) => {
335
+ node.warn(`TCP socket错误(已忽略): ${err.message}`);
336
+ });
337
+ }
296
338
 
297
339
  node.isConnected = true;
298
340
  node.reconnectAttempts = 0; // 重置重连计数
@@ -1525,6 +1567,12 @@ module.exports = function(RED) {
1525
1567
  node.isClosing = true;
1526
1568
  node.stopPolling();
1527
1569
 
1570
+ // 从全局主站实例表中移除
1571
+ if (node.masterId) {
1572
+ masterInstances.delete(node.masterId);
1573
+ node.log(`已注销主站实例 ${node.masterId},剩余主站数量: ${masterInstances.size}`);
1574
+ }
1575
+
1528
1576
  // 移除内部事件监听器
1529
1577
  if (node.internalEventHandler) {
1530
1578
  RED.events.removeListener('modbus:writeCoil', node.internalEventHandler);
@@ -1553,13 +1601,13 @@ module.exports = function(RED) {
1553
1601
  if (node.config.enableMqtt && node.mqttClient && node.mqttClient.connected) {
1554
1602
  node.publishOfflineStatus();
1555
1603
  }
1556
-
1604
+
1557
1605
  // 清理设备状态缓存(释放内存)
1558
1606
  node.deviceStates = {};
1559
1607
  node.lastErrorLog = {};
1560
1608
  node.lastWriteTime = {};
1561
1609
  node.lastMqttErrorLog = 0;
1562
-
1610
+
1563
1611
  // 关闭Modbus连接
1564
1612
  if (node.client) {
1565
1613
  try {
@@ -4,6 +4,7 @@ module.exports = function(RED) {
4
4
  function ModbusServerConfigNode(config) {
5
5
  RED.nodes.createNode(this, config);
6
6
  this.connectionType = config.connectionType;
7
+ this.tcpMode = config.tcpMode || "telnet"; // TCP模式:telnet/rtu/tcp
7
8
  this.tcpHost = config.tcpHost;
8
9
  this.tcpPort = parseInt(config.tcpPort) || 502;
9
10
  this.serialPort = config.serialPort;
@@ -12,7 +13,7 @@ module.exports = function(RED) {
12
13
  this.serialStopBits = parseInt(config.serialStopBits) || 1;
13
14
  this.serialParity = config.serialParity || "none";
14
15
  }
15
-
16
+
16
17
  RED.nodes.registerType("modbus-server-config", ModbusServerConfigNode);
17
18
  };
18
19
 
@@ -15,6 +15,7 @@
15
15
  buttonType: {value: "switch"}, // 按钮类型:switch=开关模式,scene=场景模式,mesh=Mesh模式
16
16
  switchId: {value: 0, validate: RED.validators.number()},
17
17
  buttonNumber: {value: 1, validate: RED.validators.number()},
18
+
18
19
  // Mesh模式配置
19
20
  meshMacAddress: {value: ""}, // Mesh设备MAC地址
20
21
  meshShortAddress: {value: 0}, // Mesh设备短地址
@@ -39,7 +40,8 @@
39
40
  // 开关模式和场景模式显示
40
41
  const coilDisplay = this.targetCoilNumber || 1;
41
42
  const btnLabel = this.buttonNumber == 15 ? '背光灯' : `按钮${this.buttonNumber}`;
42
- return this.name || `开关${this.switchId}-${btnLabel} 继电器${this.targetSlaveAddress}-${coilDisplay}路`;
43
+ const brandLabel = this.switchBrand === 'clowire' ? 'Clowire' : '开关';
44
+ return this.name || `${brandLabel}${this.switchId}-${btnLabel} → 继电器${this.targetSlaveAddress}-${coilDisplay}路`;
43
45
  }
44
46
  },
45
47
  oneditprepare: function() {
@@ -54,6 +56,17 @@
54
56
  }
55
57
  });
56
58
 
59
+ // 品牌切换控制
60
+ $("#node-input-switchBrand").on("change", function() {
61
+ const brand = $(this).val();
62
+ if (brand === 'clowire') {
63
+ // Clowire默认地址是2
64
+ if ($("#node-input-switchId").val() === '0') {
65
+ $("#node-input-switchId").val(2);
66
+ }
67
+ }
68
+ });
69
+
57
70
  // 按钮类型切换控制显示/隐藏
58
71
  $("#node-input-buttonType").on("change", function() {
59
72
  const buttonType = $(this).val();
@@ -268,12 +281,13 @@
268
281
  <label for="node-input-switchBrand" style="width: 110px;"><i class="fa fa-trademark"></i> 面板品牌</label>
269
282
  <select id="node-input-switchBrand" style="width: 200px;">
270
283
  <option value="symi">亖米(Symi)</option>
271
- <option value="other1" disabled style="color: #999;">其他品牌1(待开发)</option>
272
- <option value="other2" disabled style="color: #999;">其他品牌2(待开发)</option>
284
+ <option value="clowire">Clowire(克伦威尔)</option>
273
285
  </select>
274
286
  <span style="margin-left: 10px; font-size: 11px; color: #666; font-style: italic;">支持1-8键开关</span>
275
287
  </div>
276
288
 
289
+
290
+
277
291
  <div class="form-row">
278
292
  <label for="node-input-buttonType" style="width: 110px;"><i class="fa fa-cog"></i> 按钮类型</label>
279
293
  <select id="node-input-buttonType" style="width: 200px;">
@@ -388,8 +402,8 @@
388
402
  配置说明
389
403
  </div>
390
404
  <div style="margin-bottom: 8px; padding-left: 10px;">
391
- <strong>面板品牌:</strong><span style="color: #555;">亖米协议,支持1-8键开关</span><br>
392
- <strong>开关ID:</strong><span style="color: #555;">物理面板RS-485地址(0-255)</span><br>
405
+ <strong>面板品牌:</strong><span style="color: #555;">亖米/Clowire协议,支持1-8键开关</span><br>
406
+ <strong>开关ID:</strong><span style="color: #555;">物理面板RS-485地址(亖米:0-255,Clowire:1-127,默认2)</span><br>
393
407
  <strong>按钮编号:</strong><span style="color: #555;">面板按键序号(1-8)</span><br>
394
408
  <strong>从站地址:</strong><span style="color: #555;">Modbus继电器地址(10-19)</span><br>
395
409
  <strong>线圈编号:</strong><span style="color: #555;">继电器通道号(0-31)</span>
@@ -399,7 +413,8 @@
399
413
  配置示例
400
414
  </div>
401
415
  <div style="background: white; padding: 8px; border-radius: 4px; border: 1px solid #c8e6c9; font-size: 11px; color: #555;">
402
- <strong>场景:</strong>亖米开关ID=0,按钮1 → 控制继电器10的线圈0<br>
416
+ <strong>亖米:</strong>开关ID=0,按钮1 → 控制继电器10的线圈0<br>
417
+ <strong>Clowire:</strong>开关ID=2,按钮1 → 控制继电器10的线圈0<br>
403
418
  <strong>MQTT:</strong><code style="background: #f5f5f5; padding: 2px 6px; border-radius: 3px; color: #e91e63; font-family: monospace;">modbus/relay/10/0/set</code>
404
419
  </div>
405
420
  </div>
@@ -2,6 +2,7 @@ module.exports = function(RED) {
2
2
  "use strict";
3
3
  const mqtt = require("mqtt");
4
4
  const protocol = require("./lightweight-protocol");
5
+ const clowireProtocol = require("./clowire-protocol");
5
6
  const meshProtocol = require("./mesh-protocol")(RED);
6
7
  const storage = require('node-persist');
7
8
  const path = require('path');
@@ -373,7 +374,7 @@ module.exports = function(RED) {
373
374
  mqttPassword: node.mqttServerConfig ? (node.mqttServerConfig.credentials ? node.mqttServerConfig.credentials.password : "") : "",
374
375
  mqttBaseTopic: node.mqttServerConfig ? node.mqttServerConfig.baseTopic : "modbus/relay",
375
376
  // 开关面板配置
376
- switchBrand: config.switchBrand || "symi", // 面板品牌(默认亖米)
377
+ switchBrand: config.switchBrand || "symi", // 面板品牌(默认亖米,支持clowire)
377
378
  buttonType: config.buttonType || "switch", // 按钮类型:switch=开关模式,scene=场景模式,mesh=Mesh模式
378
379
  switchId: parseInt(config.switchId) || 0, // 开关ID(0-255,物理面板地址)
379
380
  buttonNumber: parseInt(config.buttonNumber) || 1, // 按钮编号(1-8)
@@ -782,7 +783,13 @@ module.exports = function(RED) {
782
783
  return;
783
784
  }
784
785
 
785
- // 使用parseAllFrames处理粘包,解析所有帧
786
+ // 根据品牌选择协议解析
787
+ if (node.config.switchBrand === 'clowire') {
788
+ node.handleClowireData(data);
789
+ return;
790
+ }
791
+
792
+ // 亖米协议:使用parseAllFrames处理粘包,解析所有帧
786
793
  const frames = protocol.parseAllFrames(data);
787
794
  if (!frames || frames.length === 0) {
788
795
  return; // 静默忽略无效帧
@@ -864,6 +871,106 @@ module.exports = function(RED) {
864
871
  }
865
872
  };
866
873
 
874
+ // 处理Clowire协议数据
875
+ node.handleClowireData = function(data) {
876
+ try {
877
+ // 检查是否是Clowire协议帧
878
+ if (!clowireProtocol.isClowireFrame(data)) {
879
+ return; // 静默忽略非Clowire帧
880
+ }
881
+
882
+ // 解析所有帧
883
+ const frames = clowireProtocol.parseAllFrames(data);
884
+ if (!frames || frames.length === 0) {
885
+ return;
886
+ }
887
+
888
+ // 处理每一个帧
889
+ for (const frame of frames) {
890
+ // 检测按键或感应事件
891
+ const event = clowireProtocol.detectButtonEvent(frame);
892
+ if (!event) {
893
+ continue;
894
+ }
895
+
896
+ // 检查是否是我们监听的面板
897
+ if (event.panelAddr !== node.config.switchId) {
898
+ continue;
899
+ }
900
+
901
+ // 处理感应器事件(通道0x0F,buttonNumber=15)
902
+ if (event.type === 'sensor' && node.config.buttonNumber === 15) {
903
+ // 感应器模式:有人/无人状态,复用按键背光灯配置
904
+ const debounceKey = `clowire-sensor-${node.config.switchId}-${node.config.targetSlaveAddress}`;
905
+ const now = Date.now();
906
+ const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
907
+
908
+ // 感应器防抖:500ms内只触发一次
909
+ if (now - lastTriggerTime < 500) {
910
+ continue;
911
+ }
912
+ globalDebounceCache.set(debounceKey, now);
913
+
914
+ node.debug(`[Clowire感应] 面板${node.config.switchId} 感应状态: ${event.state ? '有人' : '无人'}`);
915
+
916
+ // 更新状态并发送命令
917
+ node.currentState = event.state;
918
+ node.sendMqttCommand(event.state);
919
+
920
+ // 输出消息
921
+ node.send({
922
+ payload: event.state,
923
+ topic: `clowire_sensor_${node.config.switchId}`,
924
+ switchId: node.config.switchId,
925
+ sensorType: 'presence',
926
+ targetSlave: node.config.targetSlaveAddress,
927
+ targetCoil: node.config.targetCoilNumber
928
+ });
929
+
930
+ continue;
931
+ }
932
+
933
+ // 处理按键事件
934
+ if (event.type === 'button' && event.buttonNumber === node.config.buttonNumber) {
935
+ // 全局防抖
936
+ const debounceKey = `clowire-${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
937
+ const now = Date.now();
938
+ const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
939
+
940
+ // 统一防抖时间200ms(单击、双击、长按都当作单击处理)
941
+ if (now - lastTriggerTime < 200) {
942
+ continue;
943
+ }
944
+ globalDebounceCache.set(debounceKey, now);
945
+
946
+ // 设置触发源
947
+ if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
948
+ node.serialPortConfig.setTriggerSource(node.config.switchId);
949
+ }
950
+
951
+ node.debug(`[Clowire按键] 面板${node.config.switchId} 按键${node.config.buttonNumber} ${event.eventType}(当作单击处理)`);
952
+
953
+ // 所有按键事件都当作单击处理:切换状态
954
+ node.currentState = !node.currentState;
955
+ node.sendMqttCommand(node.currentState);
956
+
957
+ // 输出消息
958
+ node.send({
959
+ payload: node.currentState,
960
+ topic: `clowire_switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
961
+ switchId: node.config.switchId,
962
+ button: node.config.buttonNumber,
963
+ eventType: 'single_click', // 统一报告为单击
964
+ targetSlave: node.config.targetSlaveAddress,
965
+ targetCoil: node.config.targetCoilNumber
966
+ });
967
+ }
968
+ }
969
+ } catch (err) {
970
+ node.error(`解析Clowire数据失败: ${err.message}`);
971
+ }
972
+ };
973
+
867
974
  // 处理Mesh协议数据
868
975
  node.handleMeshData = function(data) {
869
976
  try {
@@ -1157,6 +1264,34 @@ module.exports = function(RED) {
1157
1264
 
1158
1265
  const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
1159
1266
  node.debug(`[Mesh LED] 控制帧已构建:[${hexStr}]`);
1267
+ } else if (node.config.switchBrand === 'clowire') {
1268
+ // Clowire品牌:使用Clowire协议发送LED反馈
1269
+ const buttonNumber = node.config.buttonNumber;
1270
+
1271
+ // 防止重复发送
1272
+ if (node.lastSentLedState.value === state && (now - node.lastSentLedState.timestamp) < 50) {
1273
+ node.debug(`跳过重复LED反馈(状态未变化,间隔${now - node.lastSentLedState.timestamp}ms)`);
1274
+ return;
1275
+ }
1276
+
1277
+ node.lastSentLedState.value = state;
1278
+ node.lastSentLedState.timestamp = now;
1279
+
1280
+ node.debug(`[Clowire LED发送] 面板${node.config.switchId} 按钮${buttonNumber} = ${state}`);
1281
+
1282
+ command = clowireProtocol.buildLedControlCommand(
1283
+ node.config.switchId,
1284
+ buttonNumber,
1285
+ state
1286
+ );
1287
+
1288
+ if (!command) {
1289
+ node.error(`构建Clowire LED控制帧失败`);
1290
+ return;
1291
+ }
1292
+
1293
+ const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
1294
+ node.debug(`[Clowire LED] 控制帧已构建:[${hexStr}]`);
1160
1295
  } else {
1161
1296
  // RS-485模式:使用轻量级协议
1162
1297
  const deviceAddr = node.buttonDeviceAddr;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.9.7",
4
- "description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步,工控机长期稳定运行",
3
+ "version": "2.9.9",
4
+ "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持亖米/Clowire品牌),工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {
7
7
  "test": "echo \"Error: no test specified\" && exit 1"