node-red-contrib-symi-mesh 1.6.7 → 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.
@@ -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.7",
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": {