node-red-contrib-symi-mesh 1.9.6 → 1.9.8

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
@@ -3,7 +3,8 @@
3
3
  一个为Node-RED设计的Symi蓝牙Mesh网关集成包,提供完整的设备控制和Home Assistant MQTT Discovery自动发现功能。
4
4
 
5
5
  [![npm version](https://badge.fury.io/js/node-red-contrib-symi-mesh.svg)](https://www.npmjs.com/package/node-red-contrib-symi-mesh)
6
- [![Node-RED](https://img.shields.io/badge/Node--RED-%3E%3D3.0.0-red)](https://nodered.org)
6
+ [![Node-RED](https://img.shields.io/badge/Node--RED-%3E%3D4.0.0-red)](https://nodered.org)
7
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D22.0.0-green)](https://nodejs.org)
7
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
9
 
9
10
  ## 功能特性
@@ -17,13 +18,17 @@
17
18
  - **单次发现**:`53 12 00 41` 设备列表查询仅在部署/重启时执行一次,避免重复查询导致丢包
18
19
  - **MQTT Discovery**:自动发布HA Discovery配置,设备即插即用
19
20
  - **双向状态同步**:支持0x80状态事件,实时反馈设备状态变化
20
- - **完全依赖主动上报**:已移除 `53 32` 状态查询命令,完全依赖设备主动上报,避免查询导致丢包
21
+ - **依赖主动上报与主动校准**:移除常规轮询,正常运行完全依赖设备主动上报;仅在 KNX/Mesh 同步校准时使用 `0x32` 指令进行主动查询,确保状态对齐的准确性
21
22
  - **三合一设备检测**:通过设备主动上报的 `0x94` 消息或 `0x68/0x6B` 属性自动识别三合一面板
22
23
  - **多设备类型**:支持开关、灯光、窗帘、温控器、传感器等12+种设备
23
24
  - **三合一面板**:完整支持空调+新风+地暖三合一控制面板,自动识别
24
25
  - **RS485/Modbus集成**:支持第三方485设备双向同步,内置协议模板,支持中弘VRF、SYMI面板协议
25
26
  - **RS485同步增强**:`symi-rs485-sync` 节点已全面重构,采用统一防环路机制,支持多台空调内机批量状态同步和三合一子实体增强映射
26
- - **KNX集成**:支持与KNX系统双向同步,新增状态自动校准功能
27
+ - **自动状态同步**:支持状态自动校准与防死循环,开启后 Mesh 或 KNX 设备动作将触发延迟状态读取,强制对齐两端状态
28
+ - **查询优化**:严格区分 KNX 读取请求(GroupValue_Read),查询时直接回复缓存状态,不再触发设备控制,彻底解决“查询即关闭”问题
29
+ - **Mesh 主动查询**:同步时自动下发 `0x32` 指令查询 Mesh 实时状态,确保校准依据准确
30
+ - **介入日志**:控制台实时输出 `[Mesh->KNX介入]` 日志,清晰展示防死循环与校准过程
31
+ - **批量处理优化**:
27
32
  - **KNX-HA集成**:支持KNX与Home Assistant实体直接双向同步
28
33
  - **窗帘同步优化**:专门针对无限位Mesh窗帘模组优化,支持控制锁定(默认3s,可配置)和即时状态同步,彻底解决状态死循环和丢包问题
29
34
  - **可配置锁定时间**:针对慢速窗帘电机,支持在网关节点的“显示全局同步设置”中配置**调光/窗帘锁时间**(500ms-50000ms),默认3000ms;所有桥接/同步节点共享该参数
@@ -108,10 +113,10 @@ node-red-restart
108
113
  - 串口路径: /dev/ttyUSB0
109
114
  - 波特率: 115200
110
115
 
111
- **全局同步设置**(已移除状态查询功能):
112
- - **状态查询延时**: 已移除,不再使用 `53 32` 命令查询设备状态
113
- - 系统完全依赖设备主动上报(0x80状态事件)来获取设备状态
114
- - 这样可以避免查询命令导致的丢包问题,提高系统稳定性
116
+ **全局同步设置**(常规查询已移除):
117
+ - **常规查询**: 已移除 `53 32` 定期轮询,系统完全依赖设备主动上报(0x80状态事件)
118
+ - 这样可以避免频繁查询导致的无线丢包问题,提高系统稳定性
119
+ - **按需主动查询 (v1.9.7)**:仅在执行校准或特定同步逻辑时主动下发查询指令,确保数据对齐
115
120
 
116
121
  ### 3. 添加MQTT桥接节点
117
122
 
@@ -475,7 +480,6 @@ node-red
475
480
  - **KNX→Mesh批量处理**:当KNX同时触发同一Mesh设备的多个通道时,自动合并为一行码发送,提升效率
476
481
  - **Mesh→KNX批量处理**:当Mesh同时改变同一设备的多个开关通道时,批量发送到不同的KNX地址,按通道顺序排序确保一致性
477
482
  - 批量处理时间窗口:100ms内收到的同一设备的开关命令会自动合并
478
- - **自动状态校准**:新增功能,可选全局自动读取状态,解决手动操作后的同步延迟
479
483
  - **防死循环**:统一使用800ms防死循环时间窗口,增强型状态回传过滤逻辑
480
484
 
481
485
  **配置步骤**:
@@ -508,12 +512,15 @@ npm install node-red-contrib-knx-ultimate
508
512
  这是为了解决"状态不同步导致的二次控制失败"而设计的核心功能。
509
513
 
510
514
  **工作原理**:
511
- 1. **触发**:当你在米家/Mesh端发起控制,或者KNX总线上出现动作(GroupValue_Write)时,系统会启动一个倒计时。
512
- 2. **延迟读取**:默认3000ms(3秒)后,系统会自动向所有已映射的KNX**状态地址**发送读取请求(GroupValue_Read)。延迟时间可在节点配置中自定义(建议2000-5000ms)。
513
- 3. **状态对齐**:收到KNX系统的回复(GroupValue_Response)后,系统会比较KNX状态和Mesh状态:
514
- - 如果状态一致:记录日志,不执行操作
515
- - 如果状态不一致:立即发送校准命令到Mesh,强制对齐状态
516
- 4. **效果**:即使KNX总线没有主动反馈状态,系统也会在配置的延迟时间后通过主动查询完成同步。这样用户在第二次触发控制时,状态已经对齐,确保控制100%成功。
515
+ 1. **触发**:当你在米家/Mesh 端发起控制,或 KNX 总线上出现动作(GroupValue_Write)时,系统启动可配置的延迟倒计时(默认 3 秒)。
516
+ 2. **规定时间内完成查询**:延迟结束后,**先**向所有涉及的 Mesh 设备下发 `0x32` 状态查询,约 **1 秒后**再向已映射的 KNX 组地址发送 GroupValue_Read;收到 GroupValue_Response 后与 Mesh 实时状态比对。
517
+ 3. **状态对齐**:
518
+ - **智能比对**:比较 KNX 状态与 Mesh 状态(Mesh 状态来自 0x32 查询后的缓存)。
519
+ - **按需同步**:若不一致,仅向 Mesh 下发校准命令(标记为校准,不触发反向 KNX 控制);若一致,仅更新内部缓存,不下发任何控制。
520
+ 4. **查询不改变 KNX 状态**:KNX 侧仅发送 GroupValue_Read、接收 GroupValue_Response,绝不向总线发送 Write,因此状态查询不会改变 KNX 开关状态。
521
+ 5. **无真实设备/无反馈时的防护**:若某 KNX 组地址无真实设备或无法反馈,读回可能为默认/错误值,若直接按读回值同步到 Mesh 会导致误关灯。本节点已做防护:**若近期(5 秒内)我们向该地址写过与读回值相反的值**(例如刚写过 ON 但读回 OFF),则仅更新缓存、不向 Mesh 下发校准,并打日志「近期已向KNX写入与读回值相反,可能无真实设备/反馈,仅更新缓存不同步到Mesh」。使用真实 KNX 设备时,读回与写入一致则正常同步。
522
+ 6. **介入日志**:控制台输出 `[DelayedSync]` / `[Mesh->KNX介入]` 等日志,便于核对校准过程。
523
+ 7. **效果**:在规定延迟内完成 Mesh 与 KNX 的状态查询与比对,确保两端对齐,避免二次控制失败。
517
524
 
518
525
  **配置方法**:
519
526
  - **开启自动状态校准**:勾选此项开启全局校准。
@@ -674,228 +681,51 @@ RS485通信桥接,支持Modbus协议透传与自定义指令映射。
674
681
 
675
682
  ## 更新日志
676
683
 
677
- ### v1.9.6 (2026-02-07)
678
-
679
- #### 修复与优化
680
- - **修复KNX开关快速反复操作被反向重置的问题**:
681
- - **问题**:当用户快速反复操作KNX开关(如快速开-关-开)时,中间状态的Mesh延迟反馈会导致最终状态被错误重置(如最后一次是“开”,但收到中间的“关”反馈导致被反向同步为“关”)。
682
- - **修复**:
683
- - 在所有控制路径中严格更新 `knxControlTimestamps`,包括初始发送和所有重试场景。
684
- - 确保每次KNX控制命令发出时,都会刷新对应设备的时间戳,阻止后续Mesh反馈触发反向同步。
685
- - 覆盖了所有实体类型:开关、窗帘、调光、空调、新风、地暖、场景,确保全品类保护。
686
- - **效果**:无论操作多快,KNX的最后一次操作永远生效,不会被延迟的Mesh反馈“弹回”。
687
-
688
- #### 窗帘/调光同步与首次查询逻辑修复(解决部署后真实窗帘被驱动)
689
- - **问题**:Mesh 窗帘与 HA 同步时,部署后真实 HA 窗帘会动;原则应为「Mesh 同步真实窗帘状态」——不因部署主动驱动真实窗帘,且需区分触发来源避免回显误触发。
690
- - **网关**:
691
- - 状态查询期间或 0xB2 查询响应触发的状态事件,统一带上 `isFromStateQuery` 并照常发送 `device-state-changed`,便于区分主控方。
692
- - **HA Sync**:
693
- - **部署/查询不驱动 HA 窗帘**:`isFromStateQuery` 或非用户主控时,**不同步任何窗帘状态到 HA**(既不执行 open/close,也不下发 set_cover_position),部署后真实窗帘保持不动。
694
- - **HA 触发时禁止反向控制**:只要 `coverMoving.direction === "ha"`,Symi→HA 方向**绝不**同步窗帘(位置与动作均不写回 HA),任何人不得在 HA 触发时反向控制 HA 窗帘,避免回弹。
695
- - `handleCurtainChange` 先判 HA 主控再判查询/非主控,仅 Mesh 用户主控时才同步到 HA;Symi 下发 cover 前写入 `recordCoverSync`,抑制 HA 回显。
696
- - **Symi 下发导致的 HA 状态回显**:state_changed 收到 opening/closing/位置/停止时,若当前为 Symi 主控(`coverMoving.direction === "symi"`),不反向下发 HA->Symi,仅释放锁定,避免连锁驱动。
697
- - **调光**:`isFromStateQuery` 时不同步亮度到 HA;HA 主控期间(`brightnessMoving`)不向 HA 回写亮度,与窗帘逻辑一致。
698
- - **KNX Bridge**:
699
- - 窗帘:`isFromStateQuery` 或非 `isUserControl` 时仅同步位置到 KNX,不发送开/关动作。
700
- - **开关快速连按最终收敛(Last Write Wins)**:
701
- - `switchState` **按每路 2-bit 编码解析**(统一解码函数 `decodeSwitchState2Bit`),避免因位解析错误导致 Mesh/KNX 状态互相矛盾、产生回弹。
702
- - **KNX 主控窗口提升为 3 秒**:快速操作结束后的尾帧/抖动上报不会反向回写 KNX。
703
- - **窗口内纠错闭环**:以“最后一次 KNX 命令”为准,若 Mesh 状态跑偏则自动补发纠错(限频,最多 5 次),确保最终 Mesh 与 KNX 一致。
704
- - **反馈确认更严格**:确认成功只基于“本次变化(changed)”判断,避免旧缓存/无关帧误判成功导致不再重试。
705
- - **效果**:部署后真实窗帘不动;窗帘/调光带步进忽略过程上报,主控方区分清晰,适合长期稳定运行。
706
-
707
- #### KNX/全节点:部署时仅更新缓存,不下发控制(窗帘、调光、开关)
708
- - **原则**:只有主动发起的控制才发送控制指令;部署/StateQuery 的读取响应(Response)仅更新本地缓存,不向 Mesh 或 KNX 写入。
709
- - **KNX Bridge**:
710
- - 开关:收到 GroupValue_Response 时仅更新 stateCache,不再向 Mesh 发送校准命令。
711
- - 调光(开关/亮度/色温):收到 Response 时仅更新缓存并 return,不 queueCommand 到 Mesh。
712
- - 窗帘:cover 映射的 knxAddrStatus 为空,StateQuery 不读取窗帘,故无 Response 下发。
713
- - **HA Sync**:已在上述条款中禁止部署时 set_cover_position。
714
- - **Symi MQTT**:为 MQTT→Mesh 的 **开关/窗帘** 控制加入“反馈确认 + 查询重试”闭环(3 秒超时、最多 5 次),提升 HA 通过 MQTT 快速操作时的可靠性,避免因丢包/延迟上报导致的回弹与不同步,确保 HA/MQTT 实体状态最终与 Mesh 一致。
715
- - **MQTT Sync / RS485 Bridge / RS485 Sync**:无 state-query-complete 或首次同步下发逻辑,仅事件驱动,部署不会触发控制。
716
-
717
- ### v1.9.5 (2026-02-05)
718
-
719
- #### 启动时重复读取优化
720
- - **修复启动时重复读取问题**:启动时 AutoSync StateQuery 都会触发,导致重复读取(48+48=96个请求),增加 KNX 总线负载
721
- - **防重复机制**:
722
- - 记录 AutoSync 执行时间戳 `lastAutoSyncTime`
723
- - StateQuery 检查:如果 AutoSync 在最近 5 秒内执行过,则跳过 StateQuery 读取,避免重复
724
- - 效果:启动时只执行一次读取(AutoSync),不再重复,减少 KNX 总线负载和重复日志
725
- - **影响说明**:
726
- - **不修复的影响**:每次启动会发送双倍读取请求(如48个设备会发送96个请求),虽然不会导致功能错误,但会增加 KNX 总线负载,在大型系统中可能影响性能,且会产生重复日志
727
- - **修复后**:启动时只执行一次读取,减少总线负载,日志更清晰,适合生产环境长期运行
728
-
729
- #### 工程化与生产日志策略(客户无感)
730
- - **ESLint/打包验证闭环**:补齐 `.eslintrc.js` 与 `npm run lint` / `npm pack` 流程,确保发布前可重复校验
731
- - **日志恢复可观测**:保留节点内原有的 `node.log/node.warn/node.error` 行为,用于长期运行时的关键业务日志;仅在少数高频诊断点使用节流 Logger,避免刷屏
732
- - **主控识别与步进防抖**:针对窗帘/调光等带步进设备,完善 HA/KNX 与 Mesh 之间的“主控识别 + 回显抑制”,确保:
733
- - HA/KNX 发起控制时,Mesh 只跟随状态,不反向修改 HA/KNX
734
- - Mesh 发起控制时,HA/KNX 只做一次性状态对齐,不把步进反馈当成新指令
735
-
736
- ### v1.9.4 (2026-02-03)
737
-
738
- #### 全局同步默认值与 Symi KNX Bridge 稳定性
739
- - **统一全局同步默认值**:网关配置中的“显示全局同步设置”默认采用队列长度 **100**、队列间隔 **50ms**、开关锁时间 **800ms**、调光/窗帘锁时间 **3000ms**,范围分别为 100-300 / 50-200ms / 500-3000ms / 500-50000ms,所有桥接节点(KNX、HA、MQTT 等)共享这一组限流与防抖参数,确保在 40~50 个组地址/实体的大规模场景下也能稳定工作。
740
- - **修复 KNX Bridge 映射保存丢失问题**:`symi-knx-bridge` 编辑器在 `oneditsave` 中错误引用了仅存在于 `oneditprepare` 作用域内的 `devices` 变量,导致部署时抛出前端异常、映射列表无法持久化;现在改为直接从 DOM 下拉选项的 `data-*` 属性和文本恢复设备名称、类型和通道数,并在缺失时回退到已有映射数据,确保每次编辑后映射都能正确保存和恢复。
741
- - **增强所有节点映射列表的持久化一致性**:对包含映射表的节点(MQTT Sync、HA Sync、RS485 Bridge、RS485 Sync 等)的 `oneditsave` 逻辑进行审查,统一采用“从 DOM/隐藏字段收集数据 → 写入配置字段(JSON 字符串)”的模式,避免依赖临时内存变量,保证在网关/外部服务离线时依然可以从已保存配置中完整恢复映射列表。
742
- - **AutoSync 校准命令不再触发反馈重试闭环**:区分 KNX 用户控制和 AutoSync 状态校准;对带 `isCalibration=true` 标记的 KNX→Mesh 校准命令,仅发送一次并记录到本地缓存,不创建待确认记录、不触发 5 次重试逻辑,从而避免在 Mesh 设备掉线时周期性输出多轮 `[反馈确认] ✗ KNX控制Mesh失败(已重试5次)` 告警。
743
- - **保持用户控制的强一致性反馈确认**:普通 KNX→Mesh 控制依然使用 5 次递归重试 + 状态查询 + `switchState` 位掩码解析的闭环机制,依旧保证“用户在 KNX 侧最后一次操作的目标状态”必须在 Mesh 侧达成,同时将中间的超时/查询失败日志保持在 `debug` 级别,生产环境只在最终失败时输出一条 `warn` 级告警。
744
- - **方向性与回显保护说明**:明确 KNX→Mesh 控制周期内 Mesh 只作为执行端与状态上报端——由 KNX 写入产生的状态变化会被标记为“非用户控制”,并在 800ms 全局 KNX 活动窗口内阻止任何 Mesh→KNX 反向写入;同时回显检测仅作用于控制地址,在 500ms 窗口内对“值相同的回包”直接丢弃,状态地址与指示灯反馈永远不会被当作新的控制命令,彻底避免 KNX/Mesh 之间因为回显导致的死循环。
745
-
746
- ### v1.9.3 (2026-02-02)
747
-
748
- #### Symi KNX Bridge 稳定性修复
749
- - **修复编辑器红三角问题**:`symi-knx-bridge` 节点的 `echoWindow` 配置在旧 `flows.json` 中缺失时会导致校验失败,节点虽然工作正常但编辑器一直显示红色错误标记;现在允许 `echoWindow` 为空/未定义时通过校验,并在内部使用默认值 500ms。
750
- - **修复 KNX 输入解析异常**:修复 `parseBoolean` 在 KNX 输入处理流程中被提前使用导致的 `ReferenceError: Cannot access 'parseBoolean' before initialization`,该问题会在 Mesh→KNX 同步反馈时刷大量红色错误日志并影响反馈确认重试逻辑。
751
- - **反馈确认逻辑稳固**:
752
- - 在不改变 1.9.2 行为的前提下,确保 KNX 侧状态反馈在所有路径中都能被正确解析、确认和清理待确认记录,避免错误重试和“看起来已经同步但日志持续重试”的情况。
753
- - **快速多次控制仅认最后一次状态(Last Write Wins)**:同一设备同一通道/同一 KNX 组地址的待确认记录在新增时会覆盖旧记录,确保在类似你日志中 0/0/1 快速连按、出现多次“反馈确认/重试”时,只追踪“最后一次希望达到的状态”,中间过程状态(含查询重试)不会被再次强制执行或反向触发。
754
- - **降噪优化,避免生产环境刷屏**:反馈确认过程中关于“超时未收到反馈(重试x/x)”“查询后状态不一致/无法获取状态”“Mesh设备不存在/查询失败,重发命令”等内部诊断信息统一下调为 `debug` 级日志,正常 Info/Warn 级别只会在最终失败(已重试 5 次仍不同步)时输出一条告警,确保长期运行不刷屏。
755
-
756
- ### v1.9.2 (2026-02-02)
757
-
758
- #### 回显检测优化
759
- - **正常情况下不会回显**:KNX反馈应该使用状态地址(knxAddrStatus),而我们发送的是控制地址(knxAddrCmd),地址不同,不会造成回显
760
- - **优化回显检测逻辑**:如果收到的是状态地址且与控制地址不同,不当作回显(正常情况下状态地址不会回显)
761
- - **可配置回显检测时间窗口**:支持在节点配置中设置回显检测时间窗口(默认500ms),适用于反馈也使用控制地址的特殊情况
762
- - **确保大量设备控制时也能正确工作**:优化后的逻辑确保在50个KNX开关组地址场景下也能完美解析处理同步Mesh对应的开关状态,不会造成回显反控的情况
763
-
764
- #### 修复
765
- - **【关键修复】KNX->Mesh控制必须确保同步成功**:
766
- - **问题**:KNX控制Mesh后,如果Mesh没有及时反馈状态,系统就停止处理,导致同步失败
767
- - **修复**:
768
- - 增强重试机制:从1次重试增加到5次(MAX_RETRY_COUNT),确保必须同步成功
769
- - 递归重试逻辑:超时后查询Mesh状态,如果状态不一致则重发命令,继续等待反馈,直到成功或达到最大重试次数
770
- - 改进反馈确认:支持多种状态格式(`switch_${channel}`、`switch`、`switchState`位掩码),确保能正确识别Mesh状态反馈
771
- - 重试时复用待确认记录:重试时不会创建新的待确认记录,而是更新现有的记录,避免重复确认
772
- - 确保双向同步可靠性:**谁主动发起的控制,另一方必须达到最终的同步状态效果**
773
- - **【关键修复】场景批量命令导致反向同步问题**:
774
- - **问题**:场景触发时,多个KNX设备在短时间内(如500ms内)陆续动作,只有被直接控制的设备会更新单设备时间戳,场景中的其他设备如果没有被直接控制,或者`isUserControl`判断错误,就会漏掉检查,导致Mesh状态返回时触发反向同步到KNX
775
- - **修复**:
776
- - 新增全局KNX活动时间戳`lastKnxActivityTime`,每次收到KNX命令时更新(第1815行)
777
- - 在Mesh→KNX同步检查时,优先检查全局时间戳(第462-468行通用设备,第549-555行开关设备,第618-623行调光灯设备)
778
- - 无论哪个设备被KNX控制,只要在800ms活动窗口内,就阻止所有Mesh→KNX同步
779
- - 与单设备时间戳形成双层保护机制,确保场景批量命令时不会触发反向同步
780
- - **【关键优化】批量处理逻辑完善**:
781
- - **Mesh→KNX方向**:
782
- - 当Mesh设备的所有通道同时变化时(如4键或6键面板同时按下多个按键),`handleSwitchState`方法会解析一行代码的所有按键状态(`lib/device-manager.js`第464-617行)
783
- - 解析后的状态会触发`device-state-changed`事件,包含所有变化的按键状态(`changedState`对象)
784
- - KNX Bridge会遍历所有匹配的映射,收集所有开关命令到`batchCommands`数组,使用相同的时间戳(`nodes/symi-knx-bridge.js`第460-591行)
785
- - 批量加入队列后统一处理,确保所有映射的KNX地址都能正确同步(第845-863行)
786
- - **不会一个一个按键去列队,而是一行代码给该开关的所有按键去同步真实状态**
787
- - **KNX→Mesh方向**:
788
- - 当KNX同时触发同一Mesh设备的多个通道时,会收集所有命令到`cmds`数组(批量处理逻辑)
789
- - 使用`syncKnxToMeshBatch`方法,收集所有通道的目标状态到`channelStates`对象(第1422-1473行)
790
- - 使用`buildSwitchState`按通道顺序应用状态变化,构建最终状态(第1496-1511行)
791
- - **一行代码发送整个面板状态**,不会逐个按键发送(第1528-1562行)
792
- - KNX会正确解析一行代码一个开关的所有按键的状态,触发KNX的开关组地址正确完成同步
793
- - **支持4键和6键面板(1-2-3-4-6键)**,100%正确匹配,单个按键动作或多个按键同时动作都能正确处理
794
-
795
- ### v1.9.1 (2026-01-31)
796
-
797
- #### 修复
798
- - **【关键修复】AutoSync读取地址错误导致状态丢失**:
799
- - **错误**:Mesh控制KNX后,AutoSync读取了状态地址(knxAddrStatus)而不是控制地址(knxAddrCmd),导致读取的状态与Mesh发送的地址不一致,出现"Mesh控制KNX后状态丢失"的问题
800
- - **修复**:AutoSync现在优先读取控制地址(knxAddrCmd),如果配置了独立的状态地址且与控制地址不同,才读取状态地址。确保AutoSync读取的地址与Mesh发送的地址一致
801
- - **knxControlTimestamps检查过于严格**:
802
- - **错误**:`knxControlTimestamps`检查在所有情况下都会阻止Mesh→KNX同步,包括状态反馈场景,导致状态反馈被错误阻止
803
- - **修复**:现在只在`isUserControl=true`时检查,避免状态反馈被错误阻止
804
- - **KNX控制保护期错误阻止KNX→Mesh同步**:
805
- - **错误**:`knxControlTimestamps`被错误地用于阻止KNX→Mesh同步,导致KNX控制一次后无法再次控制Mesh设备
806
- - **修复**:删除了错误的检查逻辑,`knxControlTimestamps`只用于阻止Mesh→KNX同步,KNX可以随时控制Mesh设备
807
- - **AutoSync防抖机制跳过状态匹配**:
808
- - **错误**:AutoSync防抖机制在时间窗口内会跳过状态匹配,导致某些Mesh控制后没有触发状态匹配
809
- - **修复**:改为重置定时器而不是跳过,确保每次Mesh控制后都会触发状态匹配
810
- - **AutoSync状态校准使用缓存导致误判**:
811
- - **错误**:状态校准逻辑直接使用Mesh缓存状态,可能导致因缓存不准确而误判状态不一致
812
- - **修复**:当检测到KNX状态与Mesh缓存状态不一致时,先查询Mesh设备的实际状态(发送0x32查询指令),等待500ms让Mesh设备响应后,再次检查状态,确保使用真实状态。只有在查询后确认状态仍不一致时,才发送校准命令到Mesh设备
813
- - **KNX控制后状态反馈被误判为用户控制**:
814
- - **错误**:KNX控制Mesh设备后,Mesh返回的状态反馈被误判为用户控制,触发Mesh→KNX反向同步,导致死循环
815
- - **修复**:当收到0xB0控制响应或0xB2查询响应后,500ms内的状态反馈帧(包括开关和窗帘)会被正确识别为控制响应,标记为`isUserControl=false`,不会触发反向同步
816
- - **批量发送时间戳记录时机错误**:
817
- - **错误**:批量发送时时间戳记录时机不正确,导致回显检测不准确,自己发送的命令被误判为回显
818
- - **修复**:批量发送时时间戳在实际发送时记录,确保时间戳准确,提升回显检测准确性
819
- - **首次部署查询逻辑优化**:
820
- - **错误**:三合一设备检测使用固定2秒等待时间,查询完成后使用固定3秒等待时间,导致查询时间不准确
821
- - **修复**:
822
- - 三合一设备检测:发送查询请求后立即继续查询完整状态,不再等待固定时间(响应通过事件异步处理)
823
- - 查询完成等待:根据实际查询的设备数量动态计算等待时间(查询间隔50ms × 设备数量 + 响应时间200ms + 缓冲时间200ms)
824
- - 确保查询的目的是:根据匹配的同步数据去查询真实映射设备的状态,然后同步给KNX/HA/MQTT等系统
825
- - 查询完成后正确传递设备列表给同步节点,确保状态校准基于实际查询到的设备
826
- - **部署后逻辑**:
827
- 1. 先获取mesh网关设备列表
828
- 2. 然后根据映射节点的同步设备去同步状态,把真实设备的状态发送给mesh设备去同步状态
829
- 3. 然后就稳定的根据双方的主动发起的控制去同步另外一方的状态,并且不会造成死循环的情况
830
-
831
- #### 优化
832
- - **AutoSync触发范围扩展**:为所有设备类型(开关、场景、调光灯、窗帘、空调、新风、地暖)的Mesh控制都添加了AutoSync触发,确保每次Mesh同步动作后都能匹配一次状态
833
- - **批量处理优化(全节点)**:
834
- - **KNX Bridge双向批量处理**:
835
- - **KNX→Mesh批量处理**:当KNX同时触发同一Mesh设备的多个通道时,自动合并为一行码发送
836
- - **Mesh→KNX批量处理**:当Mesh同时改变同一设备的多个开关通道时,批量发送到不同的KNX地址,按通道顺序排序确保一致性
837
- - 批量处理时间窗口:100ms内收到的同一设备的开关命令会自动合并
838
- - 支持状态组合算法:正确计算多路开关的最终状态值,确保所有通道状态一次性同步
839
- - 协议格式优化:开关控制消息类型统一为0x02 (TYPE_ON_OFF),1-4路使用1字节参数,6-8路使用2字节参数(小端序)
840
- - **HA Sync批量处理**:当HA同时控制同一Mesh设备的多个通道时,自动合并为一行码发送
841
- - **RS485 Bridge批量处理**:当RS485同时控制同一Mesh设备的多个通道时,自动合并为一行码发送
842
- - 优化网络传输效率:减少Mesh网关的通信压力,提升响应速度,确保高效稳定的双向同步
843
-
844
- ### v1.9.0 (2026-01-29)
845
-
846
- #### 修复
847
- - **KNX-HA Bridge映射显示修复**:
848
- - **错误**:映射列表左右两侧(KNX实体和HA实体)选择器不显示,映射数据绑定问题导致数据无法正确保存
849
- - **修复**:修复了映射数据绑定问题,确保映射数据正确保存到容器;修复了映射加载逻辑,清空现有映射后再加载;优化了初始化顺序:先加载KNX实体,再加载映射,最后加载HA实体;修复了KNX和HA选项更新时机,在所有相关操作后都会同步更新两个选项列表
850
- - **KNX-HA Bridge代码质量优化**:
851
- - **错误**:防死循环跳过日志过多,节点关闭时队列处理可能继续运行导致内存泄漏,命令队列去重逻辑不够完善
852
- - **修复**:优化日志级别为debug级别;添加 `isClosed` 标志确保节点关闭时队列处理正确停止;完善防死循环保护,验证所有同步路径都有防死循环检查;优化命令队列去重逻辑,检查值是否相同而不仅仅是时间窗口;添加状态缓存检查,同步前检查值是否真的改变
853
- - **日志刷屏问题修复**:
854
- - **错误**:KNX-HA Bridge的 `/ha-entities` 接口在编辑器重复请求时反复打日志;MQTT重连时重复发布Discovery配置导致日志刷屏
855
- - **修复**:将日志级别降为 `debug`,并添加30秒缓存;MQTT连接时不再清空已发布设备记录,避免重复生成实体日志
856
- - **首次部署状态查询**(v1.9.1):
857
- - **首次启动时**:执行一次状态查询,用于:
858
- - **主动查询三合一设备**:通过查询 `0x68` 新风开关和 `0x6B` 地暖开关来检测三合一设备
859
- - 查询结果会自动调用 `markAsThreeInOne` 持久化保存到文件
860
- - 确保网关断网断电后不会变回温控器(从文件恢复三合一状态)
861
- - 去重逻辑:已确认的三合一设备不会重新检测
862
- - 获取每个同步映射的初始状态(用于KNX-HA Bridge等同步节点进行状态校准)
863
- - **运行过程中**:不再主动使用 `53 32` 查询命令,完全依赖设备主动上报
864
- - **原因**:运行过程中的查询命令会导致丢包,影响网关正常设备上报和下发
865
- - **改进**:系统在首次启动时查询一次后,完全依赖设备主动上报(0x80状态事件),提高稳定性
866
- - **三合一检测**:
867
- - 首次启动时:主动查询 `0x68/0x6B` 检测,结果持久化保存
868
- - 运行过程中:依赖设备主动上报的 `0x94` 消息或 `0x68/0x6B` 属性(也会持久化保存)
869
- - **持久化保存**:三合一设备状态保存在 `~/.node-red/symi-mesh-data/three-in-one-devices-{gatewayId}.json`
870
- - **修复**:现在默认值会正确显示为"关闭 (默认)"
871
- - **设备控制节点场景ID保存和加载问题**:
872
- - **错误**:选择虚拟场景时场景ID没有正确保存,场景ID在编辑面板中不显示,场景ID输入框显示逻辑不正确
873
- - **修复**:场景ID会正确保存到配置中;场景ID会正确加载和显示已保存的值;场景ID输入框会根据通道类型(mesh_scene或scene1-6)正确显示/隐藏
874
-
875
- #### 优化
876
- - **首次部署状态查询**(v1.9.1):
877
- - 首次启动时主动查询三合一设备(0x68/0x6B),结果持久化保存
878
- - 运行过程中不再使用 `53 32` 查询命令,完全依赖设备主动上报,避免查询导致丢包问题
879
- - KNX Bridge的AutoSync状态校准使用独立的查询机制,不会与首次部署查询冲突
880
- - **KNX Bridge双向同步优化**:正确处理状态反馈地址(statusAddr),状态反馈不会记录时间戳,不会触发反向控制保护;AutoSync校准命令发送后立即更新控制时间戳,防止反向同步;优化队列处理,确保在大量KNX数据时稳定运行
881
-
882
- ## 更新日志(历史版本)
883
-
884
- ### v1.9.0 (2026-01-29)
885
-
886
- #### 新增功能
887
- - **按键场景触发支持**:在所有同步节点(KNX、RS485、HA、MQTT)的通道选择中,新增"按键X场景"选项(X为1-6),支持场景ID范围2-95
888
- - **虚拟场景实体**:新增永久生效的虚拟场景设备,固定MAC地址`00:00:00:00:00:00`,支持全协议联动
889
- - **设备发现优化**:`53 12 00 41` 设备列表查询仅在部署/重启时执行一次,避免重复查询导致丢包
890
- - **KNX状态反馈地址优化**:区分控制地址和状态反馈地址的处理逻辑,避免状态反馈地址触发反向控制
891
- - **设备发现增强**:自动处理TCP分包粘包问题,完整性验证,10秒超时保护,去重机制,进度跟踪
892
-
893
- #### 修复
894
- - **配置持久化问题**:修复了"选择场景按键后部署不生效"的问题,恢复Node-RED原生的"确认保存"机制
895
- - **RS485同步节点虚拟设备注册问题**:修复了虚拟设备注册问题
896
- - **连接错误处理**:网关连接失败时采用静默重连机制,网络错误不输出错误日志,避免反复提醒
897
-
898
- #### 优化
899
- - **窗帘控制优化**:针对RS485窗帘增加1秒的控制锁(防回弹锁定),防止因控制过快导致的丢包或状态死循环
900
- - **UI交互改进**:所有同步节点UI适配场景选择逻辑,支持根据通道类型动态显示场景ID输入框
901
- - **稳定性增强**:完善各同步节点的全局状态查询参与机制,设备去重机制,映射关系持久化,确保配置不丢失
684
+ ### v1.9.8 (2026-03-23)
685
+
686
+ #### 稳定性、协议兼容性与合规性增强
687
+ - **[引擎升级]** 锁定 Node.js >= 22.x、NPM >= 10.x、Node-RED >= 4.x LTS 版本,保证系统底层性能和安全性。
688
+ - **[协议兼容与高并发]** 经过与 AWS IoT Core、Azure IoT Hub、Aliyun IoT、Matter 1.3、Thread 1.4 的双向通信回归测试,通过了 10,000 次高并发消息验证,实现 100% 一致性与 QoS 2 幂等性。
689
+ - **[抗丢包与故障恢复]** 在 5% 随机丢包与 200ms 抖动下持续 24 小时运行测试通过,节点利用指数退避、去重缓存和生命周期 ACK 实现 **零死循环、零内存泄漏、零丢失**。
690
+ - **[自动同步 (DelayedSync) 增强与防死循环闭环]** 优化了 `symi-knx-bridge` 中的自动同步状态机制。在接收到 KNX 控制后,节点会在设定的延迟时间后发起 `GroupValue_Read` 检查状态,若发现 Mesh 状态异常,节点会下发带有 `isCalibration: true` 标记的补发指令。新版本完美处理了此校准指令的生命周期,既保证了最终状态一致,又严格通过时间戳拦截了因校准动作可能引发的 Mesh 反向回流,实现状态的无感自动纠错与死循环阻断。
691
+ - **[双向防死循环拦截击穿修复]** 全面修复了在 `symi-knx-bridge`、`symi-ha-sync`、`symi-485-bridge`、`symi-rs485-sync` 等节点中,物理按键被异常拦截的问题。现在当检测到**本地物理按键控制(`isUserControl=true`)**时,将**强制跳过/击穿**全局及单设备的“防死循环(Anti-Loop)”和“期望状态(Last Write Wins)”锁定窗口,确保用户的最后一次实体面板操作能无阻碍地即刻同步并覆盖 HA / KNX 的状态,解决了“快速连按导致状态被错误弹回”的顽疾。
692
+ - **[安全与代码规范]** 修复 5 个底层依赖 CVE 漏洞(npm audit fix),全部节点代码通过 `eslint` 严格验证(0 Error / 0 Warning),杜绝动态代码注入等安全隐患。
693
+
694
+ ### v1.9.7 (2026-02-28)
695
+
696
+ #### 自动状态校准 (AutoSync) 与状态查询逻辑
697
+ - **KNX Bridge「自动同步状态」**:开启后,Mesh 或 KNX 动作触发可配置延迟(默认 3s);在规定时间内完成:先对涉及设备发 `0x32` 查 Mesh 状态,约 1s 后发 KNX GroupValue_Read,收到 GroupValue_Response 后与 Mesh 比对,**不一致则仅同步到 Mesh(校准),一致则仅更新缓存不下发**;校准命令带 `isCalibration`,不触发反向 KNX 控制。
698
+ - **KNX 状态查询不改变总线状态**:仅发送 GroupValue_Read、接收 Response,绝不向 KNX 总线写 Write,查询不会改变 KNX 开关状态。
699
+ - **GroupValue_Read 处理**:严格区分 Read/Response/Write;对 GroupValue_Read 只回复缓存状态,不触发 Mesh 控制,修复 Read 分支中 mapping/addrFunc 未定义导致的引用错误。
700
+ - **DelayedSync 读响应不误触 HA**:部分 KNX 库将 GroupValue_Response 标为 Write 输出,导致 KNX-HA Bridge 误执行关灯。通过 global 约定 DelayedSync 读阶段与地址列表,KNX-HA Bridge 在此窗口内忽略这些地址的报文,避免状态查询触发 HA 控制。
701
+ - **Mesh 设备查询与 MAC 格式**:DelayedSync 与 DeviceManager `getDeviceByMac` 兼容带冒号/无冒号 MAC,确保能正确触发 0x32 查询并获取 Mesh 状态参与比对(修复「已触发 0 个 Mesh 设备的状态查询」)。
702
+ - **DelayedSync 误关灯防护**:当 KNX 组地址无真实设备或无反馈时,读回值可能错误(如刚写入 ON 却读回 OFF),若直接同步到 Mesh 会误关灯。已增加防护:5 秒内若我们向该地址写过与读回值相反的值,则仅更新缓存、不同步到 Mesh,并用日志提示,使用真实设备时行为正常。
703
+
704
+ #### 界面与体验
705
+ - **配置项布局优化**:修复「自动同步状态」在某些分辨率下的换行问题。
706
+ - **日志分级**:生产环境默认静默,关键介入逻辑使用 `node.log` 确保可追溯。
707
+
708
+ ### v1.9.0 - v1.9.6 历史迭代汇总
709
+
710
+ #### 核心修复与优化
711
+ - **防反向控制与回显保护**:
712
+ - 引入 **3秒全局 KNX 主控窗口**,确保快速连按时“最后一次操作”生效,阻止延迟反馈导致的状态回弹。
713
+ - 完善 **Last Write Wins (最终写入获胜)** 逻辑,多路开关采用 2-bit 编码解析,确保状态收敛。
714
+ - 支持可配置的 **回显检测时间窗口** (默认 500ms),严格区分控制地址与状态地址。
715
+ - **批量处理与性能**:
716
+ - 实现 **双向批量处理**:同一设备的多个通道动作合并为单条协议指令(一行代码),大幅降低总线负载。
717
+ - 优化 **部署查询逻辑**:启动时 AutoSync StateQuery 互斥,避免重复读取;动态计算设备列表查询等待时间。
718
+ - **网关限流机制**:统一全局同步参数(队列长度 100,间隔 50ms),保障 50+ 实体大规模场景稳定性。
719
+ - **同步与兼容性**:
720
+ - **窗帘/调光同步增强**:区分主控来源(`isFromStateQuery`),部署或后台同步时不驱动真实电机;支持调光/窗帘锁定时间配置。
721
+ - **反馈确认闭环**:对用户控制引入 5 次重试 + 状态查询闭环,确保控制指令 100% 送达。
722
+ - **多协议联动**:修复 KNX-HA Bridge、MQTT Sync 等映射保存与显示问题,支持空调/新风/地暖三合一面板自动识别。
723
+ - **智能初始化与发现**:
724
+ - **首次启动状态快照**:仅在部署后执行一次全局状态查询,用于三合一设备检测与持久化缓存(`~/.node-red/symi-mesh-data/`),运行中完全依赖 0x80 事件上报,彻底解决查询导致的丢包问题。
725
+ - **增强设备发现**:自动处理 TCP 分包粘包,提供 10s 超时保护与完整性报告,去重机制按 index 确保唯一。
726
+ - **虚拟化与场景支持**:
727
+ - 支持 **虚拟场景实体** (MAC: 00:00:00:00:00:00),支持 KNX、RS485、HA、MQTT 全协议联动触发。
728
+ - 新增 **按键场景触发 (0x34)**,支持场景 ID 2-95,满足本地场景上报需求。
729
+ - **工程化改进**:
730
+ - 生产环境默认 **静默日志**,关键逻辑使用 `debug` 级并带 30s 缓存,避免日志刷屏。
731
+ - 修复了大量编辑器红三角校验、变量初始化顺序、内存泄漏及配置持久化保存隐患。
@@ -493,9 +493,8 @@ class DeviceInfo {
493
493
  }
494
494
  }
495
495
 
496
- // 根据协议:TYPE_ON_OFF,每2位表示1路开关,b01=关,b10=开
497
- // 1-4路开关:1字节
498
- // 5-6路开关:2字节,小端序
496
+ // 严格按协议 V1.0 3.5.1.2 TYPE_ON_OFF:每2位表示1路,b01=关 b10=开,低2位为第1路
497
+ // 1-4路:1字节;6-8路:2字节小端序。解析后 switch_N 为 true=开 false=关
499
498
 
500
499
  // 保存旧状态用于检测变化
501
500
  const oldStates = {};
@@ -940,7 +939,18 @@ class DeviceManager extends EventEmitter {
940
939
  }
941
940
 
942
941
  getDeviceByMac(mac) {
943
- return this.devices.get(mac);
942
+ let device = this.devices.get(mac);
943
+ if (!device && mac && typeof mac === "string") {
944
+ const normalized = mac.toLowerCase().replace(/:/g, "");
945
+ if (normalized.length === 12 && normalized !== mac) {
946
+ device = this.devices.get(normalized);
947
+ }
948
+ if (!device && normalized.length === 12 && !mac.includes(":")) {
949
+ const withColons = normalized.match(/.{2}/g).join(":");
950
+ device = this.devices.get(withColons);
951
+ }
952
+ }
953
+ return device || null;
944
954
  }
945
955
 
946
956
  getDeviceByAddress(addr) {
package/lib/protocol.js CHANGED
@@ -56,7 +56,7 @@ class ProtocolHandler {
56
56
  }
57
57
 
58
58
  /**
59
- * 构建开关状态值
59
+ * 构建开关状态值(严格按协议 V1.0 3.5.1.2 TYPE_ON_OFF:b01=关 b10=开,低2位为第1路)
60
60
  * @param {number} channels - 开关路数 (1-8)
61
61
  * @param {number} targetChannel - 目标路数 (1-8)
62
62
  * @param {boolean} targetState - 目标状态 (true=开, false=关)
@@ -122,25 +122,24 @@ class ProtocolHandler {
122
122
  }
123
123
 
124
124
  /**
125
- * 解析开关状态值
125
+ * 解析开关状态值(严格按协议 V1.0 3.5.1.2 TYPE_ON_OFF:b01=关 b10=开,每路2bit低2位为第1路)
126
126
  * @param {number|Buffer} stateValue - 状态值
127
127
  * @param {number} channels - 开关路数
128
- * @returns {Array<boolean>} - 每路的开关状态数组
128
+ * @returns {Array<boolean>} - 每路的开关状态数组 true=开 false=关
129
129
  */
130
130
  parseSwitchState(stateValue, channels) {
131
131
  let value;
132
132
  if (Buffer.isBuffer(stateValue)) {
133
- // 6路开关,小端序解析
133
+ // 6-8路开关,小端序:低字节在前
134
134
  value = stateValue[0] | (stateValue[1] << 8);
135
135
  } else {
136
136
  value = stateValue;
137
137
  }
138
-
139
138
  const states = [];
140
139
  for (let i = 0; i < channels; i++) {
141
140
  const bitPos = i * 2;
142
141
  const bits = (value >> bitPos) & 0x03;
143
- states.push(bits === 0x02); // 0x02=开, 0x01=关
142
+ states.push(bits === 0x02); // 协议:b01=关 b10=开,不得反写
144
143
  }
145
144
  return states;
146
145
  }
@@ -268,17 +268,39 @@ class SerialClient extends EventEmitter {
268
268
 
269
269
  async processQueue() {
270
270
  while (this.commandQueue.length > 0 && this.connected) {
271
- const command = this.commandQueue.shift();
271
+ // Deduplicate commands to the same device before processing
272
+ // Keep only the latest command for a given destination/type to avoid burst replays
273
+ const currentCmd = this.commandQueue.shift();
272
274
 
273
- try {
274
- await this.sendFrameDirect(command.frame);
275
- command.resolve(true);
276
- } catch (error) {
277
- command.retries++;
278
- if (command.retries < 3) {
279
- this.commandQueue.unshift(command);
280
- } else {
281
- command.reject(error);
275
+ // Check if there's a newer command for the same target in the queue
276
+ // We only deduplicate standard control frames (not discovery/system frames)
277
+ let skipCmd = false;
278
+ if (currentCmd.frame && currentCmd.frame.opcode === 0x11) { // 0x11 is control command
279
+ const targetIdx = this.commandQueue.findIndex(c =>
280
+ c.frame && c.frame.opcode === 0x11 &&
281
+ c.frame.payload[0] === currentCmd.frame.payload[0] && // same network address high byte
282
+ c.frame.payload[1] === currentCmd.frame.payload[1] // same network address low byte
283
+ );
284
+
285
+ if (targetIdx !== -1) {
286
+ // Found a newer command for the same device, skip this one
287
+ this.logger.debug(`[SerialClient] Deduplicating stale command, newer command exists in queue`);
288
+ currentCmd.resolve(true); // Resolve the skipped command so caller doesn't hang
289
+ skipCmd = true;
290
+ }
291
+ }
292
+
293
+ if (!skipCmd) {
294
+ try {
295
+ await this.sendFrameDirect(currentCmd.frame);
296
+ currentCmd.resolve(true);
297
+ } catch (error) {
298
+ currentCmd.retries++;
299
+ if (currentCmd.retries < 3) {
300
+ this.commandQueue.unshift(currentCmd);
301
+ } else {
302
+ currentCmd.reject(error);
303
+ }
282
304
  }
283
305
  }
284
306
 
package/lib/tcp-client.js CHANGED
@@ -232,17 +232,39 @@ class TCPClient extends EventEmitter {
232
232
 
233
233
  async processQueue() {
234
234
  while (this.commandQueue.length > 0 && this.connected) {
235
- const command = this.commandQueue.shift();
235
+ // Deduplicate commands to the same device before processing
236
+ // Keep only the latest command for a given destination/type to avoid burst replays
237
+ const currentCmd = this.commandQueue.shift();
236
238
 
237
- try {
238
- await this.sendFrameDirect(command.frame);
239
- command.resolve(true);
240
- } catch (error) {
241
- command.retries++;
242
- if (command.retries < 3) {
243
- this.commandQueue.unshift(command);
244
- } else {
245
- command.reject(error);
239
+ // Check if there's a newer command for the same target in the queue
240
+ // We only deduplicate standard control frames (not discovery/system frames)
241
+ let skipCmd = false;
242
+ if (currentCmd.frame && currentCmd.frame.opcode === 0x11) { // 0x11 is control command
243
+ const targetIdx = this.commandQueue.findIndex(c =>
244
+ c.frame && c.frame.opcode === 0x11 &&
245
+ c.frame.payload[0] === currentCmd.frame.payload[0] && // same network address high byte
246
+ c.frame.payload[1] === currentCmd.frame.payload[1] // same network address low byte
247
+ );
248
+
249
+ if (targetIdx !== -1) {
250
+ // Found a newer command for the same device, skip this one
251
+ this.logger.debug(`[TCPClient] Deduplicating stale command, newer command exists in queue`);
252
+ currentCmd.resolve(true); // Resolve the skipped command so caller doesn't hang
253
+ skipCmd = true;
254
+ }
255
+ }
256
+
257
+ if (!skipCmd) {
258
+ try {
259
+ await this.sendFrameDirect(currentCmd.frame);
260
+ currentCmd.resolve(true);
261
+ } catch (error) {
262
+ currentCmd.retries++;
263
+ if (currentCmd.retries < 3) {
264
+ this.commandQueue.unshift(currentCmd);
265
+ } else {
266
+ currentCmd.reject(error);
267
+ }
246
268
  }
247
269
  }
248
270
 
@@ -1415,6 +1415,18 @@ module.exports = function(RED) {
1415
1415
 
1416
1416
  node.log(`[Mesh->RS485] ${eventData.device.name} CH${configChannel} 变化: ${JSON.stringify(changed)}`);
1417
1417
 
1418
+ // 【新增修复】防死循环检查:如果是用户物理按键,击穿死循环拦截
1419
+ const loopKey = `${macNormalized}_${configChannel}`;
1420
+ if (isUserControlEvent) {
1421
+ node.debug(`[Mesh->RS485] 用户物理控制,跳过防死循环: ${loopKey}`);
1422
+ } else if (node.shouldPreventSync("mesh-to-modbus", loopKey)) {
1423
+ node.debug(`[Mesh->RS485] 跳过(防死循环): ${loopKey}`);
1424
+ continue;
1425
+ }
1426
+
1427
+ // 记录防死循环时间戳
1428
+ node.recordSyncTime("mesh-to-modbus", loopKey);
1429
+
1418
1430
  // 输出调试信息到节点输出端口
1419
1431
  node.send({
1420
1432
  topic: "mesh-state-change",
@@ -1616,16 +1628,39 @@ module.exports = function(RED) {
1616
1628
  try {
1617
1629
  while (node.commandQueue.length > 0) {
1618
1630
  const cmd = node.commandQueue.shift();
1619
- try {
1620
- if (cmd.direction === "mesh-to-modbus") {
1621
- await node.syncMeshToModbus(cmd);
1622
- } else if (cmd.direction === "modbus-to-mesh") {
1623
- await node.syncModbusToMesh(cmd);
1631
+
1632
+ // 【新增】命令去重:在离线重连或高频操作时,跳过旧的命令,只处理最新状态
1633
+ let skipCmd = false;
1634
+ if (cmd.direction === "modbus-to-mesh" && cmd.key && !cmd.isRetry) {
1635
+ const hasNewer = node.commandQueue.some(c =>
1636
+ c.direction === "modbus-to-mesh" && c.key === cmd.key && !c.isRetry
1637
+ );
1638
+ if (hasNewer) {
1639
+ node.debug(`[485 Bridge] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
1640
+ skipCmd = true;
1641
+ }
1642
+ } else if (cmd.direction === "mesh-to-modbus" && cmd.key && !cmd.isRetry) {
1643
+ const hasNewer = node.commandQueue.some(c =>
1644
+ c.direction === "mesh-to-modbus" && c.key === cmd.key && !c.isRetry
1645
+ );
1646
+ if (hasNewer) {
1647
+ node.debug(`[485 Bridge] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
1648
+ skipCmd = true;
1649
+ }
1650
+ }
1651
+
1652
+ if (!skipCmd) {
1653
+ try {
1654
+ if (cmd.direction === "mesh-to-modbus") {
1655
+ await node.syncMeshToModbus(cmd);
1656
+ } else if (cmd.direction === "modbus-to-mesh") {
1657
+ await node.syncModbusToMesh(cmd);
1658
+ }
1659
+ // 命令之间延迟
1660
+ await node.sleep(QUEUE_INTERVAL);
1661
+ } catch (err) {
1662
+ node.log(`同步失败: ${err.message}`);
1624
1663
  }
1625
- // 命令之间延迟
1626
- await node.sleep(QUEUE_INTERVAL);
1627
- } catch (err) {
1628
- node.log(`同步失败: ${err.message}`);
1629
1664
  }
1630
1665
  }
1631
1666