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

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.
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
3
- * 版本: 1.8.4
3
+ * 版本: 1.8.5
4
+ *
5
+ * v1.8.5 更新:
6
+ * - 使用通用 SyncUtils 类统一防环路逻辑
7
+ * - 添加 StateCache 状态缓存,避免重复同步
8
+ * - 优化三合一面板同步逻辑
4
9
  *
5
10
  * v1.8.4 更新:
6
11
  * - 修复HA state_changed事件解析,支持更多消息格式
@@ -20,6 +25,7 @@
20
25
 
21
26
  module.exports = function(RED) {
22
27
  const axios = require('axios');
28
+ const { SyncUtils, StateCache, COVER_TIMEOUT, BRIGHTNESS_TIMEOUT } = require('../lib/sync-utils');
23
29
 
24
30
  // 常量定义
25
31
  const LOOP_PREVENTION_MS = 2000; // 防死循环时间窗口
@@ -29,11 +35,11 @@ module.exports = function(RED) {
29
35
  const TIMESTAMP_EXPIRE_MS = 60000;
30
36
 
31
37
  // 窗帘专用常量
32
- const COVER_LOOP_PREVENTION_MS = 30000; // 30秒防死循环
38
+ const COVER_LOOP_PREVENTION_MS = COVER_TIMEOUT; // 30秒防死循环
33
39
  const COVER_DEBOUNCE_MS = 1500; // 1.5秒防抖
34
40
 
35
41
  // 调光专用常量
36
- const BRIGHTNESS_DEBOUNCE_MS = 800; // 0.8秒防抖,过滤步进过程
42
+ const BRIGHTNESS_DEBOUNCE_MS = BRIGHTNESS_TIMEOUT; // 0.8秒防抖,过滤步进过程
37
43
 
38
44
  // Mesh属性类型
39
45
  const ATTR_SWITCH = 0x02;
@@ -68,6 +74,16 @@ module.exports = function(RED) {
68
74
  node.mqttNode = RED.nodes.getNode(config.mqttConfig);
69
75
  node.haServer = RED.nodes.getNode(config.haServer);
70
76
 
77
+ // 初始化通用同步工具
78
+ node.syncUtils = new SyncUtils({
79
+ defaultTimeout: LOOP_PREVENTION_MS,
80
+ coverTimeout: COVER_LOOP_PREVENTION_MS,
81
+ brightnessTimeout: BRIGHTNESS_DEBOUNCE_MS
82
+ });
83
+
84
+ // 初始化状态缓存(用于三合一面板细粒度状态对比)
85
+ node.stateCache = new StateCache(node.context(), 'ha_sync_state_cache');
86
+
71
87
  // 解析映射配置
72
88
  try {
73
89
  const rawMappings = JSON.parse(config.mappings || '[]');
@@ -185,25 +201,16 @@ module.exports = function(RED) {
185
201
  }
186
202
  }, CLEANUP_INTERVAL_MS);
187
203
 
188
- // 防死循环检查 - 双向时间戳检查
204
+ // 防死循环检查 - 使用通用 SyncUtils
189
205
  node.shouldPreventSync = function(direction, key) {
190
- const now = Date.now();
191
- if (direction === 'symi-to-ha') {
192
- const lastHaTime = node.lastHaToSymi[key] || 0;
193
- return (now - lastHaTime) < LOOP_PREVENTION_MS;
194
- } else {
195
- const lastSymiTime = node.lastSymiToHa[key] || 0;
196
- return (now - lastSymiTime) < LOOP_PREVENTION_MS;
197
- }
206
+ // 转换方向名称以匹配 SyncUtils 的格式
207
+ const syncDirection = direction === 'symi-to-ha' ? 'mesh-to-target' : 'target-to-mesh';
208
+ return node.syncUtils.shouldPreventSync(syncDirection, key);
198
209
  };
199
210
 
200
211
  node.recordSyncTime = function(direction, key) {
201
- const now = Date.now();
202
- if (direction === 'symi-to-ha') {
203
- node.lastSymiToHa[key] = now;
204
- } else {
205
- node.lastHaToSymi[key] = now;
206
- }
212
+ const syncDirection = direction === 'symi-to-ha' ? 'mesh-to-target' : 'target-to-mesh';
213
+ node.syncUtils.recordSyncTime(syncDirection, key);
207
214
  };
208
215
 
209
216
  node.sleep = function(ms) {
@@ -314,23 +321,35 @@ module.exports = function(RED) {
314
321
  // 支持返回数组(用于三合一同时同步多个属性)
315
322
  const syncDataList = Array.isArray(syncData) ? syncData : [syncData];
316
323
 
317
- syncDataList.forEach(data => {
318
- // 窗帘使用专门的coverMoving状态跟踪,跳过常规防死循环检查
319
- const isCoverAction = domain === 'cover' &&
320
- (data.type === 'curtain_action' || data.type === 'curtain_stop' || data.type === 'position');
324
+ syncDataList.forEach(async data => {
325
+ // 窗帘关键动作:开、关、停
326
+ const isImmediateAction = domain === 'cover' &&
327
+ (data.type === 'curtain_action' || data.type === 'curtain_stop');
321
328
 
322
- if (!isCoverAction && node.shouldPreventSync('symi-to-ha', loopKey)) {
329
+ // 窗帘位置同步(普通)
330
+ const isCoverPos = domain === 'cover' && data.type === 'position';
331
+
332
+ if (!isImmediateAction && !isCoverPos && node.shouldPreventSync('symi-to-ha', loopKey)) {
323
333
  node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
324
334
  return;
325
335
  }
326
336
 
327
- node.queueCommand({
337
+ const cmd = {
328
338
  direction: 'symi-to-ha',
329
339
  mapping: mapping,
330
340
  syncData: data,
331
341
  key: loopKey,
332
- skipLoopCheck: isCoverAction // 窗帘跳过常规防死循环检查
333
- });
342
+ skipLoopCheck: isImmediateAction || isCoverPos
343
+ };
344
+
345
+ // 窗帘关键动作:绕过队列,立即执行
346
+ if (isImmediateAction) {
347
+ node.debug(`[Symi->HA] 窗帘关键动作,立即同步: ${data.type}=${data.value}`);
348
+ node.syncSymiToHa(cmd); // 异步执行但不排队
349
+ return;
350
+ }
351
+
352
+ node.queueCommand(cmd);
334
353
  });
335
354
  }
336
355
  });
@@ -339,79 +358,54 @@ module.exports = function(RED) {
339
358
  // 处理三合一状态变化
340
359
  node.handleThreeInOneChange = function(device, mapping, state, attrType) {
341
360
  const subType = mapping.symiKey; // 'aircon', 'fresh_air', 'floor_heating'
361
+ const mac = device.macAddress.toLowerCase().replace(/:/g, '');
362
+ const changedData = [];
342
363
 
343
- // 1. 空调部分 (通常通过0x94或标准温控指令更新)
344
- if (subType === 'aircon') {
345
- // 如果是标准温控属性更新,已经在switch case中处理了
346
- // 这里主要处理0x94带来的全量更新
347
- if (attrType === ATTR_THREE_IN_ONE) {
348
- // 此时state已经包含了所有更新
349
- // 需要检查哪些属性变了,但这里只能返回一个syncData
350
- // 我们可以返回一个特殊对象,或者分别检查
351
- // 为简化,这里假设HA端会处理部分更新,或者我们按优先级返回
352
-
353
- // 检查开关
354
- if (state.climateSwitch !== undefined) {
355
- // 注意:这里需要比对旧状态,但在handleSymiStateChange中难以获取旧状态
356
- // 我们可以利用node.queueCommand的去重机制,发送所有可能的状态
357
- // 但这样会产生大量流量。
358
- // 实际上DeviceManager触发事件时,如果是0x94,是全量更新。
359
- // 我们可以只处理核心属性。
360
- // 更好的方式是:在device-manager中,0x94更新会触发一次事件。
361
- // 这里我们返回一个复合对象,或者由上层逻辑拆分。
362
- // 由于syncData只能是一个对象,我们优先同步开关,然后是模式/温度
363
- }
364
- }
365
- // 由于0x94更新时,device-manager已经更新了state
366
- // 我们可以直接从state读取当前值
364
+ // 辅助函数:检查属性是否变化,使用细粒度缓存key
365
+ const checkAndCacheProperty = (property, value, syncData) => {
366
+ if (value === undefined) return;
367
+
368
+ // 细粒度缓存key: mac_subEntity_property
369
+ const cacheKey = `${mac}_${subType}_${property}`;
367
370
 
368
- // 构造空调状态
369
- // 这里我们可能需要多次调用queueCommand,但handle函数只能返回一个
370
- // 解决方案:handleSymiStateChange支持返回数组
371
- return [
372
- { type: 'switch', value: state.climateSwitch },
373
- { type: 'temperature', value: state.targetTemp },
374
- { type: 'hvac_mode', value: AC_MODE_TO_HA[state.climateMode] || 'off', meshValue: state.climateMode },
375
- { type: 'fan_mode', value: FAN_MODE_TO_HA[state.fanMode] || 'auto', meshValue: state.fanMode }
376
- ];
371
+ // 检查是否有变化
372
+ if (node.stateCache.hasChanged(cacheKey, value)) {
373
+ node.stateCache.update(cacheKey, value);
374
+ changedData.push(syncData);
375
+ node.debug(`[三合一] ${subType}.${property} 变化: ${value}`);
376
+ }
377
+ };
378
+
379
+ // 1. 空调部分
380
+ if (subType === 'aircon') {
381
+ checkAndCacheProperty('switch', state.climateSwitch,
382
+ { type: 'switch', value: state.climateSwitch });
383
+ checkAndCacheProperty('temperature', state.targetTemp,
384
+ { type: 'temperature', value: state.targetTemp });
385
+ checkAndCacheProperty('mode', state.climateMode,
386
+ { type: 'hvac_mode', value: AC_MODE_TO_HA[state.climateMode] || 'off', meshValue: state.climateMode });
387
+ checkAndCacheProperty('fanSpeed', state.fanMode,
388
+ { type: 'fan_mode', value: FAN_MODE_TO_HA[state.fanMode] || 'auto', meshValue: state.fanMode });
377
389
  }
378
390
 
379
391
  // 2. 新风部分
380
- if (subType === 'fresh_air') {
381
- // 新风开关 (0x68或0x94)
382
- if (attrType === ATTR_FRESH_AIR_SWITCH || attrType === ATTR_THREE_IN_ONE) {
383
- if (state.freshAirSwitch !== undefined) {
384
- return { type: 'switch', value: state.freshAirSwitch };
385
- }
386
- }
387
- // 新风风速 (0x6A或0x94)
388
- if (attrType === ATTR_FRESH_AIR_SPEED || attrType === ATTR_THREE_IN_ONE) {
389
- if (state.freshAirSpeed !== undefined) {
390
- return { type: 'fan_mode', value: FAN_MODE_TO_HA[state.freshAirSpeed] || 'auto', meshValue: state.freshAirSpeed };
391
- }
392
- }
393
- // 新风模式 (0x69或0x94)
394
- // 目前HA Fan实体通常只支持on/off和speed,mode可能不支持或映射到preset_mode
395
- // 暂时忽略模式,或视需求添加
392
+ else if (subType === 'fresh_air') {
393
+ checkAndCacheProperty('switch', state.freshAirSwitch,
394
+ { type: 'switch', value: state.freshAirSwitch });
395
+ checkAndCacheProperty('speed', state.freshAirSpeed,
396
+ { type: 'fan_mode', value: FAN_MODE_TO_HA[state.freshAirSpeed] || 'auto', meshValue: state.freshAirSpeed });
396
397
  }
397
398
 
398
399
  // 3. 地暖部分
399
- if (subType === 'floor_heating') {
400
- // 地暖开关 (0x6B或0x94)
401
- if (attrType === ATTR_FLOOR_HEATING_SWITCH || attrType === ATTR_THREE_IN_ONE) {
402
- if (state.floorHeatingSwitch !== undefined) {
403
- return { type: 'switch', value: state.floorHeatingSwitch };
404
- }
405
- }
406
- // 地暖温度 (0x6C或0x94)
407
- if (attrType === ATTR_FLOOR_HEATING_TEMP || attrType === ATTR_THREE_IN_ONE) {
408
- if (state.floorHeatingTemp !== undefined) {
409
- return { type: 'temperature', value: state.floorHeatingTemp };
410
- }
411
- }
400
+ else if (subType === 'floor_heating') {
401
+ checkAndCacheProperty('switch', state.floorHeatingSwitch,
402
+ { type: 'switch', value: state.floorHeatingSwitch });
403
+ checkAndCacheProperty('temperature', state.floorHeatingTemp,
404
+ { type: 'temperature', value: state.floorHeatingTemp });
412
405
  }
413
406
 
414
- return null;
407
+ // 只返回真正变化的属性
408
+ return changedData.length > 0 ? changedData : null;
415
409
  };
416
410
 
417
411
  // 处理开关状态变化
@@ -475,56 +469,75 @@ module.exports = function(RED) {
475
469
  };
476
470
 
477
471
  // 处理窗帘变化 - 谁发起控制就只听谁的命令
478
- // Mesh控制时只同步位置,不同步动作(HA会根据位置自动更新状态)
472
+ // Mesh控制时只同步最终位置,忽略过程中的实时反馈
479
473
  node.handleCurtainChange = function(device, mapping, state, attrType) {
480
474
  const domain = node.getEntityDomain(mapping.haEntityId);
481
475
  if (domain !== 'cover') return null;
482
476
 
483
477
  const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
484
-
485
- // HA控制期间,完全忽略Mesh的所有反馈
486
- if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
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);
494
- }
495
- node.debug(`[Symi->HA] 窗帘忽略(HA控制中): ${JSON.stringify(state)}`);
496
- return null;
478
+ const now = Date.now();
479
+
480
+ // 检查控制锁定时长,超过40秒强制释放(针对无限位模块或丢包情况)
481
+ if (node.coverMoving[loopKey] && (now - node.coverMoving[loopKey].startTime > 40000)) {
482
+ node.log(`[Symi->HA] 窗帘控制锁定超时(40s),针对无限位模块强制释放: ${loopKey}`);
483
+ delete node.coverMoving[loopKey];
497
484
  }
485
+
486
+ // 获取动作状态
487
+ const action = state.curtainAction || device.state.curtainAction;
498
488
 
499
- // 窗帘运行状态变化 - 只标记控制方向,不发送动作到HA
489
+ // 1. 处理运行状态变化 (ATTR_CURTAIN_STATUS)
500
490
  if (attrType === ATTR_CURTAIN_STATUS) {
501
- const action = state.curtainAction || device.state.curtainAction;
502
-
503
491
  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,只同步位置
492
+ // 如果是由HA发起的控制,Mesh反馈正在运行是正常的,继续保持HA控制权
493
+ if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
494
+ node.debug(`[Symi->HA] 窗帘反馈正在运行(HA控制中): ${action}`);
495
+ return null;
496
+ }
497
+
498
+ // 强制同步:无论当前是否有锁定,只要收到 opening/closing 且当前状态不是对应动作,就触发一次同步
499
+ const currentAction = node.coverMoving[loopKey] ? node.coverMoving[loopKey].action : null;
500
+ if (currentAction !== action) {
501
+ node.log(`[Symi->HA] 窗帘由Mesh发起动作: ${action}`);
502
+ node.coverMoving[loopKey] = {
503
+ direction: 'symi',
504
+ startTime: now,
505
+ action: action
506
+ };
507
+
508
+ // 立即返回同步指令
509
+ return { type: 'curtain_action', value: action === 'opening' ? 'open' : 'close' };
510
+ }
508
511
  return null;
509
512
  }
510
513
 
511
514
  if (action === 'stopped') {
512
- // 停止后延迟5秒清理标记
513
- node.log(`[Symi->HA] 窗帘stopped, 释放Symi控制权`);
514
- setTimeout(() => {
515
- delete node.coverMoving[loopKey];
516
- }, 5000);
517
- return null;
515
+ node.log(`[Symi->HA] 窗帘已停止,释放控制权: ${loopKey}`);
516
+ delete node.coverMoving[loopKey];
517
+
518
+ // 停止后同步一次最终位置
519
+ const finalPos = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
520
+ if (finalPos !== undefined) {
521
+ node.debug(`[Symi->HA] 同步最终位置: ${finalPos}%`);
522
+ return { type: 'position', value: finalPos };
523
+ }
518
524
  }
519
-
520
525
  return null;
521
526
  }
522
527
 
523
- // 窗帘位置变化 - 同步位置到HA
528
+ // 2. 处理位置变化 (ATTR_CURTAIN_POSITION)
524
529
  if (attrType === ATTR_CURTAIN_POSITION) {
525
530
  const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
526
531
  if (position === undefined) return null;
527
-
532
+
533
+ // 核心需求:忽略过程中的实时反馈指令
534
+ // 如果当前有任何控制方向的锁定(正在移动中),则忽略位置反馈
535
+ if (node.coverMoving[loopKey]) {
536
+ node.debug(`[Symi->HA] 忽略移动中的实时位置反馈: ${position}%`);
537
+ return null;
538
+ }
539
+
540
+ // 没有移动锁定时,允许同步(例如初始同步或查询返回)
528
541
  return { type: 'position', value: position };
529
542
  }
530
543
 
@@ -631,15 +644,52 @@ module.exports = function(RED) {
631
644
  newState = msg.payload;
632
645
  oldState = null;
633
646
  }
634
- // 格式6: call_service 事件 - 从 service_data 中提取 entity_id
647
+ // 格式6: call_service 事件 - 专门处理窗帘控制,解决状态反馈慢的问题
635
648
  else if (msg.payload && msg.payload.event_type === 'call_service') {
636
649
  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}`);
650
+ if (event && event.domain === 'cover' && event.service_data && event.service_data.entity_id) {
651
+ const entityId = event.service_data.entity_id;
652
+ const service = event.service;
653
+
654
+ node.log(`[HA] 捕获到窗帘服务调用: ${service} -> ${entityId}`);
655
+
656
+ const mappings = node.findMappingsByHa(entityId);
657
+ if (mappings.length > 0) {
658
+ const now = Date.now();
659
+ mappings.forEach(mapping => {
660
+ const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
661
+ let syncData = null;
662
+
663
+ if (service === 'open_cover') {
664
+ node.coverMoving[loopKey] = { direction: 'ha', startTime: now };
665
+ syncData = { type: 'curtain_action', value: 1 };
666
+ } else if (service === 'close_cover') {
667
+ node.coverMoving[loopKey] = { direction: 'ha', startTime: now };
668
+ syncData = { type: 'curtain_action', value: 2 };
669
+ } else if (service === 'stop_cover') {
670
+ delete node.coverMoving[loopKey];
671
+ syncData = { type: 'curtain_stop', value: 3 };
672
+ } else if (service === 'set_cover_position') {
673
+ const pos = event.service_data.position;
674
+ if (pos !== undefined) {
675
+ node.coverMoving[loopKey] = { direction: 'ha', startTime: now };
676
+ syncData = { type: 'position', value: pos };
677
+ }
678
+ }
679
+
680
+ if (syncData) {
681
+ node.queueCommand({
682
+ direction: 'ha-to-symi',
683
+ mapping: mapping,
684
+ syncData: syncData,
685
+ key: loopKey,
686
+ skipLoopCheck: true
687
+ });
688
+ }
689
+ });
690
+ }
641
691
  }
642
- return; // call_service 事件不直接处理,等待 state_changed
692
+ return;
643
693
  }
644
694
 
645
695
  // 过滤非 state_changed 事件和无效数据
@@ -841,39 +891,38 @@ module.exports = function(RED) {
841
891
 
842
892
  case 'cover':
843
893
  const coverLoopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
844
-
845
- // Mesh控制期间,完全忽略HA的所有消息
846
- if (node.coverMoving[coverLoopKey] && node.coverMoving[coverLoopKey].direction === 'symi') {
847
- node.debug(`[HA->Symi] 窗帘忽略(Mesh控制中): ${newState.state}`);
848
- break;
894
+ const now = Date.now();
895
+
896
+ // 检查锁定时长,超过40秒强制释放(针对无限位模块)
897
+ if (node.coverMoving[coverLoopKey] && (now - node.coverMoving[coverLoopKey].startTime > 40000)) {
898
+ node.log(`[HA->Symi] 窗帘控制锁定超时(40s),强制释放: ${coverLoopKey}`);
899
+ delete node.coverMoving[coverLoopKey];
849
900
  }
850
-
851
- // 检查是否有位置变化(用户拖动滑块)
901
+
902
+ // 1. 处理位置变化 (用户拖动滑块)
852
903
  const hasPositionChange = attrs.current_position !== undefined &&
853
904
  (!oldState || oldAttrs.current_position !== attrs.current_position);
854
905
 
855
- // 优先处理位置变化(用户拖动滑块)- 这是HA主动控制
856
906
  if (hasPositionChange) {
857
- node.log(`[HA->Symi] 窗帘位置: ${attrs.current_position}`);
858
- // 标记HA正在控制
859
- node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
907
+ // HA 始终有控制优先权
908
+ node.log(`[HA->Symi] 窗帘位置控制: ${attrs.current_position}`);
909
+ node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: now };
860
910
  syncDataList.push({ type: 'position', value: attrs.current_position });
861
911
  break;
862
912
  }
863
913
 
864
- // 动作变化 - 只处理opening/closing(用户点击按钮)
865
- // 不处理open/closed(这是状态反馈,不是用户操作)
914
+ // 2. 处理动作变化(用户点击按钮)
866
915
  if (newState.state !== oldState?.state) {
867
- if (newState.state === 'opening') {
868
- node.log(`[HA->Symi] 窗帘动作: open`);
869
- node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
870
- syncDataList.push({ type: 'curtain_action', value: 'open' });
871
- } else if (newState.state === 'closing') {
872
- node.log(`[HA->Symi] 窗帘动作: close`);
873
- node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
874
- syncDataList.push({ type: 'curtain_action', value: 'close' });
916
+ if (newState.state === 'opening' || newState.state === 'closing') {
917
+ node.log(`[HA->Symi] 窗帘动作控制: ${newState.state}`);
918
+ node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: now };
919
+ syncDataList.push({ type: 'curtain_action', value: newState.state === 'opening' ? 1 : 2 });
920
+ } else if (newState.state === 'idle' || newState.state === 'stopped') {
921
+ // 停止指令
922
+ node.log(`[HA->Symi] 窗帘停止控制`);
923
+ delete node.coverMoving[coverLoopKey];
924
+ syncDataList.push({ type: 'curtain_stop', value: 3 });
875
925
  }
876
- // open/closed 是最终状态,不是动作,不需要同步
877
926
  }
878
927
  break;
879
928
 
@@ -1060,6 +1109,9 @@ module.exports = function(RED) {
1060
1109
 
1061
1110
  case 'curtain_stop':
1062
1111
  service = 'stop_cover';
1112
+ // 刷新锁定状态,防止HA反馈
1113
+ const stopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
1114
+ node.coverMoving[stopKey] = { direction: 'symi', startTime: Date.now() };
1063
1115
  break;
1064
1116
 
1065
1117
  case 'temperature':
@@ -1282,6 +1334,16 @@ module.exports = function(RED) {
1282
1334
  node.coverMoving = {}; // 清理窗帘运动状态
1283
1335
  node.brightnessMoving = {}; // 清理调光运动状态
1284
1336
 
1337
+ // 清理 SyncUtils
1338
+ if (node.syncUtils) {
1339
+ node.syncUtils.destroy();
1340
+ }
1341
+
1342
+ // 清理 StateCache(三合一状态缓存)
1343
+ if (node.stateCache) {
1344
+ node.stateCache.clear();
1345
+ }
1346
+
1285
1347
  if (gateway) {
1286
1348
  gateway.removeListener('device-state-changed', node.handleSymiStateChange);
1287
1349
  }
@@ -17,6 +17,14 @@
17
17
  label: function() { return this.name || 'KNX桥接'; },
18
18
  oneditprepare: function() {
19
19
  const node = this;
20
+
21
+ // 设置编辑面板更宽更高
22
+ var panel = $('#dialog-form').parent();
23
+ if (panel.length) {
24
+ if (panel.width() < 1000) panel.css('width', '1000px');
25
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
26
+ }
27
+
20
28
  let mappings = [], devices = [], knxEntities = [];
21
29
  const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖'};
22
30
 
@@ -3,10 +3,15 @@
3
3
  * 支持开关、窗帘等设备的双向状态同步
4
4
  * 事件驱动架构,命令队列顺序处理,防死循环机制
5
5
  *
6
- * 版本: 1.6.8
6
+ * 版本: 1.8.5
7
+ *
8
+ * v1.8.5 更新:
9
+ * - 使用通用 SyncUtils 类统一防环路逻辑
10
+ * - 移除本地 LOOP_PREVENTION_MS 常量,使用 SyncUtils.getTimeoutForDevice()
7
11
  */
8
12
 
9
13
  module.exports = function(RED) {
14
+ const { SyncUtils, COVER_TIMEOUT, BRIGHTNESS_TIMEOUT, DEFAULT_TIMEOUT } = require('../lib/sync-utils');
10
15
 
11
16
  // 设备类型配置
12
17
  const DEVICE_TYPES = {
@@ -147,12 +152,16 @@ module.exports = function(RED) {
147
152
  node.lastSyncTime = 0;
148
153
  node.stateCache = {}; // Mesh设备状态缓存
149
154
  node.knxStateCache = {}; // KNX设备状态缓存
150
- node.lastMeshToKnx = {}; // 记录Mesh->KNX发送时间,防止回环
151
- node.lastKnxToMesh = {}; // 记录KNX->Mesh发送时间,防止回环
152
155
  node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间,防止自己发的命令又被处理
153
156
 
154
- // 防死循环参数
155
- const LOOP_PREVENTION_MS = 800; // 800ms内不处理反向同步,防止回环
157
+ // 初始化通用同步工具类
158
+ node.syncUtils = new SyncUtils({
159
+ defaultTimeout: DEFAULT_TIMEOUT,
160
+ coverTimeout: COVER_TIMEOUT,
161
+ brightnessTimeout: BRIGHTNESS_TIMEOUT
162
+ });
163
+
164
+ // 防死循环参数(使用 SyncUtils 的超时配置)
156
165
  const DEBOUNCE_MS = 100; // 100ms防抖
157
166
  const MAX_QUEUE_SIZE = 100; // 最大队列大小
158
167
  const COVER_CONTROL_LOCK_MS = 3000; // 窗帘/调光控制锁定3秒(只忽略过程反馈,不长期锁定)
@@ -226,26 +235,17 @@ module.exports = function(RED) {
226
235
  return str;
227
236
  };
228
237
 
229
- // 检查是否应该阻止同步(防死循环)
238
+ // 检查是否应该阻止同步(防死循环)- 使用通用 SyncUtils
230
239
  node.shouldPreventSync = function(direction, key) {
231
- const now = Date.now();
232
- if (direction === 'mesh-to-knx') {
233
- const lastKnxTime = node.lastKnxToMesh[key] || 0;
234
- return (now - lastKnxTime) < LOOP_PREVENTION_MS;
235
- } else {
236
- const lastMeshTime = node.lastMeshToKnx[key] || 0;
237
- return (now - lastMeshTime) < LOOP_PREVENTION_MS;
238
- }
240
+ // 转换方向名称以匹配 SyncUtils 的格式
241
+ const syncDirection = direction === 'mesh-to-knx' ? 'mesh-to-target' : 'target-to-mesh';
242
+ return node.syncUtils.shouldPreventSync(syncDirection, key);
239
243
  };
240
244
 
241
- // 记录同步时间(用于防死循环)
245
+ // 记录同步时间(用于防死循环)- 使用通用 SyncUtils
242
246
  node.recordSyncTime = function(direction, key) {
243
- const now = Date.now();
244
- if (direction === 'mesh-to-knx') {
245
- node.lastMeshToKnx[key] = now;
246
- } else {
247
- node.lastKnxToMesh[key] = now;
248
- }
247
+ const syncDirection = direction === 'mesh-to-knx' ? 'mesh-to-target' : 'target-to-mesh';
248
+ node.syncUtils.recordSyncTime(syncDirection, key);
249
249
  };
250
250
 
251
251
  // 延时函数
@@ -953,7 +953,7 @@ module.exports = function(RED) {
953
953
 
954
954
  // 检查是否是我们自己发出去的命令(防止自己发的命令被监听后又处理)
955
955
  const lastSentTime = node.lastKnxAddrSent[groupAddr] || 0;
956
- if (Date.now() - lastSentTime < LOOP_PREVENTION_MS) {
956
+ if (Date.now() - lastSentTime < DEFAULT_TIMEOUT) {
957
957
  node.debug(`[KNX输入] 跳过(自己发的): ${groupAddr}`);
958
958
  done && done();
959
959
  return;
@@ -1157,12 +1157,15 @@ module.exports = function(RED) {
1157
1157
  node.gateway.removeListener('scene-executed', handleSceneExecuted);
1158
1158
  }
1159
1159
 
1160
+ // 销毁 SyncUtils 实例,清理资源
1161
+ if (node.syncUtils) {
1162
+ node.syncUtils.destroy();
1163
+ }
1164
+
1160
1165
  // 清空队列和缓存
1161
1166
  node.commandQueue = [];
1162
1167
  node.stateCache = {};
1163
1168
  node.knxStateCache = {};
1164
- node.lastMeshToKnx = {};
1165
- node.lastKnxToMesh = {};
1166
1169
 
1167
1170
  node.status({});
1168
1171
  done();
@@ -20,6 +20,13 @@
20
20
  oneditprepare: function() {
21
21
  var node = this;
22
22
 
23
+ // 设置编辑面板更宽更高
24
+ var panel = $('#dialog-form').parent();
25
+ if (panel.length) {
26
+ if (panel.width() < 1000) panel.css('width', '1000px');
27
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
28
+ }
29
+
23
30
  // 品牌切换
24
31
  function updateBrandUI() {
25
32
  var brand = $('#node-config-input-brand').val();
@@ -36,8 +36,8 @@
36
36
  // 设置编辑面板更宽更高
37
37
  var panel = $('#dialog-form').parent();
38
38
  if (panel.length) {
39
- if (panel.width() < 920) panel.css('width', '900px');
40
- if (panel.height() < 700) panel.css('min-height', '700px');
39
+ if (panel.width() < 1000) panel.css('width', '1000px');
40
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
41
41
  }
42
42
 
43
43
  var deviceTypes = {