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.
@@ -180,6 +180,46 @@ module.exports = function(RED) {
180
180
  }
181
181
  },
182
182
  // ===== 地暖 (A3B3协议头) =====
183
+ // 从机地址: 客餐厅=0x3C(60), 主卧=0x3D(61), 次卧1=0x3E(62), 次卧2=0x3F(63)
184
+ // 开关: 0x0039, 开=0x02, 关=0x00
185
+ // 温度: 0x0043
186
+ 'floor_heating_living': {
187
+ name: '客餐厅地暖',
188
+ type: 'climate',
189
+ defaultAddress: 0x3C,
190
+ registers: {
191
+ switch: { address: 0x0039, type: 'holding', on: 2, off: 0 },
192
+ targetTemp: { address: 0x0043, type: 'holding' }
193
+ }
194
+ },
195
+ 'floor_heating_master': {
196
+ name: '主卧地暖',
197
+ type: 'climate',
198
+ defaultAddress: 0x3D,
199
+ registers: {
200
+ switch: { address: 0x0039, type: 'holding', on: 2, off: 0 },
201
+ targetTemp: { address: 0x0043, type: 'holding' }
202
+ }
203
+ },
204
+ 'floor_heating_bedroom2_1': {
205
+ name: '次卧1地暖',
206
+ type: 'climate',
207
+ defaultAddress: 0x3E,
208
+ registers: {
209
+ switch: { address: 0x0039, type: 'holding', on: 2, off: 0 },
210
+ targetTemp: { address: 0x0043, type: 'holding' }
211
+ }
212
+ },
213
+ 'floor_heating_bedroom2_2': {
214
+ name: '次卧2地暖',
215
+ type: 'climate',
216
+ defaultAddress: 0x3F,
217
+ registers: {
218
+ switch: { address: 0x0039, type: 'holding', on: 2, off: 0 },
219
+ targetTemp: { address: 0x0043, type: 'holding' }
220
+ }
221
+ },
222
+ // 通用地暖(自定义从机地址)
183
223
  'floor_heating': {
184
224
  name: '地暖',
185
225
  type: 'climate',
@@ -189,9 +229,13 @@ module.exports = function(RED) {
189
229
  }
190
230
  },
191
231
  // ===== 新风 (A3B3协议头) =====
232
+ // 从机地址: 0x3C (60) - 与客餐厅地暖共用从机
233
+ // 开关: 0x0039, 开=0x01, 关=0x00 (注意:与地暖开关值不同)
234
+ // 风速: 0x004B, 高=0x02, 低=0x00
192
235
  'fresh_air': {
193
236
  name: '新风',
194
237
  type: 'fan',
238
+ defaultAddress: 0x3C,
195
239
  registers: {
196
240
  switch: { address: 0x0039, type: 'holding', on: 1, off: 0 },
197
241
  fanSpeed: { address: 0x004B, type: 'holding', map: { 0: 'low', 2: 'high' } }
@@ -374,6 +418,8 @@ module.exports = function(RED) {
374
418
  node.mappings.forEach((m, i) => {
375
419
  if (m.brand === 'duya') {
376
420
  node.log(`[映射${i+1}] Mesh: ${m.meshMac} <-> 杜亚窗帘: 地址${m.addrHigh.toString(16).padStart(2,'0')} ${m.addrLow.toString(16).padStart(2,'0')}`);
421
+ } else if (m.brand === 'custom' && m.customCodes) {
422
+ node.log(`[映射${i+1}] Mesh: ${m.meshMac} <-> 自定义: ${m.device}, codes=${JSON.stringify(m.customCodes)}`);
377
423
  } else {
378
424
  node.log(`[映射${i+1}] Mesh: ${m.meshMac} CH${m.meshChannel} <-> RS485: 从机${m.address} ${m.brand}/${m.device} CH${m.rs485Channel}`);
379
425
  }
@@ -452,11 +498,17 @@ module.exports = function(RED) {
452
498
  );
453
499
  };
454
500
 
455
- // Find mapping for RS485 device
501
+ // Find mapping for RS485 device (返回第一个匹配,用于向后兼容)
456
502
  node.findRS485Mapping = function(address) {
457
503
  const addr = parseInt(address);
458
504
  return node.mappings.find(m => m.address === addr);
459
505
  };
506
+
507
+ // Find ALL mappings for RS485 device address (用于RS485->Mesh同步)
508
+ node.findAllRS485Mappings = function(address) {
509
+ const addr = parseInt(address);
510
+ return node.mappings.filter(m => m.address === addr);
511
+ };
460
512
 
461
513
  // 获取映射的寄存器配置
462
514
  node.getRegistersForMapping = function(mapping) {
@@ -488,6 +540,7 @@ module.exports = function(RED) {
488
540
  if (!node.stateCache[mac]) node.stateCache[mac] = {};
489
541
  const cached = node.stateCache[mac];
490
542
  const changed = {};
543
+ const isFirstState = Object.keys(cached).length === 0; // 首次收到该设备状态
491
544
 
492
545
  for (const [key, value] of Object.entries(state)) {
493
546
  if (cached[key] !== value) {
@@ -498,11 +551,25 @@ module.exports = function(RED) {
498
551
 
499
552
  if (Object.keys(changed).length === 0) return; // 无变化
500
553
 
554
+ // 首次收到设备状态时只记录缓存,不触发同步(避免启动时批量发码)
555
+ // 但窗帘状态(curtainStatus)除外,因为这是用户的控制命令
556
+ const hasCurtainStatusChange = changed.curtainStatus !== undefined;
557
+ if (isFirstState && !hasCurtainStatusChange) {
558
+ node.debug(`[Mesh事件] MAC=${mac} 首次状态,仅缓存: ${JSON.stringify(changed)}`);
559
+ return;
560
+ }
561
+
501
562
  node.log(`[Mesh事件] MAC=${mac}, 变化: ${JSON.stringify(changed)}`);
502
563
 
564
+ // 规范化MAC地址用于比较
565
+ const macNormalized = mac.toLowerCase().replace(/:/g, '');
566
+
503
567
  // 遍历映射,只处理有对应变化的映射
504
568
  for (const mapping of node.mappings) {
505
- if (mapping.meshMac !== mac) continue;
569
+ const mappingMacNormalized = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
570
+ if (mappingMacNormalized !== macNormalized) continue;
571
+
572
+ node.log(`[Mesh事件] 匹配到映射: brand=${mapping.brand}, device=${mapping.device}, codes=${JSON.stringify(mapping.customCodes)}`);
506
573
 
507
574
  const configChannel = mapping.meshChannel || 1;
508
575
  const switchKey = `switch_${configChannel}`;
@@ -513,9 +580,221 @@ module.exports = function(RED) {
513
580
  const isCurtain = device.includes('curtain') || mapping.brand === 'duya';
514
581
  const isAC = device.includes('ac') || device.includes('climate') || device.includes('thermostat');
515
582
 
583
+ // 【杜亚窗帘】处理Mesh面板控制同步到485
584
+ // 根据协议文档:
585
+ // - subOpcode=0x05 (NODE_ACK) = 设备确认执行命令,是用户控制
586
+ // - subOpcode=0x06 (NODE_STATUS) 在0xB0后1500ms内也是用户控制
587
+ // - attrType=0x05 (CURT_RUN_STATUS) = 状态: 1=开, 2=关, 3=停
588
+ // - attrType=0x06 (CURT_RUN_PER_POS) = 位置: 0-100%
589
+ if (mapping.brand === 'duya') {
590
+ const now = Date.now();
591
+ const curtainKey = `duya_curtain_${mac}`;
592
+
593
+ // 只处理用户控制帧
594
+ if (!eventData.isUserControl) {
595
+ continue; // 非用户控制(电机反馈),忽略
596
+ }
597
+
598
+ const addrHigh = mapping.addrHigh || 1;
599
+ const addrLow = mapping.addrLow || 1;
600
+
601
+ // 获取设备状态
602
+ const device = node.gateway.deviceManager.getDeviceByMac(mac);
603
+ const currentPosition = device ? device.state.curtainPosition : null;
604
+
605
+ // 百分比控制 (attrType=0x06)
606
+ // 只记录位置,不立即发送(等status=2时再判断是否发百分比)
607
+ if (eventData.attrType === 0x06) {
608
+ const position = eventData.parameters && eventData.parameters.length > 0
609
+ ? eventData.parameters[0]
610
+ : null;
611
+ if (position !== null && position !== 0xFF) {
612
+ // 记录最新位置,用于status=2时判断
613
+ if (!node.curtainLastPos) node.curtainLastPos = {};
614
+ node.curtainLastPos[curtainKey] = position;
615
+ node.debug(`[Mesh->杜亚] 记录位置: ${position}%`);
616
+ }
617
+ continue;
618
+ }
619
+
620
+ // 状态控制 (attrType=0x05)
621
+ if (eventData.attrType !== 0x05) {
622
+ continue;
623
+ }
624
+
625
+ const newStatus = eventData.parameters && eventData.parameters.length > 0
626
+ ? eventData.parameters[0]
627
+ : null;
628
+
629
+ // 忽略 null 值
630
+ if (newStatus === null) {
631
+ continue;
632
+ }
633
+
634
+ // 1秒防抖:避免重复发送相同状态
635
+ if (!node.curtainDebounce) node.curtainDebounce = {};
636
+ const debounceKey = `${curtainKey}_status_${newStatus}`;
637
+ const lastTime = node.curtainDebounce[debounceKey] || 0;
638
+ if (now - lastTime < 1000) {
639
+ node.debug(`[Mesh->杜亚] 状态${newStatus}在1秒内重复, 忽略`);
640
+ continue;
641
+ }
642
+ node.curtainDebounce[debounceKey] = now;
643
+
644
+ node.log(`[Mesh->杜亚] 用户控制(subOpcode=0x${eventData.subOpcode.toString(16)}): status=${newStatus}, position=${currentPosition}%`);
645
+
646
+ // 【米家协议状态处理】:
647
+ // status=0 → 打开中 → 发open
648
+ // status=1 → 关闭中 → 发close
649
+ // status=2 → 停止 → 判断位置:
650
+ // - 位置=0% → 关闭到头,不发
651
+ // - 位置=100% → 打开到头,不发
652
+ // - 位置=其他 → 用户暂停或百分比控制,发stop+position
653
+
654
+ let action = null, actionName = '';
655
+ let sendPosition = false;
656
+
657
+ if (newStatus === 0) {
658
+ // 米家: 打开中
659
+ action = 'open';
660
+ actionName = '打开';
661
+ } else if (newStatus === 1) {
662
+ // 米家: 关闭中
663
+ action = 'close';
664
+ actionName = '关闭';
665
+ } else if (newStatus === 2) {
666
+ // 米家: 停止
667
+ const pos = currentPosition !== null ? currentPosition : (node.curtainLastPos ? node.curtainLastPos[curtainKey] : 50);
668
+ if (pos <= 2) {
669
+ // 关闭到头,不发命令
670
+ node.debug(`[Mesh->杜亚] 关闭到头(${pos}%),不发命令`);
671
+ continue;
672
+ } else if (pos >= 98) {
673
+ // 打开到头,不发命令
674
+ node.debug(`[Mesh->杜亚] 打开到头(${pos}%),不发命令`);
675
+ continue;
676
+ } else {
677
+ // 中间位置停止,发stop + position
678
+ action = 'stop';
679
+ actionName = '暂停';
680
+ sendPosition = true;
681
+ }
682
+ } else if (newStatus === 3) {
683
+ // 小程序: 停止
684
+ action = 'stop';
685
+ actionName = '暂停';
686
+ } else {
687
+ continue;
688
+ }
689
+
690
+ // 发送动作命令
691
+ if (action) {
692
+ const frame = buildA6B6Frame(addrHigh, addrLow, action);
693
+ node.sendRS485Frame(frame).then(() => {
694
+ const hexStr = frame.toString('hex').toUpperCase().match(/.{2}/g).join(' ');
695
+ node.log(`[Mesh->杜亚] ${actionName}: ${hexStr}`);
696
+ }).catch(err => {
697
+ node.error(`[Mesh->杜亚] 发送失败: ${err.message}`);
698
+ });
699
+ }
700
+
701
+ // 发送位置命令(暂停时同步位置)
702
+ if (sendPosition && currentPosition !== null && currentPosition > 2 && currentPosition < 98) {
703
+ setTimeout(() => {
704
+ const posFrame = buildA6B6Frame(addrHigh, addrLow, 'position', currentPosition);
705
+ node.sendRS485Frame(posFrame).then(() => {
706
+ const hexStr = posFrame.toString('hex').toUpperCase().match(/.{2}/g).join(' ');
707
+ node.log(`[Mesh->杜亚] 同步位置${currentPosition}%: ${hexStr}`);
708
+ }).catch(err => {
709
+ node.error(`[Mesh->杜亚] 位置同步失败: ${err.message}`);
710
+ });
711
+ }, 100); // 延迟100ms发送位置命令
712
+ }
713
+ continue;
714
+ }
715
+
716
+ // 【自定义窗帘】处理Mesh面板控制同步到485(逻辑同杜亚)
717
+ // 必须检查isUserControl,只有NODE_ACK(subOpcode=0x05)才是用户控制
718
+ if (mapping.brand === 'custom' && mapping.device === 'custom_curtain' && mapping.customCodes) {
719
+ const now = Date.now();
720
+ const curtainKey = `custom_curtain_${mac}`;
721
+
722
+ // 【重要】只处理用户控制帧,忽略电机反馈
723
+ if (!eventData.isUserControl) {
724
+ continue; // 非用户控制(NODE_STATUS),忽略
725
+ }
726
+
727
+ // 只处理窗帘状态事件 (attrType=0x05)
728
+ if (eventData.attrType !== 0x05) {
729
+ continue;
730
+ }
731
+
732
+ const newStatus = eventData.parameters && eventData.parameters.length > 0
733
+ ? eventData.parameters[0]
734
+ : null;
735
+
736
+ // 忽略 null 值
737
+ if (newStatus === null) {
738
+ continue;
739
+ }
740
+
741
+ // 1秒防抖:避免重复发送相同状态(与杜亚窗帘一致)
742
+ if (!node.curtainDebounce) node.curtainDebounce = {};
743
+ const debounceKey = `${curtainKey}_status`;
744
+ const lastTime = node.curtainDebounce[debounceKey] || 0;
745
+ if (now - lastTime < 1000) {
746
+ node.debug(`[Mesh->自定义窗帘] 状态1秒内重复, 忽略`);
747
+ continue;
748
+ }
749
+ node.curtainDebounce[debounceKey] = now;
750
+
751
+ // 【兼容两种协议】:
752
+ // 米家协议: 0=打开中, 1=关闭中, 2=停止
753
+ // 小程序协议: 1=打开, 2=关闭, 3=停止
754
+ // 使用device.state.curtainAction来获取正确的动作
755
+ const device = node.gateway.deviceManager.getDeviceByMac(mac);
756
+ const curtainAction = device ? device.state.curtainAction : null;
757
+
758
+ const codes = mapping.customCodes;
759
+ let hexCode = null, actionName = '';
760
+
761
+ if (curtainAction === 'opening' && codes.open) {
762
+ hexCode = codes.open;
763
+ actionName = '打开';
764
+ } else if (curtainAction === 'closing' && codes.close) {
765
+ hexCode = codes.close;
766
+ actionName = '关闭';
767
+ } else if (curtainAction === 'stopped' && codes.stop) {
768
+ hexCode = codes.stop;
769
+ actionName = '暂停';
770
+ } else {
771
+ // 回退:直接使用原始status值(兼容旧逻辑,小程序协议)
772
+ if (newStatus === 1 && codes.open) {
773
+ hexCode = codes.open; actionName = '打开';
774
+ } else if (newStatus === 2 && codes.close) {
775
+ hexCode = codes.close; actionName = '关闭';
776
+ } else if (newStatus === 3 && codes.stop) {
777
+ hexCode = codes.stop; actionName = '暂停';
778
+ }
779
+ }
780
+
781
+ if (hexCode) {
782
+ node.sendCustomCode(hexCode).then(() => {
783
+ node.log(`[Mesh->自定义] ${actionName}: ${hexCode}`);
784
+ }).catch(err => {
785
+ node.error(`[Mesh->自定义] 发送失败: ${err.message}`);
786
+ });
787
+ }
788
+ continue;
789
+ }
790
+
791
+ // 其他窗帘类型标记
792
+ const isCurtainControlled = false;
793
+
516
794
  // 只有对应类型的状态变化才触发对应类型的映射
517
795
  const hasSwitchChange = isSwitch && changed[switchKey] !== undefined;
518
- const hasCurtainChange = isCurtain && (
796
+ // 窗帘:如果是杜亚窗帘,跳过状态变化处理(已在curtain-control中处理)
797
+ const hasCurtainChange = isCurtain && !isCurtainControlled && (
519
798
  changed.curtainAction !== undefined ||
520
799
  changed.curtainPosition !== undefined ||
521
800
  changed.curtainStatus !== undefined
@@ -523,10 +802,26 @@ module.exports = function(RED) {
523
802
  const hasACChange = isAC && (
524
803
  changed.targetTemp !== undefined ||
525
804
  changed.acMode !== undefined ||
526
- changed.acFanSpeed !== undefined
805
+ changed.acFanSpeed !== undefined ||
806
+ // 三合一面板使用climate前缀
807
+ changed.climateSwitch !== undefined ||
808
+ changed.climateMode !== undefined ||
809
+ changed.fanMode !== undefined
810
+ );
811
+ // 新风设备检测
812
+ const isFreshAir = device.includes('fresh_air');
813
+ const hasFreshAirChange = isFreshAir && (
814
+ changed.freshAirSwitch !== undefined ||
815
+ changed.freshAirSpeed !== undefined
816
+ );
817
+ // 地暖设备检测
818
+ const isFloorHeating = device.includes('floor_heating');
819
+ const hasFloorHeatingChange = isFloorHeating && (
820
+ changed.floorHeatingSwitch !== undefined ||
821
+ changed.floorHeatingTemp !== undefined
527
822
  );
528
823
 
529
- if (!hasSwitchChange && !hasCurtainChange && !hasACChange) {
824
+ if (!hasSwitchChange && !hasCurtainChange && !hasACChange && !hasFreshAirChange && !hasFloorHeatingChange) {
530
825
  continue;
531
826
  }
532
827
 
@@ -558,66 +853,119 @@ module.exports = function(RED) {
558
853
  };
559
854
 
560
855
  // 窗帘控制命令处理(立即同步,不等状态反馈)
856
+ // 同时支持杜亚窗帘和自定义窗帘
561
857
  const handleCurtainControl = (eventData) => {
562
858
  const mac = eventData.mac;
563
859
 
564
860
  node.log(`[curtain-control事件] MAC=${mac}, action=${eventData.action}, position=${eventData.position}`);
565
861
 
566
- // 查找杜亚窗帘映射(支持大小写和冒号格式)
862
+ // 查找窗帘映射(杜亚或自定义窗帘)
567
863
  const macNormalized = mac.toLowerCase().replace(/:/g, '');
568
864
  for (const mapping of node.mappings) {
569
865
  const mappingMacNormalized = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
570
- if (mappingMacNormalized !== macNormalized || mapping.brand !== 'duya') continue;
866
+ if (mappingMacNormalized !== macNormalized) continue;
571
867
 
572
- node.log(`[curtain-control] 匹配到杜亚映射: ${mapping.meshMac}`);
573
-
574
- const addrHigh = mapping.addrHigh || 1;
575
- const addrLow = mapping.addrLow || 1;
576
-
577
- let frame = null;
578
- let actionName = '';
579
-
580
- // 处理动作命令 (attrType=0x05)
581
- if (eventData.action !== null) {
582
- if (eventData.action === 1) {
583
- frame = buildA6B6Frame(addrHigh, addrLow, 'open');
584
- actionName = '打开';
585
- } else if (eventData.action === 2) {
586
- frame = buildA6B6Frame(addrHigh, addrLow, 'close');
587
- actionName = '关闭';
588
- } else if (eventData.action === 3) {
589
- frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
590
- actionName = '暂停';
868
+ // ===== 杜亚窗帘 =====
869
+ if (mapping.brand === 'duya') {
870
+ node.log(`[curtain-control] 匹配到杜亚映射: ${mapping.meshMac}`);
871
+
872
+ const addrHigh = mapping.addrHigh || 1;
873
+ const addrLow = mapping.addrLow || 1;
874
+
875
+ let frame = null;
876
+ let actionName = '';
877
+
878
+ // 处理动作命令 (attrType=0x05)
879
+ if (eventData.action !== null) {
880
+ if (eventData.action === 1) {
881
+ frame = buildA6B6Frame(addrHigh, addrLow, 'open');
882
+ actionName = '打开';
883
+ } else if (eventData.action === 2) {
884
+ frame = buildA6B6Frame(addrHigh, addrLow, 'close');
885
+ actionName = '关闭';
886
+ } else if (eventData.action === 3) {
887
+ frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
888
+ actionName = '暂停';
889
+ }
591
890
  }
592
- }
593
- // 处理位置命令 (attrType=0x06)
594
- else if (eventData.position !== null) {
595
- const pos = eventData.position;
596
- if (pos >= 95) {
597
- frame = buildA6B6Frame(addrHigh, addrLow, 'open');
598
- actionName = '打开';
599
- } else if (pos <= 5) {
600
- frame = buildA6B6Frame(addrHigh, addrLow, 'close');
601
- actionName = '关闭';
602
- } else {
603
- frame = buildA6B6Frame(addrHigh, addrLow, 'position', pos);
604
- actionName = `位置${pos}%`;
891
+ // 处理位置命令 (attrType=0x06)
892
+ else if (eventData.position !== null) {
893
+ const pos = eventData.position;
894
+ if (pos >= 95) {
895
+ frame = buildA6B6Frame(addrHigh, addrLow, 'open');
896
+ actionName = '打开';
897
+ } else if (pos <= 5) {
898
+ frame = buildA6B6Frame(addrHigh, addrLow, 'close');
899
+ actionName = '关闭';
900
+ } else {
901
+ frame = buildA6B6Frame(addrHigh, addrLow, 'position', pos);
902
+ actionName = `位置${pos}%`;
903
+ }
904
+ }
905
+
906
+ if (frame) {
907
+ // 立即发送,记录时间防止状态反馈重复发码
908
+ node.lastCurtainControlTime = Date.now();
909
+ node.lastMeshToRS485Time = Date.now();
910
+ if (!node.lastSentTime) node.lastSentTime = {};
911
+ node.lastSentTime[`duya_${mac}_${actionName}`] = Date.now();
912
+
913
+ node.sendRS485Frame(frame).then(() => {
914
+ const hexStr = frame.toString('hex').toUpperCase();
915
+ node.log(`[Mesh控制->杜亚] 窗帘 ${actionName}, 立即发送: ${hexStr.match(/.{2}/g).join(' ')}`);
916
+ }).catch(err => {
917
+ node.error(`[Mesh控制->杜亚] 发送失败: ${err.message}`);
918
+ });
605
919
  }
606
920
  }
607
-
608
- if (frame) {
609
- // 立即发送,记录时间防止状态反馈重复发码
610
- node.lastCurtainControlTime = Date.now(); // 专门用于curtain-control事件
611
- node.lastMeshToRS485Time = Date.now();
612
- if (!node.lastSentTime) node.lastSentTime = {};
613
- node.lastSentTime[`duya_${mac}_${actionName}`] = Date.now();
921
+ // ===== 自定义窗帘 =====
922
+ else if (mapping.brand === 'custom' && mapping.device === 'custom_curtain' && mapping.customCodes) {
923
+ const codes = mapping.customCodes;
614
924
 
615
- node.sendRS485Frame(frame).then(() => {
616
- const hexStr = frame.toString('hex').toUpperCase();
617
- node.log(`[Mesh控制->杜亚] 窗帘 ${actionName}, 立即发送: ${hexStr.match(/.{2}/g).join(' ')}`);
618
- }).catch(err => {
619
- node.error(`[Mesh控制->杜亚] 发送失败: ${err.message}`);
620
- });
925
+ node.log(`[curtain-control] 匹配到自定义窗帘映射: ${mapping.meshMac}`);
926
+
927
+ let hexCode = null;
928
+ let actionName = '';
929
+
930
+ // 处理动作命令 (attrType=0x05): 1=打开, 2=关闭, 3=停止
931
+ if (eventData.action !== null) {
932
+ if (eventData.action === 1 && codes.open) {
933
+ hexCode = codes.open;
934
+ actionName = '打开';
935
+ } else if (eventData.action === 2 && codes.close) {
936
+ hexCode = codes.close;
937
+ actionName = '关闭';
938
+ } else if (eventData.action === 3 && codes.stop) {
939
+ hexCode = codes.stop;
940
+ actionName = '暂停';
941
+ }
942
+ }
943
+ // 处理位置命令 (attrType=0x06)
944
+ else if (eventData.position !== null) {
945
+ const pos = eventData.position;
946
+ if (pos >= 95 && codes.open) {
947
+ hexCode = codes.open;
948
+ actionName = '打开';
949
+ } else if (pos <= 5 && codes.close) {
950
+ hexCode = codes.close;
951
+ actionName = '关闭';
952
+ }
953
+ // 自定义窗帘不支持百分比位置,只能发开/关
954
+ }
955
+
956
+ if (hexCode) {
957
+ // 立即发送,记录时间防止状态反馈重复发码
958
+ node.lastCurtainControlTime = Date.now();
959
+ node.lastMeshToRS485Time = Date.now();
960
+ if (!node.lastSentTime) node.lastSentTime = {};
961
+ node.lastSentTime[`custom_curtain_${mac}_${hexCode}`] = Date.now();
962
+
963
+ node.sendCustomCode(hexCode).then(() => {
964
+ node.log(`[Mesh控制->自定义] 窗帘 ${actionName}, 立即发送: ${hexCode}`);
965
+ }).catch(err => {
966
+ node.error(`[Mesh控制->自定义] 发送失败: ${err.message}`);
967
+ });
968
+ }
621
969
  }
622
970
  }
623
971
  };
@@ -642,16 +990,30 @@ module.exports = function(RED) {
642
990
  });
643
991
  };
644
992
 
645
- // 命令队列顺序处理
993
+ // 命令队列顺序处理(限制最大100条,防止内存溢出)
994
+ const MAX_QUEUE_SIZE = 100;
646
995
  node.queueCommand = function(cmd) {
647
- // 检查队列中是否有相似命令(防抖)
996
+ // 队列过大时丢弃旧命令,防止内存溢出
997
+ if (node.commandQueue.length >= MAX_QUEUE_SIZE) {
998
+ node.warn(`[RS485 Bridge] 命令队列已满(${MAX_QUEUE_SIZE}),丢弃最旧命令`);
999
+ node.commandQueue.shift(); // 丢弃最旧的命令
1000
+ }
1001
+
1002
+ // 检查队列中是否有完全相同映射的命令(防抖)
1003
+ // 必须是同一个映射(meshMac + meshChannel + address + rs485Channel)才能合并
648
1004
  const existing = node.commandQueue.find(c =>
649
1005
  c.direction === cmd.direction &&
1006
+ c.mapping && cmd.mapping &&
1007
+ c.mapping.meshMac === cmd.mapping.meshMac &&
1008
+ c.mapping.meshChannel === cmd.mapping.meshChannel &&
1009
+ c.mapping.address === cmd.mapping.address &&
1010
+ c.mapping.rs485Channel === cmd.mapping.rs485Channel &&
650
1011
  Date.now() - c.timestamp < 100
651
1012
  );
652
1013
  if (existing) {
653
- // 合并状态
1014
+ // 合并状态(仅限完全相同的映射)
654
1015
  existing.state = { ...existing.state, ...cmd.state };
1016
+ node.debug(`[队列] 合并相同映射的命令: ${cmd.mapping.meshMac} CH${cmd.mapping.meshChannel}`);
655
1017
  return;
656
1018
  }
657
1019
 
@@ -740,24 +1102,32 @@ module.exports = function(RED) {
740
1102
  let frame = null;
741
1103
  let actionName = '';
742
1104
 
743
- const isRunning = currentStatus === 1 || currentStatus === 2;
1105
+ // 【兼容两种协议】:
1106
+ // 米家协议: 0=打开中, 1=关闭中, 2=停止
1107
+ // 小程序协议: 1=打开, 2=关闭, 3=停止
1108
+ // 使用curtainAction来判断动作(由device-manager解析)
1109
+ const curtainAction = state.curtainAction;
744
1110
 
745
- // 1. 暂停(status=3) → 立即发送暂停码
746
- if (currentStatus === 3) {
1111
+ if (curtainAction === 'opening') {
1112
+ frame = buildA6B6Frame(addrHigh, addrLow, 'open');
1113
+ actionName = '打开';
1114
+ } else if (curtainAction === 'closing') {
1115
+ frame = buildA6B6Frame(addrHigh, addrLow, 'close');
1116
+ actionName = '关闭';
1117
+ } else if (curtainAction === 'stopped') {
747
1118
  frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
748
1119
  actionName = '暂停';
749
1120
  }
750
- // 2. 运行状态(1/2):根据当前位置判断方向
751
- // 位置>=50(接近全开)→ 即将关闭 发关闭码
752
- // 位置<50(接近全关)→ 即将打开 发打开码
753
- else if (isRunning) {
754
- if (posForDirection >= 50) {
755
- frame = buildA6B6Frame(addrHigh, addrLow, 'close');
756
- actionName = '关闭';
757
- } else {
758
- frame = buildA6B6Frame(addrHigh, addrLow, 'open');
759
- actionName = '打开';
760
- }
1121
+ // 回退:如果curtainAction未设置,使用原始status值(小程序协议)
1122
+ else if (currentStatus === 1) {
1123
+ frame = buildA6B6Frame(addrHigh, addrLow, 'open');
1124
+ actionName = '打开';
1125
+ } else if (currentStatus === 2) {
1126
+ frame = buildA6B6Frame(addrHigh, addrLow, 'close');
1127
+ actionName = '关闭';
1128
+ } else if (currentStatus === 3) {
1129
+ frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
1130
+ actionName = '暂停';
761
1131
  }
762
1132
  // 3. 只有位置变化(没有状态变化),中间位置发百分比码
763
1133
  else if (state.curtainPosition !== undefined &&
@@ -932,8 +1302,8 @@ module.exports = function(RED) {
932
1302
  node.log(`[Mesh->RS485] 从机${mapping.address} 按键${rs485Channel}: ${value ? '开' : '关'}`);
933
1303
  }
934
1304
  }
935
- // 空调开关
936
- else if (meshKey === 'acSwitch' && registers && registers.switch) {
1305
+ // 空调开关 (支持acSwitch和climateSwitch两种字段名)
1306
+ else if ((meshKey === 'acSwitch' || meshKey === 'climateSwitch') && registers && registers.switch) {
937
1307
  const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 1) :
938
1308
  (registers.switch.off !== undefined ? registers.switch.off : 0);
939
1309
  await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
@@ -944,46 +1314,100 @@ module.exports = function(RED) {
944
1314
  await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
945
1315
  node.log(`[Mesh->RS485] 目标温度: ${value}°C`);
946
1316
  }
947
- // 空调模式 - Mesh值需要转换为RS485值
948
- // Mesh: 0=制冷, 1=制热, 2=送风, 3=除湿
949
- // A5B5: 1=制热, 2=制冷, 4=送风, 8=除湿
950
- else if (meshKey === 'acMode' && registers && registers.mode) {
951
- // 反向映射:从Mesh模式名找到RS485寄存器值
952
- let rs485Value = value;
953
- if (registers.mode.map) {
954
- const modeNames = { 0: 'cool', 1: 'heat', 2: 'fan', 3: 'dry' };
955
- const meshModeName = modeNames[value] || value;
956
- const found = Object.entries(registers.mode.map).find(([k, v]) => v === meshModeName);
957
- if (found) {
958
- rs485Value = parseInt(found[0]);
1317
+ // 空调模式 (支持acMode和climateMode两种字段名)
1318
+ // Mesh协议: 1=制冷, 2=制热, 3=送风, 4=除湿
1319
+ // RS485(话语前湾): 1=制热, 2=制冷, 4=送风, 8=除湿
1320
+ else if ((meshKey === 'acMode' || meshKey === 'climateMode') && registers && registers.mode) {
1321
+ // Mesh值转RS485
1322
+ const meshToRs485 = { 1: 2, 2: 1, 3: 4, 4: 8 }; // 1=cool->2, 2=heat->1, 3=fan->4, 4=dry->8
1323
+ let rs485Value = meshToRs485[value];
1324
+ if (rs485Value === undefined) {
1325
+ // 尝试使用寄存器map反向映射
1326
+ if (registers.mode.map) {
1327
+ const modeNames = { 1: 'cool', 2: 'heat', 3: 'fan', 4: 'dry' };
1328
+ const meshModeName = modeNames[value] || value;
1329
+ const found = Object.entries(registers.mode.map).find(([k, v]) => v === meshModeName);
1330
+ if (found) {
1331
+ rs485Value = parseInt(found[0]);
1332
+ }
959
1333
  }
960
1334
  }
961
- await node.writeModbusRegister(mapping.address, registers.mode, rs485Value);
962
- node.log(`[Mesh->RS485] 空调模式: ${value} -> RS485值${rs485Value}`);
963
- }
964
- // 风速 - Mesh值需要转换为RS485值
965
- // Mesh: 0=自动, 1=低, 2=中, 3=高
966
- // A5B5: 1=低风, 2=中风, 3=高风
967
- else if (meshKey === 'acFanSpeed' && registers && registers.fanSpeed) {
968
- let rs485Value = value;
969
- if (registers.fanSpeed.map) {
970
- const speedNames = { 0: 'auto', 1: 'low', 2: 'medium', 3: 'high' };
971
- const meshSpeedName = speedNames[value] || value;
972
- const found = Object.entries(registers.fanSpeed.map).find(([k, v]) => v === meshSpeedName);
973
- if (found) {
974
- rs485Value = parseInt(found[0]);
1335
+ if (rs485Value !== undefined) {
1336
+ await node.writeModbusRegister(mapping.address, registers.mode, rs485Value);
1337
+ node.log(`[Mesh->RS485] 空调模式: Mesh值${value} -> RS485值${rs485Value}`);
1338
+ }
1339
+ }
1340
+ // 风速 (支持acFanSpeed和fanMode两种字段名)
1341
+ // 三合一0x94协议: 0=自动, 1=低, 2=中, 4=高
1342
+ // RS485(话语前湾): 1=低风, 2=中风, 3=高风
1343
+ else if ((meshKey === 'acFanSpeed' || meshKey === 'fanMode') && registers && registers.fanSpeed) {
1344
+ // 三合一0x94协议值转RS485值: 1=低->1, 2=中->2, 4=高->3, 0=自动->1(默认低)
1345
+ const meshToRs485 = { 0: 1, 1: 1, 2: 2, 4: 3 };
1346
+ let rs485Value = meshToRs485[value];
1347
+ if (rs485Value === undefined) {
1348
+ // 尝试使用寄存器map反向映射
1349
+ if (registers.fanSpeed.map) {
1350
+ const speedNames = { 0: 'auto', 1: 'low', 2: 'medium', 4: 'high' };
1351
+ const meshSpeedName = speedNames[value] || value;
1352
+ const found = Object.entries(registers.fanSpeed.map).find(([k, v]) => v === meshSpeedName);
1353
+ if (found) {
1354
+ rs485Value = parseInt(found[0]);
1355
+ }
975
1356
  }
976
- } else {
977
- // 默认直接使用Mesh值
978
- rs485Value = value;
979
1357
  }
980
- await node.writeModbusRegister(mapping.address, registers.fanSpeed, rs485Value);
981
- node.log(`[Mesh->RS485] 空调风速: ${value} -> RS485值${rs485Value}`);
1358
+ if (rs485Value !== undefined) {
1359
+ await node.writeModbusRegister(mapping.address, registers.fanSpeed, rs485Value);
1360
+ node.log(`[Mesh->RS485] 空调风速: Mesh值${value} -> RS485值${rs485Value}`);
1361
+ }
982
1362
  }
983
1363
  else if (meshKey === 'brightness' && registers && registers.brightness) {
984
1364
  await node.writeModbusRegister(mapping.address, registers.brightness, value);
985
1365
  node.debug(`[Mesh->RS485] 亮度: ${value}`);
986
1366
  }
1367
+ // ===== 地暖控制 =====
1368
+ // Mesh状态: floorHeatingSwitch (true/false), floorHeatingTemp (18-32)
1369
+ else if (meshKey === 'floorHeatingSwitch' && registers && registers.switch) {
1370
+ const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 2) :
1371
+ (registers.switch.off !== undefined ? registers.switch.off : 0);
1372
+ await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
1373
+ node.log(`[Mesh->RS485] 地暖开关: ${value ? '开' : '关'}, 值=${writeValue}`);
1374
+ }
1375
+ else if (meshKey === 'floorHeatingTemp' && registers && registers.targetTemp) {
1376
+ await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
1377
+ node.log(`[Mesh->RS485] 地暖温度: ${value}°C`);
1378
+ }
1379
+ // ===== 新风控制 =====
1380
+ // Mesh状态: freshAirSwitch (true/false), freshAirSpeed (1=高,2=中,3=低,4=自动)
1381
+ else if (meshKey === 'freshAirSwitch' && registers && registers.switch) {
1382
+ const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 1) :
1383
+ (registers.switch.off !== undefined ? registers.switch.off : 0);
1384
+ await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
1385
+ node.log(`[Mesh->RS485] 新风开关: ${value ? '开' : '关'}, 值=${writeValue}`);
1386
+ }
1387
+ else if (meshKey === 'freshAirSpeed' && registers && registers.fanSpeed) {
1388
+ // Mesh新风风速: 1=高, 2=中, 3=低, 4=自动
1389
+ // 话语前湾新风: 0=低速, 2=高速
1390
+ const meshToRs485 = { 1: 2, 2: 2, 3: 0, 4: 0 }; // 高/中->高速, 低/自动->低速
1391
+ const rs485Value = meshToRs485[value] !== undefined ? meshToRs485[value] : 0;
1392
+ await node.writeModbusRegister(mapping.address, registers.fanSpeed, rs485Value);
1393
+ node.log(`[Mesh->RS485] 新风风速: Mesh值${value} -> RS485值${rs485Value}`);
1394
+ }
1395
+ // ===== 空调开关(兼容climateSwitch) =====
1396
+ else if (meshKey === 'climateSwitch' && registers && registers.switch) {
1397
+ const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 1) :
1398
+ (registers.switch.off !== undefined ? registers.switch.off : 0);
1399
+ await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
1400
+ node.log(`[Mesh->RS485] 空调开关(climateSwitch): ${value ? '开' : '关'}`);
1401
+ }
1402
+ // ===== 空调模式(兼容climateMode) =====
1403
+ else if (meshKey === 'climateMode' && registers && registers.mode) {
1404
+ const meshToRs485 = { 1: 2, 2: 1, 3: 4, 4: 8 };
1405
+ const rs485Value = meshToRs485[value];
1406
+ if (rs485Value !== undefined) {
1407
+ await node.writeModbusRegister(mapping.address, registers.mode, rs485Value);
1408
+ node.log(`[Mesh->RS485] 空调模式(climateMode): Mesh值${value} -> RS485值${rs485Value}`);
1409
+ }
1410
+ }
987
1411
  } catch (err) {
988
1412
  node.error(`RS485写入失败: ${meshKey}=${value}, ${err.message}`);
989
1413
  }
@@ -1008,12 +1432,33 @@ module.exports = function(RED) {
1008
1432
  node.syncModbusToMesh = async function(cmd) {
1009
1433
  const { mapping, registers, state, customMode } = cmd;
1010
1434
 
1011
- // 查找Mesh设备
1012
- const meshDevice = node.gateway.getDevice(mapping.meshMac);
1435
+ // 规范化MAC地址 - 尝试多种格式查找设备
1436
+ const meshMac = mapping.meshMac || '';
1437
+ const macNormalized = meshMac.toLowerCase().replace(/:/g, '');
1438
+
1439
+ // 尝试多种格式查找Mesh设备
1440
+ let meshDevice = node.gateway.getDevice(meshMac); // 原始格式
1013
1441
  if (!meshDevice) {
1014
- node.warn(`未找到Mesh设备: ${mapping.meshMac}`);
1442
+ meshDevice = node.gateway.getDevice(macNormalized); // 无冒号小写
1443
+ }
1444
+ if (!meshDevice) {
1445
+ // 尝试从设备管理器中遍历查找
1446
+ const allDevices = node.gateway.deviceManager?.getAllDevices() || [];
1447
+ meshDevice = allDevices.find(d => {
1448
+ const devMac = (d.macAddress || '').toLowerCase().replace(/:/g, '');
1449
+ return devMac === macNormalized;
1450
+ });
1451
+ }
1452
+
1453
+ if (!meshDevice) {
1454
+ node.warn(`[RS485->Mesh] 未找到Mesh设备: ${meshMac} (规范化: ${macNormalized})`);
1455
+ // 输出可用设备列表帮助调试
1456
+ const allDevices = node.gateway.deviceManager?.getAllDevices() || [];
1457
+ node.warn(`[RS485->Mesh] 可用设备: ${allDevices.map(d => d.macAddress).join(', ')}`);
1015
1458
  return;
1016
1459
  }
1460
+
1461
+ node.log(`[RS485->Mesh] 找到设备: ${meshDevice.name}, MAC=${meshDevice.macAddress}, 网络地址=0x${meshDevice.networkAddress.toString(16)}`);
1017
1462
 
1018
1463
  const channel = mapping.meshChannel || 1;
1019
1464
 
@@ -1095,50 +1540,51 @@ module.exports = function(RED) {
1095
1540
  else if (key.startsWith('led')) {
1096
1541
  node.debug(`[RS485] 指示灯${key}: ${value}`);
1097
1542
  }
1098
- // 空调开关 - 0x1B是空调开关属性
1543
+ // 空调开关 - 0x02是开关属性 (0x01=关, 0x02=开)
1099
1544
  else if (key === 'acSwitch' || (key === 'switch' && mapping.device && mapping.device.includes('ac'))) {
1100
- const param = Buffer.from([value ? 1 : 0]);
1101
- await node.gateway.sendControl(meshDevice.networkAddress, 0x1B, param);
1545
+ const param = Buffer.from([value ? 0x02 : 0x01]);
1546
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
1102
1547
  node.log(`[RS485->Mesh] 空调开关: ${value ? '开' : '关'}`);
1103
1548
  }
1104
- // 目标温度 - 0x1C是目标温度属性
1549
+ // 目标温度 - 0x1B是目标温度属性
1105
1550
  else if (key === 'targetTemp') {
1106
1551
  const param = Buffer.from([Math.round(value)]);
1107
- await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
1552
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1B, param);
1108
1553
  node.log(`[RS485->Mesh] 目标温度: ${value}°C`);
1109
1554
  }
1110
- // 空调模式 - 0x16是模式属性
1111
- // RS485: 1=制热, 2=制冷, 4=送风, 8=除湿
1112
- // Mesh: 0=制冷, 1=制热, 2=送风, 3=除湿
1555
+ // 空调模式 - 0x1D是模式属性
1556
+ // RS485(话语前湾): 1=制热, 2=制冷, 4=送风, 8=除湿
1557
+ // Mesh协议: 1=制冷, 2=制热, 3=送风, 4=除湿
1113
1558
  else if (key === 'mode') {
1114
1559
  // 从RS485值或字符串转换为Mesh值
1115
- let meshMode = 0;
1560
+ let meshMode = 1; // 默认制冷
1116
1561
  if (typeof value === 'string') {
1117
- const modeMap = { 'cool': 0, 'heat': 1, 'fan': 2, 'dry': 3 };
1118
- meshMode = modeMap[value] !== undefined ? modeMap[value] : 0;
1562
+ const modeMap = { 'cool': 1, 'heat': 2, 'fan': 3, 'dry': 4 };
1563
+ meshMode = modeMap[value] !== undefined ? modeMap[value] : 1;
1119
1564
  } else {
1120
- // RS485数值转Mesh值
1121
- const rs485ToMesh = { 1: 1, 2: 0, 4: 2, 8: 3 }; // 1=heat->1, 2=cool->0, 4=fan->2, 8=dry->3
1122
- meshMode = rs485ToMesh[value] !== undefined ? rs485ToMesh[value] : 0;
1565
+ // RS485(话语前湾)数值转Mesh值
1566
+ const rs485ToMesh = { 1: 2, 2: 1, 4: 3, 8: 4 }; // 1=heat->2, 2=cool->1, 4=fan->3, 8=dry->4
1567
+ meshMode = rs485ToMesh[value] !== undefined ? rs485ToMesh[value] : 1;
1123
1568
  }
1124
1569
  const param = Buffer.from([meshMode]);
1125
- await node.gateway.sendControl(meshDevice.networkAddress, 0x16, param);
1570
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
1126
1571
  node.log(`[RS485->Mesh] 空调模式: RS485值${value} -> Mesh值${meshMode}`);
1127
1572
  }
1128
- // 风速 - 0x1D是风速属性
1129
- // RS485: 1=低风, 2=中风, 3=高风
1130
- // Mesh: 0=自动, 1=低, 2=中, 3=高
1573
+ // 风速 - 0x1C是风速属性
1574
+ // RS485(话语前湾): 1=低风, 2=中风, 3=高风
1575
+ // Mesh协议: 1=高, 2=中, 3=低, 4=自动
1131
1576
  else if (key === 'fanSpeed') {
1132
- let meshSpeed = 0;
1577
+ let meshSpeed = 3; // 默认低
1133
1578
  if (typeof value === 'string') {
1134
- const speedMap = { 'low': 1, 'medium': 2, 'high': 3, 'auto': 0 };
1135
- meshSpeed = speedMap[value] !== undefined ? speedMap[value] : 0;
1579
+ const speedMap = { 'low': 3, 'medium': 2, 'high': 1, 'auto': 4 };
1580
+ meshSpeed = speedMap[value] !== undefined ? speedMap[value] : 3;
1136
1581
  } else {
1137
- // RS485数值直接对应Mesh值(1=低, 2=中, 3=高)
1138
- meshSpeed = value;
1582
+ // RS485(话语前湾)数值转Mesh值: 1=低->3, 2=中->2, 3=高->1
1583
+ const rs485ToMesh = { 1: 3, 2: 2, 3: 1 };
1584
+ meshSpeed = rs485ToMesh[value] !== undefined ? rs485ToMesh[value] : 3;
1139
1585
  }
1140
1586
  const param = Buffer.from([meshSpeed]);
1141
- await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
1587
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
1142
1588
  node.log(`[RS485->Mesh] 空调风速: RS485值${value} -> Mesh值${meshSpeed}`);
1143
1589
  }
1144
1590
  else if (key === 'brightness') {
@@ -1146,6 +1592,37 @@ module.exports = function(RED) {
1146
1592
  await node.gateway.sendControl(meshDevice.networkAddress, 0x03, param);
1147
1593
  node.debug(`[RS485->Mesh] 亮度: ${value}`);
1148
1594
  }
1595
+ // ===== 地暖控制 (RS485->Mesh) =====
1596
+ // 地暖开关 - 0x6B是地暖开关属性 (0x02=开, 0x01=关)
1597
+ else if (key === 'floorHeatingSwitch') {
1598
+ const param = Buffer.from([value ? 0x02 : 0x01]);
1599
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x6B, param);
1600
+ node.log(`[RS485->Mesh] 地暖开关: ${value ? '开' : '关'}`);
1601
+ }
1602
+ // 地暖温度 - 0x6C是地暖温度属性 (18-32°C)
1603
+ else if (key === 'floorHeatingTemp') {
1604
+ const temp = Math.max(18, Math.min(32, Math.round(value)));
1605
+ const param = Buffer.from([temp]);
1606
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x6C, param);
1607
+ node.log(`[RS485->Mesh] 地暖温度: ${temp}°C`);
1608
+ }
1609
+ // ===== 新风控制 (RS485->Mesh) =====
1610
+ // 新风开关 - 0x68是新风开关属性 (0x02=开, 0x01=关)
1611
+ else if (key === 'freshAirSwitch') {
1612
+ const param = Buffer.from([value ? 0x02 : 0x01]);
1613
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x68, param);
1614
+ node.log(`[RS485->Mesh] 新风开关: ${value ? '开' : '关'}`);
1615
+ }
1616
+ // 新风风速 - 0x6A是新风风速属性 (1=高,2=中,3=低,4=自动)
1617
+ else if (key === 'freshAirSpeed') {
1618
+ // 话语前湾新风: 0=低速, 2=高速 -> Mesh: 1=高,3=低
1619
+ let meshSpeed = 3; // 默认低
1620
+ if (value === 2 || value === 'high') meshSpeed = 1; // 高速
1621
+ else if (value === 0 || value === 'low') meshSpeed = 3; // 低速
1622
+ const param = Buffer.from([meshSpeed]);
1623
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x6A, param);
1624
+ node.log(`[RS485->Mesh] 新风风速: RS485值${value} -> Mesh值${meshSpeed}`);
1625
+ }
1149
1626
  } catch (err) {
1150
1627
  node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
1151
1628
  }
@@ -1241,19 +1718,35 @@ module.exports = function(RED) {
1241
1718
  const hexStr = frame.toString('hex').toUpperCase();
1242
1719
  const hexFormatted = hexStr.match(/.{1,2}/g)?.join(' ') || '';
1243
1720
 
1244
- // ===== 杜亚窗帘协议检测 =====
1721
+ node.log(`[RS485收到] ${hexFormatted} (${frame.length}字节)`);
1722
+
1723
+ // ===== 杜亚窗帘协议检测 (55开头) =====
1245
1724
  if (frame[0] === 0x55 && frame.length >= 7) {
1725
+ node.log(`[杜亚帧检测] 检测到55帧头, 长度=${frame.length}, 开始解析...`);
1246
1726
  const duyaData = parseA6B6Frame(frame);
1247
1727
  if (duyaData) {
1728
+ node.log(`[杜亚帧解析] 成功! 地址高=${duyaData.addrHigh}, 地址低=${duyaData.addrLow}, 动作=${duyaData.action}`);
1729
+
1248
1730
  // 查找匹配的杜亚映射
1731
+ let foundMapping = false;
1732
+ node.log(`[杜亚映射查找] 当前映射数: ${node.mappings.length}`);
1249
1733
  for (const mapping of node.mappings) {
1250
- if (mapping.brand !== 'duya') continue;
1734
+ if (mapping.brand !== 'duya') {
1735
+ // 检查是否是自定义窗帘(也可能匹配55帧)
1736
+ if (mapping.brand === 'custom' && mapping.device === 'custom_curtain') {
1737
+ node.debug(`[杜亚映射] 发现自定义窗帘映射,将在后续自定义码匹配中处理`);
1738
+ }
1739
+ continue;
1740
+ }
1251
1741
 
1252
1742
  // 检查2字节地址是否匹配
1253
1743
  const mapAddrHigh = mapping.addrHigh || 1;
1254
1744
  const mapAddrLow = mapping.addrLow || 1;
1255
1745
 
1746
+ node.debug(`[杜亚映射检查] 配置地址=${mapAddrHigh}:${mapAddrLow}, 帧地址=${duyaData.addrHigh}:${duyaData.addrLow}`);
1747
+
1256
1748
  if (duyaData.addrHigh === mapAddrHigh && duyaData.addrLow === mapAddrLow) {
1749
+ foundMapping = true;
1257
1750
  // 防死循环: 1秒内忽略响应
1258
1751
  if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 1000) {
1259
1752
  node.debug(`[防循环] 忽略杜亚响应: ${hexFormatted}`);
@@ -1284,15 +1777,24 @@ module.exports = function(RED) {
1284
1777
  return;
1285
1778
  }
1286
1779
  }
1780
+
1781
+ if (!foundMapping) {
1782
+ node.warn(`[杜亚] 未找到匹配的映射, 帧地址=${duyaData.addrHigh}:${duyaData.addrLow},请检查RS485桥配置`);
1783
+ }
1784
+ } else {
1785
+ node.debug(`[杜亚帧检测] 解析失败,可能funcCode不是0x03`);
1287
1786
  }
1288
1787
  }
1289
1788
 
1290
- // 首先检查自定义码匹配(遍历所有映射)
1789
+ // 检查自定义码匹配(遍历所有映射)
1790
+ node.log(`[自定义码检测] 开始匹配, 当前帧hex=${hexStr}, 映射数=${node.mappings.length}`);
1291
1791
  for (const mapping of node.mappings) {
1292
1792
  if (mapping.brand === 'custom' && mapping.customCodes) {
1293
1793
  const codes = mapping.customCodes;
1294
1794
  let matchedAction = null;
1295
1795
 
1796
+ node.debug(`[自定义码检测] 检查映射: device=${mapping.device}, meshMac=${mapping.meshMac}, codes=${JSON.stringify(codes)}`);
1797
+
1296
1798
  // 开关类型:匹配on/off
1297
1799
  if (mapping.device === 'custom_switch') {
1298
1800
  if (codes.on && hexStr.includes(codes.on.replace(/\s/g, '').toUpperCase())) {
@@ -1301,14 +1803,24 @@ module.exports = function(RED) {
1301
1803
  matchedAction = { switch: false };
1302
1804
  }
1303
1805
  }
1304
- // 窗帘类型:匹配open/close/stop
1806
+ // 窗帘类型:匹配open/close/stop(只设置action,避免重复发码)
1305
1807
  else if (mapping.device === 'custom_curtain') {
1306
- if (codes.open && hexStr.includes(codes.open.replace(/\s/g, '').toUpperCase())) {
1307
- matchedAction = { position: 'open', action: 'open' };
1308
- } else if (codes.close && hexStr.includes(codes.close.replace(/\s/g, '').toUpperCase())) {
1309
- matchedAction = { position: 'close', action: 'close' };
1310
- } else if (codes.stop && hexStr.includes(codes.stop.replace(/\s/g, '').toUpperCase())) {
1311
- matchedAction = { position: 'stop', action: 'stop' };
1808
+ const openCode = codes.open ? codes.open.replace(/\s/g, '').toUpperCase() : '';
1809
+ const closeCode = codes.close ? codes.close.replace(/\s/g, '').toUpperCase() : '';
1810
+ const stopCode = codes.stop ? codes.stop.replace(/\s/g, '').toUpperCase() : '';
1811
+ node.debug(`[自定义窗帘匹配] 帧hex=${hexStr}, open=${openCode}, close=${closeCode}, stop=${stopCode}`);
1812
+
1813
+ if (openCode && hexStr.includes(openCode)) {
1814
+ matchedAction = { action: 'open' };
1815
+ node.log(`[自定义窗帘匹配] 匹配到打开码!`);
1816
+ } else if (closeCode && hexStr.includes(closeCode)) {
1817
+ matchedAction = { action: 'close' };
1818
+ node.log(`[自定义窗帘匹配] 匹配到关闭码!`);
1819
+ } else if (stopCode && hexStr.includes(stopCode)) {
1820
+ matchedAction = { action: 'stop' };
1821
+ node.log(`[自定义窗帘匹配] 匹配到停止码!`);
1822
+ } else {
1823
+ node.debug(`[自定义窗帘匹配] 未匹配到任何码`);
1312
1824
  }
1313
1825
  }
1314
1826
  // 场景类型:匹配trigger
@@ -1363,22 +1875,18 @@ module.exports = function(RED) {
1363
1875
 
1364
1876
  const slaveAddr = frame[0];
1365
1877
  const fc = frame[1];
1878
+ const hexFormatted = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
1366
1879
 
1367
- // 查找对应的映射
1368
- const mapping = node.findRS485Mapping(slaveAddr);
1369
- if (!mapping) {
1370
- node.debug(`未找到从机${slaveAddr}的映射配置`);
1371
- return;
1372
- }
1373
-
1374
- // 自定义模式不走标准Modbus解析
1375
- if (mapping.brand === 'custom') return;
1376
-
1377
- const registers = node.getRegistersForMapping(mapping);
1378
- if (!registers) {
1379
- node.debug(`未找到设备${mapping.device}的寄存器定义`);
1880
+ node.log(`[Modbus解析] 从机=${slaveAddr}, 功能码=0x${fc.toString(16)}, 帧=${hexFormatted}`);
1881
+
1882
+ // 查找所有匹配从机地址的映射
1883
+ const allMappings = node.findAllRS485Mappings(slaveAddr);
1884
+ if (allMappings.length === 0) {
1885
+ node.debug(`[Modbus解析] 未找到从机${slaveAddr}的映射配置`);
1380
1886
  return;
1381
1887
  }
1888
+
1889
+ node.log(`[Modbus解析] 找到${allMappings.length}个匹配映射`);
1382
1890
 
1383
1891
  // 防死循环:检查是否刚刚从Mesh发送过来
1384
1892
  if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 500) {
@@ -1386,15 +1894,45 @@ module.exports = function(RED) {
1386
1894
  return;
1387
1895
  }
1388
1896
 
1389
- // 根据功能码解析数据
1390
- let state = {};
1897
+ // 解析寄存器地址和值
1898
+ if (fc !== 0x06 && fc !== 0x10) {
1899
+ // 暂不处理其他功能码
1900
+ return;
1901
+ }
1391
1902
 
1392
- if (fc === 0x06 || fc === 0x10) {
1393
- // 写单寄存器响应/写多寄存器响应 - 这是设备主动上报或写响应
1394
- const regAddr = frame.readUInt16BE(2);
1395
- const value = frame.readUInt16BE(4);
1903
+ const regAddr = frame.readUInt16BE(2);
1904
+ const value = frame.readUInt16BE(4);
1905
+
1906
+ // 根据寄存器地址确定是哪个rs485Channel
1907
+ // 话语前湾开关寄存器:0x1031=CH1, 0x1032=CH2, 0x1033=CH3, 0x1034=CH4, 0x1035=CH5, 0x1036=CH6
1908
+ let rs485ChannelFromReg = null;
1909
+ if (regAddr >= 0x1031 && regAddr <= 0x1036) {
1910
+ rs485ChannelFromReg = regAddr - 0x1030; // 0x1031->1, 0x1032->2, ...
1911
+ }
1912
+
1913
+ node.log(`[Modbus解析] 寄存器=0x${regAddr.toString(16).toUpperCase()}, 值=${value}, rs485Channel=${rs485ChannelFromReg}`);
1914
+
1915
+ // 遍历所有匹配的映射,只处理rs485Channel匹配的
1916
+ for (const mapping of allMappings) {
1917
+ // 自定义模式不走标准Modbus解析
1918
+ if (mapping.brand === 'custom') continue;
1919
+
1920
+ const mappingRs485Channel = mapping.rs485Channel || 1;
1921
+
1922
+ // 如果是开关寄存器,检查rs485Channel是否匹配
1923
+ if (rs485ChannelFromReg !== null && rs485ChannelFromReg !== mappingRs485Channel) {
1924
+ node.debug(`[Modbus解析] 跳过映射: rs485Channel不匹配 (帧=${rs485ChannelFromReg}, 映射=${mappingRs485Channel})`);
1925
+ continue;
1926
+ }
1927
+
1928
+ const registers = node.getRegistersForMapping(mapping);
1929
+ if (!registers) {
1930
+ node.debug(`未找到设备${mapping.device}的寄存器定义`);
1931
+ continue;
1932
+ }
1396
1933
 
1397
1934
  // 查找匹配的寄存器定义
1935
+ let state = {};
1398
1936
  for (const [key, reg] of Object.entries(registers)) {
1399
1937
  if (reg.address === regAddr) {
1400
1938
  if (key.startsWith('switch') || key.startsWith('led')) {
@@ -1408,51 +1946,35 @@ module.exports = function(RED) {
1408
1946
  break;
1409
1947
  }
1410
1948
  }
1411
- } else if (fc === 0x03 || fc === 0x04) {
1412
- const byteCount = frame[2];
1413
- for (let i = 0; i < byteCount / 2; i++) {
1414
- const value = frame.readUInt16BE(3 + i * 2);
1415
- for (const [key, reg] of Object.entries(registers)) {
1416
- if (reg.map) {
1417
- state[key] = reg.map[value] || value;
1418
- } else {
1419
- state[key] = value;
1420
- }
1421
- }
1422
- }
1423
- } else if (fc === 0x20) {
1424
- if (frame.length >= 9) {
1425
- const startReg = frame.readUInt16BE(2);
1426
- const count = frame.readUInt16BE(4);
1427
- node.debug(`[RS485] 从机${slaveAddr} 自定义上报: 起始0x${startReg.toString(16)}, 数量${count}`);
1428
- }
1429
- }
1430
-
1431
- if (Object.keys(state).length > 0) {
1432
- node.log(`[RS485->Mesh] 设备@${slaveAddr} 状态变化: ${JSON.stringify(state)}`);
1433
-
1434
- // 输出调试信息到节点输出端口
1435
- node.send({
1436
- topic: 'rs485-state-change',
1437
- payload: {
1438
- direction: 'RS485→Mesh',
1439
- slaveAddr: slaveAddr,
1440
- funcCode: fc,
1441
- brand: mapping.brand,
1442
- device: mapping.device,
1443
- meshMac: mapping.meshMac,
1444
- state: state
1445
- },
1446
- timestamp: new Date().toISOString()
1447
- });
1448
1949
 
1449
- node.queueCommand({
1450
- direction: 'modbus-to-mesh',
1451
- mapping: mapping,
1452
- registers: registers,
1453
- state: state,
1454
- timestamp: Date.now()
1455
- });
1950
+ if (Object.keys(state).length > 0) {
1951
+ node.log(`[RS485->Mesh] 映射匹配: meshMac=${mapping.meshMac}, meshCH=${mapping.meshChannel}, rs485CH=${mappingRs485Channel}, 状态=${JSON.stringify(state)}`);
1952
+
1953
+ // 输出调试信息到节点输出端口
1954
+ node.send({
1955
+ topic: 'rs485-state-change',
1956
+ payload: {
1957
+ direction: 'RS485→Mesh',
1958
+ slaveAddr: slaveAddr,
1959
+ funcCode: fc,
1960
+ brand: mapping.brand,
1961
+ device: mapping.device,
1962
+ meshMac: mapping.meshMac,
1963
+ meshChannel: mapping.meshChannel,
1964
+ rs485Channel: mappingRs485Channel,
1965
+ state: state
1966
+ },
1967
+ timestamp: new Date().toISOString()
1968
+ });
1969
+
1970
+ node.queueCommand({
1971
+ direction: 'modbus-to-mesh',
1972
+ mapping: mapping,
1973
+ registers: registers,
1974
+ state: state,
1975
+ timestamp: Date.now()
1976
+ });
1977
+ }
1456
1978
  }
1457
1979
  };
1458
1980
 
@@ -1554,9 +2076,11 @@ module.exports = function(RED) {
1554
2076
  // 清理
1555
2077
  node.on('close', (done) => {
1556
2078
  // 移除Mesh网关事件监听器
1557
- node.gateway.removeListener('device-list-complete', init);
1558
- node.gateway.removeListener('device-state-changed', handleMeshStateChange);
1559
- node.gateway.removeListener('curtain-control', handleCurtainControl);
2079
+ if (node.gateway) {
2080
+ node.gateway.removeListener('device-list-complete', init);
2081
+ node.gateway.removeListener('device-state-changed', handleMeshStateChange);
2082
+ node.gateway.removeListener('curtain-control', handleCurtainControl);
2083
+ }
1560
2084
 
1561
2085
  // 移除RS485配置节点事件监听器
1562
2086
  if (node.rs485Config && node._rs485Handlers) {
@@ -1567,6 +2091,15 @@ module.exports = function(RED) {
1567
2091
  node.rs485Config.deregister(node);
1568
2092
  }
1569
2093
 
2094
+ // 清理缓存和队列,防止内存泄漏
2095
+ node.stateCache = {};
2096
+ node.commandQueue = [];
2097
+ node.curtainDebounce = {};
2098
+ node.curtainCache = {};
2099
+ node.lastSentTime = {};
2100
+ node.processing = false;
2101
+ node.syncLock = false;
2102
+
1570
2103
  node.log('[RS485 Bridge] 节点已清理');
1571
2104
  done();
1572
2105
  });
@@ -1603,12 +2136,20 @@ module.exports = function(RED) {
1603
2136
  displayName = (d.name || '设备') + '_' + macClean;
1604
2137
  }
1605
2138
 
2139
+ // 三合一面板特殊处理
2140
+ const isThreeInOne = d.isThreeInOne || false;
2141
+ if (isThreeInOne) {
2142
+ displayName = '三合一面板_' + macClean;
2143
+ }
2144
+
1606
2145
  return {
1607
2146
  mac: d.macAddress,
1608
2147
  name: displayName,
1609
2148
  originalName: d.name,
1610
2149
  type: d.deviceType,
1611
- channels: channels
2150
+ channels: channels,
2151
+ isThreeInOne: isThreeInOne,
2152
+ entityType: d.getEntityType ? d.getEntityType() : 'unknown'
1612
2153
  };
1613
2154
  });
1614
2155
  res.json(devices);