A hardware abstraction layer for modules and chips that are connected to I2C, UART and GPIO interfaces. This reduces friction by eliminating the need to write low-level code, firmware or ROS nodes and replaces it with a config file similar to the way docker-compose works.
In a world where ICs, modules and electronics are going trough long waiting times this helps makers prototype much faster, and allows robotics fleet owners the flexiblity to replace the hardware quickly and deploy fleets with mixed hardware configurations.
Here's how this all works:
- You define your hardware in a configuration file and upload to a git repository
- ros-io downloads your config file, parses its contents and installs the proper packages and 3rd party dependencies
- Package code gets imported into the ros-io code, and worker threads are deployed:
- Workers wrap the part object, create ROS messages and map the read/update functions to rospy Subscriber and Publisher objects
ros-io:
privileged: true
image: cristidragomir97/ros-io
environment:
- ROS_HOSTNAME=ros-io
- ROS_MASTER_URI=http://ros-core:11311
- CONFIG_REPO=https://github.com/cristidragomir97/ep1-rc-car
volumes:
- ros-bin:/opt/ros/noetic
devices:
- "/dev:/dev"
Of course, your configuration might vary, but here are a few pointers:
privileged: true
is required for access to hardwareROS_HOSTNAME
has to match the service name more info on ROS, docker and networking hereCONFIG_REPO
is where ros-io can find your configuration file. It expects to find a file calledconfig.json
at the root of your repository.- ros-io doesn't contain any ROS binaries, these are loaded from the ros-core service using a volume share:
volumes:
- ros-bin:/opt/ros/noetic
The config.json
file is conceptually very similar to docker-compose.yaml
. It defines what parts you are using, how to access them, and how to expose them to ROS. The config file has the following structure:
{
"name": "",
"desc": "",
"downloads": {},
"parts": {},
}
By default ros-io doesn't contain any packages, however you can specify multiple sources for those in the Downloads
section of the config file.
"downloads":{
"repos":[
["https://github.com/cristidragomir97/motorhead", "./library/motorhead"],
["https://github.com/cristidragomir97/robot-block-lib", "./library/core-lib"]
],
},
ros-io supports two types of parts:
- Simple parts expose one ROS topic / part
- Multi-channel parts like ADCs or PWM drivers, these expose a separate ROS topic for each one of the channels.
The following fields are mandatory for any type of part:
package
is the name of the package.folder
this is where on the filesystem your package is residing. That should usually be the path you have defined in the downloads section plus the package name.address
tells ros-io how to physically talk to your device.- Everything in
args
will be unpacked and directly passed to the constructor of your part as arguments. This is how you can configure the package itself.
A good example of a simple part is the motorhead motor-driver. What defines a simple parts, is that they can only expose a single topic
and have a single role
"motor_driver": {
"role": "subscriber",
"topic": "/cmd_vel",
"folder": "/motorhead",
"package": "motorhead",
"address": "0x76",
"args":{
"radius": 0.0325,
"flip": "true",
"pins": {
"right_a": 5,
"right_b": 4,
"right_pwm": 3,
"left_a": 8,
"left_b": 7,
"left_pwm": 6,
"right_enc_a": 10,
"right_enc_b": 11,
"left_enc_a": 12,
"left_enc_b": 13
}
}
},
The configuration structure for multi-channel adds another two mandatory fields, called channel_no
and channels
:
channel_no
tells ros-io how many of the devices channels will be used andchannels
is where you define therole
andtopic
for each one of the channels. That's because each channel gets it's own instance of Subscriber or Publisher.
Oh, and depending on your part, ros-io supports mixed role parts. One channel could be an input while the other is output.
The ADS1115 ADC is a great example of a multi-channel part.
"ADC": {
"folder": "core-lib/ADS1115",
"package": "ADS1115",
"address": "0x48",
"channel_no": 4,
"channels":{
"front_floor_right": {
"pin": 0,
"role": "publisher",
"topic": "/floor/front_right",
"args":{}
},
"front_left_floor": {
"pin": 1,
"role": "publisher",
"topic": "/floor/front_left",
"args":{}
},
"right_floor": {
"pin": 2,
"role": "publisher",
"topic": "/floor/right",
"args":{}
},
"left_floor": {
"pin": 3,
"role": "publisher",
"topic": "/floor/left",
"args":{}
}
},
"args":{}
}
To understand the role of each of these, we first need to define a few terms:
- Package - the code and the configuration file
- Library - the library doesn't really exist anywhere, it's just the collection of packages that gets downloaded for your solution
- Part - a part is an instance of a package. ros-io supports two types of parts:
Here are a few of the packages I have written for parts I had lying around.
Name | Type. | Desc. |
---|---|---|
ADS1015 | Interface | 4-channel 12-bit I2C ADC |
VL53L1_Array | Range Sensor | Configurable array of ToF Sensors |
ICM20948 | Motion Sensor | 9-Axis MEMS IMU |
LSM9DS1 | Motion Sensor | 6-Axis MEMS IMU |
SparkfunTwist | Sensor | Sparkfun Dual Encoder Reader |
INA219 | Power Sensor | Voltage/Current/Power Sensor |
4245-PSOC | Motor Driver | Serial/I2C Motor Driver found in Sparkfun Auto pHat |
Creating a ros-io package for your part is pretty straightforward. Most breakout boards and modules from vendors like Seeed, Adafruit or Sparkfun come with libraries and examples for Python and Arduino.
Vendors take care of the low level communication between the part and your SBC, ros-io takes care of the ROS communication, a package is the glue between those.
Let's start with a concrete example, the ADS1x15 series of Analog-Digital-Converters.
First step is to investigate and analyse the part you are going to write a package for. A great starting point is the repository of a vendors' library for that part. Adafruit_CircuitPython_ADS1x15 in our case. Investigating their example code we can find out the steps needed to comunicate with our module.
-
Imports:
import time, board, busio import adafruit_ads1x15.ads1015 as ADS from adafruit_ads1x15.analog_in import AnalogIn
-
Initialization
# Create the I2C bus i2c = busio.I2C(board.SCL, board.SDA) # Create the ADC object using the I2C bus ads = ADS.ADS1015(i2c)
-
Create channel object:
chan = AnalogIn(ads, ADS.P0)
-
Get values from channel:
chan.value, chan.voltage
In the case of our ADC, values are integers between (0-4096), so our decision here is pretty simple. Check out std_msgs for a list of base message types.
For more specific hardware, you might need something more complex, such as sensor_msgs.msg.Imu
for, or geometry_msgs.msg.Twist
for motor controllers. These are usually found in common_msgs.
You will have to take care of encoding/decoding these messages inside your package code. For more information on how to do that check out this tutorial.
from std_msgs.msg import Int32
...
def create_msg(value):
msg = Int32()
msg.data = value
return msg
Package folder must contain a JSON configuration file that defines it's properties, dependencies, ROS message types and callback functions. All fields in this example are mandatory.
{
"name": "ADS1115",
"info": "4-channel 12-bit I2C ADC",
"dependencies": [{
"type": "pip3",
"package":"adafruit-circuitpython-ads1x15"
}],
"callback": ["read0","read1","read2","read3"],
"ros_message": ["std_msgs.msg", "Int32"]
}
Your package code can be any valid python code, however, some conventions must be respected:
-
On runtime, ros-io injects
rospy
into your scope, you can use everything you want from there to aid in writing your package. -
For the dynamic imports to work the package
ADS1015
, config fileADS1015.json
, python fileADS1015.py
and constructorADS1015(args)
must all share the same name. -
The interface between ros-io and your package are the object constructor and callback methods. These callback functions can be called however you want as long as you specify that in
package.json
. However, for simplicity i suggest usingupdate
for subscribers, andread
for publishers. -
devices with multiple channels must expose
read
andupdate
callbacks for each channel. eg:["read0","read1","read2","read3"]
-
Let us know about the packages you write, we'd be more than happy to add them on the list of supported parts.