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 +57 -238
- package/lib/device-manager.js +14 -4
- package/lib/protocol.js +5 -6
- package/nodes/symi-gateway.js +14 -0
- package/nodes/symi-knx-bridge.html +34 -1
- package/nodes/symi-knx-bridge.js +513 -39
- package/nodes/symi-knx-ha-bridge.js +25 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,13 +17,17 @@
|
|
|
17
17
|
- **单次发现**:`53 12 00 41` 设备列表查询仅在部署/重启时执行一次,避免重复查询导致丢包
|
|
18
18
|
- **MQTT Discovery**:自动发布HA Discovery配置,设备即插即用
|
|
19
19
|
- **双向状态同步**:支持0x80状态事件,实时反馈设备状态变化
|
|
20
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
113
|
-
-
|
|
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
|
|
512
|
-
2.
|
|
513
|
-
3.
|
|
514
|
-
-
|
|
515
|
-
-
|
|
516
|
-
4.
|
|
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.
|
|
678
|
-
|
|
679
|
-
####
|
|
680
|
-
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
-
|
|
696
|
-
- **
|
|
697
|
-
-
|
|
698
|
-
-
|
|
699
|
-
-
|
|
700
|
-
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
-
|
|
709
|
-
-
|
|
710
|
-
-
|
|
711
|
-
-
|
|
712
|
-
|
|
713
|
-
-
|
|
714
|
-
-
|
|
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
|
+
- 修复了大量编辑器红三角校验、变量初始化顺序、内存泄漏及配置持久化保存隐患。
|
package/lib/device-manager.js
CHANGED
|
@@ -493,9 +493,8 @@ class DeviceInfo {
|
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
-
//
|
|
497
|
-
// 1-4
|
|
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
|
-
|
|
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); //
|
|
142
|
+
states.push(bits === 0x02); // 协议:b01=关 b10=开,不得反写
|
|
144
143
|
}
|
|
145
144
|
return states;
|
|
146
145
|
}
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -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>
|
package/nodes/symi-knx-bridge.js
CHANGED
|
@@ -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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// 场景触发不属于双向状态对齐范畴,仅需触发成功即可
|
|
92
|
+
// 【KNX 协议】GroupValue_Read 必须发往「状态/反馈地址」才有正确响应;对控制地址读请求多数执行器不响应或由其他设备应答导致错误值
|
|
93
|
+
// 仅当配置了独立状态地址(且与控制地址不同)时才加入读取列表;仅配置控制地址的映射不发送读请求,避免读回错误 OFF
|
|
86
94
|
if (m.deviceType !== "scene") {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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))
|
|
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;
|