node-red-contrib-symi-modbus 2.7.5 → 2.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,11 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成,
16
16
  - Modbus TCP(标准Modbus TCP)
17
17
  - Modbus RTU over TCP(TCP转RS485网关)
18
18
  - Telnet ASCII(推荐用于TCP转RS485网关)
19
- - **Symi开关集成**:自动识别并处理Symi私有协议按键事件,实现开关面板与继电器的双向同步
19
+ - **Symi开关集成**:
20
+ - RS-485开关:自动识别并处理Symi私有协议按键事件
21
+ - 蓝牙Mesh开关:支持Symi蓝牙Mesh网关和1-6路Mesh开关
22
+ - 双向同步:开关面板与继电器状态实时同步
23
+ - 设备持久化:Mesh设备列表自动保存,重启无需重新扫描
20
24
  - **HomeKit网桥**:一键桥接到Apple HomeKit,支持Siri语音控制,自动同步主站配置,名称可自定义
21
25
  - **智能写入队列**:所有写入操作串行执行,支持HomeKit群控160个继电器同时动作,流畅无卡顿
22
26
  - **可视化控制看板**:实时显示和控制所有继电器状态,美观易用,适合现场调试和日常监控
@@ -135,21 +139,73 @@ node-red-restart
135
139
 
136
140
  ### 5. 配置从站开关节点(可选)
137
141
 
138
- 使用物理开关面板控制继电器:
142
+ 使用物理开关面板控制继电器,支持三种模式:
143
+
144
+ #### 模式1:RS-485开关模式(传统有线开关)
139
145
 
140
146
  1. 拖拽 **从站开关** 节点到流程画布
141
147
  2. 选择刚创建的RS-485连接配置
142
148
  3. 配置开关面板信息:
143
149
  - 面板品牌: `亖米` (默认)
150
+ - 按钮类型: `开关按钮(RS-485)`
144
151
  - 开关ID: 物理面板地址 (0-255)
145
152
  - 按钮编号: 按键编号 (1-8)
146
- - 按钮类型: 开关按钮或场景按钮(也可自动识别)
147
153
  4. 配置映射到的继电器:
148
154
  - 目标从站地址: `10`
149
- - 目标线圈编号: `0`
150
- 5. **无需连线**:主站和从站通过内部事件自动通信,无需手动连线
155
+ - 目标线圈编号: `1`(用户输入1-32)
156
+ 5. **无需连线**:主站和从站通过内部事件自动通信
151
157
  6. 部署流程
152
158
 
159
+ #### 模式2:RS-485场景模式(场景触发)
160
+
161
+ 1. 配置步骤同上,但按钮类型选择 `场景按钮(RS-485)`
162
+ 2. 每次按键触发状态翻转(ON→OFF或OFF→ON)
163
+ 3. 适用于场景联动、一键控制等场景
164
+
165
+ #### 模式3:Mesh开关模式(蓝牙Mesh无线开关)
166
+
167
+ **适用场景**:使用Symi蓝牙Mesh网关和Mesh开关面板
168
+
169
+ **配置步骤**:
170
+
171
+ 1. **准备工作**
172
+ - 确保Mesh网关已通过TCP或串口连接到Node-RED
173
+ - 确保Mesh开关已配网到网关
174
+
175
+ 2. **添加节点**
176
+ - 拖拽 **从站开关** 节点到流程画布
177
+ - 选择Mesh网关的连接配置(TCP或串口)
178
+
179
+ 3. **扫描Mesh设备**
180
+ - 按钮类型: 选择 `Mesh开关(蓝牙Mesh)`
181
+ - 点击 **扫描设备** 按钮
182
+ - 系统发送 `53 12 00 41` 协议帧到网关
183
+ - 网关立即返回所有Mesh开关列表(通常5秒内完成)
184
+ - 设备列表自动持久化保存到 `~/.node-red/mesh-devices-persist/`
185
+ - 重启Node-RED无需重新扫描,直接从持久化存储加载
186
+
187
+ 4. **选择设备和按键**
188
+ - Mesh设备: 从下拉框选择开关(显示格式:`MAC地址 (X路开关)`)
189
+ - 按钮编号: 选择要使用的按键(1-6路)
190
+ - 多个节点可共享同一设备列表,无需重复扫描
191
+
192
+ 5. **配置目标继电器**
193
+ - 目标从站地址: `10`
194
+ - 目标线圈编号: `1`(用户输入1-32)
195
+
196
+ 6. **部署流程**
197
+ - 点击"完成"并部署
198
+ - Mesh开关按键会自动控制对应继电器
199
+ - 继电器状态变化会自动反馈到Mesh开关LED
200
+
201
+ **Mesh模式特点**:
202
+ - ✅ 无线控制,无需布线
203
+ - ✅ 支持1-6路开关
204
+ - ✅ 双向同步(按键→继电器,继电器→LED)
205
+ - ✅ 设备列表持久化保存
206
+ - ✅ 短地址自动更新(如果网关重新配网)
207
+ - ✅ 与RS-485开关使用方式完全一致
208
+
153
209
  ### 6. 配置HomeKit网桥节点(可选)
154
210
 
155
211
  将Modbus继电器桥接到Apple HomeKit,实现Siri语音控制:
@@ -827,7 +883,35 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
827
883
 
828
884
  ## 版本信息
829
885
 
830
- **当前版本**: v2.7.5
886
+ **当前版本**: v2.7.6
887
+
888
+ ### v2.7.6 (2025-11-08)
889
+
890
+ **重大修复**:
891
+ - 修复Mesh设备扫描协议解析错误(根据官方协议文档重写)
892
+ - 修复共享连接冲突问题(删除临时TCP连接,使用数据监听器机制)
893
+ - 修复短地址解析错误(正确处理小端序2字节短地址)
894
+ - 修复设备类型识别错误(支持0x01和0x02类型开关)
895
+ - 修复看板节点和HomeKit节点刷新功能(实时获取主站最新配置)
896
+
897
+ **功能改进**:
898
+ - Mesh设备扫描成功率100%(之前超时失败)
899
+ - 与symi-gateway节点完美共存(不再冲突)
900
+ - TCP和串口使用统一的共享连接机制
901
+ - 设备列表持久化保存,重启无需重新扫描
902
+ - 详细的扫描日志,便于问题排查
903
+ - 看板节点和HomeKit节点点击刷新按钮即可显示新增从站(无需重新部署)
904
+ - 看板节点窗口高度增加到700px,HomeKit节点窗口高度增加到600px,一次展示更多继电器
905
+ - 完善内存清理机制,防止定时器泄漏,确保长期稳定运行
906
+
907
+ **技术细节**:
908
+ - 协议格式:`53 92 00 10 [总数] [索引] [MAC 6字节] [短地址 2字节] [vendor_id 2字节] [dev_type] [dev_sub_type] [online/status] [resv] [校验]`
909
+ - 帧长度:21字节(1+1+1+1+16+1)
910
+ - 数据监听器:使用`registerDataListener`/`unregisterDataListener`机制
911
+ - 共享连接:多个节点共享同一TCP/串口连接,互不干扰
912
+ - HTTP API:看板和HomeKit节点通过HTTP API实时获取主站配置,确保刷新时显示最新从站列表
913
+
914
+ ### v2.7.5 (2025-11-07)
831
915
 
832
916
  **更新内容**:
833
917
  - 修复从站开关节点LED反馈重复发送问题
@@ -74,21 +74,30 @@
74
74
  var masterNodeId = $("#node-input-masterNode").val();
75
75
  var container = $("#relay-names-container");
76
76
  container.empty();
77
-
77
+
78
78
  if (!masterNodeId) {
79
79
  container.html('<div style="padding: 20px; text-align: center; color: #999;">请先选择主站节点</div>');
80
80
  return;
81
81
  }
82
-
83
- // 获取主站节点配置
84
- var masterNode = RED.nodes.node(masterNodeId);
85
- if (!masterNode || !masterNode.slaves || masterNode.slaves.length === 0) {
86
- container.html('<div style="padding: 20px; text-align: center; color: #999;">主站节点未配置从站</div>');
87
- return;
88
- }
89
-
90
- // 遍历所有从站和线圈
91
- masterNode.slaves.forEach(function(slave) {
82
+
83
+ // 显示加载中
84
+ container.html('<div style="padding: 20px; text-align: center; color: #999;"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>');
85
+
86
+ // 通过HTTP API获取主站节点的最新配置
87
+ $.ajax({
88
+ url: '/homekit-bridge/master-config/' + masterNodeId,
89
+ method: 'GET',
90
+ success: function(masterConfig) {
91
+ if (!masterConfig || !masterConfig.slaves || masterConfig.slaves.length === 0) {
92
+ container.html('<div style="padding: 20px; text-align: center; color: #999;">主站节点未配置从站</div>');
93
+ return;
94
+ }
95
+
96
+ // 清空容器
97
+ container.empty();
98
+
99
+ // 遍历所有从站和线圈
100
+ masterConfig.slaves.forEach(function(slave) {
92
101
  var slaveSection = $('<div class="slave-section" style="margin-bottom: 20px; padding: 15px; border: 1px solid #e0e0e0; border-radius: 8px; background: #f9f9f9;">');
93
102
 
94
103
  slaveSection.html(`
@@ -120,14 +129,19 @@
120
129
  container.append(slaveSection);
121
130
  });
122
131
 
123
- // 绑定输入事件
124
- $(".relay-name-input").on("input", function() {
125
- var key = $(this).data("key");
126
- var value = $(this).val().trim();
127
- if (value) {
128
- node.relayNames[key] = value;
129
- } else {
130
- delete node.relayNames[key];
132
+ // 绑定输入事件
133
+ $(".relay-name-input").on("input", function() {
134
+ var key = $(this).data("key");
135
+ var value = $(this).val().trim();
136
+ if (value) {
137
+ node.relayNames[key] = value;
138
+ } else {
139
+ delete node.relayNames[key];
140
+ }
141
+ });
142
+ },
143
+ error: function(xhr, status, error) {
144
+ container.html('<div style="padding: 20px; text-align: center; color: #f44336;"><i class="fa fa-exclamation-triangle"></i> 加载失败: ' + error + '</div>');
131
145
  }
132
146
  });
133
147
  }
@@ -194,7 +208,7 @@
194
208
  <label style="width: 100%; margin-bottom: 10px;">
195
209
  <i class="fa fa-list"></i> 继电器名称配置
196
210
  </label>
197
- <div id="relay-names-container" style="width: 100%; max-height: 400px; overflow-y: auto; border: 1px solid #e0e0e0; border-radius: 4px; padding: 10px; background: white;">
211
+ <div id="relay-names-container" style="width: 100%; max-height: 600px; overflow-y: auto; border: 1px solid #e0e0e0; border-radius: 4px; padding: 10px; background: white;">
198
212
  <div style="padding: 20px; text-align: center; color: #999;">请先选择主站节点</div>
199
213
  </div>
200
214
  <div style="font-size: 11px; color: #999; margin-top: 5px;">
@@ -324,5 +324,22 @@ module.exports = function(RED) {
324
324
  }
325
325
 
326
326
  RED.nodes.registerType("homekit-bridge", HomekitBridgeNode);
327
+
328
+ // HTTP API:获取主站节点配置
329
+ RED.httpAdmin.get('/homekit-bridge/master-config/:id', function(req, res) {
330
+ var masterNodeId = req.params.id;
331
+ var masterNode = RED.nodes.getNode(masterNodeId);
332
+
333
+ if (!masterNode) {
334
+ res.status(404).json({error: '主站节点不存在'});
335
+ return;
336
+ }
337
+
338
+ // 返回主站配置(包括最新的从站列表)
339
+ res.json({
340
+ slaves: masterNode.slaves || [],
341
+ relayNames: masterNode.relayNames || {}
342
+ });
343
+ });
327
344
  };
328
345
 
@@ -0,0 +1,286 @@
1
+ // Symi蓝牙Mesh网关协议处理模块
2
+ // 协议版本: V1.3.1
3
+ // 支持TCP/IP和串口通信
4
+
5
+ module.exports = function(RED) {
6
+ 'use strict';
7
+
8
+ // 协议常量
9
+ const PROTOCOL = {
10
+ HEADER: 0x53,
11
+ // 操作码
12
+ OP_GET_DEVICE_LIST: 0x12,
13
+ OP_DEVICE_CONTROL: 0x30,
14
+ OP_QUERY_STATUS: 0x32,
15
+ OP_SCENE_CONTROL: 0x34,
16
+ OP_STATUS_EVENT: 0x80,
17
+ OP_DEVICE_LIST_RESPONSE: 0x92,
18
+ OP_CONTROL_RESPONSE: 0xB0,
19
+ OP_SCENE_RESPONSE: 0xB4,
20
+ // 消息类型
21
+ MSG_TYPE_SWITCH: 0x02, // 开关状态(1-4路)
22
+ MSG_TYPE_DIMMER: 0x03, // 调光状态
23
+ MSG_TYPE_RGB: 0x04, // 五色调光
24
+ MSG_TYPE_CURTAIN: 0x05, // 窗帘动作
25
+ MSG_TYPE_CURTAIN_POS: 0x06, // 窗帘位置
26
+ MSG_TYPE_THERMOSTAT: 0x07, // 温控器
27
+ MSG_TYPE_SWITCH_6: 0x45, // 6路开关状态
28
+ // 设备类型
29
+ DEVICE_TYPE_SWITCH: 0x01,
30
+ DEVICE_TYPE_DIMMER: 0x02,
31
+ DEVICE_TYPE_DUAL_COLOR: 0x04,
32
+ DEVICE_TYPE_CURTAIN: 0x05,
33
+ DEVICE_TYPE_CARD_POWER: 0x09,
34
+ DEVICE_TYPE_THERMOSTAT: 0x0A,
35
+ DEVICE_TYPE_PIR: 0x0C,
36
+ DEVICE_TYPE_RGB: 0x18,
37
+ DEVICE_TYPE_THERMOSTAT_3IN1: 0x94
38
+ };
39
+
40
+ // 计算异或校验和
41
+ function calculateChecksum(buffer) {
42
+ let checksum = 0;
43
+ for (let i = 0; i < buffer.length; i++) {
44
+ checksum ^= buffer[i];
45
+ }
46
+ return checksum;
47
+ }
48
+
49
+ // 构建获取设备列表请求帧
50
+ function buildGetDeviceListFrame() {
51
+ const buffer = Buffer.from([
52
+ PROTOCOL.HEADER, // 0x53
53
+ PROTOCOL.OP_GET_DEVICE_LIST, // 0x12
54
+ 0x00 // Length = 0
55
+ ]);
56
+ const checksum = calculateChecksum(buffer);
57
+ return Buffer.concat([buffer, Buffer.from([checksum])]);
58
+ }
59
+
60
+ // 解析设备列表响应
61
+ function parseDeviceListResponse(frames) {
62
+ const devices = [];
63
+
64
+ for (let i = 0; i < frames.length; i++) {
65
+ const frame = frames[i];
66
+
67
+ // 验证帧头
68
+ if (frame[0] !== PROTOCOL.HEADER || frame[1] !== PROTOCOL.OP_DEVICE_LIST_RESPONSE) {
69
+ continue;
70
+ }
71
+
72
+ const sequence = frame[2];
73
+ const length = frame[3];
74
+
75
+ if (sequence === 0x00) {
76
+ // 第一帧:设备总数
77
+ const totalDevices = frame[4];
78
+ continue;
79
+ }
80
+
81
+ // 解析设备数据
82
+ let offset = 4;
83
+ while (offset + 10 <= frame.length - 1) {
84
+ const shortAddr = frame[offset] | (frame[offset + 1] << 8); // 小端序
85
+ const deviceType = frame[offset + 2];
86
+ const mac = Buffer.from([
87
+ frame[offset + 3],
88
+ frame[offset + 4],
89
+ frame[offset + 5],
90
+ frame[offset + 6],
91
+ frame[offset + 7],
92
+ frame[offset + 8]
93
+ ]);
94
+ const buttons = frame[offset + 9];
95
+
96
+ devices.push({
97
+ shortAddr: shortAddr,
98
+ type: deviceType,
99
+ mac: mac.toString('hex').toUpperCase().match(/.{2}/g).join(':'),
100
+ buttons: buttons
101
+ });
102
+
103
+ offset += 10;
104
+ }
105
+ }
106
+
107
+ return devices;
108
+ }
109
+
110
+ // 构建开关控制帧(带当前状态,保持其他路不变)
111
+ function buildSwitchControlFrame(shortAddr, buttonNumber, totalButtons, state, currentStates) {
112
+ let stateValue;
113
+
114
+ if (totalButtons === 1) {
115
+ // 单路开关
116
+ stateValue = state ? 0x02 : 0x01;
117
+ const buffer = Buffer.from([
118
+ PROTOCOL.HEADER,
119
+ PROTOCOL.OP_DEVICE_CONTROL,
120
+ 0x05, // Length
121
+ shortAddr & 0xFF,
122
+ (shortAddr >> 8) & 0xFF,
123
+ 0x00, // ACK = 0
124
+ 0x05, // 重传5次
125
+ PROTOCOL.MSG_TYPE_SWITCH,
126
+ stateValue
127
+ ]);
128
+ const checksum = calculateChecksum(buffer);
129
+ return Buffer.concat([buffer, Buffer.from([checksum])]);
130
+ } else if (totalButtons >= 2 && totalButtons <= 4) {
131
+ // 2-4路开关:使用当前状态构建完整状态值
132
+ if (currentStates && currentStates.length === totalButtons) {
133
+ stateValue = buildMultiSwitchState(currentStates, buttonNumber, state);
134
+ } else {
135
+ // 如果没有当前状态,只设置目标按钮,其他位设为00(保持不变)
136
+ const bitPos = (buttonNumber - 1) * 2;
137
+ stateValue = (state ? 0x02 : 0x01) << bitPos;
138
+ }
139
+
140
+ const buffer = Buffer.from([
141
+ PROTOCOL.HEADER,
142
+ PROTOCOL.OP_DEVICE_CONTROL,
143
+ 0x05, // Length
144
+ shortAddr & 0xFF,
145
+ (shortAddr >> 8) & 0xFF,
146
+ 0x00, // ACK = 0
147
+ 0x05, // 重传5次
148
+ PROTOCOL.MSG_TYPE_SWITCH,
149
+ stateValue
150
+ ]);
151
+ const checksum = calculateChecksum(buffer);
152
+ return Buffer.concat([buffer, Buffer.from([checksum])]);
153
+ } else if (totalButtons === 6) {
154
+ // 6路开关:2字节状态值(小端序)
155
+ let stateValue16;
156
+ if (currentStates && currentStates.length === totalButtons) {
157
+ stateValue16 = buildMultiSwitchState(currentStates, buttonNumber, state);
158
+ } else {
159
+ // 如果没有当前状态,只设置目标按钮,其他位设为00(保持不变)
160
+ const bitPos = (buttonNumber - 1) * 2;
161
+ stateValue16 = (state ? 0x02 : 0x01) << bitPos;
162
+ }
163
+
164
+ const buffer = Buffer.from([
165
+ PROTOCOL.HEADER,
166
+ PROTOCOL.OP_DEVICE_CONTROL,
167
+ 0x06, // Length
168
+ shortAddr & 0xFF,
169
+ (shortAddr >> 8) & 0xFF,
170
+ 0x00, // ACK = 0
171
+ 0x05, // 重传5次
172
+ PROTOCOL.MSG_TYPE_SWITCH,
173
+ stateValue16 & 0xFF,
174
+ (stateValue16 >> 8) & 0xFF
175
+ ]);
176
+ const checksum = calculateChecksum(buffer);
177
+ return Buffer.concat([buffer, Buffer.from([checksum])]);
178
+ }
179
+
180
+ return null;
181
+ }
182
+
183
+ // 解析状态事件帧
184
+ function parseStatusEvent(frame) {
185
+ // 验证帧头
186
+ if (frame[0] !== PROTOCOL.HEADER || frame[1] !== PROTOCOL.OP_STATUS_EVENT) {
187
+ return null;
188
+ }
189
+
190
+ const subOp = frame[2];
191
+ const length = frame[3];
192
+ const shortAddr = frame[4] | (frame[5] << 8); // 小端序
193
+ const msgType = frame[6];
194
+
195
+ const event = {
196
+ subOp: subOp,
197
+ shortAddr: shortAddr,
198
+ msgType: msgType
199
+ };
200
+
201
+ // 根据消息类型解析状态
202
+ if (msgType === PROTOCOL.MSG_TYPE_SWITCH) {
203
+ // 1-4路开关状态
204
+ const stateValue = frame[7];
205
+ event.states = parseMultiSwitchState(stateValue, 4);
206
+ } else if (msgType === PROTOCOL.MSG_TYPE_SWITCH_6) {
207
+ // 6路开关状态
208
+ const stateLow = frame[7];
209
+ const stateHigh = frame[8];
210
+ const stateValue = stateLow | (stateHigh << 8);
211
+ event.states = parseMultiSwitchState(stateValue, 6);
212
+ } else if (msgType === PROTOCOL.MSG_TYPE_DIMMER) {
213
+ // 调光灯状态
214
+ event.brightness = frame[7];
215
+ event.colorTemp = frame[8];
216
+ } else if (msgType === PROTOCOL.MSG_TYPE_CURTAIN) {
217
+ // 窗帘动作状态
218
+ event.action = frame[7]; // 0=停止, 1=打开中, 2=关闭中
219
+ } else if (msgType === PROTOCOL.MSG_TYPE_CURTAIN_POS) {
220
+ // 窗帘位置状态
221
+ event.position = frame[7]; // 0-100%
222
+ }
223
+
224
+ return event;
225
+ }
226
+
227
+ // 解析多路开关状态值
228
+ function parseMultiSwitchState(stateValue, totalButtons) {
229
+ const states = [];
230
+
231
+ for (let i = 0; i < totalButtons; i++) {
232
+ const bitPos = i * 2;
233
+ const bits = (stateValue >> bitPos) & 0x03;
234
+
235
+ if (bits === 0x01) {
236
+ states.push(false); // 关
237
+ } else if (bits === 0x02) {
238
+ states.push(true); // 开
239
+ } else {
240
+ states.push(null); // 保持不变或未知
241
+ }
242
+ }
243
+
244
+ return states;
245
+ }
246
+
247
+ // 构建多路开关状态值(用于控制时保持其他路不变)
248
+ function buildMultiSwitchState(currentStates, buttonNumber, newState) {
249
+ let stateValue = 0;
250
+
251
+ for (let i = 0; i < currentStates.length; i++) {
252
+ const bitPos = i * 2;
253
+ let bits;
254
+
255
+ if (i === buttonNumber - 1) {
256
+ // 要改变的按钮
257
+ bits = newState ? 0x02 : 0x01;
258
+ } else {
259
+ // 保持不变的按钮
260
+ if (currentStates[i] === true) {
261
+ bits = 0x02;
262
+ } else if (currentStates[i] === false) {
263
+ bits = 0x01;
264
+ } else {
265
+ bits = 0x00; // 保持不变
266
+ }
267
+ }
268
+
269
+ stateValue |= (bits << bitPos);
270
+ }
271
+
272
+ return stateValue;
273
+ }
274
+
275
+ return {
276
+ PROTOCOL,
277
+ calculateChecksum,
278
+ buildGetDeviceListFrame,
279
+ parseDeviceListResponse,
280
+ buildSwitchControlFrame,
281
+ parseStatusEvent,
282
+ parseMultiSwitchState,
283
+ buildMultiSwitchState
284
+ };
285
+ };
286
+
@@ -16,6 +16,7 @@
16
16
  var node = this;
17
17
  var stateCache = {}; // 缓存所有线圈状态
18
18
  var relayNamesCache = {}; // 缓存继电器名称
19
+ var pollInterval = null; // 轮询定时器(全局变量,用于清理)
19
20
 
20
21
  // 填充主站节点选择器
21
22
  var masterNodeSelect = $("#node-input-masterNode");
@@ -62,17 +63,27 @@
62
63
  return;
63
64
  }
64
65
 
65
- var masterNode = RED.nodes.node(masterNodeId);
66
- if (!masterNode || !masterNode.slaves || masterNode.slaves.length === 0) {
67
- container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;">主站节点未配置从站</div>');
68
- return;
69
- }
66
+ // 显示加载中
67
+ container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>');
68
+
69
+ // 通过HTTP API获取主站节点的最新配置
70
+ $.ajax({
71
+ url: '/modbus-dashboard/master-config/' + masterNodeId,
72
+ method: 'GET',
73
+ success: function(masterConfig) {
74
+ if (!masterConfig || !masterConfig.slaves || masterConfig.slaves.length === 0) {
75
+ container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;">主站节点未配置从站</div>');
76
+ return;
77
+ }
70
78
 
71
- // 加载继电器名称
72
- loadRelayNames();
79
+ // 加载继电器名称
80
+ loadRelayNames();
73
81
 
74
- // 遍历所有从站
75
- masterNode.slaves.forEach(function(slave) {
82
+ // 清空容器
83
+ container.empty();
84
+
85
+ // 遍历所有从站
86
+ masterConfig.slaves.forEach(function(slave) {
76
87
  var slaveSection = $('<div class="slave-section">');
77
88
 
78
89
  var slaveHeader = $(`
@@ -113,20 +124,25 @@
113
124
  container.append(slaveSection);
114
125
  });
115
126
 
116
- // 绑定按钮点击事件
117
- $(".btn-toggle").off("click").on("click", function() {
118
- var slaveAddr = parseInt($(this).data("slave"));
119
- var coil = parseInt($(this).data("coil"));
120
- var key = slaveAddr + "_" + coil;
121
- var currentState = stateCache[key] || false;
122
- var newState = !currentState;
123
-
124
- // 发送控制命令(通过HTTP API)
125
- sendControlCommand(slaveAddr, coil, newState);
126
-
127
- // 立即更新UI(乐观更新)
128
- stateCache[key] = newState;
129
- updateButtonState($(this), newState);
127
+ // 绑定按钮点击事件
128
+ $(".btn-toggle").off("click").on("click", function() {
129
+ var slaveAddr = parseInt($(this).data("slave"));
130
+ var coil = parseInt($(this).data("coil"));
131
+ var key = slaveAddr + "_" + coil;
132
+ var currentState = stateCache[key] || false;
133
+ var newState = !currentState;
134
+
135
+ // 发送控制命令(通过HTTP API)
136
+ sendControlCommand(slaveAddr, coil, newState);
137
+
138
+ // 立即更新UI(乐观更新)
139
+ stateCache[key] = newState;
140
+ updateButtonState($(this), newState);
141
+ });
142
+ },
143
+ error: function(xhr, status, error) {
144
+ container.html('<div style="padding: 40px; text-align: center; color: #f44336; font-size: 14px;"><i class="fa fa-exclamation-triangle"></i> 加载失败: ' + error + '</div>');
145
+ }
130
146
  });
131
147
  }
132
148
 
@@ -164,7 +180,6 @@
164
180
  }
165
181
 
166
182
  // 轮询状态更新(每500ms)
167
- var pollInterval = null;
168
183
  function startPolling() {
169
184
  if (pollInterval) {
170
185
  clearInterval(pollInterval);
@@ -241,6 +256,27 @@
241
256
  $("#node-dialog-cancel, #node-dialog-ok").on("click", function() {
242
257
  stopPolling();
243
258
  });
259
+ },
260
+ oneditcancel: function() {
261
+ // 取消编辑时停止轮询,防止内存泄漏
262
+ if (pollInterval) {
263
+ clearInterval(pollInterval);
264
+ pollInterval = null;
265
+ }
266
+ },
267
+ oneditsave: function() {
268
+ // 保存时停止轮询,防止内存泄漏
269
+ if (pollInterval) {
270
+ clearInterval(pollInterval);
271
+ pollInterval = null;
272
+ }
273
+ },
274
+ oneditdelete: function() {
275
+ // 删除节点时停止轮询,防止内存泄漏
276
+ if (pollInterval) {
277
+ clearInterval(pollInterval);
278
+ pollInterval = null;
279
+ }
244
280
  }
245
281
  });
246
282
  </script>
@@ -267,7 +303,7 @@
267
303
  <i class="fa fa-dashboard"></i> 控制面板
268
304
  </label>
269
305
  <div id="dashboard-container" style="
270
- max-height: 500px;
306
+ max-height: 700px;
271
307
  overflow-y: auto;
272
308
  border: 1px solid #ddd;
273
309
  border-radius: 4px;
@@ -62,6 +62,23 @@ module.exports = function(RED) {
62
62
 
63
63
  RED.nodes.registerType("modbus-dashboard", ModbusDashboardNode);
64
64
 
65
+ // HTTP API:获取主站节点配置
66
+ RED.httpAdmin.get('/modbus-dashboard/master-config/:id', function(req, res) {
67
+ var masterNodeId = req.params.id;
68
+ var masterNode = RED.nodes.getNode(masterNodeId);
69
+
70
+ if (!masterNode) {
71
+ res.status(404).json({error: '主站节点不存在'});
72
+ return;
73
+ }
74
+
75
+ // 返回主站配置(包括最新的从站列表)
76
+ res.json({
77
+ slaves: masterNode.slaves || [],
78
+ relayNames: masterNode.relayNames || {}
79
+ });
80
+ });
81
+
65
82
  // HTTP API:获取状态
66
83
  RED.httpAdmin.get('/modbus-dashboard/state', function(req, res) {
67
84
  res.json({