node-red-contrib-symi-mesh 1.6.4 → 1.6.6
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 +235 -15
- package/lib/device-manager.js +29 -9
- package/lib/mqtt-helper.js +3 -3
- package/lib/tcp-client.js +30 -14
- package/nodes/rs485-debug.html +51 -0
- package/nodes/rs485-debug.js +43 -0
- package/nodes/symi-485-bridge.html +43 -5
- package/nodes/symi-485-bridge.js +1147 -131
- package/nodes/symi-485-config.js +54 -4
- package/nodes/symi-device.js +7 -7
- package/nodes/symi-gateway.js +105 -48
- package/nodes/symi-mqtt.js +22 -15
- package/package.json +2 -2
package/nodes/symi-485-bridge.js
CHANGED
|
@@ -180,6 +180,46 @@ module.exports = function(RED) {
|
|
|
180
180
|
}
|
|
181
181
|
},
|
|
182
182
|
// ===== 地暖 (A3B3协议头) =====
|
|
183
|
+
// 从机地址: 客餐厅=0x3C(60), 主卧=0x3D(61), 次卧1=0x3E(62), 次卧2=0x3F(63)
|
|
184
|
+
// 开关: 0x0039, 开=0x02, 关=0x00
|
|
185
|
+
// 温度: 0x0043
|
|
186
|
+
'floor_heating_living': {
|
|
187
|
+
name: '客餐厅地暖',
|
|
188
|
+
type: 'climate',
|
|
189
|
+
defaultAddress: 0x3C,
|
|
190
|
+
registers: {
|
|
191
|
+
switch: { address: 0x0039, type: 'holding', on: 2, off: 0 },
|
|
192
|
+
targetTemp: { address: 0x0043, type: 'holding' }
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
'floor_heating_master': {
|
|
196
|
+
name: '主卧地暖',
|
|
197
|
+
type: 'climate',
|
|
198
|
+
defaultAddress: 0x3D,
|
|
199
|
+
registers: {
|
|
200
|
+
switch: { address: 0x0039, type: 'holding', on: 2, off: 0 },
|
|
201
|
+
targetTemp: { address: 0x0043, type: 'holding' }
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
'floor_heating_bedroom2_1': {
|
|
205
|
+
name: '次卧1地暖',
|
|
206
|
+
type: 'climate',
|
|
207
|
+
defaultAddress: 0x3E,
|
|
208
|
+
registers: {
|
|
209
|
+
switch: { address: 0x0039, type: 'holding', on: 2, off: 0 },
|
|
210
|
+
targetTemp: { address: 0x0043, type: 'holding' }
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
'floor_heating_bedroom2_2': {
|
|
214
|
+
name: '次卧2地暖',
|
|
215
|
+
type: 'climate',
|
|
216
|
+
defaultAddress: 0x3F,
|
|
217
|
+
registers: {
|
|
218
|
+
switch: { address: 0x0039, type: 'holding', on: 2, off: 0 },
|
|
219
|
+
targetTemp: { address: 0x0043, type: 'holding' }
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
// 通用地暖(自定义从机地址)
|
|
183
223
|
'floor_heating': {
|
|
184
224
|
name: '地暖',
|
|
185
225
|
type: 'climate',
|
|
@@ -189,9 +229,13 @@ module.exports = function(RED) {
|
|
|
189
229
|
}
|
|
190
230
|
},
|
|
191
231
|
// ===== 新风 (A3B3协议头) =====
|
|
232
|
+
// 从机地址: 0x3C (60) - 与客餐厅地暖共用从机
|
|
233
|
+
// 开关: 0x0039, 开=0x01, 关=0x00 (注意:与地暖开关值不同)
|
|
234
|
+
// 风速: 0x004B, 高=0x02, 低=0x00
|
|
192
235
|
'fresh_air': {
|
|
193
236
|
name: '新风',
|
|
194
237
|
type: 'fan',
|
|
238
|
+
defaultAddress: 0x3C,
|
|
195
239
|
registers: {
|
|
196
240
|
switch: { address: 0x0039, type: 'holding', on: 1, off: 0 },
|
|
197
241
|
fanSpeed: { address: 0x004B, type: 'holding', map: { 0: 'low', 2: 'high' } }
|
|
@@ -235,6 +279,22 @@ module.exports = function(RED) {
|
|
|
235
279
|
'scene': { name: '场景', type: 'scene', registers: { trigger: { address: 0x0000, type: 'holding' } } }
|
|
236
280
|
}
|
|
237
281
|
},
|
|
282
|
+
// ===== 杜亚窗帘协议 =====
|
|
283
|
+
// 帧格式: 55 [地址高] [地址低] 03 [数据] [CRC16高] [CRC16低]
|
|
284
|
+
// 数据: 01=打开, 02=关闭, 03=停止, 04+位置=百分比
|
|
285
|
+
// 地址: 2字节,如0101表示地址高=01,地址低=01
|
|
286
|
+
'duya': {
|
|
287
|
+
name: '杜亚窗帘',
|
|
288
|
+
protocol: 'duya', // 标记使用专用协议
|
|
289
|
+
twoByteAddress: true, // 标记使用2字节地址
|
|
290
|
+
devices: {
|
|
291
|
+
'curtain': {
|
|
292
|
+
name: '窗帘',
|
|
293
|
+
type: 'cover',
|
|
294
|
+
protocol: 'duya'
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
},
|
|
238
298
|
// ===== 自定义协议 - 用户可录入任意RS485码 =====
|
|
239
299
|
'custom': {
|
|
240
300
|
name: '自定义协议',
|
|
@@ -262,6 +322,71 @@ module.exports = function(RED) {
|
|
|
262
322
|
}
|
|
263
323
|
};
|
|
264
324
|
|
|
325
|
+
// ===== 杜亚协议CRC16计算 =====
|
|
326
|
+
// 杜亚使用CRC16-MODBUS算法,低字节在前
|
|
327
|
+
function calcA6B6CRC(buffer) {
|
|
328
|
+
let crc = 0xFFFF;
|
|
329
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
330
|
+
crc ^= buffer[i];
|
|
331
|
+
for (let j = 0; j < 8; j++) {
|
|
332
|
+
if (crc & 0x0001) {
|
|
333
|
+
crc = (crc >> 1) ^ 0xA001;
|
|
334
|
+
} else {
|
|
335
|
+
crc >>= 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// 返回低字节在前(小端序)
|
|
340
|
+
return [crc & 0xFF, (crc >> 8) & 0xFF];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 构建A6B6窗帘控制帧
|
|
344
|
+
// 地址格式: 0x0102 表示地址高=01, 地址低=02
|
|
345
|
+
function buildA6B6Frame(addrHigh, addrLow, action, position) {
|
|
346
|
+
let data;
|
|
347
|
+
if (action === 'position' && position !== undefined) {
|
|
348
|
+
// 百分比控制: 55 addrH addrL 03 04 [位置] CRC
|
|
349
|
+
data = Buffer.from([0x55, addrHigh, addrLow, 0x03, 0x04, position]);
|
|
350
|
+
} else {
|
|
351
|
+
// 动作控制: 55 addrH addrL 03 [动作] CRC
|
|
352
|
+
const actionCode = action === 'open' ? 0x01 : action === 'close' ? 0x02 : 0x03;
|
|
353
|
+
data = Buffer.from([0x55, addrHigh, addrLow, 0x03, actionCode]);
|
|
354
|
+
}
|
|
355
|
+
const crc = calcA6B6CRC(data);
|
|
356
|
+
return Buffer.concat([data, Buffer.from(crc)]);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 解析A6B6窗帘响应帧
|
|
360
|
+
function parseA6B6Frame(frame) {
|
|
361
|
+
if (frame.length < 7 || frame[0] !== 0x55) return null;
|
|
362
|
+
|
|
363
|
+
const addrHigh = frame[1];
|
|
364
|
+
const addrLow = frame[2];
|
|
365
|
+
const funcCode = frame[3];
|
|
366
|
+
|
|
367
|
+
if (funcCode !== 0x03) return null;
|
|
368
|
+
|
|
369
|
+
const dataType = frame[4];
|
|
370
|
+
let result = {
|
|
371
|
+
address: (addrHigh << 8) | addrLow,
|
|
372
|
+
addrHigh: addrHigh,
|
|
373
|
+
addrLow: addrLow
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (dataType === 0x01) {
|
|
377
|
+
result.action = 'open';
|
|
378
|
+
} else if (dataType === 0x02) {
|
|
379
|
+
result.action = 'close';
|
|
380
|
+
} else if (dataType === 0x03) {
|
|
381
|
+
result.action = 'stop';
|
|
382
|
+
} else if (dataType === 0x04 && frame.length >= 8) {
|
|
383
|
+
result.action = 'position';
|
|
384
|
+
result.position = frame[5];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
265
390
|
function SymiRS485BridgeNode(config) {
|
|
266
391
|
RED.nodes.createNode(this, config);
|
|
267
392
|
const node = this;
|
|
@@ -273,9 +398,35 @@ module.exports = function(RED) {
|
|
|
273
398
|
|
|
274
399
|
// 解析实体映射
|
|
275
400
|
try {
|
|
276
|
-
|
|
401
|
+
const rawMappings = JSON.parse(config.mappings || '[]');
|
|
402
|
+
// 确保所有数值字段是正确类型
|
|
403
|
+
node.mappings = rawMappings.map(m => {
|
|
404
|
+
const mapping = {
|
|
405
|
+
...m,
|
|
406
|
+
address: parseInt(m.address) || 1,
|
|
407
|
+
meshChannel: parseInt(m.meshChannel) || 1,
|
|
408
|
+
rs485Channel: parseInt(m.rs485Channel) || 1
|
|
409
|
+
};
|
|
410
|
+
// 杜亚窗帘使用2字节地址
|
|
411
|
+
if (m.brand === 'duya') {
|
|
412
|
+
mapping.addrHigh = parseInt(m.addrHigh) || parseInt(m.address) || 1;
|
|
413
|
+
mapping.addrLow = parseInt(m.addrLow) || parseInt(m.address) || 1;
|
|
414
|
+
}
|
|
415
|
+
return mapping;
|
|
416
|
+
});
|
|
417
|
+
// 打印所有映射配置便于调试
|
|
418
|
+
node.mappings.forEach((m, i) => {
|
|
419
|
+
if (m.brand === 'duya') {
|
|
420
|
+
node.log(`[映射${i+1}] Mesh: ${m.meshMac} <-> 杜亚窗帘: 地址${m.addrHigh.toString(16).padStart(2,'0')} ${m.addrLow.toString(16).padStart(2,'0')}`);
|
|
421
|
+
} else if (m.brand === 'custom' && m.customCodes) {
|
|
422
|
+
node.log(`[映射${i+1}] Mesh: ${m.meshMac} <-> 自定义: ${m.device}, codes=${JSON.stringify(m.customCodes)}`);
|
|
423
|
+
} else {
|
|
424
|
+
node.log(`[映射${i+1}] Mesh: ${m.meshMac} CH${m.meshChannel} <-> RS485: 从机${m.address} ${m.brand}/${m.device} CH${m.rs485Channel}`);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
277
427
|
} catch (e) {
|
|
278
428
|
node.mappings = [];
|
|
429
|
+
node.error(`映射配置解析失败: ${e.message}`);
|
|
279
430
|
}
|
|
280
431
|
|
|
281
432
|
if (!node.gateway) {
|
|
@@ -347,9 +498,16 @@ module.exports = function(RED) {
|
|
|
347
498
|
);
|
|
348
499
|
};
|
|
349
500
|
|
|
350
|
-
// Find mapping for RS485 device
|
|
501
|
+
// Find mapping for RS485 device (返回第一个匹配,用于向后兼容)
|
|
351
502
|
node.findRS485Mapping = function(address) {
|
|
352
|
-
|
|
503
|
+
const addr = parseInt(address);
|
|
504
|
+
return node.mappings.find(m => m.address === addr);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// Find ALL mappings for RS485 device address (用于RS485->Mesh同步)
|
|
508
|
+
node.findAllRS485Mappings = function(address) {
|
|
509
|
+
const addr = parseInt(address);
|
|
510
|
+
return node.mappings.filter(m => m.address === addr);
|
|
353
511
|
};
|
|
354
512
|
|
|
355
513
|
// 获取映射的寄存器配置
|
|
@@ -360,34 +518,316 @@ module.exports = function(RED) {
|
|
|
360
518
|
return brand.devices[mapping.device].registers;
|
|
361
519
|
};
|
|
362
520
|
|
|
521
|
+
// 状态缓存 - 用于检测真正变化的开关
|
|
522
|
+
node.stateCache = {};
|
|
523
|
+
// 首次启动标记 - 跳过初始状态同步
|
|
524
|
+
node.initializing = true;
|
|
525
|
+
// 启动后延迟20秒再开始同步(Mesh网关需要15秒以上完成设备发现)
|
|
526
|
+
setTimeout(() => {
|
|
527
|
+
node.initializing = false;
|
|
528
|
+
node.log('[RS485 Bridge] 初始化完成,开始同步');
|
|
529
|
+
}, 20000);
|
|
530
|
+
|
|
363
531
|
// Mesh设备状态变化处理(事件驱动)
|
|
364
532
|
const handleMeshStateChange = (eventData) => {
|
|
365
533
|
if (node.syncLock) return;
|
|
534
|
+
if (node.initializing) return;
|
|
366
535
|
|
|
367
536
|
const mac = eventData.device.macAddress;
|
|
368
537
|
const state = eventData.state || {};
|
|
369
|
-
// channel可能是0-based或1-based,需要兼容处理
|
|
370
|
-
// Mesh事件中channel通常是0-based,UI配置的meshChannel是1-based
|
|
371
|
-
const eventChannel = state.channel !== undefined ? state.channel : -1;
|
|
372
538
|
|
|
373
|
-
|
|
539
|
+
// 状态缓存比较,只处理真正变化的状态
|
|
540
|
+
if (!node.stateCache[mac]) node.stateCache[mac] = {};
|
|
541
|
+
const cached = node.stateCache[mac];
|
|
542
|
+
const changed = {};
|
|
543
|
+
const isFirstState = Object.keys(cached).length === 0; // 首次收到该设备状态
|
|
544
|
+
|
|
545
|
+
for (const [key, value] of Object.entries(state)) {
|
|
546
|
+
if (cached[key] !== value) {
|
|
547
|
+
changed[key] = value;
|
|
548
|
+
cached[key] = value;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (Object.keys(changed).length === 0) return; // 无变化
|
|
553
|
+
|
|
554
|
+
// 首次收到设备状态时只记录缓存,不触发同步(避免启动时批量发码)
|
|
555
|
+
// 但窗帘状态(curtainStatus)除外,因为这是用户的控制命令
|
|
556
|
+
const hasCurtainStatusChange = changed.curtainStatus !== undefined;
|
|
557
|
+
if (isFirstState && !hasCurtainStatusChange) {
|
|
558
|
+
node.debug(`[Mesh事件] MAC=${mac} 首次状态,仅缓存: ${JSON.stringify(changed)}`);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
node.log(`[Mesh事件] MAC=${mac}, 变化: ${JSON.stringify(changed)}`);
|
|
563
|
+
|
|
564
|
+
// 规范化MAC地址用于比较
|
|
565
|
+
const macNormalized = mac.toLowerCase().replace(/:/g, '');
|
|
374
566
|
|
|
375
|
-
//
|
|
567
|
+
// 遍历映射,只处理有对应变化的映射
|
|
376
568
|
for (const mapping of node.mappings) {
|
|
377
|
-
|
|
569
|
+
const mappingMacNormalized = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
570
|
+
if (mappingMacNormalized !== macNormalized) continue;
|
|
571
|
+
|
|
572
|
+
node.log(`[Mesh事件] 匹配到映射: brand=${mapping.brand}, device=${mapping.device}, codes=${JSON.stringify(mapping.customCodes)}`);
|
|
378
573
|
|
|
379
|
-
// 通道匹配:meshChannel=0表示匹配所有,否则需要匹配具体通道
|
|
380
|
-
// UI的meshChannel是1-based,事件的channel可能是0-based
|
|
381
574
|
const configChannel = mapping.meshChannel || 1;
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
575
|
+
const switchKey = `switch_${configChannel}`;
|
|
576
|
+
const device = mapping.device || '';
|
|
577
|
+
|
|
578
|
+
// 根据映射设备类型检查是否有相关状态
|
|
579
|
+
const isSwitch = device.includes('switch') || device.includes('button');
|
|
580
|
+
const isCurtain = device.includes('curtain') || mapping.brand === 'duya';
|
|
581
|
+
const isAC = device.includes('ac') || device.includes('climate') || device.includes('thermostat');
|
|
582
|
+
|
|
583
|
+
// 【杜亚窗帘】处理Mesh面板控制同步到485
|
|
584
|
+
// 根据协议文档:
|
|
585
|
+
// - subOpcode=0x05 (NODE_ACK) = 设备确认执行命令,是用户控制
|
|
586
|
+
// - subOpcode=0x06 (NODE_STATUS) 在0xB0后1500ms内也是用户控制
|
|
587
|
+
// - attrType=0x05 (CURT_RUN_STATUS) = 状态: 1=开, 2=关, 3=停
|
|
588
|
+
// - attrType=0x06 (CURT_RUN_PER_POS) = 位置: 0-100%
|
|
589
|
+
if (mapping.brand === 'duya') {
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
const curtainKey = `duya_curtain_${mac}`;
|
|
592
|
+
|
|
593
|
+
// 只处理用户控制帧
|
|
594
|
+
if (!eventData.isUserControl) {
|
|
595
|
+
continue; // 非用户控制(电机反馈),忽略
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const addrHigh = mapping.addrHigh || 1;
|
|
599
|
+
const addrLow = mapping.addrLow || 1;
|
|
600
|
+
|
|
601
|
+
// 获取设备状态
|
|
602
|
+
const device = node.gateway.deviceManager.getDeviceByMac(mac);
|
|
603
|
+
const currentPosition = device ? device.state.curtainPosition : null;
|
|
604
|
+
|
|
605
|
+
// 百分比控制 (attrType=0x06)
|
|
606
|
+
// 只记录位置,不立即发送(等status=2时再判断是否发百分比)
|
|
607
|
+
if (eventData.attrType === 0x06) {
|
|
608
|
+
const position = eventData.parameters && eventData.parameters.length > 0
|
|
609
|
+
? eventData.parameters[0]
|
|
610
|
+
: null;
|
|
611
|
+
if (position !== null && position !== 0xFF) {
|
|
612
|
+
// 记录最新位置,用于status=2时判断
|
|
613
|
+
if (!node.curtainLastPos) node.curtainLastPos = {};
|
|
614
|
+
node.curtainLastPos[curtainKey] = position;
|
|
615
|
+
node.debug(`[Mesh->杜亚] 记录位置: ${position}%`);
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 状态控制 (attrType=0x05)
|
|
621
|
+
if (eventData.attrType !== 0x05) {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const newStatus = eventData.parameters && eventData.parameters.length > 0
|
|
626
|
+
? eventData.parameters[0]
|
|
627
|
+
: null;
|
|
628
|
+
|
|
629
|
+
// 忽略 null 值
|
|
630
|
+
if (newStatus === null) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// 1秒防抖:避免重复发送相同状态
|
|
635
|
+
if (!node.curtainDebounce) node.curtainDebounce = {};
|
|
636
|
+
const debounceKey = `${curtainKey}_status_${newStatus}`;
|
|
637
|
+
const lastTime = node.curtainDebounce[debounceKey] || 0;
|
|
638
|
+
if (now - lastTime < 1000) {
|
|
639
|
+
node.debug(`[Mesh->杜亚] 状态${newStatus}在1秒内重复, 忽略`);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
node.curtainDebounce[debounceKey] = now;
|
|
643
|
+
|
|
644
|
+
node.log(`[Mesh->杜亚] 用户控制(subOpcode=0x${eventData.subOpcode.toString(16)}): status=${newStatus}, position=${currentPosition}%`);
|
|
645
|
+
|
|
646
|
+
// 【米家协议状态处理】:
|
|
647
|
+
// status=0 → 打开中 → 发open
|
|
648
|
+
// status=1 → 关闭中 → 发close
|
|
649
|
+
// status=2 → 停止 → 判断位置:
|
|
650
|
+
// - 位置=0% → 关闭到头,不发
|
|
651
|
+
// - 位置=100% → 打开到头,不发
|
|
652
|
+
// - 位置=其他 → 用户暂停或百分比控制,发stop+position
|
|
653
|
+
|
|
654
|
+
let action = null, actionName = '';
|
|
655
|
+
let sendPosition = false;
|
|
656
|
+
|
|
657
|
+
if (newStatus === 0) {
|
|
658
|
+
// 米家: 打开中
|
|
659
|
+
action = 'open';
|
|
660
|
+
actionName = '打开';
|
|
661
|
+
} else if (newStatus === 1) {
|
|
662
|
+
// 米家: 关闭中
|
|
663
|
+
action = 'close';
|
|
664
|
+
actionName = '关闭';
|
|
665
|
+
} else if (newStatus === 2) {
|
|
666
|
+
// 米家: 停止
|
|
667
|
+
const pos = currentPosition !== null ? currentPosition : (node.curtainLastPos ? node.curtainLastPos[curtainKey] : 50);
|
|
668
|
+
if (pos <= 2) {
|
|
669
|
+
// 关闭到头,不发命令
|
|
670
|
+
node.debug(`[Mesh->杜亚] 关闭到头(${pos}%),不发命令`);
|
|
671
|
+
continue;
|
|
672
|
+
} else if (pos >= 98) {
|
|
673
|
+
// 打开到头,不发命令
|
|
674
|
+
node.debug(`[Mesh->杜亚] 打开到头(${pos}%),不发命令`);
|
|
675
|
+
continue;
|
|
676
|
+
} else {
|
|
677
|
+
// 中间位置停止,发stop + position
|
|
678
|
+
action = 'stop';
|
|
679
|
+
actionName = '暂停';
|
|
680
|
+
sendPosition = true;
|
|
681
|
+
}
|
|
682
|
+
} else if (newStatus === 3) {
|
|
683
|
+
// 小程序: 停止
|
|
684
|
+
action = 'stop';
|
|
685
|
+
actionName = '暂停';
|
|
686
|
+
} else {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 发送动作命令
|
|
691
|
+
if (action) {
|
|
692
|
+
const frame = buildA6B6Frame(addrHigh, addrLow, action);
|
|
693
|
+
node.sendRS485Frame(frame).then(() => {
|
|
694
|
+
const hexStr = frame.toString('hex').toUpperCase().match(/.{2}/g).join(' ');
|
|
695
|
+
node.log(`[Mesh->杜亚] ${actionName}: ${hexStr}`);
|
|
696
|
+
}).catch(err => {
|
|
697
|
+
node.error(`[Mesh->杜亚] 发送失败: ${err.message}`);
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 发送位置命令(暂停时同步位置)
|
|
702
|
+
if (sendPosition && currentPosition !== null && currentPosition > 2 && currentPosition < 98) {
|
|
703
|
+
setTimeout(() => {
|
|
704
|
+
const posFrame = buildA6B6Frame(addrHigh, addrLow, 'position', currentPosition);
|
|
705
|
+
node.sendRS485Frame(posFrame).then(() => {
|
|
706
|
+
const hexStr = posFrame.toString('hex').toUpperCase().match(/.{2}/g).join(' ');
|
|
707
|
+
node.log(`[Mesh->杜亚] 同步位置${currentPosition}%: ${hexStr}`);
|
|
708
|
+
}).catch(err => {
|
|
709
|
+
node.error(`[Mesh->杜亚] 位置同步失败: ${err.message}`);
|
|
710
|
+
});
|
|
711
|
+
}, 100); // 延迟100ms发送位置命令
|
|
712
|
+
}
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
385
715
|
|
|
386
|
-
|
|
716
|
+
// 【自定义窗帘】处理Mesh面板控制同步到485(逻辑同杜亚)
|
|
717
|
+
// 必须检查isUserControl,只有NODE_ACK(subOpcode=0x05)才是用户控制
|
|
718
|
+
if (mapping.brand === 'custom' && mapping.device === 'custom_curtain' && mapping.customCodes) {
|
|
719
|
+
const now = Date.now();
|
|
720
|
+
const curtainKey = `custom_curtain_${mac}`;
|
|
721
|
+
|
|
722
|
+
// 【重要】只处理用户控制帧,忽略电机反馈
|
|
723
|
+
if (!eventData.isUserControl) {
|
|
724
|
+
continue; // 非用户控制(NODE_STATUS),忽略
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// 只处理窗帘状态事件 (attrType=0x05)
|
|
728
|
+
if (eventData.attrType !== 0x05) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const newStatus = eventData.parameters && eventData.parameters.length > 0
|
|
733
|
+
? eventData.parameters[0]
|
|
734
|
+
: null;
|
|
735
|
+
|
|
736
|
+
// 忽略 null 值
|
|
737
|
+
if (newStatus === null) {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// 1秒防抖:避免重复发送相同状态(与杜亚窗帘一致)
|
|
742
|
+
if (!node.curtainDebounce) node.curtainDebounce = {};
|
|
743
|
+
const debounceKey = `${curtainKey}_status`;
|
|
744
|
+
const lastTime = node.curtainDebounce[debounceKey] || 0;
|
|
745
|
+
if (now - lastTime < 1000) {
|
|
746
|
+
node.debug(`[Mesh->自定义窗帘] 状态1秒内重复, 忽略`);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
node.curtainDebounce[debounceKey] = now;
|
|
750
|
+
|
|
751
|
+
// 【兼容两种协议】:
|
|
752
|
+
// 米家协议: 0=打开中, 1=关闭中, 2=停止
|
|
753
|
+
// 小程序协议: 1=打开, 2=关闭, 3=停止
|
|
754
|
+
// 使用device.state.curtainAction来获取正确的动作
|
|
755
|
+
const device = node.gateway.deviceManager.getDeviceByMac(mac);
|
|
756
|
+
const curtainAction = device ? device.state.curtainAction : null;
|
|
757
|
+
|
|
758
|
+
const codes = mapping.customCodes;
|
|
759
|
+
let hexCode = null, actionName = '';
|
|
760
|
+
|
|
761
|
+
if (curtainAction === 'opening' && codes.open) {
|
|
762
|
+
hexCode = codes.open;
|
|
763
|
+
actionName = '打开';
|
|
764
|
+
} else if (curtainAction === 'closing' && codes.close) {
|
|
765
|
+
hexCode = codes.close;
|
|
766
|
+
actionName = '关闭';
|
|
767
|
+
} else if (curtainAction === 'stopped' && codes.stop) {
|
|
768
|
+
hexCode = codes.stop;
|
|
769
|
+
actionName = '暂停';
|
|
770
|
+
} else {
|
|
771
|
+
// 回退:直接使用原始status值(兼容旧逻辑,小程序协议)
|
|
772
|
+
if (newStatus === 1 && codes.open) {
|
|
773
|
+
hexCode = codes.open; actionName = '打开';
|
|
774
|
+
} else if (newStatus === 2 && codes.close) {
|
|
775
|
+
hexCode = codes.close; actionName = '关闭';
|
|
776
|
+
} else if (newStatus === 3 && codes.stop) {
|
|
777
|
+
hexCode = codes.stop; actionName = '暂停';
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (hexCode) {
|
|
782
|
+
node.sendCustomCode(hexCode).then(() => {
|
|
783
|
+
node.log(`[Mesh->自定义] ${actionName}: ${hexCode}`);
|
|
784
|
+
}).catch(err => {
|
|
785
|
+
node.error(`[Mesh->自定义] 发送失败: ${err.message}`);
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// 其他窗帘类型标记
|
|
792
|
+
const isCurtainControlled = false;
|
|
793
|
+
|
|
794
|
+
// 只有对应类型的状态变化才触发对应类型的映射
|
|
795
|
+
const hasSwitchChange = isSwitch && changed[switchKey] !== undefined;
|
|
796
|
+
// 窗帘:如果是杜亚窗帘,跳过状态变化处理(已在curtain-control中处理)
|
|
797
|
+
const hasCurtainChange = isCurtain && !isCurtainControlled && (
|
|
798
|
+
changed.curtainAction !== undefined ||
|
|
799
|
+
changed.curtainPosition !== undefined ||
|
|
800
|
+
changed.curtainStatus !== undefined
|
|
801
|
+
);
|
|
802
|
+
const hasACChange = isAC && (
|
|
803
|
+
changed.targetTemp !== undefined ||
|
|
804
|
+
changed.acMode !== undefined ||
|
|
805
|
+
changed.acFanSpeed !== undefined ||
|
|
806
|
+
// 三合一面板使用climate前缀
|
|
807
|
+
changed.climateSwitch !== undefined ||
|
|
808
|
+
changed.climateMode !== undefined ||
|
|
809
|
+
changed.fanMode !== undefined
|
|
810
|
+
);
|
|
811
|
+
// 新风设备检测
|
|
812
|
+
const isFreshAir = device.includes('fresh_air');
|
|
813
|
+
const hasFreshAirChange = isFreshAir && (
|
|
814
|
+
changed.freshAirSwitch !== undefined ||
|
|
815
|
+
changed.freshAirSpeed !== undefined
|
|
816
|
+
);
|
|
817
|
+
// 地暖设备检测
|
|
818
|
+
const isFloorHeating = device.includes('floor_heating');
|
|
819
|
+
const hasFloorHeatingChange = isFloorHeating && (
|
|
820
|
+
changed.floorHeatingSwitch !== undefined ||
|
|
821
|
+
changed.floorHeatingTemp !== undefined
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
if (!hasSwitchChange && !hasCurtainChange && !hasACChange && !hasFreshAirChange && !hasFloorHeatingChange) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
387
827
|
|
|
388
828
|
const registers = node.getRegistersForMapping(mapping);
|
|
389
829
|
|
|
390
|
-
node.log(`[Mesh->RS485] ${eventData.device.name}
|
|
830
|
+
node.log(`[Mesh->RS485] ${eventData.device.name} CH${configChannel} 变化: ${JSON.stringify(changed)}`);
|
|
391
831
|
|
|
392
832
|
// 输出调试信息到节点输出端口
|
|
393
833
|
node.send({
|
|
@@ -397,7 +837,7 @@ module.exports = function(RED) {
|
|
|
397
837
|
device: eventData.device.name,
|
|
398
838
|
mac: mac,
|
|
399
839
|
channel: configChannel,
|
|
400
|
-
state:
|
|
840
|
+
state: changed
|
|
401
841
|
},
|
|
402
842
|
timestamp: new Date().toISOString()
|
|
403
843
|
});
|
|
@@ -406,11 +846,127 @@ module.exports = function(RED) {
|
|
|
406
846
|
direction: 'mesh-to-modbus',
|
|
407
847
|
mapping: mapping,
|
|
408
848
|
registers: registers,
|
|
409
|
-
state:
|
|
849
|
+
state: changed,
|
|
410
850
|
timestamp: Date.now()
|
|
411
851
|
});
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
// 窗帘控制命令处理(立即同步,不等状态反馈)
|
|
856
|
+
// 同时支持杜亚窗帘和自定义窗帘
|
|
857
|
+
const handleCurtainControl = (eventData) => {
|
|
858
|
+
const mac = eventData.mac;
|
|
859
|
+
|
|
860
|
+
node.log(`[curtain-control事件] MAC=${mac}, action=${eventData.action}, position=${eventData.position}`);
|
|
861
|
+
|
|
862
|
+
// 查找窗帘映射(杜亚或自定义窗帘)
|
|
863
|
+
const macNormalized = mac.toLowerCase().replace(/:/g, '');
|
|
864
|
+
for (const mapping of node.mappings) {
|
|
865
|
+
const mappingMacNormalized = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
866
|
+
if (mappingMacNormalized !== macNormalized) continue;
|
|
412
867
|
|
|
413
|
-
|
|
868
|
+
// ===== 杜亚窗帘 =====
|
|
869
|
+
if (mapping.brand === 'duya') {
|
|
870
|
+
node.log(`[curtain-control] 匹配到杜亚映射: ${mapping.meshMac}`);
|
|
871
|
+
|
|
872
|
+
const addrHigh = mapping.addrHigh || 1;
|
|
873
|
+
const addrLow = mapping.addrLow || 1;
|
|
874
|
+
|
|
875
|
+
let frame = null;
|
|
876
|
+
let actionName = '';
|
|
877
|
+
|
|
878
|
+
// 处理动作命令 (attrType=0x05)
|
|
879
|
+
if (eventData.action !== null) {
|
|
880
|
+
if (eventData.action === 1) {
|
|
881
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'open');
|
|
882
|
+
actionName = '打开';
|
|
883
|
+
} else if (eventData.action === 2) {
|
|
884
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'close');
|
|
885
|
+
actionName = '关闭';
|
|
886
|
+
} else if (eventData.action === 3) {
|
|
887
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
|
|
888
|
+
actionName = '暂停';
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// 处理位置命令 (attrType=0x06)
|
|
892
|
+
else if (eventData.position !== null) {
|
|
893
|
+
const pos = eventData.position;
|
|
894
|
+
if (pos >= 95) {
|
|
895
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'open');
|
|
896
|
+
actionName = '打开';
|
|
897
|
+
} else if (pos <= 5) {
|
|
898
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'close');
|
|
899
|
+
actionName = '关闭';
|
|
900
|
+
} else {
|
|
901
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'position', pos);
|
|
902
|
+
actionName = `位置${pos}%`;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (frame) {
|
|
907
|
+
// 立即发送,记录时间防止状态反馈重复发码
|
|
908
|
+
node.lastCurtainControlTime = Date.now();
|
|
909
|
+
node.lastMeshToRS485Time = Date.now();
|
|
910
|
+
if (!node.lastSentTime) node.lastSentTime = {};
|
|
911
|
+
node.lastSentTime[`duya_${mac}_${actionName}`] = Date.now();
|
|
912
|
+
|
|
913
|
+
node.sendRS485Frame(frame).then(() => {
|
|
914
|
+
const hexStr = frame.toString('hex').toUpperCase();
|
|
915
|
+
node.log(`[Mesh控制->杜亚] 窗帘 ${actionName}, 立即发送: ${hexStr.match(/.{2}/g).join(' ')}`);
|
|
916
|
+
}).catch(err => {
|
|
917
|
+
node.error(`[Mesh控制->杜亚] 发送失败: ${err.message}`);
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// ===== 自定义窗帘 =====
|
|
922
|
+
else if (mapping.brand === 'custom' && mapping.device === 'custom_curtain' && mapping.customCodes) {
|
|
923
|
+
const codes = mapping.customCodes;
|
|
924
|
+
|
|
925
|
+
node.log(`[curtain-control] 匹配到自定义窗帘映射: ${mapping.meshMac}`);
|
|
926
|
+
|
|
927
|
+
let hexCode = null;
|
|
928
|
+
let actionName = '';
|
|
929
|
+
|
|
930
|
+
// 处理动作命令 (attrType=0x05): 1=打开, 2=关闭, 3=停止
|
|
931
|
+
if (eventData.action !== null) {
|
|
932
|
+
if (eventData.action === 1 && codes.open) {
|
|
933
|
+
hexCode = codes.open;
|
|
934
|
+
actionName = '打开';
|
|
935
|
+
} else if (eventData.action === 2 && codes.close) {
|
|
936
|
+
hexCode = codes.close;
|
|
937
|
+
actionName = '关闭';
|
|
938
|
+
} else if (eventData.action === 3 && codes.stop) {
|
|
939
|
+
hexCode = codes.stop;
|
|
940
|
+
actionName = '暂停';
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
// 处理位置命令 (attrType=0x06)
|
|
944
|
+
else if (eventData.position !== null) {
|
|
945
|
+
const pos = eventData.position;
|
|
946
|
+
if (pos >= 95 && codes.open) {
|
|
947
|
+
hexCode = codes.open;
|
|
948
|
+
actionName = '打开';
|
|
949
|
+
} else if (pos <= 5 && codes.close) {
|
|
950
|
+
hexCode = codes.close;
|
|
951
|
+
actionName = '关闭';
|
|
952
|
+
}
|
|
953
|
+
// 自定义窗帘不支持百分比位置,只能发开/关
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (hexCode) {
|
|
957
|
+
// 立即发送,记录时间防止状态反馈重复发码
|
|
958
|
+
node.lastCurtainControlTime = Date.now();
|
|
959
|
+
node.lastMeshToRS485Time = Date.now();
|
|
960
|
+
if (!node.lastSentTime) node.lastSentTime = {};
|
|
961
|
+
node.lastSentTime[`custom_curtain_${mac}_${hexCode}`] = Date.now();
|
|
962
|
+
|
|
963
|
+
node.sendCustomCode(hexCode).then(() => {
|
|
964
|
+
node.log(`[Mesh控制->自定义] 窗帘 ${actionName}, 立即发送: ${hexCode}`);
|
|
965
|
+
}).catch(err => {
|
|
966
|
+
node.error(`[Mesh控制->自定义] 发送失败: ${err.message}`);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
414
970
|
}
|
|
415
971
|
};
|
|
416
972
|
|
|
@@ -434,16 +990,30 @@ module.exports = function(RED) {
|
|
|
434
990
|
});
|
|
435
991
|
};
|
|
436
992
|
|
|
437
|
-
//
|
|
993
|
+
// 命令队列顺序处理(限制最大100条,防止内存溢出)
|
|
994
|
+
const MAX_QUEUE_SIZE = 100;
|
|
438
995
|
node.queueCommand = function(cmd) {
|
|
439
|
-
//
|
|
996
|
+
// 队列过大时丢弃旧命令,防止内存溢出
|
|
997
|
+
if (node.commandQueue.length >= MAX_QUEUE_SIZE) {
|
|
998
|
+
node.warn(`[RS485 Bridge] 命令队列已满(${MAX_QUEUE_SIZE}),丢弃最旧命令`);
|
|
999
|
+
node.commandQueue.shift(); // 丢弃最旧的命令
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// 检查队列中是否有完全相同映射的命令(防抖)
|
|
1003
|
+
// 必须是同一个映射(meshMac + meshChannel + address + rs485Channel)才能合并
|
|
440
1004
|
const existing = node.commandQueue.find(c =>
|
|
441
1005
|
c.direction === cmd.direction &&
|
|
1006
|
+
c.mapping && cmd.mapping &&
|
|
1007
|
+
c.mapping.meshMac === cmd.mapping.meshMac &&
|
|
1008
|
+
c.mapping.meshChannel === cmd.mapping.meshChannel &&
|
|
1009
|
+
c.mapping.address === cmd.mapping.address &&
|
|
1010
|
+
c.mapping.rs485Channel === cmd.mapping.rs485Channel &&
|
|
442
1011
|
Date.now() - c.timestamp < 100
|
|
443
1012
|
);
|
|
444
1013
|
if (existing) {
|
|
445
|
-
//
|
|
1014
|
+
// 合并状态(仅限完全相同的映射)
|
|
446
1015
|
existing.state = { ...existing.state, ...cmd.state };
|
|
1016
|
+
node.debug(`[队列] 合并相同映射的命令: ${cmd.mapping.meshMac} CH${cmd.mapping.meshChannel}`);
|
|
447
1017
|
return;
|
|
448
1018
|
}
|
|
449
1019
|
|
|
@@ -501,6 +1071,95 @@ module.exports = function(RED) {
|
|
|
501
1071
|
// 记录发送时间(用于防死循环)
|
|
502
1072
|
node.lastMeshToRS485Time = Date.now();
|
|
503
1073
|
|
|
1074
|
+
// ===== 杜亚窗帘协议模式 =====
|
|
1075
|
+
if (mapping.brand === 'duya') {
|
|
1076
|
+
const addrHigh = mapping.addrHigh || 1;
|
|
1077
|
+
const addrLow = mapping.addrLow || 1;
|
|
1078
|
+
|
|
1079
|
+
// 缓存:记录 status 和 position
|
|
1080
|
+
if (!node.curtainCache) node.curtainCache = {};
|
|
1081
|
+
const cKey = `cc_${mapping.meshMac}`;
|
|
1082
|
+
const cache = node.curtainCache[cKey] || { status: undefined, position: 50 };
|
|
1083
|
+
|
|
1084
|
+
const lastStatus = cache.status;
|
|
1085
|
+
const currentStatus = state.curtainStatus;
|
|
1086
|
+
|
|
1087
|
+
// 【重要】先更新位置缓存,再判断方向
|
|
1088
|
+
// 第一个事件通常同时包含 status 和 position
|
|
1089
|
+
if (state.curtainPosition !== undefined) {
|
|
1090
|
+
cache.position = state.curtainPosition;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// 判断方向时使用的位置:优先使用事件中的位置,否则使用缓存
|
|
1094
|
+
const posForDirection = state.curtainPosition !== undefined ? state.curtainPosition : cache.position;
|
|
1095
|
+
|
|
1096
|
+
// 更新状态缓存
|
|
1097
|
+
if (currentStatus !== undefined) {
|
|
1098
|
+
cache.status = currentStatus;
|
|
1099
|
+
}
|
|
1100
|
+
node.curtainCache[cKey] = cache;
|
|
1101
|
+
|
|
1102
|
+
let frame = null;
|
|
1103
|
+
let actionName = '';
|
|
1104
|
+
|
|
1105
|
+
// 【兼容两种协议】:
|
|
1106
|
+
// 米家协议: 0=打开中, 1=关闭中, 2=停止
|
|
1107
|
+
// 小程序协议: 1=打开, 2=关闭, 3=停止
|
|
1108
|
+
// 使用curtainAction来判断动作(由device-manager解析)
|
|
1109
|
+
const curtainAction = state.curtainAction;
|
|
1110
|
+
|
|
1111
|
+
if (curtainAction === 'opening') {
|
|
1112
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'open');
|
|
1113
|
+
actionName = '打开';
|
|
1114
|
+
} else if (curtainAction === 'closing') {
|
|
1115
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'close');
|
|
1116
|
+
actionName = '关闭';
|
|
1117
|
+
} else if (curtainAction === 'stopped') {
|
|
1118
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
|
|
1119
|
+
actionName = '暂停';
|
|
1120
|
+
}
|
|
1121
|
+
// 回退:如果curtainAction未设置,使用原始status值(小程序协议)
|
|
1122
|
+
else if (currentStatus === 1) {
|
|
1123
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'open');
|
|
1124
|
+
actionName = '打开';
|
|
1125
|
+
} else if (currentStatus === 2) {
|
|
1126
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'close');
|
|
1127
|
+
actionName = '关闭';
|
|
1128
|
+
} else if (currentStatus === 3) {
|
|
1129
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
|
|
1130
|
+
actionName = '暂停';
|
|
1131
|
+
}
|
|
1132
|
+
// 3. 只有位置变化(没有状态变化),中间位置发百分比码
|
|
1133
|
+
else if (state.curtainPosition !== undefined &&
|
|
1134
|
+
state.curtainStatus === undefined &&
|
|
1135
|
+
state.curtainPosition > 5 && state.curtainPosition < 95) {
|
|
1136
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'position', state.curtainPosition);
|
|
1137
|
+
actionName = `位置${state.curtainPosition}%`;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (frame) {
|
|
1141
|
+
// 全局防抖: 2秒内不重复发送开关码(过滤运行中的状态抖动)
|
|
1142
|
+
if (!node.lastSentTime) node.lastSentTime = {};
|
|
1143
|
+
const isOpenClose = (actionName === '打开' || actionName === '关闭');
|
|
1144
|
+
const cacheKey = isOpenClose ? `duya_${mapping.meshMac}_openclose` : `duya_${mapping.meshMac}_${actionName}`;
|
|
1145
|
+
const debounceTime = isOpenClose ? 2000 : 1500; // 开关码用2秒防抖
|
|
1146
|
+
const now = Date.now();
|
|
1147
|
+
const lastTime = node.lastSentTime[cacheKey] || 0;
|
|
1148
|
+
|
|
1149
|
+
if (now - lastTime < debounceTime) {
|
|
1150
|
+
node.debug(`[Mesh->杜亚] 窗帘 ${actionName} ${debounceTime}ms内防抖跳过`);
|
|
1151
|
+
} else {
|
|
1152
|
+
node.lastSentTime[cacheKey] = now;
|
|
1153
|
+
const hexStr = frame.toString('hex').toUpperCase();
|
|
1154
|
+
await node.sendRS485Frame(frame);
|
|
1155
|
+
node.log(`[Mesh->杜亚] 窗帘 ${actionName}, 位置${posForDirection}%, 发送: ${hexStr.match(/.{2}/g).join(' ')}`);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
node.status({ fill: 'green', shape: 'dot', text: `杜亚同步 ${node.mappings.length}个` });
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
504
1163
|
// ===== 自定义协议模式 =====
|
|
505
1164
|
if (mapping.brand === 'custom' && mapping.customCodes) {
|
|
506
1165
|
const codes = mapping.customCodes;
|
|
@@ -521,17 +1180,63 @@ module.exports = function(RED) {
|
|
|
521
1180
|
}
|
|
522
1181
|
// 窗帘类型
|
|
523
1182
|
else if (mapping.device === 'custom_curtain') {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1183
|
+
node.log(`[Mesh->自定义] 窗帘状态: ${JSON.stringify(state)}`);
|
|
1184
|
+
|
|
1185
|
+
// 优先级:curtainAction > curtainStatus > curtainPosition
|
|
1186
|
+
// 只发送一次,避免重复
|
|
1187
|
+
let hexCode = null;
|
|
1188
|
+
let actionName = '';
|
|
1189
|
+
|
|
1190
|
+
// 1. 优先检查动作命令
|
|
1191
|
+
if (state.curtainAction !== undefined || state.action !== undefined) {
|
|
1192
|
+
const action = state.curtainAction || state.action;
|
|
1193
|
+
if (action === 1 || action === 'open') {
|
|
1194
|
+
hexCode = codes.open;
|
|
1195
|
+
actionName = '打开';
|
|
1196
|
+
} else if (action === 2 || action === 'close') {
|
|
1197
|
+
hexCode = codes.close;
|
|
1198
|
+
actionName = '关闭';
|
|
1199
|
+
} else if (action === 3 || action === 'stop') {
|
|
1200
|
+
hexCode = codes.stop;
|
|
1201
|
+
actionName = '停止';
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
// 2. 其次检查运行状态
|
|
1205
|
+
else if (state.curtainStatus !== undefined) {
|
|
1206
|
+
if (state.curtainStatus === 1) {
|
|
1207
|
+
hexCode = codes.open;
|
|
1208
|
+
actionName = '打开(运行中)';
|
|
1209
|
+
} else if (state.curtainStatus === 2) {
|
|
1210
|
+
hexCode = codes.close;
|
|
1211
|
+
actionName = '关闭(运行中)';
|
|
1212
|
+
} else if (state.curtainStatus === 0 && codes.stop) {
|
|
1213
|
+
// 0=已停止,发送停止码
|
|
1214
|
+
hexCode = codes.stop;
|
|
1215
|
+
actionName = '停止';
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// 3. 最后检查位置(仅在极端位置时)
|
|
1219
|
+
else if (state.curtainPosition !== undefined) {
|
|
1220
|
+
if (state.curtainPosition >= 95) {
|
|
1221
|
+
hexCode = codes.open;
|
|
1222
|
+
actionName = '打开(位置>=95)';
|
|
1223
|
+
} else if (state.curtainPosition <= 5) {
|
|
1224
|
+
hexCode = codes.close;
|
|
1225
|
+
actionName = '关闭(位置<=5)';
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (hexCode) {
|
|
1230
|
+
// 防抖:500ms内不重复发送相同命令
|
|
1231
|
+
const cacheKey = `curtain_${mapping.meshMac}_${hexCode}`;
|
|
1232
|
+
const now = Date.now();
|
|
1233
|
+
if (node.lastSentTime && node.lastSentTime[cacheKey] && now - node.lastSentTime[cacheKey] < 500) {
|
|
1234
|
+
node.debug(`[Mesh->自定义] 窗帘 ${actionName} 防抖跳过`);
|
|
1235
|
+
} else {
|
|
1236
|
+
if (!node.lastSentTime) node.lastSentTime = {};
|
|
1237
|
+
node.lastSentTime[cacheKey] = now;
|
|
1238
|
+
await node.sendCustomCode(hexCode);
|
|
1239
|
+
node.log(`[Mesh->自定义] 窗帘 ${actionName}, 发送: ${hexCode}`);
|
|
535
1240
|
}
|
|
536
1241
|
}
|
|
537
1242
|
}
|
|
@@ -597,22 +1302,112 @@ module.exports = function(RED) {
|
|
|
597
1302
|
node.log(`[Mesh->RS485] 从机${mapping.address} 按键${rs485Channel}: ${value ? '开' : '关'}`);
|
|
598
1303
|
}
|
|
599
1304
|
}
|
|
1305
|
+
// 空调开关 (支持acSwitch和climateSwitch两种字段名)
|
|
1306
|
+
else if ((meshKey === 'acSwitch' || meshKey === 'climateSwitch') && registers && registers.switch) {
|
|
1307
|
+
const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 1) :
|
|
1308
|
+
(registers.switch.off !== undefined ? registers.switch.off : 0);
|
|
1309
|
+
await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
|
|
1310
|
+
node.log(`[Mesh->RS485] 空调开关: ${value ? '开' : '关'}`);
|
|
1311
|
+
}
|
|
1312
|
+
// 目标温度
|
|
600
1313
|
else if ((meshKey === 'targetTemp' || meshKey === 'acTargetTemp') && registers && registers.targetTemp) {
|
|
601
1314
|
await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
|
|
602
|
-
node.
|
|
1315
|
+
node.log(`[Mesh->RS485] 目标温度: ${value}°C`);
|
|
603
1316
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1317
|
+
// 空调模式 (支持acMode和climateMode两种字段名)
|
|
1318
|
+
// Mesh协议: 1=制冷, 2=制热, 3=送风, 4=除湿
|
|
1319
|
+
// RS485(话语前湾): 1=制热, 2=制冷, 4=送风, 8=除湿
|
|
1320
|
+
else if ((meshKey === 'acMode' || meshKey === 'climateMode') && registers && registers.mode) {
|
|
1321
|
+
// Mesh值转RS485值
|
|
1322
|
+
const meshToRs485 = { 1: 2, 2: 1, 3: 4, 4: 8 }; // 1=cool->2, 2=heat->1, 3=fan->4, 4=dry->8
|
|
1323
|
+
let rs485Value = meshToRs485[value];
|
|
1324
|
+
if (rs485Value === undefined) {
|
|
1325
|
+
// 尝试使用寄存器map反向映射
|
|
1326
|
+
if (registers.mode.map) {
|
|
1327
|
+
const modeNames = { 1: 'cool', 2: 'heat', 3: 'fan', 4: 'dry' };
|
|
1328
|
+
const meshModeName = modeNames[value] || value;
|
|
1329
|
+
const found = Object.entries(registers.mode.map).find(([k, v]) => v === meshModeName);
|
|
1330
|
+
if (found) {
|
|
1331
|
+
rs485Value = parseInt(found[0]);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (rs485Value !== undefined) {
|
|
1336
|
+
await node.writeModbusRegister(mapping.address, registers.mode, rs485Value);
|
|
1337
|
+
node.log(`[Mesh->RS485] 空调模式: Mesh值${value} -> RS485值${rs485Value}`);
|
|
1338
|
+
}
|
|
607
1339
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
1340
|
+
// 风速 (支持acFanSpeed和fanMode两种字段名)
|
|
1341
|
+
// 三合一0x94协议: 0=自动, 1=低, 2=中, 4=高
|
|
1342
|
+
// RS485(话语前湾): 1=低风, 2=中风, 3=高风
|
|
1343
|
+
else if ((meshKey === 'acFanSpeed' || meshKey === 'fanMode') && registers && registers.fanSpeed) {
|
|
1344
|
+
// 三合一0x94协议值转RS485值: 1=低->1, 2=中->2, 4=高->3, 0=自动->1(默认低)
|
|
1345
|
+
const meshToRs485 = { 0: 1, 1: 1, 2: 2, 4: 3 };
|
|
1346
|
+
let rs485Value = meshToRs485[value];
|
|
1347
|
+
if (rs485Value === undefined) {
|
|
1348
|
+
// 尝试使用寄存器map反向映射
|
|
1349
|
+
if (registers.fanSpeed.map) {
|
|
1350
|
+
const speedNames = { 0: 'auto', 1: 'low', 2: 'medium', 4: 'high' };
|
|
1351
|
+
const meshSpeedName = speedNames[value] || value;
|
|
1352
|
+
const found = Object.entries(registers.fanSpeed.map).find(([k, v]) => v === meshSpeedName);
|
|
1353
|
+
if (found) {
|
|
1354
|
+
rs485Value = parseInt(found[0]);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (rs485Value !== undefined) {
|
|
1359
|
+
await node.writeModbusRegister(mapping.address, registers.fanSpeed, rs485Value);
|
|
1360
|
+
node.log(`[Mesh->RS485] 空调风速: Mesh值${value} -> RS485值${rs485Value}`);
|
|
1361
|
+
}
|
|
611
1362
|
}
|
|
612
1363
|
else if (meshKey === 'brightness' && registers && registers.brightness) {
|
|
613
1364
|
await node.writeModbusRegister(mapping.address, registers.brightness, value);
|
|
614
1365
|
node.debug(`[Mesh->RS485] 亮度: ${value}`);
|
|
615
1366
|
}
|
|
1367
|
+
// ===== 地暖控制 =====
|
|
1368
|
+
// Mesh状态: floorHeatingSwitch (true/false), floorHeatingTemp (18-32)
|
|
1369
|
+
else if (meshKey === 'floorHeatingSwitch' && registers && registers.switch) {
|
|
1370
|
+
const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 2) :
|
|
1371
|
+
(registers.switch.off !== undefined ? registers.switch.off : 0);
|
|
1372
|
+
await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
|
|
1373
|
+
node.log(`[Mesh->RS485] 地暖开关: ${value ? '开' : '关'}, 值=${writeValue}`);
|
|
1374
|
+
}
|
|
1375
|
+
else if (meshKey === 'floorHeatingTemp' && registers && registers.targetTemp) {
|
|
1376
|
+
await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
|
|
1377
|
+
node.log(`[Mesh->RS485] 地暖温度: ${value}°C`);
|
|
1378
|
+
}
|
|
1379
|
+
// ===== 新风控制 =====
|
|
1380
|
+
// Mesh状态: freshAirSwitch (true/false), freshAirSpeed (1=高,2=中,3=低,4=自动)
|
|
1381
|
+
else if (meshKey === 'freshAirSwitch' && registers && registers.switch) {
|
|
1382
|
+
const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 1) :
|
|
1383
|
+
(registers.switch.off !== undefined ? registers.switch.off : 0);
|
|
1384
|
+
await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
|
|
1385
|
+
node.log(`[Mesh->RS485] 新风开关: ${value ? '开' : '关'}, 值=${writeValue}`);
|
|
1386
|
+
}
|
|
1387
|
+
else if (meshKey === 'freshAirSpeed' && registers && registers.fanSpeed) {
|
|
1388
|
+
// Mesh新风风速: 1=高, 2=中, 3=低, 4=自动
|
|
1389
|
+
// 话语前湾新风: 0=低速, 2=高速
|
|
1390
|
+
const meshToRs485 = { 1: 2, 2: 2, 3: 0, 4: 0 }; // 高/中->高速, 低/自动->低速
|
|
1391
|
+
const rs485Value = meshToRs485[value] !== undefined ? meshToRs485[value] : 0;
|
|
1392
|
+
await node.writeModbusRegister(mapping.address, registers.fanSpeed, rs485Value);
|
|
1393
|
+
node.log(`[Mesh->RS485] 新风风速: Mesh值${value} -> RS485值${rs485Value}`);
|
|
1394
|
+
}
|
|
1395
|
+
// ===== 空调开关(兼容climateSwitch) =====
|
|
1396
|
+
else if (meshKey === 'climateSwitch' && registers && registers.switch) {
|
|
1397
|
+
const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 1) :
|
|
1398
|
+
(registers.switch.off !== undefined ? registers.switch.off : 0);
|
|
1399
|
+
await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
|
|
1400
|
+
node.log(`[Mesh->RS485] 空调开关(climateSwitch): ${value ? '开' : '关'}`);
|
|
1401
|
+
}
|
|
1402
|
+
// ===== 空调模式(兼容climateMode) =====
|
|
1403
|
+
else if (meshKey === 'climateMode' && registers && registers.mode) {
|
|
1404
|
+
const meshToRs485 = { 1: 2, 2: 1, 3: 4, 4: 8 };
|
|
1405
|
+
const rs485Value = meshToRs485[value];
|
|
1406
|
+
if (rs485Value !== undefined) {
|
|
1407
|
+
await node.writeModbusRegister(mapping.address, registers.mode, rs485Value);
|
|
1408
|
+
node.log(`[Mesh->RS485] 空调模式(climateMode): Mesh值${value} -> RS485值${rs485Value}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
616
1411
|
} catch (err) {
|
|
617
1412
|
node.error(`RS485写入失败: ${meshKey}=${value}, ${err.message}`);
|
|
618
1413
|
}
|
|
@@ -637,15 +1432,60 @@ module.exports = function(RED) {
|
|
|
637
1432
|
node.syncModbusToMesh = async function(cmd) {
|
|
638
1433
|
const { mapping, registers, state, customMode } = cmd;
|
|
639
1434
|
|
|
640
|
-
//
|
|
641
|
-
const
|
|
1435
|
+
// 规范化MAC地址 - 尝试多种格式查找设备
|
|
1436
|
+
const meshMac = mapping.meshMac || '';
|
|
1437
|
+
const macNormalized = meshMac.toLowerCase().replace(/:/g, '');
|
|
1438
|
+
|
|
1439
|
+
// 尝试多种格式查找Mesh设备
|
|
1440
|
+
let meshDevice = node.gateway.getDevice(meshMac); // 原始格式
|
|
1441
|
+
if (!meshDevice) {
|
|
1442
|
+
meshDevice = node.gateway.getDevice(macNormalized); // 无冒号小写
|
|
1443
|
+
}
|
|
1444
|
+
if (!meshDevice) {
|
|
1445
|
+
// 尝试从设备管理器中遍历查找
|
|
1446
|
+
const allDevices = node.gateway.deviceManager?.getAllDevices() || [];
|
|
1447
|
+
meshDevice = allDevices.find(d => {
|
|
1448
|
+
const devMac = (d.macAddress || '').toLowerCase().replace(/:/g, '');
|
|
1449
|
+
return devMac === macNormalized;
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
|
|
642
1453
|
if (!meshDevice) {
|
|
643
|
-
node.warn(
|
|
1454
|
+
node.warn(`[RS485->Mesh] 未找到Mesh设备: ${meshMac} (规范化: ${macNormalized})`);
|
|
1455
|
+
// 输出可用设备列表帮助调试
|
|
1456
|
+
const allDevices = node.gateway.deviceManager?.getAllDevices() || [];
|
|
1457
|
+
node.warn(`[RS485->Mesh] 可用设备: ${allDevices.map(d => d.macAddress).join(', ')}`);
|
|
644
1458
|
return;
|
|
645
1459
|
}
|
|
1460
|
+
|
|
1461
|
+
node.log(`[RS485->Mesh] 找到设备: ${meshDevice.name}, MAC=${meshDevice.macAddress}, 网络地址=0x${meshDevice.networkAddress.toString(16)}`);
|
|
646
1462
|
|
|
647
1463
|
const channel = mapping.meshChannel || 1;
|
|
648
1464
|
|
|
1465
|
+
// ===== 杜亚窗帘协议模式 =====
|
|
1466
|
+
if (cmd.duyaMode || mapping.brand === 'duya') {
|
|
1467
|
+
node.log(`[杜亚->Mesh] 设备${mapping.meshMac}, 状态: ${JSON.stringify(state)}`);
|
|
1468
|
+
|
|
1469
|
+
try {
|
|
1470
|
+
if (state.curtainAction !== undefined || state.action !== undefined) {
|
|
1471
|
+
const action = state.curtainAction || (state.action === 'open' ? 1 : state.action === 'close' ? 2 : 3);
|
|
1472
|
+
const param = Buffer.from([action]);
|
|
1473
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x05, param);
|
|
1474
|
+
node.log(`[杜亚->Mesh] 窗帘动作: ${action === 1 ? '打开' : action === 2 ? '关闭' : '停止'}`);
|
|
1475
|
+
} else if (state.curtainPosition !== undefined || state.position !== undefined) {
|
|
1476
|
+
const pos = state.curtainPosition || state.position;
|
|
1477
|
+
const param = Buffer.from([pos]);
|
|
1478
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x06, param);
|
|
1479
|
+
node.log(`[杜亚->Mesh] 窗帘位置: ${pos}%`);
|
|
1480
|
+
}
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
node.error(`[杜亚->Mesh] 写入失败: ${err.message}`);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
node.status({ fill: 'blue', shape: 'dot', text: `杜亚同步 ${node.mappings.length}个` });
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
649
1489
|
// ===== 自定义协议模式 =====
|
|
650
1490
|
if (customMode || mapping.brand === 'custom') {
|
|
651
1491
|
node.log(`[自定义->Mesh] 设备${mapping.meshMac}, 通道${channel}, 状态: ${JSON.stringify(state)}`);
|
|
@@ -661,14 +1501,16 @@ module.exports = function(RED) {
|
|
|
661
1501
|
}
|
|
662
1502
|
// 窗帘类型
|
|
663
1503
|
else if (key === 'action' || key === 'position') {
|
|
664
|
-
|
|
1504
|
+
// 窗帘动作: 1=打开, 2=关闭, 3=停止
|
|
1505
|
+
let action = 0x03; // 停止
|
|
665
1506
|
if (value === 'open') action = 0x01; // 打开
|
|
666
1507
|
else if (value === 'close') action = 0x02; // 关闭
|
|
667
|
-
else if (value === 'stop') action =
|
|
1508
|
+
else if (value === 'stop') action = 0x03; // 停止
|
|
668
1509
|
|
|
669
1510
|
const param = Buffer.from([action]);
|
|
670
|
-
|
|
671
|
-
node.
|
|
1511
|
+
// 0x05是窗帘动作控制属性
|
|
1512
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x05, param);
|
|
1513
|
+
node.log(`[自定义->Mesh] 窗帘: ${value} (动作码${action})`);
|
|
672
1514
|
}
|
|
673
1515
|
} catch (err) {
|
|
674
1516
|
node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
|
|
@@ -698,28 +1540,89 @@ module.exports = function(RED) {
|
|
|
698
1540
|
else if (key.startsWith('led')) {
|
|
699
1541
|
node.debug(`[RS485] 指示灯${key}: ${value}`);
|
|
700
1542
|
}
|
|
1543
|
+
// 空调开关 - 0x02是开关属性 (0x01=关, 0x02=开)
|
|
1544
|
+
else if (key === 'acSwitch' || (key === 'switch' && mapping.device && mapping.device.includes('ac'))) {
|
|
1545
|
+
const param = Buffer.from([value ? 0x02 : 0x01]);
|
|
1546
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
|
|
1547
|
+
node.log(`[RS485->Mesh] 空调开关: ${value ? '开' : '关'}`);
|
|
1548
|
+
}
|
|
1549
|
+
// 目标温度 - 0x1B是目标温度属性
|
|
701
1550
|
else if (key === 'targetTemp') {
|
|
702
1551
|
const param = Buffer.from([Math.round(value)]);
|
|
703
|
-
await node.gateway.sendControl(meshDevice.networkAddress,
|
|
704
|
-
node.
|
|
1552
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x1B, param);
|
|
1553
|
+
node.log(`[RS485->Mesh] 目标温度: ${value}°C`);
|
|
705
1554
|
}
|
|
1555
|
+
// 空调模式 - 0x1D是模式属性
|
|
1556
|
+
// RS485(话语前湾): 1=制热, 2=制冷, 4=送风, 8=除湿
|
|
1557
|
+
// Mesh协议: 1=制冷, 2=制热, 3=送风, 4=除湿
|
|
706
1558
|
else if (key === 'mode') {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1559
|
+
// 从RS485值或字符串转换为Mesh值
|
|
1560
|
+
let meshMode = 1; // 默认制冷
|
|
1561
|
+
if (typeof value === 'string') {
|
|
1562
|
+
const modeMap = { 'cool': 1, 'heat': 2, 'fan': 3, 'dry': 4 };
|
|
1563
|
+
meshMode = modeMap[value] !== undefined ? modeMap[value] : 1;
|
|
1564
|
+
} else {
|
|
1565
|
+
// RS485(话语前湾)数值转Mesh值
|
|
1566
|
+
const rs485ToMesh = { 1: 2, 2: 1, 4: 3, 8: 4 }; // 1=heat->2, 2=cool->1, 4=fan->3, 8=dry->4
|
|
1567
|
+
meshMode = rs485ToMesh[value] !== undefined ? rs485ToMesh[value] : 1;
|
|
1568
|
+
}
|
|
1569
|
+
const param = Buffer.from([meshMode]);
|
|
1570
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
|
|
1571
|
+
node.log(`[RS485->Mesh] 空调模式: RS485值${value} -> Mesh值${meshMode}`);
|
|
711
1572
|
}
|
|
1573
|
+
// 风速 - 0x1C是风速属性
|
|
1574
|
+
// RS485(话语前湾): 1=低风, 2=中风, 3=高风
|
|
1575
|
+
// Mesh协议: 1=高, 2=中, 3=低, 4=自动
|
|
712
1576
|
else if (key === 'fanSpeed') {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1577
|
+
let meshSpeed = 3; // 默认低
|
|
1578
|
+
if (typeof value === 'string') {
|
|
1579
|
+
const speedMap = { 'low': 3, 'medium': 2, 'high': 1, 'auto': 4 };
|
|
1580
|
+
meshSpeed = speedMap[value] !== undefined ? speedMap[value] : 3;
|
|
1581
|
+
} else {
|
|
1582
|
+
// RS485(话语前湾)数值转Mesh值: 1=低->3, 2=中->2, 3=高->1
|
|
1583
|
+
const rs485ToMesh = { 1: 3, 2: 2, 3: 1 };
|
|
1584
|
+
meshSpeed = rs485ToMesh[value] !== undefined ? rs485ToMesh[value] : 3;
|
|
1585
|
+
}
|
|
1586
|
+
const param = Buffer.from([meshSpeed]);
|
|
1587
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
|
|
1588
|
+
node.log(`[RS485->Mesh] 空调风速: RS485值${value} -> Mesh值${meshSpeed}`);
|
|
717
1589
|
}
|
|
718
1590
|
else if (key === 'brightness') {
|
|
719
1591
|
const param = Buffer.from([Math.round(value)]);
|
|
720
1592
|
await node.gateway.sendControl(meshDevice.networkAddress, 0x03, param);
|
|
721
1593
|
node.debug(`[RS485->Mesh] 亮度: ${value}`);
|
|
722
1594
|
}
|
|
1595
|
+
// ===== 地暖控制 (RS485->Mesh) =====
|
|
1596
|
+
// 地暖开关 - 0x6B是地暖开关属性 (0x02=开, 0x01=关)
|
|
1597
|
+
else if (key === 'floorHeatingSwitch') {
|
|
1598
|
+
const param = Buffer.from([value ? 0x02 : 0x01]);
|
|
1599
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x6B, param);
|
|
1600
|
+
node.log(`[RS485->Mesh] 地暖开关: ${value ? '开' : '关'}`);
|
|
1601
|
+
}
|
|
1602
|
+
// 地暖温度 - 0x6C是地暖温度属性 (18-32°C)
|
|
1603
|
+
else if (key === 'floorHeatingTemp') {
|
|
1604
|
+
const temp = Math.max(18, Math.min(32, Math.round(value)));
|
|
1605
|
+
const param = Buffer.from([temp]);
|
|
1606
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x6C, param);
|
|
1607
|
+
node.log(`[RS485->Mesh] 地暖温度: ${temp}°C`);
|
|
1608
|
+
}
|
|
1609
|
+
// ===== 新风控制 (RS485->Mesh) =====
|
|
1610
|
+
// 新风开关 - 0x68是新风开关属性 (0x02=开, 0x01=关)
|
|
1611
|
+
else if (key === 'freshAirSwitch') {
|
|
1612
|
+
const param = Buffer.from([value ? 0x02 : 0x01]);
|
|
1613
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x68, param);
|
|
1614
|
+
node.log(`[RS485->Mesh] 新风开关: ${value ? '开' : '关'}`);
|
|
1615
|
+
}
|
|
1616
|
+
// 新风风速 - 0x6A是新风风速属性 (1=高,2=中,3=低,4=自动)
|
|
1617
|
+
else if (key === 'freshAirSpeed') {
|
|
1618
|
+
// 话语前湾新风: 0=低速, 2=高速 -> Mesh: 1=高,3=低
|
|
1619
|
+
let meshSpeed = 3; // 默认低
|
|
1620
|
+
if (value === 2 || value === 'high') meshSpeed = 1; // 高速
|
|
1621
|
+
else if (value === 0 || value === 'low') meshSpeed = 3; // 低速
|
|
1622
|
+
const param = Buffer.from([meshSpeed]);
|
|
1623
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x6A, param);
|
|
1624
|
+
node.log(`[RS485->Mesh] 新风风速: RS485值${value} -> Mesh值${meshSpeed}`);
|
|
1625
|
+
}
|
|
723
1626
|
} catch (err) {
|
|
724
1627
|
node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
|
|
725
1628
|
}
|
|
@@ -815,12 +1718,83 @@ module.exports = function(RED) {
|
|
|
815
1718
|
const hexStr = frame.toString('hex').toUpperCase();
|
|
816
1719
|
const hexFormatted = hexStr.match(/.{1,2}/g)?.join(' ') || '';
|
|
817
1720
|
|
|
818
|
-
|
|
1721
|
+
node.log(`[RS485收到] ${hexFormatted} (${frame.length}字节)`);
|
|
1722
|
+
|
|
1723
|
+
// ===== 杜亚窗帘协议检测 (55开头) =====
|
|
1724
|
+
if (frame[0] === 0x55 && frame.length >= 7) {
|
|
1725
|
+
node.log(`[杜亚帧检测] 检测到55帧头, 长度=${frame.length}, 开始解析...`);
|
|
1726
|
+
const duyaData = parseA6B6Frame(frame);
|
|
1727
|
+
if (duyaData) {
|
|
1728
|
+
node.log(`[杜亚帧解析] 成功! 地址高=${duyaData.addrHigh}, 地址低=${duyaData.addrLow}, 动作=${duyaData.action}`);
|
|
1729
|
+
|
|
1730
|
+
// 查找匹配的杜亚映射
|
|
1731
|
+
let foundMapping = false;
|
|
1732
|
+
node.log(`[杜亚映射查找] 当前映射数: ${node.mappings.length}`);
|
|
1733
|
+
for (const mapping of node.mappings) {
|
|
1734
|
+
if (mapping.brand !== 'duya') {
|
|
1735
|
+
// 检查是否是自定义窗帘(也可能匹配55帧)
|
|
1736
|
+
if (mapping.brand === 'custom' && mapping.device === 'custom_curtain') {
|
|
1737
|
+
node.debug(`[杜亚映射] 发现自定义窗帘映射,将在后续自定义码匹配中处理`);
|
|
1738
|
+
}
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// 检查2字节地址是否匹配
|
|
1743
|
+
const mapAddrHigh = mapping.addrHigh || 1;
|
|
1744
|
+
const mapAddrLow = mapping.addrLow || 1;
|
|
1745
|
+
|
|
1746
|
+
node.debug(`[杜亚映射检查] 配置地址=${mapAddrHigh}:${mapAddrLow}, 帧地址=${duyaData.addrHigh}:${duyaData.addrLow}`);
|
|
1747
|
+
|
|
1748
|
+
if (duyaData.addrHigh === mapAddrHigh && duyaData.addrLow === mapAddrLow) {
|
|
1749
|
+
foundMapping = true;
|
|
1750
|
+
// 防死循环: 1秒内忽略响应
|
|
1751
|
+
if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 1000) {
|
|
1752
|
+
node.debug(`[防循环] 忽略杜亚响应: ${hexFormatted}`);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
node.log(`[杜亚->Mesh] 窗帘响应: ${duyaData.action}, 帧: ${hexFormatted}`);
|
|
1757
|
+
|
|
1758
|
+
// 构建Mesh状态
|
|
1759
|
+
let meshState = {};
|
|
1760
|
+
if (duyaData.action === 'open') {
|
|
1761
|
+
meshState = { action: 'open', curtainAction: 1 };
|
|
1762
|
+
} else if (duyaData.action === 'close') {
|
|
1763
|
+
meshState = { action: 'close', curtainAction: 2 };
|
|
1764
|
+
} else if (duyaData.action === 'stop') {
|
|
1765
|
+
meshState = { action: 'stop', curtainAction: 3 };
|
|
1766
|
+
} else if (duyaData.action === 'position') {
|
|
1767
|
+
meshState = { position: duyaData.position, curtainPosition: duyaData.position };
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
node.queueCommand({
|
|
1771
|
+
direction: 'modbus-to-mesh',
|
|
1772
|
+
mapping: mapping,
|
|
1773
|
+
state: meshState,
|
|
1774
|
+
duyaMode: true,
|
|
1775
|
+
timestamp: Date.now()
|
|
1776
|
+
});
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
if (!foundMapping) {
|
|
1782
|
+
node.warn(`[杜亚] 未找到匹配的映射, 帧地址=${duyaData.addrHigh}:${duyaData.addrLow},请检查RS485桥配置`);
|
|
1783
|
+
}
|
|
1784
|
+
} else {
|
|
1785
|
+
node.debug(`[杜亚帧检测] 解析失败,可能funcCode不是0x03`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// 检查自定义码匹配(遍历所有映射)
|
|
1790
|
+
node.log(`[自定义码检测] 开始匹配, 当前帧hex=${hexStr}, 映射数=${node.mappings.length}`);
|
|
819
1791
|
for (const mapping of node.mappings) {
|
|
820
1792
|
if (mapping.brand === 'custom' && mapping.customCodes) {
|
|
821
1793
|
const codes = mapping.customCodes;
|
|
822
1794
|
let matchedAction = null;
|
|
823
1795
|
|
|
1796
|
+
node.debug(`[自定义码检测] 检查映射: device=${mapping.device}, meshMac=${mapping.meshMac}, codes=${JSON.stringify(codes)}`);
|
|
1797
|
+
|
|
824
1798
|
// 开关类型:匹配on/off
|
|
825
1799
|
if (mapping.device === 'custom_switch') {
|
|
826
1800
|
if (codes.on && hexStr.includes(codes.on.replace(/\s/g, '').toUpperCase())) {
|
|
@@ -829,14 +1803,24 @@ module.exports = function(RED) {
|
|
|
829
1803
|
matchedAction = { switch: false };
|
|
830
1804
|
}
|
|
831
1805
|
}
|
|
832
|
-
// 窗帘类型:匹配open/close/stop
|
|
1806
|
+
// 窗帘类型:匹配open/close/stop(只设置action,避免重复发码)
|
|
833
1807
|
else if (mapping.device === 'custom_curtain') {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1808
|
+
const openCode = codes.open ? codes.open.replace(/\s/g, '').toUpperCase() : '';
|
|
1809
|
+
const closeCode = codes.close ? codes.close.replace(/\s/g, '').toUpperCase() : '';
|
|
1810
|
+
const stopCode = codes.stop ? codes.stop.replace(/\s/g, '').toUpperCase() : '';
|
|
1811
|
+
node.debug(`[自定义窗帘匹配] 帧hex=${hexStr}, open=${openCode}, close=${closeCode}, stop=${stopCode}`);
|
|
1812
|
+
|
|
1813
|
+
if (openCode && hexStr.includes(openCode)) {
|
|
1814
|
+
matchedAction = { action: 'open' };
|
|
1815
|
+
node.log(`[自定义窗帘匹配] 匹配到打开码!`);
|
|
1816
|
+
} else if (closeCode && hexStr.includes(closeCode)) {
|
|
1817
|
+
matchedAction = { action: 'close' };
|
|
1818
|
+
node.log(`[自定义窗帘匹配] 匹配到关闭码!`);
|
|
1819
|
+
} else if (stopCode && hexStr.includes(stopCode)) {
|
|
1820
|
+
matchedAction = { action: 'stop' };
|
|
1821
|
+
node.log(`[自定义窗帘匹配] 匹配到停止码!`);
|
|
1822
|
+
} else {
|
|
1823
|
+
node.debug(`[自定义窗帘匹配] 未匹配到任何码`);
|
|
840
1824
|
}
|
|
841
1825
|
}
|
|
842
1826
|
// 场景类型:匹配trigger
|
|
@@ -891,22 +1875,18 @@ module.exports = function(RED) {
|
|
|
891
1875
|
|
|
892
1876
|
const slaveAddr = frame[0];
|
|
893
1877
|
const fc = frame[1];
|
|
1878
|
+
const hexFormatted = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
|
|
894
1879
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
// 自定义模式不走标准Modbus解析
|
|
903
|
-
if (mapping.brand === 'custom') return;
|
|
904
|
-
|
|
905
|
-
const registers = node.getRegistersForMapping(mapping);
|
|
906
|
-
if (!registers) {
|
|
907
|
-
node.debug(`未找到设备${mapping.device}的寄存器定义`);
|
|
1880
|
+
node.log(`[Modbus解析] 从机=${slaveAddr}, 功能码=0x${fc.toString(16)}, 帧=${hexFormatted}`);
|
|
1881
|
+
|
|
1882
|
+
// 查找所有匹配从机地址的映射
|
|
1883
|
+
const allMappings = node.findAllRS485Mappings(slaveAddr);
|
|
1884
|
+
if (allMappings.length === 0) {
|
|
1885
|
+
node.debug(`[Modbus解析] 未找到从机${slaveAddr}的映射配置`);
|
|
908
1886
|
return;
|
|
909
1887
|
}
|
|
1888
|
+
|
|
1889
|
+
node.log(`[Modbus解析] 找到${allMappings.length}个匹配映射`);
|
|
910
1890
|
|
|
911
1891
|
// 防死循环:检查是否刚刚从Mesh发送过来
|
|
912
1892
|
if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 500) {
|
|
@@ -914,15 +1894,45 @@ module.exports = function(RED) {
|
|
|
914
1894
|
return;
|
|
915
1895
|
}
|
|
916
1896
|
|
|
917
|
-
//
|
|
918
|
-
|
|
1897
|
+
// 解析寄存器地址和值
|
|
1898
|
+
if (fc !== 0x06 && fc !== 0x10) {
|
|
1899
|
+
// 暂不处理其他功能码
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
const regAddr = frame.readUInt16BE(2);
|
|
1904
|
+
const value = frame.readUInt16BE(4);
|
|
1905
|
+
|
|
1906
|
+
// 根据寄存器地址确定是哪个rs485Channel
|
|
1907
|
+
// 话语前湾开关寄存器:0x1031=CH1, 0x1032=CH2, 0x1033=CH3, 0x1034=CH4, 0x1035=CH5, 0x1036=CH6
|
|
1908
|
+
let rs485ChannelFromReg = null;
|
|
1909
|
+
if (regAddr >= 0x1031 && regAddr <= 0x1036) {
|
|
1910
|
+
rs485ChannelFromReg = regAddr - 0x1030; // 0x1031->1, 0x1032->2, ...
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
node.log(`[Modbus解析] 寄存器=0x${regAddr.toString(16).toUpperCase()}, 值=${value}, rs485Channel=${rs485ChannelFromReg}`);
|
|
919
1914
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1915
|
+
// 遍历所有匹配的映射,只处理rs485Channel匹配的
|
|
1916
|
+
for (const mapping of allMappings) {
|
|
1917
|
+
// 自定义模式不走标准Modbus解析
|
|
1918
|
+
if (mapping.brand === 'custom') continue;
|
|
1919
|
+
|
|
1920
|
+
const mappingRs485Channel = mapping.rs485Channel || 1;
|
|
1921
|
+
|
|
1922
|
+
// 如果是开关寄存器,检查rs485Channel是否匹配
|
|
1923
|
+
if (rs485ChannelFromReg !== null && rs485ChannelFromReg !== mappingRs485Channel) {
|
|
1924
|
+
node.debug(`[Modbus解析] 跳过映射: rs485Channel不匹配 (帧=${rs485ChannelFromReg}, 映射=${mappingRs485Channel})`);
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const registers = node.getRegistersForMapping(mapping);
|
|
1929
|
+
if (!registers) {
|
|
1930
|
+
node.debug(`未找到设备${mapping.device}的寄存器定义`);
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
924
1933
|
|
|
925
1934
|
// 查找匹配的寄存器定义
|
|
1935
|
+
let state = {};
|
|
926
1936
|
for (const [key, reg] of Object.entries(registers)) {
|
|
927
1937
|
if (reg.address === regAddr) {
|
|
928
1938
|
if (key.startsWith('switch') || key.startsWith('led')) {
|
|
@@ -936,51 +1946,35 @@ module.exports = function(RED) {
|
|
|
936
1946
|
break;
|
|
937
1947
|
}
|
|
938
1948
|
}
|
|
939
|
-
} else if (fc === 0x03 || fc === 0x04) {
|
|
940
|
-
const byteCount = frame[2];
|
|
941
|
-
for (let i = 0; i < byteCount / 2; i++) {
|
|
942
|
-
const value = frame.readUInt16BE(3 + i * 2);
|
|
943
|
-
for (const [key, reg] of Object.entries(registers)) {
|
|
944
|
-
if (reg.map) {
|
|
945
|
-
state[key] = reg.map[value] || value;
|
|
946
|
-
} else {
|
|
947
|
-
state[key] = value;
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
} else if (fc === 0x20) {
|
|
952
|
-
if (frame.length >= 9) {
|
|
953
|
-
const startReg = frame.readUInt16BE(2);
|
|
954
|
-
const count = frame.readUInt16BE(4);
|
|
955
|
-
node.debug(`[RS485] 从机${slaveAddr} 自定义上报: 起始0x${startReg.toString(16)}, 数量${count}`);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
if (Object.keys(state).length > 0) {
|
|
960
|
-
node.log(`[RS485->Mesh] 设备@${slaveAddr} 状态变化: ${JSON.stringify(state)}`);
|
|
961
1949
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1950
|
+
if (Object.keys(state).length > 0) {
|
|
1951
|
+
node.log(`[RS485->Mesh] 映射匹配: meshMac=${mapping.meshMac}, meshCH=${mapping.meshChannel}, rs485CH=${mappingRs485Channel}, 状态=${JSON.stringify(state)}`);
|
|
1952
|
+
|
|
1953
|
+
// 输出调试信息到节点输出端口
|
|
1954
|
+
node.send({
|
|
1955
|
+
topic: 'rs485-state-change',
|
|
1956
|
+
payload: {
|
|
1957
|
+
direction: 'RS485→Mesh',
|
|
1958
|
+
slaveAddr: slaveAddr,
|
|
1959
|
+
funcCode: fc,
|
|
1960
|
+
brand: mapping.brand,
|
|
1961
|
+
device: mapping.device,
|
|
1962
|
+
meshMac: mapping.meshMac,
|
|
1963
|
+
meshChannel: mapping.meshChannel,
|
|
1964
|
+
rs485Channel: mappingRs485Channel,
|
|
1965
|
+
state: state
|
|
1966
|
+
},
|
|
1967
|
+
timestamp: new Date().toISOString()
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
node.queueCommand({
|
|
1971
|
+
direction: 'modbus-to-mesh',
|
|
1972
|
+
mapping: mapping,
|
|
1973
|
+
registers: registers,
|
|
1974
|
+
state: state,
|
|
1975
|
+
timestamp: Date.now()
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
984
1978
|
}
|
|
985
1979
|
};
|
|
986
1980
|
|
|
@@ -998,6 +1992,7 @@ module.exports = function(RED) {
|
|
|
998
1992
|
// 事件监听 - Mesh网关共享,无冲突
|
|
999
1993
|
node.gateway.on('device-list-complete', init);
|
|
1000
1994
|
node.gateway.on('device-state-changed', handleMeshStateChange);
|
|
1995
|
+
node.gateway.on('curtain-control', handleCurtainControl); // 窗帘控制立即同步
|
|
1001
1996
|
|
|
1002
1997
|
if (node.gateway.deviceListComplete) {
|
|
1003
1998
|
init();
|
|
@@ -1081,8 +2076,11 @@ module.exports = function(RED) {
|
|
|
1081
2076
|
// 清理
|
|
1082
2077
|
node.on('close', (done) => {
|
|
1083
2078
|
// 移除Mesh网关事件监听器
|
|
1084
|
-
node.gateway
|
|
1085
|
-
|
|
2079
|
+
if (node.gateway) {
|
|
2080
|
+
node.gateway.removeListener('device-list-complete', init);
|
|
2081
|
+
node.gateway.removeListener('device-state-changed', handleMeshStateChange);
|
|
2082
|
+
node.gateway.removeListener('curtain-control', handleCurtainControl);
|
|
2083
|
+
}
|
|
1086
2084
|
|
|
1087
2085
|
// 移除RS485配置节点事件监听器
|
|
1088
2086
|
if (node.rs485Config && node._rs485Handlers) {
|
|
@@ -1093,6 +2091,15 @@ module.exports = function(RED) {
|
|
|
1093
2091
|
node.rs485Config.deregister(node);
|
|
1094
2092
|
}
|
|
1095
2093
|
|
|
2094
|
+
// 清理缓存和队列,防止内存泄漏
|
|
2095
|
+
node.stateCache = {};
|
|
2096
|
+
node.commandQueue = [];
|
|
2097
|
+
node.curtainDebounce = {};
|
|
2098
|
+
node.curtainCache = {};
|
|
2099
|
+
node.lastSentTime = {};
|
|
2100
|
+
node.processing = false;
|
|
2101
|
+
node.syncLock = false;
|
|
2102
|
+
|
|
1096
2103
|
node.log('[RS485 Bridge] 节点已清理');
|
|
1097
2104
|
done();
|
|
1098
2105
|
});
|
|
@@ -1129,12 +2136,20 @@ module.exports = function(RED) {
|
|
|
1129
2136
|
displayName = (d.name || '设备') + '_' + macClean;
|
|
1130
2137
|
}
|
|
1131
2138
|
|
|
2139
|
+
// 三合一面板特殊处理
|
|
2140
|
+
const isThreeInOne = d.isThreeInOne || false;
|
|
2141
|
+
if (isThreeInOne) {
|
|
2142
|
+
displayName = '三合一面板_' + macClean;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
1132
2145
|
return {
|
|
1133
2146
|
mac: d.macAddress,
|
|
1134
2147
|
name: displayName,
|
|
1135
2148
|
originalName: d.name,
|
|
1136
2149
|
type: d.deviceType,
|
|
1137
|
-
channels: channels
|
|
2150
|
+
channels: channels,
|
|
2151
|
+
isThreeInOne: isThreeInOne,
|
|
2152
|
+
entityType: d.getEntityType ? d.getEntityType() : 'unknown'
|
|
1138
2153
|
};
|
|
1139
2154
|
});
|
|
1140
2155
|
res.json(devices);
|
|
@@ -1163,4 +2178,5 @@ module.exports = function(RED) {
|
|
|
1163
2178
|
res.json([]);
|
|
1164
2179
|
}
|
|
1165
2180
|
});
|
|
2181
|
+
|
|
1166
2182
|
};
|