node-red-contrib-symi-mesh 1.8.0 → 1.8.2
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 +115 -78
- package/nodes/symi-ha-sync.html +30 -4
- package/nodes/symi-ha-sync.js +397 -68
- package/nodes/symi-mqtt-sync.js +155 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -396,7 +396,7 @@ Symi HA同步节点 (`symi-ha-sync`) 用于实现Symi蓝牙Mesh设备与Home Ass
|
|
|
396
396
|
- **Symi -> HA**:Mesh设备状态变化 -> 更新HA实体状态
|
|
397
397
|
- **HA -> Symi**:HA实体状态变化 -> 控制Mesh设备
|
|
398
398
|
3. **防死循环**:
|
|
399
|
-
- 内置
|
|
399
|
+
- 内置2秒冷却机制(窗帘30秒)
|
|
400
400
|
- 区分Symi触发和HA触发,避免信号震荡
|
|
401
401
|
4. **便捷配置**:
|
|
402
402
|
- 自动加载所有Symi设备和HA实体
|
|
@@ -422,6 +422,87 @@ Symi HA同步节点 (`symi-ha-sync`) 用于实现Symi蓝牙Mesh设备与Home Ass
|
|
|
422
422
|
- **实体类型**:建议同步相同类型的实体(如开关对开关,调光灯对灯光)
|
|
423
423
|
- **多通道设备**:对于多键开关,请分别为每个按键添加一条映射
|
|
424
424
|
|
|
425
|
+
#### 三合一面板配置
|
|
426
|
+
|
|
427
|
+
三合一面板(空调+新风+地暖)需要分别配置每个子设备的映射:
|
|
428
|
+
|
|
429
|
+
1. **选择三合一设备**:在Symi设备下拉框中选择三合一面板
|
|
430
|
+
2. **选择子设备**:在按键选择器中选择要同步的功能:
|
|
431
|
+
- **空调**:同步开关、温度、模式、风速
|
|
432
|
+
- **新风**:同步开关、风速
|
|
433
|
+
- **地暖**:同步开关、温度
|
|
434
|
+
3. **选择HA实体**:
|
|
435
|
+
- 空调 → climate实体
|
|
436
|
+
- 新风 → fan实体
|
|
437
|
+
- 地暖 → climate实体
|
|
438
|
+
|
|
439
|
+
**示例配置**:
|
|
440
|
+
```
|
|
441
|
+
三合一面板_xxx [空调] ↔ climate.living_room_ac
|
|
442
|
+
三合一面板_xxx [新风] ↔ fan.living_room_fresh_air
|
|
443
|
+
三合一面板_xxx [地暖] ↔ climate.living_room_floor_heating
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
#### 窗帘同步说明
|
|
447
|
+
|
|
448
|
+
窗帘设备采用特殊的同步机制,避免运动过程中的步进反馈干扰:
|
|
449
|
+
|
|
450
|
+
- **30秒防死循环窗口**:窗帘运动时间较长,使用30秒防死循环(普通设备2秒)
|
|
451
|
+
- **1.5秒防抖**:等待窗帘位置稳定后再同步,避免步进码干扰
|
|
452
|
+
- **运动状态跟踪**:记录运动发起方(Symi/HA),忽略对方的中间状态反馈
|
|
453
|
+
- **opening/closing过滤**:HA窗帘处于运动状态时,不同步位置变化到Mesh
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
### MQTT品牌同步
|
|
457
|
+
|
|
458
|
+
Symi MQTT同步节点 (`symi-mqtt-sync`) 用于实现第三方MQTT品牌设备与Symi Mesh设备的双向状态同步。
|
|
459
|
+
|
|
460
|
+
#### 功能特性
|
|
461
|
+
|
|
462
|
+
1. **双MQTT配置**:
|
|
463
|
+
- Mesh MQTT:连接Symi网关获取设备列表
|
|
464
|
+
- 品牌MQTT:连接第三方品牌MQTT服务器
|
|
465
|
+
2. **设备自动发现**:品牌MQTT连接后自动发现设备
|
|
466
|
+
3. **双向同步**:品牌设备↔Mesh设备实时状态同步
|
|
467
|
+
4. **配置持久化**:设备列表和映射配置持久保存,断线后仍可显示
|
|
468
|
+
|
|
469
|
+
#### 支持的品牌协议
|
|
470
|
+
|
|
471
|
+
| 品牌 | 设备类型 | 功能码映射 |
|
|
472
|
+
|------|---------|-----------|
|
|
473
|
+
| HYQW(花语前湾) | 灯具(8) | 开关(fn=1)、亮度(fn=2, 0-100) |
|
|
474
|
+
| | 空调(12) | 开关(fn=1)、温度(fn=2, 18-29°C)、模式(fn=3)、风速(fn=4) |
|
|
475
|
+
| | 窗帘(14) | 动作(fn=1, 开/关/停)、位置(fn=2, 0-100%) |
|
|
476
|
+
| | 地暖(16) | 开关(fn=1)、温度(fn=2, 5-35°C) |
|
|
477
|
+
| | 新风(36) | 开关(fn=1)、风速(fn=3) |
|
|
478
|
+
|
|
479
|
+
#### 配置步骤
|
|
480
|
+
|
|
481
|
+
1. **添加品牌MQTT配置节点**:
|
|
482
|
+
- 从左侧拖入`Symi MQTT Brand`配置节点
|
|
483
|
+
- 配置品牌MQTT服务器地址、用户名、密码
|
|
484
|
+
- 选择品牌协议(如HYQW)
|
|
485
|
+
|
|
486
|
+
2. **添加MQTT同步节点**:
|
|
487
|
+
- 从左侧拖入`Symi MQTT Sync`节点
|
|
488
|
+
- 选择Mesh MQTT配置(用于获取Mesh设备)
|
|
489
|
+
- 选择品牌MQTT配置(用于获取品牌设备)
|
|
490
|
+
|
|
491
|
+
3. **配置实体映射**:
|
|
492
|
+
- 点击"添加"按钮
|
|
493
|
+
- 左侧选择Mesh设备(多路开关可选择按键)
|
|
494
|
+
- 右侧选择品牌设备
|
|
495
|
+
- 可添加多组映射
|
|
496
|
+
|
|
497
|
+
4. **部署**:点击部署,开始双向同步
|
|
498
|
+
|
|
499
|
+
#### 注意事项
|
|
500
|
+
|
|
501
|
+
- **设备类型匹配**:建议同步相同类型的设备(灯具对灯具,空调对空调)
|
|
502
|
+
- **离线设备显示**:[离线]标记缓存中但当前不在线的设备
|
|
503
|
+
- **防死循环**:内置2秒防抖机制,避免状态震荡
|
|
504
|
+
- **自动重连**:5秒重连间隔,断线自动恢复
|
|
505
|
+
|
|
425
506
|
|
|
426
507
|
## 协议说明
|
|
427
508
|
|
|
@@ -1332,8 +1413,8 @@ A: 每个房间部署一个云端同步节点,配置对应的酒店ID和房间
|
|
|
1332
1413
|
| **Symi KNX Bridge** | KNX系统双向同步 | [KNX双向同步](#knx双向同步) |
|
|
1333
1414
|
| **Symi KNX-HA Bridge** | KNX与HA实体双向同步 | [KNX-HA双向同步](#knx-ha双向同步) |
|
|
1334
1415
|
| **Symi HA Sync** | HA实体双向同步 | [HA双向同步](#ha双向同步) |
|
|
1335
|
-
| **Symi MQTT Sync** | 第三方MQTT品牌设备同步 |
|
|
1336
|
-
| **Symi MQTT Brand** | 品牌MQTT配置节点 |
|
|
1416
|
+
| **Symi MQTT Sync** | 第三方MQTT品牌设备同步 | [MQTT品牌同步](#mqtt品牌同步) |
|
|
1417
|
+
| **Symi MQTT Brand** | 品牌MQTT配置节点 | [MQTT品牌同步](#mqtt品牌同步) |
|
|
1337
1418
|
| **RS485 Debug** | RS485总线数据抓包调试 | [RS485调试节点](#rs485调试节点) |
|
|
1338
1419
|
| **Symi RS485 Sync** | 两种RS485协议双向同步 | [RS485协议同步](#rs485协议同步) |
|
|
1339
1420
|
|
|
@@ -1503,6 +1584,36 @@ node-red-contrib-symi-mesh/
|
|
|
1503
1584
|
|
|
1504
1585
|
## 更新日志
|
|
1505
1586
|
|
|
1587
|
+
### v1.8.2 (2026-01-05)
|
|
1588
|
+
- **MQTT品牌同步协议修复**:
|
|
1589
|
+
- **窗帘设备协议修复**:修复窗帘fn=1功能码的正确解析(0=关闭, 1=打开, 2=停止),之前错误地当作开关处理
|
|
1590
|
+
- **设备类型独立处理**:重构syncToMesh和syncToMqtt函数,按设备类型(8/12/14/16/36)独立处理功能码映射
|
|
1591
|
+
- **完整功能码支持**:
|
|
1592
|
+
- 灯具(8): fn=1开关, fn=2亮度
|
|
1593
|
+
- 空调(12): fn=1开关, fn=2温度, fn=3模式, fn=4风速
|
|
1594
|
+
- 窗帘(14): fn=1动作(开/关/停), fn=2位置
|
|
1595
|
+
- 地暖(16): fn=1开关, fn=2温度
|
|
1596
|
+
- 新风(36): fn=1开关, fn=3风速
|
|
1597
|
+
- **错误日志增强**:同步失败时输出详细错误信息,便于调试
|
|
1598
|
+
|
|
1599
|
+
### v1.8.1 (2026-01-05)
|
|
1600
|
+
- **三合一面板深度集成**:
|
|
1601
|
+
- **子设备选择**:HA同步节点新增三合一子设备选择功能,可分别选择“空调”、“新风”、“地暖”进行独立映射。
|
|
1602
|
+
- **完整属性同步**:支持空调(开关/模式/温度/风速)、新风(开关/风速)、地暖(开关/温度)的全功能双向同步。
|
|
1603
|
+
- **配置与文档优化**:
|
|
1604
|
+
- **纯配置同步**:明确HA同步节点为纯配置模式,无需额外连线即可实现双向同步。
|
|
1605
|
+
- **文档更新**:全面更新README,增加三合一配置示例和Config-Only说明。
|
|
1606
|
+
- **稳定性修复**:
|
|
1607
|
+
- 修复 Symi→HA 同步时的调试日志重复输出问题。
|
|
1608
|
+
- 增强 HA 事件监听的健壮性,支持多种输入格式。
|
|
1609
|
+
- **窗帘双向同步修复**:
|
|
1610
|
+
- **运动状态跟踪**:新增 `coverMoving` 状态跟踪机制,记录窗帘运动的发起方(Symi/HA)和开始时间
|
|
1611
|
+
- **步进反馈过滤**:当 HA 发起窗帘控制时,自动忽略 Mesh 窗帘运动过程中的步进位置反馈,避免干扰
|
|
1612
|
+
- **运动中状态过滤**:当 HA 窗帘处于 `opening`/`closing` 状态时,不同步位置变化到 Mesh,只同步最终位置
|
|
1613
|
+
- **专用防死循环时间**:窗帘使用 30 秒防死循环窗口(普通设备 2 秒),适应窗帘较长的运动时间
|
|
1614
|
+
- **防抖时间优化**:窗帘防抖时间从 500ms 增加到 1500ms,确保位置稳定后再同步
|
|
1615
|
+
- **自动清理机制**:运动状态超时(30秒)自动清理,防止内存泄漏
|
|
1616
|
+
|
|
1506
1617
|
### v1.8.0 (2026-01-05)
|
|
1507
1618
|
- **HA同步节点重大增强**:
|
|
1508
1619
|
- **同步模式选择**:新增“双向同步”、“仅Symi→HA”、“仅HA→Symi”三种模式,配置更加灵活。
|
|
@@ -1571,80 +1682,6 @@ node-red-contrib-symi-mesh/
|
|
|
1571
1682
|
- 完善的节点关闭清理逻辑
|
|
1572
1683
|
- 防死循环机制增强:双向时间戳检查
|
|
1573
1684
|
|
|
1574
|
-
### v1.7.7 (2026-01-05)
|
|
1575
|
-
- **RS485桥接节点逻辑优化**:
|
|
1576
|
-
- 修复自定义码模式下"反馈"按钮点击部署后依旧开启的问题
|
|
1577
|
-
- 完善 `oneditsave` 逻辑,确保反馈选项状态被持久化保存
|
|
1578
|
-
- **防循环优化**:优化 `loopKey` 生成逻辑,加入 `meshChannel` 字段,支持多通道设备独立防循环,解决多路开关干扰问题
|
|
1579
|
-
- **冷却时间调整**:将防死循环冷却时间从 500ms 延长至 800ms,提高复杂网络环境下的同步稳定性
|
|
1580
|
-
- **反馈逻辑细化**:严格执行"反馈"勾选逻辑,未勾选时禁止回环发送自定义码到总线
|
|
1581
|
-
- **调试增强**:增加自定义码匹配成功的详细日志(包含设备 MAC 和具体动作)
|
|
1582
|
-
|
|
1583
|
-
### v1.7.5 (2025-12-24)
|
|
1584
|
-
- **RS485双向同步节点**:新增`symi-rs485-sync`独立节点实现两种不同RS485协议之间的双向同步
|
|
1585
|
-
- 支持中弘VRF网关协议(功能码0x31-0x34控制,0x50查询)
|
|
1586
|
-
- 支持SYMI空调面板Modbus协议
|
|
1587
|
-
- 支持自定义码协议
|
|
1588
|
-
- 双向状态同步,2秒防抖防止死循环
|
|
1589
|
-
- 多组映射配置,持久化保存
|
|
1590
|
-
- **反馈选项**:新增"反馈"复选框,控制RS485收码后是否发送反馈码
|
|
1591
|
-
- **Mesh→RS485方向**:
|
|
1592
|
-
- 修复开关控制:只同步配置的通道,避免其他通道变化误触发
|
|
1593
|
-
- 修复空调风速:fanMode变化正确发送fanSendHigh/Mid/Low/Auto码
|
|
1594
|
-
- 修复空调中速(fanMode=2)事件触发
|
|
1595
|
-
- 修复空调开关:去重处理,避免switch和acSwitch重复发送
|
|
1596
|
-
- 添加自动风速(fanSendAuto/fanRecvAuto)配置支持
|
|
1597
|
-
- **RS485→Mesh方向**:
|
|
1598
|
-
- 修复收码匹配触发Mesh实体状态变化
|
|
1599
|
-
- 自定义开关recvOn/recvOff正确触发开关动作
|
|
1600
|
-
- 自定义空调acRecvOn/acRecvOff/fanRecvHigh等正确触发空调控制
|
|
1601
|
-
- **事件系统优化**:
|
|
1602
|
-
- DeviceManager事件添加完整字段(isUserControl、isSceneExecution等)
|
|
1603
|
-
- 用户控制事件允许绕过首次状态缓存
|
|
1604
|
-
- 修复单路温控器开关事件丢失问题
|
|
1605
|
-
- fanMode事件同时发送acFanSpeed别名字段
|
|
1606
|
-
- **防死循环优化**:
|
|
1607
|
-
- 使用映射特定时间戳,避免不同设备同步互相影响
|
|
1608
|
-
- Mesh→RS485和RS485→Mesh双向都记录同步时间
|
|
1609
|
-
- 500ms冷却时间防止状态回环
|
|
1610
|
-
- 空调命令去重(同类命令只发送一次)
|
|
1611
|
-
- **生产稳定性**:
|
|
1612
|
-
- 初始化延迟从10秒缩短为5秒
|
|
1613
|
-
- 详细日志改为debug级别,减少生产环境日志量
|
|
1614
|
-
- 保留关键同步日志便于问题排查
|
|
1615
|
-
- **UI优化**:
|
|
1616
|
-
- 自定义码折叠按钮移到同一行,减少占用空间
|
|
1617
|
-
- 修复MAC地址大小写匹配问题
|
|
1618
|
-
- 添加自动风速配置框(发自动/收自动)
|
|
1619
|
-
- **RS485-to-RS485空调桥接**:SYMI空调面板与中弘VRF系统双向同步
|
|
1620
|
-
- 支持SYMI 485空调面板协议(7E...7D帧格式,CRC8校验)
|
|
1621
|
-
- 支持中弘VRF空调协议(求和校验,功能码0x31-0x34控制,0x50查询)
|
|
1622
|
-
- 双向状态同步:SYMI面板操作→中弘VRF执行,中弘状态→SYMI面板显示
|
|
1623
|
-
|
|
1624
|
-
### v1.7.1 (2025-12-21)
|
|
1625
|
-
- **自定义协议全面修复**:完善自定义开关/窗帘/空调双向同步
|
|
1626
|
-
- **空调风速控制**:修复风速变化事件触发机制
|
|
1627
|
-
- 添加fanMode字段支持(mesh空调实际使用的字段)
|
|
1628
|
-
- 修复DeviceInfo事件触发,正确发送device-state-changed事件
|
|
1629
|
-
- 支持风速值1-4(1=高, 2=中, 3=低, 4=自动)
|
|
1630
|
-
- 自动识别温控器0x02消息中的风速控制
|
|
1631
|
-
- **空调开关控制**:修复开关状态变化事件触发
|
|
1632
|
-
- 温控器0x02开关消息正确触发device-state-changed事件
|
|
1633
|
-
- 自定义空调开关码(acSendOn/acSendOff)正确发送到RS485总线
|
|
1634
|
-
- **RS485收码匹配**:修复hexStr格式处理
|
|
1635
|
-
- hexStr完全去掉空格,确保标准格式(如030610330001BD27)
|
|
1636
|
-
- 用户录入支持带空格格式(如01 06 10 34 00 01 0D 04)
|
|
1637
|
-
- 自动匹配并触发mesh实体动作
|
|
1638
|
-
- **事件系统优化**:
|
|
1639
|
-
- DeviceInfo添加manager引用,正确触发事件
|
|
1640
|
-
- 修复事件名称不匹配问题(stateChange -> device-state-changed)
|
|
1641
|
-
- 确保所有自定义码双向同步正常
|
|
1642
|
-
- **队列处理**:
|
|
1643
|
-
- 命令队列顺序处理,防止并发冲突
|
|
1644
|
-
- 500ms防死循环机制
|
|
1645
|
-
- 队列限制100条,防止内存溢出
|
|
1646
|
-
- **调试日志**:添加详细调试日志,方便排查问题
|
|
1647
|
-
|
|
1648
1685
|
## 许可证
|
|
1649
1686
|
|
|
1650
1687
|
MIT License
|
|
@@ -1656,7 +1693,7 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
1656
1693
|
## 关于
|
|
1657
1694
|
|
|
1658
1695
|
**作者**: SYMI 亖米
|
|
1659
|
-
**版本**: 1.
|
|
1696
|
+
**版本**: 1.8.2
|
|
1660
1697
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
1661
1698
|
**最后更新**: 2026-01-05
|
|
1662
1699
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
package/nodes/symi-ha-sync.html
CHANGED
|
@@ -119,6 +119,12 @@
|
|
|
119
119
|
// 判断是否需要按键选择
|
|
120
120
|
function needsKeySelection(device, savedChannels, savedEntityType) {
|
|
121
121
|
var entityType = (device ? device.entityType : savedEntityType) || '';
|
|
122
|
+
|
|
123
|
+
// 三合一面板必须选择子设备
|
|
124
|
+
if (entityType === 'three_in_one') {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
122
128
|
// 如果是温控器、窗帘、灯具,不需要选择按键(通常是单路或特殊处理)
|
|
123
129
|
if (entityType === 'climate' || entityType === 'cover' || entityType === 'light') {
|
|
124
130
|
return false;
|
|
@@ -132,6 +138,7 @@
|
|
|
132
138
|
function getDeviceTypeLabel(device) {
|
|
133
139
|
if (!device) return '';
|
|
134
140
|
var entityType = device.entityType || '';
|
|
141
|
+
if (entityType === 'three_in_one') return ' [三合一]';
|
|
135
142
|
if (entityType === 'climate') return ' [温控器]';
|
|
136
143
|
if (entityType === 'cover') return ' [窗帘]';
|
|
137
144
|
if (entityType === 'light') return ' [灯具]';
|
|
@@ -177,14 +184,31 @@
|
|
|
177
184
|
}
|
|
178
185
|
});
|
|
179
186
|
|
|
187
|
+
var entityType = (device ? device.entityType : savedEntityType) || '';
|
|
180
188
|
if (!needsKeySelection(device, savedChannels, savedEntityType)) return '';
|
|
181
189
|
|
|
182
|
-
var channels = device ? (device.channels || 1) : (savedChannels || 1);
|
|
183
190
|
var html = '<select class="symi-key">';
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
|
|
192
|
+
// 三合一设备特殊处理
|
|
193
|
+
if (entityType === 'three_in_one') {
|
|
194
|
+
var opts = [
|
|
195
|
+
{v: 'aircon', l: '空调'},
|
|
196
|
+
{v: 'fresh_air', l: '新风'},
|
|
197
|
+
{v: 'floor_heating', l: '地暖'}
|
|
198
|
+
];
|
|
199
|
+
opts.forEach(function(o) {
|
|
200
|
+
var sel = (o.v == selectedKey) ? ' selected' : '';
|
|
201
|
+
html += '<option value="' + o.v + '"' + sel + '>' + o.l + '</option>';
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
// 多路开关
|
|
205
|
+
var channels = parseInt(device ? (device.channels || 1) : (savedChannels || 1)) || 1;
|
|
206
|
+
for (var i = 1; i <= channels; i++) {
|
|
207
|
+
var sel = (i == selectedKey) ? ' selected' : '';
|
|
208
|
+
html += '<option value="' + i + '"' + sel + '>按键' + i + '</option>';
|
|
209
|
+
}
|
|
187
210
|
}
|
|
211
|
+
|
|
188
212
|
html += '</select>';
|
|
189
213
|
return html;
|
|
190
214
|
}
|
|
@@ -337,6 +361,8 @@
|
|
|
337
361
|
haEntityId: '', haEntityName: ''
|
|
338
362
|
});
|
|
339
363
|
renderMappings();
|
|
364
|
+
var list = $('#mapping-list');
|
|
365
|
+
list.scrollTop(list.prop('scrollHeight'));
|
|
340
366
|
});
|
|
341
367
|
|
|
342
368
|
// 配置变化时重新加载
|
package/nodes/symi-ha-sync.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
|
|
3
|
-
* 版本: 1.8.
|
|
3
|
+
* 版本: 1.8.2
|
|
4
4
|
*
|
|
5
5
|
* 支持的实体类型和属性:
|
|
6
6
|
* - light: on/off, brightness (0-255)
|
|
@@ -15,10 +15,17 @@ module.exports = function(RED) {
|
|
|
15
15
|
|
|
16
16
|
// 常量定义
|
|
17
17
|
const LOOP_PREVENTION_MS = 2000; // 防死循环时间窗口
|
|
18
|
-
const DEBOUNCE_MS = 500; //
|
|
18
|
+
const DEBOUNCE_MS = 500; // 通用防抖时间
|
|
19
19
|
const MAX_QUEUE_SIZE = 100;
|
|
20
20
|
const CLEANUP_INTERVAL_MS = 60000;
|
|
21
21
|
const TIMESTAMP_EXPIRE_MS = 60000;
|
|
22
|
+
|
|
23
|
+
// 窗帘专用常量
|
|
24
|
+
const COVER_LOOP_PREVENTION_MS = 30000; // 30秒防死循环
|
|
25
|
+
const COVER_DEBOUNCE_MS = 1500; // 1.5秒防抖
|
|
26
|
+
|
|
27
|
+
// 调光专用常量
|
|
28
|
+
const BRIGHTNESS_DEBOUNCE_MS = 800; // 0.8秒防抖,过滤步进过程
|
|
22
29
|
|
|
23
30
|
// Mesh属性类型
|
|
24
31
|
const ATTR_SWITCH = 0x02;
|
|
@@ -28,6 +35,14 @@ module.exports = function(RED) {
|
|
|
28
35
|
const ATTR_TARGET_TEMP = 0x1B;
|
|
29
36
|
const ATTR_FAN_MODE = 0x1C;
|
|
30
37
|
const ATTR_CLIMATE_MODE = 0x1D;
|
|
38
|
+
|
|
39
|
+
// 三合一属性
|
|
40
|
+
const ATTR_FRESH_AIR_SWITCH = 0x68;
|
|
41
|
+
const ATTR_FRESH_AIR_MODE = 0x69;
|
|
42
|
+
const ATTR_FRESH_AIR_SPEED = 0x6A;
|
|
43
|
+
const ATTR_FLOOR_HEATING_SWITCH = 0x6B;
|
|
44
|
+
const ATTR_FLOOR_HEATING_TEMP = 0x6C;
|
|
45
|
+
const ATTR_THREE_IN_ONE = 0x94;
|
|
31
46
|
|
|
32
47
|
// 空调模式映射
|
|
33
48
|
const AC_MODE_TO_HA = { 1: 'cool', 2: 'heat', 3: 'fan_only', 4: 'dry' };
|
|
@@ -48,15 +63,23 @@ module.exports = function(RED) {
|
|
|
48
63
|
// 解析映射配置
|
|
49
64
|
try {
|
|
50
65
|
const rawMappings = JSON.parse(config.mappings || '[]');
|
|
51
|
-
node.mappings = rawMappings.map(m =>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
node.mappings = rawMappings.map(m => {
|
|
67
|
+
// symiKey可能是数字(按键索引)或字符串(三合一子设备ID)
|
|
68
|
+
let key = m.symiKey;
|
|
69
|
+
if (typeof key !== 'string' || !isNaN(parseInt(key))) {
|
|
70
|
+
key = parseInt(key) || 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
symiMac: m.symiMac,
|
|
75
|
+
symiKey: key,
|
|
76
|
+
haEntityId: m.haEntityId,
|
|
77
|
+
symiName: m.symiName || '',
|
|
78
|
+
haEntityName: m.haEntityName || '',
|
|
79
|
+
symiEntityType: m.symiEntityType || '',
|
|
80
|
+
syncMode: parseInt(m.syncMode) || 0
|
|
81
|
+
};
|
|
82
|
+
}).filter(m => m.symiMac && m.haEntityId);
|
|
60
83
|
|
|
61
84
|
if (node.mappings.length > 0) {
|
|
62
85
|
node.log(`[HA同步] 已加载 ${node.mappings.length} 个映射`);
|
|
@@ -71,6 +94,8 @@ module.exports = function(RED) {
|
|
|
71
94
|
node.lastSymiToHa = {};
|
|
72
95
|
node.lastHaToSymi = {};
|
|
73
96
|
node.pendingDebounce = {}; // 防抖定时器
|
|
97
|
+
node.coverMoving = {}; // 窗帘运动状态跟踪 { loopKey: { direction: 'symi'|'ha', startTime, targetPosition } }
|
|
98
|
+
node.brightnessMoving = {}; // 调光运动状态跟踪 { loopKey: { direction: 'ha', startTime } }
|
|
74
99
|
|
|
75
100
|
node.status({ fill: 'yellow', shape: 'ring', text: '初始化中' });
|
|
76
101
|
|
|
@@ -106,6 +131,20 @@ module.exports = function(RED) {
|
|
|
106
131
|
delete node.lastHaToSymi[key];
|
|
107
132
|
}
|
|
108
133
|
}
|
|
134
|
+
|
|
135
|
+
// 清理过期的窗帘运动状态
|
|
136
|
+
for (const key in node.coverMoving) {
|
|
137
|
+
if (now - node.coverMoving[key].startTime > COVER_LOOP_PREVENTION_MS) {
|
|
138
|
+
delete node.coverMoving[key];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 清理过期的调光运动状态
|
|
143
|
+
for (const key in node.brightnessMoving) {
|
|
144
|
+
if (now - node.brightnessMoving[key].startTime > LOOP_PREVENTION_MS) {
|
|
145
|
+
delete node.brightnessMoving[key];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
109
148
|
}, CLEANUP_INTERVAL_MS);
|
|
110
149
|
|
|
111
150
|
// 防死循环检查 - 双向时间戳检查
|
|
@@ -212,24 +251,116 @@ module.exports = function(RED) {
|
|
|
212
251
|
syncData = node.handleClimateModeChange(device, mapping, state);
|
|
213
252
|
}
|
|
214
253
|
break;
|
|
254
|
+
|
|
255
|
+
case ATTR_THREE_IN_ONE: // 0x94 三合一全量状态
|
|
256
|
+
case ATTR_FRESH_AIR_SWITCH: // 0x68 新风开关
|
|
257
|
+
case ATTR_FRESH_AIR_MODE: // 0x69 新风模式
|
|
258
|
+
case ATTR_FRESH_AIR_SPEED: // 0x6A 新风风速
|
|
259
|
+
case ATTR_FLOOR_HEATING_SWITCH: // 0x6B 地暖开关
|
|
260
|
+
case ATTR_FLOOR_HEATING_TEMP: // 0x6C 地暖温度
|
|
261
|
+
syncData = node.handleThreeInOneChange(device, mapping, state, attrType);
|
|
262
|
+
break;
|
|
215
263
|
}
|
|
216
264
|
|
|
217
265
|
if (syncData) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
266
|
+
// 支持返回数组(用于三合一同时同步多个属性)
|
|
267
|
+
const syncDataList = Array.isArray(syncData) ? syncData : [syncData];
|
|
222
268
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
269
|
+
syncDataList.forEach(data => {
|
|
270
|
+
if (node.shouldPreventSync('symi-to-ha', loopKey)) {
|
|
271
|
+
node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
node.queueCommand({
|
|
276
|
+
direction: 'symi-to-ha',
|
|
277
|
+
mapping: mapping,
|
|
278
|
+
syncData: data,
|
|
279
|
+
key: loopKey
|
|
280
|
+
});
|
|
228
281
|
});
|
|
229
282
|
}
|
|
230
283
|
});
|
|
231
284
|
};
|
|
232
285
|
|
|
286
|
+
// 处理三合一状态变化
|
|
287
|
+
node.handleThreeInOneChange = function(device, mapping, state, attrType) {
|
|
288
|
+
const subType = mapping.symiKey; // 'aircon', 'fresh_air', 'floor_heating'
|
|
289
|
+
|
|
290
|
+
// 1. 空调部分 (通常通过0x94或标准温控指令更新)
|
|
291
|
+
if (subType === 'aircon') {
|
|
292
|
+
// 如果是标准温控属性更新,已经在switch case中处理了
|
|
293
|
+
// 这里主要处理0x94带来的全量更新
|
|
294
|
+
if (attrType === ATTR_THREE_IN_ONE) {
|
|
295
|
+
// 此时state已经包含了所有更新
|
|
296
|
+
// 需要检查哪些属性变了,但这里只能返回一个syncData
|
|
297
|
+
// 我们可以返回一个特殊对象,或者分别检查
|
|
298
|
+
// 为简化,这里假设HA端会处理部分更新,或者我们按优先级返回
|
|
299
|
+
|
|
300
|
+
// 检查开关
|
|
301
|
+
if (state.climateSwitch !== undefined) {
|
|
302
|
+
// 注意:这里需要比对旧状态,但在handleSymiStateChange中难以获取旧状态
|
|
303
|
+
// 我们可以利用node.queueCommand的去重机制,发送所有可能的状态
|
|
304
|
+
// 但这样会产生大量流量。
|
|
305
|
+
// 实际上DeviceManager触发事件时,如果是0x94,是全量更新。
|
|
306
|
+
// 我们可以只处理核心属性。
|
|
307
|
+
// 更好的方式是:在device-manager中,0x94更新会触发一次事件。
|
|
308
|
+
// 这里我们返回一个复合对象,或者由上层逻辑拆分。
|
|
309
|
+
// 由于syncData只能是一个对象,我们优先同步开关,然后是模式/温度
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// 由于0x94更新时,device-manager已经更新了state
|
|
313
|
+
// 我们可以直接从state读取当前值
|
|
314
|
+
|
|
315
|
+
// 构造空调状态
|
|
316
|
+
// 这里我们可能需要多次调用queueCommand,但handle函数只能返回一个
|
|
317
|
+
// 解决方案:handleSymiStateChange支持返回数组
|
|
318
|
+
return [
|
|
319
|
+
{ type: 'switch', value: state.climateSwitch },
|
|
320
|
+
{ type: 'temperature', value: state.targetTemp },
|
|
321
|
+
{ type: 'hvac_mode', value: AC_MODE_TO_HA[state.climateMode] || 'off', meshValue: state.climateMode },
|
|
322
|
+
{ type: 'fan_mode', value: FAN_MODE_TO_HA[state.fanMode] || 'auto', meshValue: state.fanMode }
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 2. 新风部分
|
|
327
|
+
if (subType === 'fresh_air') {
|
|
328
|
+
// 新风开关 (0x68或0x94)
|
|
329
|
+
if (attrType === ATTR_FRESH_AIR_SWITCH || attrType === ATTR_THREE_IN_ONE) {
|
|
330
|
+
if (state.freshAirSwitch !== undefined) {
|
|
331
|
+
return { type: 'switch', value: state.freshAirSwitch };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// 新风风速 (0x6A或0x94)
|
|
335
|
+
if (attrType === ATTR_FRESH_AIR_SPEED || attrType === ATTR_THREE_IN_ONE) {
|
|
336
|
+
if (state.freshAirSpeed !== undefined) {
|
|
337
|
+
return { type: 'fan_mode', value: FAN_MODE_TO_HA[state.freshAirSpeed] || 'auto', meshValue: state.freshAirSpeed };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// 新风模式 (0x69或0x94)
|
|
341
|
+
// 目前HA Fan实体通常只支持on/off和speed,mode可能不支持或映射到preset_mode
|
|
342
|
+
// 暂时忽略模式,或视需求添加
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 3. 地暖部分
|
|
346
|
+
if (subType === 'floor_heating') {
|
|
347
|
+
// 地暖开关 (0x6B或0x94)
|
|
348
|
+
if (attrType === ATTR_FLOOR_HEATING_SWITCH || attrType === ATTR_THREE_IN_ONE) {
|
|
349
|
+
if (state.floorHeatingSwitch !== undefined) {
|
|
350
|
+
return { type: 'switch', value: state.floorHeatingSwitch };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// 地暖温度 (0x6C或0x94)
|
|
354
|
+
if (attrType === ATTR_FLOOR_HEATING_TEMP || attrType === ATTR_THREE_IN_ONE) {
|
|
355
|
+
if (state.floorHeatingTemp !== undefined) {
|
|
356
|
+
return { type: 'temperature', value: state.floorHeatingTemp };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return null;
|
|
362
|
+
};
|
|
363
|
+
|
|
233
364
|
// 处理开关状态变化
|
|
234
365
|
node.handleSwitchChange = function(device, mapping, state) {
|
|
235
366
|
let isOn = false;
|
|
@@ -247,21 +378,67 @@ module.exports = function(RED) {
|
|
|
247
378
|
return { type: 'switch', value: isOn };
|
|
248
379
|
};
|
|
249
380
|
|
|
250
|
-
//
|
|
381
|
+
// 处理亮度变化(带防抖,避免步进过程干扰)
|
|
251
382
|
node.handleBrightnessChange = function(device, mapping, state) {
|
|
252
383
|
const brightness = state.brightness !== undefined ? state.brightness : device.state.brightness;
|
|
253
384
|
if (brightness === undefined) return null;
|
|
254
385
|
|
|
386
|
+
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
387
|
+
|
|
388
|
+
// 检查是否是HA发起的调光,如果是则忽略Mesh的亮度反馈(步进码)
|
|
389
|
+
if (node.brightnessMoving && node.brightnessMoving[loopKey]) {
|
|
390
|
+
const elapsed = Date.now() - node.brightnessMoving[loopKey].startTime;
|
|
391
|
+
if (elapsed < LOOP_PREVENTION_MS) {
|
|
392
|
+
node.debug(`[Symi->HA] 亮度忽略(HA发起调光中): ${loopKey}`);
|
|
393
|
+
return null;
|
|
394
|
+
} else {
|
|
395
|
+
delete node.brightnessMoving[loopKey];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 防抖处理:取消之前的定时器,设置新的
|
|
400
|
+
const debounceKey = `brightness_${mapping.symiMac}_${mapping.symiKey}`;
|
|
401
|
+
if (node.pendingDebounce[debounceKey]) {
|
|
402
|
+
clearTimeout(node.pendingDebounce[debounceKey]);
|
|
403
|
+
}
|
|
404
|
+
|
|
255
405
|
// Mesh亮度0-100,HA亮度0-255
|
|
256
406
|
const haBrightness = Math.round(brightness * 255 / 100);
|
|
257
|
-
|
|
407
|
+
|
|
408
|
+
// 延迟同步,等待亮度稳定(过滤步进过程)
|
|
409
|
+
node.pendingDebounce[debounceKey] = setTimeout(() => {
|
|
410
|
+
delete node.pendingDebounce[debounceKey];
|
|
411
|
+
|
|
412
|
+
node.queueCommand({
|
|
413
|
+
direction: 'symi-to-ha',
|
|
414
|
+
mapping: mapping,
|
|
415
|
+
syncData: { type: 'brightness', value: haBrightness, meshValue: brightness },
|
|
416
|
+
key: loopKey,
|
|
417
|
+
skipLoopCheck: true
|
|
418
|
+
});
|
|
419
|
+
}, BRIGHTNESS_DEBOUNCE_MS);
|
|
420
|
+
|
|
421
|
+
return null; // 不立即同步,由定时器处理
|
|
258
422
|
};
|
|
259
423
|
|
|
260
|
-
//
|
|
424
|
+
// 处理窗帘变化(带防抖,避免步进反馈干扰)
|
|
261
425
|
node.handleCurtainChange = function(device, mapping, state, attrType) {
|
|
262
426
|
const domain = node.getEntityDomain(mapping.haEntityId);
|
|
263
427
|
if (domain !== 'cover') return null;
|
|
264
428
|
|
|
429
|
+
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
430
|
+
|
|
431
|
+
// 检查是否是HA发起的运动,如果是则忽略Mesh的位置反馈(步进码)
|
|
432
|
+
if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
|
|
433
|
+
const elapsed = Date.now() - node.coverMoving[loopKey].startTime;
|
|
434
|
+
if (elapsed < COVER_LOOP_PREVENTION_MS) {
|
|
435
|
+
node.debug(`[Symi->HA] 窗帘忽略(HA发起运动中): ${loopKey}`);
|
|
436
|
+
return null; // 忽略HA发起运动期间的Mesh反馈
|
|
437
|
+
} else {
|
|
438
|
+
delete node.coverMoving[loopKey]; // 超时清理
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
265
442
|
// 窗帘位置变化 - 使用防抖,只同步最终位置
|
|
266
443
|
if (attrType === ATTR_CURTAIN_POSITION) {
|
|
267
444
|
const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
|
|
@@ -273,27 +450,34 @@ module.exports = function(RED) {
|
|
|
273
450
|
clearTimeout(node.pendingDebounce[debounceKey]);
|
|
274
451
|
}
|
|
275
452
|
|
|
453
|
+
// 标记Symi发起的运动
|
|
454
|
+
node.coverMoving[loopKey] = { direction: 'symi', startTime: Date.now(), targetPosition: position };
|
|
455
|
+
|
|
276
456
|
// 延迟同步,等待位置稳定
|
|
277
457
|
node.pendingDebounce[debounceKey] = setTimeout(() => {
|
|
278
458
|
delete node.pendingDebounce[debounceKey];
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
459
|
+
// 运动结束,清理状态
|
|
460
|
+
delete node.coverMoving[loopKey];
|
|
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);
|
|
289
471
|
|
|
290
472
|
return null; // 不立即同步
|
|
291
473
|
}
|
|
292
474
|
|
|
293
|
-
// 窗帘运行状态 -
|
|
475
|
+
// 窗帘运行状态 - 同步停止动作
|
|
294
476
|
if (attrType === ATTR_CURTAIN_STATUS) {
|
|
295
477
|
const action = state.curtainAction || device.state.curtainAction;
|
|
296
478
|
if (action === 'stopped') {
|
|
479
|
+
// 停止时清理运动状态
|
|
480
|
+
delete node.coverMoving[loopKey];
|
|
297
481
|
return { type: 'curtain_stop' };
|
|
298
482
|
}
|
|
299
483
|
}
|
|
@@ -333,21 +517,61 @@ module.exports = function(RED) {
|
|
|
333
517
|
|
|
334
518
|
// 方式A: 通过Input输入 (server-state-changed节点)
|
|
335
519
|
node.on('input', function(msg) {
|
|
336
|
-
if (msg.
|
|
520
|
+
if (msg.payload && (msg.payload.entity_id || (msg.data && msg.data.entity_id))) {
|
|
521
|
+
const entityId = msg.payload.entity_id || msg.data.entity_id;
|
|
522
|
+
const newState = msg.payload.new_state || msg.data.new_state;
|
|
523
|
+
const oldState = msg.payload.old_state || msg.data.old_state;
|
|
524
|
+
|
|
525
|
+
if (entityId && newState) {
|
|
526
|
+
node.handleHaStateChange(entityId, newState, oldState);
|
|
527
|
+
}
|
|
528
|
+
} else if (msg.data && msg.data.entity_id && msg.data.new_state) {
|
|
529
|
+
// 兼容旧格式
|
|
337
530
|
node.handleHaStateChange(msg.data.entity_id, msg.data.new_state, msg.data.old_state);
|
|
338
531
|
}
|
|
339
532
|
});
|
|
340
533
|
|
|
341
|
-
// 方式B: 尝试订阅HA Server事件总线
|
|
342
|
-
|
|
343
|
-
node.
|
|
344
|
-
|
|
345
|
-
|
|
534
|
+
// 方式B: 尝试订阅HA Server事件总线 (静默订阅机制)
|
|
535
|
+
const subscribeToHaEvents = () => {
|
|
536
|
+
if (!node.haServer) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// node-red-contrib-home-assistant-websocket 不同版本可能暴露不同的事件总线
|
|
541
|
+
let eventBus = node.haServer.eventBus;
|
|
542
|
+
if (!eventBus && node.haServer.controller) {
|
|
543
|
+
eventBus = node.haServer.controller.events;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (eventBus) {
|
|
547
|
+
// 防止重复订阅
|
|
548
|
+
if (node.haSubscribed) return;
|
|
549
|
+
|
|
550
|
+
node.haEventHandler = (evt) => {
|
|
551
|
+
if (evt && evt.event_type === 'state_changed' && evt.data) {
|
|
552
|
+
node.handleHaStateChange(evt.data.entity_id, evt.data.new_state, evt.data.old_state);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
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
|
+
|
|
563
|
+
// 每 5 秒检查一次,直到成功为止(不设上限,因为这是后台静默检查)
|
|
564
|
+
// 只有在前 3 次且是 debug 模式下才打印
|
|
565
|
+
if (node.haSubscribedRetryCount <= 3) {
|
|
566
|
+
node.debug(`[HA同步] 等待 HA 事件总线就绪... (${node.haSubscribedRetryCount})`);
|
|
346
567
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
568
|
+
|
|
569
|
+
setTimeout(subscribeToHaEvents, 5000);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// 启动后立即开始静默尝试
|
|
574
|
+
subscribeToHaEvents();
|
|
351
575
|
|
|
352
576
|
node.handleHaStateChange = function(entityId, newState, oldState) {
|
|
353
577
|
if (!newState) return;
|
|
@@ -393,23 +617,51 @@ module.exports = function(RED) {
|
|
|
393
617
|
if (!oldState || oldAttrs.brightness !== attrs.brightness) {
|
|
394
618
|
// HA亮度0-255,Mesh亮度0-100
|
|
395
619
|
const meshBrightness = Math.round(attrs.brightness * 100 / 255);
|
|
620
|
+
// 标记HA发起的调光,防止Mesh步进反馈回传
|
|
621
|
+
node.brightnessMoving[loopKey] = { direction: 'ha', startTime: Date.now() };
|
|
396
622
|
syncDataList.push({ type: 'brightness', value: meshBrightness });
|
|
397
623
|
}
|
|
398
624
|
}
|
|
399
625
|
break;
|
|
400
626
|
|
|
401
627
|
case 'cover':
|
|
402
|
-
|
|
628
|
+
const coverLoopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
629
|
+
|
|
630
|
+
// 检查是否是Symi发起的运动,如果是则忽略HA的位置反馈
|
|
631
|
+
if (node.coverMoving[coverLoopKey] && node.coverMoving[coverLoopKey].direction === 'symi') {
|
|
632
|
+
const elapsed = Date.now() - node.coverMoving[coverLoopKey].startTime;
|
|
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}`);
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 窗帘位置变化 - 只在停止状态时同步
|
|
403
646
|
if (attrs.current_position !== undefined) {
|
|
404
647
|
if (!oldState || oldAttrs.current_position !== attrs.current_position) {
|
|
648
|
+
// 标记HA发起的运动
|
|
649
|
+
node.coverMoving[coverLoopKey] = {
|
|
650
|
+
direction: 'ha',
|
|
651
|
+
startTime: Date.now(),
|
|
652
|
+
targetPosition: attrs.current_position
|
|
653
|
+
};
|
|
405
654
|
syncDataList.push({ type: 'position', value: attrs.current_position });
|
|
406
655
|
}
|
|
407
656
|
}
|
|
408
|
-
|
|
657
|
+
|
|
658
|
+
// 窗帘动作 - open/closed 状态变化
|
|
409
659
|
if (newState.state !== oldState?.state) {
|
|
410
660
|
if (newState.state === 'open') {
|
|
661
|
+
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
|
|
411
662
|
syncDataList.push({ type: 'curtain_action', value: 'open' });
|
|
412
663
|
} else if (newState.state === 'closed') {
|
|
664
|
+
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
|
|
413
665
|
syncDataList.push({ type: 'curtain_action', value: 'close' });
|
|
414
666
|
}
|
|
415
667
|
}
|
|
@@ -419,7 +671,11 @@ module.exports = function(RED) {
|
|
|
419
671
|
// 开关状态
|
|
420
672
|
if (!oldState || newState.state !== oldState.state) {
|
|
421
673
|
const isOn = newState.state !== 'off' && newState.state !== 'unavailable';
|
|
422
|
-
|
|
674
|
+
// 仅当状态从off变on,或on变off时才同步开关
|
|
675
|
+
if ((oldState && oldState.state === 'off' && newState.state !== 'off') ||
|
|
676
|
+
(oldState && oldState.state !== 'off' && newState.state === 'off')) {
|
|
677
|
+
syncDataList.push({ type: 'switch', value: isOn });
|
|
678
|
+
}
|
|
423
679
|
}
|
|
424
680
|
// 目标温度
|
|
425
681
|
if (attrs.temperature !== undefined) {
|
|
@@ -428,9 +684,10 @@ module.exports = function(RED) {
|
|
|
428
684
|
}
|
|
429
685
|
}
|
|
430
686
|
// HVAC模式
|
|
431
|
-
|
|
687
|
+
// 过滤掉off模式的变化,因为off已经由开关状态处理
|
|
688
|
+
if (newState.state !== 'off' && (attrs.hvac_mode !== undefined || newState.state !== oldState?.state)) {
|
|
432
689
|
const hvacMode = attrs.hvac_mode || newState.state;
|
|
433
|
-
if (!oldState || (oldAttrs.hvac_mode || oldState.state) !== hvacMode) {
|
|
690
|
+
if (hvacMode !== 'off' && (!oldState || (oldAttrs.hvac_mode || oldState.state) !== hvacMode)) {
|
|
434
691
|
const meshMode = HA_TO_AC_MODE[hvacMode];
|
|
435
692
|
if (meshMode !== undefined) {
|
|
436
693
|
syncDataList.push({ type: 'hvac_mode', value: meshMode });
|
|
@@ -471,7 +728,9 @@ module.exports = function(RED) {
|
|
|
471
728
|
|
|
472
729
|
// 队列同步命令
|
|
473
730
|
syncDataList.forEach(syncData => {
|
|
474
|
-
|
|
731
|
+
// 窗帘使用专门的运动状态跟踪,不使用常规防死循环
|
|
732
|
+
const isCover = domain === 'cover';
|
|
733
|
+
if (!isCover && node.shouldPreventSync('ha-to-symi', loopKey)) {
|
|
475
734
|
node.debug(`[HA->Symi] 跳过(防死循环): ${loopKey} ${syncData.type}`);
|
|
476
735
|
return;
|
|
477
736
|
}
|
|
@@ -480,7 +739,8 @@ module.exports = function(RED) {
|
|
|
480
739
|
direction: 'ha-to-symi',
|
|
481
740
|
mapping: mapping,
|
|
482
741
|
syncData: syncData,
|
|
483
|
-
key: loopKey
|
|
742
|
+
key: loopKey,
|
|
743
|
+
skipLoopCheck: isCover // 窗帘跳过常规防死循环检查
|
|
484
744
|
});
|
|
485
745
|
});
|
|
486
746
|
});
|
|
@@ -585,8 +845,9 @@ module.exports = function(RED) {
|
|
|
585
845
|
service = 'set_fan_mode';
|
|
586
846
|
serviceData.fan_mode = syncData.value;
|
|
587
847
|
} else if (domain === 'fan') {
|
|
848
|
+
// 区分新风和普通风扇
|
|
849
|
+
// 如果是新风且HA实体是fan
|
|
588
850
|
service = 'set_percentage';
|
|
589
|
-
// 风速档位转百分比
|
|
590
851
|
const percentMap = { high: 100, medium: 66, low: 33, auto: 50 };
|
|
591
852
|
serviceData.percentage = percentMap[syncData.value] || 50;
|
|
592
853
|
}
|
|
@@ -608,7 +869,8 @@ module.exports = function(RED) {
|
|
|
608
869
|
|
|
609
870
|
node.log(`[Symi->HA] ${mapping.symiName || mapping.symiMac} -> ${mapping.haEntityId}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
610
871
|
|
|
611
|
-
//
|
|
872
|
+
// 仅输出到debug,不再重复send
|
|
873
|
+
/*
|
|
612
874
|
node.send({
|
|
613
875
|
topic: 'ha-sync/symi-to-ha',
|
|
614
876
|
payload: {
|
|
@@ -621,6 +883,7 @@ module.exports = function(RED) {
|
|
|
621
883
|
timestamp: Date.now()
|
|
622
884
|
}
|
|
623
885
|
});
|
|
886
|
+
*/
|
|
624
887
|
|
|
625
888
|
} catch (err) {
|
|
626
889
|
node.error(`[Symi->HA] 调用失败: ${err.message}`);
|
|
@@ -642,6 +905,16 @@ module.exports = function(RED) {
|
|
|
642
905
|
const channels = device.channels;
|
|
643
906
|
const targetChannel = mapping.symiKey;
|
|
644
907
|
|
|
908
|
+
// 检查是否是三合一设备(symiKey为字符串类型)
|
|
909
|
+
const isThreeInOne = typeof targetChannel === 'string' &&
|
|
910
|
+
['aircon', 'fresh_air', 'floor_heating'].includes(targetChannel);
|
|
911
|
+
|
|
912
|
+
if (isThreeInOne) {
|
|
913
|
+
// 三合一设备使用专门的控制函数
|
|
914
|
+
await node.syncThreeInOne(cmd, device, networkAddr);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
645
918
|
try {
|
|
646
919
|
let attrType, param;
|
|
647
920
|
|
|
@@ -692,23 +965,77 @@ module.exports = function(RED) {
|
|
|
692
965
|
|
|
693
966
|
node.log(`[HA->Symi] ${mapping.haEntityId} -> ${device.name || mapping.symiMac}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
694
967
|
|
|
695
|
-
|
|
696
|
-
node.
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
968
|
+
} catch (err) {
|
|
969
|
+
node.error(`[HA->Symi] 控制失败: ${err.message}`);
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
// ========== 执行 三合一控制 ==========
|
|
974
|
+
node.syncThreeInOne = async function(cmd, device, networkAddr) {
|
|
975
|
+
const { mapping, syncData } = cmd;
|
|
976
|
+
const subType = mapping.symiKey;
|
|
977
|
+
|
|
978
|
+
try {
|
|
979
|
+
let attrType, param;
|
|
980
|
+
|
|
981
|
+
if (subType === 'aircon') {
|
|
982
|
+
// 空调控制
|
|
983
|
+
switch (syncData.type) {
|
|
984
|
+
case 'switch':
|
|
985
|
+
attrType = ATTR_SWITCH;
|
|
986
|
+
param = [syncData.value ? 0x02 : 0x01];
|
|
987
|
+
break;
|
|
988
|
+
case 'temperature':
|
|
989
|
+
attrType = ATTR_TARGET_TEMP;
|
|
990
|
+
param = [syncData.value];
|
|
991
|
+
break;
|
|
992
|
+
case 'hvac_mode':
|
|
993
|
+
attrType = ATTR_CLIMATE_MODE;
|
|
994
|
+
// HA mode -> Mesh mode
|
|
995
|
+
const haToMeshMode = { cool: 1, heat: 2, fan_only: 3, dry: 4, off: 0 };
|
|
996
|
+
param = [haToMeshMode[syncData.value] || 1];
|
|
997
|
+
break;
|
|
998
|
+
case 'fan_mode':
|
|
999
|
+
attrType = ATTR_FAN_MODE;
|
|
1000
|
+
// HA fan -> Mesh fan
|
|
1001
|
+
const haToMeshFan = { high: 1, medium: 2, low: 3, auto: 4 };
|
|
1002
|
+
param = [haToMeshFan[syncData.value] || 4];
|
|
1003
|
+
break;
|
|
707
1004
|
}
|
|
708
|
-
})
|
|
1005
|
+
} else if (subType === 'fresh_air') {
|
|
1006
|
+
// 新风控制
|
|
1007
|
+
switch (syncData.type) {
|
|
1008
|
+
case 'switch':
|
|
1009
|
+
attrType = ATTR_FRESH_AIR_SWITCH;
|
|
1010
|
+
param = [syncData.value ? 0x02 : 0x01];
|
|
1011
|
+
break;
|
|
1012
|
+
case 'fan_mode':
|
|
1013
|
+
attrType = ATTR_FRESH_AIR_SPEED;
|
|
1014
|
+
const haToMeshFan = { high: 1, medium: 2, low: 3, auto: 4 };
|
|
1015
|
+
param = [haToMeshFan[syncData.value] || 4];
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
} else if (subType === 'floor_heating') {
|
|
1019
|
+
// 地暖控制
|
|
1020
|
+
switch (syncData.type) {
|
|
1021
|
+
case 'switch':
|
|
1022
|
+
attrType = ATTR_FLOOR_HEATING_SWITCH;
|
|
1023
|
+
param = [syncData.value ? 0x02 : 0x01];
|
|
1024
|
+
break;
|
|
1025
|
+
case 'temperature':
|
|
1026
|
+
attrType = ATTR_FLOOR_HEATING_TEMP;
|
|
1027
|
+
param = [syncData.value];
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (attrType && param) {
|
|
1033
|
+
await gateway.sendControl(networkAddr, attrType, param);
|
|
1034
|
+
node.log(`[HA->Symi] ${mapping.haEntityId} -> ${mapping.symiName}(${subType}): ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
1035
|
+
}
|
|
709
1036
|
|
|
710
1037
|
} catch (err) {
|
|
711
|
-
node.error(`[HA->Symi]
|
|
1038
|
+
node.error(`[HA->Symi] 三合一控制失败: ${err.message}`);
|
|
712
1039
|
}
|
|
713
1040
|
};
|
|
714
1041
|
|
|
@@ -722,6 +1049,8 @@ module.exports = function(RED) {
|
|
|
722
1049
|
clearTimeout(node.pendingDebounce[key]);
|
|
723
1050
|
}
|
|
724
1051
|
node.pendingDebounce = {};
|
|
1052
|
+
node.coverMoving = {}; // 清理窗帘运动状态
|
|
1053
|
+
node.brightnessMoving = {}; // 清理调光运动状态
|
|
725
1054
|
|
|
726
1055
|
if (gateway) {
|
|
727
1056
|
gateway.removeListener('device-state-changed', node.handleSymiStateChange);
|
|
@@ -739,7 +1068,7 @@ module.exports = function(RED) {
|
|
|
739
1068
|
// ========== HTTP API ==========
|
|
740
1069
|
|
|
741
1070
|
// 加载Symi设备
|
|
742
|
-
RED.httpAdmin.get('/symi-ha-sync/symi-devices/:id', function(req, res) {
|
|
1071
|
+
RED.httpAdmin.get('/symi-ha-sync/symi-devices/:id', RED.auth.needsPermission('symi-ha-sync.read'), function(req, res) {
|
|
743
1072
|
const mqttNode = RED.nodes.getNode(req.params.id);
|
|
744
1073
|
if (!mqttNode || !mqttNode.gateway) {
|
|
745
1074
|
return res.json([]);
|
package/nodes/symi-mqtt-sync.js
CHANGED
|
@@ -293,33 +293,86 @@ module.exports = function(RED) {
|
|
|
293
293
|
|
|
294
294
|
let property = '', value = null;
|
|
295
295
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
296
|
+
// 窗帘设备特殊处理 (typeId=14)
|
|
297
|
+
if (deviceType === 14) {
|
|
298
|
+
if (fn === 1) {
|
|
299
|
+
// fn=1: 窗帘动作控制 (fv: 0=关闭, 1=打开, 2=停止)
|
|
300
|
+
if (fv === 1) {
|
|
301
|
+
property = 'curtainAction'; value = 'open';
|
|
302
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'curtainAction', 'open');
|
|
303
|
+
} else if (fv === 0) {
|
|
304
|
+
property = 'curtainAction'; value = 'close';
|
|
305
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'curtainAction', 'close');
|
|
306
|
+
} else if (fv === 2) {
|
|
307
|
+
property = 'curtainAction'; value = 'stop';
|
|
308
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'curtainAction', 'stop');
|
|
309
|
+
}
|
|
310
|
+
} else if (fn === 2) {
|
|
311
|
+
// fn=2: 窗帘位置 (fv: 0-100%)
|
|
312
|
+
property = 'position'; value = fv;
|
|
313
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'position', fv);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// 灯具设备 (typeId=8)
|
|
317
|
+
else if (deviceType === 8) {
|
|
318
|
+
if (fn === 1) {
|
|
319
|
+
property = 'switch'; value = fv === 1;
|
|
320
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'switch', fv === 1);
|
|
321
|
+
} else if (fn === 2) {
|
|
322
|
+
property = 'brightness'; value = fv;
|
|
323
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'brightness', fv);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// 空调设备 (typeId=12)
|
|
327
|
+
else if (deviceType === 12) {
|
|
328
|
+
if (fn === 1) {
|
|
329
|
+
property = 'switch'; value = fv === 1;
|
|
330
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'switch', fv === 1);
|
|
331
|
+
} else if (fn === 2) {
|
|
332
|
+
property = 'temperature'; value = fv;
|
|
333
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'temperature', fv);
|
|
334
|
+
} else if (fn === 3) {
|
|
306
335
|
const mode = AC_MODE_MAP[fv];
|
|
307
336
|
if (mode) {
|
|
308
337
|
property = 'mode'; value = mode;
|
|
309
338
|
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'mode', mode);
|
|
310
339
|
}
|
|
311
|
-
} else if (
|
|
340
|
+
} else if (fn === 4) {
|
|
341
|
+
const speed = FAN_SPEED_MAP[fv];
|
|
342
|
+
if (speed) {
|
|
343
|
+
property = 'fanSpeed'; value = speed;
|
|
344
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'fanSpeed', speed);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// 地暖设备 (typeId=16)
|
|
349
|
+
else if (deviceType === 16) {
|
|
350
|
+
if (fn === 1) {
|
|
351
|
+
property = 'switch'; value = fv === 1;
|
|
352
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'switch', fv === 1);
|
|
353
|
+
} else if (fn === 2) {
|
|
354
|
+
property = 'temperature'; value = fv;
|
|
355
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'temperature', fv);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// 新风设备 (typeId=36)
|
|
359
|
+
else if (deviceType === 36) {
|
|
360
|
+
if (fn === 1) {
|
|
361
|
+
property = 'switch'; value = fv === 1;
|
|
362
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'switch', fv === 1);
|
|
363
|
+
} else if (fn === 3) {
|
|
312
364
|
const speed = FAN_SPEED_MAP[fv];
|
|
313
365
|
if (speed) {
|
|
314
366
|
property = 'fanSpeed'; value = speed;
|
|
315
367
|
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'fanSpeed', speed);
|
|
316
368
|
}
|
|
317
369
|
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
370
|
+
}
|
|
371
|
+
// 其他设备类型的通用处理
|
|
372
|
+
else {
|
|
373
|
+
if (fn === 1) {
|
|
374
|
+
property = 'switch'; value = fv === 1;
|
|
375
|
+
node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'switch', fv === 1);
|
|
323
376
|
}
|
|
324
377
|
}
|
|
325
378
|
|
|
@@ -335,7 +388,9 @@ module.exports = function(RED) {
|
|
|
335
388
|
}
|
|
336
389
|
});
|
|
337
390
|
}
|
|
338
|
-
} catch (e) {
|
|
391
|
+
} catch (e) {
|
|
392
|
+
node.warn(`[Brand→Mesh] 同步失败: ${e.message}`);
|
|
393
|
+
}
|
|
339
394
|
}
|
|
340
395
|
|
|
341
396
|
// ===== 同步到MQTT =====
|
|
@@ -353,18 +408,86 @@ module.exports = function(RED) {
|
|
|
353
408
|
const typeInfo = node._brandProtocol.deviceTypes[deviceType];
|
|
354
409
|
if (!typeInfo) return;
|
|
355
410
|
|
|
356
|
-
//
|
|
357
|
-
let fn;
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
411
|
+
// 根据设备类型和属性确定功能码
|
|
412
|
+
let fn, fv;
|
|
413
|
+
|
|
414
|
+
// 窗帘设备特殊处理 (typeId=14)
|
|
415
|
+
if (deviceType === 14) {
|
|
416
|
+
if (property === 'curtainAction' || property === 'action') {
|
|
417
|
+
fn = 1;
|
|
418
|
+
if (value === 'open' || value === 1) fv = 1;
|
|
419
|
+
else if (value === 'close' || value === 0) fv = 0;
|
|
420
|
+
else if (value === 'stop' || value === 2) fv = 2;
|
|
421
|
+
else return;
|
|
422
|
+
} else if (property === 'position' || property === 'curtainPosition') {
|
|
423
|
+
fn = 2;
|
|
424
|
+
fv = parseInt(value) || 0;
|
|
425
|
+
} else {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// 灯具设备 (typeId=8)
|
|
430
|
+
else if (deviceType === 8) {
|
|
431
|
+
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
432
|
+
fn = 1;
|
|
433
|
+
fv = value ? 1 : 0;
|
|
434
|
+
} else if (property === 'brightness') {
|
|
435
|
+
fn = 2;
|
|
436
|
+
fv = parseInt(value) || 0;
|
|
437
|
+
} else {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// 空调设备 (typeId=12)
|
|
442
|
+
else if (deviceType === 12) {
|
|
443
|
+
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
444
|
+
fn = 1;
|
|
445
|
+
fv = value ? 1 : 0;
|
|
446
|
+
} else if (property === 'temperature' || property === 'targetTemp') {
|
|
447
|
+
fn = 2;
|
|
448
|
+
fv = parseInt(value) || 24;
|
|
449
|
+
} else if (property === 'mode' || property === 'hvacMode' || property === 'climateMode') {
|
|
450
|
+
fn = 3;
|
|
451
|
+
fv = AC_MODE_REVERSE[value] ?? 0;
|
|
452
|
+
} else if (property === 'fanSpeed' || property === 'fanMode') {
|
|
453
|
+
fn = 4;
|
|
454
|
+
fv = FAN_SPEED_REVERSE[value] ?? 0;
|
|
455
|
+
} else {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// 地暖设备 (typeId=16)
|
|
460
|
+
else if (deviceType === 16) {
|
|
461
|
+
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
462
|
+
fn = 1;
|
|
463
|
+
fv = value ? 1 : 0;
|
|
464
|
+
} else if (property === 'temperature' || property === 'targetTemp') {
|
|
465
|
+
fn = 2;
|
|
466
|
+
fv = parseInt(value) || 20;
|
|
467
|
+
} else {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// 新风设备 (typeId=36)
|
|
472
|
+
else if (deviceType === 36) {
|
|
473
|
+
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
474
|
+
fn = 1;
|
|
475
|
+
fv = value ? 1 : 0;
|
|
476
|
+
} else if (property === 'fanSpeed' || property === 'fanMode') {
|
|
477
|
+
fn = 3;
|
|
478
|
+
fv = FAN_SPEED_REVERSE[value] ?? 0;
|
|
479
|
+
} else {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// 其他设备类型的通用处理
|
|
484
|
+
else {
|
|
485
|
+
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
486
|
+
fn = 1;
|
|
487
|
+
fv = value ? 1 : 0;
|
|
488
|
+
} else {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
368
491
|
}
|
|
369
492
|
|
|
370
493
|
// 防死循环 - 检查两个方向的时间戳
|
|
@@ -381,19 +504,6 @@ module.exports = function(RED) {
|
|
|
381
504
|
node._syncTimestamps.set(meshSyncKey, now);
|
|
382
505
|
node._syncTimestamps.set(mqttSyncKey, now);
|
|
383
506
|
|
|
384
|
-
let fv;
|
|
385
|
-
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
386
|
-
fv = value ? 1 : 0;
|
|
387
|
-
} else if (property === 'brightness' || property === 'temperature' || property === 'position') {
|
|
388
|
-
fv = parseInt(value) || 0;
|
|
389
|
-
} else if (property === 'mode' || property === 'hvacMode') {
|
|
390
|
-
fv = AC_MODE_REVERSE[value] ?? 0;
|
|
391
|
-
} else if (property === 'fanSpeed' || property === 'fanMode') {
|
|
392
|
-
fv = FAN_SPEED_REVERSE[value] ?? 0;
|
|
393
|
-
} else {
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
507
|
const topic = node._brandProtocol.getDownTopic(node);
|
|
398
508
|
const payload = node._brandProtocol.buildMessage(deviceType, deviceId, fn, fv);
|
|
399
509
|
|
|
@@ -410,7 +520,9 @@ module.exports = function(RED) {
|
|
|
410
520
|
timestamp: Date.now()
|
|
411
521
|
}
|
|
412
522
|
});
|
|
413
|
-
} catch (e) {
|
|
523
|
+
} catch (e) {
|
|
524
|
+
node.warn(`[Mesh→Brand] 同步失败: ${e.message}`);
|
|
525
|
+
}
|
|
414
526
|
}
|
|
415
527
|
|
|
416
528
|
// ===== 网关事件监听 =====
|