node-red-contrib-symi-mesh 1.8.11 → 1.8.13
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 +25 -132
- package/lib/tcp-client.js +4 -3
- package/nodes/symi-gateway.js +1 -0
- package/nodes/symi-ha-sync.html +131 -85
- package/nodes/symi-knx-bridge.html +1 -1
- package/nodes/symi-knx-bridge.js +146 -34
- package/nodes/symi-knx-ha-bridge.html +175 -95
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -704,17 +704,19 @@ node-red-contrib-symi-mesh/
|
|
|
704
704
|
|
|
705
705
|
## 更新日志
|
|
706
706
|
|
|
707
|
-
### v1.8.
|
|
707
|
+
### v1.8.13 (2026-01-18)
|
|
708
|
+
- **KNX 并发控制逻辑修复(关键更新)**:
|
|
709
|
+
- **禁止状态地址反向触发**:彻底修复了 KNX 总线在场景执行或多设备并发响应时,Mesh 设备出现“相反操作”或错误控制的问题。现在系统严格区分控制地址(Cmd)和状态地址(Status),严禁状态反馈报文触发对 Mesh 设备的控制指令。
|
|
710
|
+
- **Buffer 类型数据解析增强**:修复了当 KNX 节点传递 Buffer 类型数据(如 `<Buffer 01>`)时,系统错误解析为 `false` (关) 导致的操作反转问题。现在能正确识别 Buffer 格式的开关量。
|
|
711
|
+
- **并发稳定性**:优化了输入处理逻辑,在高并发场景下(如 KNX 场景同时触发几十个设备)能稳定运行,消除了因状态反馈震荡导致的逻辑错误。
|
|
708
712
|
|
|
709
|
-
|
|
710
|
-
- **僵尸节点彻底清除**:修复了在删除或修改 KNX 场景映射并重新部署后,旧的配置逻辑仍在后台运行的问题。
|
|
711
|
-
- **原因分析**:旧节点实例销毁时,未能完全解绑网关事件监听器,导致“僵尸节点”继续响应事件。
|
|
712
|
-
- **解决方案**:引入 `node.isClosed` 标志位,强制拦截销毁后的所有逻辑执行;同时修复了网关连接状态监听器的内存泄漏问题。
|
|
713
|
-
- **内存泄漏修复**:将所有匿名事件监听器改为具名函数,确保在节点关闭时能被正确移除,防止多次部署后的内存累积。
|
|
714
|
-
- **空指针异常防护**:在 `symi-gateway` 中增加了多处连接状态检查,防止网关断开后后台任务访问已销毁的客户端对象,彻底消除 `Cannot read properties of null (reading 'sendFrame')` 报错刷屏。
|
|
715
|
-
- **UI 显示优化**:修复了 KNX 开关类型在配置列表中错误显示“扩展”列数据的问题,现在开关类型的扩展列将正确显示为“-”。
|
|
713
|
+
### v1.8.10+ (2026-01-18)
|
|
716
714
|
|
|
717
|
-
|
|
715
|
+
**KNX 并发控制逻辑修复(关键更新)**:
|
|
716
|
+
- **禁止状态地址反向触发**:彻底修复了 KNX 总线在场景执行或多设备并发响应时,Mesh 设备出现“相反操作”或错误控制的问题。现在系统严格区分控制地址(Cmd)和状态地址(Status),严禁状态反馈报文触发对 Mesh 设备的控制指令。
|
|
717
|
+
- **Buffer 类型数据解析增强**:修复了当 KNX 节点传递 Buffer 类型数据(如 `<Buffer 01>`)时,系统错误解析为 `false` (关) 导致的操作反转问题。现在能正确识别 Buffer 格式的开关量。
|
|
718
|
+
- **并发稳定性**:优化了输入处理逻辑,在高并发场景下(如 KNX 场景同时触发几十个设备)能稳定运行,消除了因状态反馈震荡导致的逻辑错误。
|
|
719
|
+
- **日志优化**:优化了 TCP 客户端和网关初始化逻辑,对连接超时和离线错误进行节流处理,避免在断网情况下日志刷屏。
|
|
718
720
|
|
|
719
721
|
**新增功能**:
|
|
720
722
|
- **KNX 场景联动支持**:新增“场景”设备类型,支持 Mesh 开关按键与 KNX 场景的双向联动。
|
|
@@ -730,127 +732,18 @@ node-red-contrib-symi-mesh/
|
|
|
730
732
|
- **逻辑说明**:
|
|
731
733
|
- **KNX -> Mesh**:收到 KNX 场景号 -> Mesh 开关执行指定状态(如设为 0,则执行关)。
|
|
732
734
|
- **Mesh -> KNX**:Mesh 开关变为指定状态(如变为关) -> 发送 KNX 场景号。
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
-
|
|
741
|
-
-
|
|
742
|
-
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
- **修复场景**:KNX 控制打开(ON)后,立即手动关闭 Mesh 设备(OFF),旧版本会因死循环锁未过期而丢弃 OFF 状态上报。
|
|
746
|
-
- **新逻辑**:KNX 发送 ON 指令只锁定 Mesh->KNX 的 ON 上报,不影响 Mesh->KNX 的 OFF 上报,确保快速连续反向操作能 100% 同步。
|
|
747
|
-
- **状态同步零延迟**:移除不必要的通用防抖检查,确保 Mesh 端的状态变化能毫秒级同步到 KNX 总线。
|
|
748
|
-
|
|
749
|
-
### v1.8.8 (2026-01-14)
|
|
750
|
-
|
|
751
|
-
**KNX 双向同步深度修复与协议鲁棒性增强**:
|
|
752
|
-
- **分包与粘包彻底解决**:重构 `ProtocolHandler` 缓存机制,支持 **8+1 分包、多包连续粘包**等极端情况下的完整解析。通过 Buffer 累加与帧头部实时扫描技术,确保无论串口数据如何切分,都能还原为完整的协议帧。
|
|
753
|
-
- **KNX 双向同步死循环修复**:
|
|
754
|
-
- **MAC 地址标准化**:统一所有节点(配置、事件、缓存)的 MAC 地址为小写且无冒号格式,彻底解决由于大小写不一致导致的“找不到映射”或“防死循环误杀”问题。
|
|
755
|
-
- **设备级回显消除**:引入 `allKnxAddrs` 关联检查,KNX 发出指令后,自动屏蔽该设备下所有关联地址(命令/状态/位置)的短时回显,防止自发自收导致的同步环路。
|
|
756
|
-
- **4 键开关通道精准匹配**:修复多路开关状态上报时,`switch_1` 到 `switch_4` 的动态订阅逻辑,确保 Mesh 端的每一路状态都能精准同步到对应的 KNX 组地址。
|
|
757
|
-
- **生产环境日志优化**:所有原始报文降级为 `debug` 级别,仅保留关键的同步逻辑日志为 `log` 级别,确保在长时间运行下不占用额外硬盘 IO,防止 Node-RED 变慢。
|
|
758
|
-
- **三合一面板持久化增强**:完善 `symi-mesh-data` 目录下的 JSON 持久化逻辑,确保三合一设备类型在 Node-RED 重启后能立即恢复,无需重新探测。
|
|
759
|
-
|
|
760
|
-
### v1.8.7 (2026-01-08)
|
|
761
|
-
|
|
762
|
-
**生产环境日志优化与稳定性增强**:
|
|
763
|
-
- **错误日志节流 (Throttling)**:在网关连接和 RS485 配置中引入 60 秒节流机制,同类网络错误(如 `ECONNREFUSED`)每分钟仅记录一次,彻底解决离线时的日志刷屏问题。
|
|
764
|
-
- **日志级别降级**:将所有节点的 `node.error` 和 `node.warn` 统一降级为 `node.log` (Info 级别),保持 Node-RED 控制台整洁,仅在调试模式下显示详细信息。
|
|
765
|
-
- **TCP 客户端优化**:在 `tcp-client` 库级别拦截常见的网络波动报错,提升系统在高频重连场景下的静默稳定性。
|
|
766
|
-
- **全量节点适配**:完成 MQTT、HA同步、云端同步、RS485、KNX 等所有功能节点的日志规范化清理。
|
|
767
|
-
|
|
768
|
-
### v1.8.6 (2026-01-07)
|
|
769
|
-
|
|
770
|
-
**核心修复与同步增强**:
|
|
771
|
-
- **网关初始化修复**:修复 `symi-gateway` 节点在初始化查询设备状态时的语法错误 (SyntaxError),确保节点能正常加载。
|
|
772
|
-
- **三合一属性路由优化**:优化 `symi-ha-sync` 路由逻辑,支持将 0x02 属性(开关)正确重定向至三合一子实体处理器,解决三合一面板状态同步失效问题。
|
|
773
|
-
- **HA Climate 协议匹配**:修复 HA `climate` 实体协议不匹配问题,将空调/地暖开关操作映射为 `hvac_mode` (heat/cool/off),解决同步时的 500 错误。
|
|
774
|
-
- **风速同步双向修复**:
|
|
775
|
-
- **中英文映射支持**:在 `HA_TO_FAN_MODE` 中增加中文风速(高风/中风/低风/自动)映射,解决 HA 界面操作时的“未知风速值”警告。
|
|
776
|
-
- **Symi -> HA 值转换**:同步至 HA 时自动将英文风速转换为中文,匹配中文版 HA 实体要求,彻底解决同步时的 500 错误。
|
|
777
|
-
- **详细日志追踪**:增加 `[Symi->HA] 发送 HA 请求` 详细日志,包含完整的服务调用路径和 Payload。
|
|
778
|
-
|
|
779
|
-
**三合一设备持久化优化**:
|
|
780
|
-
- **文件持久化存储**:三合一设备类型信息保存到 `~/.node-red/symi-mesh-data/` 目录,实现永久记忆。
|
|
781
|
-
- **检测逻辑优化**:延长检测等待时间至 10 秒(分 20 次检查),解决因网络延迟导致的识别失败。
|
|
782
|
-
- **时序修复**:提前保存 `needsThreeInOneCheck` 状态,确保在响应到达时能正确触发持久化流程。
|
|
783
|
-
|
|
784
|
-
**Mesh -> HA 同步增强**:
|
|
785
|
-
- **状态缓存细粒度化**:采用 `StateCache` 实现 `{mac}_{subEntity}_{property}` 级别的精确对比,只同步真正变化的属性。
|
|
786
|
-
- **调试日志增强**:三合一相关事件统一使用 `log` 级别输出,便于快速排查同步链路问题。
|
|
787
|
-
|
|
788
|
-
**设备管理器功能增强**:
|
|
789
|
-
- 新增 `markAsThreeInOne` / `markAsThermostat` 等持久化管理方法,提升系统重启后的设备恢复速度。
|
|
790
|
-
|
|
791
|
-
### v1.8.5 (2026-01-06)
|
|
792
|
-
|
|
793
|
-
**通用同步工具类重构**:
|
|
794
|
-
- 新增 `lib/sync-utils.js` 通用同步工具模块
|
|
795
|
-
- 统一 SyncUtils 类:提供防环路逻辑,支持不同设备类型的超时配置
|
|
796
|
-
- 统一 StateCache 类:状态缓存对比,避免重复同步
|
|
797
|
-
- 统一 SyncQueue 类:同步命令队列管理,支持高负载场景
|
|
798
|
-
|
|
799
|
-
**所有同步节点统一使用 SyncUtils**:
|
|
800
|
-
- HA同步节点 (`symi-ha-sync.js`) 使用 SyncUtils 替代原有防环路实现
|
|
801
|
-
- MQTT同步节点 (`symi-mqtt-sync.js`) 使用 SyncUtils 替代原有防环路实现
|
|
802
|
-
- KNX桥接节点 (`symi-knx-bridge.js`) 重构使用 SyncUtils,移除独立的 LOOP_PREVENTION_MS
|
|
803
|
-
- RS485桥接节点 (`symi-485-bridge.js`) 重构使用 SyncUtils,统一防环路逻辑
|
|
804
|
-
- 统一超时配置:普通设备2秒、窗帘30秒、亮度800ms
|
|
805
|
-
|
|
806
|
-
**MQTT品牌同步三合一子实体支持**:
|
|
807
|
-
- 新增 `meshSubEntity` 映射字段,支持三合一面板子实体映射
|
|
808
|
-
- 空调子实体 → HYQW type 12 (开关/温度/模式/风速)
|
|
809
|
-
- 新风子实体 → HYQW type 36 (开关/风速)
|
|
810
|
-
- 地暖子实体 → HYQW type 16 (开关/温度)
|
|
811
|
-
- 双向同步:HYQW设备状态变化自动同步到Mesh三合一面板
|
|
812
|
-
|
|
813
|
-
**代码质量优化**:
|
|
814
|
-
- 消除重复的防环路代码,所有同步节点共用 SyncUtils
|
|
815
|
-
- 节点关闭时正确调用 SyncUtils.destroy() 清理资源
|
|
816
|
-
- 减少内存泄漏风险,统一时间戳清理机制
|
|
817
|
-
|
|
818
|
-
**三合一状态缓存细粒度优化**:
|
|
819
|
-
- HA同步节点使用 StateCache 实现细粒度状态对比
|
|
820
|
-
- 缓存key格式优化为 `{mac}_{subEntity}_{property}`
|
|
821
|
-
- 只同步真正变化的属性,减少冗余HA API调用
|
|
822
|
-
- 支持空调、新风、地暖各属性独立变化检测
|
|
823
|
-
|
|
824
|
-
### v1.8.4 (2026-01-06)
|
|
825
|
-
|
|
826
|
-
**HA同步节点窗帘双向同步重大修复**:
|
|
827
|
-
- 实现"谁发起控制就只听谁的命令"逻辑,彻底解决窗帘双向同步死循环问题
|
|
828
|
-
- Mesh控制时:只同步位置到HA,不发送动作命令
|
|
829
|
-
- HA控制时:发送动作/位置到Mesh,运动过程中忽略Mesh的所有反馈
|
|
830
|
-
- 停止后延迟5秒释放控制权,确保延迟反馈也被正确过滤
|
|
831
|
-
- 只处理HA的`opening`/`closing`状态,忽略`open`/`closed`状态反馈
|
|
832
|
-
|
|
833
|
-
**其他优化**:
|
|
834
|
-
- 修复HA state_changed事件解析,支持更多消息格式变体
|
|
835
|
-
- 优化空调同步逻辑:只在开关状态真正变化时同步
|
|
836
|
-
- 优化调光同步逻辑:HA发起调光时忽略Mesh步进反馈
|
|
837
|
-
- 增加状态变化检测:无变化时跳过处理
|
|
838
|
-
- 过滤sensor类型实体,避免不必要的处理
|
|
839
|
-
|
|
840
|
-
**MQTT品牌同步协议修复**:
|
|
841
|
-
- 窗帘设备协议修复:修复窗帘fn=1功能码的正确解析
|
|
842
|
-
- 设备类型独立处理:重构syncToMesh和syncToMqtt函数
|
|
843
|
-
- 完整功能码支持:灯具、空调、窗帘、地暖、新风
|
|
844
|
-
|
|
845
|
-
**三合一面板深度集成**:
|
|
846
|
-
- 子设备选择:HA同步节点新增三合一子设备选择功能
|
|
847
|
-
- 完整属性同步:支持空调、新风、地暖的全功能双向同步
|
|
848
|
-
|
|
849
|
-
**稳定性修复**:
|
|
850
|
-
- 修复Mesh控制窗帘时HA反向发送命令导致窗帘停止的问题
|
|
851
|
-
- 修复HA控制窗帘时Mesh反馈导致循环控制的问题
|
|
852
|
-
- 修复窗帘打开命令被防死循环机制阻止的问题
|
|
853
|
-
- 修复空调off->off重复日志的问题
|
|
735
|
+
|
|
736
|
+
**历史版本功能合并 (v1.8.4 - v1.8.10)**:
|
|
737
|
+
- **三合一面板深度集成**:
|
|
738
|
+
- 完整支持空调、新风、地暖的全功能双向同步。
|
|
739
|
+
- 持久化存储设备类型,重启后自动恢复,无需重复探测。
|
|
740
|
+
- 优化了状态缓存机制,只同步真正变化的属性,减少资源消耗。
|
|
741
|
+
- **稳定性增强**:
|
|
742
|
+
- **错误日志节流**:在网关连接和 RS485 配置中引入节流机制,彻底解决离线时的日志刷屏问题。
|
|
743
|
+
- **通用同步工具类**:重构了所有同步节点(HA、MQTT、KNX、RS485),统一使用 `SyncUtils` 类进行防环路和状态缓存管理。
|
|
744
|
+
- **窗帘同步修复**:实现了"谁发起控制就只听谁的命令"逻辑,彻底解决窗帘双向同步死循环问题。
|
|
745
|
+
- **协议鲁棒性**:增强了分包与粘包处理,修复了 Buffer 类型数据解析问题。
|
|
746
|
+
|
|
854
747
|
|
|
855
748
|
## 常见问题排查
|
|
856
749
|
|
|
@@ -889,8 +782,8 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
889
782
|
---
|
|
890
783
|
|
|
891
784
|
**作者**: SYMI 亖米
|
|
892
|
-
**版本**: 1.8.
|
|
785
|
+
**版本**: 1.8.13
|
|
893
786
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
894
|
-
**最后更新**: 2026-01-
|
|
787
|
+
**最后更新**: 2026-01-18
|
|
895
788
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
|
896
789
|
**npm包**: https://www.npmjs.com/package/node-red-contrib-symi-mesh
|
package/lib/tcp-client.js
CHANGED
|
@@ -51,7 +51,8 @@ class TCPClient extends EventEmitter {
|
|
|
51
51
|
if (this.client) {
|
|
52
52
|
this.client.destroy();
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// 超时通常也是网络问题,使用warn而不是error,并由上层逻辑决定是否显示
|
|
55
|
+
// this.logger.error('Connection timeout'); // 移除错误日志,避免刷屏
|
|
55
56
|
this.handleDisconnect();
|
|
56
57
|
reject(new Error('Connection timeout'));
|
|
57
58
|
}
|
|
@@ -109,9 +110,9 @@ class TCPClient extends EventEmitter {
|
|
|
109
110
|
this.logger.warn(`TCP连接失败: 无法连接到 ${this.host}:${this.port}`);
|
|
110
111
|
}
|
|
111
112
|
// 只在首次连接或重要错误时记录,避免大量重复日志
|
|
112
|
-
// ECONNRESET通常是网络波动,不记录错误
|
|
113
|
+
// ECONNRESET/EHOSTDOWN 通常是网络波动,不记录错误
|
|
113
114
|
// EHOSTUNREACH/ETIMEDOUT 也是常见网络错误,不记录
|
|
114
|
-
else if (!this.connected && !['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ETIMEDOUT'].includes(error.code)) {
|
|
115
|
+
else if (!this.connected && !['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'EHOSTDOWN', 'ETIMEDOUT'].includes(error.code)) {
|
|
115
116
|
this.logger.log('TCP client error: ' + error.message);
|
|
116
117
|
}
|
|
117
118
|
|
package/nodes/symi-gateway.js
CHANGED
package/nodes/symi-ha-sync.html
CHANGED
|
@@ -250,96 +250,133 @@
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
// 渲染映射列表
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
mappings.forEach(function(m, idx) {
|
|
263
|
-
try {
|
|
264
|
-
var row = $('<div class="mapping-row" data-idx="' + idx + '"></div>');
|
|
265
|
-
var symiOpts = getSymiOptions(m.symiMac, m.symiName);
|
|
266
|
-
var keyOpts = getKeyOptions(m.symiMac, m.symiKey || 1, m.symiChannels, m.symiEntityType);
|
|
267
|
-
var syncModeOpts = getSyncModeOptions(m.syncMode || 0);
|
|
268
|
-
var haOpts = getHaOptions(m.haEntityId, m.haEntityName);
|
|
269
|
-
|
|
270
|
-
row.html(
|
|
271
|
-
'<div class="mapping-main">' +
|
|
272
|
-
'<div class="symi-col">' +
|
|
273
|
-
' <select class="symi-select">' + symiOpts + '</select>' +
|
|
274
|
-
' <span class="symi-key-wrap">' + keyOpts + '</span>' +
|
|
275
|
-
'</div>' +
|
|
276
|
-
'<div class="arrow-col">' + syncModeOpts + '</div>' +
|
|
277
|
-
'<div class="ha-col">' +
|
|
278
|
-
' <select class="ha-select">' + haOpts + '</select>' +
|
|
279
|
-
'</div>' +
|
|
280
|
-
'<div class="del-col"><button type="button" class="red-ui-button red-ui-button-small btn-remove" title="删除"><i class="fa fa-times"></i></button></div>' +
|
|
281
|
-
'</div>'
|
|
282
|
-
);
|
|
283
|
-
container.append(row);
|
|
284
|
-
} catch (err) {
|
|
285
|
-
console.error("[symi-ha-sync] Render row error:", err, m);
|
|
253
|
+
$('#mapping-list').css('min-height','300px').editableList({
|
|
254
|
+
addItem: function(container, i, data) {
|
|
255
|
+
var m = data;
|
|
256
|
+
// 如果是新添加的项,初始化默认值
|
|
257
|
+
if (!m.symiMac && !m.haEntityId) {
|
|
258
|
+
m.symiMac = ''; m.symiName = ''; m.symiKey = 1; m.symiChannels = 1; m.symiDeviceType = '';
|
|
259
|
+
m.symiEntityType = ''; m.syncMode = 0;
|
|
260
|
+
m.haEntityId = ''; m.haEntityName = '';
|
|
286
261
|
}
|
|
287
|
-
});
|
|
288
262
|
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// 绑定事件
|
|
293
|
-
function bindEvents() {
|
|
294
|
-
var container = $('#mapping-list');
|
|
295
|
-
|
|
296
|
-
container.find('.symi-select').off('change').on('change', function() {
|
|
297
|
-
var row = $(this).closest('.mapping-row');
|
|
298
|
-
var idx = row.data('idx');
|
|
299
|
-
var mac = $(this).val();
|
|
300
|
-
var opt = $(this).find('option:selected');
|
|
263
|
+
var row = $('<div class="mapping-row" style="display:flex;align-items:center;gap:5px;padding:5px 0;"></div>').appendTo(container);
|
|
301
264
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
mappings[idx].symiEntityType = opt.data('entitytype') || '';
|
|
307
|
-
mappings[idx].symiKey = 1;
|
|
265
|
+
var symiOpts = getSymiOptions(m.symiMac, m.symiName);
|
|
266
|
+
var keyOpts = getKeyOptions(m.symiMac, m.symiKey || 1, m.symiChannels, m.symiEntityType);
|
|
267
|
+
var syncModeOpts = getSyncModeOptions(m.syncMode || 0);
|
|
268
|
+
var haOpts = getHaOptions(m.haEntityId, m.haEntityName);
|
|
308
269
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
270
|
+
// Symi Column
|
|
271
|
+
var symiCol = $('<div class="symi-col" style="flex:1;display:flex;gap:4px;min-width:0;"></div>').appendTo(row);
|
|
272
|
+
var symiSelect = $('<select class="symi-select" style="flex:1;padding:4px;border:1px solid #81c784;border-radius:3px;background:#e8f5e9;font-size:12px;">' + symiOpts + '</select>').appendTo(symiCol);
|
|
273
|
+
var symiKeyWrap = $('<span class="symi-key-wrap">' + keyOpts + '</span>').appendTo(symiCol);
|
|
274
|
+
|
|
275
|
+
// Arrow Column
|
|
276
|
+
var arrowCol = $('<div class="arrow-col" style="flex:0 0 100px;text-align:center;"></div>').appendTo(row);
|
|
277
|
+
var syncModeSelect = $(syncModeOpts).appendTo(arrowCol).css({width:'100%',padding:'4px',border:'1px solid #ccc',borderRadius:'3px',fontSize:'11px'});
|
|
278
|
+
|
|
279
|
+
// HA Column
|
|
280
|
+
var haCol = $('<div class="ha-col" style="flex:1;min-width:0;"></div>').appendTo(row);
|
|
281
|
+
var haSelect = $('<select class="ha-select" style="width:100%;padding:4px;border:1px solid #41BDF5;border-radius:3px;background:#e3f2fd;font-size:12px;">' + haOpts + '</select>').appendTo(haCol);
|
|
282
|
+
|
|
283
|
+
// 绑定事件
|
|
284
|
+
symiSelect.on('change', function() {
|
|
285
|
+
var mac = $(this).val();
|
|
286
|
+
var opt = $(this).find('option:selected');
|
|
287
|
+
|
|
288
|
+
m.symiMac = mac || '';
|
|
289
|
+
m.symiName = opt.data('name') || opt.text().replace(/ \[.*?\]/g, '');
|
|
290
|
+
m.symiChannels = parseInt(opt.data('channels')) || 1;
|
|
291
|
+
m.symiDeviceType = opt.data('devicetype') || '';
|
|
292
|
+
m.symiEntityType = opt.data('entitytype') || '';
|
|
293
|
+
m.symiKey = 1;
|
|
294
|
+
|
|
295
|
+
symiKeyWrap.html(getKeyOptions(mac, 1, m.symiChannels, m.symiEntityType));
|
|
296
|
+
// 重新绑定新生成的按键下拉框事件
|
|
297
|
+
bindKeyEvents();
|
|
298
|
+
});
|
|
317
299
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
300
|
+
function bindKeyEvents() {
|
|
301
|
+
symiKeyWrap.find('.symi-key').on('change', function() {
|
|
302
|
+
m.symiKey = parseInt($(this).val()) || 1;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
bindKeyEvents();
|
|
322
306
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
mappings[idx].haEntityId = $(this).val();
|
|
327
|
-
mappings[idx].haEntityName = opt.data('name') || opt.text().split(' (')[0].replace(/ \[.*?\]/g, '');
|
|
328
|
-
});
|
|
307
|
+
syncModeSelect.on('change', function() {
|
|
308
|
+
m.syncMode = parseInt($(this).val()) || 0;
|
|
309
|
+
});
|
|
329
310
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
311
|
+
haSelect.on('change', function() {
|
|
312
|
+
var opt = $(this).find('option:selected');
|
|
313
|
+
m.haEntityId = $(this).val();
|
|
314
|
+
m.haEntityName = opt.data('name') || opt.text().split(' (')[0].replace(/ \[.*?\]/g, '');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// 保存更新UI的方法供外部调用
|
|
318
|
+
container.data('refreshUI', function() {
|
|
319
|
+
var currentSymi = symiSelect.val();
|
|
320
|
+
var currentKey = symiKeyWrap.find('.symi-key').val();
|
|
321
|
+
var currentHa = haSelect.val();
|
|
322
|
+
|
|
323
|
+
symiSelect.html(getSymiOptions(m.symiMac, m.symiName));
|
|
324
|
+
// 如果之前选中的还在,保持选中;否则选中数据中的值
|
|
325
|
+
if (symiSelect.find('option[value="'+currentSymi+'"]').length) symiSelect.val(currentSymi);
|
|
326
|
+
|
|
327
|
+
// 刷新按键
|
|
328
|
+
// 注意:如果设备列表变了,channels可能变了,所以要重新生成
|
|
329
|
+
// 我们需要重新获取设备信息来确认channels
|
|
330
|
+
// 但这里简单起见,我们假设 getKeyOptions 会处理
|
|
331
|
+
// m.symiChannels 应该在 loadSymiDevices 时更新吗?
|
|
332
|
+
// loadSymiDevices 更新了 symiDevices 数组
|
|
333
|
+
// 我们需要从 symiDevices 中找到当前设备并更新 m.symiChannels
|
|
334
|
+
var dev = symiDevices.find(d => d.macAddress === m.symiMac);
|
|
335
|
+
if (dev) {
|
|
336
|
+
m.symiChannels = dev.channels;
|
|
337
|
+
m.symiEntityType = dev.entityType;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
symiKeyWrap.html(getKeyOptions(m.symiMac, m.symiKey, m.symiChannels, m.symiEntityType));
|
|
341
|
+
bindKeyEvents();
|
|
342
|
+
|
|
343
|
+
haSelect.html(getHaOptions(m.haEntityId, m.haEntityName));
|
|
344
|
+
if (haSelect.find('option[value="'+currentHa+'"]').length) haSelect.val(currentHa);
|
|
345
|
+
});
|
|
346
|
+
},
|
|
347
|
+
sortable: true,
|
|
348
|
+
removable: true,
|
|
349
|
+
addButton: false,
|
|
350
|
+
height: 400
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
function renderMappings() {
|
|
354
|
+
// 这个函数现在只用于初次加载
|
|
355
|
+
if (mappings && mappings.length > 0) {
|
|
356
|
+
// 只有当列表为空时才添加,避免重复
|
|
357
|
+
if ($('#mapping-list').editableList('items').length === 0) {
|
|
358
|
+
mappings.forEach(function(m) {
|
|
359
|
+
$('#mapping-list').editableList('addItem', m);
|
|
360
|
+
});
|
|
361
|
+
} else {
|
|
362
|
+
// 如果列表不为空,说明是刷新操作,调用 refreshUI
|
|
363
|
+
$('#mapping-list').editableList('items').each(function() {
|
|
364
|
+
var refresh = $(this).data('refreshUI');
|
|
365
|
+
if (refresh) refresh();
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
335
369
|
}
|
|
336
370
|
|
|
371
|
+
// 绑定事件 (已移至 addItem 内部)
|
|
372
|
+
function bindEvents() {}
|
|
373
|
+
|
|
337
374
|
// 刷新按钮
|
|
338
375
|
$('#refresh-symi-btn').on('click', function() {
|
|
339
376
|
var btn = $(this);
|
|
340
377
|
btn.prop('disabled', true).find('i').addClass('fa-spin');
|
|
341
378
|
loadSymiDevices(function() {
|
|
342
|
-
renderMappings();
|
|
379
|
+
renderMappings(); // 这里会触发 refreshUI
|
|
343
380
|
btn.prop('disabled', false).find('i').removeClass('fa-spin');
|
|
344
381
|
});
|
|
345
382
|
});
|
|
@@ -348,39 +385,48 @@
|
|
|
348
385
|
var btn = $(this);
|
|
349
386
|
btn.prop('disabled', true).find('i').addClass('fa-spin');
|
|
350
387
|
loadHaEntities(function() {
|
|
351
|
-
renderMappings();
|
|
388
|
+
renderMappings(); // 这里会触发 refreshUI
|
|
352
389
|
btn.prop('disabled', false).find('i').removeClass('fa-spin');
|
|
353
390
|
});
|
|
354
391
|
});
|
|
355
392
|
|
|
356
393
|
// 添加映射按钮
|
|
357
394
|
$('#btn-add-mapping').on('click', function() {
|
|
358
|
-
|
|
395
|
+
$('#mapping-list').editableList('addItem', {
|
|
359
396
|
symiMac: '', symiName: '', symiKey: 1, symiChannels: 1, symiDeviceType: '',
|
|
360
397
|
symiEntityType: '', syncMode: 0,
|
|
361
398
|
haEntityId: '', haEntityName: ''
|
|
362
399
|
});
|
|
363
|
-
renderMappings();
|
|
364
|
-
var list = $('#mapping-list');
|
|
365
|
-
list.scrollTop(list.prop('scrollHeight'));
|
|
366
400
|
});
|
|
367
401
|
|
|
368
402
|
// 配置变化时重新加载
|
|
369
403
|
$('#node-input-mqttConfig').on('change', function() {
|
|
370
404
|
setTimeout(function() {
|
|
371
|
-
loadSymiDevices(
|
|
405
|
+
loadSymiDevices(function() {
|
|
406
|
+
// 清空列表并重新渲染,或者只是刷新?
|
|
407
|
+
// 如果MQTT配置变了,设备列表完全不同,最好清空
|
|
408
|
+
// 但如果用户已经配置了一些,清空会丢失数据
|
|
409
|
+
// 所以我们尝试刷新,未找到的会显示红色
|
|
410
|
+
renderMappings();
|
|
411
|
+
});
|
|
372
412
|
}, 100);
|
|
373
413
|
});
|
|
374
414
|
|
|
375
415
|
$('#node-input-haServer').on('change', function() {
|
|
376
416
|
setTimeout(function() {
|
|
377
|
-
loadHaEntities(
|
|
417
|
+
loadHaEntities(function() {
|
|
418
|
+
renderMappings();
|
|
419
|
+
});
|
|
378
420
|
}, 100);
|
|
379
421
|
});
|
|
380
422
|
|
|
381
423
|
// 保存时更新所有数据
|
|
382
424
|
node._saveAll = function() {
|
|
383
|
-
|
|
425
|
+
var newMappings = [];
|
|
426
|
+
$('#mapping-list').editableList('items').each(function() {
|
|
427
|
+
newMappings.push($(this).data('data'));
|
|
428
|
+
});
|
|
429
|
+
node.mappings = JSON.stringify(newMappings);
|
|
384
430
|
node.cachedSymiDevices = JSON.stringify(cachedSymiDevices);
|
|
385
431
|
node.cachedHaEntities = JSON.stringify(cachedHaEntities);
|
|
386
432
|
$('#node-input-mappings').val(node.mappings);
|
|
@@ -400,7 +446,7 @@
|
|
|
400
446
|
var height = size.height;
|
|
401
447
|
for (var i = 0; i < rows.length; i++) { height -= $(rows[i]).outerHeight(true); }
|
|
402
448
|
height -= 120;
|
|
403
|
-
$('#mapping-list').
|
|
449
|
+
$('#mapping-list').editableList('height', Math.max(150, height));
|
|
404
450
|
},
|
|
405
451
|
oneditsave: function() {
|
|
406
452
|
if (this._saveAll) { this._saveAll(); }
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
'climate': ['开关地址*','温度地址','模式地址','风速地址','当前温度'],
|
|
113
113
|
'fresh_air': ['开关地址*','风速地址'],
|
|
114
114
|
'floor_heating': ['开关地址*','温度地址','当前温度'],
|
|
115
|
-
'scene': ['KNX组地址*', '场景号(1-64)*', '
|
|
115
|
+
'scene': ['KNX组地址*', '场景号(1-64)*', '触发Mesh动作(1=开/0=关)*']
|
|
116
116
|
};
|
|
117
117
|
|
|
118
118
|
// 统一的添加/编辑KNX实体面板
|
package/nodes/symi-knx-bridge.js
CHANGED
|
@@ -107,6 +107,13 @@ module.exports = function(RED) {
|
|
|
107
107
|
mapping.knxAddrTemp = knxEntity.statusAddr || '';
|
|
108
108
|
mapping.knxAddrCurrentTemp = knxEntity.ext1 || '';
|
|
109
109
|
break;
|
|
110
|
+
case 'scene':
|
|
111
|
+
// 场景: 组地址(cmd), 场景号(ext1), 动作(ext2)
|
|
112
|
+
// ext1: 场景号 1-64
|
|
113
|
+
// ext2: 动作 1=开, 0=关
|
|
114
|
+
mapping.sceneNumber = parseInt(knxEntity.ext1) || 1;
|
|
115
|
+
mapping.sceneAction = (parseInt(knxEntity.ext2) === 1);
|
|
116
|
+
break;
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
// 收集所有KNX地址用于快速查找
|
|
@@ -357,20 +364,19 @@ module.exports = function(RED) {
|
|
|
357
364
|
|
|
358
365
|
// 检查是否匹配配置的触发动作
|
|
359
366
|
if (switchValue === mapping.sceneAction) {
|
|
367
|
+
// 场景命令 DPT 17.001 (1字节无符号整数)
|
|
368
|
+
// payload = 场景号 - 1 (KNX wire format: 0-63)
|
|
369
|
+
const scenePayload = mapping.sceneNumber - 1;
|
|
370
|
+
|
|
360
371
|
// 构造防死循环key
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
const sceneLoopKey = `${loopKey}_scene_${mapping.sceneNumber}`;
|
|
372
|
+
// 统一使用 KNX 值 (0-63) 作为 key 的一部分
|
|
373
|
+
const sceneLoopKey = `${loopKey}_scene_${scenePayload}`;
|
|
364
374
|
|
|
365
375
|
if (node.shouldPreventSync('mesh-to-knx', sceneLoopKey)) {
|
|
366
376
|
node.log(`[Mesh->KNX] 跳过场景触发(防死循环): ${sceneLoopKey}`);
|
|
367
377
|
continue;
|
|
368
378
|
}
|
|
369
379
|
|
|
370
|
-
// 场景命令 DPT 17.001 (1字节无符号整数)
|
|
371
|
-
// payload = 场景号 - 1 (KNX wire format: 0-63)
|
|
372
|
-
const scenePayload = mapping.sceneNumber - 1;
|
|
373
|
-
|
|
374
380
|
const knxMsg = {
|
|
375
381
|
topic: mapping.knxAddrCmd,
|
|
376
382
|
payload: scenePayload,
|
|
@@ -1080,11 +1086,20 @@ module.exports = function(RED) {
|
|
|
1080
1086
|
// 确定地址功能(优先使用地址匹配,比DPT更可靠)
|
|
1081
1087
|
const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
|
|
1082
1088
|
|
|
1089
|
+
// 通用值解析:支持 Boolean, Number, String, Buffer
|
|
1090
|
+
const parseBoolean = (val) => {
|
|
1091
|
+
if (Buffer.isBuffer(val)) {
|
|
1092
|
+
return val.length > 0 && val[0] !== 0;
|
|
1093
|
+
}
|
|
1094
|
+
return (val === 1 || val === true || val === 'on' || val === 'ON' || val === '1');
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1083
1097
|
// 根据设备类型和地址功能处理
|
|
1098
|
+
// 重要修复:严禁 Status/反馈地址触发控制逻辑,防止反向操作和死循环
|
|
1084
1099
|
if (mapping.deviceType === 'switch') {
|
|
1085
|
-
// 开关命令(只处理cmd地址)
|
|
1086
|
-
if (addrFunc === 'cmd'
|
|
1087
|
-
const switchValue = (value
|
|
1100
|
+
// 开关命令(只处理cmd地址,忽略status地址)
|
|
1101
|
+
if (addrFunc === 'cmd') {
|
|
1102
|
+
const switchValue = parseBoolean(value);
|
|
1088
1103
|
const loopKey = `${mapping.meshMac}_${mapping.meshChannel}_switch_${switchValue}`;
|
|
1089
1104
|
|
|
1090
1105
|
// 防死循环检查
|
|
@@ -1094,7 +1109,7 @@ module.exports = function(RED) {
|
|
|
1094
1109
|
return;
|
|
1095
1110
|
}
|
|
1096
1111
|
|
|
1097
|
-
node.log(`[KNX
|
|
1112
|
+
node.log(`[KNX->Mesh] 开关控制: ${mapping.name} CH${mapping.meshChannel} = ${switchValue ? 'ON' : 'OFF'}`);
|
|
1098
1113
|
node.queueCommand({
|
|
1099
1114
|
direction: 'knx-to-mesh',
|
|
1100
1115
|
mapping: mapping,
|
|
@@ -1105,11 +1120,55 @@ module.exports = function(RED) {
|
|
|
1105
1120
|
});
|
|
1106
1121
|
}
|
|
1107
1122
|
}
|
|
1108
|
-
//
|
|
1123
|
+
// 场景触发逻辑
|
|
1124
|
+
else if (mapping.deviceType === 'scene') {
|
|
1125
|
+
if (addrFunc === 'cmd') { // 场景通常只有一个组地址,这里我们用cmdAddr匹配
|
|
1126
|
+
// 场景 DPT 17.001 实际上是 0-63 的整数
|
|
1127
|
+
// 但有时也会用 DPT 5.001 (0-255)
|
|
1128
|
+
let receivedScene = 0;
|
|
1129
|
+
if (Buffer.isBuffer(value)) receivedScene = value[0];
|
|
1130
|
+
else receivedScene = parseInt(value) || 0;
|
|
1131
|
+
|
|
1132
|
+
const targetScene = parseInt(mapping.sceneNumber) || 1; // 配置的场景号 (1-64)
|
|
1133
|
+
|
|
1134
|
+
// KNX场景值通常是 场景号-1 (例如场景1发送0)
|
|
1135
|
+
// 但也有设备发送直接的场景号,这里我们兼容两种情况:
|
|
1136
|
+
// 如果接收值 == 配置值,或者 接收值 == 配置值-1,都认为匹配
|
|
1137
|
+
|
|
1138
|
+
let matched = false;
|
|
1139
|
+
// 情况A: 标准 DPT 17.001, 收到值 = 场景号-1
|
|
1140
|
+
if (receivedScene === (targetScene - 1)) matched = true;
|
|
1141
|
+
// 情况B: 非标准或用户直接配置了0-63的原始值
|
|
1142
|
+
else if (receivedScene === targetScene) matched = true;
|
|
1143
|
+
|
|
1144
|
+
if (matched) {
|
|
1145
|
+
const action = (mapping.sceneAction === 'on' || mapping.sceneAction === true || mapping.sceneAction === 1);
|
|
1146
|
+
const loopKey = `${mapping.meshMac}_${mapping.meshChannel}_scene_${receivedScene}`;
|
|
1147
|
+
|
|
1148
|
+
// 防死循环
|
|
1149
|
+
if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
|
|
1150
|
+
node.log(`[KNX->Mesh] 跳过场景触发(防死循环): ${loopKey}`);
|
|
1151
|
+
done && done();
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
node.log(`[KNX->Mesh] 场景触发: 收到场景${receivedScene+1} -> Mesh开关=${action?'ON':'OFF'}`);
|
|
1156
|
+
node.queueCommand({
|
|
1157
|
+
direction: 'knx-to-mesh',
|
|
1158
|
+
mapping: mapping,
|
|
1159
|
+
type: 'switch',
|
|
1160
|
+
value: action,
|
|
1161
|
+
key: loopKey,
|
|
1162
|
+
sourceAddr: groupAddr
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
// 窗帘设备
|
|
1109
1168
|
else if (mapping.deviceType === 'cover') {
|
|
1110
1169
|
const deviceKey = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
1111
1170
|
const now = Date.now();
|
|
1112
|
-
const loopKey = `${mapping.meshMac}_${mapping.meshChannel}_cover`; // 窗帘使用通用key
|
|
1171
|
+
const loopKey = `${mapping.meshMac}_${mapping.meshChannel}_cover`; // 窗帘使用通用key
|
|
1113
1172
|
|
|
1114
1173
|
// KNX主动发起控制,直接抢占锁定(用户操作优先)
|
|
1115
1174
|
node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
@@ -1126,13 +1185,18 @@ module.exports = function(RED) {
|
|
|
1126
1185
|
// 停止时解除锁定
|
|
1127
1186
|
delete node.controlLock[deviceKey];
|
|
1128
1187
|
}
|
|
1129
|
-
else if (addrFunc === 'position'
|
|
1130
|
-
|
|
1188
|
+
else if (addrFunc === 'position') { // 仅 position 地址触发,忽略 status
|
|
1189
|
+
let pos = 0;
|
|
1190
|
+
if (Buffer.isBuffer(value)) pos = value[0];
|
|
1191
|
+
else pos = parseInt(value) || 0;
|
|
1192
|
+
|
|
1193
|
+
if (mapping.invertPosition) pos = 100 - pos;
|
|
1194
|
+
|
|
1131
1195
|
node.log(`[KNX->Mesh] 窗帘位置: ${pos}%`);
|
|
1132
1196
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_position', value: pos, key: loopKey, sourceAddr: groupAddr });
|
|
1133
1197
|
}
|
|
1134
1198
|
}
|
|
1135
|
-
// 调光灯设备
|
|
1199
|
+
// 调光灯设备
|
|
1136
1200
|
else if (mapping.deviceType.startsWith('light_')) {
|
|
1137
1201
|
const deviceKey = `${(mapping.meshMac || '').toLowerCase().replace(/:/g, '')}_light`;
|
|
1138
1202
|
const now = Date.now();
|
|
@@ -1140,59 +1204,107 @@ module.exports = function(RED) {
|
|
|
1140
1204
|
// KNX主动发起控制,直接抢占锁定(用户操作优先)
|
|
1141
1205
|
node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
1142
1206
|
|
|
1143
|
-
if (addrFunc === 'cmd'
|
|
1207
|
+
if (addrFunc === 'cmd') { // 仅 cmd 地址触发,忽略 status
|
|
1144
1208
|
// 开关
|
|
1145
|
-
const sw = (value
|
|
1209
|
+
const sw = parseBoolean(value);
|
|
1210
|
+
const loopKey = `${mapping.meshMac}_light_switch`;
|
|
1211
|
+
|
|
1212
|
+
if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
|
|
1213
|
+
node.debug(`[KNX->Mesh] 调光灯跳过: ${loopKey}`);
|
|
1214
|
+
done && done();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1146
1218
|
node.log(`[KNX->Mesh] 调光灯开关: ${sw ? 'ON' : 'OFF'}`);
|
|
1147
1219
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_switch', value: sw, key: loopKey, sourceAddr: groupAddr });
|
|
1148
1220
|
}
|
|
1149
1221
|
else if (addrFunc === 'brightness') {
|
|
1150
1222
|
// 亮度 (KNX 0-255 -> Mesh 0-100)
|
|
1151
|
-
|
|
1223
|
+
let val = 0;
|
|
1224
|
+
if (Buffer.isBuffer(value)) val = value[0];
|
|
1225
|
+
else val = parseInt(value) || 0;
|
|
1226
|
+
|
|
1227
|
+
const brightness = Math.round(val * 100 / 255);
|
|
1228
|
+
const loopKey = `${mapping.meshMac}_light_brightness`;
|
|
1229
|
+
|
|
1230
|
+
if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
|
|
1231
|
+
done && done();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1152
1235
|
node.log(`[KNX->Mesh] 调光灯亮度: ${brightness}%`);
|
|
1153
1236
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_brightness', value: brightness, key: loopKey, sourceAddr: groupAddr });
|
|
1154
1237
|
}
|
|
1155
1238
|
else if (addrFunc === 'colorTemp') {
|
|
1156
1239
|
// 色温
|
|
1157
|
-
|
|
1158
|
-
|
|
1240
|
+
let ct = 0;
|
|
1241
|
+
if (Buffer.isBuffer(value)) ct = value[0];
|
|
1242
|
+
else ct = parseInt(value) || 50;
|
|
1243
|
+
|
|
1244
|
+
const loopKey = `${mapping.meshMac}_light_ct`;
|
|
1245
|
+
if (node.shouldPreventSync('knx-to-mesh', loopKey)) return;
|
|
1246
|
+
|
|
1247
|
+
node.log(`[KNX->Mesh] 调光灯色温: ${ct}`);
|
|
1248
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_color_temp', value: ct, key: loopKey, sourceAddr: groupAddr });
|
|
1159
1249
|
}
|
|
1160
1250
|
}
|
|
1161
1251
|
// 空调设备
|
|
1162
1252
|
else if (mapping.deviceType === 'climate') {
|
|
1163
1253
|
if (addrFunc === 'cmd') {
|
|
1164
|
-
|
|
1254
|
+
const sw = parseBoolean(value);
|
|
1255
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_switch', value: sw, key: `${mapping.meshMac}_climate_sw`, sourceAddr: groupAddr });
|
|
1165
1256
|
}
|
|
1166
|
-
else if (addrFunc === 'temp'
|
|
1167
|
-
|
|
1257
|
+
else if (addrFunc === 'temp') { // 忽略 status
|
|
1258
|
+
let temp = 24;
|
|
1259
|
+
// DPT 9.001 浮点数解析通常由 knx-ultimate 完成,这里假设是数字
|
|
1260
|
+
if (typeof value === 'number') temp = value;
|
|
1261
|
+
else temp = parseFloat(value) || 24;
|
|
1262
|
+
|
|
1263
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_temp', value: temp, key: `${mapping.meshMac}_climate_temp`, sourceAddr: groupAddr });
|
|
1168
1264
|
}
|
|
1169
1265
|
else if (addrFunc === 'mode') {
|
|
1170
|
-
|
|
1266
|
+
let mode = 1;
|
|
1267
|
+
if (Buffer.isBuffer(value)) mode = value[0];
|
|
1268
|
+
else mode = parseInt(value) || 1;
|
|
1269
|
+
|
|
1270
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_mode', value: mode, key: `${mapping.meshMac}_climate_mode`, sourceAddr: groupAddr });
|
|
1171
1271
|
}
|
|
1172
1272
|
else if (addrFunc === 'fanSpeed') {
|
|
1173
|
-
|
|
1174
|
-
|
|
1273
|
+
let fan = 1;
|
|
1274
|
+
if (Buffer.isBuffer(value)) fan = value[0];
|
|
1275
|
+
else fan = parseInt(value) || 1;
|
|
1276
|
+
|
|
1277
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_fan', value: fan, key: `${mapping.meshMac}_climate_fan`, sourceAddr: groupAddr });
|
|
1175
1278
|
}
|
|
1176
1279
|
}
|
|
1177
1280
|
// 新风设备
|
|
1178
1281
|
else if (mapping.deviceType === 'fresh_air') {
|
|
1179
1282
|
if (addrFunc === 'cmd') {
|
|
1180
|
-
|
|
1283
|
+
const sw = parseBoolean(value);
|
|
1284
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'fresh_air_switch', value: sw, key: `${mapping.meshMac}_fa_sw`, sourceAddr: groupAddr });
|
|
1181
1285
|
}
|
|
1182
|
-
else if (addrFunc === 'fanSpeed'
|
|
1286
|
+
else if (addrFunc === 'fanSpeed') { // 忽略 status
|
|
1183
1287
|
// 百分比转风速: >66=高(1), >33=中(2), <=33=低(3)
|
|
1184
|
-
|
|
1288
|
+
let pct = 0;
|
|
1289
|
+
if (Buffer.isBuffer(value)) pct = value[0];
|
|
1290
|
+
else pct = parseInt(value) || 0;
|
|
1291
|
+
|
|
1185
1292
|
const speed = pct > 66 ? 1 : pct > 33 ? 2 : 3;
|
|
1186
|
-
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'fresh_air_speed', value: speed, key:
|
|
1293
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'fresh_air_speed', value: speed, key: `${mapping.meshMac}_fa_speed`, sourceAddr: groupAddr });
|
|
1187
1294
|
}
|
|
1188
1295
|
}
|
|
1189
1296
|
// 地暖设备
|
|
1190
1297
|
else if (mapping.deviceType === 'floor_heating') {
|
|
1191
1298
|
if (addrFunc === 'cmd') {
|
|
1192
|
-
|
|
1299
|
+
const sw = parseBoolean(value);
|
|
1300
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'floor_heating_switch', value: sw, key: `${mapping.meshMac}_fh_sw`, sourceAddr: groupAddr });
|
|
1193
1301
|
}
|
|
1194
|
-
else if (addrFunc === 'temp'
|
|
1195
|
-
|
|
1302
|
+
else if (addrFunc === 'temp') { // 忽略 status
|
|
1303
|
+
let temp = 24;
|
|
1304
|
+
if (typeof value === 'number') temp = value;
|
|
1305
|
+
else temp = parseFloat(value) || 24;
|
|
1306
|
+
|
|
1307
|
+
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'floor_heating_temp', value: temp, key: `${mapping.meshMac}_fh_temp`, sourceAddr: groupAddr });
|
|
1196
1308
|
}
|
|
1197
1309
|
}
|
|
1198
1310
|
|
|
@@ -17,17 +17,108 @@
|
|
|
17
17
|
label: function() { return this.name || 'KNX HA桥接'; },
|
|
18
18
|
oneditprepare: function() {
|
|
19
19
|
const node = this;
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
// 设置编辑面板更宽更高
|
|
22
|
+
var panel = $('#dialog-form').parent();
|
|
23
|
+
if (panel.length) {
|
|
24
|
+
if (panel.width() < 1000) panel.css('width', '1000px');
|
|
25
|
+
if (panel.height() < 1000) panel.css('min-height', '1000px');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let haEntities = [];
|
|
21
29
|
const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖'};
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
// 初始化列表容器
|
|
32
|
+
$('#knx-list').css('min-height','300px').css('max-height','500px').editableList({
|
|
33
|
+
addItem: function(container, i, data) {
|
|
34
|
+
const e = data;
|
|
35
|
+
const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
36
|
+
const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
|
|
37
|
+
|
|
38
|
+
const row = $('<div class="knx-row" style="display:flex;align-items:center;font-size:12px;"></div>').appendTo(container);
|
|
39
|
+
$('<div style="flex:2;padding:0 5px;">'+e.name+'</div>').appendTo(row);
|
|
40
|
+
$('<div style="flex:1;padding:0 5px;">'+(typeLabels[e.type]||e.type)+inv+'</div>').appendTo(row);
|
|
41
|
+
$('<div style="flex:1;padding:0 5px;">'+e.cmdAddr+'</div>').appendTo(row);
|
|
42
|
+
$('<div style="flex:1;padding:0 5px;">'+(e.statusAddr||'-')+'</div>').appendTo(row);
|
|
43
|
+
$('<div style="flex:1;padding:0 5px;">'+(ext||'-')+'</div>').appendTo(row);
|
|
44
|
+
|
|
45
|
+
const btns = $('<div style="width:60px;text-align:right;"></div>').appendTo(row);
|
|
46
|
+
$('<button class="red-ui-button red-ui-button-small e-edit" title="编辑"><i class="fa fa-pencil"></i></button>')
|
|
47
|
+
.appendTo(btns)
|
|
48
|
+
.on('click', function() { editKnxEntity(data, container); });
|
|
49
|
+
|
|
50
|
+
container.find('.e-inv').on('change', function() {
|
|
51
|
+
e.invert = $(this).is(':checked');
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
sortable: true,
|
|
55
|
+
removable: true,
|
|
56
|
+
addButton: false,
|
|
57
|
+
height: 350
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
$('#map-list').css('min-height','300px').css('max-height','500px').editableList({
|
|
61
|
+
addItem: function(container, i, data) {
|
|
62
|
+
const m = data;
|
|
63
|
+
const row = $('<div class="map-row" style="display:flex;align-items:center;gap:5px;"></div>').appendTo(container);
|
|
64
|
+
|
|
65
|
+
// KNX实体选择
|
|
66
|
+
const selKnx = $('<select class="m-knx" style="flex:1;font-size:11px;"><option value="">--选择KNX--</option></select>').appendTo(row);
|
|
67
|
+
function updateKnxOpts() {
|
|
68
|
+
const currentVal = selKnx.val() || m.knxEntityId;
|
|
69
|
+
selKnx.empty().append('<option value="">--选择KNX--</option>');
|
|
70
|
+
const entities = [];
|
|
71
|
+
$('#knx-list').editableList('items').each(function() {
|
|
72
|
+
entities.push($(this).data('data'));
|
|
73
|
+
});
|
|
74
|
+
entities.forEach(e => {
|
|
75
|
+
const inv = e.invert ? '↕' : '';
|
|
76
|
+
$('<option value="'+e.id+'">'+e.name+'['+e.cmdAddr+']('+inv+(typeLabels[e.type]||'')+')</option>').appendTo(selKnx);
|
|
77
|
+
});
|
|
78
|
+
selKnx.val(currentVal);
|
|
79
|
+
}
|
|
80
|
+
updateKnxOpts();
|
|
81
|
+
container.data('updateKnxOpts', updateKnxOpts);
|
|
82
|
+
|
|
83
|
+
// HA实体选择 (使用 datalist 实现搜索)
|
|
84
|
+
const listId = 'ha-list-' + Math.floor(Math.random() * 1000000);
|
|
85
|
+
$('<input type="text" class="m-ha-input" placeholder="输入实体ID或名称搜索" value="'+(m.haEntityId||'')+'" list="'+listId+'" style="flex:1;font-size:11px;">').appendTo(row);
|
|
86
|
+
const datalist = $('<datalist id="'+listId+'"></datalist>').appendTo(row);
|
|
87
|
+
|
|
88
|
+
function updateHaOpts() {
|
|
89
|
+
datalist.empty();
|
|
90
|
+
haEntities.forEach(e => {
|
|
91
|
+
$('<option value="'+e.entity_id+'">'+e.name+'</option>').appendTo(datalist);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
updateHaOpts();
|
|
95
|
+
// 暴露更新方法
|
|
96
|
+
container.data('updateHaOpts', updateHaOpts);
|
|
97
|
+
|
|
98
|
+
// 绑定事件
|
|
99
|
+
selKnx.on('change', function() {
|
|
100
|
+
m.knxEntityId = $(this).val();
|
|
101
|
+
});
|
|
102
|
+
row.find('.m-ha-input').on('input change', function() {
|
|
103
|
+
m.haEntityId = $(this).val();
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
sortable: true,
|
|
107
|
+
removable: true,
|
|
108
|
+
height: 350
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 加载初始数据
|
|
112
|
+
try {
|
|
113
|
+
const knxEntities = JSON.parse(node.knxEntities || '[]');
|
|
114
|
+
knxEntities.forEach(e => $('#knx-list').editableList('addItem', e));
|
|
115
|
+
} catch(e) {}
|
|
25
116
|
|
|
26
117
|
function loadHaEntities() {
|
|
27
118
|
const sid = $('#node-input-haServer').val();
|
|
28
119
|
if (!sid) {
|
|
29
120
|
haEntities = [];
|
|
30
|
-
|
|
121
|
+
refreshHaLists();
|
|
31
122
|
return;
|
|
32
123
|
}
|
|
33
124
|
|
|
@@ -37,7 +128,7 @@
|
|
|
37
128
|
.done(function(data) {
|
|
38
129
|
console.log('[KNX-HA Bridge] 收到响应:', data);
|
|
39
130
|
haEntities = data || [];
|
|
40
|
-
|
|
131
|
+
refreshHaLists();
|
|
41
132
|
if (haEntities.length > 0) {
|
|
42
133
|
RED.notify('成功加载 ' + haEntities.length + ' 个HA实体', 'success');
|
|
43
134
|
} else {
|
|
@@ -47,11 +138,28 @@
|
|
|
47
138
|
.fail(function(err) {
|
|
48
139
|
console.error('[KNX-HA Bridge] 加载失败:', err);
|
|
49
140
|
haEntities = [];
|
|
50
|
-
|
|
141
|
+
refreshHaLists();
|
|
51
142
|
RED.notify('HA服务器还未就绪,请稍后重试', 'warning');
|
|
52
143
|
});
|
|
53
144
|
}
|
|
54
145
|
|
|
146
|
+
function refreshHaLists() {
|
|
147
|
+
$('#map-list').editableList('items').each(function() {
|
|
148
|
+
const updateFunc = $(this).data('updateHaOpts');
|
|
149
|
+
if (updateFunc) updateFunc();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 刷新映射列表(主要用于初次加载)
|
|
154
|
+
function refreshMappings() {
|
|
155
|
+
if ($('#map-list').editableList('items').length === 0) {
|
|
156
|
+
try {
|
|
157
|
+
const mappings = JSON.parse(node.mappings || '[]');
|
|
158
|
+
mappings.forEach(m => $('#map-list').editableList('addItem', m));
|
|
159
|
+
} catch(e) {}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
55
163
|
$('#download-tpl-btn').on('click', function() {
|
|
56
164
|
const tpl = `# KNX实体导入模板 (Tab分隔)
|
|
57
165
|
# 格式: 名称 类型 命令地址 状态地址 扩展1 扩展2 扩展3
|
|
@@ -81,32 +189,6 @@
|
|
|
81
189
|
a.download = 'knx-template.txt'; a.click();
|
|
82
190
|
});
|
|
83
191
|
|
|
84
|
-
function renderKnxEntities() {
|
|
85
|
-
const c = $('#knx-list'); c.empty(); $('#knx-cnt').text(knxEntities.length);
|
|
86
|
-
if (!knxEntities.length) { c.html('<div class="tips">点击"导入"或"添加"管理KNX实体</div>'); return; }
|
|
87
|
-
let h = '<table class="tbl"><tr><th>名称</th><th>类型</th><th>命令</th><th>状态</th><th>扩展</th><th style="width:60px">操作</th></tr>';
|
|
88
|
-
knxEntities.forEach((e,i) => {
|
|
89
|
-
const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
90
|
-
const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
|
|
91
|
-
h += '<tr data-ei="'+i+'"><td>'+e.name+'</td><td>'+(typeLabels[e.type]||e.type)+inv+'</td><td>'+e.cmdAddr+'</td><td>'+(e.statusAddr||'-')+'</td><td>'+(ext||'-')+'</td><td><button class="red-ui-button red-ui-button-small e-edit" title="编辑"><i class="fa fa-pencil"></i></button> <button class="red-ui-button red-ui-button-small e-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
|
|
92
|
-
});
|
|
93
|
-
c.html(h+'</table>');
|
|
94
|
-
$('.e-inv').off('change').on('change', function() {
|
|
95
|
-
const ei = $(this).closest('tr').data('ei');
|
|
96
|
-
knxEntities[ei].invert = $(this).is(':checked');
|
|
97
|
-
saveKnxData();
|
|
98
|
-
});
|
|
99
|
-
$('.e-edit').off('click').on('click', function() {
|
|
100
|
-
const ei = $(this).closest('tr').data('ei');
|
|
101
|
-
editKnxEntity(ei);
|
|
102
|
-
});
|
|
103
|
-
$('.e-del').off('click').on('click', function() {
|
|
104
|
-
const ei = $(this).closest('tr').data('ei');
|
|
105
|
-
knxEntities.splice(ei, 1);
|
|
106
|
-
renderKnxEntities(); renderMappings();
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
192
|
const typeFields = {
|
|
111
193
|
'switch': ['命令地址*','状态地址'],
|
|
112
194
|
'light_mono': ['开关地址*','状态地址','亮度地址'],
|
|
@@ -119,11 +201,16 @@
|
|
|
119
201
|
'floor_heating': ['开关地址*','温度地址','当前温度']
|
|
120
202
|
};
|
|
121
203
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
204
|
+
// 编辑KNX实体
|
|
205
|
+
let currentEditContainer = null;
|
|
206
|
+
let currentEditData = null;
|
|
207
|
+
|
|
208
|
+
function showEntityPanel(data, container) {
|
|
209
|
+
const isEdit = !!data;
|
|
210
|
+
const e = isEdit ? data : {name:'',type:'switch',cmdAddr:'',statusAddr:'',ext1:'',ext2:'',ext3:'',invert:false};
|
|
211
|
+
currentEditContainer = container;
|
|
212
|
+
currentEditData = data;
|
|
213
|
+
|
|
127
214
|
const typeOpts = Object.keys(typeLabels).map(t => '<option value="'+t+'"'+(t===e.type?' selected':'')+'>'+typeLabels[t]+'</option>').join('');
|
|
128
215
|
const title = isEdit ? '编辑: '+e.name : '添加KNX实体';
|
|
129
216
|
const btnText = isEdit ? '保存' : '添加';
|
|
@@ -163,7 +250,8 @@
|
|
|
163
250
|
const name = $('#edit-name').val().trim();
|
|
164
251
|
const cmd = $('#edit-cmd').val().trim();
|
|
165
252
|
if (!name || !cmd) { RED.notify('请填写名称和命令地址', 'warning'); return; }
|
|
166
|
-
|
|
253
|
+
|
|
254
|
+
const newData = {
|
|
167
255
|
id: isEdit ? e.id : 'k'+Date.now()+Math.random().toString(36).substr(2,4),
|
|
168
256
|
name: name,
|
|
169
257
|
type: $('#edit-type').val(),
|
|
@@ -174,60 +262,35 @@
|
|
|
174
262
|
ext3: $('#edit-ext3').val().trim(),
|
|
175
263
|
invert: $('#edit-inv').is(':checked')
|
|
176
264
|
};
|
|
177
|
-
|
|
178
|
-
|
|
265
|
+
|
|
266
|
+
if (isEdit) {
|
|
267
|
+
Object.assign(currentEditData, newData);
|
|
268
|
+
const items = [];
|
|
269
|
+
$('#knx-list').editableList('items').each(function() {
|
|
270
|
+
items.push($(this).data('data'));
|
|
271
|
+
});
|
|
272
|
+
$('#knx-list').editableList('empty');
|
|
273
|
+
items.forEach(item => $('#knx-list').editableList('addItem', item));
|
|
274
|
+
} else {
|
|
275
|
+
$('#knx-list').editableList('addItem', newData);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
$('#map-list').editableList('items').each(function() {
|
|
279
|
+
const updateFunc = $(this).data('updateKnxOpts');
|
|
280
|
+
if (updateFunc) updateFunc();
|
|
281
|
+
});
|
|
282
|
+
|
|
179
283
|
$('#edit-panel').hide().empty();
|
|
180
|
-
renderKnxEntities(); renderMappings();
|
|
181
284
|
RED.notify(isEdit?'已更新':'已添加', 'success');
|
|
182
285
|
});
|
|
183
286
|
$('#cancel-edit').on('click', function() { $('#edit-panel').hide().empty(); });
|
|
184
287
|
}
|
|
185
|
-
function editKnxEntity(
|
|
186
|
-
|
|
187
|
-
function renderMappings() {
|
|
188
|
-
const c = $('#map-list'); c.empty();
|
|
189
|
-
if (!mappings.length) { c.html('<div class="tips">点击"添加"创建映射</div>'); return; }
|
|
190
|
-
let h = '<table class="tbl"><tr><th style="width:24px">#</th><th style="width:45%">KNX实体</th><th style="width:45%">HA实体</th><th style="width:32px">删除</th></tr>';
|
|
191
|
-
mappings.forEach((m, i) => {
|
|
192
|
-
h += '<tr data-i="'+i+'"><td>'+(i+1)+'</td>';
|
|
193
|
-
h += '<td><select class="m-knx"><option value="">--选择KNX--</option>';
|
|
194
|
-
knxEntities.forEach(e => {
|
|
195
|
-
const inv = e.invert ? '↕' : '';
|
|
196
|
-
h += '<option value="'+e.id+'"'+(e.id===m.knxEntityId?' selected':'')+'>'+e.name+'['+e.cmdAddr+']('+inv+(typeLabels[e.type]||'')+')</option>';
|
|
197
|
-
});
|
|
198
|
-
h += '</select></td>';
|
|
199
|
-
h += '<td><input type="text" class="m-ha-input" placeholder="输入实体ID或名称搜索" value="'+(m.haEntityId||'')+'" list="ha-list-'+i+'" style="width:100%; font-size:11px">';
|
|
200
|
-
h += '<datalist id="ha-list-'+i+'">';
|
|
201
|
-
haEntities.forEach(e => {
|
|
202
|
-
h += '<option value="'+e.entity_id+'">'+e.name+'</option>';
|
|
203
|
-
});
|
|
204
|
-
h += '</datalist></td>';
|
|
205
|
-
h += '<td><button class="red-ui-button red-ui-button-small m-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
|
|
206
|
-
});
|
|
207
|
-
c.html(h+'</table>');
|
|
208
|
-
bindEvents();
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function bindEvents() {
|
|
212
|
-
$('.m-knx').off('change').on('change', function() {
|
|
213
|
-
const i = $(this).closest('tr').data('i');
|
|
214
|
-
mappings[i].knxEntityId = $(this).val();
|
|
215
|
-
});
|
|
216
|
-
$('.m-ha-input').off('input change').on('input change', function() {
|
|
217
|
-
const i = $(this).closest('tr').data('i');
|
|
218
|
-
mappings[i].haEntityId = $(this).val();
|
|
219
|
-
});
|
|
220
|
-
$('.m-del').off('click').on('click', function() {
|
|
221
|
-
mappings.splice($(this).closest('tr').data('i'), 1);
|
|
222
|
-
renderMappings();
|
|
223
|
-
});
|
|
224
|
-
}
|
|
288
|
+
function editKnxEntity(data, container) { showEntityPanel(data, container); }
|
|
225
289
|
|
|
226
290
|
$('#add-map-btn').on('click', function() {
|
|
227
|
-
|
|
228
|
-
renderMappings();
|
|
291
|
+
$('#map-list').editableList('addItem', { knxEntityId:'', haEntityId:'' });
|
|
229
292
|
});
|
|
230
|
-
$('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) {
|
|
293
|
+
$('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) { $('#map-list').editableList('empty'); } });
|
|
231
294
|
|
|
232
295
|
$('#import-btn').on('click', function() { $('#import-modal').show(); });
|
|
233
296
|
$('#import-cancel').on('click', function() { $('#import-modal').hide(); });
|
|
@@ -241,21 +304,32 @@
|
|
|
241
304
|
const p = line.split(/\t+/);
|
|
242
305
|
if (p.length >= 2 && p[1] && /\d+\/\d+\/\d+/.test(p[2]||'')) {
|
|
243
306
|
const id = 'k' + Date.now() + Math.random().toString(36).substr(2,4);
|
|
244
|
-
|
|
307
|
+
const entity = { id, name:p[0].trim(), type:p[1].trim(), cmdAddr:p[2].trim(), statusAddr:(p[3]||'').trim(), ext1:(p[4]||'').trim(), ext2:(p[5]||'').trim(), ext3:(p[6]||'').trim(), invert:false };
|
|
308
|
+
$('#knx-list').editableList('addItem', entity);
|
|
245
309
|
cnt++;
|
|
246
310
|
}
|
|
247
311
|
});
|
|
312
|
+
// 刷新映射列表中的KNX选项
|
|
313
|
+
$('#map-list').editableList('items').each(function() {
|
|
314
|
+
const updateFunc = $(this).data('updateKnxOpts');
|
|
315
|
+
if (updateFunc) updateFunc();
|
|
316
|
+
});
|
|
248
317
|
$('#import-modal').hide(); $('#import-input').val('');
|
|
249
|
-
renderKnxEntities(); renderMappings();
|
|
250
318
|
RED.notify('导入 '+cnt+' 个实体'+(cnt?'':'(需要有效组地址格式如1/2/3)'), cnt?'success':'warning');
|
|
251
319
|
});
|
|
252
|
-
$('#add-knx-btn').on('click', function() { showEntityPanel(
|
|
253
|
-
$('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) {
|
|
320
|
+
$('#add-knx-btn').on('click', function() { showEntityPanel(null, null); });
|
|
321
|
+
$('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) { $('#knx-list').editableList('empty'); } });
|
|
254
322
|
|
|
255
|
-
function saveKnxData() {
|
|
323
|
+
function saveKnxData() {
|
|
324
|
+
const entities = [];
|
|
325
|
+
$('#knx-list').editableList('items').each(function() {
|
|
326
|
+
entities.push($(this).data('data'));
|
|
327
|
+
});
|
|
328
|
+
$('#knx-data').val(JSON.stringify(entities));
|
|
329
|
+
}
|
|
256
330
|
|
|
257
|
-
|
|
258
|
-
renderKnxEntities = function() { origRender(); saveKnxData(); };
|
|
331
|
+
// 渲染后保存 (Deprecated in editableList mode, but kept for compatibility logic if any)
|
|
332
|
+
// renderKnxEntities = function() { origRender(); saveKnxData(); };
|
|
259
333
|
|
|
260
334
|
$('#node-input-haServer').on('change', function() {
|
|
261
335
|
setTimeout(loadHaEntities, 2000);
|
|
@@ -269,7 +343,7 @@
|
|
|
269
343
|
});
|
|
270
344
|
|
|
271
345
|
setTimeout(function() {
|
|
272
|
-
|
|
346
|
+
refreshMappings();
|
|
273
347
|
if ($('#node-input-haServer').val()) {
|
|
274
348
|
setTimeout(loadHaEntities, 2000);
|
|
275
349
|
}
|
|
@@ -277,12 +351,18 @@
|
|
|
277
351
|
},
|
|
278
352
|
oneditsave: function() {
|
|
279
353
|
const maps = [];
|
|
280
|
-
$('#map-list
|
|
281
|
-
const m =
|
|
354
|
+
$('#map-list').editableList('items').each(function() {
|
|
355
|
+
const m = $(this).data('data');
|
|
282
356
|
if (m.knxEntityId && m.haEntityId) maps.push(m);
|
|
283
357
|
});
|
|
284
358
|
this.mappings = JSON.stringify(maps);
|
|
285
|
-
|
|
359
|
+
|
|
360
|
+
const entities = [];
|
|
361
|
+
$('#knx-list').editableList('items').each(function() {
|
|
362
|
+
entities.push($(this).data('data'));
|
|
363
|
+
});
|
|
364
|
+
$('#knx-data').val(JSON.stringify(entities));
|
|
365
|
+
this.knxEntities = $('#knx-data').val();
|
|
286
366
|
}
|
|
287
367
|
});
|
|
288
368
|
</script>
|