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

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.
@@ -126,7 +126,12 @@ module.exports = function(RED) {
126
126
  });
127
127
 
128
128
  node.client.on('error', (err) => {
129
- node.error(`RS485 TCP错误: ${err.message}`);
129
+ // AggregateError特殊处理(Node.js 18+的IPv4/IPv6连接失败)
130
+ if (err.name === 'AggregateError' || err.errors) {
131
+ node.warn(`RS485 TCP连接失败: 无法连接到 ${node.host}:${node.port}`);
132
+ } else {
133
+ node.error(`RS485 TCP错误: ${err.message}`);
134
+ }
130
135
  node.emit('error', err);
131
136
  });
132
137
 
@@ -146,7 +151,12 @@ module.exports = function(RED) {
146
151
  }
147
152
  });
148
153
 
149
- node.client.connect(node.port, node.host);
154
+ // 使用try-catch包装connect,防止AggregateError导致崩溃
155
+ try {
156
+ node.client.connect(node.port, node.host);
157
+ } catch (connectErr) {
158
+ node.error(`RS485 TCP连接异常: ${connectErr.message}`);
159
+ }
150
160
  }
151
161
  } catch (err) {
152
162
  node.error(`RS485连接失败: ${err.message}`);
@@ -168,7 +178,7 @@ module.exports = function(RED) {
168
178
  node.connected = false;
169
179
  };
170
180
 
171
- // 处理接收数据(仅做帧解析,原始数据已在上层emit)
181
+ // 处理接收数据(支持Modbus RTU和杜亚协议)
172
182
  node.handleData = function(data) {
173
183
  node.receiveBuffer = Buffer.concat([node.receiveBuffer, data]);
174
184
 
@@ -178,8 +188,39 @@ module.exports = function(RED) {
178
188
  node.warn('接收缓冲区溢出,已截断');
179
189
  }
180
190
 
181
- // Modbus RTU最小帧长度为4字节
191
+ // 最小帧长度为4字节
182
192
  while (node.receiveBuffer.length >= 4) {
193
+ // ===== 杜亚协议检测 (55开头) =====
194
+ if (node.receiveBuffer[0] === 0x55 && node.receiveBuffer.length >= 7) {
195
+ // 杜亚窗帘协议:55 [地址高] [地址低] 03 [数据] [CRC16低] [CRC16高]
196
+ // 固定7字节(基本命令)或8字节(带百分比)
197
+ const funcCode = node.receiveBuffer[3];
198
+ let duyaLen = 7;
199
+ if (funcCode === 0x03 && node.receiveBuffer.length >= 7) {
200
+ const dataType = node.receiveBuffer[4];
201
+ if (dataType === 0x04) {
202
+ duyaLen = 8; // 百分比命令多一个字节
203
+ }
204
+ }
205
+
206
+ if (node.receiveBuffer.length >= duyaLen) {
207
+ const frame = node.receiveBuffer.subarray(0, duyaLen);
208
+ node.receiveBuffer = node.receiveBuffer.subarray(duyaLen);
209
+
210
+ // 验证杜亚CRC16
211
+ if (node.validateDuyaCRC(frame)) {
212
+ node.emit('frame', frame);
213
+ } else {
214
+ node.debug(`杜亚CRC校验失败: ${frame.toString('hex').toUpperCase()}`);
215
+ // CRC失败也尝试emit,让上层处理
216
+ node.emit('frame', frame);
217
+ }
218
+ continue;
219
+ }
220
+ break;
221
+ }
222
+
223
+ // ===== 标准Modbus RTU协议 =====
183
224
  const frameLen = node.getFrameLength(node.receiveBuffer);
184
225
  if (frameLen === 0 || node.receiveBuffer.length < frameLen) break;
185
226
 
@@ -191,6 +232,15 @@ module.exports = function(RED) {
191
232
  }
192
233
  }
193
234
  };
235
+
236
+ // 验证杜亚CRC16
237
+ node.validateDuyaCRC = function(frame) {
238
+ if (frame.length < 7) return false;
239
+ const data = frame.subarray(0, frame.length - 2);
240
+ const receivedCRC = frame.readUInt16LE(frame.length - 2);
241
+ const calculatedCRC = node.calculateCRC16(data);
242
+ return receivedCRC === calculatedCRC;
243
+ };
194
244
 
195
245
  // 获取帧长度
196
246
  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 :
@@ -7,6 +7,34 @@ const SerialClient = require('../lib/serial-client');
7
7
  const { DeviceManager } = require('../lib/device-manager');
8
8
  const { ProtocolHandler, parseStatusEvent, OP_RESP_DEVICE_LIST } = require('../lib/protocol');
9
9
 
10
+ // 全局异常处理 - 防止AggregateError等网络错误导致Node-RED崩溃
11
+ // 只在模块首次加载时设置一次
12
+ if (!global._symiGatewayErrorHandlerSet) {
13
+ global._symiGatewayErrorHandlerSet = true;
14
+
15
+ process.on('uncaughtException', (err) => {
16
+ // 只处理网络相关的错误,其他错误继续抛出
17
+ if (err.name === 'AggregateError' ||
18
+ (err.code && ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH'].includes(err.code))) {
19
+ console.error('[symi-gateway] 网络异常已捕获,避免崩溃:', err.message || err);
20
+ // 不重新抛出,防止崩溃
21
+ } else {
22
+ // 非网络错误,继续原有行为
23
+ throw err;
24
+ }
25
+ });
26
+
27
+ process.on('unhandledRejection', (reason, promise) => {
28
+ // 检查是否是网络相关的Promise rejection
29
+ if (reason && (reason.name === 'AggregateError' ||
30
+ (reason.code && ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH'].includes(reason.code)))) {
31
+ console.error('[symi-gateway] 网络Promise rejection已捕获:', reason.message || reason);
32
+ // 不重新抛出,防止崩溃
33
+ }
34
+ // 其他rejection由Node-RED默认处理
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
  };
@@ -258,7 +258,7 @@ module.exports = function(RED) {
258
258
  SymiMQTTNode.prototype.publishAllDiscovery = function(devices, forceUpdate = false) {
259
259
  const node = this;
260
260
 
261
- const supportedTypes = [1, 2, 3, 4, 5, 8, 9, 10, 0x18, 39];
261
+ const supportedTypes = [1, 2, 3, 4, 5, 8, 9, 10, 11, 0x18, 39];
262
262
 
263
263
  devices.forEach(device => {
264
264
  const macClean = device.macAddress.replace(/:/g, '').toLowerCase();
@@ -641,23 +641,24 @@ module.exports = function(RED) {
641
641
 
642
642
  case 0x05:
643
643
  // 窗帘运行状态反馈 (CURT_RUN_STATUS)
644
- // 协议:0=空闲/到头, 1=打开中, 2=关闭中, 3=停止
645
- if (state.curtainStatus !== undefined) {
646
- const curtainStates = { 0: 'stopped', 1: 'opening', 2: 'closing', 3: 'stopped' };
647
- const curtainState = curtainStates[state.curtainStatus] || 'stopped';
644
+ // 【兼容两种协议】:
645
+ // 米家协议: 0=打开中, 1=关闭中, 2=停止
646
+ // 小程序协议: 1=打开, 2=关闭, 3=停止
647
+ // 使用curtainAction(由device-manager解析)来发布状态
648
+ if (state.curtainAction !== undefined) {
648
649
  publishes.push({
649
650
  topic: `symi_mesh/${macClean}/cover/state`,
650
- payload: curtainState
651
+ payload: state.curtainAction
651
652
  });
652
- node.debug(`发布窗帘运行状态: ${curtainState} (原始值=${state.curtainStatus})`);
653
+ node.debug(`发布窗帘运行状态: ${state.curtainAction} (原始值=${state.curtainStatus}, 协议=${state.curtainProtocol || 'unknown'})`);
653
654
 
654
- // 到头(status=0)时也发布position,确保HA显示正确位置
655
- if (state.curtainStatus === 0 && state.curtainPosition !== undefined) {
655
+ // 停止时也发布position,确保HA显示正确位置
656
+ if (state.curtainAction === 'stopped' && state.curtainPosition !== undefined) {
656
657
  publishes.push({
657
658
  topic: `symi_mesh/${macClean}/cover/position`,
658
659
  payload: state.curtainPosition.toString()
659
660
  });
660
- node.debug(`窗帘到头,同时发布位置: ${state.curtainPosition}%`);
661
+ node.debug(`窗帘停止,同时发布位置: ${state.curtainPosition}%`);
661
662
  }
662
663
  }
663
664
  break;
@@ -672,14 +673,13 @@ module.exports = function(RED) {
672
673
  node.debug(`发布窗帘位置: ${state.curtainPosition}%`);
673
674
 
674
675
  // 同时发布运行状态(确保HA正确显示)
675
- if (state.curtainStatus !== undefined) {
676
- const curtainStates = { 0: 'stopped', 1: 'opening', 2: 'closing', 3: 'stopped' };
677
- const curtainState = curtainStates[state.curtainStatus] || 'stopped';
676
+ // 使用curtainAction(由device-manager解析,兼容米家和小程序协议)
677
+ if (state.curtainAction !== undefined) {
678
678
  publishes.push({
679
679
  topic: `symi_mesh/${macClean}/cover/state`,
680
- payload: curtainState
680
+ payload: state.curtainAction
681
681
  });
682
- node.debug(`同时发布窗帘状态: ${curtainState}`);
682
+ node.debug(`同时发布窗帘状态: ${state.curtainAction}`);
683
683
  }
684
684
  }
685
685
  break;
@@ -1312,6 +1312,13 @@ module.exports = function(RED) {
1312
1312
  const action = actions[payload];
1313
1313
  if (action) {
1314
1314
  commands.push({ attrType: 0x05, param: Buffer.from([action]) });
1315
+
1316
+ // 发出窗帘控制事件,通知485桥用户的真实意图
1317
+ this.emit('curtain-control', {
1318
+ mac: device.macAddress,
1319
+ action: payload // 'OPEN', 'CLOSE', 'STOP'
1320
+ });
1321
+ this.debug(`[MQTT] 窗帘控制命令: ${payload}, MAC=${device.macAddress}`);
1315
1322
  }
1316
1323
 
1317
1324
  } else if (topic.includes('/position/set')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.6.5",
3
+ "version": "1.6.6",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "axios": "^1.7.9",
42
- "mqtt": "^5.3.0",
42
+ "mqtt": "^5.10.0",
43
43
  "serialport": "^12.0.0"
44
44
  },
45
45
  "engines": {