Introduction: Cherry Pi Split Mechanical Keyboard

I've used a Microsoft Natural Elite keyboard for years. And after almost 20 years of loyal service, it's at the end of its lifespan. During my search for a replacement, I also looked at different mechanical keyboards. And because I regularly do DIY projects, I thought it would be a great experience to make such a keyboard myself.

This is my the first mechanical keyboard project. And this will be for daily usage. Although the possibilities are almost endless, I limit myself to the basic functionality: An ergonomic keyboard with mouse functions.
While searching for parts I came across a new type of switch. A low-profile version of the Cherry MX Red. This makes it possible to make a thin mechanical keyboard. And I tried to keep this keyboard as thin as possible.

The entire design was made with Autodesk Eagle and Fusion 360. Hereby I've used the possibility to load the printed circuit board direct into the 3D drawing program. In addition to these programs, Python code is used to support various steps. This Instructabe therefore contains many Python examples.

I didn't add any 'nice to have' features which add complexibility. There are no background LEDs, additional usb ports, speakers and/or displays. There are some spare GPIO ports for additional features, but these aren't used yet.

Supplies

This keyboard contains the following parts:

The following software has been used:

Step 1: Keyboard Design

The initial idea was to rebuild a Microsoft Natural Keyboard Elite with mechanical switches. But disassembly of the keyboard revealed that this isn't that simple. The keyscaps used aren't compatible with mechanical switches. This meant that I had to find another design.

There are several projects with mechanical keyboards, but there are little with an ergonomic design. I came across two possible candidates: The Ergodox and the Ultimate Hacking Keyboard (UHK). These are both open source keyboards. The entire UHK documentation is placed on Github, and therefore a great inspiration for my own keybaord design.

The biggest difference between the Ergodox and the UHK is the placement of the keys. With the Ergodox, the keys are directly above each other. And the UHK has a more traditional layout.

Step 2: Cherry MX Switches

One of the most important choices when making a mechanical keyboard are the switches. There are several manufacturers of these switches, and I have chosen the most known and world leader manufacturer: Cherry MX. These switches are generally available and are well documented. In addition, this type is one of the most used switches by DIY mechanical keyboards. And the developer page on the Cherry website is a good start.

There are several variants and I've purchased a Cherry MX 9 key switch tester to test the different type of switches. Each switch has a different color, and this color indicates the characteristics of the switch:

Cherry MX Red         Low 45g actuation force, silent, smooth. 
Cherry MX Black       High 60g actuation force, silent, smooth. 
Cherry MX Blue        Medium 50g actuation force, clicky, loud. 
Cherry MX Brown       Low 55g actuation force, quiet tactile bump. 
Cherry MX Green       Tactile & Clicky 80g actuation force - Firm tactile and clicky switch.
Cherry MX Gray-brown  Firm Linear 60g actuation force - Tactile bump, no click. 
Cherry MX Gray-black  Tactile 80g actuation force - Firm tactile bump, no click. 
Cherry MX clear       Tactile 55g actuation force - Tactile bump, no click. 
Cherry MX white       Tactile & Clicky 65g actuation force - Tactile and lighter click switch.

My keyboard is not supposed to make a lot of noise. This reduces the possible switches to red, brown, black, grey or clear. And after some testing, I prefer brown or red switches.

Step 3: Cherry MX Low Profile

I came across the MX Red Silent and the MX low profile Red switches on the Cherry MX website. In other words, an extra silent and an extra low variant. Unfortunately not combined in a single product.

The first image shows the difference in height between the switches: it's a difference of 6,6 mm (0,25 inch). The dark switches on the second image are the regular switches and the clear ones are the low profile version.

After an email to Cherry it turned out to be possible to make a custom keyboard with these new switches (MX1B-L2NA). This came with the notice that this would be a lot harder than using regular MX switches. Partly because not all documentation is publicly available.

But I could not let go the opportunity to design and build a low profile mechanical keyboard from scratch.

Step 4: Minimum Keyboard Height

According to the documentation, a keyboard with standard Cherry MX switches and regular keycaps has a minimum height of 0.75 inch (20 mm). However, this is without bottom plate. This gives a total minimum height of approximately 1 inch (25 mm), Key A-B-C in the image.

The usage of low profile switches reduces the height of the switch by 6.6 mm (18.5 - 11.9). It must therefore be possible to make a 20 mm (0.8 inch) thick keyboard. A decrease of 20% (key D-E-F) while using regular keycaps.

Step 5: Keyboard Controller

Most DIY mechanical keyboards use a microcontroller to translate the pressed keys to an USB signal. The Teensy USB Developer Board is a commonly used ARM controller board for for such a projects.

This keyboard uses a different controller: The Raspberry Pi Zero (W). This doen't contain a microcontroller, but a microprocessor. In fact it's a small computer which runs Linux. And it's possible to logon into the keyboard and change it's behaviour.

The idea to use a Raspberry Pi comes from an Adafruit tutorial: "Turning your Raspberry Pi Zero into a USB gadget". It explains how to create several USB devices. Unfortunately the tutorial stops before explaining how to create a keyboard USB device. But the usbarmory Github page of Chris Kuethe describes how to create an USB gadget containing a keyboard.

Note: The Raspberry Pi Zero has two micro USB connectors. The port closest to the HDMI port can be configured as an USB device. The other port can only be used to power the Raspberry Pi.

Step 6: Raspberry Pi Install

Download and install Raspbian Stretch Lite from the Raspberry Pi website. And follow the installation guide. I've used BalenaEtcher to install the image on a micro-SD card. You don't have to unzip the Raspbain image, and it takes about 3 minutes to burn the image onto a SD card. You can ignore all errors about an unavailable drive (which needs formatting) after writing the image to the SD card. The prepared SD card contains two partitions: a readable boot partition and an (for windows) unreadable linux partition.

Place two files in the boot-partition of the SD-card: "ssh" and "wpa_supplicant.conf". The first file is an empty file and makes it posible to create a remote ssh-session. The second file contans all required information to connect to your WiFi network after the first boot:

country=NL
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1

network={
    ssid="MySID"
    scan_ssid=1
    psk="MyPassword"
    key_mgmt=WPA-PSK
}

Place the SD card in the Raspberry Pi Zero and power it on. Wait a while and logon to your WiFi router to find the IP address of the "raspberry" device. Dowload and use Putty to connect to the Raspberry Pi. Logon using the default username "pi" with the password "raspberry".

First update the software to the latest release (recommended, not necessary):

sudo apt-get update
sudo apt-get upgrade
sudo apt-get autoremove
sudo reboot

After upgrading it's time to setup the Raspberry Pi, and to disable some services.

sudo raspi-config

And alter the following settings:

  • Change password: My_Pa$$wor3
  • Hostname (network options): Keyboard
  • Change locale: Timezone

This is a so called headless configuration, there is no need for HDMI output. Add the following line to "rc.local" to save some power usage by disabling the HDMI port (sudo nano /etc/rc.local).

# Disable HDMI
/usr/bin/tvservice -o

Place these lines just before the "exit 0" command

The Raspbian lite-image already has a minimum of background services. There is no need to stop any of them.

sudo service --status-all | grep +

 [ + ]  avahi-daemon
 [ + ]  bluetooth
 [ + ]  cron
 [ + ]  dbus
 [ + ]  dhcpcd
 [ + ]  dphys-swapfile
 [ + ]  fake-hwclock
 [ + ]  kmod
 [ + ]  networking
 [ + ]  procps
 [ + ]  raspi-config
 [ ? ]  rng-tools
 [ + ]  rsyslog
 [ + ]  ssh
 [ + ]  triggerhappy
 [ + ]  udev

The following packages are required for the Python software (Python 3):

sudo apt-get install python3-dev python3-setuptools python3-pip python3-virtualenv 
sudo apt-get install python3-rpi.gpio python3-pip

sudo pip3 install pyusb

Now the Raspberry Pi is ready to use.


I use some small Python programs in this Instructable. In addition to the many web pages that I have visited, the following books have helped with these.

  1. Learning Python (free)
  2. Lean Python (€17)

Attachments

Step 7: Serial Cable

This keyboard can also be made with a standard Raspberry Pi Zero without WiFi. This requires an USB to TTL Serial cable. Usage of this cable is well documentet at the Adafruit website.

This cable requires the following line in the config.txt file in de boot-partition.

# Enable Serial
enable_uart=1

The connections are to the outside pin connections of the GPIO header:

  • The red lead should be connected to 5V. But only if you want to power via this cable.
  • The black lead to GND (3rd pin down).
  • The white lead to TXD (4th pin down).
  • The green lead to RXD (5th pin down).

Use Putty to connect using a serial connection with a speed of 115200. Click 'Open' to connect and remember to press the 'enter' key to start communications.


The important thing here is to only power it from one source, the USB power adaptor or the Console Lead BUT NOT BOTH.

Step 8: Read From USB Devices

This step describes how to monitor USB devices. Power the Raspberry Pi with the power micro USB port. The (left) USB port is used to read values from an USB device.

The first step is to list all connected USB devices. The following script gives a list:

#!/usr/bin/python3
import sys
import usb.core
# find USB devices
dev = usb.core.find(find_all=True)
# loop through devices, printing vendor and product ids in decimal and hex
for cfg in dev:
  sys.stdout.write('Decimal VendorID=' + str(cfg.idVendor) + ' & ProductID=' + str(cfg.idProduct) + '\n')
  sys.stdout.write('Hexadecimal VendorID=' + hex(cfg.idVendor) + ' & ProductID=' + hex(cfg.idProduct) + '\n\n')

Execute the script (as root): "sudo python3 findusb.py". This lists the Raspberry Pi default USB devices:

Decimal VendorID=7531 & ProductID=2
Hexadecimal VendorID=0x1d6b & ProductID=0x2

This is the internal USB hub inside the raspberry pi. And it's mentioned on the boot-screen

[    8.363916] hub 1-0:1.0: USB hub found
[    8.399702] hub 1-0:1.0: 1 port detected

Attach an USB device (requires a special USB cable) and rerun the Python script. There should be an additional row which identifies this USB device:

Decimal VendorID=16700 & ProductID=8195
Hexadecimal VendorID=0x413c & ProductID=0x2003

The vendor ID 0x413c reveals that this is a Dell device. Each vendor has its own ID, which can be found at linux-usb.org. And each vendor can create several (65536) different products with this vendor ID.

413c  Dell Computer Corp.
        0000  DRAC 5 Virtual Keyboard and Mouse
        0001  DRAC 5 Virtual Media
        0058  Port Replicator
        1001  Keyboard Hub
        1002  Keyboard Hub
        1003  Keyboard Hub
        1005  Multimedia Pro Keyboard Hub
        2001  Keyboard HID Support
        2002  SK-8125 Keyboard
        2003  Keyboard
        2005  RT7D50 Keyboard
        2010  Keyboard
        2011  Multimedia Pro Keyboard
        2100  SK-3106 Keyboard
        2101  SmartCard Reader Keyboard
        2105  Model L100 Keyboard
        2106  Dell QuietKey Keyboard
        2500  DRAC4 Remote Access Card
        2513  internal USB Hub of E-Port Replicator
        3010  Optical Wheel Mouse
        3012  Optical Wheel Mouse
        3016  Optical 5-Button Wheel Mouse
        3200  Mouse

Product ID 2003 is listed as a Keyboard.

These vendor ID and product ID values are used to identify the USB device. And these values are required in the following script to read the output values from the device (line 5):

#!/usr/bin/python3
import sys
import usb.core
import usb.util
dev = usb.core.find(idVendor=0x413c, idProduct=0x2003)
# first endpoint
interface = 0
endpoint = dev[0][(0,0)][0]
# if the OS kernel already claimed the device, which is most likely true
# thanks to http://stackoverflow.com/questions/8218683/pyusb-cannot-set-configuration
if dev.is_kernel_driver_active(interface) is True:
  # tell the kernel to detach
  dev.detach_kernel_driver(interface)
  # claim the device
  usb.util.claim_interface(dev, interface)
collected = 0
attempts = 100
while collected < attempts :
    try:
        data = dev.read(endpoint.bEndpointAddress,endpoint.wMaxPacketSize)
        collected += 1
        print (data)
    except usb.core.USBError as e:
        data = None
        if e.args == ('Operation timed out',):
            continue
# release the device
usb.util.release_interface(dev, interface)
# reattach the device to the OS kernel
dev.attach_kernel_driver(interface)

Start the script with "sudo python3 readusb.py" to read 100 USB messages from the usb device..

Pressing the spacebar twice gives 4 output records. With 8 bytes for each value. Only changes in key presses result in an output record. And it's the client OS which handles the keyboard's output data. This is the USB report which only contains the input values from the device.

array('B', [0, 0, 44, 0, 0, 0, 0, 0])
array('B', [0, 0, 0, 0, 0, 0, 0, 0])
array('B', [0, 0, 44, 0, 0, 0, 0, 0])
array('B', [0, 0, 0, 0, 0, 0, 0, 0])

The first byte are the modifier keys (like the shift and control key). The second byte is always 0. And the other 6 values are for the regular keys. A value of 0 means no key value for that byte.
Pressing too many keys results in a "[0, 0, 1, 1, 1, 1, 1, 1]" value.

The 8 bytes are defined in the report size of the USB device definition. It's posible to increase the report size and to allow for more keys at the same time, but it requires a client USB driver to read these additional values.


This way you can view the output of any USB device. Changing the keyboard for a mouse gives different readings (find and change the vendorID an productID).

Left button:

array('B', [1, 0, 0, 0])
array('B', [0, 0, 0, 0])

Move Right:

array('B', [0, 4, 0, 0])
array('B', [0, 6, 0, 0])

Move Up:

array('B', [0, 0, 2, 0])
array('B', [0, 0, 1, 0])

There are only 4 bytes. And it looks like each byte has a function: Buttons , y-movement, x-movement and scroll wheel.

Step 9: Single USB Host Device

A next step is to modify the Raspberry pi into an USB device. This requires two files to be modified: "config.txt" and "cmdline.txt". Both files are located in the boot-partition (/boot). First add the line "dtoverlay=dwc2" to the end of config.txt. Andplace the text "modules-load=dwc2,libcomposite" after rootwait in cmdline.txt.

# Enable Serial
enable_uart=1

# enable USB host
dtoverlay=dwc2
console=serial0,115200 console=tty1 root=PARTUUID=56cd6262-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait modules-load=dwc2,libcomposite

Shutdown the Raspberry Pi and connect it to a computer by USB. Use the USB port (closest tot the HDMI port) to connect the Raspberry Pi to a computer. This port will power the Raspberry Pi and it makes the Pi an USB device (The USB part still needs to be configured). Remember that this can only be done with a Raspberry Pi Zero. Not with a regular Raspberry Pi.

The usb.org site has several pdf documents about how to build an USB device. The entire device specifications are well documentend, but this doesn't turn a Rasperry Pi into an USB device. I've used the script on the USB Gadgets Github page to create my own USB devices on a Raspberry Pi Zero. The main script of this page creates an "usbarmory" device with four USB functions: ACM, EDM, HID and Mass_storage. This script has been altered to a single keyboard device by removing all unwanted parts.

An USB keyboard is defined as an HID (human interface device). And this requires the following (modified) parts from the script:

#!/bin/bash

# Create gadget directory
cd /sys/kernel/config/usb_gadget/
mkdir -p usbarmory
cd usbarmory

# Gadget information (device descriptor)
echo 0x1d6b > idVendor  # Assigned by USB.com (Linux Foundation)
echo 0x0104 > idProduct # Product descriptor (manufacturer)
echo 0x0100 > bcdDevice # Device release number (manufacturer) 
echo 0x0200 > bcdUSB    # USB release (USB2)

# Gadget product information (English)
mkdir -p strings/0x409
echo "fedcba9876543210" > strings/0x409/serialnumber
echo "Inverse Path"     > strings/0x409/manufacturer
echo "USB Armory"       > strings/0x409/product

# Device function instance (name.instance)
mkdir -p functions/hid.usb0
echo 1 > functions/hid.usb0/protocol
echo 1 > functions/hid.usb0/subclass
echo 8 > functions/hid.usb0/report_length
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\x65\\x05\\x07\\x19\\x00\\x29\\x65\\x81\\x00\\xc0 > functions/hid.usb0/report_desc

# Device configuration instance (name.number)(English)
mkdir -p configs/c.1/strings/0x409
echo 250                     > configs/c.1/MaxPower
echo "Config 1: ECM network" > configs/c.1/strings/0x409/configuration

# Bind function instance to configuration instance
ln -s functions/hid.usb0 configs/c.1/

# Attach the device to the USB device controller
ls /sys/class/udc > UDC

exit 0

Make the file executable and execute the file as root:

sudo chmod +x usb_armory
sudo ./usb_armory

This Creates an USB host device which is listed as /dev/hidg0 in the Linux device directory. And the following command sends 8 bytes to the connected client:

sudo su -
echo -ne "\x0\x0\x4\x0\x0\x0\x0\x0" > /dev/hidg0;sleep 1;echo -ne "\x0\x0\x0\x0\x0\x0\x0\x0" > /dev/hidg0
exit

This 'presses' the a-button for 1 second on the connected client.


Note: I have disabled some features in "cmdline.txt". This isn't a pi4 and I don't need audio:

# Additional overlays and parameters are documented /boot/overlays/README

# Enable audio (loads snd_bcm2835)
# dtparam=audio=on

# [pi4]
# Enable DRM VC4 V3D driver on top of the dispmanx display stack
# dtoverlay=vc4-fkms-v3d
# max_framebuffers=2

[all]
# dtoverlay=vc4-fkms-v3d

# Enable Serial
enable_uart=1

# enable USB host
dtoverlay=dwc2

Step 10: USB Device Definition

The previous code defined an USB Host device. All USB device definitions are documented on www.usb.org. And the developer section gives a lot of information about designing USB devices. It contains documentation and tools. The usage tables document contains all information to explain the usb_gadget from the previous example. It's quite technical, but there is also a HID descriptor tool which allows you to create, edit and validate HID Report Descriptors.

I will explain the example from the previous step briefly.
First It creates a device in the /sys/kernel/config/usb_gadget directory of the raspberry pi. This directory defines the USB device and most of these files are made by the script.

└── usbarmory
    ├── bcdDevice
    ├── bcdUSB
    ├── bDeviceClass
    ├── bDeviceProtocol
    ├── bDeviceSubClass
    ├── bMaxPacketSize0
    ├── configs
    │   └── c.1
    │       ├── bmAttributes
    │       ├── MaxPower
    │       └── strings
    │           └── 0x409
    │               └── configuration
    ├── functions
    │   └── hid.usb0
    │       ├── dev
    │       ├── protocol
    │       ├── report_desc
    │       ├── report_length
    │       └── subclass
    ├── idProduct
    ├── idVendor
    ├── os_desc
    │   ├── b_vendor_code
    │   ├── qw_sign
    │   └── use
    ├── strings
    │   └── 0x409
    │       ├── manufacturer
    │       ├── product
    │       └── serialnumber
    └── UDC

The gadget information is required to identify the USB device.

echo 0x1d6b > idVendor  # Linux Foundation
echo 0x0104 > idProduct # Multifunction Composite Gadget
echo 0x0100 > bcdDevice # v1.0.0
echo 0x0200 > bcdUSB    # USB2

The idVendor is an unique hexadecimal number. Each USB manufacturer has to obtain such a vendor ID before it is allowed to create an USB device. You can become a member of the USB organisation for $5000 (per year), or buy a logo license for $3500 (for two years). Another option is using an abandoned vendor ID. Some of these can be used for open source projects.

Each Vendor can create 65536 different product IDs. These two values are used by the client to identify the USB device.

The bcdDevice determines the device release number and the bcdUSB gives the USB specifications which the device complies to. This is version V1.0.0 of this product and it's an USB2 device.

The following files in the strings-directory give additional information about the device. These give readable information about the device. And I'll change these values for my keyboard later.

echo "fedcba9876543210" > strings/0x409/serialnumber
echo "Inverse Path"     > strings/0x409/manufacturer
echo "USB Armory"       > strings/0x409/product

The device function instance defines the report length (in bytes) and the report descriptor. The report descriptor in the usbarmory example is the same as the keyboard example from the HID descriptor tool (image). It starts with some default information about the device: Generic Desktop Keyboard.

The first Usage page in the collection is for a keyboard. It starts with leftcontrol and ends with right GUI. The logical minimum is 0 (off) and the logical maximum is 1 (on) with a report size of 1 bit. These 8 bits combine the following 8 modifier keys into one byte. And this is also the first byte of the USB message.

224  E0  Keyboard LeftControl 
225  E1  Keyboard LeftShift
226  E2  Keyboard LeftAlt
227  E3  Keyboard LeftGui
228  E4  Keyboard RightControl
229  E5  Keyboard RightShift
230  E6  Keyboard RightAlt
231  E7  Keyboard RightGui

The following byte of the descriptor is reserved. But the report descriptor defines the first 5 bits in this byte as LED input for the USB host. The other 3 bits are unused.

01  Num Lock
02  Caps Lock
03  Scroll Lock
04  Composite
05  Kana

The next 6 bytes of the report descriptor are keyboard output bytes for regular keys. Starting with key 0 and ending with key 101 of the HID usage tables (chapter 10). This keyboard doesn't support any special media keys. These have values above 101.


The report length of this keyboard is 8 bytes. And this report descriptor defines the content of these 8 bytes:

byte 0     Keyboard modifier bits
byte 1     Reserverd
byte 2-7   keyboard usage

This keyboard only allows for 6 non-modifier keys at the same time.

Step 11: USB Host Input

The keyboard definition file contains two types of reports: An input and an output report. The output report passes the current key state to the connected device (mostly a computer). The input report is used to read a single byte from the connected device. And this byte contains the LED report with the button state from the caps lock and num lock keys.

The following script can be used to read the current values:

#!/usr/bin/env python3

with open('/dev/hidg0', 'rb+') as fk:
#  Led report is one byte
   output = fk.read(1)

print(output)

Pressing caps-lock gives the value x01. And the num-lock key gives the value x02.

It's the OS which keeps track of the button states. And the keyboard only uses this byte to enable or disable a LED. Pressing caps-lock on one keyboard has an effect on all attached keyboards.


The keyboard in this Instructable currently doesn't have any LEDs. But I've reserved two pins on the controller board.

Step 12: 4 X 4 Keypad Example

This kind of keypads are mostly used in Arduino projects. The previous step made the Raspberry Pi acts as an USB device by sending 8 bytes to the /dev/hidg0 device. And this keypad will turn it into a small keyboard.

The keypad is connected to pin 10-13 and pin 20-23, just like in this thinkercad example. Pins 11 to 13 are successively made low. And (pull up) pins 20, 21, 22 or 23 are scanned for a low value. This indicates a pressed key for that column.

The Python code is very simple and repeatable. But it shows the code of a first keyboard "firmware":

import RPi.GPIO as GPIO
import time

boardLeftControl     =   1 ## 0x01
KeyboardLeftShift    =   2 ## 0x02
KeyboardLeftAlt      =   4 ## 0x04
KeyboardLeftMeta     =   8 ## 0x08
KeyboardRightControl =  16 ## 0x10
KeyboardRightShift   =  32 ## 0x20
KeyboardRightAlt     =  64 ## 0x40
KeyboardRightMeta    = 128 ## 0x80

OldKeys = [0,0,0,0,0,0]
EndKeys = [0,0,0,0,0,0]

def cmp(a, b):
    return (a > b) - (a < b)

def report_keyboard(c,k):
    with open('/dev/hidg0', 'rb+') as fk:
        fk.write((c).to_bytes(1, byteorder='big')+    \
                 (0).to_bytes(1, byteorder='big')+    \
                 (k[0]).to_bytes(1, byteorder='big')+ \
                 (k[1]).to_bytes(1, byteorder='big')+ \
                 (k[2]).to_bytes(1, byteorder='big')+ \
                 (k[3]).to_bytes(1, byteorder='big')+ \
                 (k[4]).to_bytes(1, byteorder='big')+ \
                 (k[5]).to_bytes(1, byteorder='big'))

GPIO.setmode(GPIO.BCM)

GPIO.setup(10, GPIO.OUT)
GPIO.setup(11, GPIO.OUT)
GPIO.setup(12, GPIO.OUT)
GPIO.setup(13, GPIO.OUT)

GPIO.setup(20, GPIO.IN, pull_up_down = GPIO.PUD_UP)
GPIO.setup(21, GPIO.IN, pull_up_down = GPIO.PUD_UP)
GPIO.setup(22, GPIO.IN, pull_up_down = GPIO.PUD_UP)
GPIO.setup(23, GPIO.IN, pull_up_down = GPIO.PUD_UP)

while True:

   NewKeys = [0,0,0,0,0,0]
   Keys = 0

   GPIO.output(13, 1)
   GPIO.output(11, 1)
   GPIO.output(12, 1)
   GPIO.output(10, 0)

   if(GPIO.input(20) == 0):
      NewKeys[Keys] = 19
      Keys+=1
   if(GPIO.input(21) == 0):
      NewKeys[Keys] = 18
      Keys+=1
   if(GPIO.input(22) == 0):
      NewKeys[Keys] = 17
      Keys+=1
   if(GPIO.input(23) == 0):
      NewKeys[Keys] = 16
      Keys+=1

   GPIO.output(10, 1)
   GPIO.output(13, 1)
   GPIO.output(12, 1)
   GPIO.output(11, 0)

   if(GPIO.input(20) == 0):
      NewKeys[Keys] = 15
      Keys+=1
   if(GPIO.input(21) == 0):
      NewKeys[Keys] = 14
      Keys+=1
   if(GPIO.input(22) == 0):
      NewKeys[Keys] = 13
      Keys+=1
   if(GPIO.input(23) == 0):
      NewKeys[Keys] = 12
      Keys+=1

   GPIO.output(10, 1)
   GPIO.output(11, 1)
   GPIO.output(13, 1)
   GPIO.output(12, 0)

   if(GPIO.input(20) == 0):
      NewKeys[Keys] = 11
      Keys+=1
   if(GPIO.input(21) == 0):
      NewKeys[Keys] = 10
      Keys+=1
   if(GPIO.input(22) == 0):
      NewKeys[Keys] = 9
      Keys+=1
   if(GPIO.input(23) == 0):
      NewKeys[Keys] = 8
      Keys+=1

   GPIO.output(10, 1)
   GPIO.output(11, 1)
   GPIO.output(12, 1)
   GPIO.output(13, 0)

   if(GPIO.input(20) == 0):
      NewKeys[Keys] = 7
      Keys+=1
   if(GPIO.input(21) == 0):
      NewKeys[Keys] = 6
      Keys+=1
   if(GPIO.input(22) == 0):
      NewKeys[Keys] = 5
      Keys+=1
   if(GPIO.input(23) == 0):
      NewKeys[Keys] = 4
      Keys+=1

   if cmp(OldKeys,NewKeys) != 0:
      report_keyboard(0,NewKeys)
      OldKeys = NewKeys

   time.sleep (0.1)

GPIO.cleanup()

When pin 10 is low, the input columns represent the values 19, 18, 17 and 16. Then pin 11 becomes low, and the input columns represent the values 15, 14, 13 and 12. This allows for 16 buttons with only 8 GPIO pins.

Step 13: Ghosting

The 4 x 4 keypad example works fine until you press 3 buttons at the same time. This can result in a false reading for a fourth key. This is called ghosting. And this happens when it looks like a switch has been pressed when in reality it hasn’t.

The number of input ports on the keyboard controller are limited. There aren't enough ports available for each switch individually. This requires the switches to be placed in a matrix. The 16 button keypad in the previous example has 8 connectors: 4 rows and 4 columns (first matrix).

Pressing a button connects a row and a column, and this is measured by the keyboard controller (second matrix).

Pressing two keys at the same time connects one or two rows with one or two columns (third matrix). The controller scans the matrix and measures C1/R1 and C3/R1.

Pressing a third key (fourth matrix) creates a path: C1 - R1 - C3 - R4. And for the keyboard controller it "looks" like C1 and R1 are directly connected. This will result in C1/R1. C3/R1, C3/R4 and C1/R4, including a ghosted key.

This can be solved by usage of diodes. These allow current to flow into one direction: from the columns to the rows. This prevents the R1 to C3 connection, but allows the C3 to R1 connection.

Step 14: Keycaps

Mechanical keyboards with Cherry MX switches require compatible keycaps. Although there are different (online) shops which sell these, these are usually in the United States (Wasdkeyboards) or in China (AliExpress). However, most sets are for regular keyboards. And do not take into account for a split keyboard design.

Wasdkeyboards offers single keys and has some great online documentation about keycaps. But I've chosen for the keycaps from the Ultimate Hacking Keyboard. Especially since the simple design of this keyboard appealed to me. These keycaps aren't compatible with backlighting, but I don't have any plans for this yet.

To make some adjustments to the layout, I've also ordered additional blank keycaps. I am rather attached to the location of the current cursor keys, and I want to add some special macro keys.


There are different kind of keycaps (second image). And each row on the keyboard has a different key shape. This is explained on the "Keycap Size Compatibility" page on wasdkeyboards. The space and R1 keys are used for the lower 2 rows of the keyboard (zxc) The R2 keys are used for the middle row (asd), and so on.

The UHK uses OEM shaped keycaps. I've also ordered some additional keycaps on AliExpress. These are also OEM type keycaps which mean that they can be combined with the UHK keycaps.

Step 15: Function Layers

A keyboard can exist out of different key layers that you can access through modifier keys. Most laptops have a "function" (Fn) key. This is mostly used with the function keys (F1 - F12) for multi media options or screen brightness. This leaves many combinations unused, and these combinations can be used for macros or other operations.

Layer keys are different than the eight modifier keys in the first byte of the report descriptor. The modifier keys are handled by the USB client: Sending shift + a results in an 'A' through the client, the keyboard never sends a capital 'A'.
The layer selection keys are processed by the keyboards firmware. Some keyboards don't have any function keys (F1 - F12). Pressing 1 gives a 1, and shift + 1 gives a exclamation mark. And CTRL-1 can't be processed as F1. This might give an issue when a program really requires you to enter CTRL-1.
The layer/mod keys are only used by the keyboards firmware and are never passed to the client. Pressing MOD-1 will result in a F1 key send to the host. The modifier keys change the behaviour of the keyboard.


The report descriptor of the sample keyboard (previous step) has a value of 101 as a logical maximum. The maximum in the keyboard definition table is 223 (hut1_12v2.pdf). Just above the modifier key values.

The excluded range (102 - 223) includes all kind of special keys:

  • 116 Keyboard execute
  • 117 Keyboard help
  • 118 Keyboard menu
  • 119 Keyboard select
  • 120 Keyboard stop
  • 121 Keyboard again
  • 122 Keyboard undo
  • 128 Keyboard volume up
  • 129 Keyboard volume down
  • 156 Keyboard clear
  • 180 Keyboard currency unit

Some of these codes are multi media keys. And others may be usefull (like the currency unit). It requires a modification of the report descriptor in the USB device script to use these codes.


The UHK keycaps have a mouse-key instead of a caps-lock key. I don't use the caps-lock very often, but it will be activated (or disactivated) by pressing both shift keys at the same time. This has to be handled by the keyboards firmware. It has to translate (the two modifier bits for) the shift keys into hid code 57 (x39).

Another option is to translate shift-mouse of Fn-mouse into caps-lock. Even both options can be implemented in the firmware.

The mouse button itself can be combined with the cursor keys to send mouse reports to the client. This is a special piece of code in the firmware.

Step 16: Key Matrix

There are not enough GPIO ports on a Raspberry Pi for all individual keys. That's why the switches are placed in a keyboard matrix. A regular keyboard has 101 or 104 keys. This requires a matrix of 10 rows x 11 columns for up to 110 keys: A total of 21 GPIO pins.

The matrix layout is mainly defined by the "default" keyboard layout and the available UHK keycaps. With two modifications: 4 macro keys on the left side, and additional cursor keys on the right side. The UHK right side control key has a widht of 2.25U. This has been replaced by a 1.25U and a 1U keycap. Where the 1.25U key will be used as control key and the 1U key as arrow-left key. This part of the keyboard is copied from the Microsoft sculpt keyboard.

This keyboard has a total of 74 keys. This requires a matrix of 8 rows x 9 columns: A total of 17 GPIO ports. However, this is a split keyboard with 34 and 40 keys. This requires a matrix of 5 x 7 and 5 x 8: A total of 25 GPIO pins, of which 5 can be shared. Resulting in 20 GPIO pins for maximal 75 switches.

Each row (blue in the image) has its own horizontal line of keys. The columns (red) start from left to right. And each combination of row and column is allowed to make contact (yellow dots), in this matrix.

Bear in mind that this matrix is translated into an electrical diagram. And finally into a printed circuit board (PCB). For this reason, the left side of the keyboard has 34 keys instead of the maximum number of 35.

Step 17: Cherry MX Low Profile Size

During the making of this keyboard, the Cherry Low profile switch was just on the market. As a result, there where no templates of these switches in the Autodesk Eagle library.

There also were no technical drawings available with the dimensions which are required for creating these templates. That's why I measured the dimensions of the switches myself and then designed the required templates.

Step 18: Switch Mount Test

The previous measurements have been tested with a dremel attached to my my 3D printer.

The Cherry MX low profile key mount hole has been translated to a Python script to try different sizes:

import math
 
def pytha(c,b):
    value = math.sqrt(c*c - b*b)
    return value

#location of slot
xpos = 0
ypos = 0

# size in mm
mill = 0.8
cir1 = 5 
cir2 = 6 
cir3 = 2 
ycr3 = 4 

step = 4 # degrees

xhole1 = 0 
yhole1 = 6

xhole2 = 4
yhole2 = 3

# calculated values
c1 = (cir1 / 2) - (mill /2) 
c2 = (cir2 / 2) - (mill /2)
c3 = (cir3 / 2) - (mill /2)
y3 = ycr3
x =  round (math.degrees(math.atan(c3/(pytha(c2, c3)))))

print ('G00 F200')
print ('G01 F100')
print ('G00 X{0:.4f} Y{1:.4f}' .format( xpos - c3, ypos + pytha(c1, c3)))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos - c3, ypos + ycr3))

for i in range (-90,90,step*3):
    print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + c3 * math.sin(math.radians(i)) , ypos + ycr3 + c3 * math.cos(math.radians(i))))

print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + c3, ypos + ycr3))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + c3, ypos + pytha(c1, c3)  ))

for i in range (15,75,step):
    print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + c1 * math.sin(math.radians(i)) , ypos + c1 * math.cos(math.radians(i))))

print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + pytha(c1, c3), ypos + c3))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + pytha(c2, c3), ypos + c3))

for i in range (90-x,90+x, step):
    print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + c2 * math.sin(math.radians(i)) , ypos + c2 * math.cos(math.radians(i))))

print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + pytha(c2, c3), ypos - c3))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + pytha(c1, c3), ypos - c3))

for i in range (15+90,75+90,step):
    print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + c1 * math.sin(math.radians(i)) , ypos + c1 * math.cos(math.radians(i))))

print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + c3, ypos - pytha(c1, c3)))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + c3, ypos - pytha(c2, c3)))

for i in range (180-x,180+x, step):
    print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + c2 * math.sin(math.radians(i)) , ypos + c2 * math.cos(math.radians(i))))

print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos - c3, ypos - pytha(c2, c3)))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos - c3, ypos - pytha(c1, c3)))

for i in range (15+180,75+180,step):
    print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + c1 * math.sin(math.radians(i)) , ypos + c1 * math.cos(math.radians(i))))

print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos - pytha(c1, c3), ypos - c3))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos - pytha(c2, c3), ypos - c3))

for i in range (270-x,270+x, step):
    print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + c2 * math.sin(math.radians(i)) , ypos + c2 * math.cos(math.radians(i))))

print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos - pytha(c2, c3), ypos + c3))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos - pytha(c1, c3), ypos + c3))

for i in range (15+270,75+270,step):
    print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + c1 * math.sin(math.radians(i)) , ypos + c1 * math.cos(math.radians(i))))

print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos - c3, ypos + pytha(c1, c3)  ))

print ('G00 X{0:.4f} Y{1:.4f}' .format( xpos + xhole1 - mill/2, ypos + yhole1))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + xhole1 + mill/2, ypos + yhole1))

print ('G00 X{0:.4f} Y{1:.4f}' .format( xpos + xhole2 - mill/2, ypos + yhole2))
print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos + xhole2 + mill/2, ypos + yhole2))

Executing the script with "python CherryMXLow.py" gives the GCode required to mill a single mount hole (second image).

I have tried different mount sizes. And the following values (in millimeters) gave the best results:

  • cir1 = 5.3
  • cir2 = 6.3
  • cir3 = 2.3

With this values, the Cherry MX low profile switch fits well into the PCB, and the switch does not stick.

Step 19: Diode Placement

As mentioned before, using low profile Cherry MX switches should result in a low profile keyboard. And the first image shows the most optimal configuration. This will give a keyboard with a height of 20 mm (0,8 inch). The PCB is 1.6 mm. The keyboard plate (located just above the PCB) requires 2 mm, but this doesn't add up to the keyboard thickness.

The switches and diodes are normally soldered onto the PCB and this must be insulated. This requires additional space underneath or above the PCB.

A regular 1N4148 diode is maximum 2 mm thick. There just isn't enough room above or underneath the PCB to place the diodes. That's why the diode is placed 'inside' the PCB, and the diode library in Autodesk Eagle has been altered by adding a cutout in the milling layer (third image).


I could have opted for SMD components. But I have no experience with these. And I also don't have the necessary tools to place and solder these components.

Step 20: Eagle Libraries

After testing the measurements a Eagle library has been made for the Cherry MX low profile switch. This required a milling layer for mounting the switches onto the PCB.

There are two large soldering pads (S1 and S2) to solder the switch onto the PCB. And I've added four small round SMD pads (P1 to P4) on the upper side of the PCB. These locations correspond to the four switch support points. This prevents the switch from being skewed on the PCB.


Rename the library files from ".lbr.txt" to ".lbr"files before placing them in the Eagle library folder. Uploading files to Instructables is limited to certain file-types, and library files aren't part of the allowed files.

Step 21: Eagle Schematic

The PCB is designed with Autodesk Eagle. And each PCB starts with the schematic editor. This is used for designing circuit diagrams.
The circuits clearly shows the key matrixes. Only the S1 and S2 pads from the switches are used. P1 to P4 are only for placement of the switches.

These two PCB's only contain the switches and a connector. The Raspberry Pi is placed on a third PCB.

Step 22: Key Positioning

The regular distance between two keys on a mechanical keyboard equals 0.75 inch (19.05 mm). This value is defined a 1U. This keyboard has 5 different keycap sizes: 1U, 1.25U, 1.5U, 1.75U and 2,25U.

The previous Eagle schematic has to be translated to a PCB. With all keyswitches in place. I've used an Excel file to calculate all exact positions for the switches. All values are in mils (1/1000th of an Inch) who directly can be used in the Eagle element properties window.

Step 23: PCB Design

All switches and diodes have been placed by the location values in the Excel sheet from the previous step. And the connectors are placed at the top position of the PCB.

This leaves al the wires between the components. These are already defined in the schematic, and all unprocessed wires are shown by a yellow line. I started with placing all horizontal wires on the back side, and all vertical lines on the front size of the PCB.
The wires from the connector are an exception for this rule. But in the end the matrix structure became clearly visible again.


It took a few iterations to achieve this result. It is of course also possible to let the software draw the wires.

Step 24: PCB Milling

The PCB design has been converted to Gerber- and Drill-files. And these are converted to GCode file for a CNC machine. All output files are checked with an online Gcode viewer and after this final check, the production of the PCB can start.

Multiple Gcode files are used to mill a single PCB. The first files are for the bottom of the PCB. Then the board must be turned over and aligned before the other files can be processed.

The left and right sides both have 7 files each:

  • 0_left_init.gcode
  • 1_left_bottom_etch_0.6.gcode
  • 2_left_bottom_drill_0.8.gcode
  • 3_left_bottom_drill_1.0.gcode
  • 4_left_top_etch_0.6.gcode
  • 5_left_top_mill_0.6.gcode
  • 6_left_top_holes_0.8.gcode
  • 7_left_top_outline_0.8mm.gcode

And

  • 0_right_init.gcode
  • 1_right_bottom_etch_0.6.gcode
  • 2_right_top_drill.0.8.gcode
  • 3_right_top_drill.1.0.gcode
  • 4_right_top_etch_0.6.gcode
  • 5_right_top_mill.0.6.gcode
  • 6_right_top_holes_0.8.gcode
  • 7_right_top_outline_0.8.gcode

Each filename descibes the board, orientation and operation.

The first file contains the init code for the 3D printer/CNC machine.

G21
G90
M211 S1
G92 X0 Y0 Z0
G00 F50
G01 F50

It sets the print units in milimeter and sets absolute positioning. The M211 disables the software endstops. The G92 makes the current position the home position (X0 Y0 Z0). The G00 en G01 set the printer speed to 50 mm/min.

I've descibed how to mill with a 3D printer in my previous Instructable. It is important to align the boards between milling the bottom and top. I've started with two 0.8 mm holes through the PCB and the wooden Y carriage plate (at position X0,Y0 and X165,Y0). And I've used these holes to reallign the PCB when I switched between the two sides (with usage two 0.8 mm drills).

Step 25: Gcode CNC

Some of the CNC code for the PCBs has been generated with Python. These are simple scripts with functions to create the Gcode for the different holes.

The first image shows the Python output of a single mount point for a Cherry Low profile switch. Each dot is a Gcode value, and it takes 8 layers of 0.2 mm to mill a 1.6 mm PCB.

The following example creates 15 holes of 2,54 mm for the second/right PCB:

import math

# size in mm
mill = 0.8
circ = 2.54 
zmax = -22
step = 5

r = (circ-mill)/2

def hole(xpos,ypos):
    print ('G00 Z5.0')
    print ('G00 X{0:.4f} Y{1:.4f}' .format( xpos, ypos + r))

    for z in range (-2, zmax, -2):
        print ('G01 F50')
        print ('G01 Z{0:.4f}' .format(z/10))
        print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos, ypos + r))
        for i in range (0,360,step*3):
            print ('G01 X{0:.4f} Y{1:.4f}' .format(xpos + r * math.sin(math.radians(i)) , ypos + r * math.cos(math.radians(i))))
        print ('G01 X{0:.4f} Y{1:.4f}' .format( xpos, ypos + r))
                
    print ('G00 Z5.0')

hole(11.731625, 8.73125)
hole(65.7225,  8.73125)
hole(113.3475,  8.73125)
hole(191.35725, 21.43125)
hole(183.35625, 40.48125)
hole(138.1125,40.48125)
hole(92.86875,40.48125)
hole(35.71875,40.48125)
hole(11.731625, 59.53125)
hole(36.83, 78.58125)
hole(74.93, 78.58125)
hole(113.03, 78.58125)
hole(160.655, 78.58125)
hole(51.47825, 109.22)
hole(137.95375, 109.22)

The output is very simple Gcode which can be executed by a CNC/3D printer.

G00 Z5.0
G00 X11.7316 Y9.6012
G01 F50
G01 Z-0.2000
G01 X11.7316 Y9.6012
G01 X11.7316 Y9.6012
G01 X11.9568 Y9.5716
G01 X12.1666 Y9.4847
G01 X12.3468 Y9.3464
G01 X12.4851 Y9.1662
G01 X12.5720 Y8.9564
G01 X12.6016 Y8.7312
G01 X12.5720 Y8.5061

The first line moves the toolhead 5 mm above the PCB. And the second line moves it to the start position. Then the speed is reduced to 50 mm/sec and the tool moves 0.2 mm below the surface of the pcb. The other lines define a circle in steps of 5 degrees. This is repeated in steps of 0.2 mm until the hole is completed.

The locations for the mount points and diodes are extracted from the Eagle board files. This is a readable XML-file which contains all layers, packages, signals and elements of the PCB. And each element has an x and y position:

<element name="D11" library="Cherry Keyboard Diode" package="CUSTOM4" value="1N4148THROUGH" x="26.19375" y="84.93125" smashed="yes" rot="R270">
<element name="D12" library="Cherry Keyboard Diode" package="CUSTOM4" value="1N4148THROUGH" x="45.24375" y="84.93125" smashed="yes" rot="R270">
<element name="D13" library="Cherry Keyboard Diode" package="CUSTOM4" value="1N4148THROUGH" x="64.29375" y="84.93125" smashed="yes" rot="R270">
<element name="D14" library="Cherry Keyboard Diode" package="CUSTOM4" value="1N4148THROUGH" x="83.34375" y="84.93125" smashed="yes" rot="R270">
<element name="D15" library="Cherry Keyboard Diode" package="CUSTOM4" value="1N4148THROUGH" x="102.39375" y="84.93125" smashed="yes" rot="R270">
<element name="S11" library="Cherry MX Low Profile" package="CHERRY_MX_LOW2" value="~" x="35.71875" y="88.10625" smashed="yes">
<element name="S12" library="Cherry MX Low Profile" package="CHERRY_MX_LOW2" value="1" x="54.76875" y="88.10625" smashed="yes">
<element name="S13" library="Cherry MX Low Profile" package="CHERRY_MX_LOW2" value="2" x="73.81875" y="88.10625" smashed="yes">
<element name="S14" library="Cherry MX Low Profile" package="CHERRY_MX_LOW2" value="3" x="92.86875" y="88.10625" smashed="yes">
<element name="S15" library="Cherry MX Low Profile" package="CHERRY_MX_LOW2" value="4" x="111.91875" y="88.10625" smashed="yes">

All x and y positions are used to determine the exact position about where to mill.

This results in several Gcode files:

  • Mount holes + Diode Placement for the left PCB.
  • Drill holes for the left PCB.
  • Mount holes + Diode Placement for the right PCB.
  • Drill holes for the right PCB.
  • Drill holes for the controller board.

Since the Eagle board file is used for the locations, all milling operations are on the upper side of the PCB.

Step 26: PCB Prototype

I've printed the Eagle design on paper, and placed all keycaps at the right position for a first check. But milling the prototype it a real test to see if all components fit and are in place.

All cherry MX switches fit in the PCB, and the mount keeps them in place without blocking them. There is plenty room for the diodes.

Step 27: PCB: JLCPCB

Some of my Instructables are noticed by other websites. And in one of the comments about my "3D printer to milling" Instructable someone noticed PCBShopper: A price comparison site for printed circuits boards.

I have converted my 3D printer to make PCBs for two reasons: Speed and Price. A large 20 x 15 cm board is about $2 on Aliexpress. It takes some time for them to arrive, but I always keep some of these PCBs in stock.

This keyboard requires two large PCBs. And most (local) PCB manufacturers estimate the manufacturing costs of these boards above $100, without shipment costs and customs. That's why I started to mill my PCB prototype.

But PCBShopper showed different prices. I could get 5 (identical) large boards for $15 at JLCPCB. A 10 x 10 cm double sided board is only $2 (also 5 pieces). And where most sites only have expensive express mail (2-3 days), this site allows a cheaper shipping method (2-3 weeks).
All PCBs including shipping costs would be about 30 euros. I only need one or two boards each, so I can't say it's only 6 euros per keyboard. But still relatively cheap.

That is why, after a few adjustments, all PCBs are made by JLPCB.


When making the PCBs yourself, the vias and holes are not conductive to the other side. Because of this, the components had to have all connections at the bottom side of the PCB. By ordering at JLPCB it's possible to delete a number of vias, and to further simplify the PCB.
The wires can also be much thinner, but I didn't adjust these.

Afterwards, It was not necessary to make the PCB prototype. But the experience I gained will certainly come in handy in another Instructable.

Step 28: From Eagle to Fusion 360

The big advantage of creating a PCB with Autodesk Eagle is the integration with Fusion 360. The PCB can be pushed to a 3D design in Fusion 360. This shows a PCB component in the object browser.

I made and adjusted some libraries in the Eagle software myself. However, I have not made any 3D designs in the libraries. This causes the components to be shown as squares. But the fact that the PCB is available in Fusion 360 makes the designing of a keyboard housing a lot easier.

Step 29: Component Positions in Fusion 360

All initial component locations are calculated with an Excel file. These locations have been used to create the Eagle PCB files. The PCB has been uploaded to Fusion 360, and this shows the exact positions of allmost all components.

As mentioned in a previous step, I haven't created the 3D library components in Eagle. This means that I don't see the exact dimensions of the switches and diodes. And the switches don't have an exact center point, which is required to draw the square mount holes. I can create all these points manually, but I've already have them in Excel and in the Eagle PCB definition file.

The Eagle file is an XML file (first image), and I've extracted all component positions into a text file with the following format:

X11.731625 Y8.73125
X65.7225 Y8.73125
X113.3475 Y8.73125
X35.71875 Y40.48125
X183.35625 Y40.48125
X138.1125 Y40.48125

This file can be read by a Python script which can be executed as a Fusion 360 add-in.

import adsk.core, adsk.fusion, traceback

# forums.autodesk.com/t5/fusion-360-api-and-scripts/python-script-to-create-a-chain-of-lines/td-p/7518674

def run(context):
    ui = None
    try: 
        xpos = '0.0000'
        ypos = '0.0000'

        app = adsk.core.Application.get()
        ui = app.userInterface

        design = app.activeProduct
        rootComp = design.rootComponent
        sketch = rootComp.sketches.add (rootComp.xYConstructionPlane)

        points = adsk.core.ObjectCollection.create()

        ui.messageBox('Start')

        infl = open ("D:\left.txt", "r" ) 

        for line in infl:

            if line[:3] == 'G00' or line[:3] == 'G01' or line[:1] == 'X' or line[:1] == 'Y':
                cmds = line.split()
                for comd in cmds:
                    coms = str(comd)
                    if coms[:1] == 'X':
                        xpos = (coms[1:9]) 
                    if coms[:1] == 'Y':
                        ypos = (coms[1:9]) 
                        
            points.add(adsk.core.Point3D.create(float(xpos)/10, float(ypos)/10, 0))

        for i in range(points.count):
            pt1 = points.item(i)
            sketch.sketchCurves.sketchCircles.addByCenterRadius(pt1, 0.1)

        points.clear()

        ui.messageBox('Einde')
                                               
    except:
        if ui:
            ui.messageBox('Failed:\n{}'.format(traceback.format_exc()))

This python script creates a new sketch with small circles at all X and Y position in the inputfile (second image).

And these positions have been used to create the mount points for the switches.

Step 30: Fusion 360 Parameters

The regular Cherry MX switches require a 14.0 x 14.0 mm mount hole. I've measured the low profile version, and this gave a slightly different mount size of 14.0 x 13.8 mm.

Most of my 3D design are very simple, and require little steps. But with this design I wanted to be able to change certain sizes afterwards. That is why I used parameters instead of fixed values. For example: I started with a mount size of 14.0 x 13.8 mm, which has been altered to 14 x 14 mm during the design.
After changing the corresponding parameter, the design was immediately updated. Without this parameter I would have had to adjust all mount holes one by one.

Step 31: Fusion 360 Design

The PCB has been exported to Fusion 360 with the Eagle PCB software, and all switch positions have been placed on a sketch by the Python script. These two items have been used to create the keyboard casing.

The top layer of the casing is 1.6 mm thick and this supports the Cherry MX switches.

Step 32: 3D Print

The Fusion 360 files are converted to STL files and are printed on an Anet 3D printer. The two parts are printed with PETG on a clean glass plate. This results in a smooth top layer.

Always be aware of the shrinking of the material. Especially with ABS. The switches must fit well, and the entire assembly must be straight.

Step 33: Shift Keys Modifications

The shift keys have a width of 2.25U. And this keyboard uses two switches instead of one. Therefore it takes more force to press the shift keys as it takes to press the other keys.

This has been solved by shortening the springs inside the switches. Only remove a few mm (one full winding) from the upper part of the springs. The cutted part of the spring has to be placed inside the red part of the switch.

After this, less force is needed for the shift keys than before. It's still a little more than the regular keys, but the difference is much smaller.

Step 34: Multi USB Device

The previous steps defined a single USB device with only one function: A keyboard.
It is possible to place multiple hardware functions (mouse and keyboard) in a single USB report. But it requires a client (Windows) driver to handle this 'special' report data. And I don't want to write a new device driver for this keyboard.

It is therefore easier to define multiple USB devices and use existing drivers. The keyboard will be 'shown' as two USB devices at the client.

The following code will create a keyboard and a mouse.

#!/bin/bash
#
# Gosse Adema 
# 16 feb 2020
#
# USB Armory:   https://github.com/ckuethe/usbarmory/wiki/USB-Gad...
# Matt Porter:  https://github.com/ckuethe/usbarmory/wiki/USB-Gad...
#

# If USB Gadget configfs support is enabled we’ll have a usb_gadget subdirectory present
# By creating the g1 directory, we’ve instantiated a new gadget device template to fill in.
cd /sys/kernel/config/usb_gadget/
mkdir -p g1
cd g1

# Write in our vendor/product IDs
echo 0x1209 > idVendor  #  https://github.com/ckuethe/usbarmory/wiki/USB-Gad...
echo 0x4741 > idProduct # PiBoard
echo 0x0100 > bcdDevice # v1.0.0
echo 0x0200 > bcdUSB # USB2

# Instantiate English language strings
mkdir -p strings/0x409
echo "3.14159"     > strings/0x409/serialnumber
echo "Gosse Adema" > strings/0x409/manufacturer
echo "PiBoard"     > strings/0x409/product

# Create function instances
mkdir -p functions/hid.usb0
mkdir -p functions/hid.usb1

# Define a Keyboard
# keybrd.hid
echo 1 > functions/hid.usb0/protocol
echo 1 > functions/hid.usb0/subclass
echo 8 > functions/hid.usb0/report_length
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\x65\\x05\\x07\\x19\\x00\\x29\\x65\\x81\\x00\\xc0 > functions/hid.usb0/report_desc

# Define a mouse
# mouse.hid
echo 1 > functions/hid.usb1/protocol
echo 1 > functions/hid.usb1/subclass
echo 4 > functions/hid.usb1/report_length
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x03\\x15\\x00\\x25\\x01\\x95\\x03\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x05\\x81\\x03\\x05\\x01\\x09\\x30\\x09\\x31\\x09\\x38\\x15\\x81\\x25\\x7f\\x75\\x08\\x95\\x03\\x81\\x06\\xc0\\xc0 > functions/hid.usb1/report_desc

# Create a configuration instance
mkdir -p configs/c.1
echo 250 > configs/c.1/MaxPower

# Create English language strings and write in a description for this device configuration
mkdir -p configs/c.1/strings/0x409
echo "PiBoard 3.1415" > configs/c.1/strings/0x409/configuration

# Bind each of our function instances to this configuration
ln -s functions/hid.usb0 configs/c.1
ln -s functions/hid.usb1 configs/c.1

# Attach the created gadget device to our UDC driver.
ls /sys/class/udc > UDC

Download the "PiBoard_usb.txt" file and rename it to "PiBoard_usb". Save this file in the /usr/bin-directory and make it executable with "sudo chmod +x /usr/bin/PiBoard_usb".

The USB device must be created automatically after starting the Raspberry Pi. This requires an additional line in /etc/rc.local ("sudo nano /etc/rc.local"):

# Enable USB device
/usr/bin/PiBoard_usb

There should be two usb devices (/dev/hidg0 and /dev/hidg1) after a reboot of the Raspberry Pi.


The mousekeyboard.py example file alters a 16 button keypad into a mouse and a keyboard. The mouse movement is handled by writing report data (4 bytes) to the /dev/hidg1-device, instead of to the /dev/hidg0 keyboard device.
And the following code moves the mouse in a square across the screen ("sudo python3 square.py"):

MouseButton1 = 1 ## left
MouseButton2 = 2 ## right
MouseButton3 = 4 ## middle

def report_mouse(b,x,y,w):
    if x<0:
        x=x+256
    if y<0:
        y=y+256
    if w<0:
        w=w+256
    with open('/dev/hidg1', 'rb+') as fm:
        fm.write((b).to_bytes(1, byteorder='big')+ \
                 (x).to_bytes(1, byteorder='big')+ \
                 (y).to_bytes(1, byteorder='big')+ \
                 (w).to_bytes(1, byteorder='big'))

for j in range (1, 25):
    for i in range (1, 25):
        report_mouse (0,5,0,0)

    for i in range (1, 25):
        report_mouse (0,0,5,0)

    for i in range (1, 25):
        report_mouse (0,-5,0,0)

    for i in range (1, 25):
        report_mouse (0,0,-5,0)

Step 35: Assembly

Solder the diodes on the downside of the PCBs. Make sure the diodes are placed 'inside' the PCB.

There is no need to solder the via's. The PCB has large soldering pads and wires. This makes it easy to solder the components, even for someone with not too much experience.

Place the 3D printed housing with the 2.5 mm bolts and nuts, and make sure it fits well. It may be necessary to polish/file some PCB material away near the sides. Place the the switches and press them firmly. Make sure that they are ALL properly straightened and solder them in place.

Finally solder the connector onto the PCB.


Note: There is a difference in diode placement between the left and right board!

Step 36: Keycaps and Dampeners

This keyboard uses regular keycaps. There aren't much low profile keycaps available. The only ones I found can't be used for a split keyboard. The UHK keycaps fit very well on the Cherry MX low profile switches. But if you press the 1U keys firmly, they will be locked. It is therefore necessary to remove some plastic from the corners of the switches. Then you can press them entirely without locking the keys.

After modifying the switches, the keycaps can hit the bottom plate. This has been solved by placing switch dampeners. These are placed between the keycap and the switch, providing some additional room. This prevents additional sound when typing with the keyboard.


The compatibility of the regular keycaps with the Cherry MX low profile switches was not entirely certain when I started this Instructable. Fortunately, the UHK keycaps can be used with minimal adjustments to the swiches.
Images show that the manufacturers who use these switches have created special keycaps for their mechanical keyboards. Unfortunately, these keycaps aren't yet generally available.

Step 37: First Test

The two keyboard parts will be connected to the Raspberry Pi GPIO ports. This requires a third PCB board. Until this board is finished breadboard wires are used to start programming the firmware.

The first Python code can be started with "sudo python3 testkeys.py"

import RPi.GPIO as GPIO
import time

GPIO.setmode(GPIO.BCM)

MaxtrixRows = {8, 25, 7, 24, 23}
MaxtrixCols = {9, 10, 22, 27, 17, 4, 18, 6, 13, 19, 26, 21, 20, 16, 12}

for RowValue in MaxtrixRows:
   GPIO.setup(RowValue, GPIO.IN, pull_up_down = GPIO.PUD_DOWN)

for ColValue in MaxtrixCols:
   GPIO.setup(ColValue, GPIO.OUT)
   GPIO.output(ColValue, 0)

while True:

   time.sleep (0.1)
   print ("-")
   for ColValue in MaxtrixCols:
      GPIO.output(ColValue, 1)

      for RowValue in MaxtrixRows:
         if(GPIO.input(RowValue) == 1):
            print (str(RowValue) + " " + str(ColValue))

      GPIO.output(ColValue, 0)

GPIO.cleanup()

It's a more advanced version of the 4x4 keypad code. It has a list of all GPIO ports stored in two arrays/lists. Which are used in the for-statements to loop through all rows and columns.

I didn't use any pull-up or pull-down resistors in my PCB design. The Raspberry Pi has these build-in. These are set to PUD_DOWN for each Row, and all column GPIO pins are set to OUTput pins.

Inside the infinite loop all column-row combinations are scanned: A column is set high, all rows are scanned, and the column is set to low. A high value indicates a key pressed.

This is the scanning part of the firmware. It only reads the input from the keys. The USB output depends on all measured key values. This should always test for the keyboard layers first.


The previous program printed the matrix location of all keys pressed. This must be translated into USB output values according the HID usage tables specifications. This can be done with multi dimensional matrixes in Python.

Th HID usage list starts with:

  • 0 - Reserved - No event indicated
  • 1 - Keyboard ErrorRollOver
  • 2 - Keyboard POSTfail
  • 3 - Keyboard ErrorUndefined
  • 4 - letter 'a'

The output value for my keyboard starts with the value 4 for the letter 'a'. The values 1, 2 and 3 are not used for key values. Multiple matrixes are used to map the matrix position to a HID value. This test firmware starts with 2: One for the regular keys, and one for the Function-layer. This will

I have 'cheated' with some values for special keys: the values 1 and 2 are used for the different layer keys (FN and Mouse). And the bit-values for the modifier keys (shift, control, ...) are stored as a negative value.

MaxtrixRows = [8, 25, 7, 24, 23]
MaxtrixCols = [18, 4, 17, 27, 22, 10, 9, 6, 13, 19, 26, 21, 20, 16, 12]

Hid = [[  53,  30,  31,  32,  33,  34,  35,  36,  37,  38,  39,  45,  46,  42,   0]
      ,[   0,  43,  20,  26,   8,  21,  23,  28,  24,  12,  18,  19,  47,  48,  49]
      ,[   0,   2,   4,  22,   7,   9,  10,  11,  13,  14,  15,  51,  52,  40,   0]
      ,[   0,  -2,  29,  27,   6,  25,   5,  17,  16,  54,  55,  56, -32,  82,   0]
      ,[   0,  -1,  -8,  -4,   1,  44,   0,  44,   1, -64,-128, -16,  80,  81,  79]]

HFn = [[  41,  58,  59,  60,  61,  62,  63,  64,  65,  66,  67,  68,  69,  42,   0]
      ,[   0,  43,  41,   0,   0,   0,   0,  75,  74,  82,  77,  76,  70,  71,  72]
      ,[   0,   2,  57,   0,   0,   0,   0,  78,  80,  81,  79,  73,   0,  40,   0]
      ,[   0,  -2,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0, -32,  82,   0]
      ,[   0,  -1,  -8,  -4,   1,  44,   0,  44,   1, -64,-128, -16,  80,  81,  79]]

The keyboard scanner just scans the entire matrix regardless of function layers and control keys, and stores these in memory. It doesn't know about USB codes and doesn't care about the number of keys pressed.

The keyboard processer first determines the required function layer. Then uses the regular HID matrix (upper Hid) or the function matrix (lower HFn). Then all negative values are used for the first modifier-byte and all other values are used for the 6 character bytes. The processer should give an error when these are more than 6 keys.

Note: The value of 0 in the matrix means unused.

Step 38: Felt Underlay

The 3D printed housing and the PCB provide a sturdy combination. And a 3D printed bottom plate will not make the keyboard any stronger. That is why the bottom of the keyboard is made of a sheet (150 x 200 mm) anti scratch felt. This is about 2.5 mm (0,1 inch) thick and contains a self adhesive layer.

The felt is made to measure with a laser cutter.

The 3d printed top case has a thickness of 1.6 mm. The PCB is 1.6 mm and the felt is 2.5 mm. This results in a keyboard base of 5.7 mm (just under 0,23 inch).

Step 39: Controller Board

The two keyboard parts are connected to the controller board by flatcables with 16 wires. I could have connected the Raspberry Pi onto one of the keyboard parts, but this wouldn't allow the board to be made with a CNC machine. And I also wanted to be flexible with the microcontroller/microprocessor.
This version of the keyboard contains a Raspberry Pi controller board. But It's also posible to make another controller board with a Teensy USB development board.

The board has 3 extra pins for a serial connector and 2 additional GPIO ports which can be used for an off-button and/or LEDs.

Step 40: Controller Case

The controller case has been designed in Fusion 360.

Step 41: First Keyboard Firmware

Although it sounds complicated to write your own firmware, this turned out to be surprisingly easy. It is important to think logically and to take small steps. It also helps to make small test programs to try the different parts separately.

The firmware of this keyboard runs with Python. And I'll describe a first version in this step. It starts with the required libraries and selecting the GPIO selection method.

import RPi.GPIO as GPIO
import time

GPIO.setmode(GPIO.BCM)

The report_keyboard function writes the report_descriptor to the USB device. This requires a byte (c) with the control characters and an array of minimal 6 bytes (k) for the keys. Only the first 6 bytes (0 to 5) are used, all other bytes in the array are ignored.

def report_keyboard(c,k):
    with open('/dev/hidg0', 'rb+') as fk:
        fk.write((c).to_bytes(1, byteorder='big')+    \
                 (0).to_bytes(1, byteorder='big')+    \
                 (k[0]).to_bytes(1, byteorder='big')+ \
                 (k[1]).to_bytes(1, byteorder='big')+ \
                 (k[2]).to_bytes(1, byteorder='big')+ \
                 (k[3]).to_bytes(1, byteorder='big')+ \
                 (k[4]).to_bytes(1, byteorder='big')+ \
                 (k[5]).to_bytes(1, byteorder='big'))

The GPIO ports are placed inside two Python lists. These are used to scan the keyboard matrix. Each row must have an active pull-down resistor. Since input is the default value, we only have to change the column ports to the value OUT.

MaxtrixRows = [8, 25, 7, 24, 23]
MaxtrixCols = [18, 4, 17, 27, 22, 10, 9, 6, 13, 19, 26, 21, 20, 16, 12]

for RowValue in MaxtrixRows:
   GPIO.setup(RowValue, GPIO.IN, pull_up_down = GPIO.PUD_DOWN)

for ColValue in MaxtrixCols:
   GPIO.setup(ColValue, GPIO.OUT)
   GPIO.output(ColValue, 0)

All HID codes are placed in multi dimensional lists. The Hid list is used for the default layer. And the HFn list is used for the function layer. This version doesn't support the mod-layer. This would require a third list.

Hid = [[  53,  30,  31,  32,  33,  34,  35,  36,  37,  38,  39,  45,  46,  42,   0]
      ,[   0,  43,  20,  26,   8,  21,  23,  28,  24,  12,  18,  19,  47,  48,  49]
      ,[   0,   2,   4,  22,   7,   9,  10,  11,  13,  14,  15,  51,  52,  40,   0]
      ,[   0,  -2,  29,  27,   6,  25,   5,  17,  16,  54,  55,  56, -32,  82,   0]
      ,[   0,  -1,  -8,  -4,   1,  44,   0,  44,   1, -64,-128, -16,  80,  81,  79]]

HFn = [[  41,  58,  59,  60,  61,  62,  63,  64,  65,  66,  67,  68,  69,  42,   0]
      ,[   0,  43,  41,   0,   0,   0,   0,  75,  74,  82,  77,  76,  70,  71,  72]
      ,[   0,   2,  57,   0,   0,   0,   0,  78,  80,  81,  79,  73,   0,  40,   0]
      ,[   0,  -2,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0, -32,  82,   0]
      ,[   0,  -1,  -8,  -4,   1,  44,   0,  44,   1, -64,-128, -16,  80,  81,  79]]

Some values which are used to search for the function layers. Followed by the initial keyboard output value.

KeyMouse   = [2,1] # used for quick identification of layers
KeyFnLeft  = [4,4]
KeyFnRight = [4,8]
KeyMod     = [4,5]

OldCtrl = 0
OldKeys = [0,0,0,0,0,0]

Infinitive loop with a delay:

while True:

   time.sleep (0.05)

Scan the keyboard and store the pressed switches in a "stack". This scanner only stores the row,column values of the keys. There is no additional processing.

   #
   # KeyScan
   #

   KeyStack = []

   ColNo = 0
   for ColValue in MaxtrixCols:
      GPIO.output(ColValue, 1)

      RowNo = 0
      for RowValue in MaxtrixRows:
         if(GPIO.input(RowValue) == 1):
            KeyStack.append([RowNo, ColNo])
         RowNo = RowNo + 1

      GPIO.output(ColValue, 0)
      ColNo = ColNo + 1

The keyprocessor translates the KeyStack to USB output values. The results are stored in a list for regular keys and a integer for the modifier keys. The 'pressed' variable stores the number of regular keys in the list.

   #
   # KeyProcessor
   #

   HidList = []
   HidCtrl = 0
   Pressed = 0

The count function in the KeyStack list is used to find the location of a function key. It searches for [4.4] and [4.8] and returns the position. The value of 0 is returned when the value isn't in the list.

All KeyStack values will be processed and the matrix-list is used to find the required HID values. A negative values indicate a control-key. These are all placed in the HidCtrl variable. All other keys (from 'a') are stored in the HidList variable.
The matrix is always scanned in the same order. Pressing two keys, at the same time, always result in the same order in the HidList.

   # Function Layer
   if KeyStack.count (KeyFnLeft) + KeyStack.count (KeyFnRight) > 0:

      for r,c in KeyStack:
         Key = HFn [r][c]

         if Key < 0:
            HidCtrl = HidCtrl - Key

         elif Key >= 4:
            HidList.append (Key)
            Pressed = Pressed + 1

The mouse layer isn't implemented yet.

   # Mouse Layer
   elif KeyStack.count (KeyMouse):
      print ('mouse')

The regular layer is equal to the function layer. Only this code block uses the Hid-matrix-list for the required HID values.

   # Regular Layer
   else:
      for r,c in KeyStack:
         Key = Hid [r][c]

         if Key < 0: # Control keys
            HidCtrl = HidCtrl - Key

         elif Key >= 4: # No Layer keys (1,2 or 3)
            HidList.append (Key)
            Pressed = Pressed + 1

The keyboard shouldn't give duplicate HID values (the only duplicates in the HID matrix is the space bar).

The report size for the keyboard is 8 bytes which of 6 are regular key characters. We have to append bytes to the list until there are (at least) 6 bytes. This is required for the report_keyboard function.

   # No duplicates and 6 values for the keys
   UseCtrl = HidCtrl
   UseList = list(dict.fromkeys(HidList))

   # Always 6 bytes
   for i in range (len(UseList), 6):
      UseList.append (0)

Return the HID list to the USB client, but only if this is less than 7 regular characters.

And we only report changes to the input switches. After which the old values are stored for a next comparison.

   # Only report errors or changes
   if Pressed > 6:
      report_keyboard(0 ,[1,1,1,1,1,1])
      print ('error')
      OldCtrl = 0
      OldKeys = [0,0,0,0,0,0]

   else:
      if ((OldKeys > UseList) - (OldKeys < UseList)) != 0 or (OldCtrl != UseCtrl):
         report_keyboard(UseCtrl,UseList)
         OldCtrl = UseCtrl
         OldKeys = UseList 

Place the firmware on the Raspberry Pi and start it with "sudo python3 first_firmware.py". It isn't started automaticly after booting for now..

Step 42: Mouse Layer Firmware

The mouse layer isn't implemented in the previous firmware example. It requires a second report-function to write the current button, x, y and wheel-values to the USB device:

ValMouseButton1 = 1 ## left
ValMouseButton2 = 2 ## right
ValMouseButton3 = 4 ## middle

def report_mouse(b,x,y,w):
    if x<0:
        x=x+256
    if y<0:
        y=y+256
    if w<0:
        w=w+256
    with open('/dev/hidg1', 'rb+') as fm:
        fm.write((b).to_bytes(1, byteorder='big')+ \
                 (x).to_bytes(1, byteorder='big')+ \
                 (y).to_bytes(1, byteorder='big')+ \
                 (w).to_bytes(1, byteorder='big'))

This function uses +1 for positive movements and -1 for negative (e,g, left) movements. This has to be converted to the required (hexadecimal) negative values: 255 is equal to -1, 254 is equal to -2....

If the Mouse key is pressed the KeyMouse value [2,1] will be in the KeyStack list. And after this, all keys in the KeyStack are processed one by one:

   elif KeyStack.count (KeyMouse):

      for r,c in KeyStack:

         Key = HMs [r][c]

         if KeyStack.count (KeyMod):
            MouseSpeed = 2 # Double the mouse speed with MOD key
         else:
            MouseSpeed = 1

         if Key >= 30 and Key <= 39: 
            ValMouseMove = ((Key - 30) * 2) + 1 # Set mouse speed with 1, 2, 3

         if Key == 79	:
            MouseMoveX = MouseMoveX + (ValMouseMove * MouseSpeed)
         if Key == 80	:
            MouseMoveX = MouseMoveX - (ValMouseMove * MouseSpeed)
         if Key == 81:
            MouseMoveY = MouseMoveY + (ValMouseMove * MouseSpeed)
         if Key == 82:
            MouseMoveY = MouseMoveY - (ValMouseMove * MouseSpeed)
         if Key == 75:
            MouseMoveW = MouseMoveW + (ValMouseMove * MouseSpeed)
         if Key == 78:
            MouseMoveW = MouseMoveW - (ValMouseMove * MouseSpeed)
         if Key == 4: # (a)
            MousePress = MousePress + ValMouseButton1
         if Key == 5: # (b)
            MousePress = MousePress + ValMouseButton2
         if Key == 6: # (c)
            MousePress = MousePress + ValMouseButton3

The mouse key in this example blocks all 'regular' keys. This is not necessary, it'is possible to include the characters a-z in the mouse layer.
I've made some changes: The 1 to 9 keys are used to change the mouse speed. Using the + and - keys is very sensitive. And the MOD key doubles the mouse speed.


The Pyhton code must be run with 'sudo' or as root-user. Running the code as 'pi'-user will give an error:

Traceback (most recent call last):
  File "firmware_mouse.py", line 212, in <module>
    report_keyboard(UseCtrl,UseList)
  File "firmware_mouse.py", line 17, in report_keyboard
    with open('/dev/hidg0', 'rb+') as fk:
PermissionError: [Errno 13] Permission denied: '/dev/hidg0'

Stopping the keyboard firmware from the Raspberry Pi using ctrl-C stops the Python code when started with "sudo Python3 firmware.py". This prevents the code from sending an empty report. And this is the 'end of last character' pressed signal for the connected client.
The following code gives a clean exit after pressing ctrl-C. It sends an empty report to clear all previous reports.

def handler(signum, frame):
    report_keyboard(0,[0,0,0,0,0,0])
    report_mouse(0,0,0,0)
    GPIO.cleanup()
    sys.exit()
    
signal.signal(signal.SIGINT, handler)

Step 43: Firmware Improvements

This is the firmware which I'm currently using on my readonly SD card. It has some code improvements and some additional features.

The keyscan code has been optimized to 7 lines of code. This scans all keys and places the results into the Keystack list.

   # KeyScan

   KeyStack = []

   for ColNo, ColValue in enumerate (MaxtrixCols):
      GPIO.output(ColValue, 1)
      for RowNo, RowValue in enumerate (MaxtrixRows):
         if(GPIO.input(RowValue) == 1):
            KeyStack.append([RowNo, ColNo])
      GPIO.output(ColValue, 0)

The MOD+shift key is used as caps-lock. And I noticed that the key order in the USB report shouldn't matter, but it does. My windows PC prints the pressed keys in report order. The HidList with the value [16,4,6,21,18,0] is printed as 'macro'. And the current version of this firmware uses this 'feature:

   elif KeyStack.count (KeyMod):
      for r,c in KeyStack:
 
         if Mod [r][c] != [0]:
            HidList = Mod [r][c]
            break

This reduced the Mod-key code to 5 lines. The break statement allows only one Mod-key combination at the same time, and all modifier keys are 'disabled'. Pressing Mod-Win doesn't change the first byte of the output report. The content of the matrix is used for the 6 character bytes instead.
The keyboard is always scanned in the same order. This is defined in the MatrixRow and MatrixCols lists. This order can be changed to give priority to some Mod-key combinations

The modifier matrix list contains lists of maximum six values. This value is placed into the Mod-List, which is directly passed to the output report. The client accepts a maximum of two the of same values one after the other. Mod+space can give two spaces, not three (in the same report).
This version is limited to 6 characters because it only supports a single output report. A future version has to allow multiple reports for a single keystroke.

The mod-matrix contains a list for every key inside it:

Mod = [[    [0]             ,    [0],   [0],        [0],
      ,[    [16,4,6,21,18,0],    [0],   [0],        [0],
      ,[    [16,4,6,21,18,0],    [0],   [0],        [0],
      ,[    [16,4,6,21,18,0],   [57],   [0],        [0],
      ,[    [16,4,6,21,18,0],    [0],   [0],    [44,44],

The [16,4,6,21,18,0] sends the word 'macro', the [57] is caps-lock and the [44,44] gives two spaces. These values can be replaced with your most-used words (insert, delete, update, select, from, where....).

These key-matrixes should be stored in separate files with a certain key to reload them after changing their content.


The macro keys on the left are still unused. The idea is to make a 'macro recorder' which records to the tmp-device.

Step 44: Readonly SD Card

The Raspberri Pi is a computer, and it must be shutdown before powering off. Just like a Windows computer. One of my previous projects used an off-button connected to a GPIO pin to shutdown the operating system. This is required to prevent a corrupt SD card due to write actions from the operating system.

There is a page on the Adafrult website which describes how to make Raspbian OS readlonly. This prevents write actions to the SD card so it can't become corrupted. This should be the last step, after testing the final firmware and modifications (I'm still working on the firmware macros).

The controller board has two free GPIO pins. These can be used for LEDs, or for the boot-time read/write jumper as descibed on the Adafruit website. I'm using two SD cards instead. One for development and one for regular usage. The current keyboard firmware is very small. And it is be placed on the /boot-partition in the Keyboard-folder. Although it's read-only filesystem for the Raspberry Pi, placing the SD card in a computer still makes it writable.

The Adafruit website uses a single script with many options. The following steps are the minimum required steps. They require 4 scripts in the /home/pi folder (remove the '.txt' in the filename): pi_append1, pi_append2, pi_replace and pi_replaceAppend. These files are extracted functions from the adafruit script. And they must be executable:

sudo su -
cd /home/pi
chmod +x pi_append1 pi_append2 pi_replace pi_replaceAppend

The firmware.py file must be executable and placed in the /boot/Keyboard folder:

mkdir -p /boot/Keyboard
vi /boot/Keyboard/firmware.py
chmod +x /boot/Keyboard/firmware.py

It will be started after booting the Raspberry Pi. Add the following to "/etc/rc.local":

# Start Keyboard Firmware
sleep 1
/usr/bin/python3 /boot/Keyboard/firmware.py > /tmp/firmware.log 2>&1 &

Reboot the Raspberry Pi and check if the keyboard works.

Remove some packages and replace the current log management with busybox

apt-get remove --purge triggerhappy logrotate dphys-swapfile fake-hwclock
apt-get autoremove --purge 
apt-get install ntp # (Requires network) 
apt-get install busybox-syslogd
dpkg --purge rsyslog

From now use "logread" to read the Raspberry Pi messages:

logread
Feb 16 19:59:19 keyboard syslog.info syslogd started: BusyBox v1.30.1

The logread gave some errors for the following services. These have to be disabled:

systemctl stop getty@ttyGS0
systemctl disable getty@ttyGS0

# systemctl stop ntp
# systemctl disable ntp

Enable fastboot,disable the swapfile and boot read-only:

cd /home/pi
./pi_append2 /boot/cmdline.txt fastboot fastboot
./pi_append2 /boot/cmdline.txt noswap noswap
./pi_append2 /boot/cmdline.txt ro^o^t ro

This adds "fastboot noswap ro" to the cmdline.txt-file.

Make SSH work:

./pi_replaceAppend /etc/ssh/sshd_config "^.*UsePrivilegeSeparation.*$" "UsePrivilegeSeparation no"

Make spool work

rm -rf /var/spool
ln -s /tmp /var/spool
./pi_replace /usr/lib/tmpfiles.d/var.conf "spool\s*0755" "spool 1777"

Change fstab to readonly filesystems:

./pi_replace /etc/fstab "vfat\s*defaults\s" "vfat    defaults,ro "
./pi_replace /etc/fstab "ext4\s*defaults,noatime\s" "ext4    defaults,noatime,ro "
./pi_append1 /etc/fstab "/var/log" "tmpfs /var/log tmpfs nodev,nosuid 0 0"
./pi_append1 /etc/fstab "/var/tmp" "tmpfs /var/tmp tmpfs nodev,nosuid 0 0"
./pi_append1 /etc/fstab "\s/tmp"   "tmpfs /tmp    tmpfs nodev,nosuid 0 0"

Rebooting the Raspberry Pi will enable all changes. And the filesystems are readonly or temporary:

df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/root        29G  1.4G   27G   5% /
devtmpfs        212M     0  212M   0% /dev
tmpfs           217M     0  217M   0% /dev/shm
tmpfs           217M  5.8M  211M   3% /run
tmpfs           5.0M     0  5.0M   0% /run/lock
tmpfs           217M     0  217M   0% /sys/fs/cgroup
tmpfs           217M  8.0K  217M   1% /var/log
tmpfs           217M     0  217M   0% /var/tmp
tmpfs           217M     0  217M   0% /tmp
/dev/mmcblk0p1  253M   53M  200M  21% /boot
tmpfs            44M     0   44M   0% /run/user/1000

After these steps the firmware can only be changed by using a computer with an SD-card reader. The SD card is only readonly for the Raspberry Pi.

It is possible to logon to the Raspberry Pi and 'stop' the keyboard firmware. The command "ps -ef | grep firmware.py" gives a process number. This can be stopped with the kill command:.

sudo su -
kill -9 460
cp /boot/Keyboard/firmware.py /tmp/firmware.py
python3 /tmp/firmware.py > /tmp/firmware.log 2>&1 &

The cp-statement copies the firmware to the tmp-directory. This is an in memory read-write directory. This file can be altered to modify the firmware. But it will be lost after a reboot.

Step 45: Final Words

It took some time to build this keyboard and write this Instructable. And I'm still adding new features to the firmware.
With the help of this description it should be possible to make such a keyboard yourself. Or to design your own keyboard. My advise is to start with selecting the keycaps and the design of the PCB matrix. You can use the Cherry MX low profile or the regular Cherry MX switches which are sold as a developer kit. These require a different Autodesk Eagle library, which is available in their library.

The first image of this step shows that the total thickness of this keyboard is about 0.8 inch (2 cm). This can only be lowered by using special low profile keycaps. Maybe I'll try to print these myself, but this requires a very precise dual color 3D printer
But for now I am very satisfied with the result: A low profile split programmable mechanical keyboard.

GosseAdema

Raspberry Pi Contest 2020

Runner Up in the
Raspberry Pi Contest 2020