node-red-contrib-symi-mesh 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -539,9 +539,38 @@ Mesh 四键开关 第2路 ↔ RS485 六键开关 第5路 地址:2 ✓ 支持
539
539
 
540
540
  | 品牌 | 支持设备 |
541
541
  |-----|---------|
542
- | 话语前湾 | 空调、地暖、新风、窗帘、1-8键开关 |
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
 
@@ -1032,7 +1061,7 @@ node-red-contrib-symi-mesh/
1032
1061
 
1033
1062
  ## 更新日志
1034
1063
 
1035
- ### v1.6.0 (2025-12-03)
1064
+ ### v1.6.2 (2025-12-05)
1036
1065
  - **MQTT订阅修复**:修复闭包问题导致的设备MAC映射错误,确保HA实体可控
1037
1066
  - **内存泄漏修复**:节点关闭时正确移除gateway事件监听器,防止内存累积
1038
1067
  - **三合一面板完善**:空调/新风/地暖控制和状态反馈全面优化
@@ -1051,8 +1080,8 @@ Copyright (c) 2025 SYMI 亖米
1051
1080
  ## 关于
1052
1081
 
1053
1082
  **作者**: SYMI 亖米
1054
- **版本**: 1.6.0
1083
+ **版本**: 1.6.2
1055
1084
  **协议**: 蓝牙MESH网关(初级版)串口协议V1.0
1056
- **最后更新**: 2025-12-03
1085
+ **最后更新**: 2025-12-05
1057
1086
  **仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
1058
1087
  **npm包**: https://www.npmjs.com/package/node-red-contrib-symi-mesh
@@ -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
- mappings.push({
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
  }
@@ -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
  };
@@ -271,32 +295,50 @@ module.exports = function(RED) {
271
295
  node.lastSyncTime = 0;
272
296
  node.pendingVerify = false;
273
297
 
298
+ // RS485连接信息
299
+ const rs485Info = node.rs485Config.connectionType === 'tcp'
300
+ ? `${node.rs485Config.host}:${node.rs485Config.port}`
301
+ : node.rs485Config.serialPort;
302
+
274
303
  if (node.mappings.length === 0) {
275
304
  node.status({ fill: 'grey', shape: 'ring', text: '请添加实体映射' });
276
305
  } else {
277
- node.status({ fill: 'yellow', shape: 'ring', text: '连接中...' });
306
+ node.status({ fill: 'yellow', shape: 'ring', text: `连接中 ${rs485Info}...` });
278
307
  }
279
308
 
280
- // 注册到RS485配置节点
281
- node.rs485Config.register(node);
309
+ // 【重要】先绑定事件监听器,再注册到配置节点
310
+ // 定义事件处理函数(用于清理时移除)
311
+ const onRS485Connected = () => {
312
+ node.log(`[RS485 Bridge] 已连接到 ${rs485Info}`);
313
+ node.status({ fill: 'green', shape: 'dot', text: `已连接 ${rs485Info} (${node.mappings.length}个映射)` });
314
+ };
282
315
 
283
- // 监听RS485连接事件
284
- node.rs485Config.on('connected', () => {
285
- node.status({ fill: 'green', shape: 'dot', text: `已连接 ${node.mappings.length}个映射` });
286
- });
316
+ const onRS485Disconnected = () => {
317
+ node.warn(`[RS485 Bridge] 已断开 ${rs485Info}`);
318
+ node.status({ fill: 'yellow', shape: 'ring', text: `已断开 ${rs485Info}` });
319
+ };
287
320
 
288
- node.rs485Config.on('disconnected', () => {
289
- node.status({ fill: 'yellow', shape: 'ring', text: '已断开' });
290
- });
321
+ const onRS485Error = (err) => {
322
+ node.error(`[RS485 Bridge] 连接错误 ${rs485Info}: ${err.message}`);
323
+ node.status({ fill: 'red', shape: 'ring', text: `错误 ${rs485Info}` });
324
+ };
291
325
 
292
- node.rs485Config.on('error', (err) => {
293
- node.status({ fill: 'red', shape: 'ring', text: '连接错误' });
294
- });
326
+ const onRS485Frame = (frame) => {
327
+ node.parseRS485Frame(frame); // 使用新的解析函数,支持自定义码
328
+ };
295
329
 
296
- // 监听RS485接收帧
297
- node.rs485Config.on('frame', (frame) => {
298
- node.parseModbusResponse(frame);
299
- });
330
+ // 绑定事件监听器
331
+ node.rs485Config.on('connected', onRS485Connected);
332
+ node.rs485Config.on('disconnected', onRS485Disconnected);
333
+ node.rs485Config.on('error', onRS485Error);
334
+ node.rs485Config.on('frame', onRS485Frame);
335
+
336
+ // 保存处理函数引用,用于清理
337
+ node._rs485Handlers = { onRS485Connected, onRS485Disconnected, onRS485Error, onRS485Frame };
338
+
339
+ // 现在注册到RS485配置节点(这会触发连接)
340
+ node.rs485Config.register(node);
341
+ node.log(`[RS485 Bridge] 已注册到RS485配置: ${rs485Info}`);
300
342
 
301
343
  // 查找Mesh设备的映射配置
302
344
  node.findMeshMapping = function(mac, channel) {
@@ -324,23 +366,52 @@ module.exports = function(RED) {
324
366
 
325
367
  const mac = eventData.device.macAddress;
326
368
  const state = eventData.state || {};
327
- const channel = state.channel || 0;
369
+ // channel可能是0-based或1-based,需要兼容处理
370
+ // Mesh事件中channel通常是0-based,UI配置的meshChannel是1-based
371
+ const eventChannel = state.channel !== undefined ? state.channel : -1;
328
372
 
329
- // 查找匹配的映射
330
- const mapping = node.findMeshMapping(mac, channel);
331
- if (!mapping) return;
373
+ node.debug(`[Mesh事件] MAC=${mac}, channel=${eventChannel}, state=${JSON.stringify(state)}`);
332
374
 
333
- const registers = node.getRegistersForMapping(mapping);
334
- if (!registers) return;
335
-
336
- node.log(`[Mesh->RS485] ${eventData.device.name} 状态变化`);
337
- node.queueCommand({
338
- direction: 'mesh-to-modbus',
339
- mapping: mapping,
340
- registers: registers,
341
- state: state,
342
- timestamp: Date.now()
343
- });
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
+ }
344
415
  };
345
416
 
346
417
  // RS485 device state change handler (event-driven)
@@ -423,35 +494,148 @@ module.exports = function(RED) {
423
494
  // 这是安全机制,不是轮询
424
495
  };
425
496
 
426
- // Mesh -> Modbus sync
497
+ // Mesh -> Modbus/Custom sync
427
498
  node.syncMeshToModbus = async function(cmd) {
428
499
  const { mapping, registers, state } = cmd;
429
- const stateMapping = {
430
- 'switch': 'switch', 'acSwitch': 'switch',
431
- 'targetTemp': 'targetTemp', 'acTargetTemp': 'targetTemp',
432
- 'acMode': 'mode', 'acFanSpeed': 'fanSpeed',
433
- 'brightness': 'brightness'
434
- };
500
+
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)}`);
435
554
 
436
555
  for (const [meshKey, value] of Object.entries(state)) {
437
- const regKey = stateMapping[meshKey];
438
- // Only sync if RS485 device has this register (partial sync support)
439
- if (regKey && registers[regKey]) {
440
- try {
441
- await node.writeModbusRegister(mapping.address, registers[regKey], value);
442
- node.debug(`Mesh->RS485@${mapping.address}: ${meshKey}=${value}`);
443
- } catch (err) {
444
- node.error(`RS485写入失败: ${regKey}=${value}, ${err.message}`);
556
+ try {
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;
564
+
565
+ // RS485端使用配置的rs485Channel (对应0x1031-0x1036)
566
+ const switchRegKey = `switch${rs485Channel}`;
567
+
568
+ if (registers && registers[switchRegKey]) {
569
+ const writeValue = value ? (registers[switchRegKey].on || 1) : (registers[switchRegKey].off || 0);
570
+ await node.writeModbusRegister(mapping.address, registers[switchRegKey], writeValue);
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 ? '开' : '关'}`);
586
+ }
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 ? '开' : '关'}`);
598
+ }
445
599
  }
600
+ else if ((meshKey === 'targetTemp' || meshKey === 'acTargetTemp') && registers && registers.targetTemp) {
601
+ await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
602
+ node.debug(`[Mesh->RS485] 目标温度: ${value}`);
603
+ }
604
+ else if (meshKey === 'acMode' && registers && registers.mode) {
605
+ await node.writeModbusRegister(mapping.address, registers.mode, value);
606
+ node.debug(`[Mesh->RS485] 模式: ${value}`);
607
+ }
608
+ else if (meshKey === 'acFanSpeed' && registers && registers.fanSpeed) {
609
+ await node.writeModbusRegister(mapping.address, registers.fanSpeed, value);
610
+ node.debug(`[Mesh->RS485] 风速: ${value}`);
611
+ }
612
+ else if (meshKey === 'brightness' && registers && registers.brightness) {
613
+ await node.writeModbusRegister(mapping.address, registers.brightness, value);
614
+ node.debug(`[Mesh->RS485] 亮度: ${value}`);
615
+ }
616
+ } catch (err) {
617
+ node.error(`RS485写入失败: ${meshKey}=${value}, ${err.message}`);
446
618
  }
447
619
  }
448
620
 
449
621
  node.status({ fill: 'green', shape: 'dot', text: `同步 ${node.mappings.length}个映射` });
450
622
  };
451
623
 
452
- // Modbus -> Mesh sync
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
453
637
  node.syncModbusToMesh = async function(cmd) {
454
- const { mapping, registers, state } = cmd;
638
+ const { mapping, registers, state, customMode } = cmd;
455
639
 
456
640
  // 查找Mesh设备
457
641
  const meshDevice = node.gateway.getDevice(mapping.meshMac);
@@ -460,38 +644,85 @@ module.exports = function(RED) {
460
644
  return;
461
645
  }
462
646
 
463
- const attrMapping = {
464
- 'switch': { attrType: 0x02, param: (v) => Buffer.from([v ? 0x02 : 0x01]) },
465
- 'targetTemp': { attrType: 0x1C, param: (v) => Buffer.from([Math.round(v)]) },
466
- 'mode': {
467
- attrType: 0x16,
468
- param: (v) => {
469
- const map = { 'cool': 0, 'heat': 1, 'fan': 2, 'dry': 3 };
470
- return Buffer.from([map[v] !== undefined ? map[v] : 0]);
471
- }
472
- },
473
- 'fanSpeed': {
474
- attrType: 0x1D,
475
- param: (v) => {
476
- const map = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
477
- return Buffer.from([map[v] !== undefined ? map[v] : 4]);
478
- }
479
- },
480
- 'brightness': { attrType: 0x03, param: (v) => Buffer.from([Math.round(v)]) }
481
- };
482
-
483
- for (const [key, value] of Object.entries(state)) {
484
- // 仅在Mesh设备支持此属性时同步
485
- const m = attrMapping[key];
486
- if (m) {
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)) {
487
654
  try {
488
- const param = typeof m.param === 'function' ? m.param(value) : m.param;
489
- await node.gateway.sendControl(meshDevice.networkAddress, m.attrType, param);
490
- node.debug(`RS485@${mapping.address}->Mesh: ${key}=${value}`);
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
+ }
491
673
  } catch (err) {
492
674
  node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
493
675
  }
494
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)}`);
684
+
685
+ for (const [key, value] of Object.entries(state)) {
686
+ try {
687
+ // 处理开关类型 (switch1, switch2, ... 或 led1, led2, ...)
688
+ if (key.startsWith('switch')) {
689
+ const channelFromKey = parseInt(key.replace('switch', '')) || 1;
690
+ const ch = mapping.meshChannel || channelFromKey;
691
+
692
+ const onOff = value ? 0x02 : 0x01;
693
+ const param = Buffer.from([ch - 1, onOff]);
694
+
695
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
696
+ node.log(`[RS485->Mesh] 开关${ch}: ${value ? '开' : '关'}`);
697
+ }
698
+ else if (key.startsWith('led')) {
699
+ node.debug(`[RS485] 指示灯${key}: ${value}`);
700
+ }
701
+ else if (key === 'targetTemp') {
702
+ const param = Buffer.from([Math.round(value)]);
703
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
704
+ node.debug(`[RS485->Mesh] 目标温度: ${value}`);
705
+ }
706
+ else if (key === 'mode') {
707
+ const modeMap = { 'cool': 0, 'heat': 1, 'fan': 2, 'dry': 3 };
708
+ const param = Buffer.from([modeMap[value] !== undefined ? modeMap[value] : 0]);
709
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x16, param);
710
+ node.debug(`[RS485->Mesh] 模式: ${value}`);
711
+ }
712
+ else if (key === 'fanSpeed') {
713
+ const speedMap = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
714
+ const param = Buffer.from([speedMap[value] !== undefined ? speedMap[value] : 4]);
715
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
716
+ node.debug(`[RS485->Mesh] 风速: ${value}`);
717
+ }
718
+ else if (key === 'brightness') {
719
+ const param = Buffer.from([Math.round(value)]);
720
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x03, param);
721
+ node.debug(`[RS485->Mesh] 亮度: ${value}`);
722
+ }
723
+ } catch (err) {
724
+ node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
725
+ }
495
726
  }
496
727
 
497
728
  node.status({ fill: 'blue', shape: 'dot', text: `同步 ${node.mappings.length}个映射` });
@@ -555,30 +786,160 @@ module.exports = function(RED) {
555
786
  node.warn('RS485未连接,无法发送数据');
556
787
  return;
557
788
  }
558
- await node.rs485Config.send(frame);
559
- node.debug(`发送RS485帧: ${frame.toString('hex')}`);
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
+ }
560
809
  };
561
810
 
562
- // 解析Modbus响应
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协议)
563
889
  node.parseModbusResponse = function(frame) {
890
+ if (frame.length < 6) return;
891
+
564
892
  const slaveAddr = frame[0];
565
893
  const fc = frame[1];
566
894
 
567
895
  // 查找对应的映射
568
896
  const mapping = node.findRS485Mapping(slaveAddr);
569
- if (!mapping) return;
897
+ if (!mapping) {
898
+ node.debug(`未找到从机${slaveAddr}的映射配置`);
899
+ return;
900
+ }
901
+
902
+ // 自定义模式不走标准Modbus解析
903
+ if (mapping.brand === 'custom') return;
570
904
 
571
905
  const registers = node.getRegistersForMapping(mapping);
572
- if (!registers) return;
906
+ if (!registers) {
907
+ node.debug(`未找到设备${mapping.device}的寄存器定义`);
908
+ return;
909
+ }
910
+
911
+ // 防死循环:检查是否刚刚从Mesh发送过来
912
+ if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 500) {
913
+ node.debug(`[防循环] 忽略刚刚同步的Modbus帧`);
914
+ return;
915
+ }
573
916
 
574
917
  // 根据功能码解析数据
575
918
  let state = {};
576
- if (fc === 0x03 || fc === 0x04) {
577
- // 读寄存器响应
919
+
920
+ if (fc === 0x06 || fc === 0x10) {
921
+ // 写单寄存器响应/写多寄存器响应 - 这是设备主动上报或写响应
922
+ const regAddr = frame.readUInt16BE(2);
923
+ const value = frame.readUInt16BE(4);
924
+
925
+ // 查找匹配的寄存器定义
926
+ for (const [key, reg] of Object.entries(registers)) {
927
+ if (reg.address === regAddr) {
928
+ if (key.startsWith('switch') || key.startsWith('led')) {
929
+ state[key] = value === (reg.on || 1);
930
+ } else if (reg.map) {
931
+ state[key] = reg.map[value] || value;
932
+ } else {
933
+ state[key] = value;
934
+ }
935
+ node.log(`[RS485] 从机${slaveAddr} 寄存器0x${regAddr.toString(16).toUpperCase()}: ${key}=${value}`);
936
+ break;
937
+ }
938
+ }
939
+ } else if (fc === 0x03 || fc === 0x04) {
578
940
  const byteCount = frame[2];
579
941
  for (let i = 0; i < byteCount / 2; i++) {
580
942
  const value = frame.readUInt16BE(3 + i * 2);
581
- // 根据寄存器映射解析
582
943
  for (const [key, reg] of Object.entries(registers)) {
583
944
  if (reg.map) {
584
945
  state[key] = reg.map[value] || value;
@@ -587,10 +948,32 @@ module.exports = function(RED) {
587
948
  }
588
949
  }
589
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
+ }
590
957
  }
591
958
 
592
959
  if (Object.keys(state).length > 0) {
593
- node.log(`[RS485->Mesh] 设备@${slaveAddr} 状态: ${JSON.stringify(state)}`);
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
+
594
977
  node.queueCommand({
595
978
  direction: 'modbus-to-mesh',
596
979
  mapping: mapping,
@@ -697,13 +1080,20 @@ module.exports = function(RED) {
697
1080
 
698
1081
  // 清理
699
1082
  node.on('close', (done) => {
1083
+ // 移除Mesh网关事件监听器
700
1084
  node.gateway.removeListener('device-list-complete', init);
701
1085
  node.gateway.removeListener('device-state-changed', handleMeshStateChange);
702
1086
 
703
- // 注销RS485配置节点
704
- if (node.rs485Config) {
1087
+ // 移除RS485配置节点事件监听器
1088
+ if (node.rs485Config && node._rs485Handlers) {
1089
+ node.rs485Config.removeListener('connected', node._rs485Handlers.onRS485Connected);
1090
+ node.rs485Config.removeListener('disconnected', node._rs485Handlers.onRS485Disconnected);
1091
+ node.rs485Config.removeListener('error', node._rs485Handlers.onRS485Error);
1092
+ node.rs485Config.removeListener('frame', node._rs485Handlers.onRS485Frame);
705
1093
  node.rs485Config.deregister(node);
706
1094
  }
1095
+
1096
+ node.log('[RS485 Bridge] 节点已清理');
707
1097
  done();
708
1098
  });
709
1099
  }
@@ -114,6 +114,9 @@ module.exports = function(RED) {
114
114
 
115
115
  node.client.on('connect', () => {
116
116
  node.connected = true;
117
+ // 启用TCP keep-alive,防止连接超时
118
+ node.client.setKeepAlive(true, 30000); // 30秒心跳
119
+ node.client.setNoDelay(true); // 禁用Nagle算法,立即发送
117
120
  node.warn(`[RS485] TCP已连接: ${node.host}:${node.port}`);
118
121
  node.emit('connected');
119
122
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "index.js",
6
6
  "scripts": {