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 CHANGED
@@ -704,17 +704,19 @@ node-red-contrib-symi-mesh/
704
704
 
705
705
  ## 更新日志
706
706
 
707
- ### v1.8.11 (2026-01-16)
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
- ### v1.8.10 (2026-01-16)
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
- - **错误限流**:当 Mesh 网关离线时,KNX 同步节点会自动抑制重复的连接错误日志(1分钟内只显示一次),避免日志刷屏。
735
- - **网络噪音过滤**:TCP 客户端自动过滤常见的网络连接错误(如 EHOSTUNREACH),减少生产环境的日志干扰。
736
-
737
- ### v1.8.9 (2026-01-14)
738
-
739
- **KNX 协议回显消除算法升级**:
740
- - **值校验机制**:在原有的“时间窗口”回显消除基础上,新增“状态值”深度比对。
741
- - **原理**:当 KNX 收到消息时,不仅检查该设备最近是否发送过命令,还对比收到的值与发出的值是否一致。
742
- - **效果**:彻底解决“Mesh 开 -> KNX 开 -> 立即 KNX 关”场景下,OFF 指令被误判为回显而被拦截的问题。只有当时间相近且值完全相同时,才判定为回显并忽略;值发生变化时,立即响应并同步,实现毫秒级双向跟随。
743
- **KNX 开关双向同步逻辑深度优化**:
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.6
785
+ **版本**: 1.8.13
893
786
  **协议**: 蓝牙MESH网关(初级版)串口协议V1.0
894
- **最后更新**: 2026-01-07
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
- this.logger.error('Connection timeout');
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
 
@@ -139,6 +139,7 @@ module.exports = function(RED) {
139
139
 
140
140
  } catch (error) {
141
141
  // 初始连接失败,但自动重连会继续尝试
142
+ // 使用节流日志,避免初始化时如果立刻连不上就刷屏
142
143
  this.logErrorThrottled(`Initial connection failed: ${error.message}, will retry automatically`);
143
144
  }
144
145
 
@@ -250,96 +250,133 @@
250
250
  }
251
251
 
252
252
  // 渲染映射列表
253
- function renderMappings() {
254
- var container = $('#mapping-list');
255
- container.empty();
256
-
257
- if (!mappings || !Array.isArray(mappings) || mappings.length === 0) {
258
- container.append('<div class="mapping-empty">暂无映射,点击下方按钮添加</div>');
259
- return;
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
- bindEvents();
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
- mappings[idx].symiMac = mac || '';
303
- mappings[idx].symiName = opt.data('name') || opt.text().replace(/ \[.*?\]/g, '');
304
- mappings[idx].symiChannels = parseInt(opt.data('channels')) || 1;
305
- mappings[idx].symiDeviceType = opt.data('devicetype') || '';
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
- row.find('.symi-key-wrap').html(getKeyOptions(mac, 1, mappings[idx].symiChannels, mappings[idx].symiEntityType));
310
- bindEvents();
311
- });
312
-
313
- container.find('.symi-key').off('change').on('change', function() {
314
- var idx = $(this).closest('.mapping-row').data('idx');
315
- mappings[idx].symiKey = parseInt($(this).val()) || 1;
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
- container.find('.sync-mode').off('change').on('change', function() {
319
- var idx = $(this).closest('.mapping-row').data('idx');
320
- mappings[idx].syncMode = parseInt($(this).val()) || 0;
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
- container.find('.ha-select').off('change').on('change', function() {
324
- var idx = $(this).closest('.mapping-row').data('idx');
325
- var opt = $(this).find('option:selected');
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
- container.find('.btn-remove').off('click').on('click', function() {
331
- var idx = $(this).closest('.mapping-row').data('idx');
332
- mappings.splice(idx, 1);
333
- renderMappings();
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
- mappings.push({
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(renderMappings);
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(renderMappings);
417
+ loadHaEntities(function() {
418
+ renderMappings();
419
+ });
378
420
  }, 100);
379
421
  });
380
422
 
381
423
  // 保存时更新所有数据
382
424
  node._saveAll = function() {
383
- node.mappings = JSON.stringify(mappings);
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').css('max-height', Math.max(150, height) + 'px');
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)*', '绑定Mesh开关状态(1=开/0=关)*']
115
+ 'scene': ['KNX组地址*', '场景号(1-64)*', '触发Mesh动作(1=开/0=关)*']
116
116
  };
117
117
 
118
118
  // 统一的添加/编辑KNX实体面板
@@ -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
- // 注意:这里使用的是 knx-to-mesh 方向的记录来防止 mesh-to-knx 的发送
362
- // 即:如果最近收到了 KNX 场景命令导致 Mesh 变化,这里应该拦截
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' || addrFunc === 'status') {
1087
- const switchValue = (value === 1 || value === true || value === 'on' || value === 'ON');
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输入] 收到: ${groupAddr} = ${value}, DPT=${dpt}, 功能=${addrFunc}, 设备=${mapping.deviceType}`);
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
- // 窗帘设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
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,因为有专门的controlLock
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' || addrFunc === 'status') {
1130
- const pos = mapping.invertPosition ? (100 - (parseInt(value) || 0)) : (parseInt(value) || 0);
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
- // 调光灯设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
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' || addrFunc === 'status') {
1207
+ if (addrFunc === 'cmd') { // cmd 地址触发,忽略 status
1144
1208
  // 开关
1145
- const sw = (value === 1 || value === true);
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
- const brightness = Math.round((parseInt(value) || 0) * 100 / 255);
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
- node.log(`[KNX->Mesh] 调光灯色温: ${value}`);
1158
- node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_color_temp', value: parseInt(value) || 50, key: loopKey, sourceAddr: groupAddr });
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
- node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_switch', value: value === 1 || value === true, key: loopKey, sourceAddr: groupAddr });
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' || addrFunc === 'status') {
1167
- node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_temp', value: parseFloat(value) || 24, key: loopKey, sourceAddr: groupAddr });
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
- node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_mode', value: parseInt(value) || 1, key: loopKey, sourceAddr: groupAddr });
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
- node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_fan', value: parseInt(value) || 1, key: loopKey, sourceAddr: groupAddr });
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
- node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'fresh_air_switch', value: value === 1 || value === true, key: loopKey, sourceAddr: groupAddr });
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' || addrFunc === 'status') {
1286
+ else if (addrFunc === 'fanSpeed') { // 忽略 status
1183
1287
  // 百分比转风速: >66=高(1), >33=中(2), <=33=低(3)
1184
- const pct = parseInt(value) || 0;
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: loopKey, sourceAddr: groupAddr });
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
- node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'floor_heating_switch', value: value === 1 || value === true, key: loopKey, sourceAddr: groupAddr });
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' || addrFunc === 'status') {
1195
- node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'floor_heating_temp', value: parseFloat(value) || 24, key: loopKey, sourceAddr: groupAddr });
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
- let mappings = [], haEntities = [], knxEntities = [];
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
- try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
24
- try { knxEntities = JSON.parse(node.knxEntities || '[]'); } catch(e) { knxEntities = []; }
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
- renderMappings();
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
- renderMappings();
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
- renderMappings();
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
- let editingIndex = -1;
123
- function showEntityPanel(index) {
124
- const isEdit = index >= 0;
125
- const e = isEdit ? knxEntities[index] : {name:'',type:'switch',cmdAddr:'',statusAddr:'',ext1:'',ext2:'',ext3:'',invert:false};
126
- editingIndex = index;
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
- const entity = {
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
- if (isEdit) { knxEntities[editingIndex] = entity; }
178
- else { knxEntities.push(entity); }
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(index) { showEntityPanel(index); }
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
- mappings.push({ knxEntityId:'', haEntityId:'' });
228
- renderMappings();
291
+ $('#map-list').editableList('addItem', { knxEntityId:'', haEntityId:'' });
229
292
  });
230
- $('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) { mappings=[]; renderMappings(); } });
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
- knxEntities.push({ 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 });
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(-1); });
253
- $('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) { knxEntities=[]; renderKnxEntities(); renderMappings(); } });
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() { $('#knx-data').val(JSON.stringify(knxEntities)); }
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
- const origRender = renderKnxEntities;
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
- renderKnxEntities();
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 tr[data-i]').each(function() {
281
- const m = { knxEntityId: $(this).find('.m-knx').val(), haEntityId: $(this).find('.m-ha-input').val() };
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
- this.knxEntities = $('#knx-data').val() || '[]';
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.8.11",
3
+ "version": "1.8.13",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {