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.
@@ -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 (node.shouldPreventSync('mesh-to-knx', loopKey)) {
297
- node.log(`[Mesh->KNX] 跳过(防死循环): ${loopKey}`);
298
- continue;
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
- if (changed.curtainAction !== undefined || changed.curtainStatus !== undefined) {
356
- const action = changed.curtainAction || state.curtainAction;
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
- if (changed.curtainPosition !== undefined) {
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: knxMsg.knx.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
+ };