node-red-contrib-symi-mesh 1.6.4 → 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' } }
@@ -235,6 +279,22 @@ module.exports = function(RED) {
235
279
  'scene': { name: '场景', type: 'scene', registers: { trigger: { address: 0x0000, type: 'holding' } } }
236
280
  }
237
281
  },
282
+ // ===== 杜亚窗帘协议 =====
283
+ // 帧格式: 55 [地址高] [地址低] 03 [数据] [CRC16高] [CRC16低]
284
+ // 数据: 01=打开, 02=关闭, 03=停止, 04+位置=百分比
285
+ // 地址: 2字节,如0101表示地址高=01,地址低=01
286
+ 'duya': {
287
+ name: '杜亚窗帘',
288
+ protocol: 'duya', // 标记使用专用协议
289
+ twoByteAddress: true, // 标记使用2字节地址
290
+ devices: {
291
+ 'curtain': {
292
+ name: '窗帘',
293
+ type: 'cover',
294
+ protocol: 'duya'
295
+ }
296
+ }
297
+ },
238
298
  // ===== 自定义协议 - 用户可录入任意RS485码 =====
239
299
  'custom': {
240
300
  name: '自定义协议',
@@ -262,6 +322,71 @@ module.exports = function(RED) {
262
322
  }
263
323
  };
264
324
 
325
+ // ===== 杜亚协议CRC16计算 =====
326
+ // 杜亚使用CRC16-MODBUS算法,低字节在前
327
+ function calcA6B6CRC(buffer) {
328
+ let crc = 0xFFFF;
329
+ for (let i = 0; i < buffer.length; i++) {
330
+ crc ^= buffer[i];
331
+ for (let j = 0; j < 8; j++) {
332
+ if (crc & 0x0001) {
333
+ crc = (crc >> 1) ^ 0xA001;
334
+ } else {
335
+ crc >>= 1;
336
+ }
337
+ }
338
+ }
339
+ // 返回低字节在前(小端序)
340
+ return [crc & 0xFF, (crc >> 8) & 0xFF];
341
+ }
342
+
343
+ // 构建A6B6窗帘控制帧
344
+ // 地址格式: 0x0102 表示地址高=01, 地址低=02
345
+ function buildA6B6Frame(addrHigh, addrLow, action, position) {
346
+ let data;
347
+ if (action === 'position' && position !== undefined) {
348
+ // 百分比控制: 55 addrH addrL 03 04 [位置] CRC
349
+ data = Buffer.from([0x55, addrHigh, addrLow, 0x03, 0x04, position]);
350
+ } else {
351
+ // 动作控制: 55 addrH addrL 03 [动作] CRC
352
+ const actionCode = action === 'open' ? 0x01 : action === 'close' ? 0x02 : 0x03;
353
+ data = Buffer.from([0x55, addrHigh, addrLow, 0x03, actionCode]);
354
+ }
355
+ const crc = calcA6B6CRC(data);
356
+ return Buffer.concat([data, Buffer.from(crc)]);
357
+ }
358
+
359
+ // 解析A6B6窗帘响应帧
360
+ function parseA6B6Frame(frame) {
361
+ if (frame.length < 7 || frame[0] !== 0x55) return null;
362
+
363
+ const addrHigh = frame[1];
364
+ const addrLow = frame[2];
365
+ const funcCode = frame[3];
366
+
367
+ if (funcCode !== 0x03) return null;
368
+
369
+ const dataType = frame[4];
370
+ let result = {
371
+ address: (addrHigh << 8) | addrLow,
372
+ addrHigh: addrHigh,
373
+ addrLow: addrLow
374
+ };
375
+
376
+ if (dataType === 0x01) {
377
+ result.action = 'open';
378
+ } else if (dataType === 0x02) {
379
+ result.action = 'close';
380
+ } else if (dataType === 0x03) {
381
+ result.action = 'stop';
382
+ } else if (dataType === 0x04 && frame.length >= 8) {
383
+ result.action = 'position';
384
+ result.position = frame[5];
385
+ }
386
+
387
+ return result;
388
+ }
389
+
265
390
  function SymiRS485BridgeNode(config) {
266
391
  RED.nodes.createNode(this, config);
267
392
  const node = this;
@@ -273,9 +398,35 @@ module.exports = function(RED) {
273
398
 
274
399
  // 解析实体映射
275
400
  try {
276
- node.mappings = JSON.parse(config.mappings || '[]');
401
+ const rawMappings = JSON.parse(config.mappings || '[]');
402
+ // 确保所有数值字段是正确类型
403
+ node.mappings = rawMappings.map(m => {
404
+ const mapping = {
405
+ ...m,
406
+ address: parseInt(m.address) || 1,
407
+ meshChannel: parseInt(m.meshChannel) || 1,
408
+ rs485Channel: parseInt(m.rs485Channel) || 1
409
+ };
410
+ // 杜亚窗帘使用2字节地址
411
+ if (m.brand === 'duya') {
412
+ mapping.addrHigh = parseInt(m.addrHigh) || parseInt(m.address) || 1;
413
+ mapping.addrLow = parseInt(m.addrLow) || parseInt(m.address) || 1;
414
+ }
415
+ return mapping;
416
+ });
417
+ // 打印所有映射配置便于调试
418
+ node.mappings.forEach((m, i) => {
419
+ if (m.brand === 'duya') {
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)}`);
423
+ } else {
424
+ node.log(`[映射${i+1}] Mesh: ${m.meshMac} CH${m.meshChannel} <-> RS485: 从机${m.address} ${m.brand}/${m.device} CH${m.rs485Channel}`);
425
+ }
426
+ });
277
427
  } catch (e) {
278
428
  node.mappings = [];
429
+ node.error(`映射配置解析失败: ${e.message}`);
279
430
  }
280
431
 
281
432
  if (!node.gateway) {
@@ -347,9 +498,16 @@ module.exports = function(RED) {
347
498
  );
348
499
  };
349
500
 
350
- // Find mapping for RS485 device
501
+ // Find mapping for RS485 device (返回第一个匹配,用于向后兼容)
351
502
  node.findRS485Mapping = function(address) {
352
- return node.mappings.find(m => m.address === address);
503
+ const addr = parseInt(address);
504
+ return node.mappings.find(m => m.address === addr);
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);
353
511
  };
354
512
 
355
513
  // 获取映射的寄存器配置
@@ -360,34 +518,316 @@ module.exports = function(RED) {
360
518
  return brand.devices[mapping.device].registers;
361
519
  };
362
520
 
521
+ // 状态缓存 - 用于检测真正变化的开关
522
+ node.stateCache = {};
523
+ // 首次启动标记 - 跳过初始状态同步
524
+ node.initializing = true;
525
+ // 启动后延迟20秒再开始同步(Mesh网关需要15秒以上完成设备发现)
526
+ setTimeout(() => {
527
+ node.initializing = false;
528
+ node.log('[RS485 Bridge] 初始化完成,开始同步');
529
+ }, 20000);
530
+
363
531
  // Mesh设备状态变化处理(事件驱动)
364
532
  const handleMeshStateChange = (eventData) => {
365
533
  if (node.syncLock) return;
534
+ if (node.initializing) return;
366
535
 
367
536
  const mac = eventData.device.macAddress;
368
537
  const state = eventData.state || {};
369
- // channel可能是0-based或1-based,需要兼容处理
370
- // Mesh事件中channel通常是0-based,UI配置的meshChannel是1-based
371
- const eventChannel = state.channel !== undefined ? state.channel : -1;
372
538
 
373
- node.debug(`[Mesh事件] MAC=${mac}, channel=${eventChannel}, state=${JSON.stringify(state)}`);
539
+ // 状态缓存比较,只处理真正变化的状态
540
+ if (!node.stateCache[mac]) node.stateCache[mac] = {};
541
+ const cached = node.stateCache[mac];
542
+ const changed = {};
543
+ const isFirstState = Object.keys(cached).length === 0; // 首次收到该设备状态
544
+
545
+ for (const [key, value] of Object.entries(state)) {
546
+ if (cached[key] !== value) {
547
+ changed[key] = value;
548
+ cached[key] = value;
549
+ }
550
+ }
551
+
552
+ if (Object.keys(changed).length === 0) return; // 无变化
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
+
562
+ node.log(`[Mesh事件] MAC=${mac}, 变化: ${JSON.stringify(changed)}`);
563
+
564
+ // 规范化MAC地址用于比较
565
+ const macNormalized = mac.toLowerCase().replace(/:/g, '');
374
566
 
375
- // 查找匹配的映射 - 遍历所有映射找到MAC匹配的
567
+ // 遍历映射,只处理有对应变化的映射
376
568
  for (const mapping of node.mappings) {
377
- 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)}`);
378
573
 
379
- // 通道匹配:meshChannel=0表示匹配所有,否则需要匹配具体通道
380
- // UI的meshChannel是1-based,事件的channel可能是0-based
381
574
  const configChannel = mapping.meshChannel || 1;
382
- const matchChannel = (eventChannel === -1) || // 无channel信息,匹配所有
383
- (eventChannel === configChannel) || // 1-based匹配
384
- (eventChannel === configChannel - 1); // 0-based匹配
575
+ const switchKey = `switch_${configChannel}`;
576
+ const device = mapping.device || '';
577
+
578
+ // 根据映射设备类型检查是否有相关状态
579
+ const isSwitch = device.includes('switch') || device.includes('button');
580
+ const isCurtain = device.includes('curtain') || mapping.brand === 'duya';
581
+ const isAC = device.includes('ac') || device.includes('climate') || device.includes('thermostat');
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
+ }
385
715
 
386
- if (!matchChannel) continue;
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
+
794
+ // 只有对应类型的状态变化才触发对应类型的映射
795
+ const hasSwitchChange = isSwitch && changed[switchKey] !== undefined;
796
+ // 窗帘:如果是杜亚窗帘,跳过状态变化处理(已在curtain-control中处理)
797
+ const hasCurtainChange = isCurtain && !isCurtainControlled && (
798
+ changed.curtainAction !== undefined ||
799
+ changed.curtainPosition !== undefined ||
800
+ changed.curtainStatus !== undefined
801
+ );
802
+ const hasACChange = isAC && (
803
+ changed.targetTemp !== undefined ||
804
+ changed.acMode !== 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
822
+ );
823
+
824
+ if (!hasSwitchChange && !hasCurtainChange && !hasACChange && !hasFreshAirChange && !hasFloorHeatingChange) {
825
+ continue;
826
+ }
387
827
 
388
828
  const registers = node.getRegistersForMapping(mapping);
389
829
 
390
- node.log(`[Mesh->RS485] ${eventData.device.name} 通道${configChannel} 状态变化: ${JSON.stringify(state)}`);
830
+ node.log(`[Mesh->RS485] ${eventData.device.name} CH${configChannel} 变化: ${JSON.stringify(changed)}`);
391
831
 
392
832
  // 输出调试信息到节点输出端口
393
833
  node.send({
@@ -397,7 +837,7 @@ module.exports = function(RED) {
397
837
  device: eventData.device.name,
398
838
  mac: mac,
399
839
  channel: configChannel,
400
- state: state
840
+ state: changed
401
841
  },
402
842
  timestamp: new Date().toISOString()
403
843
  });
@@ -406,11 +846,127 @@ module.exports = function(RED) {
406
846
  direction: 'mesh-to-modbus',
407
847
  mapping: mapping,
408
848
  registers: registers,
409
- state: state,
849
+ state: changed,
410
850
  timestamp: Date.now()
411
851
  });
852
+ }
853
+ };
854
+
855
+ // 窗帘控制命令处理(立即同步,不等状态反馈)
856
+ // 同时支持杜亚窗帘和自定义窗帘
857
+ const handleCurtainControl = (eventData) => {
858
+ const mac = eventData.mac;
859
+
860
+ node.log(`[curtain-control事件] MAC=${mac}, action=${eventData.action}, position=${eventData.position}`);
861
+
862
+ // 查找窗帘映射(杜亚或自定义窗帘)
863
+ const macNormalized = mac.toLowerCase().replace(/:/g, '');
864
+ for (const mapping of node.mappings) {
865
+ const mappingMacNormalized = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
866
+ if (mappingMacNormalized !== macNormalized) continue;
412
867
 
413
- break; // 找到匹配的映射就停止
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
+ }
890
+ }
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
+ });
919
+ }
920
+ }
921
+ // ===== 自定义窗帘 =====
922
+ else if (mapping.brand === 'custom' && mapping.device === 'custom_curtain' && mapping.customCodes) {
923
+ const codes = mapping.customCodes;
924
+
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
+ }
969
+ }
414
970
  }
415
971
  };
416
972
 
@@ -434,16 +990,30 @@ module.exports = function(RED) {
434
990
  });
435
991
  };
436
992
 
437
- // 命令队列顺序处理
993
+ // 命令队列顺序处理(限制最大100条,防止内存溢出)
994
+ const MAX_QUEUE_SIZE = 100;
438
995
  node.queueCommand = function(cmd) {
439
- // 检查队列中是否有相似命令(防抖)
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)才能合并
440
1004
  const existing = node.commandQueue.find(c =>
441
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 &&
442
1011
  Date.now() - c.timestamp < 100
443
1012
  );
444
1013
  if (existing) {
445
- // 合并状态
1014
+ // 合并状态(仅限完全相同的映射)
446
1015
  existing.state = { ...existing.state, ...cmd.state };
1016
+ node.debug(`[队列] 合并相同映射的命令: ${cmd.mapping.meshMac} CH${cmd.mapping.meshChannel}`);
447
1017
  return;
448
1018
  }
449
1019
 
@@ -501,6 +1071,95 @@ module.exports = function(RED) {
501
1071
  // 记录发送时间(用于防死循环)
502
1072
  node.lastMeshToRS485Time = Date.now();
503
1073
 
1074
+ // ===== 杜亚窗帘协议模式 =====
1075
+ if (mapping.brand === 'duya') {
1076
+ const addrHigh = mapping.addrHigh || 1;
1077
+ const addrLow = mapping.addrLow || 1;
1078
+
1079
+ // 缓存:记录 status 和 position
1080
+ if (!node.curtainCache) node.curtainCache = {};
1081
+ const cKey = `cc_${mapping.meshMac}`;
1082
+ const cache = node.curtainCache[cKey] || { status: undefined, position: 50 };
1083
+
1084
+ const lastStatus = cache.status;
1085
+ const currentStatus = state.curtainStatus;
1086
+
1087
+ // 【重要】先更新位置缓存,再判断方向
1088
+ // 第一个事件通常同时包含 status 和 position
1089
+ if (state.curtainPosition !== undefined) {
1090
+ cache.position = state.curtainPosition;
1091
+ }
1092
+
1093
+ // 判断方向时使用的位置:优先使用事件中的位置,否则使用缓存
1094
+ const posForDirection = state.curtainPosition !== undefined ? state.curtainPosition : cache.position;
1095
+
1096
+ // 更新状态缓存
1097
+ if (currentStatus !== undefined) {
1098
+ cache.status = currentStatus;
1099
+ }
1100
+ node.curtainCache[cKey] = cache;
1101
+
1102
+ let frame = null;
1103
+ let actionName = '';
1104
+
1105
+ // 【兼容两种协议】:
1106
+ // 米家协议: 0=打开中, 1=关闭中, 2=停止
1107
+ // 小程序协议: 1=打开, 2=关闭, 3=停止
1108
+ // 使用curtainAction来判断动作(由device-manager解析)
1109
+ const curtainAction = state.curtainAction;
1110
+
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') {
1118
+ frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
1119
+ actionName = '暂停';
1120
+ }
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 = '暂停';
1131
+ }
1132
+ // 3. 只有位置变化(没有状态变化),中间位置发百分比码
1133
+ else if (state.curtainPosition !== undefined &&
1134
+ state.curtainStatus === undefined &&
1135
+ state.curtainPosition > 5 && state.curtainPosition < 95) {
1136
+ frame = buildA6B6Frame(addrHigh, addrLow, 'position', state.curtainPosition);
1137
+ actionName = `位置${state.curtainPosition}%`;
1138
+ }
1139
+
1140
+ if (frame) {
1141
+ // 全局防抖: 2秒内不重复发送开关码(过滤运行中的状态抖动)
1142
+ if (!node.lastSentTime) node.lastSentTime = {};
1143
+ const isOpenClose = (actionName === '打开' || actionName === '关闭');
1144
+ const cacheKey = isOpenClose ? `duya_${mapping.meshMac}_openclose` : `duya_${mapping.meshMac}_${actionName}`;
1145
+ const debounceTime = isOpenClose ? 2000 : 1500; // 开关码用2秒防抖
1146
+ const now = Date.now();
1147
+ const lastTime = node.lastSentTime[cacheKey] || 0;
1148
+
1149
+ if (now - lastTime < debounceTime) {
1150
+ node.debug(`[Mesh->杜亚] 窗帘 ${actionName} ${debounceTime}ms内防抖跳过`);
1151
+ } else {
1152
+ node.lastSentTime[cacheKey] = now;
1153
+ const hexStr = frame.toString('hex').toUpperCase();
1154
+ await node.sendRS485Frame(frame);
1155
+ node.log(`[Mesh->杜亚] 窗帘 ${actionName}, 位置${posForDirection}%, 发送: ${hexStr.match(/.{2}/g).join(' ')}`);
1156
+ }
1157
+ }
1158
+
1159
+ node.status({ fill: 'green', shape: 'dot', text: `杜亚同步 ${node.mappings.length}个` });
1160
+ return;
1161
+ }
1162
+
504
1163
  // ===== 自定义协议模式 =====
505
1164
  if (mapping.brand === 'custom' && mapping.customCodes) {
506
1165
  const codes = mapping.customCodes;
@@ -521,17 +1180,63 @@ module.exports = function(RED) {
521
1180
  }
522
1181
  // 窗帘类型
523
1182
  else if (mapping.device === 'custom_curtain') {
524
- for (const [key, value] of Object.entries(state)) {
525
- if (key === 'position' || key === 'action') {
526
- let hexCode = null;
527
- if (value === 'open' || value === 100) hexCode = codes.open;
528
- else if (value === 'close' || value === 0) hexCode = codes.close;
529
- else if (value === 'stop') hexCode = codes.stop;
530
-
531
- if (hexCode) {
532
- await node.sendCustomCode(hexCode);
533
- node.log(`[Mesh->自定义] 窗帘: ${value}, 发送: ${hexCode}`);
534
- }
1183
+ node.log(`[Mesh->自定义] 窗帘状态: ${JSON.stringify(state)}`);
1184
+
1185
+ // 优先级:curtainAction > curtainStatus > curtainPosition
1186
+ // 只发送一次,避免重复
1187
+ let hexCode = null;
1188
+ let actionName = '';
1189
+
1190
+ // 1. 优先检查动作命令
1191
+ if (state.curtainAction !== undefined || state.action !== undefined) {
1192
+ const action = state.curtainAction || state.action;
1193
+ if (action === 1 || action === 'open') {
1194
+ hexCode = codes.open;
1195
+ actionName = '打开';
1196
+ } else if (action === 2 || action === 'close') {
1197
+ hexCode = codes.close;
1198
+ actionName = '关闭';
1199
+ } else if (action === 3 || action === 'stop') {
1200
+ hexCode = codes.stop;
1201
+ actionName = '停止';
1202
+ }
1203
+ }
1204
+ // 2. 其次检查运行状态
1205
+ else if (state.curtainStatus !== undefined) {
1206
+ if (state.curtainStatus === 1) {
1207
+ hexCode = codes.open;
1208
+ actionName = '打开(运行中)';
1209
+ } else if (state.curtainStatus === 2) {
1210
+ hexCode = codes.close;
1211
+ actionName = '关闭(运行中)';
1212
+ } else if (state.curtainStatus === 0 && codes.stop) {
1213
+ // 0=已停止,发送停止码
1214
+ hexCode = codes.stop;
1215
+ actionName = '停止';
1216
+ }
1217
+ }
1218
+ // 3. 最后检查位置(仅在极端位置时)
1219
+ else if (state.curtainPosition !== undefined) {
1220
+ if (state.curtainPosition >= 95) {
1221
+ hexCode = codes.open;
1222
+ actionName = '打开(位置>=95)';
1223
+ } else if (state.curtainPosition <= 5) {
1224
+ hexCode = codes.close;
1225
+ actionName = '关闭(位置<=5)';
1226
+ }
1227
+ }
1228
+
1229
+ if (hexCode) {
1230
+ // 防抖:500ms内不重复发送相同命令
1231
+ const cacheKey = `curtain_${mapping.meshMac}_${hexCode}`;
1232
+ const now = Date.now();
1233
+ if (node.lastSentTime && node.lastSentTime[cacheKey] && now - node.lastSentTime[cacheKey] < 500) {
1234
+ node.debug(`[Mesh->自定义] 窗帘 ${actionName} 防抖跳过`);
1235
+ } else {
1236
+ if (!node.lastSentTime) node.lastSentTime = {};
1237
+ node.lastSentTime[cacheKey] = now;
1238
+ await node.sendCustomCode(hexCode);
1239
+ node.log(`[Mesh->自定义] 窗帘 ${actionName}, 发送: ${hexCode}`);
535
1240
  }
536
1241
  }
537
1242
  }
@@ -597,22 +1302,112 @@ module.exports = function(RED) {
597
1302
  node.log(`[Mesh->RS485] 从机${mapping.address} 按键${rs485Channel}: ${value ? '开' : '关'}`);
598
1303
  }
599
1304
  }
1305
+ // 空调开关 (支持acSwitch和climateSwitch两种字段名)
1306
+ else if ((meshKey === 'acSwitch' || meshKey === 'climateSwitch') && registers && registers.switch) {
1307
+ const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 1) :
1308
+ (registers.switch.off !== undefined ? registers.switch.off : 0);
1309
+ await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
1310
+ node.log(`[Mesh->RS485] 空调开关: ${value ? '开' : '关'}`);
1311
+ }
1312
+ // 目标温度
600
1313
  else if ((meshKey === 'targetTemp' || meshKey === 'acTargetTemp') && registers && registers.targetTemp) {
601
1314
  await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
602
- node.debug(`[Mesh->RS485] 目标温度: ${value}`);
1315
+ node.log(`[Mesh->RS485] 目标温度: ${value}°C`);
603
1316
  }
604
- else if (meshKey === 'acMode' && registers && registers.mode) {
605
- await node.writeModbusRegister(mapping.address, registers.mode, value);
606
- node.debug(`[Mesh->RS485] 模式: ${value}`);
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
+ }
1333
+ }
1334
+ }
1335
+ if (rs485Value !== undefined) {
1336
+ await node.writeModbusRegister(mapping.address, registers.mode, rs485Value);
1337
+ node.log(`[Mesh->RS485] 空调模式: Mesh值${value} -> RS485值${rs485Value}`);
1338
+ }
607
1339
  }
608
- else if (meshKey === 'acFanSpeed' && registers && registers.fanSpeed) {
609
- await node.writeModbusRegister(mapping.address, registers.fanSpeed, value);
610
- node.debug(`[Mesh->RS485] 风速: ${value}`);
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
+ }
1356
+ }
1357
+ }
1358
+ if (rs485Value !== undefined) {
1359
+ await node.writeModbusRegister(mapping.address, registers.fanSpeed, rs485Value);
1360
+ node.log(`[Mesh->RS485] 空调风速: Mesh值${value} -> RS485值${rs485Value}`);
1361
+ }
611
1362
  }
612
1363
  else if (meshKey === 'brightness' && registers && registers.brightness) {
613
1364
  await node.writeModbusRegister(mapping.address, registers.brightness, value);
614
1365
  node.debug(`[Mesh->RS485] 亮度: ${value}`);
615
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
+ }
616
1411
  } catch (err) {
617
1412
  node.error(`RS485写入失败: ${meshKey}=${value}, ${err.message}`);
618
1413
  }
@@ -637,15 +1432,60 @@ module.exports = function(RED) {
637
1432
  node.syncModbusToMesh = async function(cmd) {
638
1433
  const { mapping, registers, state, customMode } = cmd;
639
1434
 
640
- // 查找Mesh设备
641
- 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); // 原始格式
1441
+ if (!meshDevice) {
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
+
642
1453
  if (!meshDevice) {
643
- node.warn(`未找到Mesh设备: ${mapping.meshMac}`);
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(', ')}`);
644
1458
  return;
645
1459
  }
1460
+
1461
+ node.log(`[RS485->Mesh] 找到设备: ${meshDevice.name}, MAC=${meshDevice.macAddress}, 网络地址=0x${meshDevice.networkAddress.toString(16)}`);
646
1462
 
647
1463
  const channel = mapping.meshChannel || 1;
648
1464
 
1465
+ // ===== 杜亚窗帘协议模式 =====
1466
+ if (cmd.duyaMode || mapping.brand === 'duya') {
1467
+ node.log(`[杜亚->Mesh] 设备${mapping.meshMac}, 状态: ${JSON.stringify(state)}`);
1468
+
1469
+ try {
1470
+ if (state.curtainAction !== undefined || state.action !== undefined) {
1471
+ const action = state.curtainAction || (state.action === 'open' ? 1 : state.action === 'close' ? 2 : 3);
1472
+ const param = Buffer.from([action]);
1473
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x05, param);
1474
+ node.log(`[杜亚->Mesh] 窗帘动作: ${action === 1 ? '打开' : action === 2 ? '关闭' : '停止'}`);
1475
+ } else if (state.curtainPosition !== undefined || state.position !== undefined) {
1476
+ const pos = state.curtainPosition || state.position;
1477
+ const param = Buffer.from([pos]);
1478
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x06, param);
1479
+ node.log(`[杜亚->Mesh] 窗帘位置: ${pos}%`);
1480
+ }
1481
+ } catch (err) {
1482
+ node.error(`[杜亚->Mesh] 写入失败: ${err.message}`);
1483
+ }
1484
+
1485
+ node.status({ fill: 'blue', shape: 'dot', text: `杜亚同步 ${node.mappings.length}个` });
1486
+ return;
1487
+ }
1488
+
649
1489
  // ===== 自定义协议模式 =====
650
1490
  if (customMode || mapping.brand === 'custom') {
651
1491
  node.log(`[自定义->Mesh] 设备${mapping.meshMac}, 通道${channel}, 状态: ${JSON.stringify(state)}`);
@@ -661,14 +1501,16 @@ module.exports = function(RED) {
661
1501
  }
662
1502
  // 窗帘类型
663
1503
  else if (key === 'action' || key === 'position') {
664
- let action = 0x00; // 停止
1504
+ // 窗帘动作: 1=打开, 2=关闭, 3=停止
1505
+ let action = 0x03; // 停止
665
1506
  if (value === 'open') action = 0x01; // 打开
666
1507
  else if (value === 'close') action = 0x02; // 关闭
667
- else if (value === 'stop') action = 0x00; // 停止
1508
+ else if (value === 'stop') action = 0x03; // 停止
668
1509
 
669
1510
  const param = Buffer.from([action]);
670
- await node.gateway.sendControl(meshDevice.networkAddress, 0x04, param);
671
- node.log(`[自定义->Mesh] 窗帘: ${value}`);
1511
+ // 0x05是窗帘动作控制属性
1512
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x05, param);
1513
+ node.log(`[自定义->Mesh] 窗帘: ${value} (动作码${action})`);
672
1514
  }
673
1515
  } catch (err) {
674
1516
  node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
@@ -698,28 +1540,89 @@ module.exports = function(RED) {
698
1540
  else if (key.startsWith('led')) {
699
1541
  node.debug(`[RS485] 指示灯${key}: ${value}`);
700
1542
  }
1543
+ // 空调开关 - 0x02是开关属性 (0x01=关, 0x02=开)
1544
+ else if (key === 'acSwitch' || (key === 'switch' && mapping.device && mapping.device.includes('ac'))) {
1545
+ const param = Buffer.from([value ? 0x02 : 0x01]);
1546
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
1547
+ node.log(`[RS485->Mesh] 空调开关: ${value ? '开' : '关'}`);
1548
+ }
1549
+ // 目标温度 - 0x1B是目标温度属性
701
1550
  else if (key === 'targetTemp') {
702
1551
  const param = Buffer.from([Math.round(value)]);
703
- await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
704
- node.debug(`[RS485->Mesh] 目标温度: ${value}`);
1552
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1B, param);
1553
+ node.log(`[RS485->Mesh] 目标温度: ${value}°C`);
705
1554
  }
1555
+ // 空调模式 - 0x1D是模式属性
1556
+ // RS485(话语前湾): 1=制热, 2=制冷, 4=送风, 8=除湿
1557
+ // Mesh协议: 1=制冷, 2=制热, 3=送风, 4=除湿
706
1558
  else if (key === 'mode') {
707
- const modeMap = { 'cool': 0, 'heat': 1, 'fan': 2, 'dry': 3 };
708
- const param = Buffer.from([modeMap[value] !== undefined ? modeMap[value] : 0]);
709
- await node.gateway.sendControl(meshDevice.networkAddress, 0x16, param);
710
- node.debug(`[RS485->Mesh] 模式: ${value}`);
1559
+ // 从RS485值或字符串转换为Mesh值
1560
+ let meshMode = 1; // 默认制冷
1561
+ if (typeof value === 'string') {
1562
+ const modeMap = { 'cool': 1, 'heat': 2, 'fan': 3, 'dry': 4 };
1563
+ meshMode = modeMap[value] !== undefined ? modeMap[value] : 1;
1564
+ } else {
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;
1568
+ }
1569
+ const param = Buffer.from([meshMode]);
1570
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
1571
+ node.log(`[RS485->Mesh] 空调模式: RS485值${value} -> Mesh值${meshMode}`);
711
1572
  }
1573
+ // 风速 - 0x1C是风速属性
1574
+ // RS485(话语前湾): 1=低风, 2=中风, 3=高风
1575
+ // Mesh协议: 1=高, 2=中, 3=低, 4=自动
712
1576
  else if (key === 'fanSpeed') {
713
- const speedMap = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
714
- const param = Buffer.from([speedMap[value] !== undefined ? speedMap[value] : 4]);
715
- await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
716
- node.debug(`[RS485->Mesh] 风速: ${value}`);
1577
+ let meshSpeed = 3; // 默认低
1578
+ if (typeof value === 'string') {
1579
+ const speedMap = { 'low': 3, 'medium': 2, 'high': 1, 'auto': 4 };
1580
+ meshSpeed = speedMap[value] !== undefined ? speedMap[value] : 3;
1581
+ } else {
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;
1585
+ }
1586
+ const param = Buffer.from([meshSpeed]);
1587
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
1588
+ node.log(`[RS485->Mesh] 空调风速: RS485值${value} -> Mesh值${meshSpeed}`);
717
1589
  }
718
1590
  else if (key === 'brightness') {
719
1591
  const param = Buffer.from([Math.round(value)]);
720
1592
  await node.gateway.sendControl(meshDevice.networkAddress, 0x03, param);
721
1593
  node.debug(`[RS485->Mesh] 亮度: ${value}`);
722
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
+ }
723
1626
  } catch (err) {
724
1627
  node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
725
1628
  }
@@ -815,12 +1718,83 @@ module.exports = function(RED) {
815
1718
  const hexStr = frame.toString('hex').toUpperCase();
816
1719
  const hexFormatted = hexStr.match(/.{1,2}/g)?.join(' ') || '';
817
1720
 
818
- // 首先检查自定义码匹配(遍历所有映射)
1721
+ node.log(`[RS485收到] ${hexFormatted} (${frame.length}字节)`);
1722
+
1723
+ // ===== 杜亚窗帘协议检测 (55开头) =====
1724
+ if (frame[0] === 0x55 && frame.length >= 7) {
1725
+ node.log(`[杜亚帧检测] 检测到55帧头, 长度=${frame.length}, 开始解析...`);
1726
+ const duyaData = parseA6B6Frame(frame);
1727
+ if (duyaData) {
1728
+ node.log(`[杜亚帧解析] 成功! 地址高=${duyaData.addrHigh}, 地址低=${duyaData.addrLow}, 动作=${duyaData.action}`);
1729
+
1730
+ // 查找匹配的杜亚映射
1731
+ let foundMapping = false;
1732
+ node.log(`[杜亚映射查找] 当前映射数: ${node.mappings.length}`);
1733
+ for (const mapping of node.mappings) {
1734
+ if (mapping.brand !== 'duya') {
1735
+ // 检查是否是自定义窗帘(也可能匹配55帧)
1736
+ if (mapping.brand === 'custom' && mapping.device === 'custom_curtain') {
1737
+ node.debug(`[杜亚映射] 发现自定义窗帘映射,将在后续自定义码匹配中处理`);
1738
+ }
1739
+ continue;
1740
+ }
1741
+
1742
+ // 检查2字节地址是否匹配
1743
+ const mapAddrHigh = mapping.addrHigh || 1;
1744
+ const mapAddrLow = mapping.addrLow || 1;
1745
+
1746
+ node.debug(`[杜亚映射检查] 配置地址=${mapAddrHigh}:${mapAddrLow}, 帧地址=${duyaData.addrHigh}:${duyaData.addrLow}`);
1747
+
1748
+ if (duyaData.addrHigh === mapAddrHigh && duyaData.addrLow === mapAddrLow) {
1749
+ foundMapping = true;
1750
+ // 防死循环: 1秒内忽略响应
1751
+ if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 1000) {
1752
+ node.debug(`[防循环] 忽略杜亚响应: ${hexFormatted}`);
1753
+ return;
1754
+ }
1755
+
1756
+ node.log(`[杜亚->Mesh] 窗帘响应: ${duyaData.action}, 帧: ${hexFormatted}`);
1757
+
1758
+ // 构建Mesh状态
1759
+ let meshState = {};
1760
+ if (duyaData.action === 'open') {
1761
+ meshState = { action: 'open', curtainAction: 1 };
1762
+ } else if (duyaData.action === 'close') {
1763
+ meshState = { action: 'close', curtainAction: 2 };
1764
+ } else if (duyaData.action === 'stop') {
1765
+ meshState = { action: 'stop', curtainAction: 3 };
1766
+ } else if (duyaData.action === 'position') {
1767
+ meshState = { position: duyaData.position, curtainPosition: duyaData.position };
1768
+ }
1769
+
1770
+ node.queueCommand({
1771
+ direction: 'modbus-to-mesh',
1772
+ mapping: mapping,
1773
+ state: meshState,
1774
+ duyaMode: true,
1775
+ timestamp: Date.now()
1776
+ });
1777
+ return;
1778
+ }
1779
+ }
1780
+
1781
+ if (!foundMapping) {
1782
+ node.warn(`[杜亚] 未找到匹配的映射, 帧地址=${duyaData.addrHigh}:${duyaData.addrLow},请检查RS485桥配置`);
1783
+ }
1784
+ } else {
1785
+ node.debug(`[杜亚帧检测] 解析失败,可能funcCode不是0x03`);
1786
+ }
1787
+ }
1788
+
1789
+ // 检查自定义码匹配(遍历所有映射)
1790
+ node.log(`[自定义码检测] 开始匹配, 当前帧hex=${hexStr}, 映射数=${node.mappings.length}`);
819
1791
  for (const mapping of node.mappings) {
820
1792
  if (mapping.brand === 'custom' && mapping.customCodes) {
821
1793
  const codes = mapping.customCodes;
822
1794
  let matchedAction = null;
823
1795
 
1796
+ node.debug(`[自定义码检测] 检查映射: device=${mapping.device}, meshMac=${mapping.meshMac}, codes=${JSON.stringify(codes)}`);
1797
+
824
1798
  // 开关类型:匹配on/off
825
1799
  if (mapping.device === 'custom_switch') {
826
1800
  if (codes.on && hexStr.includes(codes.on.replace(/\s/g, '').toUpperCase())) {
@@ -829,14 +1803,24 @@ module.exports = function(RED) {
829
1803
  matchedAction = { switch: false };
830
1804
  }
831
1805
  }
832
- // 窗帘类型:匹配open/close/stop
1806
+ // 窗帘类型:匹配open/close/stop(只设置action,避免重复发码)
833
1807
  else if (mapping.device === 'custom_curtain') {
834
- if (codes.open && hexStr.includes(codes.open.replace(/\s/g, '').toUpperCase())) {
835
- matchedAction = { position: 'open', action: 'open' };
836
- } else if (codes.close && hexStr.includes(codes.close.replace(/\s/g, '').toUpperCase())) {
837
- matchedAction = { position: 'close', action: 'close' };
838
- } else if (codes.stop && hexStr.includes(codes.stop.replace(/\s/g, '').toUpperCase())) {
839
- 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(`[自定义窗帘匹配] 未匹配到任何码`);
840
1824
  }
841
1825
  }
842
1826
  // 场景类型:匹配trigger
@@ -891,22 +1875,18 @@ module.exports = function(RED) {
891
1875
 
892
1876
  const slaveAddr = frame[0];
893
1877
  const fc = frame[1];
1878
+ const hexFormatted = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
894
1879
 
895
- // 查找对应的映射
896
- const mapping = node.findRS485Mapping(slaveAddr);
897
- if (!mapping) {
898
- node.debug(`未找到从机${slaveAddr}的映射配置`);
899
- return;
900
- }
901
-
902
- // 自定义模式不走标准Modbus解析
903
- if (mapping.brand === 'custom') return;
904
-
905
- const registers = node.getRegistersForMapping(mapping);
906
- if (!registers) {
907
- 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}的映射配置`);
908
1886
  return;
909
1887
  }
1888
+
1889
+ node.log(`[Modbus解析] 找到${allMappings.length}个匹配映射`);
910
1890
 
911
1891
  // 防死循环:检查是否刚刚从Mesh发送过来
912
1892
  if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 500) {
@@ -914,15 +1894,45 @@ module.exports = function(RED) {
914
1894
  return;
915
1895
  }
916
1896
 
917
- // 根据功能码解析数据
918
- let state = {};
1897
+ // 解析寄存器地址和值
1898
+ if (fc !== 0x06 && fc !== 0x10) {
1899
+ // 暂不处理其他功能码
1900
+ return;
1901
+ }
1902
+
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}`);
919
1914
 
920
- if (fc === 0x06 || fc === 0x10) {
921
- // 写单寄存器响应/写多寄存器响应 - 这是设备主动上报或写响应
922
- const regAddr = frame.readUInt16BE(2);
923
- const value = frame.readUInt16BE(4);
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
+ }
924
1933
 
925
1934
  // 查找匹配的寄存器定义
1935
+ let state = {};
926
1936
  for (const [key, reg] of Object.entries(registers)) {
927
1937
  if (reg.address === regAddr) {
928
1938
  if (key.startsWith('switch') || key.startsWith('led')) {
@@ -936,51 +1946,35 @@ module.exports = function(RED) {
936
1946
  break;
937
1947
  }
938
1948
  }
939
- } else if (fc === 0x03 || fc === 0x04) {
940
- const byteCount = frame[2];
941
- for (let i = 0; i < byteCount / 2; i++) {
942
- const value = frame.readUInt16BE(3 + i * 2);
943
- for (const [key, reg] of Object.entries(registers)) {
944
- if (reg.map) {
945
- state[key] = reg.map[value] || value;
946
- } else {
947
- state[key] = value;
948
- }
949
- }
950
- }
951
- } else if (fc === 0x20) {
952
- if (frame.length >= 9) {
953
- const startReg = frame.readUInt16BE(2);
954
- const count = frame.readUInt16BE(4);
955
- node.debug(`[RS485] 从机${slaveAddr} 自定义上报: 起始0x${startReg.toString(16)}, 数量${count}`);
956
- }
957
- }
958
-
959
- if (Object.keys(state).length > 0) {
960
- node.log(`[RS485->Mesh] 设备@${slaveAddr} 状态变化: ${JSON.stringify(state)}`);
961
1949
 
962
- // 输出调试信息到节点输出端口
963
- node.send({
964
- topic: 'rs485-state-change',
965
- payload: {
966
- direction: 'RS485→Mesh',
967
- slaveAddr: slaveAddr,
968
- funcCode: fc,
969
- brand: mapping.brand,
970
- device: mapping.device,
971
- meshMac: mapping.meshMac,
972
- state: state
973
- },
974
- timestamp: new Date().toISOString()
975
- });
976
-
977
- node.queueCommand({
978
- direction: 'modbus-to-mesh',
979
- mapping: mapping,
980
- registers: registers,
981
- state: state,
982
- timestamp: Date.now()
983
- });
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
+ }
984
1978
  }
985
1979
  };
986
1980
 
@@ -998,6 +1992,7 @@ module.exports = function(RED) {
998
1992
  // 事件监听 - Mesh网关共享,无冲突
999
1993
  node.gateway.on('device-list-complete', init);
1000
1994
  node.gateway.on('device-state-changed', handleMeshStateChange);
1995
+ node.gateway.on('curtain-control', handleCurtainControl); // 窗帘控制立即同步
1001
1996
 
1002
1997
  if (node.gateway.deviceListComplete) {
1003
1998
  init();
@@ -1081,8 +2076,11 @@ module.exports = function(RED) {
1081
2076
  // 清理
1082
2077
  node.on('close', (done) => {
1083
2078
  // 移除Mesh网关事件监听器
1084
- node.gateway.removeListener('device-list-complete', init);
1085
- node.gateway.removeListener('device-state-changed', handleMeshStateChange);
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
+ }
1086
2084
 
1087
2085
  // 移除RS485配置节点事件监听器
1088
2086
  if (node.rs485Config && node._rs485Handlers) {
@@ -1093,6 +2091,15 @@ module.exports = function(RED) {
1093
2091
  node.rs485Config.deregister(node);
1094
2092
  }
1095
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
+
1096
2103
  node.log('[RS485 Bridge] 节点已清理');
1097
2104
  done();
1098
2105
  });
@@ -1129,12 +2136,20 @@ module.exports = function(RED) {
1129
2136
  displayName = (d.name || '设备') + '_' + macClean;
1130
2137
  }
1131
2138
 
2139
+ // 三合一面板特殊处理
2140
+ const isThreeInOne = d.isThreeInOne || false;
2141
+ if (isThreeInOne) {
2142
+ displayName = '三合一面板_' + macClean;
2143
+ }
2144
+
1132
2145
  return {
1133
2146
  mac: d.macAddress,
1134
2147
  name: displayName,
1135
2148
  originalName: d.name,
1136
2149
  type: d.deviceType,
1137
- channels: channels
2150
+ channels: channels,
2151
+ isThreeInOne: isThreeInOne,
2152
+ entityType: d.getEntityType ? d.getEntityType() : 'unknown'
1138
2153
  };
1139
2154
  });
1140
2155
  res.json(devices);
@@ -1163,4 +2178,5 @@ module.exports = function(RED) {
1163
2178
  res.json([]);
1164
2179
  }
1165
2180
  });
2181
+
1166
2182
  };