Robotic Arm: Message Controller Software

Sebastian - Apr 10 '23 - - Dev Community

A robot arm manipulator consist of joints and servo motors. It’s attached to a fixed base, and upon receiving command, executes movement actions. Moving is a two-step process: First, the arm needs to receive a command, and second it needs to translate this command into coordinated actions that will move the joints accordingly. This article focuses on the first question: How to send commands effectively to the arm?

In the last article, I assembled a 4 degree-of-freedom robotic arm kit for the Raspberry Pi. The kit provides a Raspberry Pi Motor hat that exposes UART, I2C, analog inputs, and enough pins to control 3 stepper and 15 servo motors. The Kit offers a complete control suite in the official repository, but I wanted to create my own software to control the arm.

This article presents a step-by-step implementation of a client-server connection protocol based on Python and TCP sockets. In particular, multiple clients can connect to the server, send messages as a header and payload data packet, and leave the connection opened as long as necessary. The server will be responsible for receiving client connections and messages, parsing the bytes to Python objects, and process them as movement action.

This article originally appeared at my blog admantium.com.

Investigating the Arm Kit

The official repository contains the setup, example code and programs for remote controlling the arm. It provides two interfaces to the end user: A GUI application written in TK which runs on the Raspberry PI itself, or on any computer that can make a socket connection to the Pi. Or a webserver that starts on the Pi and to which any client with the right IP address and port can connect. Looking into the source code files, we can learn this:

  • The mechanism to transport movement messages is a TCP socket
  • The TCP socket is created on the raspberry Pi with a fixed Port
  • The TK GUI client, and the WebServer, accept user interactions, and send commands to the TCP socket server
  • The server accepts the message, determines the concrete message content, and instructs the arm to move
  • Messages are either simple command like X_minus, X_add, or compound positions of all axis that lead to a continuous movement

Design Considerations for Remote Controlling

With this knowledge, and the prior experience when designing the control software for my moving robot RADU, we can formulate the following requirements:

  • The arm needs to be access via a network interface
  • This interface can be written in Python and directly manipulate the arm
  • Movement commands should be simple text commands
  • Commands should be executed immediately
  • When integrating ROS, a message gateway class can be added to transform ROS messages to the custom format

So, messages need to be send to the Pi. Effectively, that’s serial data over the network, providing code decoupling, giving the means to implement different transport mechanisms. I liked the simplicity of the TCP sockets used in the official example. And so I choose to replicate the same mechanism.

Implementing the Server

To open a TCP socket, Pythons' built-in socket library is used. This library is versatile and allows several types of sockets to be created. The particular socket type of our choice is AF_INET, which means TCP with IPv4, and the subtype SOCK_STREAM, a bidirectional, continuous data stream. We also define defaults such as the IP and port address.

The first iteration of the server code will open a socket object, bind itself to the specified IP and Port, and then listen to any incoming traffic. In a continuous loop, the sever waits for any received connection. It a client connects, it will print a message to the terminal, then decode and print the message that the client sends.

from socket import socket, AF_INET, SOCK_STREAM

SOCKET_HOST = 'localhost'
SOCKET_PORT = 11314
BUFFER_SIZE = 2048

with socket(AF_INET, SOCK_STREAM) as server:
  server.bind((SOCKET_HOST, SOCKET_PORT))
  server.listen()

  print(f'{repr(server)}\n >> Server started, waiting for connections')

  while True:
    client, addr = server.accept()
    with client:
      print(f'Connected from {addr}')
      msg= client.recv(BUFFER_SIZE).decode()
      print(f'Message: << {msg} >>')
Enter fullscreen mode Exit fullscreen mode

This code works as a proof of concept. But connection handling is not very effective: It waits for exactly one client message, and then terminates the connection immediately again. Also, messages are read with a fixed buffer size. This wastes memory when the messages are way smaller, and it will be faulty when messages exceeding this limit arrive. Let’s improve this.

Server Iteration 2: Flexible Message Passing

Reading data from the client provides us with a set of very different decisions. First, how much data do we want to read? Second, how many data at a time? Assuming that messages are just strings, then we can read one character at a time, all characters until a certain stop signal (for example a newline symbol) or read all data with a fixed buffer size just as we used before.

A better option is to let the client decide and inform the server what is about to come. The protocol looks like this: First, send a message header that contains information about the message length. And then, send the message. I got this idea from this great socket tutorial.

In the following refined version, we first define the fixed length of the header message. Then, upon a successful connection, the first message is decoded and stored, and then converted to the integer value expected_length. And following this, the next message is read one character at a time until this expected length value is met.

HEADER_MSG_PADDING = 20
while True:
  client, addr = server.accept()

  with client:
    print(f'Connected from {addr}')

    header, msg = '', ''
    expected_length, current_length = 0,0

    header = client.recv(HEADER_MSG_PADDING).decode()
    expected_length = int(header)

    print(f'Header: <{header}>\nExpected length: {expected_length}')

    while not expected_length == current_length:
      char = client.recv(1).decode()
      msg += char
      current_length += 1

    print(f'Full Messages Received\n<< {msg} >>')
    print('Terminating connection..\n')
Enter fullscreen mode Exit fullscreen mode

Server Iteration 3: Multithreaded Client Connection Handling

The final iteration adds another two requirements. First, the server accepts and keeps tracks of multiple client connection. Second, the clients decide when they want to close the connection by sending a dedicated message. And third, the server handles repeatedly receiving messages (herder fist, then payload) of the connected clients.

Let’s start with the first part. The list of connected clients is a dictionary. When a new client connects, but still considered connected, an exception is raised. Otherwise, a new thread is created. Then, the new connection and the thread are stored in the connection list.

while True:
    try:
      client, addr = server.accept()

      if client_list.get(addr):
        raise ValueError

      connection_thread = Thread(target=handle_socket_connection, args=(client,))

      client_list[addr] = {'client': client, 'handler': connection_thread}

      print(f'Connected from {addr}')
      print('LIST', client_list)

      connection_thread.start()

    except Exception as e:
      print(e)
Enter fullscreen mode Exit fullscreen mode

The handler method starts a continuous loop that checks for an incoming message. It parses the first message as the header, extracting the expected length. Then it reads all incoming data up to the expected message length. If the message contains just the string TERMINATE, the loop will close, which also stops the thread. Otherwise, the message content is handled, for example as shown here for a move command. At the end of this loop, the thread is put into a short sleep time. This is important, otherwise all those threads would continuously consume all available CPUs.

def handle_socket_connection(client, addr, client_list):
    header, msg = '', ''
    expected_length = 0

    is_alive = True

    while is_alive:
      header = client.recv(HEADER_MSG_PADDING).decode()
      if header:

        expected_length = int(header)
        print(f'Header: <{header}>\nExpected length: {expected_length}')

        msg = deserialize(client.recv(expected_length))

        if match('move_', msg):
          handle_move_command(msg)
        elif msg == 'TERMINATE':
          is_alive = False
        else:
          print(f'Message: \n<< {msg} >>')

      sleep(DELAY_MS)

    client_list.pop(addr)
Enter fullscreen mode Exit fullscreen mode

Complete Server Source Code

The complete server source code is this:

#!/usr/bin/python3

#
# ---------------------------------------
# Copyright (c) Sebastian Günther 2021  |
#                                       |
# devcon@admantium.com                  |
#                                       |
# Created: 2021-08-09  20:41:12         |
# ---------------------------------------
#

from time import sleep
from pickle import loads as deserialize
from threading import Thread
from socket import socket, AF_INET, SOCK_STREAM
from re import match

SOCKET_HOST = 'localhost'
SOCKET_PORT = 11314
HEADER_MSG_PADDING = 10
DELAY_MS = 0.100

def handle_socket_connection(client, addr, client_list):
    header, msg = '', ''
    expected_length = 0

    is_alive = True

    while is_alive:
      header = client.recv(HEADER_MSG_PADDING).decode()
      if header:

        expected_length = int(header)
        print(f'Header: <{header}>\nExpected length: {expected_length}')

        msg = deserialize(client.recv(expected_length))

        if match('move_', msg):
          handle_move_command(msg)
        elif msg == 'TERMINATE':
          is_alive = False
        else:
          print(f'Message: \n<< {msg} >>')

      sleep(DELAY_MS)

    client_list.pop(addr)


def handle_move_command(msg):
  move_cmd = msg[5:len(msg)]
  print('MOVE', move_cmd)

with socket(AF_INET, SOCK_STREAM) as server:
  server.bind((SOCKET_HOST, SOCKET_PORT))
  server.listen()

  client_list = {}

  print(f'{repr(server)}\n >> Server started, waiting for connections')

  while True:
    try:
      client, addr = server.accept()

      if client_list.get(addr):
        raise ValueError

      connection_thread = Thread(target=handle_socket_connection, args=(client,addr, client_list))

      client_list[addr] = {'client': client, 'handler': connection_thread}

      print(f'Connected from {addr}')
      print('LIST', client_list)

      connection_thread.start()

    except Exception as e:
      print(e)
Enter fullscreen mode Exit fullscreen mode

Implementing the Client

The server code is done, now let’s define the client code. Similar as before, the client code also starts simple: Connecting to the server, sending a message, and closing itself.

The first version of the client code looks very similar to the server: It uses the same library, sets the same constants, and will open a connection.

from socket import socket, AF_INET, SOCK_STREAM

SOCKET_HOST = 'localhost'
SOCKET_PORT = 11314
HEADER_MSG_PADDING = 10

def send_socket_msg(client, msg):
  msg = serialize(msg)
  msg_length = len(msg)

  client.send(f'{msg_length:<{HEADER_MSG_PADDING}}'.encode())
  client.send(msg)

with socket(AF_INET, SOCK_STREAM) as client:
  client.connect((SOCKET_HOST, SOCKET_PORT))

  for i in range(10):
    if (i % 2 == 0):
      send_socket_msg(client, 'move_x+')
    else:
      send_socket_msg(client, 'move_x-')

    sleep(1.342)
Enter fullscreen mode Exit fullscreen mode

Client Code: Serializing Objects with Pickle

The second iteration optimized the message type. It’s both an optimization of speed, and optimization of the flexibility what a message contains. In Python, any object can be serialized with the Pickle library. The serialization step will convert the object to bytes, which is the native datatype for socket data anyway. So, instead of explicitly encoding a string, we serialize any object and send it.

from pickle import dumps as serialize

def send_socket_msg(client, msg):
  msg = serialize(msg)
  msg_length = len(msg)

  client.send(f'{msg_length:<{HEADER_MSG_PADDING}}'.encode())
  client.send(msg)

with socket(AF_INET, SOCK_STREAM) as client:
  client.connect((SOCKET_HOST, SOCKET_PORT))
  send_socket_msg(client, {'MOVE_X': 10, 'MOVE_Y': 30})
Enter fullscreen mode Exit fullscreen mode

A possible - and for production use required - extension is to send information about the message type together with the header. This makes handling the message on the server side much easier.

Client Code: Interactive Message sending

The final iteration of the client code will not just open the connection, send a message, and close it. Instead, it will open a command prompt in the terminal, allowing the user to type in a any message that is send to the server. This can be done with the input command in the terminal,

with socket(AF_INET, SOCK_STREAM) as client:
  client.connect((SOCKET_HOST, SOCKET_PORT))

  while True:
    cmd = input(">> ")

    if cmd.lower() == "stop":
      break;
    else:
      send_socket_msg(client, cmd)

    sleep(0.01)
Enter fullscreen mode Exit fullscreen mode

Testing

On the Raspberry Pi, server.py is started.

> python3 server.py

<socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 11314)>
 >> Server started, waiting for connections
Enter fullscreen mode Exit fullscreen mode

Then, we connect to it via client.py from any computer in the same network.

> python3 client_interactive_input.py

cmd: center
Enter fullscreen mode Exit fullscreen mode

Once connected, just type any command in the specified language, and the arm will move accordingly.

LIST {('127.0.0.1', 36148): {'client': <socket.socket fd=4, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 11314), raddr=('127.0.0.1', 36148)>, 'handler': <Thread(Thread-1, initial)>}}
Header: <16        >
Expected length: 16
Message:
<< center >>
Enter fullscreen mode Exit fullscreen mode

Conclusion

With the Raspberry Pi Robot ARM Kit you can build a 4 degree of freedom robotic manipulator. This article presented a step-by-step tutorial to implement a message controller software. In essence, a socker server is started on the Raspberry Pi, and then a client connects. To test different approaches, the article showed several iterations of the server code. First, messages can be parsed as strings or evaluated as Python data structures. Second, the server can just open one connection, or handle multiple client connections simultaneously that are stored in threads. Third, the message protocol can be improved by separating message header and message body in which the header determines the amount of bytes that the payload provides. The resulting implementation is versatile and robust. The next article continues this series and shows how to implement simple and complex arm movements.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player