node-red-contrib-symi-mesh 1.6.6 → 1.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1065 @@
1
+ /**
2
+ * Symi KNX Bridge Node - Mesh与KNX设备双向同步桥接
3
+ * 支持开关、窗帘等设备的双向状态同步
4
+ * 事件驱动架构,命令队列顺序处理,防死循环机制
5
+ *
6
+ * 版本: 1.6.7
7
+ */
8
+
9
+ module.exports = function(RED) {
10
+
11
+ // 设备类型配置
12
+ const DEVICE_TYPES = {
13
+ 'switch': { dpt: '1.001', name: '开关', hasChannel: true },
14
+ 'light_mono': { dpt: '5.001', name: '单色调光', hasChannel: false },
15
+ 'light_cct': { dpt: '5.001', name: '双色调光', hasChannel: false },
16
+ 'light_rgb': { dpt: '232.600', name: 'RGB调光', hasChannel: false },
17
+ 'light_rgbcw': { dpt: '251.600', name: 'RGBCW调光', hasChannel: false },
18
+ 'cover': { dpt: '1.008', name: '窗帘', hasChannel: false },
19
+ 'climate': { dpt: '9.001', name: '空调', hasChannel: false },
20
+ 'fresh_air': { dpt: '1.001', name: '新风', hasChannel: false },
21
+ 'floor_heating': { dpt: '9.001', name: '地暖', hasChannel: false }
22
+ };
23
+
24
+ function SymiKNXBridgeNode(config) {
25
+ RED.nodes.createNode(this, config);
26
+ const node = this;
27
+
28
+ // 基本配置
29
+ node.name = config.name || 'KNX Bridge';
30
+ node.gateway = RED.nodes.getNode(config.gateway);
31
+
32
+ // 解析KNX实体库
33
+ let knxEntities = [];
34
+ try {
35
+ knxEntities = JSON.parse(config.knxEntities || '[]');
36
+ } catch (e) {
37
+ knxEntities = [];
38
+ }
39
+
40
+ // 解析实体映射
41
+ try {
42
+ const rawMappings = JSON.parse(config.mappings || '[]');
43
+ node.mappings = rawMappings.map(m => {
44
+ // 查找对应的KNX实体
45
+ const knxEntity = knxEntities.find(e => e.id === m.knxEntityId) || {};
46
+ const deviceType = knxEntity.type || 'switch';
47
+
48
+ // 基础映射
49
+ const mapping = {
50
+ meshMac: m.meshMac,
51
+ meshChannel: parseInt(m.meshChannel) || 1,
52
+ knxEntityId: m.knxEntityId,
53
+ knxAddrCmd: knxEntity.cmdAddr || '',
54
+ knxAddrStatus: knxEntity.statusAddr || '',
55
+ deviceType: deviceType,
56
+ invertPosition: knxEntity.invert || false,
57
+ name: knxEntity.name || ''
58
+ };
59
+
60
+ // 根据设备类型映射扩展地址到具体功能
61
+ // 参考HTML模板中的typeFields定义
62
+ switch (deviceType) {
63
+ case 'light_mono':
64
+ // 单色调光: 开关, 状态, 亮度
65
+ mapping.knxAddrBrightness = knxEntity.ext1 || '';
66
+ break;
67
+ case 'light_cct':
68
+ // 双色调光: 开关, 状态, 亮度, 色温
69
+ mapping.knxAddrBrightness = knxEntity.ext1 || '';
70
+ mapping.knxAddrColorTemp = knxEntity.ext2 || '';
71
+ break;
72
+ case 'light_rgb':
73
+ // RGB调光: 开关, 状态, 亮度, RGB
74
+ mapping.knxAddrBrightness = knxEntity.ext1 || '';
75
+ mapping.knxAddrRGB = knxEntity.ext2 || '';
76
+ break;
77
+ case 'light_rgbcw':
78
+ // RGBCW: 开关, 状态, 亮度, 色温, RGB
79
+ mapping.knxAddrBrightness = knxEntity.ext1 || '';
80
+ mapping.knxAddrColorTemp = knxEntity.ext2 || '';
81
+ mapping.knxAddrRGB = knxEntity.ext3 || '';
82
+ break;
83
+ case 'cover':
84
+ // 窗帘: 上下, 位置, 停止
85
+ mapping.knxAddrPosition = knxEntity.statusAddr || '';
86
+ mapping.knxAddrStop = knxEntity.ext1 || '';
87
+ break;
88
+ case 'climate':
89
+ // 空调: 开关, 温度, 模式, 风速, 当前温度
90
+ mapping.knxAddrTemp = knxEntity.statusAddr || '';
91
+ mapping.knxAddrMode = knxEntity.ext1 || '';
92
+ mapping.knxAddrFanSpeed = knxEntity.ext2 || '';
93
+ mapping.knxAddrCurrentTemp = knxEntity.ext3 || '';
94
+ break;
95
+ case 'fresh_air':
96
+ // 新风: 开关, 风速
97
+ mapping.knxAddrFanSpeed = knxEntity.statusAddr || '';
98
+ break;
99
+ case 'floor_heating':
100
+ // 地暖: 开关, 温度, 当前温度
101
+ mapping.knxAddrTemp = knxEntity.statusAddr || '';
102
+ mapping.knxAddrCurrentTemp = knxEntity.ext1 || '';
103
+ break;
104
+ }
105
+
106
+ // 收集所有KNX地址用于快速查找
107
+ mapping.allKnxAddrs = [
108
+ mapping.knxAddrCmd,
109
+ mapping.knxAddrStatus,
110
+ mapping.knxAddrBrightness,
111
+ mapping.knxAddrColorTemp,
112
+ mapping.knxAddrRGB,
113
+ mapping.knxAddrPosition,
114
+ mapping.knxAddrStop,
115
+ mapping.knxAddrTemp,
116
+ mapping.knxAddrMode,
117
+ mapping.knxAddrFanSpeed,
118
+ mapping.knxAddrCurrentTemp
119
+ ].filter(addr => addr && addr.length > 0);
120
+
121
+ return mapping;
122
+ }).filter(m => m.meshMac && m.knxAddrCmd);
123
+
124
+ // 打印映射配置
125
+ node.mappings.forEach((m, i) => {
126
+ const typeConfig = DEVICE_TYPES[m.deviceType] || DEVICE_TYPES['switch'];
127
+ if (typeConfig.hasChannel) {
128
+ node.log(`[映射${i+1}] ${m.name}: Mesh ${m.meshMac} CH${m.meshChannel} <-> KNX ${m.knxAddrCmd} (${typeConfig.name})`);
129
+ } else {
130
+ node.log(`[映射${i+1}] ${m.name}: Mesh ${m.meshMac} <-> KNX ${m.knxAddrCmd} (${typeConfig.name})`);
131
+ }
132
+ });
133
+ } catch (e) {
134
+ node.mappings = [];
135
+ node.error(`映射配置解析失败: ${e.message}`);
136
+ }
137
+
138
+ if (!node.gateway) {
139
+ node.status({ fill: 'red', shape: 'ring', text: '未配置Mesh网关' });
140
+ return;
141
+ }
142
+
143
+ // 状态管理
144
+ node.commandQueue = [];
145
+ node.processing = false;
146
+ node.syncLock = false;
147
+ node.lastSyncTime = 0;
148
+ node.stateCache = {}; // Mesh设备状态缓存
149
+ node.knxStateCache = {}; // KNX设备状态缓存
150
+ node.lastMeshToKnx = {}; // 记录Mesh->KNX发送时间,防止回环
151
+ node.lastKnxToMesh = {}; // 记录KNX->Mesh发送时间,防止回环
152
+
153
+ // 防死循环参数
154
+ const LOOP_PREVENTION_MS = 1000; // 500ms内不处理反向同步
155
+ const DEBOUNCE_MS = 100; // 100ms防抖
156
+ const MAX_QUEUE_SIZE = 100; // 最大队列大小
157
+
158
+ // 初始化标记
159
+ node.initializing = true;
160
+ node.initTimer = setTimeout(() => {
161
+ node.initializing = false;
162
+ node.log('[KNX Bridge] 初始化完成,开始同步');
163
+ }, 20000); // 20秒初始化延迟
164
+
165
+ if (node.mappings.length === 0) {
166
+ node.status({ fill: 'grey', shape: 'ring', text: '请添加实体映射' });
167
+ } else {
168
+ node.status({ fill: 'yellow', shape: 'ring', text: `${node.mappings.length}个映射等待连接...` });
169
+ }
170
+
171
+ // ========== 辅助函数 ==========
172
+
173
+ // 查找Mesh设备的映射
174
+ node.findMeshMapping = function(mac, channel) {
175
+ const macNormalized = (mac || '').toLowerCase().replace(/:/g, '');
176
+ return node.mappings.find(m => {
177
+ const mappingMac = (m.meshMac || '').toLowerCase().replace(/:/g, '');
178
+ return mappingMac === macNormalized &&
179
+ (m.deviceType === 'cover' || m.meshChannel === channel);
180
+ });
181
+ };
182
+
183
+ // 查找所有匹配MAC的映射
184
+ node.findAllMeshMappings = function(mac) {
185
+ const macNormalized = (mac || '').toLowerCase().replace(/:/g, '');
186
+ return node.mappings.filter(m => {
187
+ const mappingMac = (m.meshMac || '').toLowerCase().replace(/:/g, '');
188
+ return mappingMac === macNormalized;
189
+ });
190
+ };
191
+
192
+ // 查找KNX组地址的映射(检查所有相关地址)
193
+ node.findKnxMapping = function(groupAddr) {
194
+ return node.mappings.find(m => m.allKnxAddrs.includes(groupAddr));
195
+ };
196
+
197
+ // 判断KNX地址的功能类型
198
+ node.getKnxAddrFunction = function(mapping, groupAddr) {
199
+ if (groupAddr === mapping.knxAddrCmd) return 'cmd';
200
+ if (groupAddr === mapping.knxAddrStatus) return 'status';
201
+ if (groupAddr === mapping.knxAddrBrightness) return 'brightness';
202
+ if (groupAddr === mapping.knxAddrColorTemp) return 'colorTemp';
203
+ if (groupAddr === mapping.knxAddrRGB) return 'rgb';
204
+ if (groupAddr === mapping.knxAddrPosition) return 'position';
205
+ if (groupAddr === mapping.knxAddrStop) return 'stop';
206
+ if (groupAddr === mapping.knxAddrTemp) return 'temp';
207
+ if (groupAddr === mapping.knxAddrMode) return 'mode';
208
+ if (groupAddr === mapping.knxAddrFanSpeed) return 'fanSpeed';
209
+ if (groupAddr === mapping.knxAddrCurrentTemp) return 'currentTemp';
210
+ return 'unknown';
211
+ };
212
+
213
+ // 标准化DPT格式 (处理 "DPT1.001" 和 "1.001" 两种格式)
214
+ node.normalizeDpt = function(dpt) {
215
+ if (!dpt) return '';
216
+ const str = String(dpt).toUpperCase();
217
+ if (str.startsWith('DPT')) {
218
+ return str.substring(3);
219
+ }
220
+ return str;
221
+ };
222
+
223
+ // 检查是否应该阻止同步(防死循环)
224
+ node.shouldPreventSync = function(direction, key) {
225
+ const now = Date.now();
226
+ if (direction === 'mesh-to-knx') {
227
+ const lastKnxTime = node.lastKnxToMesh[key] || 0;
228
+ return (now - lastKnxTime) < LOOP_PREVENTION_MS;
229
+ } else {
230
+ const lastMeshTime = node.lastMeshToKnx[key] || 0;
231
+ return (now - lastMeshTime) < LOOP_PREVENTION_MS;
232
+ }
233
+ };
234
+
235
+ // 记录同步时间(用于防死循环)
236
+ node.recordSyncTime = function(direction, key) {
237
+ const now = Date.now();
238
+ if (direction === 'mesh-to-knx') {
239
+ node.lastMeshToKnx[key] = now;
240
+ } else {
241
+ node.lastKnxToMesh[key] = now;
242
+ }
243
+ };
244
+
245
+ // 延时函数
246
+ node.sleep = function(ms) {
247
+ return new Promise(resolve => setTimeout(resolve, ms));
248
+ };
249
+
250
+ // ========== Mesh设备状态变化处理 ==========
251
+ const handleMeshStateChange = (eventData) => {
252
+ if (node.syncLock || node.initializing) return;
253
+
254
+ const mac = eventData.device.macAddress;
255
+ const state = eventData.state || {};
256
+
257
+ // 状态缓存比较
258
+ if (!node.stateCache[mac]) node.stateCache[mac] = {};
259
+ const cached = node.stateCache[mac];
260
+ const changed = {};
261
+ const isFirstState = Object.keys(cached).length === 0;
262
+
263
+ for (const [key, value] of Object.entries(state)) {
264
+ if (cached[key] !== value) {
265
+ changed[key] = value;
266
+ cached[key] = value;
267
+ }
268
+ }
269
+
270
+ if (Object.keys(changed).length === 0) return;
271
+
272
+ // 首次收到状态时只缓存,不触发同步(避免启动时批量发码)
273
+ // 窗帘控制命令除外
274
+ const hasCurtainControl = changed.curtainStatus !== undefined;
275
+ if (isFirstState && !hasCurtainControl) {
276
+ node.debug(`[Mesh事件] MAC=${mac} 首次状态,仅缓存: ${JSON.stringify(changed)}`);
277
+ return;
278
+ }
279
+
280
+ const macNormalized = mac.toLowerCase().replace(/:/g, '');
281
+
282
+ // 遍历所有匹配的映射
283
+ const matchedMappings = node.findAllMeshMappings(mac);
284
+ if (matchedMappings.length === 0) {
285
+ node.debug(`[Mesh事件] MAC=${macNormalized} 无映射配置`);
286
+ return;
287
+ }
288
+ node.log(`[Mesh事件] MAC=${macNormalized}, 找到${matchedMappings.length}个映射, 变化: ${JSON.stringify(changed)}`);
289
+
290
+ for (const mapping of matchedMappings) {
291
+ const loopKey = `${mac}_${mapping.meshChannel}`;
292
+
293
+ // 防死循环检查
294
+ if (node.shouldPreventSync('mesh-to-knx', loopKey)) {
295
+ node.log(`[Mesh->KNX] 跳过(防死循环): ${loopKey}`);
296
+ continue;
297
+ }
298
+
299
+ // 开关设备
300
+ if (mapping.deviceType === 'switch') {
301
+ const switchKey = `switch_${mapping.meshChannel}`;
302
+ if (changed[switchKey] !== undefined) {
303
+ node.log(`[Mesh->KNX] 开关变化: ${mapping.name || eventData.device.name} CH${mapping.meshChannel} = ${changed[switchKey]}`);
304
+ node.queueCommand({
305
+ direction: 'mesh-to-knx',
306
+ mapping: mapping,
307
+ type: 'switch',
308
+ value: changed[switchKey],
309
+ key: loopKey
310
+ });
311
+ }
312
+ }
313
+ // 调光灯设备(单色、双色、RGB、RGBCW)
314
+ else if (mapping.deviceType.startsWith('light_')) {
315
+ // 开关状态
316
+ if (changed.switch !== undefined) {
317
+ node.log(`[Mesh->KNX] 调光灯开关: ${mapping.name || eventData.device.name} = ${changed.switch}`);
318
+ node.queueCommand({
319
+ direction: 'mesh-to-knx',
320
+ mapping: mapping,
321
+ type: 'light_switch',
322
+ value: changed.switch,
323
+ key: loopKey
324
+ });
325
+ }
326
+ // 亮度
327
+ if (changed.brightness !== undefined) {
328
+ node.log(`[Mesh->KNX] 调光灯亮度: ${mapping.name || eventData.device.name} = ${changed.brightness}%`);
329
+ node.queueCommand({
330
+ direction: 'mesh-to-knx',
331
+ mapping: mapping,
332
+ type: 'light_brightness',
333
+ value: changed.brightness,
334
+ key: loopKey
335
+ });
336
+ }
337
+ // 色温
338
+ if (changed.colorTemp !== undefined && (mapping.deviceType === 'light_cct' || mapping.deviceType === 'light_rgbcw')) {
339
+ node.log(`[Mesh->KNX] 调光灯色温: ${mapping.name || eventData.device.name} = ${changed.colorTemp}`);
340
+ node.queueCommand({
341
+ direction: 'mesh-to-knx',
342
+ mapping: mapping,
343
+ type: 'light_color_temp',
344
+ value: changed.colorTemp,
345
+ key: loopKey
346
+ });
347
+ }
348
+ }
349
+ // 窗帘设备
350
+ else if (mapping.deviceType === 'cover') {
351
+ if (!eventData.isUserControl) continue;
352
+
353
+ if (changed.curtainAction !== undefined || changed.curtainStatus !== undefined) {
354
+ const action = changed.curtainAction || state.curtainAction;
355
+ node.log(`[Mesh->KNX] 窗帘动作: ${mapping.name || eventData.device.name} action=${action}`);
356
+ node.queueCommand({
357
+ direction: 'mesh-to-knx',
358
+ mapping: mapping,
359
+ type: 'cover_action',
360
+ value: action,
361
+ key: loopKey
362
+ });
363
+ }
364
+ if (changed.curtainPosition !== undefined) {
365
+ node.log(`[Mesh->KNX] 窗帘位置: ${mapping.name || eventData.device.name} pos=${changed.curtainPosition}%`);
366
+ node.queueCommand({
367
+ direction: 'mesh-to-knx',
368
+ mapping: mapping,
369
+ type: 'cover_position',
370
+ value: changed.curtainPosition,
371
+ key: loopKey
372
+ });
373
+ }
374
+ }
375
+ // 空调设备
376
+ else if (mapping.deviceType === 'climate') {
377
+ if (changed.acSwitch !== undefined || changed.climateSwitch !== undefined) {
378
+ const sw = changed.acSwitch !== undefined ? changed.acSwitch : changed.climateSwitch;
379
+ node.log(`[Mesh->KNX] 空调开关: ${mapping.name || eventData.device.name} = ${sw}`);
380
+ node.queueCommand({
381
+ direction: 'mesh-to-knx',
382
+ mapping: mapping,
383
+ type: 'climate_switch',
384
+ value: sw,
385
+ key: loopKey
386
+ });
387
+ }
388
+ if (changed.targetTemp !== undefined || changed.acTargetTemp !== undefined) {
389
+ const temp = changed.targetTemp !== undefined ? changed.targetTemp : changed.acTargetTemp;
390
+ node.log(`[Mesh->KNX] 空调温度: ${mapping.name || eventData.device.name} = ${temp}°C`);
391
+ node.queueCommand({
392
+ direction: 'mesh-to-knx',
393
+ mapping: mapping,
394
+ type: 'climate_temp',
395
+ value: temp,
396
+ key: loopKey
397
+ });
398
+ }
399
+ if (changed.acMode !== undefined || changed.climateMode !== undefined) {
400
+ const mode = changed.acMode !== undefined ? changed.acMode : changed.climateMode;
401
+ node.log(`[Mesh->KNX] 空调模式: ${mapping.name || eventData.device.name} = ${mode}`);
402
+ node.queueCommand({
403
+ direction: 'mesh-to-knx',
404
+ mapping: mapping,
405
+ type: 'climate_mode',
406
+ value: mode,
407
+ key: loopKey
408
+ });
409
+ }
410
+ }
411
+ // 新风设备
412
+ else if (mapping.deviceType === 'fresh_air') {
413
+ if (changed.freshAirSwitch !== undefined) {
414
+ node.log(`[Mesh->KNX] 新风开关: ${mapping.name || eventData.device.name} = ${changed.freshAirSwitch}`);
415
+ node.queueCommand({
416
+ direction: 'mesh-to-knx',
417
+ mapping: mapping,
418
+ type: 'fresh_air_switch',
419
+ value: changed.freshAirSwitch,
420
+ key: loopKey
421
+ });
422
+ }
423
+ if (changed.freshAirSpeed !== undefined) {
424
+ node.log(`[Mesh->KNX] 新风风速: ${mapping.name || eventData.device.name} = ${changed.freshAirSpeed}`);
425
+ node.queueCommand({
426
+ direction: 'mesh-to-knx',
427
+ mapping: mapping,
428
+ type: 'fresh_air_speed',
429
+ value: changed.freshAirSpeed,
430
+ key: loopKey
431
+ });
432
+ }
433
+ }
434
+ // 地暖设备
435
+ else if (mapping.deviceType === 'floor_heating') {
436
+ if (changed.floorHeatingSwitch !== undefined) {
437
+ node.log(`[Mesh->KNX] 地暖开关: ${mapping.name || eventData.device.name} = ${changed.floorHeatingSwitch}`);
438
+ node.queueCommand({
439
+ direction: 'mesh-to-knx',
440
+ mapping: mapping,
441
+ type: 'floor_heating_switch',
442
+ value: changed.floorHeatingSwitch,
443
+ key: loopKey
444
+ });
445
+ }
446
+ if (changed.floorHeatingTemp !== undefined) {
447
+ node.log(`[Mesh->KNX] 地暖温度: ${mapping.name || eventData.device.name} = ${changed.floorHeatingTemp}°C`);
448
+ node.queueCommand({
449
+ direction: 'mesh-to-knx',
450
+ mapping: mapping,
451
+ type: 'floor_heating_temp',
452
+ value: changed.floorHeatingTemp,
453
+ key: loopKey
454
+ });
455
+ }
456
+ }
457
+ }
458
+ };
459
+
460
+ // ========== 命令队列处理 ==========
461
+ node.queueCommand = function(cmd) {
462
+ // 队列大小限制
463
+ if (node.commandQueue.length >= MAX_QUEUE_SIZE) {
464
+ node.warn(`[KNX Bridge] 命令队列已满(${MAX_QUEUE_SIZE}),丢弃最旧命令`);
465
+ node.commandQueue.shift();
466
+ }
467
+
468
+ // 检查队列中是否有相同映射的命令(防抖)
469
+ const existing = node.commandQueue.find(c =>
470
+ c.direction === cmd.direction &&
471
+ c.mapping.meshMac === cmd.mapping.meshMac &&
472
+ c.mapping.meshChannel === cmd.mapping.meshChannel &&
473
+ c.type === cmd.type &&
474
+ Date.now() - (c.timestamp || 0) < DEBOUNCE_MS
475
+ );
476
+
477
+ if (existing) {
478
+ existing.value = cmd.value;
479
+ node.debug(`[队列] 合并命令: ${cmd.key}`);
480
+ return;
481
+ }
482
+
483
+ cmd.timestamp = Date.now();
484
+ node.commandQueue.push(cmd);
485
+ node.processQueue();
486
+ };
487
+
488
+ node.processQueue = async function() {
489
+ if (node.processing || node.commandQueue.length === 0) return;
490
+
491
+ node.processing = true;
492
+ node.syncLock = true;
493
+
494
+ while (node.commandQueue.length > 0) {
495
+ const cmd = node.commandQueue.shift();
496
+ try {
497
+ if (cmd.direction === 'mesh-to-knx') {
498
+ await node.syncMeshToKnx(cmd);
499
+ } else if (cmd.direction === 'knx-to-mesh') {
500
+ await node.syncKnxToMesh(cmd);
501
+ }
502
+ await node.sleep(50); // 命令间隔50ms
503
+ } catch (err) {
504
+ node.error(`同步失败: ${err.message}`);
505
+ }
506
+ }
507
+
508
+ node.syncLock = false;
509
+ node.processing = false;
510
+ node.lastSyncTime = Date.now();
511
+ };
512
+
513
+ // ========== Mesh -> KNX 同步 ==========
514
+ node.syncMeshToKnx = async function(cmd) {
515
+ const { mapping, type, value, key } = cmd;
516
+
517
+ // 记录发送时间
518
+ node.recordSyncTime('mesh-to-knx', key);
519
+
520
+ // 构建KNX消息并通过输出端口发送
521
+ let knxMsg = null;
522
+
523
+ if (type === 'switch') {
524
+ // 开关: DPT 1.001, 使用boolean类型
525
+ knxMsg = {
526
+ topic: mapping.knxAddrCmd,
527
+ payload: value === true || value === 1,
528
+ dpt: '1.001',
529
+ knx: {
530
+ destination: mapping.knxAddrCmd,
531
+ dpt: '1.001',
532
+ action: 'write'
533
+ }
534
+ };
535
+ node.log(`[Mesh->KNX] 发送开关: ${mapping.knxAddrCmd} = ${value ? 'ON' : 'OFF'}`);
536
+ }
537
+ else if (type === 'cover_action') {
538
+ // 窗帘动作: opening->false(上), closing->true(下), stopped->true
539
+ // DPT 1.008: false=上(开), true=下(关)
540
+ if (value === 'opening') {
541
+ knxMsg = {
542
+ topic: mapping.knxAddrCmd,
543
+ payload: false,
544
+ dpt: '1.008',
545
+ knx: {
546
+ destination: mapping.knxAddrCmd,
547
+ dpt: '1.008',
548
+ action: 'write'
549
+ }
550
+ };
551
+ node.log(`[Mesh->KNX] 发送窗帘: ${mapping.knxAddrCmd} = 上(开)`);
552
+ } else if (value === 'closing') {
553
+ knxMsg = {
554
+ topic: mapping.knxAddrCmd,
555
+ payload: true,
556
+ dpt: '1.008',
557
+ knx: {
558
+ destination: mapping.knxAddrCmd,
559
+ dpt: '1.008',
560
+ action: 'write'
561
+ }
562
+ };
563
+ node.log(`[Mesh->KNX] 发送窗帘: ${mapping.knxAddrCmd} = 下(关)`);
564
+ } else if (value === 'stopped') {
565
+ // 停止使用单独的停止地址
566
+ const stopAddr = mapping.knxAddrStop || mapping.knxAddrCmd;
567
+ knxMsg = {
568
+ topic: stopAddr,
569
+ payload: true,
570
+ dpt: '1.010',
571
+ knx: {
572
+ destination: stopAddr,
573
+ dpt: '1.010',
574
+ action: 'write'
575
+ }
576
+ };
577
+ node.log(`[Mesh->KNX] 发送窗帘: ${stopAddr} = 停止`);
578
+ }
579
+ }
580
+ else if (type === 'cover_position') {
581
+ // 窗帘位置: 0-100%
582
+ const pos = mapping.invertPosition ? (100 - value) : value;
583
+ const posAddr = mapping.knxAddrPosition || mapping.knxAddrStatus;
584
+ if (posAddr) {
585
+ knxMsg = {
586
+ topic: posAddr,
587
+ payload: pos,
588
+ knx: { destination: posAddr, dpt: '5.001', action: 'write' }
589
+ };
590
+ node.log(`[Mesh->KNX] 发送窗帘位置: ${posAddr} = ${pos}%`);
591
+ }
592
+ }
593
+ // 调光灯开关
594
+ else if (type === 'light_switch') {
595
+ knxMsg = {
596
+ topic: mapping.knxAddrCmd,
597
+ payload: value === true || value === 1,
598
+ dpt: '1.001',
599
+ knx: { destination: mapping.knxAddrCmd, dpt: '1.001', action: 'write' }
600
+ };
601
+ node.log(`[Mesh->KNX] 发送调光灯开关: ${mapping.knxAddrCmd} = ${value ? 'ON' : 'OFF'}`);
602
+ }
603
+ // 调光灯亮度
604
+ else if (type === 'light_brightness') {
605
+ const brightnessAddr = mapping.knxAddrBrightness || mapping.knxAddrStatus || mapping.knxAddrCmd;
606
+ if (brightnessAddr) {
607
+ knxMsg = {
608
+ topic: brightnessAddr,
609
+ payload: Math.round(value * 255 / 100),
610
+ knx: { destination: brightnessAddr, dpt: '5.001', action: 'write' }
611
+ };
612
+ node.log(`[Mesh->KNX] 发送调光灯亮度: ${brightnessAddr} = ${value}%`);
613
+ }
614
+ }
615
+ // 调光灯色温
616
+ else if (type === 'light_color_temp') {
617
+ const ctAddr = mapping.knxAddrColorTemp || mapping.knxAddrStatus;
618
+ if (ctAddr) {
619
+ knxMsg = {
620
+ topic: ctAddr,
621
+ payload: value,
622
+ knx: { destination: ctAddr, dpt: '5.001', action: 'write' }
623
+ };
624
+ node.log(`[Mesh->KNX] 发送调光灯色温: ${ctAddr} = ${value}`);
625
+ }
626
+ }
627
+ // 空调开关
628
+ else if (type === 'climate_switch') {
629
+ knxMsg = {
630
+ topic: mapping.knxAddrCmd,
631
+ payload: value === true || value === 1,
632
+ dpt: '1.001',
633
+ knx: { destination: mapping.knxAddrCmd, dpt: '1.001', action: 'write' }
634
+ };
635
+ node.log(`[Mesh->KNX] 发送空调开关: ${mapping.knxAddrCmd} = ${value ? 'ON' : 'OFF'}`);
636
+ }
637
+ // 空调温度
638
+ else if (type === 'climate_temp') {
639
+ const tempAddr = mapping.knxAddrTemp || mapping.knxAddrStatus || mapping.knxAddrCmd;
640
+ if (tempAddr) {
641
+ knxMsg = {
642
+ topic: tempAddr,
643
+ payload: value,
644
+ knx: { destination: tempAddr, dpt: '9.001', action: 'write' }
645
+ };
646
+ node.log(`[Mesh->KNX] 发送空调温度: ${tempAddr} = ${value}°C`);
647
+ }
648
+ }
649
+ // 空调模式
650
+ else if (type === 'climate_mode') {
651
+ const modeAddr = mapping.knxAddrMode || mapping.knxAddrStatus;
652
+ if (modeAddr) {
653
+ // Mesh模式: 1=制冷, 2=制热, 3=送风, 4=除湿
654
+ knxMsg = {
655
+ topic: modeAddr,
656
+ payload: value,
657
+ knx: { destination: modeAddr, dpt: '20.102', action: 'write' }
658
+ };
659
+ node.log(`[Mesh->KNX] 发送空调模式: ${modeAddr} = ${value}`);
660
+ }
661
+ }
662
+ // 新风开关
663
+ else if (type === 'fresh_air_switch') {
664
+ knxMsg = {
665
+ topic: mapping.knxAddrCmd,
666
+ payload: value === true || value === 1,
667
+ dpt: '1.001',
668
+ knx: { destination: mapping.knxAddrCmd, dpt: '1.001', action: 'write' }
669
+ };
670
+ node.log(`[Mesh->KNX] 发送新风开关: ${mapping.knxAddrCmd} = ${value ? 'ON' : 'OFF'}`);
671
+ }
672
+ // 新风风速
673
+ else if (type === 'fresh_air_speed') {
674
+ const speedAddr = mapping.knxAddrFanSpeed || mapping.knxAddrStatus || mapping.knxAddrCmd;
675
+ if (speedAddr) {
676
+ // Mesh风速: 1=高, 2=中, 3=低, 4=自动 -> 百分比
677
+ const speedMap = { 1: 100, 2: 66, 3: 33, 4: 50 };
678
+ knxMsg = {
679
+ topic: speedAddr,
680
+ payload: speedMap[value] || 50,
681
+ knx: { destination: speedAddr, dpt: '5.001', action: 'write' }
682
+ };
683
+ node.log(`[Mesh->KNX] 发送新风风速: ${speedAddr} = ${speedMap[value] || 50}%`);
684
+ }
685
+ }
686
+ // 地暖开关
687
+ else if (type === 'floor_heating_switch') {
688
+ knxMsg = {
689
+ topic: mapping.knxAddrCmd,
690
+ payload: value === true || value === 1,
691
+ dpt: '1.001',
692
+ knx: { destination: mapping.knxAddrCmd, dpt: '1.001', action: 'write' }
693
+ };
694
+ node.log(`[Mesh->KNX] 发送地暖开关: ${mapping.knxAddrCmd} = ${value ? 'ON' : 'OFF'}`);
695
+ }
696
+ // 地暖温度
697
+ else if (type === 'floor_heating_temp') {
698
+ const tempAddr = mapping.knxAddrTemp || mapping.knxAddrStatus || mapping.knxAddrCmd;
699
+ if (tempAddr) {
700
+ knxMsg = {
701
+ topic: tempAddr,
702
+ payload: value,
703
+ knx: { destination: tempAddr, dpt: '9.001', action: 'write' }
704
+ };
705
+ node.log(`[Mesh->KNX] 发送地暖温度: ${tempAddr} = ${value}°C`);
706
+ }
707
+ }
708
+
709
+ if (knxMsg) {
710
+ // knxUltimate官方格式: https://supergiovane.github.io/node-red-contrib-knx-ultimate/wiki/Device
711
+ // 必须包含: destination, payload, dpt, event
712
+ const sendMsg = {
713
+ destination: knxMsg.knx.destination,
714
+ payload: knxMsg.payload,
715
+ dpt: knxMsg.dpt || knxMsg.knx.dpt,
716
+ event: "GroupValue_Write"
717
+ };
718
+
719
+ // 调试信息
720
+ const debugMsg = {
721
+ topic: 'mesh-to-knx',
722
+ payload: {
723
+ direction: 'Mesh->KNX',
724
+ knxAddr: knxMsg.knx.destination,
725
+ value: knxMsg.payload,
726
+ dpt: knxMsg.dpt || knxMsg.knx.dpt,
727
+ meshMac: mapping.meshMac,
728
+ channel: mapping.meshChannel,
729
+ type: type
730
+ },
731
+ timestamp: new Date().toISOString()
732
+ };
733
+
734
+ node.log(`[Mesh->KNX] 发送: destination=${sendMsg.destination}, payload=${sendMsg.payload}, dpt=${sendMsg.dpt}, event=${sendMsg.event}`);
735
+
736
+ // 同时发送到两个输出端口(一次send调用)
737
+ node.send([sendMsg, debugMsg]);
738
+ }
739
+
740
+ node.status({ fill: 'green', shape: 'dot', text: `Mesh→KNX ${node.mappings.length}个映射` });
741
+ };
742
+
743
+ // ========== KNX -> Mesh 同步 ==========
744
+ node.syncKnxToMesh = async function(cmd) {
745
+ const { mapping, type, value, key } = cmd;
746
+
747
+ // 记录发送时间
748
+ node.recordSyncTime('knx-to-mesh', key);
749
+
750
+ // 查找Mesh设备
751
+ const meshMac = mapping.meshMac || '';
752
+ const macNormalized = meshMac.toLowerCase().replace(/:/g, '');
753
+ let meshDevice = node.gateway.getDevice(meshMac);
754
+
755
+ if (!meshDevice) {
756
+ meshDevice = node.gateway.getDevice(macNormalized);
757
+ }
758
+ if (!meshDevice) {
759
+ const allDevices = node.gateway.deviceManager?.getAllDevices() || [];
760
+ meshDevice = allDevices.find(d => {
761
+ const devMac = (d.macAddress || '').toLowerCase().replace(/:/g, '');
762
+ return devMac === macNormalized;
763
+ });
764
+ }
765
+
766
+ if (!meshDevice) {
767
+ node.warn(`[KNX->Mesh] 未找到Mesh设备: ${meshMac}`);
768
+ return;
769
+ }
770
+
771
+ try {
772
+ if (type === 'switch') {
773
+ const channel = mapping.meshChannel || 1;
774
+ const totalChannels = meshDevice.channels || 1;
775
+ const param = Buffer.from([totalChannels, channel, value ? 1 : 0]);
776
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
777
+ node.log(`[KNX->Mesh] 发送开关: ${meshDevice.name} CH${channel} = ${value ? 'ON' : 'OFF'}`);
778
+ }
779
+ else if (type === 'cover_action') {
780
+ const action = value === 'open' ? 1 : value === 'close' ? 2 : 3;
781
+ const param = Buffer.from([action]);
782
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x05, param);
783
+ node.log(`[KNX->Mesh] 发送窗帘: ${meshDevice.name} = ${value}`);
784
+ }
785
+ else if (type === 'cover_position') {
786
+ const pos = mapping.invertPosition ? (100 - value) : value;
787
+ const param = Buffer.from([pos]);
788
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x06, param);
789
+ node.log(`[KNX->Mesh] 发送窗帘位置: ${meshDevice.name} = ${pos}%`);
790
+ }
791
+ // 调光灯开关
792
+ else if (type === 'light_switch') {
793
+ const param = Buffer.from([value ? 0x02 : 0x01]);
794
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
795
+ node.log(`[KNX->Mesh] 发送调光灯开关: ${meshDevice.name} = ${value ? 'ON' : 'OFF'}`);
796
+ }
797
+ // 调光灯亮度
798
+ else if (type === 'light_brightness') {
799
+ const param = Buffer.from([value]);
800
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x03, param);
801
+ node.log(`[KNX->Mesh] 发送调光灯亮度: ${meshDevice.name} = ${value}%`);
802
+ }
803
+ // 调光灯色温
804
+ else if (type === 'light_color_temp') {
805
+ const param = Buffer.from([value]);
806
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x04, param);
807
+ node.log(`[KNX->Mesh] 发送调光灯色温: ${meshDevice.name} = ${value}`);
808
+ }
809
+ // 空调开关
810
+ else if (type === 'climate_switch') {
811
+ const param = Buffer.from([value ? 0x01 : 0x00]);
812
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1E, param);
813
+ node.log(`[KNX->Mesh] 发送空调开关: ${meshDevice.name} = ${value ? 'ON' : 'OFF'}`);
814
+ }
815
+ // 空调温度
816
+ else if (type === 'climate_temp') {
817
+ const param = Buffer.from([Math.round(value)]);
818
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1B, param);
819
+ node.log(`[KNX->Mesh] 发送空调温度: ${meshDevice.name} = ${value}°C`);
820
+ }
821
+ // 空调模式
822
+ else if (type === 'climate_mode') {
823
+ const param = Buffer.from([value]);
824
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
825
+ node.log(`[KNX->Mesh] 发送空调模式: ${meshDevice.name} = ${value}`);
826
+ }
827
+ // 空调风速
828
+ else if (type === 'climate_fan') {
829
+ const param = Buffer.from([value]);
830
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
831
+ node.log(`[KNX->Mesh] 发送空调风速: ${meshDevice.name} = ${value}`);
832
+ }
833
+ // 新风开关
834
+ else if (type === 'fresh_air_switch') {
835
+ const param = Buffer.from([value ? 0x01 : 0x00]);
836
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x68, param);
837
+ node.log(`[KNX->Mesh] 发送新风开关: ${meshDevice.name} = ${value ? 'ON' : 'OFF'}`);
838
+ }
839
+ // 新风风速
840
+ else if (type === 'fresh_air_speed') {
841
+ const param = Buffer.from([value]);
842
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x68, param);
843
+ node.log(`[KNX->Mesh] 发送新风风速: ${meshDevice.name} = ${value}`);
844
+ }
845
+ // 地暖开关
846
+ else if (type === 'floor_heating_switch') {
847
+ const param = Buffer.from([value ? 0x01 : 0x00]);
848
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x6B, param);
849
+ node.log(`[KNX->Mesh] 发送地暖开关: ${meshDevice.name} = ${value ? 'ON' : 'OFF'}`);
850
+ }
851
+ // 地暖温度
852
+ else if (type === 'floor_heating_temp') {
853
+ const param = Buffer.from([Math.round(value)]);
854
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x6B, param);
855
+ node.log(`[KNX->Mesh] 发送地暖温度: ${meshDevice.name} = ${value}°C`);
856
+ }
857
+
858
+ // 输出调试信息
859
+ node.send([null, {
860
+ topic: 'knx-to-mesh',
861
+ payload: {
862
+ direction: 'KNX→Mesh',
863
+ mapping: {
864
+ meshMac: mapping.meshMac,
865
+ channel: mapping.meshChannel,
866
+ knxAddr: cmd.sourceAddr
867
+ },
868
+ type: type,
869
+ value: value
870
+ },
871
+ timestamp: new Date().toISOString()
872
+ }]);
873
+
874
+ } catch (err) {
875
+ node.error(`[KNX->Mesh] 发送失败: ${err.message}`);
876
+ }
877
+
878
+ node.status({ fill: 'blue', shape: 'dot', text: `KNX→Mesh ${node.mappings.length}个映射` });
879
+ };
880
+
881
+ // ========== 处理输入消息(来自KNX节点) ==========
882
+ node.on('input', function(msg, send, done) {
883
+ if (node.initializing) {
884
+ done && done();
885
+ return;
886
+ }
887
+
888
+ // 从消息中提取KNX组地址
889
+ const groupAddr = msg.knx?.destination || msg.topic || '';
890
+ const value = msg.payload;
891
+ const dpt = node.normalizeDpt(msg.knx?.dpt || msg.dpt || '');
892
+
893
+ if (!groupAddr) {
894
+ done && done();
895
+ return;
896
+ }
897
+
898
+ // 查找映射
899
+ const mapping = node.findKnxMapping(groupAddr);
900
+ if (!mapping) {
901
+ node.debug(`[KNX输入] 未找到映射: ${groupAddr}`);
902
+ done && done();
903
+ return;
904
+ }
905
+
906
+ // 确定地址功能(优先使用地址匹配,比DPT更可靠)
907
+ const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
908
+ const loopKey = `${mapping.meshMac}_${mapping.meshChannel}`;
909
+
910
+ // 防死循环检查
911
+ if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
912
+ node.debug(`[KNX->Mesh] 跳过同步(防死循环): ${loopKey}, addr=${groupAddr}`);
913
+ done && done();
914
+ return;
915
+ }
916
+
917
+ node.log(`[KNX输入] 收到: ${groupAddr} = ${value}, DPT=${dpt}, 功能=${addrFunc}, 设备=${mapping.deviceType}`);
918
+
919
+ // 根据设备类型和地址功能处理
920
+ if (mapping.deviceType === 'switch') {
921
+ // 开关命令(只处理cmd地址)
922
+ if (addrFunc === 'cmd' || addrFunc === 'status') {
923
+ const switchValue = (value === 1 || value === true || value === 'on' || value === 'ON');
924
+ node.queueCommand({
925
+ direction: 'knx-to-mesh',
926
+ mapping: mapping,
927
+ type: 'switch',
928
+ value: switchValue,
929
+ key: loopKey,
930
+ sourceAddr: groupAddr
931
+ });
932
+ }
933
+ }
934
+ // 窗帘设备
935
+ else if (mapping.deviceType === 'cover') {
936
+ if (addrFunc === 'cmd') {
937
+ // 上下命令: 0=上(开), 1=下(关)
938
+ const action = (value === 0 || value === false) ? 'open' : 'close';
939
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_action', value: action, key: loopKey, sourceAddr: groupAddr });
940
+ }
941
+ else if (addrFunc === 'stop') {
942
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_action', value: 'stop', key: loopKey, sourceAddr: groupAddr });
943
+ }
944
+ else if (addrFunc === 'position' || addrFunc === 'status') {
945
+ const pos = mapping.invertPosition ? (100 - (parseInt(value) || 0)) : (parseInt(value) || 0);
946
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_position', value: pos, key: loopKey, sourceAddr: groupAddr });
947
+ }
948
+ }
949
+ // 调光灯设备
950
+ else if (mapping.deviceType.startsWith('light_')) {
951
+ if (addrFunc === 'cmd' || addrFunc === 'status') {
952
+ // 开关
953
+ const sw = (value === 1 || value === true);
954
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_switch', value: sw, key: loopKey, sourceAddr: groupAddr });
955
+ }
956
+ else if (addrFunc === 'brightness') {
957
+ // 亮度 (KNX 0-255 -> Mesh 0-100)
958
+ const brightness = Math.round((parseInt(value) || 0) * 100 / 255);
959
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_brightness', value: brightness, key: loopKey, sourceAddr: groupAddr });
960
+ }
961
+ else if (addrFunc === 'colorTemp') {
962
+ // 色温
963
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_color_temp', value: parseInt(value) || 50, key: loopKey, sourceAddr: groupAddr });
964
+ }
965
+ }
966
+ // 空调设备
967
+ else if (mapping.deviceType === 'climate') {
968
+ if (addrFunc === 'cmd') {
969
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_switch', value: value === 1 || value === true, key: loopKey, sourceAddr: groupAddr });
970
+ }
971
+ else if (addrFunc === 'temp' || addrFunc === 'status') {
972
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_temp', value: parseFloat(value) || 24, key: loopKey, sourceAddr: groupAddr });
973
+ }
974
+ else if (addrFunc === 'mode') {
975
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_mode', value: parseInt(value) || 1, key: loopKey, sourceAddr: groupAddr });
976
+ }
977
+ else if (addrFunc === 'fanSpeed') {
978
+ // 风速
979
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'climate_fan', value: parseInt(value) || 1, key: loopKey, sourceAddr: groupAddr });
980
+ }
981
+ }
982
+ // 新风设备
983
+ else if (mapping.deviceType === 'fresh_air') {
984
+ if (addrFunc === 'cmd') {
985
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'fresh_air_switch', value: value === 1 || value === true, key: loopKey, sourceAddr: groupAddr });
986
+ }
987
+ else if (addrFunc === 'fanSpeed' || addrFunc === 'status') {
988
+ // 百分比转风速: >66=高(1), >33=中(2), <=33=低(3)
989
+ const pct = parseInt(value) || 0;
990
+ const speed = pct > 66 ? 1 : pct > 33 ? 2 : 3;
991
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'fresh_air_speed', value: speed, key: loopKey, sourceAddr: groupAddr });
992
+ }
993
+ }
994
+ // 地暖设备
995
+ else if (mapping.deviceType === 'floor_heating') {
996
+ if (addrFunc === 'cmd') {
997
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'floor_heating_switch', value: value === 1 || value === true, key: loopKey, sourceAddr: groupAddr });
998
+ }
999
+ else if (addrFunc === 'temp' || addrFunc === 'status') {
1000
+ node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'floor_heating_temp', value: parseFloat(value) || 24, key: loopKey, sourceAddr: groupAddr });
1001
+ }
1002
+ }
1003
+
1004
+ done && done();
1005
+ });
1006
+
1007
+ // ========== 事件监听 ==========
1008
+
1009
+ // 监听网关连接状态
1010
+ node.gateway.on('gateway-connected', () => {
1011
+ node.status({ fill: 'green', shape: 'ring', text: `网关已连接 ${node.mappings.length}个映射` });
1012
+ });
1013
+
1014
+ node.gateway.on('gateway-disconnected', () => {
1015
+ node.status({ fill: 'red', shape: 'ring', text: '网关断开' });
1016
+ });
1017
+
1018
+ // 监听Mesh设备状态变化
1019
+ node.gateway.on('device-state-changed', handleMeshStateChange);
1020
+
1021
+ // ========== 清理 ==========
1022
+ node.on('close', function(done) {
1023
+ // 清除初始化定时器
1024
+ if (node.initTimer) {
1025
+ clearTimeout(node.initTimer);
1026
+ }
1027
+
1028
+ // 移除事件监听
1029
+ if (node.gateway) {
1030
+ node.gateway.removeListener('device-state-changed', handleMeshStateChange);
1031
+ }
1032
+
1033
+ // 清空队列和缓存
1034
+ node.commandQueue = [];
1035
+ node.stateCache = {};
1036
+ node.knxStateCache = {};
1037
+ node.lastMeshToKnx = {};
1038
+ node.lastKnxToMesh = {};
1039
+
1040
+ node.status({});
1041
+ done();
1042
+ });
1043
+ }
1044
+
1045
+ RED.nodes.registerType('symi-knx-bridge', SymiKNXBridgeNode);
1046
+
1047
+ // ========== HTTP API:获取Mesh设备列表 ==========
1048
+ RED.httpAdmin.get('/symi-knx-bridge/devices/:gatewayId', function(req, res) {
1049
+ const gatewayNode = RED.nodes.getNode(req.params.gatewayId);
1050
+ if (!gatewayNode || !gatewayNode.deviceManager) {
1051
+ return res.json([]);
1052
+ }
1053
+
1054
+ const devices = gatewayNode.deviceManager.getAllDevices();
1055
+ const result = devices.map(d => ({
1056
+ mac: d.macAddress,
1057
+ name: d.name,
1058
+ type: d.getEntityType(),
1059
+ channels: d.channels,
1060
+ online: d.online
1061
+ }));
1062
+
1063
+ res.json(result);
1064
+ });
1065
+ };