Table of Contents
Introduction
This simple project converts a Pi Pico into a USB-to-I2C adapter! It is useful for sending commands from a PC to talk to low-level I2C hardware.
Sometimes, when trying out new sensors, sending commands from a PC rather than a microcontroller could be convenient. I had a requirement to program a device which accepts I2C communication, so it made sense to communicate to it from a PC.
I searched around for existing USB-to-I2C options and was disappointed; a lot of code didn’t work. A project that actually works is Bus Pirate. This project is slightly similar in that respect, but my project is very much cut down since it only supports I2C. I might extend it to SPI one day at a stretch, but it will never replace the more flexible Bus Pirate. My project is more geared toward programmable control (from Python), whereas the Bus Pirate is aimed at user command-prompt interaction.
This blog post is short because there’s not much to this project. It's a 30-minute project to replicate from start to finish!
In terms of features, this project supports attaching up to eight Pi Pico boards to a PC, and you can control them all. As well as I2C control, you can also read and write GPIO.
Building It
I soldered some wires to a Pi Pico so that it could attach to the target I2C device. Only three wires are needed (SDA, SCL, and GND); however, for a bit more convenience, I also soldered wires to VBUS and 3.3V so that I can power the target from them if desired. There are optional pull-up resistors, but it's advised to add them. I used two ten kohm resistors, which are fairly weak I2C pull-ups, but means that even if the target I2C has pull-up resistors already, it should still work with the 10k resistor additions.
The firmware file is called easy_i2c_adapter.uf2 (available from the easy_pico_adapter GitHub repo) and is programmed into the Pico by first holding down the BOOTSEL button on it, inserting the USB plug, and then releasing BOOTSEL. A drive letter appears on the PC, and the firmware file is dragged into it. The Pico is programmed within seconds.
When the firmware is running, you’ll know this because the green LED on the Pico will flicker dimly.
Using it from a Terminal (Console)
The main commands are listed here. After typing them, press return to execute the commands:
Command Syntax | Example(s) | Description |
addr:<val> |
addr:104 addr:0x5a |
Used to set the I2C address 0-128. The value can be decimal, or hexadecimal, e.g., 0x10 |
bytes:<val> | bytes:2 | Used to set the desired number of bytes for sending or receiving. The value must be decimal. The value is reset to zero afterward. |
recv | recv | This performs an I2C read and dumps the output to the terminal |
send <val1> <val2> ... <valN> |
send 01 02 03 04 send 01 02↵ 03 04 |
This command sends I2C data. The values must be space-separated, in hexadecimal format, and must be two characters per value. If you wish to send a lot of data, then you can press return and continue typing bytes on the next line (up to 256 bytes total today, but I hope to extend this to 4096 bytes at some point). The I2C send command will occur once the expected number of bytes (set with the bytes: command) has been typed and the user has pressed return after the last one |
send+hold <val1> <val2> ... <valN> |
send+hold 0a |
This command behaves like the send command, except that the I2C bus is held and not released afterwards. This allows for I2C ‘repeated starts’ with the next command. |
tryaddr:<val> |
tryaddr:104 tryaddr:0x0c |
This function initiates an I2C read operation at the specified address, but deliberately aborts after the address is acknowledged (if the I2C target device exists at that address). This is useful for probing the bus to see what address devices exist, without actually reading or writing any data bytes. |
iowrite:<port>,<val> |
iowrite:6,1 |
This function sets a GPIO port to output mode, and then sets the logic level according to the val parameter (0 or 1). |
ioread:<port> |
ioread:6 |
This function sets a GPIO port as input, and then reads and displays the logic value (0 or 1). |
The screenshot here shows example interaction, where the user sent one byte to I2C address 0x0b, and then the user performed a repeated start and read 16 bytes. Next, the user made a command error and then tried receiving one byte from I2C address 0x50, which didn’t exist.
Please note that it will be easy to break things by sending poorly formatted commands. There’s not a lot of syntax checking currently.
Using it from Python
This is actually easier than the terminal (console) command line. Download the easyadapter.py Python code from the GitHub repo. You’ll also need to install pyserial.
To use the code to send I2C data, you can type the following (either directly in a Python command prompt or in a Python file); this example sends the values 0x01, 0x02, 0x03, 0x04 to I2C address 0x50:
import easyadapter as ea
adapter = ea.EasyAdapter()
result = adapter.init(0)
data = [0x01, 0x02, 0x03, 0x04]
adapter.i2c_write(0x50, data[0], data[1:])
You’ll notice that the first byte is split from the rest. This may be useful because often the first byte is a sub-address or register value.
The repeated start is performed by appending hold=1
to the parameters in the write function.
The following example shows how to read 4 bytes from the I2C address 0x50:
buffer = adapter.i2c_read(0x50, 4)
If you wish to probe the I2C bus to see if an I2C device exists, use:
result = adapter.i2c_try_address(0x0b)
It will return True if the device exists at the provided address.
To set GPIO # 5 to logic level 1:
adapter.io_write(5,1)
To set GPIO 6 to input mode, and read the logic level:
val = adapter.io_read(6)
As you can see, the functionality is identical to what can be achieved with the command prompt but with friendlier Python functions. In the background, the Python adapter.init() function issues a command to switch the Pico into an ‘M2M’ mode, which is less verbose and easier to parse, but that functionality is hidden from the user.
For those interested in extending the code, the M2M protocol looks just like the normal ASCII protocol in the direction from the PC to the Pico, but in the Pico-to-PC direction, the Pico condenses responses to a single character. All ‘positive’ responses are condensed to a single period (.). The error response is the character X. If more input is required, the character ‘&’ is issued. The character ‘~’ indicates a wire-level protocol error, i.e., the lower-level I2C command failed.
Finally, if you wish to display the data in a user-friendly format, type:
from easy_interface import print_data
print_data(buffer)
The data will be displayed as hex bytes on the left and ASCII characters on the right.
Using Multiple Adapters
Short the BOARD_ID connections (visible in the earlier diagram) to ground, to set unique board identifier values per board. A total of 8 boards can be configured in this manner (identifiers 0 to 7). The BOARD_ID GPIO pins are read upon startup. They float high, and the resultant default board identifier value is zero. To select a board identifier value of 1, short GPIO2 to ground. Here is how the identifiers are used with Python:
import easyadapter as ea
firstAdapter = ea.EasyAdapter()
secondAdapter = ea.EasyAdapter()
result = firstAdapter.init(0) # this is board ID 0
result = secondAdapter.init(1) # this is board ID 1
Summary
This is a very basic project and very prototype grade, but it might be handy occasionally when you need to access an I2C device from a PC. Although the Pico-based USB-I2C adapter only supports a slightly user-friendly command-line mode, the device is better suited to access from a programming language such as Python.
This project was tested on Windows. Some slight tweaks may be necessary to the Python code if you are using Linux or Mac. If you try the code, it would be great if you could report whether it works or fails. EDIT: I have now tested on Linux, and confirmed it works.
Thanks for reading!