├── config
├── xArm-49770F673737-gripper.json
└── xArm-49770F673737-arm.json
├── setup.py
├── package.xml
├── launch
└── display.launch
├── src
└── lewansoul_xarm
│ ├── __init__.py
│ ├── controller_ros.py
│ ├── arm_ros.py
│ ├── controller.py
│ └── arm.py
├── LICENSE
├── CMakeLists.txt
├── scripts
└── xArm-ros.py
├── rviz
├── urdf.vcg
└── urdf.rviz
├── README.md
└── urdf
└── xArm.urdf
/config/xArm-49770F673737-gripper.json:
--------------------------------------------------------------------------------
1 | {
2 | "controller-serial-number": "49770F673737",
3 | "directions": [
4 | -1.0
5 | ],
6 | "offsets": [
7 | 2.810678227411668
8 | ],
9 | "servo-ids": [
10 | 1
11 | ]
12 | }
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | ## ! DO NOT MANUALLY INVOKE THIS setup.py, USE CATKIN INSTEAD
2 | from distutils.core import setup
3 | from catkin_pkg.python_setup import generate_distutils_setup
4 |
5 | # fetch values from package.xml
6 | setup_args = generate_distutils_setup(packages=['lewansoul_xarm'],
7 | package_dir={'': 'src'},
8 | )
9 |
10 | setup(**setup_args)
11 |
--------------------------------------------------------------------------------
/config/xArm-49770F673737-arm.json:
--------------------------------------------------------------------------------
1 | {
2 | "controller-serial-number": "49770F673737",
3 | "directions": [
4 | 1.0,
5 | 1.0,
6 | -1.0,
7 | 1.0,
8 | 1.0
9 | ],
10 | "offsets": [
11 | -2.22843638894636,
12 | -2.1069614730075545,
13 | 2.0943951023931953,
14 | -2.1823596966937093,
15 | -2.165604535874564
16 | ],
17 | "servo-ids": [
18 | 6,
19 | 5,
20 | 4,
21 | 3,
22 | 2
23 | ]
24 | }
--------------------------------------------------------------------------------
/package.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | lewansoul_xarm
4 | 0.0.0
5 | Python class to communicate with LewanSoul xArm over HID
6 |
7 | Anton Deguet
8 | CISST
9 |
10 | catkin
11 | rospy
12 | rospy
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/launch/display.launch:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | [/arm/measured_js]
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/lewansoul_xarm/__init__.py:
--------------------------------------------------------------------------------
1 | # Author(s): Anton Deguet
2 | # Created on: 2020-04
3 |
4 | # (C) Copyright 2020 Johns Hopkins University (JHU), All Rights Reserved.
5 |
6 | # --- begin cisst license - do not edit ---
7 |
8 | # This software is provided "as is" under an open source license, with
9 | # no warranty. The complete license can be found in license.txt and
10 | # http://www.cisst.org/cisst/license.txt.
11 |
12 | # --- end cisst license ---
13 |
14 | __all__ = ["arm", "arm_ros", "controller", "controller_ros"]
15 |
16 | # main arm class
17 | from .arm import arm
18 | from .arm_ros import arm_ros
19 | from .controller import controller
20 | from .controller_ros import controller_ros
21 |
--------------------------------------------------------------------------------
/src/lewansoul_xarm/controller_ros.py:
--------------------------------------------------------------------------------
1 | # Author(s): Anton Deguet
2 | # Created on: 2020-04
3 |
4 | # (C) Copyright 2020 Johns Hopkins University (JHU), All Rights Reserved.
5 |
6 | # --- begin cisst license - do not edit ---
7 |
8 | # This software is provided "as is" under an open source license, with
9 | # no warranty. The complete license can be found in license.txt and
10 | # http://www.cisst.org/cisst/license.txt.
11 |
12 | # --- end cisst license ---
13 |
14 | import rospy
15 | import lewansoul_xarm
16 |
17 |
18 | class controller_ros(object):
19 | """Simple arm API wrapping around ROS messages
20 | """
21 |
22 | # initialize the controller
23 | def __init__(self, controller):
24 | self._controller = controller
25 | self._arms = []
26 | for arm in self._controller._arms:
27 | self._arms.append(lewansoul_xarm.arm_ros(arm))
28 |
29 |
30 | def loop(self, rate):
31 | ros_rate = rospy.Rate(rate)
32 | while not rospy.is_shutdown():
33 | for arm in self._arms:
34 | arm.publish()
35 | ros_rate.sleep()
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Anton Deguet
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required (VERSION 2.8.3)
2 | project (lewansoul_xarm)
3 |
4 | ## Find catkin macros and libraries
5 | ## if COMPONENTS list like find_package(catkin REQUIRED COMPONENTS xyz)
6 | ## is used, also find other catkin packages
7 | find_package (catkin REQUIRED COMPONENTS
8 | rospy
9 | )
10 |
11 | ## The catkin_package macro generates cmake config files for your package
12 | ## Declare things to be passed to dependent projects
13 | ## INCLUDE_DIRS: uncomment this if you package contains header files
14 | ## LIBRARIES: libraries you create in this project that dependent projects also need
15 | ## CATKIN_DEPENDS: catkin_packages dependent projects also need
16 | ## DEPENDS: system dependencies of this project that dependent projects also need
17 | catkin_package (
18 | # INCLUDE_DIRS include
19 | # LIBRARIES my_pkg
20 | CATKIN_DEPENDS rospy
21 | # DEPENDS system_lib
22 | )
23 |
24 | ## Mark executable scripts (Python etc.) for installation
25 | ## in contrast to setup.py, you can choose the destination
26 | # install(PROGRAMS
27 | # scripts/my_python_script
28 | # DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
29 | # )
30 |
31 | catkin_python_setup ()
32 |
--------------------------------------------------------------------------------
/scripts/xArm-ros.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | import rospy
5 | import rospkg
6 | import lewansoul_xarm
7 |
8 | def shutdown():
9 | print('\nshutting down xArm...')
10 | global controller
11 | controller.shutdown()
12 |
13 | def ros_main():
14 | # connect to xArm
15 | global controller # so we can shutdown
16 | controller = lewansoul_xarm.controller()
17 |
18 | rospack = rospkg.RosPack()
19 | pkg_path = rospack.get_path('lewansoul_xarm')
20 |
21 | urdf_file = pkg_path + '/urdf/xArm.urdf'
22 | if not os.path.isfile(urdf_file):
23 | print('couldn\'t find file: ' + urdf_file + '. cartesian moves will be disabled')
24 | urdf_file = ''
25 | arm = controller.add_arm("arm", "xArm-49770F673737-arm.json", urdf_file)
26 | arm.enable()
27 | arm.home()
28 |
29 | gripper = controller.add_arm("gripper", "xArm-49770F673737-gripper.json")
30 | gripper.enable()
31 | gripper.home()
32 |
33 | rospy.init_node('xArm_ros', anonymous = True)
34 | rospy.on_shutdown(shutdown)
35 |
36 | controller_ros = lewansoul_xarm.controller_ros(controller)
37 | controller_ros.loop(30) # 30 Hz seems to be the maximum the controller can perform
38 |
39 |
40 | if __name__ == '__main__':
41 | try:
42 | ros_main()
43 | except rospy.ROSInterruptException:
44 | pass
45 |
--------------------------------------------------------------------------------
/rviz/urdf.vcg:
--------------------------------------------------------------------------------
1 | Background\ ColorR=0.0941176
2 | Background\ ColorG=0
3 | Background\ ColorB=0.466667
4 | Fixed\ Frame=/base_link
5 | Target\ Frame=
6 | Grid.Alpha=0.5
7 | Grid.Cell\ Size=0.5
8 | Grid.ColorR=0.898039
9 | Grid.ColorG=0.898039
10 | Grid.ColorB=0.898039
11 | Grid.Enabled=1
12 | Grid.Line\ Style=0
13 | Grid.Line\ Width=0.03
14 | Grid.Normal\ Cell\ Count=0
15 | Grid.OffsetX=0
16 | Grid.OffsetY=0
17 | Grid.OffsetZ=0
18 | Grid.Plane=0
19 | Grid.Plane\ Cell\ Count=10
20 | Grid.Reference\ Frame=
21 | Robot\ Model.Alpha=1
22 | Robot\ Model.Collision\ Enabled=0
23 | Robot\ Model.Enabled=1
24 | Robot\ Model.Robot\ Description=robot_description
25 | Robot\ Model.TF\ Prefix=
26 | Robot\ Model.Update\ Interval=0
27 | Robot\ Model.Visual\ Enabled=1
28 | Robot\:\ Robot\ Model\ Link\ base_linkShow\ Axes=0
29 | Robot\:\ Robot\ Model\ Link\ base_linkShow\ Trail=0
30 | Robot\:\ Robot\ Model\ Link\ link1\ Axes=1
31 | Robot\:\ Robot\ Model\ Link\ link1\ Trail=0
32 | Robot\:\ Robot\ Model\ Link\ link2\ Axes=1
33 | Robot\:\ Robot\ Model\ Link\ link2\ Trail=0
34 | Robot\:\ Robot\ Model\ Link\ link3\ Axes=1
35 | Robot\:\ Robot\ Model\ Link\ link3\ Trail=0
36 | Robot\:\ Robot\ Model\ Link\ link4\ Axes=1
37 | Robot\:\ Robot\ Model\ Link\ link4\ Trail=0
38 | Robot\:\ Robot\ Model\ Link\ link5\ Axes=1
39 | Robot\:\ Robot\ Model\ Link\ link5\ Trail=0
40 | Tool\ 2D\ Nav\ GoalTopic=goal
41 | Tool\ 2D\ Pose\ EstimateTopic=initialpose
42 | Camera\ Type=rviz::OrbitViewController
43 | Camera\ Config=1.15779 3.76081 2.16475 0 0 0
44 | Property\ Grid\ State=selection=.Global StatusTopStatus;expanded=.Global Options,Grid.Enabled,Robot Model.Enabled;scrollpos=0,0;splitterpos=150,285;ispageselected=1
45 | [Display0]
46 | Name=Grid
47 | Package=rviz
48 | ClassName=rviz::GridDisplay
49 | [Display1]
50 | Name=Robot Model
51 | Package=rviz
52 | ClassName=rviz::RobotModelDisplay
53 |
--------------------------------------------------------------------------------
/src/lewansoul_xarm/arm_ros.py:
--------------------------------------------------------------------------------
1 | # Author(s): Anton Deguet
2 | # Created on: 2020-04
3 |
4 | # (C) Copyright 2020 Johns Hopkins University (JHU), All Rights Reserved.
5 |
6 | # --- begin cisst license - do not edit ---
7 |
8 | # This software is provided "as is" under an open source license, with
9 | # no warranty. The complete license can be found in license.txt and
10 | # http://www.cisst.org/cisst/license.txt.
11 |
12 | # --- end cisst license ---
13 |
14 | """
15 |
16 | """
17 |
18 | import numpy
19 | import rospy
20 | import std_msgs.msg
21 | import sensor_msgs.msg
22 | import geometry_msgs.msg
23 |
24 | class arm_ros(object):
25 | """
26 | """
27 |
28 | def __init__(self, arm):
29 | """
30 | """
31 | self._arm = arm
32 |
33 | ns = self._arm._name + '/'
34 | # create publishers and corresponding message
35 | # joint space
36 | self._js_msg = sensor_msgs.msg.JointState()
37 | for i in range(len(self._arm._servo_ids)):
38 | self._js_msg.name.append(self._arm._name + str(i + 1))
39 | self._measured_js_publisher = rospy.Publisher(ns + 'measured_js',
40 | sensor_msgs.msg.JointState,
41 | queue_size = 10)
42 | self._goal_js_publisher = rospy.Publisher(ns + 'goal_js',
43 | sensor_msgs.msg.JointState,
44 | queue_size = 10)
45 | # cartesian space
46 | if self._arm._has_kin:
47 | self._cp_msg = geometry_msgs.msg.TransformStamped()
48 | self._measured_cp_publisher = rospy.Publisher(ns + 'measured_cp',
49 | geometry_msgs.msg.TransformStamped,
50 | queue_size = 10)
51 |
52 | # create subscribers
53 | rospy.Subscriber(ns + "enable", std_msgs.msg.Empty, self.enable)
54 | rospy.Subscriber(ns + "disable", std_msgs.msg.Empty, self.disable)
55 | rospy.Subscriber(ns + "home", std_msgs.msg.Empty, self.home)
56 | rospy.Subscriber(ns + "calibrate", std_msgs.msg.Empty, self.calibrate)
57 | rospy.Subscriber(ns + "move_jp", sensor_msgs.msg.JointState, self.move_jp)
58 |
59 | def publish(self):
60 | try:
61 | # update data from controller
62 | self._arm.get_data()
63 | # measured joint state
64 | measured_jp = self._arm.measured_jp()
65 | self._js_msg.position[:] = measured_jp.flat
66 | self._measured_js_publisher.publish(self._js_msg)
67 | # goal joint position
68 | goal_jp = self._arm.goal_jp()
69 | self._js_msg.position[:] = goal_jp.flat
70 | self._goal_js_publisher.publish(self._js_msg)
71 | # measured cartesian position
72 | if self._arm._has_kin:
73 | measured_cp = self._arm.measured_cp()
74 | t = self._cp_msg.transform
75 | t.rotation.x, t.rotation.y, t.rotation.z, t.rotation.w = measured_cp.M.GetQuaternion()
76 | t.translation.x = measured_cp.p[0]
77 | t.translation.y = measured_cp.p[1]
78 | t.translation.z = measured_cp.p[2]
79 | self._measured_cp_publisher.publish(self._cp_msg)
80 | except:
81 | print('failed to communicate with HID device, arm might be shutting down or controller has been turned off')
82 | rospy.signal_shutdown('HID communication error')
83 |
84 | def enable(self, msg):
85 | self._arm.enable()
86 |
87 | def disable(self, msg):
88 | self._arm.disable()
89 |
90 | def home(self, msg):
91 | self._arm.home()
92 |
93 | def calibrate(self, msg):
94 | self._arm.calibrate()
95 |
96 | def move_jp(self, msg):
97 | goal = numpy.array(msg.position)
98 | self._arm.move_jp(goal)
99 |
--------------------------------------------------------------------------------
/src/lewansoul_xarm/controller.py:
--------------------------------------------------------------------------------
1 | # Author(s): Anton Deguet
2 | # Created on: 2020-04
3 |
4 | # (C) Copyright 2020 Johns Hopkins University (JHU), All Rights Reserved.
5 |
6 | # --- begin cisst license - do not edit ---
7 |
8 | # This software is provided "as is" under an open source license, with
9 | # no warranty. The complete license can be found in license.txt and
10 | # http://www.cisst.org/cisst/license.txt.
11 |
12 | # --- end cisst license ---
13 |
14 | """
15 |
16 | Code based on:
17 | https://gist.github.com/maximecb/7fd42439e8a28b9a74a4f7db68281071
18 | https://github.com/maximkulkin/lewansoul-lx16a
19 |
20 | sudo apt-get install libhidapi-hidraw0 libhidapi-libusb0 python-hid
21 | pip install --user easyhid
22 | """
23 | CMD_SERVO_MOVE = 3
24 | CMD_GET_BATTERY_VOLTAGE = 15
25 | CMD_MULT_SERVO_UNLOAD = 20
26 | CMD_MULT_SERVO_POS_READ = 21
27 |
28 | import math
29 | import easyhid
30 | import numpy
31 | import itertools
32 | import threading
33 | import lewansoul_xarm
34 |
35 | def integer_to_bits(v):
36 | lsb = v & 0xFF
37 | msb = v >> 8
38 | return lsb, msb
39 |
40 | class controller(object):
41 | """Simple arm API wrapping around ROS messages
42 | """
43 |
44 | # initialize the controller
45 | def __init__(self, pid = 22352):
46 | """
47 | """
48 | # stores an enumeration of all the connected USB HID devices
49 | en = easyhid.Enumeration()
50 |
51 | # return a list of devices based on the search parameters
52 | devices = en.find(vid = 1155, pid = pid)
53 |
54 | # print a description of the devices found
55 | for dev in devices:
56 | print(dev.description())
57 |
58 | if len(devices) == 0:
59 | raise Exception('no device detected, make sure the arm is powered, connected and you have read-write permissions on /dev/hidraw')
60 |
61 | self._device = devices[0]
62 |
63 | # open a device
64 | self._device.open()
65 | self._serial_number = self._device.serial_number.encode('ascii')
66 | print('connected to xArm controller serial number: ' + self._serial_number)
67 |
68 | # compute bits_to_rads: Lewansoul bits are 1000 for 240 degree
69 | # range, then convert from degrees to radians
70 | self.bits_to_si = math.radians((240.0 / 1000.0))
71 |
72 | # list of arms
73 | self._arms = []
74 |
75 | # lock to make sure read/writes are safe
76 | self._lock = threading.RLock()
77 |
78 |
79 | def __del__(self):
80 | print('closing xArm controller')
81 | self.shutdown()
82 | self._device.close()
83 |
84 |
85 | def add_arm(self, name, config_file, urdf_file = ''):
86 | new_arm = lewansoul_xarm.arm(self, name, config_file, urdf_file)
87 | self._arms.append(new_arm)
88 | return new_arm
89 |
90 |
91 | def measured_jp(self, servo_ids):
92 | """
93 | Read the position of all servos
94 | ServoPositionRead 21 (byte)count { (byte)id }; (byte)count { (byte)id (ushort)position }
95 | """
96 | nb_servos = len(servo_ids)
97 | with self._lock:
98 | self._device.write(itertools.chain([0x55, 0x55,
99 | 3 + nb_servos, # length of command
100 | CMD_MULT_SERVO_POS_READ,
101 | nb_servos]
102 | ,
103 | servo_ids))
104 | response = self._device.read()
105 |
106 | # check first two elements of answer
107 | assert response[0] == 0x55
108 | assert response[1] == 0x55
109 | # response should march number of servos
110 | count = response[4]
111 | assert count == nb_servos
112 | # array used to store SI positions
113 | positions = numpy.zeros(nb_servos)
114 | for i in range(nb_servos):
115 | # check that servo ids match
116 | id = response[5 + 3 * i]
117 | assert id == servo_ids[i]
118 | # compute joint position
119 | p_lsb = response[5 + 3 * i + 1] # least significant bit
120 | p_msb = response[5 + 3 * i + 2] # most significant bit
121 | pos_bits = (p_msb << 8) + p_lsb
122 | positions[i] = pos_bits * self.bits_to_si
123 | return positions
124 |
125 |
126 | def disable(self, servo_ids):
127 | # command
128 | nb_servos = len(servo_ids)
129 | command = [0x55, 0x55,
130 | nb_servos + 3, # msg length
131 | CMD_MULT_SERVO_UNLOAD,
132 | nb_servos]
133 | with self._lock:
134 | self._device.write(itertools.chain(command, servo_ids))
135 |
136 |
137 | def shutdown(self):
138 | for arm in self._arms:
139 | print('shutting down ' + arm._name)
140 | self.disable(arm._servo_ids)
141 |
142 |
143 | def move_jp(self, servo_ids, goals_si, time_s = 0.0):
144 | nb_servos = len(goals_si)
145 | assert nb_servos == len(servo_ids)
146 |
147 | # should be replaced by computation using optimal joint velocities
148 | if time_s == 0.0:
149 | time_s = 2.0
150 | elif time_s > 20:
151 | time_s = 20.0
152 | time_ms = int(time_s * 1000)
153 |
154 | # convert
155 | goals = goals_si / self.bits_to_si
156 |
157 | # command
158 | command = [0x55, 0x55,
159 | 5 + nb_servos * 3, # msg length
160 | CMD_SERVO_MOVE,
161 | nb_servos]
162 | command.extend(integer_to_bits(time_ms))
163 | for i, servo_id in enumerate(servo_ids):
164 | command.append(servo_id)
165 | command.extend(integer_to_bits(int(goals[i])))
166 |
167 | with self._lock:
168 | self._device.write(command)
169 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Introduction
2 |
3 | This package allows to control a Lewansoul xArm robot from a Linux PC with ROS. It uses the HID interface between your PC and the Arduino board imbedded in the xArm. Said Arduino board then communicates with the servos (6 of them, LX 15 D) using a serial TTL UART connection. It is also possible to communicate directly with the servo units with a serial communication (and bypass the arduino board on the base) but this is not supported by the code in this repository.
4 |
5 | To note, the arm is sometimes listed as a 6-DOF robot (degree of freedom) but it is actually a 5 DOF + 1 for the gripper. So it is an under actuated arm and one can't reach all possible 3D positions/orientations.
6 |
7 | If you find bugs or want to add some extra features, don't hesitate to create a pull request :-)
8 |
9 | Naming convention is loosely based on CRTK: https://github.com/collaborative-robotics/documentation/wiki/Robot-API-motion
10 |
11 | ## Requirements
12 |
13 | ### Software
14 |
15 | Code tested on Linux Ubuntu 18.04 using Python 2. The following packages are required:
16 | ```sh
17 | sudo apt install libhidapi-hidraw0 libhidapi-libusb0 python-hid
18 | pip install --user easyhid
19 | ```
20 |
21 | ### Permissions
22 |
23 | The HID device is created with the permissions `root.root`, `0600` by default which means that users can communicate with the robot unless they escalate to root privileges (`sudo`).
24 |
25 | To avoid this you should create a `udev` rule. To get the proper attributes, you must first identify the `hidraw` device created for your device. You can use `dmesg -w` in a shell and then plug/power on the arm. Let's assume it's `hidraw3` and launch `sudo udevadm info --attribute-walk /dev/hidraw3 | less`.
26 |
27 | You can create a rule using the product name since it seems specific enough to identify the xArm Arduino board. Create the file
28 | `/etc/udev/rules.d/80-usb-xarm.rules` with the content `SUBSYSTEM=="hidraw", ATTRS{product}=="LOBOT", GROUP="dialout", MODE="0660"` (note that all users will need to be members of the group `dialout`). To apply the rules without rebooting: `sudo udevadm control --reload-rules` and `sudo udevadm trigger` (this is a one time setup).
29 |
30 | ### `hidraw` device not created properly
31 |
32 | The last issue is a lot more annoying and we don't have a good fix. From time to time, the `hidraw` device is not properly created. I found an error message in ` /var/log/syslog` related to FuEngine. This seems to be a feature to automatically check/upgrade a device's firmware. The only way we found to avoid this is to disable the service `fwupd` using `sudo service fwupd stop` (has to be done after every reboot) or ` sudo apt remove fwupd ` (completely remove fwupd). If anyone has a better solution, please raise your hand.
33 |
34 | ## Build
35 |
36 | ```sh
37 | cd ~/catkin_ws/src
38 | catkin build
39 | ```
40 |
41 | ## Usage
42 |
43 | This is for the low-level controller, i.e. not using ROS. This code allows to create an "arm" using any subset of servos (in the example, I randomly used 5, 4 and 3). In practice, this allows to create a first group of 5 servos (the kinematic part of the arm) and a second group with one servo (the gripper). The ROS example below handles the grouping and also includes URDF import for the kinematic part.
44 |
45 | ```python
46 | import math
47 | import lewansoul_xarm
48 | c = lewansoul_xarm.controller()
49 | HIDDevice:
50 | /dev/hidraw3 | 483:5750 | MyUSB_HID | LOBOT | 49770F673737
51 | release_number: 513
52 | usage_page: 0
53 | usage: 0
54 | interface_number: 0
55 | connected to xArm controller serial number: 49770F673737
56 | a = c.add_arm(5, 4, 3)
57 | a.disable() # turn off servos
58 | # now manually move the arm to zero position
59 | a.calibrate() # this is a one time thing, you can re-use the calibration later on
60 | a.home()
61 | a.is_homed()
62 | p = a.measured_jp()
63 | p[0] = p[0] + math.pi / 4.0 # start with a small motion
64 | a.move_jp(p)
65 | ```
66 |
67 |
68 | ## ROS notes
69 |
70 | ```sh
71 | sudo apt install ros-melodic-joint-state-publisher-gui
72 | ```
73 |
74 | To start the controller ROS node:
75 | ```sh
76 | rosrun lewansoul_xarm xArm-ros.py
77 | ```
78 |
79 | The ROS node is defined in `scripts/xArm-ros.py`. It treats the first 5 servos as a kinematic chain and the last servo is used for the gripper.
80 |
81 | To calibrate (this has to be done for `/xArm` and `/gripper`):
82 | ```sh
83 | # disable servos so user can move the arm manually
84 | rostopic pub -1 /xArm/disable std_msgs/Empty "{}"
85 | # now move the arm to mechanical zero position...
86 | # once done, initiate calibration. This will save the joint offsets
87 | rostopic pub -1 /xArm/calibrate std_msgs/Empty "{}"
88 | # and now re-enable the arm. It is now ready to use...
89 | rostopic pub -1 /xArm/enable std_msgs/Empty "{}"
90 | ```
91 |
92 | Calibration results are saved in files and can be reloaded automatically. The calibration files should be uniquely named using the controller's serial number and arm name.
93 |
94 | For RViz:
95 | ```sh
96 | roslaunch lewansoul_xarm display.launch
97 | ```
98 |
99 | # Links
100 |
101 | ## Hardware
102 | * Manufacturer: Hiwonder in HK - I’m not sure what is the proper brand for this product, maybe it is sold under two different names, either Lewansoul or Hiwonder: https://www.hiwonder.hk
103 | * Amazon page: https://smile.amazon.com/gp/product/B0793PFGCY
104 | * YouTube Channel with assembly instructions:
105 | * https://www.youtube.com/watch?v=0nGwqJCcUuE
106 | * https://www.youtube.com/watch?v=IjKZ74H3Q4o&t=125s
107 | * Vendor for single actuator, has more technical data: https://usa.banggood.com/LOBOT-LX-15D-Serial-Bus-Servo-Metal-Gear-Dual-Shaft-Large-Torque-For-RC-Robot-p-1372532.html
108 | * Serial communication protocol for LX 16 A (not the exact model used in our arm): https://github.com/RoboticsBrno/Robotic-arm/blob/master/Hardware/Servo-LX16-A/Lewansoul%20Bus%20Servo%20Communication%20Protocol.pdf
109 | * LX 16 A documentation (not the exact model used in our arm): https://images-na.ssl-images-amazon.com/images/I/91DLnW8nTnL.pdf
110 | * CAD for LX-15D, the servo used in the arm we have: https://grabcad.com/library/lewansoul-lx-15d-servo-1
111 |
112 | ## Software
113 | * Light Python wrapper for direct servo: https://github.com/Roger-random/SGVHAK_Rover/blob/master/SGVHAK_Rover/lewansoul_wrapper.py
114 | * Another Python wrapper with GUI for direct servo: https://github.com/maximkulkin/lewansoul-lx16a
115 | * Python over HID (code I started from):
116 | * https://gist.github.com/maximecb/7fd42439e8a28b9a74a4f7db68281071
117 | * https://github.com/ccourson/LewanSoul-xArm/issues/7?fbclid=IwAR0rXDFUnZRi0rPvOVP7_1yvddLER_ChwoFYI8bTqBlQylNzd1iQYaT3XX4#issuecomment-504570455
118 |
119 |
120 |
121 |
122 |
--------------------------------------------------------------------------------
/rviz/urdf.rviz:
--------------------------------------------------------------------------------
1 | Panels:
2 | - Class: rviz/Displays
3 | Help Height: 78
4 | Name: Displays
5 | Property Tree Widget:
6 | Expanded:
7 | - /Global Options1
8 | - /Status1
9 | - /Grid1
10 | - /RobotModel1
11 | - /TF1
12 | Splitter Ratio: 0.5
13 | Tree Height: 418
14 | - Class: rviz/Selection
15 | Name: Selection
16 | - Class: rviz/Tool Properties
17 | Expanded:
18 | - /2D Pose Estimate1
19 | - /2D Nav Goal1
20 | - /Publish Point1
21 | Name: Tool Properties
22 | Splitter Ratio: 0.5886790156364441
23 | - Class: rviz/Views
24 | Expanded:
25 | - /Current View1
26 | Name: Views
27 | Splitter Ratio: 0.5
28 | - Class: rviz/Time
29 | Experimental: false
30 | Name: Time
31 | SyncMode: 0
32 | SyncSource: ""
33 | Preferences:
34 | PromptSaveOnExit: true
35 | Toolbars:
36 | toolButtonStyle: 2
37 | Visualization Manager:
38 | Class: ""
39 | Displays:
40 | - Alpha: 0.5
41 | Cell Size: 1
42 | Class: rviz/Grid
43 | Color: 160; 160; 164
44 | Enabled: true
45 | Line Style:
46 | Line Width: 0.029999999329447746
47 | Value: Lines
48 | Name: Grid
49 | Normal Cell Count: 0
50 | Offset:
51 | X: 0
52 | Y: 0
53 | Z: 0
54 | Plane: XY
55 | Plane Cell Count: 10
56 | Reference Frame:
57 | Value: true
58 | - Alpha: 1
59 | Class: rviz/RobotModel
60 | Collision Enabled: false
61 | Enabled: true
62 | Links:
63 | All Links Enabled: true
64 | Expand Joint Details: false
65 | Expand Link Details: false
66 | Expand Tree: false
67 | Link Tree Style: Links in Alphabetic Order
68 | base_link:
69 | Alpha: 1
70 | Show Axes: false
71 | Show Trail: false
72 | link1:
73 | Alpha: 1
74 | Show Axes: false
75 | Show Trail: false
76 | Value: true
77 | link2:
78 | Alpha: 1
79 | Show Axes: false
80 | Show Trail: false
81 | Value: true
82 | link3:
83 | Alpha: 1
84 | Show Axes: false
85 | Show Trail: false
86 | Value: true
87 | link4:
88 | Alpha: 1
89 | Show Axes: false
90 | Show Trail: false
91 | link5:
92 | Alpha: 1
93 | Show Axes: false
94 | Show Trail: false
95 | world:
96 | Alpha: 1
97 | Show Axes: false
98 | Show Trail: false
99 | Name: RobotModel
100 | Robot Description: robot_description
101 | TF Prefix: ""
102 | Update Interval: 0
103 | Value: true
104 | Visual Enabled: true
105 | - Class: rviz/TF
106 | Enabled: true
107 | Frame Timeout: 15
108 | Frames:
109 | All Enabled: true
110 | base_link:
111 | Value: true
112 | link1:
113 | Value: true
114 | link2:
115 | Value: true
116 | link3:
117 | Value: true
118 | link4:
119 | Value: true
120 | link5:
121 | Value: true
122 | world:
123 | Value: true
124 | Marker Scale: 0.5
125 | Name: TF
126 | Show Arrows: false
127 | Show Axes: false
128 | Show Names: false
129 | Tree:
130 | world:
131 | base_link:
132 | link1:
133 | link2:
134 | link3:
135 | link4:
136 | link5:
137 | {}
138 | Update Interval: 0
139 | Value: true
140 | Enabled: true
141 | Global Options:
142 | Background Color: 48; 48; 48
143 | Default Light: true
144 | Fixed Frame: base_link
145 | Frame Rate: 30
146 | Name: root
147 | Tools:
148 | - Class: rviz/Interact
149 | Hide Inactive Objects: true
150 | - Class: rviz/MoveCamera
151 | - Class: rviz/Select
152 | - Class: rviz/FocusCamera
153 | - Class: rviz/Measure
154 | - Class: rviz/SetInitialPose
155 | Theta std deviation: 0.2617993950843811
156 | Topic: /initialpose
157 | X std deviation: 0.5
158 | Y std deviation: 0.5
159 | - Class: rviz/SetGoal
160 | Topic: /move_base_simple/goal
161 | - Class: rviz/PublishPoint
162 | Single click: true
163 | Topic: /clicked_point
164 | Value: true
165 | Views:
166 | Current:
167 | Class: rviz/Orbit
168 | Distance: 0.8512046337127686
169 | Enable Stereo Rendering:
170 | Stereo Eye Separation: 0.05999999865889549
171 | Stereo Focal Distance: 1
172 | Swap Stereo Eyes: false
173 | Value: false
174 | Focal Point:
175 | X: 0.054874010384082794
176 | Y: -0.037138987332582474
177 | Z: 0.08454325050115585
178 | Focal Shape Fixed Size: false
179 | Focal Shape Size: 0.05000000074505806
180 | Invert Z Axis: false
181 | Name: Current View
182 | Near Clip Distance: 0.009999999776482582
183 | Pitch: 0.6003977060317993
184 | Target Frame:
185 | Value: Orbit (rviz)
186 | Yaw: 2.3303987979888916
187 | Saved: ~
188 | Window Geometry:
189 | Displays:
190 | collapsed: false
191 | Height: 882
192 | Hide Left Dock: false
193 | Hide Right Dock: false
194 | QMainWindow State: 000000ff00000000fd000000040000000000000156000002cefc0200000008fb0000001200530065006c0065006300740069006f006e010000003d0000009b0000005c00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c00610079007301000000de0000022d000000c900fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261000000010000010f000002cefc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073010000003d000002ce000000a400fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000005df00000044fc0100000002fb0000000800540069006d00650100000000000005df000002eb00fffffffb0000000800540069006d006501000000000000045000000000000000000000036e000002ce00000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730100000000ffffffff0000000000000000
195 | Selection:
196 | collapsed: false
197 | Time:
198 | collapsed: false
199 | Tool Properties:
200 | collapsed: false
201 | Views:
202 | collapsed: false
203 | Width: 1503
204 | X: 347
205 | Y: 22
206 |
--------------------------------------------------------------------------------
/urdf/xArm.urdf:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
--------------------------------------------------------------------------------
/src/lewansoul_xarm/arm.py:
--------------------------------------------------------------------------------
1 | # Author(s): Anton Deguet
2 | # Created on: 2020-04
3 |
4 | # (C) Copyright 2020 Johns Hopkins University (JHU), All Rights Reserved.
5 |
6 | # --- begin cisst license - do not edit ---
7 |
8 | # This software is provided "as is" under an open source license, with
9 | # no warranty. The complete license can be found in license.txt and
10 | # http://www.cisst.org/cisst/license.txt.
11 |
12 | # --- end cisst license ---
13 |
14 | """
15 |
16 | """
17 |
18 | import numpy
19 | import json
20 | import urdf_parser_py.urdf
21 | import kdl_parser_py.urdf
22 | import PyKDL
23 |
24 | class arm(object):
25 | """Arm class to define a logical arm on top of the servo controller.
26 | This allows to use a subset of servos to define different kinematic
27 | chains.
28 |
29 | The most practical use is to separate the servos used for the arm
30 | kinematic from the gripper itself. The xArm is actually not a 6
31 | DOFs robot, it has 5 servos ([6, 5, 4, 3, 2]) for the arm and the
32 | last one ([1]). As such it is underactuated.
33 |
34 | The typical use is:
35 | import lewansoul_xarm
36 | c = lewansoul_xarm.controller()
37 | arm = c.add_arm(6, 5, 4, 3, 2)
38 | gripper = c.add_arm(1)
39 | arm.home()
40 | position = arm.measured_jp()
41 | position[0] = position[0] + math.pi / 4.0
42 | arm.move_jp(position)
43 | """
44 |
45 | def __init__(self, controller, name, config_file, urdf_file = ''):
46 | """Initialize the robot using a controller and a list of servo ids.
47 | The constructor also creates a unique id for the arm using the
48 | controller's serial number and the list of servo ids used for
49 | the arm.
50 | """
51 | self._controller = controller
52 | self._name = name
53 | self._config_file = config_file
54 | self._configuration = {}
55 | self._servo_ids = []
56 | self._is_homed = False
57 | self._has_kin = False
58 | self.configure(config_file)
59 | if not urdf_file == '':
60 | self._has_kin = True
61 | self.configure_urdf(urdf_file)
62 |
63 |
64 | def __del__(self):
65 | self.disable()
66 |
67 |
68 | def is_homed(self):
69 | return self._is_homed
70 |
71 |
72 | def calibrate(self, reference_position_si = 0.0):
73 | """Calibrate the servos position offsets. The xArm servos are
74 | absolute, i.e. you can turn off/on the controller and the
75 | origin will not be reset. The issue is that "zero" can be
76 | anywhere depending on how you assembled he arm. The calibrate
77 | method computes and offset between the reference positions
78 | ([0..0] by default) and the current position.
79 |
80 | The calibration procedure is:
81 | - disable the arm so the servos are free to move (arm.disable())
82 | - position the arm in "zero" position
83 | - call arm.calibrate()
84 |
85 | The position offsets are used after the calibration and saved
86 | in a JSON configuration file using the arm unique id (see
87 | __init__ documentation). Future calls of home() or
88 | configure() will try to locate the JSON configuration file and
89 | load it. This allows to perform the calibration to be a one
90 | time task.
91 | """
92 | # get raw positions from controller
93 | position_si = numpy.multiply(self._directions, self._controller.measured_jp(self._servo_ids))
94 | self._position_offsets = reference_position_si - position_si
95 | # save calibrations results for later use, convert array to list
96 | self._configuration['offsets'] = self._position_offsets.tolist()
97 | with open(self._config_file, 'w') as f:
98 | f.write(json.dumps(self._configuration, sort_keys = True,
99 | indent = 4, separators = (', ', ': ')))
100 |
101 | # no need to home now
102 | self._is_homed = True
103 |
104 |
105 | def configure(self, config_file):
106 | """Configure the arm using a JSON configuration file. The
107 | configuration file must contain the board ids, controller
108 | serial number and the position offsets vector (see
109 | calibrate()).
110 | """
111 | try:
112 | with open(config_file, 'r') as f:
113 | self._configuration = json.load(f)
114 | except ValueError as e:
115 | print(e)
116 | raise
117 | except:
118 | raise
119 |
120 | # serial number [required]
121 | if not 'controller-serial-number' in self._configuration.keys():
122 | raise Exception('controller-serial-number is not defined in ' + config_file)
123 | assert self._configuration['controller-serial-number'] == self._controller._serial_number
124 |
125 | # servo ids [required]
126 | if not 'servo-ids' in self._configuration.keys():
127 | raise Exception('servo-ids are not defined in ' + config_file)
128 | self._servo_ids = self._configuration['servo-ids']
129 |
130 | # offsets [optional]
131 | if not 'offsets' in self._configuration.keys():
132 | self._position_offsets = numpy.zeros(len(self._servo_ids))
133 | else:
134 | if len(self._configuration['offsets']) != len(self._servo_ids):
135 | raise Exception('incorrect number of offsets found in ' + config_file)
136 | else:
137 | self._position_offsets = numpy.array(self._configuration['offsets'])
138 |
139 | # directions [optional]
140 | if not 'directions' in self._configuration.keys():
141 | self._directions = numpy.ones(len(self._servo_ids))
142 | else:
143 | if len(self._configuration['directions']) != len(self._servo_ids):
144 | raise Exception('incorrect number of directions found in ' + config_file)
145 | else:
146 | self._directions = numpy.array(self._configuration['directions'])
147 |
148 | self._measured_jp = numpy.zeros(len(self._servo_ids))
149 | self._goal_jp = numpy.zeros(len(self._servo_ids))
150 | self._measured_cp = PyKDL.Frame()
151 |
152 |
153 | def configure_urdf(self, urdf_file):
154 | self._kdl_urdf = urdf_parser_py.urdf.URDF.from_xml_file(urdf_file)
155 | status, self._kdl_tree = kdl_parser_py.urdf.treeFromUrdfModel(self._kdl_urdf)
156 | assert status
157 | self._kdl_chain = self._kdl_tree.getChain('base_link', 'link5')
158 | self._kdl_fk = PyKDL.ChainFkSolverPos_recursive(self._kdl_chain)
159 | self._kdl_joints = PyKDL.JntArray(5)
160 |
161 |
162 |
163 | def enable(self):
164 | """Enable the servo controllers. This method first read the current
165 | positions and then asks the servos to "go to" the current
166 | position.
167 | """
168 | # afaik there's no command to just enable the arm
169 | position = self.measured_jp()
170 | self.move_jp(position)
171 |
172 |
173 | def disable(self):
174 | """Disable the servo controllers
175 | """
176 | self._controller.disable(self._servo_ids)
177 |
178 |
179 | def home(self):
180 | if not self._is_homed:
181 | self.get_data()
182 | self.move_jp(self._measured_jp)
183 | self._is_homed = True
184 |
185 |
186 | def get_data(self):
187 | self._measured_jp = numpy.multiply(self._directions, self._controller.measured_jp(self._servo_ids)) + self._position_offsets
188 | if self._has_kin:
189 | for i in range(5):
190 | self._kdl_joints[i] = self._measured_jp[i]
191 | status = self._kdl_fk.JntToCart(self._kdl_joints, self._measured_cp, 5)
192 |
193 |
194 | def measured_jp(self):
195 | return self._measured_jp
196 |
197 |
198 | def measured_cp(self):
199 | return self._measured_cp
200 |
201 |
202 | def goal_jp(self):
203 | """Return the last joint position goal. The goal is based on the
204 | last move_jp call.
205 | """
206 | return self._goal_jp
207 |
208 |
209 | def move_jp(self, goals_si, time_s = 0.0):
210 | self._goal_jp = goals_si[:]
211 | self._controller.move_jp(self._servo_ids,
212 | numpy.multiply(self._directions, (goals_si - self._position_offsets)),
213 | time_s)
214 |
--------------------------------------------------------------------------------