Using PySerial for Hardware Software Test

18 April 2019

Imagine we develop a software for a microcontroller or any hardware written in C/C++. Then, we flash the software on the hardware. So now, how can we test the behavior of the software on the real hardware? In this case, we assume that we have a serial communication to the hardware.

The Python library PySerial allows Python programmer to communicate with devices or micro-controller through the serial interface or COM port. The library is very well documented and we can see several examples on how to use it, for example:

    >>> import serial
    >>> ser = serial.Serial('/dev/ttyUSB0')  # open serial port
    >>> print(ser.name)         # check which port was really used
    >>> ser.write(b'hello')     # write a string
    >>> ser.close()             # close port

The example codes are mostly for Linux environments. Thus, in this article, I've built a small test framework for embedded micro-controllers and I would like to share my experience using PySerial on Windows 7/10 in the last couple of months.

Detecting Connected Devices on COM Ports

In Windows we can check the serial COM port by opening the device manager and expanding the Ports(COM & LPT), see the below picture.

Now our goal is to detect all of these COM port entries in our Python program. Therefore, we can use the method list_ports.comports() from PySerial.

    >>> import serial.tools.list_ports as list_ports
    >>> list_ports.comports()
    <generator object comports at 0x02765710>

The function returns a generator object. In order to acquire it as a list of entries, we can convert it to a list using this simple command list(list_ports.comports()). It returns such an output list:

    [('COM7', 'USB Serial Port (COM7)', 'FTDIBUS\\VID_0409+PID_6014+DEV_LABEL1\\0000'), ('COM4', 'XDS100 Class USB Serial Port (COM4)', 'FTDIBUS\\VID_0409+PID_A6D0+MCRM48\\0000'), ('COM3', 'USB Serial Port (COM3)', 'FTDIBUS\\VID_0409+PID_6014+DEV_LABEL2\\0000'), ('COM11', 'XDS100 Class USB Serial Port (COM11)', 'FTDIBUS\\VID_0409+PID_A6D0+DEV_LABEL3\\0000'), ('COM9', 'XDS100 Class USB Serial Port (COM9)', 'FTDIBUS\\VID_0409+PID_A6D0+MC42B\\0000'), ('COM5', 'USB Serial Port (COM5)', 'FTDIBUS\\VID_0403+PID_6014+MC42A\\0000')]

For one serial device, the method returns: 1. the port name: 'COM7' 2. the user-friendly port name: 'USB Serial Port (COM7)' 3. the hardware ID information which contains the manufacturer and device label: 'FTDIBUS\\VID_0409+PID_6014+DEV_LABEL1\\0000'

Knowing the hardware ID information and its COM port allows us to build an automated test program. We can write a program code to detect automatically the desired device under test.

Opening and Working with the PySerial

From the official documentation we can open a COM port using this command:

    >>> ser = serial.Serial('COM3', 38400, timeout=0,
    ...                     parity=serial.PARITY_EVEN, rtscts=1) 
    >>> s = ser.read(100)       # read up to one hundred bytes

Furthermore, we can create a class, called SerDevices, for abstracting several devices and having simple device management. The class can be then imported in several Python scripts or in the test cases. This approach reduces the code redundancy in our test scripts and the test framework.

    import serial
    import serial.tools.list_ports as list_ports

    class SerDevices():

        def __init__(self):
            self._portList = list(list_ports.comports())

        def getPortList(self):
            return self._portList

However, this approach reaches its limit if the usage of the multiple SerDevices class spreads around the test framework which uses multithreading. Another case for example if we run a long test process and in parallel, we run a short test script. The SerDevices class can be instantiated sequentially or simultaneously. However, there is only one process can reserve the interface. If another program reserves the port while executing another test script, these SerialException will be thrown since the port is blocked:

serial.serialutil.SerialException: could not open port 'COM3': WindowsError(5, 'Access is denied.')

Moreover, running several test scripts which utilize the SerDevices class will toggle the serial ports to open ports in the setup() function and close in the teardown().

In order to avoid those both effects, we need to modify the class to a singleton class.

Singleton for PySerial Class

The described issue with the reserved open port can be handled by a singleton class. A singleton class prevents the creation of a new object of the same class. I wrote several times a singleton class in C++, see my C++ singleton article. But wait, I've asked myself, how can I implement the singleton pattern in Python? It is very interesting, after coding several years with Python I have difficulties to implement it.

After some research on the internet, I found out some discussions in StackOverflow. However, I think the answer from Olivier is the most suitable for me. So, we can modify the class like this:

import serial
import serial.tools.list_ports as list_ports
import threading
import functools
# =============== Thread safe Singleton Metaclass =================

lock = threading.Lock()

def synchronized(lock):
    '''Synchronization decorator'''

    def syncWrapper(func):
        @functools.wraps(func)
        def innerWrapper(*args, **kw):
            with lock:
                return func(*args, **kw)
        return innerWrapper
    return syncWrapper


class Singleton(type):
    '''Singleton Metaclass'''
    _instance = None

    @synchronized(lock)
    def instance(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super(Singleton, cls).__call__(*args, **kwargs)

        return cls._instance
    
    @synchronized(lock)
    def __call__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super(Singleton, cls).__call__(*args, **kwargs)
        else:
            print("This singleton class %s has been created before" %
                cls.__name__)
        return cls._instance

# =================================================

class SerDevices(metaclass=Singleton):  # Python3

    #__metaclass__ = Singleton # for Python2.7
    def __init__(self):
        self._portList = list(list_ports.comports())

    def getPortList(self):
        return self._portList

We can create an object with SerDevices() and get the instance using SerDevices.instance() or SerDevices(). Using __call__, I can overload the constructor call. This approach has saved me a lot of time to refactor 40 Python test scripts. A simple example to test the class in the Python3 interpreter is like this:

>>> from SerDevices import SerDevices
>>> a = SerDevices()
>>> b = SerDevices()
This singleton class SerDevices has been created before
>>> c = SerDevices.instance()
>>> type(a)
<class 'SerDevices.SerDevices'>
>>> type(b)
<class 'SerDevices.SerDevices'>
>>> type(c)
<class 'SerDevices.SerDevices'>

I hope this tutorial helps you to write better Python code in your project.