Maker.io main logo

Not A Typewriter

2025-08-19 | By Adafruit Industries

License: See Original Project 3D Printing Adafruit Feather

Courtesy of Adafruit

Guide by Ruiz Brothers and Liz Clark

Overview

 

 

build_1

Build a "Not A Typewriter" with I2C Solenoid Driver and Adafruit Feather RP2040 running CircuitPython code. This device is designed to give your modern computer keyboard the essence of a vintage typewriter.

The 3D printed case is designed to look like a classic typewriter and features a real service bell. It has two mini solenoids, one to emulate the loud clack of the typebars, and the other to triumphantly announce the ding of the carriage return.

3d_printing_ring-loop

Inspiration

This project was inspired by Tetora Tech Lab's Fake Typewriter project we saw posted on x.com. Their project is open source and available on GitHub.

project_2

Two Flavors of Typewriter

There are two methods that you can choose from to bring your modern typing back to the age of Mad Men.

Bring Your Own Keyboard

You can plug a USB keyboard into the USB host Feather and plug the Feather into your computer. The Feather hosts the keyboard and sends its keystrokes to your computer. And of course, the solenoids trigger depending on the keys that are pressed.

keyboard_3

Computer Accessory

But what if you're on a laptop, writing the next great American novel in the spirit of Jack Kerouac or pushing the boundaries of gonzo journalism like Hunter S. Thompson? You can run a CPython script on your computer that sends your keystrokes to the Feather over serial. The Feather runs CircuitPython code that listens for the incoming keystrokes and triggers the solenoids.

accessory_4

Parts from Adafruit

Parts from Amazon

  • 1 x Small Bell

  • 2 PCS 2.56 Inch Service Bell

Hardware

  • M2 size screw tap

  • 4x M2 x 6mm long screws

  • 6x M2.5 x 6mm long machine screws

Circuit Diagram

The diagram below provides a general visual reference for wiring of the components once you get to the Assembly page. This diagram was created using the software package Fritzing.

Adafruit Library for Fritzing

Adafruit uses the Adafruit Fritzing parts library to create circuit diagrams for projects. You can download the library or just grab individual parts. Get the library and parts from GitHub - Adafruit Fritzing Parts.

parts_5

Wired Connections

  • Solenoid A ground wire to 0 pin on Solenoid Driver

  • Solenoid A voltage wire to + pin on Solenoid Driver

  • Solenoid B ground wire to 1 pin on Solenoid Driver

  • Solenoid B voltage wire to + pin on Solenoid Driver

  • STEMMA QT on Solenoid Driver to Feather RP2040 USB Host STEMMA QT port

The Feather RP2040 USB Host is powered via USB.

CAD Files

3d_printing_cad

CAD Assembly

The main assembly is available in Fusion 360 and STEP file formats. This includes all of the 3D printed parts and electronic components used in the project. Use the main assembly to create any edits, updates, or modifications.

cad_6

3D Printed Parts

Individual 3MF files for 3D printing are oriented and ready to print on FDM machines using PLA filament. Original design source may be downloaded using the links below.

printed_7

Download Source (STEP, F360)

Download 3MF.zip

Design Source Files

The project assembly was designed in Fusion 360. Once opened in Fusion 360, It can be exported in different formats like STEP, STL and more.

Electronic components like Adafruit's boards, displays, connectors and more can be downloaded from the Adafruit CAD parts GitHub Repo.

files_8

Wiring

All Parts

Gather up the parts needed for this build.

  • Feather RP2040 USB Host

  • 8 Channel I2C Solenoid Driver

  • 2x 5V mini solenoids

  • 2x JST-PH 2-pin cables

  • STEMMA QT cable - 50mm long

  • 2x short (2") stranded hook-up wires

wiring_9

Wires for Feather

Use the red and black colored spool of wires to create two wires that are approximately 2 inches (50mm) long.

Solder the black wire to the ground pin on the Feather and the red wire to the USB pin.

wires4_10

wires4_11

Cables for Driver

Cut the wires from the JST-PH cables so they're approximately 2.5 inches long (64mm). Use wire strippers to remove a bit of insulation from the tips of each wire.

Insert the black wire from the first JST cable to pin 0 and red wire to + positive pin.

Insert the black wire from the second JST cable to pin 1 and red wire on + positive pin.

Use a small flat head screwdriver to secure the wires to the screw-block terminals.

cables_12

cables_13

cables_14

Connect Feather to Solenoid Driver

Insert the red wire from the USB pin on the Feather to the positive terminal on the solenoid driver's power screw block.

Insert the black wire from the GND pin on the Feather to the negative terminal on the solenoid driver's ground screw block.

Use a small flat head screwdriver to secure the wires to the screw-block terminals.

connect_15

connect_16

Connect STEMMA QT

Plug in the STEMMA QT cable to the STEMMA QT connector on the solenoid driver.

Plug in the other end of the STEMMA QT cable to the STEMMA QT connector on the Feather RP2040 USB Host.

stemma_17

Assembly

Bell Disassembly

Start by pulling out the actuator from the tip of the bell.

Then, pull out the collar from the bell.

Lastly, pull out the base from the bell.

Set the plastic parts aside, they won't be used in this project.

bell_18

bell_19

bell_20

Secure PCBs

Gather up the hardware screws needed to secure the two PCBs to the 3D printed bottom cover.

Place the Feather over the standoffs with the USB A port fitted over the tab. Insert and fasten M2.5 x 6mm long screws to secure the PCB.

Place the solenoid driver over the remaining standoffs with the orientation matching the assembly photo. Insert and fasten M2.5 x 6mm long screws to secure the PCB.

secure_21

secure_22

secure_23

PCBs Secured

Double check the PCBs are fully secured to the 3D printed bottom cover in the correct orientation.

pcb_24

Top Cover Solenoid Mounting Plate

Gather up the 3D printed top cover and bell mounting plate.

Orient the two parts together so the mounting holes are lined up.

Join the two parts together using two M2.5 x 6mm long machine screws.

top_25

top_26

top_27

Assembled Top Cover

Take a moment to ensure the top cover and bell mounting plate are secured correctly.

cover_28

Screw Tap Solenoids

Use a M2 size screw tap to create threads in the mounting holes on the two solenoids.

Be careful not to screw too far in and damage the wire core.

screw_29

screw_30

Secure Solenoid Bell Ringer

Fit the JST-PH connector from one of the solenoids through the square hole on the top cover assembly.

Orient the mounting holes on the solenoid with the holes on the 3D printed mounting plate.

Insert and fasten two M2 x 6mm long machine screws to secure the solenoid.

ringer_31

ringer_32

ringer_33

Test Solenoid

Ensure the solenoid is secured and mounted to the 3D printed part correctly. Push on the plunger to ensure its pushing in the correct orientation.

test_34

Secure Typing Solenoid

Gather up the 3D printed case, M2 screws, and solenoid.

Place the solenoid inside the 3D printed case with the mounting holes lined up.

Insert and fasten two M2 x 6mm long machine screws to secure the solenoid.

typing_35

typing_36

typing_37

Snap Bottom Cover

Get the bottom cover and the case ready to join together.

Orient the bottom cover with the case so the USB A port from the Feather is lined up with the USB cutout on the case.

Firmly press the two parts to snap fit them together.

Ensure the wires are not pinched or kinked.

cover_38

cover_40

cover_41

Connect Cables

Grab the JST cable from the solenoid inside the case and connect it to the JST cable that is connected to the 0 pin on the solenoid driver.

Connect the remaining JST cable to the solenoid that is secured to the top cover mounting plate.

cables_41

cables_42

Snap Top

Orient the top cover with the case so it matches the assembly photo. Firmly press the top onto the case so the two-parts snap fit together.

Ensure none of the wires are pinched or kinked.

snap_43

Install Bell

Place the bell over the post on the top cover.

Press the 3D printed top pin into the post until its fully seated.

bell_44

bell_45

bell_46

Install Keys

Press the 3D printed faux keyboard onto the cavity on the case. Firmly press the edges into the cavity so it snap fits.

keys_47

Assembled Case

Take a moment to ensure the case has been assembled correctly.

case_48

Connect USB Keyboard

Plug in the USB cable from the keyboard to the USB A port on the Feather RP2040.

Plug in the USB-C cable to the USB-C port on the Feather. Then, plug it into your computer or mobile device.

keyboard_49

keyboard_50

keyboard_51

Install CircuitPython

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.

CircuitPython Quickstart

Follow this step-by-step to quickly get CircuitPython running on your board.

Download the latest version of CircuitPython for this board via circuitpython.org

Click the link above to download the latest CircuitPython UF2 file.

Save it wherever is convenient for you.

click_52

board_53

To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above). Continue to hold the BOOT/BOOTSEL button until the RPI-RP2 drive appears!

If the drive does not appear, release all the buttons, and then repeat the process above.

You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.

A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.

You will see a new disk drive appear called RPI-RP2.

Drag the adafruit_circuitpython_etc.uf2 file to RPI-RP2.

drag_54

drag_55

The RPI-RP2 drive will disappear, and a new disk drive called CIRCUITPY will appear.

That's it, you're done! :)

drive_56

Safe Mode

You want to edit your code.py or modify the files on your CIRCUITPY drive but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.

Safe mode in CircuitPython does not run any user code on startup and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.

Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.

Entering Safe Mode

To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)

In Safe Mode

If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.

If you connect to the serial console, you'll find the following message.

Copy Code
Auto-reload is off.
Running in safe mode! Not running saved code.

CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode.

Press any key to enter the REPL. Use CTRL-D to reload.

You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.

Flash Resetting UF2

If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RPI-RP2. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.

Download flash erasing "nuke" UF2

Use the USB Host Code

3d_printing_useage-host

This version of the code lets you plug a USB keyboard into the USB-A port on the Feather RP2040 USB Host. The Feather hosts the keyboard and sends its keystrokes over USB to your computer. The solenoids are triggered whenever you type on the attached keyboard.

There are two code options for this project. This page goes overusing the Not A Typewriter as a USB Host.

Once you've finished setting up your Feather RP2040 USB Host with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.

To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT

import array
import time
import board
from adafruit_mcp230xx.mcp23017 import MCP23017

import usb
import adafruit_usb_host_descriptors
import usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode

# Typewriter configuration
KEYSTROKE_BELL_INTERVAL = 25  # Ring bell every 25 keystrokes
SOLENOID_STRIKE_TIME = 0.03   # Duration in seconds for solenoid activation (reduced)
SOLENOID_DELAY = 0.01         # Small delay between solenoid operations (reduced)
ENTER_KEY_CODE = 0x28         # HID code for Enter key
ESCAPE_KEY_CODE = 0x29        # HID code for Escape key
BACKSPACE_KEY_CODE = 0x2A     # HID code for Backspace key
TAB_KEY_CODE = 0x2B           # HID code for Tab key
bell_keys = {ENTER_KEY_CODE, ESCAPE_KEY_CODE, TAB_KEY_CODE}

# Set up USB HID keyboard
hid_keyboard = Keyboard(usb_hid.devices)

# HID to Keycode mapping dictionary
hid_to_keycode = {
    0x04: Keycode.A,
    0x05: Keycode.B,
    0x06: Keycode.C,
    0x07: Keycode.D,
    0x08: Keycode.E,
    0x09: Keycode.F,
    0x0A: Keycode.G,
    0x0B: Keycode.H,
    0x0C: Keycode.I,
    0x0D: Keycode.J,
    0x0E: Keycode.K,
    0x0F: Keycode.L,
    0x10: Keycode.M,
    0x11: Keycode.N,
    0x12: Keycode.O,
    0x13: Keycode.P,
    0x14: Keycode.Q,
    0x15: Keycode.R,
    0x16: Keycode.S,
    0x17: Keycode.T,
    0x18: Keycode.U,
    0x19: Keycode.V,
    0x1A: Keycode.W,
    0x1B: Keycode.X,
    0x1C: Keycode.Y,
    0x1D: Keycode.Z,
    0x1E: Keycode.ONE,
    0x1F: Keycode.TWO,
    0x20: Keycode.THREE,
    0x21: Keycode.FOUR,
    0x22: Keycode.FIVE,
    0x23: Keycode.SIX,
    0x24: Keycode.SEVEN,
    0x25: Keycode.EIGHT,
    0x26: Keycode.NINE,
    0x27: Keycode.ZERO,
    0x28: Keycode.ENTER,
    0x29: Keycode.ESCAPE,
    0x2A: Keycode.BACKSPACE,
    0x2B: Keycode.TAB,
    0x2C: Keycode.SPACE,
    0x2D: Keycode.MINUS,
    0x2E: Keycode.EQUALS,
    0x2F: Keycode.LEFT_BRACKET,
    0x30: Keycode.RIGHT_BRACKET,
    0x31: Keycode.BACKSLASH,
    0x33: Keycode.SEMICOLON,
    0x34: Keycode.QUOTE,
    0x35: Keycode.GRAVE_ACCENT,
    0x36: Keycode.COMMA,
    0x37: Keycode.PERIOD,
    0x38: Keycode.FORWARD_SLASH,
    0x39: Keycode.CAPS_LOCK,
    0x3A: Keycode.F1,
    0x3B: Keycode.F2,
    0x3C: Keycode.F3,
    0x3D: Keycode.F4,
    0x3E: Keycode.F5,
    0x3F: Keycode.F6,
    0x40: Keycode.F7,
    0x41: Keycode.F8,
    0x42: Keycode.F9,
    0x43: Keycode.F10,
    0x44: Keycode.F11,
    0x45: Keycode.F12,
    0x4F: Keycode.RIGHT_ARROW,
    0x50: Keycode.LEFT_ARROW,
    0x51: Keycode.DOWN_ARROW,
    0x52: Keycode.UP_ARROW,
}

# Modifier mapping
modifier_to_keycode = {
    0x01: Keycode.LEFT_CONTROL,
    0x02: Keycode.LEFT_SHIFT,
    0x04: Keycode.LEFT_ALT,
    0x08: Keycode.LEFT_GUI,
    0x10: Keycode.RIGHT_CONTROL,
    0x20: Keycode.RIGHT_SHIFT,
    0x40: Keycode.RIGHT_ALT,
    0x80: Keycode.RIGHT_GUI,
}

#interface index, and endpoint addresses for USB Device instance
kbd_interface_index = None
kbd_endpoint_address = None
keyboard = None

i2c = board.STEMMA_I2C()

mcp = MCP23017(i2c)

noid_2 = mcp.get_pin(0)  # Key strike solenoid
noid_1 = mcp.get_pin(1)  # Bell solenoid
noid_1.switch_to_output(value=False)
noid_2.switch_to_output(value=False)

# Typewriter state tracking
keystroke_count = 0
previous_keys = set()  # Track previously pressed keys to detect new presses
previous_modifiers = 0  # Track modifier state

#interface index, and endpoint addresses for USB Device instance
kbd_interface_index = None
kbd_endpoint_address = None
keyboard = None

# scan for connected USB devices
for device in usb.core.find(find_all=True):
    # check for boot keyboard endpoints on this device
    kbd_interface_index, kbd_endpoint_address = (
        adafruit_usb_host_descriptors.find_boot_keyboard_endpoint(device)
    )
    # if a boot keyboard interface index and endpoint address were found
    if kbd_interface_index is not None and kbd_interface_index is not None:
        keyboard = device

        # detach device from kernel if needed
        if keyboard.is_kernel_driver_active(0):
            keyboard.detach_kernel_driver(0)

        # set the configuration so it can be used
        keyboard.set_configuration()

if keyboard is None:
    raise RuntimeError("No boot keyboard endpoint found")

buf = array.array("b", [0] * 8)

def strike_key_solenoid():
    """Activate the key strike solenoid briefly"""
    noid_1.value = True
    time.sleep(SOLENOID_STRIKE_TIME)
    noid_1.value = False

def ring_bell_solenoid():
    """Activate the bell solenoid briefly"""
    noid_2.value = True
    time.sleep(SOLENOID_STRIKE_TIME)
    noid_2.value = False

def get_pressed_keys(report_data):
    """Extract currently pressed keys from HID report"""
    pressed_keys = set()

    # Check bytes 2-7 for key codes (up to 6 simultaneous keys)
    for i in range(2, 8):
        k = report_data[i]
        # Skip if no key (0) or error rollover (1)
        if k > 1:
            pressed_keys.add(k)

    return pressed_keys

def print_keyboard_report(report_data):
    # Dictionary for modifier keys (first byte)
    modifier_dict = {
        0x01: "LEFT_CTRL",
        0x02: "LEFT_SHIFT",
        0x04: "LEFT_ALT",
        0x08: "LEFT_GUI",
        0x10: "RIGHT_CTRL",
        0x20: "RIGHT_SHIFT",
        0x40: "RIGHT_ALT",
        0x80: "RIGHT_GUI",
    }

    # Dictionary for key codes (main keys)
    key_dict = {
        0x04: "A",
        0x05: "B",
        0x06: "C",
        0x07: "D",
        0x08: "E",
        0x09: "F",
        0x0A: "G",
        0x0B: "H",
        0x0C: "I",
        0x0D: "J",
        0x0E: "K",
        0x0F: "L",
        0x10: "M",
        0x11: "N",
        0x12: "O",
        0x13: "P",
        0x14: "Q",
        0x15: "R",
        0x16: "S",
        0x17: "T",
        0x18: "U",
        0x19: "V",
        0x1A: "W",
        0x1B: "X",
        0x1C: "Y",
        0x1D: "Z",
        0x1E: "1",
        0x1F: "2",
        0x20: "3",
        0x21: "4",
        0x22: "5",
        0x23: "6",
        0x24: "7",
        0x25: "8",
        0x26: "9",
        0x27: "0",
        0x28: "ENTER",
        0x29: "ESC",
        0x2A: "BACKSPACE",
        0x2B: "TAB",
        0x2C: "SPACE",
        0x2D: "MINUS",
        0x2E: "EQUAL",
        0x2F: "LBRACKET",
        0x30: "RBRACKET",
        0x31: "BACKSLASH",
        0x33: "SEMICOLON",
        0x34: "QUOTE",
        0x35: "GRAVE",
        0x36: "COMMA",
        0x37: "PERIOD",
        0x38: "SLASH",
        0x39: "CAPS_LOCK",
        0x4F: "RIGHT_ARROW",
        0x50: "LEFT_ARROW",
        0x51: "DOWN_ARROW",
        0x52: "UP_ARROW",
    }

    # Add F1-F12 keys to the dictionary
    for i in range(12):
        key_dict[0x3A + i] = f"F{i + 1}"

    # First byte contains modifier keys
    modifiers = report_data[0]

    # Print modifier keys if pressed
    if modifiers > 0:
        print("Modifiers:", end=" ")

        # Check each bit for modifiers and print if pressed
        for b, name in modifier_dict.items():
            if modifiers & b:
                print(name, end=" ")

        print()

    # Bytes 2-7 contain up to 6 key codes (byte 1 is reserved)
    keys_pressed = False

    for i in range(2, 8):
        k = report_data[i]

        # Skip if no key or error rollover
        if k in {0, 1}:
            continue

        if not keys_pressed:
            print("Keys:", end=" ")
            keys_pressed = True

        # Print key name based on dictionary lookup
        if k in key_dict:
            print(key_dict[k], end=" ")
        else:
            # For keys not in the dictionary, print the HID code
            print(f"0x{k:02X}", end=" ")

    if keys_pressed:
        print()
    elif modifiers == 0:
        print("No keys pressed")


print("USB Typewriter starting...")
print(f"Bell will ring every {KEYSTROKE_BELL_INTERVAL} keystrokes or when Enter is pressed")

while True:
    # try to read data from the keyboard
    try:
        count = keyboard.read(kbd_endpoint_address, buf, timeout=10)

    # if there is no data it will raise USBTimeoutError
    except usb.core.USBTimeoutError:
        # Nothing to do if there is no data for this keyboard
        continue

    # Get currently pressed keys and modifiers
    current_keys = get_pressed_keys(buf)
    current_modifiers = buf[0]

    # Find newly pressed keys (not in previous scan)
    new_keys = current_keys - previous_keys

    # Find released keys for HID pass-through
    released_keys = previous_keys - current_keys

    # Handle modifier changes
    if current_modifiers != previous_modifiers:
        # Build list of modifier keycodes to press/release
        for bit, keycode in modifier_to_keycode.items():
            if current_modifiers & bit and not previous_modifiers & bit:
                # Modifier newly pressed
                hid_keyboard.press(keycode)
            elif not (current_modifiers & bit) and (previous_modifiers & bit):
                # Modifier released
                hid_keyboard.release(keycode)

    # Release any keys that were let go
    for key in released_keys:
        if key in hid_to_keycode:
            hid_keyboard.release(hid_to_keycode[key])

    # Process each newly pressed key
    for key in new_keys:
        # Increment keystroke counter
        keystroke_count += 1
        # Strike the key solenoid for typewriter effect
        strike_key_solenoid()
        # Pass through the key press via USB HID
        if key in hid_to_keycode:
            hid_keyboard.press(hid_to_keycode[key])

        # Check if special keys were pressed
        if key == ENTER_KEY_CODE:
            ring_bell_solenoid()
            keystroke_count = 0  # Reset counter for new line
        elif key == ESCAPE_KEY_CODE:
            ring_bell_solenoid()
            keystroke_count = 0  # Reset counter
        elif key == TAB_KEY_CODE:
            ring_bell_solenoid()
            keystroke_count = 0  # Reset counter
        elif key == BACKSPACE_KEY_CODE:
            keystroke_count = 0  # Reset counter but no bell
        elif keystroke_count % KEYSTROKE_BELL_INTERVAL == 0:
            print(f"\n*** DING! ({keystroke_count} keystrokes) ***\n")
            ring_bell_solenoid()
    # Special handling for bell keys that are still held
    # check if they were released and re-pressed
    # This handles rapid double-taps where the key might not fully release

    for key in bell_keys:
        if key in current_keys and key in previous_keys and key not in new_keys:
            # Key is being held, check if it was briefly released by looking at the raw state
            # For held keys, we'll check if this is a repeat event
            if len(current_keys) != len(previous_keys) or current_keys != previous_keys:
                # Something changed, might be a repeat
                continue

    # Update previous keys and modifiers for next scan
    previous_keys = current_keys
    previous_modifiers = current_modifiers

    # Still print the keyboard report for debugging
    if new_keys:  # Only print if there are new key presses
        print_keyboard_report(buf)
        print(f"Total keystrokes: {keystroke_count}")

View on GitHub

Upload the Code and Libraries to the Feather RP2040 USB Host

After downloading the Project Bundle, plug your Feather RP2040 USB Host into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the Feather RP2040 USB Host's CIRCUITPY drive.

  • lib folder

  • code.py

Your Feather RP2040 USB Host CIRCUITPY drive should look like this after copying the lib folder and code.py file:

code_57

Use

Plug in your USB keyboard into the USB-A port on the Feather. Then, plug the Feather into your computer. As you type on the keyboard, you'll hear the solenoids trigger. The Feather also sends the keystrokes to your computer from the keyboard.

Use the Desktop Python Code

3d_printing_useage-desktop

This version of the code lets you plug in the Not A Typewriter to your computer without having to plug your keyboard into the Feather USB host port. A CPython script runs on your computer sending your keyboard inputs via serial to the attached Feather. The Feather runs CircuitPython code that is listening for those key presses. When a key press is received, the solenoids are triggered.

There are two code options for this project. This page goes overusing the Not A Typewriter as a keyboard listener.

Once you've finished setting up your Feather RP2040 USB Host with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.

To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""
USB Typewriter Feather-side Script
Converts incoming keystrokes to solenoid clicks
"""

import time
import struct
import usb_cdc
import board
from adafruit_mcp230xx.mcp23017 import MCP23017

# Typewriter configuration
KEYSTROKE_BELL_INTERVAL = 25  # Ring bell every 25 keystrokes
SOLENOID_STRIKE_TIME = 0.03   # Duration in seconds for solenoid activation
ENTER_KEY_CODE = 0x28         # HID code for Enter key
ESCAPE_KEY_CODE = 0x29        # HID code for Escape key
BACKSPACE_KEY_CODE = 0x2A     # HID code for Backspace key
TAB_KEY_CODE = 0x2B           # HID code for Tab key

# Key name mapping for debug output
key_names = {
    0x04: "A", 0x05: "B", 0x06: "C", 0x07: "D",
    0x08: "E", 0x09: "F", 0x0A: "G", 0x0B: "H",
    0x0C: "I", 0x0D: "J", 0x0E: "K", 0x0F: "L",
    0x10: "M", 0x11: "N", 0x12: "O", 0x13: "P",
    0x14: "Q", 0x15: "R", 0x16: "S", 0x17: "T",
    0x18: "U", 0x19: "V", 0x1A: "W", 0x1B: "X",
    0x1C: "Y", 0x1D: "Z",
    0x1E: "1", 0x1F: "2", 0x20: "3", 0x21: "4",
    0x22: "5", 0x23: "6", 0x24: "7", 0x25: "8",
    0x26: "9", 0x27: "0",
    0x28: "ENTER", 0x29: "ESC", 0x2A: "BACKSPACE",
    0x2B: "TAB", 0x2C: "SPACE", 0x2D: "MINUS",
    0x2E: "EQUAL", 0x2F: "LBRACKET", 0x30: "RBRACKET",
    0x31: "BACKSLASH", 0x33: "SEMICOLON", 0x34: "QUOTE",
    0x35: "GRAVE", 0x36: "COMMA", 0x37: "PERIOD",
    0x38: "SLASH", 0x39: "CAPS_LOCK",
    0x4F: "RIGHT", 0x50: "LEFT", 0x51: "DOWN", 0x52: "UP",
}

# Add F1-F12 keys
for i in range(12):
    key_names[0x3A + i] = f"F{i + 1}"

# Set up I2C and MCP23017
i2c = board.STEMMA_I2C()
mcp = MCP23017(i2c)

# Configure solenoid pins
noid_1 = mcp.get_pin(0)  # Bell solenoid
noid_2 = mcp.get_pin(1)  # Key strike solenoid
noid_1.switch_to_output(value=False)
noid_2.switch_to_output(value=False)

# Typewriter state tracking
keystroke_count = 0
current_keys = set()  # Track currently pressed keys

# Check if USB CDC data is available
if usb_cdc.data is None:
    print("ERROR: USB CDC data not enabled!")
    print("Please create a boot.py file with:")
    print("  import usb_cdc")
    print("  usb_cdc.enable(console=True, data=True)")
    print("\nThen reset the board.")
    while True:
        time.sleep(1)

serial = usb_cdc.data

def strike_key_solenoid():
    """Activate the key strike solenoid briefly"""
    noid_2.value = True
    time.sleep(SOLENOID_STRIKE_TIME)
    noid_2.value = False

def ring_bell_solenoid():
    """Activate the bell solenoid briefly"""
    noid_1.value = True
    time.sleep(SOLENOID_STRIKE_TIME)
    noid_1.value = False

def process_key_event(mod, code, p): # pylint: disable=too-many-branches
    """Process a key event from the computer"""
    global keystroke_count # pylint: disable=global-statement

    # Debug output
    key_name = key_names.get(code, f"0x{code:02X}")
    action = "pressed" if p else "released"

    # Handle modifier display
    if mod > 0:
        mod_str = []
        if mod & 0x01:
            mod_str.append("L_CTRL")
        if mod & 0x02:
            mod_str.append("L_SHIFT")
        if mod & 0x04:
            mod_str.append("L_ALT")
        if mod & 0x08:
            mod_str.append("L_GUI")
        if mod & 0x10:
            mod_str.append("R_CTRL")
        if mod & 0x20:
            mod_str.append("R_SHIFT")
        if mod & 0x40:
            mod_str.append("R_ALT")
        if mod & 0x80:
            mod_str.append("R_GUI")
        print(f"[{'+'.join(mod_str)}] {key_name} {action}")
    else:
        print(f"{key_name} {action}")

    # Only process key presses (not releases) for solenoid activation
    if p and code > 0:  # key_code 0 means modifier-only update
        # Check if this is a new key press
        if code not in current_keys:
            current_keys.add(code)

            # Increment keystroke counter
            keystroke_count += 1

            # Strike the key solenoid
            strike_key_solenoid()

            # Check for special keys
            if code == ENTER_KEY_CODE:
                ring_bell_solenoid()
                keystroke_count = 0  # Reset counter for new line
            elif code == ESCAPE_KEY_CODE:
                ring_bell_solenoid()
                keystroke_count = 0  # Reset counter
            elif code == TAB_KEY_CODE:
                ring_bell_solenoid()
                keystroke_count = 0  # Reset counter
            elif code == BACKSPACE_KEY_CODE:
                keystroke_count = 0  # Reset counter but no bell
            elif keystroke_count % KEYSTROKE_BELL_INTERVAL == 0:
                print(f"\n*** DING! ({keystroke_count} keystrokes) ***\n")
                ring_bell_solenoid()

            print(f"Total keystrokes: {keystroke_count}")

    elif not p and code > 0:
        # Remove key from pressed set when released
        current_keys.discard(code)

print("USB Typewriter Receiver starting...")
print(f"Bell will ring every {KEYSTROKE_BELL_INTERVAL} keystrokes or on special keys")
print("Waiting for key events from computer...")
print("-" * 40)

# Buffer for incoming data
buffer = bytearray(4)
buffer_pos = 0

while True:
    # Check for incoming serial data
    if serial.in_waiting > 0:
        # Read available bytes
        data = serial.read(serial.in_waiting)

        for byte in data:
            # Look for start marker
            if buffer_pos == 0:
                if byte == 0xAA:
                    buffer[0] = byte
                    buffer_pos = 1
            else:
                # Fill buffer
                buffer[buffer_pos] = byte
                buffer_pos += 1

                # Process complete message
                if buffer_pos >= 4:
                    # Unpack the message
                    _, modifier, key_code, pressed = struct.unpack('BBBB', buffer)

                    # Process the key event
                    process_key_event(modifier, key_code, pressed)

                    # Reset buffer
                    buffer_pos = 0

    # Small delay to prevent busy-waiting
    time.sleep(0.001)

View on GitHub

Upload the Code and Libraries to the Feather RP2040 USB Host

After downloading the Project Bundle, plug your Feather RP2040 USB Host into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the Feather RP2040 USB Host's CIRCUITPY drive.

  • lib folder

  • code.py

  • boot.py

Your Feather RP2040 USB Host CIRCUITPY drive should look like this after copying the lib folder, boot.py file and code.py file:

code_58

CPython Keyboard Sender Code

To run the script, you will need a desktop or laptop computer with Python 3 installed.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT

#!/usr/bin/env python3
"""
USB Typewriter Computer-side Script
Captures keyboard input and sends it to the Feather via serial
"""

import struct
import time
import threading
import queue
import sys
import serial
import serial.tools.list_ports
from pynput import keyboard

class TypewriterSender:
    def __init__(self):
        self.serial_port = None
        self.key_queue = queue.Queue()
        self.running = True
        self.modifier_state = 0

        # Map pynput keys to HID keycodes
        self.key_to_hid = {
            # Letters
            'a': 0x04, 'b': 0x05, 'c': 0x06, 'd': 0x07,
            'e': 0x08, 'f': 0x09, 'g': 0x0A, 'h': 0x0B,
            'i': 0x0C, 'j': 0x0D, 'k': 0x0E, 'l': 0x0F,
            'm': 0x10, 'n': 0x11, 'o': 0x12, 'p': 0x13,
            'q': 0x14, 'r': 0x15, 's': 0x16, 't': 0x17,
            'u': 0x18, 'v': 0x19, 'w': 0x1A, 'x': 0x1B,
            'y': 0x1C, 'z': 0x1D,
            # Numbers
            '1': 0x1E, '2': 0x1F, '3': 0x20, '4': 0x21,
            '5': 0x22, '6': 0x23, '7': 0x24, '8': 0x25,
            '9': 0x26, '0': 0x27,
            # Special keys
            keyboard.Key.enter: 0x28,
            keyboard.Key.esc: 0x29,
            keyboard.Key.backspace: 0x2A,
            keyboard.Key.tab: 0x2B,
            keyboard.Key.space: 0x2C,
            '-': 0x2D, '=': 0x2E, '[': 0x2F, ']': 0x30,
            '\\': 0x31, ';': 0x33, "'": 0x34, '`': 0x35,
            ',': 0x36, '.': 0x37, '/': 0x38,
            keyboard.Key.caps_lock: 0x39,
            # Arrow keys
            keyboard.Key.right: 0x4F,
            keyboard.Key.left: 0x50,
            keyboard.Key.down: 0x51,
            keyboard.Key.up: 0x52,
        }

        # Add function keys
        for i in range(1, 13):
            self.key_to_hid[getattr(keyboard.Key, f'f{i}')] = 0x3A + i - 1

        # Modifier bits
        self.modifier_bits = {
            keyboard.Key.ctrl_l: 0x01,
            keyboard.Key.shift_l: 0x02,
            keyboard.Key.alt_l: 0x04,
            keyboard.Key.cmd_l: 0x08,  # Windows/Command key
            keyboard.Key.ctrl_r: 0x10,
            keyboard.Key.shift_r: 0x20,
            keyboard.Key.alt_r: 0x40,
            keyboard.Key.cmd_r: 0x80,
        }

    @staticmethod
    def find_feather_port():
        """Find the Feather's serial port"""
        ports = serial.tools.list_ports.comports()

        print("Available serial ports:")
        for i, port in enumerate(ports):
            print(f"{i}: {port.device} - {port.description}")
        feather_port = None

        if not feather_port:
            # Manual selection
            try:
                choice = int(input("\nSelect port number: "))
                if 0 <= choice < len(ports):
                    feather_port = ports[choice].device
                else:
                    print("Invalid selection")
                    return None
            except (ValueError, IndexError):
                print("Invalid input")
                return None

        return feather_port

    def connect(self):
        """Connect to the Feather via serial"""
        port = self.find_feather_port()
        if not port:
            return False

        try:
            self.serial_port = serial.Serial(port, 115200, timeout=0.1)
            time.sleep(2)  # Wait for connection to stabilize
            print(f"Connected to {port}")
            return True
        except Exception as e: # pylint: disable=broad-except
            print(f"Failed to connect: {e}")
            return False

    def send_key_event(self, hid_code, pressed):
        """Send a key event to the Feather"""
        if self.serial_port and self.serial_port.is_open:
            try:
                # Protocol: [0xAA][modifier_byte][key_code][pressed]
                # 0xAA is a start marker
                data = struct.pack('BBBB', 0xAA, self.modifier_state, hid_code, 1 if pressed else 0)
                self.serial_port.write(data)
                self.serial_port.flush()
            except Exception as e: # pylint: disable=broad-except
                print(f"Error sending data: {e}")

    def on_press(self, key):
        """Handle key press events"""
        # Check for modifier keys
        if key in self.modifier_bits:
            self.modifier_state |= self.modifier_bits[key]
            self.send_key_event(0, True)  # Send modifier update
            return

        # Get HID code for the key
        hid_code = None

        # Check if it's a special key
        if hasattr(key, 'value') and key in self.key_to_hid:
            hid_code = self.key_to_hid[key]
        # Check if it's a regular character
        elif hasattr(key, 'char') and key.char:
            hid_code = self.key_to_hid.get(key.char.lower())

        if hid_code:
            self.key_queue.put((hid_code, True))

    def on_release(self, key):
        """Handle key release events"""
        # Check for modifier keys
        if key in self.modifier_bits:
            self.modifier_state &= ~self.modifier_bits[key]
            self.send_key_event(0, False)  # Send modifier update
            return None

        # Get HID code for the key
        hid_code = None

        # Check if it's a special key
        if hasattr(key, 'value') and key in self.key_to_hid:
            hid_code = self.key_to_hid[key]
        # Check if it's a regular character
        elif hasattr(key, 'char') and key.char:
            hid_code = self.key_to_hid.get(key.char.lower())

        if hid_code:
            self.key_queue.put((hid_code, False))

        # Check for escape to quit
        if key == keyboard.Key.esc:
            print("\nESC pressed - exiting...")
            self.running = False
            return False

        return None

    def process_queue(self):
        """Process queued key events"""
        while self.running:
            try:
                hid_code, pressed = self.key_queue.get(timeout=0.1)
                self.send_key_event(hid_code, pressed)

                # Debug output
                action = "pressed" if pressed else "released"
                print(f"Key {action}: 0x{hid_code:02X}")

            except queue.Empty:
                continue

    def run(self):
        """Main run loop"""
        if not self.connect():
            print("Failed to connect to Feather")
            return

        print("\nNot A Typewriter")
        print("Press keys to send to typewriter")
        print("Press ESC to exit")
        print("-" * 30)

        # Start queue processor thread
        queue_thread = threading.Thread(target=self.process_queue)
        queue_thread.daemon = True
        queue_thread.start()

        # Start keyboard listener
        with keyboard.Listener(
            on_press=self.on_press,
            on_release=self.on_release) as listener:
            listener.join()

        # Cleanup
        if self.serial_port:
            self.serial_port.close()
        print("Disconnected")

if __name__ == "__main__":
    try:
        sender = TypewriterSender()
        sender.run()
    except KeyboardInterrupt:
        print("\nInterrupted")
        sys.exit(0)

View on GitHub

CPython Dependencies

You'll use pip to install the Python libraries required to run the script:

Copy Code
pip install pyserial
pip install pynput

Use

First, you'll plug the Feather running the CircuitPython code into a USB port on your computer. This mounts the USB CDC port to your computer, which the CPython script needs to access.

To run the CPython script, open a terminal window and navigate to the directory where you have the script. Run the script with:

Copy Code
python keyboard_sender.py

When you launch the script, you'll be prompted to select the USB CDC port on the Feather. The boot.py file on the Feather allows for two COM ports to be opened on the Feather. These ports are numbered consecutively, and the CDC port will always be the second one. For example, in the screenshot you can see ports COM53 and COM54 are available. The CDC port is COM54.

screen_59

As you type, you'll hear the solenoids begin triggering. In the terminal where you launched the script, you'll see the keycodes printed out as you type.

3d_printing_cpythonGif

製造商零件編號 5723
ADAFRUIT FEATHER RP2040 WITH USB
Adafruit Industries LLC
I2C TO 8 CHANNEL SOLENOID DRIVER
製造商零件編號 6318
I2C TO 8 CHANNEL SOLENOID DRIVER
Adafruit Industries LLC
製造商零件編號 6278
BLUE USB TYPE C TO USB A CABLE W
Adafruit Industries LLC
製造商零件編號 4399
STEMMA QWIIC JST SH CABLE 50MM
Adafruit Industries LLC
製造商零件編號 3814
JST PH 2 PIN CABLE MALE HEADER 2
Adafruit Industries LLC
製造商零件編號 2513
HOOK-UP 26AWG 600V RED 25'
Adafruit Industries LLC
製造商零件編號 2517
HOOK-UP 26AWG 600V BLACK 25'
Adafruit Industries LLC
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.