node-red-contrib-symi-mesh 1.6.2 → 1.6.4
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 +31 -2
- package/nodes/symi-485-bridge.html +91 -4
- package/nodes/symi-485-bridge.js +342 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -539,9 +539,38 @@ Mesh 四键开关 第2路 ↔ RS485 六键开关 第5路 地址:2 ✓ 支持
|
|
|
539
539
|
|
|
540
540
|
| 品牌 | 支持设备 |
|
|
541
541
|
|-----|---------|
|
|
542
|
-
| 话语前湾 |
|
|
542
|
+
| 话语前湾 | 空调(客厅/主卧/次卧1/次卧2)、地暖、新风、窗帘、1-8键开关 |
|
|
543
|
+
| 通用Modbus | 各类标准Modbus设备 |
|
|
544
|
+
| 自定义协议 | 任意485码匹配(开关/窗帘/场景) |
|
|
543
545
|
|
|
544
|
-
|
|
546
|
+
### 自定义协议模式
|
|
547
|
+
|
|
548
|
+
当内置协议无法满足需求时,可使用"自定义协议"模式,手动录入RS485十六进制码进行双向匹配:
|
|
549
|
+
|
|
550
|
+
#### 自定义开关
|
|
551
|
+
- **打开码**:当Mesh开关打开时发送此码,收到此码时触发Mesh开关打开
|
|
552
|
+
- **关闭码**:当Mesh开关关闭时发送此码,收到此码时触发Mesh开关关闭
|
|
553
|
+
|
|
554
|
+
#### 自定义窗帘
|
|
555
|
+
- **打开码**:Mesh窗帘打开 ↔ RS485打开命令
|
|
556
|
+
- **关闭码**:Mesh窗帘关闭 ↔ RS485关闭命令
|
|
557
|
+
- **停止码**:Mesh窗帘停止 ↔ RS485停止命令
|
|
558
|
+
|
|
559
|
+
#### 配置示例
|
|
560
|
+
```
|
|
561
|
+
品牌: 自定义协议
|
|
562
|
+
类型: 自定义窗帘
|
|
563
|
+
打开码: 55 02 02 03 01 49 44
|
|
564
|
+
关闭码: 55 02 02 03 02 09 45
|
|
565
|
+
停止码: 55 02 02 03 03 C8 85
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
#### 工作原理
|
|
569
|
+
1. **Mesh→RS485**:Mesh设备状态变化时,发送对应的自定义码
|
|
570
|
+
2. **RS485→Mesh**:收到的RS485帧与自定义码匹配,触发对应Mesh设备
|
|
571
|
+
3. **防死循环**:500ms内不重复触发,避免Mesh→RS485→Mesh循环
|
|
572
|
+
|
|
573
|
+
> 自定义码支持任意长度的十六进制字符串,支持空格分隔(如 `55 01 01` 或 `550101`)
|
|
545
574
|
|
|
546
575
|
### 注意事项
|
|
547
576
|
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
mappings.forEach(function(m, idx) {
|
|
162
|
-
var row = $('<div class="mapping-row" data-idx="' + idx + '"></div>');
|
|
162
|
+
var row = $('<div class="mapping-row" data-idx="' + idx + '" style="flex-wrap:wrap;"></div>');
|
|
163
163
|
row.html(
|
|
164
164
|
'<div class="mesh-col">' +
|
|
165
165
|
' <select class="mesh-select">' + getMeshOptions(m.meshMac) + '</select>' +
|
|
@@ -179,6 +179,10 @@
|
|
|
179
179
|
'<div class="del-col"><button type="button" class="red-ui-button red-ui-button-small btn-remove" title="删除"><i class="fa fa-times"></i></button></div>'
|
|
180
180
|
);
|
|
181
181
|
container.append(row);
|
|
182
|
+
// 如果是自定义协议,显示自定义码输入框
|
|
183
|
+
if (m.brand === 'custom' && m.device) {
|
|
184
|
+
updateCustomCodesVisibility(row, m.brand, m.device, idx);
|
|
185
|
+
}
|
|
182
186
|
});
|
|
183
187
|
|
|
184
188
|
bindEvents();
|
|
@@ -194,9 +198,11 @@
|
|
|
194
198
|
var brandId = $(this).val();
|
|
195
199
|
row.find('.device-select').html(getDeviceOptions(brandId, ''));
|
|
196
200
|
row.find('.rs485-ch-wrap').empty();
|
|
201
|
+
row.find('.custom-codes-row').remove(); // 移除自定义码区域
|
|
197
202
|
mappings[idx].brand = brandId;
|
|
198
203
|
mappings[idx].device = '';
|
|
199
204
|
mappings[idx].rs485Channel = 1;
|
|
205
|
+
mappings[idx].customCodes = {};
|
|
200
206
|
});
|
|
201
207
|
|
|
202
208
|
container.find('.device-select').off('change').on('change', function() {
|
|
@@ -208,6 +214,8 @@
|
|
|
208
214
|
// 更新RS485按键选择
|
|
209
215
|
row.find('.rs485-ch-wrap').html(getRS485ChannelOptions(brandId, deviceId, 1));
|
|
210
216
|
mappings[idx].rs485Channel = 1;
|
|
217
|
+
// 显示/隐藏自定义码输入区域
|
|
218
|
+
updateCustomCodesVisibility(row, brandId, deviceId, idx);
|
|
211
219
|
bindEvents(); // 重新绑定新添加的元素事件
|
|
212
220
|
});
|
|
213
221
|
|
|
@@ -242,11 +250,73 @@
|
|
|
242
250
|
mappings.splice(idx, 1);
|
|
243
251
|
renderMappings();
|
|
244
252
|
});
|
|
253
|
+
|
|
254
|
+
// 自定义码输入事件
|
|
255
|
+
container.find('.custom-code-on').off('change').on('change', function() {
|
|
256
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
257
|
+
mappings[idx].customCodes = mappings[idx].customCodes || {};
|
|
258
|
+
mappings[idx].customCodes.on = $(this).val();
|
|
259
|
+
});
|
|
260
|
+
container.find('.custom-code-off').off('change').on('change', function() {
|
|
261
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
262
|
+
mappings[idx].customCodes = mappings[idx].customCodes || {};
|
|
263
|
+
mappings[idx].customCodes.off = $(this).val();
|
|
264
|
+
});
|
|
265
|
+
container.find('.custom-code-open').off('change').on('change', function() {
|
|
266
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
267
|
+
mappings[idx].customCodes = mappings[idx].customCodes || {};
|
|
268
|
+
mappings[idx].customCodes.open = $(this).val();
|
|
269
|
+
});
|
|
270
|
+
container.find('.custom-code-close').off('change').on('change', function() {
|
|
271
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
272
|
+
mappings[idx].customCodes = mappings[idx].customCodes || {};
|
|
273
|
+
mappings[idx].customCodes.close = $(this).val();
|
|
274
|
+
});
|
|
275
|
+
container.find('.custom-code-stop').off('change').on('change', function() {
|
|
276
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
277
|
+
mappings[idx].customCodes = mappings[idx].customCodes || {};
|
|
278
|
+
mappings[idx].customCodes.stop = $(this).val();
|
|
279
|
+
});
|
|
280
|
+
container.find('.custom-code-trigger').off('change').on('change', function() {
|
|
281
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
282
|
+
mappings[idx].customCodes = mappings[idx].customCodes || {};
|
|
283
|
+
mappings[idx].customCodes.trigger = $(this).val();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 更新自定义码输入区域的显示
|
|
288
|
+
function updateCustomCodesVisibility(row, brandId, deviceId, idx) {
|
|
289
|
+
row.find('.custom-codes-row').remove();
|
|
290
|
+
if (brandId !== 'custom') return;
|
|
291
|
+
|
|
292
|
+
var codes = mappings[idx].customCodes || {};
|
|
293
|
+
var html = '<div class="custom-codes-row" style="width:100%;padding:8px;margin-top:6px;background:#fff8e1;border:1px dashed #ffa726;border-radius:4px;">';
|
|
294
|
+
html += '<div style="font-size:11px;color:#f57c00;margin-bottom:6px;"><i class="fa fa-code"></i> 自定义RS485码(十六进制,如: 55 01 01 03 01 B9 00)</div>';
|
|
295
|
+
|
|
296
|
+
if (deviceId === 'custom_switch') {
|
|
297
|
+
html += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
|
|
298
|
+
html += '<label style="font-size:11px;">打开码: <input type="text" class="custom-code-on" value="' + (codes.on || '') + '" placeholder="55 01 01..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
|
|
299
|
+
html += '<label style="font-size:11px;">关闭码: <input type="text" class="custom-code-off" value="' + (codes.off || '') + '" placeholder="55 01 02..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
|
|
300
|
+
html += '</div>';
|
|
301
|
+
} else if (deviceId === 'custom_curtain') {
|
|
302
|
+
html += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
|
|
303
|
+
html += '<label style="font-size:11px;">打开码: <input type="text" class="custom-code-open" value="' + (codes.open || '') + '" placeholder="55 02 02 03 01..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
|
|
304
|
+
html += '<label style="font-size:11px;">关闭码: <input type="text" class="custom-code-close" value="' + (codes.close || '') + '" placeholder="55 02 02 03 02..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
|
|
305
|
+
html += '<label style="font-size:11px;">停止码: <input type="text" class="custom-code-stop" value="' + (codes.stop || '') + '" placeholder="55 02 02 03 03..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
|
|
306
|
+
html += '</div>';
|
|
307
|
+
} else if (deviceId === 'custom_scene') {
|
|
308
|
+
html += '<div style="display:flex;gap:8px;">';
|
|
309
|
+
html += '<label style="font-size:11px;">触发码: <input type="text" class="custom-code-trigger" value="' + (codes.trigger || '') + '" placeholder="55 01 01..." style="width:200px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
|
|
310
|
+
html += '</div>';
|
|
311
|
+
}
|
|
312
|
+
html += '</div>';
|
|
313
|
+
row.append(html);
|
|
314
|
+
bindEvents();
|
|
245
315
|
}
|
|
246
316
|
|
|
247
317
|
// 添加新映射
|
|
248
318
|
$('#btn-add-mapping').on('click', function() {
|
|
249
|
-
mappings.push({ meshMac: '', meshChannel: 0, brand: '', device: '', address: 1, rs485Channel: 1 });
|
|
319
|
+
mappings.push({ meshMac: '', meshChannel: 0, brand: '', device: '', address: 1, rs485Channel: 1, customCodes: {} });
|
|
250
320
|
renderMappings();
|
|
251
321
|
});
|
|
252
322
|
|
|
@@ -256,14 +326,31 @@
|
|
|
256
326
|
oneditsave: function() {
|
|
257
327
|
var mappings = [];
|
|
258
328
|
$('#mapping-list .mapping-row').each(function() {
|
|
259
|
-
|
|
329
|
+
var m = {
|
|
260
330
|
meshMac: $(this).find('.mesh-select').val() || '',
|
|
261
331
|
meshChannel: parseInt($(this).find('.mesh-channel').val()) || 1,
|
|
262
332
|
brand: $(this).find('.brand-select').val() || '',
|
|
263
333
|
device: $(this).find('.device-select').val() || '',
|
|
264
334
|
address: parseInt($(this).find('.addr-input').val()) || 1,
|
|
265
335
|
rs485Channel: parseInt($(this).find('.rs485-channel').val()) || 1
|
|
266
|
-
}
|
|
336
|
+
};
|
|
337
|
+
// 保存自定义码
|
|
338
|
+
if (m.brand === 'custom') {
|
|
339
|
+
m.customCodes = {};
|
|
340
|
+
var on = $(this).find('.custom-code-on').val();
|
|
341
|
+
var off = $(this).find('.custom-code-off').val();
|
|
342
|
+
var open = $(this).find('.custom-code-open').val();
|
|
343
|
+
var close = $(this).find('.custom-code-close').val();
|
|
344
|
+
var stop = $(this).find('.custom-code-stop').val();
|
|
345
|
+
var trigger = $(this).find('.custom-code-trigger').val();
|
|
346
|
+
if (on) m.customCodes.on = on;
|
|
347
|
+
if (off) m.customCodes.off = off;
|
|
348
|
+
if (open) m.customCodes.open = open;
|
|
349
|
+
if (close) m.customCodes.close = close;
|
|
350
|
+
if (stop) m.customCodes.stop = stop;
|
|
351
|
+
if (trigger) m.customCodes.trigger = trigger;
|
|
352
|
+
}
|
|
353
|
+
mappings.push(m);
|
|
267
354
|
});
|
|
268
355
|
this.mappings = JSON.stringify(mappings);
|
|
269
356
|
}
|
package/nodes/symi-485-bridge.js
CHANGED
|
@@ -234,6 +234,30 @@ module.exports = function(RED) {
|
|
|
234
234
|
'fresh_air': { name: '新风', type: 'fan', registers: { switch: { address: 0x0000, type: 'coil' }, fanSpeed: { address: 0x0001, type: 'holding' } } },
|
|
235
235
|
'scene': { name: '场景', type: 'scene', registers: { trigger: { address: 0x0000, type: 'holding' } } }
|
|
236
236
|
}
|
|
237
|
+
},
|
|
238
|
+
// ===== 自定义协议 - 用户可录入任意RS485码 =====
|
|
239
|
+
'custom': {
|
|
240
|
+
name: '自定义协议',
|
|
241
|
+
devices: {
|
|
242
|
+
'custom_switch': {
|
|
243
|
+
name: '自定义开关',
|
|
244
|
+
type: 'switch',
|
|
245
|
+
customMode: true, // 标记为自定义模式
|
|
246
|
+
// 用户需要在映射中配置: customCodes.on, customCodes.off
|
|
247
|
+
},
|
|
248
|
+
'custom_curtain': {
|
|
249
|
+
name: '自定义窗帘',
|
|
250
|
+
type: 'cover',
|
|
251
|
+
customMode: true,
|
|
252
|
+
// 用户需要在映射中配置: customCodes.open, customCodes.close, customCodes.stop
|
|
253
|
+
},
|
|
254
|
+
'custom_scene': {
|
|
255
|
+
name: '自定义场景',
|
|
256
|
+
type: 'scene',
|
|
257
|
+
customMode: true,
|
|
258
|
+
// 用户需要在映射中配置: customCodes.trigger
|
|
259
|
+
}
|
|
260
|
+
}
|
|
237
261
|
}
|
|
238
262
|
}
|
|
239
263
|
};
|
|
@@ -300,7 +324,7 @@ module.exports = function(RED) {
|
|
|
300
324
|
};
|
|
301
325
|
|
|
302
326
|
const onRS485Frame = (frame) => {
|
|
303
|
-
node.
|
|
327
|
+
node.parseRS485Frame(frame); // 使用新的解析函数,支持自定义码
|
|
304
328
|
};
|
|
305
329
|
|
|
306
330
|
// 绑定事件监听器
|
|
@@ -342,23 +366,52 @@ module.exports = function(RED) {
|
|
|
342
366
|
|
|
343
367
|
const mac = eventData.device.macAddress;
|
|
344
368
|
const state = eventData.state || {};
|
|
345
|
-
|
|
369
|
+
// channel可能是0-based或1-based,需要兼容处理
|
|
370
|
+
// Mesh事件中channel通常是0-based,UI配置的meshChannel是1-based
|
|
371
|
+
const eventChannel = state.channel !== undefined ? state.channel : -1;
|
|
346
372
|
|
|
347
|
-
|
|
348
|
-
const mapping = node.findMeshMapping(mac, channel);
|
|
349
|
-
if (!mapping) return;
|
|
373
|
+
node.debug(`[Mesh事件] MAC=${mac}, channel=${eventChannel}, state=${JSON.stringify(state)}`);
|
|
350
374
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
mapping
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
375
|
+
// 查找匹配的映射 - 遍历所有映射找到MAC匹配的
|
|
376
|
+
for (const mapping of node.mappings) {
|
|
377
|
+
if (mapping.meshMac !== mac) continue;
|
|
378
|
+
|
|
379
|
+
// 通道匹配:meshChannel=0表示匹配所有,否则需要匹配具体通道
|
|
380
|
+
// UI的meshChannel是1-based,事件的channel可能是0-based
|
|
381
|
+
const configChannel = mapping.meshChannel || 1;
|
|
382
|
+
const matchChannel = (eventChannel === -1) || // 无channel信息,匹配所有
|
|
383
|
+
(eventChannel === configChannel) || // 1-based匹配
|
|
384
|
+
(eventChannel === configChannel - 1); // 0-based匹配
|
|
385
|
+
|
|
386
|
+
if (!matchChannel) continue;
|
|
387
|
+
|
|
388
|
+
const registers = node.getRegistersForMapping(mapping);
|
|
389
|
+
|
|
390
|
+
node.log(`[Mesh->RS485] ${eventData.device.name} 通道${configChannel} 状态变化: ${JSON.stringify(state)}`);
|
|
391
|
+
|
|
392
|
+
// 输出调试信息到节点输出端口
|
|
393
|
+
node.send({
|
|
394
|
+
topic: 'mesh-state-change',
|
|
395
|
+
payload: {
|
|
396
|
+
direction: 'Mesh→RS485',
|
|
397
|
+
device: eventData.device.name,
|
|
398
|
+
mac: mac,
|
|
399
|
+
channel: configChannel,
|
|
400
|
+
state: state
|
|
401
|
+
},
|
|
402
|
+
timestamp: new Date().toISOString()
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
node.queueCommand({
|
|
406
|
+
direction: 'mesh-to-modbus',
|
|
407
|
+
mapping: mapping,
|
|
408
|
+
registers: registers,
|
|
409
|
+
state: state,
|
|
410
|
+
timestamp: Date.now()
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
break; // 找到匹配的映射就停止
|
|
414
|
+
}
|
|
362
415
|
};
|
|
363
416
|
|
|
364
417
|
// RS485 device state change handler (event-driven)
|
|
@@ -441,48 +494,122 @@ module.exports = function(RED) {
|
|
|
441
494
|
// 这是安全机制,不是轮询
|
|
442
495
|
};
|
|
443
496
|
|
|
444
|
-
// Mesh -> Modbus sync
|
|
497
|
+
// Mesh -> Modbus/Custom sync
|
|
445
498
|
node.syncMeshToModbus = async function(cmd) {
|
|
446
499
|
const { mapping, registers, state } = cmd;
|
|
447
500
|
|
|
448
|
-
|
|
501
|
+
// 记录发送时间(用于防死循环)
|
|
502
|
+
node.lastMeshToRS485Time = Date.now();
|
|
503
|
+
|
|
504
|
+
// ===== 自定义协议模式 =====
|
|
505
|
+
if (mapping.brand === 'custom' && mapping.customCodes) {
|
|
506
|
+
const codes = mapping.customCodes;
|
|
507
|
+
|
|
508
|
+
node.log(`[Mesh->自定义] 设备${mapping.device}, 状态: ${JSON.stringify(state)}`);
|
|
509
|
+
|
|
510
|
+
// 开关类型
|
|
511
|
+
if (mapping.device === 'custom_switch') {
|
|
512
|
+
for (const [key, value] of Object.entries(state)) {
|
|
513
|
+
if (key === 'switch' || key === 'acSwitch') {
|
|
514
|
+
const hexCode = value ? codes.on : codes.off;
|
|
515
|
+
if (hexCode) {
|
|
516
|
+
await node.sendCustomCode(hexCode);
|
|
517
|
+
node.log(`[Mesh->自定义] 开关: ${value ? '开' : '关'}, 发送: ${hexCode}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// 窗帘类型
|
|
523
|
+
else if (mapping.device === 'custom_curtain') {
|
|
524
|
+
for (const [key, value] of Object.entries(state)) {
|
|
525
|
+
if (key === 'position' || key === 'action') {
|
|
526
|
+
let hexCode = null;
|
|
527
|
+
if (value === 'open' || value === 100) hexCode = codes.open;
|
|
528
|
+
else if (value === 'close' || value === 0) hexCode = codes.close;
|
|
529
|
+
else if (value === 'stop') hexCode = codes.stop;
|
|
530
|
+
|
|
531
|
+
if (hexCode) {
|
|
532
|
+
await node.sendCustomCode(hexCode);
|
|
533
|
+
node.log(`[Mesh->自定义] 窗帘: ${value}, 发送: ${hexCode}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// 场景类型
|
|
539
|
+
else if (mapping.device === 'custom_scene') {
|
|
540
|
+
if (state.trigger && codes.trigger) {
|
|
541
|
+
await node.sendCustomCode(codes.trigger);
|
|
542
|
+
node.log(`[Mesh->自定义] 场景触发, 发送: ${codes.trigger}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
node.status({ fill: 'green', shape: 'dot', text: `自定义同步 ${node.mappings.length}个` });
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ===== 标准Modbus协议模式 =====
|
|
551
|
+
const rs485Channel = mapping.rs485Channel || 1; // RS485端的通道号
|
|
552
|
+
const meshChannel = mapping.meshChannel || 1; // Mesh端的通道号
|
|
553
|
+
node.log(`[Mesh->RS485] 同步到从机${mapping.address}, Mesh通道${meshChannel}->RS485通道${rs485Channel}, 状态: ${JSON.stringify(state)}`);
|
|
449
554
|
|
|
450
555
|
for (const [meshKey, value] of Object.entries(state)) {
|
|
451
556
|
try {
|
|
452
|
-
// 处理开关状态 - Mesh的
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
557
|
+
// 处理开关状态 - Mesh的switch_N字段对应RS485的switchN (0x1031-0x1036)
|
|
558
|
+
// 只处理按键寄存器,不处理指示灯寄存器
|
|
559
|
+
const switchMatch = meshKey.match(/^switch_(\d+)$/);
|
|
560
|
+
if (switchMatch) {
|
|
561
|
+
const meshSwitchChannel = parseInt(switchMatch[1]);
|
|
562
|
+
// 只处理配置的Mesh通道
|
|
563
|
+
if (meshSwitchChannel !== meshChannel) continue;
|
|
458
564
|
|
|
459
|
-
//
|
|
460
|
-
|
|
565
|
+
// RS485端使用配置的rs485Channel (对应0x1031-0x1036)
|
|
566
|
+
const switchRegKey = `switch${rs485Channel}`;
|
|
567
|
+
|
|
568
|
+
if (registers && registers[switchRegKey]) {
|
|
461
569
|
const writeValue = value ? (registers[switchRegKey].on || 1) : (registers[switchRegKey].off || 0);
|
|
462
570
|
await node.writeModbusRegister(mapping.address, registers[switchRegKey], writeValue);
|
|
463
|
-
|
|
571
|
+
|
|
572
|
+
// 输出发送的帧信息
|
|
573
|
+
node.send({
|
|
574
|
+
topic: 'rs485-tx',
|
|
575
|
+
payload: {
|
|
576
|
+
direction: 'Mesh→RS485',
|
|
577
|
+
slaveAddr: mapping.address,
|
|
578
|
+
register: `0x${registers[switchRegKey].address.toString(16).toUpperCase()}`,
|
|
579
|
+
value: writeValue,
|
|
580
|
+
action: value ? '开' : '关'
|
|
581
|
+
},
|
|
582
|
+
timestamp: new Date().toISOString()
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
node.log(`[Mesh->RS485] 从机${mapping.address} 按键${rs485Channel} (0x${registers[switchRegKey].address.toString(16)}): ${value ? '开' : '关'}`);
|
|
464
586
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 兼容旧格式: switch, acSwitch
|
|
591
|
+
if (meshKey === 'switch' || meshKey === 'acSwitch') {
|
|
592
|
+
const switchRegKey = `switch${rs485Channel}`;
|
|
593
|
+
|
|
594
|
+
if (registers && registers[switchRegKey]) {
|
|
595
|
+
const writeValue = value ? (registers[switchRegKey].on || 1) : (registers[switchRegKey].off || 0);
|
|
596
|
+
await node.writeModbusRegister(mapping.address, registers[switchRegKey], writeValue);
|
|
597
|
+
node.log(`[Mesh->RS485] 从机${mapping.address} 按键${rs485Channel}: ${value ? '开' : '关'}`);
|
|
470
598
|
}
|
|
471
599
|
}
|
|
472
|
-
|
|
473
|
-
else if ((meshKey === 'targetTemp' || meshKey === 'acTargetTemp') && registers.targetTemp) {
|
|
600
|
+
else if ((meshKey === 'targetTemp' || meshKey === 'acTargetTemp') && registers && registers.targetTemp) {
|
|
474
601
|
await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
|
|
475
602
|
node.debug(`[Mesh->RS485] 目标温度: ${value}`);
|
|
476
603
|
}
|
|
477
|
-
else if (meshKey === 'acMode' && registers.mode) {
|
|
604
|
+
else if (meshKey === 'acMode' && registers && registers.mode) {
|
|
478
605
|
await node.writeModbusRegister(mapping.address, registers.mode, value);
|
|
479
606
|
node.debug(`[Mesh->RS485] 模式: ${value}`);
|
|
480
607
|
}
|
|
481
|
-
else if (meshKey === 'acFanSpeed' && registers.fanSpeed) {
|
|
608
|
+
else if (meshKey === 'acFanSpeed' && registers && registers.fanSpeed) {
|
|
482
609
|
await node.writeModbusRegister(mapping.address, registers.fanSpeed, value);
|
|
483
610
|
node.debug(`[Mesh->RS485] 风速: ${value}`);
|
|
484
611
|
}
|
|
485
|
-
else if (meshKey === 'brightness' && registers.brightness) {
|
|
612
|
+
else if (meshKey === 'brightness' && registers && registers.brightness) {
|
|
486
613
|
await node.writeModbusRegister(mapping.address, registers.brightness, value);
|
|
487
614
|
node.debug(`[Mesh->RS485] 亮度: ${value}`);
|
|
488
615
|
}
|
|
@@ -494,9 +621,21 @@ module.exports = function(RED) {
|
|
|
494
621
|
node.status({ fill: 'green', shape: 'dot', text: `同步 ${node.mappings.length}个映射` });
|
|
495
622
|
};
|
|
496
623
|
|
|
497
|
-
//
|
|
624
|
+
// 发送自定义十六进制码
|
|
625
|
+
node.sendCustomCode = async function(hexCode) {
|
|
626
|
+
if (!hexCode) return;
|
|
627
|
+
const hexStr = hexCode.replace(/\s/g, '');
|
|
628
|
+
if (!/^[0-9A-Fa-f]+$/.test(hexStr)) {
|
|
629
|
+
node.warn(`无效的十六进制码: ${hexCode}`);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const frame = Buffer.from(hexStr, 'hex');
|
|
633
|
+
await node.sendRS485Frame(frame);
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// Modbus/Custom -> Mesh sync
|
|
498
637
|
node.syncModbusToMesh = async function(cmd) {
|
|
499
|
-
const { mapping, registers, state } = cmd;
|
|
638
|
+
const { mapping, registers, state, customMode } = cmd;
|
|
500
639
|
|
|
501
640
|
// 查找Mesh设备
|
|
502
641
|
const meshDevice = node.gateway.getDevice(mapping.meshMac);
|
|
@@ -505,30 +644,60 @@ module.exports = function(RED) {
|
|
|
505
644
|
return;
|
|
506
645
|
}
|
|
507
646
|
|
|
508
|
-
|
|
647
|
+
const channel = mapping.meshChannel || 1;
|
|
648
|
+
|
|
649
|
+
// ===== 自定义协议模式 =====
|
|
650
|
+
if (customMode || mapping.brand === 'custom') {
|
|
651
|
+
node.log(`[自定义->Mesh] 设备${mapping.meshMac}, 通道${channel}, 状态: ${JSON.stringify(state)}`);
|
|
652
|
+
|
|
653
|
+
for (const [key, value] of Object.entries(state)) {
|
|
654
|
+
try {
|
|
655
|
+
// 开关类型
|
|
656
|
+
if (key === 'switch') {
|
|
657
|
+
const onOff = value ? 0x02 : 0x01;
|
|
658
|
+
const param = Buffer.from([channel - 1, onOff]);
|
|
659
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
|
|
660
|
+
node.log(`[自定义->Mesh] 开关${channel}: ${value ? '开' : '关'}`);
|
|
661
|
+
}
|
|
662
|
+
// 窗帘类型
|
|
663
|
+
else if (key === 'action' || key === 'position') {
|
|
664
|
+
let action = 0x00; // 停止
|
|
665
|
+
if (value === 'open') action = 0x01; // 打开
|
|
666
|
+
else if (value === 'close') action = 0x02; // 关闭
|
|
667
|
+
else if (value === 'stop') action = 0x00; // 停止
|
|
668
|
+
|
|
669
|
+
const param = Buffer.from([action]);
|
|
670
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x04, param);
|
|
671
|
+
node.log(`[自定义->Mesh] 窗帘: ${value}`);
|
|
672
|
+
}
|
|
673
|
+
} catch (err) {
|
|
674
|
+
node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
node.status({ fill: 'blue', shape: 'dot', text: `自定义同步 ${node.mappings.length}个` });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ===== 标准Modbus协议模式 =====
|
|
683
|
+
node.log(`[RS485->Mesh] 同步到设备 ${mapping.meshMac}, 通道${channel}, 状态: ${JSON.stringify(state)}`);
|
|
509
684
|
|
|
510
685
|
for (const [key, value] of Object.entries(state)) {
|
|
511
686
|
try {
|
|
512
687
|
// 处理开关类型 (switch1, switch2, ... 或 led1, led2, ...)
|
|
513
688
|
if (key.startsWith('switch')) {
|
|
514
|
-
// 从键名提取通道号,如 switch1 -> 1, switch2 -> 2
|
|
515
689
|
const channelFromKey = parseInt(key.replace('switch', '')) || 1;
|
|
516
|
-
|
|
517
|
-
const channel = mapping.meshChannel || channelFromKey;
|
|
690
|
+
const ch = mapping.meshChannel || channelFromKey;
|
|
518
691
|
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
const param = Buffer.from([channel - 1, onOff]); // 通道从0开始
|
|
692
|
+
const onOff = value ? 0x02 : 0x01;
|
|
693
|
+
const param = Buffer.from([ch - 1, onOff]);
|
|
522
694
|
|
|
523
695
|
await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
|
|
524
|
-
node.log(`[RS485->Mesh] 开关${
|
|
696
|
+
node.log(`[RS485->Mesh] 开关${ch}: ${value ? '开' : '关'}`);
|
|
525
697
|
}
|
|
526
|
-
// 处理指示灯(可选,某些场景需要同步指示灯状态)
|
|
527
698
|
else if (key.startsWith('led')) {
|
|
528
|
-
// 指示灯状态通常不需要同步回Mesh,仅记录
|
|
529
699
|
node.debug(`[RS485] 指示灯${key}: ${value}`);
|
|
530
700
|
}
|
|
531
|
-
// 处理温控器属性
|
|
532
701
|
else if (key === 'targetTemp') {
|
|
533
702
|
const param = Buffer.from([Math.round(value)]);
|
|
534
703
|
await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
|
|
@@ -617,11 +786,106 @@ module.exports = function(RED) {
|
|
|
617
786
|
node.warn('RS485未连接,无法发送数据');
|
|
618
787
|
return;
|
|
619
788
|
}
|
|
620
|
-
|
|
621
|
-
|
|
789
|
+
try {
|
|
790
|
+
const hexStr = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
|
|
791
|
+
node.log(`[RS485 TX] 发送帧: ${hexStr}`);
|
|
792
|
+
|
|
793
|
+
await node.rs485Config.send(frame);
|
|
794
|
+
|
|
795
|
+
// 输出到节点端口
|
|
796
|
+
node.send({
|
|
797
|
+
topic: 'rs485-frame-tx',
|
|
798
|
+
payload: {
|
|
799
|
+
direction: 'TX',
|
|
800
|
+
hex: hexStr,
|
|
801
|
+
slaveAddr: frame[0],
|
|
802
|
+
funcCode: frame[1]
|
|
803
|
+
},
|
|
804
|
+
timestamp: new Date().toISOString()
|
|
805
|
+
});
|
|
806
|
+
} catch (err) {
|
|
807
|
+
node.error(`RS485发送失败: ${err.message}`);
|
|
808
|
+
}
|
|
622
809
|
};
|
|
623
810
|
|
|
624
|
-
//
|
|
811
|
+
// 解析原始RS485帧 - 支持标准Modbus和自定义码匹配
|
|
812
|
+
node.parseRS485Frame = function(frame) {
|
|
813
|
+
if (frame.length < 4) return;
|
|
814
|
+
|
|
815
|
+
const hexStr = frame.toString('hex').toUpperCase();
|
|
816
|
+
const hexFormatted = hexStr.match(/.{1,2}/g)?.join(' ') || '';
|
|
817
|
+
|
|
818
|
+
// 首先检查自定义码匹配(遍历所有映射)
|
|
819
|
+
for (const mapping of node.mappings) {
|
|
820
|
+
if (mapping.brand === 'custom' && mapping.customCodes) {
|
|
821
|
+
const codes = mapping.customCodes;
|
|
822
|
+
let matchedAction = null;
|
|
823
|
+
|
|
824
|
+
// 开关类型:匹配on/off
|
|
825
|
+
if (mapping.device === 'custom_switch') {
|
|
826
|
+
if (codes.on && hexStr.includes(codes.on.replace(/\s/g, '').toUpperCase())) {
|
|
827
|
+
matchedAction = { switch: true };
|
|
828
|
+
} else if (codes.off && hexStr.includes(codes.off.replace(/\s/g, '').toUpperCase())) {
|
|
829
|
+
matchedAction = { switch: false };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// 窗帘类型:匹配open/close/stop
|
|
833
|
+
else if (mapping.device === 'custom_curtain') {
|
|
834
|
+
if (codes.open && hexStr.includes(codes.open.replace(/\s/g, '').toUpperCase())) {
|
|
835
|
+
matchedAction = { position: 'open', action: 'open' };
|
|
836
|
+
} else if (codes.close && hexStr.includes(codes.close.replace(/\s/g, '').toUpperCase())) {
|
|
837
|
+
matchedAction = { position: 'close', action: 'close' };
|
|
838
|
+
} else if (codes.stop && hexStr.includes(codes.stop.replace(/\s/g, '').toUpperCase())) {
|
|
839
|
+
matchedAction = { position: 'stop', action: 'stop' };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// 场景类型:匹配trigger
|
|
843
|
+
else if (mapping.device === 'custom_scene') {
|
|
844
|
+
if (codes.trigger && hexStr.includes(codes.trigger.replace(/\s/g, '').toUpperCase())) {
|
|
845
|
+
matchedAction = { trigger: true };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (matchedAction) {
|
|
850
|
+
// 防死循环:检查是否刚刚从Mesh发送过来
|
|
851
|
+
if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 500) {
|
|
852
|
+
node.debug(`[防循环] 忽略刚刚同步的帧: ${hexFormatted}`);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
node.log(`[自定义码匹配] ${mapping.device}: ${JSON.stringify(matchedAction)}, 帧: ${hexFormatted}`);
|
|
857
|
+
|
|
858
|
+
// 输出调试信息到节点输出端口
|
|
859
|
+
node.send({
|
|
860
|
+
topic: 'custom-code-match',
|
|
861
|
+
payload: {
|
|
862
|
+
direction: 'RS485→Mesh(自定义)',
|
|
863
|
+
device: mapping.device,
|
|
864
|
+
meshMac: mapping.meshMac,
|
|
865
|
+
action: matchedAction,
|
|
866
|
+
hex: hexFormatted
|
|
867
|
+
},
|
|
868
|
+
timestamp: new Date().toISOString()
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
node.queueCommand({
|
|
872
|
+
direction: 'modbus-to-mesh',
|
|
873
|
+
mapping: mapping,
|
|
874
|
+
registers: null,
|
|
875
|
+
state: matchedAction,
|
|
876
|
+
customMode: true,
|
|
877
|
+
timestamp: Date.now()
|
|
878
|
+
});
|
|
879
|
+
return; // 匹配到自定义码就不再继续
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// 标准Modbus解析
|
|
885
|
+
node.parseModbusResponse(frame);
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
// 解析Modbus响应/上报帧(标准Modbus协议)
|
|
625
889
|
node.parseModbusResponse = function(frame) {
|
|
626
890
|
if (frame.length < 6) return;
|
|
627
891
|
|
|
@@ -635,25 +899,32 @@ module.exports = function(RED) {
|
|
|
635
899
|
return;
|
|
636
900
|
}
|
|
637
901
|
|
|
902
|
+
// 自定义模式不走标准Modbus解析
|
|
903
|
+
if (mapping.brand === 'custom') return;
|
|
904
|
+
|
|
638
905
|
const registers = node.getRegistersForMapping(mapping);
|
|
639
906
|
if (!registers) {
|
|
640
907
|
node.debug(`未找到设备${mapping.device}的寄存器定义`);
|
|
641
908
|
return;
|
|
642
909
|
}
|
|
643
910
|
|
|
911
|
+
// 防死循环:检查是否刚刚从Mesh发送过来
|
|
912
|
+
if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 500) {
|
|
913
|
+
node.debug(`[防循环] 忽略刚刚同步的Modbus帧`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
644
917
|
// 根据功能码解析数据
|
|
645
918
|
let state = {};
|
|
646
919
|
|
|
647
920
|
if (fc === 0x06 || fc === 0x10) {
|
|
648
921
|
// 写单寄存器响应/写多寄存器响应 - 这是设备主动上报或写响应
|
|
649
|
-
// 格式: 从机地址 + 功能码 + 寄存器地址(2字节) + 值(2字节) + CRC
|
|
650
922
|
const regAddr = frame.readUInt16BE(2);
|
|
651
923
|
const value = frame.readUInt16BE(4);
|
|
652
924
|
|
|
653
925
|
// 查找匹配的寄存器定义
|
|
654
926
|
for (const [key, reg] of Object.entries(registers)) {
|
|
655
927
|
if (reg.address === regAddr) {
|
|
656
|
-
// 处理开关类型
|
|
657
928
|
if (key.startsWith('switch') || key.startsWith('led')) {
|
|
658
929
|
state[key] = value === (reg.on || 1);
|
|
659
930
|
} else if (reg.map) {
|
|
@@ -666,7 +937,6 @@ module.exports = function(RED) {
|
|
|
666
937
|
}
|
|
667
938
|
}
|
|
668
939
|
} else if (fc === 0x03 || fc === 0x04) {
|
|
669
|
-
// 读寄存器响应
|
|
670
940
|
const byteCount = frame[2];
|
|
671
941
|
for (let i = 0; i < byteCount / 2; i++) {
|
|
672
942
|
const value = frame.readUInt16BE(3 + i * 2);
|
|
@@ -679,18 +949,31 @@ module.exports = function(RED) {
|
|
|
679
949
|
}
|
|
680
950
|
}
|
|
681
951
|
} else if (fc === 0x20) {
|
|
682
|
-
// 自定义功能码0x20 - 可能是批量上报
|
|
683
|
-
// 格式: 从机地址 + 0x20 + 起始寄存器(2字节) + 数量(2字节) + 数据... + CRC
|
|
684
952
|
if (frame.length >= 9) {
|
|
685
953
|
const startReg = frame.readUInt16BE(2);
|
|
686
954
|
const count = frame.readUInt16BE(4);
|
|
687
955
|
node.debug(`[RS485] 从机${slaveAddr} 自定义上报: 起始0x${startReg.toString(16)}, 数量${count}`);
|
|
688
|
-
// 暂不处理,记录日志供分析
|
|
689
956
|
}
|
|
690
957
|
}
|
|
691
958
|
|
|
692
959
|
if (Object.keys(state).length > 0) {
|
|
693
960
|
node.log(`[RS485->Mesh] 设备@${slaveAddr} 状态变化: ${JSON.stringify(state)}`);
|
|
961
|
+
|
|
962
|
+
// 输出调试信息到节点输出端口
|
|
963
|
+
node.send({
|
|
964
|
+
topic: 'rs485-state-change',
|
|
965
|
+
payload: {
|
|
966
|
+
direction: 'RS485→Mesh',
|
|
967
|
+
slaveAddr: slaveAddr,
|
|
968
|
+
funcCode: fc,
|
|
969
|
+
brand: mapping.brand,
|
|
970
|
+
device: mapping.device,
|
|
971
|
+
meshMac: mapping.meshMac,
|
|
972
|
+
state: state
|
|
973
|
+
},
|
|
974
|
+
timestamp: new Date().toISOString()
|
|
975
|
+
});
|
|
976
|
+
|
|
694
977
|
node.queueCommand({
|
|
695
978
|
direction: 'modbus-to-mesh',
|
|
696
979
|
mapping: mapping,
|