node-red-contrib-symi-mesh 1.8.3 → 1.8.5
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 +252 -1157
- package/lib/sync-utils.js +398 -0
- package/nodes/rs485-debug.html +4 -3
- package/nodes/symi-485-bridge.html +4 -3
- package/nodes/symi-485-bridge.js +32 -0
- package/nodes/symi-485-config.html +7 -0
- package/nodes/symi-cloud-sync.html +7 -0
- package/nodes/symi-device.html +7 -0
- package/nodes/symi-gateway.html +7 -0
- package/nodes/symi-ha-sync.html +5 -47
- package/nodes/symi-ha-sync.js +360 -179
- package/nodes/symi-knx-bridge.html +8 -0
- package/nodes/symi-knx-bridge.js +27 -24
- package/nodes/symi-mqtt-brand.html +7 -0
- package/nodes/symi-mqtt-sync.html +2 -2
- package/nodes/symi-mqtt-sync.js +164 -34
- package/nodes/symi-mqtt.html +7 -0
- package/nodes/symi-rs485-sync.html +3 -2
- package/nodes/symi-rs485-sync.js +78 -22
- package/package.json +1 -1
package/nodes/symi-ha-sync.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
|
|
3
|
-
* 版本: 1.8.
|
|
3
|
+
* 版本: 1.8.5
|
|
4
|
+
*
|
|
5
|
+
* v1.8.5 更新:
|
|
6
|
+
* - 使用通用 SyncUtils 类统一防环路逻辑
|
|
7
|
+
* - 添加 StateCache 状态缓存,避免重复同步
|
|
8
|
+
* - 优化三合一面板同步逻辑
|
|
9
|
+
*
|
|
10
|
+
* v1.8.4 更新:
|
|
11
|
+
* - 修复HA state_changed事件解析,支持更多消息格式
|
|
12
|
+
* - 优化窗帘同步:动作命令直接同步,位置只在非运动状态同步
|
|
13
|
+
* - 优化空调同步:只在开关状态真正变化时同步,避免off->off无效日志
|
|
14
|
+
* - 优化调光同步:HA发起调光时忽略Mesh步进反馈
|
|
15
|
+
* - 增加状态变化检测,无变化时跳过处理
|
|
16
|
+
* - 过滤sensor类型实体,避免不必要的处理
|
|
4
17
|
*
|
|
5
18
|
* 支持的实体类型和属性:
|
|
6
19
|
* - light: on/off, brightness (0-255)
|
|
@@ -12,6 +25,7 @@
|
|
|
12
25
|
|
|
13
26
|
module.exports = function(RED) {
|
|
14
27
|
const axios = require('axios');
|
|
28
|
+
const { SyncUtils, StateCache, COVER_TIMEOUT, BRIGHTNESS_TIMEOUT } = require('../lib/sync-utils');
|
|
15
29
|
|
|
16
30
|
// 常量定义
|
|
17
31
|
const LOOP_PREVENTION_MS = 2000; // 防死循环时间窗口
|
|
@@ -21,11 +35,11 @@ module.exports = function(RED) {
|
|
|
21
35
|
const TIMESTAMP_EXPIRE_MS = 60000;
|
|
22
36
|
|
|
23
37
|
// 窗帘专用常量
|
|
24
|
-
const COVER_LOOP_PREVENTION_MS =
|
|
38
|
+
const COVER_LOOP_PREVENTION_MS = COVER_TIMEOUT; // 30秒防死循环
|
|
25
39
|
const COVER_DEBOUNCE_MS = 1500; // 1.5秒防抖
|
|
26
40
|
|
|
27
41
|
// 调光专用常量
|
|
28
|
-
const BRIGHTNESS_DEBOUNCE_MS =
|
|
42
|
+
const BRIGHTNESS_DEBOUNCE_MS = BRIGHTNESS_TIMEOUT; // 0.8秒防抖,过滤步进过程
|
|
29
43
|
|
|
30
44
|
// Mesh属性类型
|
|
31
45
|
const ATTR_SWITCH = 0x02;
|
|
@@ -60,6 +74,16 @@ module.exports = function(RED) {
|
|
|
60
74
|
node.mqttNode = RED.nodes.getNode(config.mqttConfig);
|
|
61
75
|
node.haServer = RED.nodes.getNode(config.haServer);
|
|
62
76
|
|
|
77
|
+
// 初始化通用同步工具
|
|
78
|
+
node.syncUtils = new SyncUtils({
|
|
79
|
+
defaultTimeout: LOOP_PREVENTION_MS,
|
|
80
|
+
coverTimeout: COVER_LOOP_PREVENTION_MS,
|
|
81
|
+
brightnessTimeout: BRIGHTNESS_DEBOUNCE_MS
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 初始化状态缓存(用于三合一面板细粒度状态对比)
|
|
85
|
+
node.stateCache = new StateCache(node.context(), 'ha_sync_state_cache');
|
|
86
|
+
|
|
63
87
|
// 解析映射配置
|
|
64
88
|
try {
|
|
65
89
|
const rawMappings = JSON.parse(config.mappings || '[]');
|
|
@@ -177,25 +201,16 @@ module.exports = function(RED) {
|
|
|
177
201
|
}
|
|
178
202
|
}, CLEANUP_INTERVAL_MS);
|
|
179
203
|
|
|
180
|
-
// 防死循环检查 -
|
|
204
|
+
// 防死循环检查 - 使用通用 SyncUtils
|
|
181
205
|
node.shouldPreventSync = function(direction, key) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return (now - lastHaTime) < LOOP_PREVENTION_MS;
|
|
186
|
-
} else {
|
|
187
|
-
const lastSymiTime = node.lastSymiToHa[key] || 0;
|
|
188
|
-
return (now - lastSymiTime) < LOOP_PREVENTION_MS;
|
|
189
|
-
}
|
|
206
|
+
// 转换方向名称以匹配 SyncUtils 的格式
|
|
207
|
+
const syncDirection = direction === 'symi-to-ha' ? 'mesh-to-target' : 'target-to-mesh';
|
|
208
|
+
return node.syncUtils.shouldPreventSync(syncDirection, key);
|
|
190
209
|
};
|
|
191
210
|
|
|
192
211
|
node.recordSyncTime = function(direction, key) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
node.lastSymiToHa[key] = now;
|
|
196
|
-
} else {
|
|
197
|
-
node.lastHaToSymi[key] = now;
|
|
198
|
-
}
|
|
212
|
+
const syncDirection = direction === 'symi-to-ha' ? 'mesh-to-target' : 'target-to-mesh';
|
|
213
|
+
node.syncUtils.recordSyncTime(syncDirection, key);
|
|
199
214
|
};
|
|
200
215
|
|
|
201
216
|
node.sleep = function(ms) {
|
|
@@ -221,17 +236,27 @@ module.exports = function(RED) {
|
|
|
221
236
|
|
|
222
237
|
// ========== 1. 监听Symi设备状态变化 (Symi -> HA) ==========
|
|
223
238
|
node.handleSymiStateChange = function(eventData) {
|
|
224
|
-
if (!eventData.device || !eventData.device.macAddress)
|
|
239
|
+
if (!eventData.device || !eventData.device.macAddress) {
|
|
240
|
+
node.debug('[Symi->HA] 忽略: 无效的设备数据');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
225
243
|
|
|
226
244
|
const device = eventData.device;
|
|
227
245
|
const attrType = eventData.attrType;
|
|
228
246
|
const state = eventData.state || {};
|
|
229
247
|
|
|
248
|
+
node.debug(`[Symi->HA] 设备状态变化: ${device.macAddress}, attrType=0x${attrType?.toString(16) || 'unknown'}, state=${JSON.stringify(state)}`);
|
|
249
|
+
|
|
230
250
|
// 遍历该设备的所有映射
|
|
231
251
|
const deviceMappings = node.mappings.filter(m =>
|
|
232
252
|
m.symiMac.toLowerCase().replace(/:/g, '') === device.macAddress.toLowerCase().replace(/:/g, '')
|
|
233
253
|
);
|
|
234
|
-
if (deviceMappings.length === 0)
|
|
254
|
+
if (deviceMappings.length === 0) {
|
|
255
|
+
node.debug(`[Symi->HA] 设备不在映射中: ${device.macAddress}`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
node.debug(`[Symi->HA] 找到 ${deviceMappings.length} 个映射`);
|
|
235
260
|
|
|
236
261
|
deviceMappings.forEach(mapping => {
|
|
237
262
|
// 检查同步模式 (0:双向, 1:Symi->HA, 2:HA->Symi)
|
|
@@ -296,18 +321,35 @@ module.exports = function(RED) {
|
|
|
296
321
|
// 支持返回数组(用于三合一同时同步多个属性)
|
|
297
322
|
const syncDataList = Array.isArray(syncData) ? syncData : [syncData];
|
|
298
323
|
|
|
299
|
-
syncDataList.forEach(data => {
|
|
300
|
-
|
|
324
|
+
syncDataList.forEach(async data => {
|
|
325
|
+
// 窗帘关键动作:开、关、停
|
|
326
|
+
const isImmediateAction = domain === 'cover' &&
|
|
327
|
+
(data.type === 'curtain_action' || data.type === 'curtain_stop');
|
|
328
|
+
|
|
329
|
+
// 窗帘位置同步(普通)
|
|
330
|
+
const isCoverPos = domain === 'cover' && data.type === 'position';
|
|
331
|
+
|
|
332
|
+
if (!isImmediateAction && !isCoverPos && node.shouldPreventSync('symi-to-ha', loopKey)) {
|
|
301
333
|
node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
|
|
302
334
|
return;
|
|
303
335
|
}
|
|
304
336
|
|
|
305
|
-
|
|
337
|
+
const cmd = {
|
|
306
338
|
direction: 'symi-to-ha',
|
|
307
339
|
mapping: mapping,
|
|
308
340
|
syncData: data,
|
|
309
|
-
key: loopKey
|
|
310
|
-
|
|
341
|
+
key: loopKey,
|
|
342
|
+
skipLoopCheck: isImmediateAction || isCoverPos
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// 窗帘关键动作:绕过队列,立即执行
|
|
346
|
+
if (isImmediateAction) {
|
|
347
|
+
node.debug(`[Symi->HA] 窗帘关键动作,立即同步: ${data.type}=${data.value}`);
|
|
348
|
+
node.syncSymiToHa(cmd); // 异步执行但不排队
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
node.queueCommand(cmd);
|
|
311
353
|
});
|
|
312
354
|
}
|
|
313
355
|
});
|
|
@@ -316,79 +358,54 @@ module.exports = function(RED) {
|
|
|
316
358
|
// 处理三合一状态变化
|
|
317
359
|
node.handleThreeInOneChange = function(device, mapping, state, attrType) {
|
|
318
360
|
const subType = mapping.symiKey; // 'aircon', 'fresh_air', 'floor_heating'
|
|
361
|
+
const mac = device.macAddress.toLowerCase().replace(/:/g, '');
|
|
362
|
+
const changedData = [];
|
|
319
363
|
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
// 需要检查哪些属性变了,但这里只能返回一个syncData
|
|
327
|
-
// 我们可以返回一个特殊对象,或者分别检查
|
|
328
|
-
// 为简化,这里假设HA端会处理部分更新,或者我们按优先级返回
|
|
329
|
-
|
|
330
|
-
// 检查开关
|
|
331
|
-
if (state.climateSwitch !== undefined) {
|
|
332
|
-
// 注意:这里需要比对旧状态,但在handleSymiStateChange中难以获取旧状态
|
|
333
|
-
// 我们可以利用node.queueCommand的去重机制,发送所有可能的状态
|
|
334
|
-
// 但这样会产生大量流量。
|
|
335
|
-
// 实际上DeviceManager触发事件时,如果是0x94,是全量更新。
|
|
336
|
-
// 我们可以只处理核心属性。
|
|
337
|
-
// 更好的方式是:在device-manager中,0x94更新会触发一次事件。
|
|
338
|
-
// 这里我们返回一个复合对象,或者由上层逻辑拆分。
|
|
339
|
-
// 由于syncData只能是一个对象,我们优先同步开关,然后是模式/温度
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
// 由于0x94更新时,device-manager已经更新了state
|
|
343
|
-
// 我们可以直接从state读取当前值
|
|
364
|
+
// 辅助函数:检查属性是否变化,使用细粒度缓存key
|
|
365
|
+
const checkAndCacheProperty = (property, value, syncData) => {
|
|
366
|
+
if (value === undefined) return;
|
|
367
|
+
|
|
368
|
+
// 细粒度缓存key: mac_subEntity_property
|
|
369
|
+
const cacheKey = `${mac}_${subType}_${property}`;
|
|
344
370
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
{
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
371
|
+
// 检查是否有变化
|
|
372
|
+
if (node.stateCache.hasChanged(cacheKey, value)) {
|
|
373
|
+
node.stateCache.update(cacheKey, value);
|
|
374
|
+
changedData.push(syncData);
|
|
375
|
+
node.debug(`[三合一] ${subType}.${property} 变化: ${value}`);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// 1. 空调部分
|
|
380
|
+
if (subType === 'aircon') {
|
|
381
|
+
checkAndCacheProperty('switch', state.climateSwitch,
|
|
382
|
+
{ type: 'switch', value: state.climateSwitch });
|
|
383
|
+
checkAndCacheProperty('temperature', state.targetTemp,
|
|
384
|
+
{ type: 'temperature', value: state.targetTemp });
|
|
385
|
+
checkAndCacheProperty('mode', state.climateMode,
|
|
386
|
+
{ type: 'hvac_mode', value: AC_MODE_TO_HA[state.climateMode] || 'off', meshValue: state.climateMode });
|
|
387
|
+
checkAndCacheProperty('fanSpeed', state.fanMode,
|
|
388
|
+
{ type: 'fan_mode', value: FAN_MODE_TO_HA[state.fanMode] || 'auto', meshValue: state.fanMode });
|
|
354
389
|
}
|
|
355
390
|
|
|
356
391
|
// 2. 新风部分
|
|
357
|
-
if (subType === 'fresh_air') {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
// 新风风速 (0x6A或0x94)
|
|
365
|
-
if (attrType === ATTR_FRESH_AIR_SPEED || attrType === ATTR_THREE_IN_ONE) {
|
|
366
|
-
if (state.freshAirSpeed !== undefined) {
|
|
367
|
-
return { type: 'fan_mode', value: FAN_MODE_TO_HA[state.freshAirSpeed] || 'auto', meshValue: state.freshAirSpeed };
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
// 新风模式 (0x69或0x94)
|
|
371
|
-
// 目前HA Fan实体通常只支持on/off和speed,mode可能不支持或映射到preset_mode
|
|
372
|
-
// 暂时忽略模式,或视需求添加
|
|
392
|
+
else if (subType === 'fresh_air') {
|
|
393
|
+
checkAndCacheProperty('switch', state.freshAirSwitch,
|
|
394
|
+
{ type: 'switch', value: state.freshAirSwitch });
|
|
395
|
+
checkAndCacheProperty('speed', state.freshAirSpeed,
|
|
396
|
+
{ type: 'fan_mode', value: FAN_MODE_TO_HA[state.freshAirSpeed] || 'auto', meshValue: state.freshAirSpeed });
|
|
373
397
|
}
|
|
374
398
|
|
|
375
399
|
// 3. 地暖部分
|
|
376
|
-
if (subType === 'floor_heating') {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
// 地暖温度 (0x6C或0x94)
|
|
384
|
-
if (attrType === ATTR_FLOOR_HEATING_TEMP || attrType === ATTR_THREE_IN_ONE) {
|
|
385
|
-
if (state.floorHeatingTemp !== undefined) {
|
|
386
|
-
return { type: 'temperature', value: state.floorHeatingTemp };
|
|
387
|
-
}
|
|
388
|
-
}
|
|
400
|
+
else if (subType === 'floor_heating') {
|
|
401
|
+
checkAndCacheProperty('switch', state.floorHeatingSwitch,
|
|
402
|
+
{ type: 'switch', value: state.floorHeatingSwitch });
|
|
403
|
+
checkAndCacheProperty('temperature', state.floorHeatingTemp,
|
|
404
|
+
{ type: 'temperature', value: state.floorHeatingTemp });
|
|
389
405
|
}
|
|
390
406
|
|
|
391
|
-
|
|
407
|
+
// 只返回真正变化的属性
|
|
408
|
+
return changedData.length > 0 ? changedData : null;
|
|
392
409
|
};
|
|
393
410
|
|
|
394
411
|
// 处理开关状态变化
|
|
@@ -451,65 +468,77 @@ module.exports = function(RED) {
|
|
|
451
468
|
return null; // 不立即同步,由定时器处理
|
|
452
469
|
};
|
|
453
470
|
|
|
454
|
-
//
|
|
471
|
+
// 处理窗帘变化 - 谁发起控制就只听谁的命令
|
|
472
|
+
// Mesh控制时只同步最终位置,忽略过程中的实时反馈
|
|
455
473
|
node.handleCurtainChange = function(device, mapping, state, attrType) {
|
|
456
474
|
const domain = node.getEntityDomain(mapping.haEntityId);
|
|
457
475
|
if (domain !== 'cover') return null;
|
|
458
476
|
|
|
459
477
|
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
return null; // 忽略HA发起运动期间的Mesh反馈
|
|
467
|
-
} else {
|
|
468
|
-
delete node.coverMoving[loopKey]; // 超时清理
|
|
469
|
-
}
|
|
478
|
+
const now = Date.now();
|
|
479
|
+
|
|
480
|
+
// 检查控制锁定时长,超过40秒强制释放(针对无限位模块或丢包情况)
|
|
481
|
+
if (node.coverMoving[loopKey] && (now - node.coverMoving[loopKey].startTime > 40000)) {
|
|
482
|
+
node.log(`[Symi->HA] 窗帘控制锁定超时(40s),针对无限位模块强制释放: ${loopKey}`);
|
|
483
|
+
delete node.coverMoving[loopKey];
|
|
470
484
|
}
|
|
485
|
+
|
|
486
|
+
// 获取动作状态
|
|
487
|
+
const action = state.curtainAction || device.state.curtainAction;
|
|
471
488
|
|
|
472
|
-
//
|
|
473
|
-
if (attrType ===
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
489
|
+
// 1. 处理运行状态变化 (ATTR_CURTAIN_STATUS)
|
|
490
|
+
if (attrType === ATTR_CURTAIN_STATUS) {
|
|
491
|
+
if (action === 'opening' || action === 'closing') {
|
|
492
|
+
// 如果是由HA发起的控制,Mesh反馈正在运行是正常的,继续保持HA控制权
|
|
493
|
+
if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
|
|
494
|
+
node.debug(`[Symi->HA] 窗帘反馈正在运行(HA控制中): ${action}`);
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 强制同步:无论当前是否有锁定,只要收到 opening/closing 且当前状态不是对应动作,就触发一次同步
|
|
499
|
+
const currentAction = node.coverMoving[loopKey] ? node.coverMoving[loopKey].action : null;
|
|
500
|
+
if (currentAction !== action) {
|
|
501
|
+
node.log(`[Symi->HA] 窗帘由Mesh发起动作: ${action}`);
|
|
502
|
+
node.coverMoving[loopKey] = {
|
|
503
|
+
direction: 'symi',
|
|
504
|
+
startTime: now,
|
|
505
|
+
action: action
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// 立即返回同步指令
|
|
509
|
+
return { type: 'curtain_action', value: action === 'opening' ? 'open' : 'close' };
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
481
512
|
}
|
|
482
513
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
// 延迟同步,等待位置稳定
|
|
487
|
-
node.pendingDebounce[debounceKey] = setTimeout(() => {
|
|
488
|
-
delete node.pendingDebounce[debounceKey];
|
|
489
|
-
// 运动结束,清理状态
|
|
514
|
+
if (action === 'stopped') {
|
|
515
|
+
node.log(`[Symi->HA] 窗帘已停止,释放控制权: ${loopKey}`);
|
|
490
516
|
delete node.coverMoving[loopKey];
|
|
491
517
|
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}, COVER_DEBOUNCE_MS);
|
|
501
|
-
|
|
502
|
-
return null; // 不立即同步
|
|
518
|
+
// 停止后同步一次最终位置
|
|
519
|
+
const finalPos = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
|
|
520
|
+
if (finalPos !== undefined) {
|
|
521
|
+
node.debug(`[Symi->HA] 同步最终位置: ${finalPos}%`);
|
|
522
|
+
return { type: 'position', value: finalPos };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return null;
|
|
503
526
|
}
|
|
504
527
|
|
|
505
|
-
//
|
|
506
|
-
if (attrType ===
|
|
507
|
-
const
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
528
|
+
// 2. 处理位置变化 (ATTR_CURTAIN_POSITION)
|
|
529
|
+
if (attrType === ATTR_CURTAIN_POSITION) {
|
|
530
|
+
const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
|
|
531
|
+
if (position === undefined) return null;
|
|
532
|
+
|
|
533
|
+
// 核心需求:忽略过程中的实时反馈指令
|
|
534
|
+
// 如果当前有任何控制方向的锁定(正在移动中),则忽略位置反馈
|
|
535
|
+
if (node.coverMoving[loopKey]) {
|
|
536
|
+
node.debug(`[Symi->HA] 忽略移动中的实时位置反馈: ${position}%`);
|
|
537
|
+
return null;
|
|
512
538
|
}
|
|
539
|
+
|
|
540
|
+
// 没有移动锁定时,允许同步(例如初始同步或查询返回)
|
|
541
|
+
return { type: 'position', value: position };
|
|
513
542
|
}
|
|
514
543
|
|
|
515
544
|
return null;
|
|
@@ -556,6 +585,11 @@ module.exports = function(RED) {
|
|
|
556
585
|
node.log('[HA同步] 已收到HA输入,双向同步已启用');
|
|
557
586
|
}
|
|
558
587
|
|
|
588
|
+
// 调试:记录收到的消息结构
|
|
589
|
+
if (msg.payload && msg.payload.event_type) {
|
|
590
|
+
node.debug(`[HA输入] event_type=${msg.payload.event_type}, entity_id=${msg.payload.entity_id}, event=${msg.payload.event ? 'object' : 'null'}`);
|
|
591
|
+
}
|
|
592
|
+
|
|
559
593
|
// 支持多种消息格式
|
|
560
594
|
let entityId, newState, oldState;
|
|
561
595
|
|
|
@@ -567,6 +601,22 @@ module.exports = function(RED) {
|
|
|
567
601
|
if (msg.payload.event) {
|
|
568
602
|
newState = msg.payload.event.new_state;
|
|
569
603
|
oldState = msg.payload.event.old_state;
|
|
604
|
+
// 如果 event 中也有 entity_id,优先使用(更可靠)
|
|
605
|
+
if (msg.payload.event.entity_id) {
|
|
606
|
+
entityId = msg.payload.event.entity_id;
|
|
607
|
+
}
|
|
608
|
+
// 调试:记录 event 对象的结构
|
|
609
|
+
if (!newState) {
|
|
610
|
+
node.debug(`[HA输入] event对象结构: ${JSON.stringify(Object.keys(msg.payload.event))}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// 兼容格式:event.data 包含 new_state/old_state
|
|
614
|
+
if (!newState && msg.payload.event && msg.payload.event.data) {
|
|
615
|
+
newState = msg.payload.event.data.new_state;
|
|
616
|
+
oldState = msg.payload.event.data.old_state;
|
|
617
|
+
if (msg.payload.event.data.entity_id) {
|
|
618
|
+
entityId = msg.payload.event.data.entity_id;
|
|
619
|
+
}
|
|
570
620
|
}
|
|
571
621
|
}
|
|
572
622
|
// 格式2: server-state-changed 节点的标准格式 (msg.data)
|
|
@@ -594,6 +644,53 @@ module.exports = function(RED) {
|
|
|
594
644
|
newState = msg.payload;
|
|
595
645
|
oldState = null;
|
|
596
646
|
}
|
|
647
|
+
// 格式6: call_service 事件 - 专门处理窗帘控制,解决状态反馈慢的问题
|
|
648
|
+
else if (msg.payload && msg.payload.event_type === 'call_service') {
|
|
649
|
+
const event = msg.payload.event;
|
|
650
|
+
if (event && event.domain === 'cover' && event.service_data && event.service_data.entity_id) {
|
|
651
|
+
const entityId = event.service_data.entity_id;
|
|
652
|
+
const service = event.service;
|
|
653
|
+
|
|
654
|
+
node.log(`[HA] 捕获到窗帘服务调用: ${service} -> ${entityId}`);
|
|
655
|
+
|
|
656
|
+
const mappings = node.findMappingsByHa(entityId);
|
|
657
|
+
if (mappings.length > 0) {
|
|
658
|
+
const now = Date.now();
|
|
659
|
+
mappings.forEach(mapping => {
|
|
660
|
+
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
661
|
+
let syncData = null;
|
|
662
|
+
|
|
663
|
+
if (service === 'open_cover') {
|
|
664
|
+
node.coverMoving[loopKey] = { direction: 'ha', startTime: now };
|
|
665
|
+
syncData = { type: 'curtain_action', value: 1 };
|
|
666
|
+
} else if (service === 'close_cover') {
|
|
667
|
+
node.coverMoving[loopKey] = { direction: 'ha', startTime: now };
|
|
668
|
+
syncData = { type: 'curtain_action', value: 2 };
|
|
669
|
+
} else if (service === 'stop_cover') {
|
|
670
|
+
delete node.coverMoving[loopKey];
|
|
671
|
+
syncData = { type: 'curtain_stop', value: 3 };
|
|
672
|
+
} else if (service === 'set_cover_position') {
|
|
673
|
+
const pos = event.service_data.position;
|
|
674
|
+
if (pos !== undefined) {
|
|
675
|
+
node.coverMoving[loopKey] = { direction: 'ha', startTime: now };
|
|
676
|
+
syncData = { type: 'position', value: pos };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (syncData) {
|
|
681
|
+
node.queueCommand({
|
|
682
|
+
direction: 'ha-to-symi',
|
|
683
|
+
mapping: mapping,
|
|
684
|
+
syncData: syncData,
|
|
685
|
+
key: loopKey,
|
|
686
|
+
skipLoopCheck: true
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
597
694
|
|
|
598
695
|
// 过滤非 state_changed 事件和无效数据
|
|
599
696
|
if (!entityId || !newState) {
|
|
@@ -605,14 +702,45 @@ module.exports = function(RED) {
|
|
|
605
702
|
return;
|
|
606
703
|
}
|
|
607
704
|
|
|
705
|
+
// 过滤 sensor 类型的实体(传感器不需要同步控制)
|
|
706
|
+
if (entityId.startsWith('sensor.')) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
608
710
|
// 检查是否在映射列表中
|
|
609
711
|
const mappings = node.findMappingsByHa(entityId);
|
|
610
712
|
if (mappings.length === 0) {
|
|
611
713
|
return; // 不在映射中的实体静默忽略
|
|
612
714
|
}
|
|
613
715
|
|
|
614
|
-
//
|
|
615
|
-
|
|
716
|
+
// 检查状态是否有变化(避免 off -> off 这种无效处理)
|
|
717
|
+
const hasStateChange = !oldState || newState.state !== oldState.state;
|
|
718
|
+
const attrs = newState.attributes || {};
|
|
719
|
+
const oldAttrs = oldState ? (oldState.attributes || {}) : {};
|
|
720
|
+
const domain = entityId.split('.')[0];
|
|
721
|
+
|
|
722
|
+
// 根据实体类型检查相关属性变化
|
|
723
|
+
let hasAttrChange = false;
|
|
724
|
+
if (domain === 'light') {
|
|
725
|
+
hasAttrChange = attrs.brightness !== oldAttrs.brightness;
|
|
726
|
+
} else if (domain === 'cover') {
|
|
727
|
+
// cover需要检查位置变化,状态变化已经在hasStateChange中检查了
|
|
728
|
+
hasAttrChange = attrs.current_position !== oldAttrs.current_position;
|
|
729
|
+
} else if (domain === 'climate') {
|
|
730
|
+
hasAttrChange = attrs.temperature !== oldAttrs.temperature ||
|
|
731
|
+
attrs.hvac_mode !== oldAttrs.hvac_mode ||
|
|
732
|
+
attrs.fan_mode !== oldAttrs.fan_mode;
|
|
733
|
+
} else if (domain === 'fan') {
|
|
734
|
+
hasAttrChange = attrs.percentage !== oldAttrs.percentage ||
|
|
735
|
+
attrs.preset_mode !== oldAttrs.preset_mode;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// 如果状态和属性都没变化,完全跳过
|
|
739
|
+
if (!hasStateChange && !hasAttrChange) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 不在这里打印日志,让handleHaStateChange内部处理
|
|
616
744
|
node.handleHaStateChange(entityId, newState, oldState);
|
|
617
745
|
});
|
|
618
746
|
|
|
@@ -684,14 +812,38 @@ module.exports = function(RED) {
|
|
|
684
812
|
|
|
685
813
|
node.handleHaStateChange = function(entityId, newState, oldState) {
|
|
686
814
|
if (!newState) {
|
|
815
|
+
node.debug(`[HA->Symi] 忽略: ${entityId} newState为空`);
|
|
687
816
|
return;
|
|
688
817
|
}
|
|
689
818
|
|
|
819
|
+
// 如果新旧状态完全相同,跳过处理(避免无效同步)
|
|
820
|
+
if (oldState && newState.state === oldState.state) {
|
|
821
|
+
const attrs = newState.attributes || {};
|
|
822
|
+
const oldAttrs = oldState.attributes || {};
|
|
823
|
+
// 检查关键属性是否有变化
|
|
824
|
+
const hasAttrChange =
|
|
825
|
+
attrs.brightness !== oldAttrs.brightness ||
|
|
826
|
+
attrs.current_position !== oldAttrs.current_position ||
|
|
827
|
+
attrs.temperature !== oldAttrs.temperature ||
|
|
828
|
+
attrs.hvac_mode !== oldAttrs.hvac_mode ||
|
|
829
|
+
attrs.fan_mode !== oldAttrs.fan_mode ||
|
|
830
|
+
attrs.percentage !== oldAttrs.percentage;
|
|
831
|
+
|
|
832
|
+
if (!hasAttrChange) {
|
|
833
|
+
node.debug(`[HA->Symi] 忽略: ${entityId} 状态无变化`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
690
838
|
const mappings = node.findMappingsByHa(entityId);
|
|
691
839
|
if (mappings.length === 0) {
|
|
840
|
+
// 不在映射中的实体静默忽略,但记录调试信息
|
|
841
|
+
node.debug(`[HA->Symi] 实体不在映射中: ${entityId}`);
|
|
692
842
|
return;
|
|
693
843
|
}
|
|
694
844
|
|
|
845
|
+
node.debug(`[HA->Symi] 处理实体: ${entityId}, 找到 ${mappings.length} 个映射`);
|
|
846
|
+
|
|
695
847
|
const domain = node.getEntityDomain(entityId);
|
|
696
848
|
const attrs = newState.attributes || {};
|
|
697
849
|
const oldAttrs = oldState ? (oldState.attributes || {}) : {};
|
|
@@ -739,79 +891,82 @@ module.exports = function(RED) {
|
|
|
739
891
|
|
|
740
892
|
case 'cover':
|
|
741
893
|
const coverLoopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
break; // 忽略Symi发起运动期间的HA反馈
|
|
749
|
-
}
|
|
894
|
+
const now = Date.now();
|
|
895
|
+
|
|
896
|
+
// 检查锁定时长,超过40秒强制释放(针对无限位模块)
|
|
897
|
+
if (node.coverMoving[coverLoopKey] && (now - node.coverMoving[coverLoopKey].startTime > 40000)) {
|
|
898
|
+
node.log(`[HA->Symi] 窗帘控制锁定超时(40s),强制释放: ${coverLoopKey}`);
|
|
899
|
+
delete node.coverMoving[coverLoopKey];
|
|
750
900
|
}
|
|
901
|
+
|
|
902
|
+
// 1. 处理位置变化 (用户拖动滑块)
|
|
903
|
+
const hasPositionChange = attrs.current_position !== undefined &&
|
|
904
|
+
(!oldState || oldAttrs.current_position !== attrs.current_position);
|
|
751
905
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
node.
|
|
906
|
+
if (hasPositionChange) {
|
|
907
|
+
// HA 始终有控制优先权
|
|
908
|
+
node.log(`[HA->Symi] 窗帘位置控制: ${attrs.current_position}`);
|
|
909
|
+
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: now };
|
|
910
|
+
syncDataList.push({ type: 'position', value: attrs.current_position });
|
|
755
911
|
break;
|
|
756
912
|
}
|
|
757
913
|
|
|
758
|
-
//
|
|
759
|
-
if (attrs.current_position !== undefined) {
|
|
760
|
-
if (!oldState || oldAttrs.current_position !== attrs.current_position) {
|
|
761
|
-
// 标记HA发起的运动
|
|
762
|
-
node.coverMoving[coverLoopKey] = {
|
|
763
|
-
direction: 'ha',
|
|
764
|
-
startTime: Date.now(),
|
|
765
|
-
targetPosition: attrs.current_position
|
|
766
|
-
};
|
|
767
|
-
syncDataList.push({ type: 'position', value: attrs.current_position });
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// 窗帘动作 - open/closed 状态变化
|
|
914
|
+
// 2. 处理动作变化(用户点击按钮)
|
|
772
915
|
if (newState.state !== oldState?.state) {
|
|
773
|
-
if (newState.state === '
|
|
774
|
-
node.
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
916
|
+
if (newState.state === 'opening' || newState.state === 'closing') {
|
|
917
|
+
node.log(`[HA->Symi] 窗帘动作控制: ${newState.state}`);
|
|
918
|
+
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: now };
|
|
919
|
+
syncDataList.push({ type: 'curtain_action', value: newState.state === 'opening' ? 1 : 2 });
|
|
920
|
+
} else if (newState.state === 'idle' || newState.state === 'stopped') {
|
|
921
|
+
// 停止指令
|
|
922
|
+
node.log(`[HA->Symi] 窗帘停止控制`);
|
|
923
|
+
delete node.coverMoving[coverLoopKey];
|
|
924
|
+
syncDataList.push({ type: 'curtain_stop', value: 3 });
|
|
779
925
|
}
|
|
780
926
|
}
|
|
781
927
|
break;
|
|
782
928
|
|
|
783
929
|
case 'climate':
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
930
|
+
node.debug(`[HA->Symi] 空调状态: ${oldState?.state} -> ${newState.state}, 温度: ${attrs.temperature}, 模式: ${attrs.hvac_mode}, 风速: ${attrs.fan_mode}`);
|
|
931
|
+
|
|
932
|
+
// 开关状态 - 只在状态真正变化时同步
|
|
933
|
+
const isOn = newState.state !== 'off' && newState.state !== 'unavailable';
|
|
934
|
+
const wasOn = oldState ? (oldState.state !== 'off' && oldState.state !== 'unavailable') : null;
|
|
935
|
+
|
|
936
|
+
// 只有当开关状态真正变化时才同步(避免 off -> off 的无效同步)
|
|
937
|
+
if (wasOn !== null && isOn !== wasOn) {
|
|
938
|
+
node.debug(`[HA->Symi] 空调开关: ${wasOn} -> ${isOn}`);
|
|
939
|
+
syncDataList.push({ type: 'switch', value: isOn });
|
|
792
940
|
}
|
|
941
|
+
|
|
793
942
|
// 目标温度
|
|
794
943
|
if (attrs.temperature !== undefined) {
|
|
795
944
|
if (!oldState || oldAttrs.temperature !== attrs.temperature) {
|
|
945
|
+
node.debug(`[HA->Symi] 空调温度: ${oldAttrs.temperature} -> ${attrs.temperature}`);
|
|
796
946
|
syncDataList.push({ type: 'temperature', value: Math.round(attrs.temperature) });
|
|
797
947
|
}
|
|
798
948
|
}
|
|
799
|
-
|
|
800
|
-
//
|
|
801
|
-
if (newState.state !== 'off' &&
|
|
949
|
+
|
|
950
|
+
// HVAC模式 - 只在非off状态时同步
|
|
951
|
+
if (newState.state !== 'off' && newState.state !== 'unavailable') {
|
|
802
952
|
const hvacMode = attrs.hvac_mode || newState.state;
|
|
803
|
-
|
|
953
|
+
const oldHvacMode = oldAttrs.hvac_mode || oldState?.state;
|
|
954
|
+
|
|
955
|
+
if (hvacMode !== 'off' && hvacMode !== oldHvacMode) {
|
|
804
956
|
const meshMode = HA_TO_AC_MODE[hvacMode];
|
|
805
957
|
if (meshMode !== undefined) {
|
|
958
|
+
node.debug(`[HA->Symi] 空调模式: ${oldHvacMode} -> ${hvacMode} (mesh: ${meshMode})`);
|
|
806
959
|
syncDataList.push({ type: 'hvac_mode', value: meshMode });
|
|
807
960
|
}
|
|
808
961
|
}
|
|
809
962
|
}
|
|
963
|
+
|
|
810
964
|
// 风速
|
|
811
965
|
if (attrs.fan_mode !== undefined) {
|
|
812
966
|
if (!oldState || oldAttrs.fan_mode !== attrs.fan_mode) {
|
|
813
967
|
const meshFan = HA_TO_FAN_MODE[attrs.fan_mode];
|
|
814
968
|
if (meshFan !== undefined) {
|
|
969
|
+
node.debug(`[HA->Symi] 空调风速: ${oldAttrs.fan_mode} -> ${attrs.fan_mode} (mesh: ${meshFan})`);
|
|
815
970
|
syncDataList.push({ type: 'fan_mode', value: meshFan });
|
|
816
971
|
}
|
|
817
972
|
}
|
|
@@ -937,10 +1092,26 @@ module.exports = function(RED) {
|
|
|
937
1092
|
case 'position':
|
|
938
1093
|
service = 'set_cover_position';
|
|
939
1094
|
serviceData.position = syncData.value;
|
|
1095
|
+
// 刷新coverMoving状态,防止HA反馈被处理
|
|
1096
|
+
const posKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
1097
|
+
node.coverMoving[posKey] = { direction: 'symi', startTime: Date.now(), targetPosition: syncData.value };
|
|
1098
|
+
node.debug(`[Symi->HA] 窗帘位置同步,刷新coverMoving: ${posKey}`);
|
|
1099
|
+
break;
|
|
1100
|
+
|
|
1101
|
+
case 'curtain_action':
|
|
1102
|
+
// open/close 动作 - 确保标记为Symi发起的运动
|
|
1103
|
+
service = syncData.value === 'open' ? 'open_cover' : 'close_cover';
|
|
1104
|
+
// 刷新coverMoving状态,防止HA反馈被处理
|
|
1105
|
+
const coverKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
1106
|
+
node.coverMoving[coverKey] = { direction: 'symi', startTime: Date.now() };
|
|
1107
|
+
node.debug(`[Symi->HA] 窗帘动作同步,刷新coverMoving: ${coverKey}`);
|
|
940
1108
|
break;
|
|
941
1109
|
|
|
942
1110
|
case 'curtain_stop':
|
|
943
1111
|
service = 'stop_cover';
|
|
1112
|
+
// 刷新锁定状态,防止HA反馈
|
|
1113
|
+
const stopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
1114
|
+
node.coverMoving[stopKey] = { direction: 'symi', startTime: Date.now() };
|
|
944
1115
|
break;
|
|
945
1116
|
|
|
946
1117
|
case 'temperature':
|
|
@@ -970,7 +1141,7 @@ module.exports = function(RED) {
|
|
|
970
1141
|
return;
|
|
971
1142
|
}
|
|
972
1143
|
|
|
973
|
-
const serviceDomain = (syncData.type === 'position' || syncData.type === 'curtain_stop') ? 'cover' : domain;
|
|
1144
|
+
const serviceDomain = (syncData.type === 'position' || syncData.type === 'curtain_stop' || syncData.type === 'curtain_action') ? 'cover' : domain;
|
|
974
1145
|
|
|
975
1146
|
await axios.post(`${baseURL}/api/services/${serviceDomain}/${service}`, serviceData, {
|
|
976
1147
|
headers: {
|
|
@@ -1163,6 +1334,16 @@ module.exports = function(RED) {
|
|
|
1163
1334
|
node.coverMoving = {}; // 清理窗帘运动状态
|
|
1164
1335
|
node.brightnessMoving = {}; // 清理调光运动状态
|
|
1165
1336
|
|
|
1337
|
+
// 清理 SyncUtils
|
|
1338
|
+
if (node.syncUtils) {
|
|
1339
|
+
node.syncUtils.destroy();
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// 清理 StateCache(三合一状态缓存)
|
|
1343
|
+
if (node.stateCache) {
|
|
1344
|
+
node.stateCache.clear();
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1166
1347
|
if (gateway) {
|
|
1167
1348
|
gateway.removeListener('device-state-changed', node.handleSymiStateChange);
|
|
1168
1349
|
}
|