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 +22 -0
- package/nodes/symi-ha-sync.html +3 -45
- package/nodes/symi-ha-sync.js +207 -88
- package/package.json +1 -1
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同步节点重大修复**:
|
package/nodes/symi-ha-sync.html
CHANGED
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
});
|
|
203
203
|
} else {
|
|
204
204
|
// 多路开关
|
|
205
|
-
var channels =
|
|
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
|
|
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
|
|
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>
|
package/nodes/symi-ha-sync.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
|
|
3
|
-
* 版本: 1.8.
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
485
|
+
// HA控制期间,完全忽略Mesh的所有反馈
|
|
462
486
|
if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
|
|
463
|
-
const
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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 ===
|
|
474
|
-
const
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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 ===
|
|
507
|
-
const
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
845
|
+
// Mesh控制期间,完全忽略HA的所有消息
|
|
744
846
|
if (node.coverMoving[coverLoopKey] && node.coverMoving[coverLoopKey].direction === 'symi') {
|
|
745
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
//
|
|
864
|
+
// 动作变化 - 只处理opening/closing(用户点击按钮)
|
|
865
|
+
// 不处理open/closed(这是状态反馈,不是用户操作)
|
|
772
866
|
if (newState.state !== oldState?.state) {
|
|
773
|
-
if (newState.state === '
|
|
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 === '
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
800
|
-
//
|
|
801
|
-
if (newState.state !== 'off' &&
|
|
900
|
+
|
|
901
|
+
// HVAC模式 - 只在非off状态时同步
|
|
902
|
+
if (newState.state !== 'off' && newState.state !== 'unavailable') {
|
|
802
903
|
const hvacMode = attrs.hvac_mode || newState.state;
|
|
803
|
-
|
|
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: {
|