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

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
@@ -1032,7 +1032,7 @@ node-red-contrib-symi-mesh/
1032
1032
 
1033
1033
  ## 更新日志
1034
1034
 
1035
- ### v1.6.0 (2025-12-03)
1035
+ ### v1.6.2 (2025-12-05)
1036
1036
  - **MQTT订阅修复**:修复闭包问题导致的设备MAC映射错误,确保HA实体可控
1037
1037
  - **内存泄漏修复**:节点关闭时正确移除gateway事件监听器,防止内存累积
1038
1038
  - **三合一面板完善**:空调/新风/地暖控制和状态反馈全面优化
@@ -1051,8 +1051,8 @@ Copyright (c) 2025 SYMI 亖米
1051
1051
  ## 关于
1052
1052
 
1053
1053
  **作者**: SYMI 亖米
1054
- **版本**: 1.6.0
1054
+ **版本**: 1.6.2
1055
1055
  **协议**: 蓝牙MESH网关(初级版)串口协议V1.0
1056
- **最后更新**: 2025-12-03
1056
+ **最后更新**: 2025-12-05
1057
1057
  **仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
1058
1058
  **npm包**: https://www.npmjs.com/package/node-red-contrib-symi-mesh
@@ -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: '地暖',
@@ -214,32 +271,50 @@ module.exports = function(RED) {
214
271
  node.lastSyncTime = 0;
215
272
  node.pendingVerify = false;
216
273
 
274
+ // RS485连接信息
275
+ const rs485Info = node.rs485Config.connectionType === 'tcp'
276
+ ? `${node.rs485Config.host}:${node.rs485Config.port}`
277
+ : node.rs485Config.serialPort;
278
+
217
279
  if (node.mappings.length === 0) {
218
280
  node.status({ fill: 'grey', shape: 'ring', text: '请添加实体映射' });
219
281
  } else {
220
- node.status({ fill: 'yellow', shape: 'ring', text: '连接中...' });
282
+ node.status({ fill: 'yellow', shape: 'ring', text: `连接中 ${rs485Info}...` });
221
283
  }
222
284
 
223
- // 注册到RS485配置节点
224
- node.rs485Config.register(node);
225
-
226
- // 监听RS485连接事件
227
- node.rs485Config.on('connected', () => {
228
- node.status({ fill: 'green', shape: 'dot', text: `已连接 ${node.mappings.length}个映射` });
229
- });
285
+ // 【重要】先绑定事件监听器,再注册到配置节点
286
+ // 定义事件处理函数(用于清理时移除)
287
+ const onRS485Connected = () => {
288
+ node.log(`[RS485 Bridge] 已连接到 ${rs485Info}`);
289
+ node.status({ fill: 'green', shape: 'dot', text: `已连接 ${rs485Info} (${node.mappings.length}个映射)` });
290
+ };
230
291
 
231
- node.rs485Config.on('disconnected', () => {
232
- node.status({ fill: 'yellow', shape: 'ring', text: '已断开' });
233
- });
292
+ const onRS485Disconnected = () => {
293
+ node.warn(`[RS485 Bridge] 已断开 ${rs485Info}`);
294
+ node.status({ fill: 'yellow', shape: 'ring', text: `已断开 ${rs485Info}` });
295
+ };
234
296
 
235
- node.rs485Config.on('error', (err) => {
236
- node.status({ fill: 'red', shape: 'ring', text: '连接错误' });
237
- });
297
+ const onRS485Error = (err) => {
298
+ node.error(`[RS485 Bridge] 连接错误 ${rs485Info}: ${err.message}`);
299
+ node.status({ fill: 'red', shape: 'ring', text: `错误 ${rs485Info}` });
300
+ };
238
301
 
239
- // 监听RS485接收帧
240
- node.rs485Config.on('frame', (frame) => {
302
+ const onRS485Frame = (frame) => {
241
303
  node.parseModbusResponse(frame);
242
- });
304
+ };
305
+
306
+ // 绑定事件监听器
307
+ node.rs485Config.on('connected', onRS485Connected);
308
+ node.rs485Config.on('disconnected', onRS485Disconnected);
309
+ node.rs485Config.on('error', onRS485Error);
310
+ node.rs485Config.on('frame', onRS485Frame);
311
+
312
+ // 保存处理函数引用,用于清理
313
+ node._rs485Handlers = { onRS485Connected, onRS485Disconnected, onRS485Error, onRS485Frame };
314
+
315
+ // 现在注册到RS485配置节点(这会触发连接)
316
+ node.rs485Config.register(node);
317
+ node.log(`[RS485 Bridge] 已注册到RS485配置: ${rs485Info}`);
243
318
 
244
319
  // 查找Mesh设备的映射配置
245
320
  node.findMeshMapping = function(mac, channel) {
@@ -369,23 +444,50 @@ module.exports = function(RED) {
369
444
  // Mesh -> Modbus sync
370
445
  node.syncMeshToModbus = async function(cmd) {
371
446
  const { mapping, registers, state } = cmd;
372
- const stateMapping = {
373
- 'switch': 'switch', 'acSwitch': 'switch',
374
- 'targetTemp': 'targetTemp', 'acTargetTemp': 'targetTemp',
375
- 'acMode': 'mode', 'acFanSpeed': 'fanSpeed',
376
- 'brightness': 'brightness'
377
- };
447
+
448
+ node.log(`[Mesh->RS485] 同步到从机${mapping.address}, 通道${mapping.meshChannel || 0}, 状态: ${JSON.stringify(state)}`);
378
449
 
379
450
  for (const [meshKey, value] of Object.entries(state)) {
380
- const regKey = stateMapping[meshKey];
381
- // Only sync if RS485 device has this register (partial sync support)
382
- if (regKey && registers[regKey]) {
383
- try {
384
- await node.writeModbusRegister(mapping.address, registers[regKey], value);
385
- node.debug(`Mesh->RS485@${mapping.address}: ${meshKey}=${value}`);
386
- } catch (err) {
387
- node.error(`RS485写入失败: ${regKey}=${value}, ${err.message}`);
451
+ try {
452
+ // 处理开关状态 - Mesh的switch字段对应RS485的switch1/switch2等
453
+ if (meshKey === 'switch' || meshKey === 'acSwitch') {
454
+ // 根据映射中的通道号选择对应的寄存器
455
+ const channel = mapping.meshChannel || 1;
456
+ const switchRegKey = `switch${channel}`;
457
+ const ledRegKey = `led${channel}`;
458
+
459
+ // 同步开关状态
460
+ if (registers[switchRegKey]) {
461
+ const writeValue = value ? (registers[switchRegKey].on || 1) : (registers[switchRegKey].off || 0);
462
+ await node.writeModbusRegister(mapping.address, registers[switchRegKey], writeValue);
463
+ node.log(`[Mesh->RS485] 开关${channel}: ${value ? '开' : '关'} (寄存器0x${registers[switchRegKey].address.toString(16)})`);
464
+ }
465
+ // 同时同步指示灯状态
466
+ if (registers[ledRegKey]) {
467
+ const writeValue = value ? (registers[ledRegKey].on || 1) : (registers[ledRegKey].off || 0);
468
+ await node.writeModbusRegister(mapping.address, registers[ledRegKey], writeValue);
469
+ node.debug(`[Mesh->RS485] 指示灯${channel}: ${value ? '开' : '关'}`);
470
+ }
471
+ }
472
+ // 处理温控器属性
473
+ else if ((meshKey === 'targetTemp' || meshKey === 'acTargetTemp') && registers.targetTemp) {
474
+ await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
475
+ node.debug(`[Mesh->RS485] 目标温度: ${value}`);
476
+ }
477
+ else if (meshKey === 'acMode' && registers.mode) {
478
+ await node.writeModbusRegister(mapping.address, registers.mode, value);
479
+ node.debug(`[Mesh->RS485] 模式: ${value}`);
480
+ }
481
+ else if (meshKey === 'acFanSpeed' && registers.fanSpeed) {
482
+ await node.writeModbusRegister(mapping.address, registers.fanSpeed, value);
483
+ node.debug(`[Mesh->RS485] 风速: ${value}`);
484
+ }
485
+ else if (meshKey === 'brightness' && registers.brightness) {
486
+ await node.writeModbusRegister(mapping.address, registers.brightness, value);
487
+ node.debug(`[Mesh->RS485] 亮度: ${value}`);
388
488
  }
489
+ } catch (err) {
490
+ node.error(`RS485写入失败: ${meshKey}=${value}, ${err.message}`);
389
491
  }
390
492
  }
391
493
 
@@ -403,37 +505,54 @@ module.exports = function(RED) {
403
505
  return;
404
506
  }
405
507
 
406
- const attrMapping = {
407
- 'switch': { attrType: 0x02, param: (v) => Buffer.from([v ? 0x02 : 0x01]) },
408
- 'targetTemp': { attrType: 0x1C, param: (v) => Buffer.from([Math.round(v)]) },
409
- 'mode': {
410
- attrType: 0x16,
411
- param: (v) => {
412
- const map = { 'cool': 0, 'heat': 1, 'fan': 2, 'dry': 3 };
413
- return Buffer.from([map[v] !== undefined ? map[v] : 0]);
414
- }
415
- },
416
- 'fanSpeed': {
417
- attrType: 0x1D,
418
- param: (v) => {
419
- const map = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
420
- return Buffer.from([map[v] !== undefined ? map[v] : 4]);
421
- }
422
- },
423
- 'brightness': { attrType: 0x03, param: (v) => Buffer.from([Math.round(v)]) }
424
- };
508
+ node.log(`[RS485->Mesh] 同步到设备 ${mapping.meshMac}, 通道${mapping.meshChannel || 0}, 状态: ${JSON.stringify(state)}`);
425
509
 
426
510
  for (const [key, value] of Object.entries(state)) {
427
- // 仅在Mesh设备支持此属性时同步
428
- const m = attrMapping[key];
429
- if (m) {
430
- try {
431
- const param = typeof m.param === 'function' ? m.param(value) : m.param;
432
- await node.gateway.sendControl(meshDevice.networkAddress, m.attrType, param);
433
- node.debug(`RS485@${mapping.address}->Mesh: ${key}=${value}`);
434
- } catch (err) {
435
- node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
511
+ try {
512
+ // 处理开关类型 (switch1, switch2, ... 或 led1, led2, ...)
513
+ if (key.startsWith('switch')) {
514
+ // 从键名提取通道号,如 switch1 -> 1, switch2 -> 2
515
+ const channelFromKey = parseInt(key.replace('switch', '')) || 1;
516
+ // 使用映射中配置的通道,或从键名获取
517
+ const channel = mapping.meshChannel || channelFromKey;
518
+
519
+ // Mesh开关控制:attrType=0x02, param=[通道, 开/关]
520
+ const onOff = value ? 0x02 : 0x01; // 0x02=开, 0x01=关
521
+ const param = Buffer.from([channel - 1, onOff]); // 通道从0开始
522
+
523
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
524
+ node.log(`[RS485->Mesh] 开关${channel}: ${value ? '开' : '关'}`);
436
525
  }
526
+ // 处理指示灯(可选,某些场景需要同步指示灯状态)
527
+ else if (key.startsWith('led')) {
528
+ // 指示灯状态通常不需要同步回Mesh,仅记录
529
+ node.debug(`[RS485] 指示灯${key}: ${value}`);
530
+ }
531
+ // 处理温控器属性
532
+ else if (key === 'targetTemp') {
533
+ const param = Buffer.from([Math.round(value)]);
534
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
535
+ node.debug(`[RS485->Mesh] 目标温度: ${value}`);
536
+ }
537
+ else if (key === 'mode') {
538
+ const modeMap = { 'cool': 0, 'heat': 1, 'fan': 2, 'dry': 3 };
539
+ const param = Buffer.from([modeMap[value] !== undefined ? modeMap[value] : 0]);
540
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x16, param);
541
+ node.debug(`[RS485->Mesh] 模式: ${value}`);
542
+ }
543
+ else if (key === 'fanSpeed') {
544
+ const speedMap = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
545
+ const param = Buffer.from([speedMap[value] !== undefined ? speedMap[value] : 4]);
546
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
547
+ node.debug(`[RS485->Mesh] 风速: ${value}`);
548
+ }
549
+ else if (key === 'brightness') {
550
+ const param = Buffer.from([Math.round(value)]);
551
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x03, param);
552
+ node.debug(`[RS485->Mesh] 亮度: ${value}`);
553
+ }
554
+ } catch (err) {
555
+ node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
437
556
  }
438
557
  }
439
558
 
@@ -502,26 +621,55 @@ module.exports = function(RED) {
502
621
  node.debug(`发送RS485帧: ${frame.toString('hex')}`);
503
622
  };
504
623
 
505
- // 解析Modbus响应
624
+ // 解析Modbus响应/上报帧
506
625
  node.parseModbusResponse = function(frame) {
626
+ if (frame.length < 6) return;
627
+
507
628
  const slaveAddr = frame[0];
508
629
  const fc = frame[1];
509
630
 
510
631
  // 查找对应的映射
511
632
  const mapping = node.findRS485Mapping(slaveAddr);
512
- if (!mapping) return;
633
+ if (!mapping) {
634
+ node.debug(`未找到从机${slaveAddr}的映射配置`);
635
+ return;
636
+ }
513
637
 
514
638
  const registers = node.getRegistersForMapping(mapping);
515
- if (!registers) return;
639
+ if (!registers) {
640
+ node.debug(`未找到设备${mapping.device}的寄存器定义`);
641
+ return;
642
+ }
516
643
 
517
644
  // 根据功能码解析数据
518
645
  let state = {};
519
- if (fc === 0x03 || fc === 0x04) {
646
+
647
+ if (fc === 0x06 || fc === 0x10) {
648
+ // 写单寄存器响应/写多寄存器响应 - 这是设备主动上报或写响应
649
+ // 格式: 从机地址 + 功能码 + 寄存器地址(2字节) + 值(2字节) + CRC
650
+ const regAddr = frame.readUInt16BE(2);
651
+ const value = frame.readUInt16BE(4);
652
+
653
+ // 查找匹配的寄存器定义
654
+ for (const [key, reg] of Object.entries(registers)) {
655
+ if (reg.address === regAddr) {
656
+ // 处理开关类型
657
+ if (key.startsWith('switch') || key.startsWith('led')) {
658
+ state[key] = value === (reg.on || 1);
659
+ } else if (reg.map) {
660
+ state[key] = reg.map[value] || value;
661
+ } else {
662
+ state[key] = value;
663
+ }
664
+ node.log(`[RS485] 从机${slaveAddr} 寄存器0x${regAddr.toString(16).toUpperCase()}: ${key}=${value}`);
665
+ break;
666
+ }
667
+ }
668
+ } else if (fc === 0x03 || fc === 0x04) {
520
669
  // 读寄存器响应
521
670
  const byteCount = frame[2];
522
671
  for (let i = 0; i < byteCount / 2; i++) {
523
672
  const value = frame.readUInt16BE(3 + i * 2);
524
- // 根据寄存器映射解析
525
673
  for (const [key, reg] of Object.entries(registers)) {
526
674
  if (reg.map) {
527
675
  state[key] = reg.map[value] || value;
@@ -530,10 +678,19 @@ module.exports = function(RED) {
530
678
  }
531
679
  }
532
680
  }
681
+ } else if (fc === 0x20) {
682
+ // 自定义功能码0x20 - 可能是批量上报
683
+ // 格式: 从机地址 + 0x20 + 起始寄存器(2字节) + 数量(2字节) + 数据... + CRC
684
+ if (frame.length >= 9) {
685
+ const startReg = frame.readUInt16BE(2);
686
+ const count = frame.readUInt16BE(4);
687
+ node.debug(`[RS485] 从机${slaveAddr} 自定义上报: 起始0x${startReg.toString(16)}, 数量${count}`);
688
+ // 暂不处理,记录日志供分析
689
+ }
533
690
  }
534
691
 
535
692
  if (Object.keys(state).length > 0) {
536
- node.log(`[RS485->Mesh] 设备@${slaveAddr} 状态: ${JSON.stringify(state)}`);
693
+ node.log(`[RS485->Mesh] 设备@${slaveAddr} 状态变化: ${JSON.stringify(state)}`);
537
694
  node.queueCommand({
538
695
  direction: 'modbus-to-mesh',
539
696
  mapping: mapping,
@@ -563,15 +720,97 @@ module.exports = function(RED) {
563
720
  init();
564
721
  }
565
722
 
723
+ // 输出调试信息
724
+ node.outputDebug = function(direction, info) {
725
+ const msg = {
726
+ topic: 'rs485-bridge/' + direction,
727
+ payload: info,
728
+ timestamp: new Date().toISOString()
729
+ };
730
+ node.send(msg);
731
+ };
732
+
733
+ // 监听RS485帧事件并输出
734
+ if (node.rs485Config) {
735
+ node.rs485Config.on('frame', (frame) => {
736
+ const slaveAddr = frame[0];
737
+ const fc = frame[1];
738
+ const hexData = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
739
+ node.outputDebug('rx', {
740
+ direction: 'RX',
741
+ slaveAddr: slaveAddr,
742
+ funcCode: fc,
743
+ hex: hexData,
744
+ raw: frame
745
+ });
746
+ });
747
+
748
+ node.rs485Config.on('tx', (frame) => {
749
+ const hexData = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
750
+ node.outputDebug('tx', {
751
+ direction: 'TX',
752
+ hex: hexData,
753
+ raw: frame
754
+ });
755
+ });
756
+ }
757
+
758
+ // 处理输入消息(手动控制)
759
+ node.on('input', function(msg) {
760
+ if (!msg.payload) return;
761
+
762
+ // 支持直接发送Modbus帧
763
+ if (msg.payload.modbusFrame || msg.payload.hex) {
764
+ let frame;
765
+ if (Buffer.isBuffer(msg.payload.modbusFrame)) {
766
+ frame = msg.payload.modbusFrame;
767
+ } else if (typeof msg.payload.hex === 'string') {
768
+ const hexStr = msg.payload.hex.replace(/\s/g, '');
769
+ if (/^[0-9A-Fa-f]+$/.test(hexStr)) {
770
+ frame = Buffer.from(hexStr, 'hex');
771
+ }
772
+ }
773
+ if (frame && node.rs485Config && node.rs485Config.connected) {
774
+ node.rs485Config.send(frame);
775
+ node.log(`手动发送Modbus帧: ${frame.toString('hex')}`);
776
+ }
777
+ return;
778
+ }
779
+
780
+ // 支持通过消息触发同步
781
+ if (msg.payload.sync === 'mesh-to-rs485' && msg.payload.mac) {
782
+ const mapping = node.findMeshMapping(msg.payload.mac, msg.payload.channel || 0);
783
+ if (mapping) {
784
+ const registers = node.getRegistersForMapping(mapping);
785
+ if (registers) {
786
+ node.queueCommand({
787
+ direction: 'mesh-to-modbus',
788
+ mapping: mapping,
789
+ registers: registers,
790
+ state: msg.payload.state || {},
791
+ timestamp: Date.now()
792
+ });
793
+ }
794
+ }
795
+ }
796
+ });
797
+
566
798
  // 清理
567
799
  node.on('close', (done) => {
800
+ // 移除Mesh网关事件监听器
568
801
  node.gateway.removeListener('device-list-complete', init);
569
802
  node.gateway.removeListener('device-state-changed', handleMeshStateChange);
570
803
 
571
- // 注销RS485配置节点
572
- if (node.rs485Config) {
804
+ // 移除RS485配置节点事件监听器
805
+ if (node.rs485Config && node._rs485Handlers) {
806
+ node.rs485Config.removeListener('connected', node._rs485Handlers.onRS485Connected);
807
+ node.rs485Config.removeListener('disconnected', node._rs485Handlers.onRS485Disconnected);
808
+ node.rs485Config.removeListener('error', node._rs485Handlers.onRS485Error);
809
+ node.rs485Config.removeListener('frame', node._rs485Handlers.onRS485Frame);
573
810
  node.rs485Config.deregister(node);
574
811
  }
812
+
813
+ node.log('[RS485 Bridge] 节点已清理');
575
814
  done();
576
815
  });
577
816
  }
@@ -595,13 +834,16 @@ module.exports = function(RED) {
595
834
  channels = d.channels || d.switchState?.length || 1;
596
835
  }
597
836
 
598
- // 生成显示名称
837
+ // 生成显示名称 - 使用完整MAC地址(去除冒号)
838
+ const macClean = d.macAddress?.replace(/:/g, '') || '';
599
839
  let displayName = d.name;
600
840
  if (isSwitch && channels >= 1) {
601
- // 开关设备:用按键数命名(一键、二键等)
841
+ // 开关设备:用按键数命名 + 完整MAC地址
602
842
  const chName = channelNames[channels] || channels + '键';
603
- const macSuffix = d.macAddress?.replace(/:/g, '').slice(-4) || '';
604
- displayName = chName + '开关_' + macSuffix;
843
+ displayName = chName + '开关_' + macClean;
844
+ } else {
845
+ // 非开关设备也显示完整MAC
846
+ displayName = (d.name || '设备') + '_' + macClean;
605
847
  }
606
848
 
607
849
  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.2",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "index.js",
6
6
  "scripts": {