node-red-contrib-symi-mesh 1.3.1 → 1.6.1

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.
@@ -37,7 +37,7 @@ class DeviceInfo {
37
37
  }
38
38
 
39
39
  getChannelCount() {
40
- if ([1, 2, 3, 9, 12].includes(this.deviceType)) {
40
+ if ([1, 2, 3, 9, 12, 39].includes(this.deviceType)) {
41
41
  return this.deviceSubType;
42
42
  }
43
43
  return 1;
@@ -48,12 +48,12 @@ class DeviceInfo {
48
48
  if (this.isThreeInOne) {
49
49
  return 'three_in_one';
50
50
  }
51
-
51
+
52
52
  const typeMap = {
53
53
  1: 'switch', 2: 'switch', 3: 'switch', 4: 'light',
54
54
  5: 'cover', 6: 'scene', 7: 'binary_sensor', 8: 'binary_sensor',
55
55
  9: 'switch', 10: 'climate', 11: 'sensor', 12: 'switch',
56
- 0x14: 'scene', 0x18: 'light'
56
+ 0x14: 'scene', 0x18: 'light', 39: 'switch'
57
57
  };
58
58
  return typeMap[this.deviceType] || 'switch';
59
59
  }
@@ -83,9 +83,9 @@ class DeviceInfo {
83
83
  }
84
84
  break;
85
85
  case 0x45:
86
- // 6键开关状态上报(2字节,小端序)
86
+ // 6-8路开关状态上报(2字节,小端序)
87
87
  // 用于场景执行后的状态同步
88
- if (this.channels === 6) {
88
+ if (this.channels >= 6) {
89
89
  this.handleSwitchState(parameters);
90
90
  }
91
91
  break;
@@ -349,11 +349,17 @@ class DeviceInfo {
349
349
 
350
350
  handleSwitchState(parameters) {
351
351
  if (parameters.length === 0) return;
352
-
352
+
353
353
  // 根据协议:TYPE_ON_OFF,每2位表示1路开关,b01=关,b10=开
354
354
  // 1-4路开关:1字节
355
355
  // 5-6路开关:2字节,小端序
356
-
356
+
357
+ // 保存旧状态用于检测变化
358
+ const oldStates = {};
359
+ for (let i = 1; i <= this.channels; i++) {
360
+ oldStates[i] = this.state[`switch_${i}`];
361
+ }
362
+
357
363
  if (this.channels === 1) {
358
364
  // 单路开关
359
365
  const value = parameters[0];
@@ -372,8 +378,8 @@ class DeviceInfo {
372
378
  this.state[`switch_${i + 1}`] = true;
373
379
  }
374
380
  }
375
- } else if (this.channels === 6) {
376
- // 6路开关:2字节,小端序
381
+ } else if (this.channels === 6 || this.channels === 8) {
382
+ // 6路或8路开关:2字节,小端序
377
383
  let value;
378
384
  if (parameters.length >= 2) {
379
385
  // 小端序:低字节在前
@@ -382,12 +388,12 @@ class DeviceInfo {
382
388
  // 兼容1字节情况,只处理前4路
383
389
  value = parameters[0];
384
390
  }
385
-
391
+
386
392
  // 保存原始状态值供控制时使用
387
393
  this.state.switchState = value;
388
-
389
- // 处理所有6路
390
- for (let i = 0; i < 6; i++) {
394
+
395
+ // 处理所有路数
396
+ for (let i = 0; i < this.channels; i++) {
391
397
  const bitPos = i * 2;
392
398
  const bitValue = (value >> bitPos) & 0x03;
393
399
  if (bitValue === 0x01) {
@@ -397,6 +403,60 @@ class DeviceInfo {
397
403
  }
398
404
  }
399
405
  }
406
+
407
+ // 检测哪些按键状态发生了变化
408
+ const changedButtons = [];
409
+ for (let i = 1; i <= this.channels; i++) {
410
+ const oldState = oldStates[i];
411
+ const newState = this.state[`switch_${i}`];
412
+ if (oldState !== undefined && oldState !== newState) {
413
+ changedButtons.push({
414
+ button: i,
415
+ oldState: oldState,
416
+ newState: newState
417
+ });
418
+ }
419
+ }
420
+
421
+ // 存储变化的按键信息,供外部使用
422
+ this.lastChangedButtons = changedButtons;
423
+
424
+ return changedButtons;
425
+ }
426
+
427
+ // 获取按键绑定的场景ID
428
+ getButtonSceneId(buttonIndex) {
429
+ if (!this.subDeviceConfigs || buttonIndex < 1 || buttonIndex > this.subDeviceConfigs.length) {
430
+ return null;
431
+ }
432
+
433
+ const config = this.subDeviceConfigs[buttonIndex - 1];
434
+ if (!config) return null;
435
+
436
+ // 场景按键:直接返回scene_id
437
+ if (config.sub_type === '场景' && config.scene_id) {
438
+ return {
439
+ type: 'scene',
440
+ sceneId: config.scene_id,
441
+ name: config.sub_name
442
+ };
443
+ }
444
+
445
+ // 双控/总控按键:根据当前状态返回对应的场景ID
446
+ if ((config.sub_type === '双控' || config.sub_type === '总控')) {
447
+ const currentState = this.state[`switch_${buttonIndex}`];
448
+ const sceneId = currentState ? config.on_scene_id : config.off_scene_id;
449
+ if (sceneId) {
450
+ return {
451
+ type: config.sub_type === '双控' ? 'dual_control' : 'master_control',
452
+ sceneId: sceneId,
453
+ name: config.sub_name,
454
+ state: currentState ? 'on' : 'off'
455
+ };
456
+ }
457
+ }
458
+
459
+ return null;
400
460
  }
401
461
 
402
462
  // 获取当前开关状态值(用于控制时组合状态)
@@ -412,9 +472,9 @@ class DeviceInfo {
412
472
  stateValue |= (bitValue << bitPos);
413
473
  }
414
474
  return stateValue;
415
- } else if (this.channels === 6) {
475
+ } else if (this.channels === 6 || this.channels === 8) {
416
476
  let stateValue = 0;
417
- for (let i = 0; i < 6; i++) {
477
+ for (let i = 0; i < this.channels; i++) {
418
478
  const bitPos = i * 2;
419
479
  const channelState = this.state[`switch_${i + 1}`];
420
480
  const bitValue = (channelState === true) ? 0x02 : 0x01;
@@ -427,10 +487,11 @@ class DeviceInfo {
427
487
  }
428
488
 
429
489
  class DeviceManager extends EventEmitter {
430
- constructor(context, logger = console) {
490
+ constructor(context, logger = console, gatewayId = 'default') {
431
491
  super();
432
492
  this.context = context;
433
493
  this.logger = logger;
494
+ this.gatewayId = gatewayId;
434
495
  this.devices = new Map();
435
496
  this.macToAddress = new Map();
436
497
  this.addressToMac = new Map();
@@ -439,9 +500,9 @@ class DeviceManager extends EventEmitter {
439
500
 
440
501
  loadDevices() {
441
502
  try {
442
- const saved = this.context.get('symi_devices') || {};
443
- const macMap = this.context.get('symi_mac_map') || {};
444
- const addrMap = this.context.get('symi_addr_map') || {};
503
+ const saved = this.context.get(`symi_devices_${this.gatewayId}`) || {};
504
+ const macMap = this.context.get(`symi_mac_map_${this.gatewayId}`) || {};
505
+ const addrMap = this.context.get(`symi_addr_map_${this.gatewayId}`) || {};
445
506
 
446
507
  Object.entries(saved).forEach(([mac, data]) => {
447
508
  const device = new DeviceInfo(data);
@@ -449,6 +510,17 @@ class DeviceManager extends EventEmitter {
449
510
  if (data.isThreeInOne) {
450
511
  device.isThreeInOne = true;
451
512
  }
513
+ // 恢复温控器确认标记
514
+ if (data.thermostatConfirmed) {
515
+ device.thermostatConfirmed = true;
516
+ }
517
+ // 恢复按键名称和配置
518
+ if (data.subDeviceNames) {
519
+ device.subDeviceNames = data.subDeviceNames;
520
+ }
521
+ if (data.subDeviceConfigs) {
522
+ device.subDeviceConfigs = data.subDeviceConfigs;
523
+ }
452
524
  this.devices.set(mac, device);
453
525
  });
454
526
 
@@ -474,7 +546,8 @@ class DeviceManager extends EventEmitter {
474
546
  name: device.name,
475
547
  channels: device.channels,
476
548
  online: device.online,
477
- isThreeInOne: device.isThreeInOne || false
549
+ isThreeInOne: device.isThreeInOne || false,
550
+ thermostatConfirmed: device.thermostatConfirmed || false
478
551
  };
479
552
  });
480
553
 
@@ -483,9 +556,9 @@ class DeviceManager extends EventEmitter {
483
556
  Array.from(this.addressToMac.entries()).map(([k, v]) => [k.toString(), v])
484
557
  );
485
558
 
486
- this.context.set('symi_devices', devicesObj);
487
- this.context.set('symi_mac_map', macMapObj);
488
- this.context.set('symi_addr_map', addrMapObj);
559
+ this.context.set(`symi_devices_${this.gatewayId}`, devicesObj);
560
+ this.context.set(`symi_mac_map_${this.gatewayId}`, macMapObj);
561
+ this.context.set(`symi_addr_map_${this.gatewayId}`, addrMapObj);
489
562
  } catch (error) {
490
563
  this.logger.error('Error saving devices:', error);
491
564
  }
@@ -576,6 +649,19 @@ class DeviceManager extends EventEmitter {
576
649
  this.context.set('symi_mac_map', {});
577
650
  this.context.set('symi_addr_map', {});
578
651
  }
652
+
653
+ // 清理资源,防止内存泄漏
654
+ cleanup() {
655
+ // 移除所有事件监听器
656
+ this.removeAllListeners();
657
+
658
+ // 清理设备对象中的定时器和监听器
659
+ for (const device of this.devices.values()) {
660
+ if (device.removeAllListeners) {
661
+ device.removeAllListeners();
662
+ }
663
+ }
664
+ }
579
665
  }
580
666
 
581
667
  module.exports = { DeviceManager, DeviceInfo, DEVICE_TYPE_NAMES };
@@ -29,7 +29,7 @@ const DEVICE_CLASSES = {
29
29
  11: 'temperature' // 温湿度传感器(主要传感器)
30
30
  };
31
31
 
32
- function generateDiscoveryConfig(device, mqttPrefix = 'homeassistant') {
32
+ function generateDiscoveryConfig(device, mqttPrefix = 'homeassistant', logger = console) {
33
33
  const macClean = device.macAddress.replace(/:/g, '').toLowerCase();
34
34
  const entityType = device.getEntityType();
35
35
  const configs = [];
@@ -94,17 +94,22 @@ function generateDiscoveryConfig(device, mqttPrefix = 'homeassistant') {
94
94
  let channelName = '';
95
95
  if (device.subDeviceNames && device.subDeviceNames[i - 1]) {
96
96
  channelName = ` ${device.subDeviceNames[i - 1]}`;
97
+ logger.debug(`[Discovery] ${device.name} 第${i}路使用云端名称: ${device.subDeviceNames[i - 1]}`);
97
98
  } else if (device.channels > 1) {
98
99
  channelName = ` 第${i}路`;
100
+ logger.debug(`[Discovery] ${device.name} 第${i}路使用默认名称`);
99
101
  }
100
102
 
101
103
  const stateTopic = device.channels === 1 ? `symi_mesh/${macClean}/switch/state` : `symi_mesh/${macClean}/switch_${i}/state`;
102
104
  const commandTopic = device.channels === 1 ? `symi_mesh/${macClean}/switch/set` : `symi_mesh/${macClean}/switch_${i}/set`;
103
105
 
106
+ const entityName = `${device.name}${channelName}`;
107
+ logger.debug(`[Discovery配置] ${device.name} 第${i}路: name="${entityName}", state_topic="${stateTopic}"`);
108
+
104
109
  configs.push({
105
110
  topic: `${mqttPrefix}/switch/${objectId}/config`,
106
111
  payload: JSON.stringify({
107
- name: `${device.name}${channelName}`,
112
+ name: entityName,
108
113
  unique_id: objectId,
109
114
  state_topic: stateTopic,
110
115
  command_topic: commandTopic,
@@ -657,9 +662,25 @@ function convertStateValue(entityType, attrType, value, deviceType = null) {
657
662
  }
658
663
  }
659
664
 
665
+ /**
666
+ * 将字符串转换为安全的MQTT topic ID(仅保留ASCII字母、数字、下划线、连字符)
667
+ * 中文字符会被转换为URL编码格式
668
+ */
669
+ function sanitizeTopicId(str) {
670
+ if (!str) return 'unknown';
671
+
672
+ // 将字符串转换为URL编码,然后替换%为_,确保只包含安全字符
673
+ return encodeURIComponent(String(str))
674
+ .replace(/%/g, '_')
675
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
676
+ .toLowerCase();
677
+ }
678
+
660
679
  function generateSceneButtonConfig(scene, roomNo, mqttPrefix = 'homeassistant') {
661
680
  const sceneId = `scene_${scene.scene_id}`;
662
- const objectId = `symi_room_${roomNo}_${sceneId}`;
681
+ // 将roomNo转换为合法的MQTT Discovery object_id(只允许字母、数字、下划线、连字符)
682
+ const sanitizedRoomNo = sanitizeTopicId(roomNo);
683
+ const objectId = `symi_room_${sanitizedRoomNo}_${sceneId}`;
663
684
 
664
685
  return {
665
686
  topic: `${mqttPrefix}/button/${objectId}/config`,
@@ -670,7 +691,7 @@ function generateSceneButtonConfig(scene, roomNo, mqttPrefix = 'homeassistant')
670
691
  payload_press: 'PRESS',
671
692
  icon: 'mdi:play-circle',
672
693
  device: {
673
- identifiers: [`symi_room_${roomNo}`],
694
+ identifiers: [`symi_room_${sanitizedRoomNo}`],
674
695
  name: `房间${roomNo}场景控制`,
675
696
  model: 'Symi 场景控制器',
676
697
  manufacturer: 'SYMI 亖米'
package/lib/protocol.js CHANGED
@@ -56,16 +56,16 @@ class ProtocolHandler {
56
56
 
57
57
  /**
58
58
  * 构建开关状态值
59
- * @param {number} channels - 开关路数 (1-6)
60
- * @param {number} targetChannel - 目标路数 (1-6)
59
+ * @param {number} channels - 开关路数 (1-8)
60
+ * @param {number} targetChannel - 目标路数 (1-8)
61
61
  * @param {boolean} targetState - 目标状态 (true=开, false=关)
62
62
  * @param {number|null} currentState - 当前状态值,null时使用默认全关状态
63
- * @returns {number|Buffer} - 1-4路返回number,6路返回Buffer(2字节)
63
+ * @returns {number|Buffer} - 1-4路返回number,6-8路返回Buffer(2字节)
64
64
  */
65
65
  buildSwitchState(channels, targetChannel, targetState, currentState = null) {
66
66
  // 验证参数
67
- if (channels < 1 || channels > 6) {
68
- throw new Error(`Invalid channels count: ${channels}, must be 1-6`);
67
+ if (channels < 1 || channels > 8) {
68
+ throw new Error(`Invalid channels count: ${channels}, must be 1-8`);
69
69
  }
70
70
  if (targetChannel < 1 || targetChannel > channels) {
71
71
  throw new Error(`Invalid target channel: ${targetChannel}, must be 1-${channels}`);
@@ -95,19 +95,27 @@ class ProtocolHandler {
95
95
 
96
96
  stateValue = (stateValue & mask) | newBits;
97
97
  return stateValue;
98
- } else if (channels === 6) {
99
- // 6路开关,2字节状态,小端序
98
+ } else if (channels === 6 || channels === 8) {
99
+ // 6-8路开关,2字节状态,小端序
100
+ // 协议说明:低2位开始表示第一路开关(与1-4路一致)
101
+ // 第1路=bits0-1, 第2路=bits2-3, ..., 第8路=bits14-15
100
102
  const defaultState = 0x5555; // 全关状态
101
103
  stateValue = currentState !== null ? currentState : defaultState;
102
-
103
- // 6路开关状态组合
104
+
105
+ // 确保currentState是16位值
106
+ if (typeof stateValue === 'number' && stateValue < 0x100) {
107
+ // 如果只有低8位,补充高8位为0x55(全关)
108
+ stateValue = stateValue | 0x5500;
109
+ }
110
+
111
+ // 6-8路开关状态组合(从低位开始)
104
112
  const bitPos = (targetChannel - 1) * 2;
105
113
  const mask = ~(0x03 << bitPos);
106
114
  const newBits = (targetState ? 0x02 : 0x01) << bitPos;
107
-
115
+
108
116
  stateValue = (stateValue & mask) | newBits;
109
-
110
- // 返回小端序Buffer
117
+
118
+ // 返回小端序Buffer(低字节在前)
111
119
  return Buffer.from([stateValue & 0xFF, (stateValue >> 8) & 0xFF]);
112
120
  }
113
121
  }
@@ -445,8 +453,8 @@ class ProtocolHandler {
445
453
  /**
446
454
  * 构建开关控制帧(便捷方法)
447
455
  * @param {number} networkAddr - 网络地址
448
- * @param {number} channels - 开关路数 (1-6)
449
- * @param {number} targetChannel - 目标路数 (1-6)
456
+ * @param {number} channels - 开关路数 (1-8)
457
+ * @param {number} targetChannel - 目标路数 (1-8)
450
458
  * @param {boolean} targetState - 目标状态 (true=开, false=关)
451
459
  * @param {number|null} currentState - 当前状态值,null时需要先查询
452
460
  * @returns {Buffer} - 控制帧数据
package/lib/tcp-client.js CHANGED
@@ -75,12 +75,7 @@ class TCPClient extends EventEmitter {
75
75
 
76
76
  this.client.on('data', (data) => {
77
77
  try {
78
- // 添加原始数据日志
79
- // const dataHex = data.toString('hex').toUpperCase();
80
- // this.logger.log(`[TCP Raw Data] 收到${data.length}字节: ${dataHex}`);
81
-
82
78
  const frames = this.protocolHandler.addData(data);
83
- // this.logger.log(`[TCP Parse] 解析出${frames.length}个帧`);
84
79
 
85
80
  frames.forEach(frame => {
86
81
  this.emit('frame', frame);
@@ -93,11 +88,15 @@ class TCPClient extends EventEmitter {
93
88
 
94
89
  this.client.on('error', (error) => {
95
90
  clearTimeout(timeout);
96
- this.logger.error('TCP client error:', error.message);
97
-
91
+ // 只在首次连接或重要错误时记录,避免大量重复日志
92
+ // ECONNRESET通常是网络波动,不记录错误
93
+ if (!this.connected && error.code !== 'ECONNREFUSED' && error.code !== 'ECONNRESET') {
94
+ this.logger.error('TCP client error:', error.message);
95
+ }
96
+
98
97
  // 确保错误不会导致uncaught exception
99
98
  this.emit('error', error);
100
-
99
+
101
100
  if (!resolved && !rejected) {
102
101
  rejected = true;
103
102
  this.handleDisconnect();
@@ -107,13 +106,16 @@ class TCPClient extends EventEmitter {
107
106
 
108
107
  this.client.on('close', () => {
109
108
  clearTimeout(timeout);
110
- this.logger.log('Connection closed');
111
-
109
+ // 只在正常连接后断开时记录,避免重连时大量日志
110
+ if (this.connected) {
111
+ this.logger.log('Connection closed');
112
+ }
113
+
112
114
  if (!resolved && !rejected) {
113
115
  rejected = true;
114
116
  reject(new Error('Connection closed during connect'));
115
117
  }
116
-
118
+
117
119
  this.handleDisconnect();
118
120
  });
119
121
  });
@@ -143,13 +145,13 @@ class TCPClient extends EventEmitter {
143
145
  if (this.autoReconnect && !this.reconnectTimer) {
144
146
  this.reconnectTimer = setTimeout(() => {
145
147
  this.reconnectTimer = null;
146
- this.logger.log('Attempting to reconnect...');
148
+ // 减少重连日志,只在成功时记录
147
149
  this.connect()
148
150
  .then(() => {
149
151
  this.logger.log('Reconnected successfully');
150
152
  })
151
153
  .catch((error) => {
152
- this.logger.error(`Reconnect failed: ${error.message}`);
154
+ // 静默失败,避免大量重连失败日志
153
155
  // 失败后会自动继续尝试重连(通过handleDisconnect)
154
156
  });
155
157
  }, this.reconnectDelay);