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-mqtt-sync.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MQTT同步节点 - 实现第三方MQTT品牌设备与Mesh设备的双向同步
|
|
3
|
+
* 版本: 1.8.5
|
|
4
|
+
*
|
|
5
|
+
* v1.8.5 更新:
|
|
6
|
+
* - 使用通用 SyncUtils 类统一防环路逻辑
|
|
7
|
+
* - 添加三合一面板子实体同步支持 (空调/新风/地暖)
|
|
8
|
+
* - 支持 meshSubEntity 映射字段
|
|
3
9
|
*
|
|
4
10
|
* 架构设计:
|
|
5
11
|
* - 使用symi-mqtt配置节点共享MQTT连接(与Mesh网关MQTT一致)
|
|
@@ -11,6 +17,7 @@
|
|
|
11
17
|
|
|
12
18
|
module.exports = function(RED) {
|
|
13
19
|
const mqtt = require('mqtt');
|
|
20
|
+
const { SyncUtils, THREE_IN_ONE_SUB_ENTITIES, getSubEntityByHyqwType } = require('../lib/sync-utils');
|
|
14
21
|
|
|
15
22
|
// ===== 常量定义 =====
|
|
16
23
|
const SYNC_DEBOUNCE_MS = 2000;
|
|
@@ -110,6 +117,11 @@ module.exports = function(RED) {
|
|
|
110
117
|
node._cleanupTimer = null;
|
|
111
118
|
node._closing = false;
|
|
112
119
|
node._lastErrorLog = 0; // 错误日志限流
|
|
120
|
+
|
|
121
|
+
// 初始化通用同步工具
|
|
122
|
+
node._syncUtils = new SyncUtils({
|
|
123
|
+
defaultTimeout: SYNC_DEBOUNCE_MS
|
|
124
|
+
});
|
|
113
125
|
|
|
114
126
|
if (!node._mqttConfig) {
|
|
115
127
|
node.status({ fill: 'red', shape: 'ring', text: '未选择Mesh MQTT' });
|
|
@@ -263,36 +275,41 @@ module.exports = function(RED) {
|
|
|
263
275
|
function syncToMesh(deviceType, deviceId, fn, fv) {
|
|
264
276
|
if (!node._gateway?.deviceManager) return;
|
|
265
277
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
278
|
+
// 查找映射 - 支持三合一子实体
|
|
279
|
+
const mapping = node.mappings.find(m => {
|
|
280
|
+
const typeMatch = parseInt(m.brandDeviceType) === deviceType;
|
|
281
|
+
const idMatch = parseInt(m.brandDeviceId) === deviceId;
|
|
282
|
+
return typeMatch && idMatch;
|
|
283
|
+
});
|
|
269
284
|
if (!mapping || !mapping.meshMac) return;
|
|
270
285
|
|
|
271
286
|
const meshMac = mapping.meshMac;
|
|
272
287
|
const meshChannel = parseInt(mapping.meshChannel) || 1;
|
|
288
|
+
const meshSubEntity = mapping.meshSubEntity; // 三合一子实体类型
|
|
273
289
|
|
|
274
|
-
//
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const lastMeshSync = node._syncTimestamps.get(meshSyncKey) || 0;
|
|
280
|
-
|
|
281
|
-
// 如果任一方向在防抖时间内有同步,跳过
|
|
282
|
-
if (now - lastMqttSync < SYNC_DEBOUNCE_MS || now - lastMeshSync < SYNC_DEBOUNCE_MS) return;
|
|
290
|
+
// 使用 SyncUtils 防环路
|
|
291
|
+
const syncKey = `${meshMac}_${meshChannel}_${meshSubEntity || 'default'}_${fn}`;
|
|
292
|
+
if (node._syncUtils.shouldPreventSync('target-to-mesh', syncKey)) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
283
295
|
|
|
284
296
|
const device = node._gateway.deviceManager.getDeviceByMac(meshMac);
|
|
285
297
|
if (!device) return;
|
|
286
298
|
|
|
287
|
-
//
|
|
288
|
-
node.
|
|
289
|
-
node._syncTimestamps.set(meshSyncKey, now);
|
|
299
|
+
// 记录同步时间
|
|
300
|
+
node._syncUtils.recordSyncTime('target-to-mesh', syncKey);
|
|
290
301
|
|
|
291
302
|
const typeInfo = node._brandProtocol.deviceTypes[deviceType];
|
|
292
303
|
if (!typeInfo) return;
|
|
293
304
|
|
|
294
305
|
let property = '', value = null;
|
|
295
306
|
try {
|
|
307
|
+
// 三合一子实体特殊处理
|
|
308
|
+
if (meshSubEntity && THREE_IN_ONE_SUB_ENTITIES[meshSubEntity]) {
|
|
309
|
+
syncThreeInOneToMesh(device, meshMac, meshSubEntity, fn, fv);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
296
313
|
// 窗帘设备特殊处理 (typeId=14)
|
|
297
314
|
if (deviceType === 14) {
|
|
298
315
|
if (fn === 1) {
|
|
@@ -392,11 +409,88 @@ module.exports = function(RED) {
|
|
|
392
409
|
node.warn(`[Brand→Mesh] 同步失败: ${e.message}`);
|
|
393
410
|
}
|
|
394
411
|
}
|
|
412
|
+
|
|
413
|
+
// ===== 三合一子实体同步到Mesh =====
|
|
414
|
+
function syncThreeInOneToMesh(device, meshMac, subEntity, fn, fv) {
|
|
415
|
+
const subConfig = THREE_IN_ONE_SUB_ENTITIES[subEntity];
|
|
416
|
+
if (!subConfig) return;
|
|
417
|
+
|
|
418
|
+
let property = '', value = null;
|
|
419
|
+
|
|
420
|
+
// 根据子实体类型和功能码处理
|
|
421
|
+
if (subEntity === 'aircon') {
|
|
422
|
+
// 空调子实体
|
|
423
|
+
if (fn === 1) {
|
|
424
|
+
property = 'climateSwitch'; value = fv === 1;
|
|
425
|
+
} else if (fn === 2) {
|
|
426
|
+
property = 'targetTemp'; value = fv;
|
|
427
|
+
} else if (fn === 3) {
|
|
428
|
+
property = 'climateMode'; value = AC_MODE_MAP[fv] || 'cool';
|
|
429
|
+
} else if (fn === 4) {
|
|
430
|
+
property = 'fanMode'; value = FAN_SPEED_MAP[fv] || 'auto';
|
|
431
|
+
}
|
|
432
|
+
} else if (subEntity === 'fresh_air') {
|
|
433
|
+
// 新风子实体
|
|
434
|
+
if (fn === 1) {
|
|
435
|
+
property = 'freshAirSwitch'; value = fv === 1;
|
|
436
|
+
} else if (fn === 3) {
|
|
437
|
+
property = 'freshAirSpeed'; value = FAN_SPEED_MAP[fv] || 'auto';
|
|
438
|
+
}
|
|
439
|
+
} else if (subEntity === 'floor_heating') {
|
|
440
|
+
// 地暖子实体
|
|
441
|
+
if (fn === 1) {
|
|
442
|
+
property = 'floorHeatingSwitch'; value = fv === 1;
|
|
443
|
+
} else if (fn === 2) {
|
|
444
|
+
property = 'floorHeatingTemp'; value = fv;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (property && node._gateway) {
|
|
449
|
+
// 使用网关发送三合一控制命令
|
|
450
|
+
const attrMap = {
|
|
451
|
+
'climateSwitch': 0x02,
|
|
452
|
+
'targetTemp': 0x1B,
|
|
453
|
+
'climateMode': 0x1D,
|
|
454
|
+
'fanMode': 0x1C,
|
|
455
|
+
'freshAirSwitch': 0x68,
|
|
456
|
+
'freshAirSpeed': 0x6A,
|
|
457
|
+
'floorHeatingSwitch': 0x6B,
|
|
458
|
+
'floorHeatingTemp': 0x6C
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const attrType = attrMap[property];
|
|
462
|
+
if (attrType && device.networkAddress) {
|
|
463
|
+
let param;
|
|
464
|
+
if (property.includes('Switch')) {
|
|
465
|
+
param = [value ? 0x02 : 0x01];
|
|
466
|
+
} else if (property.includes('Temp')) {
|
|
467
|
+
param = [value];
|
|
468
|
+
} else if (property.includes('Mode') || property.includes('Speed')) {
|
|
469
|
+
const reverseMap = { auto: 4, low: 3, medium: 2, high: 1, cool: 1, heat: 2, fan_only: 3, dry: 4 };
|
|
470
|
+
param = [reverseMap[value] || 1];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (param) {
|
|
474
|
+
node._gateway.sendControl(device.networkAddress, attrType, param);
|
|
475
|
+
node.send({
|
|
476
|
+
topic: 'mqtt-sync/brand-to-mesh',
|
|
477
|
+
payload: {
|
|
478
|
+
direction: 'Brand→Mesh(三合一)',
|
|
479
|
+
subEntity, property, value,
|
|
480
|
+
meshMac, attrType: `0x${attrType.toString(16)}`,
|
|
481
|
+
timestamp: Date.now()
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
395
488
|
|
|
396
489
|
// ===== 同步到MQTT =====
|
|
397
490
|
function syncToMqtt(meshMac, channel, property, value) {
|
|
398
491
|
if (!node._connected || !node._mqttClient) return;
|
|
399
492
|
|
|
493
|
+
// 查找映射 - 支持三合一子实体
|
|
400
494
|
const mapping = node.mappings.find(m =>
|
|
401
495
|
m.meshMac?.toLowerCase() === meshMac?.toLowerCase() &&
|
|
402
496
|
parseInt(m.meshChannel) === channel
|
|
@@ -405,14 +499,28 @@ module.exports = function(RED) {
|
|
|
405
499
|
|
|
406
500
|
const deviceType = parseInt(mapping.brandDeviceType);
|
|
407
501
|
const deviceId = parseInt(mapping.brandDeviceId);
|
|
502
|
+
const meshSubEntity = mapping.meshSubEntity; // 三合一子实体类型
|
|
408
503
|
const typeInfo = node._brandProtocol.deviceTypes[deviceType];
|
|
409
504
|
if (!typeInfo) return;
|
|
410
505
|
|
|
506
|
+
// 使用 SyncUtils 防环路
|
|
507
|
+
const syncKey = `${meshMac}_${channel}_${meshSubEntity || 'default'}_${property}`;
|
|
508
|
+
if (node._syncUtils.shouldPreventSync('mesh-to-target', syncKey)) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
411
512
|
// 根据设备类型和属性确定功能码
|
|
412
513
|
let fn, fv;
|
|
413
514
|
|
|
515
|
+
// 三合一子实体特殊处理
|
|
516
|
+
if (meshSubEntity && THREE_IN_ONE_SUB_ENTITIES[meshSubEntity]) {
|
|
517
|
+
const result = getThreeInOneFnFv(meshSubEntity, property, value);
|
|
518
|
+
if (!result) return;
|
|
519
|
+
fn = result.fn;
|
|
520
|
+
fv = result.fv;
|
|
521
|
+
}
|
|
414
522
|
// 窗帘设备特殊处理 (typeId=14)
|
|
415
|
-
if (deviceType === 14) {
|
|
523
|
+
else if (deviceType === 14) {
|
|
416
524
|
if (property === 'curtainAction' || property === 'action') {
|
|
417
525
|
fn = 1;
|
|
418
526
|
if (value === 'open' || value === 1) fv = 1;
|
|
@@ -440,7 +548,7 @@ module.exports = function(RED) {
|
|
|
440
548
|
}
|
|
441
549
|
// 空调设备 (typeId=12)
|
|
442
550
|
else if (deviceType === 12) {
|
|
443
|
-
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
551
|
+
if (property === 'switch' || property === 'on' || property === 'isOn' || property === 'climateSwitch') {
|
|
444
552
|
fn = 1;
|
|
445
553
|
fv = value ? 1 : 0;
|
|
446
554
|
} else if (property === 'temperature' || property === 'targetTemp') {
|
|
@@ -458,10 +566,10 @@ module.exports = function(RED) {
|
|
|
458
566
|
}
|
|
459
567
|
// 地暖设备 (typeId=16)
|
|
460
568
|
else if (deviceType === 16) {
|
|
461
|
-
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
569
|
+
if (property === 'switch' || property === 'on' || property === 'isOn' || property === 'floorHeatingSwitch') {
|
|
462
570
|
fn = 1;
|
|
463
571
|
fv = value ? 1 : 0;
|
|
464
|
-
} else if (property === 'temperature' || property === 'targetTemp') {
|
|
572
|
+
} else if (property === 'temperature' || property === 'targetTemp' || property === 'floorHeatingTemp') {
|
|
465
573
|
fn = 2;
|
|
466
574
|
fv = parseInt(value) || 20;
|
|
467
575
|
} else {
|
|
@@ -470,10 +578,10 @@ module.exports = function(RED) {
|
|
|
470
578
|
}
|
|
471
579
|
// 新风设备 (typeId=36)
|
|
472
580
|
else if (deviceType === 36) {
|
|
473
|
-
if (property === 'switch' || property === 'on' || property === 'isOn') {
|
|
581
|
+
if (property === 'switch' || property === 'on' || property === 'isOn' || property === 'freshAirSwitch') {
|
|
474
582
|
fn = 1;
|
|
475
583
|
fv = value ? 1 : 0;
|
|
476
|
-
} else if (property === 'fanSpeed' || property === 'fanMode') {
|
|
584
|
+
} else if (property === 'fanSpeed' || property === 'fanMode' || property === 'freshAirSpeed') {
|
|
477
585
|
fn = 3;
|
|
478
586
|
fv = FAN_SPEED_REVERSE[value] ?? 0;
|
|
479
587
|
} else {
|
|
@@ -490,19 +598,8 @@ module.exports = function(RED) {
|
|
|
490
598
|
}
|
|
491
599
|
}
|
|
492
600
|
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
const meshSyncKey = `mesh_${meshMac}_${channel}_${fn}`;
|
|
496
|
-
const mqttSyncKey = `mqtt_${deviceType}_${deviceId}_${fn}`;
|
|
497
|
-
const lastMeshSync = node._syncTimestamps.get(meshSyncKey) || 0;
|
|
498
|
-
const lastMqttSync = node._syncTimestamps.get(mqttSyncKey) || 0;
|
|
499
|
-
|
|
500
|
-
// 如果任一方向在防抖时间内有同步,跳过
|
|
501
|
-
if (now - lastMeshSync < SYNC_DEBOUNCE_MS || now - lastMqttSync < SYNC_DEBOUNCE_MS) return;
|
|
502
|
-
|
|
503
|
-
// 记录时间戳 - 同时标记两个方向
|
|
504
|
-
node._syncTimestamps.set(meshSyncKey, now);
|
|
505
|
-
node._syncTimestamps.set(mqttSyncKey, now);
|
|
601
|
+
// 记录同步时间
|
|
602
|
+
node._syncUtils.recordSyncTime('mesh-to-target', syncKey);
|
|
506
603
|
|
|
507
604
|
const topic = node._brandProtocol.getDownTopic(node);
|
|
508
605
|
const payload = node._brandProtocol.buildMessage(deviceType, deviceId, fn, fv);
|
|
@@ -516,6 +613,7 @@ module.exports = function(RED) {
|
|
|
516
613
|
payload: {
|
|
517
614
|
direction: 'Mesh→Brand',
|
|
518
615
|
meshMac, channel, property, value,
|
|
616
|
+
meshSubEntity: meshSubEntity || null,
|
|
519
617
|
brandType: deviceType, brandId: deviceId, fn, fv,
|
|
520
618
|
timestamp: Date.now()
|
|
521
619
|
}
|
|
@@ -524,6 +622,34 @@ module.exports = function(RED) {
|
|
|
524
622
|
node.warn(`[Mesh→Brand] 同步失败: ${e.message}`);
|
|
525
623
|
}
|
|
526
624
|
}
|
|
625
|
+
|
|
626
|
+
// ===== 获取三合一子实体的功能码和值 =====
|
|
627
|
+
function getThreeInOneFnFv(subEntity, property, value) {
|
|
628
|
+
if (subEntity === 'aircon') {
|
|
629
|
+
if (property === 'climateSwitch' || property === 'switch') {
|
|
630
|
+
return { fn: 1, fv: value ? 1 : 0 };
|
|
631
|
+
} else if (property === 'targetTemp' || property === 'temperature') {
|
|
632
|
+
return { fn: 2, fv: parseInt(value) || 24 };
|
|
633
|
+
} else if (property === 'climateMode' || property === 'mode') {
|
|
634
|
+
return { fn: 3, fv: AC_MODE_REVERSE[value] ?? 0 };
|
|
635
|
+
} else if (property === 'fanMode' || property === 'fanSpeed') {
|
|
636
|
+
return { fn: 4, fv: FAN_SPEED_REVERSE[value] ?? 0 };
|
|
637
|
+
}
|
|
638
|
+
} else if (subEntity === 'fresh_air') {
|
|
639
|
+
if (property === 'freshAirSwitch' || property === 'switch') {
|
|
640
|
+
return { fn: 1, fv: value ? 1 : 0 };
|
|
641
|
+
} else if (property === 'freshAirSpeed' || property === 'fanSpeed') {
|
|
642
|
+
return { fn: 3, fv: FAN_SPEED_REVERSE[value] ?? 0 };
|
|
643
|
+
}
|
|
644
|
+
} else if (subEntity === 'floor_heating') {
|
|
645
|
+
if (property === 'floorHeatingSwitch' || property === 'switch') {
|
|
646
|
+
return { fn: 1, fv: value ? 1 : 0 };
|
|
647
|
+
} else if (property === 'floorHeatingTemp' || property === 'temperature') {
|
|
648
|
+
return { fn: 2, fv: parseInt(value) || 20 };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
527
653
|
|
|
528
654
|
// ===== 网关事件监听 =====
|
|
529
655
|
function setupGatewayListeners() {
|
|
@@ -584,6 +710,10 @@ module.exports = function(RED) {
|
|
|
584
710
|
node._gateway.deviceManager.removeListener('device-state-changed', node._stateChangeHandler);
|
|
585
711
|
}
|
|
586
712
|
if (node._mqttClient) { try { node._mqttClient.end(true); } catch (e) {} node._mqttClient = null; }
|
|
713
|
+
// 清理 SyncUtils
|
|
714
|
+
if (node._syncUtils) {
|
|
715
|
+
node._syncUtils.destroy();
|
|
716
|
+
}
|
|
587
717
|
node._syncTimestamps.clear();
|
|
588
718
|
node._deviceStates.clear();
|
|
589
719
|
node._discoveredDevices.clear();
|
package/nodes/symi-mqtt.html
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
return this.name || 'MQTT配置';
|
|
14
14
|
},
|
|
15
15
|
oneditprepare: function() {
|
|
16
|
+
// 设置编辑面板更宽更高
|
|
17
|
+
var panel = $('#dialog-form').parent();
|
|
18
|
+
if (panel.length) {
|
|
19
|
+
if (panel.width() < 1000) panel.css('width', '1000px');
|
|
20
|
+
if (panel.height() < 1000) panel.css('min-height', '1000px');
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
// 自动填充默认值到输入框
|
|
17
24
|
if (!this.mqttBroker || this.mqttBroker === '') {
|
|
18
25
|
$('#node-config-input-mqttBroker').val('mqtt://localhost:1883');
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
return 'RS485同步';
|
|
25
25
|
},
|
|
26
26
|
oneditprepare: function() {
|
|
27
|
-
//
|
|
27
|
+
// 设置编辑面板更宽更高
|
|
28
28
|
var panel = $('#dialog-form').parent();
|
|
29
29
|
if (panel.length) {
|
|
30
|
-
panel.css('
|
|
30
|
+
if (panel.width() < 1000) panel.css('width', '1000px');
|
|
31
|
+
if (panel.height() < 1000) panel.css('min-height', '1000px');
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
var node = this;
|
package/nodes/symi-rs485-sync.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module.exports = function(RED) {
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { SyncUtils, StateCache, DEFAULT_TIMEOUT } = require('../lib/sync-utils');
|
|
5
|
+
|
|
4
6
|
function SymiRS485SyncNode(config) {
|
|
5
7
|
RED.nodes.createNode(this, config);
|
|
6
8
|
const node = this;
|
|
@@ -22,13 +24,13 @@ module.exports = function(RED) {
|
|
|
22
24
|
node._pollTimer = null;
|
|
23
25
|
node._pollIndex = 0;
|
|
24
26
|
|
|
25
|
-
//
|
|
26
|
-
node.
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// 初始化通用同步工具类
|
|
28
|
+
node.syncUtils = new SyncUtils({
|
|
29
|
+
defaultTimeout: DEFAULT_TIMEOUT
|
|
30
|
+
});
|
|
29
31
|
|
|
30
32
|
// 状态缓存
|
|
31
|
-
node.
|
|
33
|
+
node.stateCache = new StateCache(node.context(), 'rs485_sync_state_cache');
|
|
32
34
|
|
|
33
35
|
if (!node.rs485ConfigA || !node.rs485ConfigB) {
|
|
34
36
|
node.status({ fill: 'red', shape: 'ring', text: '未配置RS485连接' });
|
|
@@ -42,6 +44,19 @@ module.exports = function(RED) {
|
|
|
42
44
|
|
|
43
45
|
node.status({ fill: 'yellow', shape: 'dot', text: '初始化...' });
|
|
44
46
|
|
|
47
|
+
// 检查是否应该阻止同步(防死循环)
|
|
48
|
+
node.shouldPreventSync = function(direction, key) {
|
|
49
|
+
// A->B 对应 mesh-to-target, B->A 对应 target-to-mesh
|
|
50
|
+
const syncDirection = direction === 'a-to-b' ? 'mesh-to-target' : 'target-to-mesh';
|
|
51
|
+
return node.syncUtils.shouldPreventSync(syncDirection, key);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// 记录同步时间(用于防死循环)
|
|
55
|
+
node.recordSyncTime = function(direction, key) {
|
|
56
|
+
const syncDirection = direction === 'a-to-b' ? 'mesh-to-target' : 'target-to-mesh';
|
|
57
|
+
node.syncUtils.recordSyncTime(syncDirection, key);
|
|
58
|
+
};
|
|
59
|
+
|
|
45
60
|
// 计算校验和(中弘协议)
|
|
46
61
|
function checksum(data) {
|
|
47
62
|
let sum = 0;
|
|
@@ -229,7 +244,7 @@ module.exports = function(RED) {
|
|
|
229
244
|
const addr = cfg.address || 1;
|
|
230
245
|
// 获取当前缓存的状态
|
|
231
246
|
const cacheKey = `symi_${addr}`;
|
|
232
|
-
const cached = node.
|
|
247
|
+
const cached = node.stateCache.get(cacheKey) || { power: 0, temp: 24, mode: 0x01, fan: 0x01 };
|
|
233
248
|
|
|
234
249
|
let power = cached.power;
|
|
235
250
|
let temp = cached.temp;
|
|
@@ -293,15 +308,24 @@ module.exports = function(RED) {
|
|
|
293
308
|
|
|
294
309
|
// 同步状态:从A到B
|
|
295
310
|
function syncAtoB(mapping, state) {
|
|
296
|
-
const
|
|
297
|
-
|
|
311
|
+
const loopKey = JSON.stringify({ a: mapping.configA, b: mapping.configB });
|
|
312
|
+
|
|
313
|
+
// 使用通用工具检查防环路
|
|
314
|
+
if (node.shouldPreventSync('a-to-b', loopKey)) {
|
|
315
|
+
node.debug(`[A->B] 防环路跳过: ${loopKey}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
298
318
|
|
|
299
|
-
|
|
300
|
-
|
|
319
|
+
// 使用状态缓存检查是否有实际变化
|
|
320
|
+
const cacheKey = `mapping_${loopKey}`;
|
|
321
|
+
if (!node.stateCache.hasChanged(cacheKey, state)) {
|
|
322
|
+
node.debug(`[A->B] 状态未变化跳过: ${JSON.stringify(state)}`);
|
|
301
323
|
return;
|
|
302
324
|
}
|
|
325
|
+
node.stateCache.update(cacheKey, state);
|
|
303
326
|
|
|
304
|
-
|
|
327
|
+
// 记录同步时间
|
|
328
|
+
node.recordSyncTime('a-to-b', loopKey);
|
|
305
329
|
|
|
306
330
|
node.log(`[A->B] 同步状态: ${JSON.stringify(state)}`);
|
|
307
331
|
|
|
@@ -349,15 +373,24 @@ module.exports = function(RED) {
|
|
|
349
373
|
|
|
350
374
|
// 同步状态:从B到A
|
|
351
375
|
function syncBtoA(mapping, state) {
|
|
352
|
-
const
|
|
353
|
-
const now = Date.now();
|
|
376
|
+
const loopKey = JSON.stringify({ a: mapping.configA, b: mapping.configB });
|
|
354
377
|
|
|
355
|
-
|
|
356
|
-
|
|
378
|
+
// 使用通用工具检查防环路
|
|
379
|
+
if (node.shouldPreventSync('b-to-a', loopKey)) {
|
|
380
|
+
node.debug(`[B->A] 防环路跳过: ${loopKey}`);
|
|
357
381
|
return;
|
|
358
382
|
}
|
|
359
383
|
|
|
360
|
-
|
|
384
|
+
// 使用状态缓存检查是否有实际变化
|
|
385
|
+
const cacheKey = `mapping_${loopKey}`;
|
|
386
|
+
if (!node.stateCache.hasChanged(cacheKey, state)) {
|
|
387
|
+
node.debug(`[B->A] 状态未变化跳过: ${JSON.stringify(state)}`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
node.stateCache.update(cacheKey, state);
|
|
391
|
+
|
|
392
|
+
// 记录同步时间
|
|
393
|
+
node.recordSyncTime('b-to-a', loopKey);
|
|
361
394
|
|
|
362
395
|
node.log(`[B->A] 同步状态: ${JSON.stringify(state)}`);
|
|
363
396
|
|
|
@@ -451,6 +484,11 @@ module.exports = function(RED) {
|
|
|
451
484
|
mode: climate.mode,
|
|
452
485
|
fanMode: climate.fanMode
|
|
453
486
|
};
|
|
487
|
+
|
|
488
|
+
// 更新本地状态缓存,供B侧同步参考
|
|
489
|
+
const addrKey = `zhonghong_${climate.outdoorAddr}_${climate.indoorAddr}`;
|
|
490
|
+
node.stateCache.update(addrKey, state);
|
|
491
|
+
|
|
454
492
|
node.log(`[A->B] 内机${climate.outdoorAddr}-${climate.indoorAddr}: 开=${state.power}, 温=${state.targetTemp}, 模式=${state.mode}`);
|
|
455
493
|
syncAtoB(mapping, state);
|
|
456
494
|
}
|
|
@@ -471,13 +509,13 @@ module.exports = function(RED) {
|
|
|
471
509
|
} else if (mapping.protocolA === 'symi_climate') {
|
|
472
510
|
state = parseSymiClimateFrame(frame, mapping.configA);
|
|
473
511
|
if (state) {
|
|
474
|
-
const
|
|
475
|
-
node.
|
|
512
|
+
const addrKey = `symi_${mapping.configA.address || 1}`;
|
|
513
|
+
node.stateCache.update(addrKey, {
|
|
476
514
|
power: state.power ? 1 : 0,
|
|
477
515
|
temp: state.targetTemp,
|
|
478
516
|
mode: state.mode,
|
|
479
517
|
fan: state.fanMode
|
|
480
|
-
};
|
|
518
|
+
});
|
|
481
519
|
state.mode = SYMI_TO_ZH_MODE[state.mode] || state.mode;
|
|
482
520
|
state.fanMode = SYMI_TO_ZH_FAN[state.fanMode] || state.fanMode;
|
|
483
521
|
}
|
|
@@ -495,6 +533,10 @@ module.exports = function(RED) {
|
|
|
495
533
|
}
|
|
496
534
|
|
|
497
535
|
if (state && state.type === 'status') {
|
|
536
|
+
// 处理三合一子实体同步(空调、新风、地暖)
|
|
537
|
+
if (state.meshSubEntity) {
|
|
538
|
+
node.log(`[A->B] 三合一子实体同步: ${state.meshSubEntity}`);
|
|
539
|
+
}
|
|
498
540
|
syncAtoB(mapping, state);
|
|
499
541
|
}
|
|
500
542
|
}
|
|
@@ -545,6 +587,12 @@ module.exports = function(RED) {
|
|
|
545
587
|
mode: climate.mode,
|
|
546
588
|
fanMode: climate.fanMode
|
|
547
589
|
};
|
|
590
|
+
|
|
591
|
+
// 更新本地状态缓存,供A侧同步参考
|
|
592
|
+
const addrKey = `zhonghong_${climate.outdoorAddr}_${climate.indoorAddr}`;
|
|
593
|
+
node.stateCache.update(addrKey, state);
|
|
594
|
+
|
|
595
|
+
node.log(`[B->A] 内机${climate.outdoorAddr}-${climate.indoorAddr}: 开=${state.power}, 温=${state.targetTemp}, 模式=${state.mode}`);
|
|
548
596
|
syncBtoA(mapping, state);
|
|
549
597
|
}
|
|
550
598
|
}
|
|
@@ -564,13 +612,13 @@ module.exports = function(RED) {
|
|
|
564
612
|
} else if (mapping.protocolB === 'symi_climate') {
|
|
565
613
|
state = parseSymiClimateFrame(frame, mapping.configB);
|
|
566
614
|
if (state) {
|
|
567
|
-
const
|
|
568
|
-
node.
|
|
615
|
+
const addrKey = `symi_${mapping.configB.address || 1}`;
|
|
616
|
+
node.stateCache.update(addrKey, {
|
|
569
617
|
power: state.power ? 1 : 0,
|
|
570
618
|
temp: state.targetTemp,
|
|
571
619
|
mode: state.mode,
|
|
572
620
|
fan: state.fanMode
|
|
573
|
-
};
|
|
621
|
+
});
|
|
574
622
|
state.mode = SYMI_TO_ZH_MODE[state.mode] || state.mode;
|
|
575
623
|
state.fanMode = SYMI_TO_ZH_FAN[state.fanMode] || state.fanMode;
|
|
576
624
|
}
|
|
@@ -588,6 +636,10 @@ module.exports = function(RED) {
|
|
|
588
636
|
}
|
|
589
637
|
|
|
590
638
|
if (state && state.type === 'status') {
|
|
639
|
+
// 处理三合一子实体同步
|
|
640
|
+
if (state.meshSubEntity) {
|
|
641
|
+
node.log(`[B->A] 三合一子实体同步: ${state.meshSubEntity}`);
|
|
642
|
+
}
|
|
591
643
|
syncBtoA(mapping, state);
|
|
592
644
|
}
|
|
593
645
|
}
|
|
@@ -756,6 +808,10 @@ module.exports = function(RED) {
|
|
|
756
808
|
|
|
757
809
|
node.rs485ConfigA.deregister(node);
|
|
758
810
|
node.rs485ConfigB.deregister(node);
|
|
811
|
+
|
|
812
|
+
if (node.syncUtils) {
|
|
813
|
+
node.syncUtils.destroy();
|
|
814
|
+
}
|
|
759
815
|
|
|
760
816
|
done();
|
|
761
817
|
});
|