Having good hardware is only good if you can interface with it, this past phase of the construction has been trying to work out the best way to interact with my software using the PCB I created previously.

Architecture

To better understand the approach it’s worth first talking about the physicality of how this all connects together so we know where to start when it comes to writing code.

The board is based around an MCP23017 GPIO expander connected via I2C, all the buttons and rotary encoder are on bank A of this chip. In my case this is then connected to a Pi Zero 2 W via the I2C breakout on the back of the HyperPixel display, this adds a little bit of added complexity as the I2C on the HyperPixel display as it turns out is I2C over GPIO, but more on that later.

Initial Testing

When I first got the PCBs I needed to quickly test they worked as planned, so for the initial testing I connected the boards directly to the standard Raspberry Pi I2C breakouts (GPIO 2&3) and made sure I could actually see the device, then once that worked making sure I could do the same via the HyperPixel I2C pins.

# List all available I2C busses
robco@pipboy:~ $ i2cdetect -l
i2c-2	i2c       	bcm2835 (i2c@7e805000)          	I2C adapter
i2c-11	i2c       	i2c@0                           	I2C adapter

# In this instance we're using the HyperPixel I2C bus at 11
# So check what devices are present on that bus.
robco@pipboy:~ $ i2cdetect -y 11
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: 20 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --

The default address of the MCP23017 on my board is 0x20 and we can see that there is a device connected on that address in the output above, so that’s a great start.

Now I can see the device I wanted to test the basic functionality, and for quick dirty hacks Python is always useful. I started with the MCP23017 library from https://pypi.org/project/mcp23017 and used it to turn on the handy ‘info’ LED I added to the board.

import smbus
from mcp23017 import *

# Connect to address 0x20 on bus 11 as discovered above
mcp = MCP23017(0x20, smbus.SMBus(11))

# Bank A pin 7 is the LED, set it to be an output
mcp.pin_mode(GPA7, OUTPUT)

# Turn the LED on
mcp.digital_write(GPA7, HIGH)

The LED illuminates, we’ve proven that everything is connected up correctly and we can control it. From here I did some further testing of the inputs etc. to make sure all buttons were giving me a reading. I didn’t add any pull-up resistors in the PCB but the MCP23017 has some weak internal pull up resistors, these need to be pulled up for the inputs to read a high enough voltage to register a 1, the Python library didn’t have any obvious method to do this but it was pretty simple to write to the appropriate chip register over I2C as follows:

# Enable pull-ups on bank A, on address 0x20
mcp.i2c.write_to(0x20, GPPUA, HIGH)

# Set all pins as inputs
mcp.set_all_input()

# Read 'STAT' button state
mcp.digital_read(GPA1)

# You can also set the IPOL register on bank A to inverse the polarity,
# the pins are usually 1 when open and 0 when pressed as they're grounded
# inverting the polarity for a pin will cause it to read 1 when pressed
mcp.set_bit_enabled(IPOLA, GPA1, True)

Approach A: I2C from the codebase

My first approach was just to take what I’d done in testing with Python and just implement the same thing in C++ and plug it right into my existing codebase. In theory this sounded like the most simple option but it ended up proving to be quite awkward. The main issue with this approach was just getting all the libraries in place, as the target platform is Linux on a Pi I’d be using linux/i2c-dev.h as the standard I2C interface, which meant I’d have to develop (or at least build) on Linux, the Pi doesn’t have enough grunt to run a whole desktop and Qt Creator etc. There was an amount of remote development I could do with pigpiod which was enough that I could operate the Pi GPIO pins over the network, but each way I tackled this approach proved to be awkward and suboptimal, so I went looking for other approaches.

Approach B: Linux Devicetree

When using off-the-shelf components like HATs for the Pi, their software usually comes as a devicetree overlay, which is the standard way to connect devices to the Linux kernel while keeping your apps separate. The devicetree itself is complex and requires effort to understand, especially when writing overlays and seeing how they modify the system. I encountered some inconsistencies where certain overlays allow live updates for real-time testing, but others only work at boot, which slows down development since you have to compile, reboot, and check the results each time.

Due to how the devicetree and my system are set up, I couldn’t simply overlay an I2C device onto the devicetree, as the I2C bus I needed was connected through the Hyperpixel 4. So, the first step was to reverse-engineer how the Hyperpixel handles I2C. Luckily, Pimoroni’s overlay is open source and included in the Raspberry Pi OS repos, which helped. The Hyperpixel routes two of the Pi GPIO pins (10 and 11) for I2C using the gpio-i2c kernel module. Since the driver doesn’t expose symbols for using the I2C bus, we’ll need to handle it ourselves.

The kernel has modules for gpio-keys and gpio-led etc. and also one for the MCP23017, I had assumed it would just be a case of layering all of the overlays in such a way that everything was wired up to work together but I didn’t have much luck, I had the LED working, but the keys and rotary encoder worked off interrupts rather than polling which added further complications. In the end no matter how many approaches I tried, it seemed fairly futile.

Approach C: /dev/uinput

After doing some further research into various ways that Linux device drivers can exist, one method popped up that I hadn’t previously considered and was somewhat simpler to implement; this method was to create a service/daemon that runs in the background and registers an input device with /dev/uinput. This service would run the whole time the system is running as a systemd service and on detecting the expected I2C device, register a new user input device via /dev/uinput, poll the device for the expected events and then emit input_event’s via the new virtual input device.

The code I ended up with is over here https://gitlab.com/robco-industries/hid-driver/-/blob/main/uinput/pipboy-control.c . A lot of it was based on code I stumbled across from a blog about creating a Raspberry Pi gamepad that also happened to use the same MCP23017 I was using so there wasn’t too much to adapt for my use case.