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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,8 @@
18
18
  },
19
19
  oneditprepare: function() {
20
20
  var node = this;
21
+ var autoRefreshInterval = null;
22
+ var autoRefreshEnabled = true;
21
23
 
22
24
  // 设置编辑面板更宽
23
25
  var panel = $('#dialog-form').parent();
@@ -25,17 +27,34 @@
25
27
  panel.css('width', '800px');
26
28
  }
27
29
 
28
- // 加载历史消息
30
+ // 加载历史消息 - 显示全部100条(增量更新避免闪烁)
31
+ var lastMessageCount = 0;
32
+ var lastMessageTime = '';
33
+
29
34
  function loadHistory() {
30
35
  if (!node.id) return;
31
36
  $.getJSON('/rs485-debug/history/' + node.id, function(messages) {
32
37
  var container = $('#debug-history');
33
- container.empty();
38
+
34
39
  if (messages.length === 0) {
35
- container.append('<div class="debug-empty">暂无数据,部署后将显示RS485通信数据</div>');
40
+ if (lastMessageCount !== 0) {
41
+ container.empty();
42
+ container.append('<div class="debug-empty">暂无数据,部署后将显示RS485通信数据</div>');
43
+ lastMessageCount = 0;
44
+ lastMessageTime = '';
45
+ }
36
46
  return;
37
47
  }
38
- messages.slice(-20).forEach(function(msg) {
48
+
49
+ // 检查是否有新消息(通过最后一条消息的时间戳判断)
50
+ var latestTime = messages.length > 0 ? messages[messages.length - 1].timestamp : '';
51
+ if (latestTime === lastMessageTime && messages.length === lastMessageCount) {
52
+ return; // 没有新消息,不更新DOM
53
+ }
54
+
55
+ // 有新消息时才重建列表
56
+ container.empty();
57
+ messages.forEach(function(msg) {
39
58
  var dir = msg.direction === 'TX' ? '→ TX' : '← RX';
40
59
  var dirClass = msg.direction === 'TX' ? 'tx' : 'rx';
41
60
  var html = '<div class="debug-line ' + dirClass + '">' +
@@ -49,9 +68,31 @@
49
68
  container.append(html);
50
69
  });
51
70
  container.scrollTop(container[0].scrollHeight);
71
+
72
+ // 更新状态
73
+ lastMessageCount = messages.length;
74
+ lastMessageTime = latestTime;
75
+ $('#debug-status').text('已缓存 ' + messages.length + ' 条');
52
76
  });
53
77
  }
54
78
 
79
+ // 自动刷新(实时采集)
80
+ function startAutoRefresh() {
81
+ if (autoRefreshInterval) clearInterval(autoRefreshInterval);
82
+ autoRefreshInterval = setInterval(function() {
83
+ if (autoRefreshEnabled) {
84
+ loadHistory();
85
+ }
86
+ }, 500); // 每500ms刷新一次
87
+ }
88
+
89
+ function stopAutoRefresh() {
90
+ if (autoRefreshInterval) {
91
+ clearInterval(autoRefreshInterval);
92
+ autoRefreshInterval = null;
93
+ }
94
+ }
95
+
55
96
  // 清空历史
56
97
  $('#btn-clear-history').on('click', function() {
57
98
  $.post('/rs485-debug/clear/' + node.id, function() {
@@ -59,11 +100,37 @@
59
100
  });
60
101
  });
61
102
 
62
- // 刷新
103
+ // 手动刷新
63
104
  $('#btn-refresh-history').on('click', loadHistory);
64
105
 
65
- // 初始加载
106
+ // 切换自动刷新
107
+ $('#btn-auto-refresh').on('click', function() {
108
+ autoRefreshEnabled = !autoRefreshEnabled;
109
+ if (autoRefreshEnabled) {
110
+ $(this).addClass('active').find('i').removeClass('fa-pause').addClass('fa-play');
111
+ $(this).find('span').text('实时');
112
+ startAutoRefresh();
113
+ } else {
114
+ $(this).removeClass('active').find('i').removeClass('fa-play').addClass('fa-pause');
115
+ $(this).find('span').text('暂停');
116
+ }
117
+ });
118
+
119
+ // 初始加载并启动自动刷新
66
120
  loadHistory();
121
+ startAutoRefresh();
122
+
123
+ // 编辑面板关闭时停止自动刷新
124
+ var originalCancel = RED.editor.cancel;
125
+ var cleanupDone = false;
126
+ function cleanup() {
127
+ if (!cleanupDone) {
128
+ cleanupDone = true;
129
+ stopAutoRefresh();
130
+ }
131
+ }
132
+ // 监听对话框关闭事件
133
+ $(document).one('editableDialogClose', cleanup);
67
134
  }
68
135
  });
69
136
  </script>
@@ -115,9 +182,12 @@
115
182
  </div>
116
183
 
117
184
  <div class="debug-section">
118
- <h4><i class="fa fa-terminal"></i> 通信数据预览(最近20条)</h4>
185
+ <h4><i class="fa fa-terminal"></i> 通信数据预览 <span id="debug-status" style="font-weight:normal;color:#888;font-size:11px;"></span></h4>
119
186
  <div id="debug-history"></div>
120
187
  <div class="debug-buttons">
188
+ <button type="button" id="btn-auto-refresh" class="red-ui-button red-ui-button-small active" style="background:#4CAF50;color:white;">
189
+ <i class="fa fa-play"></i> <span>实时</span>
190
+ </button>
121
191
  <button type="button" id="btn-refresh-history" class="red-ui-button red-ui-button-small">
122
192
  <i class="fa fa-refresh"></i> 刷新
123
193
  </button>
@@ -23,15 +23,13 @@ module.exports = function(RED) {
23
23
  return;
24
24
  }
25
25
 
26
- // 注册到RS485配置节点
27
- node.rs485Config.register(node);
28
-
29
26
  // 更新状态
30
27
  function updateStatus() {
28
+ const configName = node.rs485Config.name || `${node.rs485Config.host}:${node.rs485Config.port}`;
31
29
  if (node.rs485Config.connected) {
32
- node.status({ fill: 'green', shape: 'dot', text: '监听中 (' + node.messageBuffer.length + '条)' });
30
+ node.status({ fill: 'green', shape: 'dot', text: `监听中 ${configName} (${node.messageBuffer.length}条)` });
33
31
  } else {
34
- node.status({ fill: 'yellow', shape: 'ring', text: '等待连接...' });
32
+ node.status({ fill: 'yellow', shape: 'ring', text: `等待连接 ${configName}...` });
35
33
  }
36
34
  }
37
35
 
@@ -127,6 +125,9 @@ module.exports = function(RED) {
127
125
  updateStatus();
128
126
  }
129
127
 
128
+ // 【重要】先绑定事件监听器,再注册到配置节点
129
+ // 否则如果连接很快建立,事件可能丢失
130
+
130
131
  // 监听发送帧
131
132
  node.rs485Config.on('tx', function(frame) {
132
133
  handleFrame(frame, 'TX');
@@ -139,7 +140,7 @@ module.exports = function(RED) {
139
140
 
140
141
  // 连接状态事件
141
142
  node.rs485Config.on('connected', function() {
142
- node.log('RS485连接已建立');
143
+ node.log(`RS485连接已建立: ${node.rs485Config.host}:${node.rs485Config.port}`);
143
144
  updateStatus();
144
145
  });
145
146
 
@@ -153,6 +154,10 @@ module.exports = function(RED) {
153
154
  node.status({ fill: 'red', shape: 'ring', text: '错误: ' + err.message });
154
155
  });
155
156
 
157
+ // 现在注册到配置节点(这会触发连接)
158
+ node.rs485Config.register(node);
159
+ node.log(`已注册到RS485配置: ${node.rs485Config.host}:${node.rs485Config.port}`);
160
+
156
161
  // 处理输入消息(手动发送测试帧)
157
162
  node.on('input', function(msg) {
158
163
  if (msg.payload) {
@@ -8,8 +8,8 @@
8
8
  rs485Config: { value: '', type: 'symi-485-config', required: true },
9
9
  mappings: { value: '[]' }
10
10
  },
11
- inputs: 0,
12
- outputs: 0,
11
+ inputs: 1,
12
+ outputs: 1,
13
13
  icon: 'bridge.png',
14
14
  paletteLabel: 'RS485桥接',
15
15
  label: function() {
@@ -344,19 +344,33 @@
344
344
  <li>命令队列顺序处理</li>
345
345
  <li>防循环保护机制</li>
346
346
  <li>断电重启自动恢复</li>
347
+ <li>支持多个桥接节点共享同一Mesh网关</li>
348
+ <li>输出端口可连接debug节点查看通信数据</li>
347
349
  </ul>
348
350
 
349
351
  <h3>配置说明</h3>
350
352
  <dl>
351
- <dt>Mesh网关</dt><dd>选择Symi Mesh网关节点</dd>
352
- <dt>RS485连接</dt><dd>选择RS485连接配置</dd>
353
+ <dt>Mesh网关</dt><dd>选择Symi Mesh网关节点(可多个桥接节点共享)</dd>
354
+ <dt>RS485连接</dt><dd>选择RS485连接配置(不同TCP端口独立配置)</dd>
353
355
  <dt>实体映射</dt><dd>配置Mesh与RS485实体的对应关系</dd>
354
356
  </dl>
355
357
 
358
+ <h3>输入</h3>
359
+ <p>支持手动发送Modbus帧或触发同步:</p>
360
+ <pre>msg.payload = { hex: "01 06 10 31 00 01" } // 发送十六进制帧</pre>
361
+ <pre>msg.payload = { sync: "mesh-to-rs485", mac: "xxx", state: {...} }</pre>
362
+
363
+ <h3>输出</h3>
364
+ <p>输出RS485通信数据供调试:</p>
365
+ <dl>
366
+ <dt>topic</dt><dd>rs485-bridge/rx 或 rs485-bridge/tx</dd>
367
+ <dt>payload</dt><dd>包含方向、从机地址、功能码、十六进制数据</dd>
368
+ </dl>
369
+
356
370
  <h3>同步规则</h3>
357
371
  <ul>
358
372
  <li>只同步双方都支持的功能点</li>
359
- <li>不支持的功能点自动跳过</li>
373
+ <li>开关协议:按键0x1031-0x1036,指示灯0x1021-0x1026</li>
360
374
  <li>开关设备需指定具体按键</li>
361
375
  </ul>
362
376
  </script>
@@ -15,12 +15,15 @@ module.exports = function(RED) {
15
15
  name: '话语前湾',
16
16
  devices: {
17
17
  // ===== 开关类型 (A4B3协议头,FC=06写单寄存器) =====
18
+ // 按键: 0x1031-0x1036 (按键1-6)
19
+ // 指示灯: 0x1021-0x1026 (指示灯1-6)
18
20
  'switch_1': {
19
21
  name: '一键开关',
20
22
  type: 'switch',
21
23
  channels: 1,
22
24
  registers: {
23
- switch1: { address: 0x1031, type: 'holding', on: 1, off: 0 }
25
+ switch1: { address: 0x1031, type: 'holding', on: 1, off: 0 },
26
+ led1: { address: 0x1021, type: 'holding', on: 1, off: 0 }
24
27
  }
25
28
  },
26
29
  'switch_2': {
@@ -29,7 +32,9 @@ module.exports = function(RED) {
29
32
  channels: 2,
30
33
  registers: {
31
34
  switch1: { address: 0x1031, type: 'holding', on: 1, off: 0 },
32
- switch2: { address: 0x1032, type: 'holding', on: 1, off: 0 }
35
+ switch2: { address: 0x1032, type: 'holding', on: 1, off: 0 },
36
+ led1: { address: 0x1021, type: 'holding', on: 1, off: 0 },
37
+ led2: { address: 0x1022, type: 'holding', on: 1, off: 0 }
33
38
  }
34
39
  },
35
40
  'switch_3': {
@@ -39,7 +44,10 @@ module.exports = function(RED) {
39
44
  registers: {
40
45
  switch1: { address: 0x1031, type: 'holding', on: 1, off: 0 },
41
46
  switch2: { address: 0x1032, type: 'holding', on: 1, off: 0 },
42
- switch3: { address: 0x1033, type: 'holding', on: 1, off: 0 }
47
+ switch3: { address: 0x1033, type: 'holding', on: 1, off: 0 },
48
+ led1: { address: 0x1021, type: 'holding', on: 1, off: 0 },
49
+ led2: { address: 0x1022, type: 'holding', on: 1, off: 0 },
50
+ led3: { address: 0x1023, type: 'holding', on: 1, off: 0 }
43
51
  }
44
52
  },
45
53
  'switch_4': {
@@ -50,7 +58,11 @@ module.exports = function(RED) {
50
58
  switch1: { address: 0x1031, type: 'holding', on: 1, off: 0 },
51
59
  switch2: { address: 0x1032, type: 'holding', on: 1, off: 0 },
52
60
  switch3: { address: 0x1033, type: 'holding', on: 1, off: 0 },
53
- switch4: { address: 0x1034, type: 'holding', on: 1, off: 0 }
61
+ switch4: { address: 0x1034, type: 'holding', on: 1, off: 0 },
62
+ led1: { address: 0x1021, type: 'holding', on: 1, off: 0 },
63
+ led2: { address: 0x1022, type: 'holding', on: 1, off: 0 },
64
+ led3: { address: 0x1023, type: 'holding', on: 1, off: 0 },
65
+ led4: { address: 0x1024, type: 'holding', on: 1, off: 0 }
54
66
  }
55
67
  },
56
68
  'switch_6': {
@@ -63,7 +75,13 @@ module.exports = function(RED) {
63
75
  switch3: { address: 0x1033, type: 'holding', on: 1, off: 0 },
64
76
  switch4: { address: 0x1034, type: 'holding', on: 1, off: 0 },
65
77
  switch5: { address: 0x1035, type: 'holding', on: 1, off: 0 },
66
- switch6: { address: 0x1036, type: 'holding', on: 1, off: 0 }
78
+ switch6: { address: 0x1036, type: 'holding', on: 1, off: 0 },
79
+ led1: { address: 0x1021, type: 'holding', on: 1, off: 0 },
80
+ led2: { address: 0x1022, type: 'holding', on: 1, off: 0 },
81
+ led3: { address: 0x1023, type: 'holding', on: 1, off: 0 },
82
+ led4: { address: 0x1024, type: 'holding', on: 1, off: 0 },
83
+ led5: { address: 0x1025, type: 'holding', on: 1, off: 0 },
84
+ led6: { address: 0x1026, type: 'holding', on: 1, off: 0 }
67
85
  }
68
86
  },
69
87
  'switch_8': {
@@ -78,7 +96,15 @@ module.exports = function(RED) {
78
96
  switch5: { address: 0x1035, type: 'holding', on: 1, off: 0 },
79
97
  switch6: { address: 0x1036, type: 'holding', on: 1, off: 0 },
80
98
  switch7: { address: 0x1037, type: 'holding', on: 1, off: 0 },
81
- switch8: { address: 0x1038, type: 'holding', on: 1, off: 0 }
99
+ switch8: { address: 0x1038, type: 'holding', on: 1, off: 0 },
100
+ led1: { address: 0x1021, type: 'holding', on: 1, off: 0 },
101
+ led2: { address: 0x1022, type: 'holding', on: 1, off: 0 },
102
+ led3: { address: 0x1023, type: 'holding', on: 1, off: 0 },
103
+ led4: { address: 0x1024, type: 'holding', on: 1, off: 0 },
104
+ led5: { address: 0x1025, type: 'holding', on: 1, off: 0 },
105
+ led6: { address: 0x1026, type: 'holding', on: 1, off: 0 },
106
+ led7: { address: 0x1027, type: 'holding', on: 1, off: 0 },
107
+ led8: { address: 0x1028, type: 'holding', on: 1, off: 0 }
82
108
  }
83
109
  },
84
110
  // ===== 调光类型 =====
@@ -111,10 +137,11 @@ module.exports = function(RED) {
111
137
  }
112
138
  },
113
139
  // ===== 空调 (A5B5协议头) =====
114
- 'ac': {
115
- name: '空调',
140
+ // 模式: 1=制热, 2=制冷, 4=送风, 8=除湿
141
+ // 风速: 1=低风, 2=中风, 3=高风
142
+ 'ac_living': {
143
+ name: '客厅空调',
116
144
  type: 'climate',
117
- // 注意:不同空调寄存器地址不同,这里用基址,实际地址=基址+偏移
118
145
  registers: {
119
146
  switch: { address: 0x0FA0, type: 'holding', on: 1, off: 0 },
120
147
  mode: { address: 0x0FA1, type: 'holding', map: { 1: 'heat', 2: 'cool', 4: 'fan', 8: 'dry' } },
@@ -122,6 +149,36 @@ module.exports = function(RED) {
122
149
  fanSpeed: { address: 0x0FA3, type: 'holding', map: { 1: 'low', 2: 'medium', 3: 'high' } }
123
150
  }
124
151
  },
152
+ 'ac_bedroom2_1': {
153
+ name: '次卧1空调',
154
+ type: 'climate',
155
+ registers: {
156
+ switch: { address: 0x0FA4, type: 'holding', on: 1, off: 0 },
157
+ mode: { address: 0x0FA5, type: 'holding', map: { 1: 'heat', 2: 'cool', 4: 'fan', 8: 'dry' } },
158
+ targetTemp: { address: 0x0FA6, type: 'holding' },
159
+ fanSpeed: { address: 0x0FA7, type: 'holding', map: { 1: 'low', 2: 'medium', 3: 'high' } }
160
+ }
161
+ },
162
+ 'ac_bedroom2_2': {
163
+ name: '次卧2空调',
164
+ type: 'climate',
165
+ registers: {
166
+ switch: { address: 0x0FA8, type: 'holding', on: 1, off: 0 },
167
+ mode: { address: 0x0FA9, type: 'holding', map: { 1: 'heat', 2: 'cool', 4: 'fan', 8: 'dry' } },
168
+ targetTemp: { address: 0x0FAA, type: 'holding' },
169
+ fanSpeed: { address: 0x0FAB, type: 'holding', map: { 1: 'low', 2: 'medium', 3: 'high' } }
170
+ }
171
+ },
172
+ 'ac_master': {
173
+ name: '主卧空调',
174
+ type: 'climate',
175
+ registers: {
176
+ switch: { address: 0x0FAC, type: 'holding', on: 1, off: 0 },
177
+ mode: { address: 0x0FAD, type: 'holding', map: { 1: 'heat', 2: 'cool', 4: 'fan', 8: 'dry' } },
178
+ targetTemp: { address: 0x0FAE, type: 'holding' },
179
+ fanSpeed: { address: 0x0FAF, type: 'holding', map: { 1: 'low', 2: 'medium', 3: 'high' } }
180
+ }
181
+ },
125
182
  // ===== 地暖 (A3B3协议头) =====
126
183
  'floor_heating': {
127
184
  name: '地暖',
@@ -563,6 +620,81 @@ module.exports = function(RED) {
563
620
  init();
564
621
  }
565
622
 
623
+ // 输出调试信息
624
+ node.outputDebug = function(direction, info) {
625
+ const msg = {
626
+ topic: 'rs485-bridge/' + direction,
627
+ payload: info,
628
+ timestamp: new Date().toISOString()
629
+ };
630
+ node.send(msg);
631
+ };
632
+
633
+ // 监听RS485帧事件并输出
634
+ if (node.rs485Config) {
635
+ node.rs485Config.on('frame', (frame) => {
636
+ const slaveAddr = frame[0];
637
+ const fc = frame[1];
638
+ const hexData = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
639
+ node.outputDebug('rx', {
640
+ direction: 'RX',
641
+ slaveAddr: slaveAddr,
642
+ funcCode: fc,
643
+ hex: hexData,
644
+ raw: frame
645
+ });
646
+ });
647
+
648
+ node.rs485Config.on('tx', (frame) => {
649
+ const hexData = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
650
+ node.outputDebug('tx', {
651
+ direction: 'TX',
652
+ hex: hexData,
653
+ raw: frame
654
+ });
655
+ });
656
+ }
657
+
658
+ // 处理输入消息(手动控制)
659
+ node.on('input', function(msg) {
660
+ if (!msg.payload) return;
661
+
662
+ // 支持直接发送Modbus帧
663
+ if (msg.payload.modbusFrame || msg.payload.hex) {
664
+ let frame;
665
+ if (Buffer.isBuffer(msg.payload.modbusFrame)) {
666
+ frame = msg.payload.modbusFrame;
667
+ } else if (typeof msg.payload.hex === 'string') {
668
+ const hexStr = msg.payload.hex.replace(/\s/g, '');
669
+ if (/^[0-9A-Fa-f]+$/.test(hexStr)) {
670
+ frame = Buffer.from(hexStr, 'hex');
671
+ }
672
+ }
673
+ if (frame && node.rs485Config && node.rs485Config.connected) {
674
+ node.rs485Config.send(frame);
675
+ node.log(`手动发送Modbus帧: ${frame.toString('hex')}`);
676
+ }
677
+ return;
678
+ }
679
+
680
+ // 支持通过消息触发同步
681
+ if (msg.payload.sync === 'mesh-to-rs485' && msg.payload.mac) {
682
+ const mapping = node.findMeshMapping(msg.payload.mac, msg.payload.channel || 0);
683
+ if (mapping) {
684
+ const registers = node.getRegistersForMapping(mapping);
685
+ if (registers) {
686
+ node.queueCommand({
687
+ direction: 'mesh-to-modbus',
688
+ mapping: mapping,
689
+ registers: registers,
690
+ state: msg.payload.state || {},
691
+ timestamp: Date.now()
692
+ });
693
+ }
694
+ }
695
+ }
696
+ });
697
+
566
698
  // 清理
567
699
  node.on('close', (done) => {
568
700
  node.gateway.removeListener('device-list-complete', init);
@@ -595,13 +727,16 @@ module.exports = function(RED) {
595
727
  channels = d.channels || d.switchState?.length || 1;
596
728
  }
597
729
 
598
- // 生成显示名称
730
+ // 生成显示名称 - 使用完整MAC地址(去除冒号)
731
+ const macClean = d.macAddress?.replace(/:/g, '') || '';
599
732
  let displayName = d.name;
600
733
  if (isSwitch && channels >= 1) {
601
- // 开关设备:用按键数命名(一键、二键等)
734
+ // 开关设备:用按键数命名 + 完整MAC地址
602
735
  const chName = channelNames[channels] || channels + '键';
603
- const macSuffix = d.macAddress?.replace(/:/g, '').slice(-4) || '';
604
- displayName = chName + '开关_' + macSuffix;
736
+ displayName = chName + '开关_' + macClean;
737
+ } else {
738
+ // 非开关设备也显示完整MAC
739
+ displayName = (d.name || '设备') + '_' + macClean;
605
740
  }
606
741
 
607
742
  return {
@@ -75,6 +75,9 @@ module.exports = function(RED) {
75
75
  });
76
76
 
77
77
  node.client.on('data', (data) => {
78
+ // 触发原始数据事件(用于调试节点)
79
+ node.emit('data', data);
80
+ // 帧解析
78
81
  node.handleData(data);
79
82
  });
80
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "index.js",
6
6
  "scripts": {