node-red-contrib-symi-mesh 1.6.5 → 1.6.7

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.
@@ -6,6 +6,37 @@
6
6
  const { SerialPort } = require('serialport');
7
7
  const net = require('net');
8
8
 
9
+ // 全局禁用 Happy Eyeballs 算法,防止 AggregateError 导致 Node-RED 崩溃
10
+ // 这会影响所有使用 net.Socket 的模块(包括第三方模块如 KNX Ultimate)
11
+ if (typeof net.setDefaultAutoSelectFamily === 'function') {
12
+ net.setDefaultAutoSelectFamily(false);
13
+ }
14
+
15
+ // 全局未捕获异常处理 - 防止网络错误导致 Node-RED 崩溃
16
+ if (!global._symiErrorHandlerInstalled) {
17
+ global._symiErrorHandlerInstalled = true;
18
+
19
+ process.on('uncaughtException', (err) => {
20
+ // 网络相关错误不崩溃
21
+ const netErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH', 'ENOTFOUND'];
22
+ if (err && err.code && netErrors.includes(err.code)) {
23
+ console.error('[symi] 网络错误已捕获,继续运行:', err.message);
24
+ return; // 不崩溃
25
+ }
26
+ // AggregateError
27
+ if (err && (err.name === 'AggregateError' || (err.errors && Array.isArray(err.errors)))) {
28
+ console.error('[symi] AggregateError已捕获:', err.message);
29
+ return; // 不崩溃
30
+ }
31
+ // 其他错误打印但不崩溃(保护Node-RED)
32
+ console.error('[symi] 未捕获异常:', err);
33
+ });
34
+
35
+ process.on('unhandledRejection', (reason) => {
36
+ console.error('[symi] Promise rejection:', reason?.message || reason);
37
+ });
38
+ }
39
+
9
40
  module.exports = function(RED) {
10
41
 
11
42
  function SymiRS485ConfigNode(config) {
@@ -114,7 +145,7 @@ module.exports = function(RED) {
114
145
 
115
146
  node.client.on('connect', () => {
116
147
  node.connected = true;
117
- node.warn(`[RS485] TCP已连接: ${node.host}:${node.port}`);
148
+ node.log(`[RS485] TCP已连接: ${node.host}:${node.port}`);
118
149
  node.emit('connected');
119
150
  });
120
151
 
@@ -126,7 +157,12 @@ module.exports = function(RED) {
126
157
  });
127
158
 
128
159
  node.client.on('error', (err) => {
129
- node.error(`RS485 TCP错误: ${err.message}`);
160
+ // AggregateError特殊处理(Node.js 18+的IPv4/IPv6连接失败)
161
+ if (err.name === 'AggregateError' || err.errors) {
162
+ node.warn(`RS485 TCP连接失败: 无法连接到 ${node.host}:${node.port}`);
163
+ } else {
164
+ node.error(`RS485 TCP错误: ${err.message}`);
165
+ }
130
166
  node.emit('error', err);
131
167
  });
132
168
 
@@ -146,7 +182,16 @@ module.exports = function(RED) {
146
182
  }
147
183
  });
148
184
 
149
- node.client.connect(node.port, node.host);
185
+ // 使用family:4强制IPv4,避免Node.js 18+ Happy Eyeballs导致AggregateError
186
+ try {
187
+ node.client.connect({
188
+ port: node.port,
189
+ host: node.host,
190
+ family: 4 // 强制IPv4,避免IPv6连接失败导致AggregateError
191
+ });
192
+ } catch (connectErr) {
193
+ node.error(`RS485 TCP连接异常: ${connectErr.message}`);
194
+ }
150
195
  }
151
196
  } catch (err) {
152
197
  node.error(`RS485连接失败: ${err.message}`);
@@ -168,7 +213,7 @@ module.exports = function(RED) {
168
213
  node.connected = false;
169
214
  };
170
215
 
171
- // 处理接收数据(仅做帧解析,原始数据已在上层emit)
216
+ // 处理接收数据(支持Modbus RTU和杜亚协议)
172
217
  node.handleData = function(data) {
173
218
  node.receiveBuffer = Buffer.concat([node.receiveBuffer, data]);
174
219
 
@@ -178,8 +223,39 @@ module.exports = function(RED) {
178
223
  node.warn('接收缓冲区溢出,已截断');
179
224
  }
180
225
 
181
- // Modbus RTU最小帧长度为4字节
226
+ // 最小帧长度为4字节
182
227
  while (node.receiveBuffer.length >= 4) {
228
+ // ===== 杜亚协议检测 (55开头) =====
229
+ if (node.receiveBuffer[0] === 0x55 && node.receiveBuffer.length >= 7) {
230
+ // 杜亚窗帘协议:55 [地址高] [地址低] 03 [数据] [CRC16低] [CRC16高]
231
+ // 固定7字节(基本命令)或8字节(带百分比)
232
+ const funcCode = node.receiveBuffer[3];
233
+ let duyaLen = 7;
234
+ if (funcCode === 0x03 && node.receiveBuffer.length >= 7) {
235
+ const dataType = node.receiveBuffer[4];
236
+ if (dataType === 0x04) {
237
+ duyaLen = 8; // 百分比命令多一个字节
238
+ }
239
+ }
240
+
241
+ if (node.receiveBuffer.length >= duyaLen) {
242
+ const frame = node.receiveBuffer.subarray(0, duyaLen);
243
+ node.receiveBuffer = node.receiveBuffer.subarray(duyaLen);
244
+
245
+ // 验证杜亚CRC16
246
+ if (node.validateDuyaCRC(frame)) {
247
+ node.emit('frame', frame);
248
+ } else {
249
+ node.debug(`杜亚CRC校验失败: ${frame.toString('hex').toUpperCase()}`);
250
+ // CRC失败也尝试emit,让上层处理
251
+ node.emit('frame', frame);
252
+ }
253
+ continue;
254
+ }
255
+ break;
256
+ }
257
+
258
+ // ===== 标准Modbus RTU协议 =====
183
259
  const frameLen = node.getFrameLength(node.receiveBuffer);
184
260
  if (frameLen === 0 || node.receiveBuffer.length < frameLen) break;
185
261
 
@@ -191,6 +267,15 @@ module.exports = function(RED) {
191
267
  }
192
268
  }
193
269
  };
270
+
271
+ // 验证杜亚CRC16
272
+ node.validateDuyaCRC = function(frame) {
273
+ if (frame.length < 7) return false;
274
+ const data = frame.subarray(0, frame.length - 2);
275
+ const receivedCRC = frame.readUInt16LE(frame.length - 2);
276
+ const calculatedCRC = node.calculateCRC16(data);
277
+ return receivedCRC === calculatedCRC;
278
+ };
194
279
 
195
280
  // 获取帧长度
196
281
  node.getFrameLength = function(buffer) {
@@ -171,13 +171,13 @@ module.exports = function(RED) {
171
171
  command = { attrType: 0x04, param: Buffer.from([colorTemp]) };
172
172
 
173
173
  } else if (msg.command === 'rgb' && typeof payload === 'object') {
174
- // 五色调光:RGB+WW+CW,参数范围0-100(百分比)
175
- const r = Math.max(0, Math.min(100, parseInt(payload.r || 0)));
176
- const g = Math.max(0, Math.min(100, parseInt(payload.g || 0)));
177
- const b = Math.max(0, Math.min(100, parseInt(payload.b || 0)));
178
- const ww = Math.max(0, Math.min(100, parseInt(payload.ww || 0)));
179
- const cw = Math.max(0, Math.min(100, parseInt(payload.cw || 0)));
180
- command = { attrType: 0x11, param: Buffer.from([r, g, b, ww, cw]) };
174
+ // 五色调光:RGB+WW+CW,参数范围0-255
175
+ const r = Math.max(0, Math.min(255, parseInt(payload.r || 0)));
176
+ const g = Math.max(0, Math.min(255, parseInt(payload.g || 0)));
177
+ const b = Math.max(0, Math.min(255, parseInt(payload.b || 0)));
178
+ const ww = Math.max(0, Math.min(255, parseInt(payload.ww || 0)));
179
+ const cw = Math.max(0, Math.min(255, parseInt(payload.cw || 0)));
180
+ command = { attrType: 0x4C, param: Buffer.from([r, g, b, ww, cw]) };
181
181
 
182
182
  } else if (typeof payload === 'boolean' || typeof payload === 'string') {
183
183
  const value = typeof payload === 'boolean' ? payload :
@@ -2,11 +2,39 @@
2
2
  * Symi Gateway Configuration Node
3
3
  */
4
4
 
5
+ const net = require('net');
5
6
  const TCPClient = require('../lib/tcp-client');
6
7
  const SerialClient = require('../lib/serial-client');
7
8
  const { DeviceManager } = require('../lib/device-manager');
8
9
  const { ProtocolHandler, parseStatusEvent, OP_RESP_DEVICE_LIST } = require('../lib/protocol');
9
10
 
11
+ // 全局禁用 Happy Eyeballs 算法
12
+ if (typeof net.setDefaultAutoSelectFamily === 'function') {
13
+ net.setDefaultAutoSelectFamily(false);
14
+ }
15
+
16
+ // 全局未捕获异常处理 - 防止网络错误导致 Node-RED 崩溃
17
+ if (!global._symiErrorHandlerInstalled) {
18
+ global._symiErrorHandlerInstalled = true;
19
+
20
+ process.on('uncaughtException', (err) => {
21
+ const netErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH', 'ENOTFOUND', 'EADDRNOTAVAIL'];
22
+ if (err && err.code && netErrors.includes(err.code)) {
23
+ console.error('[symi] 网络错误已捕获,继续运行:', err.message);
24
+ return;
25
+ }
26
+ if (err && (err.name === 'AggregateError' || (err.errors && Array.isArray(err.errors)))) {
27
+ console.error('[symi] AggregateError已捕获:', err.message);
28
+ return;
29
+ }
30
+ console.error('[symi] 未捕获异常:', err);
31
+ });
32
+
33
+ process.on('unhandledRejection', (reason) => {
34
+ console.error('[symi] Promise rejection:', reason?.message || reason);
35
+ });
36
+ }
37
+
10
38
  module.exports = function(RED) {
11
39
  function SymiGatewayNode(config) {
12
40
  RED.nodes.createNode(this, config);
@@ -126,17 +154,22 @@ module.exports = function(RED) {
126
154
  this.parseDeviceListFrame(frame);
127
155
  } else if (frame.isDeviceStatusEvent()) {
128
156
  // 将状态事件加入队列处理,限制队列大小防止内存泄漏
157
+ // 同时记录接收时间,用于判断是否在0xB0之后
158
+ const frameWithTime = { frame, receiveTime: Date.now() };
129
159
  if (this.stateEventQueue.length < this.maxQueueSize) {
130
- this.stateEventQueue.push(frame);
160
+ this.stateEventQueue.push(frameWithTime);
131
161
  this.processStateEventQueue();
132
162
  } else {
133
163
  this.debug(`状态事件队列已满(${this.maxQueueSize}),丢弃旧事件`);
134
164
  this.stateEventQueue.shift(); // 移除最旧的事件
135
- this.stateEventQueue.push(frame);
165
+ this.stateEventQueue.push(frameWithTime);
136
166
  }
137
167
  } else if (frame.opcode === 0xB0) {
138
- // 控制命令响应
139
- this.debug(`[控制响应] 0xB0: ${frameHex} ${frame.status === 0 ? '(成功)' : '(失败)'}`)
168
+ // 控制命令响应 - 记录时间戳,用于标记后续帧为用户控制
169
+ // 规则:53 B0 后面紧跟的第一个窗帘帧就是真实用户控制
170
+ // 注意:必须用时间戳而不是标记,因为队列中可能有旧帧等待处理
171
+ this.lastB0Time = Date.now();
172
+ this.log(`[控制响应] 0xB0: ${frameHex},记录时间戳`)
140
173
  } else if (frame.opcode === 0xB4) {
141
174
  // 场景控制响应
142
175
  if (frame.status === 0) {
@@ -168,7 +201,9 @@ module.exports = function(RED) {
168
201
  this.isProcessingStateEvent = true;
169
202
 
170
203
  while (this.stateEventQueue.length > 0) {
171
- const frame = this.stateEventQueue.shift();
204
+ const frameWithTime = this.stateEventQueue.shift();
205
+ const frame = frameWithTime.frame;
206
+ const receiveTime = frameWithTime.receiveTime;
172
207
 
173
208
  try {
174
209
  const event = parseStatusEvent(frame);
@@ -233,12 +268,44 @@ module.exports = function(RED) {
233
268
  // 只在非查询状态期间才发送state-changed事件到MQTT
234
269
  // 查询状态期间的事件只用于更新设备状态,不触发MQTT发布
235
270
  if (device && !this.isQueryingStates) {
271
+ // 【兼容两种协议】判断用户控制:
272
+ // 小程序协议: subOpcode=0x05 (NODE_ACK) = 设备确认执行命令
273
+ // 米家协议: subOpcode=0x06 (NODE_STATUS) + status=0/1 = 用户操作
274
+ //
275
+ // 米家协议特征:status=0(打开中)或status=1(关闭中)只会在用户操作时出现
276
+ // 电机反馈通常是status=2(停止)或位置变化(attrType=0x06)
277
+ const isCurtainEvent = (event.attrType === 0x05 || event.attrType === 0x06);
278
+ let isUserControl = false;
279
+
280
+ if (isCurtainEvent) {
281
+ if (event.subOpcode === 0x05) {
282
+ // NODE_ACK: 小程序协议,设备确认执行命令
283
+ isUserControl = true;
284
+ } else if (event.subOpcode === 0x06) {
285
+ // NODE_STATUS: 米家协议
286
+ if (event.attrType === 0x05) {
287
+ // CURT_RUN_STATUS: 状态变化
288
+ // status=0(打开中), status=1(关闭中), status=2(暂停) 都是用户控制
289
+ const status = event.parameters && event.parameters.length > 0 ? event.parameters[0] : null;
290
+ if (status === 0 || status === 1 || status === 2) {
291
+ isUserControl = true;
292
+ }
293
+ } else if (event.attrType === 0x06) {
294
+ // CURT_RUN_PER_POS: 位置变化
295
+ // 位置变化也需要同步,标记为用户控制
296
+ isUserControl = true;
297
+ }
298
+ }
299
+ }
300
+
236
301
  this.emit('device-state-changed', {
237
302
  device: device,
238
303
  attrType: event.attrType,
239
304
  parameters: event.parameters,
240
305
  state: device.state,
241
- isSceneExecution: this.sceneExecutionInProgress // 标记是否是场景执行导致的状态变化
306
+ subOpcode: event.subOpcode,
307
+ isUserControl: isUserControl, // 0xB0后500ms内的第一个窗帘帧
308
+ isSceneExecution: this.sceneExecutionInProgress
242
309
  });
243
310
  }
244
311
 
@@ -299,14 +366,19 @@ module.exports = function(RED) {
299
366
 
300
367
  // 在后台异步查询设备状态和确认三合一设备
301
368
  setTimeout(async () => {
302
- this.log('开始后台查询设备状态...');
303
- this.isQueryingStates = true;
304
- await this.queryAllDeviceStates();
305
- this.isQueryingStates = false;
306
- this.log('后台状态查询完成');
307
-
308
- // 状态查询完成后,如果有设备类型变化(如三合一确认),重新发布MQTT Discovery
309
- this.emit('device-states-synced', this.deviceManager.getAllDevices());
369
+ try {
370
+ this.log('开始后台查询设备状态...');
371
+ this.isQueryingStates = true;
372
+ await this.queryAllDeviceStates();
373
+ this.isQueryingStates = false;
374
+ this.log('后台状态查询完成');
375
+
376
+ // 状态查询完成后,如果有设备类型变化(如三合一确认),重新发布MQTT Discovery
377
+ this.emit('device-states-synced', this.deviceManager.getAllDevices());
378
+ } catch (err) {
379
+ this.isQueryingStates = false;
380
+ this.error(`后台查询设备状态失败: ${err.message}`);
381
+ }
310
382
  }, 500);
311
383
  }
312
384
  };