node-red-contrib-symi-modbus 2.6.8 → 2.6.9
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 +212 -366
- package/examples/basic-flow.json +33 -21
- package/nodes/custom-protocol.html +276 -0
- package/nodes/custom-protocol.js +240 -0
- package/nodes/homekit-bridge.html +44 -22
- package/nodes/homekit-bridge.js +18 -0
- package/nodes/mesh-protocol.js +286 -0
- package/nodes/modbus-dashboard.html +444 -0
- package/nodes/modbus-dashboard.js +116 -0
- package/nodes/modbus-debug.js +10 -2
- package/nodes/modbus-master.js +175 -74
- package/nodes/modbus-slave-switch.html +196 -12
- package/nodes/modbus-slave-switch.js +479 -157
- package/nodes/serial-port-config.js +84 -21
- package/package.json +5 -3
package/examples/basic-flow.json
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "modbus_server_config_1",
|
|
4
|
+
"type": "modbus-server-config",
|
|
5
|
+
"name": "Modbus服务器(TCP)",
|
|
6
|
+
"connectionType": "tcp",
|
|
7
|
+
"tcpHost": "192.168.1.100",
|
|
8
|
+
"tcpPort": "502"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "serial_port_config_1",
|
|
12
|
+
"type": "serial-port-config",
|
|
13
|
+
"name": "RS-485串口",
|
|
14
|
+
"connectionType": "serial",
|
|
15
|
+
"serialPort": "/dev/ttyUSB0",
|
|
16
|
+
"baudRate": "9600",
|
|
17
|
+
"dataBits": "8",
|
|
18
|
+
"parity": "none",
|
|
19
|
+
"stopBits": "1"
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"id": "modbus_master_1",
|
|
4
23
|
"type": "modbus-master",
|
|
5
24
|
"name": "Modbus主站",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"slaveCount": "1",
|
|
16
|
-
"coilStart": "0",
|
|
17
|
-
"coilEnd": "31",
|
|
18
|
-
"pollInterval": "100",
|
|
25
|
+
"serverConfig": "modbus_server_config_1",
|
|
26
|
+
"slaves": [
|
|
27
|
+
{
|
|
28
|
+
"address": 10,
|
|
29
|
+
"coilStart": 0,
|
|
30
|
+
"coilEnd": 31,
|
|
31
|
+
"pollInterval": 300
|
|
32
|
+
}
|
|
33
|
+
],
|
|
19
34
|
"enableMqtt": false,
|
|
20
|
-
"
|
|
21
|
-
"mqttUsername": "",
|
|
22
|
-
"mqttPassword": "",
|
|
23
|
-
"mqttBaseTopic": "modbus/relay",
|
|
35
|
+
"mqttConfig": "",
|
|
24
36
|
"x": 320,
|
|
25
37
|
"y": 140,
|
|
26
38
|
"wires": [["debug_1"]]
|
|
@@ -123,14 +135,14 @@
|
|
|
123
135
|
"id": "switch_node_1",
|
|
124
136
|
"type": "modbus-slave-switch",
|
|
125
137
|
"name": "面板1按键1(开关模式)",
|
|
126
|
-
"serialPortConfig": "",
|
|
138
|
+
"serialPortConfig": "serial_port_config_1",
|
|
127
139
|
"mqttServer": "",
|
|
128
140
|
"switchBrand": "symi",
|
|
129
141
|
"buttonType": "switch",
|
|
130
142
|
"switchId": "1",
|
|
131
143
|
"buttonNumber": "1",
|
|
132
144
|
"targetSlaveAddress": "10",
|
|
133
|
-
"targetCoilNumber": "
|
|
145
|
+
"targetCoilNumber": "0",
|
|
134
146
|
"x": 340,
|
|
135
147
|
"y": 300,
|
|
136
148
|
"wires": [["debug_2"]]
|
|
@@ -193,14 +205,14 @@
|
|
|
193
205
|
"id": "switch_node_2",
|
|
194
206
|
"type": "modbus-slave-switch",
|
|
195
207
|
"name": "面板1按键2(场景模式)",
|
|
196
|
-
"serialPortConfig": "",
|
|
208
|
+
"serialPortConfig": "serial_port_config_1",
|
|
197
209
|
"mqttServer": "",
|
|
198
210
|
"switchBrand": "symi",
|
|
199
211
|
"buttonType": "scene",
|
|
200
212
|
"switchId": "1",
|
|
201
213
|
"buttonNumber": "2",
|
|
202
214
|
"targetSlaveAddress": "10",
|
|
203
|
-
"targetCoilNumber": "
|
|
215
|
+
"targetCoilNumber": "1",
|
|
204
216
|
"x": 340,
|
|
205
217
|
"y": 360,
|
|
206
218
|
"wires": [["debug_3"]]
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('custom-protocol', {
|
|
3
|
+
category: 'SYMI-MODBUS',
|
|
4
|
+
color: '#FF9800',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: {value: "自定义协议"},
|
|
7
|
+
deviceType: {value: "switch", required: true},
|
|
8
|
+
serialConfig: {value: "", type: "serial-port-config", required: true},
|
|
9
|
+
openCmd: {value: ""},
|
|
10
|
+
closeCmd: {value: ""},
|
|
11
|
+
pauseCmd: {value: ""}
|
|
12
|
+
},
|
|
13
|
+
inputs: 1,
|
|
14
|
+
outputs: 1,
|
|
15
|
+
icon: "font-awesome/fa-code",
|
|
16
|
+
label: function() {
|
|
17
|
+
var typeNames = {
|
|
18
|
+
'switch': '开关',
|
|
19
|
+
'curtain': '窗帘',
|
|
20
|
+
'other': '其他'
|
|
21
|
+
};
|
|
22
|
+
var typeName = typeNames[this.deviceType] || '自定义';
|
|
23
|
+
return this.name || (typeName + "协议");
|
|
24
|
+
},
|
|
25
|
+
oneditprepare: function() {
|
|
26
|
+
var node = this;
|
|
27
|
+
|
|
28
|
+
// 设备类型切换
|
|
29
|
+
$("#node-input-deviceType").on("change", function() {
|
|
30
|
+
var deviceType = $(this).val();
|
|
31
|
+
if (deviceType === "curtain") {
|
|
32
|
+
$("#pause-cmd-row").show();
|
|
33
|
+
} else {
|
|
34
|
+
$("#pause-cmd-row").hide();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 初始化显示
|
|
39
|
+
if (node.deviceType === "curtain") {
|
|
40
|
+
$("#pause-cmd-row").show();
|
|
41
|
+
} else {
|
|
42
|
+
$("#pause-cmd-row").hide();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 16进制输入验证和格式化
|
|
46
|
+
function validateHexInput(input) {
|
|
47
|
+
var value = $(input).val().trim();
|
|
48
|
+
// 移除所有非16进制字符
|
|
49
|
+
value = value.replace(/[^0-9A-Fa-f\s]/g, '');
|
|
50
|
+
// 自动添加空格分隔
|
|
51
|
+
value = value.replace(/\s+/g, '').match(/.{1,2}/g);
|
|
52
|
+
if (value) {
|
|
53
|
+
value = value.join(' ').toUpperCase();
|
|
54
|
+
// 限制48字节
|
|
55
|
+
var bytes = value.split(' ');
|
|
56
|
+
if (bytes.length > 48) {
|
|
57
|
+
bytes = bytes.slice(0, 48);
|
|
58
|
+
value = bytes.join(' ');
|
|
59
|
+
RED.notify('指令长度已限制为48字节', 'warning');
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
value = '';
|
|
63
|
+
}
|
|
64
|
+
$(input).val(value);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 绑定输入验证
|
|
69
|
+
$("#node-input-openCmd, #node-input-closeCmd, #node-input-pauseCmd").on("blur", function() {
|
|
70
|
+
validateHexInput(this);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// 测试按钮功能
|
|
74
|
+
function sendTestCommand(hexString, cmdName) {
|
|
75
|
+
if (!hexString || hexString.trim() === '') {
|
|
76
|
+
RED.notify('请先输入16进制指令', 'warning');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var serialConfig = $("#node-input-serialConfig").val();
|
|
81
|
+
if (!serialConfig) {
|
|
82
|
+
RED.notify('请先选择串口配置', 'warning');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 发送测试命令
|
|
87
|
+
$.ajax({
|
|
88
|
+
url: '/custom-protocol/test',
|
|
89
|
+
method: 'POST',
|
|
90
|
+
contentType: 'application/json',
|
|
91
|
+
data: JSON.stringify({
|
|
92
|
+
serialConfig: serialConfig,
|
|
93
|
+
hexString: hexString,
|
|
94
|
+
cmdName: cmdName
|
|
95
|
+
}),
|
|
96
|
+
success: function(result) {
|
|
97
|
+
if (result.success) {
|
|
98
|
+
RED.notify(cmdName + '指令已发送: ' + hexString, 'success');
|
|
99
|
+
} else {
|
|
100
|
+
RED.notify('发送失败: ' + result.error, 'error');
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
error: function(err) {
|
|
104
|
+
RED.notify('发送失败: ' + err.statusText, 'error');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 绑定测试按钮
|
|
110
|
+
$("#btn-test-open").on("click", function() {
|
|
111
|
+
var hexString = validateHexInput("#node-input-openCmd");
|
|
112
|
+
sendTestCommand(hexString, '打开');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
$("#btn-test-close").on("click", function() {
|
|
116
|
+
var hexString = validateHexInput("#node-input-closeCmd");
|
|
117
|
+
sendTestCommand(hexString, '关闭');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
$("#btn-test-pause").on("click", function() {
|
|
121
|
+
var hexString = validateHexInput("#node-input-pauseCmd");
|
|
122
|
+
sendTestCommand(hexString, '暂停');
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
oneditsave: function() {
|
|
126
|
+
// 保存前验证和格式化
|
|
127
|
+
validateHexInput("#node-input-openCmd");
|
|
128
|
+
validateHexInput("#node-input-closeCmd");
|
|
129
|
+
if (this.deviceType === "curtain") {
|
|
130
|
+
validateHexInput("#node-input-pauseCmd");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// 辅助函数
|
|
136
|
+
function validateHexInput(selector) {
|
|
137
|
+
var input = $(selector);
|
|
138
|
+
var value = input.val().trim();
|
|
139
|
+
value = value.replace(/[^0-9A-Fa-f\s]/g, '');
|
|
140
|
+
value = value.replace(/\s+/g, '').match(/.{1,2}/g);
|
|
141
|
+
if (value) {
|
|
142
|
+
value = value.join(' ').toUpperCase();
|
|
143
|
+
var bytes = value.split(' ');
|
|
144
|
+
if (bytes.length > 48) {
|
|
145
|
+
bytes = bytes.slice(0, 48);
|
|
146
|
+
value = bytes.join(' ');
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
value = '';
|
|
150
|
+
}
|
|
151
|
+
input.val(value);
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<script type="text/html" data-template-name="custom-protocol">
|
|
157
|
+
<div class="form-row">
|
|
158
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 节点名称</label>
|
|
159
|
+
<input type="text" id="node-input-name" placeholder="自定义协议">
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="form-row">
|
|
163
|
+
<label for="node-input-deviceType"><i class="fa fa-cog"></i> 设备类型</label>
|
|
164
|
+
<select id="node-input-deviceType" style="width: 70%;">
|
|
165
|
+
<option value="switch">开关(打开/关闭)</option>
|
|
166
|
+
<option value="curtain">窗帘(打开/关闭/暂停循环)</option>
|
|
167
|
+
<option value="other">其他(打开/关闭)</option>
|
|
168
|
+
</select>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div class="form-row">
|
|
172
|
+
<label for="node-input-serialConfig"><i class="fa fa-plug"></i> 串口配置</label>
|
|
173
|
+
<input type="text" id="node-input-serialConfig" style="width: 70%;">
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div class="form-row">
|
|
177
|
+
<label style="width: 100%; font-weight: bold; margin-top: 15px; margin-bottom: 10px;">
|
|
178
|
+
<i class="fa fa-code"></i> 16进制指令配置(最多48字节)
|
|
179
|
+
</label>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div class="form-row">
|
|
183
|
+
<label for="node-input-openCmd"><i class="fa fa-arrow-up"></i> 打开指令</label>
|
|
184
|
+
<input type="text" id="node-input-openCmd" placeholder="例如: 01 05 00 00 FF 00 8C 3A" style="width: 50%;">
|
|
185
|
+
<button type="button" id="btn-test-open" class="red-ui-button" style="margin-left: 5px;">
|
|
186
|
+
<i class="fa fa-play"></i> 测试
|
|
187
|
+
</button>
|
|
188
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px; margin-left: 105px;">
|
|
189
|
+
输入16进制码,空格分隔,自动格式化
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="form-row">
|
|
194
|
+
<label for="node-input-closeCmd"><i class="fa fa-arrow-down"></i> 关闭指令</label>
|
|
195
|
+
<input type="text" id="node-input-closeCmd" placeholder="例如: 01 05 00 00 00 00 CD CA" style="width: 50%;">
|
|
196
|
+
<button type="button" id="btn-test-close" class="red-ui-button" style="margin-left: 5px;">
|
|
197
|
+
<i class="fa fa-play"></i> 测试
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div class="form-row" id="pause-cmd-row" style="display: none;">
|
|
202
|
+
<label for="node-input-pauseCmd"><i class="fa fa-pause"></i> 暂停指令</label>
|
|
203
|
+
<input type="text" id="node-input-pauseCmd" placeholder="例如: 01 05 00 01 FF 00 DD FA" style="width: 50%;">
|
|
204
|
+
<button type="button" id="btn-test-pause" class="red-ui-button" style="margin-left: 5px;">
|
|
205
|
+
<i class="fa fa-play"></i> 测试
|
|
206
|
+
</button>
|
|
207
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px; margin-left: 105px;">
|
|
208
|
+
仅窗帘模式需要配置
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="form-row" style="margin-top: 20px;">
|
|
213
|
+
<div style="padding: 10px; background: #f0f8ff; border-left: 4px solid #2196F3; border-radius: 4px;">
|
|
214
|
+
<strong>使用说明:</strong><br>
|
|
215
|
+
<ul style="margin: 5px 0; padding-left: 20px; font-size: 12px;">
|
|
216
|
+
<li><strong>开关/其他模式</strong>: true发送打开指令,false发送关闭指令</li>
|
|
217
|
+
<li><strong>窗帘模式</strong>: true/false交替触发,循环发送打开→关闭→暂停→打开...</li>
|
|
218
|
+
<li>16进制码自动格式化为大写,空格分隔</li>
|
|
219
|
+
<li>点击"测试"按钮可直接发送到串口总线验证</li>
|
|
220
|
+
<li>配置自动持久化保存</li>
|
|
221
|
+
</ul>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</script>
|
|
225
|
+
|
|
226
|
+
<script type="text/html" data-help-name="custom-protocol">
|
|
227
|
+
<p>自定义协议节点,用于控制非标准Modbus协议的485设备。</p>
|
|
228
|
+
|
|
229
|
+
<h3>功能特性</h3>
|
|
230
|
+
<ul>
|
|
231
|
+
<li>支持三种设备类型:开关、窗帘、其他</li>
|
|
232
|
+
<li>窗帘模式支持打开/关闭/暂停循环控制</li>
|
|
233
|
+
<li>16进制指令配置,最多48字节</li>
|
|
234
|
+
<li>配置界面可直接测试发送指令</li>
|
|
235
|
+
<li>配置持久化保存</li>
|
|
236
|
+
</ul>
|
|
237
|
+
|
|
238
|
+
<h3>设备类型说明</h3>
|
|
239
|
+
<dl>
|
|
240
|
+
<dt>开关模式</dt>
|
|
241
|
+
<dd>接收true发送打开指令,接收false发送关闭指令</dd>
|
|
242
|
+
|
|
243
|
+
<dt>窗帘模式</dt>
|
|
244
|
+
<dd>true/false交替触发,循环发送:打开→关闭→暂停→打开...</dd>
|
|
245
|
+
|
|
246
|
+
<dt>其他模式</dt>
|
|
247
|
+
<dd>与开关模式相同,接收true/false发送对应指令</dd>
|
|
248
|
+
</dl>
|
|
249
|
+
|
|
250
|
+
<h3>输入消息</h3>
|
|
251
|
+
<p>从从站开关节点接收:</p>
|
|
252
|
+
<pre>msg.payload = true/false</pre>
|
|
253
|
+
|
|
254
|
+
<h3>输出消息</h3>
|
|
255
|
+
<p>输出Buffer格式的16进制数据,可连线到debug节点发送到串口:</p>
|
|
256
|
+
<pre>msg.payload = Buffer</pre>
|
|
257
|
+
|
|
258
|
+
<h3>使用示例</h3>
|
|
259
|
+
<ol>
|
|
260
|
+
<li>配置串口节点</li>
|
|
261
|
+
<li>选择设备类型(开关/窗帘/其他)</li>
|
|
262
|
+
<li>输入16进制指令(空格分隔)</li>
|
|
263
|
+
<li>点击"测试"按钮验证指令是否正确</li>
|
|
264
|
+
<li>连线:从站开关 → 自定义协议 → debug节点</li>
|
|
265
|
+
<li>部署流程,触发从站开关即可发送自定义指令</li>
|
|
266
|
+
</ol>
|
|
267
|
+
|
|
268
|
+
<h3>注意事项</h3>
|
|
269
|
+
<ul>
|
|
270
|
+
<li>16进制指令最多48字节</li>
|
|
271
|
+
<li>窗帘模式需要配置三个指令(打开、关闭、暂停)</li>
|
|
272
|
+
<li>测试功能需要先选择串口配置</li>
|
|
273
|
+
<li>输出需要连线到debug节点才能发送到串口</li>
|
|
274
|
+
</ul>
|
|
275
|
+
</script>
|
|
276
|
+
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
function CustomProtocolNode(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
var node = this;
|
|
7
|
+
|
|
8
|
+
// 配置
|
|
9
|
+
node.config = {
|
|
10
|
+
name: config.name || "自定义协议",
|
|
11
|
+
deviceType: config.deviceType || "switch",
|
|
12
|
+
serialConfig: config.serialConfig,
|
|
13
|
+
openCmd: config.openCmd || "",
|
|
14
|
+
closeCmd: config.closeCmd || "",
|
|
15
|
+
pauseCmd: config.pauseCmd || ""
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// 窗帘模式的状态索引(0=打开, 1=关闭, 2=暂停)
|
|
19
|
+
node.curtainStateIndex = 0;
|
|
20
|
+
|
|
21
|
+
// 获取串口配置节点
|
|
22
|
+
var serialNode = RED.nodes.getNode(node.config.serialConfig);
|
|
23
|
+
if (!serialNode) {
|
|
24
|
+
node.error('未找到串口配置节点');
|
|
25
|
+
node.status({fill: "red", shape: "ring", text: "未配置串口"});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 16进制字符串转Buffer
|
|
30
|
+
function hexStringToBuffer(hexString) {
|
|
31
|
+
if (!hexString || hexString.trim() === '') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// 移除空格和非16进制字符
|
|
37
|
+
var hex = hexString.replace(/\s+/g, '').replace(/[^0-9A-Fa-f]/g, '');
|
|
38
|
+
|
|
39
|
+
// 确保是偶数长度
|
|
40
|
+
if (hex.length % 2 !== 0) {
|
|
41
|
+
node.warn('16进制字符串长度必须为偶数: ' + hexString);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 限制48字节
|
|
46
|
+
if (hex.length > 96) {
|
|
47
|
+
hex = hex.substring(0, 96);
|
|
48
|
+
node.warn('指令长度超过48字节,已截断');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 转换为Buffer
|
|
52
|
+
var buffer = Buffer.from(hex, 'hex');
|
|
53
|
+
return buffer;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
node.error('16进制字符串转换失败: ' + err.message);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 发送指令到串口
|
|
61
|
+
function sendCommand(buffer, cmdName) {
|
|
62
|
+
if (!buffer) {
|
|
63
|
+
node.warn('指令为空,跳过发送');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 直接通过串口配置节点发送数据
|
|
68
|
+
if (!serialNode || !serialNode.connection) {
|
|
69
|
+
node.error('串口连接未建立');
|
|
70
|
+
node.status({fill: "red", shape: "ring", text: "连接未建立"});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 检查连接状态
|
|
75
|
+
var isConnected = false;
|
|
76
|
+
if (serialNode.connectionType === 'tcp') {
|
|
77
|
+
isConnected = serialNode.connection && !serialNode.connection.destroyed;
|
|
78
|
+
} else {
|
|
79
|
+
isConnected = serialNode.connection && serialNode.connection.isOpen;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!isConnected) {
|
|
83
|
+
node.error('串口/TCP连接未打开');
|
|
84
|
+
node.status({fill: "red", shape: "ring", text: "连接未打开"});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 使用串口配置节点的write方法(带队列机制)
|
|
89
|
+
serialNode.write(buffer, function(err) {
|
|
90
|
+
if (err) {
|
|
91
|
+
node.error('发送失败: ' + err.message);
|
|
92
|
+
node.status({fill: "red", shape: "ring", text: "发送失败"});
|
|
93
|
+
} else {
|
|
94
|
+
node.log(cmdName + '指令已发送: ' + buffer.toString('hex').toUpperCase());
|
|
95
|
+
node.status({fill: "green", shape: "dot", text: cmdName + " (" + buffer.length + "字节)"});
|
|
96
|
+
|
|
97
|
+
// 3秒后清除状态
|
|
98
|
+
setTimeout(function() {
|
|
99
|
+
node.status({fill: "blue", shape: "ring", text: "就绪"});
|
|
100
|
+
}, 3000);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 处理输入消息
|
|
106
|
+
node.on('input', function(msg) {
|
|
107
|
+
var value = msg.payload;
|
|
108
|
+
|
|
109
|
+
// 只接受布尔值
|
|
110
|
+
if (typeof value !== 'boolean') {
|
|
111
|
+
node.warn('输入必须为true/false,当前类型: ' + typeof value);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (node.config.deviceType === 'curtain') {
|
|
116
|
+
// 窗帘模式:无论收到true还是false,都触发下一个指令
|
|
117
|
+
// 适配有状态开关(true/false交替)和无状态开关(只发true或false)
|
|
118
|
+
// 循环顺序:打开 → 暂停 → 关闭 → 暂停 → 打开...
|
|
119
|
+
var commands = [
|
|
120
|
+
{name: '打开', hex: node.config.openCmd},
|
|
121
|
+
{name: '暂停', hex: node.config.pauseCmd},
|
|
122
|
+
{name: '关闭', hex: node.config.closeCmd},
|
|
123
|
+
{name: '暂停', hex: node.config.pauseCmd}
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// 获取当前指令
|
|
127
|
+
var currentCmd = commands[node.curtainStateIndex];
|
|
128
|
+
if (!currentCmd || !currentCmd.hex) {
|
|
129
|
+
node.warn('窗帘模式缺少' + currentCmd.name + '指令配置');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 转换并发送
|
|
134
|
+
var buffer = hexStringToBuffer(currentCmd.hex);
|
|
135
|
+
if (buffer) {
|
|
136
|
+
sendCommand(buffer, currentCmd.name);
|
|
137
|
+
|
|
138
|
+
// 移动到下一个状态(无论收到true还是false)
|
|
139
|
+
node.curtainStateIndex = (node.curtainStateIndex + 1) % 4;
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// 开关/其他模式:true发送打开,false发送关闭
|
|
143
|
+
var cmdName = value ? '打开' : '关闭';
|
|
144
|
+
var hexString = value ? node.config.openCmd : node.config.closeCmd;
|
|
145
|
+
|
|
146
|
+
if (!hexString) {
|
|
147
|
+
node.warn(cmdName + '指令未配置');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
var buffer = hexStringToBuffer(hexString);
|
|
152
|
+
if (buffer) {
|
|
153
|
+
sendCommand(buffer, cmdName);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 初始状态
|
|
159
|
+
var typeNames = {
|
|
160
|
+
'switch': '开关',
|
|
161
|
+
'curtain': '窗帘',
|
|
162
|
+
'other': '其他'
|
|
163
|
+
};
|
|
164
|
+
var typeName = typeNames[node.config.deviceType] || '自定义';
|
|
165
|
+
node.status({fill: "blue", shape: "ring", text: typeName + "模式就绪"});
|
|
166
|
+
|
|
167
|
+
// 清理
|
|
168
|
+
node.on('close', function() {
|
|
169
|
+
node.status({});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
RED.nodes.registerType("custom-protocol", CustomProtocolNode);
|
|
174
|
+
|
|
175
|
+
// HTTP API:测试发送指令
|
|
176
|
+
RED.httpAdmin.post('/custom-protocol/test', function(req, res) {
|
|
177
|
+
var serialConfigId = req.body.serialConfig;
|
|
178
|
+
var hexString = req.body.hexString;
|
|
179
|
+
var cmdName = req.body.cmdName;
|
|
180
|
+
|
|
181
|
+
if (!serialConfigId || !hexString) {
|
|
182
|
+
res.status(400).json({success: false, error: '参数错误'});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 获取串口配置节点
|
|
187
|
+
var serialNode = RED.nodes.getNode(serialConfigId);
|
|
188
|
+
if (!serialNode) {
|
|
189
|
+
res.status(404).json({success: false, error: '未找到串口配置节点'});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// 转换16进制字符串为Buffer
|
|
195
|
+
var hex = hexString.replace(/\s+/g, '').replace(/[^0-9A-Fa-f]/g, '');
|
|
196
|
+
if (hex.length % 2 !== 0) {
|
|
197
|
+
res.status(400).json({success: false, error: '16进制字符串长度必须为偶数'});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (hex.length > 96) {
|
|
201
|
+
hex = hex.substring(0, 96);
|
|
202
|
+
}
|
|
203
|
+
var buffer = Buffer.from(hex, 'hex');
|
|
204
|
+
|
|
205
|
+
// 检查连接状态
|
|
206
|
+
if (!serialNode.connection) {
|
|
207
|
+
res.status(503).json({success: false, error: '连接未建立'});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
var isConnected = false;
|
|
212
|
+
if (serialNode.connectionType === 'tcp') {
|
|
213
|
+
isConnected = !serialNode.connection.destroyed;
|
|
214
|
+
} else {
|
|
215
|
+
isConnected = serialNode.connection.isOpen;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!isConnected) {
|
|
219
|
+
res.status(503).json({success: false, error: '连接未打开'});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 使用串口配置节点的write方法(带队列机制)
|
|
224
|
+
serialNode.write(buffer, function(err) {
|
|
225
|
+
if (err) {
|
|
226
|
+
res.json({success: false, error: err.message});
|
|
227
|
+
} else {
|
|
228
|
+
res.json({
|
|
229
|
+
success: true,
|
|
230
|
+
message: cmdName + '指令已发送',
|
|
231
|
+
bytes: buffer.length
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
res.status(500).json({success: false, error: err.message});
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
|