Electronics Software Development

Adding Digital I/O To Your CircuitPython Compatible Board: Part 2 – The 74HC165

More I/O Graphic
Written by John Woolsey

Last Updated: August 18, 2021
Originally Published: July 2, 2021

Skill Level: Intermediate

Table Of Contents


Sometimes, a project needs more digital I/O than what is available on your development board. This often happens when you connect to components that require a lot of pins for their interface, e.g. some displays, or your project uses many discrete sensors and/or actuators.

This three-part tutorial teaches you how to add more digital inputs and outputs to your CircuitPython compatible board. Each part focuses on a specific integrated circuit (IC) chip.

Part 1 – The 74HC595 described how to add digital outputs using the 74HC595 8-bit serial-in parallel-out (SIPO) shift register IC.

Part 2 – The 74HC165 (currently reading) describes how to add digital inputs using the 74HC165 8-bit parallel-in serial-out (PISO) shift register IC.

Part 3 – The MCP23017 will describe how to add both digital inputs and outputs using the MCP23017 16-Bit I2C I/O Expander With Serial Interface IC.

A basic understanding of electronics and programming is expected along with some familiarity with the CircuitPython ecosystem. If you are new to CircuitPython, or would just like to refresh your knowledge, please see our Getting Started With CircuitPython On Compatible Microcontroller Boards or Getting Started With CircuitPython On Raspberry Pi With Blinka tutorials before proceeding with this one.

This tutorial uses a solderless breadboard to build a circuit from a schematic diagram. The All About Circuit’s Understanding Schematics, SparkFun’s How to Read a Schematic, Core Electronics’ How to Use Breadboards, and Science Buddies’ How to Use a Breadboard guides are good resources for learning how to translate a schematic to a breadboard.

The resources created for this tutorial are available on GitHub for your reference.

What Is Needed

Background Information

The low-cost standard 7400 series 74HC165 IC is a good choice to incorporate into your design when you only need additional digital inputs for your project. It is an 8-bit parallel-in serial-out (PISO) shift register that provides the ability to read and latch in separate parallel digital inputs and serially shift the input data into your CircuitPython compatible board using the dedicated SPI serial bus.

A single 74HC165 chip provides 8 additional digital inputs with a single 8-bit data transfer. One nice feature about the ‘165s is that they can be daisy chained together to get even more inputs without utilizing any additional connections to your development board. For instance, incorporating four daisy chained ‘165s into your design will provide you an additional 32 (4 x 8) inputs with a 32-bit data transfer. Daisy chaining simply involves connecting the QH output from one ‘165 to the SER input of another ‘165. When more than 8 bits are shifted into one shift register, they continue to be propagated into the next shift register.

There are multiple ways to visualize and read your digital input data from the shift register within a CircuitPython program. Each has its pros and cons and can be heavily dependent on the nature of the additional inputs required. For instance, are the additional inputs highly disparate and need to be read separately, or are they more homogeneous and can be referred to as a single block? I will present three different approaches in this tutorial, all producing the same basic functionality, so that you can easily compare among them and choose the best approach for your own design. While all of these approaches eventually read and shift all the input values in a single read from the 74HC165 shift register, each approach provides a different way to read individual input changes. I will also include an additional example at the end that will only print the input values if a change in one of them is detected.

I am using Adafruit’s Feather M4 Express microcontroller board connected to a macOS based computer with the Mu Python editor for this tutorial. I also verified the CircuitPython program created in this tutorial works on a Raspberry Pi 3 Model B running the Raspberry Pi OS operating system using the Blinka library for CircuitPython support. If you are using a different CircuitPython compatible board, computer setup, or code editor, the vast majority of this tutorial should still apply, however, some minor changes may be necessary.

If you need assistance with your particular setup, post a question in the comments section below and I, or someone else, can try to help you.

Building The Circuit

Before connecting any circuitry to your CircuitPython compatible board, disconnect it from power and your computer. This avoids accidental damage during wiring.

Place the components and wire up the circuit on a breadboard according to the schematic diagram shown below.

Schematic Diagram Of A 74HC165 Digital Inputs Circuit Connected To A CircuitPython Compatible Board
Schematic Diagram Of A 74HC165 Digital Inputs Circuit Connected To A CircuitPython Compatible Board

The 74HC165 symbol used in the KiCad schematic utilizes a different pin naming convention than what is used in the Texas Instruments datasheet for this part. The table below shows the corresponding equivalent pins between the two naming conventions. The _n suffix used in the names denote active low or complementary signals, those with a bar above their pin names in the datasheet and schematic. I will be using the pin naming convention used by the TI datasheet throughout this tutorial.

KiCad Schematic 74HC165 PinsTI Datasheet 74HC165 Pins
CE_n (clock enable input)CLK INH (clock inhibit input)
CP (clock input)CLK (clock input)
D0-D7 (parallel data inputs)A-H (parallel inputs)
DS (serial data input)SER (serial input)
PL_n (parallel load input)SH/LD_n (shift/load input)
Q7 (serial output)QH (serial output)
Q7_n (complementary serial output)QH_n (complementary serial output)
74HC165 Pin Equivalence Between Schematic And Datasheet

An 8-position dip switch, with attached pull-down resistors, is connected to the shift register’s inputs (AH) constituting the 8 digital inputs being added to the system. Eight individual standard single-pole single-throw (SPST) switches may be used instead of the integrated 8-position dip switch if you prefer.

A 0.1 µF bypass capacitor was placed across the Vcc and ground pins of the shift register, as recommended in the 74HC165 datasheet, in order to reduce any power supply noise that may be present.

The SER (10) and CLK INH (15) pins are tied directly to ground since we will not be shifting any data into the shift register or inhibiting the clock signal.

The SPI_MISO, SPI_SCK, and D5 pins shown on the schematic should be connected to the associated pins of your development board, e.g. the MI, SCK, and 5 pins of a general CircuitPython compatible microcontroller board or the GPIO9 (SPI0 MISO), GPIO11 (SPI0 SCLK), and GPIO5 pins of a Raspberry Pi.

The circuit should look similar to the one shown below once completed.

Completed 74HC165 Digital Inputs Circuit Connected To A Feather M4 Express Board
Completed 74HC165 Digital Inputs Circuit Connected To A Feather M4 Express Board

Once the circuit is built, connect your general CircuitPython compatible microcontroller board to your computer with the USB cable. For a Raspberry Pi, connect the power and boot it up.

Installing The Library

Before installing any libraries, make sure you are running the latest stable release of CircuitPython on your compatible microcontroller board or the Blinka library on a Raspberry Pi.

$ pip3 install --upgrade Adafruit-Blinka

Adafruit does not provide a CircuitPython driver library to communicate with the 74HC165 like they do for the 74HC595, so I created my own. The WoolseyWorkshop_CircuitPython_74HC165 library is based on the Adafruit_CircuitPython_74HC595 library that I converted for use with the 74HC165 by changing the pin directions from outputs to inputs and updating the latching mechanism. My library was also accepted by Adafruit and is now included in the CircuitPython Community Bundle.

For a general CircuitPython compatible microcontroller board, you can either download and retrieve the wws_74hc165.mpy (compiled) file from the bundle or the wws_74hc165.py (source) file from my library’s repository. Either will work just fine. Install the library by copying either file (.mpy or .py) into the lib directory of the board’s CIRCUITPY drive.

On a Raspberry Pi, install the library with the following command.

$ pip3 install woolseyworkshop-circuitpython-74hc165

Reading Single Inputs Using Familiar Pin Reading Functionality

This first approach implements the standard CircuitPython pin reading mechanism by reading the value attribute of a single pin. It should be very familiar to most CircuitPython users and the easiest to understand. However, when reading multiple inputs, it will involve more shift operations than other approaches since only one input at a time can be read.

Open Mu or your favorite code editor and create a CircuitPython program with the code shown below.

import time
import board
import digitalio
import wws_74hc165

isr_latch_pin = digitalio.DigitalInOut(board.D5)


isr = wws_74hc165.ShiftRegister74HC165(board.SPI(), isr_latch_pin, SHIFT_REGISTERS_NUM)

def read_single_inputs():
    # Input pin definitions (pin references)
    input_a = isr.get_pin(0)
    input_b = isr.get_pin(1)
    input_c = isr.get_pin(2)
    input_d = isr.get_pin(3)
    input_e = isr.get_pin(4)
    input_f = isr.get_pin(5)
    input_g = isr.get_pin(6)
    input_h = isr.get_pin(7)

    # Read and print individual inputs
    print(f"Input A = {input_a.value}")
    print(f"Input B = {input_b.value}")
    print(f"Input C = {input_c.value}")
    print(f"Input D = {input_d.value}")
    print(f"Input E = {input_e.value}")
    print(f"Input F = {input_f.value}")
    print(f"Input G = {input_g.value}")
    print(f"Input H = {input_h.value}")

# Main
previous_time = time.monotonic()  # time in seconds
while True:
    current_time = time.monotonic()  # time in seconds
    if current_time - previous_time >= 1.0 / SAMPLE_RATE:
        previous_time = current_time

The 74HC165 shift register only needs three pins to communicate with a CircuitPython compatible board. The SPI MISO pin receives data that is shifted (clocked) out of the register with the SPI SCK pin. Together, along with the ignored SPI MOSI pin, these pins constitute the SPI serial bus. The isr_latch_pin, specified as D5 on line 6, is used to latch the shift register’s input values and enable shifting of that data to the CircuitPython board.

The 74HC165 shift register library instance is defined on line 11 as isr (short for input shift register) and requires references to the board’s SPI port and the latch pin that will be utilized for shifting operations. The last argument, SHIFT_REGISTERS_NUM defined on line 9, is optional and specifies the number of 74HC165 shift registers that are daisy chained together.

The sampling rate, defined on line 8 in hertz (cycles per second), governs how often we will check the inputs. I chose 5 seconds (1/0.2 = 5) in order to give you time to change the switches between readings.

Since we will be applying multiple approaches, I separated each approach into its own distinct function. Therefore, the read_single_inputs() function, this first approach, is the only function I am currently calling within the main section’s endless loop (while True:) at the end of the program. The rest of the loop checks and compares the elapsed time so that the read_single_inputs() function is only called at the specified sampling rate. The monotonic() method ensures that the reported time can not go backwards.

Within the read_single_inputs() function, we first define our named variables, input_ainput_h, to make our calls easier to understand. They use the library’s get_pin() method to retrieve references to the 74HC165’s input pins. The variables are defined within this example function, instead of the typical location at the top of the file, in order to avoid collisions with different definitions in other approaches. The rest of the function just uses the standard CircuitPython pin reading mechanism to read and print all of the individual input values, represented as either True or False, with each read initiating a new shift operation.

If there is something that needs further explanation, please let me know in the comment section and I will try to answer your question.

Now that our circuit is built and our software is written, let’s run and test our program.

If you are using the Mu editor with a general CircuitPython compatible microcontroller board, click the Serial icon within the menu bar at the top to open the serial console. It will appear at the bottom of the Mu editor’s window and will be used to view the program’s output. Save the program as code.py to the top level of the board’s CIRCUITPY drive and it will begin running automatically.

On a Raspberry Pi, save the program as input_shift_register.py and then run the program.

$ python3 input_shift_register.py

You should see the 74HC165 shift register’s input values being printed every 5 seconds. Flip a few of the dip switches and watch the inputs change.

On a Raspberry Pi, press CTRL-C to exit the program when you are done.

Reading All Inputs Using Binary Values

This next approach is the simplest of all the approaches. It produces the most concise code, but it does not indicate the meaning of each of the individual inputs. It is a good option to use when all inputs are of the same type and you want to refer to the entire collection as a single entity.

Add the following function to your program, just before the main section.

def read_inputs_with_binary_values():
    # Read and print all inputs, separated in bytes, from shift register in binary format
    print("Inputs: ", end="")
    for byte in isr.gpio:
        print(f"{byte:08b}", end=" ")  # print the current byte in binary format

Then comment out the call to the read_single_inputs() function in the endless loop within the main section and add a call to the new read_inputs_with_binary_values() function so that we’re calling the newly added function instead of the first one.

# read_single_inputs()

Line 4 of the function retrieves all of the library’s shift register input values (isr.gpio) in a single shift operation. The values are then separated and printed into bytes with each byte representing each daisy chained 74HC165 IC utilized.

Save and run your program to test the code. You should see all input values being printed in binary format every 5 seconds. Only one byte will be printed if only one shift register is used. Flip a few of the dip switches and watch the changes.

Reading All Inputs Using Defined Names

This last approach is somewhat of a compromise between the previous two approaches we covered. It only reads the shift register once, but allows you to refer to the individual inputs.

Add the following two functions to your program, just before the main section.

def bit_read(data, position):
    byte_pos = int(position // 8)  # byte position in number
    bit_pos = int(position % 8)    # bit position in byte
    return bool((data[byte_pos] & (1 << bit_pos)) >> bit_pos)

def read_inputs_with_defined_names():
    # Input pin definitions (bit positions)
    input_a = 0
    input_b = 1
    input_c = 2
    input_d = 3
    input_e = 4
    input_f = 5
    input_g = 6
    input_h = 7

    # Read all inputs from shift register
    inputs = isr.gpio

    # Read and print individual inputs
    print(f"Input A = {bit_read(inputs, input_a)}")
    print(f"Input B = {bit_read(inputs, input_b)}")
    print(f"Input C = {bit_read(inputs, input_c)}")
    print(f"Input D = {bit_read(inputs, input_d)}")
    print(f"Input E = {bit_read(inputs, input_e)}")
    print(f"Input F = {bit_read(inputs, input_f)}")
    print(f"Input G = {bit_read(inputs, input_g)}")
    print(f"Input H = {bit_read(inputs, input_h)}")

Then add the function call to your endless loop in the main section and comment out the other calls.

# read_single_inputs()
# read_inputs_with_binary_values()

The first function, bit_read(), returns the value (True = 1, False = 0) of the specified bit position within a number. The number, data, needs to be a bytearray data type to match the type used for the library’s isr.gpio method. The byte_pos variable represents the byte position within the byte array where the specified bit position is located. The bit_pos variable represents the bit position within that byte where the specified bit position is located. Once the specified bit value (1 or 0) is retrieved, it is converted to a boolean (True or False) and returned.

The second function, read_inputs_with_defined_names(), begins by defining the input variables, input_ainput_h, based on their pin positions. All inputs are then read followed by printing each input based on the input’s position using the bit_read() function.

This approach is very similar to the read_single_inputs() approach, but it only reads the shift register once and uses bit operations to read each individual input (bit).

Save and run your program. Watch the input values change as you flip a few of the dip switches.

Printing Changing Inputs Example

All of the example approaches we covered previously required an arbitrary sample rate that printed the shift register’s input values every 5 seconds regardless of whether any of the inputs had changed. This example only prints the values once a change in inputs is detected.

First, add the following global variable below the SHIFT_REGISTERS_NUM global variable definition and above the isr class instantiation towards the top of the program.

previous_inputs = bytearray(SHIFT_REGISTERS_NUM)

Next, add the following function to your program, just before the main section.

def read_and_print_inputs_on_change():
    global previous_inputs
    current_inputs = isr.gpio  # read all inputs from shift register
    if current_inputs != previous_inputs:  # print values only if they changed
        print("Inputs: ", end="")
        for byte in current_inputs:
            print(f"{byte:08b}", end=" ")  # print the current byte in binary format
        previous_inputs = current_inputs[:]  # save (copy) current inputs for next comparison

Next, comment out the entire main section used for all of the previous approaches.

Then, create a new endless loop, below the original one, for use with this example.

while True:

The previous_inputs variable retains the shift register’s input values from the last time they were checked. A byte array is created with the byte number equal to the number of daisy chained 74HC165 ICs utilized.

The read_and_print_inputs_on_change() function saves the previous and current input values retrieved from the shift register and compares them before printing all inputs in binary if any change is detected.

Since the only active statement now in the endless loop calls the read_and_print_inputs_on_change() function, this function constantly repeats and alerts us to any input changes.

Again, save and run your program. You should now only see inputs printed after you have flipped one or more of the switches.

Additional Resources


In this tutorial, we learned how to add digital inputs to your CircuitPython compatible board using the 74HC165 parallel-in serial-out (PISO) shift register. I presented multiple approaches for how to represent the inputs in your CircuitPython code so that you can compare and choose the right implementation in your own designs. They range from a simplistic approach that reads all inputs with a single read from the shift register to reading a single input at a time using CircuitPython’s familiar pin reading mechanism. I also included an example that only prints the input values when a change is detected.

The final source code and schematic used for this tutorial are available on GitHub. The GitHub version of the code is fully commented to include additional information, such as the program’s description, circuit connections, code clarifications, and other details. The comments are also Sphinx compatible in case you want to generate the code documentation.

The next part of this three-part tutorial, Part 3 – The MCP23017, will describe how to add both digital inputs and outputs using the MCP23017 16-bit I/O expander IC.

Thank you for joining me along this journey and I hope you enjoyed the experience. Please feel free to share your thoughts or questions in the comments section below.

About the author

John Woolsey

John is an electrical engineer who loves science, math, and technology and teaching it to others even more.
He knew he wanted to work with electronics from an early age, building his first robot when he was in 8th grade. His first computer was a Timex/Sinclair 2068 followed by the Tandy 1000 TL (aka really old stuff).
He put himself through college (The University of Texas at Austin) by working at Motorola where he worked for many years afterward in the Semiconductor Products Sector in Research and Development.
John started developing mobile app software in 2010 for himself and for other companies. He has also taught programming to kids for summer school and enjoyed years of judging kids science projects at the Austin Energy Regional Science Festival.
Electronics, software, and teaching all culminate in his new venture to learn, make, and teach others via the Woolsey Workshop website.


  • Thank you so much! All I could find were videos for Arduino but they use 4 pins instead of the 3 you used. That was confusing. But I managed to set it up on Pi Pico finally thanks to your tutorial.

    I have a question for you: How can I add a second 74HC165 to this setup? Again all I can find are tutorials for Arduino.

    Btw, I found out that you can use the keypad module instead of wws_74hc165. I don’t know which one would be more efficient though.

    • You are very welcome.

      Good to know about the keypad module; thanks for sharing.

      As for daisy chaining two ‘165s:
      Hardware wise, connect the QH output from one ‘165 to the SER input of another ‘165 and have the SPI CLK and SH/LD_n connected to both chips.
      For the software, change the SHIFT_REGISTERS_NUM constant from 1 to 2 and use the read_inputs_with_binary_values() routine to first see all of the inputs from both ‘165s. Once that is working, you can use isr.get_pin or bit_read with higher values than 7 to get the individual values from the second ‘165.

      I hope that works for you.

  • Thanks, John! It’s working I can even daisy chain three 165s. But there is a small problem; If I pull down/up all groups the same way, the second group of buttons works the opposite (reads when I release). To solve this I had to arrange it like this; the first group of buttons is pulled DOWN, the second group is pulled UP and if I add a third group they are pulled DOWN again. How can I pull down or up all buttons in the same direction?

    • Glad to hear it is working for you. As for the pull-up/down issue, that is very strange and I have no idea what may be going on. The only thing I can think of is that the second group of buttons are normally closed versus normally open.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.