node-red-contrib-symi-mesh 1.7.7 → 1.7.8

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.
@@ -1,8 +1,74 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * 品牌MQTT配置节点 - 支持HYQW等第三方MQTT协议
5
+ * v1.7.8 增强版:完整HYQW协议支持,可扩展架构
6
+ */
7
+
3
8
  const mqtt = require('mqtt');
4
9
 
5
10
  module.exports = function(RED) {
11
+ // 品牌协议定义
12
+ const BRAND_PROTOCOLS = {
13
+ hyqw: {
14
+ name: 'HYQW (花语前湾)',
15
+ needsProjectConfig: true,
16
+ getUploadTopic: (cfg) => cfg.projectCode && cfg.deviceSn
17
+ ? `FMQ/${cfg.projectCode}/${cfg.deviceSn}/UPLOAD/2002` : null,
18
+ getDownTopic: (cfg) => cfg.projectCode && cfg.deviceSn
19
+ ? `FMQ/${cfg.projectCode}/${cfg.deviceSn}/DOWN/2001` : null,
20
+ parseMessage: (payload) => {
21
+ try {
22
+ const data = JSON.parse(payload.toString());
23
+ if (!data.payload) return null;
24
+ const { st, si, fn, fv } = data.payload;
25
+ if (st === undefined || si === undefined || fn === undefined || fv === undefined) return null;
26
+ return { deviceType: st, deviceId: si, fn, fv };
27
+ } catch (e) { return null; }
28
+ },
29
+ buildMessage: (st, si, fn, fv) => {
30
+ return JSON.stringify({ payload: { st, si, fn, fv } });
31
+ },
32
+ deviceTypes: {
33
+ 8: { name: '灯具', meshType: 'light', channels: 8 },
34
+ 12: { name: '空调', meshType: 'climate', channels: 1 },
35
+ 14: { name: '窗帘', meshType: 'cover', channels: 1 },
36
+ 16: { name: '地暖', meshType: 'climate', channels: 1 },
37
+ 36: { name: '新风', meshType: 'fan', channels: 1 }
38
+ },
39
+ // 功能码定义
40
+ functionCodes: {
41
+ 8: { // 灯具
42
+ 1: { name: '开关', meshProp: 'switch', valueMap: { 0: false, 1: true }, reverseMap: { false: 0, true: 1 } },
43
+ 2: { name: '亮度', meshProp: 'brightness', range: [0, 100] }
44
+ },
45
+ 12: { // 空调
46
+ 1: { name: '开关', meshProp: 'switch', valueMap: { 0: false, 1: true }, reverseMap: { false: 0, true: 1 } },
47
+ 2: { name: '温度', meshProp: 'targetTemp', range: [18, 29] },
48
+ 3: { name: '模式', meshProp: 'climateMode', valueMap: { 0: 'cool', 1: 'heat', 2: 'fan_only', 3: 'dry' },
49
+ reverseMap: { cool: 0, heat: 1, fan_only: 2, dry: 3 } },
50
+ 4: { name: '风速', meshProp: 'fanMode', valueMap: { 0: 'auto', 1: 'low', 2: 'medium', 3: 'high' },
51
+ reverseMap: { auto: 0, low: 1, medium: 2, high: 3 } }
52
+ },
53
+ 14: { // 窗帘
54
+ 1: { name: '动作', meshProp: 'curtainAction', valueMap: { 0: 'close', 1: 'open', 2: 'stop' },
55
+ reverseMap: { close: 0, open: 1, stop: 2 } },
56
+ 2: { name: '位置', meshProp: 'curtainPosition', range: [0, 100] }
57
+ },
58
+ 16: { // 地暖
59
+ 1: { name: '开关', meshProp: 'floorHeatingSwitch', valueMap: { 0: false, 1: true }, reverseMap: { false: 0, true: 1 } },
60
+ 2: { name: '温度', meshProp: 'floorHeatingTemp', range: [5, 35] }
61
+ },
62
+ 36: { // 新风
63
+ 1: { name: '开关', meshProp: 'freshAirSwitch', valueMap: { 0: false, 1: true }, reverseMap: { false: 0, true: 1 } },
64
+ 3: { name: '风速', meshProp: 'freshAirSpeed', valueMap: { 0: 'auto', 1: 'low', 2: 'medium', 3: 'high' },
65
+ reverseMap: { auto: 0, low: 1, medium: 2, high: 3 } }
66
+ }
67
+ }
68
+ }
69
+ // 后续可扩展其他品牌: tuya, custom 等
70
+ };
71
+
6
72
  // 品牌MQTT配置节点
7
73
  function SymiMqttBrandNode(config) {
8
74
  RED.nodes.createNode(this, config);
@@ -17,34 +83,19 @@ module.exports = function(RED) {
17
83
  node.projectCode = config.projectCode || '';
18
84
  node.deviceSn = config.deviceSn || '';
19
85
 
86
+ // 获取协议处理器
87
+ node._protocol = BRAND_PROTOCOLS[node.brand] || BRAND_PROTOCOLS.hyqw;
88
+
20
89
  // 运行时状态
21
90
  node._client = null;
22
91
  node._connected = false;
23
92
  node._discoveredDevices = new Map();
93
+ node._deviceStates = new Map();
24
94
  node._subscribers = new Set();
25
95
  node._closing = false;
26
96
  node._reconnectTimer = null;
27
97
  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
- }
98
+ node._cleanupTimer = null;
48
99
 
49
100
  // 限流错误日志
50
101
  function logErrorThrottled(msg) {
@@ -55,6 +106,15 @@ module.exports = function(RED) {
55
106
  }
56
107
  }
57
108
 
109
+ // 获取MQTT主题
110
+ function getUploadTopic() {
111
+ return node._protocol.getUploadTopic(node);
112
+ }
113
+
114
+ function getDownTopic() {
115
+ return node._protocol.getDownTopic(node);
116
+ }
117
+
58
118
  // 连接MQTT
59
119
  function connect() {
60
120
  if (node._closing || node._client) return;
@@ -88,49 +148,20 @@ module.exports = function(RED) {
88
148
  node.log(`品牌MQTT已连接: ${brokerUrl}`);
89
149
 
90
150
  // 订阅上报主题
91
- node._client.subscribe(uploadTopic, function(err) {
151
+ node._client.subscribe(uploadTopic, { qos: 0 }, function(err) {
92
152
  if (!err) {
93
153
  node.log(`已订阅: ${uploadTopic}`);
154
+ } else {
155
+ node.error(`订阅失败: ${err.message}`);
94
156
  }
95
157
  });
96
158
 
97
159
  // 通知订阅者
98
- notifySubscribers('connected');
160
+ notifySubscribers('connected', { broker: brokerUrl });
99
161
  });
100
162
 
101
163
  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
- }
164
+ handleMessage(topic, payload);
134
165
  });
135
166
 
136
167
  node._client.on('error', function(err) {
@@ -138,8 +169,12 @@ module.exports = function(RED) {
138
169
  });
139
170
 
140
171
  node._client.on('close', function() {
172
+ const wasConnected = node._connected;
141
173
  node._connected = false;
142
- notifySubscribers('disconnected');
174
+
175
+ if (wasConnected) {
176
+ notifySubscribers('disconnected');
177
+ }
143
178
 
144
179
  if (!node._closing && !node._reconnectTimer) {
145
180
  node._reconnectTimer = setTimeout(function() {
@@ -158,6 +193,65 @@ module.exports = function(RED) {
158
193
  }
159
194
  }
160
195
 
196
+ // 处理接收到的消息
197
+ function handleMessage(topic, payload) {
198
+ try {
199
+ const parsed = node._protocol.parseMessage(payload);
200
+ if (!parsed) return;
201
+
202
+ const { deviceType, deviceId, fn, fv } = parsed;
203
+ const deviceKey = `${deviceType}_${deviceId}`;
204
+ const deviceTypeInfo = node._protocol.deviceTypes[deviceType];
205
+
206
+ // 发现设备
207
+ if (!node._discoveredDevices.has(deviceKey)) {
208
+ node._discoveredDevices.set(deviceKey, {
209
+ deviceType,
210
+ deviceId,
211
+ typeName: deviceTypeInfo ? deviceTypeInfo.name : `类型${deviceType}`,
212
+ meshType: deviceTypeInfo ? deviceTypeInfo.meshType : 'unknown',
213
+ channels: deviceTypeInfo ? deviceTypeInfo.channels : 1,
214
+ lastSeen: Date.now(),
215
+ lastState: {}
216
+ });
217
+ node.log(`发现品牌设备: ${deviceTypeInfo ? deviceTypeInfo.name : '未知'} ID:${deviceId}`);
218
+ }
219
+
220
+ // 更新设备状态
221
+ const device = node._discoveredDevices.get(deviceKey);
222
+ device.lastSeen = Date.now();
223
+ device.lastState[fn] = fv;
224
+
225
+ // 更新状态缓存
226
+ if (!node._deviceStates.has(deviceKey)) {
227
+ node._deviceStates.set(deviceKey, {});
228
+ }
229
+ node._deviceStates.get(deviceKey)[fn] = fv;
230
+
231
+ // 转换为Mesh属性
232
+ const fnConfig = node._protocol.functionCodes[deviceType]?.[fn];
233
+ let meshProp = null, meshValue = null;
234
+
235
+ if (fnConfig) {
236
+ meshProp = fnConfig.meshProp;
237
+ if (fnConfig.valueMap) {
238
+ meshValue = fnConfig.valueMap[fv];
239
+ } else {
240
+ meshValue = fv;
241
+ }
242
+ }
243
+
244
+ // 通知订阅者状态更新
245
+ notifySubscribers('state', {
246
+ deviceType, deviceId, fn, fv,
247
+ meshProp, meshValue,
248
+ deviceKey
249
+ });
250
+ } catch (e) {
251
+ // 静默处理解析错误
252
+ }
253
+ }
254
+
161
255
  // 通知订阅者
162
256
  function notifySubscribers(event, data) {
163
257
  node._subscribers.forEach(function(callback) {
@@ -169,16 +263,44 @@ module.exports = function(RED) {
169
263
 
170
264
  // 发布控制命令
171
265
  node.publish = function(st, si, fn, fv) {
172
- if (!node._client || !node._connected) return false;
266
+ if (!node._client || !node._connected) {
267
+ node.warn('品牌MQTT未连接,无法发送命令');
268
+ return false;
269
+ }
173
270
 
174
271
  const downTopic = getDownTopic();
175
272
  if (!downTopic) return false;
176
273
 
177
- const payload = JSON.stringify({ payload: { st, si, fn, fv } });
178
- node._client.publish(downTopic, payload);
274
+ const payload = node._protocol.buildMessage(st, si, fn, fv);
275
+ node._client.publish(downTopic, payload, { qos: 0 });
276
+ node.debug(`发送品牌命令: st=${st}, si=${si}, fn=${fn}, fv=${fv}`);
179
277
  return true;
180
278
  };
181
279
 
280
+ // 根据Mesh属性发送命令
281
+ node.publishByMeshProp = function(deviceType, deviceId, meshProp, meshValue) {
282
+ const fnConfigs = node._protocol.functionCodes[deviceType];
283
+ if (!fnConfigs) return false;
284
+
285
+ for (const [fn, config] of Object.entries(fnConfigs)) {
286
+ if (config.meshProp === meshProp) {
287
+ let fv;
288
+ if (config.reverseMap) {
289
+ fv = config.reverseMap[meshValue];
290
+ if (fv === undefined) fv = meshValue ? 1 : 0;
291
+ } else {
292
+ fv = parseInt(meshValue) || 0;
293
+ // 范围限制
294
+ if (config.range) {
295
+ fv = Math.max(config.range[0], Math.min(config.range[1], fv));
296
+ }
297
+ }
298
+ return node.publish(deviceType, deviceId, parseInt(fn), fv);
299
+ }
300
+ }
301
+ return false;
302
+ };
303
+
182
304
  // 订阅状态更新
183
305
  node.subscribe = function(callback) {
184
306
  node._subscribers.add(callback);
@@ -192,14 +314,55 @@ module.exports = function(RED) {
192
314
  return Array.from(node._discoveredDevices.values());
193
315
  };
194
316
 
317
+ // 获取设备状态
318
+ node.getDeviceState = function(deviceType, deviceId) {
319
+ const key = `${deviceType}_${deviceId}`;
320
+ return node._deviceStates.get(key) || {};
321
+ };
322
+
195
323
  // 检查是否已连接
196
324
  node.isConnected = function() {
197
325
  return node._connected;
198
326
  };
199
327
 
328
+ // 获取协议信息
329
+ node.getProtocol = function() {
330
+ return node._protocol;
331
+ };
332
+
333
+ // 定期清理过期设备(超过1小时未更新)
334
+ function startCleanupTimer() {
335
+ node._cleanupTimer = setInterval(function() {
336
+ const now = Date.now();
337
+ const expireTime = 3600000; // 1小时
338
+
339
+ for (const [key, device] of node._discoveredDevices) {
340
+ if (now - device.lastSeen > expireTime) {
341
+ node._discoveredDevices.delete(key);
342
+ node._deviceStates.delete(key);
343
+ }
344
+ }
345
+
346
+ // 限制设备数量
347
+ if (node._discoveredDevices.size > 200) {
348
+ const sorted = [...node._discoveredDevices.entries()]
349
+ .sort((a, b) => a[1].lastSeen - b[1].lastSeen);
350
+ sorted.slice(0, sorted.length - 200).forEach(([key]) => {
351
+ node._discoveredDevices.delete(key);
352
+ node._deviceStates.delete(key);
353
+ });
354
+ }
355
+ }, 60000);
356
+ }
357
+
200
358
  // 启动连接
201
359
  if (node.projectCode && node.deviceSn) {
202
- setTimeout(connect, 1000);
360
+ setTimeout(function() {
361
+ if (!node._closing) {
362
+ connect();
363
+ startCleanupTimer();
364
+ }
365
+ }, 1000);
203
366
  }
204
367
 
205
368
  // 清理
@@ -211,6 +374,11 @@ module.exports = function(RED) {
211
374
  node._reconnectTimer = null;
212
375
  }
213
376
 
377
+ if (node._cleanupTimer) {
378
+ clearInterval(node._cleanupTimer);
379
+ node._cleanupTimer = null;
380
+ }
381
+
214
382
  if (node._client) {
215
383
  try {
216
384
  node._client.end(true);
@@ -220,6 +388,7 @@ module.exports = function(RED) {
220
388
 
221
389
  node._subscribers.clear();
222
390
  node._discoveredDevices.clear();
391
+ node._deviceStates.clear();
223
392
  done();
224
393
  });
225
394
  }
@@ -235,4 +404,19 @@ module.exports = function(RED) {
235
404
  res.json([]);
236
405
  }
237
406
  });
407
+
408
+ // HTTP API - 获取支持的品牌列表
409
+ RED.httpAdmin.get('/symi-mqtt-brand/brands', function(req, res) {
410
+ const brands = Object.entries(BRAND_PROTOCOLS).map(([id, proto]) => ({
411
+ id,
412
+ name: proto.name,
413
+ needsProjectConfig: proto.needsProjectConfig,
414
+ deviceTypes: Object.entries(proto.deviceTypes).map(([type, info]) => ({
415
+ type: parseInt(type),
416
+ name: info.name,
417
+ meshType: info.meshType
418
+ }))
419
+ }));
420
+ res.json(brands);
421
+ });
238
422
  };