node-red-contrib-symi-mesh 1.7.1 → 1.7.3

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.
@@ -322,10 +322,521 @@ module.exports = function(RED) {
322
322
  // 用户需要在映射中配置: customCodes.trigger
323
323
  }
324
324
  }
325
+ },
326
+ // ===== SYMI空调面板协议 =====
327
+ // 帧格式: 7E [本机地址] [数据类型] [数据长度] [设备类型] [品牌ID] [设备地址] [设备通道] [房间信息3字节] [操作码] [操作信息] [CRC8] 7D
328
+ // 数据类型: 0x01=应答, 0x02=查询, 0x03=设置, 0x04=上报
329
+ // 操作码: 0x01=电源, 0x02=模式, 0x03=风速, 0x04=温度
330
+ 'symi': {
331
+ name: 'SYMI空调面板',
332
+ protocol: 'symi',
333
+ devices: {
334
+ 'climate': {
335
+ name: '空调',
336
+ type: 'climate',
337
+ protocol: 'symi'
338
+ }
339
+ }
340
+ },
341
+ // ===== 中弘VRF空调协议 =====
342
+ // 帧格式: [从机地址] [功能码] [控制值] [空调数量] [外机地址] [内机地址] [校验和]
343
+ // 功能码: 0x31=开关, 0x32=温度, 0x33=模式, 0x34=风速, 0x50=查询
344
+ // 模式: 0x01=制冷, 0x02=除湿, 0x04=送风, 0x08=制热
345
+ // 风速: 0x01=高, 0x02=中, 0x04=低
346
+ 'zhonghong': {
347
+ name: '中弘VRF',
348
+ protocol: 'zhonghong',
349
+ devices: {
350
+ 'climate': {
351
+ name: '空调',
352
+ type: 'climate',
353
+ protocol: 'zhonghong'
354
+ }
355
+ }
325
356
  }
326
357
  }
327
358
  };
328
359
 
360
+ // ===== SYMI协议常量定义 =====
361
+ const SYMI_HEADER = 0x7E;
362
+ const SYMI_FOOTER = 0x7D;
363
+ const SYMI_DEVICE_TYPE_CLIMATE = 0x02;
364
+
365
+ // SYMI数据类型
366
+ const SYMI_DATA_TYPE = {
367
+ RESPONSE: 0x01, // 应答
368
+ QUERY: 0x02, // 查询
369
+ CONTROL: 0x03, // 设置/控制
370
+ REPORT: 0x04 // 上报
371
+ };
372
+
373
+ // SYMI操作码
374
+ const SYMI_OP_CODE = {
375
+ POWER: 0x01, // 电源控制
376
+ MODE: 0x02, // 模式控制
377
+ FAN_SPEED: 0x03, // 风速控制
378
+ TEMPERATURE: 0x04 // 温度控制
379
+ };
380
+
381
+ // SYMI模式值
382
+ const SYMI_MODE = {
383
+ AUTO: 0x00,
384
+ COOL: 0x01,
385
+ DEHUMIDIFY: 0x02,
386
+ FAN: 0x03,
387
+ HEAT: 0x04
388
+ };
389
+
390
+ // SYMI风速值
391
+ const SYMI_FAN_SPEED = {
392
+ AUTO: 0x00,
393
+ LOW: 0x01,
394
+ MEDIUM: 0x02,
395
+ HIGH: 0x03
396
+ };
397
+
398
+ // ===== 中弘协议常量定义 =====
399
+ const ZHONGHONG_FUNC = {
400
+ CTRL_SWITCH: 0x31,
401
+ CTRL_TEMPERATURE: 0x32,
402
+ CTRL_MODE: 0x33,
403
+ CTRL_FAN_MODE: 0x34,
404
+ QUERY: 0x50
405
+ };
406
+
407
+ const ZHONGHONG_MODE = {
408
+ COOL: 0x01,
409
+ DRY: 0x02,
410
+ FAN: 0x04,
411
+ HEAT: 0x08
412
+ };
413
+
414
+ const ZHONGHONG_FAN_SPEED = {
415
+ HIGH: 0x01,
416
+ MEDIUM: 0x02,
417
+ LOW: 0x04
418
+ };
419
+
420
+ // ===== 模式转换映射 =====
421
+ // SYMI模式 -> 中弘模式
422
+ const SYMI_TO_ZH_MODE = {
423
+ 0x00: 0x01, // auto -> cool
424
+ 0x01: 0x01, // cool -> cool
425
+ 0x02: 0x02, // dehumidify -> dry
426
+ 0x03: 0x04, // fan -> fan
427
+ 0x04: 0x08 // heat -> heat
428
+ };
429
+
430
+ // 中弘模式 -> SYMI模式
431
+ const ZH_TO_SYMI_MODE = {
432
+ 0x01: 0x01, // cool -> cool
433
+ 0x02: 0x02, // dry -> dehumidify
434
+ 0x04: 0x03, // fan -> fan
435
+ 0x08: 0x04 // heat -> heat
436
+ };
437
+
438
+ // SYMI风速 -> 中弘风速
439
+ const SYMI_TO_ZH_FAN = {
440
+ 0x00: 0x04, // auto -> low
441
+ 0x01: 0x04, // low -> low
442
+ 0x02: 0x02, // medium -> medium
443
+ 0x03: 0x01 // high -> high
444
+ };
445
+
446
+ // 中弘风速 -> SYMI风速
447
+ const ZH_TO_SYMI_FAN = {
448
+ 0x01: 0x03, // high -> high
449
+ 0x02: 0x02, // medium -> medium
450
+ 0x04: 0x01 // low -> low
451
+ };
452
+
453
+ // ===== SYMI协议核心函数 =====
454
+
455
+ /**
456
+ * 计算SYMI CRC8 (XOR校验)
457
+ * @param {Buffer} data - 从帧头到操作信息的数据
458
+ * @returns {number} - CRC8值
459
+ */
460
+ function calcSymiCrc8(data) {
461
+ let crc = 0;
462
+ for (let i = 0; i < data.length; i++) {
463
+ crc ^= data[i];
464
+ }
465
+ return crc & 0xFF;
466
+ }
467
+
468
+ /**
469
+ * 解析SYMI帧
470
+ * @param {Buffer} frame - 原始帧数据
471
+ * @returns {Object|null} - 解析结果或null
472
+ */
473
+ function parseSymiFrame(frame) {
474
+ // 验证帧头帧尾
475
+ if (frame[0] !== SYMI_HEADER || frame[frame.length - 1] !== SYMI_FOOTER) {
476
+ return null;
477
+ }
478
+
479
+ // 验证最小长度: 7E + localAddr + dataType + dataLen + ... + CRC + 7D
480
+ if (frame.length < 8) {
481
+ return null;
482
+ }
483
+
484
+ const localAddr = frame[1];
485
+ const dataType = frame[2];
486
+ const dataLen = frame[3];
487
+
488
+ // 验证长度: header(1) + localAddr(1) + dataType(1) + dataLen(1) + data(dataLen) + crc(1) + footer(1)
489
+ const expectedLen = 4 + dataLen + 2;
490
+ if (frame.length !== expectedLen) {
491
+ return null;
492
+ }
493
+
494
+ // 验证CRC (从帧头到操作信息,不含CRC和帧尾)
495
+ const crcIndex = frame.length - 2;
496
+ const calculatedCrc = calcSymiCrc8(frame.slice(0, crcIndex));
497
+ if (frame[crcIndex] !== calculatedCrc) {
498
+ return null;
499
+ }
500
+
501
+ // 解析数据部分
502
+ // 格式: deviceType(1) + brandId(1) + deviceAddr(1) + deviceChannel(1) + roomInfo(3) + opCode(1) + opData(n)
503
+ const data = frame.slice(4, 4 + dataLen);
504
+
505
+ // 最小数据长度: deviceType + brandId + deviceAddr + deviceChannel + roomInfo(3) + opCode = 8
506
+ if (data.length < 8) {
507
+ return null;
508
+ }
509
+
510
+ return {
511
+ localAddr,
512
+ dataType,
513
+ deviceType: data[0],
514
+ brandId: data[1],
515
+ deviceAddr: data[2],
516
+ deviceChannel: data[3],
517
+ roomInfo: data.slice(4, 7),
518
+ opCode: data[7],
519
+ opData: data.slice(8)
520
+ };
521
+ }
522
+
523
+ /**
524
+ * 构建SYMI帧
525
+ * @param {Object} params - 帧参数
526
+ * @returns {Buffer} - 完整帧
527
+ */
528
+ function buildSymiFrame(params) {
529
+ const {
530
+ localAddr,
531
+ dataType,
532
+ deviceType = SYMI_DEVICE_TYPE_CLIMATE,
533
+ brandId = 0x00,
534
+ deviceAddr,
535
+ deviceChannel,
536
+ roomInfo = Buffer.from([0x00, 0x00, 0x00]),
537
+ opCode,
538
+ opData
539
+ } = params;
540
+
541
+ // 构建数据部分
542
+ const roomInfoBuf = Buffer.isBuffer(roomInfo) ? roomInfo : Buffer.from(roomInfo);
543
+ const opDataBuf = Buffer.isBuffer(opData) ? opData : Buffer.from(Array.isArray(opData) ? opData : [opData]);
544
+
545
+ const data = Buffer.concat([
546
+ Buffer.from([deviceType, brandId, deviceAddr, deviceChannel]),
547
+ roomInfoBuf,
548
+ Buffer.from([opCode]),
549
+ opDataBuf
550
+ ]);
551
+
552
+ // 构建帧(不含CRC和尾部)
553
+ const frameWithoutCrc = Buffer.concat([
554
+ Buffer.from([SYMI_HEADER, localAddr, dataType, data.length]),
555
+ data
556
+ ]);
557
+
558
+ // 计算CRC
559
+ const crc = calcSymiCrc8(frameWithoutCrc);
560
+
561
+ // 完整帧
562
+ return Buffer.concat([frameWithoutCrc, Buffer.from([crc, SYMI_FOOTER])]);
563
+ }
564
+
565
+ /**
566
+ * 构建SYMI状态上报帧 (用于Zhonghong->SYMI同步)
567
+ * @param {Object} params - 状态参数
568
+ * @returns {Buffer} - 完整帧
569
+ */
570
+ function buildSymiStatusFrame(params) {
571
+ const {
572
+ localAddr,
573
+ deviceAddr,
574
+ deviceChannel,
575
+ brandId = 0x00,
576
+ power, // boolean
577
+ mode, // SYMI mode value
578
+ fanSpeed, // SYMI fan speed value
579
+ targetTemp, // 16-30
580
+ currentTemp // room temperature
581
+ } = params;
582
+
583
+ // 状态上报数据: [switch, mode, fan, targetTemp, roomTemp]
584
+ const opData = Buffer.from([
585
+ power ? 0x01 : 0x00,
586
+ mode,
587
+ fanSpeed,
588
+ targetTemp,
589
+ currentTemp || targetTemp
590
+ ]);
591
+
592
+ return buildSymiFrame({
593
+ localAddr,
594
+ dataType: SYMI_DATA_TYPE.REPORT,
595
+ deviceType: SYMI_DEVICE_TYPE_CLIMATE,
596
+ brandId,
597
+ deviceAddr,
598
+ deviceChannel,
599
+ opCode: 0x00, // 状态上报使用0x00
600
+ opData
601
+ });
602
+ }
603
+
604
+ // ===== 中弘协议核心函数 =====
605
+
606
+ /**
607
+ * 计算中弘校验和 (求和取低8位)
608
+ * @param {Buffer|Array} data - 不含校验和的帧数据
609
+ * @returns {number} - 校验和
610
+ */
611
+ function calcZhonghongChecksum(data) {
612
+ let sum = 0;
613
+ for (let i = 0; i < data.length; i++) {
614
+ sum += data[i];
615
+ }
616
+ return sum & 0xFF;
617
+ }
618
+
619
+ /**
620
+ * 构建中弘控制帧
621
+ * @param {Object} cmd - 命令参数
622
+ * @returns {Buffer} - 完整帧
623
+ */
624
+ function buildZhonghongControlFrame(cmd) {
625
+ const frame = Buffer.from([
626
+ cmd.slaveAddr,
627
+ cmd.funcCode,
628
+ cmd.value,
629
+ 0x01, // 空调数量
630
+ cmd.outdoorAddr,
631
+ cmd.indoorAddr
632
+ ]);
633
+
634
+ const checksum = calcZhonghongChecksum(frame);
635
+ return Buffer.concat([frame, Buffer.from([checksum])]);
636
+ }
637
+
638
+ /**
639
+ * 构建中弘查询帧
640
+ * @param {number} slaveAddr - 从机地址
641
+ * @param {number} outdoorAddr - 外机地址
642
+ * @param {number} indoorAddr - 内机地址
643
+ * @returns {Buffer} - 完整帧
644
+ */
645
+ function buildZhonghongQueryFrame(slaveAddr, outdoorAddr, indoorAddr) {
646
+ const frame = Buffer.from([
647
+ slaveAddr,
648
+ ZHONGHONG_FUNC.QUERY, // 0x50 查询功能码
649
+ 0x01, // 单机查询
650
+ 0x01, // 空调数量
651
+ outdoorAddr,
652
+ indoorAddr
653
+ ]);
654
+
655
+ const checksum = calcZhonghongChecksum(frame);
656
+ return Buffer.concat([frame, Buffer.from([checksum])]);
657
+ }
658
+
659
+ /**
660
+ * 解析中弘状态响应
661
+ * @param {Buffer} frame - 原始帧数据
662
+ * @param {number} expectedSlaveAddr - 期望的从机地址
663
+ * @returns {Object|null} - 解析结果或null
664
+ */
665
+ function parseZhonghongResponse(frame, expectedSlaveAddr) {
666
+ // 最小长度: slaveAddr + func + funcValue + num + (outdoor + indoor + status*8) + checksum
667
+ if (frame.length < 15) {
668
+ return null;
669
+ }
670
+
671
+ const slaveAddr = frame[0];
672
+ const func = frame[1];
673
+
674
+ // 验证从机地址
675
+ if (expectedSlaveAddr !== undefined && slaveAddr !== expectedSlaveAddr) {
676
+ return null;
677
+ }
678
+
679
+ // 只处理查询响应
680
+ if (func !== ZHONGHONG_FUNC.QUERY) {
681
+ return null;
682
+ }
683
+
684
+ const funcValue = frame[2];
685
+ const num = frame[3];
686
+
687
+ // 状态查询响应 (funcValue=0x01)
688
+ if (funcValue !== 0x01) {
689
+ return null;
690
+ }
691
+
692
+ // 计算期望长度: 4 + num * 10 + 1
693
+ const expectedLen = 4 + num * 10 + 1;
694
+ if (frame.length < expectedLen) {
695
+ return null;
696
+ }
697
+
698
+ // 验证校验和
699
+ const checksum = calcZhonghongChecksum(frame.slice(0, expectedLen - 1));
700
+ if (frame[expectedLen - 1] !== checksum) {
701
+ return null;
702
+ }
703
+
704
+ // 解析空调状态
705
+ const climates = [];
706
+ for (let i = 0; i < num; i++) {
707
+ const offset = 4 + i * 10;
708
+ climates.push({
709
+ outdoorAddr: frame[offset],
710
+ indoorAddr: frame[offset + 1],
711
+ onOff: frame[offset + 2] === 0x01,
712
+ targetTemp: frame[offset + 3],
713
+ mode: frame[offset + 4],
714
+ fanMode: frame[offset + 5],
715
+ currentTemp: frame[offset + 6]
716
+ });
717
+ }
718
+
719
+ return {
720
+ slaveAddr,
721
+ funcValue,
722
+ num,
723
+ climates
724
+ };
725
+ }
726
+
727
+ // ===== 协议转换函数 =====
728
+
729
+ /**
730
+ * 转换SYMI命令到中弘命令
731
+ * @param {Object} symiCmd - SYMI解析结果
732
+ * @param {Object} mapping - 映射配置
733
+ * @returns {Object|null} - 中弘命令或null
734
+ */
735
+ function convertSymiToZhonghong(symiCmd, mapping) {
736
+ const zhCmd = {
737
+ slaveAddr: mapping.zhSlaveAddr || 0x01,
738
+ outdoorAddr: mapping.zhOutdoorAddr || 0x01,
739
+ indoorAddr: mapping.zhIndoorAddr || 0x01
740
+ };
741
+
742
+ switch (symiCmd.opCode) {
743
+ case SYMI_OP_CODE.POWER:
744
+ zhCmd.funcCode = ZHONGHONG_FUNC.CTRL_SWITCH;
745
+ zhCmd.value = symiCmd.opData[0] === 0x01 ? 0x01 : 0x00;
746
+ break;
747
+ case SYMI_OP_CODE.MODE:
748
+ zhCmd.funcCode = ZHONGHONG_FUNC.CTRL_MODE;
749
+ zhCmd.value = SYMI_TO_ZH_MODE[symiCmd.opData[0]] || ZHONGHONG_MODE.COOL;
750
+ break;
751
+ case SYMI_OP_CODE.FAN_SPEED:
752
+ zhCmd.funcCode = ZHONGHONG_FUNC.CTRL_FAN_MODE;
753
+ zhCmd.value = SYMI_TO_ZH_FAN[symiCmd.opData[0]] || ZHONGHONG_FAN_SPEED.LOW;
754
+ break;
755
+ case SYMI_OP_CODE.TEMPERATURE:
756
+ zhCmd.funcCode = ZHONGHONG_FUNC.CTRL_TEMPERATURE;
757
+ zhCmd.value = clampTemperature(symiCmd.opData[0]);
758
+ break;
759
+ default:
760
+ return null;
761
+ }
762
+
763
+ return zhCmd;
764
+ }
765
+
766
+ /**
767
+ * 转换中弘状态到SYMI状态
768
+ * @param {Object} zhStatus - 中弘状态
769
+ * @param {Object} mapping - 映射配置
770
+ * @returns {Object} - SYMI状态帧参数
771
+ */
772
+ function convertZhonghongToSymi(zhStatus, mapping) {
773
+ return {
774
+ localAddr: mapping.symiLocalAddr || 0x01,
775
+ deviceAddr: mapping.symiDeviceAddr || 0x01,
776
+ deviceChannel: mapping.symiDeviceChannel || 0x00,
777
+ brandId: mapping.symiBrandId || 0x00,
778
+ power: zhStatus.onOff,
779
+ mode: ZH_TO_SYMI_MODE[zhStatus.mode] || SYMI_MODE.COOL,
780
+ fanSpeed: ZH_TO_SYMI_FAN[zhStatus.fanMode] || SYMI_FAN_SPEED.LOW,
781
+ targetTemp: zhStatus.targetTemp,
782
+ currentTemp: zhStatus.currentTemp
783
+ };
784
+ }
785
+
786
+ /**
787
+ * 温度范围限制 (16-30°C)
788
+ * @param {number} temp - 输入温度
789
+ * @returns {number} - 限制后的温度
790
+ */
791
+ function clampTemperature(temp) {
792
+ return Math.max(16, Math.min(30, temp));
793
+ }
794
+
795
+ // ===== RS485-to-RS485桥接状态缓存 =====
796
+ const climateStateCache = {};
797
+ const SYNC_COOLDOWN_MS = 1000;
798
+ const lastSyncTime = {};
799
+
800
+ /**
801
+ * 检查是否应该同步(防循环)
802
+ * @param {string} sourceKey - 源标识
803
+ * @returns {boolean} - 是否应该同步
804
+ */
805
+ function shouldSync(sourceKey) {
806
+ const now = Date.now();
807
+ if (lastSyncTime[sourceKey] && now - lastSyncTime[sourceKey] < SYNC_COOLDOWN_MS) {
808
+ return false;
809
+ }
810
+ lastSyncTime[sourceKey] = now;
811
+ return true;
812
+ }
813
+
814
+ /**
815
+ * 检查状态是否变化(去重)
816
+ * @param {string} cacheKey - 缓存键
817
+ * @param {Object} newState - 新状态
818
+ * @returns {boolean} - 是否有变化
819
+ */
820
+ function hasStateChanged(cacheKey, newState) {
821
+ const cached = climateStateCache[cacheKey];
822
+ if (!cached) {
823
+ climateStateCache[cacheKey] = { ...newState, lastUpdate: Date.now() };
824
+ return true;
825
+ }
826
+
827
+ const changed =
828
+ cached.power !== newState.power ||
829
+ cached.mode !== newState.mode ||
830
+ cached.fanSpeed !== newState.fanSpeed ||
831
+ cached.targetTemp !== newState.targetTemp;
832
+
833
+ if (changed) {
834
+ climateStateCache[cacheKey] = { ...newState, lastUpdate: Date.now() };
835
+ }
836
+
837
+ return changed;
838
+ }
839
+
329
840
  // ===== 杜亚协议CRC16计算 =====
330
841
  // 杜亚使用CRC16-MODBUS算法,低字节在前
331
842
  function calcA6B6CRC(buffer) {
@@ -528,11 +1039,11 @@ module.exports = function(RED) {
528
1039
  node.climateCache = {};
529
1040
  // 首次启动标记 - 跳过初始状态同步
530
1041
  node.initializing = true;
531
- // 启动后延迟20秒再开始同步(Mesh网关需要15秒以上完成设备发现)
1042
+ // 启动后延迟5秒再开始同步(等待Mesh网关完成设备发现)
532
1043
  setTimeout(() => {
533
1044
  node.initializing = false;
534
1045
  node.log('[RS485 Bridge] 初始化完成,开始同步');
535
- }, 10000); // 10秒初始化延迟
1046
+ }, 5000); // 5秒初始化延迟
536
1047
 
537
1048
  // Mesh设备状态变化处理(事件驱动)
538
1049
  const handleMeshStateChange = (eventData) => {
@@ -540,7 +1051,7 @@ module.exports = function(RED) {
540
1051
  if (node.initializing) return;
541
1052
 
542
1053
  const mac = eventData.device.macAddress;
543
- const state = eventData.state || {};
1054
+ let state = eventData.state || {};
544
1055
 
545
1056
  // 状态缓存比较,只处理真正变化的状态
546
1057
  if (!node.stateCache[mac]) node.stateCache[mac] = {};
@@ -558,14 +1069,17 @@ module.exports = function(RED) {
558
1069
  if (Object.keys(changed).length === 0) return; // 无变化
559
1070
 
560
1071
  // 首次收到设备状态时只记录缓存,不触发同步(避免启动时批量发码)
561
- // 但窗帘状态(curtainStatus)除外,因为这是用户的控制命令
1072
+ // 但以下情况除外,因为这些是用户的控制命令:
1073
+ // - 窗帘状态(curtainStatus)
1074
+ // - 来自用户控制的事件(isUserControl=true)
562
1075
  const hasCurtainStatusChange = changed.curtainStatus !== undefined;
563
- if (isFirstState && !hasCurtainStatusChange) {
1076
+ const isUserControlEvent = eventData.isUserControl === true;
1077
+ if (isFirstState && !hasCurtainStatusChange && !isUserControlEvent) {
564
1078
  node.debug(`[Mesh事件] MAC=${mac} 首次状态,仅缓存: ${JSON.stringify(changed)}`);
565
1079
  return;
566
1080
  }
567
1081
 
568
- node.log(`[Mesh事件] MAC=${mac}, 变化: ${JSON.stringify(changed)}`);
1082
+ node.debug(`[Mesh事件] MAC=${mac}, 变化: ${JSON.stringify(changed)}`);
569
1083
 
570
1084
  // 规范化MAC地址用于比较
571
1085
  const macNormalized = mac.toLowerCase().replace(/:/g, '');
@@ -575,7 +1089,7 @@ module.exports = function(RED) {
575
1089
  const mappingMacNormalized = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
576
1090
  if (mappingMacNormalized !== macNormalized) continue;
577
1091
 
578
- node.log(`[Mesh事件] 匹配到映射: brand=${mapping.brand}, device=${mapping.device}, codes=${JSON.stringify(mapping.customCodes)}`);
1092
+ node.debug(`[Mesh事件] 匹配到映射: brand=${mapping.brand}, device=${mapping.device}`);
579
1093
 
580
1094
  const configChannel = mapping.meshChannel || 1;
581
1095
  const switchKey = `switch_${configChannel}`;
@@ -719,31 +1233,19 @@ module.exports = function(RED) {
719
1233
  continue;
720
1234
  }
721
1235
 
722
- // 【自定义窗帘】处理Mesh面板控制同步到485(逻辑同杜亚)
1236
+ // 【自定义窗帘】处理Mesh面板控制同步到485
723
1237
  if (mapping.brand === 'custom' && mapping.device === 'custom_curtain' && mapping.customCodes) {
724
1238
  const now = Date.now();
725
1239
  const curtainKey = `custom_curtain_${mac}`;
726
1240
 
727
- // 【重要】只处理用户控制帧,忽略电机反馈
728
- if (!eventData.isUserControl) {
729
- continue; // 非用户控制(NODE_STATUS),忽略
730
- }
731
-
732
- // 只处理窗帘状态事件 (attrType=0x05)
733
- if (eventData.attrType !== 0x05) {
1241
+ // 检查是否有窗帘状态变化
1242
+ if (changed.curtainStatus === undefined && changed.curtainAction === undefined) {
734
1243
  continue;
735
1244
  }
736
1245
 
737
- const newStatus = eventData.parameters && eventData.parameters.length > 0
738
- ? eventData.parameters[0]
739
- : null;
740
-
741
- // 忽略 null 值
742
- if (newStatus === null) {
743
- continue;
744
- }
1246
+ node.log(`[Mesh->自定义窗帘] ${eventData.device.name} 状态变化: ${JSON.stringify(changed)}`);
745
1247
 
746
- // 1秒防抖:避免重复发送相同状态(与杜亚窗帘一致)
1248
+ // 1秒防抖:避免重复发送相同状态
747
1249
  if (!node.curtainDebounce) node.curtainDebounce = {};
748
1250
  const debounceKey = `${curtainKey}_status`;
749
1251
  const lastTime = node.curtainDebounce[debounceKey] || 0;
@@ -753,42 +1255,42 @@ module.exports = function(RED) {
753
1255
  }
754
1256
  node.curtainDebounce[debounceKey] = now;
755
1257
 
756
- // 【兼容两种协议】:
757
- // 米家协议: 0=打开中, 1=关闭中, 2=停止
758
- // 小程序协议: 1=打开, 2=关闭, 3=停止
759
- // 使用device.state.curtainAction来获取正确的动作
760
- const device = node.gateway.deviceManager.getDeviceByMac(mac);
761
- const curtainAction = device ? device.state.curtainAction : null;
1258
+ // 获取窗帘动作
1259
+ const curtainAction = changed.curtainAction || eventData.device.state.curtainAction;
1260
+ const curtainStatus = changed.curtainStatus;
762
1261
 
763
1262
  const codes = mapping.customCodes;
764
1263
  let hexCode = null, actionName = '';
765
1264
 
766
- if (curtainAction === 'opening' && codes.open) {
767
- hexCode = codes.open;
1265
+ // 优先使用curtainAction判断
1266
+ if (curtainAction === 'opening' && codes.sendOpen) {
1267
+ hexCode = codes.sendOpen;
768
1268
  actionName = '打开';
769
- } else if (curtainAction === 'closing' && codes.close) {
770
- hexCode = codes.close;
1269
+ } else if (curtainAction === 'closing' && codes.sendClose) {
1270
+ hexCode = codes.sendClose;
771
1271
  actionName = '关闭';
772
- } else if (curtainAction === 'stopped' && codes.stop) {
773
- hexCode = codes.stop;
774
- actionName = '暂停';
775
- } else {
776
- // 回退:直接使用原始status值(兼容旧逻辑,小程序协议)
777
- if (newStatus === 1 && codes.open) {
778
- hexCode = codes.open; actionName = '打开';
779
- } else if (newStatus === 2 && codes.close) {
780
- hexCode = codes.close; actionName = '关闭';
781
- } else if (newStatus === 3 && codes.stop) {
782
- hexCode = codes.stop; actionName = '暂停';
1272
+ } else if (curtainAction === 'stopped' && codes.sendStop) {
1273
+ hexCode = codes.sendStop;
1274
+ actionName = '停止';
1275
+ } else if (curtainStatus !== undefined) {
1276
+ // 回退:使用curtainStatus(小程序协议:1=开,2=关,3=停)
1277
+ if (curtainStatus === 1 && codes.sendOpen) {
1278
+ hexCode = codes.sendOpen; actionName = '打开';
1279
+ } else if (curtainStatus === 2 && codes.sendClose) {
1280
+ hexCode = codes.sendClose; actionName = '关闭';
1281
+ } else if ((curtainStatus === 0 || curtainStatus === 3) && codes.sendStop) {
1282
+ hexCode = codes.sendStop; actionName = '停止';
783
1283
  }
784
1284
  }
785
1285
 
786
1286
  if (hexCode) {
787
1287
  node.sendCustomCode(hexCode).then(() => {
788
- node.log(`[Mesh->自定义] ${actionName}: ${hexCode}`);
1288
+ node.log(`[Mesh->自定义窗帘] ${actionName}: ${hexCode}`);
789
1289
  }).catch(err => {
790
- node.error(`[Mesh->自定义] 发送失败: ${err.message}`);
1290
+ node.error(`[Mesh->自定义窗帘] 发送失败: ${err.message}`);
791
1291
  });
1292
+ } else {
1293
+ node.debug(`[Mesh->自定义窗帘] 无匹配码, action=${curtainAction}, status=${curtainStatus}`);
792
1294
  }
793
1295
  continue;
794
1296
  }
@@ -797,7 +1299,28 @@ module.exports = function(RED) {
797
1299
  const isCurtainControlled = false;
798
1300
 
799
1301
  // 只有对应类型的状态变化才触发对应类型的映射
800
- const hasSwitchChange = isSwitch && changed[switchKey] !== undefined;
1302
+ // 对于custom_switch,检查任意switch_*字段变化(因为用户可能控制任意一路)
1303
+ let hasSwitchChange = false;
1304
+ if (isSwitch) {
1305
+ // 检查配置的通道
1306
+ if (changed[switchKey] !== undefined) {
1307
+ hasSwitchChange = true;
1308
+ }
1309
+ // 对于custom品牌,只检查配置的通道或通用switch字段
1310
+ if (mapping.brand === 'custom' && !hasSwitchChange) {
1311
+ // 通用switch字段(单路开关或空调开关)
1312
+ if (changed.switch !== undefined || changed.acSwitch !== undefined || changed.climateSwitch !== undefined) {
1313
+ hasSwitchChange = true;
1314
+ }
1315
+ }
1316
+ // 非custom品牌也检查通用switch字段
1317
+ if (!hasSwitchChange && (changed.switch !== undefined || changed.acSwitch !== undefined)) {
1318
+ hasSwitchChange = true;
1319
+ }
1320
+ }
1321
+
1322
+ node.debug(`[Mesh事件] isSwitch=${isSwitch}, hasSwitchChange=${hasSwitchChange}, switchKey=${switchKey}`);
1323
+
801
1324
  // 窗帘:如果是杜亚窗帘,跳过状态变化处理(已在curtain-control中处理)
802
1325
  const hasCurtainChange = isCurtain && !isCurtainControlled && (
803
1326
  changed.curtainAction !== undefined ||
@@ -1179,17 +1702,59 @@ module.exports = function(RED) {
1179
1702
  if (mapping.brand === 'custom' && mapping.customCodes) {
1180
1703
  const codes = mapping.customCodes;
1181
1704
 
1182
- node.log(`[Mesh->自定义] 设备${mapping.device}, 状态: ${JSON.stringify(state)}`);
1705
+ // 检查反馈选项:如果feedback=false,检查是否是RS485触发的状态变化
1706
+ // 如果是RS485触发的(500ms内有同步记录),则跳过发送反馈码
1707
+ const loopKey = `${mapping.meshMac}_${mapping.device}`;
1708
+ if (mapping.feedback === false) {
1709
+ const lastSync = node.lastSyncTime ? node.lastSyncTime[loopKey] : 0;
1710
+ if (lastSync && Date.now() - lastSync < 500) {
1711
+ node.debug(`[Mesh->自定义] 反馈已禁用,跳过发送`);
1712
+ return;
1713
+ }
1714
+ }
1715
+
1716
+ node.debug(`[Mesh->自定义] 设备${mapping.device}, 状态: ${JSON.stringify(state)}`);
1183
1717
 
1184
1718
  // 开关类型
1185
1719
  if (mapping.device === 'custom_switch') {
1186
- for (const [key, value] of Object.entries(state)) {
1187
- if (key === 'switch' || key === 'acSwitch' || key.startsWith('switch_')) {
1188
- const hexCode = value ? codes.sendOn : codes.sendOff;
1189
- if (hexCode) {
1190
- await node.sendCustomCode(hexCode);
1191
- node.log(`[Mesh->自定义] 开关: ${value ? '开' : '关'}, 发送: ${hexCode}`);
1192
- }
1720
+ const configChannel = mapping.meshChannel || 1;
1721
+ const targetKey = `switch_${configChannel}`;
1722
+
1723
+ // 查找开关状态:只检查配置的通道或通用switch字段
1724
+ let switchValue = undefined;
1725
+ let foundKey = null;
1726
+
1727
+ // 1. 检查配置的通道
1728
+ if (state[targetKey] !== undefined) {
1729
+ switchValue = state[targetKey];
1730
+ foundKey = targetKey;
1731
+ }
1732
+ // 2. 回退到通用switch字段(单路开关或空调开关)
1733
+ if (switchValue === undefined && state.switch !== undefined) {
1734
+ switchValue = state.switch;
1735
+ foundKey = 'switch';
1736
+ }
1737
+ if (switchValue === undefined && state.acSwitch !== undefined) {
1738
+ switchValue = state.acSwitch;
1739
+ foundKey = 'acSwitch';
1740
+ }
1741
+ if (switchValue === undefined && state.climateSwitch !== undefined) {
1742
+ switchValue = state.climateSwitch;
1743
+ foundKey = 'climateSwitch';
1744
+ }
1745
+
1746
+ if (switchValue !== undefined) {
1747
+ const hexCode = switchValue ? codes.sendOn : codes.sendOff;
1748
+ if (hexCode) {
1749
+ // 记录发送时间用于防死循环
1750
+ const loopKey = `${mapping.meshMac}_${mapping.device}`;
1751
+ if (!node.lastSyncTime) node.lastSyncTime = {};
1752
+ node.lastSyncTime[loopKey] = Date.now();
1753
+
1754
+ await node.sendCustomCode(hexCode);
1755
+ node.log(`[Mesh->自定义] 开关(${foundKey}): ${switchValue ? '开' : '关'}, 发送: ${hexCode}`);
1756
+ } else {
1757
+ node.warn(`[Mesh->自定义] 开关(${foundKey}): ${switchValue ? '开' : '关'}, 缺少${switchValue ? 'sendOn' : 'sendOff'}码`);
1193
1758
  }
1194
1759
  }
1195
1760
  }
@@ -1238,6 +1803,12 @@ module.exports = function(RED) {
1238
1803
  } else {
1239
1804
  if (!node.lastSentTime) node.lastSentTime = {};
1240
1805
  node.lastSentTime[cacheKey] = now;
1806
+
1807
+ // 记录发送时间用于防死循环
1808
+ const loopKey = `${mapping.meshMac}_${mapping.device}`;
1809
+ if (!node.lastSyncTime) node.lastSyncTime = {};
1810
+ node.lastSyncTime[loopKey] = now;
1811
+
1241
1812
  await node.sendCustomCode(hexCode);
1242
1813
  node.log(`[Mesh->自定义] 窗帘 ${actionName}, 发送: ${hexCode}`);
1243
1814
  }
@@ -1245,47 +1816,75 @@ module.exports = function(RED) {
1245
1816
  }
1246
1817
  // 空调类型
1247
1818
  else if (mapping.device === 'custom_climate') {
1248
- node.log(`[自定义空调] 处理状态: ${JSON.stringify(state)}, codes存在: ${!!codes}`);
1819
+ node.debug(`[自定义空调] 处理状态: ${JSON.stringify(state)}, codes存在: ${!!codes}`);
1820
+
1821
+ // 记录发送时间用于防死循环
1822
+ const loopKey = `${mapping.meshMac}_${mapping.device}`;
1823
+ if (!node.lastSyncTime) node.lastSyncTime = {};
1824
+
1825
+ // 去重处理:记录已处理的命令类型
1826
+ const processedTypes = new Set();
1827
+
1249
1828
  for (const [key, value] of Object.entries(state)) {
1250
1829
  let hexCode = null;
1830
+ let codeType = '';
1831
+ let cmdCategory = ''; // 命令类别,用于去重
1251
1832
 
1252
- // 1. 开关控制 (处理 acSwitch, climateSwitch, switch)
1833
+ // 1. 开关控制 (处理 acSwitch, climateSwitch, switch) - 只处理一次
1253
1834
  if (key === 'acSwitch' || key === 'climateSwitch' || key === 'switch') {
1835
+ cmdCategory = 'switch';
1836
+ if (processedTypes.has(cmdCategory)) continue; // 跳过重复
1837
+ processedTypes.add(cmdCategory);
1838
+
1254
1839
  const isOn = value === true || value === 1 || value === '1' || value === 'on' || value === 'ON';
1255
1840
  hexCode = isOn ? (codes.acSendOn || codes.sendOn) : (codes.acSendOff || codes.sendOff);
1256
- node.log(`[自定义空调] 开关控制: ${key}=${value}, isOn=${isOn}, hexCode=${hexCode}`);
1841
+ codeType = isOn ? 'acSendOn' : 'acSendOff';
1257
1842
  }
1258
- // 2. 风速控制
1843
+ // 2. 风速控制 - 只处理一次
1259
1844
  else if (['acFanSpeed', 'fanSpeed', 'climateFanSpeed', 'fanMode', 'fan_speed', 'fanLevel', 'fan_level', 'speed'].includes(key)) {
1845
+ cmdCategory = 'fan';
1846
+ if (processedTypes.has(cmdCategory)) continue; // 跳过重复
1847
+ processedTypes.add(cmdCategory);
1848
+
1260
1849
  const val = parseInt(value);
1261
- node.log(`[自定义空调] 风速控制: ${key}=${value}, val=${val}`);
1262
1850
  // 标准Mesh协议: 1=高, 2=中, 3=低, 4=自动
1263
1851
  if (val === 1) {
1264
1852
  hexCode = codes.fanSendHigh;
1265
- node.log(`[自定义空调] 高风, hexCode=${hexCode}`);
1853
+ codeType = 'fanSendHigh';
1266
1854
  }
1267
1855
  else if (val === 2) {
1268
1856
  hexCode = codes.fanSendMid;
1269
- node.log(`[自定义空调] 中风, hexCode=${hexCode}`);
1857
+ codeType = 'fanSendMid';
1270
1858
  }
1271
- else if (val === 3 || val === 0 || val === 4) {
1859
+ else if (val === 3) {
1272
1860
  hexCode = codes.fanSendLow;
1273
- node.log(`[自定义空调] 低风, hexCode=${hexCode}`);
1861
+ codeType = 'fanSendLow';
1862
+ }
1863
+ else if (val === 4 || val === 0) {
1864
+ hexCode = codes.fanSendAuto;
1865
+ codeType = 'fanSendAuto';
1274
1866
  }
1275
1867
  }
1276
- // 3. 模式控制
1868
+ // 3. 模式控制 - 只处理一次
1277
1869
  else if (key === 'acMode' || key === 'climateMode' || key === 'mode') {
1278
- if (value === 1) hexCode = codes.modeSendCool;
1279
- else if (value === 2) hexCode = codes.modeSendHeat;
1280
- else if (value === 4) hexCode = codes.modeSendDry;
1281
- else if (value === 3) hexCode = codes.modeSendFan;
1870
+ cmdCategory = 'mode';
1871
+ if (processedTypes.has(cmdCategory)) continue;
1872
+ processedTypes.add(cmdCategory);
1873
+
1874
+ if (value === 1) { hexCode = codes.modeSendCool; codeType = 'modeSendCool'; }
1875
+ else if (value === 2) { hexCode = codes.modeSendHeat; codeType = 'modeSendHeat'; }
1876
+ else if (value === 4) { hexCode = codes.modeSendDry; codeType = 'modeSendDry'; }
1877
+ else if (value === 3) { hexCode = codes.modeSendFan; codeType = 'modeSendFan'; }
1282
1878
 
1283
1879
  // 更新缓存中的模式
1284
1880
  if (!node.climateCache[mapping.meshMac]) node.climateCache[mapping.meshMac] = {};
1285
1881
  node.climateCache[mapping.meshMac].mode = value;
1286
1882
  }
1287
- // 4. 温度控制
1883
+ // 4. 温度控制 - 只处理一次
1288
1884
  else if (key === 'targetTemp' || key === 'acTargetTemp' || key === 'temperature') {
1885
+ cmdCategory = 'temp';
1886
+ if (processedTypes.has(cmdCategory)) continue;
1887
+ processedTypes.add(cmdCategory);
1289
1888
  const temp = Math.round(value);
1290
1889
  // 优先从当前变化中获取模式,否则从缓存中获取,默认制冷(1)
1291
1890
  const mode = state.acMode || state.climateMode || (node.climateCache[mapping.meshMac] ? node.climateCache[mapping.meshMac].mode : 1);
@@ -1308,8 +1907,11 @@ module.exports = function(RED) {
1308
1907
  }
1309
1908
 
1310
1909
  if (hexCode) {
1910
+ node.lastSyncTime[loopKey] = Date.now();
1311
1911
  await node.sendCustomCode(hexCode);
1312
1912
  node.log(`[Mesh->自定义] 空调 ${key}=${value}, 发送: ${hexCode}`);
1913
+ } else if (codeType) {
1914
+ node.warn(`[Mesh->自定义] 空调 ${key}=${value}, 缺少${codeType}码`);
1313
1915
  }
1314
1916
  }
1315
1917
  }
@@ -1568,10 +2170,10 @@ module.exports = function(RED) {
1568
2170
  // 开关类型
1569
2171
  if (key === 'switch' || key.startsWith('switch_')) {
1570
2172
  const ch = key.startsWith('switch_') ? parseInt(key.replace('switch_', '')) : channel;
1571
- const onOff = value ? 0x02 : 0x01;
1572
- const param = Buffer.from([ch - 1, onOff]);
2173
+ const totalChannels = meshDevice.channels || 1;
2174
+ const param = Buffer.from([totalChannels, ch, value ? 1 : 0]);
1573
2175
  await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
1574
- node.log(`[自定义->Mesh] 开关${ch}: ${value ? '开' : '关'}`);
2176
+ node.log(`[自定义->Mesh] 开关${ch}/${totalChannels}路: ${value ? '开' : '关'}`);
1575
2177
  }
1576
2178
  // 空调开关
1577
2179
  else if (key === 'acSwitch' || key === 'climateSwitch') {
@@ -1628,12 +2230,11 @@ module.exports = function(RED) {
1628
2230
  if (key.startsWith('switch')) {
1629
2231
  const channelFromKey = parseInt(key.replace('switch', '')) || 1;
1630
2232
  const ch = mapping.meshChannel || channelFromKey;
1631
-
1632
- const onOff = value ? 0x02 : 0x01;
1633
- const param = Buffer.from([ch - 1, onOff]);
2233
+ const totalChannels = meshDevice.channels || 1;
2234
+ const param = Buffer.from([totalChannels, ch, value ? 1 : 0]);
1634
2235
 
1635
2236
  await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
1636
- node.log(`[RS485->Mesh] 开关${ch}: ${value ? '开' : '关'}`);
2237
+ node.log(`[RS485->Mesh] 开关${ch}/${totalChannels}路: ${value ? '开' : '关'}`);
1637
2238
  }
1638
2239
  else if (key.startsWith('led')) {
1639
2240
  node.debug(`[RS485] 指示灯${key}: ${value}`);
@@ -1789,7 +2390,7 @@ module.exports = function(RED) {
1789
2390
  }
1790
2391
  try {
1791
2392
  const hexStr = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
1792
- node.log(`[RS485 TX] 发送帧: ${hexStr}`);
2393
+ node.debug(`[RS485 TX] 发送帧: ${hexStr}`);
1793
2394
 
1794
2395
  await node.rs485Config.send(frame);
1795
2396
 
@@ -1811,12 +2412,12 @@ module.exports = function(RED) {
1811
2412
 
1812
2413
  // 解析原始RS485帧 - 支持标准Modbus和自定义码匹配
1813
2414
  node.parseRS485Frame = function(frame) {
1814
- if (frame.length < 4) return;
2415
+ if (frame.length < 2) return; // 自定义码可能很短,改为2字节
1815
2416
 
1816
2417
  const hexStr = frame.toString('hex').toUpperCase().replace(/\s/g, ''); // 确保完全去掉空格
1817
2418
  const hexFormatted = hexStr.match(/.{1,2}/g)?.join(' ') || '';
1818
2419
 
1819
- node.log(`[RS485收到] ${hexFormatted} (${frame.length}字节)`);
2420
+ node.log(`[RS485帧解析] 收到: ${hexFormatted} (${frame.length}字节)`);
1820
2421
 
1821
2422
  // ===== 杜亚窗帘协议检测 (55开头) =====
1822
2423
  if (frame[0] === 0x55 && frame.length >= 7) {
@@ -1884,21 +2485,190 @@ module.exports = function(RED) {
1884
2485
  }
1885
2486
  }
1886
2487
 
2488
+ // ===== SYMI空调面板协议检测 (7E开头7D结尾) =====
2489
+ if (frame[0] === SYMI_HEADER && frame[frame.length - 1] === SYMI_FOOTER && frame.length >= 8) {
2490
+ node.log(`[SYMI帧检测] 检测到7E...7D帧, 长度=${frame.length}`);
2491
+ const symiData = parseSymiFrame(frame);
2492
+ if (symiData) {
2493
+ node.log(`[SYMI帧解析] 成功! localAddr=${symiData.localAddr}, dataType=${symiData.dataType}, opCode=${symiData.opCode}`);
2494
+
2495
+ // 只处理空调设备类型
2496
+ if (symiData.deviceType !== SYMI_DEVICE_TYPE_CLIMATE) {
2497
+ node.debug(`[SYMI] 非空调设备类型: ${symiData.deviceType}`);
2498
+ return;
2499
+ }
2500
+
2501
+ // 查找匹配的SYMI->Zhonghong桥接映射
2502
+ for (const mapping of node.mappings) {
2503
+ if (mapping.brand !== 'symi' || !mapping.zhBridgeTarget) {
2504
+ continue;
2505
+ }
2506
+
2507
+ // 检查SYMI地址是否匹配
2508
+ const symiLocalAddr = mapping.symiLocalAddr || 0x01;
2509
+ const symiDeviceAddr = mapping.symiDeviceAddr || 0x01;
2510
+ const symiDeviceChannel = mapping.symiDeviceChannel || 0x00;
2511
+
2512
+ if (symiData.localAddr !== symiLocalAddr ||
2513
+ symiData.deviceAddr !== symiDeviceAddr ||
2514
+ symiData.deviceChannel !== symiDeviceChannel) {
2515
+ continue;
2516
+ }
2517
+
2518
+ // 防循环检查
2519
+ const syncKey = `symi_${symiLocalAddr}_${symiDeviceAddr}_${symiDeviceChannel}`;
2520
+ if (!shouldSync(syncKey)) {
2521
+ node.debug(`[防循环] 忽略SYMI帧: ${hexFormatted}`);
2522
+ return;
2523
+ }
2524
+
2525
+ // 只处理控制命令 (dataType=0x03)
2526
+ if (symiData.dataType !== SYMI_DATA_TYPE.CONTROL) {
2527
+ node.debug(`[SYMI] 非控制命令: dataType=${symiData.dataType}`);
2528
+ continue;
2529
+ }
2530
+
2531
+ node.log(`[SYMI->Zhonghong] 控制命令: opCode=${symiData.opCode}, opData=${symiData.opData.toString('hex')}`);
2532
+
2533
+ // 转换为中弘命令
2534
+ const zhCmd = convertSymiToZhonghong(symiData, mapping);
2535
+ if (!zhCmd) {
2536
+ node.warn(`[SYMI->Zhonghong] 未知操作码: ${symiData.opCode}`);
2537
+ continue;
2538
+ }
2539
+
2540
+ // 构建并发送中弘控制帧
2541
+ const zhFrame = buildZhonghongControlFrame(zhCmd);
2542
+ node.sendRS485Frame(zhFrame).then(() => {
2543
+ const zhHex = zhFrame.toString('hex').toUpperCase().match(/.{2}/g).join(' ');
2544
+ node.log(`[SYMI->Zhonghong] 发送: ${zhHex}`);
2545
+
2546
+ // 输出调试信息
2547
+ node.send({
2548
+ topic: 'symi-to-zhonghong',
2549
+ payload: {
2550
+ direction: 'SYMI→Zhonghong',
2551
+ symiOpCode: symiData.opCode,
2552
+ zhFuncCode: zhCmd.funcCode,
2553
+ zhValue: zhCmd.value,
2554
+ frame: zhHex
2555
+ },
2556
+ timestamp: new Date().toISOString()
2557
+ });
2558
+ }).catch(err => {
2559
+ node.error(`[SYMI->Zhonghong] 发送失败: ${err.message}`);
2560
+ });
2561
+
2562
+ return;
2563
+ }
2564
+
2565
+ node.debug(`[SYMI] 未找到匹配的桥接映射`);
2566
+ } else {
2567
+ node.debug(`[SYMI帧检测] CRC校验失败或格式错误`);
2568
+ }
2569
+ }
2570
+
2571
+ // ===== 中弘VRF协议检测 (查询响应) =====
2572
+ // 中弘响应帧: slaveAddr + 0x50 + funcValue + num + data... + checksum
2573
+ if (frame.length >= 15 && frame[1] === ZHONGHONG_FUNC.QUERY) {
2574
+ node.log(`[中弘帧检测] 检测到查询响应, 长度=${frame.length}`);
2575
+
2576
+ // 查找匹配的Zhonghong->SYMI桥接映射
2577
+ for (const mapping of node.mappings) {
2578
+ if (mapping.brand !== 'zhonghong' || !mapping.symiBridgeTarget) {
2579
+ continue;
2580
+ }
2581
+
2582
+ const zhSlaveAddr = mapping.zhSlaveAddr || 0x01;
2583
+ const zhOutdoorAddr = mapping.zhOutdoorAddr || 0x01;
2584
+ const zhIndoorAddr = mapping.zhIndoorAddr || 0x01;
2585
+
2586
+ // 验证从机地址
2587
+ if (frame[0] !== zhSlaveAddr) {
2588
+ continue;
2589
+ }
2590
+
2591
+ const zhResponse = parseZhonghongResponse(frame, zhSlaveAddr);
2592
+ if (!zhResponse) {
2593
+ node.debug(`[中弘帧检测] 解析失败或校验错误`);
2594
+ continue;
2595
+ }
2596
+
2597
+ // 查找匹配的空调状态
2598
+ const climate = zhResponse.climates.find(c =>
2599
+ c.outdoorAddr === zhOutdoorAddr && c.indoorAddr === zhIndoorAddr
2600
+ );
2601
+
2602
+ if (!climate) {
2603
+ node.debug(`[中弘] 未找到匹配的空调: outdoor=${zhOutdoorAddr}, indoor=${zhIndoorAddr}`);
2604
+ continue;
2605
+ }
2606
+
2607
+ // 防循环检查
2608
+ const syncKey = `zh_${zhSlaveAddr}_${zhOutdoorAddr}_${zhIndoorAddr}`;
2609
+ if (!shouldSync(syncKey)) {
2610
+ node.debug(`[防循环] 忽略中弘响应: ${hexFormatted}`);
2611
+ return;
2612
+ }
2613
+
2614
+ // 状态去重检查
2615
+ const cacheKey = `zh_climate_${zhSlaveAddr}_${zhOutdoorAddr}_${zhIndoorAddr}`;
2616
+ const newState = {
2617
+ power: climate.onOff,
2618
+ mode: climate.mode,
2619
+ fanSpeed: climate.fanMode,
2620
+ targetTemp: climate.targetTemp
2621
+ };
2622
+
2623
+ if (!hasStateChanged(cacheKey, newState)) {
2624
+ node.debug(`[中弘] 状态未变化,跳过同步`);
2625
+ continue;
2626
+ }
2627
+
2628
+ node.log(`[Zhonghong->SYMI] 状态: power=${climate.onOff}, mode=${climate.mode}, fan=${climate.fanMode}, temp=${climate.targetTemp}°C`);
2629
+
2630
+ // 转换为SYMI状态
2631
+ const symiParams = convertZhonghongToSymi(climate, mapping);
2632
+
2633
+ // 构建并发送SYMI状态帧
2634
+ const symiFrame = buildSymiStatusFrame(symiParams);
2635
+ node.sendRS485Frame(symiFrame).then(() => {
2636
+ const symiHex = symiFrame.toString('hex').toUpperCase().match(/.{2}/g).join(' ');
2637
+ node.log(`[Zhonghong->SYMI] 发送: ${symiHex}`);
2638
+
2639
+ // 输出调试信息
2640
+ node.send({
2641
+ topic: 'zhonghong-to-symi',
2642
+ payload: {
2643
+ direction: 'Zhonghong→SYMI',
2644
+ zhStatus: climate,
2645
+ symiParams: symiParams,
2646
+ frame: symiHex
2647
+ },
2648
+ timestamp: new Date().toISOString()
2649
+ });
2650
+ }).catch(err => {
2651
+ node.error(`[Zhonghong->SYMI] 发送失败: ${err.message}`);
2652
+ });
2653
+
2654
+ return;
2655
+ }
2656
+ }
2657
+
1887
2658
  // 检查自定义码匹配(遍历所有映射)
1888
- node.log(`[自定义码检测] 开始匹配, 当前帧hex=${hexStr}, 映射数=${node.mappings.length}`);
2659
+ node.debug(`[自定义码检测] 开始匹配, 当前帧hex=${hexStr}, 映射数=${node.mappings.length}`);
1889
2660
  for (const mapping of node.mappings) {
1890
2661
  if (mapping.brand === 'custom' && mapping.customCodes) {
1891
2662
  const codes = mapping.customCodes;
1892
2663
  let matchedAction = null;
1893
2664
 
1894
- node.log(`[自定义码检测] 检查映射: device=${mapping.device}, meshMac=${mapping.meshMac}`);
2665
+ node.debug(`[自定义码检测] 检查映射: device=${mapping.device}, meshMac=${mapping.meshMac}`);
1895
2666
 
1896
2667
  // 开关类型:匹配recvOn/recvOff
1897
2668
  if (mapping.device === 'custom_switch') {
1898
2669
  const recvOn = (codes.recvOn || '').replace(/\s/g, '').toUpperCase();
1899
2670
  const recvOff = (codes.recvOff || '').replace(/\s/g, '').toUpperCase();
1900
- node.log(`[自定义开关] recvOn=${recvOn}, recvOff=${recvOff}, hexStr=${hexStr}`);
1901
- node.log(`[自定义开关] 包含recvOn? ${hexStr.includes(recvOn)}, 包含recvOff? ${hexStr.includes(recvOff)}`);
2671
+ node.debug(`[自定义开关] recvOn=${recvOn}, recvOff=${recvOff}`);
1902
2672
 
1903
2673
  // 翻转模式:收开码=收关码
1904
2674
  if (recvOn && recvOff && recvOn === recvOff && hexStr.includes(recvOn)) {
@@ -1907,14 +2677,14 @@ module.exports = function(RED) {
1907
2677
  const stateKey = mapping.meshChannel > 1 ? `switch_${channel}` : 'switch';
1908
2678
  const currentState = device?.state?.[stateKey] || false;
1909
2679
  matchedAction = { switch: !currentState };
1910
- node.log(`[自定义开关] 翻转: ${stateKey} ${currentState} -> ${!currentState}`);
2680
+ node.debug(`[自定义开关] 翻转: ${stateKey} ${currentState} -> ${!currentState}`);
1911
2681
  } else {
1912
2682
  if (recvOn && hexStr.includes(recvOn)) {
1913
2683
  matchedAction = { switch: true };
1914
- node.log(`[自定义开关] 匹配到收开码`);
2684
+ node.debug(`[自定义开关] 匹配到收开码`);
1915
2685
  } else if (recvOff && hexStr.includes(recvOff)) {
1916
2686
  matchedAction = { switch: false };
1917
- node.log(`[自定义开关] 匹配到收关码`);
2687
+ node.debug(`[自定义开关] 匹配到收关码`);
1918
2688
  }
1919
2689
  }
1920
2690
  }
@@ -1926,13 +2696,13 @@ module.exports = function(RED) {
1926
2696
 
1927
2697
  if (recvOpen && hexStr.includes(recvOpen)) {
1928
2698
  matchedAction = { action: 'open' };
1929
- node.log(`[自定义窗帘] 收开码`);
2699
+ node.debug(`[自定义窗帘] 收开码`);
1930
2700
  } else if (recvClose && hexStr.includes(recvClose)) {
1931
2701
  matchedAction = { action: 'close' };
1932
- node.log(`[自定义窗帘] 收关码`);
2702
+ node.debug(`[自定义窗帘] 收关码`);
1933
2703
  } else if (recvStop && hexStr.includes(recvStop)) {
1934
2704
  matchedAction = { action: 'stop' };
1935
- node.log(`[自定义窗帘] 收停码`);
2705
+ node.debug(`[自定义窗帘] 收停码`);
1936
2706
  }
1937
2707
  }
1938
2708
  // 空调类型:匹配收码
@@ -2024,7 +2794,7 @@ module.exports = function(RED) {
2024
2794
  if (!node.climateCache[mapping.meshMac]) node.climateCache[mapping.meshMac] = {};
2025
2795
  node.climateCache[mapping.meshMac].mode = matchedAction.acMode;
2026
2796
  }
2027
- node.log(`[自定义空调] 匹配: ${JSON.stringify(matchedAction)}`);
2797
+ node.debug(`[自定义空调] 匹配: ${JSON.stringify(matchedAction)}`);
2028
2798
  }
2029
2799
  }
2030
2800
  // 场景类型:匹配trigger
@@ -2036,7 +2806,10 @@ module.exports = function(RED) {
2036
2806
 
2037
2807
  if (matchedAction) {
2038
2808
  // 防死循环:检查是否刚刚从Mesh发送过来
2039
- if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 500) {
2809
+ // 使用映射特定的时间戳,避免其他设备的同步影响
2810
+ const loopKey = `${mapping.meshMac}_${mapping.device}`;
2811
+ if (!node.lastSyncTime) node.lastSyncTime = {};
2812
+ if (node.lastSyncTime[loopKey] && Date.now() - node.lastSyncTime[loopKey] < 500) {
2040
2813
  node.debug(`[防循环] 忽略刚刚同步的帧: ${hexFormatted}`);
2041
2814
  return;
2042
2815
  }
@@ -2056,6 +2829,9 @@ module.exports = function(RED) {
2056
2829
  timestamp: new Date().toISOString()
2057
2830
  });
2058
2831
 
2832
+ // 记录同步时间用于防死循环
2833
+ node.lastSyncTime[loopKey] = Date.now();
2834
+
2059
2835
  node.queueCommand({
2060
2836
  direction: 'modbus-to-mesh',
2061
2837
  mapping: mapping,
@@ -2081,7 +2857,7 @@ module.exports = function(RED) {
2081
2857
  const fc = frame[1];
2082
2858
  const hexFormatted = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
2083
2859
 
2084
- node.log(`[Modbus解析] 从机=${slaveAddr}, 功能码=0x${fc.toString(16)}, 帧=${hexFormatted}`);
2860
+ node.debug(`[Modbus解析] 从机=${slaveAddr}, 功能码=0x${fc.toString(16)}`);
2085
2861
 
2086
2862
  // 查找所有匹配从机地址的映射
2087
2863
  const allMappings = node.findAllRS485Mappings(slaveAddr);
@@ -2090,7 +2866,7 @@ module.exports = function(RED) {
2090
2866
  return;
2091
2867
  }
2092
2868
 
2093
- node.log(`[Modbus解析] 找到${allMappings.length}个匹配映射`);
2869
+ node.debug(`[Modbus解析] 找到${allMappings.length}个匹配映射`);
2094
2870
 
2095
2871
  // 防死循环:检查是否刚刚从Mesh发送过来
2096
2872
  if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 500) {
@@ -2114,7 +2890,7 @@ module.exports = function(RED) {
2114
2890
  rs485ChannelFromReg = regAddr - 0x1030; // 0x1031->1, 0x1032->2, ...
2115
2891
  }
2116
2892
 
2117
- node.log(`[Modbus解析] 寄存器=0x${regAddr.toString(16).toUpperCase()}, 值=${value}, rs485Channel=${rs485ChannelFromReg}`);
2893
+ node.debug(`[Modbus解析] 寄存器=0x${regAddr.toString(16).toUpperCase()}, 值=${value}, rs485Channel=${rs485ChannelFromReg}`);
2118
2894
 
2119
2895
  // 遍历所有匹配的映射,只处理rs485Channel匹配的
2120
2896
  for (const mapping of allMappings) {
@@ -2145,14 +2921,14 @@ module.exports = function(RED) {
2145
2921
  state[key] = reg.map[value] || value;
2146
2922
  } else {
2147
2923
  state[key] = value;
2924
+ node.debug(`[RS485] 从机${slaveAddr} 寄存器0x${regAddr.toString(16).toUpperCase()}: ${key}=${value}`);
2148
2925
  }
2149
- node.log(`[RS485] 从机${slaveAddr} 寄存器0x${regAddr.toString(16).toUpperCase()}: ${key}=${value}`);
2150
2926
  break;
2151
2927
  }
2152
2928
  }
2153
2929
 
2154
2930
  if (Object.keys(state).length > 0) {
2155
- node.log(`[RS485->Mesh] 映射匹配: meshMac=${mapping.meshMac}, meshCH=${mapping.meshChannel}, rs485CH=${mappingRs485Channel}, 状态=${JSON.stringify(state)}`);
2931
+ node.debug(`[RS485->Mesh] 映射匹配: meshMac=${mapping.meshMac}, meshCH=${mapping.meshChannel}, rs485CH=${mappingRs485Channel}`);
2156
2932
 
2157
2933
  // 输出调试信息到节点输出端口
2158
2934
  node.send({