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 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
- - 内置800ms冷却机制
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品牌设备同步 | v1.7.3新增 |
1336
- | **Symi MQTT Brand** | 品牌MQTT配置节点 | v1.7.3新增 |
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.1
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
@@ -477,11 +477,44 @@
477
477
  <h3>功能特性</h3>
478
478
  <ul>
479
479
  <li><strong>完美双向同步</strong>:Symi↔HA实时状态同步</li>
480
- <li><strong>多设备类型支持</strong>:开关、调光灯、窗帘、温控器/空调</li>
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>:窗帘/调光灯只同步最终位置</li>
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>
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
3
- * 版本: 1.8.1
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
- node.status({ fill: 'red', shape: 'ring', text: '未配置MQTT节点' });
101
- return;
102
- }
103
- if (!node.haServer) {
104
- node.status({ fill: 'red', shape: 'ring', text: '未配置HA服务器' });
105
- return;
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
- // 获取Gateway引用
109
- const gateway = node.mqttNode.gateway;
110
- if (!gateway) {
111
- node.status({ fill: 'red', shape: 'ring', text: 'MQTT节点未关联网关' });
112
- return;
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].timestamp > COVER_LOOP_PREVENTION_MS) {
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
- return { type: 'brightness', value: haBrightness, meshValue: brightness };
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.on('device-state-changed', node.handleSymiStateChange);
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输入 (server-state-changed节点)
550
+ // 方式A: 通过Input输入 (支持多种HA节点格式)
475
551
  node.on('input', function(msg) {
476
- if (msg.payload && (msg.payload.entity_id || (msg.data && msg.data.entity_id))) {
477
- const entityId = msg.payload.entity_id || msg.data.entity_id;
478
- const newState = msg.payload.new_state || msg.data.new_state;
479
- const oldState = msg.payload.old_state || msg.data.old_state;
480
-
481
- if (entityId && newState) {
482
- node.handleHaStateChange(entityId, newState, oldState);
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
- if (node.haServer && node.haServer.eventBus) {
492
- node.haEventHandler = (evt) => {
493
- // node.debug(`[HA事件] type=${evt.event_type}, data=${JSON.stringify(evt.data)}`);
494
- if (evt && evt.event_type === 'state_changed' && evt.data) {
495
- node.handleHaStateChange(evt.data.entity_id, evt.data.new_state, evt.data.old_state);
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
- node.haServer.eventBus.on('ha_events:all', node.haEventHandler);
499
- node.log('[HA同步] 已订阅HA事件总线');
500
- } else {
501
- node.warn('[HA同步] 未能订阅HA事件总线,请确保HA节点配置正确且已连接');
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) return;
686
+ if (!newState) {
687
+ return;
688
+ }
506
689
 
507
690
  const mappings = node.findMappingsByHa(entityId);
508
- if (mappings.length === 0) return;
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
- const device = gateway.getDevice(mapping.symiMac);
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 gateway.sendControl(networkAddr, attrType, param);
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 gateway.sendControl(networkAddr, attrType, param);
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);
@@ -293,33 +293,86 @@ module.exports = function(RED) {
293
293
 
294
294
  let property = '', value = null;
295
295
  try {
296
- if (fn === 1) {
297
- property = 'switch'; value = fv === 1;
298
- node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'switch', fv === 1);
299
- } else if (fn === 2) {
300
- property = typeInfo.meshType === 'light' ? 'brightness' :
301
- typeInfo.meshType === 'cover' ? 'position' : 'temperature';
302
- value = fv;
303
- node._gateway.deviceManager.controlDevice(meshMac, meshChannel, property, fv);
304
- } else if (fn === 3) {
305
- if (typeInfo.meshType === 'climate') {
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 (typeInfo.meshType === 'fan') {
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
- } else if (fn === 4 && typeInfo.meshType === 'climate') {
319
- const speed = FAN_SPEED_MAP[fv];
320
- if (speed) {
321
- property = 'fanSpeed'; value = speed;
322
- node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'fanSpeed', speed);
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
- if (property === 'switch' || property === 'on' || property === 'isOn') {
359
- fn = 1;
360
- } else if (property === 'brightness' || property === 'temperature' || property === 'position') {
361
- fn = 2;
362
- } else if (property === 'mode' || property === 'hvacMode') {
363
- fn = 3;
364
- } else if (property === 'fanSpeed' || property === 'fanMode') {
365
- fn = typeInfo.meshType === 'climate' ? 4 : 3;
366
- } else {
367
- return;
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
  // ===== 网关事件监听 =====
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {