node-red-contrib-symi-mesh 1.6.8 → 1.6.9

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
@@ -16,6 +16,7 @@
16
16
  - **三合一面板**:完整支持空调+新风+地暖三合一控制面板,自动识别
17
17
  - **RS485/Modbus集成**:支持第三方485设备双向同步,内置协议模板
18
18
  - **KNX集成**:支持与KNX系统双向同步
19
+ - **KNX-HA集成**:支持KNX与Home Assistant实体直接双向同步
19
20
  - **云端同步**:从酒店云云平台自动获取设备名称和场景信息
20
21
  - **稳定可靠**:完善的错误处理和自动重连机制
21
22
 
@@ -179,6 +180,208 @@ node-red-restart
179
180
 
180
181
  每个功能独立同步,互不干扰。
181
182
 
183
+ ## KNX-HA双向同步
184
+
185
+ 本节点支持KNX与Home Assistant实体直接双向同步,实现KNX系统与HA的实时互联互通。
186
+
187
+ ### 功能特点
188
+
189
+ - **直接实体同步**:通过HA events-state节点实时接收HA状态变化事件
190
+ - **KNX实体导入**:支持Tab分隔格式导入KNX组地址配置,与KNX桥接节点100%一致
191
+ - **HA实体加载**:从HA服务器节点自动加载所有支持的实体
192
+ - **快速输入**:支持直接输入实体ID或名称搜索,带autocomplete提示,支持806+实体
193
+ - **多设备类型**:开关、灯光、窗帘、空调、风扇
194
+ - **双向同步**:自动处理KNX↔HA状态同步
195
+ - **智能防抖**:调光300ms、窗帘500ms防抖,避免过程中频繁同步
196
+ - **防死循环**:内置800ms防抖机制
197
+ - **持久化保存**:映射配置自动保存,重启后保持
198
+ - **宽屏界面**:对话框最小900px宽度,适配大屏幕
199
+
200
+ ### 配置步骤
201
+
202
+ #### 1. 安装依赖节点
203
+
204
+ ```bash
205
+ cd ~/.node-red
206
+ npm install node-red-contrib-knx-ultimate
207
+ npm install node-red-contrib-home-assistant-websocket
208
+ ```
209
+
210
+ #### 2. 配置HA服务器
211
+
212
+ 1. 添加Home Assistant服务器配置节点(如果还没有)
213
+ 2. 输入HA的URL和访问令牌
214
+ 3. 测试连接确保成功
215
+
216
+ #### 3. 添加KNX-HA桥接节点
217
+
218
+ 从"Symi Mesh"分类中拖入"KNX-HA桥接"节点
219
+
220
+ #### 4. 选择HA服务器
221
+
222
+ 在节点配置中选择已配置的Home Assistant服务器节点
223
+
224
+ #### 5. 导入KNX实体
225
+
226
+ 1. 点击"下载模板"获取导入格式示例
227
+ 2. 按照Tab分隔格式准备KNX组地址数据
228
+ 3. 点击"导入"按钮,粘贴数据
229
+ 4. 确认导入
230
+
231
+ **导入格式示例**:
232
+ ```
233
+ 玄关射灯 switch 1/1/28 1/2/28
234
+ 客厅吊灯 light 1/1/10 1/2/10 1/3/10 1/4/10
235
+ 客厅布帘 cover 2/1/5 2/2/5 2/3/5
236
+ 主卧空调 climate 3/1/1 3/2/1 3/3/1 3/4/1 3/5/1
237
+ ```
238
+
239
+ #### 6. 加载HA实体
240
+
241
+ 1. 选择HA服务器后,系统会自动加载实体(约2秒延迟)
242
+ 2. 如果未成功加载,点击"刷新"按钮重新加载
243
+ 3. 系统使用HA REST API直接获取实体列表
244
+
245
+ #### 7. 配置映射
246
+
247
+ 1. 点击"添加"按钮
248
+ 2. 选择KNX实体
249
+ 3. 输入或选择HA实体(支持直接输入实体ID或输入名称快速搜索)
250
+ 4. 可以配置多个映射关系
251
+
252
+ #### 8. 连接KNX节点
253
+
254
+ ```
255
+ [knxUltimate-in] → [KNX-HA桥接] → [knxUltimate-out]
256
+ ```
257
+
258
+ **knxUltimate-in配置**:
259
+ - Listen to all GA:勾选
260
+ - Telegram type:写入
261
+ - Notify on write:勾选
262
+ - Notify on response:勾选
263
+
264
+ **knxUltimate-out配置**:
265
+ - Listen to all GA:勾选(Universal Mode)
266
+ - Output type:Write
267
+
268
+ #### 9. 连接HA节点(实现HA→KNX同步)
269
+
270
+ ```
271
+ [HA server-state-changed] → [KNX-HA桥接]
272
+ ```
273
+
274
+ **HA server-state-changed配置**:
275
+ - 节点类型:events: state changed
276
+ - 实体ID:选择映射中的HA实体(或留空监听所有)
277
+ - 输出属性:data(包含entity_id、new_state、old_state)
278
+
279
+ **重要**:server-state-changed节点必须直接连接到KNX-HA桥接节点的输入,不要通过knxUltimate-in
280
+
281
+ #### 10. 部署
282
+
283
+ 点击"部署"按钮,开始双向同步
284
+
285
+ ### 支持的设备类型
286
+
287
+ | KNX设备类型 | HA实体类型 | 同步内容 | KNX地址字段 |
288
+ |------------|-----------|----------|-------------|
289
+ | 开关 | switch | 开/关状态 | 命令, 状态 |
290
+ | 灯光 | light | 开关、亮度、色温 | 开关, 状态, 亮度, 色温 |
291
+ | 窗帘 | cover | 开关、位置、停止 | 上下, 位置, 停止 |
292
+ | 空调 | climate | 开关、温度、模式 | 开关, 温度, 模式, 风速, 当前温度 |
293
+ | 风扇 | fan | 开关、风速 | 开关, 风速 |
294
+
295
+ ### 连接方式
296
+
297
+ ```
298
+ [knxUltimate-in] → [KNX-HA桥接] → [knxUltimate-out]
299
+ [server-state-changed] → [KNX-HA桥接] ↗
300
+ ```
301
+
302
+ **说明**:
303
+ - KNX→HA:knxUltimate-in → KNX-HA桥接,自动调用HA服务
304
+ - HA→KNX:server-state-changed → KNX-HA桥接,桥接节点发送KNX命令到knxUltimate-out
305
+
306
+ ### 工作原理
307
+
308
+ #### KNX → HA 同步
309
+
310
+ 1. KNX设备状态变化(如开关按下)
311
+ 2. knxUltimate-in节点接收GroupValue_Write
312
+ 3. KNX-HA桥接节点检测到变化
313
+ 4. 调用HA REST API服务(如switch.turn_on)
314
+ 5. HA实体状态更新
315
+
316
+ #### HA → KNX 同步
317
+
318
+ 1. HA实体状态变化(如在HA界面操作)
319
+ 2. server-state-changed节点实时推送state_changed事件
320
+ 3. KNX-HA桥接节点接收HA事件
321
+ 4. 桥接节点发送KNX GroupValue_Write命令到knxUltimate-out
322
+ 5. KNX设备执行动作
323
+
324
+ **防抖机制**:
325
+ - 调光灯亮度:300ms防抖,只同步最终值
326
+ - 窗帘位置:500ms防抖,避免移动过程中频繁同步
327
+ - 开关控制:立即同步,无延迟
328
+
329
+ ### 注意事项
330
+
331
+ 1. **5秒初始化延迟**:部署后前5秒不同步,等待系统初始化
332
+ 2. **事件驱动**:HA→KNX通过events-state节点实时推送,无轮询开销
333
+ 3. **防死循环**:KNX/HA控制后800ms内,反向状态变化不会同步
334
+ 4. **智能防抖**:调光300ms、窗帘500ms防抖,只同步最终值
335
+ 5. **持久化配置**:所有映射配置自动保存,重启后保持
336
+ 6. **内存优化**:命令队列限制100条,自动清理过期缓存
337
+ 7. **宽屏界面**:对话框最小1000px,映射列表高度400px
338
+ 8. **快速输入**:支持直接输入实体ID或输入名称快速定位
339
+ 9. **断线恢复**:支持断电断网后自动重连
340
+
341
+ ### 故障排查
342
+
343
+ **KNX→HA不工作**:
344
+ 1. 检查日志是否有`[KNX输入]`输出
345
+ 2. 确认knxUltimate-in节点已启用"Listen to all GA"
346
+ 3. 确认KNX组地址与映射中的地址一致
347
+ 4. 检查HA URL和Token是否正确
348
+ 5. 手动操作KNX设备,观察日志
349
+ 6. 查看调试输出端口的消息
350
+
351
+ **HA→KNX不工作**:
352
+ 1. 检查日志是否有`[HA->KNX]`输出
353
+ 2. 确认HA实体ID与映射中的实体ID一致
354
+ 3. 确认HA服务器节点已连接并启用WebSocket
355
+ 4. 在HA中操作实体,观察日志
356
+ 5. 检查knxUltimate-out节点配置
357
+ 6. 确认knxUltimate-out已启用"Listen to all GA"(Universal Mode)
358
+
359
+ **加载HA实体失败**:
360
+ 1. 检查HA服务器节点是否正确配置
361
+ 2. 确认HA服务器节点已连接
362
+ 3. 点击"刷新"按钮重新加载
363
+ 4. 检查是否安装了node-red-contrib-home-assistant-websocket
364
+ 5. 查看Node-RED日志中的详细错误信息
365
+
366
+ ### 使用场景
367
+
368
+ **适合使用KNX-HA桥接的场景**:
369
+ - HA中已有KNX集成,希望与其他系统整合
370
+ - 需要KNX设备与HA中的Zigbee、WiFi等设备联动
371
+ - 希望在HA中统一管理所有设备
372
+ - 需要利用HA的自动化和场景功能
373
+ - 已安装node-red-contrib-home-assistant-websocket,共享HA配置
374
+
375
+ **与KNX直接集成的区别**:
376
+ - KNX直接集成:HA作为KNX系统的一部分
377
+ - KNX-HA桥接:KNX与HA作为两个独立系统,通过桥接互通
378
+
379
+ **优势**:
380
+ - 无需修改现有KNX配置
381
+ - 可以选择性同步部分设备
382
+ - 支持与HA中任意实体同步
383
+ - 配置灵活,易于调整
384
+
182
385
  ## 协议说明
183
386
 
184
387
  ### 核心协议格式
@@ -853,6 +1056,7 @@ A: 每个房间部署一个云端同步节点,配置对应的酒店ID和房间
853
1056
  | **Symi Cloud Sync** | 云端同步设备名称和场景 | [云端数据同步](#云端数据同步) |
854
1057
  | **Symi RS485 Bridge** | RS485设备双向同步 | [RS485/Modbus双向同步](#rs485modbus双向同步) |
855
1058
  | **Symi KNX Bridge** | KNX系统双向同步 | [KNX双向同步](#knx双向同步) |
1059
+ | **Symi KNX-HA Bridge** | KNX与HA实体双向同步 | [KNX-HA双向同步](#knx-ha双向同步) |
856
1060
  | **RS485 Debug** | RS485总线数据抓包调试 | [RS485调试节点](#rs485调试节点) |
857
1061
 
858
1062
  ## 开发者信息
@@ -1017,6 +1221,35 @@ node-red-contrib-symi-mesh/
1017
1221
 
1018
1222
  ## 更新日志
1019
1223
 
1224
+ ### v1.6.9 (2025-12-20)
1225
+ - **KNX-HA双向同步**:新增`symi-knx-ha-bridge`节点
1226
+ - 直接连接KNX与HA实体,实现双向同步
1227
+ - 支持Tab分隔格式导入KNX组地址配置(与KNX桥接节点100%一致)
1228
+ - 使用共享HA服务器节点,通过REST API自动加载HA实体列表
1229
+ - 支持开关、灯光、窗帘、空调、风扇等设备类型
1230
+ - 完整双向同步:KNX↔HA实时状态同步
1231
+ - 事件驱动架构,通过HA events-state节点实时接收事件
1232
+ - 智能防抖:调光300ms、窗帘500ms,只同步最终值
1233
+ - 内置防死循环机制(800ms防抖)
1234
+ - 快速输入框:支持输入实体ID或名称搜索,自动提示806+实体
1235
+ - 手动刷新按钮,随时重新加载HA实体
1236
+ - 宽屏界面:对话框最小1000px宽度
1237
+ - 持久化配置保存,内存优化
1238
+ - 连接knxUltimate节点,无缝集成
1239
+ - 适用于已有KNX系统与HA整合的场景
1240
+
1241
+ ### v1.6.8 (2025-12-15)
1242
+ - **KNX双向同步修复**:彻底修复米家/面板操作KNX不同步的问题
1243
+ - 修复0x45消息类型处理:米家/面板操作发送msgType=0x45,不再限制channels>=6
1244
+ - 支持1-4路开关2字节参数解析(米家/面板场景触发格式)
1245
+ - 场景执行通知(0x11)后自动查询设备状态
1246
+ - **syncLock阻塞修复**:移除handleMeshStateChange中syncLock检查
1247
+ - 避免队列处理期间丢失状态变化事件
1248
+ - 改用per-device时间戳防回环机制
1249
+ - **processQueue健壮性**:添加try/finally确保processing标志正确重置
1250
+ - **防死循环参数调整**:LOOP_PREVENTION_MS调整为800ms
1251
+ - **同时修复RS485桥接**:应用相同syncLock修复
1252
+
1020
1253
  ### v1.6.7 (2025-12-10)
1021
1254
  - **KNX双向同步完善**:`symi-knx-bridge`节点全面升级
1022
1255
  - **knxUltimate消息格式**:符合官方规范,使用`destination`+`payload`+`dpt`+`event`
@@ -1036,18 +1269,6 @@ node-red-contrib-symi-mesh/
1036
1269
  - 多设备类型:开关、调光灯、窗帘、空调、新风、地暖
1037
1270
  - 智能通道选择:根据Mesh设备实际路数显示可选通道
1038
1271
 
1039
- ### v1.6.8 (2025-12-15)
1040
- - **KNX双向同步修复**:彻底修复米家/面板操作KNX不同步的问题
1041
- - 修复0x45消息类型处理:米家/面板操作发送msgType=0x45,不再限制channels>=6
1042
- - 支持1-4路开关2字节参数解析(米家/面板场景触发格式)
1043
- - 场景执行通知(0x11)后自动查询设备状态
1044
- - **syncLock阻塞修复**:移除handleMeshStateChange中syncLock检查
1045
- - 避免队列处理期间丢失状态变化事件
1046
- - 改用per-device时间戳防回环机制
1047
- - **processQueue健壮性**:添加try/finally确保processing标志正确重置
1048
- - **防死循环参数调整**:LOOP_PREVENTION_MS调整为800ms
1049
- - **同时修复RS485桥接**:应用相同syncLock修复
1050
-
1051
1272
  ### v1.6.6 (2025-12-09)
1052
1273
  - **三合一面板完整双向同步**:支持空调+新风+地暖独立RS485映射
1053
1274
  - 三合一0x94协议完整解析:空调(开关/模式/风速/温度)、地暖(开关/温度)、新风(开关/风速)
@@ -1076,23 +1297,6 @@ node-red-contrib-symi-mesh/
1076
1297
  - **内存安全**:命令队列限制100条,节点关闭时清理所有缓存
1077
1298
  - **文档更新**:添加三合一面板RS485配置说明和协议对照表
1078
1299
 
1079
- ### v1.6.5 (2025-12-06)
1080
- - **杜亚窗帘协议**:原生支持杜亚窗帘协议(A6B6),2字节地址,自动CRC16计算
1081
- - 帧格式:55 [地址高] [地址低] 03 [动作/位置] [CRC16低] [CRC16高]
1082
- - 支持打开(01)、关闭(02)、暂停(03)、百分比(04+位置)
1083
- - **窗帘控制智能判断**:根据当前位置判断方向
1084
- - 位置>=50% + curtainStatus变化 → 发关闭码
1085
- - 位置<50% + curtainStatus变化 → 发打开码
1086
- - 暂停(curtainStatus=3)最高优先级
1087
- - **窗帘百分比模式修复**:修复百分比控制后开/关命令失效的问题
1088
- - 百分比控制时进入百分比模式(inPosMode)
1089
- - 窗帘到位(curtainStatus=0)后自动退出百分比模式
1090
- - 退出后开/关命令可正常发送
1091
- - **发码防抖**:500ms内不重复发相同码,避免Mesh状态混乱
1092
- - **设备类型过滤**:映射只响应对应类型的状态变化
1093
- - **RS485调试增强**:新增协议测试发送功能
1094
- - **初始化延迟**:20秒,避免部署时误发命令
1095
-
1096
1300
  ## 许可证
1097
1301
 
1098
1302
  MIT License
@@ -1104,8 +1308,8 @@ Copyright (c) 2025 SYMI 亖米
1104
1308
  ## 关于
1105
1309
 
1106
1310
  **作者**: SYMI 亖米
1107
- **版本**: 1.6.8
1311
+ **版本**: 1.6.9
1108
1312
  **协议**: 蓝牙MESH网关(初级版)串口协议V1.0
1109
- **最后更新**: 2025-12-15
1313
+ **最后更新**: 2025-12-20
1110
1314
  **仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
1111
1315
  **npm包**: https://www.npmjs.com/package/node-red-contrib-symi-mesh
@@ -0,0 +1,370 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('symi-knx-ha-bridge', {
3
+ category: 'Symi Mesh',
4
+ color: '#41BDF5',
5
+ defaults: {
6
+ name: { value: '' },
7
+ haServer: { value: '', type: 'server' },
8
+ mappings: { value: '[]' },
9
+ knxEntities: { value: '[]' }
10
+ },
11
+ inputs: 1,
12
+ outputs: 2,
13
+ outputLabels: ['KNX输出', '调试信息'],
14
+ icon: 'bridge.svg',
15
+ label: function() { return this.name || 'KNX-HA桥接'; },
16
+ paletteLabel: 'KNX-HA桥接',
17
+ oneditprepare: function() {
18
+ const node = this;
19
+ let mappings = [], haEntities = [], knxEntities = [];
20
+ const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖'};
21
+
22
+ try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
23
+ try { knxEntities = JSON.parse(node.knxEntities || '[]'); } catch(e) { knxEntities = []; }
24
+
25
+ function loadHaEntities() {
26
+ const sid = $('#node-input-haServer').val();
27
+ if (!sid) {
28
+ haEntities = [];
29
+ renderMappings();
30
+ return;
31
+ }
32
+
33
+ console.log('[KNX-HA Bridge] 开始加载HA实体,服务器ID:', sid);
34
+
35
+ $.getJSON('symi-knx-ha-bridge/ha-entities/' + sid)
36
+ .done(function(data) {
37
+ console.log('[KNX-HA Bridge] 收到响应:', data);
38
+ haEntities = data || [];
39
+ renderMappings();
40
+ if (haEntities.length > 0) {
41
+ RED.notify('成功加载 ' + haEntities.length + ' 个HA实体', 'success');
42
+ } else {
43
+ RED.notify('HA服务器还未就绪,请稍后重试', 'warning');
44
+ }
45
+ })
46
+ .fail(function(err) {
47
+ console.error('[KNX-HA Bridge] 加载失败:', err);
48
+ haEntities = [];
49
+ renderMappings();
50
+ RED.notify('HA服务器还未就绪,请稍后重试', 'warning');
51
+ });
52
+ }
53
+
54
+ $('#download-tpl-btn').on('click', function() {
55
+ const tpl = `# KNX实体导入模板 (Tab分隔)
56
+ # 格式: 名称 类型 命令地址 状态地址 扩展1 扩展2 扩展3
57
+ # 类型: switch, light_mono, light_cct, light_rgb, light_rgbcw, cover, climate, fresh_air, floor_heating
58
+ #
59
+ # 开关 (命令, 状态)
60
+ 玄关射灯 switch 1/1/28 1/2/28
61
+ 客厅射灯 switch 1/1/25 1/2/25
62
+ # 单色调光 (开关, 状态, 亮度)
63
+ 卧室筒灯 light_mono 1/1/1 1/2/1 1/3/1
64
+ # 双色调光 (开关, 状态, 亮度, 色温)
65
+ 客厅吊灯 light_cct 1/1/10 1/2/10 1/3/10 1/4/10
66
+ # RGB (开关, 状态, 亮度, RGB)
67
+ 氛围灯 light_rgb 1/1/20 1/2/20 1/3/20 1/5/20
68
+ # RGBCW (开关, 状态, 亮度, 色温, RGB)
69
+ 主灯 light_rgbcw 1/1/30 1/2/30 1/3/30 1/4/30 1/5/30
70
+ # 窗帘 (上下, 位置, 停止)
71
+ 客厅布帘 cover 2/1/5 2/2/5 2/3/5
72
+ # 空调 (开关, 温度, 模式, 风速, 当前温度)
73
+ 主卧空调 climate 3/1/1 3/2/1 3/3/1 3/4/1 3/5/1
74
+ # 新风 (开关, 风速)
75
+ 全屋新风 fresh_air 4/1/1 4/2/1
76
+ # 地暖 (开关, 温度, 当前温度)
77
+ 客厅地暖 floor_heating 5/1/1 5/2/1 5/3/1`;
78
+ const blob = new Blob([tpl], {type:'text/plain;charset=utf-8'});
79
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
80
+ a.download = 'knx-template.txt'; a.click();
81
+ });
82
+
83
+ function renderKnxEntities() {
84
+ const c = $('#knx-list'); c.empty(); $('#knx-cnt').text(knxEntities.length);
85
+ if (!knxEntities.length) { c.html('<div class="tips">点击"导入"或"添加"管理KNX实体</div>'); return; }
86
+ let h = '<table class="tbl"><tr><th>名称</th><th>类型</th><th>命令</th><th>状态</th><th>扩展</th><th style="width:60px">操作</th></tr>';
87
+ knxEntities.forEach((e,i) => {
88
+ const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
89
+ const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
90
+ 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>';
91
+ });
92
+ c.html(h+'</table>');
93
+ $('.e-inv').off('change').on('change', function() {
94
+ const ei = $(this).closest('tr').data('ei');
95
+ knxEntities[ei].invert = $(this).is(':checked');
96
+ saveKnxData();
97
+ });
98
+ $('.e-edit').off('click').on('click', function() {
99
+ const ei = $(this).closest('tr').data('ei');
100
+ editKnxEntity(ei);
101
+ });
102
+ $('.e-del').off('click').on('click', function() {
103
+ const ei = $(this).closest('tr').data('ei');
104
+ knxEntities.splice(ei, 1);
105
+ renderKnxEntities(); renderMappings();
106
+ });
107
+ }
108
+
109
+ const typeFields = {
110
+ 'switch': ['命令地址*','状态地址'],
111
+ 'light_mono': ['开关地址*','状态地址','亮度地址'],
112
+ 'light_cct': ['开关地址*','状态地址','亮度地址','色温地址'],
113
+ 'light_rgb': ['开关地址*','状态地址','亮度地址','RGB地址'],
114
+ 'light_rgbcw': ['开关地址*','状态地址','亮度地址','色温地址','RGB地址'],
115
+ 'cover': ['上下地址*','位置地址','停止地址'],
116
+ 'climate': ['开关地址*','温度地址','模式地址','风速地址','当前温度'],
117
+ 'fresh_air': ['开关地址*','风速地址'],
118
+ 'floor_heating': ['开关地址*','温度地址','当前温度']
119
+ };
120
+
121
+ let editingIndex = -1;
122
+ function showEntityPanel(index) {
123
+ const isEdit = index >= 0;
124
+ const e = isEdit ? knxEntities[index] : {name:'',type:'switch',cmdAddr:'',statusAddr:'',ext1:'',ext2:'',ext3:'',invert:false};
125
+ editingIndex = index;
126
+ const typeOpts = Object.keys(typeLabels).map(t => '<option value="'+t+'"'+(t===e.type?' selected':'')+'>'+typeLabels[t]+'</option>').join('');
127
+ const title = isEdit ? '编辑: '+e.name : '添加KNX实体';
128
+ const btnText = isEdit ? '保存' : '添加';
129
+ $('#edit-panel').html(
130
+ '<div class="edit-form"><h4>'+title+'</h4>'+
131
+ '<div class="form-row"><label>名称*</label><input type="text" id="edit-name" value="'+(e.name||'')+'" placeholder="如: 客厅灯"></div>'+
132
+ '<div class="form-row"><label>类型</label><select id="edit-type">'+typeOpts+'</select></div>'+
133
+ '<div class="form-row" id="row-cmd"><label id="lbl-cmd">命令地址*</label><input type="text" id="edit-cmd" value="'+(e.cmdAddr||'')+'"></div>'+
134
+ '<div class="form-row" id="row-status"><label id="lbl-status">状态地址</label><input type="text" id="edit-status" value="'+(e.statusAddr||'')+'"></div>'+
135
+ '<div class="form-row" id="row-ext1"><label id="lbl-ext1">扩展1</label><input type="text" id="edit-ext1" value="'+(e.ext1||'')+'"></div>'+
136
+ '<div class="form-row" id="row-ext2"><label id="lbl-ext2">扩展2</label><input type="text" id="edit-ext2" value="'+(e.ext2||'')+'"></div>'+
137
+ '<div class="form-row" id="row-ext3"><label id="lbl-ext3">扩展3</label><input type="text" id="edit-ext3" value="'+(e.ext3||'')+'"></div>'+
138
+ '<div class="form-row" id="row-inv" style="display:none"><label>位置反转</label><input type="checkbox" id="edit-inv"'+(e.invert?' checked':'')+'></div>'+
139
+ '<div class="form-row"><button id="save-edit" class="red-ui-button red-ui-button-small">'+btnText+'</button> <button id="cancel-edit" class="red-ui-button red-ui-button-small">取消</button></div>'+
140
+ '</div>'
141
+ ).show();
142
+
143
+ function updateFieldLabels() {
144
+ const type = $('#edit-type').val();
145
+ const fields = typeFields[type] || ['命令地址*','状态地址'];
146
+ const rows = ['row-cmd','row-status','row-ext1','row-ext2','row-ext3'];
147
+ const labels = ['lbl-cmd','lbl-status','lbl-ext1','lbl-ext2','lbl-ext3'];
148
+ rows.forEach((row,i) => {
149
+ if (i < fields.length) {
150
+ $('#'+row).show();
151
+ $('#'+labels[i]).text(fields[i]);
152
+ } else {
153
+ $('#'+row).hide();
154
+ }
155
+ });
156
+ $('#row-inv').toggle(type === 'cover');
157
+ }
158
+ updateFieldLabels();
159
+ $('#edit-type').on('change', updateFieldLabels);
160
+
161
+ $('#save-edit').on('click', function() {
162
+ const name = $('#edit-name').val().trim();
163
+ const cmd = $('#edit-cmd').val().trim();
164
+ if (!name || !cmd) { RED.notify('请填写名称和命令地址', 'warning'); return; }
165
+ const entity = {
166
+ id: isEdit ? e.id : 'k'+Date.now()+Math.random().toString(36).substr(2,4),
167
+ name: name,
168
+ type: $('#edit-type').val(),
169
+ cmdAddr: cmd,
170
+ statusAddr: $('#edit-status').val().trim(),
171
+ ext1: $('#edit-ext1').val().trim(),
172
+ ext2: $('#edit-ext2').val().trim(),
173
+ ext3: $('#edit-ext3').val().trim(),
174
+ invert: $('#edit-inv').is(':checked')
175
+ };
176
+ if (isEdit) { knxEntities[editingIndex] = entity; }
177
+ else { knxEntities.push(entity); }
178
+ $('#edit-panel').hide().empty();
179
+ renderKnxEntities(); renderMappings();
180
+ RED.notify(isEdit?'已更新':'已添加', 'success');
181
+ });
182
+ $('#cancel-edit').on('click', function() { $('#edit-panel').hide().empty(); });
183
+ }
184
+ function editKnxEntity(index) { showEntityPanel(index); }
185
+
186
+ function renderMappings() {
187
+ const c = $('#map-list'); c.empty();
188
+ if (!mappings.length) { c.html('<div class="tips">点击"添加"创建映射</div>'); return; }
189
+ 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>';
190
+ mappings.forEach((m, i) => {
191
+ h += '<tr data-i="'+i+'"><td>'+(i+1)+'</td>';
192
+ h += '<td><select class="m-knx"><option value="">--选择KNX--</option>';
193
+ knxEntities.forEach(e => {
194
+ const inv = e.invert ? '↕' : '';
195
+ h += '<option value="'+e.id+'"'+(e.id===m.knxEntityId?' selected':'')+'>'+e.name+'['+e.cmdAddr+']('+inv+(typeLabels[e.type]||'')+')</option>';
196
+ });
197
+ h += '</select></td>';
198
+ h += '<td><input type="text" class="m-ha-input" placeholder="输入实体ID或名称搜索" value="'+(m.haEntityId||'')+'" list="ha-list-'+i+'" style="width:100%; font-size:11px">';
199
+ h += '<datalist id="ha-list-'+i+'">';
200
+ haEntities.forEach(e => {
201
+ h += '<option value="'+e.entity_id+'">'+e.name+'</option>';
202
+ });
203
+ h += '</datalist></td>';
204
+ h += '<td><button class="red-ui-button red-ui-button-small m-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
205
+ });
206
+ c.html(h+'</table>');
207
+ bindEvents();
208
+ }
209
+
210
+ function bindEvents() {
211
+ $('.m-knx').off('change').on('change', function() {
212
+ const i = $(this).closest('tr').data('i');
213
+ mappings[i].knxEntityId = $(this).val();
214
+ });
215
+ $('.m-ha-input').off('input change').on('input change', function() {
216
+ const i = $(this).closest('tr').data('i');
217
+ mappings[i].haEntityId = $(this).val();
218
+ });
219
+ $('.m-del').off('click').on('click', function() {
220
+ mappings.splice($(this).closest('tr').data('i'), 1);
221
+ renderMappings();
222
+ });
223
+ }
224
+
225
+ $('#add-map-btn').on('click', function() {
226
+ mappings.push({ knxEntityId:'', haEntityId:'' });
227
+ renderMappings();
228
+ });
229
+ $('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) { mappings=[]; renderMappings(); } });
230
+
231
+ $('#import-btn').on('click', function() { $('#import-modal').show(); });
232
+ $('#import-cancel').on('click', function() { $('#import-modal').hide(); });
233
+ $('#import-confirm').on('click', function() {
234
+ const text = $('#import-input').val().trim();
235
+ if (!text) { $('#import-modal').hide(); return; }
236
+ let cnt = 0;
237
+ text.split('\n').forEach(line => {
238
+ line = line.trim();
239
+ if (!line || line.startsWith('#')) return;
240
+ const p = line.split(/\t+/);
241
+ if (p.length >= 2 && p[1] && /\d+\/\d+\/\d+/.test(p[2]||'')) {
242
+ const id = 'k' + Date.now() + Math.random().toString(36).substr(2,4);
243
+ 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 });
244
+ cnt++;
245
+ }
246
+ });
247
+ $('#import-modal').hide(); $('#import-input').val('');
248
+ renderKnxEntities(); renderMappings();
249
+ RED.notify('导入 '+cnt+' 个实体'+(cnt?'':'(需要有效组地址格式如1/2/3)'), cnt?'success':'warning');
250
+ });
251
+ $('#add-knx-btn').on('click', function() { showEntityPanel(-1); });
252
+ $('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) { knxEntities=[]; renderKnxEntities(); renderMappings(); } });
253
+
254
+ function saveKnxData() { $('#knx-data').val(JSON.stringify(knxEntities)); }
255
+
256
+ const origRender = renderKnxEntities;
257
+ renderKnxEntities = function() { origRender(); saveKnxData(); };
258
+
259
+ $('#node-input-haServer').on('change', function() {
260
+ setTimeout(loadHaEntities, 2000);
261
+ });
262
+ $('#reload-ha-btn').on('click', function() {
263
+ $(this).prop('disabled', true).text('加载中...');
264
+ setTimeout(function() {
265
+ loadHaEntities();
266
+ $('#reload-ha-btn').prop('disabled', false).text('刷新');
267
+ }, 100);
268
+ });
269
+
270
+ setTimeout(function() {
271
+ renderKnxEntities();
272
+ if ($('#node-input-haServer').val()) {
273
+ setTimeout(loadHaEntities, 2000);
274
+ }
275
+ }, 100);
276
+ },
277
+ oneditsave: function() {
278
+ const maps = [];
279
+ $('#map-list tr[data-i]').each(function() {
280
+ const m = { knxEntityId: $(this).find('.m-knx').val(), haEntityId: $(this).find('.m-ha-input').val() };
281
+ if (m.knxEntityId && m.haEntityId) maps.push(m);
282
+ });
283
+ this.mappings = JSON.stringify(maps);
284
+ this.knxEntities = $('#knx-data').val() || '[]';
285
+ }
286
+ });
287
+ </script>
288
+
289
+ <script type="text/html" data-template-name="symi-knx-ha-bridge">
290
+ <style>
291
+ #dialog-form { min-width: 1000px; }
292
+ .tbl { width:100%; border-collapse:collapse; font-size:11px; }
293
+ .tbl th, .tbl td { padding:3px 5px; border:1px solid #ddd; }
294
+ .tbl th { background:#f0f0f0; }
295
+ .tbl select { width:100%; font-size:11px; padding:2px; }
296
+ .tbl input[type="checkbox"] { margin:0; }
297
+ #knx-list { max-height:180px; overflow-y:auto; border:1px solid #ccc; margin:5px 0; padding:3px; }
298
+ #map-list { max-height:400px; overflow-y:auto; border:1px solid #ccc; margin:5px 0; padding:3px; }
299
+ .tips { color:#666; padding:8px; text-align:center; font-size:12px; }
300
+ .sec { display:flex; justify-content:space-between; align-items:center; margin:10px 0 4px; padding-bottom:4px; border-bottom:1px solid #ddd; }
301
+ .sec b { font-size:12px; }
302
+ .btns button { margin-left:4px; }
303
+ .modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:99999; }
304
+ .modal-box { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:#fff; padding:15px; border-radius:5px; box-shadow:0 4px 20px rgba(0,0,0,0.3); z-index:100000; }
305
+ #import-modal .modal-box { width:650px; }
306
+ #import-input { width:100%; height:160px; font-family:monospace; font-size:10px; }
307
+ .modal-box h4 { margin:0 0 10px; }
308
+ .modal-box .mbtns { text-align:right; margin-top:10px; }
309
+ .info { background:#f8f8e8; border:1px solid #e0e0c0; padding:5px 8px; margin:6px 0; font-size:11px; border-radius:3px; }
310
+ .edit-form h4 { margin:0 0 10px; color:#333; }
311
+ .edit-form .form-row { margin-bottom:8px; display:flex; align-items:center; }
312
+ .edit-form .form-row label { width:70px; font-size:12px; }
313
+ .edit-form .form-row input, .edit-form .form-row select { flex:1; padding:4px; font-size:12px; }
314
+ datalist { max-height: 300px !important; }
315
+ input[list]::-webkit-calendar-picker-indicator { display: block; }
316
+ </style>
317
+
318
+ <div class="form-row">
319
+ <label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
320
+ <input type="text" id="node-input-name" placeholder="KNX-HA桥接">
321
+ </div>
322
+ <div class="form-row">
323
+ <label for="node-input-haServer"><i class="fa fa-home"></i> HA服务器</label>
324
+ <input type="text" id="node-input-haServer" style="width:calc(100% - 180px)">
325
+ <button type="button" id="reload-ha-btn" class="red-ui-button red-ui-button-small" style="margin-left:5px">刷新</button>
326
+ </div>
327
+
328
+ <div class="info"><b>连接:</b> <code>[knxUltimate-in] → [KNX-HA桥接] → [knxUltimate-out]</code></div>
329
+
330
+ <div class="sec">
331
+ <b><i class="fa fa-database"></i> KNX实体库 (<span id="knx-cnt">0</span>)</b>
332
+ <span class="btns">
333
+ <button type="button" class="red-ui-button red-ui-button-small" id="download-tpl-btn"><i class="fa fa-download"></i> 模板</button>
334
+ <button type="button" class="red-ui-button red-ui-button-small" id="import-btn"><i class="fa fa-upload"></i> 导入</button>
335
+ <button type="button" class="red-ui-button red-ui-button-small" id="add-knx-btn"><i class="fa fa-plus"></i> 添加</button>
336
+ <button type="button" class="red-ui-button red-ui-button-small" id="clear-knx-btn"><i class="fa fa-trash"></i></button>
337
+ </span>
338
+ </div>
339
+ <div id="knx-list"><div class="tips">点击"导入"或"添加"管理KNX实体</div></div>
340
+ <div id="edit-panel" style="display:none;background:#fffde7;border:1px solid #ffc107;padding:10px;margin:5px 0;border-radius:4px;"></div>
341
+ <input type="hidden" id="knx-data">
342
+
343
+ <div class="sec">
344
+ <b><i class="fa fa-exchange"></i> 实体映射</b>
345
+ <span class="btns">
346
+ <button type="button" class="red-ui-button red-ui-button-small" id="add-map-btn"><i class="fa fa-plus"></i> 添加</button>
347
+ <button type="button" class="red-ui-button red-ui-button-small" id="clear-map-btn"><i class="fa fa-trash"></i> 清空</button>
348
+ </span>
349
+ </div>
350
+ <div id="map-list"><div class="tips">添加KNX↔HA映射</div></div>
351
+
352
+ <div id="import-modal" class="modal-overlay">
353
+ <div class="modal-box">
354
+ <h4>导入KNX实体</h4>
355
+ <p style="font-size:11px;margin:0 0 8px">Tab分隔: <code>名称 类型 命令地址 状态地址 扩展...</code>(无效行自动忽略)</p>
356
+ <textarea id="import-input" placeholder="玄关射灯 switch 1/1/28 1/2/28
357
+ 客厅吊灯 light_cct 1/1/10 1/2/10 1/3/10 1/4/10
358
+ 客厅布帘 cover 2/1/5 2/2/5 2/3/5"></textarea>
359
+ <div class="mbtns">
360
+ <button type="button" class="red-ui-button" id="import-cancel">取消</button>
361
+ <button type="button" class="red-ui-button red-ui-button-primary" id="import-confirm">导入</button>
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ </script>
367
+
368
+ <script type="text/html" data-help-name="symi-knx-ha-bridge">
369
+ <p>KNX与Home Assistant实体双向同步桥接</p>
370
+ </script>
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Symi KNX-HA Bridge Node - KNX与Home Assistant实体双向同步桥接
3
+ * 版本: 1.6.9
4
+ */
5
+
6
+ module.exports = function(RED) {
7
+
8
+ function SymiKNXHABridgeNode(config) {
9
+ RED.nodes.createNode(this, config);
10
+ const node = this;
11
+
12
+ node.name = config.name || 'KNX-HA Bridge';
13
+ node.haServer = RED.nodes.getNode(config.haServer);
14
+
15
+ let knxEntities = [];
16
+ try {
17
+ knxEntities = JSON.parse(config.knxEntities || '[]');
18
+ } catch (e) {
19
+ node.error('KNX实体配置解析失败: ' + e.message);
20
+ }
21
+
22
+ try {
23
+ const rawMappings = JSON.parse(config.mappings || '[]');
24
+ node.mappings = rawMappings.map(m => {
25
+ const knxEntity = knxEntities.find(e => e.id === m.knxEntityId) || {};
26
+
27
+ return {
28
+ knxEntityId: m.knxEntityId,
29
+ haEntityId: m.haEntityId,
30
+ knxName: knxEntity.name || '',
31
+ knxType: knxEntity.type || 'switch',
32
+ knxAddrCmd: knxEntity.cmdAddr || '',
33
+ knxAddrStatus: knxEntity.statusAddr || '',
34
+ knxAddrExt1: knxEntity.ext1 || '',
35
+ knxAddrExt2: knxEntity.ext2 || '',
36
+ knxAddrExt3: knxEntity.ext3 || '',
37
+ invertPosition: knxEntity.invert || false,
38
+ allKnxAddrs: [
39
+ knxEntity.cmdAddr,
40
+ knxEntity.statusAddr,
41
+ knxEntity.ext1,
42
+ knxEntity.ext2,
43
+ knxEntity.ext3
44
+ ].filter(addr => addr && addr.length > 0)
45
+ };
46
+ }).filter(m => m.knxEntityId && m.haEntityId);
47
+
48
+ if (node.mappings.length > 0) {
49
+ node.mappings.forEach((m, i) => {
50
+ node.log(`[映射${i+1}] KNX ${m.knxName}[${m.knxAddrCmd}] <-> HA ${m.haEntityId}`);
51
+ });
52
+ }
53
+ } catch (e) {
54
+ node.mappings = [];
55
+ node.error('映射配置解析失败: ' + e.message);
56
+ }
57
+
58
+ node.commandQueue = [];
59
+ node.processing = false;
60
+ node.knxStateCache = {};
61
+ node.haStateCache = {};
62
+ node.lastKnxToHa = {};
63
+ node.lastHaToKnx = {};
64
+ node.dimmingTimers = {};
65
+ node.coverTimers = {};
66
+
67
+ const LOOP_PREVENTION_MS = 800;
68
+ const DEBOUNCE_MS = 100;
69
+ const MAX_QUEUE_SIZE = 100;
70
+ const DIMMING_DEBOUNCE_MS = 300;
71
+ const COVER_DEBOUNCE_MS = 500;
72
+
73
+ node.initializing = true;
74
+ node.initTimer = setTimeout(() => {
75
+ node.initializing = false;
76
+ node.log('[KNX-HA Bridge] 初始化完成,开始同步');
77
+ }, 5000);
78
+
79
+ node.log(`[KNX-HA Bridge] haServer: ${!!node.haServer}, credentials: ${!!node.haServer?.credentials}`);
80
+
81
+ if (node.mappings.length === 0) {
82
+ node.status({ fill: 'grey', shape: 'ring', text: '未配置映射' });
83
+ } else {
84
+ node.status({ fill: 'green', shape: 'dot', text: `${node.mappings.length}个映射已激活` });
85
+ }
86
+
87
+ node.findKnxMapping = function(groupAddr) {
88
+ return node.mappings.find(m => m.allKnxAddrs.includes(groupAddr));
89
+ };
90
+
91
+ node.findHaMapping = function(entityId) {
92
+ return node.mappings.find(m => m.haEntityId === entityId);
93
+ };
94
+
95
+ node.getKnxAddrFunction = function(mapping, groupAddr) {
96
+ if (groupAddr === mapping.knxAddrCmd) return 'cmd';
97
+ if (groupAddr === mapping.knxAddrStatus) return 'status';
98
+ if (groupAddr === mapping.knxAddrExt1) return 'ext1';
99
+ if (groupAddr === mapping.knxAddrExt2) return 'ext2';
100
+ if (groupAddr === mapping.knxAddrExt3) return 'ext3';
101
+ return 'unknown';
102
+ };
103
+
104
+ node.shouldPreventSync = function(direction, key) {
105
+ const now = Date.now();
106
+ if (direction === 'knx-to-ha') {
107
+ const lastHaTime = node.lastHaToKnx[key] || 0;
108
+ return (now - lastHaTime) < LOOP_PREVENTION_MS;
109
+ } else {
110
+ const lastKnxTime = node.lastKnxToHa[key] || 0;
111
+ return (now - lastKnxTime) < LOOP_PREVENTION_MS;
112
+ }
113
+ };
114
+
115
+ node.recordSyncTime = function(direction, key) {
116
+ const now = Date.now();
117
+ if (direction === 'knx-to-ha') {
118
+ node.lastKnxToHa[key] = now;
119
+ } else {
120
+ node.lastHaToKnx[key] = now;
121
+ }
122
+ };
123
+
124
+ node.sleep = function(ms) {
125
+ return new Promise(resolve => setTimeout(resolve, ms));
126
+ };
127
+
128
+ // 输入处理:支持KNX和HA两种消息
129
+ node.on('input', function(msg) {
130
+ if (node.initializing) return;
131
+
132
+ // 处理来自HA server-state-changed节点的消息
133
+ if (msg.data && msg.data.entity_id && msg.data.new_state && msg.data.old_state) {
134
+ const entityId = msg.data.entity_id;
135
+ const mapping = node.findHaMapping(entityId);
136
+
137
+ if (!mapping) {
138
+ node.debug(`[HA输入] 实体 ${entityId} 无映射配置`);
139
+ return;
140
+ }
141
+
142
+ const loopKey = `${mapping.knxEntityId}_${mapping.haEntityId}`;
143
+
144
+ if (node.shouldPreventSync('ha-to-knx', loopKey)) {
145
+ node.debug(`[HA->KNX] 跳过(防死循环): ${entityId}`);
146
+ return;
147
+ }
148
+
149
+ const newState = msg.data.new_state;
150
+ const oldState = msg.data.old_state;
151
+
152
+ if (oldState.state === newState.state) return;
153
+
154
+ node.log(`[HA->KNX] ${entityId} 状态变化: ${oldState.state} -> ${newState.state}`);
155
+
156
+ const domain = entityId.split('.')[0];
157
+ node.queueCommand({
158
+ direction: 'ha-to-knx',
159
+ mapping: mapping,
160
+ type: domain === 'switch' ? 'switch' : 'light_switch',
161
+ value: newState.state === 'on',
162
+ key: loopKey
163
+ });
164
+ return;
165
+ }
166
+
167
+ // 处理来自KNX的消息
168
+ if (!msg.knx || !msg.knx.destination) {
169
+ return;
170
+ }
171
+
172
+ const groupAddr = msg.knx.destination;
173
+ const mapping = node.findKnxMapping(groupAddr);
174
+
175
+ if (!mapping) {
176
+ node.debug(`[KNX输入] 组地址 ${groupAddr} 无映射配置`);
177
+ return;
178
+ }
179
+
180
+ const loopKey = `${mapping.knxEntityId}_${mapping.haEntityId}`;
181
+
182
+ if (node.shouldPreventSync('knx-to-ha', loopKey)) {
183
+ node.log(`[KNX->HA] 跳过(防死循环): ${groupAddr}`);
184
+ return;
185
+ }
186
+
187
+ node.log(`[KNX输入] ${groupAddr} = ${msg.payload}, 映射到 ${mapping.haEntityId}`);
188
+
189
+ const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
190
+ const knxType = mapping.knxType;
191
+
192
+ if (knxType === 'switch') {
193
+ if (addrFunc === 'cmd' || addrFunc === 'status') {
194
+ node.queueCommand({
195
+ direction: 'knx-to-ha',
196
+ mapping: mapping,
197
+ type: 'switch',
198
+ value: msg.payload === true || msg.payload === 1,
199
+ key: loopKey
200
+ });
201
+ }
202
+ }
203
+ else if (knxType.startsWith('light_')) {
204
+ if (addrFunc === 'cmd') {
205
+ node.queueCommand({
206
+ direction: 'knx-to-ha',
207
+ mapping: mapping,
208
+ type: 'light_switch',
209
+ value: msg.payload === true || msg.payload === 1,
210
+ key: loopKey
211
+ });
212
+ } else if (addrFunc === 'ext1') {
213
+ const timerKey = `${mapping.haEntityId}_brightness`;
214
+ if (node.dimmingTimers[timerKey]) {
215
+ clearTimeout(node.dimmingTimers[timerKey]);
216
+ }
217
+ node.dimmingTimers[timerKey] = setTimeout(() => {
218
+ node.queueCommand({
219
+ direction: 'knx-to-ha',
220
+ mapping: mapping,
221
+ type: 'light_brightness',
222
+ value: Math.round(msg.payload * 100 / 255),
223
+ key: loopKey
224
+ });
225
+ delete node.dimmingTimers[timerKey];
226
+ }, DIMMING_DEBOUNCE_MS);
227
+ }
228
+ }
229
+ else if (knxType === 'cover') {
230
+ if (addrFunc === 'cmd') {
231
+ node.queueCommand({
232
+ direction: 'knx-to-ha',
233
+ mapping: mapping,
234
+ type: 'cover_action',
235
+ value: msg.payload ? 'closing' : 'opening',
236
+ key: loopKey
237
+ });
238
+ } else if (addrFunc === 'status') {
239
+ const timerKey = `${mapping.haEntityId}_position`;
240
+ if (node.coverTimers[timerKey]) {
241
+ clearTimeout(node.coverTimers[timerKey]);
242
+ }
243
+ node.coverTimers[timerKey] = setTimeout(() => {
244
+ const pos = mapping.invertPosition ? (100 - msg.payload) : msg.payload;
245
+ node.queueCommand({
246
+ direction: 'knx-to-ha',
247
+ mapping: mapping,
248
+ type: 'cover_position',
249
+ value: pos,
250
+ key: loopKey
251
+ });
252
+ delete node.coverTimers[timerKey];
253
+ }, COVER_DEBOUNCE_MS);
254
+ }
255
+ }
256
+ else if (knxType === 'climate') {
257
+ if (addrFunc === 'cmd') {
258
+ node.queueCommand({
259
+ direction: 'knx-to-ha',
260
+ mapping: mapping,
261
+ type: 'climate_switch',
262
+ value: msg.payload === true || msg.payload === 1,
263
+ key: loopKey
264
+ });
265
+ }
266
+ }
267
+ });
268
+
269
+ node.queueCommand = function(cmd) {
270
+ if (node.commandQueue.length >= MAX_QUEUE_SIZE) {
271
+ node.commandQueue.shift();
272
+ }
273
+
274
+ const existing = node.commandQueue.find(c =>
275
+ c.direction === cmd.direction &&
276
+ c.mapping.knxEntityId === cmd.mapping.knxEntityId &&
277
+ c.type === cmd.type &&
278
+ Date.now() - (c.timestamp || 0) < DEBOUNCE_MS
279
+ );
280
+
281
+ if (existing) {
282
+ existing.value = cmd.value;
283
+ return;
284
+ }
285
+
286
+ cmd.timestamp = Date.now();
287
+ node.commandQueue.push(cmd);
288
+ node.processQueue();
289
+ };
290
+
291
+ node.processQueue = async function() {
292
+ if (node.processing || node.commandQueue.length === 0) return;
293
+
294
+ node.processing = true;
295
+
296
+ try {
297
+ while (node.commandQueue.length > 0) {
298
+ const cmd = node.commandQueue.shift();
299
+ try {
300
+ if (cmd.direction === 'knx-to-ha') {
301
+ await node.syncKnxToHa(cmd);
302
+ } else if (cmd.direction === 'ha-to-knx') {
303
+ await node.syncHaToKnx(cmd);
304
+ }
305
+ await node.sleep(50);
306
+ } catch (err) {
307
+ node.error(`同步失败: ${err.message}`);
308
+ }
309
+ }
310
+ } finally {
311
+ node.processing = false;
312
+ }
313
+ };
314
+
315
+ // KNX -> HA 同步
316
+ node.syncKnxToHa = async function(cmd) {
317
+ const { mapping, type, value, key } = cmd;
318
+ node.recordSyncTime('knx-to-ha', key);
319
+
320
+ if (!node.haServer || !node.haServer.credentials) {
321
+ node.warn('[KNX->HA] HA服务器未配置');
322
+ return;
323
+ }
324
+
325
+ try {
326
+ const axios = require('axios');
327
+ const baseURL = node.haServer.credentials.host || 'http://localhost:8123';
328
+ const token = node.haServer.credentials.access_token;
329
+
330
+ if (!token) {
331
+ node.warn('[KNX->HA] HA访问令牌未配置');
332
+ return;
333
+ }
334
+
335
+ const domain = mapping.haEntityId.split('.')[0];
336
+ let service = '';
337
+ let serviceData = { entity_id: mapping.haEntityId };
338
+
339
+ if (type === 'switch') {
340
+ service = value ? 'turn_on' : 'turn_off';
341
+ }
342
+ else if (type === 'light_switch') {
343
+ service = value ? 'turn_on' : 'turn_off';
344
+ }
345
+ else if (type === 'light_brightness') {
346
+ service = 'turn_on';
347
+ serviceData.brightness = Math.round(value * 255 / 100);
348
+ }
349
+ else if (type === 'cover_action') {
350
+ service = value === 'opening' ? 'open_cover' : 'close_cover';
351
+ }
352
+ else if (type === 'cover_position') {
353
+ service = 'set_cover_position';
354
+ serviceData.position = value;
355
+ }
356
+ else if (type === 'climate_switch') {
357
+ service = value ? 'turn_on' : 'turn_off';
358
+ }
359
+
360
+ if (service) {
361
+ await axios.post(`${baseURL}/api/services/${domain}/${service}`, serviceData, {
362
+ headers: {
363
+ 'Authorization': `Bearer ${token}`,
364
+ 'Content-Type': 'application/json'
365
+ },
366
+ timeout: 5000
367
+ });
368
+
369
+ node.log(`[KNX->HA] ${mapping.haEntityId} ${service}`);
370
+
371
+ node.send([null, {
372
+ payload: {
373
+ type: 'knx-to-ha',
374
+ entity: mapping.haEntityId,
375
+ service: service
376
+ }
377
+ }]);
378
+ }
379
+ } catch (err) {
380
+ node.error(`[KNX->HA] 调用HA服务失败: ${err.message}`);
381
+ }
382
+ };
383
+
384
+
385
+ // HA -> KNX 同步
386
+ node.syncHaToKnx = async function(cmd) {
387
+ const { mapping, type, value, key } = cmd;
388
+ node.recordSyncTime('ha-to-knx', key);
389
+
390
+ let knxMsg = null;
391
+
392
+ if (type === 'switch') {
393
+ knxMsg = {
394
+ destination: mapping.knxAddrCmd,
395
+ payload: value,
396
+ dpt: '1.001',
397
+ event: "GroupValue_Write"
398
+ };
399
+ }
400
+ else if (type === 'light_switch') {
401
+ knxMsg = {
402
+ destination: mapping.knxAddrCmd,
403
+ payload: value,
404
+ dpt: '1.001',
405
+ event: "GroupValue_Write"
406
+ };
407
+ }
408
+ else if (type === 'light_brightness') {
409
+ knxMsg = {
410
+ destination: mapping.knxAddrExt1 || mapping.knxAddrStatus,
411
+ payload: Math.round(value * 255 / 100),
412
+ dpt: '5.001',
413
+ event: "GroupValue_Write"
414
+ };
415
+ }
416
+ else if (type === 'cover_action') {
417
+ knxMsg = {
418
+ destination: mapping.knxAddrCmd,
419
+ payload: value === 'closing',
420
+ dpt: '1.008',
421
+ event: "GroupValue_Write"
422
+ };
423
+ }
424
+ else if (type === 'cover_position') {
425
+ const pos = mapping.invertPosition ? (100 - value) : value;
426
+ knxMsg = {
427
+ destination: mapping.knxAddrStatus || mapping.knxAddrExt1,
428
+ payload: pos,
429
+ dpt: '5.001',
430
+ event: "GroupValue_Write"
431
+ };
432
+ }
433
+
434
+ if (knxMsg) {
435
+ node.log(`[HA->KNX] ${knxMsg.destination} = ${value}`);
436
+ node.send([knxMsg, null]);
437
+ }
438
+ };
439
+
440
+ node.on('close', function(done) {
441
+ if (node.initTimer) clearTimeout(node.initTimer);
442
+
443
+ // 清理HA事件监听
444
+ if (node.haEventHandlers) {
445
+ node.haEventHandlers.forEach(({ eventName, handler, ha }) => {
446
+ if (ha && ha.eventBus) {
447
+ ha.eventBus.removeListener(eventName, handler);
448
+ }
449
+ });
450
+ node.haEventHandlers = [];
451
+ }
452
+
453
+ Object.values(node.dimmingTimers).forEach(timer => clearTimeout(timer));
454
+ Object.values(node.coverTimers).forEach(timer => clearTimeout(timer));
455
+
456
+ node.commandQueue = [];
457
+ node.knxStateCache = {};
458
+ node.haStateCache = {};
459
+ node.lastKnxToHa = {};
460
+ node.lastHaToKnx = {};
461
+ node.dimmingTimers = {};
462
+ node.coverTimers = {};
463
+
464
+ done();
465
+ });
466
+ }
467
+
468
+ RED.nodes.registerType('symi-knx-ha-bridge', SymiKNXHABridgeNode);
469
+
470
+ // HTTP API: 加载HA实体 - 直接使用HA REST API
471
+ RED.httpAdmin.get('/symi-knx-ha-bridge/ha-entities/:id', async function(req, res) {
472
+ try {
473
+ const serverNode = RED.nodes.getNode(req.params.id);
474
+
475
+ if (!serverNode || !serverNode.credentials) {
476
+ return res.json([]);
477
+ }
478
+
479
+ const axios = require('axios');
480
+ const baseURL = serverNode.credentials.host || 'http://localhost:8123';
481
+ const token = serverNode.credentials.access_token;
482
+
483
+ RED.log.info('[KNX-HA Bridge] baseURL: ' + baseURL);
484
+ RED.log.info('[KNX-HA Bridge] token length: ' + (token ? token.length : 0));
485
+
486
+ if (!token) {
487
+ RED.log.warn('[KNX-HA Bridge] HA访问令牌未配置');
488
+ return res.json([]);
489
+ }
490
+
491
+ RED.log.info('[KNX-HA Bridge] 使用REST API加载HA实体: ' + baseURL);
492
+
493
+ const response = await axios.get(`${baseURL}/api/states`, {
494
+ headers: {
495
+ 'Authorization': `Bearer ${token}`,
496
+ 'Content-Type': 'application/json'
497
+ },
498
+ timeout: 10000
499
+ });
500
+
501
+ const entities = [];
502
+ if (response.data && Array.isArray(response.data)) {
503
+ response.data.forEach(state => {
504
+ if (!state || !state.entity_id) return;
505
+
506
+ const domain = state.entity_id.split('.')[0];
507
+ if (['switch', 'light', 'cover', 'climate', 'fan'].includes(domain)) {
508
+ entities.push({
509
+ entity_id: state.entity_id,
510
+ name: (state.attributes && state.attributes.friendly_name) || state.entity_id,
511
+ type: domain
512
+ });
513
+ }
514
+ });
515
+ }
516
+
517
+ RED.log.info('[KNX-HA Bridge] 成功加载 ' + entities.length + ' 个HA实体');
518
+ res.json(entities);
519
+ } catch (err) {
520
+ RED.log.error('[KNX-HA Bridge] 加载HA实体失败: ' + err.message);
521
+ res.json([]);
522
+ }
523
+ });
524
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.6.8",
3
+ "version": "1.6.9",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {
@@ -35,7 +35,8 @@
35
35
  "symi-485-config": "nodes/symi-485-config.js",
36
36
  "symi-rs485-bridge": "nodes/symi-485-bridge.js",
37
37
  "rs485-debug": "nodes/rs485-debug.js",
38
- "symi-knx-bridge": "nodes/symi-knx-bridge.js"
38
+ "symi-knx-bridge": "nodes/symi-knx-bridge.js",
39
+ "symi-knx-ha-bridge": "nodes/symi-knx-ha-bridge.js"
39
40
  }
40
41
  },
41
42
  "dependencies": {