node-red-contrib-symi-mesh 1.2.4 → 1.3.1
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 +311 -13
- package/lib/cloud-api.js +96 -0
- package/lib/device-manager.js +7 -0
- package/lib/mqtt-helper.js +33 -2
- package/lib/protocol.js +19 -2
- package/nodes/symi-cloud-sync.html +430 -0
- package/nodes/symi-cloud-sync.js +388 -0
- package/nodes/symi-device.html +7 -1
- package/nodes/symi-gateway.html +3 -3
- package/nodes/symi-gateway.js +104 -19
- package/nodes/symi-mqtt.html +28 -23
- package/nodes/symi-mqtt.js +46 -16
- package/package.json +5 -3
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symi Cloud Sync Node
|
|
3
|
+
* 云端数据同步节点 - 获取设备名称和场景信息
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CloudAPI = require('../lib/cloud-api');
|
|
7
|
+
const { generateSceneButtonConfig } = require('../lib/mqtt-helper');
|
|
8
|
+
|
|
9
|
+
module.exports = function(RED) {
|
|
10
|
+
function SymiCloudSyncNode(config) {
|
|
11
|
+
RED.nodes.createNode(this, config);
|
|
12
|
+
const node = this;
|
|
13
|
+
|
|
14
|
+
node.gateway = RED.nodes.getNode(config.gateway);
|
|
15
|
+
node.mqttConfig = RED.nodes.getNode(config.mqttConfig);
|
|
16
|
+
node.appId = config.appId;
|
|
17
|
+
node.appSecret = config.appSecret;
|
|
18
|
+
node.hotelId = config.hotelId;
|
|
19
|
+
node.roomNo = config.roomNo;
|
|
20
|
+
node.roomUuid = config.roomUuid;
|
|
21
|
+
node.autoSync = config.autoSync !== false;
|
|
22
|
+
node.selectedDevices = config.selectedDevices || [];
|
|
23
|
+
node.selectedScenes = config.selectedScenes || [];
|
|
24
|
+
|
|
25
|
+
if (!node.gateway) {
|
|
26
|
+
node.error('未配置网关');
|
|
27
|
+
node.status({ fill: 'red', shape: 'ring', text: '未配置网关' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!node.appId || !node.appSecret) {
|
|
32
|
+
node.warn('未配置云端认证信息');
|
|
33
|
+
node.status({ fill: 'yellow', shape: 'ring', text: '未配置认证' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
node.cloudAPI = new CloudAPI(node.appId, node.appSecret, node);
|
|
38
|
+
node.syncInProgress = false;
|
|
39
|
+
|
|
40
|
+
node.status({ fill: 'yellow', shape: 'ring', text: '等待同步' });
|
|
41
|
+
|
|
42
|
+
const loadCachedData = () => {
|
|
43
|
+
const cached = node.context().get('cloudData');
|
|
44
|
+
if (cached) {
|
|
45
|
+
node.log(`加载缓存的云端数据: ${cached.devices?.length || 0}个设备, ${cached.scenes?.length || 0}个场景`);
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const saveCachedData = (data) => {
|
|
52
|
+
node.context().set('cloudData', data);
|
|
53
|
+
node.log('云端数据已缓存');
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const syncFromCloud = async () => {
|
|
57
|
+
if (node.syncInProgress) {
|
|
58
|
+
node.warn('同步正在进行中,跳过');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!node.hotelId || !node.roomNo) {
|
|
63
|
+
node.warn('未配置酒店ID或房间号,跳过同步');
|
|
64
|
+
node.status({ fill: 'yellow', shape: 'ring', text: '未配置房间' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
node.syncInProgress = true;
|
|
69
|
+
node.status({ fill: 'blue', shape: 'dot', text: '同步中...' });
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
node.log(`开始从云端获取数据: 酒店ID=${node.hotelId}, 房间号=${node.roomNo}`);
|
|
73
|
+
|
|
74
|
+
const roomInfo = await node.cloudAPI.getRoomInfo({
|
|
75
|
+
hotel_id: parseInt(node.hotelId),
|
|
76
|
+
room_no: node.roomNo
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!roomInfo || !roomInfo.device_list) {
|
|
80
|
+
throw new Error('云端返回数据格式错误');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cloudData = {
|
|
84
|
+
room_no: roomInfo.room_no,
|
|
85
|
+
devices: roomInfo.device_list || [],
|
|
86
|
+
scenes: roomInfo.scene_list || [],
|
|
87
|
+
lastSync: new Date().toISOString()
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
node.log(`云端数据获取成功: ${cloudData.devices.length}个设备, ${cloudData.scenes.length}个场景`);
|
|
91
|
+
|
|
92
|
+
saveCachedData(cloudData);
|
|
93
|
+
|
|
94
|
+
applyCloudData(cloudData);
|
|
95
|
+
|
|
96
|
+
node.status({ fill: 'green', shape: 'dot', text: `已同步 ${new Date().toLocaleTimeString()}` });
|
|
97
|
+
|
|
98
|
+
} catch (error) {
|
|
99
|
+
node.error(`云端同步失败: ${error.message}`);
|
|
100
|
+
node.status({ fill: 'red', shape: 'ring', text: '同步失败' });
|
|
101
|
+
|
|
102
|
+
const cached = loadCachedData();
|
|
103
|
+
if (cached) {
|
|
104
|
+
node.log('使用缓存的云端数据');
|
|
105
|
+
applyCloudData(cached);
|
|
106
|
+
node.status({ fill: 'yellow', shape: 'dot', text: '使用缓存' });
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
node.syncInProgress = false;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const applyCloudData = (cloudData) => {
|
|
114
|
+
if (!cloudData || !cloudData.devices) {
|
|
115
|
+
node.warn('无可用的云端数据');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const devices = node.gateway.deviceManager.getAllDevices();
|
|
120
|
+
let matchedCount = 0;
|
|
121
|
+
let updatedCount = 0;
|
|
122
|
+
|
|
123
|
+
const devicesToSync = node.selectedDevices.length > 0
|
|
124
|
+
? cloudData.devices.filter(d => node.selectedDevices.includes(d.mac))
|
|
125
|
+
: cloudData.devices;
|
|
126
|
+
|
|
127
|
+
devicesToSync.forEach(cloudDevice => {
|
|
128
|
+
// 云端MAC地址是反序存储的,需要反转后匹配
|
|
129
|
+
const cloudMacRaw = cloudDevice.mac.toLowerCase().replace(/[:-]/g, '');
|
|
130
|
+
const cloudMacReversed = cloudMacRaw.match(/.{2}/g)?.reverse().join('') || cloudMacRaw;
|
|
131
|
+
|
|
132
|
+
const localDevice = devices.find(d => {
|
|
133
|
+
const localMac = d.macAddress.toLowerCase().replace(/[:-]/g, '');
|
|
134
|
+
return localMac === cloudMacReversed;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (localDevice) {
|
|
138
|
+
matchedCount++;
|
|
139
|
+
let deviceUpdated = false;
|
|
140
|
+
|
|
141
|
+
// 优先使用nick_name,如果是"未命名"则使用device_name,如果device_name也是"未命名"则保持原有名称
|
|
142
|
+
let newName = cloudDevice.nick_name;
|
|
143
|
+
if (!newName || newName === '未命名' || newName.trim() === '') {
|
|
144
|
+
newName = cloudDevice.device_name;
|
|
145
|
+
}
|
|
146
|
+
if (!newName || newName === '未命名' || newName.trim() === '') {
|
|
147
|
+
newName = localDevice.name; // 保持原有名称(包含设备类型)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (localDevice.name !== newName) {
|
|
151
|
+
const oldName = localDevice.name;
|
|
152
|
+
localDevice.name = newName;
|
|
153
|
+
deviceUpdated = true;
|
|
154
|
+
node.log(`设备名称已更新: ${oldName} -> ${newName} (MAC: ${cloudDevice.mac})`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 同步按键名称
|
|
158
|
+
if (cloudDevice.sub_device && Array.isArray(cloudDevice.sub_device) && cloudDevice.sub_device.length > 0) {
|
|
159
|
+
const newSubNames = cloudDevice.sub_device.map(sub => sub.sub_name);
|
|
160
|
+
const oldSubNames = localDevice.subDeviceNames || [];
|
|
161
|
+
|
|
162
|
+
node.log(`[按键名称] 设备: ${localDevice.name}, 云端按键数: ${cloudDevice.sub_device.length}, 按键名称: [${newSubNames.join(', ')}]`);
|
|
163
|
+
|
|
164
|
+
// 检查是否有变化
|
|
165
|
+
const hasChanged = newSubNames.length !== oldSubNames.length ||
|
|
166
|
+
newSubNames.some((name, idx) => name !== oldSubNames[idx]);
|
|
167
|
+
|
|
168
|
+
if (hasChanged) {
|
|
169
|
+
localDevice.subDeviceNames = newSubNames;
|
|
170
|
+
deviceUpdated = true;
|
|
171
|
+
node.log(`[按键名称] 设备按键名称已更新: ${localDevice.name} -> [${newSubNames.join(', ')}]`);
|
|
172
|
+
} else {
|
|
173
|
+
node.log(`[按键名称] 设备按键名称无变化: ${localDevice.name}`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
node.log(`[按键名称] 设备无sub_device数据: ${localDevice.name}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (deviceUpdated) {
|
|
180
|
+
updatedCount++;
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
node.log(`云端设备未匹配: ${cloudDevice.device_name} (云端MAC: ${cloudMacRaw} → 反转后: ${cloudMacReversed})`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
node.log(`设备匹配完成: ${matchedCount}/${devicesToSync.length} 匹配, ${updatedCount} 个名称已更新`);
|
|
188
|
+
|
|
189
|
+
if (cloudData.scenes && cloudData.scenes.length > 0) {
|
|
190
|
+
const scenesToSync = node.selectedScenes.length > 0
|
|
191
|
+
? cloudData.scenes.filter(s => node.selectedScenes.includes(s.scene_id))
|
|
192
|
+
: cloudData.scenes;
|
|
193
|
+
|
|
194
|
+
node.context().set('scenes', scenesToSync);
|
|
195
|
+
node.log(`场景列表已保存: ${scenesToSync.map(s => s.scene_name).join(', ')}`);
|
|
196
|
+
|
|
197
|
+
if (node.mqttConfig && scenesToSync.length > 0) {
|
|
198
|
+
publishSceneButtons(scenesToSync);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (updatedCount > 0 && node.mqttConfig) {
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
node.log('重新发布MQTT Discovery配置(名称已更新)');
|
|
205
|
+
node.mqttConfig.publishAllDiscovery(devices);
|
|
206
|
+
}, 1000);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const publishSceneButtons = (scenes) => {
|
|
211
|
+
if (!node.mqttConfig || !node.mqttConfig.mqttClient) {
|
|
212
|
+
node.warn('MQTT客户端未连接,无法发布场景按钮');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const mqttClient = node.mqttConfig.mqttClient;
|
|
217
|
+
const mqttPrefix = node.mqttConfig.mqttPrefix || 'homeassistant';
|
|
218
|
+
|
|
219
|
+
const roomNo = node.roomNo || 'unknown';
|
|
220
|
+
node.log(`开始发布 ${scenes.length} 个场景按钮到Home Assistant`);
|
|
221
|
+
|
|
222
|
+
scenes.forEach((scene, index) => {
|
|
223
|
+
const config = generateSceneButtonConfig(scene, roomNo, mqttPrefix);
|
|
224
|
+
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
mqttClient.publish(config.topic, config.payload, { retain: true }, (err) => {
|
|
227
|
+
if (err) {
|
|
228
|
+
node.error(`发布场景按钮失败: ${scene.scene_name}, ${err.message}`);
|
|
229
|
+
} else {
|
|
230
|
+
node.debug(`场景按钮已发布: ${scene.scene_name}`);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}, index * 100);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// 订阅场景触发主题
|
|
237
|
+
node.mqttConfig.subscribeSceneTriggers(roomNo);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
node.gateway.on('device-list-complete', () => {
|
|
241
|
+
if (node.autoSync) {
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
node.log('设备列表完成,开始自动同步云端数据');
|
|
244
|
+
syncFromCloud();
|
|
245
|
+
}, 2000);
|
|
246
|
+
} else {
|
|
247
|
+
const cached = loadCachedData();
|
|
248
|
+
if (cached) {
|
|
249
|
+
node.log('自动同步已禁用,使用缓存数据');
|
|
250
|
+
applyCloudData(cached);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// 监听场景触发事件
|
|
256
|
+
if (node.mqttConfig) {
|
|
257
|
+
node.mqttConfig.on('scene-trigger', (topic, message) => {
|
|
258
|
+
const roomNo = node.roomNo || 'unknown';
|
|
259
|
+
if (topic.startsWith(`symi_mesh/room_${roomNo}/scene/`) && topic.endsWith('/trigger')) {
|
|
260
|
+
const sceneIdMatch = topic.match(/scene\/(\d+)\/trigger/);
|
|
261
|
+
if (sceneIdMatch) {
|
|
262
|
+
const sceneId = parseInt(sceneIdMatch[1]);
|
|
263
|
+
const scenes = node.context().get('scenes') || [];
|
|
264
|
+
const scene = scenes.find(s => s.scene_id === sceneId);
|
|
265
|
+
|
|
266
|
+
if (scene) {
|
|
267
|
+
node.log(`[场景控制] 收到场景触发请求: ${scene.scene_name} (ID: ${sceneId})`);
|
|
268
|
+
|
|
269
|
+
if (node.gateway && node.gateway.connected) {
|
|
270
|
+
// 构建场景控制帧
|
|
271
|
+
const frame = node.gateway.protocolHandler.buildSceneControlFrame(sceneId);
|
|
272
|
+
node.log(`[场景控制] 场景控制帧: ${frame.toString('hex').toUpperCase()}`);
|
|
273
|
+
|
|
274
|
+
node.gateway.sendScene(sceneId).then(() => {
|
|
275
|
+
node.log(`[场景控制] ✓ 场景控制命令已发送: ${scene.scene_name} (ID: ${sceneId})`);
|
|
276
|
+
}).catch(err => {
|
|
277
|
+
node.error(`[场景控制] ✗ 场景控制命令发送失败: ${err.message}`);
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
node.error('[场景控制] 网关未连接,无法执行场景');
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
node.warn(`[场景控制] 场景ID ${sceneId} 未找到`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
node.on('input', async function(msg) {
|
|
291
|
+
if (msg.payload === 'sync' || msg.payload === true) {
|
|
292
|
+
node.log('收到手动同步请求');
|
|
293
|
+
await syncFromCloud();
|
|
294
|
+
} else if (msg.payload && msg.payload.scene_id !== undefined) {
|
|
295
|
+
const scenes = node.context().get('scenes') || [];
|
|
296
|
+
const scene = scenes.find(s => s.scene_id === msg.payload.scene_id);
|
|
297
|
+
if (scene) {
|
|
298
|
+
node.log(`执行场景: ${scene.scene_name} (ID: ${scene.scene_id})`);
|
|
299
|
+
msg.payload = { scene_id: scene.scene_id };
|
|
300
|
+
node.send(msg);
|
|
301
|
+
} else {
|
|
302
|
+
node.warn(`场景ID ${msg.payload.scene_id} 不存在`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
node.on('close', (done) => {
|
|
308
|
+
node.log('云端同步节点关闭');
|
|
309
|
+
done();
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
RED.nodes.registerType('symi-cloud-sync', SymiCloudSyncNode);
|
|
314
|
+
|
|
315
|
+
RED.httpAdmin.post('/symi-cloud-sync/test-connection', async (req, res) => {
|
|
316
|
+
try {
|
|
317
|
+
const { appId, appSecret } = req.body;
|
|
318
|
+
if (!appId || !appSecret) {
|
|
319
|
+
return res.json({ success: false, error: '缺少认证信息' });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const api = new CloudAPI(appId, appSecret);
|
|
323
|
+
await api.getHotelList(1, 1);
|
|
324
|
+
|
|
325
|
+
res.json({ success: true });
|
|
326
|
+
} catch (error) {
|
|
327
|
+
res.json({ success: false, error: error.message });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
RED.httpAdmin.post('/symi-cloud-sync/get-hotels', async (req, res) => {
|
|
332
|
+
try {
|
|
333
|
+
const { appId, appSecret } = req.body;
|
|
334
|
+
if (!appId || !appSecret) {
|
|
335
|
+
return res.json({ success: false, error: '缺少认证信息' });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const api = new CloudAPI(appId, appSecret);
|
|
339
|
+
const result = await api.getHotelList(1, 100);
|
|
340
|
+
|
|
341
|
+
res.json({
|
|
342
|
+
success: true,
|
|
343
|
+
hotels: result.data || []
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
res.json({ success: false, error: error.message });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
RED.httpAdmin.post('/symi-cloud-sync/get-rooms', async (req, res) => {
|
|
351
|
+
try {
|
|
352
|
+
const { appId, appSecret, hotelId } = req.body;
|
|
353
|
+
if (!appId || !appSecret || !hotelId) {
|
|
354
|
+
return res.json({ success: false, error: '缺少必要参数' });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const api = new CloudAPI(appId, appSecret);
|
|
358
|
+
const result = await api.getRoomList(hotelId, 1, 100);
|
|
359
|
+
|
|
360
|
+
res.json({
|
|
361
|
+
success: true,
|
|
362
|
+
rooms: result.data || []
|
|
363
|
+
});
|
|
364
|
+
} catch (error) {
|
|
365
|
+
res.json({ success: false, error: error.message });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
RED.httpAdmin.post('/symi-cloud-sync/get-room-info', async (req, res) => {
|
|
370
|
+
try {
|
|
371
|
+
const { appId, appSecret, roomUuid } = req.body;
|
|
372
|
+
if (!appId || !appSecret || !roomUuid) {
|
|
373
|
+
return res.json({ success: false, error: '缺少必要参数' });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const api = new CloudAPI(appId, appSecret);
|
|
377
|
+
const result = await api.getRoomInfo({ room_uuid: roomUuid });
|
|
378
|
+
|
|
379
|
+
res.json({
|
|
380
|
+
success: true,
|
|
381
|
+
roomInfo: result
|
|
382
|
+
});
|
|
383
|
+
} catch (error) {
|
|
384
|
+
res.json({ success: false, error: error.message });
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
};
|
|
388
|
+
|
package/nodes/symi-device.html
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
defaults: {
|
|
6
6
|
name: { value: '' },
|
|
7
7
|
gateway: { value: '', type: 'symi-gateway', required: true },
|
|
8
|
+
mqttConfig: { value: '', type: 'symi-mqtt' },
|
|
8
9
|
deviceMac: { value: '', required: true },
|
|
9
10
|
channel: { value: 1 },
|
|
10
11
|
deviceData: { value: '' },
|
|
@@ -161,7 +162,12 @@
|
|
|
161
162
|
<label for="node-input-gateway"><i class="fa fa-server"></i> 网关</label>
|
|
162
163
|
<input type="text" id="node-input-gateway">
|
|
163
164
|
</div>
|
|
164
|
-
|
|
165
|
+
|
|
166
|
+
<div class="form-row">
|
|
167
|
+
<label for="node-input-mqttConfig"><i class="fa fa-exchange"></i> MQTT配置</label>
|
|
168
|
+
<input type="text" id="node-input-mqttConfig">
|
|
169
|
+
</div>
|
|
170
|
+
|
|
165
171
|
<div class="form-row">
|
|
166
172
|
<label for="node-input-deviceMac"><i class="fa fa-microchip"></i> 设备</label>
|
|
167
173
|
<select id="node-input-deviceMac" style="width:70%">
|
package/nodes/symi-gateway.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
baudRate: { value: 115200 }
|
|
11
11
|
},
|
|
12
12
|
label: function() {
|
|
13
|
-
return this.name || (this.connectionType === 'tcp'
|
|
14
|
-
? `Symi Gateway (${this.host}:${this.port})`
|
|
13
|
+
return this.name || (this.connectionType === 'tcp'
|
|
14
|
+
? `Symi Gateway (${this.host}:${this.port})`
|
|
15
15
|
: `Symi Gateway (${this.serialPort})`);
|
|
16
16
|
},
|
|
17
17
|
oneditprepare: function() {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
<label for="node-config-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
34
34
|
<input type="text" id="node-config-input-name" placeholder="网关名称">
|
|
35
35
|
</div>
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
<div class="form-row">
|
|
38
38
|
<label for="node-config-input-connectionType"><i class="fa fa-plug"></i> 连接方式</label>
|
|
39
39
|
<select id="node-config-input-connectionType">
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -10,21 +10,27 @@ const { ProtocolHandler, parseStatusEvent, OP_RESP_DEVICE_LIST } = require('../l
|
|
|
10
10
|
module.exports = function(RED) {
|
|
11
11
|
function SymiGatewayNode(config) {
|
|
12
12
|
RED.nodes.createNode(this, config);
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
this.name = config.name;
|
|
15
15
|
this.connectionType = config.connectionType || 'tcp';
|
|
16
16
|
this.host = config.host;
|
|
17
17
|
this.port = parseInt(config.port) || 4196;
|
|
18
18
|
this.serialPort = config.serialPort;
|
|
19
19
|
this.baudRate = parseInt(config.baudRate) || 115200;
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
this.client = null;
|
|
22
22
|
this.deviceManager = new DeviceManager(this.context(), this);
|
|
23
23
|
this.protocolHandler = new ProtocolHandler();
|
|
24
24
|
this.connected = false;
|
|
25
25
|
this.deviceListComplete = false;
|
|
26
26
|
this.isQueryingStates = false; // 标记是否正在查询状态
|
|
27
|
-
|
|
27
|
+
|
|
28
|
+
// 状态事件处理队列 - 确保场景执行后的大量状态更新能被正确处理
|
|
29
|
+
this.stateEventQueue = [];
|
|
30
|
+
this.isProcessingStateEvent = false;
|
|
31
|
+
this.sceneExecutionInProgress = false; // 场景执行中标志
|
|
32
|
+
this.sceneExecutionTimer = null; // 场景执行超时定时器
|
|
33
|
+
|
|
28
34
|
this.log(`Initializing Symi Gateway: ${this.connectionType === 'tcp' ? `${this.host}:${this.port}` : this.serialPort}`);
|
|
29
35
|
|
|
30
36
|
// 监听三合一设备检测事件
|
|
@@ -113,6 +119,20 @@ module.exports = function(RED) {
|
|
|
113
119
|
// 初始连接失败,但自动重连会继续尝试
|
|
114
120
|
this.error(`Initial connection failed: ${error.message}, will retry automatically`);
|
|
115
121
|
}
|
|
122
|
+
|
|
123
|
+
// 节点关闭时清理资源
|
|
124
|
+
this.on('close', (done) => {
|
|
125
|
+
// 清理场景执行定时器
|
|
126
|
+
if (this.sceneExecutionTimer) {
|
|
127
|
+
clearTimeout(this.sceneExecutionTimer);
|
|
128
|
+
this.sceneExecutionTimer = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 清空队列
|
|
132
|
+
this.stateEventQueue = [];
|
|
133
|
+
|
|
134
|
+
done();
|
|
135
|
+
});
|
|
116
136
|
};
|
|
117
137
|
|
|
118
138
|
SymiGatewayNode.prototype.disconnect = async function() {
|
|
@@ -131,16 +151,75 @@ module.exports = function(RED) {
|
|
|
131
151
|
if (frame.opcode === OP_RESP_DEVICE_LIST && frame.status === 0x00) {
|
|
132
152
|
this.parseDeviceListFrame(frame);
|
|
133
153
|
} else if (frame.isDeviceStatusEvent()) {
|
|
154
|
+
// 将状态事件加入队列处理
|
|
155
|
+
this.stateEventQueue.push(frame);
|
|
156
|
+
this.processStateEventQueue();
|
|
157
|
+
} else if (frame.opcode === 0xB0) {
|
|
158
|
+
// 控制命令响应
|
|
159
|
+
this.debug(`[控制响应] 0xB0: ${frameHex} ${frame.status === 0 ? '(成功)' : '(失败)'}`)
|
|
160
|
+
} else if (frame.opcode === 0xB4) {
|
|
161
|
+
// 场景控制响应
|
|
162
|
+
if (frame.status === 0) {
|
|
163
|
+
this.log(`[场景控制] 场景控制命令已被网关接受`);
|
|
164
|
+
// 标记场景执行开始,2秒内的状态事件都认为是场景执行导致的
|
|
165
|
+
this.sceneExecutionInProgress = true;
|
|
166
|
+
if (this.sceneExecutionTimer) {
|
|
167
|
+
clearTimeout(this.sceneExecutionTimer);
|
|
168
|
+
}
|
|
169
|
+
this.sceneExecutionTimer = setTimeout(() => {
|
|
170
|
+
this.sceneExecutionInProgress = false;
|
|
171
|
+
this.log(`[场景控制] 场景执行完成`);
|
|
172
|
+
}, 2000);
|
|
173
|
+
} else {
|
|
174
|
+
this.error(`[场景控制] 场景控制命令失败: status=${frame.status}`);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
// 其他frame类型
|
|
178
|
+
this.debug(`[其他Frame] ${frameHex}`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// 处理状态事件队列 - 使用异步队列确保所有事件都被处理
|
|
183
|
+
SymiGatewayNode.prototype.processStateEventQueue = async function() {
|
|
184
|
+
if (this.isProcessingStateEvent || this.stateEventQueue.length === 0) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.isProcessingStateEvent = true;
|
|
189
|
+
|
|
190
|
+
while (this.stateEventQueue.length > 0) {
|
|
191
|
+
const frame = this.stateEventQueue.shift();
|
|
192
|
+
|
|
134
193
|
try {
|
|
135
194
|
const event = parseStatusEvent(frame);
|
|
136
|
-
|
|
137
|
-
|
|
195
|
+
|
|
196
|
+
// 检查是否是场景执行通知事件
|
|
197
|
+
if (event.subOpcode === 0x11) {
|
|
198
|
+
const sceneId = event.parameters.length > 0 ? event.parameters[0] : 'unknown';
|
|
199
|
+
const frameHex = Buffer.concat([
|
|
200
|
+
Buffer.from([frame.header, frame.opcode, frame.status, frame.length]),
|
|
201
|
+
frame.payload,
|
|
202
|
+
Buffer.from([frame.checksum])
|
|
203
|
+
]).toString('hex').toUpperCase();
|
|
204
|
+
this.log(`[场景执行] 收到场景执行通知事件: 场景ID=${sceneId}, 设备地址=0x${event.networkAddress.toString(16).toUpperCase()}, 原始帧=${frameHex}`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 记录状态事件
|
|
209
|
+
const logPrefix = this.sceneExecutionInProgress ? '[场景执行]' : '[状态事件]';
|
|
210
|
+
if (this.sceneExecutionInProgress) {
|
|
211
|
+
// 场景执行期间使用log级别,便于查看
|
|
212
|
+
this.log(`${logPrefix} 地址=0x${event.networkAddress.toString(16).toUpperCase()}, 消息类型=0x${event.attrType.toString(16).toUpperCase()}, 参数=[${Array.from(event.parameters).map(p => '0x' + p.toString(16).toUpperCase()).join(', ')}]`);
|
|
213
|
+
} else {
|
|
214
|
+
this.debug(`${logPrefix} 地址=0x${event.networkAddress.toString(16).toUpperCase()}, 消息类型=0x${event.attrType.toString(16).toUpperCase()}, 参数=[${Array.from(event.parameters).map(p => '0x' + p.toString(16).toUpperCase()).join(', ')}]`);
|
|
215
|
+
}
|
|
216
|
+
|
|
138
217
|
const device = this.deviceManager.updateDeviceState(
|
|
139
218
|
event.networkAddress,
|
|
140
219
|
event.attrType,
|
|
141
220
|
event.parameters
|
|
142
221
|
);
|
|
143
|
-
|
|
222
|
+
|
|
144
223
|
// 只在非查询状态期间才发送state-changed事件到MQTT
|
|
145
224
|
// 查询状态期间的事件只用于更新设备状态,不触发MQTT发布
|
|
146
225
|
if (device && !this.isQueryingStates) {
|
|
@@ -148,24 +227,26 @@ module.exports = function(RED) {
|
|
|
148
227
|
device: device,
|
|
149
228
|
attrType: event.attrType,
|
|
150
229
|
parameters: event.parameters,
|
|
151
|
-
state: device.state
|
|
230
|
+
state: device.state,
|
|
231
|
+
isSceneExecution: this.sceneExecutionInProgress // 标记是否是场景执行导致的状态变化
|
|
152
232
|
});
|
|
153
233
|
}
|
|
234
|
+
|
|
235
|
+
// 添加小延迟,避免CPU占用过高
|
|
236
|
+
if (this.stateEventQueue.length > 0) {
|
|
237
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
238
|
+
}
|
|
154
239
|
} catch (error) {
|
|
155
240
|
this.error(`Error parsing status event: ${error.message}`);
|
|
156
241
|
}
|
|
157
|
-
} else if (frame.opcode === 0xB0) {
|
|
158
|
-
// 控制命令响应
|
|
159
|
-
this.debug(`[控制响应] 0xB0: ${frameHex} ${frame.status === 0 ? '(成功)' : '(失败)'}`)
|
|
160
|
-
} else {
|
|
161
|
-
// 其他frame类型
|
|
162
|
-
this.debug(`[其他Frame] ${frameHex}`);
|
|
163
242
|
}
|
|
243
|
+
|
|
244
|
+
this.isProcessingStateEvent = false;
|
|
164
245
|
};
|
|
165
246
|
|
|
166
247
|
SymiGatewayNode.prototype.parseDeviceListFrame = function(frame) {
|
|
167
248
|
if (frame.payload.length < 16) return;
|
|
168
|
-
|
|
249
|
+
|
|
169
250
|
const maxDevices = frame.payload[0];
|
|
170
251
|
const index = frame.payload[1];
|
|
171
252
|
const macBytes = frame.payload.slice(2, 8);
|
|
@@ -209,14 +290,15 @@ module.exports = function(RED) {
|
|
|
209
290
|
SymiGatewayNode.prototype.queryAllDeviceStates = async function() {
|
|
210
291
|
const devices = this.deviceManager.getAllDevices();
|
|
211
292
|
this.log(`开始查询${devices.length}个设备的初始状态...`);
|
|
212
|
-
|
|
293
|
+
|
|
213
294
|
for (const device of devices) {
|
|
214
295
|
try {
|
|
215
296
|
let queryAttrs = [];
|
|
216
|
-
|
|
297
|
+
|
|
217
298
|
// 根据设备类型查询不同属性
|
|
218
299
|
if ([1, 2, 3].includes(device.deviceType)) {
|
|
219
300
|
queryAttrs = [0x02]; // 开关状态
|
|
301
|
+
this.log(`查询开关设备: ${device.name} (地址=0x${device.networkAddress.toString(16).toUpperCase()}, 路数=${device.channels})`);
|
|
220
302
|
} else if (device.deviceType === 9) {
|
|
221
303
|
queryAttrs = [0x02, 0x0E]; // 插卡取电:开关状态、插卡状态
|
|
222
304
|
} else if (device.deviceType === 4 || device.deviceType === 0x18) {
|
|
@@ -367,17 +449,20 @@ module.exports = function(RED) {
|
|
|
367
449
|
|
|
368
450
|
// 使用状态组合算法
|
|
369
451
|
const stateValue = this.protocolHandler.buildSwitchState(channels, targetChannel, targetState, currentState);
|
|
370
|
-
|
|
452
|
+
|
|
371
453
|
let controlParam;
|
|
454
|
+
let stateValueHex;
|
|
372
455
|
if (Buffer.isBuffer(stateValue)) {
|
|
373
456
|
// 6路开关,2字节状态
|
|
374
457
|
controlParam = stateValue;
|
|
458
|
+
stateValueHex = stateValue.toString('hex').toUpperCase();
|
|
375
459
|
} else {
|
|
376
460
|
// 1-4路开关,1字节状态
|
|
377
461
|
controlParam = Buffer.from([stateValue]);
|
|
462
|
+
stateValueHex = stateValue.toString(16).toUpperCase();
|
|
378
463
|
}
|
|
379
|
-
|
|
380
|
-
this.debug(`发送开关控制: 地址0x${networkAddr.toString(16).toUpperCase()}, 第${targetChannel}路${targetState ? '开' : '关'}, 状态值0x${
|
|
464
|
+
|
|
465
|
+
this.debug(`发送开关控制: 地址0x${networkAddr.toString(16).toUpperCase()}, 第${targetChannel}路${targetState ? '开' : '关'}, 状态值0x${stateValueHex}`);
|
|
381
466
|
const frame = this.protocolHandler.buildDeviceControlFrame(networkAddr, attrType, controlParam);
|
|
382
467
|
return await this.client.sendFrame(frame, 1);
|
|
383
468
|
|