Create a Mature Test CLI Tool with Python Click

Create a Mature Test CLI Tool with Python Click

Introduction

In firmware development, effective testing is crucial. Developers often need reliable ways to interact with their hardware. This tutorial will guide you through creating a Python-based command-line interface (CLI) tool that leverage the CANable USB-to-CAN adapter for testing devices on a CAN bus. By building this tool, you’ll streamline your testing process and provide a valuable resource for your entire team.

(Check out this link for more information about the CANable.)

What is Python Click?

We’ll be using Python Click, a powerful package for creating command-line interfaces. Click, short for “Command Line Interface Creation Kit,” offers several advantages:

  • Arbitrary nesting of commands
  • Automatic help page generation
  • Lazy loading of commands at runtime

These features make Click an excellent choice for building maintainable and user-friendly CLI tools. For more details, visit the Python Click official website.

Setting Up the Project

It’s easy enough to run your script locally, but we want to allow the easy sharing of these tools across our team. To make distribution and use of the tool easier, we can use Python setuptools.

Let’s start by creating a setup.py file in your project directory. First, import the setup() function from the setuptools package and pass a name and a version to the setup() function. The name will be the name of the tool you install and execute to run the helper script. Since we are using the Click package, add 'click == 8.1.7' to the install_requires entry. This will automatically install the v8.1.7 Click package when we install the tool.

# setup.py

from setuptools import setup

setup(
    name='canable',
    version='0.1.0',
    install_requires=[
        'click == 8.1.7',
    ],
)

Creating the CLI Structure

Our Click-based CLI tool will run in a similar way to any other CLI tool. For most cases you will specify an executable name, then a command, and then one or more flags.

$ <executable> <command> <--flag1> <--flag2>

Our executable will be named canable as specified in our setup.py. To interact with the CAN bus, we want to be able to send and receive CAN messages. Therefore we want a send command and a receive command. Click uses a Python construct called a Decorator to mark each function as a command.

In Click, each command is part of a group. To simplify this example, we create a single group called cli that ultimately works just as a pass-thru, but your tool could include layers of hierarchy as it evolves.

Let’s dive into the actual script. Create a file named main.py.

# main.py

import click

@click.group()
def cli():
    pass

@cli.command()
def send():
    """Sends a CAN message using a CANable."""
    click.echo('Sending CAN messages...')

@cli.command()
def receive():
    """Receives CAN messages using a CANable."""
    click.echo('Receiving CAN messages...')

if __name__ == '__main__':
    cli()

Preparing and Installing the Package

Now let’s prepare our script to be installed and run by our users. We can make use of the entry_points entry in setup.py to achieve this. The canable = main:cli maps the name of our executable canable to the main:cli function in our main.py script.

The following is the usage of the entry_points argument:

# setup.py

setup(
    # ...
    entry_points={
        'console_scripts': [
            'canable = main:cli',
        ]
    }
)

When we install the package, a canable executable will be created. The canable executable will invoke the cli() function in main.py, which will in turn execute the appropriate command in the cli() group.

Now that we have everything ready, it’s time to install the package with the following command:

pip install --editable <PATH/TO/PROJECT>

The —-editable or -e option allows us to continue editing the script even after installing it.

Running the Tool

After the package is installed, we can now run the tool. Although the tool doesn’t do much yet, Python Click has automatically created a help page for us!

Run the following command to show the help page:

$ canable --help
Usage: canable [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  receive  Receives CAN messages using a CANable.
  send     Sends a CAN message using a CANable.

In order to send or receive CAN messages, we need to first import the can module into main.py. To install the can module automatically, add the can module to setup.py.

# setup.py

setup(
    ...
    install_requires=[
        'click == 8.1.7',
        'python-can[serial] == 4.3.1',
    ],
    ...
)

# main.py

import click
import can

...

Implementing CAN Message Sending and Receiving

For those who haven’t worked with CAN bus devices, it is a differential pair bus used often on machinery, heavy equipment, automotive, industrial automation, and a variety of other cases. Devices on the bus typically need to know the bit rate to participate on the bus.

Here’s the code to initialize the CAN bus device and send and receive some dummy data.

# main.py

...

@cli.command()
def send():
    """Sends a CAN message using a CANable."""
    with can.interface.Bus(bustype='slcan', channel='/dev/cu.usbmodem11301', bitrate=500000) as bus:
        try:
            msg = can.Message(arbitration_id=0xc0ffee,
                  data=[0, 25, 0, 1, 3, 1, 4, 1],
                  is_extended_id=True)
            try:
                bus.send(msg)
                click.echo("Message {} sent successfully".format(message))
            except can.CanError:
                click.echo("Failed to send message {}.".format(message))
        except KeyboardInterrupt:
            bus.shutdown()
            pass

@cli.command()
def receive(bustype, channel, bitrate):
    """Receives CAN messages using a CANable."""
    click.echo('Receiving CAN messages...')
    with can.interface.Bus(bustype='slcan', channel='/dev/cu.usbmodem11301', bitrate=500000) as bus:
        try:
            while True:
                msg = bus.recv(1)
                if not msg:
                    continue
                click.echo(msg)
        except KeyboardInterrupt:
            bus.shutdown()
            pass
  
 ...

In this version, you may have noticed that we had to hard-code the bustype, channel, and bitrate. These values, especially the channel, are almost certainly different for your application. Let’s learn about options to find out how we can make this tool more flexible and avoid hard-coding.

Adding Options (Flags) to Your CLI Tool

Click also supports options – which are often referred to as flags. Let’s enhance our tool by adding some flags to our commands.

For both send and receive commands, a few useful options may be to set the protocol of the USB-to-CAN adapter, the USB-to-CAN adapter device port, and CAN bit rate. We can set common default values and show them when we display our help.

slcan is a common communication protocol for serial-based USB-to-CAN adapter devices and is well supported on many platforms.

For the device port, we can set a default value automatically by using serial.tools.list_ports. Our version simply grabs the first available CANable. This way most users will be able to use the tool without providing the port, but if an advanced user has multiple CANable devices, they can use the tool as well by specifying the ports.

500 kbps is a common rate for CAN bus devices, so we set the default of 500000.

# main.py

...
import serial.tools.list_ports

channel = ""
# The CANable will appear as a USB CDC device: /dev/ttyACMX or /dev/ttyUSBX on Linux or /dev/cu.usbmodemXXXX on on Mac.
ports = serial.tools.list_ports.grep("^/dev/cu.usbmodem")
for p in ports:
    if "CANable" in p.description:
        channel = p.device
        break

...
        
@cli.command()
@click.option('--bustype', default='slcan', show_default=True, help='The CAN bus firmware.')
@click.option('--channel', default=channel, show_default=True, help='The USB CDC device port.')
@click.option('--bitrate', default=500000, show_default=True, help='The CAN bit rate in Hz.')
def send(bustype, channel, bitrate):
    """Sends a CAN message using a CANable."""
    with can.interface.Bus(bustype=bustype, channel=channel, bitrate=bitrate) as bus:
        try:
            msg = can.Message(arbitration_id=0xc0ffee,
                  data=[0, 25, 0, 1, 3, 1, 4, 1],
                  is_extended_id=True)
            try:
                bus.send(msg)
                click.echo("Message {} sent successfully".format(message))
            except can.CanError:
                click.echo("Failed to send message {}.".format(message))
        except KeyboardInterrupt:
            bus.shutdown()
            pass

@cli.command()
@click.option('--bustype', default='slcan', show_default=True, help='The CAN bus firmware.')
@click.option('--channel', default=channel, show_default=True, help='The USB CDC device port.')
@click.option('--bitrate', default=500000, show_default=True, help='The CAN bit rate in Hz.')
def receive(bustype, channel, bitrate):
    """Receives CAN messages using a CANable."""
    click.echo('Receiving CAN messages...')
    with can.interface.Bus(bustype=bustype, channel=channel, bitrate=bitrate) as bus:
        try:
            while True:
                msg = bus.recv(1)
                if not msg:
                    continue
                click.echo(msg)
        except KeyboardInterrupt:
            bus.shutdown()
            pass
            
...

Adding an Option for Message Content

Currently, we are only able to send a fixed message. To pass the message as an argument to the command, we can make use of the callback argument with a helper function. This keeps the send() command code clean and allows the message to be passed directly to the can.Message() constructor without additional manipulation.

# main.py

...

def split_commas(ctx, param, value: str) -> list[int]:
    """Splits commas. Used as callback in click Options."""
    if not value:
        return []
    return [int(x) for x in value.split(",")]

...

@cli.command()
@click.option('--bustype', default='slcan', show_default=True, help='The CAN bus firmware.')
@click.option('--channel', default=channel, show_default=True, help='The USB CDC device port.')
@click.option('--bitrate', default=500000, show_default=True, help='The CAN bit rate in Hz.')
@click.option('--message', type=str, callback=split_commas, help='The message to be sent.')
def send(bustype, channel, bitrate, message):
    """Sends a CAN message using a CANable."""
    with can.interface.Bus(bustype=bustype, channel=channel, bitrate=bitrate) as bus:
        try:
            msg = can.Message(arbitration_id=0xc0ffee,
                  data=message,
                  is_extended_id=True)
            try:
                bus.send(msg)
                click.echo("Message {} sent successfully".format(message))
            except can.CanError:
                click.echo("Failed to send message {}.".format(message))
        except KeyboardInterrupt:
            bus.shutdown()
            pass

...

Conclusion

You’ve now created a functional CLI tool that can send and receive CAN messages. By creating this tool, you’ve significantly streamlined the process of interacting with CAN bus devices using a CANable device. This tool can be easily distributed to your team, ensuring consistent testing procedures and decreasing the testing burden across your team!

Sending a message:

$ canable send --message 1,2,3,4,5,6,7,8,9
Message [1, 2, 3, 4, 5, 6, 7, 8, 9] sent successfully

Receiving a message:

$ canable receive
Receiving CAN messages...

Dojo Five can help you create test scripts to interact with or validate your board. We bring modern tools, techniques, and best practices from the web and mobile development environments, paired with leading-edge innovations in firmware, to our customers to help them build successful products and successful clients. Our talented engineers are on hand, ready to help you with all aspects of your EmbedOps journey. Bring your interesting problems that need solving – we are always happy to help out. You can reach out at any time on LinkedIn or through email!

Sign up to get our content updates!

Unlock the full potential of your embedded projects with our expert insights! Dive into our comprehensive resources to stay ahead in firmware development. Subscribe now to get the latest best practices and guides delivered straight to your inbox.

Sign Up for Updates

Discover why Dojo Five EmbedOps is the embedded enterprise choice for build tool and test management.

Sign up to receive a free account to the EmbedOps platform and start building with confidence..

  • Connect a repo
  • Use Dev Containers with your Continuous Integration (CI) provider
  • Analyze memory usage
  • Integrate and visualize static analysis results
  • Perform Hardware-in-the-Loop (HIL) tests
  • Install the Command Line Interface for a developer-friendly experience

Subscribe to our Monthly Newsletter

Subscribe to our monthly newsletter for development insights delivered straight to your inbox.

Interested in learning more?

Best-in-class embedded firmware content, resources and best practices

Laptop with some code on screen

I want to write my first embedded program. Where do I start?

The boom in the Internet of Things (IoT) commercial devices and hobbyist platforms like the Raspberry Pi and Arduino have created a lot of options, offering inexpensive platforms with easy to use development tools for creating embedded projects. You have a lot of options to choose from. An embedded development platform is typically a microcontroller chip mounted on a circuit board designed to show off its features. There are typically two types out there: there are inexpensive versions, sometimes called

Read More »
Medical device monitoring vitals

IEC-62304 Medical Device Software – Software Life Cycle Processes Primer – Part 1

IEC-62304 Software Lifecycle requires a lot of self-reflection to scrutinize and document your development processes. There is an endless pursuit of perfection when it comes to heavily regulated industries. How can you guarantee something will have zero defects? That’s a pretty hefty task. The regulatory approach for the medical device industry is process control. The concept essentially states that if you document how every step must be completed, and provide checks to show every step has been completed properly, you

Read More »
Operating room filled with medical devices

IEC-62304 Medical Device Software – Software Life Cycle Processes Primer – Part II

Part I provides some background to IEC-62304. Part II provides a slightly more in-depth look at some of the specifics. The IEC 62304 Medical Device Software – Software Lifecycle Processes looks into your development processes for creating and maintaining your software. The standard is available for purchase here. So what activities does the standard look at? Here are some of the major topics. For any given topic, there will be a lot more specifics. This will look at a few

Read More »