Pico USB serial communications with CircuitPython

My Raspberry Pi Pico-based Motorola 6809 emulator uses the RP2040’s built-in serial-over-USB functionality to receive machine code sent from a host computer. The 6809 and its support code is written in C, but can you make use of the same process under Python? Yes, you can, and here’s an easy way to do it.

Computer to display via RP2040 serial comms

For this technique I’m using CircuitPython running on an Adafruit QT2040 Trinkey, which is an RP2040 chip on a very compact board featuring a male USB A connector, a Stemma QT/Qwiic connector, 8MB of Flash and not a lot else. But it’s a good way to hang I2C devices off your computer, using the programmable Trinkey as a go-between.

CircuitPython includes a Python module called usb_cdc. It manages the device’s connection with a host via USB. Primarily it’s used to provide host-side access to the Python interpreter and REPL, but you can also use it for data. With some devices, including the Trinkey, you can do both at the same time.

Check the usb_cdc documentation for a list of supported devices.

Adafruit’s RP2040-based Trinket

How do you use it? In your code.py file, for example, you would include:

import usb_cdc

and later:

serial = usb_cdc.data

This call yields a reference to usb_cdc’s data connection. Initially, this will be None, but we’ll sort out in a moment by enabling data usage. Assuming for the time being that serial now references a real object, your code can contain something along these lines to listen out for incoming serial data:

if serial.in_waiting > 0:
    byte = serial.read(1)
    if byte == b'\r':
        out_data = in_data
        out_data += b'  '
        in_data = bytearray()
        out_index = 0
        in_data += byte
        if len(in_data) == 129:
            in_data = in_data[128] + in_data[0:127]

Basically, if there’s data in the buffer, in_waiting yields how many bytes there are available to process. You then read() some bytes, just one in this case. If the byte is not a carriage return — the line terminator — the code adds it to a byte store, otherwise it preps a new input store to allow the main body of the code free access to the transmitted data.

The input buffer is circular: if we receive more than 128 bytes, the excess is written at the start and nudges the remaining bytes along.

As it stands, however, the serial data will not flow because it has not been enabled. This needs to take place outside of code.py in the separate boot.py file CircuitPython runs when it first starts up. Here’s its code:

import usb_cdc
usb_cdc.enable(console=True, data=True)

By default, console usage is enabled and data usage is disabled, so you can just activate the latter. I keep the console parameter in place so I can easily disable it once debugging’s done.

With these elements in place, when the Trinkey is re-connected to the host, it now appears as two serial devices. Here I‘ve used my shell script dlist to make it easier:

/Users/tsmith > dlist
1. /dev/cu.usbmodem1414301
2. /dev/cu.usbmodem1414303

On macOS, the ports are typically /dev/cu.usbmodem<some_value>. The some_value varies based on the USB bus and port used. On Linux, the ports are typically /dev/ttyACM0 and /dev/ttyACM1. For both platforms, the console port will usually be first.

On Windows, each serial device is visible as a separate COM port. The ports will often be assigned consecutively, console first, but this is not always the case.

Device 1 is the console; 2 the serial feed. To use the latter, you can run a program like minicom, target the second device, and type in text that will be picked up by the Trinkey:

minicom -o -D $(dlist 2)

A better use is in an application capable of gathering data and sending via the serial device to the Trinkey.

To try it out, I fitted the Trinkey with a couple of SparkFun Qwiic Alphanumeric Displays. These are four-digit, 14-segment LEDs based on the Freenove VK16K33 driver, which is a clone of the Holtek HT16K33 driver targeted by my HT16K33-Python library. The VK16K33 isn’t exactly the same as the HT16K33, so creating a driver was generally straightforward but required a rebuild of the driver’s character set.

Daisy-chain the segment LEDs with QT Stemma/Qwiic cables

The Trinkey-side code receives a text string by serial and scrolls in repeatedly across the two LEDs’ eight characters.

The host-side app is a Python app written to contact an IMAP email server, log in every five minutes and find out if there are any new messages. Based on the number of unread mails, it generates a text string and sends it to the Trinkey to be displayed on the LEDs.

Copy imap.py to another location on your computer along with a copy of secrets.py. Edit the latter to add your IMAP host URL and access credentials, making sure you edit the copy and the copy is nowhere where it’ll be inadvertently sync’d with a public GitHub repo.

# Connect to the IMAP inbox
imap_server = imaplib.IMAP4_SSL(host=IMAP_HOST, port=IMAP_PORT)
imap_server.login(secrets["mail"], secrets["password"])

Run python3 imap.py to start the mail checker. It uses the standard Python library imaplib to interact with the target server and retrieve message counts and messages. The latter are decoded using the standard Python library email. The code assembles a display string and, as described earlier, uses serial to transmit the string to the RP2040:

# Get the new messages' subject lines
for message_number in message_numbers:
    _, msg = imap_server.fetch(message_number, '(RFC822)')
    message = email.message_from_bytes(msg[0][1])
    out += f' {message["subject"]},'
out = out[:-1] + "\r"

# Write the assembled string out
serial_write(port, out)

You can find all the code in my RP2040 IMAP GitHub repo, which also contains hardware and software setup information.