node-red-contrib-symi-mesh 1.8.3 → 1.8.4

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.
package/README.md CHANGED
@@ -1537,6 +1537,28 @@ node-red-contrib-symi-mesh/
1537
1537
 
1538
1538
  ## 更新日志
1539
1539
 
1540
+ ### v1.8.4 (2026-01-06)
1541
+
1542
+ **HA同步节点窗帘双向同步重大修复**:
1543
+ - 实现"谁发起控制就只听谁的命令"逻辑,彻底解决窗帘双向同步死循环问题
1544
+ - Mesh控制时:只同步位置到HA,不发送动作命令(HA根据位置自动更新状态)
1545
+ - HA控制时:发送动作/位置到Mesh,运动过程中忽略Mesh的所有反馈
1546
+ - 停止后延迟5秒释放控制权,确保延迟反馈也被正确过滤
1547
+ - 只处理HA的`opening`/`closing`状态(用户操作),忽略`open`/`closed`(状态反馈)
1548
+
1549
+ **其他优化**:
1550
+ - 修复HA state_changed事件解析,支持更多消息格式变体
1551
+ - 优化空调同步逻辑:只在开关状态真正变化时同步,避免off->off无效日志
1552
+ - 优化调光同步逻辑:HA发起调光时忽略Mesh步进反馈,防止状态回弹
1553
+ - 增加状态变化检测:无变化时跳过处理,减少无效日志
1554
+ - 过滤sensor类型实体,避免不必要的处理
1555
+
1556
+ **问题修复**:
1557
+ - 修复Mesh控制窗帘时HA反向发送命令导致窗帘停止的问题
1558
+ - 修复HA控制窗帘时Mesh反馈导致循环控制的问题
1559
+ - 修复窗帘打开命令被防死循环机制阻止的问题
1560
+ - 修复空调off->off重复日志的问题
1561
+
1540
1562
  ### v1.8.3 (2026-01-05)
1541
1563
 
1542
1564
  **HA同步节点重大修复**:
@@ -202,7 +202,7 @@
202
202
  });
203
203
  } else {
204
204
  // 多路开关
205
- var channels = parseInt(device ? (device.channels || 1) : (savedChannels || 1)) || 1;
205
+ var channels = device ? (device.channels || 1) : (savedChannels || 1);
206
206
  for (var i = 1; i <= channels; i++) {
207
207
  var sel = (i == selectedKey) ? ' selected' : '';
208
208
  html += '<option value="' + i + '"' + sel + '>按键' + i + '</option>';
@@ -477,44 +477,11 @@
477
477
  <h3>功能特性</h3>
478
478
  <ul>
479
479
  <li><strong>完美双向同步</strong>:Symi↔HA实时状态同步</li>
480
- <li><strong>多设备类型支持</strong>:开关、调光灯、窗帘、温控器/空调、新风、地暖</li>
480
+ <li><strong>多设备类型支持</strong>:开关、调光灯、窗帘、温控器/空调</li>
481
481
  <li><strong>智能按键选择</strong>:只有多路开关才显示按键选择</li>
482
482
  <li><strong>配置持久化</strong>:设备列表和映射配置持久保存</li>
483
483
  <li><strong>防死循环</strong>:内置2秒防抖机制</li>
484
- <li><strong>智能防抖</strong>:窗帘/调光灯只同步最终位置,过程状态不同步</li>
485
- </ul>
486
-
487
- <h3>⚠️ 双向同步连接方式(重要)</h3>
488
- <p>要实现 HA→Symi 方向同步,必须连接 HA 事件节点到本节点输入端:</p>
489
-
490
- <h4>方式1:使用 server-events 节点(推荐)</h4>
491
- <pre>
492
- [server-events] → [symi-ha-sync]
493
- (事件类型: state_changed)
494
- </pre>
495
- <p><strong>配置步骤:</strong></p>
496
- <ol>
497
- <li>添加 <code>events: all</code> 节点(Home Assistant 分类下)</li>
498
- <li>事件类型(Event Type)填写: <code>state_changed</code></li>
499
- <li>将输出连接到 symi-ha-sync 节点的输入端</li>
500
- </ol>
501
-
502
- <h4>方式2:使用 server-state-changed 节点</h4>
503
- <pre>
504
- [server-state-changed] → [symi-ha-sync]
505
- </pre>
506
- <p><strong>配置步骤:</strong></p>
507
- <ol>
508
- <li>添加 <code>events: state</code> 节点</li>
509
- <li>实体ID可留空(监听所有实体)或指定特定实体</li>
510
- <li>将输出连接到 symi-ha-sync 节点的输入端</li>
511
- </ol>
512
-
513
- <h3>状态指示</h3>
514
- <ul>
515
- <li><strong>蓝色 "Mesh→HA"</strong>:仅 Mesh 到 HA 方向工作(未连接 HA 事件节点)</li>
516
- <li><strong>绿色 "双向同步"</strong>:双向同步正常工作</li>
517
- <li><strong>红色</strong>:配置错误</li>
484
+ <li><strong>智能防抖</strong>:窗帘/调光灯只同步最终位置</li>
518
485
  </ul>
519
486
 
520
487
  <h3>支持的设备类型</h3>
@@ -523,15 +490,6 @@
523
490
  <li><strong>调光灯</strong>:开关 + 亮度 (0-100)</li>
524
491
  <li><strong>窗帘</strong>:开/关/停 + 位置 (0-100%)</li>
525
492
  <li><strong>温控器/空调</strong>:开关 + 温度 + 模式 + 风速</li>
526
- <li><strong>三合一面板</strong>:空调 + 新风 + 地暖,分别配置</li>
527
- </ul>
528
-
529
- <h3>三合一面板配置</h3>
530
- <p>三合一面板需要分别为每个子设备创建映射:</p>
531
- <ul>
532
- <li><strong>空调</strong>:选择子设备"空调",映射到 climate 实体</li>
533
- <li><strong>新风</strong>:选择子设备"新风",映射到 fan 实体</li>
534
- <li><strong>地暖</strong>:选择子设备"地暖",映射到 climate 实体</li>
535
493
  </ul>
536
494
 
537
495
  <h3>离线设备显示</h3>
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
3
- * 版本: 1.8.3
3
+ * 版本: 1.8.4
4
+ *
5
+ * v1.8.4 更新:
6
+ * - 修复HA state_changed事件解析,支持更多消息格式
7
+ * - 优化窗帘同步:动作命令直接同步,位置只在非运动状态同步
8
+ * - 优化空调同步:只在开关状态真正变化时同步,避免off->off无效日志
9
+ * - 优化调光同步:HA发起调光时忽略Mesh步进反馈
10
+ * - 增加状态变化检测,无变化时跳过处理
11
+ * - 过滤sensor类型实体,避免不必要的处理
4
12
  *
5
13
  * 支持的实体类型和属性:
6
14
  * - light: on/off, brightness (0-255)
@@ -221,17 +229,27 @@ module.exports = function(RED) {
221
229
 
222
230
  // ========== 1. 监听Symi设备状态变化 (Symi -> HA) ==========
223
231
  node.handleSymiStateChange = function(eventData) {
224
- if (!eventData.device || !eventData.device.macAddress) return;
232
+ if (!eventData.device || !eventData.device.macAddress) {
233
+ node.debug('[Symi->HA] 忽略: 无效的设备数据');
234
+ return;
235
+ }
225
236
 
226
237
  const device = eventData.device;
227
238
  const attrType = eventData.attrType;
228
239
  const state = eventData.state || {};
229
240
 
241
+ node.debug(`[Symi->HA] 设备状态变化: ${device.macAddress}, attrType=0x${attrType?.toString(16) || 'unknown'}, state=${JSON.stringify(state)}`);
242
+
230
243
  // 遍历该设备的所有映射
231
244
  const deviceMappings = node.mappings.filter(m =>
232
245
  m.symiMac.toLowerCase().replace(/:/g, '') === device.macAddress.toLowerCase().replace(/:/g, '')
233
246
  );
234
- if (deviceMappings.length === 0) return;
247
+ if (deviceMappings.length === 0) {
248
+ node.debug(`[Symi->HA] 设备不在映射中: ${device.macAddress}`);
249
+ return;
250
+ }
251
+
252
+ node.debug(`[Symi->HA] 找到 ${deviceMappings.length} 个映射`);
235
253
 
236
254
  deviceMappings.forEach(mapping => {
237
255
  // 检查同步模式 (0:双向, 1:Symi->HA, 2:HA->Symi)
@@ -297,7 +315,11 @@ module.exports = function(RED) {
297
315
  const syncDataList = Array.isArray(syncData) ? syncData : [syncData];
298
316
 
299
317
  syncDataList.forEach(data => {
300
- if (node.shouldPreventSync('symi-to-ha', loopKey)) {
318
+ // 窗帘使用专门的coverMoving状态跟踪,跳过常规防死循环检查
319
+ const isCoverAction = domain === 'cover' &&
320
+ (data.type === 'curtain_action' || data.type === 'curtain_stop' || data.type === 'position');
321
+
322
+ if (!isCoverAction && node.shouldPreventSync('symi-to-ha', loopKey)) {
301
323
  node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
302
324
  return;
303
325
  }
@@ -306,7 +328,8 @@ module.exports = function(RED) {
306
328
  direction: 'symi-to-ha',
307
329
  mapping: mapping,
308
330
  syncData: data,
309
- key: loopKey
331
+ key: loopKey,
332
+ skipLoopCheck: isCoverAction // 窗帘跳过常规防死循环检查
310
333
  });
311
334
  });
312
335
  }
@@ -451,65 +474,58 @@ module.exports = function(RED) {
451
474
  return null; // 不立即同步,由定时器处理
452
475
  };
453
476
 
454
- // 处理窗帘变化(带防抖,避免步进反馈干扰)
477
+ // 处理窗帘变化 - 谁发起控制就只听谁的命令
478
+ // Mesh控制时只同步位置,不同步动作(HA会根据位置自动更新状态)
455
479
  node.handleCurtainChange = function(device, mapping, state, attrType) {
456
480
  const domain = node.getEntityDomain(mapping.haEntityId);
457
481
  if (domain !== 'cover') return null;
458
482
 
459
483
  const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
460
484
 
461
- // 检查是否是HA发起的运动,如果是则忽略Mesh的位置反馈(步进码)
485
+ // HA控制期间,完全忽略Mesh的所有反馈
462
486
  if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
463
- const elapsed = Date.now() - node.coverMoving[loopKey].startTime;
464
- if (elapsed < COVER_LOOP_PREVENTION_MS) {
465
- node.debug(`[Symi->HA] 窗帘忽略(HA发起运动中): ${loopKey}`);
466
- return null; // 忽略HA发起运动期间的Mesh反馈
467
- } else {
468
- delete node.coverMoving[loopKey]; // 超时清理
487
+ const action = state.curtainAction || device.state.curtainAction;
488
+ if (action === 'stopped') {
489
+ // Mesh停止了,延迟清理HA控制标记
490
+ node.log(`[Symi->HA] 窗帘stopped, 释放HA控制权`);
491
+ setTimeout(() => {
492
+ delete node.coverMoving[loopKey];
493
+ }, 5000);
469
494
  }
495
+ node.debug(`[Symi->HA] 窗帘忽略(HA控制中): ${JSON.stringify(state)}`);
496
+ return null;
470
497
  }
471
498
 
472
- // 窗帘位置变化 - 使用防抖,只同步最终位置
473
- if (attrType === ATTR_CURTAIN_POSITION) {
474
- const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
475
- if (position === undefined) return null;
499
+ // 窗帘运行状态变化 - 只标记控制方向,不发送动作到HA
500
+ if (attrType === ATTR_CURTAIN_STATUS) {
501
+ const action = state.curtainAction || device.state.curtainAction;
476
502
 
477
- // 防抖处理:取消之前的定时器,设置新的
478
- const debounceKey = `curtain_${mapping.symiMac}_${mapping.symiKey}`;
479
- if (node.pendingDebounce[debounceKey]) {
480
- clearTimeout(node.pendingDebounce[debounceKey]);
503
+ if (action === 'opening' || action === 'closing') {
504
+ // 标记Mesh正在控制,用于过滤HA的状态反馈
505
+ node.log(`[Symi->HA] 窗帘开始${action}, 标记Symi控制`);
506
+ node.coverMoving[loopKey] = { direction: 'symi', startTime: Date.now() };
507
+ // 不发送动作到HA,只同步位置
508
+ return null;
481
509
  }
482
510
 
483
- // 标记Symi发起的运动
484
- node.coverMoving[loopKey] = { direction: 'symi', startTime: Date.now(), targetPosition: position };
485
-
486
- // 延迟同步,等待位置稳定
487
- node.pendingDebounce[debounceKey] = setTimeout(() => {
488
- delete node.pendingDebounce[debounceKey];
489
- // 运动结束,清理状态
490
- delete node.coverMoving[loopKey];
491
-
492
- // 直接入队,跳过常规防死循环检查(窗帘有专门的运动状态跟踪)
493
- node.queueCommand({
494
- direction: 'symi-to-ha',
495
- mapping: mapping,
496
- syncData: { type: 'position', value: position },
497
- key: loopKey,
498
- skipLoopCheck: true
499
- });
500
- }, COVER_DEBOUNCE_MS);
511
+ if (action === 'stopped') {
512
+ // 停止后延迟5秒清理标记
513
+ node.log(`[Symi->HA] 窗帘stopped, 释放Symi控制权`);
514
+ setTimeout(() => {
515
+ delete node.coverMoving[loopKey];
516
+ }, 5000);
517
+ return null;
518
+ }
501
519
 
502
- return null; // 不立即同步
520
+ return null;
503
521
  }
504
522
 
505
- // 窗帘运行状态 - 同步停止动作
506
- if (attrType === ATTR_CURTAIN_STATUS) {
507
- const action = state.curtainAction || device.state.curtainAction;
508
- if (action === 'stopped') {
509
- // 停止时清理运动状态
510
- delete node.coverMoving[loopKey];
511
- return { type: 'curtain_stop' };
512
- }
523
+ // 窗帘位置变化 - 同步位置到HA
524
+ if (attrType === ATTR_CURTAIN_POSITION) {
525
+ const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
526
+ if (position === undefined) return null;
527
+
528
+ return { type: 'position', value: position };
513
529
  }
514
530
 
515
531
  return null;
@@ -556,6 +572,11 @@ module.exports = function(RED) {
556
572
  node.log('[HA同步] 已收到HA输入,双向同步已启用');
557
573
  }
558
574
 
575
+ // 调试:记录收到的消息结构
576
+ if (msg.payload && msg.payload.event_type) {
577
+ node.debug(`[HA输入] event_type=${msg.payload.event_type}, entity_id=${msg.payload.entity_id}, event=${msg.payload.event ? 'object' : 'null'}`);
578
+ }
579
+
559
580
  // 支持多种消息格式
560
581
  let entityId, newState, oldState;
561
582
 
@@ -567,6 +588,22 @@ module.exports = function(RED) {
567
588
  if (msg.payload.event) {
568
589
  newState = msg.payload.event.new_state;
569
590
  oldState = msg.payload.event.old_state;
591
+ // 如果 event 中也有 entity_id,优先使用(更可靠)
592
+ if (msg.payload.event.entity_id) {
593
+ entityId = msg.payload.event.entity_id;
594
+ }
595
+ // 调试:记录 event 对象的结构
596
+ if (!newState) {
597
+ node.debug(`[HA输入] event对象结构: ${JSON.stringify(Object.keys(msg.payload.event))}`);
598
+ }
599
+ }
600
+ // 兼容格式:event.data 包含 new_state/old_state
601
+ if (!newState && msg.payload.event && msg.payload.event.data) {
602
+ newState = msg.payload.event.data.new_state;
603
+ oldState = msg.payload.event.data.old_state;
604
+ if (msg.payload.event.data.entity_id) {
605
+ entityId = msg.payload.event.data.entity_id;
606
+ }
570
607
  }
571
608
  }
572
609
  // 格式2: server-state-changed 节点的标准格式 (msg.data)
@@ -594,6 +631,16 @@ module.exports = function(RED) {
594
631
  newState = msg.payload;
595
632
  oldState = null;
596
633
  }
634
+ // 格式6: call_service 事件 - 从 service_data 中提取 entity_id
635
+ else if (msg.payload && msg.payload.event_type === 'call_service') {
636
+ const event = msg.payload.event;
637
+ if (event && event.service_data && event.service_data.entity_id) {
638
+ // call_service 事件需要特殊处理,我们需要等待后续的 state_changed 事件
639
+ // 这里只记录日志,不直接处理
640
+ node.debug(`[HA] call_service: ${event.domain}.${event.service} -> ${event.service_data.entity_id}`);
641
+ }
642
+ return; // call_service 事件不直接处理,等待 state_changed
643
+ }
597
644
 
598
645
  // 过滤非 state_changed 事件和无效数据
599
646
  if (!entityId || !newState) {
@@ -605,14 +652,45 @@ module.exports = function(RED) {
605
652
  return;
606
653
  }
607
654
 
655
+ // 过滤 sensor 类型的实体(传感器不需要同步控制)
656
+ if (entityId.startsWith('sensor.')) {
657
+ return;
658
+ }
659
+
608
660
  // 检查是否在映射列表中
609
661
  const mappings = node.findMappingsByHa(entityId);
610
662
  if (mappings.length === 0) {
611
663
  return; // 不在映射中的实体静默忽略
612
664
  }
613
665
 
614
- // 只记录映射中的实体状态变化
615
- node.log(`[HA->Symi] ${entityId}: ${oldState?.state || 'null'} -> ${newState.state}`);
666
+ // 检查状态是否有变化(避免 off -> off 这种无效处理)
667
+ const hasStateChange = !oldState || newState.state !== oldState.state;
668
+ const attrs = newState.attributes || {};
669
+ const oldAttrs = oldState ? (oldState.attributes || {}) : {};
670
+ const domain = entityId.split('.')[0];
671
+
672
+ // 根据实体类型检查相关属性变化
673
+ let hasAttrChange = false;
674
+ if (domain === 'light') {
675
+ hasAttrChange = attrs.brightness !== oldAttrs.brightness;
676
+ } else if (domain === 'cover') {
677
+ // cover需要检查位置变化,状态变化已经在hasStateChange中检查了
678
+ hasAttrChange = attrs.current_position !== oldAttrs.current_position;
679
+ } else if (domain === 'climate') {
680
+ hasAttrChange = attrs.temperature !== oldAttrs.temperature ||
681
+ attrs.hvac_mode !== oldAttrs.hvac_mode ||
682
+ attrs.fan_mode !== oldAttrs.fan_mode;
683
+ } else if (domain === 'fan') {
684
+ hasAttrChange = attrs.percentage !== oldAttrs.percentage ||
685
+ attrs.preset_mode !== oldAttrs.preset_mode;
686
+ }
687
+
688
+ // 如果状态和属性都没变化,完全跳过
689
+ if (!hasStateChange && !hasAttrChange) {
690
+ return;
691
+ }
692
+
693
+ // 不在这里打印日志,让handleHaStateChange内部处理
616
694
  node.handleHaStateChange(entityId, newState, oldState);
617
695
  });
618
696
 
@@ -684,14 +762,38 @@ module.exports = function(RED) {
684
762
 
685
763
  node.handleHaStateChange = function(entityId, newState, oldState) {
686
764
  if (!newState) {
765
+ node.debug(`[HA->Symi] 忽略: ${entityId} newState为空`);
687
766
  return;
688
767
  }
689
768
 
769
+ // 如果新旧状态完全相同,跳过处理(避免无效同步)
770
+ if (oldState && newState.state === oldState.state) {
771
+ const attrs = newState.attributes || {};
772
+ const oldAttrs = oldState.attributes || {};
773
+ // 检查关键属性是否有变化
774
+ const hasAttrChange =
775
+ attrs.brightness !== oldAttrs.brightness ||
776
+ attrs.current_position !== oldAttrs.current_position ||
777
+ attrs.temperature !== oldAttrs.temperature ||
778
+ attrs.hvac_mode !== oldAttrs.hvac_mode ||
779
+ attrs.fan_mode !== oldAttrs.fan_mode ||
780
+ attrs.percentage !== oldAttrs.percentage;
781
+
782
+ if (!hasAttrChange) {
783
+ node.debug(`[HA->Symi] 忽略: ${entityId} 状态无变化`);
784
+ return;
785
+ }
786
+ }
787
+
690
788
  const mappings = node.findMappingsByHa(entityId);
691
789
  if (mappings.length === 0) {
790
+ // 不在映射中的实体静默忽略,但记录调试信息
791
+ node.debug(`[HA->Symi] 实体不在映射中: ${entityId}`);
692
792
  return;
693
793
  }
694
794
 
795
+ node.debug(`[HA->Symi] 处理实体: ${entityId}, 找到 ${mappings.length} 个映射`);
796
+
695
797
  const domain = node.getEntityDomain(entityId);
696
798
  const attrs = newState.attributes || {};
697
799
  const oldAttrs = oldState ? (oldState.attributes || {}) : {};
@@ -740,78 +842,82 @@ module.exports = function(RED) {
740
842
  case 'cover':
741
843
  const coverLoopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
742
844
 
743
- // 检查是否是Symi发起的运动,如果是则忽略HA的位置反馈
845
+ // Mesh控制期间,完全忽略HA的所有消息
744
846
  if (node.coverMoving[coverLoopKey] && node.coverMoving[coverLoopKey].direction === 'symi') {
745
- const elapsed = Date.now() - node.coverMoving[coverLoopKey].startTime;
746
- if (elapsed < COVER_LOOP_PREVENTION_MS) {
747
- node.debug(`[HA->Symi] 窗帘忽略(Symi发起运动中): ${coverLoopKey}`);
748
- break; // 忽略Symi发起运动期间的HA反馈
749
- }
750
- }
751
-
752
- // 运动中状态(opening/closing)不同步位置,避免步进反馈干扰
753
- if (newState.state === 'opening' || newState.state === 'closing') {
754
- node.debug(`[HA->Symi] 窗帘运动中,跳过位置同步: ${newState.state}`);
847
+ node.debug(`[HA->Symi] 窗帘忽略(Mesh控制中): ${newState.state}`);
755
848
  break;
756
849
  }
757
850
 
758
- // 窗帘位置变化 - 只在停止状态时同步
759
- if (attrs.current_position !== undefined) {
760
- if (!oldState || oldAttrs.current_position !== attrs.current_position) {
761
- // 标记HA发起的运动
762
- node.coverMoving[coverLoopKey] = {
763
- direction: 'ha',
764
- startTime: Date.now(),
765
- targetPosition: attrs.current_position
766
- };
767
- syncDataList.push({ type: 'position', value: attrs.current_position });
768
- }
851
+ // 检查是否有位置变化(用户拖动滑块)
852
+ const hasPositionChange = attrs.current_position !== undefined &&
853
+ (!oldState || oldAttrs.current_position !== attrs.current_position);
854
+
855
+ // 优先处理位置变化(用户拖动滑块)- 这是HA主动控制
856
+ if (hasPositionChange) {
857
+ node.log(`[HA->Symi] 窗帘位置: ${attrs.current_position}`);
858
+ // 标记HA正在控制
859
+ node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
860
+ syncDataList.push({ type: 'position', value: attrs.current_position });
861
+ break;
769
862
  }
770
863
 
771
- // 窗帘动作 - open/closed 状态变化
864
+ // 动作变化 - 只处理opening/closing(用户点击按钮)
865
+ // 不处理open/closed(这是状态反馈,不是用户操作)
772
866
  if (newState.state !== oldState?.state) {
773
- if (newState.state === 'open') {
867
+ if (newState.state === 'opening') {
868
+ node.log(`[HA->Symi] 窗帘动作: open`);
774
869
  node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
775
870
  syncDataList.push({ type: 'curtain_action', value: 'open' });
776
- } else if (newState.state === 'closed') {
871
+ } else if (newState.state === 'closing') {
872
+ node.log(`[HA->Symi] 窗帘动作: close`);
777
873
  node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
778
874
  syncDataList.push({ type: 'curtain_action', value: 'close' });
779
875
  }
876
+ // open/closed 是最终状态,不是动作,不需要同步
780
877
  }
781
878
  break;
782
879
 
783
880
  case 'climate':
784
- // 开关状态
785
- if (!oldState || newState.state !== oldState.state) {
786
- const isOn = newState.state !== 'off' && newState.state !== 'unavailable';
787
- // 仅当状态从off变on,或on变off时才同步开关
788
- if ((oldState && oldState.state === 'off' && newState.state !== 'off') ||
789
- (oldState && oldState.state !== 'off' && newState.state === 'off')) {
790
- syncDataList.push({ type: 'switch', value: isOn });
791
- }
881
+ node.debug(`[HA->Symi] 空调状态: ${oldState?.state} -> ${newState.state}, 温度: ${attrs.temperature}, 模式: ${attrs.hvac_mode}, 风速: ${attrs.fan_mode}`);
882
+
883
+ // 开关状态 - 只在状态真正变化时同步
884
+ const isOn = newState.state !== 'off' && newState.state !== 'unavailable';
885
+ const wasOn = oldState ? (oldState.state !== 'off' && oldState.state !== 'unavailable') : null;
886
+
887
+ // 只有当开关状态真正变化时才同步(避免 off -> off 的无效同步)
888
+ if (wasOn !== null && isOn !== wasOn) {
889
+ node.debug(`[HA->Symi] 空调开关: ${wasOn} -> ${isOn}`);
890
+ syncDataList.push({ type: 'switch', value: isOn });
792
891
  }
892
+
793
893
  // 目标温度
794
894
  if (attrs.temperature !== undefined) {
795
895
  if (!oldState || oldAttrs.temperature !== attrs.temperature) {
896
+ node.debug(`[HA->Symi] 空调温度: ${oldAttrs.temperature} -> ${attrs.temperature}`);
796
897
  syncDataList.push({ type: 'temperature', value: Math.round(attrs.temperature) });
797
898
  }
798
899
  }
799
- // HVAC模式
800
- // 过滤掉off模式的变化,因为off已经由开关状态处理
801
- if (newState.state !== 'off' && (attrs.hvac_mode !== undefined || newState.state !== oldState?.state)) {
900
+
901
+ // HVAC模式 - 只在非off状态时同步
902
+ if (newState.state !== 'off' && newState.state !== 'unavailable') {
802
903
  const hvacMode = attrs.hvac_mode || newState.state;
803
- if (hvacMode !== 'off' && (!oldState || (oldAttrs.hvac_mode || oldState.state) !== hvacMode)) {
904
+ const oldHvacMode = oldAttrs.hvac_mode || oldState?.state;
905
+
906
+ if (hvacMode !== 'off' && hvacMode !== oldHvacMode) {
804
907
  const meshMode = HA_TO_AC_MODE[hvacMode];
805
908
  if (meshMode !== undefined) {
909
+ node.debug(`[HA->Symi] 空调模式: ${oldHvacMode} -> ${hvacMode} (mesh: ${meshMode})`);
806
910
  syncDataList.push({ type: 'hvac_mode', value: meshMode });
807
911
  }
808
912
  }
809
913
  }
914
+
810
915
  // 风速
811
916
  if (attrs.fan_mode !== undefined) {
812
917
  if (!oldState || oldAttrs.fan_mode !== attrs.fan_mode) {
813
918
  const meshFan = HA_TO_FAN_MODE[attrs.fan_mode];
814
919
  if (meshFan !== undefined) {
920
+ node.debug(`[HA->Symi] 空调风速: ${oldAttrs.fan_mode} -> ${attrs.fan_mode} (mesh: ${meshFan})`);
815
921
  syncDataList.push({ type: 'fan_mode', value: meshFan });
816
922
  }
817
923
  }
@@ -937,6 +1043,19 @@ module.exports = function(RED) {
937
1043
  case 'position':
938
1044
  service = 'set_cover_position';
939
1045
  serviceData.position = syncData.value;
1046
+ // 刷新coverMoving状态,防止HA反馈被处理
1047
+ const posKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
1048
+ node.coverMoving[posKey] = { direction: 'symi', startTime: Date.now(), targetPosition: syncData.value };
1049
+ node.debug(`[Symi->HA] 窗帘位置同步,刷新coverMoving: ${posKey}`);
1050
+ break;
1051
+
1052
+ case 'curtain_action':
1053
+ // open/close 动作 - 确保标记为Symi发起的运动
1054
+ service = syncData.value === 'open' ? 'open_cover' : 'close_cover';
1055
+ // 刷新coverMoving状态,防止HA反馈被处理
1056
+ const coverKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
1057
+ node.coverMoving[coverKey] = { direction: 'symi', startTime: Date.now() };
1058
+ node.debug(`[Symi->HA] 窗帘动作同步,刷新coverMoving: ${coverKey}`);
940
1059
  break;
941
1060
 
942
1061
  case 'curtain_stop':
@@ -970,7 +1089,7 @@ module.exports = function(RED) {
970
1089
  return;
971
1090
  }
972
1091
 
973
- const serviceDomain = (syncData.type === 'position' || syncData.type === 'curtain_stop') ? 'cover' : domain;
1092
+ const serviceDomain = (syncData.type === 'position' || syncData.type === 'curtain_stop' || syncData.type === 'curtain_action') ? 'cover' : domain;
974
1093
 
975
1094
  await axios.post(`${baseURL}/api/services/${serviceDomain}/${service}`, serviceData, {
976
1095
  headers: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.8.3",
3
+ "version": "1.8.4",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {