node-red-contrib-symi-mesh 1.8.4 → 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 +237 -1164
- 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 +2 -2
- package/nodes/symi-ha-sync.js +209 -147
- 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,11 @@
|
|
|
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
|
+
* - 优化三合一面板同步逻辑
|
|
4
9
|
*
|
|
5
10
|
* v1.8.4 更新:
|
|
6
11
|
* - 修复HA state_changed事件解析,支持更多消息格式
|
|
@@ -20,6 +25,7 @@
|
|
|
20
25
|
|
|
21
26
|
module.exports = function(RED) {
|
|
22
27
|
const axios = require('axios');
|
|
28
|
+
const { SyncUtils, StateCache, COVER_TIMEOUT, BRIGHTNESS_TIMEOUT } = require('../lib/sync-utils');
|
|
23
29
|
|
|
24
30
|
// 常量定义
|
|
25
31
|
const LOOP_PREVENTION_MS = 2000; // 防死循环时间窗口
|
|
@@ -29,11 +35,11 @@ module.exports = function(RED) {
|
|
|
29
35
|
const TIMESTAMP_EXPIRE_MS = 60000;
|
|
30
36
|
|
|
31
37
|
// 窗帘专用常量
|
|
32
|
-
const COVER_LOOP_PREVENTION_MS =
|
|
38
|
+
const COVER_LOOP_PREVENTION_MS = COVER_TIMEOUT; // 30秒防死循环
|
|
33
39
|
const COVER_DEBOUNCE_MS = 1500; // 1.5秒防抖
|
|
34
40
|
|
|
35
41
|
// 调光专用常量
|
|
36
|
-
const BRIGHTNESS_DEBOUNCE_MS =
|
|
42
|
+
const BRIGHTNESS_DEBOUNCE_MS = BRIGHTNESS_TIMEOUT; // 0.8秒防抖,过滤步进过程
|
|
37
43
|
|
|
38
44
|
// Mesh属性类型
|
|
39
45
|
const ATTR_SWITCH = 0x02;
|
|
@@ -68,6 +74,16 @@ module.exports = function(RED) {
|
|
|
68
74
|
node.mqttNode = RED.nodes.getNode(config.mqttConfig);
|
|
69
75
|
node.haServer = RED.nodes.getNode(config.haServer);
|
|
70
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
|
+
|
|
71
87
|
// 解析映射配置
|
|
72
88
|
try {
|
|
73
89
|
const rawMappings = JSON.parse(config.mappings || '[]');
|
|
@@ -185,25 +201,16 @@ module.exports = function(RED) {
|
|
|
185
201
|
}
|
|
186
202
|
}, CLEANUP_INTERVAL_MS);
|
|
187
203
|
|
|
188
|
-
// 防死循环检查 -
|
|
204
|
+
// 防死循环检查 - 使用通用 SyncUtils
|
|
189
205
|
node.shouldPreventSync = function(direction, key) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return (now - lastHaTime) < LOOP_PREVENTION_MS;
|
|
194
|
-
} else {
|
|
195
|
-
const lastSymiTime = node.lastSymiToHa[key] || 0;
|
|
196
|
-
return (now - lastSymiTime) < LOOP_PREVENTION_MS;
|
|
197
|
-
}
|
|
206
|
+
// 转换方向名称以匹配 SyncUtils 的格式
|
|
207
|
+
const syncDirection = direction === 'symi-to-ha' ? 'mesh-to-target' : 'target-to-mesh';
|
|
208
|
+
return node.syncUtils.shouldPreventSync(syncDirection, key);
|
|
198
209
|
};
|
|
199
210
|
|
|
200
211
|
node.recordSyncTime = function(direction, key) {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
node.lastSymiToHa[key] = now;
|
|
204
|
-
} else {
|
|
205
|
-
node.lastHaToSymi[key] = now;
|
|
206
|
-
}
|
|
212
|
+
const syncDirection = direction === 'symi-to-ha' ? 'mesh-to-target' : 'target-to-mesh';
|
|
213
|
+
node.syncUtils.recordSyncTime(syncDirection, key);
|
|
207
214
|
};
|
|
208
215
|
|
|
209
216
|
node.sleep = function(ms) {
|
|
@@ -314,23 +321,35 @@ module.exports = function(RED) {
|
|
|
314
321
|
// 支持返回数组(用于三合一同时同步多个属性)
|
|
315
322
|
const syncDataList = Array.isArray(syncData) ? syncData : [syncData];
|
|
316
323
|
|
|
317
|
-
syncDataList.forEach(data => {
|
|
318
|
-
//
|
|
319
|
-
const
|
|
320
|
-
(data.type === 'curtain_action' || data.type === 'curtain_stop'
|
|
324
|
+
syncDataList.forEach(async data => {
|
|
325
|
+
// 窗帘关键动作:开、关、停
|
|
326
|
+
const isImmediateAction = domain === 'cover' &&
|
|
327
|
+
(data.type === 'curtain_action' || data.type === 'curtain_stop');
|
|
321
328
|
|
|
322
|
-
|
|
329
|
+
// 窗帘位置同步(普通)
|
|
330
|
+
const isCoverPos = domain === 'cover' && data.type === 'position';
|
|
331
|
+
|
|
332
|
+
if (!isImmediateAction && !isCoverPos && node.shouldPreventSync('symi-to-ha', loopKey)) {
|
|
323
333
|
node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
|
|
324
334
|
return;
|
|
325
335
|
}
|
|
326
336
|
|
|
327
|
-
|
|
337
|
+
const cmd = {
|
|
328
338
|
direction: 'symi-to-ha',
|
|
329
339
|
mapping: mapping,
|
|
330
340
|
syncData: data,
|
|
331
341
|
key: loopKey,
|
|
332
|
-
skipLoopCheck:
|
|
333
|
-
}
|
|
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);
|
|
334
353
|
});
|
|
335
354
|
}
|
|
336
355
|
});
|
|
@@ -339,79 +358,54 @@ module.exports = function(RED) {
|
|
|
339
358
|
// 处理三合一状态变化
|
|
340
359
|
node.handleThreeInOneChange = function(device, mapping, state, attrType) {
|
|
341
360
|
const subType = mapping.symiKey; // 'aircon', 'fresh_air', 'floor_heating'
|
|
361
|
+
const mac = device.macAddress.toLowerCase().replace(/:/g, '');
|
|
362
|
+
const changedData = [];
|
|
342
363
|
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// 需要检查哪些属性变了,但这里只能返回一个syncData
|
|
350
|
-
// 我们可以返回一个特殊对象,或者分别检查
|
|
351
|
-
// 为简化,这里假设HA端会处理部分更新,或者我们按优先级返回
|
|
352
|
-
|
|
353
|
-
// 检查开关
|
|
354
|
-
if (state.climateSwitch !== undefined) {
|
|
355
|
-
// 注意:这里需要比对旧状态,但在handleSymiStateChange中难以获取旧状态
|
|
356
|
-
// 我们可以利用node.queueCommand的去重机制,发送所有可能的状态
|
|
357
|
-
// 但这样会产生大量流量。
|
|
358
|
-
// 实际上DeviceManager触发事件时,如果是0x94,是全量更新。
|
|
359
|
-
// 我们可以只处理核心属性。
|
|
360
|
-
// 更好的方式是:在device-manager中,0x94更新会触发一次事件。
|
|
361
|
-
// 这里我们返回一个复合对象,或者由上层逻辑拆分。
|
|
362
|
-
// 由于syncData只能是一个对象,我们优先同步开关,然后是模式/温度
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
// 由于0x94更新时,device-manager已经更新了state
|
|
366
|
-
// 我们可以直接从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}`;
|
|
367
370
|
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
{
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 });
|
|
377
389
|
}
|
|
378
390
|
|
|
379
391
|
// 2. 新风部分
|
|
380
|
-
if (subType === 'fresh_air') {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
// 新风风速 (0x6A或0x94)
|
|
388
|
-
if (attrType === ATTR_FRESH_AIR_SPEED || attrType === ATTR_THREE_IN_ONE) {
|
|
389
|
-
if (state.freshAirSpeed !== undefined) {
|
|
390
|
-
return { type: 'fan_mode', value: FAN_MODE_TO_HA[state.freshAirSpeed] || 'auto', meshValue: state.freshAirSpeed };
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
// 新风模式 (0x69或0x94)
|
|
394
|
-
// 目前HA Fan实体通常只支持on/off和speed,mode可能不支持或映射到preset_mode
|
|
395
|
-
// 暂时忽略模式,或视需求添加
|
|
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 });
|
|
396
397
|
}
|
|
397
398
|
|
|
398
399
|
// 3. 地暖部分
|
|
399
|
-
if (subType === 'floor_heating') {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
// 地暖温度 (0x6C或0x94)
|
|
407
|
-
if (attrType === ATTR_FLOOR_HEATING_TEMP || attrType === ATTR_THREE_IN_ONE) {
|
|
408
|
-
if (state.floorHeatingTemp !== undefined) {
|
|
409
|
-
return { type: 'temperature', value: state.floorHeatingTemp };
|
|
410
|
-
}
|
|
411
|
-
}
|
|
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 });
|
|
412
405
|
}
|
|
413
406
|
|
|
414
|
-
|
|
407
|
+
// 只返回真正变化的属性
|
|
408
|
+
return changedData.length > 0 ? changedData : null;
|
|
415
409
|
};
|
|
416
410
|
|
|
417
411
|
// 处理开关状态变化
|
|
@@ -475,56 +469,75 @@ module.exports = function(RED) {
|
|
|
475
469
|
};
|
|
476
470
|
|
|
477
471
|
// 处理窗帘变化 - 谁发起控制就只听谁的命令
|
|
478
|
-
// Mesh
|
|
472
|
+
// Mesh控制时只同步最终位置,忽略过程中的实时反馈
|
|
479
473
|
node.handleCurtainChange = function(device, mapping, state, attrType) {
|
|
480
474
|
const domain = node.getEntityDomain(mapping.haEntityId);
|
|
481
475
|
if (domain !== 'cover') return null;
|
|
482
476
|
|
|
483
477
|
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
node.log(`[Symi->HA] 窗帘stopped, 释放HA控制权`);
|
|
491
|
-
setTimeout(() => {
|
|
492
|
-
delete node.coverMoving[loopKey];
|
|
493
|
-
}, 5000);
|
|
494
|
-
}
|
|
495
|
-
node.debug(`[Symi->HA] 窗帘忽略(HA控制中): ${JSON.stringify(state)}`);
|
|
496
|
-
return null;
|
|
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];
|
|
497
484
|
}
|
|
485
|
+
|
|
486
|
+
// 获取动作状态
|
|
487
|
+
const action = state.curtainAction || device.state.curtainAction;
|
|
498
488
|
|
|
499
|
-
//
|
|
489
|
+
// 1. 处理运行状态变化 (ATTR_CURTAIN_STATUS)
|
|
500
490
|
if (attrType === ATTR_CURTAIN_STATUS) {
|
|
501
|
-
const action = state.curtainAction || device.state.curtainAction;
|
|
502
|
-
|
|
503
491
|
if (action === 'opening' || action === 'closing') {
|
|
504
|
-
//
|
|
505
|
-
node.
|
|
506
|
-
|
|
507
|
-
|
|
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
|
+
}
|
|
508
511
|
return null;
|
|
509
512
|
}
|
|
510
513
|
|
|
511
514
|
if (action === 'stopped') {
|
|
512
|
-
|
|
513
|
-
node.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
515
|
+
node.log(`[Symi->HA] 窗帘已停止,释放控制权: ${loopKey}`);
|
|
516
|
+
delete node.coverMoving[loopKey];
|
|
517
|
+
|
|
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
|
+
}
|
|
518
524
|
}
|
|
519
|
-
|
|
520
525
|
return null;
|
|
521
526
|
}
|
|
522
527
|
|
|
523
|
-
//
|
|
528
|
+
// 2. 处理位置变化 (ATTR_CURTAIN_POSITION)
|
|
524
529
|
if (attrType === ATTR_CURTAIN_POSITION) {
|
|
525
530
|
const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
|
|
526
531
|
if (position === undefined) return null;
|
|
527
|
-
|
|
532
|
+
|
|
533
|
+
// 核心需求:忽略过程中的实时反馈指令
|
|
534
|
+
// 如果当前有任何控制方向的锁定(正在移动中),则忽略位置反馈
|
|
535
|
+
if (node.coverMoving[loopKey]) {
|
|
536
|
+
node.debug(`[Symi->HA] 忽略移动中的实时位置反馈: ${position}%`);
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 没有移动锁定时,允许同步(例如初始同步或查询返回)
|
|
528
541
|
return { type: 'position', value: position };
|
|
529
542
|
}
|
|
530
543
|
|
|
@@ -631,15 +644,52 @@ module.exports = function(RED) {
|
|
|
631
644
|
newState = msg.payload;
|
|
632
645
|
oldState = null;
|
|
633
646
|
}
|
|
634
|
-
// 格式6: call_service 事件 -
|
|
647
|
+
// 格式6: call_service 事件 - 专门处理窗帘控制,解决状态反馈慢的问题
|
|
635
648
|
else if (msg.payload && msg.payload.event_type === 'call_service') {
|
|
636
649
|
const event = msg.payload.event;
|
|
637
|
-
if (event && event.service_data && event.service_data.entity_id) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
+
}
|
|
641
691
|
}
|
|
642
|
-
return;
|
|
692
|
+
return;
|
|
643
693
|
}
|
|
644
694
|
|
|
645
695
|
// 过滤非 state_changed 事件和无效数据
|
|
@@ -841,39 +891,38 @@ module.exports = function(RED) {
|
|
|
841
891
|
|
|
842
892
|
case 'cover':
|
|
843
893
|
const coverLoopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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];
|
|
849
900
|
}
|
|
850
|
-
|
|
851
|
-
//
|
|
901
|
+
|
|
902
|
+
// 1. 处理位置变化 (用户拖动滑块)
|
|
852
903
|
const hasPositionChange = attrs.current_position !== undefined &&
|
|
853
904
|
(!oldState || oldAttrs.current_position !== attrs.current_position);
|
|
854
905
|
|
|
855
|
-
// 优先处理位置变化(用户拖动滑块)- 这是HA主动控制
|
|
856
906
|
if (hasPositionChange) {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime:
|
|
907
|
+
// HA 始终有控制优先权
|
|
908
|
+
node.log(`[HA->Symi] 窗帘位置控制: ${attrs.current_position}`);
|
|
909
|
+
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: now };
|
|
860
910
|
syncDataList.push({ type: 'position', value: attrs.current_position });
|
|
861
911
|
break;
|
|
862
912
|
}
|
|
863
913
|
|
|
864
|
-
//
|
|
865
|
-
// 不处理open/closed(这是状态反馈,不是用户操作)
|
|
914
|
+
// 2. 处理动作变化(用户点击按钮)
|
|
866
915
|
if (newState.state !== oldState?.state) {
|
|
867
|
-
if (newState.state === 'opening') {
|
|
868
|
-
node.log(`[HA->Symi]
|
|
869
|
-
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime:
|
|
870
|
-
syncDataList.push({ type: 'curtain_action', value: '
|
|
871
|
-
} else if (newState.state === '
|
|
872
|
-
|
|
873
|
-
node.
|
|
874
|
-
|
|
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 });
|
|
875
925
|
}
|
|
876
|
-
// open/closed 是最终状态,不是动作,不需要同步
|
|
877
926
|
}
|
|
878
927
|
break;
|
|
879
928
|
|
|
@@ -1060,6 +1109,9 @@ module.exports = function(RED) {
|
|
|
1060
1109
|
|
|
1061
1110
|
case 'curtain_stop':
|
|
1062
1111
|
service = 'stop_cover';
|
|
1112
|
+
// 刷新锁定状态,防止HA反馈
|
|
1113
|
+
const stopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
1114
|
+
node.coverMoving[stopKey] = { direction: 'symi', startTime: Date.now() };
|
|
1063
1115
|
break;
|
|
1064
1116
|
|
|
1065
1117
|
case 'temperature':
|
|
@@ -1282,6 +1334,16 @@ module.exports = function(RED) {
|
|
|
1282
1334
|
node.coverMoving = {}; // 清理窗帘运动状态
|
|
1283
1335
|
node.brightnessMoving = {}; // 清理调光运动状态
|
|
1284
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
|
+
|
|
1285
1347
|
if (gateway) {
|
|
1286
1348
|
gateway.removeListener('device-state-changed', node.handleSymiStateChange);
|
|
1287
1349
|
}
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
label: function() { return this.name || 'KNX桥接'; },
|
|
18
18
|
oneditprepare: function() {
|
|
19
19
|
const node = this;
|
|
20
|
+
|
|
21
|
+
// 设置编辑面板更宽更高
|
|
22
|
+
var panel = $('#dialog-form').parent();
|
|
23
|
+
if (panel.length) {
|
|
24
|
+
if (panel.width() < 1000) panel.css('width', '1000px');
|
|
25
|
+
if (panel.height() < 1000) panel.css('min-height', '1000px');
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
let mappings = [], devices = [], knxEntities = [];
|
|
21
29
|
const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖'};
|
|
22
30
|
|
package/nodes/symi-knx-bridge.js
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
* 支持开关、窗帘等设备的双向状态同步
|
|
4
4
|
* 事件驱动架构,命令队列顺序处理,防死循环机制
|
|
5
5
|
*
|
|
6
|
-
* 版本: 1.
|
|
6
|
+
* 版本: 1.8.5
|
|
7
|
+
*
|
|
8
|
+
* v1.8.5 更新:
|
|
9
|
+
* - 使用通用 SyncUtils 类统一防环路逻辑
|
|
10
|
+
* - 移除本地 LOOP_PREVENTION_MS 常量,使用 SyncUtils.getTimeoutForDevice()
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
module.exports = function(RED) {
|
|
14
|
+
const { SyncUtils, COVER_TIMEOUT, BRIGHTNESS_TIMEOUT, DEFAULT_TIMEOUT } = require('../lib/sync-utils');
|
|
10
15
|
|
|
11
16
|
// 设备类型配置
|
|
12
17
|
const DEVICE_TYPES = {
|
|
@@ -147,12 +152,16 @@ module.exports = function(RED) {
|
|
|
147
152
|
node.lastSyncTime = 0;
|
|
148
153
|
node.stateCache = {}; // Mesh设备状态缓存
|
|
149
154
|
node.knxStateCache = {}; // KNX设备状态缓存
|
|
150
|
-
node.lastMeshToKnx = {}; // 记录Mesh->KNX发送时间,防止回环
|
|
151
|
-
node.lastKnxToMesh = {}; // 记录KNX->Mesh发送时间,防止回环
|
|
152
155
|
node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间,防止自己发的命令又被处理
|
|
153
156
|
|
|
154
|
-
//
|
|
155
|
-
|
|
157
|
+
// 初始化通用同步工具类
|
|
158
|
+
node.syncUtils = new SyncUtils({
|
|
159
|
+
defaultTimeout: DEFAULT_TIMEOUT,
|
|
160
|
+
coverTimeout: COVER_TIMEOUT,
|
|
161
|
+
brightnessTimeout: BRIGHTNESS_TIMEOUT
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// 防死循环参数(使用 SyncUtils 的超时配置)
|
|
156
165
|
const DEBOUNCE_MS = 100; // 100ms防抖
|
|
157
166
|
const MAX_QUEUE_SIZE = 100; // 最大队列大小
|
|
158
167
|
const COVER_CONTROL_LOCK_MS = 3000; // 窗帘/调光控制锁定3秒(只忽略过程反馈,不长期锁定)
|
|
@@ -226,26 +235,17 @@ module.exports = function(RED) {
|
|
|
226
235
|
return str;
|
|
227
236
|
};
|
|
228
237
|
|
|
229
|
-
//
|
|
238
|
+
// 检查是否应该阻止同步(防死循环)- 使用通用 SyncUtils
|
|
230
239
|
node.shouldPreventSync = function(direction, key) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return (now - lastKnxTime) < LOOP_PREVENTION_MS;
|
|
235
|
-
} else {
|
|
236
|
-
const lastMeshTime = node.lastMeshToKnx[key] || 0;
|
|
237
|
-
return (now - lastMeshTime) < LOOP_PREVENTION_MS;
|
|
238
|
-
}
|
|
240
|
+
// 转换方向名称以匹配 SyncUtils 的格式
|
|
241
|
+
const syncDirection = direction === 'mesh-to-knx' ? 'mesh-to-target' : 'target-to-mesh';
|
|
242
|
+
return node.syncUtils.shouldPreventSync(syncDirection, key);
|
|
239
243
|
};
|
|
240
244
|
|
|
241
|
-
//
|
|
245
|
+
// 记录同步时间(用于防死循环)- 使用通用 SyncUtils
|
|
242
246
|
node.recordSyncTime = function(direction, key) {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
node.lastMeshToKnx[key] = now;
|
|
246
|
-
} else {
|
|
247
|
-
node.lastKnxToMesh[key] = now;
|
|
248
|
-
}
|
|
247
|
+
const syncDirection = direction === 'mesh-to-knx' ? 'mesh-to-target' : 'target-to-mesh';
|
|
248
|
+
node.syncUtils.recordSyncTime(syncDirection, key);
|
|
249
249
|
};
|
|
250
250
|
|
|
251
251
|
// 延时函数
|
|
@@ -953,7 +953,7 @@ module.exports = function(RED) {
|
|
|
953
953
|
|
|
954
954
|
// 检查是否是我们自己发出去的命令(防止自己发的命令被监听后又处理)
|
|
955
955
|
const lastSentTime = node.lastKnxAddrSent[groupAddr] || 0;
|
|
956
|
-
if (Date.now() - lastSentTime <
|
|
956
|
+
if (Date.now() - lastSentTime < DEFAULT_TIMEOUT) {
|
|
957
957
|
node.debug(`[KNX输入] 跳过(自己发的): ${groupAddr}`);
|
|
958
958
|
done && done();
|
|
959
959
|
return;
|
|
@@ -1157,12 +1157,15 @@ module.exports = function(RED) {
|
|
|
1157
1157
|
node.gateway.removeListener('scene-executed', handleSceneExecuted);
|
|
1158
1158
|
}
|
|
1159
1159
|
|
|
1160
|
+
// 销毁 SyncUtils 实例,清理资源
|
|
1161
|
+
if (node.syncUtils) {
|
|
1162
|
+
node.syncUtils.destroy();
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1160
1165
|
// 清空队列和缓存
|
|
1161
1166
|
node.commandQueue = [];
|
|
1162
1167
|
node.stateCache = {};
|
|
1163
1168
|
node.knxStateCache = {};
|
|
1164
|
-
node.lastMeshToKnx = {};
|
|
1165
|
-
node.lastKnxToMesh = {};
|
|
1166
1169
|
|
|
1167
1170
|
node.status({});
|
|
1168
1171
|
done();
|
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
oneditprepare: function() {
|
|
21
21
|
var node = this;
|
|
22
22
|
|
|
23
|
+
// 设置编辑面板更宽更高
|
|
24
|
+
var panel = $('#dialog-form').parent();
|
|
25
|
+
if (panel.length) {
|
|
26
|
+
if (panel.width() < 1000) panel.css('width', '1000px');
|
|
27
|
+
if (panel.height() < 1000) panel.css('min-height', '1000px');
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
// 品牌切换
|
|
24
31
|
function updateBrandUI() {
|
|
25
32
|
var brand = $('#node-config-input-brand').val();
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
// 设置编辑面板更宽更高
|
|
37
37
|
var panel = $('#dialog-form').parent();
|
|
38
38
|
if (panel.length) {
|
|
39
|
-
if (panel.width() <
|
|
40
|
-
if (panel.height() <
|
|
39
|
+
if (panel.width() < 1000) panel.css('width', '1000px');
|
|
40
|
+
if (panel.height() < 1000) panel.css('min-height', '1000px');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
var deviceTypes = {
|