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.
@@ -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
 
@@ -3,10 +3,15 @@
3
3
  * 支持开关、窗帘等设备的双向状态同步
4
4
  * 事件驱动架构,命令队列顺序处理,防死循环机制
5
5
  *
6
- * 版本: 1.6.8
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
- const LOOP_PREVENTION_MS = 800; // 800ms内不处理反向同步,防止回环
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
- const now = Date.now();
232
- if (direction === 'mesh-to-knx') {
233
- const lastKnxTime = node.lastKnxToMesh[key] || 0;
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 now = Date.now();
244
- if (direction === 'mesh-to-knx') {
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 < LOOP_PREVENTION_MS) {
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() < 920) panel.css('width', '900px');
40
- if (panel.height() < 700) panel.css('min-height', '700px');
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 = {
@@ -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
- const mapping = node.mappings.find(m =>
267
- parseInt(m.brandDeviceType) === deviceType && parseInt(m.brandDeviceId) === deviceId
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 now = Date.now();
276
- const mqttSyncKey = `mqtt_${deviceType}_${deviceId}_${fn}`;
277
- const meshSyncKey = `mesh_${meshMac}_${meshChannel}_${fn}`;
278
- const lastMqttSync = node._syncTimestamps.get(mqttSyncKey) || 0;
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._syncTimestamps.set(mqttSyncKey, now);
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
- const now = Date.now();
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();
@@ -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('min-width', '700px');
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;