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

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
@@ -17,13 +17,17 @@
17
17
  - **单次发现**:`53 12 00 41` 设备列表查询仅在部署/重启时执行一次,避免重复查询导致丢包
18
18
  - **MQTT Discovery**:自动发布HA Discovery配置,设备即插即用
19
19
  - **双向状态同步**:支持0x80状态事件,实时反馈设备状态变化
20
- - **完全依赖主动上报**:已移除 `53 32` 状态查询命令,完全依赖设备主动上报,避免查询导致丢包
20
+ - **依赖主动上报与主动校准**:移除常规轮询,正常运行完全依赖设备主动上报;仅在 KNX/Mesh 同步校准时使用 `0x32` 指令进行主动查询,确保状态对齐的准确性
21
21
  - **三合一设备检测**:通过设备主动上报的 `0x94` 消息或 `0x68/0x6B` 属性自动识别三合一面板
22
22
  - **多设备类型**:支持开关、灯光、窗帘、温控器、传感器等12+种设备
23
23
  - **三合一面板**:完整支持空调+新风+地暖三合一控制面板,自动识别
24
24
  - **RS485/Modbus集成**:支持第三方485设备双向同步,内置协议模板,支持中弘VRF、SYMI面板协议
25
25
  - **RS485同步增强**:`symi-rs485-sync` 节点已全面重构,采用统一防环路机制,支持多台空调内机批量状态同步和三合一子实体增强映射
26
- - **KNX集成**:支持与KNX系统双向同步,新增状态自动校准功能
26
+ - **自动状态同步**:支持状态自动校准与防死循环,开启后 Mesh 或 KNX 设备动作将触发延迟状态读取,强制对齐两端状态
27
+ - **查询优化**:严格区分 KNX 读取请求(GroupValue_Read),查询时直接回复缓存状态,不再触发设备控制,彻底解决“查询即关闭”问题
28
+ - **Mesh 主动查询**:同步时自动下发 `0x32` 指令查询 Mesh 实时状态,确保校准依据准确
29
+ - **介入日志**:控制台实时输出 `[Mesh->KNX介入]` 日志,清晰展示防死循环与校准过程
30
+ - **批量处理优化**:
27
31
  - **KNX-HA集成**:支持KNX与Home Assistant实体直接双向同步
28
32
  - **窗帘同步优化**:专门针对无限位Mesh窗帘模组优化,支持控制锁定(默认3s,可配置)和即时状态同步,彻底解决状态死循环和丢包问题
29
33
  - **可配置锁定时间**:针对慢速窗帘电机,支持在网关节点的“显示全局同步设置”中配置**调光/窗帘锁时间**(500ms-50000ms),默认3000ms;所有桥接/同步节点共享该参数
@@ -108,10 +112,10 @@ node-red-restart
108
112
  - 串口路径: /dev/ttyUSB0
109
113
  - 波特率: 115200
110
114
 
111
- **全局同步设置**(已移除状态查询功能):
112
- - **状态查询延时**: 已移除,不再使用 `53 32` 命令查询设备状态
113
- - 系统完全依赖设备主动上报(0x80状态事件)来获取设备状态
114
- - 这样可以避免查询命令导致的丢包问题,提高系统稳定性
115
+ **全局同步设置**(常规查询已移除):
116
+ - **常规查询**: 已移除 `53 32` 定期轮询,系统完全依赖设备主动上报(0x80状态事件)
117
+ - 这样可以避免频繁查询导致的无线丢包问题,提高系统稳定性
118
+ - **按需主动查询 (v1.9.7)**:仅在执行校准或特定同步逻辑时主动下发查询指令,确保数据对齐
115
119
 
116
120
  ### 3. 添加MQTT桥接节点
117
121
 
@@ -475,7 +479,6 @@ node-red
475
479
  - **KNX→Mesh批量处理**:当KNX同时触发同一Mesh设备的多个通道时,自动合并为一行码发送,提升效率
476
480
  - **Mesh→KNX批量处理**:当Mesh同时改变同一设备的多个开关通道时,批量发送到不同的KNX地址,按通道顺序排序确保一致性
477
481
  - 批量处理时间窗口:100ms内收到的同一设备的开关命令会自动合并
478
- - **自动状态校准**:新增功能,可选全局自动读取状态,解决手动操作后的同步延迟
479
482
  - **防死循环**:统一使用800ms防死循环时间窗口,增强型状态回传过滤逻辑
480
483
 
481
484
  **配置步骤**:
@@ -508,12 +511,15 @@ npm install node-red-contrib-knx-ultimate
508
511
  这是为了解决"状态不同步导致的二次控制失败"而设计的核心功能。
509
512
 
510
513
  **工作原理**:
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%成功。
514
+ 1. **触发**:当你在米家/Mesh 端发起控制,或 KNX 总线上出现动作(GroupValue_Write)时,系统启动可配置的延迟倒计时(默认 3 秒)。
515
+ 2. **规定时间内完成查询**:延迟结束后,**先**向所有涉及的 Mesh 设备下发 `0x32` 状态查询,约 **1 秒后**再向已映射的 KNX 组地址发送 GroupValue_Read;收到 GroupValue_Response 后与 Mesh 实时状态比对。
516
+ 3. **状态对齐**:
517
+ - **智能比对**:比较 KNX 状态与 Mesh 状态(Mesh 状态来自 0x32 查询后的缓存)。
518
+ - **按需同步**:若不一致,仅向 Mesh 下发校准命令(标记为校准,不触发反向 KNX 控制);若一致,仅更新内部缓存,不下发任何控制。
519
+ 4. **查询不改变 KNX 状态**:KNX 侧仅发送 GroupValue_Read、接收 GroupValue_Response,绝不向总线发送 Write,因此状态查询不会改变 KNX 开关状态。
520
+ 5. **无真实设备/无反馈时的防护**:若某 KNX 组地址无真实设备或无法反馈,读回可能为默认/错误值,若直接按读回值同步到 Mesh 会导致误关灯。本节点已做防护:**若近期(5 秒内)我们向该地址写过与读回值相反的值**(例如刚写过 ON 但读回 OFF),则仅更新缓存、不向 Mesh 下发校准,并打日志「近期已向KNX写入与读回值相反,可能无真实设备/反馈,仅更新缓存不同步到Mesh」。使用真实 KNX 设备时,读回与写入一致则正常同步。
521
+ 6. **介入日志**:控制台输出 `[DelayedSync]` / `[Mesh->KNX介入]` 等日志,便于核对校准过程。
522
+ 7. **效果**:在规定延迟内完成 Mesh 与 KNX 的状态查询与比对,确保两端对齐,避免二次控制失败。
517
523
 
518
524
  **配置方法**:
519
525
  - **开启自动状态校准**:勾选此项开启全局校准。
@@ -674,228 +680,41 @@ RS485通信桥接,支持Modbus协议透传与自定义指令映射。
674
680
 
675
681
  ## 更新日志
676
682
 
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
- - **稳定性增强**:完善各同步节点的全局状态查询参与机制,设备去重机制,映射关系持久化,确保配置不丢失
683
+ ### v1.9.7 (2026-02-28)
684
+
685
+ #### 自动状态校准 (AutoSync) 与状态查询逻辑
686
+ - **KNX Bridge「自动同步状态」**:开启后,Mesh 或 KNX 动作触发可配置延迟(默认 3s);在规定时间内完成:先对涉及设备发 `0x32` 查 Mesh 状态,约 1s 后发 KNX GroupValue_Read,收到 GroupValue_Response 后与 Mesh 比对,**不一致则仅同步到 Mesh(校准),一致则仅更新缓存不下发**;校准命令带 `isCalibration`,不触发反向 KNX 控制。
687
+ - **KNX 状态查询不改变总线状态**:仅发送 GroupValue_Read、接收 Response,绝不向 KNX 总线写 Write,查询不会改变 KNX 开关状态。
688
+ - **GroupValue_Read 处理**:严格区分 Read/Response/Write;对 GroupValue_Read 只回复缓存状态,不触发 Mesh 控制,修复 Read 分支中 mapping/addrFunc 未定义导致的引用错误。
689
+ - **DelayedSync 读响应不误触 HA**:部分 KNX 库将 GroupValue_Response 标为 Write 输出,导致 KNX-HA Bridge 误执行关灯。通过 global 约定 DelayedSync 读阶段与地址列表,KNX-HA Bridge 在此窗口内忽略这些地址的报文,避免状态查询触发 HA 控制。
690
+ - **Mesh 设备查询与 MAC 格式**:DelayedSync 与 DeviceManager `getDeviceByMac` 兼容带冒号/无冒号 MAC,确保能正确触发 0x32 查询并获取 Mesh 状态参与比对(修复「已触发 0 个 Mesh 设备的状态查询」)。
691
+ - **DelayedSync 误关灯防护**:当 KNX 组地址无真实设备或无反馈时,读回值可能错误(如刚写入 ON 却读回 OFF),若直接同步到 Mesh 会误关灯。已增加防护:5 秒内若我们向该地址写过与读回值相反的值,则仅更新缓存、不同步到 Mesh,并用日志提示,使用真实设备时行为正常。
692
+
693
+ #### 界面与体验
694
+ - **配置项布局优化**:修复「自动同步状态」在某些分辨率下的换行问题。
695
+ - **日志分级**:生产环境默认静默,关键介入逻辑使用 `node.log` 确保可追溯。
696
+
697
+ ### v1.9.0 - v1.9.6 历史迭代汇总
698
+
699
+ #### 核心修复与优化
700
+ - **防反向控制与回显保护**:
701
+ - 引入 **3秒全局 KNX 主控窗口**,确保快速连按时“最后一次操作”生效,阻止延迟反馈导致的状态回弹。
702
+ - 完善 **Last Write Wins (最终写入获胜)** 逻辑,多路开关采用 2-bit 编码解析,确保状态收敛。
703
+ - 支持可配置的 **回显检测时间窗口** (默认 500ms),严格区分控制地址与状态地址。
704
+ - **批量处理与性能**:
705
+ - 实现 **双向批量处理**:同一设备的多个通道动作合并为单条协议指令(一行代码),大幅降低总线负载。
706
+ - 优化 **部署查询逻辑**:启动时 AutoSync 与 StateQuery 互斥,避免重复读取;动态计算设备列表查询等待时间。
707
+ - **网关限流机制**:统一全局同步参数(队列长度 100,间隔 50ms),保障 50+ 实体大规模场景稳定性。
708
+ - **同步与兼容性**:
709
+ - **窗帘/调光同步增强**:区分主控来源(`isFromStateQuery`),部署或后台同步时不驱动真实电机;支持调光/窗帘锁定时间配置。
710
+ - **反馈确认闭环**:对用户控制引入 5 次重试 + 状态查询闭环,确保控制指令 100% 送达。
711
+ - **多协议联动**:修复 KNX-HA Bridge、MQTT Sync 等映射保存与显示问题,支持空调/新风/地暖三合一面板自动识别。
712
+ - **智能初始化与发现**:
713
+ - **首次启动状态快照**:仅在部署后执行一次全局状态查询,用于三合一设备检测与持久化缓存(`~/.node-red/symi-mesh-data/`),运行中完全依赖 0x80 事件上报,彻底解决查询导致的丢包问题。
714
+ - **增强设备发现**:自动处理 TCP 分包粘包,提供 10s 超时保护与完整性报告,去重机制按 index 确保唯一。
715
+ - **虚拟化与场景支持**:
716
+ - 支持 **虚拟场景实体** (MAC: 00:00:00:00:00:00),支持 KNX、RS485、HA、MQTT 全协议联动触发。
717
+ - 新增 **按键场景触发 (0x34)**,支持场景 ID 2-95,满足本地场景上报需求。
718
+ - **工程化改进**:
719
+ - 生产环境默认 **静默日志**,关键逻辑使用 `debug` 级并带 30s 缓存,避免日志刷屏。
720
+ - 修复了大量编辑器红三角校验、变量初始化顺序、内存泄漏及配置持久化保存隐患。
@@ -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
  }
@@ -1080,6 +1080,20 @@ module.exports = function(RED) {
1080
1080
  }
1081
1081
  };
1082
1082
 
1083
+ SymiGatewayNode.prototype.queryDeviceStatus = async function(networkAddr, attrType = 0x00) {
1084
+ if (!this.connected || !this.client) {
1085
+ return false;
1086
+ }
1087
+ try {
1088
+ const frame = this.protocolHandler.buildDeviceStatusQueryFrame(networkAddr, attrType);
1089
+ await this.client.sendFrame(frame, 2);
1090
+ return true;
1091
+ } catch (error) {
1092
+ this.error(`[queryDeviceStatus] 查询设备 0x${networkAddr.toString(16).toUpperCase()} 属性 0x${attrType.toString(16).toUpperCase()} 失败: ${error.message}`);
1093
+ return false;
1094
+ }
1095
+ };
1096
+
1083
1097
  SymiGatewayNode.prototype.sendControl = async function(networkAddr, attrType, param, opcode) {
1084
1098
  if (!this.connected) {
1085
1099
  throw new Error("Not connected");
@@ -10,11 +10,18 @@
10
10
  mappings: { value: '[]' },
11
11
  knxEntities: { value: '[]' },
12
12
  echoWindowEnabled: { value: false },
13
- // 允许旧 flows.json 中缺失该字段时通过校验(否则会出现“节点运行正常但编辑器一直红三角”)
13
+ // 允许旧 flows.json 中缺失该字段时通过校验(否则会出现"节点运行正常但编辑器一直红三角")
14
14
  echoWindow: { value: 500, validate: function(v) {
15
15
  if (v === '' || v === undefined || v === null) return true;
16
16
  var n = Number(v);
17
17
  return Number.isFinite(n) && n >= 0 && n <= 5000;
18
+ } },
19
+ // 自动同步状态:KNX动作后延迟读取状态并同步到Mesh
20
+ autoSyncEnabled: { value: false },
21
+ autoSyncDelay: { value: 3, validate: function(v) {
22
+ if (v === '' || v === undefined || v === null) return true;
23
+ var n = Number(v);
24
+ return Number.isFinite(n) && n >= 1 && n <= 3600;
18
25
  } }
19
26
  },
20
27
  inputs: 1,
@@ -58,6 +65,19 @@
58
65
  $echoEnabled.on('change', applyEchoUi);
59
66
  applyEchoUi();
60
67
 
68
+ // 自动同步状态初始化
69
+ const $autoSyncEnabled = $('#node-input-autoSyncEnabled');
70
+ const $autoSyncDelay = $('#node-input-autoSyncDelay');
71
+ $autoSyncEnabled.prop('checked', node.autoSyncEnabled === true || node.autoSyncEnabled === 'true');
72
+ $autoSyncDelay.val(parseInt(node.autoSyncDelay, 10) || 3);
73
+ function applyAutoSyncUi() {
74
+ const en = $autoSyncEnabled.is(':checked');
75
+ $autoSyncDelay.prop('disabled', !en);
76
+ if (!en) $autoSyncDelay.val(3);
77
+ }
78
+ $autoSyncEnabled.on('change', applyAutoSyncUi);
79
+ applyAutoSyncUi();
80
+
61
81
  function loadDevices() {
62
82
  const gid = $('#node-input-gateway').val();
63
83
  if (!gid) {
@@ -538,6 +558,9 @@
538
558
  // 回显检测窗口:不勾选则固定500ms;勾选则保存用户输入
539
559
  this.echoWindowEnabled = $('#node-input-echoWindowEnabled').is(':checked');
540
560
  this.echoWindow = this.echoWindowEnabled ? (parseInt($('#node-input-echoWindow').val(), 10) || 500) : 500;
561
+ // 自动同步状态
562
+ this.autoSyncEnabled = $('#node-input-autoSyncEnabled').is(':checked');
563
+ this.autoSyncDelay = this.autoSyncEnabled ? (parseInt($('#node-input-autoSyncDelay').val(), 10) || 3) : 3;
541
564
  }
542
565
  });
543
566
  </script>
@@ -594,6 +617,16 @@
594
617
  <label for="node-input-gateway"><i class="fa fa-server"></i> Mesh网关</label>
595
618
  <input type="text" id="node-input-gateway">
596
619
  </div>
620
+ <div class="form-row" style="display:flex;align-items:center;flex-wrap:nowrap;">
621
+ <label style="white-space:nowrap;"><i class="fa fa-refresh"></i> 自动同步状态</label>
622
+ <div style="display:flex;align-items:center;gap:6px;flex-wrap:nowrap;">
623
+ <input type="checkbox" id="node-input-autoSyncEnabled" style="width:auto;margin:0;">
624
+ <span style="white-space:nowrap;">开启后延迟</span>
625
+ <input type="number" id="node-input-autoSyncDelay" min="1" max="3600" style="width:70px;">
626
+ <span style="white-space:nowrap;">秒读取KNX状态并同步到Mesh</span>
627
+ </div>
628
+ </div>
629
+ <div class="info" style="font-size:10px;margin-top:2px;"><b>说明:</b>KNX动作后延迟读取所有开关状态并同步到Mesh,解决状态不同步问题。不会造成死循环。</div>
597
630
 
598
631
  <!-- 【已隐藏】回显检测单独配置,逻辑仍保留在节点内部,统一复用全局的开关锁时间窗口 -->
599
632
  <div class="info"><b>连接:</b> <code>[knxUltimate-in] → [KNX桥接] → [knxUltimate-out]</code> | KNX IP网关由knx-ultimate配置(端口3671)</div>
@@ -59,6 +59,15 @@ module.exports = function(RED) {
59
59
  config.echoWindowEnabled === "1";
60
60
  node.echoWindow = echoWindowEnabled ? (parseInt(config.echoWindow) || 500) : 500;
61
61
 
62
+ // 【新增】自动同步状态配置:KNX动作后延迟读取状态并同步到Mesh
63
+ node.autoSyncEnabled = config.autoSyncEnabled === true ||
64
+ config.autoSyncEnabled === "true" ||
65
+ config.autoSyncEnabled === 1 ||
66
+ config.autoSyncEnabled === "1";
67
+ node.autoSyncDelay = Math.max(1, Math.min(3600, parseInt(config.autoSyncDelay, 10) || 3)); // 1-3600秒
68
+ node.autoSyncTimer = null; // 延迟校准定时器
69
+ node.autoSyncPending = false; // 是否有待执行的校准
70
+
62
71
  // 【已删除】自动同步触发器 - 不再使用3秒倒计时
63
72
  // 现在完全依赖设备主动上报和首次部署查询进行状态同步
64
73
  node.triggerAutoSync = function(reason) {
@@ -80,20 +89,16 @@ module.exports = function(RED) {
80
89
  const readAddrs = new Set();
81
90
 
82
91
  node.mappings.forEach(m => {
83
- // 【关键修复】AutoSync应该读取控制地址(knxAddrCmd),因为Mesh发送到控制地址
84
- // 如果配置了独立的状态地址,优先读取状态地址;否则读取控制地址
85
- // 场景触发不属于双向状态对齐范畴,仅需触发成功即可
92
+ // 【KNX 协议】GroupValue_Read 必须发往「状态/反馈地址」才有正确响应;对控制地址读请求多数执行器不响应或由其他设备应答导致错误值
93
+ // 仅当配置了独立状态地址(且与控制地址不同)时才加入读取列表;仅配置控制地址的映射不发送读请求,避免读回错误 OFF
86
94
  if (m.deviceType !== "scene") {
87
- // 优先使用状态地址(如果配置了且与控制地址不同)
88
- // 如果状态地址与控制地址相同,或者没有状态地址,使用控制地址
89
- const targetAddr = (m.knxAddrStatus && m.knxAddrStatus !== m.knxAddrCmd)
90
- ? m.knxAddrStatus
91
- : m.knxAddrCmd;
92
-
93
- if (targetAddr) {
94
- readAddrs.add(targetAddr);
95
- node.debug(`[AutoSync] 设备 ${m.name}: 使用地址 ${targetAddr} (状态地址=${m.knxAddrStatus || "无"}, 控制地址=${m.knxAddrCmd})`);
95
+ const hasDedicatedStatus = m.knxAddrStatus && m.knxAddrStatus.trim() !== "" && m.knxAddrStatus !== m.knxAddrCmd;
96
+ if (!hasDedicatedStatus) {
97
+ node.debug(`[AutoSync] 设备 ${m.name}: 未配置独立状态地址,跳过读取 (控制地址=${m.knxAddrCmd})`);
98
+ return;
96
99
  }
100
+ readAddrs.add(m.knxAddrStatus);
101
+ node.debug(`[AutoSync] 设备 ${m.name}: 读取状态地址 ${m.knxAddrStatus}`);
97
102
  }
98
103
  });
99
104
 
@@ -128,7 +133,228 @@ module.exports = function(RED) {
128
133
  }
129
134
  node.syncTimer = null;
130
135
  };
131
-
136
+
137
+ // 【新增】延迟校准函数:KNX动作后延迟读取所有开关状态并同步到Mesh
138
+ // 触发条件:KNX配置的同步实体发生任何动作
139
+ // 执行逻辑:延迟N秒后读取所有KNX开关组地址状态,批量同步到Mesh
140
+ // 防死循环:同步到Mesh时标记为校准命令,不触发反向KNX控制
141
+ node.triggerDelayedSync = function(reason) {
142
+ if (!node.autoSyncEnabled) {
143
+ node.debug("[DelayedSync] 自动同步未开启,跳过");
144
+ return;
145
+ }
146
+
147
+ // 清除之前的定时器(防抖:多次触发只执行最后一次)
148
+ if (node.autoSyncTimer) {
149
+ clearTimeout(node.autoSyncTimer);
150
+ node.autoSyncTimer = null;
151
+ }
152
+
153
+ // 标记有待执行的校准
154
+ node.autoSyncPending = true;
155
+
156
+ const delayMs = node.autoSyncDelay * 1000;
157
+ node.log(`[DelayedSync] KNX动作触发,${node.autoSyncDelay}秒后读取所有KNX状态并同步到Mesh (原因: ${reason})`);
158
+
159
+ node.autoSyncTimer = setTimeout(() => {
160
+ if (node.isClosed || !node.autoSyncPending) return;
161
+ node.autoSyncTimer = null;
162
+ node.autoSyncPending = false;
163
+ node.performDelayedSync();
164
+ }, delayMs);
165
+ };
166
+
167
+ // 执行延迟校准:读取所有KNX开关状态并同步到Mesh
168
+ // DelayedSync 流程(KNX 动作后延迟 node.autoSyncDelay 秒,由配置决定):先查 Mesh 真实状态(0x32),约 1 秒后再查 KNX 状态(GroupValue_Read 仅发往已配置的独立状态地址),收到 Response 后对比 KNX 与 Mesh,仅在不一致且读回可信时同步到 Mesh;若读回与近期已发状态相反则仅更新缓存、不向 Mesh 下发,灯不会因查询而关闭
169
+ node.performDelayedSync = function() {
170
+ if (!node.gateway || !node.mappings || node.mappings.length === 0) {
171
+ node.debug("[DelayedSync] 网关未连接或无映射配置,跳过");
172
+ return;
173
+ }
174
+
175
+ // 步骤1:主动查询 Mesh 设备状态(0x32),确保约 1 秒后对比时 Mesh 状态已更新
176
+ // 收集所有涉及的Mesh设备MAC(去重)
177
+ const meshMacs = new Set();
178
+ node.mappings.forEach(m => {
179
+ if (m.meshMac) meshMacs.add(m.meshMac.toLowerCase().replace(/:/g, ""));
180
+ });
181
+
182
+ if (meshMacs.size > 0 && node.gateway.deviceManager) {
183
+ let queryCount = 0;
184
+ meshMacs.forEach(mac => {
185
+ // deviceManager 的 key 可能是带冒号格式,兼容无冒号传入
186
+ let device = node.gateway.deviceManager.getDeviceByMac(mac);
187
+ if (!device && mac.length === 12 && !mac.includes(":")) {
188
+ const macWithColons = mac.match(/.{2}/g).join(":");
189
+ device = node.gateway.deviceManager.getDeviceByMac(macWithColons);
190
+ }
191
+ if (device && device.networkAddress) {
192
+ // Opcode 0x32 (50), 无参数表示查询所有状态
193
+ node.gateway.queryDeviceStatus(device.networkAddress, 0x00)
194
+ .then(success => {
195
+ if (success) {
196
+ // 标记该设备刚刚被查询,handleMeshStateChange 会据此防止反向回环
197
+ node.pendingMeshQueries.set(mac, Date.now());
198
+ }
199
+ })
200
+ .catch(err => node.warn(`[DelayedSync] 查询Mesh设备失败: ${mac}, ${err.message}`));
201
+ queryCount++;
202
+ }
203
+ });
204
+ node.log(`[DelayedSync] 已触发 ${queryCount} 个Mesh设备的状态查询`);
205
+ }
206
+
207
+ // 步骤2:延迟 1 秒后读取 KNX 状态(仅对已配置独立状态地址的开关发 GroupValue_Read),给 Mesh 0x32 响应留出时间
208
+ setTimeout(() => {
209
+ node.log("[DelayedSync] 开始读取所有KNX开关状态...");
210
+
211
+ // 收集所有需要读取的KNX开关组地址(去重)
212
+ const readAddrs = new Set();
213
+ const addrToMapping = new Map(); // 地址到映射的映射
214
+
215
+ node.mappings.forEach(m => {
216
+ // 只处理开关类型设备
217
+ if (m.deviceType === "switch") {
218
+ // 【KNX 协议】GroupValue_Read 必须发往「状态/反馈地址」;对控制地址读请求执行器通常不响应,易收到错误 OFF
219
+ // 仅当配置了独立状态地址时才加入 DelayedSync 读取列表;仅控制地址的映射不读,避免「查错地址」导致 KNX=OFF、Mesh=ON 误判
220
+ const hasDedicatedStatus = m.knxAddrStatus && m.knxAddrStatus.trim() !== "" && m.knxAddrStatus !== m.knxAddrCmd;
221
+ if (!hasDedicatedStatus) {
222
+ node.debug(`[DelayedSync] 开关 ${m.name} 未配置独立状态地址,跳过读取 (控制地址=${m.knxAddrCmd})`);
223
+ return;
224
+ }
225
+ const targetAddr = m.knxAddrStatus;
226
+ readAddrs.add(targetAddr);
227
+ if (!addrToMapping.has(targetAddr)) {
228
+ addrToMapping.set(targetAddr, []);
229
+ }
230
+ addrToMapping.get(targetAddr).push(m);
231
+ }
232
+ });
233
+
234
+ if (readAddrs.size === 0) {
235
+ node.debug("[DelayedSync] 没有需要同步的开关设备");
236
+ return;
237
+ }
238
+
239
+ // 记录读取请求时间戳,用于识别Response
240
+ const now = Date.now();
241
+ node.delayedSyncReadTime = now;
242
+
243
+ // 通知其他节点(如 KNX-HA Bridge):当前处于 DelayedSync 读阶段,这些地址的报文是 Read 的 Response,不要当作用户 Write 处理
244
+ const readAddrList = [...readAddrs];
245
+ try {
246
+ const g = node.context().global;
247
+ g.set("symi_knx_delayed_sync_until", now + 5000);
248
+ g.set("symi_knx_delayed_sync_addrs", JSON.stringify(readAddrList));
249
+ } catch (e) { /* 无 global 时忽略 */ }
250
+
251
+ readAddrs.forEach(addr => {
252
+ node.pendingAutoSyncReads.set(addr, now);
253
+ // 发送GroupValue_Read请求
254
+ node.send([{
255
+ topic: addr,
256
+ destination: addr,
257
+ payload: 0,
258
+ dpt: "1.001",
259
+ event: "GroupValue_Read"
260
+ }, null]);
261
+ });
262
+
263
+ node.log(`[DelayedSync] 已发送 ${readAddrs.size} 个KNX状态读取请求,等待响应后同步到Mesh`);
264
+
265
+ // 10秒后清理过期的读取请求记录
266
+ setTimeout(() => {
267
+ const expireTime = Date.now() - 10000;
268
+ for (const [addr, timestamp] of node.pendingAutoSyncReads.entries()) {
269
+ if (timestamp < expireTime) {
270
+ node.pendingAutoSyncReads.delete(addr);
271
+ }
272
+ }
273
+ }, 10000);
274
+ }, 1000); // 延迟1秒读取KNX
275
+ };
276
+
277
+ // 【新增】处理延迟校准的Response:将KNX状态同步到Mesh
278
+ node.handleDelayedSyncResponse = function(groupAddr, value, mapping) {
279
+ if (!node.delayedSyncReadTime) return false;
280
+
281
+ // 检查是否是延迟校准的Response(在读取请求后10秒内)
282
+ const readTime = node.pendingAutoSyncReads.get(groupAddr);
283
+ if (!readTime || Date.now() - readTime > 10000) return false;
284
+
285
+ // 检查是否是延迟校准期间的Response
286
+ if (readTime < node.delayedSyncReadTime - 100) return false;
287
+
288
+ // 解析布尔值
289
+ let switchValue = false;
290
+ if (Buffer.isBuffer(value)) {
291
+ switchValue = value.length > 0 && value[0] !== 0;
292
+ } else {
293
+ switchValue = (value === 1 || value === true || value === "on" || value === "ON" || value === "1");
294
+ }
295
+ const macNormalized = (mapping.meshMac || "").toLowerCase().replace(/:/g, "");
296
+
297
+ // 获取Mesh当前状态
298
+ const meshDevice = node.gateway.deviceManager ?
299
+ node.gateway.deviceManager.getDeviceByMac(macNormalized) : null;
300
+
301
+ if (!meshDevice) {
302
+ node.debug(`[DelayedSync] 设备 ${mapping.name} 不在线,跳过同步`);
303
+ return true;
304
+ }
305
+
306
+ // 检查Mesh当前状态
307
+ const switchKey = `switch_${mapping.meshChannel}`;
308
+ const meshState = meshDevice.state ? meshDevice.state[switchKey] : undefined;
309
+
310
+ // 【关键修复】如果Mesh状态未知,不要默认为OFF,而是跳过同步并警告
311
+ if (meshState === undefined) {
312
+ node.warn(`[DelayedSync] ${mapping.name} CH${mapping.meshChannel} Mesh状态未知(即使已查询),跳过同步。KNX=${switchValue ? "ON" : "OFF"}`);
313
+ return true;
314
+ }
315
+
316
+ const meshBool = (meshState === true || meshState === 1 || meshState === "on" || meshState === "ON");
317
+
318
+ if (meshBool === switchValue) {
319
+ node.debug(`[DelayedSync] ${mapping.name} CH${mapping.meshChannel} 状态一致: ${switchValue ? "ON" : "OFF"}`);
320
+ return true;
321
+ }
322
+
323
+ // 防护1:近期我们向该 KNX 地址写过与读回值相反;防护2:近期从 KNX 收到并已发往 Mesh 的值与读回值相反
324
+ const lastSentTime = node.lastKnxAddrSent[groupAddr] || 0;
325
+ const lastSentVal = node.lastKnxValueSent[groupAddr];
326
+ const lastWasOn = (lastSentVal === true || lastSentVal === 1 || lastSentVal === "on" || lastSentVal === "ON");
327
+ const lastWasOff = (lastSentVal === false || lastSentVal === 0 || lastSentVal === "off" || lastSentVal === "OFF");
328
+ const recentWriteOpposite = (Date.now() - lastSentTime < 5000) && ((lastWasOn && !switchValue) || (lastWasOff && switchValue));
329
+ const deviceKey = `${macNormalized}_${mapping.meshChannel}`;
330
+ const lastToMeshTime = node.lastKnxToMeshTime ? node.lastKnxToMeshTime[deviceKey] : 0;
331
+ const lastToMeshVal = node.lastKnxToMeshValue ? node.lastKnxToMeshValue[deviceKey] : undefined;
332
+ const recentKnxToMeshOpposite = (Date.now() - lastToMeshTime < 5000) && (lastToMeshVal === true && !switchValue || lastToMeshVal === false && switchValue);
333
+ if (recentWriteOpposite || recentKnxToMeshOpposite) {
334
+ node.log(`[DelayedSync] ${mapping.name} CH${mapping.meshChannel} 读回与近期已发状态相反,可能无真实设备/反馈,仅更新缓存不同步到Mesh`);
335
+ if (!node.stateCache[macNormalized]) node.stateCache[macNormalized] = {};
336
+ node.stateCache[macNormalized][switchKey] = switchValue;
337
+ if (mapping.meshChannel === 1) node.stateCache[macNormalized]["switch"] = switchValue;
338
+ return true;
339
+ }
340
+
341
+ // 状态不一致,同步到Mesh
342
+ node.log(`[DelayedSync] ${mapping.name} CH${mapping.meshChannel} 状态不一致: KNX=${switchValue ? "ON" : "OFF"}, Mesh=${meshBool ? "ON" : "OFF"} -> 同步到Mesh`);
343
+ if (!node.stateCache[macNormalized]) node.stateCache[macNormalized] = {};
344
+ node.stateCache[macNormalized][switchKey] = switchValue;
345
+ if (mapping.meshChannel === 1) node.stateCache[macNormalized]["switch"] = switchValue;
346
+ node.queueCommand({
347
+ direction: "knx-to-mesh",
348
+ mapping: mapping,
349
+ type: "switch",
350
+ value: switchValue,
351
+ key: `${mapping.meshMac}_${mapping.meshChannel}_delayed_sync`,
352
+ sourceAddr: groupAddr,
353
+ isCalibration: true
354
+ });
355
+ return true;
356
+ };
357
+
132
358
  // 解析KNX实体库
133
359
  let knxEntities = [];
134
360
  try {
@@ -250,7 +476,10 @@ module.exports = function(RED) {
250
476
  // 打印映射配置
251
477
  node.mappings.forEach((m, i) => {
252
478
  const typeConfig = DEVICE_TYPES[m.deviceType] || DEVICE_TYPES["switch"];
253
- node.log(`[映射${i+1}] ${m.name}: Mesh ${m.meshMac} <-> KNX ${m.knxAddrCmd} (${typeConfig.name})`);
479
+ const statusPart = (m.knxAddrStatus && m.knxAddrStatus.trim() !== "" && m.knxAddrStatus !== m.knxAddrCmd)
480
+ ? `, 状态=${m.knxAddrStatus}`
481
+ : "";
482
+ node.log(`[映射${i+1}] ${m.name}: Mesh ${m.meshMac} <-> KNX ${m.knxAddrCmd}${statusPart} (${typeConfig.name})`);
254
483
  });
255
484
  } catch (e) {
256
485
  node.mappings = [];
@@ -283,6 +512,9 @@ module.exports = function(RED) {
283
512
  node.knxStateCache = {}; // KNX设备状态缓存
284
513
  node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间
285
514
  node.lastKnxValueSent = {}; // 记录发送到KNX地址的值
515
+ // 记录“从 KNX 收到并已发往 Mesh”的值与时间,用于 DelayedSync 时判断读回是否可信(避免无反馈地址导致误关灯)
516
+ node.lastKnxToMeshValue = {}; // deviceKey -> true/false
517
+ node.lastKnxToMeshTime = {}; // deviceKey -> timestamp
286
518
 
287
519
  // 【新增】反馈确认机制:记录待确认的命令
288
520
  // { commandId: { direction, mapping, expectedValue, sentTime, timeout } }
@@ -613,12 +845,12 @@ module.exports = function(RED) {
613
845
  }
614
846
  node.log(`[Mesh事件] MAC=${macNormalized}, 找到${matchedMappings.length}个映射, 变化: ${JSON.stringify(changed)}`);
615
847
 
616
- // 【优化】触发自动同步 (Layer 2 Protection)
617
- // 只要有用户控制,就在3秒后校准,确保状态一致
618
- // 【修复】移除这里的AutoSync触发,改为在每个具体控制命令后触发
619
- // 这样可以更精确地控制触发时机,避免重复触发
620
- // 注意:每个设备类型的控制逻辑中都会单独触发AutoSync
621
-
848
+ // 【优化】触发延迟校准 (Layer 2 Protection)
849
+ // 只要有用户控制,就在指定秒数后校准,确保状态一致
850
+ if (eventData.isUserControl && node.autoSyncEnabled) {
851
+ node.triggerDelayedSync(`Mesh事件: ${macNormalized}`);
852
+ }
853
+
622
854
  // 【关键优化】批量处理:先收集所有需要处理的开关命令,然后一次性批量加入队列
623
855
  // 这样当Mesh设备的所有通道同时变化时,可以批量处理所有映射的KNX地址
624
856
  // 而不是逐个处理,提高同步效率(50个KNX开关同时动作时只需要很少的队列命令)
@@ -634,7 +866,7 @@ module.exports = function(RED) {
634
866
  // 无论哪个设备被KNX控制,只要在活动窗口内就阻止所有Mesh→KNX同步
635
867
  // 这样即使某个设备没有被直接控制,或者isUserControl判断错误,也能被阻止
636
868
  if (eventData.isUserControl && node.lastKnxActivityTime && (now - node.lastKnxActivityTime) < KNX_MASTER_WINDOW_MS) {
637
- node.debug(`[Mesh->KNX] 阻止(全局KNX活动): ${deviceKey}, 剩余${KNX_MASTER_WINDOW_MS - (now - node.lastKnxActivityTime)}ms`);
869
+ node.log(`[Mesh->KNX介入] 阻止同步(全局KNX活动窗口内): ${deviceKey}, 剩余${KNX_MASTER_WINDOW_MS - (now - node.lastKnxActivityTime)}ms`);
638
870
  continue;
639
871
  }
640
872
 
@@ -645,7 +877,7 @@ module.exports = function(RED) {
645
877
  // 因为状态反馈(isUserControl=false)本身就应该被跳过,不需要knxControlTimestamps检查
646
878
  const knxControlTime = node.knxControlTimestamps[deviceKey];
647
879
  if (eventData.isUserControl && knxControlTime && (now - knxControlTime) < KNX_MASTER_WINDOW_MS) {
648
- node.debug(`[Mesh->KNX] 阻止(KNX控制中): ${deviceKey}, 剩余${KNX_MASTER_WINDOW_MS - (now - knxControlTime)}ms`);
880
+ node.log(`[Mesh->KNX介入] 阻止同步(单设备KNX控制窗口内): ${deviceKey}, 剩余${KNX_MASTER_WINDOW_MS - (now - knxControlTime)}ms`);
649
881
  continue;
650
882
  }
651
883
 
@@ -728,7 +960,7 @@ module.exports = function(RED) {
728
960
  // 【关键优化】检查全局KNX活动时间窗口(解决场景批量命令问题)
729
961
  const now = Date.now();
730
962
  if (node.lastKnxActivityTime && (now - node.lastKnxActivityTime) < KNX_MASTER_WINDOW_MS) {
731
- node.debug(`[Mesh->KNX] 阻止(全局KNX活动): ${mapping.name} CH${mapping.meshChannel}, 剩余${KNX_MASTER_WINDOW_MS - (now - node.lastKnxActivityTime)}ms`);
963
+ node.log(`[Mesh->KNX介入] 阻止同步(全局KNX活动窗口内): ${mapping.name} CH${mapping.meshChannel}, 剩余${KNX_MASTER_WINDOW_MS - (now - node.lastKnxActivityTime)}ms`);
732
964
  continue;
733
965
  }
734
966
 
@@ -807,7 +1039,7 @@ module.exports = function(RED) {
807
1039
  // 【关键优化】检查全局KNX活动时间窗口(解决场景批量命令问题)
808
1040
  const now = Date.now();
809
1041
  if (node.lastKnxActivityTime && (now - node.lastKnxActivityTime) < KNX_MASTER_WINDOW_MS) {
810
- node.debug(`[Mesh->KNX] 阻止(全局KNX活动): ${mapping.name}, 剩余${KNX_MASTER_WINDOW_MS - (now - node.lastKnxActivityTime)}ms`);
1042
+ node.log(`[Mesh->KNX介入] 阻止调光灯同步(全局KNX活动窗口内): ${mapping.name}, 剩余${KNX_MASTER_WINDOW_MS - (now - node.lastKnxActivityTime)}ms`);
811
1043
  continue;
812
1044
  }
813
1045
 
@@ -2237,7 +2469,9 @@ module.exports = function(RED) {
2237
2469
  }
2238
2470
 
2239
2471
  // 【关键修复】确保在发送命令前更新时间戳,防止反向同步
2240
- if (type === "switch" && !isStatusFeedback && !isCalibration) {
2472
+ // 即使是 Calibration 命令也需要更新时间戳,因为 calibration 会触发 Mesh 状态变化
2473
+ // 如果不更新时间戳,Mesh 状态变化会被误判为用户控制,从而反向同步回 KNX,导致状态震荡
2474
+ if (type === "switch" && !isStatusFeedback) {
2241
2475
  const deviceKey = `${macNormalized}_${mapping.meshChannel}`;
2242
2476
  node.knxControlTimestamps[deviceKey] = Date.now();
2243
2477
  }
@@ -2335,7 +2569,12 @@ module.exports = function(RED) {
2335
2569
  totalChannels: totalChannels,
2336
2570
  channel: channel
2337
2571
  });
2338
-
2572
+ // 记录本次 KNX->Mesh 已发送的值,供 DelayedSync 判断“读回是否与刚发的相反”(无反馈地址时会误同步)
2573
+ if (!cmd.isCalibration) {
2574
+ const deviceKey = `${macNormalized}_${channel}`;
2575
+ node.lastKnxToMeshValue[deviceKey] = !!finalValue;
2576
+ node.lastKnxToMeshTime[deviceKey] = Date.now();
2577
+ }
2339
2578
  // 如果处于熔断窗口内:本次命令不进入反馈确认闭环,避免再次触发 5 次重试逻辑
2340
2579
  if (failStat && nowForCircuit - failStat.lastWarnTime < CIRCUIT_BREAKER_MS) {
2341
2580
  node.debug(`[反馈确认] KNX->Mesh 命令处于失败熔断窗口内,仅单次发送: ${mapping.name || "未知设备"} CH${channel} = ${finalValue ? "ON" : "OFF"}`);
@@ -2723,6 +2962,136 @@ module.exports = function(RED) {
2723
2962
  const dpt = node.normalizeDpt(msg.knx?.dpt || msg.dpt || "");
2724
2963
  const event = msg.knx?.event || msg.event || "GroupValue_Write";
2725
2964
 
2965
+ // 【关键修复】严格区分 KNX 事件类型
2966
+ // 1. GroupValue_Read: 必须拦截并回复 Response,绝对禁止流转到控制逻辑
2967
+ // 2. GroupValue_Response: 必须拦截并更新缓存,绝对禁止流转到控制逻辑
2968
+ // 3. GroupValue_Write: 只有 Write 才能流转到控制逻辑
2969
+ // 4. 其他未知事件: 直接忽略
2970
+ // Read/Response 分支需要先解析 mapping 与 addrFunc
2971
+ const mapping = node.findKnxMapping(groupAddr);
2972
+ const addrFunc = mapping ? node.getKnxAddrFunction(mapping, groupAddr) : "";
2973
+
2974
+ if (event === "GroupValue_Read") {
2975
+ if (!mapping) {
2976
+ node.debug(`[KNX查询] 收到读取请求 ${groupAddr},未找到映射,忽略`);
2977
+ done && done();
2978
+ return;
2979
+ }
2980
+ const macNormalized = (mapping.meshMac || "").toLowerCase().replace(/:/g, "");
2981
+ let cachedVal = undefined;
2982
+ let responseDpt = dpt || "1.001";
2983
+
2984
+ // 尝试从缓存获取状态并回复 Response
2985
+ if (node.stateCache[macNormalized]) {
2986
+ const cached = node.stateCache[macNormalized];
2987
+
2988
+ // 开关/调光灯 (Switch/Light)
2989
+ if (mapping.deviceType === "switch" || mapping.deviceType.startsWith("light_")) {
2990
+ // 判断查询的是开关还是其他属性
2991
+ if (addrFunc === "cmd" || addrFunc === "status") {
2992
+ const switchKey = `switch_${mapping.meshChannel}`;
2993
+ cachedVal = cached[switchKey];
2994
+ if (cachedVal === undefined && mapping.meshChannel === 1) {
2995
+ cachedVal = cached["switch"];
2996
+ }
2997
+ responseDpt = "1.001";
2998
+ } else if (addrFunc === "brightness") {
2999
+ cachedVal = cached.brightness;
3000
+ if (cachedVal !== undefined) {
3001
+ // 转换为 KNX 0-255 范围 (Mesh 0-100)
3002
+ cachedVal = Math.round(cachedVal * 255 / 100);
3003
+ }
3004
+ responseDpt = "5.001";
3005
+ } else if (addrFunc === "colorTemp") {
3006
+ cachedVal = cached.colorTemp;
3007
+ responseDpt = "5.001"; // 或 7.600
3008
+ }
3009
+ }
3010
+ // 窗帘 (Cover)
3011
+ else if (mapping.deviceType === "cover") {
3012
+ if (addrFunc === "position" || addrFunc === "status") {
3013
+ cachedVal = cached.curtainPosition;
3014
+ if (cachedVal !== undefined) {
3015
+ // 如果反转位置
3016
+ if (mapping.invertPosition) {
3017
+ cachedVal = 100 - cachedVal;
3018
+ }
3019
+ }
3020
+ responseDpt = "5.001";
3021
+ }
3022
+ }
3023
+ // 空调 (Climate)
3024
+ else if (mapping.deviceType === "climate") {
3025
+ if (addrFunc === "cmd" || addrFunc === "status") {
3026
+ cachedVal = cached.climateSwitch || cached.acSwitch;
3027
+ responseDpt = "1.001";
3028
+ } else if (addrFunc === "temp") {
3029
+ cachedVal = cached.targetTemp || cached.acTargetTemp;
3030
+ responseDpt = "9.001";
3031
+ } else if (addrFunc === "currentTemp") {
3032
+ cachedVal = cached.currentTemp || cached.temperature;
3033
+ responseDpt = "9.001";
3034
+ } else if (addrFunc === "mode") {
3035
+ cachedVal = cached.climateMode || cached.acMode;
3036
+ responseDpt = "20.102";
3037
+ } else if (addrFunc === "fanSpeed") {
3038
+ // Mesh风速: 1=高, 2=中, 3=低, 4=自动 -> 百分比
3039
+ const speedMap = { 1: 100, 2: 66, 3: 33, 4: 50 };
3040
+ const val = cached.fanMode || cached.acFanSpeed;
3041
+ if (val) cachedVal = speedMap[val] || 50;
3042
+ responseDpt = "5.001";
3043
+ }
3044
+ }
3045
+ // 新风 (Fresh Air)
3046
+ else if (mapping.deviceType === "fresh_air") {
3047
+ if (addrFunc === "cmd" || addrFunc === "status") {
3048
+ cachedVal = cached.freshAirSwitch;
3049
+ responseDpt = "1.001";
3050
+ } else if (addrFunc === "fanSpeed") {
3051
+ const speedMap = { 1: 100, 2: 66, 3: 33, 4: 50 };
3052
+ const val = cached.freshAirSpeed;
3053
+ if (val) cachedVal = speedMap[val] || 50;
3054
+ responseDpt = "5.001";
3055
+ }
3056
+ }
3057
+ // 地暖 (Floor Heating)
3058
+ else if (mapping.deviceType === "floor_heating") {
3059
+ if (addrFunc === "cmd" || addrFunc === "status") {
3060
+ cachedVal = cached.floorHeatingSwitch;
3061
+ responseDpt = "1.001";
3062
+ } else if (addrFunc === "temp") {
3063
+ cachedVal = cached.floorHeatingTemp;
3064
+ responseDpt = "9.001";
3065
+ } else if (addrFunc === "currentTemp") {
3066
+ cachedVal = cached.currentTemp || cached.temperature || cached.floorHeatingTemp;
3067
+ responseDpt = "9.001";
3068
+ }
3069
+ }
3070
+ }
3071
+
3072
+ if (cachedVal !== undefined) {
3073
+ const responseMsg = {
3074
+ topic: groupAddr,
3075
+ destination: groupAddr,
3076
+ payload: cachedVal,
3077
+ dpt: responseDpt,
3078
+ event: "GroupValue_Response"
3079
+ };
3080
+ node.send([responseMsg, null]);
3081
+ node.log(`[KNX查询] 收到读取请求 ${groupAddr} (${mapping.name} ${addrFunc}),回复缓存状态: ${cachedVal}`);
3082
+ } else {
3083
+ node.debug(`[KNX查询] 收到读取请求 ${groupAddr} (${mapping.name} ${addrFunc}),缓存无状态或不支持该类型,忽略`);
3084
+ }
3085
+
3086
+ done && done();
3087
+ return;
3088
+ }
3089
+
3090
+ // 如果不是 Write 事件,且不是 Response (Response 在下面有专门逻辑),则忽略
3091
+ // 注意:下面的 Response 逻辑依赖于 isResponse 变量,所以这里先不返回
3092
+ // 但是为了安全起见,我们应该明确标记非 Write 事件不能进入控制逻辑
3093
+ const isWrite = (event === "GroupValue_Write");
3094
+
2726
3095
  // 【增强】Response 检测:除了检查 event,还检查是否是 AutoSync 读取请求的响应
2727
3096
  // 如果该地址在 pendingAutoSyncReads 中,且距离读取请求时间在10秒内,认为是 Response
2728
3097
  const isExplicitResponse = event === "GroupValue_Response";
@@ -2730,10 +3099,20 @@ module.exports = function(RED) {
2730
3099
  (Date.now() - node.pendingAutoSyncReads.get(groupAddr)) < 10000;
2731
3100
  const isResponse = isExplicitResponse || isAutoSyncResponse;
2732
3101
 
2733
- // 如果是 AutoSync Response,记录日志并清理跟踪记录
3102
+ // 【关键修复】最后一道防线:严格检查事件类型
3103
+ // 只有 GroupValue_Write 事件才能触发控制逻辑
3104
+ // Response 事件在下面有专门处理逻辑 (DelayedSync 和 StatusOnly)
3105
+ // Read 事件在最上面已经处理并 return 了
3106
+ if (!isWrite && !isResponse) {
3107
+ node.debug(`[KNX输入] 跳过非Write/Response事件: ${groupAddr} event=${event}`);
3108
+ done && done();
3109
+ return;
3110
+ }
3111
+
3112
+ // 【注意】不要在这里删除 pendingAutoSyncReads 记录!
3113
+ // 需要在后面的 DelayedSync 或 AutoSync 处理完成后才删除
2734
3114
  if (isAutoSyncResponse && !isExplicitResponse) {
2735
- node.log(`[AutoSync] 识别到 Response (通过时间戳匹配): ${groupAddr} = ${value}`);
2736
- node.pendingAutoSyncReads.delete(groupAddr);
3115
+ node.debug(`[AutoSync] 识别到 Response (通过时间戳匹配): ${groupAddr} = ${value}`);
2737
3116
  }
2738
3117
 
2739
3118
  if (!groupAddr) {
@@ -2741,17 +3120,13 @@ module.exports = function(RED) {
2741
3120
  return;
2742
3121
  }
2743
3122
 
2744
- // 查找映射
2745
- const mapping = node.findKnxMapping(groupAddr);
3123
+ // 映射与 addrFunc 已在上面统一解析;非 Read 分支也需有映射才继续
2746
3124
  if (!mapping) {
2747
3125
  node.debug(`[KNX输入] 未找到映射: ${groupAddr}`);
2748
3126
  done && done();
2749
3127
  return;
2750
3128
  }
2751
-
2752
- // 确定地址功能(优先使用地址匹配,比DPT更可靠)
2753
- // 【关键优化】必须在回显检测之前确定地址功能,用于判断是否是状态地址
2754
- const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
3129
+ // addrFunc 已在上面根据 mapping 解析
2755
3130
 
2756
3131
  // 通用值解析:支持 Boolean, Number, String, Buffer
2757
3132
  // 【关键修复】parseBoolean 必须在所有使用之前定义,避免 "Cannot access 'parseBoolean' before initialization"
@@ -2959,15 +3334,90 @@ module.exports = function(RED) {
2959
3334
 
2960
3335
  const loopKey = `${mapping.meshMac}_${mapping.meshChannel}_switch_${switchValue}`;
2961
3336
 
2962
- // ========== 【关键修改】Response处理逻辑:部署/查询响应仅更新缓存,不发送控制 ==========
2963
- // 原则:只有主动发起的控制才发送控制指令,Response 为读取回复,仅更新缓存
3337
+ // ========== 【关键修改】Response处理逻辑 ==========
3338
+ // 1. 延迟校准Response:比较状态后同步到Mesh(不触发反向KNX控制)
3339
+ // 2. 部署/查询Response:仅更新缓存,不发送控制
2964
3340
  if (isResponse) {
2965
- node.log(`[AutoSync] 收到状态响应: ${mapping.name} CH${mapping.meshChannel}, KNX=${switchValue ? "ON" : "OFF"}, 仅更新缓存不下发`);
2966
3341
  const macNormalized = (mapping.meshMac || "").toLowerCase().replace(/:/g, "");
3342
+
3343
+ // 【新增】检查是否是延迟校准期间的Response
3344
+ const hasDelayedSyncTime = !!node.delayedSyncReadTime;
3345
+ const hasPendingRead = node.pendingAutoSyncReads.has(groupAddr);
3346
+
3347
+ if (hasDelayedSyncTime && hasPendingRead) {
3348
+ const readTime = node.pendingAutoSyncReads.get(groupAddr);
3349
+ const timeDiff = Date.now() - readTime;
3350
+ const isDelayedSyncResponse = timeDiff < 10000 && readTime >= node.delayedSyncReadTime - 100;
3351
+
3352
+ node.debug(`[DelayedSync检查] groupAddr=${groupAddr}, readTime=${readTime}, delayedSyncReadTime=${node.delayedSyncReadTime}, timeDiff=${timeDiff}ms, isDelayedSyncResponse=${isDelayedSyncResponse}`);
3353
+
3354
+ if (isDelayedSyncResponse) {
3355
+ // 这是延迟校准的Response,执行状态比较和同步
3356
+ const meshDevice = node.gateway ? node.gateway.getDevice(macNormalized) : null;
3357
+ const switchKey = `switch_${mapping.meshChannel}`;
3358
+ const meshState = meshDevice && meshDevice.state ? meshDevice.state[switchKey] : undefined;
3359
+ const meshBool = (meshState === true || meshState === 1 || meshState === "on" || meshState === "ON");
3360
+
3361
+ // 关键排障信息:哪个GA、谁应答、事件类型(有些 knx 库会把 Response 标成 Write,这里需要看 src/event 来确认)
3362
+ const srcAddr = msg && msg.knx ? (msg.knx.src || msg.knx.source || "") : "";
3363
+ node.log(`[DelayedSync] 收到Response: ${mapping.name} CH${mapping.meshChannel}, GA=${groupAddr}, Src=${srcAddr || "N/A"}, Event=${event}, KNX=${switchValue ? "ON" : "OFF"}, Mesh=${meshState !== undefined ? (meshBool ? "ON" : "OFF") : "未知"}`);
3364
+
3365
+ if (meshState !== undefined && meshBool !== switchValue) {
3366
+ // 防护1:若近期我们向该 KNX 地址写过与 Response 相反的值,可能是该组地址无真实设备/无反馈
3367
+ const lastSentTime = node.lastKnxAddrSent[groupAddr] || 0;
3368
+ const lastSentVal = node.lastKnxValueSent[groupAddr];
3369
+ const lastWasOn = (lastSentVal === true || lastSentVal === 1 || lastSentVal === "on" || lastSentVal === "ON");
3370
+ const lastWasOff = (lastSentVal === false || lastSentVal === 0 || lastSentVal === "off" || lastSentVal === "OFF");
3371
+ const recentWriteOpposite = (Date.now() - lastSentTime < 5000) && ((lastWasOn && !switchValue) || (lastWasOff && switchValue));
3372
+ // 防护2:若近期从 KNX 收到并已发往 Mesh 的值与读回值相反(例如 KNX 面板刚开灯、我们已发 ON 到 Mesh,但读回 OFF),说明读回不可信,不向 Mesh 下发校准
3373
+ const deviceKey = `${macNormalized}_${mapping.meshChannel}`;
3374
+ const lastToMeshTime = node.lastKnxToMeshTime ? node.lastKnxToMeshTime[deviceKey] : 0;
3375
+ const lastToMeshVal = node.lastKnxToMeshValue ? node.lastKnxToMeshValue[deviceKey] : undefined;
3376
+ const recentKnxToMeshOpposite = (Date.now() - lastToMeshTime < 5000) && (lastToMeshVal === true && !switchValue || lastToMeshVal === false && switchValue);
3377
+ if (recentWriteOpposite || recentKnxToMeshOpposite) {
3378
+ node.log(`[DelayedSync] ${mapping.name} CH${mapping.meshChannel} 读回与近期已发状态相反,可能无真实设备/反馈,仅更新缓存不同步到Mesh(未向Mesh下发,灯不会因查询而关闭)`);
3379
+ if (!node.stateCache[macNormalized]) node.stateCache[macNormalized] = {};
3380
+ node.stateCache[macNormalized][switchKey] = switchValue;
3381
+ if (mapping.meshChannel === 1) node.stateCache[macNormalized]["switch"] = switchValue;
3382
+ } else {
3383
+ // 状态不一致,同步到Mesh
3384
+ node.log(`[DelayedSync] ${mapping.name} CH${mapping.meshChannel} 状态不一致: KNX=${switchValue ? "ON" : "OFF"}, Mesh=${meshBool ? "ON" : "OFF"} -> 同步到Mesh`);
3385
+ if (!node.stateCache[macNormalized]) node.stateCache[macNormalized] = {};
3386
+ node.stateCache[macNormalized][switchKey] = switchValue;
3387
+ if (mapping.meshChannel === 1) node.stateCache[macNormalized]["switch"] = switchValue;
3388
+ node.queueCommand({
3389
+ direction: "knx-to-mesh",
3390
+ mapping: mapping,
3391
+ type: "switch",
3392
+ value: switchValue,
3393
+ key: `${loopKey}_delayed_sync`,
3394
+ sourceAddr: groupAddr,
3395
+ isCalibration: true
3396
+ });
3397
+ }
3398
+ } else if (meshState === undefined) {
3399
+ node.log(`[DelayedSync] ${mapping.name} CH${mapping.meshChannel} Mesh状态未知,更新缓存但不发送控制`);
3400
+ } else {
3401
+ node.log(`[DelayedSync] ${mapping.name} CH${mapping.meshChannel} 状态一致: ${switchValue ? "ON" : "OFF"}`);
3402
+ }
3403
+
3404
+ // 清理读取记录
3405
+ node.pendingAutoSyncReads.delete(groupAddr);
3406
+ done && done();
3407
+ return;
3408
+ }
3409
+ } else {
3410
+ node.debug(`[DelayedSync检查] 跳过: hasDelayedSyncTime=${hasDelayedSyncTime}, hasPendingRead=${hasPendingRead}`);
3411
+ }
3412
+
3413
+ // 普通Response(部署/查询):仅更新缓存,不发送控制
3414
+ node.log(`[AutoSync] 收到状态响应: ${mapping.name} CH${mapping.meshChannel}, KNX=${switchValue ? "ON" : "OFF"}, 仅更新缓存不下发`);
2967
3415
  if (!node.stateCache[macNormalized]) node.stateCache[macNormalized] = {};
2968
3416
  const switchKey = `switch_${mapping.meshChannel}`;
2969
3417
  node.stateCache[macNormalized][switchKey] = switchValue;
2970
3418
  if (mapping.meshChannel === 1) node.stateCache[macNormalized]["switch"] = switchValue;
3419
+ // 清理读取记录
3420
+ node.pendingAutoSyncReads.delete(groupAddr);
2971
3421
  done && done();
2972
3422
  return;
2973
3423
  }
@@ -2995,6 +3445,15 @@ module.exports = function(RED) {
2995
3445
  // 状态反馈地址:只同步状态到Mesh,不记录时间戳,不触发反向控制保护
2996
3446
  node.log(`[KNX状态反馈] ${mapping.name} CH${mapping.meshChannel} = ${switchValue ? "ON" : "OFF"} (仅同步状态,不触发反向控制)`);
2997
3447
 
3448
+ const desired = node.knxDesiredStates.get(deviceKey);
3449
+ if (desired && (Date.now() - desired.setAt) <= KNX_MASTER_WINDOW_MS) {
3450
+ if (switchValue !== desired.desired) {
3451
+ node.debug(`[KNX状态反馈] 跳过回写: ${mapping.name} CH${mapping.meshChannel}, KNX=${switchValue ? "ON" : "OFF"}, 期望=${desired.desired ? "ON" : "OFF"} (主控窗口内)`);
3452
+ done && done();
3453
+ return;
3454
+ }
3455
+ }
3456
+
2998
3457
  // 检查Mesh当前状态
2999
3458
  const macNormalized = (mapping.meshMac || "").toLowerCase().replace(/:/g, "");
3000
3459
  const meshDevice = node.gateway.getDevice(macNormalized);
@@ -3255,7 +3714,10 @@ module.exports = function(RED) {
3255
3714
  done && done();
3256
3715
  return;
3257
3716
  }
3258
- if (node.shouldPreventSync("knx-to-mesh", loopKey)) return;
3717
+ if (node.shouldPreventSync("knx-to-mesh", loopKey)) {
3718
+ done && done();
3719
+ return;
3720
+ }
3259
3721
  node.knxControlTimestamps[`${lightMacNormalized}_${mapping.meshChannel}`] = Date.now();
3260
3722
  node.recordSyncTime("knx-to-mesh", loopKey);
3261
3723
  node.log(`[KNX->Mesh] 调光灯色温: ${ct}`);
@@ -3354,6 +3816,12 @@ module.exports = function(RED) {
3354
3816
  }
3355
3817
  }
3356
3818
 
3819
+ // 【已恢复】触发延迟校准:KNX动作后延迟读取所有开关状态并同步到Mesh
3820
+ // 解决多控场景下,KNX面板动作后,延迟读取KNX真实状态并强制同步到Mesh
3821
+ if (!isResponse && !isEcho && !isStatusOnly && node.autoSyncEnabled) {
3822
+ node.triggerDelayedSync(`KNX输入: ${groupAddr}`);
3823
+ }
3824
+
3357
3825
  done && done();
3358
3826
  });
3359
3827
 
@@ -3527,6 +3995,12 @@ module.exports = function(RED) {
3527
3995
  clearTimeout(node.initTimer);
3528
3996
  }
3529
3997
 
3998
+ // 清除延迟校准定时器
3999
+ if (node.autoSyncTimer) {
4000
+ clearTimeout(node.autoSyncTimer);
4001
+ node.autoSyncTimer = null;
4002
+ }
4003
+
3530
4004
  // 移除事件监听
3531
4005
  if (node.gateway) {
3532
4006
  node.gateway.removeListener("device-state-changed", handleMeshStateChange);
@@ -188,8 +188,31 @@ module.exports = function(RED) {
188
188
  if (!msg.knx || !msg.knx.destination) {
189
189
  return;
190
190
  }
191
-
192
191
  const groupAddr = msg.knx.destination;
192
+
193
+ // 【关键】忽略 DelayedSync 读响应:KNX Bridge 发 GroupValue_Read 后,总线回复的 Response 可能被库标成 Write,
194
+ // 若当作用户控制会误触发 HA 关灯。通过 global 约定:在约定时间窗口内、且地址在“读请求列表”中则视为 Response,不处理
195
+ try {
196
+ const until = node.context().global.get("symi_knx_delayed_sync_until");
197
+ if (until && Date.now() < until) {
198
+ const addrsJson = node.context().global.get("symi_knx_delayed_sync_addrs");
199
+ if (addrsJson) {
200
+ const addrs = JSON.parse(addrsJson);
201
+ if (Array.isArray(addrs) && addrs.includes(groupAddr)) {
202
+ node.debug(`[KNX输入] 忽略DelayedSync响应(视为Response): ${groupAddr}`);
203
+ return;
204
+ }
205
+ }
206
+ }
207
+ } catch (e) { /* 无 global 或解析失败时继续按原逻辑 */ }
208
+
209
+ // 【关键修复】只处理 GroupValue_Write (控制命令),忽略 Response 和 Read
210
+ // 避免 DelayedSync 的状态查询响应被误认为是用户控制命令,导致状态回弹
211
+ if (msg.knx.event !== "GroupValue_Write") {
212
+ node.log(`[KNX输入] 忽略非写操作: Event=${msg.knx.event}, Src=${msg.knx.src}, Dest=${msg.knx.destination}`);
213
+ return;
214
+ }
215
+
193
216
  const mapping = node.findKnxMapping(groupAddr);
194
217
 
195
218
  if (!mapping) {
@@ -204,7 +227,7 @@ module.exports = function(RED) {
204
227
  return;
205
228
  }
206
229
 
207
- node.log(`[KNX输入] ${groupAddr} = ${msg.payload}, 映射到 ${mapping.haEntityId}`);
230
+ node.log(`[KNX输入] Event=${msg.knx.event}, ${groupAddr} = ${msg.payload}, 映射到 ${mapping.haEntityId}`);
208
231
 
209
232
  const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
210
233
  const knxType = mapping.knxType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.9.6",
3
+ "version": "1.9.7",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {