node-red-contrib-symi-modbus 1.0.0 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,66 @@ module.exports = function(RED) {
2
2
  "use strict";
3
3
  const ModbusRTU = require("modbus-serial");
4
4
  const mqtt = require("mqtt");
5
+
6
+ // 串口列表API - 支持Windows、Linux、macOS所有串口设备
7
+ RED.httpAdmin.get('/modbus-master/serialports', async function(req, res) {
8
+ try {
9
+ // 尝试从多个可能的位置获取serialport模块
10
+ let SerialPort;
11
+ try {
12
+ // 优先尝试使用node_modules中的serialport
13
+ SerialPort = require('serialport');
14
+ } catch (e) {
15
+ try {
16
+ // 如果失败,尝试从modbus-serial的依赖中获取
17
+ const ModbusRTU = require('modbus-serial');
18
+ SerialPort = ModbusRTU.SerialPort || require('serialport');
19
+ } catch (e2) {
20
+ // 两种方式都失败,返回空列表
21
+ return res.json([]);
22
+ }
23
+ }
24
+
25
+ // serialport v10+ (使用SerialPort.SerialPort.list)
26
+ if (SerialPort && SerialPort.SerialPort && SerialPort.SerialPort.list) {
27
+ const ports = await SerialPort.SerialPort.list();
28
+ const portList = ports.map(port => ({
29
+ comName: port.path || port.comName,
30
+ manufacturer: port.manufacturer || '未知设备',
31
+ vendorId: port.vendorId || '',
32
+ productId: port.productId || ''
33
+ }));
34
+ return res.json(portList);
35
+ }
36
+
37
+ // serialport v9 (使用SerialPort.list)
38
+ if (SerialPort && SerialPort.list) {
39
+ const ports = await SerialPort.list();
40
+ const portList = ports.map(port => ({
41
+ comName: port.path || port.comName,
42
+ manufacturer: port.manufacturer || '未知设备',
43
+ vendorId: port.vendorId || '',
44
+ productId: port.productId || ''
45
+ }));
46
+ return res.json(portList);
47
+ }
48
+
49
+ // 如果以上方法都不可用,返回空列表
50
+ res.json([]);
51
+ } catch (err) {
52
+ // 发生错误时记录日志并返回空列表
53
+ RED.log.warn(`串口列表获取失败: ${err.message}`);
54
+ res.json([]);
55
+ }
56
+ });
5
57
 
6
58
  function ModbusMasterNode(config) {
7
59
  RED.nodes.createNode(this, config);
8
60
  const node = this;
9
61
 
62
+ // 获取MQTT服务器配置节点
63
+ node.mqttServerConfig = RED.nodes.getNode(config.mqttServer);
64
+
10
65
  // 配置参数
11
66
  node.config = {
12
67
  connectionType: config.connectionType,
@@ -24,10 +79,11 @@ module.exports = function(RED) {
24
79
  pollInterval: 200
25
80
  }],
26
81
  enableMqtt: config.enableMqtt,
27
- mqttBroker: config.mqttBroker,
28
- mqttUsername: config.mqttUsername,
29
- mqttPassword: config.mqttPassword,
30
- mqttBaseTopic: config.mqttBaseTopic
82
+ // config节点读取MQTT配置
83
+ mqttBroker: node.mqttServerConfig ? node.mqttServerConfig.broker : "",
84
+ mqttUsername: node.mqttServerConfig ? node.mqttServerConfig.username : "",
85
+ mqttPassword: node.mqttServerConfig ? (node.mqttServerConfig.credentials ? node.mqttServerConfig.credentials.password : "") : "",
86
+ mqttBaseTopic: node.mqttServerConfig ? node.mqttServerConfig.baseTopic : "modbus/relay"
31
87
  };
32
88
 
33
89
  // Modbus客户端
@@ -39,6 +95,9 @@ module.exports = function(RED) {
39
95
  node.deviceStates = {}; // 存储每个设备的状态
40
96
  node.mqttClient = null;
41
97
  node.isClosing = false;
98
+ node.lastErrorLog = {}; // 记录每个从站的最后错误日志时间
99
+ node.lastMqttErrorLog = 0; // MQTT错误日志时间
100
+ node.errorLogInterval = 10 * 60 * 1000; // 错误日志间隔:10分钟
42
101
 
43
102
  // 初始化设备状态(基于从站配置列表)
44
103
  node.config.slaves.forEach((slave) => {
@@ -73,6 +132,10 @@ module.exports = function(RED) {
73
132
  node.isConnected = true;
74
133
  node.status({fill: "green", shape: "dot", text: "已连接"});
75
134
 
135
+ // 清除错误日志记录(重新部署或重连时允许再次显示错误)
136
+ node.lastErrorLog = {};
137
+ node.lastMqttErrorLog = 0;
138
+
76
139
  // 启动轮询
77
140
  node.startPolling();
78
141
 
@@ -98,7 +161,10 @@ module.exports = function(RED) {
98
161
  }
99
162
 
100
163
  const options = {
101
- clientId: `modbus_master_${Math.random().toString(16).substr(2, 8)}`
164
+ clientId: `modbus_master_${Math.random().toString(16).substr(2, 8)}`,
165
+ clean: false, // 持久化会话,断线重连后继续接收消息
166
+ reconnectPeriod: 5000, // 5秒自动重连
167
+ queueQoSZero: false // 不缓存QoS=0的消息
102
168
  };
103
169
 
104
170
  if (node.config.mqttUsername) {
@@ -119,7 +185,14 @@ module.exports = function(RED) {
119
185
  });
120
186
 
121
187
  node.mqttClient.on('error', (err) => {
122
- node.error(`MQTT错误: ${err.message}`);
188
+ // 日志限流:MQTT错误最多每10分钟输出一次
189
+ const now = Date.now();
190
+ const shouldLog = (now - node.lastMqttErrorLog) > node.errorLogInterval;
191
+
192
+ if (shouldLog) {
193
+ node.error(`MQTT错误: ${err.message} [此错误将在10分钟后再次显示]`);
194
+ node.lastMqttErrorLog = now;
195
+ }
123
196
  });
124
197
 
125
198
  node.mqttClient.on('message', (topic, message) => {
@@ -215,11 +288,12 @@ module.exports = function(RED) {
215
288
  }
216
289
 
217
290
  const commandTopic = `${node.config.mqttBaseTopic}/+/+/set`;
218
- node.mqttClient.subscribe(commandTopic, (err) => {
291
+ // 使用QoS=1订阅,确保命令不丢失
292
+ node.mqttClient.subscribe(commandTopic, { qos: 1 }, (err) => {
219
293
  if (err) {
220
294
  node.error(`订阅MQTT命令主题失败: ${err.message}`);
221
295
  } else {
222
- node.log(`已订阅MQTT命令主题: ${commandTopic}`);
296
+ node.log(`已订阅MQTT命令主题: ${commandTopic}(QoS=1)`);
223
297
  }
224
298
  });
225
299
  };
@@ -252,7 +326,12 @@ module.exports = function(RED) {
252
326
  const stateTopic = `${node.config.mqttBaseTopic}/${slaveId}/${coil}/state`;
253
327
  const payload = value ? 'ON' : 'OFF';
254
328
 
255
- node.mqttClient.publish(stateTopic, payload, { retain: true });
329
+ // 使用QoS=1确保消息送达,retain=true确保断线重连后可获取最新状态
330
+ node.mqttClient.publish(stateTopic, payload, { qos: 1, retain: true }, (err) => {
331
+ if (err) {
332
+ node.warn(`发布状态失败: ${stateTopic} - ${err.message}`);
333
+ }
334
+ });
256
335
  };
257
336
 
258
337
  // 开始轮询
@@ -261,6 +340,10 @@ module.exports = function(RED) {
261
340
  return;
262
341
  }
263
342
 
343
+ // 清除错误日志记录(重新开始轮询时允许显示错误)
344
+ node.lastErrorLog = {};
345
+ node.lastMqttErrorLog = 0;
346
+
264
347
  node.log(`开始轮询 ${node.config.slaves.length} 个从站设备`);
265
348
  node.currentSlaveIndex = 0;
266
349
 
@@ -352,9 +435,15 @@ module.exports = function(RED) {
352
435
  } catch (err) {
353
436
  node.deviceStates[slaveId].error = err.message;
354
437
 
355
- // 容错机制:单个从站失败不影响其他从站
356
- // 只记录警告,继续轮询下一个从站
357
- node.warn(`轮询从站${slaveId}失败(不影响其他从站): ${err.message}`);
438
+ // 日志限流:每个从站的错误日志最多每10分钟输出一次
439
+ const now = Date.now();
440
+ const lastLogTime = node.lastErrorLog[slaveId] || 0;
441
+ const shouldLog = (now - lastLogTime) > node.errorLogInterval;
442
+
443
+ if (shouldLog) {
444
+ node.warn(`轮询从站${slaveId}失败(不影响其他从站): ${err.message} [此错误将在10分钟后再次显示]`);
445
+ node.lastErrorLog[slaveId] = now;
446
+ }
358
447
 
359
448
  // 更新状态显示
360
449
  const failedCount = Object.values(node.deviceStates).filter(s => s.error).length;
@@ -370,7 +459,12 @@ module.exports = function(RED) {
370
459
  (err.message.includes('ECONNRESET') ||
371
460
  err.message.includes('ETIMEDOUT') ||
372
461
  err.message.includes('ENOTCONN'))) {
373
- node.warn('所有从站都失败,检测到连接断开,尝试重连...');
462
+
463
+ // 连接断开也使用限流日志
464
+ if (shouldLog) {
465
+ node.warn('所有从站都失败,检测到连接断开,尝试重连...');
466
+ }
467
+
374
468
  node.isConnected = false;
375
469
  node.stopPolling();
376
470
 
@@ -3,67 +3,424 @@
3
3
  category: 'modbus',
4
4
  color: '#E9967A',
5
5
  defaults: {
6
- name: {value: "开关"},
7
- masterNode: {value: "", type: "modbus-master"},
8
- slaveId: {value: 10, validate: RED.validators.number()},
9
- coilNumber: {value: 0, validate: RED.validators.number()}
6
+ name: {value: "从站开关"},
7
+ // RS-485总线连接配置
8
+ connectionType: {value: "tcp"},
9
+ tcpHost: {value: "127.0.0.1"},
10
+ tcpPort: {value: 8888},
11
+ serialPort: {value: "COM1"},
12
+ serialBaudRate: {value: 9600},
13
+ serialDataBits: {value: 8},
14
+ serialStopBits: {value: 1},
15
+ serialParity: {value: "none"},
16
+ // MQTT配置
17
+ mqttServer: {value: "", type: "mqtt-server-config"},
18
+ // 开关面板配置
19
+ switchBrand: {value: "symi"}, // 品牌选择
20
+ switchId: {value: 0, validate: RED.validators.number()},
21
+ buttonNumber: {value: 1, validate: RED.validators.number()},
22
+ // 映射到继电器
23
+ targetSlaveAddress: {value: 10, validate: RED.validators.number()},
24
+ targetCoilNumber: {value: 0, validate: RED.validators.number()}
10
25
  },
11
26
  inputs: 1,
12
27
  outputs: 1,
13
28
  icon: "light.png",
14
29
  label: function() {
15
- return this.name || `开关 ${this.slaveId}-${this.coilNumber}`;
30
+ return this.name || `开关${this.switchId}-按钮${this.buttonNumber} → 继电器${this.targetSlaveAddress}-${this.targetCoilNumber}`;
31
+ },
32
+ oneditprepare: function() {
33
+ // 切换连接类型时显示/隐藏相关配置
34
+ $("#node-input-connectionType").on("change", function() {
35
+ var connType = $(this).val();
36
+ if (connType === "tcp") {
37
+ $(".form-row-tcp").show();
38
+ $(".form-row-serial").hide();
39
+ } else {
40
+ $(".form-row-tcp").hide();
41
+ $(".form-row-serial").show();
42
+ }
43
+ });
44
+
45
+ // 搜索串口按钮
46
+ $("#btn-search-ports").on("click", function() {
47
+ var btn = $(this);
48
+ var inputBox = $("#node-input-serialPort");
49
+ var selectBox = $("#port-list");
50
+
51
+ btn.prop("disabled", true).html('<i class="fa fa-spinner fa-spin"></i> 搜索中');
52
+
53
+ $.ajax({
54
+ url: 'modbus-slave-switch/serialports',
55
+ type: 'GET',
56
+ success: function(ports) {
57
+ selectBox.empty().append('<option value="">-- 选择检测到的串口 --</option>');
58
+
59
+ if (ports.length === 0) {
60
+ selectBox.append('<option disabled>未找到可用串口</option>');
61
+ RED.notify("未找到可用串口,请手动输入串口路径", "warning");
62
+ } else {
63
+ ports.forEach(function(port) {
64
+ var label = port.comName;
65
+ if (port.manufacturer && port.manufacturer !== '未知设备') {
66
+ label += ' - ' + port.manufacturer;
67
+ }
68
+ selectBox.append('<option value="' + port.comName + '">' + label + '</option>');
69
+ });
70
+ // 显示下拉框,隐藏输入框
71
+ inputBox.hide();
72
+ selectBox.show();
73
+ }
74
+
75
+ btn.prop("disabled", false).html('<i class="fa fa-search"></i> 搜索');
76
+ },
77
+ error: function() {
78
+ RED.notify("搜索串口失败", "error");
79
+ btn.prop("disabled", false).html('<i class="fa fa-search"></i> 搜索');
80
+ }
81
+ });
82
+ });
83
+
84
+ // 选择串口(下拉选择)
85
+ $("#port-list").on("change", function() {
86
+ var selectedPort = $(this).val();
87
+ if (selectedPort) {
88
+ $("#node-input-serialPort").val(selectedPort).show();
89
+ $(this).hide();
90
+ }
91
+ });
92
+
93
+ // 初始化显示
94
+ $("#node-input-connectionType").trigger("change");
16
95
  }
17
96
  });
18
97
  </script>
19
98
 
20
99
  <script type="text/html" data-template-name="modbus-slave-switch">
100
+ <!-- 基本配置 -->
21
101
  <div class="form-row">
22
102
  <label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
23
- <input type="text" id="node-input-name" placeholder="开关">
103
+ <input type="text" id="node-input-name" placeholder="从站开关">
104
+ </div>
105
+
106
+ <!-- RS-485总线连接配置 -->
107
+ <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
108
+ <div class="form-row">
109
+ <label style="width: 100%; margin-bottom: 8px;">
110
+ <i class="fa fa-plug" style="color: #ff9800;"></i>
111
+ <span style="font-size: 14px; font-weight: 600; color: #333;">RS-485总线连接配置</span>
112
+ </label>
113
+ <div style="font-size: 11px; color: #555; padding: 10px 12px; background: linear-gradient(135deg, #fff3cd 0%, #fffbe6 100%); border-left: 4px solid #ffc107; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
114
+ <strong>总线说明:</strong>连接物理开关面板的RS-485总线,监听按键事件并发送控制指令
115
+ </div>
116
+ </div>
117
+
118
+ <div class="form-row">
119
+ <label for="node-input-connectionType" style="width: 110px;"><i class="fa fa-exchange"></i> 连接类型</label>
120
+ <select id="node-input-connectionType" style="width: calc(70% - 110px);">
121
+ <option value="tcp">TCP/IP</option>
122
+ <option value="serial">串口</option>
123
+ </select>
124
+ </div>
125
+
126
+ <!-- TCP配置 -->
127
+ <div class="form-row form-row-tcp">
128
+ <label for="node-input-tcpHost" style="width: 110px;"><i class="fa fa-server"></i> TCP主机</label>
129
+ <input type="text" id="node-input-tcpHost" placeholder="192.168.1.200" style="width: calc(70% - 110px);">
130
+ <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
131
+ RS-485转TCP网关IP地址
132
+ </div>
133
+ </div>
134
+
135
+ <div class="form-row form-row-tcp">
136
+ <label for="node-input-tcpPort" style="width: 110px;"><i class="fa fa-plug"></i> TCP端口</label>
137
+ <input type="number" id="node-input-tcpPort" placeholder="8888" style="width: 100px;">
138
+ <span style="margin-left: 10px; font-size: 12px; color: #666;">默认 8888</span>
139
+ </div>
140
+
141
+ <!-- 串口配置 -->
142
+ <div class="form-row form-row-serial">
143
+ <label for="node-input-serialPort" style="width: 110px;"><i class="fa fa-terminal"></i> 串口</label>
144
+ <div style="display: inline-block; width: calc(70% - 110px);">
145
+ <div style="display: flex; gap: 5px; align-items: center;">
146
+ <input type="text" id="node-input-serialPort" placeholder="COM1, /dev/ttyUSB0, /dev/ttyS1" style="flex: 1; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
147
+ <select id="port-list" style="flex: 1; padding: 5px; font-family: monospace; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; display: none;">
148
+ <option value="">-- 选择检测到的串口 --</option>
149
+ </select>
150
+ <button type="button" id="btn-search-ports" style="padding: 6px 12px; background: #2196f3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; transition: background 0.3s;">
151
+ <i class="fa fa-search"></i> 搜索
152
+ </button>
153
+ </div>
154
+ <div style="font-size: 11px; color: #888; margin-top: 3px;">
155
+ 支持COM1、/dev/ttyUSB0、/dev/ttyS1等RS-485串口设备
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
+ <div class="form-row form-row-serial">
161
+ <label for="node-input-serialBaudRate" style="width: 110px;"><i class="fa fa-tachometer"></i> 波特率</label>
162
+ <select id="node-input-serialBaudRate" style="width: 150px;">
163
+ <option value="9600">9600</option>
164
+ <option value="19200">19200</option>
165
+ <option value="38400">38400</option>
166
+ <option value="57600">57600</option>
167
+ <option value="115200">115200</option>
168
+ </select>
169
+ <span style="margin-left: 10px; font-size: 11px; color: #888;">8-N-1固定配置(亖米协议)</span>
170
+ </div>
171
+
172
+ <div class="form-row form-row-serial" style="display: none;">
173
+ <label for="node-input-serialDataBits" style="width: 110px;"><i class="fa fa-database"></i> 数据位</label>
174
+ <select id="node-input-serialDataBits" style="width: 100px;">
175
+ <option value="7">7</option>
176
+ <option value="8" selected>8</option>
177
+ </select>
178
+ </div>
179
+
180
+ <div class="form-row form-row-serial" style="display: none;">
181
+ <label for="node-input-serialStopBits" style="width: 110px;"><i class="fa fa-stop"></i> 停止位</label>
182
+ <select id="node-input-serialStopBits" style="width: 100px;">
183
+ <option value="1" selected>1</option>
184
+ <option value="2">2</option>
185
+ </select>
186
+ </div>
187
+
188
+ <div class="form-row form-row-serial" style="display: none;">
189
+ <label for="node-input-serialParity" style="width: 110px;"><i class="fa fa-check"></i> 校验位</label>
190
+ <select id="node-input-serialParity" style="width: 100px;">
191
+ <option value="none" selected>无</option>
192
+ <option value="even">偶校验</option>
193
+ <option value="odd">奇校验</option>
194
+ </select>
195
+ </div>
196
+
197
+ <!-- MQTT配置 -->
198
+ <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
199
+ <div class="form-row">
200
+ <label style="width: 100%; margin-bottom: 8px;">
201
+ <i class="fa fa-cloud" style="color: #9c27b0;"></i>
202
+ <span style="font-size: 14px; font-weight: 600; color: #333;">MQTT服务器配置</span>
203
+ </label>
204
+ </div>
205
+
206
+ <div class="form-row">
207
+ <label for="node-input-mqttServer" style="width: 110px;"><i class="fa fa-server"></i> MQTT服务器</label>
208
+ <input type="text" id="node-input-mqttServer" placeholder="选择或添加MQTT服务器配置" style="width: calc(70% - 110px);">
209
+ <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
210
+ 选择已配置的MQTT服务器(需与主站节点使用同一配置)
211
+ </div>
212
+ </div>
213
+
214
+ <!-- 物理开关面板配置 -->
215
+ <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
216
+ <div class="form-row">
217
+ <label style="width: 100%; margin-bottom: 8px;">
218
+ <i class="fa fa-toggle-on" style="color: #3f51b5;"></i>
219
+ <span style="font-size: 14px; font-weight: 600; color: #333;">物理开关面板配置</span>
220
+ </label>
24
221
  </div>
25
222
 
26
223
  <div class="form-row">
27
- <label for="node-input-masterNode"><i class="fa fa-server"></i> 主站节点</label>
28
- <input type="text" id="node-input-masterNode" placeholder="选择主站节点">
224
+ <label for="node-input-switchBrand" style="width: 110px;"><i class="fa fa-trademark"></i> 面板品牌</label>
225
+ <select id="node-input-switchBrand" style="width: 200px;">
226
+ <option value="symi">亖米(Symi)</option>
227
+ <option value="other1" disabled style="color: #999;">其他品牌1(待开发)</option>
228
+ <option value="other2" disabled style="color: #999;">其他品牌2(待开发)</option>
229
+ </select>
230
+ <span style="margin-left: 10px; font-size: 11px; color: #666; font-style: italic;">支持1-8键开关</span>
29
231
  </div>
30
232
 
31
233
  <div class="form-row">
32
- <label for="node-input-slaveId"><i class="fa fa-map-marker"></i> 从站地址</label>
33
- <input type="number" id="node-input-slaveId" placeholder="10" min="1" max="247">
234
+ <label for="node-input-switchId" style="width: 110px;"><i class="fa fa-id-card"></i> 开关ID</label>
235
+ <input type="number" id="node-input-switchId" placeholder="0" min="0" max="255" style="width: 90px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
236
+ <span style="margin-left: 10px; color: #666; font-size: 12px;">物理面板地址:<strong>0-255</strong></span>
237
+ <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
238
+ RS-485总线上的设备地址标识
239
+ </div>
34
240
  </div>
35
241
 
36
242
  <div class="form-row">
37
- <label for="node-input-coilNumber"><i class="fa fa-toggle-on"></i> 线圈编号</label>
38
- <input type="number" id="node-input-coilNumber" placeholder="0" min="0" max="31">
243
+ <label for="node-input-buttonNumber" style="width: 110px;"><i class="fa fa-hand-pointer-o"></i> 按钮编号</label>
244
+ <select id="node-input-buttonNumber" style="width: 150px;">
245
+ <option value="1">按钮 1</option>
246
+ <option value="2">按钮 2</option>
247
+ <option value="3">按钮 3</option>
248
+ <option value="4">按钮 4</option>
249
+ <option value="5">按钮 5</option>
250
+ <option value="6">按钮 6</option>
251
+ <option value="7">按钮 7</option>
252
+ <option value="8">按钮 8</option>
253
+ </select>
254
+ <span style="margin-left: 10px; font-size: 11px; color: #666; font-style: italic;">面板物理按键</span>
255
+ </div>
256
+
257
+ <!-- 映射到继电器配置 -->
258
+ <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
259
+ <div class="form-row">
260
+ <label style="width: 100%; margin-bottom: 8px;">
261
+ <i class="fa fa-arrow-right" style="color: #4caf50;"></i>
262
+ <span style="font-size: 14px; font-weight: 600; color: #333;">映射到继电器设备</span>
263
+ </label>
264
+ </div>
265
+
266
+ <div class="form-row">
267
+ <label for="node-input-targetSlaveAddress" style="width: 110px;"><i class="fa fa-map-marker"></i> 从站地址</label>
268
+ <input type="number" id="node-input-targetSlaveAddress" placeholder="10" min="1" max="247" style="width: 90px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
269
+ <span style="margin-left: 10px; color: #666; font-size: 12px;">Modbus继电器:<strong>10-19</strong></span>
270
+ <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
271
+ 主站节点中配置的从站设备地址
272
+ </div>
273
+ </div>
274
+
275
+ <div class="form-row">
276
+ <label for="node-input-targetCoilNumber" style="width: 110px;"><i class="fa fa-plug"></i> 线圈编号</label>
277
+ <input type="number" id="node-input-targetCoilNumber" placeholder="0" min="0" max="31" style="width: 90px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
278
+ <span style="margin-left: 10px; color: #666; font-size: 12px;">继电器通道:<strong>0-31</strong></span>
279
+ <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
280
+ 32路继电器的具体通道编号
281
+ </div>
282
+ </div>
283
+
284
+ <div class="form-row" style="margin-top: 20px; padding: 14px; background: linear-gradient(135deg, #e8f5e9 0%, #f1f8f4 100%); border-left: 4px solid #4caf50; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.08);">
285
+ <div style="font-size: 12px; color: #333; line-height: 1.8;">
286
+ <div style="font-weight: 600; color: #2e7d32; margin-bottom: 10px; font-size: 13px;">
287
+ 配置说明
288
+ </div>
289
+ <div style="margin-bottom: 8px; padding-left: 10px;">
290
+ <strong>面板品牌:</strong><span style="color: #555;">亖米协议,支持1-8键开关</span><br>
291
+ <strong>开关ID:</strong><span style="color: #555;">物理面板RS-485地址(0-255)</span><br>
292
+ <strong>按钮编号:</strong><span style="color: #555;">面板按键序号(1-8)</span><br>
293
+ <strong>从站地址:</strong><span style="color: #555;">Modbus继电器地址(10-19)</span><br>
294
+ <strong>线圈编号:</strong><span style="color: #555;">继电器通道号(0-31)</span>
295
+ </div>
296
+ <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #a5d6a7;">
297
+ <div style="font-size: 11px; color: #2e7d32; font-weight: 500; margin-bottom: 5px;">
298
+ 配置示例
299
+ </div>
300
+ <div style="background: white; padding: 8px; border-radius: 4px; border: 1px solid #c8e6c9; font-size: 11px; color: #555;">
301
+ <strong>场景:</strong>亖米开关ID=0,按钮1 → 控制继电器10的线圈0<br>
302
+ <strong>MQTT:</strong><code style="background: #f5f5f5; padding: 2px 6px; border-radius: 3px; color: #e91e63; font-family: monospace;">modbus/relay/10/0/set</code>
303
+ </div>
304
+ </div>
305
+ </div>
39
306
  </div>
40
307
  </script>
41
308
 
42
309
  <script type="text/html" data-help-name="modbus-slave-switch">
43
- <p>Modbus开关从站节点,用于控制单个继电器线圈。</p>
310
+ <p>Modbus从站开关节点,将物理开关面板的按钮映射到Modbus继电器设备,通过MQTT实现控制。</p>
311
+
312
+ <h3>工作原理</h3>
313
+ <p>本节点实现物理开关面板(RS-485)到Modbus继电器的映射:</p>
314
+ <ul>
315
+ <li><strong>物理面板</strong>:开关ID(0-255)+ 按钮编号(1-8)</li>
316
+ <li><strong>映射到</strong>:Modbus从站地址(10-19)+ 线圈编号(0-31)</li>
317
+ <li><strong>通过MQTT</strong>:内置MQTT客户端,无需连线到主站节点</li>
318
+ </ul>
44
319
 
45
- <h3>配置</h3>
320
+ <h3>MQTT配置</h3>
46
321
  <dl class="message-properties">
47
- <dt>主站节点<span class="property-type">node</span></dt>
48
- <dd>选择关联的Modbus主站节点</dd>
322
+ <dt>MQTT服务器<span class="property-type">string</span></dt>
323
+ <dd>MQTT Broker地址(如:mqtt://192.168.1.100:1883)</dd>
49
324
 
50
- <dt>从站地址<span class="property-type">number</span></dt>
51
- <dd>目标从站设备地址</dd>
325
+ <dt>用户名/密码<span class="property-type">string</span></dt>
326
+ <dd>MQTT认证信息(可选)</dd>
52
327
 
53
- <dt>线圈编号<span class="property-type">number</span></dt>
54
- <dd>要控制的线圈编号(0-31)</dd>
328
+ <dt>基础主题<span class="property-type">string</span></dt>
329
+ <dd>MQTT主题前缀(需与主站节点配置一致,默认:modbus/relay)</dd>
330
+ </dl>
331
+
332
+ <h3>物理开关面板配置</h3>
333
+ <dl class="message-properties">
334
+ <dt>开关ID<span class="property-type">number (0-255)</span></dt>
335
+ <dd>物理开关面板的设备地址(RS-485总线地址)</dd>
336
+
337
+ <dt>按钮编号<span class="property-type">number (1-8)</span></dt>
338
+ <dd>物理面板上的按键编号(每个面板最多8个按钮)</dd>
339
+ </dl>
340
+
341
+ <h3>映射到继电器</h3>
342
+ <dl class="message-properties">
343
+ <dt>目标从站地址<span class="property-type">number (10-247)</span></dt>
344
+ <dd>要控制的Modbus继电器设备地址</dd>
345
+
346
+ <dt>目标线圈编号<span class="property-type">number (0-31)</span></dt>
347
+ <dd>继电器的具体通道(32路继电器)</dd>
55
348
  </dl>
56
349
 
57
350
  <h3>输入</h3>
58
351
  <dl class="message-properties">
59
352
  <dt>payload<span class="property-type">boolean|string|number</span></dt>
60
- <dd>开关状态:true/false, "ON"/"OFF", 1/0</dd>
353
+ <dd>开关命令:
354
+ <ul>
355
+ <li>布尔值:true=开,false=关</li>
356
+ <li>字符串:"ON"/"OFF", "true"/"false", "1"/"0"</li>
357
+ <li>数字:1=开,0=关</li>
358
+ </ul>
359
+ </dd>
61
360
  </dl>
62
361
 
63
362
  <h3>输出</h3>
64
363
  <dl class="message-properties">
65
364
  <dt>payload<span class="property-type">boolean</span></dt>
66
- <dd>当前开关状态</dd>
365
+ <dd>当前开关状态(true=开,false=关)</dd>
366
+
367
+ <dt>topic<span class="property-type">string</span></dt>
368
+ <dd>主题:switch_{开关ID}_btn{按钮编号}</dd>
369
+
370
+ <dt>switchId<span class="property-type">number</span></dt>
371
+ <dd>物理开关面板ID(0-255)</dd>
372
+
373
+ <dt>button<span class="property-type">number</span></dt>
374
+ <dd>物理面板按钮编号(1-8)</dd>
375
+
376
+ <dt>targetSlave<span class="property-type">number</span></dt>
377
+ <dd>映射到的继电器从站地址</dd>
378
+
379
+ <dt>targetCoil<span class="property-type">number</span></dt>
380
+ <dd>映射到的继电器线圈编号</dd>
67
381
  </dl>
382
+
383
+ <h3>MQTT主题</h3>
384
+ <p>本节点自动订阅和发布以下MQTT主题(基于目标继电器):</p>
385
+ <ul>
386
+ <li><strong>状态主题</strong>:modbus/relay/{目标从站地址}/{目标线圈}/state(订阅)</li>
387
+ <li><strong>命令主题</strong>:modbus/relay/{目标从站地址}/{目标线圈}/set(发布)</li>
388
+ </ul>
389
+
390
+ <h3>使用示例</h3>
391
+ <p><strong>场景1:</strong>开关ID=0(物理面板),按钮1 → 控制继电器10的线圈0</p>
392
+ <ul>
393
+ <li>开关ID:0(物理面板地址)</li>
394
+ <li>按钮编号:1(面板按钮)</li>
395
+ <li>目标从站地址:10(Modbus继电器)</li>
396
+ <li>目标线圈编号:0(继电器通道)</li>
397
+ </ul>
398
+ <p>MQTT主题:</p>
399
+ <ul>
400
+ <li>状态:modbus/relay/10/0/state</li>
401
+ <li>命令:modbus/relay/10/0/set</li>
402
+ </ul>
403
+
404
+ <p><strong>场景2:</strong>开关ID=5,按钮3 → 控制继电器11的线圈15</p>
405
+ <ul>
406
+ <li>开关ID:5</li>
407
+ <li>按钮编号:3</li>
408
+ <li>目标从站地址:11</li>
409
+ <li>目标线圈编号:15</li>
410
+ </ul>
411
+ <p>MQTT主题:</p>
412
+ <ul>
413
+ <li>状态:modbus/relay/11/15/state</li>
414
+ <li>命令:modbus/relay/11/15/set</li>
415
+ </ul>
416
+
417
+ <h3>特点</h3>
418
+ <ul>
419
+ <li>✅ 完全解耦:无需连线到主站节点</li>
420
+ <li>✅ 内置MQTT:自动管理MQTT连接</li>
421
+ <li>✅ 自动重连:连接断开自动恢复</li>
422
+ <li>✅ 状态同步:实时接收设备状态反馈</li>
423
+ <li>✅ 配置持久化:配置自动保存</li>
424
+ </ul>
68
425
  </script>
69
426