node-red-contrib-symi-mesh 1.8.3 → 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,19 @@
1
1
  /**
2
2
  * Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
3
- * 版本: 1.8.3
3
+ * 版本: 1.8.5
4
+ *
5
+ * v1.8.5 更新:
6
+ * - 使用通用 SyncUtils 类统一防环路逻辑
7
+ * - 添加 StateCache 状态缓存,避免重复同步
8
+ * - 优化三合一面板同步逻辑
9
+ *
10
+ * v1.8.4 更新:
11
+ * - 修复HA state_changed事件解析,支持更多消息格式
12
+ * - 优化窗帘同步:动作命令直接同步,位置只在非运动状态同步
13
+ * - 优化空调同步:只在开关状态真正变化时同步,避免off->off无效日志
14
+ * - 优化调光同步:HA发起调光时忽略Mesh步进反馈
15
+ * - 增加状态变化检测,无变化时跳过处理
16
+ * - 过滤sensor类型实体,避免不必要的处理
4
17
  *
5
18
  * 支持的实体类型和属性:
6
19
  * - light: on/off, brightness (0-255)
@@ -12,6 +25,7 @@
12
25
 
13
26
  module.exports = function(RED) {
14
27
  const axios = require('axios');
28
+ const { SyncUtils, StateCache, COVER_TIMEOUT, BRIGHTNESS_TIMEOUT } = require('../lib/sync-utils');
15
29
 
16
30
  // 常量定义
17
31
  const LOOP_PREVENTION_MS = 2000; // 防死循环时间窗口
@@ -21,11 +35,11 @@ module.exports = function(RED) {
21
35
  const TIMESTAMP_EXPIRE_MS = 60000;
22
36
 
23
37
  // 窗帘专用常量
24
- const COVER_LOOP_PREVENTION_MS = 30000; // 30秒防死循环
38
+ const COVER_LOOP_PREVENTION_MS = COVER_TIMEOUT; // 30秒防死循环
25
39
  const COVER_DEBOUNCE_MS = 1500; // 1.5秒防抖
26
40
 
27
41
  // 调光专用常量
28
- const BRIGHTNESS_DEBOUNCE_MS = 800; // 0.8秒防抖,过滤步进过程
42
+ const BRIGHTNESS_DEBOUNCE_MS = BRIGHTNESS_TIMEOUT; // 0.8秒防抖,过滤步进过程
29
43
 
30
44
  // Mesh属性类型
31
45
  const ATTR_SWITCH = 0x02;
@@ -60,6 +74,16 @@ module.exports = function(RED) {
60
74
  node.mqttNode = RED.nodes.getNode(config.mqttConfig);
61
75
  node.haServer = RED.nodes.getNode(config.haServer);
62
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
+
63
87
  // 解析映射配置
64
88
  try {
65
89
  const rawMappings = JSON.parse(config.mappings || '[]');
@@ -177,25 +201,16 @@ module.exports = function(RED) {
177
201
  }
178
202
  }, CLEANUP_INTERVAL_MS);
179
203
 
180
- // 防死循环检查 - 双向时间戳检查
204
+ // 防死循环检查 - 使用通用 SyncUtils
181
205
  node.shouldPreventSync = function(direction, key) {
182
- const now = Date.now();
183
- if (direction === 'symi-to-ha') {
184
- const lastHaTime = node.lastHaToSymi[key] || 0;
185
- return (now - lastHaTime) < LOOP_PREVENTION_MS;
186
- } else {
187
- const lastSymiTime = node.lastSymiToHa[key] || 0;
188
- return (now - lastSymiTime) < LOOP_PREVENTION_MS;
189
- }
206
+ // 转换方向名称以匹配 SyncUtils 的格式
207
+ const syncDirection = direction === 'symi-to-ha' ? 'mesh-to-target' : 'target-to-mesh';
208
+ return node.syncUtils.shouldPreventSync(syncDirection, key);
190
209
  };
191
210
 
192
211
  node.recordSyncTime = function(direction, key) {
193
- const now = Date.now();
194
- if (direction === 'symi-to-ha') {
195
- node.lastSymiToHa[key] = now;
196
- } else {
197
- node.lastHaToSymi[key] = now;
198
- }
212
+ const syncDirection = direction === 'symi-to-ha' ? 'mesh-to-target' : 'target-to-mesh';
213
+ node.syncUtils.recordSyncTime(syncDirection, key);
199
214
  };
200
215
 
201
216
  node.sleep = function(ms) {
@@ -221,17 +236,27 @@ module.exports = function(RED) {
221
236
 
222
237
  // ========== 1. 监听Symi设备状态变化 (Symi -> HA) ==========
223
238
  node.handleSymiStateChange = function(eventData) {
224
- if (!eventData.device || !eventData.device.macAddress) return;
239
+ if (!eventData.device || !eventData.device.macAddress) {
240
+ node.debug('[Symi->HA] 忽略: 无效的设备数据');
241
+ return;
242
+ }
225
243
 
226
244
  const device = eventData.device;
227
245
  const attrType = eventData.attrType;
228
246
  const state = eventData.state || {};
229
247
 
248
+ node.debug(`[Symi->HA] 设备状态变化: ${device.macAddress}, attrType=0x${attrType?.toString(16) || 'unknown'}, state=${JSON.stringify(state)}`);
249
+
230
250
  // 遍历该设备的所有映射
231
251
  const deviceMappings = node.mappings.filter(m =>
232
252
  m.symiMac.toLowerCase().replace(/:/g, '') === device.macAddress.toLowerCase().replace(/:/g, '')
233
253
  );
234
- if (deviceMappings.length === 0) return;
254
+ if (deviceMappings.length === 0) {
255
+ node.debug(`[Symi->HA] 设备不在映射中: ${device.macAddress}`);
256
+ return;
257
+ }
258
+
259
+ node.debug(`[Symi->HA] 找到 ${deviceMappings.length} 个映射`);
235
260
 
236
261
  deviceMappings.forEach(mapping => {
237
262
  // 检查同步模式 (0:双向, 1:Symi->HA, 2:HA->Symi)
@@ -296,18 +321,35 @@ module.exports = function(RED) {
296
321
  // 支持返回数组(用于三合一同时同步多个属性)
297
322
  const syncDataList = Array.isArray(syncData) ? syncData : [syncData];
298
323
 
299
- syncDataList.forEach(data => {
300
- if (node.shouldPreventSync('symi-to-ha', loopKey)) {
324
+ syncDataList.forEach(async data => {
325
+ // 窗帘关键动作:开、关、停
326
+ const isImmediateAction = domain === 'cover' &&
327
+ (data.type === 'curtain_action' || data.type === 'curtain_stop');
328
+
329
+ // 窗帘位置同步(普通)
330
+ const isCoverPos = domain === 'cover' && data.type === 'position';
331
+
332
+ if (!isImmediateAction && !isCoverPos && node.shouldPreventSync('symi-to-ha', loopKey)) {
301
333
  node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
302
334
  return;
303
335
  }
304
336
 
305
- node.queueCommand({
337
+ const cmd = {
306
338
  direction: 'symi-to-ha',
307
339
  mapping: mapping,
308
340
  syncData: data,
309
- key: loopKey
310
- });
341
+ key: loopKey,
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);
311
353
  });
312
354
  }
313
355
  });
@@ -316,79 +358,54 @@ module.exports = function(RED) {
316
358
  // 处理三合一状态变化
317
359
  node.handleThreeInOneChange = function(device, mapping, state, attrType) {
318
360
  const subType = mapping.symiKey; // 'aircon', 'fresh_air', 'floor_heating'
361
+ const mac = device.macAddress.toLowerCase().replace(/:/g, '');
362
+ const changedData = [];
319
363
 
320
- // 1. 空调部分 (通常通过0x94或标准温控指令更新)
321
- if (subType === 'aircon') {
322
- // 如果是标准温控属性更新,已经在switch case中处理了
323
- // 这里主要处理0x94带来的全量更新
324
- if (attrType === ATTR_THREE_IN_ONE) {
325
- // 此时state已经包含了所有更新
326
- // 需要检查哪些属性变了,但这里只能返回一个syncData
327
- // 我们可以返回一个特殊对象,或者分别检查
328
- // 为简化,这里假设HA端会处理部分更新,或者我们按优先级返回
329
-
330
- // 检查开关
331
- if (state.climateSwitch !== undefined) {
332
- // 注意:这里需要比对旧状态,但在handleSymiStateChange中难以获取旧状态
333
- // 我们可以利用node.queueCommand的去重机制,发送所有可能的状态
334
- // 但这样会产生大量流量。
335
- // 实际上DeviceManager触发事件时,如果是0x94,是全量更新。
336
- // 我们可以只处理核心属性。
337
- // 更好的方式是:在device-manager中,0x94更新会触发一次事件。
338
- // 这里我们返回一个复合对象,或者由上层逻辑拆分。
339
- // 由于syncData只能是一个对象,我们优先同步开关,然后是模式/温度
340
- }
341
- }
342
- // 由于0x94更新时,device-manager已经更新了state
343
- // 我们可以直接从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}`;
344
370
 
345
- // 构造空调状态
346
- // 这里我们可能需要多次调用queueCommand,但handle函数只能返回一个
347
- // 解决方案:handleSymiStateChange支持返回数组
348
- return [
349
- { type: 'switch', value: state.climateSwitch },
350
- { type: 'temperature', value: state.targetTemp },
351
- { type: 'hvac_mode', value: AC_MODE_TO_HA[state.climateMode] || 'off', meshValue: state.climateMode },
352
- { type: 'fan_mode', value: FAN_MODE_TO_HA[state.fanMode] || 'auto', meshValue: state.fanMode }
353
- ];
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 });
354
389
  }
355
390
 
356
391
  // 2. 新风部分
357
- if (subType === 'fresh_air') {
358
- // 新风开关 (0x68或0x94)
359
- if (attrType === ATTR_FRESH_AIR_SWITCH || attrType === ATTR_THREE_IN_ONE) {
360
- if (state.freshAirSwitch !== undefined) {
361
- return { type: 'switch', value: state.freshAirSwitch };
362
- }
363
- }
364
- // 新风风速 (0x6A或0x94)
365
- if (attrType === ATTR_FRESH_AIR_SPEED || attrType === ATTR_THREE_IN_ONE) {
366
- if (state.freshAirSpeed !== undefined) {
367
- return { type: 'fan_mode', value: FAN_MODE_TO_HA[state.freshAirSpeed] || 'auto', meshValue: state.freshAirSpeed };
368
- }
369
- }
370
- // 新风模式 (0x69或0x94)
371
- // 目前HA Fan实体通常只支持on/off和speed,mode可能不支持或映射到preset_mode
372
- // 暂时忽略模式,或视需求添加
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 });
373
397
  }
374
398
 
375
399
  // 3. 地暖部分
376
- if (subType === 'floor_heating') {
377
- // 地暖开关 (0x6B或0x94)
378
- if (attrType === ATTR_FLOOR_HEATING_SWITCH || attrType === ATTR_THREE_IN_ONE) {
379
- if (state.floorHeatingSwitch !== undefined) {
380
- return { type: 'switch', value: state.floorHeatingSwitch };
381
- }
382
- }
383
- // 地暖温度 (0x6C或0x94)
384
- if (attrType === ATTR_FLOOR_HEATING_TEMP || attrType === ATTR_THREE_IN_ONE) {
385
- if (state.floorHeatingTemp !== undefined) {
386
- return { type: 'temperature', value: state.floorHeatingTemp };
387
- }
388
- }
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 });
389
405
  }
390
406
 
391
- return null;
407
+ // 只返回真正变化的属性
408
+ return changedData.length > 0 ? changedData : null;
392
409
  };
393
410
 
394
411
  // 处理开关状态变化
@@ -451,65 +468,77 @@ module.exports = function(RED) {
451
468
  return null; // 不立即同步,由定时器处理
452
469
  };
453
470
 
454
- // 处理窗帘变化(带防抖,避免步进反馈干扰)
471
+ // 处理窗帘变化 - 谁发起控制就只听谁的命令
472
+ // Mesh控制时只同步最终位置,忽略过程中的实时反馈
455
473
  node.handleCurtainChange = function(device, mapping, state, attrType) {
456
474
  const domain = node.getEntityDomain(mapping.haEntityId);
457
475
  if (domain !== 'cover') return null;
458
476
 
459
477
  const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
460
-
461
- // 检查是否是HA发起的运动,如果是则忽略Mesh的位置反馈(步进码)
462
- 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]; // 超时清理
469
- }
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];
470
484
  }
485
+
486
+ // 获取动作状态
487
+ const action = state.curtainAction || device.state.curtainAction;
471
488
 
472
- // 窗帘位置变化 - 使用防抖,只同步最终位置
473
- if (attrType === ATTR_CURTAIN_POSITION) {
474
- const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
475
- if (position === undefined) return null;
476
-
477
- // 防抖处理:取消之前的定时器,设置新的
478
- const debounceKey = `curtain_${mapping.symiMac}_${mapping.symiKey}`;
479
- if (node.pendingDebounce[debounceKey]) {
480
- clearTimeout(node.pendingDebounce[debounceKey]);
489
+ // 1. 处理运行状态变化 (ATTR_CURTAIN_STATUS)
490
+ if (attrType === ATTR_CURTAIN_STATUS) {
491
+ if (action === 'opening' || action === 'closing') {
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
+ }
511
+ return null;
481
512
  }
482
513
 
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
- // 运动结束,清理状态
514
+ if (action === 'stopped') {
515
+ node.log(`[Symi->HA] 窗帘已停止,释放控制权: ${loopKey}`);
490
516
  delete node.coverMoving[loopKey];
491
517
 
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);
501
-
502
- return null; // 不立即同步
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
+ }
524
+ }
525
+ return null;
503
526
  }
504
527
 
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' };
528
+ // 2. 处理位置变化 (ATTR_CURTAIN_POSITION)
529
+ if (attrType === ATTR_CURTAIN_POSITION) {
530
+ const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
531
+ if (position === undefined) return null;
532
+
533
+ // 核心需求:忽略过程中的实时反馈指令
534
+ // 如果当前有任何控制方向的锁定(正在移动中),则忽略位置反馈
535
+ if (node.coverMoving[loopKey]) {
536
+ node.debug(`[Symi->HA] 忽略移动中的实时位置反馈: ${position}%`);
537
+ return null;
512
538
  }
539
+
540
+ // 没有移动锁定时,允许同步(例如初始同步或查询返回)
541
+ return { type: 'position', value: position };
513
542
  }
514
543
 
515
544
  return null;
@@ -556,6 +585,11 @@ module.exports = function(RED) {
556
585
  node.log('[HA同步] 已收到HA输入,双向同步已启用');
557
586
  }
558
587
 
588
+ // 调试:记录收到的消息结构
589
+ if (msg.payload && msg.payload.event_type) {
590
+ node.debug(`[HA输入] event_type=${msg.payload.event_type}, entity_id=${msg.payload.entity_id}, event=${msg.payload.event ? 'object' : 'null'}`);
591
+ }
592
+
559
593
  // 支持多种消息格式
560
594
  let entityId, newState, oldState;
561
595
 
@@ -567,6 +601,22 @@ module.exports = function(RED) {
567
601
  if (msg.payload.event) {
568
602
  newState = msg.payload.event.new_state;
569
603
  oldState = msg.payload.event.old_state;
604
+ // 如果 event 中也有 entity_id,优先使用(更可靠)
605
+ if (msg.payload.event.entity_id) {
606
+ entityId = msg.payload.event.entity_id;
607
+ }
608
+ // 调试:记录 event 对象的结构
609
+ if (!newState) {
610
+ node.debug(`[HA输入] event对象结构: ${JSON.stringify(Object.keys(msg.payload.event))}`);
611
+ }
612
+ }
613
+ // 兼容格式:event.data 包含 new_state/old_state
614
+ if (!newState && msg.payload.event && msg.payload.event.data) {
615
+ newState = msg.payload.event.data.new_state;
616
+ oldState = msg.payload.event.data.old_state;
617
+ if (msg.payload.event.data.entity_id) {
618
+ entityId = msg.payload.event.data.entity_id;
619
+ }
570
620
  }
571
621
  }
572
622
  // 格式2: server-state-changed 节点的标准格式 (msg.data)
@@ -594,6 +644,53 @@ module.exports = function(RED) {
594
644
  newState = msg.payload;
595
645
  oldState = null;
596
646
  }
647
+ // 格式6: call_service 事件 - 专门处理窗帘控制,解决状态反馈慢的问题
648
+ else if (msg.payload && msg.payload.event_type === 'call_service') {
649
+ const event = msg.payload.event;
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
+ }
691
+ }
692
+ return;
693
+ }
597
694
 
598
695
  // 过滤非 state_changed 事件和无效数据
599
696
  if (!entityId || !newState) {
@@ -605,14 +702,45 @@ module.exports = function(RED) {
605
702
  return;
606
703
  }
607
704
 
705
+ // 过滤 sensor 类型的实体(传感器不需要同步控制)
706
+ if (entityId.startsWith('sensor.')) {
707
+ return;
708
+ }
709
+
608
710
  // 检查是否在映射列表中
609
711
  const mappings = node.findMappingsByHa(entityId);
610
712
  if (mappings.length === 0) {
611
713
  return; // 不在映射中的实体静默忽略
612
714
  }
613
715
 
614
- // 只记录映射中的实体状态变化
615
- node.log(`[HA->Symi] ${entityId}: ${oldState?.state || 'null'} -> ${newState.state}`);
716
+ // 检查状态是否有变化(避免 off -> off 这种无效处理)
717
+ const hasStateChange = !oldState || newState.state !== oldState.state;
718
+ const attrs = newState.attributes || {};
719
+ const oldAttrs = oldState ? (oldState.attributes || {}) : {};
720
+ const domain = entityId.split('.')[0];
721
+
722
+ // 根据实体类型检查相关属性变化
723
+ let hasAttrChange = false;
724
+ if (domain === 'light') {
725
+ hasAttrChange = attrs.brightness !== oldAttrs.brightness;
726
+ } else if (domain === 'cover') {
727
+ // cover需要检查位置变化,状态变化已经在hasStateChange中检查了
728
+ hasAttrChange = attrs.current_position !== oldAttrs.current_position;
729
+ } else if (domain === 'climate') {
730
+ hasAttrChange = attrs.temperature !== oldAttrs.temperature ||
731
+ attrs.hvac_mode !== oldAttrs.hvac_mode ||
732
+ attrs.fan_mode !== oldAttrs.fan_mode;
733
+ } else if (domain === 'fan') {
734
+ hasAttrChange = attrs.percentage !== oldAttrs.percentage ||
735
+ attrs.preset_mode !== oldAttrs.preset_mode;
736
+ }
737
+
738
+ // 如果状态和属性都没变化,完全跳过
739
+ if (!hasStateChange && !hasAttrChange) {
740
+ return;
741
+ }
742
+
743
+ // 不在这里打印日志,让handleHaStateChange内部处理
616
744
  node.handleHaStateChange(entityId, newState, oldState);
617
745
  });
618
746
 
@@ -684,14 +812,38 @@ module.exports = function(RED) {
684
812
 
685
813
  node.handleHaStateChange = function(entityId, newState, oldState) {
686
814
  if (!newState) {
815
+ node.debug(`[HA->Symi] 忽略: ${entityId} newState为空`);
687
816
  return;
688
817
  }
689
818
 
819
+ // 如果新旧状态完全相同,跳过处理(避免无效同步)
820
+ if (oldState && newState.state === oldState.state) {
821
+ const attrs = newState.attributes || {};
822
+ const oldAttrs = oldState.attributes || {};
823
+ // 检查关键属性是否有变化
824
+ const hasAttrChange =
825
+ attrs.brightness !== oldAttrs.brightness ||
826
+ attrs.current_position !== oldAttrs.current_position ||
827
+ attrs.temperature !== oldAttrs.temperature ||
828
+ attrs.hvac_mode !== oldAttrs.hvac_mode ||
829
+ attrs.fan_mode !== oldAttrs.fan_mode ||
830
+ attrs.percentage !== oldAttrs.percentage;
831
+
832
+ if (!hasAttrChange) {
833
+ node.debug(`[HA->Symi] 忽略: ${entityId} 状态无变化`);
834
+ return;
835
+ }
836
+ }
837
+
690
838
  const mappings = node.findMappingsByHa(entityId);
691
839
  if (mappings.length === 0) {
840
+ // 不在映射中的实体静默忽略,但记录调试信息
841
+ node.debug(`[HA->Symi] 实体不在映射中: ${entityId}`);
692
842
  return;
693
843
  }
694
844
 
845
+ node.debug(`[HA->Symi] 处理实体: ${entityId}, 找到 ${mappings.length} 个映射`);
846
+
695
847
  const domain = node.getEntityDomain(entityId);
696
848
  const attrs = newState.attributes || {};
697
849
  const oldAttrs = oldState ? (oldState.attributes || {}) : {};
@@ -739,79 +891,82 @@ module.exports = function(RED) {
739
891
 
740
892
  case 'cover':
741
893
  const coverLoopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
742
-
743
- // 检查是否是Symi发起的运动,如果是则忽略HA的位置反馈
744
- 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
- }
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];
750
900
  }
901
+
902
+ // 1. 处理位置变化 (用户拖动滑块)
903
+ const hasPositionChange = attrs.current_position !== undefined &&
904
+ (!oldState || oldAttrs.current_position !== attrs.current_position);
751
905
 
752
- // 运动中状态(opening/closing)不同步位置,避免步进反馈干扰
753
- if (newState.state === 'opening' || newState.state === 'closing') {
754
- node.debug(`[HA->Symi] 窗帘运动中,跳过位置同步: ${newState.state}`);
906
+ if (hasPositionChange) {
907
+ // HA 始终有控制优先权
908
+ node.log(`[HA->Symi] 窗帘位置控制: ${attrs.current_position}`);
909
+ node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: now };
910
+ syncDataList.push({ type: 'position', value: attrs.current_position });
755
911
  break;
756
912
  }
757
913
 
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
- }
769
- }
770
-
771
- // 窗帘动作 - open/closed 状态变化
914
+ // 2. 处理动作变化(用户点击按钮)
772
915
  if (newState.state !== oldState?.state) {
773
- if (newState.state === 'open') {
774
- node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
775
- syncDataList.push({ type: 'curtain_action', value: 'open' });
776
- } else if (newState.state === 'closed') {
777
- node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
778
- 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 });
779
925
  }
780
926
  }
781
927
  break;
782
928
 
783
929
  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
- }
930
+ node.debug(`[HA->Symi] 空调状态: ${oldState?.state} -> ${newState.state}, 温度: ${attrs.temperature}, 模式: ${attrs.hvac_mode}, 风速: ${attrs.fan_mode}`);
931
+
932
+ // 开关状态 - 只在状态真正变化时同步
933
+ const isOn = newState.state !== 'off' && newState.state !== 'unavailable';
934
+ const wasOn = oldState ? (oldState.state !== 'off' && oldState.state !== 'unavailable') : null;
935
+
936
+ // 只有当开关状态真正变化时才同步(避免 off -> off 的无效同步)
937
+ if (wasOn !== null && isOn !== wasOn) {
938
+ node.debug(`[HA->Symi] 空调开关: ${wasOn} -> ${isOn}`);
939
+ syncDataList.push({ type: 'switch', value: isOn });
792
940
  }
941
+
793
942
  // 目标温度
794
943
  if (attrs.temperature !== undefined) {
795
944
  if (!oldState || oldAttrs.temperature !== attrs.temperature) {
945
+ node.debug(`[HA->Symi] 空调温度: ${oldAttrs.temperature} -> ${attrs.temperature}`);
796
946
  syncDataList.push({ type: 'temperature', value: Math.round(attrs.temperature) });
797
947
  }
798
948
  }
799
- // HVAC模式
800
- // 过滤掉off模式的变化,因为off已经由开关状态处理
801
- if (newState.state !== 'off' && (attrs.hvac_mode !== undefined || newState.state !== oldState?.state)) {
949
+
950
+ // HVAC模式 - 只在非off状态时同步
951
+ if (newState.state !== 'off' && newState.state !== 'unavailable') {
802
952
  const hvacMode = attrs.hvac_mode || newState.state;
803
- if (hvacMode !== 'off' && (!oldState || (oldAttrs.hvac_mode || oldState.state) !== hvacMode)) {
953
+ const oldHvacMode = oldAttrs.hvac_mode || oldState?.state;
954
+
955
+ if (hvacMode !== 'off' && hvacMode !== oldHvacMode) {
804
956
  const meshMode = HA_TO_AC_MODE[hvacMode];
805
957
  if (meshMode !== undefined) {
958
+ node.debug(`[HA->Symi] 空调模式: ${oldHvacMode} -> ${hvacMode} (mesh: ${meshMode})`);
806
959
  syncDataList.push({ type: 'hvac_mode', value: meshMode });
807
960
  }
808
961
  }
809
962
  }
963
+
810
964
  // 风速
811
965
  if (attrs.fan_mode !== undefined) {
812
966
  if (!oldState || oldAttrs.fan_mode !== attrs.fan_mode) {
813
967
  const meshFan = HA_TO_FAN_MODE[attrs.fan_mode];
814
968
  if (meshFan !== undefined) {
969
+ node.debug(`[HA->Symi] 空调风速: ${oldAttrs.fan_mode} -> ${attrs.fan_mode} (mesh: ${meshFan})`);
815
970
  syncDataList.push({ type: 'fan_mode', value: meshFan });
816
971
  }
817
972
  }
@@ -937,10 +1092,26 @@ module.exports = function(RED) {
937
1092
  case 'position':
938
1093
  service = 'set_cover_position';
939
1094
  serviceData.position = syncData.value;
1095
+ // 刷新coverMoving状态,防止HA反馈被处理
1096
+ const posKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
1097
+ node.coverMoving[posKey] = { direction: 'symi', startTime: Date.now(), targetPosition: syncData.value };
1098
+ node.debug(`[Symi->HA] 窗帘位置同步,刷新coverMoving: ${posKey}`);
1099
+ break;
1100
+
1101
+ case 'curtain_action':
1102
+ // open/close 动作 - 确保标记为Symi发起的运动
1103
+ service = syncData.value === 'open' ? 'open_cover' : 'close_cover';
1104
+ // 刷新coverMoving状态,防止HA反馈被处理
1105
+ const coverKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
1106
+ node.coverMoving[coverKey] = { direction: 'symi', startTime: Date.now() };
1107
+ node.debug(`[Symi->HA] 窗帘动作同步,刷新coverMoving: ${coverKey}`);
940
1108
  break;
941
1109
 
942
1110
  case 'curtain_stop':
943
1111
  service = 'stop_cover';
1112
+ // 刷新锁定状态,防止HA反馈
1113
+ const stopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
1114
+ node.coverMoving[stopKey] = { direction: 'symi', startTime: Date.now() };
944
1115
  break;
945
1116
 
946
1117
  case 'temperature':
@@ -970,7 +1141,7 @@ module.exports = function(RED) {
970
1141
  return;
971
1142
  }
972
1143
 
973
- const serviceDomain = (syncData.type === 'position' || syncData.type === 'curtain_stop') ? 'cover' : domain;
1144
+ const serviceDomain = (syncData.type === 'position' || syncData.type === 'curtain_stop' || syncData.type === 'curtain_action') ? 'cover' : domain;
974
1145
 
975
1146
  await axios.post(`${baseURL}/api/services/${serviceDomain}/${service}`, serviceData, {
976
1147
  headers: {
@@ -1163,6 +1334,16 @@ module.exports = function(RED) {
1163
1334
  node.coverMoving = {}; // 清理窗帘运动状态
1164
1335
  node.brightnessMoving = {}; // 清理调光运动状态
1165
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
+
1166
1347
  if (gateway) {
1167
1348
  gateway.removeListener('device-state-changed', node.handleSymiStateChange);
1168
1349
  }