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
|
@@ -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 = {
|
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;
|