node-red-contrib-symi-mesh 1.8.1 → 1.8.3
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 +171 -63
- package/nodes/symi-ha-sync.html +44 -2
- package/nodes/symi-ha-sync.js +263 -84
- package/nodes/symi-mqtt-sync.js +155 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -393,16 +393,47 @@ 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触发,避免信号震荡
|
|
401
401
|
4. **便捷配置**:
|
|
402
402
|
- 自动加载所有Symi设备和HA实体
|
|
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,98 @@ 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.`开头的实体(如按键点击事件),不会同步
|
|
458
|
+
|
|
459
|
+
#### 三合一面板配置
|
|
460
|
+
|
|
461
|
+
三合一面板(空调+新风+地暖)需要分别配置每个子设备的映射:
|
|
462
|
+
|
|
463
|
+
1. **选择三合一设备**:在Symi设备下拉框中选择三合一面板
|
|
464
|
+
2. **选择子设备**:在按键选择器中选择要同步的功能:
|
|
465
|
+
- **空调**:同步开关、温度、模式、风速
|
|
466
|
+
- **新风**:同步开关、风速
|
|
467
|
+
- **地暖**:同步开关、温度
|
|
468
|
+
3. **选择HA实体**:
|
|
469
|
+
- 空调 → climate实体
|
|
470
|
+
- 新风 → fan实体
|
|
471
|
+
- 地暖 → climate实体
|
|
472
|
+
|
|
473
|
+
**示例配置**:
|
|
474
|
+
```
|
|
475
|
+
三合一面板_xxx [空调] ↔ climate.living_room_ac
|
|
476
|
+
三合一面板_xxx [新风] ↔ fan.living_room_fresh_air
|
|
477
|
+
三合一面板_xxx [地暖] ↔ climate.living_room_floor_heating
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
#### 窗帘同步说明
|
|
481
|
+
|
|
482
|
+
窗帘设备采用特殊的同步机制,避免运动过程中的步进反馈干扰:
|
|
483
|
+
|
|
484
|
+
- **30秒防死循环窗口**:窗帘运动时间较长,使用30秒防死循环(普通设备2秒)
|
|
485
|
+
- **1.5秒防抖**:等待窗帘位置稳定后再同步,避免步进码干扰
|
|
486
|
+
- **运动状态跟踪**:记录运动发起方(Symi/HA),忽略对方的中间状态反馈
|
|
487
|
+
- **opening/closing过滤**:HA窗帘处于运动状态时,不同步位置变化到Mesh
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
### MQTT品牌同步
|
|
491
|
+
|
|
492
|
+
Symi MQTT同步节点 (`symi-mqtt-sync`) 用于实现第三方MQTT品牌设备与Symi Mesh设备的双向状态同步。
|
|
493
|
+
|
|
494
|
+
#### 功能特性
|
|
495
|
+
|
|
496
|
+
1. **双MQTT配置**:
|
|
497
|
+
- Mesh MQTT:连接Symi网关获取设备列表
|
|
498
|
+
- 品牌MQTT:连接第三方品牌MQTT服务器
|
|
499
|
+
2. **设备自动发现**:品牌MQTT连接后自动发现设备
|
|
500
|
+
3. **双向同步**:品牌设备↔Mesh设备实时状态同步
|
|
501
|
+
4. **配置持久化**:设备列表和映射配置持久保存,断线后仍可显示
|
|
502
|
+
|
|
503
|
+
#### 支持的品牌协议
|
|
504
|
+
|
|
505
|
+
| 品牌 | 设备类型 | 功能码映射 |
|
|
506
|
+
|------|---------|-----------|
|
|
507
|
+
| HYQW(花语前湾) | 灯具(8) | 开关(fn=1)、亮度(fn=2, 0-100) |
|
|
508
|
+
| | 空调(12) | 开关(fn=1)、温度(fn=2, 18-29°C)、模式(fn=3)、风速(fn=4) |
|
|
509
|
+
| | 窗帘(14) | 动作(fn=1, 开/关/停)、位置(fn=2, 0-100%) |
|
|
510
|
+
| | 地暖(16) | 开关(fn=1)、温度(fn=2, 5-35°C) |
|
|
511
|
+
| | 新风(36) | 开关(fn=1)、风速(fn=3) |
|
|
512
|
+
|
|
513
|
+
#### 配置步骤
|
|
514
|
+
|
|
515
|
+
1. **添加品牌MQTT配置节点**:
|
|
516
|
+
- 从左侧拖入`Symi MQTT Brand`配置节点
|
|
517
|
+
- 配置品牌MQTT服务器地址、用户名、密码
|
|
518
|
+
- 选择品牌协议(如HYQW)
|
|
519
|
+
|
|
520
|
+
2. **添加MQTT同步节点**:
|
|
521
|
+
- 从左侧拖入`Symi MQTT Sync`节点
|
|
522
|
+
- 选择Mesh MQTT配置(用于获取Mesh设备)
|
|
523
|
+
- 选择品牌MQTT配置(用于获取品牌设备)
|
|
524
|
+
|
|
525
|
+
3. **配置实体映射**:
|
|
526
|
+
- 点击"添加"按钮
|
|
527
|
+
- 左侧选择Mesh设备(多路开关可选择按键)
|
|
528
|
+
- 右侧选择品牌设备
|
|
529
|
+
- 可添加多组映射
|
|
530
|
+
|
|
531
|
+
4. **部署**:点击部署,开始双向同步
|
|
532
|
+
|
|
533
|
+
#### 注意事项
|
|
534
|
+
|
|
535
|
+
- **设备类型匹配**:建议同步相同类型的设备(灯具对灯具,空调对空调)
|
|
536
|
+
- **离线设备显示**:[离线]标记缓存中但当前不在线的设备
|
|
537
|
+
- **防死循环**:内置2秒防抖机制,避免状态震荡
|
|
538
|
+
- **自动重连**:5秒重连间隔,断线自动恢复
|
|
424
539
|
|
|
425
540
|
|
|
426
541
|
## 协议说明
|
|
@@ -1332,8 +1447,8 @@ A: 每个房间部署一个云端同步节点,配置对应的酒店ID和房间
|
|
|
1332
1447
|
| **Symi KNX Bridge** | KNX系统双向同步 | [KNX双向同步](#knx双向同步) |
|
|
1333
1448
|
| **Symi KNX-HA Bridge** | KNX与HA实体双向同步 | [KNX-HA双向同步](#knx-ha双向同步) |
|
|
1334
1449
|
| **Symi HA Sync** | HA实体双向同步 | [HA双向同步](#ha双向同步) |
|
|
1335
|
-
| **Symi MQTT Sync** | 第三方MQTT品牌设备同步 |
|
|
1336
|
-
| **Symi MQTT Brand** | 品牌MQTT配置节点 |
|
|
1450
|
+
| **Symi MQTT Sync** | 第三方MQTT品牌设备同步 | [MQTT品牌同步](#mqtt品牌同步) |
|
|
1451
|
+
| **Symi MQTT Brand** | 品牌MQTT配置节点 | [MQTT品牌同步](#mqtt品牌同步) |
|
|
1337
1452
|
| **RS485 Debug** | RS485总线数据抓包调试 | [RS485调试节点](#rs485调试节点) |
|
|
1338
1453
|
| **Symi RS485 Sync** | 两种RS485协议双向同步 | [RS485协议同步](#rs485协议同步) |
|
|
1339
1454
|
|
|
@@ -1420,6 +1535,28 @@ node-red-contrib-symi-mesh/
|
|
|
1420
1535
|
- 无TODO/FIXME/HACK标记
|
|
1421
1536
|
- 代码无重复,模块化设计
|
|
1422
1537
|
|
|
1538
|
+
## 更新日志
|
|
1539
|
+
|
|
1540
|
+
### v1.8.3 (2026-01-05)
|
|
1541
|
+
|
|
1542
|
+
**HA同步节点重大修复**:
|
|
1543
|
+
- 修复HA→Symi方向不工作的问题(input监听器未正确注册)
|
|
1544
|
+
- 支持多种HA节点消息格式(server-events、server-state-changed、trigger:state等)
|
|
1545
|
+
- 自动过滤event类型实体(如按键点击事件),避免无效同步
|
|
1546
|
+
- 优化状态显示:蓝色"Mesh→HA"表示单向,绿色"双向同步"表示双向正常
|
|
1547
|
+
- 精简日志输出,只记录映射中的实体状态变化
|
|
1548
|
+
- 更新HTML帮助文档,详细说明双向同步连接方式
|
|
1549
|
+
- 更新README文档,添加完整的HA同步配置指南
|
|
1550
|
+
|
|
1551
|
+
**连接方式说明**:
|
|
1552
|
+
- Symi→HA:自动工作,无需额外配置
|
|
1553
|
+
- HA→Symi:需连接`events: all`或`events: state`节点到symi-ha-sync输入端
|
|
1554
|
+
|
|
1555
|
+
### v1.8.2 (2025-12-xx)
|
|
1556
|
+
|
|
1557
|
+
- 窗帘和调光灯智能防抖,只同步最终状态
|
|
1558
|
+
- 三合一面板完整双向同步支持
|
|
1559
|
+
|
|
1423
1560
|
## 技术支持
|
|
1424
1561
|
|
|
1425
1562
|
如遇问题,请提供以下信息:
|
|
@@ -1503,6 +1640,33 @@ node-red-contrib-symi-mesh/
|
|
|
1503
1640
|
|
|
1504
1641
|
## 更新日志
|
|
1505
1642
|
|
|
1643
|
+
### v1.8.3 (2026-01-05)
|
|
1644
|
+
|
|
1645
|
+
**HA同步节点重大修复**:
|
|
1646
|
+
- 修复HA→Symi方向不工作的问题(input监听器未正确注册)
|
|
1647
|
+
- 支持多种HA节点消息格式(server-events、server-state-changed、trigger:state等)
|
|
1648
|
+
- 自动过滤event类型实体(如按键点击事件),避免无效同步
|
|
1649
|
+
- 优化状态显示:蓝色"Mesh→HA"表示单向,绿色"双向同步"表示双向正常
|
|
1650
|
+
- 精简日志输出,只记录映射中的实体状态变化
|
|
1651
|
+
- 更新HTML帮助文档,详细说明双向同步连接方式
|
|
1652
|
+
- 更新README文档,添加完整的HA同步配置指南
|
|
1653
|
+
|
|
1654
|
+
**连接方式说明**:
|
|
1655
|
+
- Symi→HA:自动工作,无需额外配置
|
|
1656
|
+
- HA→Symi:需连接`events: all`或`events: state`节点到symi-ha-sync输入端
|
|
1657
|
+
|
|
1658
|
+
### v1.8.2 (2026-01-05)
|
|
1659
|
+
- **MQTT品牌同步协议修复**:
|
|
1660
|
+
- **窗帘设备协议修复**:修复窗帘fn=1功能码的正确解析(0=关闭, 1=打开, 2=停止),之前错误地当作开关处理
|
|
1661
|
+
- **设备类型独立处理**:重构syncToMesh和syncToMqtt函数,按设备类型(8/12/14/16/36)独立处理功能码映射
|
|
1662
|
+
- **完整功能码支持**:
|
|
1663
|
+
- 灯具(8): fn=1开关, fn=2亮度
|
|
1664
|
+
- 空调(12): fn=1开关, fn=2温度, fn=3模式, fn=4风速
|
|
1665
|
+
- 窗帘(14): fn=1动作(开/关/停), fn=2位置
|
|
1666
|
+
- 地暖(16): fn=1开关, fn=2温度
|
|
1667
|
+
- 新风(36): fn=1开关, fn=3风速
|
|
1668
|
+
- **错误日志增强**:同步失败时输出详细错误信息,便于调试
|
|
1669
|
+
|
|
1506
1670
|
### v1.8.1 (2026-01-05)
|
|
1507
1671
|
- **三合一面板深度集成**:
|
|
1508
1672
|
- **子设备选择**:HA同步节点新增三合一子设备选择功能,可分别选择“空调”、“新风”、“地暖”进行独立映射。
|
|
@@ -1533,62 +1697,6 @@ node-red-contrib-symi-mesh/
|
|
|
1533
1697
|
- 优化了双向同步的防死循环逻辑,减少了在高频触发场景下的 CPU 占用。
|
|
1534
1698
|
- 修复了 MQTT 配置下拉框在节点编辑面板打开时偶尔出现的加载卡顿问题。
|
|
1535
1699
|
|
|
1536
|
-
### v1.7.9 (2026-01-05)
|
|
1537
|
-
- **HA同步节点UI修复**:修复添加映射按钮不显示选择界面的问题
|
|
1538
|
-
- 修复`renderMappings()`函数中的数组检查逻辑
|
|
1539
|
-
- 修复`mergeDevices()`函数中的空值检查
|
|
1540
|
-
- 使用`entityType`字段判断设备类型(climate/cover/light不显示按键选择)
|
|
1541
|
-
- 添加设备类型标签显示:[温控器]、[窗帘]、[灯具]、[N路开关]、[单路开关]
|
|
1542
|
-
- 添加错误捕获和日志输出,便于调试
|
|
1543
|
-
|
|
1544
|
-
### v1.7.8 (2026-01-05)
|
|
1545
|
-
- **配置持久化增强**:所有同步节点的设备列表和映射配置持久保存
|
|
1546
|
-
- **MQTT同步节点**:Mesh设备和品牌设备列表持久化,断线后仍可显示已配置的映射
|
|
1547
|
-
- **HA同步节点**:Symi设备和HA实体列表持久化,断线后仍可显示已配置的映射
|
|
1548
|
-
- **RS485桥接节点**:Mesh设备列表持久化,断线后仍可显示已配置的映射
|
|
1549
|
-
- **离线设备显示**:[离线]标记缓存中但当前不在线的设备,[未找到]标记不在缓存中的设备
|
|
1550
|
-
- **刷新按钮**:各节点添加独立刷新按钮,可手动刷新设备列表
|
|
1551
|
-
- **MQTT品牌同步增强**:完整对接HYQW(花语前湾)MQTT协议
|
|
1552
|
-
- **完整设备类型支持**:灯具(8)、空调(12)、窗帘(14)、地暖(16)、新风(36)
|
|
1553
|
-
- **功能码映射**:完整的fn/fv到Mesh属性双向转换
|
|
1554
|
-
- 灯具:开关(fn=1)、亮度(fn=2, 0-100)
|
|
1555
|
-
- 空调:开关(fn=1)、温度(fn=2, 18-29°C)、模式(fn=3)、风速(fn=4)
|
|
1556
|
-
- 窗帘:动作(fn=1, 开/关/停)、位置(fn=2, 0-100%)
|
|
1557
|
-
- 地暖:开关(fn=1)、温度(fn=2, 5-35°C)
|
|
1558
|
-
- 新风:开关(fn=1)、风速(fn=3)
|
|
1559
|
-
- **可扩展架构**:BRAND_PROTOCOLS对象支持添加新品牌协议
|
|
1560
|
-
- **双向状态同步**:HYQW↔Mesh实时同步,2秒防抖防死循环
|
|
1561
|
-
- **自动设备发现**:自动发现品牌MQTT设备,限制200个
|
|
1562
|
-
- **错误日志限流**:每60秒最多记录一次错误,避免日志爆炸
|
|
1563
|
-
- **自动重连**:5秒重连间隔,断线自动恢复
|
|
1564
|
-
- **资源清理**:完善的定时器和事件监听器清理机制
|
|
1565
|
-
- **HA同步节点完整重构**:实现所有实体类型的完美双向同步
|
|
1566
|
-
- **完整实体类型支持**:
|
|
1567
|
-
- switch/input_boolean:开关状态 (on/off)
|
|
1568
|
-
- light:开关 + 亮度 (0-255 ↔ 0-100)
|
|
1569
|
-
- cover:开/关/停 + 位置 (0-100%)
|
|
1570
|
-
- climate:开关 + 温度 + 模式(cool/heat/fan_only/dry) + 风速(high/medium/low/auto)
|
|
1571
|
-
- fan:开关 + 风速
|
|
1572
|
-
- **Mesh属性完整映射**:
|
|
1573
|
-
- 0x02 开关状态(单路/多路)
|
|
1574
|
-
- 0x03 亮度 (0-100)
|
|
1575
|
-
- 0x05 窗帘运行状态
|
|
1576
|
-
- 0x06 窗帘位置 (0-100)
|
|
1577
|
-
- 0x1B 目标温度 (16-30°C)
|
|
1578
|
-
- 0x1C 风速 (1=高/2=中/3=低/4=自动)
|
|
1579
|
-
- 0x1D 空调模式 (1=制冷/2=制热/3=送风/4=除湿)
|
|
1580
|
-
- **智能按键选择**:只有多路开关才显示按键选择,温控器/窗帘/调光灯显示"-"
|
|
1581
|
-
- **设备类型标签**:自动识别并显示设备类型([温控器]、[窗帘]、[调光灯]、[N路开关])
|
|
1582
|
-
- **智能防抖机制**:窗帘和调光灯使用500ms防抖,只同步最终位置/亮度
|
|
1583
|
-
- **防死循环增强**:2秒冷却时间 + 双向时间戳检查
|
|
1584
|
-
- **配置持久化**:设备列表和映射配置持久保存
|
|
1585
|
-
- **内存泄漏防护**:定时清理过期时间戳和防抖定时器
|
|
1586
|
-
- **代码质量优化**:
|
|
1587
|
-
- 同步时间戳Map自动清理60秒以上的条目
|
|
1588
|
-
- 设备发现数量限制,防止内存溢出
|
|
1589
|
-
- 完善的节点关闭清理逻辑
|
|
1590
|
-
- 防死循环机制增强:双向时间戳检查
|
|
1591
|
-
|
|
1592
1700
|
## 许可证
|
|
1593
1701
|
|
|
1594
1702
|
MIT License
|
|
@@ -1600,7 +1708,7 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
1600
1708
|
## 关于
|
|
1601
1709
|
|
|
1602
1710
|
**作者**: SYMI 亖米
|
|
1603
|
-
**版本**: 1.8.
|
|
1711
|
+
**版本**: 1.8.3
|
|
1604
1712
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
1605
1713
|
**最后更新**: 2026-01-05
|
|
1606
1714
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
package/nodes/symi-ha-sync.html
CHANGED
|
@@ -477,11 +477,44 @@
|
|
|
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
|
|
484
|
+
<li><strong>智能防抖</strong>:窗帘/调光灯只同步最终位置,过程状态不同步</li>
|
|
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>
|
|
485
518
|
</ul>
|
|
486
519
|
|
|
487
520
|
<h3>支持的设备类型</h3>
|
|
@@ -490,6 +523,15 @@
|
|
|
490
523
|
<li><strong>调光灯</strong>:开关 + 亮度 (0-100)</li>
|
|
491
524
|
<li><strong>窗帘</strong>:开/关/停 + 位置 (0-100%)</li>
|
|
492
525
|
<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>
|
|
493
535
|
</ul>
|
|
494
536
|
|
|
495
537
|
<h3>离线设备显示</h3>
|
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.3
|
|
4
4
|
*
|
|
5
5
|
* 支持的实体类型和属性:
|
|
6
6
|
* - light: on/off, brightness (0-255)
|
|
@@ -15,7 +15,7 @@ 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;
|
|
@@ -23,6 +23,9 @@ module.exports = function(RED) {
|
|
|
23
23
|
// 窗帘专用常量
|
|
24
24
|
const COVER_LOOP_PREVENTION_MS = 30000; // 30秒防死循环
|
|
25
25
|
const COVER_DEBOUNCE_MS = 1500; // 1.5秒防抖
|
|
26
|
+
|
|
27
|
+
// 调光专用常量
|
|
28
|
+
const BRIGHTNESS_DEBOUNCE_MS = 800; // 0.8秒防抖,过滤步进过程
|
|
26
29
|
|
|
27
30
|
// Mesh属性类型
|
|
28
31
|
const ATTR_SWITCH = 0x02;
|
|
@@ -92,28 +95,49 @@ module.exports = function(RED) {
|
|
|
92
95
|
node.lastHaToSymi = {};
|
|
93
96
|
node.pendingDebounce = {}; // 防抖定时器
|
|
94
97
|
node.coverMoving = {}; // 窗帘运动状态跟踪 { loopKey: { direction: 'symi'|'ha', startTime, targetPosition } }
|
|
98
|
+
node.brightnessMoving = {}; // 调光运动状态跟踪 { loopKey: { direction: 'ha', startTime } }
|
|
99
|
+
node.haInputReceived = false; // 是否收到过HA输入
|
|
95
100
|
|
|
96
101
|
node.status({ fill: 'yellow', shape: 'ring', text: '初始化中' });
|
|
102
|
+
|
|
103
|
+
// 更新状态显示的辅助函数
|
|
104
|
+
node.updateStatus = function() {
|
|
105
|
+
if (configError) {
|
|
106
|
+
node.status({ fill: 'red', shape: 'ring', text: configError });
|
|
107
|
+
} else if (node.haInputReceived) {
|
|
108
|
+
node.status({ fill: 'green', shape: 'dot', text: `双向同步 (${node.mappings.length}组)` });
|
|
109
|
+
} else if (gateway) {
|
|
110
|
+
node.status({ fill: 'blue', shape: 'dot', text: `Mesh→HA (${node.mappings.length}组)` });
|
|
111
|
+
} else {
|
|
112
|
+
node.status({ fill: 'yellow', shape: 'ring', text: '等待连接' });
|
|
113
|
+
}
|
|
114
|
+
};
|
|
97
115
|
|
|
98
|
-
// 检查配置
|
|
116
|
+
// 检查配置 - 但不要直接return,允许input监听器注册
|
|
117
|
+
let gateway = null;
|
|
118
|
+
let configError = null;
|
|
119
|
+
|
|
99
120
|
if (!node.mqttNode) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
configError = '未配置MQTT节点';
|
|
122
|
+
} else if (!node.haServer) {
|
|
123
|
+
configError = '未配置HA服务器';
|
|
124
|
+
} else {
|
|
125
|
+
// 获取Gateway引用
|
|
126
|
+
gateway = node.mqttNode.gateway;
|
|
127
|
+
if (!gateway) {
|
|
128
|
+
configError = 'MQTT节点未关联网关';
|
|
129
|
+
}
|
|
106
130
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
131
|
+
|
|
132
|
+
if (configError) {
|
|
133
|
+
node.status({ fill: 'red', shape: 'ring', text: configError });
|
|
134
|
+
node.warn(`[HA同步] ${configError}`);
|
|
135
|
+
// 不要return,继续注册input监听器
|
|
136
|
+
} else {
|
|
137
|
+
// 初始状态:只有Mesh→HA方向,等待HA输入连接
|
|
138
|
+
node.updateStatus();
|
|
113
139
|
}
|
|
114
140
|
|
|
115
|
-
node.status({ fill: 'green', shape: 'dot', text: `运行中 (${node.mappings.length}组)` });
|
|
116
|
-
|
|
117
141
|
// 定期清理过期的防抖时间戳
|
|
118
142
|
node.cleanupInterval = setInterval(function() {
|
|
119
143
|
const now = Date.now();
|
|
@@ -130,10 +154,27 @@ module.exports = function(RED) {
|
|
|
130
154
|
|
|
131
155
|
// 清理过期的窗帘运动状态
|
|
132
156
|
for (const key in node.coverMoving) {
|
|
133
|
-
if (now - node.coverMoving[key].
|
|
157
|
+
if (now - node.coverMoving[key].startTime > COVER_LOOP_PREVENTION_MS) {
|
|
134
158
|
delete node.coverMoving[key];
|
|
135
159
|
}
|
|
136
160
|
}
|
|
161
|
+
|
|
162
|
+
// 清理过期的调光运动状态
|
|
163
|
+
for (const key in node.brightnessMoving) {
|
|
164
|
+
if (now - node.brightnessMoving[key].startTime > LOOP_PREVENTION_MS) {
|
|
165
|
+
delete node.brightnessMoving[key];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 尝试重新获取gateway(如果之前没有)
|
|
170
|
+
if (!gateway && node.mqttNode && node.mqttNode.gateway) {
|
|
171
|
+
gateway = node.mqttNode.gateway;
|
|
172
|
+
if (gateway) {
|
|
173
|
+
node.log('[HA同步] 网关已就绪,注册设备状态监听');
|
|
174
|
+
gateway.on('device-state-changed', node.handleSymiStateChange);
|
|
175
|
+
node.status({ fill: 'green', shape: 'dot', text: `运行中 (${node.mappings.length}组)` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
137
178
|
}, CLEANUP_INTERVAL_MS);
|
|
138
179
|
|
|
139
180
|
// 防死循环检查 - 双向时间戳检查
|
|
@@ -367,14 +408,47 @@ module.exports = function(RED) {
|
|
|
367
408
|
return { type: 'switch', value: isOn };
|
|
368
409
|
};
|
|
369
410
|
|
|
370
|
-
//
|
|
411
|
+
// 处理亮度变化(带防抖,避免步进过程干扰)
|
|
371
412
|
node.handleBrightnessChange = function(device, mapping, state) {
|
|
372
413
|
const brightness = state.brightness !== undefined ? state.brightness : device.state.brightness;
|
|
373
414
|
if (brightness === undefined) return null;
|
|
374
415
|
|
|
416
|
+
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
417
|
+
|
|
418
|
+
// 检查是否是HA发起的调光,如果是则忽略Mesh的亮度反馈(步进码)
|
|
419
|
+
if (node.brightnessMoving && node.brightnessMoving[loopKey]) {
|
|
420
|
+
const elapsed = Date.now() - node.brightnessMoving[loopKey].startTime;
|
|
421
|
+
if (elapsed < LOOP_PREVENTION_MS) {
|
|
422
|
+
node.debug(`[Symi->HA] 亮度忽略(HA发起调光中): ${loopKey}`);
|
|
423
|
+
return null;
|
|
424
|
+
} else {
|
|
425
|
+
delete node.brightnessMoving[loopKey];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 防抖处理:取消之前的定时器,设置新的
|
|
430
|
+
const debounceKey = `brightness_${mapping.symiMac}_${mapping.symiKey}`;
|
|
431
|
+
if (node.pendingDebounce[debounceKey]) {
|
|
432
|
+
clearTimeout(node.pendingDebounce[debounceKey]);
|
|
433
|
+
}
|
|
434
|
+
|
|
375
435
|
// Mesh亮度0-100,HA亮度0-255
|
|
376
436
|
const haBrightness = Math.round(brightness * 255 / 100);
|
|
377
|
-
|
|
437
|
+
|
|
438
|
+
// 延迟同步,等待亮度稳定(过滤步进过程)
|
|
439
|
+
node.pendingDebounce[debounceKey] = setTimeout(() => {
|
|
440
|
+
delete node.pendingDebounce[debounceKey];
|
|
441
|
+
|
|
442
|
+
node.queueCommand({
|
|
443
|
+
direction: 'symi-to-ha',
|
|
444
|
+
mapping: mapping,
|
|
445
|
+
syncData: { type: 'brightness', value: haBrightness, meshValue: brightness },
|
|
446
|
+
key: loopKey,
|
|
447
|
+
skipLoopCheck: true
|
|
448
|
+
});
|
|
449
|
+
}, BRIGHTNESS_DEBOUNCE_MS);
|
|
450
|
+
|
|
451
|
+
return null; // 不立即同步,由定时器处理
|
|
378
452
|
};
|
|
379
453
|
|
|
380
454
|
// 处理窗帘变化(带防抖,避免步进反馈干扰)
|
|
@@ -466,46 +540,157 @@ module.exports = function(RED) {
|
|
|
466
540
|
return { type: 'hvac_mode', value: haMode, meshValue: mode };
|
|
467
541
|
};
|
|
468
542
|
|
|
469
|
-
//
|
|
470
|
-
gateway
|
|
543
|
+
// 监听网关事件(如果gateway可用)
|
|
544
|
+
if (gateway) {
|
|
545
|
+
gateway.on('device-state-changed', node.handleSymiStateChange);
|
|
546
|
+
}
|
|
471
547
|
|
|
472
548
|
// ========== 2. 监听HA状态变化 (HA -> Symi) ==========
|
|
473
549
|
|
|
474
|
-
// 方式A: 通过Input输入 (
|
|
550
|
+
// 方式A: 通过Input输入 (支持多种HA节点格式)
|
|
475
551
|
node.on('input', function(msg) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
552
|
+
// 首次收到HA输入时更新状态
|
|
553
|
+
if (!node.haInputReceived) {
|
|
554
|
+
node.haInputReceived = true;
|
|
555
|
+
node.updateStatus();
|
|
556
|
+
node.log('[HA同步] 已收到HA输入,双向同步已启用');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// 支持多种消息格式
|
|
560
|
+
let entityId, newState, oldState;
|
|
561
|
+
|
|
562
|
+
// 格式1: server-events 节点格式 (msg.payload.event_type + msg.payload.event)
|
|
563
|
+
// 实际格式: { event_type: "state_changed", entity_id: "...", event: { entity_id, new_state, old_state } }
|
|
564
|
+
if (msg.payload && msg.payload.event_type === 'state_changed') {
|
|
565
|
+
entityId = msg.payload.entity_id;
|
|
566
|
+
// event 直接包含 new_state 和 old_state(不是 event.data)
|
|
567
|
+
if (msg.payload.event) {
|
|
568
|
+
newState = msg.payload.event.new_state;
|
|
569
|
+
oldState = msg.payload.event.old_state;
|
|
483
570
|
}
|
|
484
|
-
} else if (msg.data && msg.data.entity_id && msg.data.new_state) {
|
|
485
|
-
// 兼容旧格式
|
|
486
|
-
node.handleHaStateChange(msg.data.entity_id, msg.data.new_state, msg.data.old_state);
|
|
487
571
|
}
|
|
572
|
+
// 格式2: server-state-changed 节点的标准格式 (msg.data)
|
|
573
|
+
else if (msg.data && msg.data.entity_id) {
|
|
574
|
+
entityId = msg.data.entity_id;
|
|
575
|
+
newState = msg.data.new_state;
|
|
576
|
+
oldState = msg.data.old_state;
|
|
577
|
+
}
|
|
578
|
+
// 格式3: payload 中直接包含 entity_id 和 new_state
|
|
579
|
+
else if (msg.payload && msg.payload.entity_id && msg.payload.new_state) {
|
|
580
|
+
entityId = msg.payload.entity_id;
|
|
581
|
+
newState = msg.payload.new_state;
|
|
582
|
+
oldState = msg.payload.old_state;
|
|
583
|
+
}
|
|
584
|
+
// 格式4: 直接的 event 数据 (msg.event)
|
|
585
|
+
else if (msg.event && msg.event.new_state) {
|
|
586
|
+
entityId = msg.event.entity_id;
|
|
587
|
+
newState = msg.event.new_state;
|
|
588
|
+
oldState = msg.event.old_state;
|
|
589
|
+
}
|
|
590
|
+
// 格式5: trigger: state 节点格式 (msg.payload 是状态对象)
|
|
591
|
+
else if (msg.payload && typeof msg.payload === 'object' &&
|
|
592
|
+
msg.payload.state !== undefined && msg.payload.attributes !== undefined) {
|
|
593
|
+
entityId = msg.topic || msg.payload.entity_id;
|
|
594
|
+
newState = msg.payload;
|
|
595
|
+
oldState = null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 过滤非 state_changed 事件和无效数据
|
|
599
|
+
if (!entityId || !newState) {
|
|
600
|
+
return; // 静默忽略无法解析的消息
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 过滤 event 类型的实体(如 event.xxx_click_e_xxx),这些不需要同步
|
|
604
|
+
if (entityId.startsWith('event.')) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 检查是否在映射列表中
|
|
609
|
+
const mappings = node.findMappingsByHa(entityId);
|
|
610
|
+
if (mappings.length === 0) {
|
|
611
|
+
return; // 不在映射中的实体静默忽略
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// 只记录映射中的实体状态变化
|
|
615
|
+
node.log(`[HA->Symi] ${entityId}: ${oldState?.state || 'null'} -> ${newState.state}`);
|
|
616
|
+
node.handleHaStateChange(entityId, newState, oldState);
|
|
488
617
|
});
|
|
489
618
|
|
|
490
|
-
// 方式B: 尝试订阅HA Server事件总线
|
|
491
|
-
|
|
492
|
-
node.
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
619
|
+
// 方式B: 尝试订阅HA Server事件总线 (备用方式,大多数情况不可用)
|
|
620
|
+
const subscribeToHaEvents = () => {
|
|
621
|
+
if (!node.haServer) {
|
|
622
|
+
// 静默处理,不显示警告
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 尝试多种方式获取事件总线
|
|
627
|
+
let eventBus = null;
|
|
628
|
+
|
|
629
|
+
// 方式1: 直接访问 eventBus
|
|
630
|
+
if (node.haServer.eventBus) {
|
|
631
|
+
eventBus = node.haServer.eventBus;
|
|
632
|
+
}
|
|
633
|
+
// 方式2: 通过 controller
|
|
634
|
+
else if (node.haServer.controller && node.haServer.controller.events) {
|
|
635
|
+
eventBus = node.haServer.controller.events;
|
|
636
|
+
}
|
|
637
|
+
// 方式3: 通过 websocket
|
|
638
|
+
else if (node.haServer.websocket && node.haServer.websocket.eventBus) {
|
|
639
|
+
eventBus = node.haServer.websocket.eventBus;
|
|
640
|
+
}
|
|
641
|
+
// 方式4: 检查 _events
|
|
642
|
+
else if (node.haServer._events) {
|
|
643
|
+
eventBus = node.haServer;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (eventBus && typeof eventBus.on === 'function') {
|
|
647
|
+
// 防止重复订阅
|
|
648
|
+
if (node.haSubscribed) return;
|
|
649
|
+
|
|
650
|
+
node.haEventHandler = (evt) => {
|
|
651
|
+
if (evt && evt.event_type === 'state_changed' && evt.data) {
|
|
652
|
+
if (!node.haInputReceived) {
|
|
653
|
+
node.haInputReceived = true;
|
|
654
|
+
node.updateStatus();
|
|
655
|
+
}
|
|
656
|
+
node.handleHaStateChange(evt.data.entity_id, evt.data.new_state, evt.data.old_state);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// 尝试订阅不同的事件名
|
|
661
|
+
try {
|
|
662
|
+
eventBus.on('ha_events:all', node.haEventHandler);
|
|
663
|
+
node.haSubscribed = true;
|
|
664
|
+
node.haInputReceived = true;
|
|
665
|
+
node.log('[HA同步] 已成功订阅HA事件总线');
|
|
666
|
+
node.updateStatus();
|
|
667
|
+
} catch (e1) {
|
|
668
|
+
try {
|
|
669
|
+
eventBus.on('state_changed', node.haEventHandler);
|
|
670
|
+
node.haSubscribed = true;
|
|
671
|
+
node.haInputReceived = true;
|
|
672
|
+
node.log('[HA同步] 已成功订阅HA事件总线');
|
|
673
|
+
node.updateStatus();
|
|
674
|
+
} catch (e2) {
|
|
675
|
+
// 静默失败
|
|
676
|
+
}
|
|
496
677
|
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
678
|
+
}
|
|
679
|
+
// 事件总线不可用时不再重试,依赖 input 方式
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
// 延迟启动订阅尝试,给HA服务器时间初始化
|
|
683
|
+
setTimeout(subscribeToHaEvents, 3000);
|
|
503
684
|
|
|
504
685
|
node.handleHaStateChange = function(entityId, newState, oldState) {
|
|
505
|
-
if (!newState)
|
|
686
|
+
if (!newState) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
506
689
|
|
|
507
690
|
const mappings = node.findMappingsByHa(entityId);
|
|
508
|
-
if (mappings.length === 0)
|
|
691
|
+
if (mappings.length === 0) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
509
694
|
|
|
510
695
|
const domain = node.getEntityDomain(entityId);
|
|
511
696
|
const attrs = newState.attributes || {};
|
|
@@ -545,6 +730,8 @@ module.exports = function(RED) {
|
|
|
545
730
|
if (!oldState || oldAttrs.brightness !== attrs.brightness) {
|
|
546
731
|
// HA亮度0-255,Mesh亮度0-100
|
|
547
732
|
const meshBrightness = Math.round(attrs.brightness * 100 / 255);
|
|
733
|
+
// 标记HA发起的调光,防止Mesh步进反馈回传
|
|
734
|
+
node.brightnessMoving[loopKey] = { direction: 'ha', startTime: Date.now() };
|
|
548
735
|
syncDataList.push({ type: 'brightness', value: meshBrightness });
|
|
549
736
|
}
|
|
550
737
|
}
|
|
@@ -795,22 +982,6 @@ module.exports = function(RED) {
|
|
|
795
982
|
|
|
796
983
|
node.log(`[Symi->HA] ${mapping.symiName || mapping.symiMac} -> ${mapping.haEntityId}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
797
984
|
|
|
798
|
-
// 仅输出到debug,不再重复send
|
|
799
|
-
/*
|
|
800
|
-
node.send({
|
|
801
|
-
topic: 'ha-sync/symi-to-ha',
|
|
802
|
-
payload: {
|
|
803
|
-
direction: 'Symi→HA',
|
|
804
|
-
symiMac: mapping.symiMac,
|
|
805
|
-
symiKey: mapping.symiKey,
|
|
806
|
-
haEntityId: mapping.haEntityId,
|
|
807
|
-
syncType: syncData.type,
|
|
808
|
-
value: syncData.value,
|
|
809
|
-
timestamp: Date.now()
|
|
810
|
-
}
|
|
811
|
-
});
|
|
812
|
-
*/
|
|
813
|
-
|
|
814
985
|
} catch (err) {
|
|
815
986
|
node.error(`[Symi->HA] 调用失败: ${err.message}`);
|
|
816
987
|
}
|
|
@@ -821,7 +992,14 @@ module.exports = function(RED) {
|
|
|
821
992
|
const { mapping, syncData, key } = cmd;
|
|
822
993
|
node.recordSyncTime('ha-to-symi', key);
|
|
823
994
|
|
|
824
|
-
|
|
995
|
+
// 动态获取gateway(可能在初始化后才可用)
|
|
996
|
+
const currentGateway = gateway || (node.mqttNode && node.mqttNode.gateway);
|
|
997
|
+
if (!currentGateway) {
|
|
998
|
+
node.warn(`[HA->Symi] 网关未就绪,无法控制设备`);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const device = currentGateway.getDevice(mapping.symiMac);
|
|
825
1003
|
if (!device) {
|
|
826
1004
|
node.warn(`[HA->Symi] 设备未找到: ${mapping.symiMac}`);
|
|
827
1005
|
return;
|
|
@@ -831,6 +1009,16 @@ module.exports = function(RED) {
|
|
|
831
1009
|
const channels = device.channels;
|
|
832
1010
|
const targetChannel = mapping.symiKey;
|
|
833
1011
|
|
|
1012
|
+
// 检查是否是三合一设备(symiKey为字符串类型)
|
|
1013
|
+
const isThreeInOne = typeof targetChannel === 'string' &&
|
|
1014
|
+
['aircon', 'fresh_air', 'floor_heating'].includes(targetChannel);
|
|
1015
|
+
|
|
1016
|
+
if (isThreeInOne) {
|
|
1017
|
+
// 三合一设备使用专门的控制函数
|
|
1018
|
+
await node.syncThreeInOne(cmd, device, networkAddr);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
834
1022
|
try {
|
|
835
1023
|
let attrType, param;
|
|
836
1024
|
|
|
@@ -877,27 +1065,10 @@ module.exports = function(RED) {
|
|
|
877
1065
|
return;
|
|
878
1066
|
}
|
|
879
1067
|
|
|
880
|
-
await
|
|
1068
|
+
await currentGateway.sendControl(networkAddr, attrType, param);
|
|
881
1069
|
|
|
882
1070
|
node.log(`[HA->Symi] ${mapping.haEntityId} -> ${device.name || mapping.symiMac}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
883
1071
|
|
|
884
|
-
// 仅输出到debug,不再重复send
|
|
885
|
-
/*
|
|
886
|
-
node.send({
|
|
887
|
-
topic: 'ha-sync/ha-to-symi',
|
|
888
|
-
payload: {
|
|
889
|
-
direction: 'HA→Symi',
|
|
890
|
-
haEntityId: mapping.haEntityId,
|
|
891
|
-
symiMac: mapping.symiMac,
|
|
892
|
-
symiKey: mapping.symiKey,
|
|
893
|
-
syncType: syncData.type,
|
|
894
|
-
value: syncData.value,
|
|
895
|
-
attrType: attrType,
|
|
896
|
-
timestamp: Date.now()
|
|
897
|
-
}
|
|
898
|
-
});
|
|
899
|
-
*/
|
|
900
|
-
|
|
901
1072
|
} catch (err) {
|
|
902
1073
|
node.error(`[HA->Symi] 控制失败: ${err.message}`);
|
|
903
1074
|
}
|
|
@@ -908,6 +1079,13 @@ module.exports = function(RED) {
|
|
|
908
1079
|
const { mapping, syncData } = cmd;
|
|
909
1080
|
const subType = mapping.symiKey;
|
|
910
1081
|
|
|
1082
|
+
// 动态获取gateway
|
|
1083
|
+
const currentGateway = gateway || (node.mqttNode && node.mqttNode.gateway);
|
|
1084
|
+
if (!currentGateway) {
|
|
1085
|
+
node.warn(`[HA->Symi] 网关未就绪,无法控制三合一设备`);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
911
1089
|
try {
|
|
912
1090
|
let attrType, param;
|
|
913
1091
|
|
|
@@ -963,7 +1141,7 @@ module.exports = function(RED) {
|
|
|
963
1141
|
}
|
|
964
1142
|
|
|
965
1143
|
if (attrType && param) {
|
|
966
|
-
await
|
|
1144
|
+
await currentGateway.sendControl(networkAddr, attrType, param);
|
|
967
1145
|
node.log(`[HA->Symi] ${mapping.haEntityId} -> ${mapping.symiName}(${subType}): ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
968
1146
|
}
|
|
969
1147
|
|
|
@@ -982,7 +1160,8 @@ module.exports = function(RED) {
|
|
|
982
1160
|
clearTimeout(node.pendingDebounce[key]);
|
|
983
1161
|
}
|
|
984
1162
|
node.pendingDebounce = {};
|
|
985
|
-
node.coverMoving = {};
|
|
1163
|
+
node.coverMoving = {}; // 清理窗帘运动状态
|
|
1164
|
+
node.brightnessMoving = {}; // 清理调光运动状态
|
|
986
1165
|
|
|
987
1166
|
if (gateway) {
|
|
988
1167
|
gateway.removeListener('device-state-changed', node.handleSymiStateChange);
|
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
|
// ===== 网关事件监听 =====
|