node-red-contrib-symi-mesh 1.2.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,1113 @@
1
+ /**
2
+ * Symi MQTT Bridge Node
3
+ * 提供Home Assistant MQTT Discovery和状态同步
4
+ */
5
+
6
+ const mqtt = require('mqtt');
7
+ const { generateDiscoveryConfig, generateStateTopics, convertStateValue } = require('../lib/mqtt-helper');
8
+
9
+ module.exports = function(RED) {
10
+ function SymiMQTTNode(config) {
11
+ RED.nodes.createNode(this, config);
12
+ const node = this;
13
+
14
+ node.gateway = RED.nodes.getNode(config.gateway);
15
+ node.mqttBroker = config.mqttBroker;
16
+ node.mqttUsername = config.mqttUsername;
17
+ node.mqttPassword = config.mqttPassword;
18
+ node.mqttPrefix = config.mqttPrefix || 'homeassistant';
19
+
20
+ if (!node.gateway) {
21
+ node.error('未配置网关');
22
+ node.status({ fill: 'red', shape: 'ring', text: '未配置网关' });
23
+ return;
24
+ }
25
+
26
+ node.mqttClient = null;
27
+ node.publishedDevices = new Set();
28
+ node.subscriptions = new Map();
29
+
30
+ node.status({ fill: 'yellow', shape: 'ring', text: '连接中' });
31
+
32
+ node.connectMQTT();
33
+
34
+ node.gateway.on('device-list-complete', (devices) => {
35
+ if (node.mqttClient && node.mqttClient.connected) {
36
+ setTimeout(() => {
37
+ node.log(`网关设备列表同步完成,发布${devices.length}个设备到MQTT`);
38
+ node.publishAllDiscovery(devices);
39
+ }, 500);
40
+ } else {
41
+ node.warn('设备列表已完成但MQTT未连接,等待MQTT连接后发布');
42
+ }
43
+ });
44
+
45
+ node.gateway.on('gateway-connected', () => {
46
+ node.log('网关已连接,等待设备发现完成');
47
+ });
48
+
49
+ node.gateway.on('gateway-disconnected', () => {
50
+ node.log('网关已断开');
51
+ });
52
+
53
+ node.gateway.on('device-state-changed', (eventData) => {
54
+ if (node.mqttClient && node.mqttClient.connected) {
55
+ node.publishDeviceState(eventData);
56
+ }
57
+ });
58
+
59
+
60
+ node.on('close', (done) => {
61
+ if (node.mqttClient && node.mqttClient.connected) {
62
+ const devices = node.gateway.deviceManager.getAllDevices();
63
+ devices.forEach(device => {
64
+ const macClean = device.macAddress.replace(/:/g, '').toLowerCase();
65
+ node.mqttClient.publish(`symi_mesh/${macClean}/availability`, 'offline', { retain: true });
66
+ });
67
+
68
+ setTimeout(() => {
69
+ node.mqttClient.end(false, {}, done);
70
+ }, 200);
71
+ } else {
72
+ done();
73
+ }
74
+ });
75
+ }
76
+
77
+ SymiMQTTNode.prototype.connectMQTT = function() {
78
+ const node = this;
79
+
80
+ try {
81
+ const options = {
82
+ clientId: `symi-mesh-${Math.random().toString(16).substring(2, 10)}`,
83
+ clean: true,
84
+ reconnectPeriod: 5000
85
+ };
86
+
87
+ if (node.mqttUsername) {
88
+ options.username = node.mqttUsername;
89
+ options.password = node.mqttPassword;
90
+ }
91
+
92
+ node.mqttClient = mqtt.connect(node.mqttBroker, options);
93
+
94
+ node.mqttClient.on('message', (topic, message) => {
95
+ node.log(`[MQTT消息] topic=${topic}, message=${message.toString()}`);
96
+ node.handleMQTTMessage(topic, message);
97
+ });
98
+
99
+ node.mqttClient.on('connect', () => {
100
+ node.log('MQTT已连接');
101
+ node.status({ fill: 'green', shape: 'dot', text: '已连接' });
102
+
103
+ // MQTT重连后需要重新发布设备(因为broker可能重启了)
104
+ node.publishedDevices.clear();
105
+ node.subscriptions.clear();
106
+
107
+ if (node.gateway.deviceListComplete) {
108
+ const devices = node.gateway.deviceManager.getAllDevices();
109
+ setTimeout(() => {
110
+ node.log(`MQTT重连后重新发布${devices.length}个设备`);
111
+ node.publishAllDiscovery(devices);
112
+ }, 1000);
113
+ }
114
+ });
115
+
116
+ node.mqttClient.on('reconnect', () => {
117
+ node.log('MQTT正在重连...');
118
+ node.status({ fill: 'yellow', shape: 'ring', text: '重连中' });
119
+ });
120
+
121
+ node.mqttClient.on('error', (error) => {
122
+ node.error(`MQTT错误: ${error.message}`);
123
+ node.status({ fill: 'red', shape: 'ring', text: '错误' });
124
+ });
125
+
126
+ node.mqttClient.on('offline', () => {
127
+ node.status({ fill: 'yellow', shape: 'ring', text: '离线' });
128
+ });
129
+
130
+ } catch (error) {
131
+ node.error(`MQTT连接失败: ${error.message}`);
132
+ node.status({ fill: 'red', shape: 'ring', text: '失败' });
133
+ }
134
+ };
135
+
136
+ SymiMQTTNode.prototype.publishAllDiscovery = function(devices) {
137
+ const node = this;
138
+
139
+ const supportedTypes = [1, 2, 3, 4, 5, 8, 9, 10, 0x18];
140
+
141
+ devices.forEach(device => {
142
+ const macClean = device.macAddress.replace(/:/g, '').toLowerCase();
143
+
144
+ // 跳过正在检测中的设备(等待三合一识别完成)
145
+ if (device.needsThreeInOneCheck) {
146
+ node.log(`设备${device.name}正在检测类型,跳过发布(等待识别完成)`);
147
+ return; // 不添加到publishedDevices,等识别完成后再发布
148
+ }
149
+
150
+ // 跳过温控器类型但未确认的设备(防止在识别过程中被提前发布)
151
+ if (device.deviceType === 10 && !device.isThreeInOne && !device.thermostatConfirmed) {
152
+ node.log(`温控器${device.name}尚未确认类型,跳过发布`);
153
+ return;
154
+ }
155
+
156
+ // 三合一设备通过isThreeInOne标记识别,不依赖deviceType
157
+ if (!supportedTypes.includes(device.deviceType) && !device.isThreeInOne) {
158
+ node.log(`跳过非支持设备: ${device.name} (type=${device.deviceType})`);
159
+ return;
160
+ }
161
+
162
+ if (node.publishedDevices.has(macClean)) {
163
+ node.log(`设备已发布,跳过: ${device.name}`);
164
+ return;
165
+ }
166
+
167
+ const configs = generateDiscoveryConfig(device, node.mqttPrefix);
168
+ const topics = generateStateTopics(device);
169
+
170
+ node.log(`发布设备 ${device.name} (${configs.length}个实体)`);
171
+
172
+ // 先立即发布availability: online,确保HA认为设备可用
173
+ node.mqttClient.publish(`symi_mesh/${macClean}/availability`, 'online', { retain: true }, (err) => {
174
+ if (err) {
175
+ node.error(`发布availability失败: ${err.message}`);
176
+ }
177
+ });
178
+
179
+ // 延迟发布discovery config,确保availability先到达
180
+ configs.forEach((config, index) => {
181
+ setTimeout(() => {
182
+ node.mqttClient.publish(config.topic, config.payload, { retain: true }, (err) => {
183
+ if (err) {
184
+ node.error(`发布discovery失败: ${config.topic}, ${err.message}`);
185
+ }
186
+ });
187
+ }, index * 50 + 100);
188
+ });
189
+
190
+ topics.command.forEach(topic => {
191
+ if (!node.subscriptions.has(topic)) {
192
+ node.mqttClient.subscribe(topic, (err) => {
193
+ if (!err) {
194
+ node.subscriptions.set(topic, device.macAddress);
195
+ node.log(`订阅topic: ${topic} → ${device.macAddress}`);
196
+ } else {
197
+ node.error(`订阅失败: ${topic}, ${err.message}`);
198
+ }
199
+ });
200
+ }
201
+ });
202
+
203
+ // 发布初始状态 - 在discovery config发布后
204
+ setTimeout(() => {
205
+ publishInitialDeviceState(device, node);
206
+ }, configs.length * 50 + 300);
207
+
208
+ node.publishedDevices.add(macClean);
209
+ });
210
+ };
211
+
212
+ // 发布设备初始状态
213
+ function publishInitialDeviceState(device, node) {
214
+ const macClean = device.macAddress.replace(/:/g, '').toLowerCase();
215
+
216
+ // 先发布availability: online,确保HA实体可用
217
+ node.mqttClient.publish(`symi_mesh/${macClean}/availability`, 'online', { retain: true }, (err) => {
218
+ if (err) {
219
+ node.error(`发布availability失败: ${err.message}`);
220
+ }
221
+ });
222
+
223
+ // 根据设备类型发布默认状态
224
+ switch (device.deviceType) {
225
+ case 1:
226
+ case 2:
227
+ case 3: // 零火开关、单火开关、智能插座
228
+ for (let i = 1; i <= device.channels; i++) {
229
+ const channelSuffix = device.channels === 1 ? '' : `_${i}`;
230
+ node.mqttClient.publish(`symi_mesh/${macClean}/switch${channelSuffix}/state`, 'OFF', { retain: true });
231
+ }
232
+ break;
233
+
234
+ case 0x09: // 插卡取电
235
+ node.mqttClient.publish(`symi_mesh/${macClean}/switch/state`, 'OFF', { retain: true });
236
+ node.mqttClient.publish(`symi_mesh/${macClean}/card_sensor/state`, 'OFF', { retain: true });
237
+ break;
238
+
239
+ case 4: // 双色调光灯(支持亮度+色温)
240
+ const dualLightState = {
241
+ state: 'OFF',
242
+ brightness: 0,
243
+ color_mode: 'color_temp',
244
+ color_temp: 350 // 默认中间色温
245
+ };
246
+ node.mqttClient.publish(`symi_mesh/${macClean}/light/state`, JSON.stringify(dualLightState), { retain: true });
247
+ break;
248
+
249
+ case 0x18: // 五色调光灯(支持RGB+色温)
250
+ const fiveColorLightState = {
251
+ state: 'OFF',
252
+ brightness: 0,
253
+ color_mode: 'rgb',
254
+ color: { r: 255, g: 255, b: 255 }
255
+ };
256
+ node.mqttClient.publish(`symi_mesh/${macClean}/light/state`, JSON.stringify(fiveColorLightState), { retain: true });
257
+ break;
258
+
259
+ case 0x05: // 窗帘
260
+ node.mqttClient.publish(`symi_mesh/${macClean}/cover/state`, 'closed', { retain: true });
261
+ node.mqttClient.publish(`symi_mesh/${macClean}/cover/position`, '0', { retain: true });
262
+ break;
263
+
264
+ case 8: // 人体感应
265
+ node.mqttClient.publish(`symi_mesh/${macClean}/binary_sensor/state`, 'OFF', { retain: true });
266
+ break;
267
+
268
+ case 10: // 温控器
269
+ // 发布初始状态到各个独立topic
270
+ node.mqttClient.publish(`symi_mesh/${macClean}/climate/current_temp`, '20.0', { retain: true });
271
+ node.mqttClient.publish(`symi_mesh/${macClean}/climate/target_temp`, '20.0', { retain: true });
272
+ node.mqttClient.publish(`symi_mesh/${macClean}/climate/mode`, 'off', { retain: true });
273
+ node.mqttClient.publish(`symi_mesh/${macClean}/climate/fan_mode`, 'auto', { retain: true });
274
+ break;
275
+ }
276
+
277
+ // 三合一设备的初始状态(通过isThreeInOne标记识别)
278
+ // 空调使用climate主题(与温控器一致)
279
+ if (device.isThreeInOne) {
280
+ node.mqttClient.publish(`symi_mesh/${macClean}/climate/mode`, 'off', { retain: true });
281
+ node.mqttClient.publish(`symi_mesh/${macClean}/climate/target_temp`, '20', { retain: true });
282
+ node.mqttClient.publish(`symi_mesh/${macClean}/climate/current_temp`, '20', { retain: true });
283
+ node.mqttClient.publish(`symi_mesh/${macClean}/climate/fan_mode`, 'auto', { retain: true });
284
+ node.mqttClient.publish(`symi_mesh/${macClean}/fresh_air/state`, 'OFF', { retain: true });
285
+ node.mqttClient.publish(`symi_mesh/${macClean}/fresh_air/speed`, '0', { retain: true });
286
+ node.mqttClient.publish(`symi_mesh/${macClean}/fresh_air/mode`, 'auto', { retain: true });
287
+ node.mqttClient.publish(`symi_mesh/${macClean}/fresh_air/direction`, 'forward', { retain: true });
288
+ node.mqttClient.publish(`symi_mesh/${macClean}/floor_heating/mode`, 'off', { retain: true });
289
+ node.mqttClient.publish(`symi_mesh/${macClean}/floor_heating/target_temp`, '20', { retain: true });
290
+ node.mqttClient.publish(`symi_mesh/${macClean}/floor_heating/current_temp`, '20', { retain: true });
291
+ }
292
+
293
+ node.log(`发布设备 ${device.name} 初始状态`);
294
+ }
295
+
296
+ SymiMQTTNode.prototype.publishDeviceState = function(eventData) {
297
+ const node = this;
298
+ const device = eventData.device;
299
+ const state = eventData.state;
300
+ const attrType = eventData.attrType;
301
+ const macClean = device.macAddress.replace(/:/g, '').toLowerCase();
302
+
303
+ let publishes = [];
304
+
305
+ // 注意:不在每次状态更新时发布availability,避免日志泛滥
306
+ // availability只在设备发现完成时发布一次
307
+ // publishes.push({
308
+ // topic: `symi_mesh/${macClean}/availability`,
309
+ // payload: 'online'
310
+ // });
311
+
312
+ switch (attrType) {
313
+ case 0x02:
314
+ // 开关状态反馈 (TYPE_ON_OFF)
315
+ // 窗帘(type=5)不使用0x02,忽略避免误操作
316
+ if (device.deviceType === 5) {
317
+ node.debug(`窗帘设备忽略0x02消息(窗帘使用0x05/0x06)`);
318
+ break;
319
+ }
320
+
321
+ if (device.isThreeInOne || device.deviceType === 10) {
322
+ // 三合一和温控器的0x02表示空调开关(使用climate主题)
323
+ if (state.switch) {
324
+ // 开启状态:根据climateMode发布模式,如果没有模式则不发布(等待0x1D消息)
325
+ if (state.climateMode) {
326
+ const modeMap = { 1: 'cool', 2: 'heat', 3: 'fan_only', 4: 'dry' };
327
+ const mode = modeMap[state.climateMode] || 'cool';
328
+ publishes.push({
329
+ topic: `symi_mesh/${macClean}/climate/mode`,
330
+ payload: mode
331
+ });
332
+ node.log(`发布温控器模式: ${mode} (开关=ON, climateMode=${state.climateMode})`);
333
+ } else {
334
+ node.log(`温控器开启但模式未设置,等待0x1D消息`);
335
+ }
336
+ } else {
337
+ // 关闭状态:明确发布off
338
+ publishes.push({
339
+ topic: `symi_mesh/${macClean}/climate/mode`,
340
+ payload: 'off'
341
+ });
342
+ node.log(`发布温控器模式: off`);
343
+ }
344
+ } else if (device.deviceType === 4 || device.deviceType === 0x18) {
345
+ // 灯光开关:发布JSON格式
346
+ const lightState = {
347
+ state: state.switch ? 'ON' : 'OFF'
348
+ };
349
+
350
+ // 保留亮度、色温、RGB信息
351
+ if (state.brightness !== undefined) {
352
+ lightState.brightness = Math.round(state.brightness * 2.55);
353
+ }
354
+
355
+ if (device.deviceType === 4) {
356
+ lightState.color_mode = 'color_temp';
357
+ if (state.colorTemp !== undefined) {
358
+ lightState.color_temp = convertStateValue('light', 0x04, state.colorTemp);
359
+ }
360
+ } else if (device.deviceType === 0x18) {
361
+ lightState.color_mode = 'rgb';
362
+ if (state.rgb) {
363
+ lightState.color = { r: state.rgb.r, g: state.rgb.g, b: state.rgb.b };
364
+ }
365
+ }
366
+
367
+ publishes.push({
368
+ topic: `symi_mesh/${macClean}/light/state`,
369
+ payload: JSON.stringify(lightState)
370
+ });
371
+ node.log(`发布灯光开关: ${lightState.state} (JSON)`);
372
+ } else if (device.deviceType === 9) {
373
+ // 插卡取电器
374
+ const switchState = convertStateValue('switch', 0x02, state.switch);
375
+ publishes.push({
376
+ topic: `symi_mesh/${macClean}/switch/state`,
377
+ payload: switchState
378
+ });
379
+ node.log(`发布插卡取电器开关状态: ${switchState}`);
380
+ } else if (device.channels === 1) {
381
+ const switchState = convertStateValue('switch', 0x02, state.switch);
382
+ publishes.push({
383
+ topic: `symi_mesh/${macClean}/switch/state`,
384
+ payload: switchState
385
+ });
386
+ node.log(`发布开关状态: ${switchState}`);
387
+ } else {
388
+ // 发布每个继电器的状态
389
+ for (let i = 1; i <= device.channels; i++) {
390
+ const value = state[`switch_${i}`];
391
+ if (value !== undefined) {
392
+ const switchState = convertStateValue('switch', 0x02, value);
393
+ publishes.push({
394
+ topic: `symi_mesh/${macClean}/switch_${i}/state`,
395
+ payload: switchState
396
+ });
397
+ node.log(`发布开关路${i}: ${switchState}`);
398
+ }
399
+ }
400
+ }
401
+ break;
402
+
403
+ case 0x0E:
404
+ // 插卡状态反馈 (CARD_DET_STATUS)
405
+ if (device.deviceType === 9) {
406
+ const cardState = convertStateValue('binary_sensor', 0x0E, state.cardInserted);
407
+ const switchState = convertStateValue('switch', 0x02, state.switch);
408
+ const cardTopic = `symi_mesh/${macClean}/card_sensor/state`;
409
+ const switchTopic = `symi_mesh/${macClean}/switch/state`;
410
+
411
+ publishes.push({
412
+ topic: cardTopic,
413
+ payload: cardState
414
+ });
415
+ publishes.push({
416
+ topic: switchTopic,
417
+ payload: switchState
418
+ });
419
+ node.log(`发布插卡状态: ${cardState} → ${cardTopic}`);
420
+ node.log(`发布开关状态: ${switchState} → ${switchTopic}`);
421
+ node.log(`插卡原始值: cardInserted=${state.cardInserted}, switch=${state.switch}`);
422
+ }
423
+ break;
424
+
425
+ case 0x03:
426
+ // 亮度反馈:使用JSON格式,完整保留现有状态
427
+ if (state.brightness !== undefined) {
428
+ // 构建完整的灯光状态,保留RGB或色温信息
429
+ const lightState = {
430
+ state: state.switch ? 'ON' : 'OFF',
431
+ brightness: Math.round(state.brightness * 2.55) // 转换为0-255范围
432
+ };
433
+
434
+ // 根据设备类型设置color_mode
435
+ if (device.deviceType === 0x18) {
436
+ lightState.color_mode = 'rgb';
437
+ if (state.rgb) {
438
+ lightState.color = {
439
+ r: state.rgb.r,
440
+ g: state.rgb.g,
441
+ b: state.rgb.b
442
+ };
443
+ } else {
444
+ lightState.color = { r: 255, g: 255, b: 255 };
445
+ }
446
+ } else if (device.deviceType === 4) {
447
+ lightState.color_mode = 'color_temp';
448
+ if (state.colorTemp !== undefined) {
449
+ lightState.color_temp = convertStateValue('light', 0x04, state.colorTemp);
450
+ } else {
451
+ lightState.color_temp = 350;
452
+ }
453
+ }
454
+
455
+ publishes.push({
456
+ topic: `symi_mesh/${macClean}/light/state`,
457
+ payload: JSON.stringify(lightState)
458
+ });
459
+ node.log(`发布灯光亮度: ${state.brightness}% -> ${lightState.brightness}/255`);
460
+ }
461
+ break;
462
+
463
+ case 0x04:
464
+ // 色温反馈:使用JSON格式,完整保留现有状态
465
+ if (state.colorTemp !== undefined) {
466
+ const lightState = {
467
+ state: state.switch !== false ? 'ON' : 'OFF',
468
+ color_mode: 'color_temp',
469
+ color_temp: convertStateValue('light', 0x04, state.colorTemp)
470
+ };
471
+ if (state.brightness !== undefined) {
472
+ lightState.brightness = Math.round(state.brightness * 2.55); // 转换为0-255范围
473
+ } else {
474
+ lightState.brightness = 255; // 默认全亮
475
+ }
476
+ publishes.push({
477
+ topic: `symi_mesh/${macClean}/light/state`,
478
+ payload: JSON.stringify(lightState)
479
+ });
480
+ node.log(`发布灯光色温: ${state.colorTemp}% -> ${lightState.color_temp}mireds, 亮度: ${lightState.brightness}/255`);
481
+ }
482
+ break;
483
+
484
+ case 0x05:
485
+ // 窗帘运行状态反馈 (CURT_RUN_STATUS)
486
+ // 协议:0=空闲/到头, 1=打开中, 2=关闭中, 3=停止
487
+ if (state.curtainStatus !== undefined) {
488
+ const curtainStates = { 0: 'stopped', 1: 'opening', 2: 'closing', 3: 'stopped' };
489
+ const curtainState = curtainStates[state.curtainStatus] || 'stopped';
490
+ publishes.push({
491
+ topic: `symi_mesh/${macClean}/cover/state`,
492
+ payload: curtainState
493
+ });
494
+ node.log(`发布窗帘运行状态: ${curtainState} (原始值=${state.curtainStatus})`);
495
+
496
+ // 到头(status=0)时也发布position,确保HA显示正确位置
497
+ if (state.curtainStatus === 0 && state.curtainPosition !== undefined) {
498
+ publishes.push({
499
+ topic: `symi_mesh/${macClean}/cover/position`,
500
+ payload: state.curtainPosition.toString()
501
+ });
502
+ node.log(`窗帘到头,同时发布位置: ${state.curtainPosition}%`);
503
+ }
504
+ }
505
+ break;
506
+
507
+ case 0x06:
508
+ // 窗帘位置反馈 (CURT_RUN_PER_POS)
509
+ if (state.curtainPosition !== undefined) {
510
+ publishes.push({
511
+ topic: `symi_mesh/${macClean}/cover/position`,
512
+ payload: state.curtainPosition.toString()
513
+ });
514
+ node.log(`发布窗帘位置: ${state.curtainPosition}%`);
515
+
516
+ // 同时发布运行状态(确保HA正确显示)
517
+ if (state.curtainStatus !== undefined) {
518
+ const curtainStates = { 0: 'stopped', 1: 'opening', 2: 'closing', 3: 'stopped' };
519
+ const curtainState = curtainStates[state.curtainStatus] || 'stopped';
520
+ publishes.push({
521
+ topic: `symi_mesh/${macClean}/cover/state`,
522
+ payload: curtainState
523
+ });
524
+ node.log(`同时发布窗帘状态: ${curtainState}`);
525
+ }
526
+ }
527
+ break;
528
+
529
+ case 0x0C:
530
+ // 人体感应器状态反馈 (HB_DET_STATUS)
531
+ if (state.motion !== undefined) {
532
+ const motionState = convertStateValue('binary_sensor', 0x0C, state.motion);
533
+ const motionTopic = `symi_mesh/${macClean}/binary_sensor/state`;
534
+ publishes.push({
535
+ topic: motionTopic,
536
+ payload: motionState
537
+ });
538
+ node.log(`发布人体感应状态: ${motionState} → ${motionTopic}`);
539
+ node.log(`人体感应原始值: motion=${state.motion}, MAC=${device.macAddress}`);
540
+ }
541
+ break;
542
+
543
+ case 0x11:
544
+ // RGB反馈:使用JSON格式 (五色调光灯)
545
+ if (state.rgb) {
546
+ const lightState = {
547
+ state: 'ON',
548
+ brightness: Math.round((state.rgb.r + state.rgb.g + state.rgb.b) / 3 * 2.55 / 255 * 100),
549
+ color_mode: 'rgb',
550
+ color: {
551
+ r: state.rgb.r,
552
+ g: state.rgb.g,
553
+ b: state.rgb.b
554
+ }
555
+ };
556
+ publishes.push({
557
+ topic: `symi_mesh/${macClean}/light/state`,
558
+ payload: JSON.stringify(lightState)
559
+ });
560
+ node.log(`发布RGB颜色: R=${state.rgb.r}, G=${state.rgb.g}, B=${state.rgb.b}`);
561
+ }
562
+ break;
563
+
564
+ case 0x1B:
565
+ // 温控器/三合一目标温度反馈(1字节直接温度值)
566
+ // 注意:不自动推断开关状态,应该由0x02消息决定
567
+ if (state.targetTemp !== undefined) {
568
+ // 三合一和温控器都使用climate主题
569
+ publishes.push({
570
+ topic: `symi_mesh/${macClean}/climate/target_temp`,
571
+ payload: state.targetTemp.toString()
572
+ });
573
+ publishes.push({
574
+ topic: `symi_mesh/${macClean}/climate/current_temp`,
575
+ payload: state.targetTemp.toString()
576
+ });
577
+ // 只有当明确有switch状态且为true时,才发布模式
578
+ if (state.switch !== false && state.climateMode) {
579
+ const modeMap = { 1: 'cool', 2: 'heat', 3: 'fan_only', 4: 'dry' };
580
+ const mode = modeMap[state.climateMode] || 'cool';
581
+ publishes.push({
582
+ topic: `symi_mesh/${macClean}/climate/mode`,
583
+ payload: mode
584
+ });
585
+ }
586
+ }
587
+ break;
588
+
589
+ case 0x1C:
590
+ // 温控器/三合一风速反馈 (0x1C单独消息)
591
+ // 协议值:1=高, 2=中, 3=低, 4=自动
592
+ if (state.fanMode !== undefined) {
593
+ const fanMap = { 1: 'high', 2: 'medium', 3: 'low', 4: 'auto' };
594
+ const fanMode = fanMap[state.fanMode] || 'auto';
595
+ publishes.push({
596
+ topic: `symi_mesh/${macClean}/climate/fan_mode`,
597
+ payload: fanMode
598
+ });
599
+ node.log(`发布温控器风速: ${fanMode} (原始值=${state.fanMode})`);
600
+ }
601
+ break;
602
+
603
+ case 0x1D:
604
+ // 温控器/三合一空调模式反馈(不影响地暖,地暖由0x6B单独控制)
605
+ if (state.climateMode !== undefined) {
606
+ const modeMap = { 1: 'cool', 2: 'heat', 3: 'fan_only', 4: 'dry' };
607
+ const mode = modeMap[state.climateMode] || 'off';
608
+ publishes.push({
609
+ topic: `symi_mesh/${macClean}/climate/mode`,
610
+ payload: mode
611
+ });
612
+ }
613
+ break;
614
+
615
+ // 添加其他设备类型的状态发布
616
+ case 0x0A:
617
+ // 门磁传感器状态 (DOOR_SENSOR_STATUS)
618
+ if (state.doorOpen !== undefined) {
619
+ const doorState = convertStateValue('binary_sensor', 0x0A, state.doorOpen);
620
+ publishes.push({
621
+ topic: `symi_mesh/${macClean}/binary_sensor/state`,
622
+ payload: doorState
623
+ });
624
+ node.log(`发布门磁状态: ${doorState} (${state.doorOpen})`);
625
+ }
626
+ break;
627
+
628
+ case 0x16:
629
+ // 温度传感器 (SENSOR_TEMP) - 温湿度传感器或温控器当前温度
630
+ if (state.temperature !== undefined) {
631
+ publishes.push({
632
+ topic: `symi_mesh/${macClean}/temperature/state`,
633
+ payload: state.temperature.toFixed(1)
634
+ });
635
+ node.log(`发布温度: ${state.temperature.toFixed(1)}°C`);
636
+ }
637
+ // 温控器/三合一的当前温度也是0x16
638
+ if (state.currentTemp !== undefined && (device.deviceType === 10 || device.isThreeInOne)) {
639
+ publishes.push({
640
+ topic: `symi_mesh/${macClean}/climate/current_temp`,
641
+ payload: state.currentTemp.toFixed(1)
642
+ });
643
+ if (device.isThreeInOne) {
644
+ // 三合一设备:地暖也发布当前温度
645
+ publishes.push({
646
+ topic: `symi_mesh/${macClean}/floor_heating/current_temp`,
647
+ payload: state.currentTemp.toFixed(1)
648
+ });
649
+ }
650
+ }
651
+ break;
652
+
653
+ case 0x17:
654
+ // 湿度传感器 (SENSOR_HUMI)
655
+ if (state.humidity !== undefined) {
656
+ publishes.push({
657
+ topic: `symi_mesh/${macClean}/humidity/state`,
658
+ payload: state.humidity.toString()
659
+ });
660
+ node.log(`发布湿度: ${state.humidity}%`);
661
+ }
662
+ break;
663
+
664
+ case 0x0F:
665
+ // 门禁读卡器
666
+ if (state.cardId !== undefined) {
667
+ publishes.push({
668
+ topic: `symi_mesh/${macClean}/sensor/state`,
669
+ payload: state.cardId
670
+ });
671
+ }
672
+ if (state.accessGranted !== undefined) {
673
+ publishes.push({
674
+ topic: `symi_mesh/${macClean}/switch/state`,
675
+ payload: convertStateValue('switch', state.accessGranted)
676
+ });
677
+ }
678
+ break;
679
+
680
+ case 0x68:
681
+ // 新风开关反馈
682
+ if (device.isThreeInOne) {
683
+ const switchState = state.freshAirSwitch ? 'ON' : 'OFF';
684
+ publishes.push({
685
+ topic: `symi_mesh/${macClean}/fresh_air/state`,
686
+ payload: switchState
687
+ });
688
+ }
689
+ break;
690
+
691
+ case 0x69:
692
+ // 新风模式反馈
693
+ if (device.isThreeInOne && state.freshAirMode !== undefined) {
694
+ const modeText = state.freshAirMode === 0 ? 'forward' : 'reverse';
695
+ publishes.push({
696
+ topic: `symi_mesh/${macClean}/fresh_air/direction`,
697
+ payload: modeText
698
+ });
699
+ }
700
+ break;
701
+
702
+ case 0x6A:
703
+ // 新风风速反馈
704
+ if (device.isThreeInOne && state.freshAirSpeed !== undefined) {
705
+ const fanMap = { 1: 'high', 2: 'medium', 3: 'low', 4: 'auto' };
706
+ const fanMode = fanMap[state.freshAirSpeed] || 'auto';
707
+ publishes.push({
708
+ topic: `symi_mesh/${macClean}/fresh_air/mode`,
709
+ payload: fanMode
710
+ });
711
+ const speedPercent = state.freshAirSpeed === 1 ? 100 :
712
+ state.freshAirSpeed === 2 ? 66 :
713
+ state.freshAirSpeed === 3 ? 33 : 50;
714
+ publishes.push({
715
+ topic: `symi_mesh/${macClean}/fresh_air/speed`,
716
+ payload: speedPercent.toString()
717
+ });
718
+ }
719
+ break;
720
+
721
+ case 0x6B:
722
+ // 地暖开关反馈
723
+ if (device.isThreeInOne) {
724
+ const mode = state.floorHeatingSwitch ? 'heat' : 'off';
725
+ publishes.push({
726
+ topic: `symi_mesh/${macClean}/floor_heating/mode`,
727
+ payload: mode
728
+ });
729
+ }
730
+ break;
731
+
732
+ case 0x6C:
733
+ // 地暖温度反馈
734
+ if (device.isThreeInOne && state.floorHeatingTemp !== undefined) {
735
+ publishes.push({
736
+ topic: `symi_mesh/${macClean}/floor_heating/target_temp`,
737
+ payload: state.floorHeatingTemp.toString()
738
+ });
739
+ publishes.push({
740
+ topic: `symi_mesh/${macClean}/floor_heating/current_temp`,
741
+ payload: state.floorHeatingTemp.toString()
742
+ });
743
+ }
744
+ break;
745
+
746
+ case 0x94:
747
+ // 三合一设备完整状态
748
+ if (device.isThreeInOne) {
749
+ // 发布新风状态
750
+ if (state.freshAirSwitch !== undefined) {
751
+ const switchState = state.freshAirSwitch ? 'ON' : 'OFF';
752
+ publishes.push({
753
+ topic: `symi_mesh/${macClean}/fresh_air/state`,
754
+ payload: switchState
755
+ });
756
+ }
757
+
758
+ // 发布新风方向
759
+ if (state.freshAirMode !== undefined) {
760
+ const modeText = state.freshAirMode === 0 ? 'forward' : 'reverse';
761
+ publishes.push({
762
+ topic: `symi_mesh/${macClean}/fresh_air/direction`,
763
+ payload: modeText
764
+ });
765
+ }
766
+
767
+ // 发布新风风速
768
+ if (state.freshAirSpeed !== undefined) {
769
+ const fanMap = { 0: 'auto', 1: 'low', 2: 'medium', 4: 'high' };
770
+ const fanMode = fanMap[state.freshAirSpeed] || 'auto';
771
+ publishes.push({
772
+ topic: `symi_mesh/${macClean}/fresh_air/mode`,
773
+ payload: fanMode
774
+ });
775
+ const speedPercent = state.freshAirSpeed === 4 ? 100 :
776
+ state.freshAirSpeed === 2 ? 66 :
777
+ state.freshAirSpeed === 1 ? 33 : 50;
778
+ publishes.push({
779
+ topic: `symi_mesh/${macClean}/fresh_air/speed`,
780
+ payload: speedPercent.toString()
781
+ });
782
+ }
783
+
784
+ // 发布空调状态
785
+ if (state.climateSwitch !== undefined) {
786
+ if (state.climateSwitch) {
787
+ const modeMap = { 1: 'cool', 2: 'heat', 3: 'fan_only', 4: 'dry' };
788
+ const currentMode = state.climateMode ? (modeMap[state.climateMode] || 'cool') : 'cool';
789
+ publishes.push({
790
+ topic: `symi_mesh/${macClean}/climate/mode`,
791
+ payload: currentMode
792
+ });
793
+ if (state.targetTemp !== undefined) {
794
+ publishes.push({
795
+ topic: `symi_mesh/${macClean}/climate/target_temp`,
796
+ payload: state.targetTemp.toString()
797
+ });
798
+ }
799
+ if (state.fanMode !== undefined) {
800
+ // 0x94消息中的空调风速值:0=自动, 1=低, 2=中, 4=高
801
+ const fanMap = { 0: 'auto', 1: 'low', 2: 'medium', 4: 'high' };
802
+ const fanMode = fanMap[state.fanMode] || 'auto';
803
+ publishes.push({
804
+ topic: `symi_mesh/${macClean}/climate/fan_mode`,
805
+ payload: fanMode
806
+ });
807
+ }
808
+ } else {
809
+ publishes.push({
810
+ topic: `symi_mesh/${macClean}/climate/mode`,
811
+ payload: 'off'
812
+ });
813
+ }
814
+ }
815
+
816
+ // 发布地暖状态
817
+ if (state.floorHeatingSwitch !== undefined) {
818
+ const mode = state.floorHeatingSwitch ? 'heat' : 'off';
819
+ publishes.push({
820
+ topic: `symi_mesh/${macClean}/floor_heating/mode`,
821
+ payload: mode
822
+ });
823
+ }
824
+
825
+ // 发布地暖温度
826
+ if (state.floorHeatingTemp !== undefined && state.floorHeatingTemp >= 18) {
827
+ publishes.push({
828
+ topic: `symi_mesh/${macClean}/floor_heating/target_temp`,
829
+ payload: state.floorHeatingTemp.toString()
830
+ });
831
+ publishes.push({
832
+ topic: `symi_mesh/${macClean}/floor_heating/current_temp`,
833
+ payload: state.floorHeatingTemp.toString()
834
+ });
835
+ }
836
+ }
837
+ break;
838
+
839
+ default:
840
+ // 未处理的消息类型
841
+ node.debug(`未处理的消息类型: 0x${attrType.toString(16)}, 设备: ${device.name}, 状态: ${JSON.stringify(state)}`);
842
+ break;
843
+ }
844
+
845
+ // 发布所有状态更新
846
+ if (publishes.length > 0 && node.mqttClient && node.mqttClient.connected) {
847
+ publishes.forEach(p => {
848
+ node.mqttClient.publish(p.topic, p.payload, { retain: true });
849
+ });
850
+ }
851
+ };
852
+
853
+ SymiMQTTNode.prototype.handleMQTTMessage = function(topic, message) {
854
+ const node = this;
855
+ const deviceMac = node.subscriptions.get(topic);
856
+
857
+ if (!deviceMac) {
858
+ node.warn(`未找到topic订阅: ${topic}`);
859
+ return;
860
+ }
861
+
862
+ const device = node.gateway.getDevice(deviceMac);
863
+ if (!device) {
864
+ node.warn(`设备未找到: ${deviceMac}`);
865
+ return;
866
+ }
867
+
868
+ const payload = message.toString();
869
+ node.log(`[MQTT收到] topic=${topic}, payload=${payload}, deviceType=${device.deviceType}, isThreeInOne=${device.isThreeInOne}`);
870
+
871
+ const commands = node.parseMQTTCommand(topic, payload, device);
872
+
873
+ if (commands && commands.length > 0) {
874
+ node.log(`[MQTT解析] 解析出${commands.length}个命令:`);
875
+ commands.forEach((cmd, idx) => {
876
+ node.log(` 命令${idx + 1}: attrType=0x${cmd.attrType.toString(16).toUpperCase()}, param=[${Array.from(cmd.param).map(p => '0x' + p.toString(16).toUpperCase()).join(', ')}]`);
877
+ });
878
+
879
+ // 使用for循环而不是forEach以正确处理async
880
+ (async () => {
881
+ for (const command of commands) {
882
+ const paramHex = Array.from(command.param).map(p => '0x' + p.toString(16).toUpperCase()).join(' ');
883
+ node.log(`[MQTT发送] → 网关: addr=0x${device.networkAddress.toString(16).toUpperCase()}, attr=0x${command.attrType.toString(16).toUpperCase()}, param=[${paramHex}]`);
884
+ try {
885
+ await node.gateway.sendControl(device.networkAddress, command.attrType, command.param);
886
+ node.log(`[MQTT发送] ✓ 成功: ${device.name}`);
887
+
888
+ // 立即发布状态更新(optimistic update)
889
+ node.publishCommandFeedback(device, command, payload, topic);
890
+
891
+ } catch(err) {
892
+ node.error(`[MQTT发送] ✗ 失败: ${err.message}`);
893
+ }
894
+ }
895
+ })();
896
+ } else {
897
+ node.warn(`[MQTT解析] 无法解析命令 - topic: ${topic}, payload: ${payload}`);
898
+ }
899
+ };
900
+
901
+ SymiMQTTNode.prototype.publishCommandFeedback = function(device, command, payload, topic) {
902
+ const node = this;
903
+ const macClean = device.macAddress.replace(/:/g, '').toLowerCase();
904
+
905
+ node.log(`反馈类型: attr=0x${command.attrType.toString(16)}, deviceType=${device.deviceType}, payload=${typeof payload === 'string' ? payload : JSON.stringify(payload)}`);
906
+
907
+ // 根据命令类型立即发布状态反馈(optimistic update)
908
+ if (command.attrType === 0x02 && device.deviceType !== 4 && device.deviceType !== 0x18 && device.deviceType !== 10 && !device.isThreeInOne) {
909
+ // 普通开关控制反馈(排除灯光、温控器、三合一)
910
+ if (device.channels === 1 || device.deviceType === 9) {
911
+ const state = payload === 'ON' ? 'ON' : 'OFF';
912
+ // 更新设备缓存状态
913
+ device.state.switch = (payload === 'ON');
914
+ node.mqttClient.publish(`symi_mesh/${macClean}/switch/state`, state, { retain: false });
915
+ node.log(`立即反馈开关状态: ${state}`);
916
+ } else {
917
+ // 多路开关
918
+ const match = topic.match(/switch_(\d+)\/set/);
919
+ const channel = match ? parseInt(match[1]) : 1;
920
+ const state = payload === 'ON' ? 'ON' : 'OFF';
921
+ // 更新设备缓存状态
922
+ device.state[`switch_${channel}`] = (payload === 'ON');
923
+ node.mqttClient.publish(`symi_mesh/${macClean}/switch_${channel}/state`, state, { retain: false });
924
+ node.log(`立即反馈开关路${channel}状态: ${state}`);
925
+ }
926
+ } else if (command.attrType === 0x02 && (device.deviceType === 4 || device.deviceType === 0x18)) {
927
+ // 灯光开关控制 - 不需要optimistic update,等待53 80反馈
928
+ // 因为0x02命令后会立即收到53 80事件,optimistic update反而会造成状态混乱
929
+ node.debug(`灯光开关命令已发送,等待53 80反馈`);
930
+
931
+ } else if (command.attrType === 0x03 || command.attrType === 0x04 || command.attrType === 0x4C) {
932
+ // 灯光亮度/色温/RGB控制 - 不需要optimistic update,等待53 80反馈
933
+ node.debug(`灯光调节命令已发送,等待53 80反馈`);
934
+ } else if (command.attrType === 0x05 || command.attrType === 0x06) {
935
+ // 窗帘控制反馈
936
+ if (command.attrType === 0x05) {
937
+ const actions = { 'OPEN': 'opening', 'CLOSE': 'closing', 'STOP': 'stopped' };
938
+ const state = actions[payload] || 'stopped';
939
+ node.mqttClient.publish(`symi_mesh/${macClean}/cover/state`, state, { retain: false });
940
+ } else if (command.attrType === 0x06) {
941
+ node.mqttClient.publish(`symi_mesh/${macClean}/cover/position`, payload, { retain: false });
942
+ }
943
+ } else if (command.attrType === 0x1B || command.attrType === 0x1C || command.attrType === 0x1D || (command.attrType === 0x02 && (device.deviceType === 10 || device.isThreeInOne))) {
944
+ // 温控器/三合一反馈 - 完全依赖53 80反馈,不做optimistic update
945
+ // 因为这些设备的状态会通过53 80事件准确上报,optimistic update会被53 80覆盖导致状态不一致
946
+ node.log(`温控器/三合一命令已发送(attr=0x${command.attrType.toString(16)}),等待53 80反馈`);
947
+ } else if (command.attrType === 0x68 || command.attrType === 0x6A || command.attrType === 0x6B || command.attrType === 0x6C) {
948
+ // 三合一专用消息 - 完全依赖53 80反馈
949
+ node.log(`三合一专用命令已发送(attr=0x${command.attrType.toString(16)}),等待53 80反馈`);
950
+ } else if (command.attrType === 0x03 && device.isThreeInOne && topic.includes('/fresh_air/')) {
951
+ // 三合一新风亮度/风速控制反馈
952
+ const speed = Math.round(parseFloat(payload));
953
+ node.mqttClient.publish(`symi_mesh/${macClean}/fresh_air/speed`, speed.toString(), { retain: false });
954
+ node.log(`立即反馈新风风速: ${speed}%`);
955
+ }
956
+ };
957
+
958
+ SymiMQTTNode.prototype.parseMQTTCommand = function(topic, payload, device) {
959
+ const commands = [];
960
+
961
+ // 灯光JSON schema控制
962
+ if (topic.endsWith('/light/set')) {
963
+ try {
964
+ const json = JSON.parse(payload);
965
+
966
+ if (json.state !== undefined) {
967
+ const on = json.state === 'ON';
968
+ commands.push({ attrType: 0x02, param: Buffer.from([on ? 0x02 : 0x01]) });
969
+ }
970
+
971
+ if (json.brightness !== undefined) {
972
+ const brightness = Math.round(json.brightness / 2.55);
973
+ commands.push({ attrType: 0x03, param: Buffer.from([brightness]) });
974
+ }
975
+
976
+ if (json.color_temp !== undefined) {
977
+ // HA: 153mireds=冷白(6500K), 500mireds=暖白(2000K)
978
+ // 协议:0%=暖白, 100%=冷白
979
+ // 转换:mireds -> 百分比(反向)
980
+ const colorTempPercent = Math.round((1 - (json.color_temp - 153) / (500 - 153)) * 100);
981
+ commands.push({ attrType: 0x04, param: Buffer.from([colorTempPercent]) });
982
+ }
983
+
984
+ if (json.color && json.color.r !== undefined) {
985
+ // RGB使用0-255范围(标准RGB)
986
+ const r = Math.max(0, Math.min(255, Math.round(json.color.r)));
987
+ const g = Math.max(0, Math.min(255, Math.round(json.color.g)));
988
+ const b = Math.max(0, Math.min(255, Math.round(json.color.b)));
989
+ // 自动打开灯光
990
+ if (json.state === undefined) {
991
+ commands.unshift({ attrType: 0x02, param: Buffer.from([0x02]) });
992
+ }
993
+ // 五色调光RGB控制使用0x4C(不是0x11),5字节数据
994
+ commands.push({ attrType: 0x4C, param: Buffer.from([r, g, b, 0, 0]) });
995
+ }
996
+
997
+ return commands;
998
+ } catch(e) {
999
+ this.log(`JSON解析失败: ${e.message}`);
1000
+ return [];
1001
+ }
1002
+ }
1003
+
1004
+ // 开关控制
1005
+ if (topic.includes('/switch')) {
1006
+ if (device.deviceType === 9) {
1007
+ // 插卡取电器:单路控制
1008
+ const value = payload === 'ON';
1009
+ commands.push({ attrType: 0x02, param: Buffer.from([value ? 0x02 : 0x01]) });
1010
+ } else {
1011
+ const match = topic.match(/switch_(\d+)\/set/);
1012
+ const channel = match ? parseInt(match[1]) : 1;
1013
+ const value = payload === 'ON';
1014
+
1015
+ if (device.channels === 1) {
1016
+ commands.push({ attrType: 0x02, param: Buffer.from([value ? 0x02 : 0x01]) });
1017
+ } else {
1018
+ // 多路开关:使用网关的状态组合算法
1019
+ // 传递参数:[channels, targetChannel, targetState]
1020
+ commands.push({
1021
+ attrType: 0x02,
1022
+ param: Buffer.from([device.channels, channel, value ? 1 : 0])
1023
+ });
1024
+ }
1025
+ }
1026
+ } else if (topic.includes('/fresh_air/') && device.isThreeInOne) {
1027
+ // 三合一 - 新风控制(使用专用消息类型)
1028
+ this.log(`[MQTT解析] 新风控制 - topic=${topic}, payload=${payload}, isThreeInOne=${device.isThreeInOne}`);
1029
+ if (topic.endsWith('/fresh_air/set')) {
1030
+ const value = payload === 'ON';
1031
+ commands.push({ attrType: 0x68, param: Buffer.from([value ? 0x02 : 0x01]) });
1032
+ } else if (topic.endsWith('/fresh_air/speed/set')) {
1033
+ const speed = Math.max(0, Math.min(100, Math.round(parseFloat(payload))));
1034
+ const level = speed === 0 ? 4 : (speed <= 33 ? 3 : speed <= 66 ? 2 : 1);
1035
+ commands.push({ attrType: 0x68, param: Buffer.from([0x02]) });
1036
+ commands.push({ attrType: 0x6A, param: Buffer.from([level]) });
1037
+ } else if (topic.endsWith('/fresh_air/mode/set')) {
1038
+ const fans = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
1039
+ const fan = fans[payload];
1040
+ if (fan !== undefined) {
1041
+ commands.push({ attrType: 0x68, param: Buffer.from([0x02]) });
1042
+ commands.push({ attrType: 0x6A, param: Buffer.from([fan]) });
1043
+ }
1044
+ } else if (topic.endsWith('/fresh_air/direction/set')) {
1045
+ const directions = { 'supply': 0, 'exhaust': 1, 'forward': 0, 'reverse': 1 };
1046
+ const direction = directions[payload];
1047
+ if (direction !== undefined) {
1048
+ commands.push({ attrType: 0x68, param: Buffer.from([0x02]) });
1049
+ commands.push({ attrType: 0x69, param: Buffer.from([direction]) });
1050
+ }
1051
+ }
1052
+
1053
+ } else if (topic.includes('/floor_heating/') && device.isThreeInOne) {
1054
+ if (topic.endsWith('/floor_heating/target_temp/set')) {
1055
+ const temp = Math.max(18, Math.min(32, Math.round(parseFloat(payload))));
1056
+ commands.push({ attrType: 0x6B, param: Buffer.from([0x02]) });
1057
+ commands.push({ attrType: 0x6C, param: Buffer.from([temp]) });
1058
+ } else if (topic.endsWith('/floor_heating/mode/set')) {
1059
+ const modes = { 'off': 0, 'heat': 1 };
1060
+ const mode = modes[payload];
1061
+ if (mode !== undefined) {
1062
+ commands.push({ attrType: 0x6B, param: Buffer.from([mode === 0 ? 0x01 : 0x02]) });
1063
+ }
1064
+ }
1065
+
1066
+ } else if (topic.endsWith('/target_temp/set')) {
1067
+ const temp = Math.max(16, Math.min(30, Math.round(parseFloat(payload))));
1068
+ commands.push({ attrType: 0x02, param: Buffer.from([0x02]) });
1069
+ commands.push({ attrType: 0x1B, param: Buffer.from([temp]) });
1070
+
1071
+ } else if (topic.includes('/climate/mode/set')) {
1072
+ // 温控器/三合一空调模式
1073
+ const modes = { 'off': 0, 'cool': 1, 'heat': 2, 'fan_only': 3, 'dry': 4 };
1074
+ const mode = modes[payload];
1075
+ if (mode !== undefined) {
1076
+ if (mode === 0) {
1077
+ commands.push({ attrType: 0x02, param: Buffer.from([0x01]) });
1078
+ this.log(`[MQTT解析] 空调模式命令: 0x02=0x01 (关闭)`);
1079
+ } else {
1080
+ // 先开启空调
1081
+ commands.push({ attrType: 0x02, param: Buffer.from([0x02]) });
1082
+ commands.push({ attrType: 0x1D, param: Buffer.from([mode]) });
1083
+ this.log(`[MQTT解析] 空调模式命令: 先开启(0x02) + 设置模式(0x1D=0x${mode.toString(16).toUpperCase()})`);
1084
+ }
1085
+ }
1086
+
1087
+ } else if (topic.includes('/fan_mode/set')) {
1088
+ // 温控器/三合一空调风速 (0x1C协议: 1=高, 2=中, 3=低, 4=自动)
1089
+ const fans = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
1090
+ const fan = fans[payload];
1091
+ if (fan !== undefined) {
1092
+ commands.push({ attrType: 0x1C, param: Buffer.from([fan]) });
1093
+ this.log(`[MQTT解析] 空调风速命令: 0x1C=0x${fan.toString(16).toUpperCase()} (${payload})`);
1094
+ }
1095
+
1096
+ } else if (topic.endsWith('/cover/set')) {
1097
+ const actions = { 'OPEN': 0x01, 'CLOSE': 0x02, 'STOP': 0x03 };
1098
+ const action = actions[payload];
1099
+ if (action) {
1100
+ commands.push({ attrType: 0x05, param: Buffer.from([action]) });
1101
+ }
1102
+
1103
+ } else if (topic.includes('/position/set')) {
1104
+ const position = Math.max(0, Math.min(100, parseInt(payload)));
1105
+ commands.push({ attrType: 0x06, param: Buffer.from([position]) });
1106
+ }
1107
+
1108
+ return commands;
1109
+ };
1110
+
1111
+ RED.nodes.registerType('symi-mqtt', SymiMQTTNode);
1112
+ };
1113
+