node-red-contrib-symi-mesh 1.7.2 → 1.7.4
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 +55 -17
- package/lib/serial-client.js +39 -9
- package/nodes/symi-485-bridge.js +7 -1
- package/nodes/symi-485-config.html +62 -21
- package/nodes/symi-485-config.js +13 -4
- package/nodes/symi-gateway.html +85 -3
- package/nodes/symi-gateway.js +41 -1
- package/nodes/symi-knx-bridge.js +96 -17
- package/nodes/symi-mqtt-brand.html +75 -0
- package/nodes/symi-mqtt-brand.js +238 -0
- package/nodes/symi-mqtt-sync.html +381 -0
- package/nodes/symi-mqtt-sync.js +473 -0
- package/package.json +4 -2
package/nodes/symi-knx-bridge.js
CHANGED
|
@@ -149,11 +149,17 @@ module.exports = function(RED) {
|
|
|
149
149
|
node.knxStateCache = {}; // KNX设备状态缓存
|
|
150
150
|
node.lastMeshToKnx = {}; // 记录Mesh->KNX发送时间,防止回环
|
|
151
151
|
node.lastKnxToMesh = {}; // 记录KNX->Mesh发送时间,防止回环
|
|
152
|
+
node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间,防止自己发的命令又被处理
|
|
152
153
|
|
|
153
154
|
// 防死循环参数
|
|
154
155
|
const LOOP_PREVENTION_MS = 800; // 800ms内不处理反向同步,防止回环
|
|
155
156
|
const DEBOUNCE_MS = 100; // 100ms防抖
|
|
156
157
|
const MAX_QUEUE_SIZE = 100; // 最大队列大小
|
|
158
|
+
const COVER_CONTROL_LOCK_MS = 3000; // 窗帘/调光控制锁定3秒(只忽略过程反馈,不长期锁定)
|
|
159
|
+
|
|
160
|
+
// 控制锁定状态:谁先动作,锁定期间忽略另一方的反馈
|
|
161
|
+
// { deviceKey: { controller: 'mesh'|'knx', lockUntil: timestamp } }
|
|
162
|
+
node.controlLock = {};
|
|
157
163
|
|
|
158
164
|
// 初始化标记
|
|
159
165
|
node.initializing = true;
|
|
@@ -292,10 +298,15 @@ module.exports = function(RED) {
|
|
|
292
298
|
for (const mapping of matchedMappings) {
|
|
293
299
|
const loopKey = `${mac}_${mapping.meshChannel}`;
|
|
294
300
|
|
|
295
|
-
//
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
301
|
+
// 窗帘设备需要单独处理防死循环(动作和位置分开检查)
|
|
302
|
+
if (mapping.deviceType === 'cover') {
|
|
303
|
+
// 窗帘的防死循环在内部单独处理,这里不跳过
|
|
304
|
+
} else {
|
|
305
|
+
// 其他设备统一防死循环检查
|
|
306
|
+
if (node.shouldPreventSync('mesh-to-knx', loopKey)) {
|
|
307
|
+
node.log(`[Mesh->KNX] 跳过(防死循环): ${loopKey}`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
299
310
|
}
|
|
300
311
|
|
|
301
312
|
// 开关设备
|
|
@@ -312,8 +323,23 @@ module.exports = function(RED) {
|
|
|
312
323
|
});
|
|
313
324
|
}
|
|
314
325
|
}
|
|
315
|
-
// 调光灯设备(单色、双色、RGB、RGBCW
|
|
326
|
+
// 调光灯设备(单色、双色、RGB、RGBCW)- 简化逻辑:谁动谁跟,忽略过程反馈
|
|
316
327
|
else if (mapping.deviceType.startsWith('light_')) {
|
|
328
|
+
if (!eventData.isUserControl) continue;
|
|
329
|
+
|
|
330
|
+
const deviceKey = `${macNormalized}_light`;
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
const lock = node.controlLock[deviceKey];
|
|
333
|
+
|
|
334
|
+
// 如果KNX正在控制中,忽略Mesh的反馈
|
|
335
|
+
if (lock && lock.controller === 'knx' && now < lock.lockUntil) {
|
|
336
|
+
node.debug(`[Mesh->KNX] 调光灯跳过(KNX控制中): ${deviceKey}`);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Mesh发起控制,锁定20秒
|
|
341
|
+
node.controlLock[deviceKey] = { controller: 'mesh', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
342
|
+
|
|
317
343
|
// 开关状态
|
|
318
344
|
if (changed.switch !== undefined) {
|
|
319
345
|
node.log(`[Mesh->KNX] 调光灯开关: ${mapping.name || eventData.device.name} = ${changed.switch}`);
|
|
@@ -325,8 +351,8 @@ module.exports = function(RED) {
|
|
|
325
351
|
key: loopKey
|
|
326
352
|
});
|
|
327
353
|
}
|
|
328
|
-
//
|
|
329
|
-
if (changed.brightness !== undefined) {
|
|
354
|
+
// 亮度(只在没有开关变化时同步,避免重复)
|
|
355
|
+
if (changed.brightness !== undefined && changed.switch === undefined) {
|
|
330
356
|
node.log(`[Mesh->KNX] 调光灯亮度: ${mapping.name || eventData.device.name} = ${changed.brightness}%`);
|
|
331
357
|
node.queueCommand({
|
|
332
358
|
direction: 'mesh-to-knx',
|
|
@@ -348,29 +374,50 @@ module.exports = function(RED) {
|
|
|
348
374
|
});
|
|
349
375
|
}
|
|
350
376
|
}
|
|
351
|
-
// 窗帘设备
|
|
377
|
+
// 窗帘设备 - 简化逻辑:谁动谁跟,忽略过程反馈
|
|
352
378
|
else if (mapping.deviceType === 'cover') {
|
|
353
379
|
if (!eventData.isUserControl) continue;
|
|
354
380
|
|
|
355
|
-
|
|
356
|
-
|
|
381
|
+
const deviceKey = macNormalized;
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
const lock = node.controlLock[deviceKey];
|
|
384
|
+
|
|
385
|
+
// 如果KNX正在控制中,忽略Mesh的反馈(避免回环)
|
|
386
|
+
if (lock && lock.controller === 'knx' && now < lock.lockUntil) {
|
|
387
|
+
node.debug(`[Mesh->KNX] 窗帘跳过(KNX控制中): ${deviceKey}`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Mesh发起控制,锁定20秒
|
|
392
|
+
node.controlLock[deviceKey] = { controller: 'mesh', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
393
|
+
|
|
394
|
+
// 处理动作(打开/关闭/停止)
|
|
395
|
+
if (changed.curtainAction !== undefined) {
|
|
396
|
+
const action = changed.curtainAction;
|
|
357
397
|
node.log(`[Mesh->KNX] 窗帘动作: ${mapping.name || eventData.device.name} action=${action}`);
|
|
358
398
|
node.queueCommand({
|
|
359
399
|
direction: 'mesh-to-knx',
|
|
360
400
|
mapping: mapping,
|
|
361
401
|
type: 'cover_action',
|
|
362
402
|
value: action,
|
|
363
|
-
key: loopKey
|
|
403
|
+
key: `${loopKey}_action`
|
|
364
404
|
});
|
|
405
|
+
|
|
406
|
+
// 停止时解除锁定
|
|
407
|
+
if (action === 'stopped') {
|
|
408
|
+
delete node.controlLock[deviceKey];
|
|
409
|
+
}
|
|
365
410
|
}
|
|
366
|
-
|
|
411
|
+
|
|
412
|
+
// 处理位置变化(只在没有动作变化时同步位置)
|
|
413
|
+
if (changed.curtainPosition !== undefined && changed.curtainAction === undefined) {
|
|
367
414
|
node.log(`[Mesh->KNX] 窗帘位置: ${mapping.name || eventData.device.name} pos=${changed.curtainPosition}%`);
|
|
368
415
|
node.queueCommand({
|
|
369
416
|
direction: 'mesh-to-knx',
|
|
370
417
|
mapping: mapping,
|
|
371
418
|
type: 'cover_position',
|
|
372
419
|
value: changed.curtainPosition,
|
|
373
|
-
key: loopKey
|
|
420
|
+
key: `${loopKey}_position`
|
|
374
421
|
});
|
|
375
422
|
}
|
|
376
423
|
}
|
|
@@ -709,10 +756,14 @@ module.exports = function(RED) {
|
|
|
709
756
|
}
|
|
710
757
|
|
|
711
758
|
if (knxMsg) {
|
|
759
|
+
// 记录发送到的KNX地址和时间,防止自己发的命令又被处理
|
|
760
|
+
const destAddr = knxMsg.knx.destination;
|
|
761
|
+
node.lastKnxAddrSent[destAddr] = Date.now();
|
|
762
|
+
|
|
712
763
|
// knxUltimate官方格式: https://supergiovane.github.io/node-red-contrib-knx-ultimate/wiki/Device
|
|
713
764
|
// 必须包含: destination, payload, dpt, event
|
|
714
765
|
const sendMsg = {
|
|
715
|
-
destination:
|
|
766
|
+
destination: destAddr,
|
|
716
767
|
payload: knxMsg.payload,
|
|
717
768
|
dpt: knxMsg.dpt || knxMsg.knx.dpt,
|
|
718
769
|
event: "GroupValue_Write"
|
|
@@ -897,6 +948,14 @@ module.exports = function(RED) {
|
|
|
897
948
|
return;
|
|
898
949
|
}
|
|
899
950
|
|
|
951
|
+
// 检查是否是我们自己发出去的命令(防止自己发的命令被监听后又处理)
|
|
952
|
+
const lastSentTime = node.lastKnxAddrSent[groupAddr] || 0;
|
|
953
|
+
if (Date.now() - lastSentTime < LOOP_PREVENTION_MS) {
|
|
954
|
+
node.debug(`[KNX输入] 跳过(自己发的): ${groupAddr}`);
|
|
955
|
+
done && done();
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
900
959
|
// 查找映射
|
|
901
960
|
const mapping = node.findKnxMapping(groupAddr);
|
|
902
961
|
if (!mapping) {
|
|
@@ -909,7 +968,7 @@ module.exports = function(RED) {
|
|
|
909
968
|
const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
|
|
910
969
|
const loopKey = `${mapping.meshMac}_${mapping.meshChannel}`;
|
|
911
970
|
|
|
912
|
-
//
|
|
971
|
+
// 防死循环检查(基于设备的双向同步防护)
|
|
913
972
|
if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
|
|
914
973
|
node.debug(`[KNX->Mesh] 跳过同步(防死循环): ${loopKey}, addr=${groupAddr}`);
|
|
915
974
|
done && done();
|
|
@@ -933,35 +992,55 @@ module.exports = function(RED) {
|
|
|
933
992
|
});
|
|
934
993
|
}
|
|
935
994
|
}
|
|
936
|
-
// 窗帘设备
|
|
995
|
+
// 窗帘设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
|
|
937
996
|
else if (mapping.deviceType === 'cover') {
|
|
997
|
+
const deviceKey = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
998
|
+
const now = Date.now();
|
|
999
|
+
|
|
1000
|
+
// KNX主动发起控制,直接抢占锁定(用户操作优先)
|
|
1001
|
+
node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
1002
|
+
|
|
938
1003
|
if (addrFunc === 'cmd') {
|
|
939
1004
|
// 上下命令: 0=上(开), 1=下(关)
|
|
940
1005
|
const action = (value === 0 || value === false) ? 'open' : 'close';
|
|
1006
|
+
node.log(`[KNX->Mesh] 窗帘动作: ${action}`);
|
|
941
1007
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_action', value: action, key: loopKey, sourceAddr: groupAddr });
|
|
942
1008
|
}
|
|
943
1009
|
else if (addrFunc === 'stop') {
|
|
1010
|
+
node.log(`[KNX->Mesh] 窗帘停止`);
|
|
944
1011
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_action', value: 'stop', key: loopKey, sourceAddr: groupAddr });
|
|
1012
|
+
// 停止时解除锁定
|
|
1013
|
+
delete node.controlLock[deviceKey];
|
|
945
1014
|
}
|
|
946
1015
|
else if (addrFunc === 'position' || addrFunc === 'status') {
|
|
947
1016
|
const pos = mapping.invertPosition ? (100 - (parseInt(value) || 0)) : (parseInt(value) || 0);
|
|
1017
|
+
node.log(`[KNX->Mesh] 窗帘位置: ${pos}%`);
|
|
948
1018
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_position', value: pos, key: loopKey, sourceAddr: groupAddr });
|
|
949
1019
|
}
|
|
950
1020
|
}
|
|
951
|
-
// 调光灯设备
|
|
1021
|
+
// 调光灯设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
|
|
952
1022
|
else if (mapping.deviceType.startsWith('light_')) {
|
|
1023
|
+
const deviceKey = `${(mapping.meshMac || '').toLowerCase().replace(/:/g, '')}_light`;
|
|
1024
|
+
const now = Date.now();
|
|
1025
|
+
|
|
1026
|
+
// KNX主动发起控制,直接抢占锁定(用户操作优先)
|
|
1027
|
+
node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
1028
|
+
|
|
953
1029
|
if (addrFunc === 'cmd' || addrFunc === 'status') {
|
|
954
1030
|
// 开关
|
|
955
1031
|
const sw = (value === 1 || value === true);
|
|
1032
|
+
node.log(`[KNX->Mesh] 调光灯开关: ${sw ? 'ON' : 'OFF'}`);
|
|
956
1033
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_switch', value: sw, key: loopKey, sourceAddr: groupAddr });
|
|
957
1034
|
}
|
|
958
1035
|
else if (addrFunc === 'brightness') {
|
|
959
1036
|
// 亮度 (KNX 0-255 -> Mesh 0-100)
|
|
960
1037
|
const brightness = Math.round((parseInt(value) || 0) * 100 / 255);
|
|
1038
|
+
node.log(`[KNX->Mesh] 调光灯亮度: ${brightness}%`);
|
|
961
1039
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_brightness', value: brightness, key: loopKey, sourceAddr: groupAddr });
|
|
962
1040
|
}
|
|
963
1041
|
else if (addrFunc === 'colorTemp') {
|
|
964
1042
|
// 色温
|
|
1043
|
+
node.log(`[KNX->Mesh] 调光灯色温: ${value}`);
|
|
965
1044
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_color_temp', value: parseInt(value) || 50, key: loopKey, sourceAddr: groupAddr });
|
|
966
1045
|
}
|
|
967
1046
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('symi-mqtt-brand', {
|
|
3
|
+
category: 'config',
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: '' },
|
|
6
|
+
brand: { value: 'hyqw' },
|
|
7
|
+
mqttBroker: { value: 'mqtt://localhost:1883', required: true },
|
|
8
|
+
mqttUsername: { value: '' },
|
|
9
|
+
mqttPassword: { value: '' },
|
|
10
|
+
projectCode: { value: '', required: true },
|
|
11
|
+
deviceSn: { value: '', required: true }
|
|
12
|
+
},
|
|
13
|
+
label: function() {
|
|
14
|
+
if (this.name) return this.name;
|
|
15
|
+
if (this.projectCode && this.deviceSn) {
|
|
16
|
+
return 'HYQW: ' + this.projectCode;
|
|
17
|
+
}
|
|
18
|
+
return '品牌MQTT';
|
|
19
|
+
},
|
|
20
|
+
oneditprepare: function() {
|
|
21
|
+
var node = this;
|
|
22
|
+
|
|
23
|
+
// 品牌切换
|
|
24
|
+
function updateBrandUI() {
|
|
25
|
+
var brand = $('#node-config-input-brand').val();
|
|
26
|
+
if (brand === 'hyqw') {
|
|
27
|
+
$('.brand-hyqw-config').show();
|
|
28
|
+
} else {
|
|
29
|
+
$('.brand-hyqw-config').hide();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
$('#node-config-input-brand').on('change', updateBrandUI);
|
|
33
|
+
updateBrandUI();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<script type="text/html" data-template-name="symi-mqtt-brand">
|
|
39
|
+
<div class="form-row">
|
|
40
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
41
|
+
<input type="text" id="node-config-input-name" placeholder="如:花语前湾MQTT">
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="form-row">
|
|
45
|
+
<label for="node-config-input-brand"><i class="fa fa-plug"></i> 品牌协议</label>
|
|
46
|
+
<select id="node-config-input-brand">
|
|
47
|
+
<option value="hyqw">HYQW (花语前湾)</option>
|
|
48
|
+
</select>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="form-row">
|
|
52
|
+
<label for="node-config-input-mqttBroker"><i class="fa fa-server"></i> MQTT地址</label>
|
|
53
|
+
<input type="text" id="node-config-input-mqttBroker" placeholder="mqtt://192.168.1.100:1883">
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="form-row">
|
|
57
|
+
<label for="node-config-input-mqttUsername"><i class="fa fa-user"></i> 用户名</label>
|
|
58
|
+
<input type="text" id="node-config-input-mqttUsername" placeholder="可选">
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="form-row">
|
|
62
|
+
<label for="node-config-input-mqttPassword"><i class="fa fa-lock"></i> 密码</label>
|
|
63
|
+
<input type="password" id="node-config-input-mqttPassword" placeholder="可选">
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="form-row brand-hyqw-config">
|
|
67
|
+
<label for="node-config-input-projectCode"><i class="fa fa-folder"></i> 项目代码</label>
|
|
68
|
+
<input type="text" id="node-config-input-projectCode" placeholder="如: SH-485-V22">
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="form-row brand-hyqw-config">
|
|
72
|
+
<label for="node-config-input-deviceSn"><i class="fa fa-barcode"></i> 设备SN</label>
|
|
73
|
+
<input type="text" id="node-config-input-deviceSn" placeholder="如: FB485V222024110500000377">
|
|
74
|
+
</div>
|
|
75
|
+
</script>
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const mqtt = require('mqtt');
|
|
4
|
+
|
|
5
|
+
module.exports = function(RED) {
|
|
6
|
+
// 品牌MQTT配置节点
|
|
7
|
+
function SymiMqttBrandNode(config) {
|
|
8
|
+
RED.nodes.createNode(this, config);
|
|
9
|
+
const node = this;
|
|
10
|
+
|
|
11
|
+
// 配置参数
|
|
12
|
+
node.name = config.name || '品牌MQTT';
|
|
13
|
+
node.brand = config.brand || 'hyqw';
|
|
14
|
+
node.mqttBroker = config.mqttBroker || 'mqtt://localhost:1883';
|
|
15
|
+
node.mqttUsername = config.mqttUsername || '';
|
|
16
|
+
node.mqttPassword = config.mqttPassword || '';
|
|
17
|
+
node.projectCode = config.projectCode || '';
|
|
18
|
+
node.deviceSn = config.deviceSn || '';
|
|
19
|
+
|
|
20
|
+
// 运行时状态
|
|
21
|
+
node._client = null;
|
|
22
|
+
node._connected = false;
|
|
23
|
+
node._discoveredDevices = new Map();
|
|
24
|
+
node._subscribers = new Set();
|
|
25
|
+
node._closing = false;
|
|
26
|
+
node._reconnectTimer = null;
|
|
27
|
+
node._lastErrorLog = 0;
|
|
28
|
+
|
|
29
|
+
// 设备类型定义
|
|
30
|
+
const DEVICE_TYPES = {
|
|
31
|
+
8: { name: '灯具', type: 'light' },
|
|
32
|
+
12: { name: '空调', type: 'climate' },
|
|
33
|
+
14: { name: '窗帘', type: 'cover' },
|
|
34
|
+
16: { name: '地暖', type: 'climate' },
|
|
35
|
+
36: { name: '新风', type: 'fan' }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// 获取MQTT主题
|
|
39
|
+
function getUploadTopic() {
|
|
40
|
+
if (!node.projectCode || !node.deviceSn) return null;
|
|
41
|
+
return `FMQ/${node.projectCode}/${node.deviceSn}/UPLOAD/2002`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getDownTopic() {
|
|
45
|
+
if (!node.projectCode || !node.deviceSn) return null;
|
|
46
|
+
return `FMQ/${node.projectCode}/${node.deviceSn}/DOWN/2001`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 限流错误日志
|
|
50
|
+
function logErrorThrottled(msg) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
if (now - node._lastErrorLog > 60000) {
|
|
53
|
+
node._lastErrorLog = now;
|
|
54
|
+
node.warn(msg);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 连接MQTT
|
|
59
|
+
function connect() {
|
|
60
|
+
if (node._closing || node._client) return;
|
|
61
|
+
|
|
62
|
+
const uploadTopic = getUploadTopic();
|
|
63
|
+
if (!uploadTopic) {
|
|
64
|
+
node.warn('品牌MQTT配置不完整:缺少项目代码或设备SN');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const brokerUrl = node.mqttBroker || 'mqtt://localhost:1883';
|
|
69
|
+
const options = {
|
|
70
|
+
clientId: `symi_brand_${node.id}_${Date.now()}`,
|
|
71
|
+
clean: true,
|
|
72
|
+
connectTimeout: 10000,
|
|
73
|
+
reconnectPeriod: 0,
|
|
74
|
+
keepalive: 60
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (node.mqttUsername) {
|
|
78
|
+
options.username = node.mqttUsername;
|
|
79
|
+
options.password = node.mqttPassword || '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
node._client = mqtt.connect(brokerUrl, options);
|
|
84
|
+
|
|
85
|
+
node._client.on('connect', function() {
|
|
86
|
+
node._connected = true;
|
|
87
|
+
node._lastErrorLog = 0;
|
|
88
|
+
node.log(`品牌MQTT已连接: ${brokerUrl}`);
|
|
89
|
+
|
|
90
|
+
// 订阅上报主题
|
|
91
|
+
node._client.subscribe(uploadTopic, function(err) {
|
|
92
|
+
if (!err) {
|
|
93
|
+
node.log(`已订阅: ${uploadTopic}`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 通知订阅者
|
|
98
|
+
notifySubscribers('connected');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
node._client.on('message', function(topic, payload) {
|
|
102
|
+
try {
|
|
103
|
+
const data = JSON.parse(payload.toString());
|
|
104
|
+
if (data.payload) {
|
|
105
|
+
const { st, si, fn, fv } = data.payload;
|
|
106
|
+
if (st !== undefined && si !== undefined) {
|
|
107
|
+
// 发现设备
|
|
108
|
+
const deviceKey = `${st}_${si}`;
|
|
109
|
+
const deviceType = DEVICE_TYPES[st] || { name: `类型${st}`, type: 'unknown' };
|
|
110
|
+
|
|
111
|
+
if (!node._discoveredDevices.has(deviceKey)) {
|
|
112
|
+
node._discoveredDevices.set(deviceKey, {
|
|
113
|
+
deviceType: st,
|
|
114
|
+
deviceId: si,
|
|
115
|
+
typeName: deviceType.name,
|
|
116
|
+
meshType: deviceType.type,
|
|
117
|
+
lastSeen: Date.now(),
|
|
118
|
+
lastState: { fn, fv }
|
|
119
|
+
});
|
|
120
|
+
node.log(`发现品牌设备: ${deviceType.name} ID:${si}`);
|
|
121
|
+
} else {
|
|
122
|
+
const dev = node._discoveredDevices.get(deviceKey);
|
|
123
|
+
dev.lastSeen = Date.now();
|
|
124
|
+
dev.lastState = { fn, fv };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 通知订阅者状态更新
|
|
128
|
+
notifySubscribers('state', { st, si, fn, fv });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
// 静默处理解析错误
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
node._client.on('error', function(err) {
|
|
137
|
+
logErrorThrottled(`品牌MQTT错误: ${err.message}`);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
node._client.on('close', function() {
|
|
141
|
+
node._connected = false;
|
|
142
|
+
notifySubscribers('disconnected');
|
|
143
|
+
|
|
144
|
+
if (!node._closing && !node._reconnectTimer) {
|
|
145
|
+
node._reconnectTimer = setTimeout(function() {
|
|
146
|
+
node._reconnectTimer = null;
|
|
147
|
+
if (node._client) {
|
|
148
|
+
try { node._client.end(true); } catch(e) {}
|
|
149
|
+
node._client = null;
|
|
150
|
+
}
|
|
151
|
+
connect();
|
|
152
|
+
}, 5000);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
} catch (err) {
|
|
157
|
+
logErrorThrottled(`品牌MQTT连接失败: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 通知订阅者
|
|
162
|
+
function notifySubscribers(event, data) {
|
|
163
|
+
node._subscribers.forEach(function(callback) {
|
|
164
|
+
try {
|
|
165
|
+
callback(event, data);
|
|
166
|
+
} catch (e) {}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 发布控制命令
|
|
171
|
+
node.publish = function(st, si, fn, fv) {
|
|
172
|
+
if (!node._client || !node._connected) return false;
|
|
173
|
+
|
|
174
|
+
const downTopic = getDownTopic();
|
|
175
|
+
if (!downTopic) return false;
|
|
176
|
+
|
|
177
|
+
const payload = JSON.stringify({ payload: { st, si, fn, fv } });
|
|
178
|
+
node._client.publish(downTopic, payload);
|
|
179
|
+
return true;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// 订阅状态更新
|
|
183
|
+
node.subscribe = function(callback) {
|
|
184
|
+
node._subscribers.add(callback);
|
|
185
|
+
return function() {
|
|
186
|
+
node._subscribers.delete(callback);
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// 获取已发现的设备列表
|
|
191
|
+
node.getDiscoveredDevices = function() {
|
|
192
|
+
return Array.from(node._discoveredDevices.values());
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// 检查是否已连接
|
|
196
|
+
node.isConnected = function() {
|
|
197
|
+
return node._connected;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// 启动连接
|
|
201
|
+
if (node.projectCode && node.deviceSn) {
|
|
202
|
+
setTimeout(connect, 1000);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 清理
|
|
206
|
+
node.on('close', function(done) {
|
|
207
|
+
node._closing = true;
|
|
208
|
+
|
|
209
|
+
if (node._reconnectTimer) {
|
|
210
|
+
clearTimeout(node._reconnectTimer);
|
|
211
|
+
node._reconnectTimer = null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (node._client) {
|
|
215
|
+
try {
|
|
216
|
+
node._client.end(true);
|
|
217
|
+
} catch (e) {}
|
|
218
|
+
node._client = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
node._subscribers.clear();
|
|
222
|
+
node._discoveredDevices.clear();
|
|
223
|
+
done();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
RED.nodes.registerType('symi-mqtt-brand', SymiMqttBrandNode);
|
|
228
|
+
|
|
229
|
+
// HTTP API - 获取已发现的设备
|
|
230
|
+
RED.httpAdmin.get('/symi-mqtt-brand/devices/:id', function(req, res) {
|
|
231
|
+
const node = RED.nodes.getNode(req.params.id);
|
|
232
|
+
if (node && typeof node.getDiscoveredDevices === 'function') {
|
|
233
|
+
res.json(node.getDiscoveredDevices());
|
|
234
|
+
} else {
|
|
235
|
+
res.json([]);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
};
|