node-red-contrib-symi-mesh 1.8.2 → 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 +97 -60
- package/nodes/symi-ha-sync.html +1 -1
- package/nodes/symi-ha-sync.js +385 -155
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -393,8 +393,8 @@ Symi HA同步节点 (`symi-ha-sync`) 用于实现Symi蓝牙Mesh设备与Home Ass
|
|
|
393
393
|
- 复用`symi-mqtt`配置节点获取Symi设备信息
|
|
394
394
|
- 复用`server`配置节点连接Home Assistant
|
|
395
395
|
2. **双向同步**:
|
|
396
|
-
- **Symi -> HA**:Mesh设备状态变化 -> 更新HA
|
|
397
|
-
- **HA -> Symi**:HA实体状态变化 -> 控制Mesh
|
|
396
|
+
- **Symi -> HA**:Mesh设备状态变化 -> 更新HA实体状态(自动工作)
|
|
397
|
+
- **HA -> Symi**:HA实体状态变化 -> 控制Mesh设备(需连接HA事件节点)
|
|
398
398
|
3. **防死循环**:
|
|
399
399
|
- 内置2秒冷却机制(窗帘30秒)
|
|
400
400
|
- 区分Symi触发和HA触发,避免信号震荡
|
|
@@ -403,6 +403,37 @@ Symi HA同步节点 (`symi-ha-sync`) 用于实现Symi蓝牙Mesh设备与Home Ass
|
|
|
403
403
|
- 支持按键通道选择(1-4键)
|
|
404
404
|
- 支持实体ID搜索和下拉选择
|
|
405
405
|
|
|
406
|
+
#### ⚠️ 双向同步连接方式(重要)
|
|
407
|
+
|
|
408
|
+
要实现完整的双向同步,必须连接HA事件节点到`symi-ha-sync`节点的输入端:
|
|
409
|
+
|
|
410
|
+
**方式1:使用 server-events 节点(推荐)**
|
|
411
|
+
```
|
|
412
|
+
[events: all] → [symi-ha-sync]
|
|
413
|
+
(事件类型: state_changed)
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
配置步骤:
|
|
417
|
+
1. 添加 `events: all` 节点(Home Assistant 分类下)
|
|
418
|
+
2. 事件类型(Event Type)填写: `state_changed`
|
|
419
|
+
3. 将输出连接到 symi-ha-sync 节点的输入端
|
|
420
|
+
|
|
421
|
+
**方式2:使用 server-state-changed 节点**
|
|
422
|
+
```
|
|
423
|
+
[events: state] → [symi-ha-sync]
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
配置步骤:
|
|
427
|
+
1. 添加 `events: state` 节点
|
|
428
|
+
2. 实体ID可留空(监听所有实体)或指定特定实体
|
|
429
|
+
3. 将输出连接到 symi-ha-sync 节点的输入端
|
|
430
|
+
|
|
431
|
+
#### 状态指示
|
|
432
|
+
|
|
433
|
+
- **蓝色 "Mesh→HA (N组)"**:仅 Mesh 到 HA 方向工作(未连接 HA 事件节点)
|
|
434
|
+
- **绿色 "双向同步 (N组)"**:双向同步正常工作
|
|
435
|
+
- **红色**:配置错误
|
|
436
|
+
|
|
406
437
|
#### 配置步骤
|
|
407
438
|
|
|
408
439
|
1. **添加节点**:从左侧拖入`Symi HA Sync`节点
|
|
@@ -413,14 +444,17 @@ Symi HA同步节点 (`symi-ha-sync`) 用于实现Symi蓝牙Mesh设备与Home Ass
|
|
|
413
444
|
- **Symi设备**:下拉选择Mesh设备(显示名称和MAC)
|
|
414
445
|
- **按键**:选择控制通道(按键1-4)
|
|
415
446
|
- **HA实体**:输入或选择要同步的HA实体ID(如`switch.living_room_light`)
|
|
416
|
-
5.
|
|
447
|
+
5. **连接HA事件节点**:添加`events: all`或`events: state`节点,连接到输入端
|
|
448
|
+
6. **部署**:点击部署,立即生效
|
|
417
449
|
|
|
418
450
|
#### 注意事项
|
|
419
451
|
|
|
420
452
|
- **MQTT配置**:必须选择`symi-mqtt`配置节点,用于获取设备列表和接收Mesh事件
|
|
421
453
|
- **HA连接**:必须确保HA服务器节点连接正常
|
|
454
|
+
- **HA事件节点**:必须连接HA事件节点才能实现HA→Symi方向同步
|
|
422
455
|
- **实体类型**:建议同步相同类型的实体(如开关对开关,调光灯对灯光)
|
|
423
456
|
- **多通道设备**:对于多键开关,请分别为每个按键添加一条映射
|
|
457
|
+
- **event实体过滤**:系统自动过滤`event.`开头的实体(如按键点击事件),不会同步
|
|
424
458
|
|
|
425
459
|
#### 三合一面板配置
|
|
426
460
|
|
|
@@ -1501,6 +1535,50 @@ node-red-contrib-symi-mesh/
|
|
|
1501
1535
|
- 无TODO/FIXME/HACK标记
|
|
1502
1536
|
- 代码无重复,模块化设计
|
|
1503
1537
|
|
|
1538
|
+
## 更新日志
|
|
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
|
+
|
|
1562
|
+
### v1.8.3 (2026-01-05)
|
|
1563
|
+
|
|
1564
|
+
**HA同步节点重大修复**:
|
|
1565
|
+
- 修复HA→Symi方向不工作的问题(input监听器未正确注册)
|
|
1566
|
+
- 支持多种HA节点消息格式(server-events、server-state-changed、trigger:state等)
|
|
1567
|
+
- 自动过滤event类型实体(如按键点击事件),避免无效同步
|
|
1568
|
+
- 优化状态显示:蓝色"Mesh→HA"表示单向,绿色"双向同步"表示双向正常
|
|
1569
|
+
- 精简日志输出,只记录映射中的实体状态变化
|
|
1570
|
+
- 更新HTML帮助文档,详细说明双向同步连接方式
|
|
1571
|
+
- 更新README文档,添加完整的HA同步配置指南
|
|
1572
|
+
|
|
1573
|
+
**连接方式说明**:
|
|
1574
|
+
- Symi→HA:自动工作,无需额外配置
|
|
1575
|
+
- HA→Symi:需连接`events: all`或`events: state`节点到symi-ha-sync输入端
|
|
1576
|
+
|
|
1577
|
+
### v1.8.2 (2025-12-xx)
|
|
1578
|
+
|
|
1579
|
+
- 窗帘和调光灯智能防抖,只同步最终状态
|
|
1580
|
+
- 三合一面板完整双向同步支持
|
|
1581
|
+
|
|
1504
1582
|
## 技术支持
|
|
1505
1583
|
|
|
1506
1584
|
如遇问题,请提供以下信息:
|
|
@@ -1584,6 +1662,21 @@ node-red-contrib-symi-mesh/
|
|
|
1584
1662
|
|
|
1585
1663
|
## 更新日志
|
|
1586
1664
|
|
|
1665
|
+
### v1.8.3 (2026-01-05)
|
|
1666
|
+
|
|
1667
|
+
**HA同步节点重大修复**:
|
|
1668
|
+
- 修复HA→Symi方向不工作的问题(input监听器未正确注册)
|
|
1669
|
+
- 支持多种HA节点消息格式(server-events、server-state-changed、trigger:state等)
|
|
1670
|
+
- 自动过滤event类型实体(如按键点击事件),避免无效同步
|
|
1671
|
+
- 优化状态显示:蓝色"Mesh→HA"表示单向,绿色"双向同步"表示双向正常
|
|
1672
|
+
- 精简日志输出,只记录映射中的实体状态变化
|
|
1673
|
+
- 更新HTML帮助文档,详细说明双向同步连接方式
|
|
1674
|
+
- 更新README文档,添加完整的HA同步配置指南
|
|
1675
|
+
|
|
1676
|
+
**连接方式说明**:
|
|
1677
|
+
- Symi→HA:自动工作,无需额外配置
|
|
1678
|
+
- HA→Symi:需连接`events: all`或`events: state`节点到symi-ha-sync输入端
|
|
1679
|
+
|
|
1587
1680
|
### v1.8.2 (2026-01-05)
|
|
1588
1681
|
- **MQTT品牌同步协议修复**:
|
|
1589
1682
|
- **窗帘设备协议修复**:修复窗帘fn=1功能码的正确解析(0=关闭, 1=打开, 2=停止),之前错误地当作开关处理
|
|
@@ -1626,62 +1719,6 @@ node-red-contrib-symi-mesh/
|
|
|
1626
1719
|
- 优化了双向同步的防死循环逻辑,减少了在高频触发场景下的 CPU 占用。
|
|
1627
1720
|
- 修复了 MQTT 配置下拉框在节点编辑面板打开时偶尔出现的加载卡顿问题。
|
|
1628
1721
|
|
|
1629
|
-
### v1.7.9 (2026-01-05)
|
|
1630
|
-
- **HA同步节点UI修复**:修复添加映射按钮不显示选择界面的问题
|
|
1631
|
-
- 修复`renderMappings()`函数中的数组检查逻辑
|
|
1632
|
-
- 修复`mergeDevices()`函数中的空值检查
|
|
1633
|
-
- 使用`entityType`字段判断设备类型(climate/cover/light不显示按键选择)
|
|
1634
|
-
- 添加设备类型标签显示:[温控器]、[窗帘]、[灯具]、[N路开关]、[单路开关]
|
|
1635
|
-
- 添加错误捕获和日志输出,便于调试
|
|
1636
|
-
|
|
1637
|
-
### v1.7.8 (2026-01-05)
|
|
1638
|
-
- **配置持久化增强**:所有同步节点的设备列表和映射配置持久保存
|
|
1639
|
-
- **MQTT同步节点**:Mesh设备和品牌设备列表持久化,断线后仍可显示已配置的映射
|
|
1640
|
-
- **HA同步节点**:Symi设备和HA实体列表持久化,断线后仍可显示已配置的映射
|
|
1641
|
-
- **RS485桥接节点**:Mesh设备列表持久化,断线后仍可显示已配置的映射
|
|
1642
|
-
- **离线设备显示**:[离线]标记缓存中但当前不在线的设备,[未找到]标记不在缓存中的设备
|
|
1643
|
-
- **刷新按钮**:各节点添加独立刷新按钮,可手动刷新设备列表
|
|
1644
|
-
- **MQTT品牌同步增强**:完整对接HYQW(花语前湾)MQTT协议
|
|
1645
|
-
- **完整设备类型支持**:灯具(8)、空调(12)、窗帘(14)、地暖(16)、新风(36)
|
|
1646
|
-
- **功能码映射**:完整的fn/fv到Mesh属性双向转换
|
|
1647
|
-
- 灯具:开关(fn=1)、亮度(fn=2, 0-100)
|
|
1648
|
-
- 空调:开关(fn=1)、温度(fn=2, 18-29°C)、模式(fn=3)、风速(fn=4)
|
|
1649
|
-
- 窗帘:动作(fn=1, 开/关/停)、位置(fn=2, 0-100%)
|
|
1650
|
-
- 地暖:开关(fn=1)、温度(fn=2, 5-35°C)
|
|
1651
|
-
- 新风:开关(fn=1)、风速(fn=3)
|
|
1652
|
-
- **可扩展架构**:BRAND_PROTOCOLS对象支持添加新品牌协议
|
|
1653
|
-
- **双向状态同步**:HYQW↔Mesh实时同步,2秒防抖防死循环
|
|
1654
|
-
- **自动设备发现**:自动发现品牌MQTT设备,限制200个
|
|
1655
|
-
- **错误日志限流**:每60秒最多记录一次错误,避免日志爆炸
|
|
1656
|
-
- **自动重连**:5秒重连间隔,断线自动恢复
|
|
1657
|
-
- **资源清理**:完善的定时器和事件监听器清理机制
|
|
1658
|
-
- **HA同步节点完整重构**:实现所有实体类型的完美双向同步
|
|
1659
|
-
- **完整实体类型支持**:
|
|
1660
|
-
- switch/input_boolean:开关状态 (on/off)
|
|
1661
|
-
- light:开关 + 亮度 (0-255 ↔ 0-100)
|
|
1662
|
-
- cover:开/关/停 + 位置 (0-100%)
|
|
1663
|
-
- climate:开关 + 温度 + 模式(cool/heat/fan_only/dry) + 风速(high/medium/low/auto)
|
|
1664
|
-
- fan:开关 + 风速
|
|
1665
|
-
- **Mesh属性完整映射**:
|
|
1666
|
-
- 0x02 开关状态(单路/多路)
|
|
1667
|
-
- 0x03 亮度 (0-100)
|
|
1668
|
-
- 0x05 窗帘运行状态
|
|
1669
|
-
- 0x06 窗帘位置 (0-100)
|
|
1670
|
-
- 0x1B 目标温度 (16-30°C)
|
|
1671
|
-
- 0x1C 风速 (1=高/2=中/3=低/4=自动)
|
|
1672
|
-
- 0x1D 空调模式 (1=制冷/2=制热/3=送风/4=除湿)
|
|
1673
|
-
- **智能按键选择**:只有多路开关才显示按键选择,温控器/窗帘/调光灯显示"-"
|
|
1674
|
-
- **设备类型标签**:自动识别并显示设备类型([温控器]、[窗帘]、[调光灯]、[N路开关])
|
|
1675
|
-
- **智能防抖机制**:窗帘和调光灯使用500ms防抖,只同步最终位置/亮度
|
|
1676
|
-
- **防死循环增强**:2秒冷却时间 + 双向时间戳检查
|
|
1677
|
-
- **配置持久化**:设备列表和映射配置持久保存
|
|
1678
|
-
- **内存泄漏防护**:定时清理过期时间戳和防抖定时器
|
|
1679
|
-
- **代码质量优化**:
|
|
1680
|
-
- 同步时间戳Map自动清理60秒以上的条目
|
|
1681
|
-
- 设备发现数量限制,防止内存溢出
|
|
1682
|
-
- 完善的节点关闭清理逻辑
|
|
1683
|
-
- 防死循环机制增强:双向时间戳检查
|
|
1684
|
-
|
|
1685
1722
|
## 许可证
|
|
1686
1723
|
|
|
1687
1724
|
MIT License
|
|
@@ -1693,7 +1730,7 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
1693
1730
|
## 关于
|
|
1694
1731
|
|
|
1695
1732
|
**作者**: SYMI 亖米
|
|
1696
|
-
**版本**: 1.8.
|
|
1733
|
+
**版本**: 1.8.3
|
|
1697
1734
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
1698
1735
|
**最后更新**: 2026-01-05
|
|
1699
1736
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
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>';
|
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)
|
|
@@ -96,28 +104,48 @@ module.exports = function(RED) {
|
|
|
96
104
|
node.pendingDebounce = {}; // 防抖定时器
|
|
97
105
|
node.coverMoving = {}; // 窗帘运动状态跟踪 { loopKey: { direction: 'symi'|'ha', startTime, targetPosition } }
|
|
98
106
|
node.brightnessMoving = {}; // 调光运动状态跟踪 { loopKey: { direction: 'ha', startTime } }
|
|
107
|
+
node.haInputReceived = false; // 是否收到过HA输入
|
|
99
108
|
|
|
100
109
|
node.status({ fill: 'yellow', shape: 'ring', text: '初始化中' });
|
|
110
|
+
|
|
111
|
+
// 更新状态显示的辅助函数
|
|
112
|
+
node.updateStatus = function() {
|
|
113
|
+
if (configError) {
|
|
114
|
+
node.status({ fill: 'red', shape: 'ring', text: configError });
|
|
115
|
+
} else if (node.haInputReceived) {
|
|
116
|
+
node.status({ fill: 'green', shape: 'dot', text: `双向同步 (${node.mappings.length}组)` });
|
|
117
|
+
} else if (gateway) {
|
|
118
|
+
node.status({ fill: 'blue', shape: 'dot', text: `Mesh→HA (${node.mappings.length}组)` });
|
|
119
|
+
} else {
|
|
120
|
+
node.status({ fill: 'yellow', shape: 'ring', text: '等待连接' });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
101
123
|
|
|
102
|
-
// 检查配置
|
|
124
|
+
// 检查配置 - 但不要直接return,允许input监听器注册
|
|
125
|
+
let gateway = null;
|
|
126
|
+
let configError = null;
|
|
127
|
+
|
|
103
128
|
if (!node.mqttNode) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
129
|
+
configError = '未配置MQTT节点';
|
|
130
|
+
} else if (!node.haServer) {
|
|
131
|
+
configError = '未配置HA服务器';
|
|
132
|
+
} else {
|
|
133
|
+
// 获取Gateway引用
|
|
134
|
+
gateway = node.mqttNode.gateway;
|
|
135
|
+
if (!gateway) {
|
|
136
|
+
configError = 'MQTT节点未关联网关';
|
|
137
|
+
}
|
|
110
138
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
|
|
140
|
+
if (configError) {
|
|
141
|
+
node.status({ fill: 'red', shape: 'ring', text: configError });
|
|
142
|
+
node.warn(`[HA同步] ${configError}`);
|
|
143
|
+
// 不要return,继续注册input监听器
|
|
144
|
+
} else {
|
|
145
|
+
// 初始状态:只有Mesh→HA方向,等待HA输入连接
|
|
146
|
+
node.updateStatus();
|
|
117
147
|
}
|
|
118
148
|
|
|
119
|
-
node.status({ fill: 'green', shape: 'dot', text: `运行中 (${node.mappings.length}组)` });
|
|
120
|
-
|
|
121
149
|
// 定期清理过期的防抖时间戳
|
|
122
150
|
node.cleanupInterval = setInterval(function() {
|
|
123
151
|
const now = Date.now();
|
|
@@ -145,6 +173,16 @@ module.exports = function(RED) {
|
|
|
145
173
|
delete node.brightnessMoving[key];
|
|
146
174
|
}
|
|
147
175
|
}
|
|
176
|
+
|
|
177
|
+
// 尝试重新获取gateway(如果之前没有)
|
|
178
|
+
if (!gateway && node.mqttNode && node.mqttNode.gateway) {
|
|
179
|
+
gateway = node.mqttNode.gateway;
|
|
180
|
+
if (gateway) {
|
|
181
|
+
node.log('[HA同步] 网关已就绪,注册设备状态监听');
|
|
182
|
+
gateway.on('device-state-changed', node.handleSymiStateChange);
|
|
183
|
+
node.status({ fill: 'green', shape: 'dot', text: `运行中 (${node.mappings.length}组)` });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
148
186
|
}, CLEANUP_INTERVAL_MS);
|
|
149
187
|
|
|
150
188
|
// 防死循环检查 - 双向时间戳检查
|
|
@@ -191,17 +229,27 @@ module.exports = function(RED) {
|
|
|
191
229
|
|
|
192
230
|
// ========== 1. 监听Symi设备状态变化 (Symi -> HA) ==========
|
|
193
231
|
node.handleSymiStateChange = function(eventData) {
|
|
194
|
-
if (!eventData.device || !eventData.device.macAddress)
|
|
232
|
+
if (!eventData.device || !eventData.device.macAddress) {
|
|
233
|
+
node.debug('[Symi->HA] 忽略: 无效的设备数据');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
195
236
|
|
|
196
237
|
const device = eventData.device;
|
|
197
238
|
const attrType = eventData.attrType;
|
|
198
239
|
const state = eventData.state || {};
|
|
199
240
|
|
|
241
|
+
node.debug(`[Symi->HA] 设备状态变化: ${device.macAddress}, attrType=0x${attrType?.toString(16) || 'unknown'}, state=${JSON.stringify(state)}`);
|
|
242
|
+
|
|
200
243
|
// 遍历该设备的所有映射
|
|
201
244
|
const deviceMappings = node.mappings.filter(m =>
|
|
202
245
|
m.symiMac.toLowerCase().replace(/:/g, '') === device.macAddress.toLowerCase().replace(/:/g, '')
|
|
203
246
|
);
|
|
204
|
-
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} 个映射`);
|
|
205
253
|
|
|
206
254
|
deviceMappings.forEach(mapping => {
|
|
207
255
|
// 检查同步模式 (0:双向, 1:Symi->HA, 2:HA->Symi)
|
|
@@ -267,7 +315,11 @@ module.exports = function(RED) {
|
|
|
267
315
|
const syncDataList = Array.isArray(syncData) ? syncData : [syncData];
|
|
268
316
|
|
|
269
317
|
syncDataList.forEach(data => {
|
|
270
|
-
|
|
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)) {
|
|
271
323
|
node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
|
|
272
324
|
return;
|
|
273
325
|
}
|
|
@@ -276,7 +328,8 @@ module.exports = function(RED) {
|
|
|
276
328
|
direction: 'symi-to-ha',
|
|
277
329
|
mapping: mapping,
|
|
278
330
|
syncData: data,
|
|
279
|
-
key: loopKey
|
|
331
|
+
key: loopKey,
|
|
332
|
+
skipLoopCheck: isCoverAction // 窗帘跳过常规防死循环检查
|
|
280
333
|
});
|
|
281
334
|
});
|
|
282
335
|
}
|
|
@@ -421,65 +474,58 @@ module.exports = function(RED) {
|
|
|
421
474
|
return null; // 不立即同步,由定时器处理
|
|
422
475
|
};
|
|
423
476
|
|
|
424
|
-
//
|
|
477
|
+
// 处理窗帘变化 - 谁发起控制就只听谁的命令
|
|
478
|
+
// Mesh控制时只同步位置,不同步动作(HA会根据位置自动更新状态)
|
|
425
479
|
node.handleCurtainChange = function(device, mapping, state, attrType) {
|
|
426
480
|
const domain = node.getEntityDomain(mapping.haEntityId);
|
|
427
481
|
if (domain !== 'cover') return null;
|
|
428
482
|
|
|
429
483
|
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
430
484
|
|
|
431
|
-
//
|
|
485
|
+
// HA控制期间,完全忽略Mesh的所有反馈
|
|
432
486
|
if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
|
|
433
|
-
const
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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);
|
|
439
494
|
}
|
|
495
|
+
node.debug(`[Symi->HA] 窗帘忽略(HA控制中): ${JSON.stringify(state)}`);
|
|
496
|
+
return null;
|
|
440
497
|
}
|
|
441
498
|
|
|
442
|
-
//
|
|
443
|
-
if (attrType ===
|
|
444
|
-
const
|
|
445
|
-
if (position === undefined) return null;
|
|
499
|
+
// 窗帘运行状态变化 - 只标记控制方向,不发送动作到HA
|
|
500
|
+
if (attrType === ATTR_CURTAIN_STATUS) {
|
|
501
|
+
const action = state.curtainAction || device.state.curtainAction;
|
|
446
502
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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;
|
|
451
509
|
}
|
|
452
510
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
// 直接入队,跳过常规防死循环检查(窗帘有专门的运动状态跟踪)
|
|
463
|
-
node.queueCommand({
|
|
464
|
-
direction: 'symi-to-ha',
|
|
465
|
-
mapping: mapping,
|
|
466
|
-
syncData: { type: 'position', value: position },
|
|
467
|
-
key: loopKey,
|
|
468
|
-
skipLoopCheck: true
|
|
469
|
-
});
|
|
470
|
-
}, 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
|
+
}
|
|
471
519
|
|
|
472
|
-
return null;
|
|
520
|
+
return null;
|
|
473
521
|
}
|
|
474
522
|
|
|
475
|
-
//
|
|
476
|
-
if (attrType ===
|
|
477
|
-
const
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
return { type: 'curtain_stop' };
|
|
482
|
-
}
|
|
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 };
|
|
483
529
|
}
|
|
484
530
|
|
|
485
531
|
return null;
|
|
@@ -510,74 +556,243 @@ module.exports = function(RED) {
|
|
|
510
556
|
return { type: 'hvac_mode', value: haMode, meshValue: mode };
|
|
511
557
|
};
|
|
512
558
|
|
|
513
|
-
//
|
|
514
|
-
gateway
|
|
559
|
+
// 监听网关事件(如果gateway可用)
|
|
560
|
+
if (gateway) {
|
|
561
|
+
gateway.on('device-state-changed', node.handleSymiStateChange);
|
|
562
|
+
}
|
|
515
563
|
|
|
516
564
|
// ========== 2. 监听HA状态变化 (HA -> Symi) ==========
|
|
517
565
|
|
|
518
|
-
// 方式A: 通过Input输入 (
|
|
566
|
+
// 方式A: 通过Input输入 (支持多种HA节点格式)
|
|
519
567
|
node.on('input', function(msg) {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
568
|
+
// 首次收到HA输入时更新状态
|
|
569
|
+
if (!node.haInputReceived) {
|
|
570
|
+
node.haInputReceived = true;
|
|
571
|
+
node.updateStatus();
|
|
572
|
+
node.log('[HA同步] 已收到HA输入,双向同步已启用');
|
|
573
|
+
}
|
|
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
|
+
|
|
580
|
+
// 支持多种消息格式
|
|
581
|
+
let entityId, newState, oldState;
|
|
582
|
+
|
|
583
|
+
// 格式1: server-events 节点格式 (msg.payload.event_type + msg.payload.event)
|
|
584
|
+
// 实际格式: { event_type: "state_changed", entity_id: "...", event: { entity_id, new_state, old_state } }
|
|
585
|
+
if (msg.payload && msg.payload.event_type === 'state_changed') {
|
|
586
|
+
entityId = msg.payload.entity_id;
|
|
587
|
+
// event 直接包含 new_state 和 old_state(不是 event.data)
|
|
588
|
+
if (msg.payload.event) {
|
|
589
|
+
newState = msg.payload.event.new_state;
|
|
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
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// 格式2: server-state-changed 节点的标准格式 (msg.data)
|
|
610
|
+
else if (msg.data && msg.data.entity_id) {
|
|
611
|
+
entityId = msg.data.entity_id;
|
|
612
|
+
newState = msg.data.new_state;
|
|
613
|
+
oldState = msg.data.old_state;
|
|
614
|
+
}
|
|
615
|
+
// 格式3: payload 中直接包含 entity_id 和 new_state
|
|
616
|
+
else if (msg.payload && msg.payload.entity_id && msg.payload.new_state) {
|
|
617
|
+
entityId = msg.payload.entity_id;
|
|
618
|
+
newState = msg.payload.new_state;
|
|
619
|
+
oldState = msg.payload.old_state;
|
|
620
|
+
}
|
|
621
|
+
// 格式4: 直接的 event 数据 (msg.event)
|
|
622
|
+
else if (msg.event && msg.event.new_state) {
|
|
623
|
+
entityId = msg.event.entity_id;
|
|
624
|
+
newState = msg.event.new_state;
|
|
625
|
+
oldState = msg.event.old_state;
|
|
626
|
+
}
|
|
627
|
+
// 格式5: trigger: state 节点格式 (msg.payload 是状态对象)
|
|
628
|
+
else if (msg.payload && typeof msg.payload === 'object' &&
|
|
629
|
+
msg.payload.state !== undefined && msg.payload.attributes !== undefined) {
|
|
630
|
+
entityId = msg.topic || msg.payload.entity_id;
|
|
631
|
+
newState = msg.payload;
|
|
632
|
+
oldState = null;
|
|
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}`);
|
|
527
641
|
}
|
|
528
|
-
|
|
529
|
-
// 兼容旧格式
|
|
530
|
-
node.handleHaStateChange(msg.data.entity_id, msg.data.new_state, msg.data.old_state);
|
|
642
|
+
return; // call_service 事件不直接处理,等待 state_changed
|
|
531
643
|
}
|
|
644
|
+
|
|
645
|
+
// 过滤非 state_changed 事件和无效数据
|
|
646
|
+
if (!entityId || !newState) {
|
|
647
|
+
return; // 静默忽略无法解析的消息
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 过滤 event 类型的实体(如 event.xxx_click_e_xxx),这些不需要同步
|
|
651
|
+
if (entityId.startsWith('event.')) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 过滤 sensor 类型的实体(传感器不需要同步控制)
|
|
656
|
+
if (entityId.startsWith('sensor.')) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// 检查是否在映射列表中
|
|
661
|
+
const mappings = node.findMappingsByHa(entityId);
|
|
662
|
+
if (mappings.length === 0) {
|
|
663
|
+
return; // 不在映射中的实体静默忽略
|
|
664
|
+
}
|
|
665
|
+
|
|
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内部处理
|
|
694
|
+
node.handleHaStateChange(entityId, newState, oldState);
|
|
532
695
|
});
|
|
533
696
|
|
|
534
|
-
// 方式B: 尝试订阅HA Server事件总线 (
|
|
697
|
+
// 方式B: 尝试订阅HA Server事件总线 (备用方式,大多数情况不可用)
|
|
535
698
|
const subscribeToHaEvents = () => {
|
|
536
699
|
if (!node.haServer) {
|
|
700
|
+
// 静默处理,不显示警告
|
|
537
701
|
return;
|
|
538
702
|
}
|
|
539
703
|
|
|
540
|
-
//
|
|
541
|
-
let eventBus =
|
|
542
|
-
|
|
704
|
+
// 尝试多种方式获取事件总线
|
|
705
|
+
let eventBus = null;
|
|
706
|
+
|
|
707
|
+
// 方式1: 直接访问 eventBus
|
|
708
|
+
if (node.haServer.eventBus) {
|
|
709
|
+
eventBus = node.haServer.eventBus;
|
|
710
|
+
}
|
|
711
|
+
// 方式2: 通过 controller
|
|
712
|
+
else if (node.haServer.controller && node.haServer.controller.events) {
|
|
543
713
|
eventBus = node.haServer.controller.events;
|
|
544
714
|
}
|
|
715
|
+
// 方式3: 通过 websocket
|
|
716
|
+
else if (node.haServer.websocket && node.haServer.websocket.eventBus) {
|
|
717
|
+
eventBus = node.haServer.websocket.eventBus;
|
|
718
|
+
}
|
|
719
|
+
// 方式4: 检查 _events
|
|
720
|
+
else if (node.haServer._events) {
|
|
721
|
+
eventBus = node.haServer;
|
|
722
|
+
}
|
|
545
723
|
|
|
546
|
-
if (eventBus) {
|
|
724
|
+
if (eventBus && typeof eventBus.on === 'function') {
|
|
547
725
|
// 防止重复订阅
|
|
548
726
|
if (node.haSubscribed) return;
|
|
549
727
|
|
|
550
728
|
node.haEventHandler = (evt) => {
|
|
551
729
|
if (evt && evt.event_type === 'state_changed' && evt.data) {
|
|
730
|
+
if (!node.haInputReceived) {
|
|
731
|
+
node.haInputReceived = true;
|
|
732
|
+
node.updateStatus();
|
|
733
|
+
}
|
|
552
734
|
node.handleHaStateChange(evt.data.entity_id, evt.data.new_state, evt.data.old_state);
|
|
553
735
|
}
|
|
554
736
|
};
|
|
555
|
-
eventBus.on('ha_events:all', node.haEventHandler);
|
|
556
|
-
node.haSubscribed = true;
|
|
557
|
-
node.log('[HA同步] 已成功订阅HA事件总线');
|
|
558
|
-
node.status({ fill: 'green', shape: 'dot', text: '已连接' });
|
|
559
|
-
} else {
|
|
560
|
-
// 静默重试,不打印 info 日志,只在后台运行
|
|
561
|
-
node.haSubscribedRetryCount = (node.haSubscribedRetryCount || 0) + 1;
|
|
562
737
|
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
node.
|
|
738
|
+
// 尝试订阅不同的事件名
|
|
739
|
+
try {
|
|
740
|
+
eventBus.on('ha_events:all', node.haEventHandler);
|
|
741
|
+
node.haSubscribed = true;
|
|
742
|
+
node.haInputReceived = true;
|
|
743
|
+
node.log('[HA同步] 已成功订阅HA事件总线');
|
|
744
|
+
node.updateStatus();
|
|
745
|
+
} catch (e1) {
|
|
746
|
+
try {
|
|
747
|
+
eventBus.on('state_changed', node.haEventHandler);
|
|
748
|
+
node.haSubscribed = true;
|
|
749
|
+
node.haInputReceived = true;
|
|
750
|
+
node.log('[HA同步] 已成功订阅HA事件总线');
|
|
751
|
+
node.updateStatus();
|
|
752
|
+
} catch (e2) {
|
|
753
|
+
// 静默失败
|
|
754
|
+
}
|
|
567
755
|
}
|
|
568
|
-
|
|
569
|
-
setTimeout(subscribeToHaEvents, 5000);
|
|
570
756
|
}
|
|
757
|
+
// 事件总线不可用时不再重试,依赖 input 方式
|
|
571
758
|
};
|
|
572
759
|
|
|
573
|
-
//
|
|
574
|
-
subscribeToHaEvents
|
|
760
|
+
// 延迟启动订阅尝试,给HA服务器时间初始化
|
|
761
|
+
setTimeout(subscribeToHaEvents, 3000);
|
|
575
762
|
|
|
576
763
|
node.handleHaStateChange = function(entityId, newState, oldState) {
|
|
577
|
-
if (!newState)
|
|
764
|
+
if (!newState) {
|
|
765
|
+
node.debug(`[HA->Symi] 忽略: ${entityId} newState为空`);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
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
|
+
}
|
|
578
787
|
|
|
579
788
|
const mappings = node.findMappingsByHa(entityId);
|
|
580
|
-
if (mappings.length === 0)
|
|
789
|
+
if (mappings.length === 0) {
|
|
790
|
+
// 不在映射中的实体静默忽略,但记录调试信息
|
|
791
|
+
node.debug(`[HA->Symi] 实体不在映射中: ${entityId}`);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
node.debug(`[HA->Symi] 处理实体: ${entityId}, 找到 ${mappings.length} 个映射`);
|
|
581
796
|
|
|
582
797
|
const domain = node.getEntityDomain(entityId);
|
|
583
798
|
const attrs = newState.attributes || {};
|
|
@@ -627,78 +842,82 @@ module.exports = function(RED) {
|
|
|
627
842
|
case 'cover':
|
|
628
843
|
const coverLoopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
629
844
|
|
|
630
|
-
//
|
|
845
|
+
// Mesh控制期间,完全忽略HA的所有消息
|
|
631
846
|
if (node.coverMoving[coverLoopKey] && node.coverMoving[coverLoopKey].direction === 'symi') {
|
|
632
|
-
|
|
633
|
-
if (elapsed < COVER_LOOP_PREVENTION_MS) {
|
|
634
|
-
node.debug(`[HA->Symi] 窗帘忽略(Symi发起运动中): ${coverLoopKey}`);
|
|
635
|
-
break; // 忽略Symi发起运动期间的HA反馈
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// 运动中状态(opening/closing)不同步位置,避免步进反馈干扰
|
|
640
|
-
if (newState.state === 'opening' || newState.state === 'closing') {
|
|
641
|
-
node.debug(`[HA->Symi] 窗帘运动中,跳过位置同步: ${newState.state}`);
|
|
847
|
+
node.debug(`[HA->Symi] 窗帘忽略(Mesh控制中): ${newState.state}`);
|
|
642
848
|
break;
|
|
643
849
|
}
|
|
644
850
|
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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;
|
|
656
862
|
}
|
|
657
863
|
|
|
658
|
-
//
|
|
864
|
+
// 动作变化 - 只处理opening/closing(用户点击按钮)
|
|
865
|
+
// 不处理open/closed(这是状态反馈,不是用户操作)
|
|
659
866
|
if (newState.state !== oldState?.state) {
|
|
660
|
-
if (newState.state === '
|
|
867
|
+
if (newState.state === 'opening') {
|
|
868
|
+
node.log(`[HA->Symi] 窗帘动作: open`);
|
|
661
869
|
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
|
|
662
870
|
syncDataList.push({ type: 'curtain_action', value: 'open' });
|
|
663
|
-
} else if (newState.state === '
|
|
871
|
+
} else if (newState.state === 'closing') {
|
|
872
|
+
node.log(`[HA->Symi] 窗帘动作: close`);
|
|
664
873
|
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
|
|
665
874
|
syncDataList.push({ type: 'curtain_action', value: 'close' });
|
|
666
875
|
}
|
|
876
|
+
// open/closed 是最终状态,不是动作,不需要同步
|
|
667
877
|
}
|
|
668
878
|
break;
|
|
669
879
|
|
|
670
880
|
case 'climate':
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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 });
|
|
679
891
|
}
|
|
892
|
+
|
|
680
893
|
// 目标温度
|
|
681
894
|
if (attrs.temperature !== undefined) {
|
|
682
895
|
if (!oldState || oldAttrs.temperature !== attrs.temperature) {
|
|
896
|
+
node.debug(`[HA->Symi] 空调温度: ${oldAttrs.temperature} -> ${attrs.temperature}`);
|
|
683
897
|
syncDataList.push({ type: 'temperature', value: Math.round(attrs.temperature) });
|
|
684
898
|
}
|
|
685
899
|
}
|
|
686
|
-
|
|
687
|
-
//
|
|
688
|
-
if (newState.state !== 'off' &&
|
|
900
|
+
|
|
901
|
+
// HVAC模式 - 只在非off状态时同步
|
|
902
|
+
if (newState.state !== 'off' && newState.state !== 'unavailable') {
|
|
689
903
|
const hvacMode = attrs.hvac_mode || newState.state;
|
|
690
|
-
|
|
904
|
+
const oldHvacMode = oldAttrs.hvac_mode || oldState?.state;
|
|
905
|
+
|
|
906
|
+
if (hvacMode !== 'off' && hvacMode !== oldHvacMode) {
|
|
691
907
|
const meshMode = HA_TO_AC_MODE[hvacMode];
|
|
692
908
|
if (meshMode !== undefined) {
|
|
909
|
+
node.debug(`[HA->Symi] 空调模式: ${oldHvacMode} -> ${hvacMode} (mesh: ${meshMode})`);
|
|
693
910
|
syncDataList.push({ type: 'hvac_mode', value: meshMode });
|
|
694
911
|
}
|
|
695
912
|
}
|
|
696
913
|
}
|
|
914
|
+
|
|
697
915
|
// 风速
|
|
698
916
|
if (attrs.fan_mode !== undefined) {
|
|
699
917
|
if (!oldState || oldAttrs.fan_mode !== attrs.fan_mode) {
|
|
700
918
|
const meshFan = HA_TO_FAN_MODE[attrs.fan_mode];
|
|
701
919
|
if (meshFan !== undefined) {
|
|
920
|
+
node.debug(`[HA->Symi] 空调风速: ${oldAttrs.fan_mode} -> ${attrs.fan_mode} (mesh: ${meshFan})`);
|
|
702
921
|
syncDataList.push({ type: 'fan_mode', value: meshFan });
|
|
703
922
|
}
|
|
704
923
|
}
|
|
@@ -824,6 +1043,19 @@ module.exports = function(RED) {
|
|
|
824
1043
|
case 'position':
|
|
825
1044
|
service = 'set_cover_position';
|
|
826
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}`);
|
|
827
1059
|
break;
|
|
828
1060
|
|
|
829
1061
|
case 'curtain_stop':
|
|
@@ -857,7 +1089,7 @@ module.exports = function(RED) {
|
|
|
857
1089
|
return;
|
|
858
1090
|
}
|
|
859
1091
|
|
|
860
|
-
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;
|
|
861
1093
|
|
|
862
1094
|
await axios.post(`${baseURL}/api/services/${serviceDomain}/${service}`, serviceData, {
|
|
863
1095
|
headers: {
|
|
@@ -869,22 +1101,6 @@ module.exports = function(RED) {
|
|
|
869
1101
|
|
|
870
1102
|
node.log(`[Symi->HA] ${mapping.symiName || mapping.symiMac} -> ${mapping.haEntityId}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
871
1103
|
|
|
872
|
-
// 仅输出到debug,不再重复send
|
|
873
|
-
/*
|
|
874
|
-
node.send({
|
|
875
|
-
topic: 'ha-sync/symi-to-ha',
|
|
876
|
-
payload: {
|
|
877
|
-
direction: 'Symi→HA',
|
|
878
|
-
symiMac: mapping.symiMac,
|
|
879
|
-
symiKey: mapping.symiKey,
|
|
880
|
-
haEntityId: mapping.haEntityId,
|
|
881
|
-
syncType: syncData.type,
|
|
882
|
-
value: syncData.value,
|
|
883
|
-
timestamp: Date.now()
|
|
884
|
-
}
|
|
885
|
-
});
|
|
886
|
-
*/
|
|
887
|
-
|
|
888
1104
|
} catch (err) {
|
|
889
1105
|
node.error(`[Symi->HA] 调用失败: ${err.message}`);
|
|
890
1106
|
}
|
|
@@ -895,7 +1111,14 @@ module.exports = function(RED) {
|
|
|
895
1111
|
const { mapping, syncData, key } = cmd;
|
|
896
1112
|
node.recordSyncTime('ha-to-symi', key);
|
|
897
1113
|
|
|
898
|
-
|
|
1114
|
+
// 动态获取gateway(可能在初始化后才可用)
|
|
1115
|
+
const currentGateway = gateway || (node.mqttNode && node.mqttNode.gateway);
|
|
1116
|
+
if (!currentGateway) {
|
|
1117
|
+
node.warn(`[HA->Symi] 网关未就绪,无法控制设备`);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const device = currentGateway.getDevice(mapping.symiMac);
|
|
899
1122
|
if (!device) {
|
|
900
1123
|
node.warn(`[HA->Symi] 设备未找到: ${mapping.symiMac}`);
|
|
901
1124
|
return;
|
|
@@ -961,7 +1184,7 @@ module.exports = function(RED) {
|
|
|
961
1184
|
return;
|
|
962
1185
|
}
|
|
963
1186
|
|
|
964
|
-
await
|
|
1187
|
+
await currentGateway.sendControl(networkAddr, attrType, param);
|
|
965
1188
|
|
|
966
1189
|
node.log(`[HA->Symi] ${mapping.haEntityId} -> ${device.name || mapping.symiMac}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
967
1190
|
|
|
@@ -975,6 +1198,13 @@ module.exports = function(RED) {
|
|
|
975
1198
|
const { mapping, syncData } = cmd;
|
|
976
1199
|
const subType = mapping.symiKey;
|
|
977
1200
|
|
|
1201
|
+
// 动态获取gateway
|
|
1202
|
+
const currentGateway = gateway || (node.mqttNode && node.mqttNode.gateway);
|
|
1203
|
+
if (!currentGateway) {
|
|
1204
|
+
node.warn(`[HA->Symi] 网关未就绪,无法控制三合一设备`);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
978
1208
|
try {
|
|
979
1209
|
let attrType, param;
|
|
980
1210
|
|
|
@@ -1030,7 +1260,7 @@ module.exports = function(RED) {
|
|
|
1030
1260
|
}
|
|
1031
1261
|
|
|
1032
1262
|
if (attrType && param) {
|
|
1033
|
-
await
|
|
1263
|
+
await currentGateway.sendControl(networkAddr, attrType, param);
|
|
1034
1264
|
node.log(`[HA->Symi] ${mapping.haEntityId} -> ${mapping.symiName}(${subType}): ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
1035
1265
|
}
|
|
1036
1266
|
|