node-red-contrib-symi-mesh 1.7.7 → 1.7.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,787 @@
1
+ /**
2
+ * Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
3
+ * 版本: 1.7.9
4
+ *
5
+ * 支持的实体类型和属性:
6
+ * - light: on/off, brightness (0-255)
7
+ * - switch/input_boolean: on/off
8
+ * - cover: open/close/stop, position (0-100)
9
+ * - climate: on/off, temperature, hvac_mode, fan_mode
10
+ * - fan: on/off, speed
11
+ */
12
+
13
+ module.exports = function(RED) {
14
+ const axios = require('axios');
15
+
16
+ // 常量定义
17
+ const LOOP_PREVENTION_MS = 2000; // 防死循环时间窗口
18
+ const DEBOUNCE_MS = 500; // 防抖时间(窗帘/调光用)
19
+ const MAX_QUEUE_SIZE = 100;
20
+ const CLEANUP_INTERVAL_MS = 60000;
21
+ const TIMESTAMP_EXPIRE_MS = 60000;
22
+
23
+ // Mesh属性类型
24
+ const ATTR_SWITCH = 0x02;
25
+ const ATTR_BRIGHTNESS = 0x03;
26
+ const ATTR_CURTAIN_STATUS = 0x05;
27
+ const ATTR_CURTAIN_POSITION = 0x06;
28
+ const ATTR_TARGET_TEMP = 0x1B;
29
+ const ATTR_FAN_MODE = 0x1C;
30
+ const ATTR_CLIMATE_MODE = 0x1D;
31
+
32
+ // 空调模式映射
33
+ const AC_MODE_TO_HA = { 1: 'cool', 2: 'heat', 3: 'fan_only', 4: 'dry' };
34
+ const HA_TO_AC_MODE = { cool: 1, heat: 2, fan_only: 3, dry: 4, off: 0 };
35
+
36
+ // 风速映射 (Mesh: 1=高, 2=中, 3=低, 4=自动)
37
+ const FAN_MODE_TO_HA = { 1: 'high', 2: 'medium', 3: 'low', 4: 'auto' };
38
+ const HA_TO_FAN_MODE = { high: 1, medium: 2, low: 3, auto: 4 };
39
+
40
+ function SymiHASyncNode(config) {
41
+ RED.nodes.createNode(this, config);
42
+ const node = this;
43
+
44
+ node.name = config.name || 'HA同步';
45
+ node.mqttNode = RED.nodes.getNode(config.mqttConfig);
46
+ node.haServer = RED.nodes.getNode(config.haServer);
47
+
48
+ // 解析映射配置
49
+ try {
50
+ const rawMappings = JSON.parse(config.mappings || '[]');
51
+ node.mappings = rawMappings.map(m => ({
52
+ symiMac: m.symiMac,
53
+ symiKey: parseInt(m.symiKey) || 1,
54
+ haEntityId: m.haEntityId,
55
+ symiName: m.symiName || '',
56
+ haEntityName: m.haEntityName || ''
57
+ })).filter(m => m.symiMac && m.haEntityId);
58
+
59
+ if (node.mappings.length > 0) {
60
+ node.log(`[HA同步] 已加载 ${node.mappings.length} 个映射`);
61
+ }
62
+ } catch (e) {
63
+ node.mappings = [];
64
+ node.error('映射配置解析失败: ' + e.message);
65
+ }
66
+
67
+ node.commandQueue = [];
68
+ node.processing = false;
69
+ node.lastSymiToHa = {};
70
+ node.lastHaToSymi = {};
71
+ node.pendingDebounce = {}; // 防抖定时器
72
+
73
+ node.status({ fill: 'yellow', shape: 'ring', text: '初始化中' });
74
+
75
+ // 检查配置
76
+ if (!node.mqttNode) {
77
+ node.status({ fill: 'red', shape: 'ring', text: '未配置MQTT节点' });
78
+ return;
79
+ }
80
+ if (!node.haServer) {
81
+ node.status({ fill: 'red', shape: 'ring', text: '未配置HA服务器' });
82
+ return;
83
+ }
84
+
85
+ // 获取Gateway引用
86
+ const gateway = node.mqttNode.gateway;
87
+ if (!gateway) {
88
+ node.status({ fill: 'red', shape: 'ring', text: 'MQTT节点未关联网关' });
89
+ return;
90
+ }
91
+
92
+ node.status({ fill: 'green', shape: 'dot', text: `运行中 (${node.mappings.length}组)` });
93
+
94
+ // 定期清理过期的防抖时间戳
95
+ node.cleanupInterval = setInterval(function() {
96
+ const now = Date.now();
97
+ for (const key in node.lastSymiToHa) {
98
+ if (now - node.lastSymiToHa[key] > TIMESTAMP_EXPIRE_MS) {
99
+ delete node.lastSymiToHa[key];
100
+ }
101
+ }
102
+ for (const key in node.lastHaToSymi) {
103
+ if (now - node.lastHaToSymi[key] > TIMESTAMP_EXPIRE_MS) {
104
+ delete node.lastHaToSymi[key];
105
+ }
106
+ }
107
+ }, CLEANUP_INTERVAL_MS);
108
+
109
+ // 防死循环检查 - 双向时间戳检查
110
+ node.shouldPreventSync = function(direction, key) {
111
+ const now = Date.now();
112
+ if (direction === 'symi-to-ha') {
113
+ const lastHaTime = node.lastHaToSymi[key] || 0;
114
+ return (now - lastHaTime) < LOOP_PREVENTION_MS;
115
+ } else {
116
+ const lastSymiTime = node.lastSymiToHa[key] || 0;
117
+ return (now - lastSymiTime) < LOOP_PREVENTION_MS;
118
+ }
119
+ };
120
+
121
+ node.recordSyncTime = function(direction, key) {
122
+ const now = Date.now();
123
+ if (direction === 'symi-to-ha') {
124
+ node.lastSymiToHa[key] = now;
125
+ } else {
126
+ node.lastHaToSymi[key] = now;
127
+ }
128
+ };
129
+
130
+ node.sleep = function(ms) {
131
+ return new Promise(resolve => setTimeout(resolve, ms));
132
+ };
133
+
134
+ // 查找映射
135
+ node.findMappingsBySymi = function(mac, key) {
136
+ return node.mappings.filter(m =>
137
+ m.symiMac.toLowerCase().replace(/:/g, '') === mac.toLowerCase().replace(/:/g, '') &&
138
+ m.symiKey === key
139
+ );
140
+ };
141
+
142
+ node.findMappingsByHa = function(entityId) {
143
+ return node.mappings.filter(m => m.haEntityId === entityId);
144
+ };
145
+
146
+ // 获取HA实体域
147
+ node.getEntityDomain = function(entityId) {
148
+ return entityId ? entityId.split('.')[0] : '';
149
+ };
150
+
151
+ // ========== 1. 监听Symi设备状态变化 (Symi -> HA) ==========
152
+ node.handleSymiStateChange = function(eventData) {
153
+ if (!eventData.device || !eventData.device.macAddress) return;
154
+
155
+ const device = eventData.device;
156
+ const attrType = eventData.attrType;
157
+ const state = eventData.state || {};
158
+
159
+ // 遍历该设备的所有映射
160
+ const deviceMappings = node.mappings.filter(m =>
161
+ m.symiMac.toLowerCase().replace(/:/g, '') === device.macAddress.toLowerCase().replace(/:/g, '')
162
+ );
163
+ if (deviceMappings.length === 0) return;
164
+
165
+ deviceMappings.forEach(mapping => {
166
+ const domain = node.getEntityDomain(mapping.haEntityId);
167
+ const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
168
+
169
+ // 根据attrType和实体类型处理
170
+ let syncData = null;
171
+
172
+ switch (attrType) {
173
+ case ATTR_SWITCH: // 0x02 开关状态
174
+ syncData = node.handleSwitchChange(device, mapping, state);
175
+ break;
176
+
177
+ case ATTR_BRIGHTNESS: // 0x03 亮度
178
+ if (domain === 'light') {
179
+ syncData = node.handleBrightnessChange(device, mapping, state);
180
+ }
181
+ break;
182
+
183
+ case ATTR_CURTAIN_STATUS: // 0x05 窗帘运行状态
184
+ case ATTR_CURTAIN_POSITION: // 0x06 窗帘位置
185
+ if (domain === 'cover') {
186
+ syncData = node.handleCurtainChange(device, mapping, state, attrType);
187
+ }
188
+ break;
189
+
190
+ case ATTR_TARGET_TEMP: // 0x1B 目标温度
191
+ if (domain === 'climate') {
192
+ syncData = node.handleTemperatureChange(device, mapping, state);
193
+ }
194
+ break;
195
+
196
+ case ATTR_FAN_MODE: // 0x1C 风速
197
+ if (domain === 'climate' || domain === 'fan') {
198
+ syncData = node.handleFanModeChange(device, mapping, state);
199
+ }
200
+ break;
201
+
202
+ case ATTR_CLIMATE_MODE: // 0x1D 空调模式
203
+ if (domain === 'climate') {
204
+ syncData = node.handleClimateModeChange(device, mapping, state);
205
+ }
206
+ break;
207
+ }
208
+
209
+ if (syncData) {
210
+ if (node.shouldPreventSync('symi-to-ha', loopKey)) {
211
+ node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey}`);
212
+ return;
213
+ }
214
+
215
+ node.queueCommand({
216
+ direction: 'symi-to-ha',
217
+ mapping: mapping,
218
+ syncData: syncData,
219
+ key: loopKey
220
+ });
221
+ }
222
+ });
223
+ };
224
+
225
+ // 处理开关状态变化
226
+ node.handleSwitchChange = function(device, mapping, state) {
227
+ let isOn = false;
228
+
229
+ if (device.channels === 1) {
230
+ isOn = state.switch !== undefined ? state.switch : device.state.power;
231
+ } else {
232
+ // 多路开关
233
+ const channelKey = `switch_${mapping.symiKey}`;
234
+ isOn = state[channelKey] !== undefined ? state[channelKey] : device.state[channelKey];
235
+ }
236
+
237
+ if (isOn === undefined) return null;
238
+
239
+ return { type: 'switch', value: isOn };
240
+ };
241
+
242
+ // 处理亮度变化
243
+ node.handleBrightnessChange = function(device, mapping, state) {
244
+ const brightness = state.brightness !== undefined ? state.brightness : device.state.brightness;
245
+ if (brightness === undefined) return null;
246
+
247
+ // Mesh亮度0-100,HA亮度0-255
248
+ const haBrightness = Math.round(brightness * 255 / 100);
249
+ return { type: 'brightness', value: haBrightness, meshValue: brightness };
250
+ };
251
+
252
+ // 处理窗帘变化(带防抖)
253
+ node.handleCurtainChange = function(device, mapping, state, attrType) {
254
+ const domain = node.getEntityDomain(mapping.haEntityId);
255
+ if (domain !== 'cover') return null;
256
+
257
+ // 窗帘位置变化 - 使用防抖,只同步最终位置
258
+ if (attrType === ATTR_CURTAIN_POSITION) {
259
+ const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
260
+ if (position === undefined) return null;
261
+
262
+ // 防抖处理:取消之前的定时器,设置新的
263
+ const debounceKey = `curtain_${mapping.symiMac}_${mapping.symiKey}`;
264
+ if (node.pendingDebounce[debounceKey]) {
265
+ clearTimeout(node.pendingDebounce[debounceKey]);
266
+ }
267
+
268
+ // 延迟同步,等待位置稳定
269
+ node.pendingDebounce[debounceKey] = setTimeout(() => {
270
+ delete node.pendingDebounce[debounceKey];
271
+ const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
272
+ if (!node.shouldPreventSync('symi-to-ha', loopKey)) {
273
+ node.queueCommand({
274
+ direction: 'symi-to-ha',
275
+ mapping: mapping,
276
+ syncData: { type: 'position', value: position },
277
+ key: loopKey
278
+ });
279
+ }
280
+ }, DEBOUNCE_MS);
281
+
282
+ return null; // 不立即同步
283
+ }
284
+
285
+ // 窗帘运行状态 - 可选同步动作
286
+ if (attrType === ATTR_CURTAIN_STATUS) {
287
+ const action = state.curtainAction || device.state.curtainAction;
288
+ if (action === 'stopped') {
289
+ return { type: 'curtain_stop' };
290
+ }
291
+ }
292
+
293
+ return null;
294
+ };
295
+
296
+ // 处理温度变化
297
+ node.handleTemperatureChange = function(device, mapping, state) {
298
+ const temp = state.targetTemp !== undefined ? state.targetTemp : device.state.targetTemp;
299
+ if (temp === undefined) return null;
300
+ return { type: 'temperature', value: temp };
301
+ };
302
+
303
+ // 处理风速变化
304
+ node.handleFanModeChange = function(device, mapping, state) {
305
+ const fanMode = state.fanMode !== undefined ? state.fanMode : device.state.fanMode;
306
+ if (fanMode === undefined) return null;
307
+
308
+ const haFanMode = FAN_MODE_TO_HA[fanMode] || 'auto';
309
+ return { type: 'fan_mode', value: haFanMode, meshValue: fanMode };
310
+ };
311
+
312
+ // 处理空调模式变化
313
+ node.handleClimateModeChange = function(device, mapping, state) {
314
+ const mode = state.climateMode !== undefined ? state.climateMode : device.state.climateMode;
315
+ if (mode === undefined) return null;
316
+
317
+ const haMode = AC_MODE_TO_HA[mode] || 'off';
318
+ return { type: 'hvac_mode', value: haMode, meshValue: mode };
319
+ };
320
+
321
+ // 监听网关事件
322
+ gateway.on('device-state-changed', node.handleSymiStateChange);
323
+
324
+ // ========== 2. 监听HA状态变化 (HA -> Symi) ==========
325
+
326
+ // 方式A: 通过Input输入 (server-state-changed节点)
327
+ node.on('input', function(msg) {
328
+ if (msg.data && msg.data.entity_id && msg.data.new_state) {
329
+ node.handleHaStateChange(msg.data.entity_id, msg.data.new_state, msg.data.old_state);
330
+ }
331
+ });
332
+
333
+ // 方式B: 尝试订阅HA Server事件总线
334
+ if (node.haServer && node.haServer.eventBus) {
335
+ node.haEventHandler = (evt) => {
336
+ if (evt && evt.event_type === 'state_changed' && evt.data) {
337
+ node.handleHaStateChange(evt.data.entity_id, evt.data.new_state, evt.data.old_state);
338
+ }
339
+ };
340
+ node.haServer.eventBus.on('ha_events:all', node.haEventHandler);
341
+ node.log('[HA同步] 已订阅HA事件总线');
342
+ }
343
+
344
+ node.handleHaStateChange = function(entityId, newState, oldState) {
345
+ if (!newState) return;
346
+
347
+ const mappings = node.findMappingsByHa(entityId);
348
+ if (mappings.length === 0) return;
349
+
350
+ const domain = node.getEntityDomain(entityId);
351
+ const attrs = newState.attributes || {};
352
+ const oldAttrs = oldState ? (oldState.attributes || {}) : {};
353
+
354
+ mappings.forEach(mapping => {
355
+ const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
356
+
357
+ // 根据实体类型提取变化
358
+ let syncDataList = [];
359
+
360
+ switch (domain) {
361
+ case 'switch':
362
+ case 'input_boolean':
363
+ if (!oldState || newState.state !== oldState.state) {
364
+ if (newState.state === 'on' || newState.state === 'off') {
365
+ syncDataList.push({ type: 'switch', value: newState.state === 'on' });
366
+ }
367
+ }
368
+ break;
369
+
370
+ case 'light':
371
+ // 开关状态
372
+ if (!oldState || newState.state !== oldState.state) {
373
+ if (newState.state === 'on' || newState.state === 'off') {
374
+ syncDataList.push({ type: 'switch', value: newState.state === 'on' });
375
+ }
376
+ }
377
+ // 亮度变化(仅当灯开着时)
378
+ if (newState.state === 'on' && attrs.brightness !== undefined) {
379
+ if (!oldState || oldAttrs.brightness !== attrs.brightness) {
380
+ // HA亮度0-255,Mesh亮度0-100
381
+ const meshBrightness = Math.round(attrs.brightness * 100 / 255);
382
+ syncDataList.push({ type: 'brightness', value: meshBrightness });
383
+ }
384
+ }
385
+ break;
386
+
387
+ case 'cover':
388
+ // 窗帘位置
389
+ if (attrs.current_position !== undefined) {
390
+ if (!oldState || oldAttrs.current_position !== attrs.current_position) {
391
+ syncDataList.push({ type: 'position', value: attrs.current_position });
392
+ }
393
+ }
394
+ // 窗帘动作
395
+ if (newState.state !== oldState?.state) {
396
+ if (newState.state === 'open') {
397
+ syncDataList.push({ type: 'curtain_action', value: 'open' });
398
+ } else if (newState.state === 'closed') {
399
+ syncDataList.push({ type: 'curtain_action', value: 'close' });
400
+ }
401
+ }
402
+ break;
403
+
404
+ case 'climate':
405
+ // 开关状态
406
+ if (!oldState || newState.state !== oldState.state) {
407
+ const isOn = newState.state !== 'off' && newState.state !== 'unavailable';
408
+ syncDataList.push({ type: 'switch', value: isOn });
409
+ }
410
+ // 目标温度
411
+ if (attrs.temperature !== undefined) {
412
+ if (!oldState || oldAttrs.temperature !== attrs.temperature) {
413
+ syncDataList.push({ type: 'temperature', value: Math.round(attrs.temperature) });
414
+ }
415
+ }
416
+ // HVAC模式
417
+ if (attrs.hvac_mode !== undefined || newState.state !== oldState?.state) {
418
+ const hvacMode = attrs.hvac_mode || newState.state;
419
+ if (!oldState || (oldAttrs.hvac_mode || oldState.state) !== hvacMode) {
420
+ const meshMode = HA_TO_AC_MODE[hvacMode];
421
+ if (meshMode !== undefined) {
422
+ syncDataList.push({ type: 'hvac_mode', value: meshMode });
423
+ }
424
+ }
425
+ }
426
+ // 风速
427
+ if (attrs.fan_mode !== undefined) {
428
+ if (!oldState || oldAttrs.fan_mode !== attrs.fan_mode) {
429
+ const meshFan = HA_TO_FAN_MODE[attrs.fan_mode];
430
+ if (meshFan !== undefined) {
431
+ syncDataList.push({ type: 'fan_mode', value: meshFan });
432
+ }
433
+ }
434
+ }
435
+ break;
436
+
437
+ case 'fan':
438
+ // 开关
439
+ if (!oldState || newState.state !== oldState.state) {
440
+ if (newState.state === 'on' || newState.state === 'off') {
441
+ syncDataList.push({ type: 'switch', value: newState.state === 'on' });
442
+ }
443
+ }
444
+ // 风速
445
+ if (attrs.percentage !== undefined) {
446
+ if (!oldState || oldAttrs.percentage !== attrs.percentage) {
447
+ // 百分比转风速档位
448
+ let meshFan = 4; // auto
449
+ if (attrs.percentage > 66) meshFan = 1; // high
450
+ else if (attrs.percentage > 33) meshFan = 2; // medium
451
+ else if (attrs.percentage > 0) meshFan = 3; // low
452
+ syncDataList.push({ type: 'fan_mode', value: meshFan });
453
+ }
454
+ }
455
+ break;
456
+ }
457
+
458
+ // 队列同步命令
459
+ syncDataList.forEach(syncData => {
460
+ if (node.shouldPreventSync('ha-to-symi', loopKey)) {
461
+ node.debug(`[HA->Symi] 跳过(防死循环): ${loopKey} ${syncData.type}`);
462
+ return;
463
+ }
464
+
465
+ node.queueCommand({
466
+ direction: 'ha-to-symi',
467
+ mapping: mapping,
468
+ syncData: syncData,
469
+ key: loopKey
470
+ });
471
+ });
472
+ });
473
+ };
474
+
475
+ // ========== 队列处理 ==========
476
+ node.queueCommand = function(cmd) {
477
+ if (node.commandQueue.length >= MAX_QUEUE_SIZE) {
478
+ node.commandQueue.shift();
479
+ }
480
+
481
+ // 去重:相同方向、相同映射、相同类型的命令在短时间内只保留最新的
482
+ const existing = node.commandQueue.findIndex(c =>
483
+ c.direction === cmd.direction &&
484
+ c.mapping.symiMac === cmd.mapping.symiMac &&
485
+ c.mapping.symiKey === cmd.mapping.symiKey &&
486
+ c.mapping.haEntityId === cmd.mapping.haEntityId &&
487
+ c.syncData.type === cmd.syncData.type &&
488
+ Date.now() - (c.timestamp || 0) < DEBOUNCE_MS
489
+ );
490
+
491
+ if (existing >= 0) {
492
+ node.commandQueue[existing].syncData = cmd.syncData;
493
+ return;
494
+ }
495
+
496
+ cmd.timestamp = Date.now();
497
+ node.commandQueue.push(cmd);
498
+ node.processQueue();
499
+ };
500
+
501
+ node.processQueue = async function() {
502
+ if (node.processing || node.commandQueue.length === 0) return;
503
+ node.processing = true;
504
+
505
+ try {
506
+ while (node.commandQueue.length > 0) {
507
+ const cmd = node.commandQueue.shift();
508
+ try {
509
+ if (cmd.direction === 'symi-to-ha') {
510
+ await node.syncSymiToHa(cmd);
511
+ } else {
512
+ await node.syncHaToSymi(cmd);
513
+ }
514
+ await node.sleep(50);
515
+ } catch (err) {
516
+ node.error(`同步失败: ${err.message}`);
517
+ }
518
+ }
519
+ } finally {
520
+ node.processing = false;
521
+ }
522
+ };
523
+
524
+ // ========== 执行 Symi -> HA ==========
525
+ node.syncSymiToHa = async function(cmd) {
526
+ const { mapping, syncData, key } = cmd;
527
+ node.recordSyncTime('symi-to-ha', key);
528
+
529
+ if (!node.haServer || !node.haServer.credentials) return;
530
+
531
+ const baseURL = node.haServer.credentials.host || 'http://localhost:8123';
532
+ const token = node.haServer.credentials.access_token;
533
+ if (!token) return;
534
+
535
+ const domain = node.getEntityDomain(mapping.haEntityId);
536
+ let service = '';
537
+ let serviceData = { entity_id: mapping.haEntityId };
538
+
539
+ try {
540
+ switch (syncData.type) {
541
+ case 'switch':
542
+ service = syncData.value ? 'turn_on' : 'turn_off';
543
+ break;
544
+
545
+ case 'brightness':
546
+ service = 'turn_on';
547
+ serviceData.brightness = syncData.value; // 已经是0-255
548
+ break;
549
+
550
+ case 'position':
551
+ service = 'set_cover_position';
552
+ serviceData.position = syncData.value;
553
+ break;
554
+
555
+ case 'curtain_stop':
556
+ service = 'stop_cover';
557
+ break;
558
+
559
+ case 'temperature':
560
+ service = 'set_temperature';
561
+ serviceData.temperature = syncData.value;
562
+ break;
563
+
564
+ case 'hvac_mode':
565
+ service = 'set_hvac_mode';
566
+ serviceData.hvac_mode = syncData.value;
567
+ break;
568
+
569
+ case 'fan_mode':
570
+ if (domain === 'climate') {
571
+ service = 'set_fan_mode';
572
+ serviceData.fan_mode = syncData.value;
573
+ } else if (domain === 'fan') {
574
+ service = 'set_percentage';
575
+ // 风速档位转百分比
576
+ const percentMap = { high: 100, medium: 66, low: 33, auto: 50 };
577
+ serviceData.percentage = percentMap[syncData.value] || 50;
578
+ }
579
+ break;
580
+
581
+ default:
582
+ return;
583
+ }
584
+
585
+ const serviceDomain = (syncData.type === 'position' || syncData.type === 'curtain_stop') ? 'cover' : domain;
586
+
587
+ await axios.post(`${baseURL}/api/services/${serviceDomain}/${service}`, serviceData, {
588
+ headers: {
589
+ 'Authorization': `Bearer ${token}`,
590
+ 'Content-Type': 'application/json'
591
+ },
592
+ timeout: 5000
593
+ });
594
+
595
+ node.log(`[Symi->HA] ${mapping.symiName || mapping.symiMac} -> ${mapping.haEntityId}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
596
+
597
+ // 输出到debug
598
+ node.send({
599
+ topic: 'ha-sync/symi-to-ha',
600
+ payload: {
601
+ direction: 'Symi→HA',
602
+ symiMac: mapping.symiMac,
603
+ symiKey: mapping.symiKey,
604
+ haEntityId: mapping.haEntityId,
605
+ syncType: syncData.type,
606
+ value: syncData.value,
607
+ timestamp: Date.now()
608
+ }
609
+ });
610
+
611
+ } catch (err) {
612
+ node.error(`[Symi->HA] 调用失败: ${err.message}`);
613
+ }
614
+ };
615
+
616
+ // ========== 执行 HA -> Symi ==========
617
+ node.syncHaToSymi = async function(cmd) {
618
+ const { mapping, syncData, key } = cmd;
619
+ node.recordSyncTime('ha-to-symi', key);
620
+
621
+ const device = gateway.getDevice(mapping.symiMac);
622
+ if (!device) {
623
+ node.warn(`[HA->Symi] 设备未找到: ${mapping.symiMac}`);
624
+ return;
625
+ }
626
+
627
+ const networkAddr = device.networkAddress;
628
+ const channels = device.channels;
629
+ const targetChannel = mapping.symiKey;
630
+
631
+ try {
632
+ let attrType, param;
633
+
634
+ switch (syncData.type) {
635
+ case 'switch':
636
+ attrType = ATTR_SWITCH;
637
+ param = [channels, targetChannel, syncData.value ? 1 : 0];
638
+ break;
639
+
640
+ case 'brightness':
641
+ attrType = ATTR_BRIGHTNESS;
642
+ // Mesh亮度0-100
643
+ param = [syncData.value];
644
+ break;
645
+
646
+ case 'position':
647
+ attrType = ATTR_CURTAIN_POSITION;
648
+ param = [syncData.value];
649
+ break;
650
+
651
+ case 'curtain_action':
652
+ attrType = ATTR_CURTAIN_STATUS;
653
+ // 1=打开, 2=关闭, 3=停止
654
+ const actionMap = { open: 1, close: 2, stop: 3 };
655
+ param = [actionMap[syncData.value] || 3];
656
+ break;
657
+
658
+ case 'temperature':
659
+ attrType = ATTR_TARGET_TEMP;
660
+ param = [syncData.value];
661
+ break;
662
+
663
+ case 'hvac_mode':
664
+ attrType = ATTR_CLIMATE_MODE;
665
+ param = [syncData.value];
666
+ break;
667
+
668
+ case 'fan_mode':
669
+ attrType = ATTR_FAN_MODE;
670
+ param = [syncData.value];
671
+ break;
672
+
673
+ default:
674
+ return;
675
+ }
676
+
677
+ await gateway.sendControl(networkAddr, attrType, param);
678
+
679
+ node.log(`[HA->Symi] ${mapping.haEntityId} -> ${device.name || mapping.symiMac}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
680
+
681
+ // 输出到debug
682
+ node.send({
683
+ topic: 'ha-sync/ha-to-symi',
684
+ payload: {
685
+ direction: 'HA→Symi',
686
+ haEntityId: mapping.haEntityId,
687
+ symiMac: mapping.symiMac,
688
+ symiKey: mapping.symiKey,
689
+ syncType: syncData.type,
690
+ value: syncData.value,
691
+ attrType: attrType,
692
+ timestamp: Date.now()
693
+ }
694
+ });
695
+
696
+ } catch (err) {
697
+ node.error(`[HA->Symi] 控制失败: ${err.message}`);
698
+ }
699
+ };
700
+
701
+ // ========== 节点关闭 ==========
702
+ node.on('close', function(done) {
703
+ if (node.cleanupInterval) {
704
+ clearInterval(node.cleanupInterval);
705
+ }
706
+ // 清理防抖定时器
707
+ for (const key in node.pendingDebounce) {
708
+ clearTimeout(node.pendingDebounce[key]);
709
+ }
710
+ node.pendingDebounce = {};
711
+
712
+ if (gateway) {
713
+ gateway.removeListener('device-state-changed', node.handleSymiStateChange);
714
+ }
715
+ if (node.haServer && node.haServer.eventBus && node.haEventHandler) {
716
+ node.haServer.eventBus.removeListener('ha_events:all', node.haEventHandler);
717
+ }
718
+ node.commandQueue = [];
719
+ done();
720
+ });
721
+ }
722
+
723
+ RED.nodes.registerType('symi-ha-sync', SymiHASyncNode);
724
+
725
+ // ========== HTTP API ==========
726
+
727
+ // 加载Symi设备
728
+ RED.httpAdmin.get('/symi-ha-sync/symi-devices/:id', function(req, res) {
729
+ const mqttNode = RED.nodes.getNode(req.params.id);
730
+ if (!mqttNode || !mqttNode.gateway) {
731
+ return res.json([]);
732
+ }
733
+
734
+ try {
735
+ const devices = mqttNode.gateway.deviceManager.getAllDevices();
736
+ const list = devices.map(d => ({
737
+ name: d.name,
738
+ macAddress: d.macAddress,
739
+ channels: d.channels,
740
+ deviceType: d.deviceType,
741
+ entityType: d.getEntityType()
742
+ }));
743
+ res.json(list);
744
+ } catch (err) {
745
+ res.json([]);
746
+ }
747
+ });
748
+
749
+ // 加载HA实体 - 支持更多实体类型
750
+ RED.httpAdmin.get('/symi-ha-sync/ha-entities/:id', async function(req, res) {
751
+ try {
752
+ const serverNode = RED.nodes.getNode(req.params.id);
753
+ if (!serverNode || !serverNode.credentials) return res.json([]);
754
+
755
+ const baseURL = serverNode.credentials.host || 'http://localhost:8123';
756
+ const token = serverNode.credentials.access_token;
757
+
758
+ if (!token) return res.json([]);
759
+
760
+ const response = await axios.get(`${baseURL}/api/states`, {
761
+ headers: { 'Authorization': `Bearer ${token}` },
762
+ timeout: 5000
763
+ });
764
+
765
+ const entities = [];
766
+ // 支持的实体类型
767
+ const supportedDomains = ['switch', 'light', 'input_boolean', 'cover', 'climate', 'fan'];
768
+
769
+ if (response.data && Array.isArray(response.data)) {
770
+ response.data.forEach(state => {
771
+ if (!state || !state.entity_id) return;
772
+ const domain = state.entity_id.split('.')[0];
773
+ if (supportedDomains.includes(domain)) {
774
+ entities.push({
775
+ entity_id: state.entity_id,
776
+ name: (state.attributes && state.attributes.friendly_name) || state.entity_id,
777
+ type: domain
778
+ });
779
+ }
780
+ });
781
+ }
782
+ res.json(entities);
783
+ } catch (err) {
784
+ res.json([]);
785
+ }
786
+ });
787
+ };