node-red-contrib-symi-mesh 1.7.1 → 1.7.3

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.
@@ -0,0 +1,473 @@
1
+ /**
2
+ * MQTT同步节点 - 实现第三方MQTT品牌设备与Mesh设备的双向同步
3
+ *
4
+ * 架构设计:
5
+ * - 使用symi-mqtt配置节点共享MQTT连接(与Mesh网关MQTT一致)
6
+ * - 品牌协议可扩展(HYQW、涂鸦等,后续添加)
7
+ * - 自动发现 + 手动映射
8
+ * - 双向状态同步,2秒防抖防死循环
9
+ * - 错误日志频率限制,避免网络故障时日志爆炸
10
+ */
11
+
12
+ module.exports = function(RED) {
13
+ const mqtt = require('mqtt');
14
+
15
+ // ===== 常量定义 =====
16
+ const SYNC_DEBOUNCE_MS = 2000;
17
+ const RECONNECT_INTERVAL = 5000;
18
+ const MAX_DISCOVERED = 200;
19
+ const CLEANUP_INTERVAL = 60000;
20
+ const ERROR_LOG_INTERVAL = 60000; // 错误日志最小间隔60秒
21
+
22
+ // ===== 品牌协议定义 (可扩展) =====
23
+ const BRAND_PROTOCOLS = {
24
+ hyqw: {
25
+ name: 'HYQW (花语前湾)',
26
+ needsProjectConfig: true,
27
+ getUploadTopic: (cfg) => cfg.projectCode && cfg.deviceSn ? `FMQ/${cfg.projectCode}/${cfg.deviceSn}/UPLOAD/2002` : null,
28
+ getDownTopic: (cfg) => cfg.projectCode && cfg.deviceSn ? `FMQ/${cfg.projectCode}/${cfg.deviceSn}/DOWN/2001` : null,
29
+ parseMessage: (payload) => {
30
+ try {
31
+ const data = JSON.parse(payload.toString());
32
+ if (!data.payload) return null;
33
+ const { st, si, fn, fv } = data.payload;
34
+ if (st === undefined || si === undefined || fn === undefined || fv === undefined) return null;
35
+ return { deviceType: st, deviceId: si, fn, fv };
36
+ } catch (e) { return null; }
37
+ },
38
+ buildMessage: (deviceType, deviceId, fn, fv) => {
39
+ return JSON.stringify({ payload: { st: deviceType, si: deviceId, fn, fv } });
40
+ },
41
+ deviceTypes: {
42
+ 8: { name: '灯具', meshType: 'light' },
43
+ 12: { name: '空调', meshType: 'climate' },
44
+ 14: { name: '窗帘', meshType: 'cover' },
45
+ 16: { name: '地暖', meshType: 'climate' },
46
+ 36: { name: '新风', meshType: 'fan' }
47
+ }
48
+ }
49
+ // 后续可扩展其他品牌: tuya, custom 等
50
+ };
51
+
52
+ // 通用映射
53
+ const AC_MODE_MAP = { 0: 'cool', 1: 'heat', 2: 'fan_only', 3: 'dry' };
54
+ const AC_MODE_REVERSE = { cool: 0, heat: 1, fan_only: 2, dry: 3 };
55
+ const FAN_SPEED_MAP = { 0: 'auto', 1: 'low', 2: 'medium', 3: 'high' };
56
+ const FAN_SPEED_REVERSE = { auto: 0, low: 1, medium: 2, high: 3 };
57
+
58
+ // ===== 节点注册 =====
59
+ function SymiMqttSyncNode(config) {
60
+ RED.nodes.createNode(this, config);
61
+ const node = this;
62
+
63
+ // 基础配置
64
+ node.name = config.name || 'MQTT同步';
65
+ node.mqttConfigId = config.mqttConfig;
66
+ node.brandMqttConfigId = config.brandMqttConfig;
67
+ node.autoDiscover = config.autoDiscover !== false;
68
+
69
+ // 获取symi-mqtt配置节点(Mesh网关MQTT)
70
+ node._mqttConfig = null;
71
+ node._gateway = null;
72
+ if (node.mqttConfigId) {
73
+ node._mqttConfig = RED.nodes.getNode(node.mqttConfigId);
74
+ if (node._mqttConfig) {
75
+ node._gateway = node._mqttConfig.gateway;
76
+ }
77
+ }
78
+
79
+ // 获取品牌MQTT配置节点
80
+ node._brandMqttConfig = null;
81
+ if (node.brandMqttConfigId) {
82
+ node._brandMqttConfig = RED.nodes.getNode(node.brandMqttConfigId);
83
+ }
84
+
85
+ // 从品牌MQTT配置节点获取参数
86
+ node.brand = node._brandMqttConfig ? node._brandMqttConfig.brand : 'hyqw';
87
+ node.projectCode = node._brandMqttConfig ? node._brandMqttConfig.projectCode : '';
88
+ node.deviceSn = node._brandMqttConfig ? node._brandMqttConfig.deviceSn : '';
89
+ node.brandMqttBroker = node._brandMqttConfig ? node._brandMqttConfig.mqttBroker : '';
90
+ node.brandMqttUsername = node._brandMqttConfig ? node._brandMqttConfig.mqttUsername : '';
91
+ node.brandMqttPassword = node._brandMqttConfig ? node._brandMqttConfig.mqttPassword : '';
92
+
93
+ // 获取品牌协议处理器
94
+ node._brandProtocol = BRAND_PROTOCOLS[node.brand] || BRAND_PROTOCOLS.hyqw;
95
+
96
+ // 解析映射配置
97
+ try {
98
+ node.mappings = JSON.parse(config.mappings || '[]');
99
+ } catch (e) {
100
+ node.mappings = [];
101
+ }
102
+
103
+ // 运行时状态
104
+ node._mqttClient = null;
105
+ node._connected = false;
106
+ node._reconnectTimer = null;
107
+ node._syncTimestamps = new Map();
108
+ node._deviceStates = new Map();
109
+ node._discoveredDevices = new Map();
110
+ node._cleanupTimer = null;
111
+ node._closing = false;
112
+ node._lastErrorLog = 0; // 错误日志限流
113
+
114
+ if (!node._mqttConfig) {
115
+ node.status({ fill: 'red', shape: 'ring', text: '未选择Mesh MQTT' });
116
+ return;
117
+ }
118
+
119
+ if (!node._gateway) {
120
+ node.status({ fill: 'red', shape: 'ring', text: 'Mesh网关未连接' });
121
+ return;
122
+ }
123
+
124
+ if (!node._brandMqttConfig) {
125
+ node.status({ fill: 'red', shape: 'ring', text: '未选择品牌MQTT' });
126
+ return;
127
+ }
128
+
129
+ // 初始化
130
+ node.status({ fill: 'yellow', shape: 'ring', text: '初始化...' });
131
+
132
+ // 延迟启动
133
+ setTimeout(() => {
134
+ if (!node._closing) {
135
+ connectMqtt();
136
+ setupGatewayListeners();
137
+ startCleanupTimer();
138
+ }
139
+ }, 2000);
140
+
141
+ // ===== 限流错误日志 =====
142
+ function logErrorThrottled(msg) {
143
+ const now = Date.now();
144
+ if (now - node._lastErrorLog > ERROR_LOG_INTERVAL) {
145
+ node._lastErrorLog = now;
146
+ node.warn(msg);
147
+ }
148
+ }
149
+
150
+ // ===== MQTT连接 =====
151
+ function connectMqtt() {
152
+ if (node._closing || node._mqttClient) return;
153
+
154
+ const uploadTopic = node._brandProtocol.getUploadTopic(node);
155
+ if (!uploadTopic) {
156
+ node.status({ fill: 'red', shape: 'ring', text: '品牌配置不完整' });
157
+ return;
158
+ }
159
+
160
+ // 使用品牌MQTT服务配置(独立连接)
161
+ const mqttBroker = node.brandMqttBroker || 'mqtt://localhost:1883';
162
+ const mqttUsername = node.brandMqttUsername || '';
163
+ const mqttPassword = node.brandMqttPassword || '';
164
+
165
+ const options = {
166
+ clientId: `symi_mqtt_sync_${node.id}_${Date.now()}`,
167
+ clean: true,
168
+ connectTimeout: 10000,
169
+ reconnectPeriod: 0,
170
+ keepalive: 60
171
+ };
172
+
173
+ if (mqttUsername) {
174
+ options.username = mqttUsername;
175
+ options.password = mqttPassword;
176
+ }
177
+
178
+ try {
179
+ node._mqttClient = mqtt.connect(mqttBroker, options);
180
+
181
+ node._mqttClient.on('connect', () => {
182
+ node._connected = true;
183
+ node._mqttClient.subscribe(uploadTopic, { qos: 0 }, (err) => {
184
+ if (!err) {
185
+ updateStatusWithCount();
186
+ }
187
+ });
188
+ });
189
+
190
+ node._mqttClient.on('message', handleMqttMessage);
191
+
192
+ node._mqttClient.on('error', (err) => {
193
+ node._connected = false;
194
+ logErrorThrottled(`MQTT错误: ${err.message}`);
195
+ scheduleReconnect();
196
+ });
197
+
198
+ node._mqttClient.on('close', () => {
199
+ node._connected = false;
200
+ if (!node._closing) {
201
+ node.status({ fill: 'red', shape: 'ring', text: '已断开' });
202
+ scheduleReconnect();
203
+ }
204
+ });
205
+
206
+ } catch (err) {
207
+ logErrorThrottled(`MQTT连接失败: ${err.message}`);
208
+ scheduleReconnect();
209
+ }
210
+ }
211
+
212
+ function scheduleReconnect() {
213
+ if (node._closing || node._reconnectTimer) return;
214
+ node._reconnectTimer = setTimeout(() => {
215
+ node._reconnectTimer = null;
216
+ if (node._mqttClient) {
217
+ try { node._mqttClient.end(true); } catch (e) {}
218
+ node._mqttClient = null;
219
+ }
220
+ connectMqtt();
221
+ }, RECONNECT_INTERVAL);
222
+ }
223
+
224
+ // ===== MQTT消息处理 =====
225
+ function handleMqttMessage(topic, message) {
226
+ if (node._closing) return;
227
+
228
+ try {
229
+ const parsed = node._brandProtocol.parseMessage(message);
230
+ if (!parsed) return;
231
+
232
+ const { deviceType, deviceId, fn, fv } = parsed;
233
+ const deviceKey = `${deviceType}_${deviceId}`;
234
+
235
+ // 自动发现
236
+ if (node.autoDiscover && !node._discoveredDevices.has(deviceKey)) {
237
+ if (node._discoveredDevices.size < MAX_DISCOVERED) {
238
+ const typeInfo = node._brandProtocol.deviceTypes[deviceType];
239
+ node._discoveredDevices.set(deviceKey, {
240
+ deviceType, deviceId,
241
+ typeName: typeInfo ? typeInfo.name : `类型${deviceType}`,
242
+ meshType: typeInfo ? typeInfo.meshType : 'unknown',
243
+ lastSeen: Date.now()
244
+ });
245
+ updateStatusWithCount();
246
+ }
247
+ }
248
+
249
+ // 更新设备状态缓存
250
+ if (!node._deviceStates.has(deviceKey)) {
251
+ node._deviceStates.set(deviceKey, {});
252
+ }
253
+ node._deviceStates.get(deviceKey)[fn] = fv;
254
+
255
+ // 同步到Mesh
256
+ syncToMesh(deviceType, deviceId, fn, fv);
257
+ } catch (e) {
258
+ // 静默
259
+ }
260
+ }
261
+
262
+ // ===== 同步到Mesh =====
263
+ function syncToMesh(deviceType, deviceId, fn, fv) {
264
+ if (!node._gateway?.deviceManager) return;
265
+
266
+ const mapping = node.mappings.find(m =>
267
+ parseInt(m.brandDeviceType) === deviceType && parseInt(m.brandDeviceId) === deviceId
268
+ );
269
+ if (!mapping || !mapping.meshMac) return;
270
+
271
+ // 防死循环
272
+ const syncKey = `mqtt_${deviceType}_${deviceId}_${fn}`;
273
+ const lastSync = node._syncTimestamps.get(syncKey) || 0;
274
+ if (Date.now() - lastSync < SYNC_DEBOUNCE_MS) return;
275
+
276
+ const meshMac = mapping.meshMac;
277
+ const meshChannel = parseInt(mapping.meshChannel) || 1;
278
+ const device = node._gateway.deviceManager.getDeviceByMac(meshMac);
279
+ if (!device) return;
280
+
281
+ node._syncTimestamps.set(syncKey, Date.now());
282
+ node._syncTimestamps.set(`mesh_${meshMac}_${meshChannel}`, Date.now());
283
+
284
+ const typeInfo = node._brandProtocol.deviceTypes[deviceType];
285
+ if (!typeInfo) return;
286
+
287
+ let property = '', value = null;
288
+ try {
289
+ if (fn === 1) {
290
+ property = 'switch'; value = fv === 1;
291
+ node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'switch', fv === 1);
292
+ } else if (fn === 2) {
293
+ property = typeInfo.meshType === 'light' ? 'brightness' :
294
+ typeInfo.meshType === 'cover' ? 'position' : 'temperature';
295
+ value = fv;
296
+ node._gateway.deviceManager.controlDevice(meshMac, meshChannel, property, fv);
297
+ } else if (fn === 3) {
298
+ if (typeInfo.meshType === 'climate') {
299
+ const mode = AC_MODE_MAP[fv];
300
+ if (mode) {
301
+ property = 'mode'; value = mode;
302
+ node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'mode', mode);
303
+ }
304
+ } else if (typeInfo.meshType === 'fan') {
305
+ const speed = FAN_SPEED_MAP[fv];
306
+ if (speed) {
307
+ property = 'fanSpeed'; value = speed;
308
+ node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'fanSpeed', speed);
309
+ }
310
+ }
311
+ } else if (fn === 4 && typeInfo.meshType === 'climate') {
312
+ const speed = FAN_SPEED_MAP[fv];
313
+ if (speed) {
314
+ property = 'fanSpeed'; value = speed;
315
+ node._gateway.deviceManager.controlDevice(meshMac, meshChannel, 'fanSpeed', speed);
316
+ }
317
+ }
318
+
319
+ // 输出到debug
320
+ if (property) {
321
+ node.send({
322
+ topic: 'mqtt-sync/brand-to-mesh',
323
+ payload: {
324
+ direction: 'Brand→Mesh',
325
+ brandType: deviceType, brandId: deviceId, fn, fv,
326
+ meshMac, meshChannel, property, value,
327
+ timestamp: Date.now()
328
+ }
329
+ });
330
+ }
331
+ } catch (e) {}
332
+ }
333
+
334
+ // ===== 同步到MQTT =====
335
+ function syncToMqtt(meshMac, channel, property, value) {
336
+ if (!node._connected || !node._mqttClient) return;
337
+
338
+ const mapping = node.mappings.find(m =>
339
+ m.meshMac?.toLowerCase() === meshMac?.toLowerCase() &&
340
+ parseInt(m.meshChannel) === channel
341
+ );
342
+ if (!mapping) return;
343
+
344
+ const deviceType = parseInt(mapping.brandDeviceType);
345
+ const deviceId = parseInt(mapping.brandDeviceId);
346
+ const typeInfo = node._brandProtocol.deviceTypes[deviceType];
347
+ if (!typeInfo) return;
348
+
349
+ // 防死循环
350
+ const syncKey = `mesh_${meshMac}_${channel}`;
351
+ const lastSync = node._syncTimestamps.get(syncKey) || 0;
352
+ if (Date.now() - lastSync < SYNC_DEBOUNCE_MS) return;
353
+ node._syncTimestamps.set(syncKey, Date.now());
354
+
355
+ let fn, fv;
356
+ if (property === 'switch' || property === 'on' || property === 'isOn') {
357
+ fn = 1; fv = value ? 1 : 0;
358
+ } else if (property === 'brightness' || property === 'temperature' || property === 'position') {
359
+ fn = 2; fv = parseInt(value) || 0;
360
+ } else if (property === 'mode' || property === 'hvacMode') {
361
+ fn = 3; fv = AC_MODE_REVERSE[value] ?? 0;
362
+ } else if (property === 'fanSpeed' || property === 'fanMode') {
363
+ fn = typeInfo.meshType === 'climate' ? 4 : 3;
364
+ fv = FAN_SPEED_REVERSE[value] ?? 0;
365
+ } else {
366
+ return;
367
+ }
368
+
369
+ const topic = node._brandProtocol.getDownTopic(node);
370
+ const payload = node._brandProtocol.buildMessage(deviceType, deviceId, fn, fv);
371
+
372
+ try {
373
+ node._mqttClient.publish(topic, payload, { qos: 0 });
374
+ node._syncTimestamps.set(`mqtt_${deviceType}_${deviceId}_${fn}`, Date.now());
375
+
376
+ // 输出到debug
377
+ node.send({
378
+ topic: 'mqtt-sync/mesh-to-brand',
379
+ payload: {
380
+ direction: 'Mesh→Brand',
381
+ meshMac, channel, property, value,
382
+ brandType: deviceType, brandId: deviceId, fn, fv,
383
+ timestamp: Date.now()
384
+ }
385
+ });
386
+ } catch (e) {}
387
+ }
388
+
389
+ // ===== 网关事件监听 =====
390
+ function setupGatewayListeners() {
391
+ if (!node._gateway?.deviceManager) return;
392
+
393
+ const handleStateChange = (event) => {
394
+ if (node._closing) return;
395
+ const { mac, channel, changes } = event;
396
+ if (!mac || !changes) return;
397
+ for (const [prop, val] of Object.entries(changes)) {
398
+ syncToMqtt(mac, channel || 1, prop, val);
399
+ }
400
+ };
401
+
402
+ node._gateway.deviceManager.on('device-state-changed', handleStateChange);
403
+ node._stateChangeHandler = handleStateChange;
404
+ }
405
+
406
+ // ===== 状态显示 =====
407
+ function updateStatusWithCount() {
408
+ if (node._closing) return;
409
+ const mapCount = node.mappings.length;
410
+ const discCount = node._discoveredDevices.size;
411
+ if (node._connected) {
412
+ node.status({ fill: 'green', shape: 'dot', text: `已连接 映射:${mapCount} 发现:${discCount}` });
413
+ }
414
+ }
415
+
416
+ // ===== 定期清理 =====
417
+ function startCleanupTimer() {
418
+ node._cleanupTimer = setInterval(() => {
419
+ if (node._closing) return;
420
+ const now = Date.now();
421
+ for (const [key, ts] of node._syncTimestamps) {
422
+ if (now - ts > 60000) node._syncTimestamps.delete(key);
423
+ }
424
+ if (node._discoveredDevices.size > MAX_DISCOVERED) {
425
+ const sorted = [...node._discoveredDevices.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen);
426
+ sorted.slice(0, sorted.length - MAX_DISCOVERED).forEach(([key]) => node._discoveredDevices.delete(key));
427
+ }
428
+ }, CLEANUP_INTERVAL);
429
+ }
430
+
431
+ // ===== 输入处理 =====
432
+ node.on('input', (msg) => {
433
+ if (node._closing) return;
434
+ if (msg.topic === 'refresh' || msg.payload?.action === 'refresh') {
435
+ updateStatusWithCount();
436
+ }
437
+ });
438
+
439
+ // ===== 节点关闭 =====
440
+ node.on('close', (done) => {
441
+ node._closing = true;
442
+ if (node._reconnectTimer) { clearTimeout(node._reconnectTimer); node._reconnectTimer = null; }
443
+ if (node._cleanupTimer) { clearInterval(node._cleanupTimer); node._cleanupTimer = null; }
444
+ if (node._gateway?.deviceManager && node._stateChangeHandler) {
445
+ node._gateway.deviceManager.removeListener('device-state-changed', node._stateChangeHandler);
446
+ }
447
+ if (node._mqttClient) { try { node._mqttClient.end(true); } catch (e) {} node._mqttClient = null; }
448
+ node._syncTimestamps.clear();
449
+ node._deviceStates.clear();
450
+ node._discoveredDevices.clear();
451
+ done();
452
+ });
453
+ }
454
+
455
+ // ===== HTTP API =====
456
+ RED.httpAdmin.get('/symi-mqtt-sync/discovered/:nodeId', (req, res) => {
457
+ const targetNode = RED.nodes.getNode(req.params.nodeId);
458
+ if (targetNode && targetNode._discoveredDevices) {
459
+ res.json(Array.from(targetNode._discoveredDevices.entries()).map(([key, info]) => ({ key, ...info })));
460
+ } else {
461
+ res.json([]);
462
+ }
463
+ });
464
+
465
+ RED.httpAdmin.get('/symi-mqtt-sync/brands', (req, res) => {
466
+ const brands = Object.entries(BRAND_PROTOCOLS).map(([id, proto]) => ({
467
+ id, name: proto.name, needsProjectConfig: proto.needsProjectConfig
468
+ }));
469
+ res.json(brands);
470
+ });
471
+
472
+ RED.nodes.registerType('symi-mqtt-sync', SymiMqttSyncNode);
473
+ };