node-red-contrib-symi-mesh 1.7.5 → 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.
- package/README.md +101 -75
- package/nodes/symi-485-bridge.html +71 -14
- package/nodes/symi-485-bridge.js +13 -13
- package/nodes/symi-ha-sync.html +451 -0
- package/nodes/symi-ha-sync.js +787 -0
- package/nodes/symi-mqtt-brand.js +243 -59
- package/nodes/symi-mqtt-sync.html +151 -42
- package/nodes/symi-mqtt-sync.js +45 -18
- package/package.json +2 -1
package/nodes/symi-mqtt-brand.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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(
|
|
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
|
};
|