-
Notifications
You must be signed in to change notification settings - Fork 7
Communications
Many types of hardware exist for the protocols defined above, and can often be used to interface two different protocols together (e.g. i2c to UART, UART to TCP/IP, etc.)
For wired connections, all of the protocols listed above have some sort of hardware to either transmit or receive data.
Most commonly, for robotics use, protocols for transmitting data to many devices (e.g. i2c and SPI) can be integrated with microcontrollers that are used to aggregate data from sensors, or that are used to act as device controllers.
Less synchronous protocols (i.e. UART), are often used to transmit all sorts of telemetry and other data from a device to some form of GCS. Such devices will often rely on a converter chip (e.g. a Serial-to-USB converter) to connect the two devices together over a wire, and read in the raw data through the serial port (which can easily be accessed from a program on a PC).
High-level protocol (i.e. TCP/IP) will often utilize dedicated hardware for the transmission of data, such as Ethernet devices (or in some cases, it can be bridged across low-level protocols, such as using IP over a serial connection). Such protocols are often designed with redundancy, or with other methods of ensure the data makes it to the destination correctly (quality over speed).
For all wireless connections, there are 3 main considerations that should always be remembered:
- Data is often collected in a buffer and then transmitted in a single packet (increases latency, but also increases reliability)
- Data transmission is not guaranteed (packets can sometimes be dropped)
- Transmission rate can vary (some protocols like WiFi introduce a random transmission delay)
While they do exist, wireless adapters for synchronous low-level protocols, such as i2c and SPI, are often not worth the hassle of integrating due to the complexities of managing IDs and timing. In general, most applications would utilize a microcontroller to interface with the devices or sensors and aggregate the data, then use a different protocol to transmit a data packet.
For protocols such as UART, that rely on byte-level transmission, many devices can be found for wireless transmission of data, all with varying degrees of reliability. Some more common examples are the RFD900 Serial Bridge, or the XBee Radios.
The most prevalent option for a wireless TCP/IP connection is WiFi. While WiFi is presently quite common, it can sometimes be difficult to ensure that an adapter will work as expected for a device. When looking at wireless chips for modern computers, it is often useful to look up the chip ID that is used in the device to ensure your computer has compatible drivers. The use of additional hardware, such as a router, will most likely be required if you would like to have a dynamic network, or would like to offload the additional network processing to another device (although this could add a slight amount of latency depending on the quality of the device and the network setup). There are other methods that could be used to avoid the additional hardware, such as setting up an AdHoc network, or on modern hardware, one device could configure a Hotspot to manage other devices.
For sending information between systems, we will often need some form of predefined structure to ensure the data gets to it's destination correctly and without any errors. For systems that use defined methods for data transport (such as i2c and TCP/IP), such methods are already defined. From there you can take a piece of data, tell it where it needs to go, and that's it. However, this will not be adequate for more complicated systems, such as those where we would like to send either a lot of different types of data, or those that have multiple components all sending data at once. Another consideration is that we may want to use one of the predefined transport protocols to create a layered effect (for example, we might want to have the same protocol for use over UART and TCP/IP for simplicity).
For most applications on modern systems, it is often found that there are many options to integrate a high-level data structure, containing the data to be sent, as a payload. As a base, the TCP/IP protocol can perform this for us, as it will offer the niceties of all the back-end management, and simply present the data packet at from the transmitter directly to the receiver.
A great example of a high-level transmission interface is the ROS message structure. In ROS, message interfaces are defined in a very simple way, using the .msg
format. A complicated ROS package will often define a collection of messages as a stand-alone package, such that it can be installed on it's own without dragging in all the dependencies of the required packages. A good example of this is the package geometry_msgs
; a collection of message definitions for sending and receiving geometric data (find out more here). As an example, let's look at the Vector3 message:
# This represents a vector in free space.
# It is only meant to represent a direction. Therefore, it does not
# make sense to apply a translation to it (e.g., when applying a
# generic rigid transformation to a Vector3, tf2 will only apply the
# rotation). If you want your data to be translatable too, use the
# geometry_msgs/Point message instead.
float64 x
float64 y
float64 z
The above text is taken directly from the message definition Vector3.msg
, and is used to describe a vector in 3D space (e.g. a position of a robot).
When use in a ROS Node, we are presented with a data structure that will provide us access to the internal variables just as if it were any other variable in C++:
include <geometry_msgs::Vector3.h>
double x_measurement;
double y_measurement;
double z_measurement;
...
//Inside callback function
x_measurement = msg_in.x;
y_measurement = msg_in.y;
z_measurement = msg_in.z;
...
geometry_msgs::Vector3 msg_out;
msg_out.x = x_measurement * 2;
msg_out.y = y_measurement * 2;
msg_out.z = z_measurement * 2;
Or in Python:
from geometry_msgs import Vector3
x_measurement = 0.0
y_measurement = 0.0
z_measurement = 0.0
...
# Inside callback function
x_measurement = msg_in.x;
y_measurement = msg_in.y;
z_measurement = msg_in.z;
...
msg_out = Vector3();
msg_out.x = x_measurement
msg_out.y = y_measurement
msg_out.z = z_measurement
From here, we can use a Publisher and Subscriber to send and receive the message data between any Nodes in a ROS network with a single line command (C++ and Python).
The below description of packet structure heavily references the method that is employed by the MAVLINK communication library.
The packet structure of a MAVLINK message is as follows:
[(Start), (header), (Payload), (Checksum)]
[(0xFE), (len, seq, sys, cmp, id), (Payload), (chk0, chk1)]
For MAVLINK, the start byte is always the hexadecimal value: 0xFE.
The header for a MAVLINK message contains 5 values:
- len: The length of the payload
- seq: A message sequence counter (should increment every message to ensure the checksum changes for identical messages)
- sys: The ID of the system sending the message
- cmp: The ID of the component sending the message
- id: The ID of the message being sent
MAVLINK defines both a common structure and a common set of messages as a baseline for the protocol. These message definitions can change from device to device, the base set of messages can be found here.
The payload is organized such that the largest variables are added into the payload first (i.e. UInt64, then UInt32, then UInt16, etc.). The variables are packed from Least Significant Byte (LSB) to Most Significant Byte (MSB).
For a MAVLINK message, the message is run through an algorithm to accumulate the checksum, then performs some additional logic to calculate the final checksum bytes. The 2 calculated checksum bytes are then attached to the end of the message.
An example of the checksum implementation can be found here, under the "crc_calculate" function.
To send information, we need to pack it into a basic format that we can easily send. One main thing to take note of is that most of our processing will need to be done at a byte-level. For example, it is likely that the easiest access to a communication device will be done using characters, or in our case bytes. Because of this, all of the information we will want to send will need to be organized into a byte array (an array of 8-bit unsigned integers).
Here we will define a packet as having 4 main sections:
[(Start), (Header), (Payload), (Checksum)]
Signals that the receiver should start listening. for a message. Chosen somewhat arbitrary.
Contains any other useful information about the message, usually: the sender ID, packet ID, payload length, and a sequence ID, etc.
For the most part, the start byte will not change, and all of the IDs in the header won't usually be more than a byte (and for simpler cases the header probably only needs to consist of a packet type, and everything else can be assumed).
The payload structure is usually the trickiest part of a structuring a packet. For more complicated examples, there may be multiple variables worth of data in a row.
The structure of the payload should be defined in line with the packet ID. For each variable in the payload, we need to break it down, such that we can place all of it's values into a sequence, and be able to pull the back out reliably.
In the following explanation, we will take a look at packing a payload on a 32-bit microcontroller, using integer point numbers (32 bit Integer, or int32_t) for the payload data, and packing them into a byte array to be passed for transmission over UART.
Take, for example, 3 integer numbers to represent a vector:
[x; y; z]
This would be represented in memory as:
[(x0, x1, x2, x3), (y0, y1, y2, y3), (z0, z1, z2, z3)]
So assuming we look at the packet structure defined before, the final message will look something like this:
[(Start), (Header), x0, x1, x2, x3, y0, y1, y2, y3, z0, z1, z2, z3, (Checksum)]
To do this, we need to do a little bit of fancy coding to manipulate the data just how we want it. Let's just look at packing the 'x' variable.
First we need to extract out each byte (x0 -> x3). To do this, we will use 2 processes: bit shifting and masking. The next examples will be written for use 'C'.
Presume that the binary data stored in the 'z' variable is the following (the spaces are just for our convenience):
//x: 01101001 01001110 10100101 10101001
int32_t z = 0b01101001010011101010010110101001;
Masking a variable is similar to turning off all the bits we don't care about, so all we are left with is relevant part. This is done with a bitwise AND operation. The process of masking is the following:
//0xFF: 0b00000000000000000000000011111111
//0xFF00: 0b00000000000000001111111100000000
//0xFF0000: 0b00000000111111110000000000000000
//0xFF000000: 0b11111111000000000000000000000000
//z: 0b01101001010011101010010110101001
uint32_t t0 = z & 0xFF;
uint32_t t1 = z & 0xFF00;
uint32_t t2 = z & 0xFF0000;
uint32_t t3 = z & 0xFF000000;
//t0: 0b00000000000000000000000010101001
//t1: 0b00000000000000001010010100000000
//t2: 0b00000000010011100000000000000000
//t3: 0b01101001000000000000000000000000
Note that we used unsigned integers for storing the data. While this doesn't really matter, it could potentially save the compiler messing with the data when we just want it in the most raw format we can.
Now that we have the data we care about isolated, we can use bit shifting to move the data across so it all fits within a single byte worth of space (so we can pack it into the byte array). Expanding on the previous:
//0xFF: 0b00000000000000000000000011111111
//0xFF00: 0b00000000000000001111111100000000
//0xFF0000: 0b00000000111111110000000000000000
//0xFF000000: 0b11111111000000000000000000000000
//z: 0b01101001010011101010010110101001
uint8_t t0 = z & 0xFF; //First bits aren't shifted
uint8_t t1 = (z & 0xFF00) >> 8;
uint8_t t2 = (z & 0xFF0000) >> 16;
uint8_t t3 = (z & 0xFF000000) >> 24;
//t0: 0b10101001
//t1: 0b10100101
//t2: 0b01001110
//t3: 0b01101001
Note that we are now using 8-bit Unsigned Integers to store our data. The last step is to pack it into the appropriate spot in our message buffer:
[(Start), (Header), x0, x1, x2, x3, y0, y1, y2, y3, 10101001, 10100101, 01001110, 01101001, (Checksum)]
After the message is sent, received, and verified to be correct, we can now unpack the data using the bit shifting method:
uint8_t buffer[] = {0b10101001, 0b10100101, 0b01001110, 0b01101001}; //Take this as a snippet of the actual packet
uint32_t tmp = ( buffer[1] << 24 ) || (buffer[1] << 16 ) || ( buffer[1] << 8 ) || ( buffer[0] );
int32_t z = (int32_t)tmp;
Note in the last step, we cast the final value to the type we want, which will convert the byte representation of 'tmp' from unsigned to signed. It is note always this simple (floating point tends to give a lot of hassles), so other techniques may be required.
Usually 1 or 2 bytes generated from the original packet that helps verify the received packet is the same as the original packet. Depending on the type of implementation, the checksum generator may be a very simple or a very complex algorithm.
The general idea is that you will pass the entire message (except for the checksum) through the algorithm, then append the the output as the final values of the message. Once the message is received by another system, it is ran back through the checksum, with the newly generated value compared to the values received in the packet. If they differ, then something is wrong with the received data. If they match, then the data should be correct and fine for use.
- Home
- Common Terminology
- UAV Setup Guides (2024)
- UAV Setup Guides (2022)
- UAV Setup Guides (2021)
- UAV Setup Guides (--2020)
- General Computing
- On-Board Computers
- ROS
- Sensors & Estimation
- Flight Controllers & Autopilots
- Control in ROS
- Navigation & Localization
- Communications
- Airframes
- Power Systems
- Propulsion
- Imagery & Cameras
- Image Processing
- Web Interfaces
- Simulation